Image mirroring¶
How container images are kept in sync with Harbor.
Overview¶
The image-mirror component (pkg/iac/harbor/mirror.go) deploys automatically when both are
true:
harbor:robotPasswordis set (push authentication)harbor:mirrorEnabledistrue
Setting harbor:robotPassword does more than gate the mirror: it's the desired password for
Harbor's k8s-harbor-sa robot account, which pulumi up creates (or updates to match) via
pkg/iac/harbor/setup.go — see the main README's deployment flow. The mirror simply reuses
that same account once it exists.
It manages two independent lists, each mirrored into Harbor with the same logic: compare the source and destination digests, copy only if they differ.
Public images¶
Config: harbor:publicImages — one src|dest pair per line.
Deployed as a one-shot Job (harbor-image-mirror-public), not a recurring schedule. The
list is fixed by configuration, so there is nothing to poll for — the Job only re-runs when
harbor:publicImages itself changes.
pulumi config set harbor:mirrorEnabled true
pulumi config set --secret harbor:robotPassword <robot-password>
pulumi config set harbor:publicImages "$(cat <<'EOF'
nginx:alpine|images-infra/nginx:alpine
alpine:3.20|images-infra/alpine:3.20
EOF
)"
Private (GHCR) images¶
Config: harbor:ghcrOrg, harbor:ghcrSyncMode, harbor:ghcrUser / harbor:ghcrToken.
Three sync modes:
| Mode | Behavior | Deployment |
|---|---|---|
all (default) |
Every published tag for every package in the org, discovered via the GitHub Packages API | Recurring CronJob, every 5 minutes |
min-version |
Only tags ≥ a per-package floor (harbor:ghcrMinVersions) |
Recurring CronJob |
pinned |
Exact package:tag pairs (harbor:ghcrPinnedImages), no API calls |
One-shot Job, re-runs only when the list changes |
all and min-version need real polling — new tags can appear without any config change.
pinned is a fixed list, same reasoning as public images, so it runs as a Job instead.
Without harbor:ghcrUser and harbor:ghcrToken, private-image mirroring is skipped
entirely, in every mode — even for packages that happen to be public, since GitHub's
Packages API requires an authenticated token to list them regardless of visibility.
Packages not covered by the active mode's list (pinned or min-version) are skipped
entirely — there is no fallback to all for whatever is missing.
pulumi config set harbor:ghcrOrg axem-solutions
pulumi config set harbor:ghcrUser <ghcr-username>
pulumi config set --secret harbor:ghcrToken <ghcr-token>
# min-version: mirror every tag >= the listed floor, per package
pulumi config set harbor:ghcrSyncMode min-version
pulumi config set harbor:ghcrMinVersions "$(cat <<'EOF'
shaide_server|v0.11.0
control_panel|v0.4.0
EOF
)"
# pinned: mirror only these exact tags, no discovery call
pulumi config set harbor:ghcrSyncMode pinned
pulumi config set harbor:ghcrPinnedImages "$(cat <<'EOF'
ghcr.io/axem-solutions/shaide_server:v0.11.0|images-shaide/shaide_server:v0.11.0
EOF
)"
Why a Job for fixed lists¶
A Kubernetes Job's pod spec is immutable once created. The image list is embedded directly
in the pod spec as an environment variable, not mounted from a ConfigMap — so editing the
underlying config value changes that spec. Pulumi then deletes and recreates the Job
(ReplaceOnChanges), which is exactly "re-run when the list changes." Left untouched, the
Job runs exactly once.
Push authentication¶
Every mirror path authenticates to Harbor with the robot account (harbor:robotPassword),
regardless of whether the target project is public or private — pushing always requires
authentication; only pulling can be anonymous.
Fix: Harbor Service port 443¶
Harbor runs with TLS disabled, so its Service originally had no port 443 at all. skopeo
(used for every copy) always attempts HTTPS first, even with --tls-verify=false — that flag
only skips certificate validation, not the attempt itself. With no matching Service rule, the
connection was silently dropped instead of refused, adding roughly 30-60 seconds of delay per
image, per run.
Fix: a Service patch (fixHarborHTTPSPortBlackhole in pkg/iac/harbor/harbor.go) adds port
443, routed to the same HTTP-only backend as port 80. The TLS handshake now fails fast, and
skopeo falls back to HTTP immediately instead of waiting out a timeout.