Coverage for cli/commands/images_cmd.py: 93.02%

341 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-30 21:22 +0000

1"""Container image registry commands. 

2 

3Subcommands wrap :class:`cli.images.ImageManager`. Read-only commands 

4(`list`, `tags`, `describe`, `uri`, replication get/status) need no 

5confirmation; administrative commands (`init`, `lifecycle`, replication 

6sync) are idempotent; destructive commands (`delete-tag`, `delete-repo`, 

7`cleanup`, `prune`) require ``-y`` / ``--yes``. 

8""" 

9 

10from __future__ import annotations 

11 

12import json 

13import sys 

14from typing import Any 

15 

16import click 

17 

18from ..config import GCOConfig 

19from ..output import get_output_formatter 

20 

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

22 

23 

24def _discard_mirror_log(_message: str) -> None: 

25 """Suppress human progress logs when machine-readable output is requested.""" 

26 

27 

28@click.group() 

29@pass_config 

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

31 """Manage container images in the project ECR registry (gco/* repos).""" 

32 pass 

33 

34 

35# --------------------------------------------------------------------------- 

36# Administrative 

37# --------------------------------------------------------------------------- 

38 

39 

40@images.command("init") 

41@click.argument("name") 

42@click.option("--retain/--no-retain", default=False, help="Apply gco:retain=true tag") 

43@pass_config 

44def images_init(config: Any, name: Any, retain: Any) -> None: 

45 """Create a project repository with the default lifecycle policy. 

46 

47 Examples: 

48 gco images init my-app 

49 gco images init my-app --retain 

50 """ 

51 from ..images import get_image_manager 

52 

53 formatter = get_output_formatter(config) 

54 try: 

55 manager = get_image_manager(config) 

56 result = manager.init(name, retain=retain) 

57 if config.output_format == "table": 

58 if result.get("created"): 

59 formatter.print_success(f"Created repository {result['name']}") 

60 else: 

61 formatter.print_info(f"Repository {result['name']} already existed") 

62 else: 

63 formatter.print(result) 

64 except Exception as e: 

65 formatter.print_error(f"Failed to init repository: {e}") 

66 sys.exit(1) 

67 

68 

69# --------------------------------------------------------------------------- 

70# Read-only 

71# --------------------------------------------------------------------------- 

72 

73 

74@images.command("list") 

75@pass_config 

76def images_list(config: Any) -> None: 

77 """List every repository under the project's gco/ prefix.""" 

78 from ..images import get_image_manager 

79 

80 formatter = get_output_formatter(config) 

81 try: 

82 repos = get_image_manager(config).list_repos() 

83 if not repos: 

84 formatter.print_info("No repositories found.") 

85 return 

86 formatter.print(repos) 

87 except Exception as e: 

88 formatter.print_error(f"Failed to list repositories: {e}") 

89 sys.exit(1) 

90 

91 

92@images.command("tags") 

93@click.argument("name") 

94@pass_config 

95def images_tags(config: Any, name: Any) -> None: 

96 """List tags within a repository.""" 

97 from ..images import get_image_manager 

98 

99 formatter = get_output_formatter(config) 

100 try: 

101 rows = get_image_manager(config).list_tags(name) 

102 if not rows: 

103 formatter.print_info("No tags found.") 

104 return 

105 formatter.print(rows) 

106 except Exception as e: 

107 formatter.print_error(f"Failed to list tags: {e}") 

108 sys.exit(1) 

109 

110 

111@images.command("describe") 

112@click.argument("name") 

113@click.argument("tag") 

114@pass_config 

115def images_describe(config: Any, name: Any, tag: Any) -> None: 

116 """Print the full ECR details for a single image tag.""" 

117 from ..images import get_image_manager 

118 

119 formatter = get_output_formatter(config) 

120 try: 

121 result = get_image_manager(config).describe(name, tag) 

122 if not result: 

123 formatter.print_info(f"Tag '{tag}' not found in {name}") 

124 return 

125 formatter.print(result) 

126 except Exception as e: 

127 formatter.print_error(f"Failed to describe image: {e}") 

128 sys.exit(1) 

129 

130 

131@images.command("uri") 

132@click.argument("name") 

133@click.option("--tag", "-t", default="latest", help="Image tag (default: latest)") 

134@pass_config 

135def images_uri(config: Any, name: Any, tag: Any) -> None: 

136 """Print the registry URI for an image without making any AWS calls.""" 

137 from ..images import get_image_manager 

138 

139 formatter = get_output_formatter(config) 

