Skip to content

Commit 5208d5d

Browse files
committed
Feat(aws): Add list_jobs.py
1 parent c29ad01 commit 5208d5d

5 files changed

Lines changed: 268 additions & 2 deletions

File tree

.cursor/rules/style.mdc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ alwaysApply: true
2323
15. Command line arguments should be parsed with `argparse`.
2424
16. Generally prefer to use keyword arguments over positional arguments. If there are somewhat specific optional arguments, mark them as keyword-only.
2525
17. Use logger instead of print statements.
26+
18. Avoid long indented blocks in functions if they are only for else: return None. Instead you can just test for this early.
2627

2728
## Test style
2829

aws/cancel_job.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
#!/usr/bin/env python3
2+
"""
3+
AWS Batch job cancellation script for CodeClash.
4+
5+
This script can cancel AWS Batch jobs by job name or job ID.
6+
It can handle multiple jobs at once for cancellation.
7+
8+
Usage:
9+
python cancel_job.py job-id-1 job-id-2 job-name-3
10+
python cancel_job.py --reason "Manual cancellation" job-id-1
11+
"""
12+
13+
import argparse
14+
from typing import Any
15+
16+
import boto3
17+
18+
from codeclash.utils.log import get_logger
19+
20+
logger = get_logger("cancel")
21+
22+
23+
class AWSBatchJobCanceller:
24+
def __init__(self):
25+
self.batch_client = boto3.client("batch")
26+
27+
def find_job_by_name(self, job_name: str) -> list[dict[str, Any]]:
28+
"""Find jobs by name. Returns list of job info dictionaries."""
29+
jobs = []
30+
31+
# Search in different job queues and statuses
32+
for status in ["SUBMITTED", "PENDING", "RUNNABLE", "STARTING", "RUNNING"]:
33+
response = self.batch_client.list_jobs(
34+
jobQueue="codeclash-queue", # Default queue name from run_job.py
35+
jobStatus=status,
36+
)
37+
38+
for job in response["jobs"]:
39+
if job["jobName"] == job_name:
40+
jobs.append(job)
41+
42+
return jobs
43+
44+
def get_job_info(self, job_identifier: str) -> dict[str, Any] | None:
45+
"""Get job info by job ID or job name."""
46+
# First try as job ID
47+
try:
48+
response = self.batch_client.describe_jobs(jobs=[job_identifier])
49+
if response["jobs"]:
50+
return response["jobs"][0]
51+
except Exception:
52+
pass
53+
54+
# Try as job name
55+
jobs = self.find_job_by_name(job_identifier)
56+
if len(jobs) == 1:
57+
return jobs[0]
58+
elif len(jobs) > 1:
59+
logger.warning(f"Multiple jobs found with name '{job_identifier}', using most recent")
60+
return max(jobs, key=lambda x: x["createdAt"])
61+
62+
return None
63+
64+
def cancel_job(self, job_identifier: str, *, reason: str = "Cancelled by user") -> bool:
65+
"""Cancel a job by ID or name. Returns True if successful."""
66+
job_info = self.get_job_info(job_identifier)
67+
68+
if not job_info:
69+
logger.error(f"Job not found: {job_identifier}")
70+
return False
71+
72+
job_id = job_info["jobId"]
73+
job_name = job_info["jobName"]
74+
status = job_info["status"]
75+
76+
if status in ["SUCCEEDED", "FAILED"]:
77+
logger.warning(f"Job {job_name} ({job_id}) is already {status.lower()}")
78+
return True
79+
80+
try:
81+
self.batch_client.cancel_job(jobId=job_id, reason=reason)
82+
logger.info(f"Successfully cancelled job: {job_name} ({job_id})")
83+
return True
84+
except Exception as e:
85+
logger.error(f"Failed to cancel job {job_name} ({job_id}): {e}", exc_info=True)
86+
return False
87+
88+
def cancel_jobs(self, job_identifiers: list[str], *, reason: str = "Cancelled by user") -> int:
89+
"""Cancel multiple jobs. Returns number of successfully cancelled jobs."""
90+
successful_cancellations = 0
91+
92+
for job_identifier in job_identifiers:
93+
if self.cancel_job(job_identifier, reason=reason):
94+
successful_cancellations += 1
95+
96+
return successful_cancellations
97+
98+
99+
def main():
100+
parser = argparse.ArgumentParser(
101+
description="Cancel AWS Batch jobs by name or ID",
102+
formatter_class=argparse.RawDescriptionHelpFormatter,
103+
)
104+
parser.add_argument("jobs", nargs="+", help="Job IDs or job names to cancel")
105+
parser.add_argument(
106+
"--reason", default="Cancelled by user", help="Reason for cancellation (default: 'Cancelled by user')"
107+
)
108+
109+
args = parser.parse_args()
110+
111+
canceller = AWSBatchJobCanceller()
112+
113+
# Cancel jobs
114+
logger.info(f"Cancelling {len(args.jobs)} job(s)...")
115+
canceller.cancel_jobs(args.jobs, reason=args.reason)
116+
117+
118+
if __name__ == "__main__":
119+
main()

