Coverage for cli/images.py: 85.81%

544 statements  

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

1""" 

2Container image registry management for GCO CLI. 

3 

4Provides ``ImageManager`` for building, pushing, and managing user 

5container images stored in per-project ECR repositories under the 

6``gco/`` prefix. Builds run through the same container runtime 

7(Docker, Finch, or Podman) used by CDK asset bundling, detected via 

8``cli._container_runtime``. 

9 

10The ECR repository layout mirrors the project naming convention: 

11``<account>.dkr.ecr.<region>.<url-suffix>/gco/<name>:<tag>``. 

12 

13Read-only methods (``list_repos``, ``list_tags``, ``describe``, 

14``get_uri``, ``replication_get``, ``replication_status``) hit ECR 

15directly via boto3 and do not invoke any container runtime. 

16 

17Administrative methods (``init``, ``lifecycle_get``, ``lifecycle_set``, 

18``replication_sync``) configure the repository surface and are 

19idempotent — re-running them is safe. 

20 

21Destructive methods (``delete_tag``, ``delete_repo``, ``cleanup``, 

22``prune``, ``orphans``) require explicit caller intent and never run 

23implicitly. 

24""" 

25 

26from __future__ import annotations 

27 

28import base64 

29import json 

30import logging 

31import os 

32import re 

33import subprocess 

34from datetime import UTC, datetime, timedelta 

35from pathlib import Path 

36from typing import Any 

37 

38import boto3 

39from botocore.exceptions import ClientError 

40 

41from ._container_runtime import detect_container_runtime 

42from ._image_uri import ( 

43 aws_partition, 

44 ecr_registry_host, 

45) 

46from ._image_uri import ( 

47 rewrite_image_uri_for_region as _rewrite_image_uri_for_region, # noqa: F401 

48) 

49from .config import GCOConfig, _load_cdk_json, get_config 

50 

51# <pyflowchart-code-diagram> BEGIN - auto-inserted, do not edit 

52# Generated at (UTC): 2026-07-18T01:03:40Z 

53# Flowchart(s) generated from this file: 

54# * ``ImageManager.build`` -> ``diagrams/code_diagrams/cli/images.ImageManager_build.html`` 

55# (PNG: ``diagrams/code_diagrams/cli/images.ImageManager_build.png``) 

56# * ``ImageManager.push`` -> ``diagrams/code_diagrams/cli/images.ImageManager_push.html`` 

57# (PNG: ``diagrams/code_diagrams/cli/images.ImageManager_push.png``) 

58# * ``ImageManager.cleanup`` -> ``diagrams/code_diagrams/cli/images.ImageManager_cleanup.html`` 

59# (PNG: ``diagrams/code_diagrams/cli/images.ImageManager_cleanup.png``) 

60# Regenerate with ``python diagrams/code_diagrams/generate.py``. 

61# <pyflowchart-code-diagram> END 

62 

63 

64logger = logging.getLogger(__name__) 

65 

66# Image name and tag validation regexes. 

67# 

68# Names: short, dns-friendly. Lowercase letter start, lowercase 

69# alphanumerics and dashes after, max 63 characters total. The regex 

70# also accepts a single character (``^[a-z]$``) — any longer name 

71# requires a closing alphanumeric so dangling dashes are rejected. 

72_NAME_RE = re.compile(r"^[a-z][a-z0-9-]{0,62}$") 

73 

74# Tags: docker reference grammar. First character must be alnum or 

75# underscore; subsequent characters allow dot, dash, underscore. 

76# 128 chars max. 

77_TAG_RE = re.compile(r"^[a-zA-Z0-9_][a-zA-Z0-9_.\-]{0,127}$") 

78 

79# Project repository prefix. Every repo this manager creates lives under 

80# ``<project_name>/`` (default ``gco/``) so a single replication / lifecycle / 

81# removal-policy rule can target the whole deployment, and two deployments in 

82# one account+region get isolated ECR namespaces (#139). Resolved per-instance 

83# from ``config.project_name`` into ``self._repo_prefix`` in ``__init__``. 

84 

85# First-party images that GCO builds and ships itself, as opposed to 

86# the user images pushed through ``build``/``push``. Each entry pairs 

87# the logical image name (which becomes the ``gco/<name>`` ECR 

88# repository suffix) with the Dockerfile under ``dockerfiles/`` that 

89# produces it. Listing these here lets callers enumerate the shipped 

90# images and resolve any one of them to its registry URI by name, 

91# the same way the platform services (health-monitor, 

92# manifest-processor, queue-processor, inference-monitor, 

93# inference-proxy) are built from their matching 

94# ``dockerfiles/<name>-dockerfile``. 

95_MAINTAINED_IMAGES: dict[str, str] = { 

96 "health-monitor": "dockerfiles/health-monitor-dockerfile", 

97 "manifest-processor": "dockerfiles/manifest-processor-dockerfile", 

98 "queue-processor": "dockerfiles/queue-processor-dockerfile", 

99 "inference-monitor": "dockerfiles/inference-monitor-dockerfile", 

100 "inference-proxy": "dockerfiles/inference-proxy-dockerfile", 

101} 

102 

103# Default image served by disaggregated prefill/decode deployments when the 

104# operator does not supply one. As of this tag the upstream vLLM OpenAI server 

105# image bundles the Mooncake transfer engine as a first-class KV-connector 

106# dependency, so GCO no longer builds or maintains its own image — deploys pull 

107# this upstream image directly from Docker Hub. Pinned to an explicit version 

108# for reproducibility; bump intentionally when validating a new vLLM release 

109# and never use a mutable/rolling tag such as ``latest``. 

110_DISAGGREGATED_DEFAULT_IMAGE = "vllm/vllm-openai:v0.26.0" 

111 

112# Default lifecycle policy parameters. 

113_DEFAULT_KEEP_TAGGED = 20 

114_DEFAULT_EXPIRE_UNTAGGED_DAYS = 7 

115 

116# Digest extraction from ``docker push`` stdout/stderr. The runtime 

117# emits a line of the form ``... digest: sha256:... size: ...``. 

118_DIGEST_RE = re.compile(r"sha256:[a-f0-9]{64}") 

119 

120 

121class ImageManager: 

122 """Manages user container images in ECR. 

123 

124 Construction is cheap — no AWS calls happen until a method is 

125 invoked. The account ID and target region are resolved lazily. 

126 """ 

127 

128 def __init__(self, config: GCOConfig | None = None, region: str | None = None): 

129 self.config = config or get_config() 

130 # ECR repo namespace for this deployment (#139): repos live under 

