Coverage for cli / _image_mirror.py: 100.00%

216 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-09-14 22:07 +0000

1""" 

2Mirror third-party container images into the project's own ECR — shared core. 

3 

4Some upstream registries (chiefly Docker Hub, ``docker.io``) rate-limit 

5anonymous pulls and have **no credential-free** ECR pull-through cache. On a 

6cold cluster that can stall image pulls — most visibly Volcano, whose images 

7(``volcanosh/vc-*``) live only on Docker Hub and gate its Helm install. The 

8credential-free fix is to **mirror** those images into a ``gco/*`` ECR namespace 

9and point the consumer (a Helm values override, a manifest, …) at the mirror, so 

10the cluster pulls from same-account ECR with the pull-only node role it already 

11has. See ``docs/CUSTOMIZATION.md``. 

12 

13This module is the reusable, **general** mirror core — it copies an arbitrary 

14list of images, not just Volcano's. It is shared by two callers: 

15 

16- ``gco images mirror`` (in ``cli/commands/images_cmd.py``) — the operator CLI. 

17- ``cli/stacks.py`` (``StackManager.deploy``) — the auto-mirror that runs before 

18 a regional stack's Helm install, so a fresh ``gco stacks deploy`` with 

19 ``volcano_image_mirror.enabled`` just works (no separate manual step). 

20 

21═══════════════════════════════════════════════════════════════════════════ 

22HOW TO ADD AN IMAGE TO THE MIRROR 

23═══════════════════════════════════════════════════════════════════════════ 

24The set of images to mirror is produced by :func:`collect_source_refs`, which 

25returns a flat list of fully-qualified upstream refs 

26(``"<registry>/<repo>:<tag>"``). To add one, extend that function — two flavors: 

27 

281. **Static ref** — append a literal, e.g.:: 

29 

30 refs.append("docker.io/bitnami/redis:7.4.1") 

31 

322. **Chart-derived ref** — derive the name/tag from 

33 ``lambda/helm-installer/charts.yaml`` so the mirror never drifts from the 

34 deployed Helm chart version. Use :func:`_volcano_source_refs` as the template 

35 (read the chart's ``values`` block, build ``docker.io/<image>:<tag>``). 

36 

37Then wire up the **consumer** so the cluster actually pulls the mirrored copy 

38instead of the upstream — typically a Helm ``image_registry``/``image`` override 

39in ``gco/stacks/regional_stack.py`` (see ``_helm_chart_value_overrides`` / 

40``_configure_volcano_image_mirror`` for the Volcano example) or a manifest image 

41reference. The mirror copies ``<registry>/<repo>:<tag>`` to 

42``<account>.dkr.ecr.<region>.<url-suffix>/<ecr_namespace>/<repo>:<tag>``, so the 

43consumer must point at ``<…>/<ecr_namespace>/<repo>``. 

44 

45WHY mirror rather than pull-through cache: ECR pull-through cache for Docker Hub 

46*requires* stored credentials (anonymous is unsupported), and on EKS Auto Mode 

47the pull-only, service-managed node role complicates cache-miss imports. 

48Mirroring needs no credential — the images become plain ``gco/*`` ECR repos. 

49═══════════════════════════════════════════════════════════════════════════ 

50 

51The copy preserves the **full manifest list** (every architecture) so an arm64 

52(Graviton) node and an amd64 node both find a matching image; a naive 

53``pull``/``tag``/``push`` (which drops all but the host architecture) is never 

54used. The concrete mechanism is chosen at runtime — Docker Buildx 

55(``buildx imagetools create``), Finch/nerdctl (``--all-platforms``), or skopeo 

56(``copy --all``) — see :func:`resolve_copy_strategy`. 

57""" 

58 

59from __future__ import annotations 

60 

61import base64 

62import json 

63import shutil 

64import subprocess # nosec B404 - invokes container CLI / skopeo with fixed, non-shell argv 

65from collections.abc import Callable, Mapping 

66from dataclasses import dataclass 

67from pathlib import Path 

68from typing import Any 

69 

70import boto3 

71import yaml 

72 

73from ._image_uri import ecr_registry_host 

74 

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

76# Generated at (UTC): 2026-09-01T14:42:56Z 

77# Generated from Git commit: 89b000378ed5a912a38c06f4feab2b029936ebcc 

78# Flowchart(s) generated from this file: 