140 try: 

141 uri = get_image_manager(config).get_uri(name, tag=tag) 

142 print(uri) 

143 except Exception as e: 

144 formatter.print_error(f"Failed to compute URI: {e}") 

145 sys.exit(1) 

146 

147 

148# --------------------------------------------------------------------------- 

149# Build / push 

150# --------------------------------------------------------------------------- 

151 

152 

153@images.command("build") 

154@click.argument("context") 

155@click.option("--name", "-n", required=True, help="Image name") 

156@click.option("--tag", "-t", default=None, help="Image tag (default: git SHA or 'latest')") 

157@click.option("--dockerfile", "-f", default="Dockerfile", help="Path to Dockerfile") 

158@click.option("--build-arg", "build_args", multiple=True, help="Build arg KEY=VALUE") 

159@click.option("--platform", default="linux/amd64", help="Target platform") 

160@click.option("--retain/--no-retain", default=False, help="Apply gco:retain=true tag") 

161@pass_config 

162def images_build( 

163 config: Any, 

164 context: Any, 

165 name: Any, 

166 tag: Any, 

167 dockerfile: Any, 

168 build_args: Any, 

169 platform: Any, 

170 retain: Any, 

171) -> None: 

172 """Build a container image and push it to the project's ECR repo. 

173 

174 Examples: 

175 gco images build ./my-app --name my-app --tag v1 

176 gco images build ./svc --name svc --build-arg VERSION=1.2.3 

177 """ 

178 from ..images import get_image_manager 

179 

180 formatter = get_output_formatter(config) 

181 

182 args_dict: dict[str, str] = {} 

183 for arg in build_args or (): 

184 if "=" not in arg: 

185 formatter.print_error(f"Invalid --build-arg (missing '='): {arg}") 

186 sys.exit(1) 

187 key, value = arg.split("=", 1) 

188 args_dict[key] = value 

189 

190 try: 

191 manager = get_image_manager(config) 

192 result = manager.build( 

193 context=context, 

194 name=name, 

195 tag=tag, 

196 dockerfile=dockerfile, 

197 build_args=args_dict or None, 

198 platform=platform, 

199 retain=retain, 

200 quiet=config.output_format != "table", 

201 ) 

202 if config.output_format == "table": 

203 formatter.print_success(f"Built and pushed {result['image_uri']}") 

204 if result.get("digest"): 204 ↛ exitline 204 didn't return from function 'images_build' because the condition on line 204 was always true

205 formatter.print_info(f"Digest: {result['digest']}") 

206 else: 

207 formatter.print(result) 

208 except Exception as e: 

209 formatter.print_error(f"Failed to build image: {e}") 

210 sys.exit(1) 

211 

212 

213@images.command("push") 

214@click.argument("name") 

215@click.option("--tag", "-t", required=True, help="Image tag") 

216@click.option("--local-image", required=True, help="Existing local image reference") 

217@click.option("--retain/--no-retain", default=False, help="Apply gco:retain=true tag") 

218@pass_config 

219def images_push( 

220 config: Any, 

221 name: Any, 

222 tag: Any, 

223 local_image: Any, 

224 retain: Any, 

225) -> None: 

226 """Push an already-built local image to the project's ECR repo.""" 

227 from ..images import get_image_manager 

228 

229 formatter = get_output_formatter(config) 

230 try: 

231 result = get_image_manager(config).push( 

232 name=name, 

233 tag=tag, 

234 local_image=local_image, 

235 retain=retain, 

236 quiet=config.output_format != "table", 

237 ) 

238 if config.output_format == "table": 

239 formatter.print_success(f"Pushed {result['image_uri']}") 

240 if result.get("digest"): 240 ↛ exitline 240 didn't return from function 'images_push' because the condition on line 240 was always true

241 formatter.print_info(f"Digest: {result['digest']}") 

242 else: 

243 formatter.print(result) 

244 except Exception as e: 

245 formatter.print_error(f"Failed to push image: {e}") 

246 sys.exit(1) 

247 

248 

249# --------------------------------------------------------------------------- 

250# Mirror third-party images into the project ECR 

251# --------------------------------------------------------------------------- 

252 

253 

254@images.command("mirror") 

255@click.option( 

256 "--region", 

257 "-r", 

258 required=True, 

259 help="Target AWS region (must match the regional stack, e.g. us-east-1).", 

260) 