131 # ``<project_name>/`` so two deployments in one account+region don't 

132 # share an ECR namespace. Defaults to ``gco`` — byte-identical to the 

133 # pre-#139 hardcoded prefix for the stock deployment. 

134 self._repo_prefix = self.config.project_name 

135 self.region = self._resolve_region(region) 

136 self._account_id_cache: str | None = None 

137 

138 # ------------------------------------------------------------------ 

139 # Region / account helpers 

140 # ------------------------------------------------------------------ 

141 def _resolve_region(self, region: str | None) -> str: 

142 """Pick a region for ECR API calls. 

143 

144 Priority: explicit argument, ``AWS_DEFAULT_REGION``, then the global 

145 region where the shared ECR registry is deployed. ``GCOConfig`` has no 

146 ``regions`` attribute; deployment-region discovery is handled 

147 separately by :meth:`_replication_regions`. 

148 """ 

149 if region: 

150 return region 

151 env_region = os.environ.get("AWS_DEFAULT_REGION") 

152 if env_region: 

153 return env_region 

154 return str(self.config.global_region) 

155 

156 def _account_id(self) -> str: 

157 """Return the AWS account ID via STS GetCallerIdentity (cached).""" 

158 if self._account_id_cache is None: 

159 sts = boto3.client("sts") 

160 self._account_id_cache = sts.get_caller_identity()["Account"] 

161 return self._account_id_cache 

162 

163 def _registry_host(self) -> str: 

164 """Return the partition-correct ECR registry host for this region.""" 

165 return ecr_registry_host(self._account_id(), self.region) 

166 

167 def _repo_arn(self, name: str) -> str: 

168 """Return the full ARN of the repository under the project prefix.""" 

169 return ( 

170 f"arn:{aws_partition(self.region)}:ecr:{self.region}:{self._account_id()}:" 

171 f"repository/{self._repo_prefix}/{name}" 

172 ) 

173 

174 def _ecr_client(self) -> Any: 

175 """Return a boto3 ECR client targeting the manager's region.""" 

176 return boto3.client("ecr", region_name=self.region) 

177 

178 # ------------------------------------------------------------------ 

179 # Validation helpers 

180 # ------------------------------------------------------------------ 

181 def _validate_context(self, context: str) -> Path: 

182 """Validate the build context path. 

183 

184 The path must exist on disk and resolve to a directory. Raw 

185 ``..`` segments in the supplied string are rejected outright 

186 so the caller can't trick the manager into reaching outside an 

187 intended workspace; the resolved path is then returned for use 

188 as ``cwd`` of the build. 

189 """ 

190 # Reject string-level traversal segments BEFORE resolving the 

191 # path so callers receive a clear error rather than a silent 

192 # rewrite up the tree. 

193 parts = Path(context).parts 

194 if ".." in parts: 

195 raise ValueError(f"Invalid build context: path traversal not allowed: {context}") 

196 resolved = Path(context).resolve() 

197 if not resolved.exists(): 

198 raise FileNotFoundError(f"Build context not found: {context}") 

199 if not resolved.is_dir(): 

200 raise ValueError(f"Build context is not a directory: {context}") 

201 return resolved 

202 

203 def _validate_name(self, name: str) -> str: 

204 """Validate an image name against ``_NAME_RE``.""" 

205 if not _NAME_RE.match(name): 

206 raise ValueError( 

207 f"Invalid image name: {name!r}. Expected lowercase letters, " 

208 "digits, and dashes; must start with a letter; max 63 chars." 

209 ) 

210 return name 

211 

212 def _validate_tag(self, tag: str) -> str: 

213 """Validate an image tag against ``_TAG_RE``.""" 

214 if not _TAG_RE.match(tag): 

215 raise ValueError( 

216 f"Invalid image tag: {tag!r}. Expected alphanumerics, dots, " 

217 "dashes, and underscores; max 128 chars." 

218 ) 

219 return tag 

220 

221 # ------------------------------------------------------------------ 

222 # Default-value helpers 

223 # ------------------------------------------------------------------ 

224 def _git_short_sha(self) -> str | None: 

225 """Return the current short git SHA, or ``None`` when unavailable.""" 

226 try: 

227 result = subprocess.run( 

228 ["git", "rev-parse", "--short", "HEAD"], 

229 capture_output=True, 

230 text=True, 

231 timeout=5, 

232 check=False, 

233 ) 

234 if result.returncode == 0 and result.stdout.strip(): 

235 return result.stdout.strip() 

236 except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as e: 

237 logger.debug("git rev-parse failed: %s", e) 

238 return None 

239 

240 def _default_tag(self) -> str: 

241 """Return ``_git_short_sha()`` when available, else ``"latest"``.""" 

242 sha = self._git_short_sha() 

243 return sha if sha else "latest" 

244 

245 def _default_lifecycle_policy(self) -> dict[str, Any]: 

246 """Return the default ECR lifecycle policy as a dict. 

247 

248 The policy keeps the most recent ``_DEFAULT_KEEP_TAGGED`` tagged 

249 images and expires untagged images after 

250 ``_DEFAULT_EXPIRE_UNTAGGED_DAYS`` days. The structure matches 

251 the JSON shape that ``ecr.put_lifecycle_policy`` accepts after 

252 being JSON-stringified at the call site. 

253 """ 

254 return { 

255 "rules": [ 

256 { 

257 "rulePriority": 1, 

258 "description": (f"Keep last {_DEFAULT_KEEP_TAGGED} tagged images"), 

259 "selection": { 

260 "tagStatus": "tagged", 

261 "countType": "imageCountMoreThan", 

262 "countNumber": _DEFAULT_KEEP_TAGGED, 

263 "tagPatternList": ["*"], 

264 }, 

265 "action": {"type": "expire"}, 

266 }, 

267 { 

268 "rulePriority": 2, 

269 "description": (f"Expire untagged after {_DEFAULT_EXPIRE_UNTAGGED_DAYS} days"), 

270 "selection": { 

271 "tagStatus": "untagged", 

272 "countType": "sinceImagePushed", 

273 "countUnit": "days", 

274 "countNumber": _DEFAULT_EXPIRE_UNTAGGED_DAYS, 

275 }, 

276 "action": {"type": "expire"}, 

277 }, 

278 ], 

279 } 

280 

281 # ------------------------------------------------------------------ 

282 # Output helpers 

283 # ------------------------------------------------------------------ 

284 def _extract_digest(self, push_output: str) -> str | None: 

285 """Pull the first ``sha256:...`` digest out of push stdout/stderr.""" 

286 match = _DIGEST_RE.search(push_output) 

287 return match.group(0) if match else None 