79# * ``read_mirror_config`` -> ``diagrams/code_diagrams/cli/_image_mirror.read_mirror_config.html`` 

80# (PNG: ``diagrams/code_diagrams/cli/_image_mirror.read_mirror_config.png``) 

81# * ``mirror_images`` -> ``diagrams/code_diagrams/cli/_image_mirror.mirror_images.html`` 

82# (PNG: ``diagrams/code_diagrams/cli/_image_mirror.mirror_images.png``) 

83# Regenerate with ``SOURCE_DATE_EPOCH=<unix-seconds> GCO_DIAGRAM_SOURCE_COMMIT=<40-char-sha> python diagrams/generate.py --code-only``. 

84# <pyflowchart-code-diagram> END 

85 

86 

87# Repo root is the parent of cli/. 

88_REPO_ROOT = Path(__file__).resolve().parent.parent 

89_CHARTS_YAML = _REPO_ROOT / "lambda" / "helm-installer" / "charts.yaml" 

90_CDK_JSON = _REPO_ROOT / "cdk.json" 

91 

92# Fallback mirror namespace used only when cdk.json can't be read to resolve 

93# the project prefix. Normal paths derive ``<project_name>/dockerhub`` from 

94# cdk.json's ``project_name`` (see ``read_mirror_config``) so two deployments 

95# mirror into isolated ECR namespaces (#139). 

96_DEFAULT_NAMESPACE = "gco/dockerhub" 

97 

98# The Volcano components enabled by default (controller, scheduler, admission), 

99# keyed by the ``basic.<key>`` image-name field in charts.yaml. The 

100# agent/agent-scheduler images are not pulled in the default configuration and 

101# are intentionally excluded. 

102_VOLCANO_IMAGE_NAME_KEYS = ( 

103 "controller_image_name", 

104 "scheduler_image_name", 

105 "admission_image_name", 

106) 

107# Upstream registry Volcano's chart pulls from by default (chart value 

108# ``basic.image_registry``). 

109_VOLCANO_UPSTREAM_REGISTRY = "docker.io" 

110 

111# Default logger — callers may pass their own ``log`` callable (e.g. to route 

112# through a deploy progress stream). 

113LogFn = Callable[[str], None] 

114RepositoryCreatedCallback = Callable[[str, Mapping[str, Any]], None] 

115 

116 

117def _bind_repository_created_callback( 

118 callback: RepositoryCreatedCallback, 

119 region: str, 

120) -> Callable[[Mapping[str, Any]], None]: 

121 """Bind one target Region without obscuring the callback's payload type.""" 

122 

123 def notify(repository: Mapping[str, Any]) -> None: 

124 callback(region, repository) 

125 

126 return notify 

127 

128 

129@dataclass(frozen=True) 

130class MirrorItem: 

131 """One image to copy: ``source_ref`` -> ``dest_ref`` (repo ``dest_repo``).""" 

132 

133 source_ref: str 

134 dest_repo: str 

135 dest_ref: str 

136 

137 @property 

138 def tag(self) -> str: 

139 """The image tag (the segment after the final ``:`` of ``dest_ref``).""" 

140 return self.dest_ref.rsplit(":", 1)[1] 

141 

142 

143def parse_source_ref(ref: str) -> tuple[str, str]: 

144 """Split ``"<registry>/<repo>:<tag>"`` into ``(repo_path, tag)``. 

145 

146 The registry host (first slash-delimited segment) is dropped so the image 

147 can be re-homed under the ECR namespace while preserving its repo path, e.g. 

148 ``"docker.io/volcanosh/vc-scheduler:v1.15.0"`` -> ``("volcanosh/vc-scheduler", 

149 "v1.15.0")``. 

150 """ 

151 if "/" not in ref: 

152 raise ValueError(f"source ref must include a registry host: {ref!r}") 

153 _registry, rest = ref.split("/", 1) 

154 if ":" not in rest: 

155 raise ValueError(f"source ref must include a tag: {ref!r}") 

156 repo_path, tag = rest.rsplit(":", 1) 

157 if not repo_path or not tag: 

158 raise ValueError(f"could not parse source ref {ref!r}") 

159 return repo_path, tag 

160 

161 

162def load_charts_config(charts_path: Path = _CHARTS_YAML) -> dict[str, Any]: 

163 """Load and return the parsed ``charts.yaml`` mapping.""" 

