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

190 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-09-14 22:07 +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 pass 

39 

40 

41@queue.command("submit") 

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

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

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

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

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

47@click.option( 

48 "--max-spot-price", 

49 type=float, 

50 help=( 

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

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

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

54 ), 

55) 

56@click.option( 

57 "--spot-instance-type", 

58 help=( 

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

60 "Requires --max-spot-price." 

61 ), 

62) 

63@pass_config 

64def queue_submit( 

65 config: Any, 

66 manifest_path: Any, 

67 region: Any, 

68 namespace: Any, 

69 priority: Any, 

70 label: Any, 

71 max_spot_price: Any, 

72 spot_instance_type: Any, 

73) -> None: 

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

75 

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

77 manifest processor. This enables global job submission with 

78 centralized tracking. 

79 

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

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

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

83 price never clears. 

84 

85 Examples: 

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

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

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

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

90 """ 

91 

92 from gco.services.manifest_processor import safe_load_yaml 

93 from gco.services.spot_price_gate import validate_spot_gate_fields 

94 

95 formatter = get_output_formatter(config) 

96 

97 gate_error = validate_spot_gate_fields(max_spot_price, spot_instance_type) 

98 if gate_error: 

99 formatter.print_error(gate_error) 

100 sys.exit(1) 

101 

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

103 labels = _parse_labels(tuple(label)) 

104 

105 try: 

106 # Load manifest 

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

108 manifest = safe_load_yaml(f, allow_aliases=False) 

109 

110 # Submit via API 

111 from ..aws_client import get_aws_client 

112 

113 aws_client = get_aws_client(config) 

114 

115 body = { 

116 "manifest": manifest, 

117 "target_region": region, 

118 "namespace": namespace, 

119 "priority": priority, 

120 "labels": labels if labels else None, 

121 } 

122 if max_spot_price is not None: 

123 body["max_spot_price"] = max_spot_price 

124 body["spot_instance_type"] = spot_instance_type 

125 

126 result = aws_client.call_api( 

127 method="POST", 

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

129 region=region if config.use_regional_api else None, 

130 body=body, 

131 ) 

132 

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

134 if max_spot_price is not None: 

135 formatter.print_info( 

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

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

138 ) 

139 formatter.print(result) 

140 

141 except Exception as e: 

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

143 sys.exit(1) 

144 

145 

146@queue.command("list") 

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

148@click.option( 

149 "--status", 

150 "-s", 

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

152 help="Filter by status", 

153) 

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

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

156@pass_config 

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

158 """List jobs in the global queue. 

159 

160 Examples: 

161 gco queue list 

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

163 gco queue list -s running 

164 """ 

165 formatter = get_output_formatter(config) 

166 

167 try: 

168 from ..aws_client import get_aws_client 

169 

170 aws_client = get_aws_client(config) 

171 

172 # Build query params 

173 params = {"limit": limit} 

174 if region: 

175 params["target_region"] = region 

176 if status: 

177 params["status"] = status 

178 if namespace: 

179 params["namespace"] = namespace 

180 

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

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

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

184 result = aws_client.call_api( 

185 method="GET", 

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

187 region=query_region, 

188 params=params, 

189 ) 

190 

191 if config.output_format == "table": 

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

193 if not jobs: 

194 formatter.print_info("No jobs found") 

195 return 

196 

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

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

199 print( 

200 " JOB ID NAME REGION STATUS" 

201 ) 

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

203 for job in jobs: 

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

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

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

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

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

209 else: 

210 formatter.print(result) 

211 

212 except Exception as e: 

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

214 sys.exit(1) 

215 

216 

217@queue.command("get") 

218@click.argument("job_id") 

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

220@pass_config 

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

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

223 

224 Examples: 

225 gco queue get abc123-def456 

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

227 """ 

228 formatter = get_output_formatter(config) 

229 

230 try: 

231 from ..aws_client import get_aws_client 

232 

233 aws_client = get_aws_client(config) 

234 

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

236 result = aws_client.call_api( 

237 method="GET", 

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

239 region=query_region, 

240 ) 

241 

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

243 

244 if config.output_format == "table": 

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

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

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

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

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

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

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

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

253 if job.get("spot_max_price"): 

254 print( 

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

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

257 ) 

258 if job.get("spot_gate_observed_price"): 

259 print( 

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

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

262 ) 

263 if job.get("claimed_by"): 

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

265 if job.get("completed_at"): 

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

267 if job.get("error_message"): 

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

269 

270 # Show status history 

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

272 if history: 

273 print("\n Status History:") 

274 for entry in history: 

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

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

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

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

279 else: 

280 formatter.print(result) 

281 

282 except Exception as e: 

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

284 sys.exit(1) 

285 

286 

287@queue.command("cancel") 

288@click.argument("job_id") 

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

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

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

292@pass_config 

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

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

295 

296 Examples: 

297 gco queue cancel abc123-def456 

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

299 """ 

300 formatter = get_output_formatter(config) 

301 

302 if not yes: 

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

304 

305 try: 

306 from ..aws_client import get_aws_client 

307 

308 aws_client = get_aws_client(config) 

309 

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

311 params = {} 

312 if reason: 

313 params["reason"] = reason 

314 

315 result = aws_client.call_api( 

316 method="DELETE", 

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

318 region=query_region, 

319 params=params, 

320 ) 

321 

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

323 formatter.print(result) 

324 

325 except Exception as e: 

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

327 sys.exit(1) 

328 

329 

330@queue.command("stats") 

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

332@pass_config 

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

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

335 

336 Examples: 

337 gco queue stats 

338 """ 

339 formatter = get_output_formatter(config) 

340 

341 try: 

342 from ..aws_client import get_aws_client 

343 

344 aws_client = get_aws_client(config) 

345 

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

347 result = aws_client.call_api( 

348 method="GET", 

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

350 region=query_region, 

351 ) 

352 

353 if config.output_format == "table": 

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

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

356 

357 print("\n Job Queue Statistics") 

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

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

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

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

362 

363 if by_region: 

364 print("\n By Region:") 

365 print(" REGION QUEUED RUNNING SUCCEEDED FAILED") 

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

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

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

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

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

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

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

373 else: 

374 formatter.print(result) 

375 

376 except Exception as e: 

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

378 sys.exit(1)