288 

289 # ------------------------------------------------------------------ 

290 # ECR repository helpers (used by build/push) 

291 # ------------------------------------------------------------------ 

292 def _runtime_or_error(self) -> str: 

293 """Return the detected container runtime, or raise a friendly error.""" 

294 runtime = detect_container_runtime() 

295 if not runtime: 

296 raise RuntimeError( 

297 "No container runtime found. Install Docker, Finch, or " 

298 "Podman, or set CDK_DOCKER=<path>.\n" 

299 " - Docker: https://docs.docker.com/get-docker/\n" 

300 " - Finch: brew install finch && finch vm init\n" 

301 " - Podman: https://podman.io/getting-started/installation" 

302 ) 

303 return runtime 

304 

305 def _ecr_login(self, runtime: str) -> None: 

306 """Authenticate the runtime against the ECR registry.""" 

307 ecr = self._ecr_client() 

308 token = ecr.get_authorization_token()["authorizationData"][0]["authorizationToken"] 

309 username, password = base64.b64decode(token).decode().split(":", 1) 

310 registry = self._registry_host() 

311 result = subprocess.run( 

312 [runtime, "login", "-u", username, "--password-stdin", registry], 

313 input=password.encode(), 

314 capture_output=True, 

315 check=False, 

316 ) 

317 if result.returncode != 0: 

318 raise RuntimeError( 

319 f"{runtime} login to {registry} failed: " 

320 f"{result.stderr.decode(errors='replace').strip()}" 

321 ) 

322 

323 def _check_tag_immutable_collision(self, name: str, tag: str) -> None: 

324 """Block re-pushing a tag when the repo is immutable. 

325 

326 ECR repos can be configured with ``imageTagMutability=IMMUTABLE``, 

327 in which case attempting to overwrite an existing tag silently 

328 succeeds at build time but fails at push time with a confusing 

329 error. Catch this earlier and surface a helpful message. 

330 """ 

331 ecr = self._ecr_client() 

332 repo_name = f"{self._repo_prefix}/{name}" 

333 try: 

334 repo_resp = ecr.describe_repositories(repositoryNames=[repo_name]) 

335 except ecr.exceptions.RepositoryNotFoundException: 

336 return 

337 except ClientError as e: 

338 code = e.response.get("Error", {}).get("Code", "") 

339 if code == "RepositoryNotFoundException": 

340 return 

341 raise 

342 

343 repos = repo_resp.get("repositories", []) 

344 if not repos: 

345 return 

346 mutability = repos[0].get("imageTagMutability", "MUTABLE") 

347 if mutability != "IMMUTABLE": 

348 return 

349 

350 try: 

351 existing = ecr.describe_images( 

352 repositoryName=repo_name, 

353 imageIds=[{"imageTag": tag}], 

354 ) 

355 except ecr.exceptions.ImageNotFoundException: 

356 return 

357 except ClientError as e: 

358 code = e.response.get("Error", {}).get("Code", "") 

359 if code == "ImageNotFoundException": 

360 return 

361 raise 

362 

363 if existing.get("imageDetails"): 363 ↛ exitline 363 didn't return from function '_check_tag_immutable_collision' because the condition on line 363 was always true

364 raise RuntimeError( 

365 f"Tag {tag!r} already exists on immutable repo " 

366 f"{repo_name!r}. Re-run with a different tag, e.g. " 

367 f"--tag <new_tag>." 

368 ) 

369 

370 def _apply_retain_tag(self, name: str) -> None: 

371 """Apply the ``gco:retain=true`` resource tag to the repository.""" 

372 ecr = self._ecr_client() 

373 ecr.tag_resource( 

374 resourceArn=self._repo_arn(name), 

375 tags=[{"Key": "gco:retain", "Value": "true"}], 

376 ) 

377 

378 # ------------------------------------------------------------------ 

379 # build / push 

380 # ------------------------------------------------------------------ 

381 def build( 

382 self, 

383 context: str, 

384 name: str, 

385 tag: str | None = None, 

386 dockerfile: str = "Dockerfile", 

387 build_args: dict[str, str] | None = None, 

388 platform: str = "linux/amd64", 

389 retain: bool = False, 

390 quiet: bool = False, 

391 ) -> dict[str, Any]: 

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

393 

394 Args: 

395 context: Build context directory. 

396 name: Image name (validated; lowercase letters, digits, dashes). 

397 tag: Image tag (defaults to git short SHA, else ``latest``). 

398 dockerfile: Path to the Dockerfile, relative to ``context``. 

399 build_args: Optional ``KEY=value`` build args. 

400 platform: ``--platform`` argument for the build (default 

401 ``linux/amd64``). 

402 retain: When True, mark the repository with ``gco:retain=true`` 

403 so it survives stack destroys. 

404 quiet: Capture container build output instead of writing it to the 

405 command's output stream. Used for machine-readable CLI output. 

406 

407 Returns: 

408 ``{"image_uri", "digest", "size_bytes", ...}``. 

