Coverage for cli / commands / queue_cmd.py: 100.00%

189 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-10-28 16:19 +0000

1"""Global job queue commands.""" 

2 

3import sys 

4from typing import Any 

5 

6import click 

7 

8from ..config import GCOConfig 

9from ..output import confirm, get_output_formatter 

10 

11pass_config = click.make_pass_decorator(GCOConfig, ensure=True) 

12 

13 

14def _parse_labels(raw_labels: tuple[str, ...]) -> dict[str, str]: 

15 """Parse repeated ``--label KEY=VALUE`` options without dropping bad input.""" 

16 labels: dict[str, str] = {} 

17 for raw_label in raw_labels: 

18 key, separator, value = raw_label.partition("=") 

19 if not separator or not key or not value: 

20 raise click.BadParameter( 

21 "must be KEY=VALUE with a non-empty key and value", 

22 param_hint="--label", 

23 ) 

24 labels[key] = value 

25 return labels 

26 

27 

28@click.group() 

29@pass_config 

30def queue(config: Any) -> None: 

31 """Manage the global job queue (DynamoDB-backed). 

32 

33 The job queue provides centralized job submission and tracking: 

34 - Submit jobs to any region from anywhere 

35 - Track job status globally 

36 - View job history and statistics 

37 """ 

38 

39 

40@queue.command("submit") 

41@click.argument("manifest_path", type=click.Path(exists=True)) 

42@click.option("--region", "-r", required=True, help="Target region for job execution") 

43@click.option("--namespace", "-n", default="gco-jobs", help="Kubernetes namespace") 

44@click.option("--priority", "-p", default=0, help="Job priority (0-100, higher = more important)") 

45@click.option("--label", "-l", multiple=True, help="Add labels (key=value)") 

46@click.option( 

47 "--max-spot-price", 

48 type=float, 

49 help=( 

50 "Spot price cap in USD/hour. The job is held in the queue until the " 

51 "current spot price of --spot-instance-type in the target region " 

52 "drops to or below this value. Requires --spot-instance-type." 

53 ), 

54) 

55@click.option( 

56 "--spot-instance-type", 

57 help=( 

58 "EC2 instance type whose spot price gates dispatch (e.g. g5.xlarge). " 

59 "Requires --max-spot-price." 

60 ), 

61) 

62@pass_config 

63def queue_submit( 

64 config: Any, 

65 manifest_path: Any, 

66 region: Any, 

67 namespace: Any, 

68 priority: Any, 

69 label: Any, 

70 max_spot_price: Any, 

71 spot_instance_type: Any, 

72) -> None: 

73 """Submit a job to the global queue for regional pickup. 

74 

75 Jobs are stored in DynamoDB and picked up by the target region's 

76 manifest processor. This enables global job submission with 

77 centralized tracking. 

78 

79 With --max-spot-price and --spot-instance-type the job is cost-gated: 

80 it stays queued until spot pricing for that instance type in the target 

81 region drops to or below the cap. Cancel with `gco queue cancel` if the 

82 price never clears. 

83 

84 Examples: 

85 gco queue submit job.yaml --region us-east-1 

86 gco queue submit job.yaml -r us-west-2 --priority 50 

87 gco queue submit job.yaml -r us-east-1 -l team=ml -l project=training 

88 gco queue submit job.yaml -r us-east-1 --max-spot-price 0.50 --spot-instance-type g5.xlarge 

89 """ 

90 

91 from gco.services.manifest_processor import safe_load_yaml 

92 from gco.services.spot_price_gate import validate_spot_gate_fields 

93 

94 formatter = get_output_formatter(config) 

95 

96 gate_error = validate_spot_gate_fields(max_spot_price, spot_instance_type) 

97 if gate_error: 

98 formatter.print_error(gate_error) 

99 sys.exit(1) 

100 

101 # Parse labels before opening the manifest or constructing an AWS client. 

102 labels = _parse_labels(tuple(label)) 

103 

104 try: 

105 # Load manifest 

106 with open(manifest_path, encoding="utf-8") as f: 

107 manifest = safe_load_yaml(f, allow_aliases=False) 

108 

109 # Submit via API 

110 from ..aws_client import get_aws_client 

111 

112 aws_client = get_aws_client(config) 

113 

114 body = { 

115 "manifest": manifest, 

116 "target_region": region, 

117 "namespace": namespace, 

118 "priority": priority, 

119 "labels": labels if labels else None, 

120 } 

121 if max_spot_price is not None: 

122 body["max_spot_price"] = max_spot_price 