164 with open(charts_path, encoding="utf-8") as f: 

165 data = yaml.safe_load(f) or {} 

166 if not isinstance(data, dict): 

167 raise ValueError(f"{charts_path} did not parse to a mapping") 

168 return data 

169 

170 

171def _volcano_source_refs(charts_config: dict[str, Any]) -> list[str]: 

172 """Return Volcano's upstream image refs derived from ``charts.yaml``. 

173 

174 Reads ``charts.charts.volcano.values.basic`` — the per-component 

175 ``*_image_name`` fields and the shared ``image_tag_version`` (each 

176 component's own ``*_image_tag_version`` takes precedence when set, matching 

177 the chart's templating) — and returns ``["docker.io/<image>:<tag>", ...]``. 

178 Deriving from the chart keeps the mirror tag identical to what Helm requests. 

179 """ 

180 volcano = (charts_config.get("charts", {}) or {}).get("volcano", {}) or {} 

181 basic = (volcano.get("values", {}) or {}).get("basic", {}) or {} 

182 shared_tag = str(basic.get("image_tag_version") or "").strip() 

183 if not shared_tag: 

184 raise ValueError( 

185 "volcano.values.basic.image_tag_version is missing from charts.yaml; " 

186 "cannot determine which Volcano image tag to mirror." 

187 ) 

188 

189 refs: list[str] = [] 

190 for name_key in _VOLCANO_IMAGE_NAME_KEYS: 

191 image_name = str(basic.get(name_key) or "").strip() 

192 if not image_name: 

193 continue 

194 component = name_key.removesuffix("_image_name") 

195 per_component_tag = str(basic.get(f"{component}_image_tag_version") or "").strip() 

196 tag = per_component_tag or shared_tag 

197 refs.append(f"{_VOLCANO_UPSTREAM_REGISTRY}/{image_name}:{tag}") 

198 if not refs: 

199 raise ValueError( 

200 "No Volcano component image names found under volcano.values.basic in charts.yaml." 

201 ) 

202 return refs 

203 

204 

205def collect_source_refs(charts_config: dict[str, Any] | None = None) -> list[str]: 

206 """Return every upstream image ref to mirror (``"<registry>/<repo>:<tag>"``). 

207 

208 This is the single extension point for the mirror. To add an image, append 

209 to ``refs`` below — either a static literal or a chart-derived ref (see the 

210 module docstring, "HOW TO ADD AN IMAGE TO THE MIRROR"). Remember to also 

211 point the image's *consumer* at the mirrored copy. 

212 """ 

213 charts_config = charts_config if charts_config is not None else load_charts_config() 

214 refs: list[str] = [] 

215 

216 # Volcano (docker.io/volcanosh/vc-*) — the reason this mirror exists. 

217 refs.extend(_volcano_source_refs(charts_config)) 

218 

219 # ── ADD MORE IMAGES HERE ────────────────────────────────────────────── 

220 # e.g. refs.append("docker.io/bitnami/redis:7.4.1") # static, or 

221 # refs.extend(_my_chart_source_refs(charts_config)) # chart-derived 

222 # Then wire the consumer to <ecr_namespace>/<repo> (see module docstring). 

223 

224 return refs 

225 

226 

227def plan_from_sources( 

228 source_refs: list[str], registry_host: str, ecr_namespace: str 

229) -> list[MirrorItem]: 

230 """Compute the copy plan: one :class:`MirrorItem` per source ref. 

231 

232 ``registry_host`` is ``<account>.dkr.ecr.<region>.<url-suffix>`` and 

233 ``ecr_namespace`` is the destination prefix (e.g. ``gco/dockerhub``). The 

234 destination preserves the upstream repo path so it lines up with whatever 

235 ``image_registry``/``image`` override the consumer points at 

236 (``<registry_host>/<ecr_namespace>`` + ``/<repo_path>``). 

237 """ 

238 ecr_namespace = ecr_namespace.strip("/") 

239 items: list[MirrorItem] = [] 

240 for ref in source_refs: 

241 repo_path, tag = parse_source_ref(ref) 

242 dest_repo = f"{ecr_namespace}/{repo_path}" 

243 dest_ref = f"{registry_host}/{dest_repo}:{tag}" 

244 items.append(MirrorItem(source_ref=ref, dest_repo=dest_repo, dest_ref=dest_ref)) 