409 """ 

410 ctx = self._validate_context(context) 

411 validated_name = self._validate_name(name) 

412 validated_tag = self._validate_tag(tag if tag is not None else self._default_tag()) 

413 

414 df_path = (ctx / dockerfile).resolve() 

415 if not df_path.exists() or not df_path.is_file(): 415 ↛ 416line 415 didn't jump to line 416 because the condition on line 415 was never true

416 raise FileNotFoundError(f"Dockerfile not found: {df_path} (relative to {ctx})") 

417 # Confine the Dockerfile to the build context. 

418 if not str(df_path).startswith(str(ctx)): 418 ↛ 419line 418 didn't jump to line 419 because the condition on line 418 was never true

419 raise ValueError(f"Dockerfile must live inside the build context: {df_path}") 

420 

421 runtime = self._runtime_or_error() 

422 self.init(name, retain=retain) 

423 self._check_tag_immutable_collision(validated_name, validated_tag) 

424 self._ecr_login(runtime) 

425 

426 full_uri = f"{self._registry_host()}/{self._repo_prefix}/{validated_name}:{validated_tag}" 

427 

428 build_cmd: list[str] = [ 

429 runtime, 

430 "build", 

431 "-t", 

432 full_uri, 

433 "--platform", 

434 platform, 

435 "-f", 

436 str(df_path), 

437 ] 

438 for key, value in (build_args or {}).items(): 438 ↛ 439line 438 didn't jump to line 439 because the loop on line 438 never started

439 build_cmd.extend(["--build-arg", f"{key}={value}"]) 

440 build_cmd.append(str(ctx)) 

441 

442 logger.info("Building image: %s", " ".join(build_cmd)) 

443 build_run_kwargs: dict[str, Any] = {"check": True, "cwd": str(ctx)} 

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

445 build_run_kwargs.update(capture_output=True, text=True) 

446 subprocess.run(build_cmd, **build_run_kwargs) 

447 

448 push_result = subprocess.run( 

449 [runtime, "push", full_uri], 

450 capture_output=True, 

451 text=True, 

452 check=True, 

453 cwd=str(ctx), 

454 ) 

455 digest = self._extract_digest((push_result.stdout or "") + (push_result.stderr or "")) 

456 

457 if retain: 457 ↛ 458line 457 didn't jump to line 458 because the condition on line 457 was never true

458 self._apply_retain_tag(validated_name) 

459 

460 size_bytes = self._image_size_bytes(validated_name, validated_tag) 

461 

462 return { 

463 "image_uri": full_uri, 

464 "digest": digest, 

465 "size_bytes": size_bytes, 

466 "runtime": runtime, 

467 "repository": f"{self._repo_prefix}/{validated_name}", 

468 "tag": validated_tag, 

469 "region": self.region, 

470 "retain": retain, 

471 } 

472 

473 def push( 

474 self, 

475 name: str, 

476 tag: str, 

477 local_image: str, 

478 retain: bool = False, 

479 quiet: bool = False, 

480 ) -> dict[str, Any]: 

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

482 

483 Tags ``local_image`` as the project URI before invoking 

484 ``<runtime> push``. Skips the build step but otherwise mirrors 

485 ``build`` (init repo, login, push, optional retain tag). When ``quiet`` 

486 is true, the local tag command is captured for machine-readable output. 

487 """ 

488 validated_name = self._validate_name(name) 

489 validated_tag = self._validate_tag(tag) 

490 if not local_image: 

491 raise ValueError("local_image must be a non-empty image reference") 

492 

493 runtime = self._runtime_or_error() 

494 self.init(name, retain=retain) 

495 self._check_tag_immutable_collision(validated_name, validated_tag) 

496 self._ecr_login(runtime) 

497 

498 full_uri = f"{self._registry_host()}/{self._repo_prefix}/{validated_name}:{validated_tag}" 

499 

500 tag_run_kwargs: dict[str, Any] = {"check": True} 

501 if quiet: 501 ↛ 502line 501 didn't jump to line 502 because the condition on line 501 was never true

502 tag_run_kwargs.update(capture_output=True, text=True) 

503 subprocess.run([runtime, "tag", local_image, full_uri], **tag_run_kwargs) 

504 push_result = subprocess.run( 

505 [runtime, "push", full_uri], 

506 capture_output=True, 

507 text=True, 

508 check=True, 

509 ) 

510 digest = self._extract_digest((push_result.stdout or "") + (push_result.stderr or "")) 

511 

512 if retain: 512 ↛ 513line 512 didn't jump to line 513 because the condition on line 512 was never true

513 self._apply_retain_tag(validated_name) 

514 

515 size_bytes = self._image_size_bytes(validated_name, validated_tag) 

516 

517 return { 

518 "image_uri": full_uri, 

519 "digest": digest, 

520 "size_bytes": size_bytes, 

521 "runtime": runtime, 

522 "repository": f"{self._repo_prefix}/{validated_name}", 

523 "tag": validated_tag, 

524 "region": self.region, 

525 "retain": retain, 

526 } 

527 

528 def _image_size_bytes(self, name: str, tag: str) -> int | None: 

529 """Best-effort ECR lookup for the pushed image size.""" 

530 ecr = self._ecr_client() 

531 try: 

532 resp = ecr.describe_images( 

533 repositoryName=f"{self._repo_prefix}/{name}", 

534 imageIds=[{"imageTag": tag}], 

535 ) 

536 details = resp.get("imageDetails", []) 

537 if details: 537 ↛ 538line 537 didn't jump to line 538 because the condition on line 537 was never true

538 size = details[0].get("imageSizeInBytes") 

539 if isinstance(size, int): 

540 return size 

541 except Exception as e: # noqa: BLE001 

542 logger.debug("describe_images for size lookup failed: %s", e) 

543 return None 

544 

545 # ------------------------------------------------------------------ 

546 # Read-only methods 

547 # ------------------------------------------------------------------ 

548 def list_repos(self) -> list[dict[str, Any]]: 

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

550 ecr = self._ecr_client() 

551 repos: list[dict[str, Any]] = [] 

552 paginator = ecr.get_paginator("describe_repositories") 

553 for page in paginator.paginate(): 

554 for repo in page.get("repositories", []): 

555 repo_name = repo.get("repositoryName", "") 

556 if not repo_name.startswith(f"{self._repo_prefix}/"): 

557 continue 

558 image_count = self._image_count(repo_name) 

559 repos.append( 

560 { 

561 "name": repo_name, 

562 "arn": repo.get("repositoryArn"), 

563 "uri": repo.get("repositoryUri"), 

564 "created_at": _isoformat(repo.get("createdAt")), 

565 "image_count": image_count, 

566 "tag_mutability": repo.get("imageTagMutability"), 

567 } 

568 ) 

569 return repos 

570 

571 def _image_count(self, repository_name: str) -> int: 

572 """Best-effort count of images in a repository.""" 

573 ecr = self._ecr_client() 

574 try: 

575 count = 0 

576 paginator = ecr.get_paginator("describe_images") 

577 for page in paginator.paginate(repositoryName=repository_name): 

578 count += len(page.get("imageDetails", [])) 

579 return count 

580 except Exception as e: # noqa: BLE001 

581 logger.debug("describe_images count for %s failed: %s", repository_name, e) 

582 return 0 

583 

584 def list_tags(self, name: str) -> list[dict[str, Any]]: 

585 """List every tag (with digest, pushed date, size) on a repository.""" 

586 validated = self._validate_name(name) 

587 ecr = self._ecr_client() 

588 rows: list[dict[str, Any]] = [] 

589 paginator = ecr.get_paginator("describe_images") 

590 for page in paginator.paginate( 

591 repositoryName=f"{self._repo_prefix}/{validated}", 

592 ): 

593 for detail in page.get("imageDetails", []): 

594 for tag in detail.get("imageTags", []) or [None]: 

595 rows.append( 

596 { 

597 "tag": tag, 

598 "digest": detail.get("imageDigest"), 

599 "pushed_at": _isoformat(detail.get("imagePushedAt")), 

600 "size_bytes": detail.get("imageSizeInBytes"), 

601 } 

602 ) 

603 return rows 

604 

