Coverage for cli/_image_mirror.py: 99.64%

216 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-30 21:22 +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-07-18T01:03:40Z 

77# Flowchart(s) generated from this file: 

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

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

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

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

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

83# <pyflowchart-code-diagram> END 

84 

85 

86# Repo root is the parent of cli/. 

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

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

89_CDK_JSON = _REPO_ROOT / "cdk.json" 

90 

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

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

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

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

95_DEFAULT_NAMESPACE = "gco/dockerhub" 

96 

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

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

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

100# are intentionally excluded. 

101_VOLCANO_IMAGE_NAME_KEYS = ( 

102 "controller_image_name", 

103 "scheduler_image_name", 

104 "admission_image_name", 

105) 

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

107# ``basic.image_registry``). 

108_VOLCANO_UPSTREAM_REGISTRY = "docker.io" 

109 

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

111# through a deploy progress stream). 

112LogFn = Callable[[str], None] 

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

114 

115 

116def _bind_repository_created_callback( 

117 callback: RepositoryCreatedCallback, 

118 region: str, 

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

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

121 

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

123 callback(region, repository) 

124 

125 return notify 

126 

127 

128@dataclass(frozen=True) 

129class MirrorItem: 

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

131 

132 source_ref: str 

133 dest_repo: str 

134 dest_ref: str 

135 

136 @property 

137 def tag(self) -> str: 

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

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

140 

141 

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

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

144 

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

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

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

148 "v1.15.0")``. 

149 """ 

150 if "/" not in ref: 

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

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

153 if ":" not in rest: 

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

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

156 if not repo_path or not tag: 

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

158 return repo_path, tag 

159 

160 

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

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

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

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

165 if not isinstance(data, dict): 

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

167 return data 

168 

169 

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

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

172 

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

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

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

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

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

178 """ 

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

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

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

182 if not shared_tag: 

183 raise ValueError( 

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

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

186 ) 

187 

188 refs: list[str] = [] 

189 for name_key in _VOLCANO_IMAGE_NAME_KEYS: 

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

191 if not image_name: 

192 continue 

193 component = name_key.removesuffix("_image_name") 

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

195 tag = per_component_tag or shared_tag 

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

197 if not refs: 

198 raise ValueError( 

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

200 ) 

201 return refs 

202 

203 

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

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

206 

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

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

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

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

211 """ 

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

213 refs: list[str] = [] 

214 

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

216 refs.extend(_volcano_source_refs(charts_config)) 

217 

218 # ── ADD MORE IMAGES HERE ────────────────────────────────────────────── 

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

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

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

222 

223 return refs 

224 

225 

226def plan_from_sources( 

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

228) -> list[MirrorItem]: 

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

230 

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

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

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

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

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

236 """ 

237 ecr_namespace = ecr_namespace.strip("/") 

238 items: list[MirrorItem] = [] 

239 for ref in source_refs: 

240 repo_path, tag = parse_source_ref(ref) 

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

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

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

244 return items 

245 

246 

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

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

249 

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

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

252 into its own ECR namespace (#139). 

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

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

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

256 """ 

257 try: 

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

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

260 except OSError, json.JSONDecodeError: 

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

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

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

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

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

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

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

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

269 

270 

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

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

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

274 

275 

276def _account_id() -> str: 

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

278 

279 

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

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

282 return ecr_registry_host(account_id, region) 

283 

284 

285def detect_runtime() -> str: 

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

287 

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

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

290 copy strategy is resolved separately by probing for capabilities rather 

291 than trusting the command name. 

292 """ 

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

294 if shutil.which(cmd): 

295 return cmd 

296 raise RuntimeError( 

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

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

299 ) 

300 

301 

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

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

304 try: 

305 return ( 

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

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

308 ).returncode 

309 == 0 

310 ) 

311 except OSError, subprocess.SubprocessError: 

312 return False 

313 

314 

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

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

317 try: 

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

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

320 ) 

321 except OSError, subprocess.SubprocessError: 

322 return False 

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

324 

325 

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

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

328 

329 Priority: 

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

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

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

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

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

335 

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

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

338 guidance if none is available. 

339 """ 

340 if _runtime_has_buildx(runtime): 

341 return "buildx" 

342 if _runtime_supports_all_platforms(runtime): 

343 return "all-platforms" 

344 if shutil.which("skopeo"): 

345 return "skopeo" 

346 raise RuntimeError( 

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

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

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

350 ) 

351 

352 

353def ensure_repository( 

354 ecr_client: Any, 

355 repo_name: str, 

356 log: LogFn = print, 

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

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

359) -> bool: 

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

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

362 if repository_tags: 

363 kwargs["tags"] = [ 

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

365 ] 

366 try: 

367 response = ecr_client.create_repository(**kwargs) 

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

369 if on_created is not None: 

370 if not isinstance(repository, dict): 

371 raise RuntimeError( 

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

373 ) 

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

375 raise RuntimeError( 

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

377 ) 

378 on_created(repository) 

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

380 return True 

381 except ecr_client.exceptions.RepositoryAlreadyExistsException: 

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

383 return False 

384 

385 

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

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

388 

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

390 mirrors it. Any other error propagates. 

391 """ 