245 return items 

246 

247 

248def read_mirror_config(cdk_json_path: Path = _CDK_JSON) -> dict[str, Any]: 

249 """Return ``{enabled, ecr_namespace}`` from cdk.json ``volcano_image_mirror``. 

250 

251 Defaults to disabled / ``<project_name>/dockerhub`` (``gco/dockerhub`` for 

252 the stock project) when the block is absent, so a second deployment mirrors 

253 into its own ECR namespace (#139). 

254 Used by the deploy path to decide whether to auto-mirror. (The cdk.json key 

255 is still named ``volcano_image_mirror`` — Volcano is the only consumer today 

256 — but the mirror itself is general; see :func:`collect_source_refs`.) 

257 """ 

258 try: 

259 with open(cdk_json_path, encoding="utf-8") as f: 

260 ctx = json.load(f).get("context", {}) or {} 

261 except OSError, json.JSONDecodeError: 

262 return {"enabled": False, "ecr_namespace": _DEFAULT_NAMESPACE} 

263 # Default the mirror namespace to the deployment's own project prefix 

264 # (``<project_name>/dockerhub``) so a second deployment mirrors into its own 

265 # ECR namespace (#139); resolves to ``gco/dockerhub`` for the stock project. 

266 default_namespace = f"{ctx.get('project_name') or 'gco'}/dockerhub" 

267 block = ctx.get("volcano_image_mirror") or {} 

268 namespace = str(block.get("ecr_namespace", default_namespace)).strip("/") or default_namespace 

269 return {"enabled": bool(block.get("enabled", False)), "ecr_namespace": namespace} 

270 

271 

272def cdk_default_namespace(cdk_json_path: Path = _CDK_JSON) -> str: 

273 """Return ``volcano_image_mirror.ecr_namespace`` from cdk.json (default gco/dockerhub).""" 

274 return str(read_mirror_config(cdk_json_path)["ecr_namespace"]) 

275 

276 

277def _account_id() -> str: 

278 return str(boto3.client("sts").get_caller_identity()["Account"]) 

279 

280 

281def _registry_host(account_id: str, region: str) -> str: 

282 """Return the ECR host using botocore's partition URL suffix metadata.""" 

283 return ecr_registry_host(account_id, region) 

284 

285 

286def detect_runtime() -> str: 

287 """Return the container CLI to drive (``docker``, ``finch``, or ``podman``). 

288 

289 Mirrors the project's runtime preference (docker > finch > podman). Note 

290 that on a Finch-based setup the ``docker`` shim may itself be Finch — the 

291 copy strategy is resolved separately by probing for capabilities rather 

292 than trusting the command name. 

293 """ 

294 for cmd in ("docker", "finch", "podman"): 

295 if shutil.which(cmd): 

296 return cmd 

297 raise RuntimeError( 

298 "No container CLI found (looked for docker, finch, podman). Install one, " 

299 "or install skopeo for a daemon-less copy." 

300 ) 

301 

302 

303def _runtime_has_buildx(runtime: str) -> bool: 

304 """True if ``<runtime> buildx version`` succeeds (Docker Buildx present).""" 

305 try: 

306 return ( 

307 subprocess.run( # nosec B603 - fixed argv, no shell 

308 [runtime, "buildx", "version"], capture_output=True, timeout=15 

309 ).returncode 

310 == 0 

311 ) 

312 except OSError, subprocess.SubprocessError: 

313 return False 

314 

315 

316def _runtime_supports_all_platforms(runtime: str) -> bool: 

317 """True if ``<runtime> pull`` advertises ``--all-platforms`` (Finch/nerdctl).""" 

318 try: 

319 out = subprocess.run( # nosec B603 - fixed argv, no shell 

320 [runtime, "pull", "--help"], capture_output=True, text=True, timeout=15 

321 ) 

322 except OSError, subprocess.SubprocessError: 

323 return False 

324 return "--all-platforms" in (out.stdout + out.stderr) 

325 

326 

327def resolve_copy_strategy(runtime: str) -> str: 