605 def describe(self, name: str, tag: str) -> dict[str, Any]: 

606 """Return the full ECR image details for a single tag.""" 

607 validated_name = self._validate_name(name) 

608 validated_tag = self._validate_tag(tag) 

609 ecr = self._ecr_client() 

610 resp = ecr.describe_images( 

611 repositoryName=f"{self._repo_prefix}/{validated_name}", 

612 imageIds=[{"imageTag": validated_tag}], 

613 ) 

614 details = resp.get("imageDetails", []) 

615 if not details: 

616 return {} 

617 detail = details[0] 

618 return { 

619 "name": f"{self._repo_prefix}/{validated_name}", 

620 "tag": validated_tag, 

621 "digest": detail.get("imageDigest"), 

622 "pushed_at": _isoformat(detail.get("imagePushedAt")), 

623 "size_bytes": detail.get("imageSizeInBytes"), 

624 "tags": detail.get("imageTags", []), 

625 "scan_findings_summary": detail.get("imageScanFindingsSummary"), 

626 } 

627 

628 def get_uri(self, name: str, tag: str = "latest") -> str: 

629 """Return the full registry URI for ``name:tag``. No API call.""" 

630 validated_name = self._validate_name(name) 

631 validated_tag = self._validate_tag(tag) 

632 return f"{self._registry_host()}/{self._repo_prefix}/{validated_name}:{validated_tag}" 

633 

634 def list_maintained_images(self, tag: str = "latest") -> list[dict[str, Any]]: 

635 """List the first-party images GCO builds and ships. 

636 

637 Returns one row per shipped image with its logical name, the 

638 ``gco/<name>`` repository, the Dockerfile that produces it, and 

639 the registry URI for ``tag``. No API call — the catalog is 

640 resolved locally from the shipped Dockerfile set. 

641 """ 

642 rows: list[dict[str, Any]] = [] 

643 for name, dockerfile in _MAINTAINED_IMAGES.items(): 

644 rows.append( 

645 { 

646 "name": name, 

647 "repository": f"{self._repo_prefix}/{name}", 

648 "dockerfile": dockerfile, 

649 "uri": self.get_uri(name, tag), 

650 } 

651 ) 

652 return rows 

653 

654 def get_maintained_image(self, name: str, tag: str = "latest") -> dict[str, Any]: 

655 """Resolve a single shipped image by name. 

656 

657 Raises ``ValueError`` for a name that is not one of the shipped 

658 images, listing the known names so the caller can correct the 

659 lookup. 

660 """ 

661 dockerfile = _MAINTAINED_IMAGES.get(name) 

662 if dockerfile is None: 

663 known = ", ".join(sorted(_MAINTAINED_IMAGES)) 

664 raise ValueError(f"Unknown maintained image: {name!r}. Known images: {known}.") 

665 return { 

666 "name": name, 

667 "repository": f"{self._repo_prefix}/{name}", 

668 "dockerfile": dockerfile, 

669 "uri": self.get_uri(name, tag), 

670 } 

671 

672 def default_disaggregated_image_uri(self, tag: str | None = None) -> str: 

673 """Return the image reference disaggregated prefill/decode deploys serve from. 

674 

675 As of the pinned tag, the upstream ``vllm/vllm-openai`` image bundles 

676 the Mooncake transfer engine, so GCO no longer builds its own image — 

677 deploys pull this upstream image from Docker Hub directly. Returns the 

678 pinned reference; ``tag``, when given, overrides only the version. 

679 """ 

680 if tag: 680 ↛ 681line 680 didn't jump to line 681 because the condition on line 680 was never true

681 repo = _DISAGGREGATED_DEFAULT_IMAGE.rsplit(":", 1)[0] 

682 return f"{repo}:{tag}" 

683 return _DISAGGREGATED_DEFAULT_IMAGE 

684 

685 def _current_replication_configuration(self, ecr: Any) -> tuple[str | None, dict[str, Any]]: 

686 """Return the registry ID and current ECR replication configuration.""" 

687 try: 

688 response = ecr.get_replication_configuration() 

689 except ClientError as e: 

690 code = e.response.get("Error", {}).get("Code", "") 

691 if code == "ReplicationConfigurationNotFoundException": 

692 return None, {"rules": []} 

693 raise 

694 

695 configuration = response.get("replicationConfiguration") or {"rules": []} 

696 if not isinstance(configuration, dict): 696 ↛ 697line 696 didn't jump to line 697 because the condition on line 696 was never true

697 configuration = {"rules": []} 

698 if not isinstance(configuration.get("rules"), list): 698 ↛ 699line 698 didn't jump to line 699 because the condition on line 698 was never true

699 configuration = {**configuration, "rules": []} 

700 return response.get("registryId"), configuration 

701 

702 def replication_get(self) -> dict[str, Any]: 

703 """Return the current ECR replication configuration, or ``{}``. 

704 

705 The historical ``policy`` response key is retained for CLI/API 

706 compatibility, but its value now comes from the replication API rather 

707 than the unrelated registry-permissions policy API. 

708 """ 

709 registry_id, configuration = self._current_replication_configuration(self._ecr_client()) 

710 if not configuration.get("rules"): 

711 return {} 

712 return { 

713 "registryId": registry_id, 

714 "policy": configuration, 

715 } 

716 

717 def _replication_regions(self) -> list[str]: 

718 """Resolve deployed regional destinations from supported config data.""" 

719 deployment_regions = _load_cdk_json().get("regional", []) 

720 candidates = deployment_regions if isinstance(deployment_regions, list) else [] 

721 if not candidates: 

722 default_region = getattr(self.config, "default_region", None) 

723 if isinstance(default_region, str) and default_region: 

724 candidates = [default_region] 

725 

726 # Preserve declaration order while removing invalid values/duplicates. 

727 return list( 

728 dict.fromkeys(region for region in candidates if isinstance(region, str) and region) 

729 ) 

730 

731 def replication_status(self) -> list[dict[str, Any]]: 

732 """Per-repo replication status across the project repos.""" 

733 ecr = self._ecr_client() 

734 rows: list[dict[str, Any]] = [] 

735 for repo in self.list_repos(): 

736 repo_name = repo["name"] 

737 paginator = ecr.get_paginator("describe_images") 

738 try: 

739 for page in paginator.paginate(repositoryName=repo_name): 

740 for detail in page.get("imageDetails", []): 

741 digest = detail.get("imageDigest") 

742 try: 

743 status = ecr.describe_image_replication_status( 

744 repositoryName=repo_name, 

745 imageId={"imageDigest": digest}, 

746 ) 

747 for entry in status.get("replicationStatuses", []): 

