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
« 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.
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-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
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"
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"
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"
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]
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."""
123 def notify(repository: Mapping[str, Any]) -> None:
124 callback(region, repository)
126 return notify
129@dataclass(frozen=True)
130class MirrorItem:
131 """One image to copy: ``source_ref`` -> ``dest_ref`` (repo ``dest_repo``)."""
133 source_ref: str
134 dest_repo: str
135 dest_ref: str
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]
143def parse_source_ref(ref: str) -> tuple[str, str]:
144 """Split ``"<registry>/<repo>:<tag>"`` into ``(repo_path, tag)``.
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
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
171def _volcano_source_refs(charts_config: dict[str, Any]) -> list[str]:
172 """Return Volcano's upstream image refs derived from ``charts.yaml``.
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 )
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
205def collect_source_refs(charts_config: dict[str, Any] | None = None) -> list[str]:
206 """Return every upstream image ref to mirror (``"<registry>/<repo>:<tag>"``).
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] = []
216 # Volcano (docker.io/volcanosh/vc-*) — the reason this mirror exists.
217 refs.extend(_volcano_source_refs(charts_config))
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).
224 return refs
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.
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
248def read_mirror_config(cdk_json_path: Path = _CDK_JSON) -> dict[str, Any]:
249 """Return ``{enabled, ecr_namespace}`` from cdk.json ``volcano_image_mirror``.
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}
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"])
277def _account_id() -> str:
278 return str(boto3.client("sts").get_caller_identity()["Account"])
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)
286def detect_runtime() -> str:
287 """Return the container CLI to drive (``docker``, ``finch``, or ``podman``).
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 )
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
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)
327def resolve_copy_strategy(runtime: str) -> str:
328 """Pick a multi-arch-preserving copy strategy from what's available.
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.
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 )
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
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).
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
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
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}")
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.
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}")
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.
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 )
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.
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`.
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 }
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.
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 }
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).
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)
583 account_id = _account_id()
584 registry_host = _registry_host(account_id, region)
585 plan = plan_from_sources(source_refs, registry_host, ecr_namespace)
587 runtime = detect_runtime()
588 strategy = resolve_copy_strategy(runtime)
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 )
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)
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)
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 }