261@click.option( 

262 "--ecr-namespace", 

263 default=None, 

264 help=( 

265 "Destination ECR namespace. Defaults to cdk.json " 

266 "volcano_image_mirror.ecr_namespace (gco/dockerhub); must match it so the " 

267 "consumer's image override resolves to the mirror." 

268 ), 

269) 

270@click.option( 

271 "--no-skip-existing", 

272 is_flag=True, 

273 default=False, 

274 help="Re-copy images even if the tag already exists in ECR.", 

275) 

276@click.option( 

277 "--dry-run", 

278 is_flag=True, 

279 default=False, 

280 help="Print the copy plan without creating repositories or copying images.", 

281) 

282@pass_config 

283def images_mirror( 

284 config: Any, 

285 region: Any, 

286 ecr_namespace: Any, 

287 no_skip_existing: Any, 

288 dry_run: Any, 

289) -> None: 

290 """Mirror third-party images (e.g. Volcano's docker.io images) into the ECR. 

291 

292 This is the same multi-arch copy ``gco stacks deploy`` runs automatically when 

293 ``volcano_image_mirror.enabled`` is set. Run it directly to pre-seed a region 

294 before enabling the toggle, or to re-mirror after bumping a mirrored image's 

295 version. Wraps the shared ``cli._image_mirror`` core (also used by the deploy 

296 auto-mirror and the ``images_mirror`` MCP tool). 

297 

298 Examples: 

299 gco images mirror --region us-east-1 

300 gco images mirror --region us-east-1 --dry-run 

301 gco images mirror --region us-east-1 --ecr-namespace gco/dockerhub 

302 """ 

303 from .. import _image_mirror as mirror 

304 

305 formatter = get_output_formatter(config) 

306 namespace = (ecr_namespace or mirror.cdk_default_namespace()).strip("/") 

307 table_output = config.output_format == "table" 

308 try: 

309 if dry_run: 

310 # Partition metadata is local, so this remains free of AWS API calls. 

311 registry_host = mirror._registry_host("<account>", region) 

312 plan = mirror.plan_from_sources(mirror.collect_source_refs(), registry_host, namespace) 

313 result = { 

314 "region": region, 

315 "ecr_namespace": namespace, 

316 "images": [ 

317 {"source_ref": item.source_ref, "dest_ref": item.dest_ref} for item in plan 

318 ], 

319 } 

320 if table_output: 

321 formatter.print_info( 

322 f"[dry-run] would mirror {len(plan)} image(s) into namespace {namespace!r}:" 

323 ) 

324 for item in plan: 

325 formatter.print_info(f" {item.source_ref} -> {item.dest_ref}") 

326 else: 

327 formatter.print(result) 

328 return 

329 

330 result = mirror.mirror_images( 

331 region, 

332 ecr_namespace=namespace, 

333 skip_existing=not no_skip_existing, 

334 log=print if table_output else _discard_mirror_log, 

335 ) 

336 if table_output: 336 ↛ 337line 336 didn't jump to line 337 because the condition on line 336 was never true

337 formatter.print_success( 

338 f"Mirrored {len(result['mirrored'])}, skipped {len(result['skipped'])} " 

339 f"into {result['registry']} (strategy: {result['strategy']})." 

340 ) 

341 else: 

342 formatter.print(result) 

343 except Exception as e: 

344 formatter.print_error(f"Failed to mirror images: {e}") 

345 sys.exit(1) 

346 

347 

348# --------------------------------------------------------------------------- 

349# Destructive 

350# --------------------------------------------------------------------------- 

351 

352 

353@images.command("delete-tag") 

354@click.argument("name") 

355@click.argument("tag") 

356@click.option("--yes", "-y", is_flag=True, required=True, help="Required confirmation") 

357@pass_config 

358def images_delete_tag(config: Any, name: Any, tag: Any, yes: Any) -> None: 

359 """Delete a single tag from a repository (irreversible).""" 

360 from ..images import get_image_manager 

361 

362 formatter = get_output_formatter(config) 

363 if not yes: 363 ↛ 364line 363 didn't jump to line 364 because the condition on line 363 was never true

364 formatter.print_error("--yes is required for destructive commands") 

365 sys.exit(1) 

366 try: 

367 result = get_image_manager(config).delete_tag(name, tag) 

368 formatter.print_success( 

369 f"Deleted {len(result.get('deleted', []))} image(s) from {result['name']}" 

370 ) 

371 if config.output_format != "table": 371 ↛ 372line 371 didn't jump to line 372 because the condition on line 371 was never true

372 formatter.print(result) 

373 except Exception as e: 

