Generates a bash script that bulk-mirrors NVCF images (and optionally helm charts) from NGC to Azure Container Registry by iterating an `image-manifest/manifest.yaml` SBOM. Resolves the NGC source path **per manifest group** from a built-in mapping table that mirrors `image-manifest/source-registry-config.yaml` (see the per-group source mapping below), overridable per-group via `streaming-env.sh`. Two emit modes — `regsync` (declarative, idempotent, parallel; preferred) or `az acr import` loop (no extra deps). Handles scalar and list tag values, skips `helm-*` keys for the image pass, and applies the `<acr>.azurecr.io/<sub-path>/<image>:<tag>` convention so NVCF helm charts (which concatenate `<registry>/<repository>/<image>`) don't render double-slash paths. Use after the `acr` foundation skill has provisioned the ACR and before `nvcf-self-managed-installation`. Trigger when the user mentions "mirror images", "populate ACR", "regsync", "az acr import", "NGC → ACR", "bulk mirror", "image-manifest sync", or "s
Installation
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
Generates a bash script that bulk-mirrors NVCF images (and optionally helm charts) from NGC to Azure Container Registry by iterating an `image-manifest/manifest.yaml` SBOM. Resolves the NGC source path **per manifest group** from a built-in mapping table that mirrors `image-manifest/source-registry-config.yaml` (see the per-group source mapping below), overridable per-group via `streaming-env.sh`. Two emit modes — `regsync` (declarative, idempotent, parallel; preferred) or `az acr import` loop (no extra deps). Handles scalar and list tag values, skips `helm-*` keys for the image pass, and applies the `<acr>.azurecr.io/<sub-path>/<image>:<tag>` convention so NVCF helm charts (which concatenate `<registry>/<repository>/<image>`) don't render double-slash paths. Use after the `acr` foundation skill has provisioned the ACR and before `nvcf-self-managed-installation`. Trigger when the user mentions "mirror images", "populate ACR", "regsync", "az acr import", "NGC → ACR", "bulk mirror", "image-manifest sync", or "sync NVCF SBOM to ACR".
Bulk mirror NGC → ACR
What this skill produces
A self-contained bash script that:
Locates the NVCF image-manifest SBOM (image-manifest/manifest.yaml at the repo root of a streaming-staging clone) — see Manifest sources for how this works from a streaming-staging clone vs an azure-base-only checkout.
Iterates the nvcf-self-managed-stack group, emitting one mirror entry per image:tag pair.
Skips chart artifacts (keys prefixed helm-) for the image pass. Mirroring them is covered in Helm chart mirroring.
Handles both scalar values (bitnami-cassandra: 5.0.6-nv-1) and list values (nvcf-openbao: [2.2.2-nv-1, 2.5.1-nv-1.1.0] during upgrade transitions).
Writes either a regsync.yaml + run command (default), or a loop of az acr import commands.
This skill is the operational counterpart to acr: acr creates the registry; acr-mirror populates it.
When to use it
In the aks-bootstrap flow, this is stage 04b — runs after acr (stage 04) creates the registry and before nvcf-self-managed-installation (post-foundation) installs the helmfile bundle, because that install tries to pull images from <acr>.azurecr.io/<repo>/....
It is not part of the mandatory aks-bootstrap cycle — bootstrap is foundation-only. Mirroring is the first post-foundation step.
Inputs
Sourced from streaming-env.sh and the upstream stages' generated/.env.* files.
Required:
Variable
Description
Example
ACR_NAME
Target ACR (from streaming-env.sh)
acrnvcftestwestus3
NGC_API_KEY_FILE
Path to a mode-600 file containing the raw NGC API key
Used only when FETCH_FROM_GITLAB=true — tag or SHA of the manifest commit to fetch
main
FETCH_FROM_GITLAB
If true and MANIFEST_PATH doesn't exist, fetch the manifest from omniverse/ovonsm/image-manifest via the GitLab API. Requires GITLAB_TOKEN.
false
NGC_SOURCE_REGISTRY
Source registry host
nvcr.io
TARGET_REPOSITORY
Sub-path under the ACR. Never empty — see Why the sub-path matters. Defaults to ${CLUSTER_NAME} so multiple deployments sharing one ACR don't collide.
${CLUSTER_NAME}
INCLUDE_GROUPS
Comma-separated manifest groups to mirror (subset filter). Empty = all non-metadata groups.
(all)
NGC_SOURCE_REPO_<GROUP>
Per-group override for the NGC source path. Group hyphens become underscores in the env var name (e.g. NGC_SOURCE_REPO_nvcf_self_managed_stack). See Per-group source mapping for built-in defaults.
(defaults below)
MIRROR_TOOL
regsync (recommended) or az_acr_import
regsync
INCLUDE_HELM_CHARTS
Also mirror helm-* chart artifacts (OCI helm)
false
SKIP_KEYS
Comma-separated manifest keys to skip by name — entries that are not OCI artifacts and 404 if mirrored. The skill can't read component_type at runtime (versions.yaml, which carries it, isn't shipped beside manifest.yaml), so this is a hand-maintained list. Default nvcf-stack is component_type: package upstream (an SBOM umbrella label).
nvcf-stack
DRY_RUN
Skip the actual push; emit the config and print what would be mirrored
false
Per-group source mapping
The skill ships with these defaults for every group currently in manifest.yaml:
Manifest group
Default NGC source path (under nvcr.io/)
nvcf-self-managed-stack
0651155215864979/ncp-dev
nvcf-compute-plane-stack
0651155215864979/ncp-dev
container_cache
0651155215864979/ncp-dev
gxcache
0833294136851237/nvcf-ncp-staging
lls
0795883928596950
ucc
nvidia/omniverse
ddcs
nvidia/omniverse
storage-api
nvidia/omniverse
These mirror image-manifest/source-registry-config.yaml (the deployment source of truth) — group keys match manifest.yaml exactly (container_cache, not caches). A group present in the manifest but absent from this map is a hard error, not a silent skip; use INCLUDE_GROUPS to scope a partial run.
To override per-group: set NGC_SOURCE_REPO_<GROUP> in streaming-env.sh, replacing hyphens with underscores. Example:
# In streaming-env.shexport NGC_SOURCE_REPO_nvcf_self_managed_stack="0833294136851237/some-other-team"export NGC_SOURCE_REPO_storage_api="nvidia/something-else"
A group that's in the manifest but missing from both the built-in defaults AND any override produces a WARN and is skipped — the script doesn't fail, so unknown future groups don't break existing mirrors. Set the env var to include them.
Multiple NGC source orgs — one key may not cover them all
The groups source from more than one NGC org (see the per-group source table above for the current paths). The mirror authenticates to nvcr.io with a single NGC_API_KEY, and access to private orgs is membership-scoped: if the key's NGC user isn't a member of a private source org, the pull returns 403 Forbidden for that org — which looks like a bad or expired key but is actually an org-access gap, and is easy to lose hours to.
Public orgs don't gate on membership. Some groups source from a public NGC org, where any valid NGC key resolves the images — it's the authenticated token that matters, not org membership. Only the private source orgs require membership, so a key that belongs to the private org you source the control-plane groups from will also pull the public-org groups.
Consequences:
Usually one key is enough: a key whose user is a member of the private org(s) the manifest sources from covers everything, public-org groups included. Point NGC_API_KEY_FILE at it.
Two private orgs need per-org passes: the multi-key problem only bites when the manifest spans two private orgs that no single key is a member of both (e.g. the key belongs to one private org but a group is sourced from a different private org — the failure in the bug). Then run the mirror once per private org — INCLUDE_GROUPS to that org's groups, NGC_API_KEY_FILE to that org's key:
# Pass 1 — groups sourced from private org A, using org A's key
INCLUDE_GROUPS="<groups-for-org-A>" NGC_API_KEY_FILE=~/.nvcf-secrets/ngc-key-A ./<script>.sh
# Pass 2 — groups sourced from private org B, using org B's key
INCLUDE_GROUPS="<groups-for-org-B>" NGC_API_KEY_FILE=~/.nvcf-secrets/ngc-key-B ./<script>.sh
# (Public-org groups can ride along in any pass — any valid key pulls them.)
This applies only to the mirror step (which pulls from NGC). Once everything lands in the one ACR, the split disappears for the cluster: AcrPull node identity authenticates all pulls, so no per-org pull secrets are needed in-cluster.
Outputs
Bash:generated/stageNN-<cluster-name>-acr-mirror.sh (NN = next available stage number in the deployment cycle; commonly 04b or 08).
regsync config (when MIRROR_TOOL=regsync):generated/regsync.yaml — emitted at script runtime; consumed by regsync once -c generated/regsync.yaml.
The script does NOT write a .env.acr-mirror file because nothing downstream consumes its outputs — successful exit is the signal. It does print a final tally line (mirrored N images, M charts) and (in DRY_RUN=true) the list of pairs without executing.
Manifest sources
Two scenarios, in priority order:
From a streaming-staging clone
image-manifest/manifest.yaml ships at the repo root, pre-synced from omniverse/ovonsm/image-manifest (alongside a COMMIT file for provenance and source-registry-config.yaml). The default MANIFEST_PATH=${REPO_ROOT}/image-manifest/manifest.yaml resolves correctly. To advance to a newer manifest, pin a different streaming-staging tag/SHA and git pull.
From an azure-base-only checkout
No manifest ships with azure-base. Two options:
Set MANIFEST_PATH in streaming-env.sh to a local manifest checkout (e.g., a sibling clone of omniverse/ovonsm/image-manifest).
Set FETCH_FROM_GITLAB=true and provide GITLAB_TOKEN. The script fetches the manifest from the GitLab API at MANIFEST_REF — equivalent to what streaming-staging's sync-nvcf.sh does, but at deploy time.
The script echoes manifest source: <path> and the version / generated_at fields so the source is auditable from logs.
Why the sub-path matters
Two reasons the sub-path cannot be empty or static:
Empty breaks helm pulls. NVCF helm charts concatenate <registry>/<repository>/<image>:<tag>. The helmfile env file's global.image.repository is set to match TARGET_REPOSITORY. If TARGET_REPOSITORY is empty, the chart renders <acr>.azurecr.io//<image>:<tag> (double slash), which docker pull and many container runtimes reject.
A static sub-path collides across deployments. When two clusters share one ACR (team registry, prod ACR, dev/staging side-by-side), both mirroring to <acr>/nvcf/<image>:<tag> race to overwrite tags — last write wins, and if the two manifest commits pin different versions of the same image, one cluster pulls the wrong tag.
The default ${CLUSTER_NAME} gives every deployment its own namespace under the ACR. After mirror, set in the helmfile env file:
global:image:registry:<acr>.azurecr.io# not nvcr.iorepository:aks-nvcf-validation-westus3# matches TARGET_REPOSITORY (= ${CLUSTER_NAME})helm:sources:registry:<acr>.azurecr.iorepository:aks-nvcf-validation-westus3
No image-pull secret needed once you point at the ACR. The acr skill granted the AKS kubelet identity AcrPull on this registry, so a pull from <acr>.azurecr.io/... is authenticated by the node identity. Once global.image and global.helm.sources point at the ACR you can drop the entire NGC pull-secret apparatus the NGC-direct path requires: the per-namespace nvcr-pull-secret, the global.imagePullSecrets entry, and the docker login/helm registry login nvcr.io steps. This also removes the multi-org pull-secret problem (function pods otherwise pull several nvcr.io orgs, each needing a different key) — everything is one AcrPull-authenticated registry. See the "Pull from a registry mirror (ACR/ECR)" section of the nvcf-self-managed-installation skill for the consumer-side env + step changes.
Override TARGET_REPOSITORY only when the ACR has an existing namespacing convention (e.g., prod/<release-name>) or when multiple deployments must share tags.
Manifest schema (consumed by this skill)
version:'1.0'generated_at:'2026-05-28T22:34:11Z'nvcf-self-managed-stack:bitnami-cassandra:5.0.6-nv-1# scalarnvcf-openbao:# list (upgrade transition; rare)-2.2.2-nv-1-2.5.1-nv-1.1.0helm-nvcf-cassandra:0.11.1# chart artifact — skipped in image passgxcache:gxcache-init:1e47f722gxcache-service:b206ce39helm-gxcache:0.8.2lls:gdn-streaming:2.0.1streaming-proxy:2.0.1storage-api:helm-discovery-service:2.3.8storage-service:1.0.2# ... etc. (ucc, ddcs)
Generation rules:
Top-level meta keys (version, generated_at) are not groups — skip.
Every other top-level key is a group. Resolve its NGC source path via the per-group mapping (built-in default or env override).
Within each group:
Keys starting with helm- are OCI helm charts. Skip in the image pass. If INCLUDE_HELM_CHARTS=true, mirror them via OCI helm push (see Helm chart mirroring).
All other keys are container images. The key IS the image name.
The value is either a string tag (scalar) or a list of tags. Mirror each tag separately.
Bash snippet (reference)
#!/bin/bashset -euo pipefail
# Resolve repo root from script location — works regardless of CWD.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"source"$REPO_ROOT/streaming-env.sh"if [[ ! -f "$REPO_ROOT/generated/.env.acr" ]]; thenecho"ERROR: $REPO_ROOT/generated/.env.acr not found. Run the acr skill (stage 04) first." >&2
exit 1
fisource"$REPO_ROOT/generated/.env.acr"
: "${ACR_NAME:?}""${NGC_API_KEY_FILE:?}"
[[ -f "$NGC_API_KEY_FILE" ]] || { echo"ERROR: NGC_API_KEY_FILE='$NGC_API_KEY_FILE' is not a file" >&2; exit 1; }
NGC_SOURCE_REGISTRY="${NGC_SOURCE_REGISTRY:-nvcr.io}"
TARGET_REPOSITORY="${TARGET_REPOSITORY:-$CLUSTER_NAME}"
[[ -n "$TARGET_REPOSITORY" ]] || { echo"ERROR: TARGET_REPOSITORY is empty (no CLUSTER_NAME fallback). Set it in streaming-env.sh." >&2; exit 1; }
MIRROR_TOOL="${MIRROR_TOOL:-regsync}"
INCLUDE_HELM_CHARTS="${INCLUDE_HELM_CHARTS:-false}"
DRY_RUN="${DRY_RUN:-false}"# Manifest keys to skip BY NAME because they are not OCI artifacts and 404 if# mirrored. This is a hand-maintained deny-list, NOT a type lookup: the skill# can't see component_type at runtime — versions.yaml (which carries it) is not# shipped beside manifest.yaml. The default, nvcf-stack, is component_type:# package upstream (an SBOM umbrella label). Durable fix: have the aggregator# emit component_type into manifest.yaml so the type can be read directly.
SKIP_KEYS="${SKIP_KEYS:-nvcf-stack}"
IFS=','read -ra SKIP_KEYS_ARR <<< "$SKIP_KEYS"
MANIFEST_PATH="${MANIFEST_PATH:-$REPO_ROOT/image-manifest/manifest.yaml}"# Temp paths allocated lazily below. Single cleanup trap covers both the# fetched-manifest tempfile and the helm-charts tempdir so concurrent script# runs cannot collide on shared /tmp paths.
FETCHED_MANIFEST=""
CHARTS_DIR=""cleanup() {
[[ -n "$FETCHED_MANIFEST" && -f "$FETCHED_MANIFEST" ]] && rm -f "$FETCHED_MANIFEST"
[[ -n "$CHARTS_DIR" && -d "$CHARTS_DIR" ]] && rm -rf "$CHARTS_DIR"# Always succeed: as the last command in an EXIT trap, a short-circuited &&# guard (e.g. both paths empty in a default run) would otherwise set the# script's exit status to 1 after a fully successful mirror.return 0
}
trap cleanup EXIT
# Per-group NGC source path defaults. Override per-group via env vars named# NGC_SOURCE_REPO_<group> with hyphens replaced by underscores (e.g.# NGC_SOURCE_REPO_nvcf_self_managed_stack). A group with no default and no# override emits a WARN and is skipped — the script still mirrors the rest.# Mirrors image-manifest/source-registry-config.yaml (the source of truth).# Keys are the manifest group names exactly as they appear in manifest.yaml# (e.g. container_cache, not caches).declare -A DEFAULT_SOURCE_REPO=(
["nvcf-self-managed-stack"]="0651155215864979/ncp-dev"
["nvcf-compute-plane-stack"]="0651155215864979/ncp-dev"
["container_cache"]="0651155215864979/ncp-dev"
["gxcache"]="0833294136851237/nvcf-ncp-staging"
["lls"]="0795883928596950"
["ucc"]="nvidia/omniverse"
["ddcs"]="nvidia/omniverse"
["storage-api"]="nvidia/omniverse"
)
resolve_source_repo() {
local group="$1" var="NGC_SOURCE_REPO_${1//-/_}"printf'%s'"${!var:-${DEFAULT_SOURCE_REPO[$group]:-}}"
}
# Resolve manifest — local first, then optionally fetch from GitLab.if [[ ! -f "$MANIFEST_PATH" ]]; thenif [[ "${FETCH_FROM_GITLAB:-false}" == "true" ]]; then
: "${GITLAB_TOKEN:?required when FETCH_FROM_GITLAB=true}"
MANIFEST_REF="${MANIFEST_REF:-main}"echo"--- Fetching manifest from upstream (ref=$MANIFEST_REF) ---"
FETCHED_MANIFEST=$(mktemp -t acr-mirror-manifest.XXXXXX)
curl -fsSL -H "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab-master.nvidia.com/api/v4/projects/omniverse%2Fovonsm%2Fimage-manifest/repository/files/manifest.yaml/raw?ref=${MANIFEST_REF}" \
-o "$FETCHED_MANIFEST"
MANIFEST_PATH="$FETCHED_MANIFEST"elseecho"ERROR: manifest not found at $MANIFEST_PATH" >&2
echo" Either: (a) run from a streaming-staging clone (ships image-manifest/manifest.yaml at repo root)," >&2
echo" (b) set MANIFEST_PATH in streaming-env.sh to a local manifest checkout, or" >&2
echo" (c) set FETCH_FROM_GITLAB=true + GITLAB_TOKEN to fetch from upstream." >&2
exit 1
fificommand -v yq >/dev/null || { echo"ERROR: yq required" >&2; exit 1; }
# The enumeration queries below are written for mikefarah yq v4 (the QA/repo# toolchain). The jq-wrapper yq (python/kislyuk) uses incompatible expression# syntax, so fail clearly rather than emit a confusing lexer error mid-run.
yq --version 2>&1 | grep -qi mikefarah || {
echo"ERROR: this script targets mikefarah yq v4 (https://github.com/mikefarah/yq)." >&2
echo" Found: $(yq --version 2>&1). Install mikefarah yq and re-run." >&2
exit 1
}
echo"--- Manifest source: $MANIFEST_PATH ---"
yq -r '" version: " + .version + " generated_at: " + .generated_at'"$MANIFEST_PATH"# Collect groups (top-level keys minus metadata).mapfile -t ALL_GROUPS < <(yq -r 'keys | .[] | select(. != "version" and . != "generated_at")'"$MANIFEST_PATH")
# Apply INCLUDE_GROUPS filter if set.# NB: do NOT name this array GROUPS — bash auto-populates a special array of# that name with the caller's supplementary group IDs; assignments to it are# dropped, so the loop would iterate GIDs and mirror nothing.if [[ -n "${INCLUDE_GROUPS:-}" ]]; then
IFS=','read -ra REQUESTED <<< "$INCLUDE_GROUPS"
SELECTED_GROUPS=()
for g in"${ALL_GROUPS[@]}"; dofor r in"${REQUESTED[@]}"; do [[ "$g" == "$r" ]] && SELECTED_GROUPS+=("$g") && break; donedoneelse
SELECTED_GROUPS=("${ALL_GROUPS[@]}")
fiecho"--- Groups to process: ${SELECTED_GROUPS[*]} ---"# Auth.echo"--- Authenticating to ACR ---"
az acr login --name "$ACR_NAME"if [[ "$INCLUDE_HELM_CHARTS" == "true" ]]; thenecho"--- Authenticating to NGC for helm OCI ---"
helm registry login "$NGC_SOURCE_REGISTRY" -u '$oauthtoken' --password-stdin < "$NGC_API_KEY_FILE"fi# Build the flat list of (group, image, tag, source-repo) tuples, skipping# helm-* keys (charts handled separately).
TUPLES=() # "<group>\t<image>\t<tag>\t<source-repo>"for group in"${SELECTED_GROUPS[@]}"; do
source_repo=$(resolve_source_repo "$group")
if [[ -z "$source_repo" ]]; then# Hard-fail rather than WARN-skip: a silent skip drops a whole group's# images and only surfaces later as ImagePullBackOff. If a group is# intentionally excluded, use INCLUDE_GROUPS instead.echo"ERROR: no NGC source path for group '$group'. Add it to DEFAULT_SOURCE_REPO or set NGC_SOURCE_REPO_${group//-/_}; use INCLUDE_GROUPS to scope the run." >&2
exit 1
fiwhile IFS= read -r pair; do
image="${pair%%:*}"; tag="${pair#*:}"
skip=""; for s in"${SKIP_KEYS_ARR[@]}"; do [[ "$image" == "$s" ]] && skip=1 && break; doneif [[ -n "$skip" ]]; thenecho"SKIP: $group/$image (non-OCI / package artifact, not mirrorable)" >&2
continuefi
TUPLES+=("${group}\t${image}\t${tag}\t${source_repo}")
done < <(yq -r "
.\"$group\"
| to_entries
| .[]
| select(.key | test(\"^helm-\") | not)
| .key as \$name
| (.value | [.] | flatten | .[]) as \$tag
| \$name + \":\" + \$tag
""$MANIFEST_PATH")
doneecho"--- ${#TUPLES[@]} image:tag pairs to mirror ---"# A silent no-op (0 pairs) is the dangerous failure mode — a later ACR-backed# install then fails with ImagePullBackOff while this step looked successful.# Fail loudly instead, unless the operator deliberately filtered to nothing.if [[ "${#TUPLES[@]}" -eq 0 && -z "${INCLUDE_GROUPS:-}" ]]; thenecho"ERROR: resolved 0 image:tag pairs from $MANIFEST_PATH — nothing to mirror." >&2
echo" Check the manifest has non-helm image keys and that yq enumerated them." >&2
exit 1
fiif [[ "$MIRROR_TOOL" == "regsync" ]]; thenmkdir -p "$REPO_ROOT/generated"
CONFIG="$REPO_ROOT/generated/regsync.yaml"
{
cat <<EOF
version: 1
creds:
# repoAuth: true forces per-repo auth. NGC's namespace returns a fresh
# auth challenge per repo; the cached-token path mishandles this with
# "invalid challenge header".
- registry: ${NGC_SOURCE_REGISTRY}
user: \$oauthtoken
pass: '{{env "NGC_API_KEY"}}'
repoAuth: true
- registry: ${ACR_NAME}.azurecr.io
defaults:
parallel: 4
sync:
EOFfor tuple in"${TUPLES[@]}"; do
IFS=$'\t'read -r group image tag source_repo <<< "$(printf '%b' "$tuple")"cat <<EOF
- source: ${NGC_SOURCE_REGISTRY}/${source_repo}/${image}:${tag}
target: ${ACR_NAME}.azurecr.io/${TARGET_REPOSITORY}/${image}:${tag}
type: image
EOFdone
} > "$CONFIG"echo" wrote $CONFIG"command -v regsync >/dev/null || { echo"ERROR: regsync required (brew install regclient)"; exit 1; }
export NGC_API_KEY
NGC_API_KEY="$(< "$NGC_API_KEY_FILE")"; NGC_API_KEY="${NGC_API_KEY%$'\n'}"if [[ "$DRY_RUN" == "true" ]]; then regsync check -c "$CONFIG"; else regsync once -c "$CONFIG"; fiunset NGC_API_KEY
elif [[ "$MIRROR_TOOL" == "az_acr_import" ]]; then# No extra binary needed; sequential and slower (no parallelism).for tuple in"${TUPLES[@]}"; do
IFS=$'\t'read -r group image tag source_repo <<< "$(printf '%b' "$tuple")"
src="${NGC_SOURCE_REGISTRY}/${source_repo}/${image}:${tag}"
dst="${TARGET_REPOSITORY}/${image}:${tag}"if [[ "$DRY_RUN" == "true" ]]; thenecho" DRY: az acr import --source $src --image $dst (group=$group)"elseecho" [$group] $src → $dst"
az acr import --name "$ACR_NAME" \
--source"$src" --image "$dst" \
--username '$oauthtoken' --password "$(< "$NGC_API_KEY_FILE")" \
--only-show-errors
fidoneelseecho"ERROR: unknown MIRROR_TOOL='$MIRROR_TOOL' (use regsync or az_acr_import)" >&2
exit 1
fi# Helm charts (optional). Same per-group source resolution as for images.if [[ "$INCLUDE_HELM_CHARTS" == "true" ]]; thenecho"--- Mirroring helm charts (OCI) ---"
CHARTS_DIR=$(mktemp -d -t acr-mirror-charts.XXXXXX)
for group in"${SELECTED_GROUPS[@]}"; do
source_repo=$(resolve_source_repo "$group"); [[ -z "$source_repo" ]] && continuewhile IFS= read -r pair; do
chart="${pair%%:*}"; tag="${pair#*:}"if [[ "$DRY_RUN" == "true" ]]; thenecho" DRY: [$group] pull oci://${NGC_SOURCE_REGISTRY}/${source_repo}/${chart}:${tag} → push oci://${ACR_NAME}.azurecr.io/${TARGET_REPOSITORY}"else
helm pull "oci://${NGC_SOURCE_REGISTRY}/${source_repo}/${chart}" --version "$tag" -d "$CHARTS_DIR"
helm push "$CHARTS_DIR/${chart}-${tag}.tgz""oci://${ACR_NAME}.azurecr.io/${TARGET_REPOSITORY}"rm -f "$CHARTS_DIR/${chart}-${tag}.tgz"fidone < <(yq -r "
.\"$group\"
| to_entries
| .[]
| select(.key | test(\"^helm-\"))
| .key as \$name
| (.value | [.] | flatten | .[]) as \$tag
| \$name + \":\" + \$tag
""$MANIFEST_PATH")
donefiecho"--- Done. Verify: az acr repository list --name $ACR_NAME --output table ---"
Helm chart mirroring (optional)
Charts in the manifest are keys prefixed helm- (e.g., helm-nvcf-cassandra: 0.11.1). They are OCI helm artifacts at the source — they need helm pull + helm push, not regsync or az acr import. The script above handles this when INCLUDE_HELM_CHARTS=true.
Why optional: if the cluster has outbound access to nvcr.io and a working NGC auth secret, the helmfile install pulls charts directly from NGC at install time — no mirror needed. Mirror them only for full air-gap where the cluster must not reach nvcr.io.
When mirroring charts, update the helmfile env file:
global:helm:sources:registry:<acr>.azurecr.iorepository:<target-repository># same sub-path as the images
Verification
After mirror, spot-check:
# Image:tag count vs SBOM (across all groups; same exclusion as the mirror — skip helm-*).# Both counts are at the tag level — an image with 2 tags (e.g. nvcf-openbao# during an upgrade transition) contributes 2 to each side.
SBOM_COUNT=$(yq -r '
to_entries | .[]
| select(.key != "version" and .key != "generated_at")
| .value | to_entries | .[]
| select(.key | test("^helm-") | not)
| (.value | [.] | flatten | .[])
| "x"
'"$MANIFEST_PATH" | wc -l | tr -d ' ')
ACR_COUNT=0
while IFS= read -r repo; do
[[ -z "$repo" ]] && continue
tag_count=$(az acr repository show-tags --name "$ACR_NAME" --repository "$repo" -o tsv 2>/dev/null | wc -l | tr -d ' ')
ACR_COUNT=$((ACR_COUNT + tag_count))
done < <(az acr repository list --name "$ACR_NAME" -o tsv | grep "^${TARGET_REPOSITORY}/")
echo"SBOM expects: $SBOM_COUNT image:tag pair(s) / ACR has: $ACR_COUNT under ${TARGET_REPOSITORY}/"# Spot-check a tag from any group
az acr repository show-tags --name "$ACR_NAME" --repository "${TARGET_REPOSITORY}/bitnami-cassandra" -o tsv
If counts diverge, re-run with DRY_RUN=true to inspect the planned sync list (each entry is prefixed with its [group]), then re-run for real.
Teardown
Mirror state is just data in ACR. Either:
az acr repository delete --name "$ACR_NAME" --repository "<sub-path>/<image>" --yes per image (when retiring specific images), or
Let the ACR itself be torn down by the acr skill's --teardown (which destroys the registry and everything in it).
There is no acr-mirror --teardown because the deliberate destructive action lives in the foundation skill.
Validation checklist
Script opens with set -euo pipefail and resolves repo root via BASH_SOURCE
Sources streaming-env.sh and .env.acr (guard: error if stage 04 hasn't run)
Required vars (ACR_NAME, NGC_API_KEY_FILE) have fail-fast checks; no other required inputs
NGC_API_KEY_FILE is verified to be a file ([[ -f ]]) before any redirection — the leak-postmortem pattern from earlier in this project
DEFAULT_SOURCE_REPO table is present and covers every group currently in manifest.yaml (nvcf-self-managed-stack, nvcf-compute-plane-stack, container_cache, gxcache, lls, ucc, ddcs, storage-api) and matches source-registry-config.yaml
An unmapped non-metadata group is a hard error (exit 1), not a WARN-skip
cleanup() ends with return 0 so the EXIT trap can't fail a successful run
resolve_source_repo honors per-group env override (NGC_SOURCE_REPO_<GROUP> with hyphens→underscores) before falling back to the default table
Unknown groups emit WARN and are skipped — the script does NOT fail on them
Manifest path resolves with the streaming-staging-clone default; fallback to GitLab fetch is gated behind FETCH_FROM_GITLAB=true
Tempfiles for the fetched manifest and helm-charts directory are allocated via mktemp / mktemp -d (never fixed /tmp/<name> paths) and a single trap cleanup EXIT removes them on every exit path — safe under concurrent runs
Manifest provenance is echoed (version, generated_at) for audit
Top-level metadata keys (version, generated_at) are NOT treated as groups
INCLUDE_GROUPS filter (comma-separated) subsets the groups when set
SKIP_KEYS (default nvcf-stack) drops non-OCI package-type keys from the image pass with a logged note
helm-* keys are skipped in the image pass
Both scalar and list tag values handled (verify against the nvcf-openbao upgrade-transition case)
TARGET_REPOSITORY is non-empty (defaults to ${CLUSTER_NAME}); explicit fail-fast guards against empty (e.g., if both env var and CLUSTER_NAME are unset)
"Why the sub-path matters" covers both the double-slash and the multi-deployment collision risk
MIRROR_TOOL=regsync emits regsync.yaml with repoAuth: true on the NGC creds entry (NGC's per-repo auth challenge breaks token caching)
MIRROR_TOOL=az_acr_import works without regsync installed
Each generated mirror entry (regsync or az acr import) uses the per-group source repo, not a single hardcoded one
DRY_RUN=true produces output without pushing anything; output is prefixed with [group] so per-group entries are visible
INCLUDE_HELM_CHARTS=true mirrors OCI helm artifacts via helm pull + helm push, with the same per-group source resolution
Verification snippet (image-count compare across all groups) is documented