aws/list_jobs.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
#!/usr/bin/env python3
2+
"""
3+
AWS Batch job listing script for CodeClash.
4+
5+
This is a convenience script that lists all AWS Batch jobs sorted by creation time.
6+
7+
Usage:
8+
python list_jobs.py
9+
python list_jobs.py --status RUNNING
10+
python list_jobs.py --queue my-queue --limit 20
11+
"""
12+
13+
import argparse
14+
from datetime import datetime
15+
from typing import Any
16+
17+
import boto3
18+
19+
from codeclash.utils.log import get_logger
20+
21+
logger = get_logger("list_jobs", emoji="📋")
22+
23+
24+
class AWSBatchJobLister:
25+
def __init__(self, job_queue: str = "codeclash-queue"):
26+
self.batch_client = boto3.client("batch")
27+
self.job_queue = job_queue
28+
29+
def list_jobs(self, *, status: str | None = None, limit: int | None = None) -> list[dict[str, Any]]:
30+
"""List jobs from AWS Batch, optionally filtered by status."""
31+
all_jobs = []
32+
33+
# If specific status is requested, only query that status
34+
if status:
35+
statuses = [status.upper()]
36+
else:
37+
# Query all active statuses
38+
statuses = ["SUBMITTED", "PENDING", "RUNNABLE", "STARTING", "RUNNING", "SUCCEEDED", "FAILED"]
39+
40+
for job_status in statuses:
41+
try:
42+
# Use paginator to handle large number of jobs
43+
paginator = self.batch_client.get_paginator("list_jobs")
44+
page_iterator = paginator.paginate(jobQueue=self.job_queue, jobStatus=job_status)
45+
46+
for page in page_iterator:
47+
jobs_in_page = page.get("jobSummaryList", [])
48+
all_jobs.extend(jobs_in_page)
49+
50+
except Exception as e:
51+
logger.warning(f"Failed to list jobs with status {job_status}: {e}")
52+
continue
53+
54+
# Sort by creation time (newest first)
55+
all_jobs.sort(key=lambda x: x["createdAt"], reverse=True)
56+
57+
# Apply limit if specified
58+
if limit:
59+
all_jobs = all_jobs[:limit]
60+
61+
return all_jobs
62+
63+
def format_job_info(self, job: dict[str, Any]) -> str:
64+
"""Format job information for display."""
65+
job_id = job["jobId"]
66+
job_name = job["jobName"]
67+
status = job["status"]
68+
created_at = job["createdAt"]
69+
70+
# Format creation time
71+
if isinstance(created_at, datetime):
72+
created_str = created_at.strftime("%Y-%m-%d %H:%M:%S")
73+
else:
74+
# Handle timestamp (milliseconds since epoch)
75+
created_str = datetime.fromtimestamp(created_at / 1000).strftime("%Y-%m-%d %H:%M:%S")
76+
77+
# Get additional info if available
78+
started_at = job.get("startedAt")
79+
stopped_at = job.get("stoppedAt")
80+
81+
duration = ""
82+
if started_at and stopped_at:
83+
if isinstance(started_at, datetime):
84+
start_time = started_at
85+
stop_time = stopped_at
86+
else:
87+
start_time = datetime.fromtimestamp(started_at / 1000)
88+
stop_time = datetime.fromtimestamp(stopped_at / 1000)
89+
duration_seconds = (stop_time - start_time).total_seconds()
90+
duration = f" ({duration_seconds:.0f}s)"
91+
elif started_at:
92+
if isinstance(started_at, datetime):
93+
start_time = started_at
94+
else:
95+
start_time = datetime.fromtimestamp(started_at / 1000)
96+
running_seconds = (datetime.now() - start_time).total_seconds()
97+
duration = f" (running {running_seconds:.0f}s)"
98+
99+
return f"{job_id:<36} {status:<10} {created_str} {job_name}{duration}"
100+
101+
def print_jobs(self, jobs: list[dict[str, Any]]) -> None:
102+
"""Print jobs in a formatted table."""
103+
if not jobs:
104+
logger.info("No jobs found")
105+
return
106+
107+
# Print header
108+
header = f"{'Job ID':<36} {'Status':<10} {'Created':<19} {'Job Name'}"
109+
print(header)
110+
print("-" * len(header))
111+
112+
# Print jobs
113+
for job in jobs:
114+
print(self.format_job_info(job))
115+
116+
print(f"\nTotal: {len(jobs)} job(s)")
117+
118+
119+
def main():
120+
parser = argparse.ArgumentParser(
121+
description="List AWS Batch jobs",
122+
formatter_class=argparse.RawDescriptionHelpFormatter,
123+
)
124+
parser.add_argument(
125+
"--status",
126+
choices=["SUBMITTED", "PENDING", "RUNNABLE", "STARTING", "RUNNING", "SUCCEEDED", "FAILED"],
127+
help="Filter jobs by status (default: show all statuses)",
128+
)
129+
parser.add_argument("--queue", default="codeclash-queue", help="Job queue name (default: codeclash-queue)")
130+
parser.add_argument("--limit", type=int, help="Maximum number of jobs to display")
131+
132+
args = parser.parse_args()
133+
134+
lister = AWSBatchJobLister(job_queue=args.queue)
135+
136+
# List jobs
137+
logger.info(f"Listing jobs from queue: {args.queue}")
138+
if args.status:
139+
logger.info(f"Filtering by status: {args.status}")
140+
141+
jobs = lister.list_jobs(status=args.status, limit=args.limit)
142+
lister.print_jobs(jobs)
143+
144+
145+
if __name__ == "__main__":
146+
main()

aws/setup/batch/job_definition.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
"type": "VCPU"
2222
},
2323
{
24-
"value": "8192",
24+
"value": "7500",
2525
"type": "MEMORY"
2626
}
2727
],

aws/setup/batch/launch_template.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
{
66
"DeviceName": "/dev/xvda",
77
"Ebs": {
8-
"VolumeSize": 30,
8+
"VolumeSize": 50,
99
"VolumeType": "gp3"
1010
}
1111
}

0 commit comments

Comments
 (0)