374 formatter.print_error(f"Failed to delete tag: {e}") 

375 sys.exit(1) 

376 

377 

378@images.command("delete-repo") 

379@click.argument("name") 

380@click.option("--force/--no-force", default=False, help="Delete even if non-empty") 

381@click.option("--yes", "-y", is_flag=True, required=True, help="Required confirmation") 

382@pass_config 

383def images_delete_repo(config: Any, name: Any, force: Any, yes: Any) -> None: 

384 """Delete a whole repository (irreversible).""" 

385 from ..images import get_image_manager 

386 

387 formatter = get_output_formatter(config) 

388 if not yes: 388 ↛ 389line 388 didn't jump to line 389 because the condition on line 388 was never true

389 formatter.print_error("--yes is required for destructive commands") 

390 sys.exit(1) 

391 try: 

392 result = get_image_manager(config).delete_repo(name, force=force) 

393 formatter.print_success(f"Deleted repository {result['name']}") 

394 if config.output_format != "table": 394 ↛ 395line 394 didn't jump to line 395 because the condition on line 394 was never true

395 formatter.print(result) 

396 except Exception as e: 

397 formatter.print_error(f"Failed to delete repository: {e}") 

398 sys.exit(1) 

399 

400 

401@images.command("cleanup") 

402@click.option("--name", "-n", default=None, help="Single repository to clean up") 

403@click.option("--all", "all_repos", is_flag=True, help="Clean up every project repo") 

404@click.option("--yes", "-y", is_flag=True, required=True, help="Required confirmation") 

405@pass_config 

406def images_cleanup(config: Any, name: Any, all_repos: Any, yes: Any) -> None: 

407 """Remove untagged images across one or all project repos.""" 

408 from ..images import get_image_manager 

409 

410 formatter = get_output_formatter(config) 

411 if not yes: 411 ↛ 412line 411 didn't jump to line 412 because the condition on line 411 was never true

412 formatter.print_error("--yes is required for destructive commands") 

413 sys.exit(1) 

414 if not name and not all_repos: 

415 formatter.print_error("Provide --name <repo> or --all") 

416 sys.exit(1) 

417 try: 

418 result = get_image_manager(config).cleanup(name=name, all=all_repos) 

419 formatter.print_success( 

420 f"Cleaned up: repos_touched={result['repos_touched']} " 

421 f"tags_deleted={result['tags_deleted']} " 

422 f"bytes_freed={result['bytes_freed']}" 

423 ) 

424 if config.output_format != "table": 424 ↛ 425line 424 didn't jump to line 425 because the condition on line 424 was never true

425 formatter.print(result) 

426 except Exception as e: 

427 formatter.print_error(f"Failed to clean up: {e}") 

428 sys.exit(1) 

429 

430 

431@images.command("prune") 

432@click.option( 

433 "--dry-run/--no-dry-run", 

434 default=True, 

435 help="Dry run by default; pass --no-dry-run to actually delete", 

436) 

437@click.option("--yes", "-y", is_flag=True, required=True, help="Required confirmation") 

438@pass_config 

439def images_prune(config: Any, dry_run: Any, yes: Any) -> None: 

440 """Remove untagged images older than 30 days (dry-run by default).""" 

441 from ..images import get_image_manager 

442 

443 formatter = get_output_formatter(config) 

444 if not yes: 444 ↛ 445line 444 didn't jump to line 445 because the condition on line 444 was never true

445 formatter.print_error("--yes is required for destructive commands") 

446 sys.exit(1) 

447 try: 

448 result = get_image_manager(config).prune(dry_run=dry_run) 

449 verb = "Would delete" if dry_run else "Deleted" 

450 formatter.print_success( 

451 f"{verb}: repos_touched={result['repos_touched']} " 

452 f"tags_deleted={result['tags_deleted']} " 

453 f"bytes_freed={result['bytes_freed']}" 

454 ) 

455 if config.output_format != "table": 455 ↛ 456line 455 didn't jump to line 456 because the condition on line 455 was never true

456 formatter.print(result) 

457 except Exception as e: 

458 formatter.print_error(f"Failed to prune: {e}") 

459 sys.exit(1) 

460 

461 

462@images.command("orphans") 

463@click.option( 

464 "--threshold-days", 

465 default=30, 

466 type=int, 

467 help="Only report tags older than this many days", 

468) 

469@pass_config 

470def images_orphans(config: Any, threshold_days: Any) -> None: 

471 """List tags older than threshold_days that are not referenced anywhere.""" 