748 rows.append( 

749 { 

750 "repository": repo_name, 

751 "digest": digest, 

752 "region": entry.get("region"), 

753 "status": entry.get("status"), 

754 "registry_id": entry.get("registryId"), 

755 } 

756 ) 

757 except (ClientError, AttributeError) as e: 

758 logger.debug( 

759 "describe_image_replication_status failed for %s %s: %s", 

760 repo_name, 

761 digest, 

762 e, 

763 ) 

764 except ClientError as e: 

765 logger.debug("describe_images failed for %s: %s", repo_name, e) 

766 return rows 

767 

768 # ------------------------------------------------------------------ 

769 # Administrative methods 

770 # ------------------------------------------------------------------ 

771 def init(self, name: str, retain: bool = False) -> dict[str, Any]: 

772 """Create the project repository idempotently with default lifecycle. 

773 

774 ``CreateRepository`` is invoked with ``imageTagMutability=MUTABLE`` 

775 and ``scanOnPush=True``. If the repository already exists, the 

776 method becomes a no-op for repository creation but still applies 

777 the default lifecycle policy and the optional ``gco:retain`` tag. 

778 """ 

779 validated = self._validate_name(name) 

780 repo_name = f"{self._repo_prefix}/{validated}" 

781 ecr = self._ecr_client() 

782 

783 created = False 

784 try: 

785 ecr.create_repository( 

786 repositoryName=repo_name, 

787 imageTagMutability="MUTABLE", 

788 imageScanningConfiguration={"scanOnPush": True}, 

789 tags=[ 

790 {"Key": "Project", "Value": self.config.project_name}, 

791 ], 

792 ) 

793 created = True 

794 except ecr.exceptions.RepositoryAlreadyExistsException: 

795 # Idempotent init — re-running ``gco images init`` against an 

796 # already-provisioned repo is a no-op for create_repository. 

797 # We still flow through the lifecycle/retain blocks below so 

798 # any drift in policy is healed on every call. 

799 logger.debug("repository %s already exists; skipping create", repo_name) 

800 except ClientError as e: 

801 code = e.response.get("Error", {}).get("Code", "") 

802 if code != "RepositoryAlreadyExistsException": 802 ↛ 805line 802 didn't jump to line 805 because the condition on line 802 was always true

803 raise 

804 

805 try: 

806 ecr.put_lifecycle_policy( 

807 repositoryName=repo_name, 

808 lifecyclePolicyText=json.dumps(self._default_lifecycle_policy()), 

809 ) 

810 except ClientError as e: 

811 logger.debug("put_lifecycle_policy on %s failed: %s", repo_name, e) 

812 

813 if retain: 

814 try: 

815 self._apply_retain_tag(validated) 

816 except ClientError as e: 

817 logger.debug("apply retain tag on %s failed: %s", repo_name, e) 

818 

819 return { 

820 "name": repo_name, 

821 "created": created, 

822 "retain": retain, 

823 } 

824 

825 def lifecycle_get(self, name: str) -> dict[str, Any]: 

826 """Return the lifecycle policy on a repository, or ``{}``.""" 

827 validated = self._validate_name(name) 

828 ecr = self._ecr_client() 

829 try: 

830 resp = ecr.get_lifecycle_policy( 

831 repositoryName=f"{self._repo_prefix}/{validated}", 

832 ) 

833 policy_text = resp.get("lifecyclePolicyText") 

834 if policy_text: 834 ↛ 846line 834 didn't jump to line 846 because the condition on line 834 was always true

835 return { 

836 "name": f"{self._repo_prefix}/{validated}", 

837 "policy": json.loads(policy_text), 

838 } 

839 except ecr.exceptions.LifecyclePolicyNotFoundException: 

840 return {} 

841 except ClientError as e: 

842 code = e.response.get("Error", {}).get("Code", "") 

843 if code == "LifecyclePolicyNotFoundException": 843 ↛ 844line 843 didn't jump to line 844 because the condition on line 843 was never true

844 return {} 

845 raise 

846 return {} 

847 

848 def lifecycle_set(self, name: str, policy: dict[str, Any]) -> dict[str, Any]: 

849 """Replace the lifecycle policy on a repository.""" 

850 validated = self._validate_name(name) 

851 ecr = self._ecr_client() 

852 resp = ecr.put_lifecycle_policy( 

853 repositoryName=f"{self._repo_prefix}/{validated}", 

854 lifecyclePolicyText=json.dumps(policy), 

855 ) 

856 return { 

857 "name": f"{self._repo_prefix}/{validated}", 

858 "registry_id": resp.get("registryId"), 

859 "policy": policy, 

860 } 

861 

862 def replication_sync(self) -> dict[str, Any]: 

863 """Apply this project's replication rule without clobbering others. 

864 

865 Existing rules for unrelated repository prefixes are retained. If no 

866 non-source destination can be resolved, no write is made; this avoids 

867 replacing a valid registry configuration with an empty rule set. 

868 """ 

869 ecr = self._ecr_client() 

870 registry_id, current = self._current_replication_configuration(ecr) 

871 destinations = [region for region in self._replication_regions() if region != self.region] 

872 

873 if not destinations: 

874 return { 

875 "configuration": current, 

876 "destinations": [], 

877 "registry_id": registry_id, 

878 "updated": False, 

879 } 

880 

881 account = self._account_id() 

882 managed_filter = { 

883 "filter": f"{self._repo_prefix}/", 

884 "filterType": "PREFIX_MATCH", 

885 } 

886 managed_rule = { 

887 "destinations": [{"region": region, "registryId": account} for region in destinations], 

888 "repositoryFilters": [managed_filter], 

889 } 

890 

891 preserved_rules: list[dict[str, Any]] = [] 

892 for existing_rule in current.get("rules", []): 

893 if not isinstance(existing_rule, dict): 893 ↛ 894line 893 didn't jump to line 894 because the condition on line 893 was never true

894 continue 

895 filters = existing_rule.get("repositoryFilters") or [] 

896 managed_filters = [item for item in filters if item == managed_filter] 

897 if not managed_filters: 897 ↛ 904line 897 didn't jump to line 904 because the condition on line 897 was always true

898 preserved_rules.append(existing_rule) 

899 continue 

900 

901 # A rule can contain filters for multiple prefixes. Retain the 

902 # unrelated filters with their original destinations while replacing 

903 # only this project's managed filter. 

904 unrelated_filters = [item for item in filters if item != managed_filter] 

905 if unrelated_filters: 

906 preserved_rules.append({**existing_rule, "repositoryFilters": unrelated_filters}) 

907 