392 try: 

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

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

395 except ecr_client.exceptions.ImageNotFoundException: 

396 return False 

397 except ecr_client.exceptions.RepositoryNotFoundException: 

398 return False 

399 

400 

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

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

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

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

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

406 return username, password 

407 

408 

409def runtime_login( 

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

411) -> None: 

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

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

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

415 input=password.encode(), 

416 capture_output=True, 

417 check=False, 

418 ) 

419 if result.returncode != 0: 

420 raise RuntimeError( 

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

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

423 ) 

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

425 

426 

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

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

429 

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

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

432 """ 

433 if strategy == "buildx": 

434 return [ 

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

436 ] 

437 if strategy == "all-platforms": 

438 return [ 

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

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

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

442 ] 

443 if strategy == "skopeo": 

444 return [ 

445 [ 

446 "skopeo", 

447 "copy", 

448 "--all", 

449 "--dest-creds", 

450 f"AWS:{password}", 

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

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

453 ] 

454 ] 

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

456 

457 

458def copy_image( 

459 item: MirrorItem, 

460 runtime: str = "docker", 

461 strategy: str = "buildx", 

462 password: str = "", 

463 log: LogFn = print, 

464) -> None: 

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

466 

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

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

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

470 """ 

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

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

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

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

475 ) 

476 if result.returncode != 0: 

477 raise RuntimeError( 

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

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

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

481 ) 

482 

483 

484def plan_mirror( 

485 region: str, 

486 ecr_namespace: str | None = None, 

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

488 charts_path: Path | None = None, 

489) -> dict[str, Any]: 

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

491 

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

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

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

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

496 by :func:`mirror_status`. 

497 

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

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

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

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

502 """ 

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

504 if source_refs is None: 

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

506 registry_host = _registry_host(_account_id(), region) 

507 plan = plan_from_sources(source_refs, registry_host, ecr_namespace) 

508 return { 

509 "region": region, 

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

511 "ecr_namespace": ecr_namespace, 

512 "images": [ 

513 { 

514 "source_ref": item.source_ref, 

515 "dest_repo": item.dest_repo, 

516 "dest_ref": item.dest_ref, 

517 "tag": item.tag, 

518 } 

519 for item in plan 

520 ], 

521 } 

522 

523 

524def mirror_status( 

525 region: str, 

526 ecr_namespace: str | None = None, 

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

528 charts_path: Path | None = None, 

529) -> dict[str, Any]: 

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

531 

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

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

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

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

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

537 anything to copy before the consuming Helm install runs. 

538 """ 

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

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

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

542 missing: list[str] = [] 

543 for img in plan["images"]: 

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

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

546 if not present: 

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

548 return { 

549 "region": plan["region"], 

550 "registry": plan["registry"], 

551 "ecr_namespace": plan["ecr_namespace"], 

552 "images": images, 

553 "all_mirrored": not missing, 

554 "missing": missing, 

555 } 

556 

557 

558def mirror_images( 

559 region: str, 

560 ecr_namespace: str | None = None, 

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

562 charts_path: Path | None = None, 

563 skip_existing: bool = True, 

564 log: LogFn = print, 

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

566 on_repository_created: RepositoryCreatedCallback | None = None, 

567) -> dict[str, Any]: 

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

569 

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

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

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

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

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

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

576 ``skipped`` destination refs. 

577 """ 

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

579 if source_refs is None: 

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

581 

582 account_id = _account_id() 

583 registry_host = _registry_host(account_id, region) 

584 plan = plan_from_sources(source_refs, registry_host, ecr_namespace) 

585 

586 runtime = detect_runtime() 

587 strategy = resolve_copy_strategy(runtime) 

588 

589 log( 

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

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

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

593 ) 

594 

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

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

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

598 username, password = ecr_auth(region) 

599 if strategy != "skopeo": 

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

601 

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

603 mirrored: list[str] = [] 

604 skipped: list[str] = [] 

605 created_repositories: list[str] = [] 

606 on_created = ( 

607 _bind_repository_created_callback(on_repository_created, region) 

608 if on_repository_created is not None 

609 else None 

610 ) 

611 for item in plan: 

612 if ensure_repository( 

613 ecr_client, 

614 item.dest_repo, 

615 log=log, 

616 repository_tags=repository_tags, 

617 on_created=on_created, 

618 ): 

619 created_repositories.append(item.dest_repo) 

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

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

622 skipped.append(item.dest_ref) 

623 continue 

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

625 mirrored.append(item.dest_ref) 

626 

627 return { 

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

629 "strategy": strategy, 

630 "mirrored": mirrored, 

631 "skipped": skipped, 

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

633 }