123 body["spot_instance_type"] = spot_instance_type 

124 

125 result = aws_client.call_api( 

126 method="POST", 

127 path="/api/v1/queue/jobs", 

128 region=region if config.use_regional_api else None, 

129 body=body, 

130 ) 

131 

132 formatter.print_success(f"Job queued for {region}") 

133 if max_spot_price is not None: 

134 formatter.print_info( 

135 f"Spot price gate: dispatches when {spot_instance_type} spot " 

136 f"price in {region} is <= ${max_spot_price}/hour" 

137 ) 

138 formatter.print(result) 

139 

140 except Exception as e: 

141 formatter.print_error(f"Failed to queue job: {e}") 

142 sys.exit(1) 

143 

144 

145@queue.command("list") 

146@click.option("--region", "-r", help="Filter by target region") 

147@click.option( 

148 "--status", 

149 "-s", 

150 type=click.Choice(["queued", "claimed", "running", "succeeded", "failed", "cancelled"]), 

151 help="Filter by status", 

152) 

153@click.option("--namespace", "-n", help="Filter by namespace") 

154@click.option("--limit", "-l", default=50, help="Maximum results") 

155@pass_config 

156def queue_list(config: Any, region: Any, status: Any, namespace: Any, limit: Any) -> None: 

157 """List jobs in the global queue. 

158 

159 Examples: 

160 gco queue list 

161 gco queue list --region us-east-1 --status queued 

162 gco queue list -s running 

163 """ 

164 formatter = get_output_formatter(config) 

165 

166 try: 

167 from ..aws_client import get_aws_client 

168 

169 aws_client = get_aws_client(config) 

170 

171 # Build query params 

172 params = {"limit": limit} 

173 if region: 

174 params["target_region"] = region 

175 if status: 

176 params["status"] = status 

177 if namespace: 

178 params["namespace"] = namespace 

179 

180 # The region is a DynamoDB filter, not a transport pin. The global API 

181 # can serve it; forced regional mode uses the configured default bridge. 

182 query_region = config.default_region if config.use_regional_api else None 

183 result = aws_client.call_api( 

184 method="GET", 

185 path="/api/v1/queue/jobs", 

186 region=query_region, 

187 params=params, 

188 ) 

189 

190 if config.output_format == "table": 

191 jobs = result.get("jobs", []) 

192 if not jobs: 

193 formatter.print_info("No jobs found") 

194 return 

195 

196 print(f"\n Queued Jobs ({result.get('count', 0)} total)") 

197 print(" " + "-" * 90) 

198 print( 

199 " JOB ID NAME REGION STATUS" 

200 ) 

201 print(" " + "-" * 90) 

202 for job in jobs: 

203 job_id = job.get("job_id", "")[:36] 

204 name = job.get("job_name", "")[:22] 

205 target = job.get("target_region", "")[:14] 

206 job_status = job.get("status", "")[:10] 

207 print(f" {job_id:<36} {name:<23} {target:<15} {job_status}") 

208 else: 

209 formatter.print(result) 

210 

211 except Exception as e: 

212 formatter.print_error(f"Failed to list queued jobs: {e}") 

213 sys.exit(1) 

214 

215 

216@queue.command("get") 

217@click.argument("job_id") 

218@click.option("--region", "-r", help="Region to query (any region works)") 

219@pass_config 

220def queue_get(config: Any, job_id: Any, region: Any) -> None: 

221 """Get details of a queued job including status history. 

222 

223 Examples: 

224 gco queue get abc123-def456 

225 gco queue get abc123-def456 --region us-east-1 

226 """ 

227 formatter = get_output_formatter(config) 

228 

229 try: 

230 from ..aws_client import get_aws_client 

231 

232 aws_client = get_aws_client(config) 

233 

234 query_region = region or (config.default_region if config.use_regional_api else None) 

235 result = aws_client.call_api( 

236 method="GET", 

237 path=f"/api/v1/queue/jobs/{job_id}", 

238 region=query_region, 

239 ) 

240 

241 job = result.get("job", {}) 

242 

243 if config.output_format == "table": 

244 print(f"\n Job: {job.get('job_id')}") 

245 print(" " + "-" * 50) 

246 print(f" Name: {job.get('job_name')}") 

247 print(f" Target Region: {job.get('target_region')}") 

248 print(f" Namespace: {job.get('namespace')}") 

249 print(f" Status: {job.get('status')}") 

250 print(f" Priority: {job.get('priority')}") 

251 print(f" Submitted: {job.get('submitted_at')}") 

252 if job.get("spot_max_price"): 

