Phase 1 — Image Mirroring to Harbor¶
Mirror all platform and application container images from public registries and Azure ACR into the Core42 Harbor private registry, so the air-gapped Kubernetes cluster never needs internet access at runtime.
Tools
| Tool | Script | Config | Run from |
|---|---|---|---|
| Image mirror | core42-mirror-image.py |
mirror-config.yaml |
~/harbor-images/ |
| Chart mirror | core42-mirror-charts.py |
mirror-charts.yaml |
~/helm-charts/ |
Harbor Project Layout¶
Create these projects in Harbor before running the mirror (one-time setup):
| Harbor Project | Source Registry | Contents |
|---|---|---|
ask |
Azure ACR | core-service + workers (app images) |
gitlab |
registry.gitlab.com |
GitLab CNG components (13 images) |
dockerhub |
docker.io |
postgres, python, hashicorp/vault, vault-k8s, VSO |
bitnamilegacy |
docker.io/bitnamilegacy/* |
postgresql, redis, postgres-exporter, redis-exporter |
ghcr |
ghcr.io |
cloudnative-pg, stakater/reloader |
quay |
quay.io |
cert-manager ×5, argocd, kube-rbac-proxy |
helm-charts |
Helm OCI push | Helm chart .tgz packages |
Create gitlab and quay projects before first run
These are not auto-created.
Harbor UI → + New Project for each:
| Project | Access |
|---|---|
gitlab |
Private |
quay |
Private |
Step 1.1 — Verify prerequisites¶
Run from
~/harbor-images/
# Confirm skopeo is available
skopeo --version
# Confirm mirror script, config and credentials are present
ls ~/harbor-images/
# Expected: core42-mirror-image.py mirror-config.yaml harbor-robot.json image-cache/
# Confirm helm chart tool is present
ls ~/helm-charts/
# Expected: core42-mirror-charts.py mirror-charts.yaml harbor-robot.json README.md
Step 1.2 — Dry-run images (always run first)¶
Run from
~/harbor-images/
Expected output (33 images, 1 disabled):
[proxy] ON https_proxy=http://10.96.137.37:3128 ...
[1/33] [DISABLED] acr/core-service:1.0.0 (features.use_acr: false)
[2/33] [DRY] download registry.gitlab.com/gitlab-org/build/cng/gitaly:v18.11.4 -> cache
[2/33] [DRY] push -> harbor.cl1.sq4.aegis.internal/gitlab/gitlab-org/build/cng/gitaly:v18.11.4
...
[15/33] [DRY] push -> harbor.cl1.sq4.aegis.internal/dockerhub/postgres:16-alpine
[21/33] [DRY] push -> harbor.cl1.sq4.aegis.internal/bitnamilegacy/postgresql:16.6.0
[27/33] [DRY] push -> harbor.cl1.sq4.aegis.internal/quay/jetstack/cert-manager-controller:v1.20.2
[33/33] [DRY] push -> harbor.cl1.sq4.aegis.internal/quay/brancz/kube-rbac-proxy:v0.18.1
[proxy] OFF (environment restored)
=== summary ===
pushed : 0
skipped : 0
from_cache : 0
disabled : 1
failed : 0
✅ Verify all 33 destinations show correct harbor.cl1.sq4.aegis.internal/<project>/... paths before proceeding.
Step 1.3 — Download images to cache (internet side)¶
Run from
~/harbor-images/
Pulls all images into ./image-cache/ as portable OCI layouts. No Harbor access needed.
Idempotent
Already-cached images show [CACHED] and are skipped — safe to re-run if interrupted.
Step 1.4 — Push images to Harbor (air-gapped side)¶
Run from
~/harbor-images/
If cache was built on a different host
Transfer the cache to the bastion first:
Then run--push-only on the bastion.
Step 1.5 — Full mirror (download + push in one pass)¶
Run from
~/harbor-images/
If the bastion has both internet access and Harbor access:
Why --no-cache
Streams images directly source → Harbor without staging on disk. Use when the bastion has simultaneous internet + Harbor access.
Step 1.6 — Verify images in Harbor¶
Run from
~/harbor-images/
python3 - << 'EOF'
import json, urllib.request, base64, ssl
j = json.load(open("./harbor-robot.json"))
user = j["name"]
passwd = j["secret"]
base = "https://harbor.cl1.sq4.aegis.internal/api/v2.0/projects"
token = base64.b64encode(f"{user}:{passwd}".encode()).decode()
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for project in ["gitlab", "dockerhub", "bitnamilegacy", "ghcr", "quay"]:
url = f"{base}/{project}/repositories"
req = urllib.request.Request(url, headers={"Authorization": f"Basic {token}"})
with urllib.request.urlopen(req, context=ctx) as resp:
repos = json.load(resp)
print(f"\n=== {project} ({len(repos)} repos) ===")
for r in repos:
print(f" {r['name']}")
EOF
Expected output:
gitlab/gitlab-org/build/cng/gitaly
gitlab/gitlab-org/build/cng/gitlab-base
gitlab/gitlab-org/build/cng/gitlab-container-registry
gitlab/gitlab-org/build/cng/gitlab-exporter
gitlab/gitlab-org/build/cng/gitlab-kas
gitlab/gitlab-org/build/cng/gitlab-shell
gitlab/gitlab-org/build/cng/gitlab-sidekiq-ee
gitlab/gitlab-org/build/cng/gitlab-toolbox-ee
gitlab/gitlab-org/build/cng/gitlab-webservice-ee
gitlab/gitlab-org/build/cng/gitlab-workhorse-ee
gitlab/gitlab-org/build/cng/kubectl
gitlab/gitlab-org/build/cng/certificates
gitlab/gitlab-org/cloud-native/mirror/images/minio/mc
Step 1.7 — Mirror Helm charts to Harbor¶
Helm chart .tgz files are pushed to the Harbor helm-charts project via OCI (helm push).
Harbor acts as a single registry for both container images and Helm charts.
Tool
Script: ~/helm-charts/core42-mirror-charts.py
Config: ~/helm-charts/mirror-charts.yaml
Step 1.7a — Dry-run charts¶
Run from
~/helm-charts/
Expected output:
[proxy] ON https_proxy=http://10.96.137.37:3128 ...
[auth] credentials loaded from ./harbor-robot.json
[1/5] [DRY] helm repo add gitlab https://charts.gitlab.io
[1/5] [DRY] helm pull gitlab/gitlab --version 9.11.5 -> cache
[1/5] [DRY] helm push gitlab-9.11.5.tgz oci://harbor.cl1.sq4.aegis.internal/helm-charts
...
[proxy] OFF (environment restored)
=== summary ===
pushed : 0
skipped : 0
from_cache : 0
failed : 0
Step 1.7b — Mirror all charts¶
Run from
~/helm-charts/
# With cache (default)
./core42-mirror-charts.py
# Without cache — direct pull → push
./core42-mirror-charts.py --no-cache
# Single chart only
./core42-mirror-charts.py --only vault
./core42-mirror-charts.py --only gitlab
Step 1.7c — Verify charts in Harbor¶
Run from
~/helm-charts/
python3 - << 'EOF'
import json, urllib.request, base64, ssl
j = json.load(open("./harbor-robot.json"))
user = j["name"]
passwd = j["secret"]
url = "https://harbor.cl1.sq4.aegis.internal/api/v2.0/projects/helm-charts/repositories"
token = base64.b64encode(f"{user}:{passwd}".encode()).decode()
req = urllib.request.Request(url, headers={"Authorization": f"Basic {token}"})
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
with urllib.request.urlopen(req, context=ctx) as resp:
repos = json.load(resp)
if isinstance(repos, list):
for r in repos:
print(r["name"])
else:
print("ERROR:", repos)
EOF
Expected output (verified ✅):
helm-charts/reloader
helm-charts/vault-secrets-operator
helm-charts/vault
helm-charts/argo-cd
helm-charts/gitlab
Or verify with helm pull:
Chart inventory¶
| Helm Chart | Version | Upstream Source | Harbor OCI Path |
|---|---|---|---|
gitlab |
9.11.5 |
charts.gitlab.io |
harbor.../helm-charts/gitlab:9.11.5 |
argo-cd |
9.5.15 |
argoproj.github.io/argo-helm |
harbor.../helm-charts/argo-cd:9.5.15 |
vault |
0.32.0 |
helm.releases.hashicorp.com |
harbor.../helm-charts/vault:0.32.0 |
vault-secrets-operator |
0.9.1 |
helm.releases.hashicorp.com |
harbor.../helm-charts/vault-secrets-operator:0.9.1 |
reloader |
2.2.12 |
stakater.github.io/stakater-charts |
harbor.../helm-charts/reloader:2.2.12 |
Summary checklist¶
- [x] Harbor projects
gitlabandquaycreated - [x] Image dry-run shows all 33 correct destinations
- [x] All images pushed —
failed: 0 - [x] Images verified in Harbor (all 6 projects)
- [x] Helm charts dry-run validated
- [x] All 5 charts pushed to
helm-chartsproject - [x] Charts verified in Harbor