328 """Pick a multi-arch-preserving copy strategy from what's available. 

329 

330 Priority: 

331 1. ``buildx`` — ``<runtime> buildx imagetools create`` (registry-to-registry, 

332 no local pull). Best when Docker Buildx is present. 

333 2. ``all-platforms`` — ``<runtime> pull/tag/push --all-platforms`` 

334 (Finch / nerdctl), preserves the manifest list via containerd. 

335 3. ``skopeo`` — ``skopeo copy --all`` (daemon-less), if skopeo is on PATH. 

336 

337 Every strategy preserves all architectures; a plain ``pull``/``tag``/``push`` 

338 (which would drop every arch except the host's) is never used. Raises with 

339 guidance if none is available. 

340 """ 

341 if _runtime_has_buildx(runtime): 

342 return "buildx" 

343 if _runtime_supports_all_platforms(runtime): 

344 return "all-platforms" 

345 if shutil.which("skopeo"): 

346 return "skopeo" 

347 raise RuntimeError( 

348 f"No multi-arch image-copy method available. Need one of: " 

349 f"'{runtime} buildx' (Docker Buildx), '{runtime} pull --all-platforms' " 

350 f"(Finch/nerdctl), or skopeo on PATH." 

351 ) 

352 

353 

354def ensure_repository( 

355 ecr_client: Any, 

356 repo_name: str, 

357 log: LogFn = print, 

358 repository_tags: Mapping[str, str] | None = None, 

359 on_created: Callable[[Mapping[str, Any]], None] | None = None, 

360) -> bool: 

361 """Create a repository and synchronously publish its causal acknowledgement.""" 

362 kwargs: dict[str, Any] = {"repositoryName": repo_name} 

363 if repository_tags: 

364 kwargs["tags"] = [ 

365 {"Key": str(key), "Value": str(value)} for key, value in sorted(repository_tags.items()) 

366 ] 

367 try: 

368 response = ecr_client.create_repository(**kwargs) 

369 repository = response.get("repository") if isinstance(response, dict) else None 

370 if on_created is not None: 

371 if not isinstance(repository, dict): 

372 raise RuntimeError( 

373 f"ECR create_repository omitted its acknowledgement for {repo_name}" 

374 ) 

375 if str(repository.get("repositoryName") or "") != repo_name: 

376 raise RuntimeError( 

377 f"ECR create_repository acknowledged a different repository for {repo_name}" 

378 ) 

379 on_created(repository) 

380 log(f" created ECR repository {repo_name}") 

381 return True 

382 except ecr_client.exceptions.RepositoryAlreadyExistsException: 

383 log(f" ECR repository {repo_name} already exists") 

384 return False 

385 

386 

387def tag_exists(ecr_client: Any, repo_name: str, tag: str) -> bool: 

388 """True if ``tag`` already exists in the ECR repo (drives skip-if-mirrored). 

389 

390 Returns False when the repository or tag does not exist, so the caller 

391 mirrors it. Any other error propagates. 

392 """ 

393 try: 

394 resp = ecr_client.describe_images(repositoryName=repo_name, imageIds=[{"imageTag": tag}]) 

395 return bool(resp.get("imageDetails")) 

396 except ecr_client.exceptions.ImageNotFoundException: 

397 return False 

398 except ecr_client.exceptions.RepositoryNotFoundException: 

399 return False 

400 

401 

402def ecr_auth(region: str) -> tuple[str, str]: 

403 """Return ``(username, password)`` for the region's ECR registry.""" 

404 ecr = boto3.client("ecr", region_name=region) 

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

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

407 return username, password 

408 

409 

410def runtime_login( 

411 runtime: str, registry_host: str, username: str, password: str, log: LogFn = print 

412) -> None: 

413 """Authenticate the container runtime against the ECR registry.""" 

414 result = subprocess.run( # nosec B603 - fixed argv, no shell 

415 [runtime, "login", "--username", username, "--password-stdin", registry_host], 

416 input=password.encode(), 

417 capture_output=True, 

418 check=False, 

419 ) 

420 if result.returncode != 0: 

421 raise RuntimeError( 

422 f"{runtime} login to {registry_host} failed: " 

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

424 ) 

425 log(f" authenticated {runtime} to {registry_host}") 

426 

427 

428def _copy_commands(item: MirrorItem, runtime: str, strategy: str, password: str) -> list[list[str]]: 

429 """Build the argv list(s) for one image copy under the chosen strategy. 

430 

431 Factored out (pure) so the command shape is unit-testable without invoking 

432 any runtime. All strategies preserve the full multi-arch manifest list. 

433 """ 

434 if strategy == "buildx": 