908 configuration = { 

909 **current, 

910 "rules": [*preserved_rules, managed_rule], 

911 } 

912 response = ecr.put_replication_configuration(replicationConfiguration=configuration) 

913 return { 

914 "configuration": configuration, 

915 "destinations": destinations, 

916 "registry_id": response.get("registryId") or registry_id or account, 

917 "updated": True, 

918 } 

919 

920 # ------------------------------------------------------------------ 

921 # Destructive methods 

922 # ------------------------------------------------------------------ 

923 def delete_tag(self, name: str, tag: str) -> dict[str, Any]: 

924 """Delete a single tag from a repository.""" 

925 validated_name = self._validate_name(name) 

926 validated_tag = self._validate_tag(tag) 

927 ecr = self._ecr_client() 

928 resp = ecr.batch_delete_image( 

929 repositoryName=f"{self._repo_prefix}/{validated_name}", 

930 imageIds=[{"imageTag": validated_tag}], 

931 ) 

932 return { 

933 "name": f"{self._repo_prefix}/{validated_name}", 

934 "tag": validated_tag, 

935 "deleted": [ 

936 {"digest": d.get("imageDigest"), "tag": d.get("imageTag")} 

937 for d in resp.get("imageIds", []) 

938 ], 

939 "failures": resp.get("failures", []), 

940 } 

941 

942 def delete_repo(self, name: str, force: bool = False) -> dict[str, Any]: 

943 """Delete a repository (optionally including its images).""" 

944 validated = self._validate_name(name) 

945 ecr = self._ecr_client() 

946 resp = ecr.delete_repository( 

947 repositoryName=f"{self._repo_prefix}/{validated}", 

948 force=force, 

949 ) 

950 return { 

951 "name": f"{self._repo_prefix}/{validated}", 

952 "deleted": True, 

953 "registry_id": resp.get("repository", {}).get("registryId"), 

954 } 

955 

956 def cleanup( 

957 self, 

958 name: str | None = None, 

959 all: bool = False, 

960 ) -> dict[str, Any]: 

961 """Delete every untagged image across one or all project repos.""" 

962 if not name and not all: 

963 raise ValueError("cleanup() requires either a name or all=True") 

964 

965 repos: list[str] 

966 if name: 

967 validated = self._validate_name(name) 

968 repos = [f"{self._repo_prefix}/{validated}"] 

969 else: 

970 repos = [r["name"] for r in self.list_repos()] 

971 

972 ecr = self._ecr_client() 

973 repos_touched = 0 

974 tags_deleted = 0 

975 bytes_freed = 0 

976 

977 for repo_name in repos: 

978 untagged_ids: list[dict[str, str]] = [] 

979 untagged_size = 0 

980 try: 

981 paginator = ecr.get_paginator("describe_images") 

982 for page in paginator.paginate( 

983 repositoryName=repo_name, 

984 filter={"tagStatus": "UNTAGGED"}, 

985 ): 

986 for detail in page.get("imageDetails", []): 

987 digest = detail.get("imageDigest") 

988 if not digest: 

989 continue 

990 untagged_ids.append({"imageDigest": digest}) 

991 size = detail.get("imageSizeInBytes") or 0 

992 if isinstance(size, int): 992 ↛ 986line 992 didn't jump to line 986 because the condition on line 992 was always true

993 untagged_size += size 

994 except ClientError as e: 

995 logger.debug("describe_images for cleanup of %s failed: %s", repo_name, e) 

996 continue 

997 

998 if not untagged_ids: 998 ↛ 999line 998 didn't jump to line 999 because the condition on line 998 was never true

999 continue 

1000 repos_touched += 1 

1001 # batch_delete_image accepts up to 100 ids per call. 

1002 for chunk_start in range(0, len(untagged_ids), 100): 

1003 chunk = untagged_ids[chunk_start : chunk_start + 100] 

1004 resp = ecr.batch_delete_image( 

1005 repositoryName=repo_name, 

1006 imageIds=chunk, 

1007 ) 

1008 tags_deleted += len(resp.get("imageIds", [])) 

1009 bytes_freed += untagged_size 

1010 

1011 return { 

1012 "repos_touched": repos_touched, 

1013 "tags_deleted": tags_deleted, 

1014 "bytes_freed": bytes_freed, 

1015 } 

1016 

1017 def prune(self, dry_run: bool = True) -> dict[str, Any]: 

1018 """Remove untagged images older than 30 days. 

1019 

1020 Returns the same shape as ``cleanup``; when ``dry_run`` is True 

1021 (the default), no images are deleted. 

1022 """ 

1023 cutoff = datetime.now(UTC) - timedelta(days=30) 

1024 ecr = self._ecr_client() 

1025 repos_touched = 0 

1026 tags_deleted = 0 

1027 bytes_freed = 0 

1028 

1029 for repo in self.list_repos(): 

1030 repo_name = repo["name"] 

1031 stale_ids: list[dict[str, str]] = [] 

1032 stale_size = 0 

1033 try: 

1034 paginator = ecr.get_paginator("describe_images") 

1035 for page in paginator.paginate( 

1036 repositoryName=repo_name, 

1037 filter={"tagStatus": "UNTAGGED"}, 

1038 ): 

1039 for detail in page.get("imageDetails", []): 

1040 pushed = detail.get("imagePushedAt") 

1041 if pushed and pushed >= cutoff: 

1042 continue 

1043 digest = detail.get("imageDigest") 

1044 if not digest: 1044 ↛ 1045line 1044 didn't jump to line 1045 because the condition on line 1044 was never true

1045 continue 

1046 stale_ids.append({"imageDigest": digest}) 

1047 size = detail.get("imageSizeInBytes") or 0 

1048 if isinstance(size, int): 1048 ↛ 1039line 1048 didn't jump to line 1039 because the condition on line 1048 was always true

1049 stale_size += size 

1050 except ClientError as e: 

1051 logger.debug("describe_images for prune of %s failed: %s", repo_name, e) 

1052 continue 

1053 

1054 if not stale_ids: 

1055 continue 

1056 repos_touched += 1 

1057 tags_deleted += len(stale_ids) 

1058 bytes_freed += stale_size 

1059 if dry_run: 

1060 continue 

1061 for chunk_start in range(0, len(stale_ids), 100): 

1062 chunk = stale_ids[chunk_start : chunk_start + 100] 

1063 ecr.batch_delete_image( 

1064 repositoryName=repo_name, 

1065 imageIds=chunk, 

1066 ) 

1067 

1068 return { 

1069 "dry_run": dry_run, 

1070 "repos_touched": repos_touched, 

1071 "tags_deleted": tags_deleted, 

1072 "bytes_freed": bytes_freed, 

1073 } 