472 from ..images import get_image_manager 

473 

474 formatter = get_output_formatter(config) 

475 try: 

476 rows = get_image_manager(config).orphans(threshold_days=threshold_days) 

477 if not rows: 

478 formatter.print_info("No orphans found.") 

479 return 

480 formatter.print(rows) 

481 except Exception as e: 

482 formatter.print_error(f"Failed to detect orphans: {e}") 

483 sys.exit(1) 

484 

485 

486# --------------------------------------------------------------------------- 

487# Lifecycle 

488# --------------------------------------------------------------------------- 

489 

490 

491@images.group("lifecycle") 

492def lifecycle() -> None: 

493 """Lifecycle policy management.""" 

494 pass 

495 

496 

497@lifecycle.command("get") 

498@click.argument("name") 

499@pass_config 

500def lifecycle_get(config: Any, name: Any) -> None: 

501 """Print the lifecycle policy on a repository.""" 

502 from ..images import get_image_manager 

503 

504 formatter = get_output_formatter(config) 

505 try: 

506 result = get_image_manager(config).lifecycle_get(name) 

507 if not result: 

508 formatter.print_info(f"No lifecycle policy on {name}.") 

509 return 

510 formatter.print(result) 

511 except Exception as e: 

512 formatter.print_error(f"Failed to read lifecycle policy: {e}") 

513 sys.exit(1) 

514 

515 

516@lifecycle.command("set") 

517@click.argument("name") 

518@click.option("--file", "-f", "policy_file", required=True, help="Path to lifecycle JSON") 

519@pass_config 

520def lifecycle_set(config: Any, name: Any, policy_file: Any) -> None: 

521 """Replace the lifecycle policy on a repository from a JSON file.""" 

522 from ..images import get_image_manager 

523 

524 formatter = get_output_formatter(config) 

525 try: 

526 with open(policy_file, encoding="utf-8") as f: 

527 policy = json.load(f) 

528 result = get_image_manager(config).lifecycle_set(name, policy) 

529 formatter.print_success(f"Updated lifecycle policy on {result['name']}") 

530 if config.output_format != "table": 530 ↛ 531line 530 didn't jump to line 531 because the condition on line 530 was never true

531 formatter.print(result) 

532 except Exception as e: 

533 formatter.print_error(f"Failed to set lifecycle policy: {e}") 

534 sys.exit(1) 

535 

536 

537# --------------------------------------------------------------------------- 

538# Replication 

539# --------------------------------------------------------------------------- 

540 

541 

542@images.group("replication") 

543def replication() -> None: 

544 """Replication management.""" 

545 pass 

546 

547 

548@replication.command("get") 

549@pass_config 

550def replication_get(config: Any) -> None: 

551 """Print the current ECR replication configuration.""" 

552 from ..images import get_image_manager 

553 

554 formatter = get_output_formatter(config) 

555 try: 

556 result = get_image_manager(config).replication_get() 

557 if not result: 

558 formatter.print_info("No replication policy configured.") 

559 return 

560 formatter.print(result) 

561 except Exception as e: 

562 formatter.print_error(f"Failed to read replication policy: {e}") 

563 sys.exit(1) 

564 

565 

566@replication.command("status") 

567@pass_config 

568def replication_status(config: Any) -> None: 

569 """Print per-image replication status across project repos.""" 

570 from ..images import get_image_manager 

571 

572 formatter = get_output_formatter(config) 

573 try: 

574 rows = get_image_manager(config).replication_status() 

575 if not rows: 

576 formatter.print_info("No replication status entries.") 

577 return 

578 formatter.print(rows) 

579 except Exception as e: 

580 formatter.print_error(f"Failed to read replication status: {e}") 

581 sys.exit(1) 

582 

583 

584@replication.command("sync") 

585@pass_config 

586def replication_sync(config: Any) -> None: 

587 """Apply the project's standard replication rule (gco/* to all regions).""" 

588 from ..images import get_image_manager 

589 

590 formatter = get_output_formatter(config) 

591 try: 

592 result = get_image_manager(config).replication_sync() 

593 dests = result.get("destinations") or [] 

594 formatter.print_success( 

595 f"Replication rule synced: destinations={', '.join(dests) or 'none'}" 

596 ) 

597 if config.output_format != "table": 597 ↛ 598line 597 didn't jump to line 598 because the condition on line 597 was never true

598 formatter.print(result) 

599 except Exception as e: 

600 formatter.print_error(f"Failed to sync replication rule: {e}") 

601 sys.exit(1)