435 return [ 

436 [runtime, "buildx", "imagetools", "create", "--tag", item.dest_ref, item.source_ref] 

437 ] 

438 if strategy == "all-platforms": 

439 return [ 

440 [runtime, "pull", "--all-platforms", item.source_ref], 

441 [runtime, "tag", item.source_ref, item.dest_ref], 

442 [runtime, "push", "--all-platforms", item.dest_ref], 

443 ] 

444 if strategy == "skopeo": 

445 return [ 

446 [ 

447 "skopeo", 

448 "copy", 

449 "--all", 

450 "--dest-creds", 

451 f"AWS:{password}", 

452 f"docker://{item.source_ref}", 

453 f"docker://{item.dest_ref}", 

454 ] 

455 ] 

456 raise ValueError(f"unknown copy strategy: {strategy!r}") 

457 

458 

459def copy_image( 

460 item: MirrorItem, 

461 runtime: str = "docker", 

462 strategy: str = "buildx", 

463 password: str = "", 

464 log: LogFn = print, 

465) -> None: 

466 """Copy one image registry-to-registry, preserving the full manifest list. 

467 

468 Dispatches to the resolved ``strategy`` (``buildx`` / ``all-platforms`` / 

469 ``skopeo``) — see :func:`resolve_copy_strategy`. Every strategy carries all 

470 architectures so both amd64 and arm64 (Graviton) nodes find a match. 

471 """ 

472 log(f" copying {item.source_ref} -> {item.dest_ref} [{strategy}]") 

473 for cmd in _copy_commands(item, runtime, strategy, password): 

474 result = subprocess.run( # nosec B603 - fixed argv, no shell 

475 cmd, capture_output=True, text=True, check=False 

476 ) 

477 if result.returncode != 0: 

478 raise RuntimeError( 

479 f"image copy failed for {item.source_ref} " 

480 f"(strategy {strategy}, step {cmd[:3]}): " 

481 f"{(result.stderr or result.stdout).strip()}" 

482 ) 

483 

484 

485def plan_mirror( 

486 region: str, 

487 ecr_namespace: str | None = None, 

488 source_refs: list[str] | None = None, 

489 charts_path: Path | None = None, 

490) -> dict[str, Any]: 

491 """Resolve the mirror plan as plain data — no ECR writes, no image copies. 

492 

493 Read-only counterpart to :func:`mirror_images`: it resolves the account and 

494 destination registry (a single STS ``GetCallerIdentity`` call) and computes 

495 where each upstream image *would* be mirrored, but creates no repositories 

496 and copies nothing. Backs the ``images_mirror_plan`` MCP tool and is reused 

497 by :func:`mirror_status`. 

498 

499 ``source_refs`` defaults to :func:`collect_source_refs`; ``ecr_namespace`` 

500 defaults to :func:`cdk_default_namespace`. Returns ``{region, registry, 

501 ecr_namespace, images: [{source_ref, dest_repo, dest_ref, tag}, ...]}`` where 

502 ``registry`` is ``<account>.dkr.ecr.<region>.<url-suffix>/<ecr_namespace>``. 

503 """ 

504 ecr_namespace = (ecr_namespace or cdk_default_namespace()).strip("/") 

505 if source_refs is None: 

506 source_refs = collect_source_refs(load_charts_config(charts_path) if charts_path else None) 

507 registry_host = _registry_host(_account_id(), region) 

508 plan = plan_from_sources(source_refs, registry_host, ecr_namespace) 

509 return { 

510 "region": region, 

511 "registry": f"{registry_host}/{ecr_namespace}", 

512 "ecr_namespace": ecr_namespace, 

513 "images": [ 

514 { 

515 "source_ref": item.source_ref, 

516 "dest_repo": item.dest_repo, 

517 "dest_ref": item.dest_ref, 

518 "tag": item.tag, 

519 } 

520 for item in plan 

521 ], 

522 } 

523 

524 

525def mirror_status( 

526 region: str, 

527 ecr_namespace: str | None = None, 

528 source_refs: list[str] | None = None, 

529 charts_path: Path | None = None, 

530) -> dict[str, Any]: 

531 """Report, per planned image, whether it is already mirrored in ECR. 

532 

533 Read-only: builds the plan via :func:`plan_mirror`, then probes each 

534 destination tag with :func:`tag_exists` (ECR ``DescribeImages``; no writes). 

535 Returns the plan augmented with a ``mirrored`` bool per image plus top-level 

536 ``all_mirrored`` and ``missing`` (the destination refs not yet present), so 

537 an operator can tell at a glance whether a deploy's auto-mirror still has 

538 anything to copy before the consuming Helm install runs. 

539 """ 

