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
« 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.
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``.
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:
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).
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:
281. **Static ref** — append a literal, e.g.::
30 refs.append("docker.io/bitnami/redis:7.4.1")
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>``).
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>``.
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═══════════════════════════════════════════════════════════════════════════
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"""
59from __future__ import annotations
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
70import boto3
71import yaml
73from ._image_uri import ecr_registry_host
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
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"
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"
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"
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]
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."""
122 def notify(repository: Mapping[str, Any]) -> None:
123 callback(region, repository)
125 return notify
128@dataclass(frozen=True)
129class MirrorItem:
130 """One image to copy: ``source_ref`` -> ``dest_ref`` (repo ``dest_repo``)."""
132 source_ref: str
133 dest_repo: str
134 dest_ref: str
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]
142def parse_source_ref(ref: str) -> tuple[str, str]:
143 """Split ``"<registry>/<repo>:<tag>"`` into ``(repo_path, tag)``.
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
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
170def _volcano_source_refs(charts_config: dict[str, Any]) -> list[str]:
171 """Return Volcano's upstream image refs derived from ``charts.yaml``.
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 )
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
204def collect_source_refs(charts_config: dict[str, Any] | None = None) -> list[str]:
205 """Return every upstream image ref to mirror (``"<registry>/<repo>:<tag>"``).
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] = []
215 # Volcano (docker.io/volcanosh/vc-*) — the reason this mirror exists.
216 refs.extend(_volcano_source_refs(charts_config))
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).
223 return refs
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.
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
247def read_mirror_config(cdk_json_path: Path = _CDK_JSON) -> dict[str, Any]:
248 """Return ``{enabled, ecr_namespace}`` from cdk.json ``volcano_image_mirror``.
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}
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"])
276def _account_id() -> str:
277 return str(boto3.client("sts").get_caller_identity()["Account"])
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)
285def detect_runtime() -> str:
286 """Return the container CLI to drive (``docker``, ``finch``, or ``podman``).
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 )
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
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)
326def resolve_copy_strategy(runtime: str) -> str:
327 """Pick a multi-arch-preserving copy strategy from what's available.
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.
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 )
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
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).
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
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
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}")
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.
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}")
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.
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 )
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.
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`.
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 }
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.
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 }
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).
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)
582 account_id = _account_id()
583 registry_host = _registry_host(account_id, region)
584 plan = plan_from_sources(source_refs, registry_host, ecr_namespace)
586 runtime = detect_runtime()
587 strategy = resolve_copy_strategy(runtime)
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 )
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)
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)
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 }