253 print( 

254 f" Spot Gate: {job.get('spot_instance_type')} <= " 

255 f"${job.get('spot_max_price')}/hour" 

256 ) 

257 if job.get("spot_gate_observed_price"): 

258 print( 

259 f" Last Price: ${job.get('spot_gate_observed_price')} " 

260 f"(checked {job.get('spot_gate_checked_at')})" 

261 ) 

262 if job.get("claimed_by"): 

263 print(f" Claimed By: {job.get('claimed_by')}") 

264 if job.get("completed_at"): 

265 print(f" Completed: {job.get('completed_at')}") 

266 if job.get("error_message"): 

267 print(f" Error: {job.get('error_message')}") 

268 

269 # Show status history 

270 history = job.get("status_history", []) 

271 if history: 

272 print("\n Status History:") 

273 for entry in history: 

274 ts = entry.get("timestamp", "")[:19] 

275 st = entry.get("status", "") 

276 msg = entry.get("message", "")[:40] 

277 print(f" [{ts}] {st}: {msg}") 

278 else: 

279 formatter.print(result) 

280 

281 except Exception as e: 

282 formatter.print_error(f"Failed to get job: {e}") 

283 sys.exit(1) 

284 

285 

286@queue.command("cancel") 

287@click.argument("job_id") 

288@click.option("--reason", help="Cancellation reason") 

289@click.option("--region", "-r", help="Region to query (any region works)") 

290@click.option("--yes", "-y", is_flag=True, help="Skip confirmation") 

291@pass_config 

292def queue_cancel(config: Any, job_id: Any, reason: Any, region: Any, yes: Any) -> None: 

293 """Cancel a queued job (only works for jobs not yet running). 

294 

295 Examples: 

296 gco queue cancel abc123-def456 

297 gco queue cancel abc123-def456 --reason "No longer needed" 

298 """ 

299 formatter = get_output_formatter(config) 

300 

301 if not yes: 

302 confirm(f"Cancel job {job_id}?", abort=True) 

303 

304 try: 

305 from ..aws_client import get_aws_client 

306 

307 aws_client = get_aws_client(config) 

308 

309 query_region = region or (config.default_region if config.use_regional_api else None) 

310 params = {} 

311 if reason: 

312 params["reason"] = reason 

313 

314 result = aws_client.call_api( 

315 method="DELETE", 

316 path=f"/api/v1/queue/jobs/{job_id}", 

317 region=query_region, 

318 params=params, 

319 ) 

320 

321 formatter.print_success(f"Job {job_id} cancelled") 

322 formatter.print(result) 

323 

324 except Exception as e: 

325 formatter.print_error(f"Failed to cancel job: {e}") 

326 sys.exit(1) 

327 

328 

329@queue.command("stats") 

330@click.option("--region", "-r", help="Region to query (any region works)") 

331@pass_config 

332def queue_stats(config: Any, region: Any) -> None: 

333 """Get job queue statistics by region and status. 

334 

335 Examples: 

336 gco queue stats 

337 """ 

338 formatter = get_output_formatter(config) 

339 

340 try: 

341 from ..aws_client import get_aws_client 

342 

343 aws_client = get_aws_client(config) 

344 

345 query_region = region or (config.default_region if config.use_regional_api else None) 

346 result = aws_client.call_api( 

347 method="GET", 

348 path="/api/v1/queue/stats", 

349 region=query_region, 

350 ) 

351 

352 if config.output_format == "table": 

353 summary = result.get("summary", {}) 

354 by_region = result.get("by_region", {}) 

355 

356 print("\n Job Queue Statistics") 

357 print(" " + "-" * 50) 

358 print(f" Total Jobs: {summary.get('total_jobs', 0)}") 

359 print(f" Queued: {summary.get('total_queued', 0)}") 

360 print(f" Running: {summary.get('total_running', 0)}") 

361 

362 if by_region: 

363 print("\n By Region:") 

364 print(" REGION QUEUED RUNNING SUCCEEDED FAILED") 

365 print(" " + "-" * 55) 

366 for reg, statuses in by_region.items(): 

367 queued = statuses.get("queued", 0) 

368 running = statuses.get("running", 0) 

369 succeeded = statuses.get("succeeded", 0) 

370 failed = statuses.get("failed", 0) 

371 print(f" {reg:<15} {queued:>6} {running:>7} {succeeded:>9} {failed:>6}") 

372 else: 

373 formatter.print(result) 

374 

375 except Exception as e: 

376 formatter.print_error(f"Failed to get queue stats: {e}") 

377 sys.exit(1)