540 plan = plan_mirror(region, ecr_namespace, source_refs, charts_path) 

541 ecr_client = boto3.client("ecr", region_name=region) 

542 images: list[dict[str, Any]] = [] 

543 missing: list[str] = [] 

544 for img in plan["images"]: 

545 present = tag_exists(ecr_client, img["dest_repo"], img["tag"]) 

546 images.append({**img, "mirrored": present}) 

547 if not present: 

548 missing.append(img["dest_ref"]) 

549 return { 

550 "region": plan["region"], 

551 "registry": plan["registry"], 

552 "ecr_namespace": plan["ecr_namespace"], 

553 "images": images, 

554 "all_mirrored": not missing, 

555 "missing": missing, 

556 } 

557 

558 

559def mirror_images( 

560 region: str, 

561 ecr_namespace: str | None = None, 

562 source_refs: list[str] | None = None, 

563 charts_path: Path | None = None, 

564 skip_existing: bool = True, 

565 log: LogFn = print, 

566 repository_tags: Mapping[str, str] | None = None, 

567 on_repository_created: RepositoryCreatedCallback | None = None, 

568) -> dict[str, Any]: 

569 """Mirror the configured upstream images into ECR for ``region`` (full flow). 

570 

571 ``source_refs`` defaults to :func:`collect_source_refs` (every registered 

572 image). Resolves the account/registry, builds the plan, picks a multi-arch 

573 copy strategy, authenticates, then for each image ensures the repo exists and 

574 copies it — skipping any tag already present when ``skip_existing`` is True 

575 (so repeat deploys are a fast no-op). Returns a summary dict with the 

576 destination ``registry``, the ``strategy`` used, and the ``mirrored`` / 

577 ``skipped`` destination refs. 

578 """ 

579 ecr_namespace = (ecr_namespace or cdk_default_namespace()).strip("/") 

580 if source_refs is None: 

581 source_refs = collect_source_refs(load_charts_config(charts_path) if charts_path else None) 

582 

583 account_id = _account_id() 

584 registry_host = _registry_host(account_id, region) 

585 plan = plan_from_sources(source_refs, registry_host, ecr_namespace) 

586 

587 runtime = detect_runtime() 

588 strategy = resolve_copy_strategy(runtime) 

589 

590 log( 

591 f"Mirroring {len(plan)} image(s) into " 

592 f"{registry_host}/{ecr_namespace} (region {region}) " 

593 f"via {strategy} (runtime: {runtime}):" 

594 ) 

595 

596 # Auth: every strategy needs ECR push credentials. buildx / all-platforms 

597 # use the runtime's credential store (so we `<runtime> login`); skopeo takes 

598 # the password inline via --dest-creds. 

599 username, password = ecr_auth(region) 

600 if strategy != "skopeo": 

601 runtime_login(runtime, registry_host, username, password, log=log) 

602 

603 ecr_client = boto3.client("ecr", region_name=region) 

604 mirrored: list[str] = [] 

605 skipped: list[str] = [] 

606 created_repositories: list[str] = [] 

607 on_created = ( 

608 _bind_repository_created_callback(on_repository_created, region) 

609 if on_repository_created is not None 

610 else None 

611 ) 

612 for item in plan: 

613 if ensure_repository( 

614 ecr_client, 

615 item.dest_repo, 

616 log=log, 

617 repository_tags=repository_tags, 

618 on_created=on_created, 

619 ): 

620 created_repositories.append(item.dest_repo) 

621 if skip_existing and tag_exists(ecr_client, item.dest_repo, item.tag): 

622 log(f" skip (already mirrored): {item.dest_ref}") 

623 skipped.append(item.dest_ref) 

624 continue 

625 copy_image(item, runtime=runtime, strategy=strategy, password=password, log=log) 

626 mirrored.append(item.dest_ref) 

627 

628 return { 

629 "registry": f"{registry_host}/{ecr_namespace}", 

630 "strategy": strategy, 

631 "mirrored": mirrored, 

632 "skipped": skipped, 

633 "created_repositories": sorted(set(created_repositories)), 

634 }