1074 

1075 def orphans(self, threshold_days: int = 30) -> list[dict[str, Any]]: 

1076 """List ``gco/*`` tags older than ``threshold_days`` with no references. 

1077 

1078 Cross-references against: 

1079 * inference endpoint specs (via :class:`cli.inference.InferenceManager`), 

1080 * recent jobs (best-effort; returns empty for the jobs side when 

1081 the queue table schema is unavailable). 

1082 """ 

1083 cutoff = datetime.now(UTC) - timedelta(days=threshold_days) 

1084 referenced: set[str] = set() 

1085 referenced.update(self._collect_inference_image_refs()) 

1086 referenced.update(self._collect_recent_job_image_refs(threshold_days)) 

1087 

1088 rows: list[dict[str, Any]] = [] 

1089 for repo in self.list_repos(): 

1090 repo_name = repo["name"] 

1091 for tag_row in self.list_tags(repo_name.removeprefix(f"{self._repo_prefix}/")): 

1092 tag = tag_row.get("tag") 

1093 if not tag: 1093 ↛ 1094line 1093 didn't jump to line 1094 because the condition on line 1093 was never true

1094 continue 

1095 pushed = self._parse_iso(tag_row.get("pushed_at")) 

1096 if pushed and pushed >= cutoff: 

1097 continue 

1098 uri = f"{self._registry_host()}/{repo_name}:{tag}" 

1099 if uri in referenced: 

1100 continue 

1101 rows.append( 

1102 { 

1103 "repository": repo_name, 

1104 "tag": tag, 

1105 "digest": tag_row.get("digest"), 

1106 "pushed_at": tag_row.get("pushed_at"), 

1107 "uri": uri, 

1108 } 

1109 ) 

1110 return rows 

1111 

1112 def _collect_inference_image_refs(self) -> set[str]: 

1113 """Return every image URI referenced by a registered inference endpoint.""" 

1114 try: 

1115 from .inference import InferenceManager 

1116 except Exception as e: # noqa: BLE001 

1117 logger.debug("InferenceManager unavailable: %s", e) 

1118 return set() 

1119 try: 

1120 manager = InferenceManager(self.config) 

1121 endpoints = manager.list_endpoints() 

1122 except Exception as e: # noqa: BLE001 

1123 logger.debug("list_endpoints failed: %s", e) 

1124 return set() 

1125 refs: set[str] = set() 

1126 for ep in endpoints or []: 

1127 spec = ep.get("spec") or {} 

1128 image = spec.get("image") if isinstance(spec, dict) else None 

1129 if image: 

1130 refs.add(image) 

1131 canary = spec.get("canary") if isinstance(spec, dict) else None 

1132 if isinstance(canary, dict) and canary.get("image"): 

1133 refs.add(canary["image"]) 

1134 return refs 

1135 

1136 def _collect_recent_job_image_refs(self, threshold_days: int = 30) -> set[str]: 

1137 """Return image URIs referenced by jobs newer than ``threshold_days``. 

1138 

1139 Walks every deployed region via :class:`cli.jobs.JobManager` and 

1140 unions the ``image_refs`` field on each ``JobInfo`` whose 

1141 ``created_time`` is within the cutoff. Treats jobs without a 

1142 ``created_time`` as in-window so a freshly-submitted job that 

1143 hasn't yet been picked up by the cluster's status loop isn't 

1144 accidentally considered orphaned. 

1145 

1146 Best-effort: any per-region failure is logged at debug and 

1147 skipped so the orphan scan still completes against the 

1148 regions that did respond. The deferred import breaks an 

1149 otherwise-circular ``cli.images`` ↔ ``cli.jobs`` dependency. 

1150 """ 

1151 try: 

1152 from .jobs import JobManager 

1153 except Exception as e: # noqa: BLE001 

1154 logger.debug("JobManager unavailable: %s", e) 

1155 return set() 

1156 

1157 try: 

1158 manager = JobManager(self.config) 

1159 except Exception as e: # noqa: BLE001 

1160 logger.debug("JobManager init failed: %s", e) 

1161 return set() 

1162 

1163 try: 

1164 jobs = manager.list_jobs(all_regions=True) 

1165 except Exception as e: # noqa: BLE001 

1166 logger.debug("list_jobs(all_regions=True) failed: %s", e) 

1167 return set() 

1168 

1169 cutoff = datetime.now(UTC) - timedelta(days=threshold_days) 

1170 refs: set[str] = set() 

1171 for job in jobs or []: 

1172 created = getattr(job, "created_time", None) 

1173 if isinstance(created, datetime): 

1174 created_aware = created if created.tzinfo else created.replace(tzinfo=UTC) 

1175 if created_aware < cutoff: 

1176 continue 

1177 image_refs = getattr(job, "image_refs", None) or [] 

1178 for ref in image_refs: 

1179 if isinstance(ref, str) and ref: 

1180 refs.add(ref) 

1181 return refs 

1182 

1183 @staticmethod 

1184 def _parse_iso(value: Any) -> datetime | None: 

1185 """Parse an ISO-8601 string into a tz-aware datetime, else None.""" 

1186 if isinstance(value, datetime): 

1187 return value if value.tzinfo else value.replace(tzinfo=UTC) 

1188 if not isinstance(value, str): 

1189 return None 

1190 try: 

1191 parsed = datetime.fromisoformat(value) 

1192 except ValueError: 

1193 return None 

1194 return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC) 

1195 

1196 

1197def _isoformat(value: Any) -> str | None: 

1198 """Return ISO-8601 form of a datetime, or pass-through for strings.""" 

1199 if value is None: 

1200 return None 

1201 if isinstance(value, datetime): 

1202 return value.isoformat() 

1203 return str(value) 

1204 

1205 

1206def get_image_manager(config: GCOConfig | None = None, region: str | None = None) -> ImageManager: 

1207 """Factory function for ``ImageManager``.""" 

1208 return ImageManager(config=config, region=region) 

1209 

1210 

1211def default_disaggregated_image( 

1212 config: GCOConfig | None = None, 

1213 region: str | None = None, 

1214 tag: str | None = None, 

1215) -> str: 

1216 """Resolve the default image reference for disaggregated prefill/decode deploys. 

1217 

1218 Convenience wrapper around 

1219 :meth:`ImageManager.default_disaggregated_image_uri` for callers 

1220 that only need the reference and do not otherwise hold a manager. 

1221 """ 

1222 return get_image_manager(config=config, region=region).default_disaggregated_image_uri(tag)