Repo Sanitization — Pushing Dev Repos to the Air-Gap Environment¶
Purpose: a repeatable, auditable process for taking a development repository and pushing only what is relevant and safe to our GitLab (gitlab.cl1.sq4.aegis.internal). Applies to every repo we onboard — ask-core-service is the worked example, but every other service follows the same categories and steps.
Where sanitized repos live
Sanitized repos land in gitlab.cl1.sq4.aegis.internal/modgpt/<repo-name> — the GitLab instance we own and operate. This single GitLab serves two roles simultaneously:
| Role | Who uses it | How |
|---|---|---|
| Cluster-facing | ArgoCD (automated) | Reads helm/ chart + values-ai71-uaen-prod-modgpt.yaml to deploy workloads to the cluster |
| Customer-facing | The customer / ops team (human) | Browses, reviews, and updates deployment config via the GitLab UI or git |
Each application gets its own repo + its own ApplicationSet (not bundled into cluster-addons which is for platform infrastructure). ArgoCD naming follows the traceable pattern: modgpt.<app>.<service> (e.g. modgpt.ask.core-service).
Dev repo (Azure DevOps / GitHub) Our GitLab (gitlab.cl1.sq4.aegis.internal)
───────────────────────────────── ──────────────────────────────────────
ask-core-service-development/ modgpt/ask-core-service
src/ ✗ stripped helm/
tests/ ✗ stripped Chart.yaml
security/ ✗ stripped values.yaml
scripts/…ado ✗ stripped templates/
helm/ ✅ kept environments/
.cruft.json ✗ stripped values-ai71-uaen-prod-modgpt.yaml ← environment-specific
… README.md
CHANGELOG.md
Never push a dev repo directly — always sanitize first
Dev repos contain cloud-provider endpoints, internal author emails, ADO/Azure DevOps SSH keys in template configs, Sonar org keys, ACR image refs, and production environment files. Even @format {env[...]} refs are safe in isolation, but the surrounding config (host names, org IDs, domain names) reveals production infrastructure topology. Strip it before it touches our GitLab.
The principle: push only what the cluster needs to run
The cluster needs one thing from each app repo: the Helm chart (or manifests) to deploy it. Everything else — Python source, tests, CI pipelines, dev tooling, application documentation, ADO scripts — is irrelevant to the cluster and should not be in the cluster-facing repo.
DEV REPO (full) AIR-GAP REPO (sanitized)
──────────────────────────────── ────────────────────────────────
src/ (Python source) ✗
tests/ (test suite) ✗
alembic/ (DB migrations) ✗ ← migrations run inside the app
scripts/ (ADO/dev ops) ✗
docs/ + site/ (MkDocs) ✗
security/ (CI pipelines) ✗
.claude/ (AI tooling) ✗
.cruft.json (ADO template) ✗
sonar-project.properties ✗
pyproject.toml / uv.lock ✗
config/*.toml (app config) ✗ ← baked into the image already
helm/ (chart) ✅ ← the only thing the cluster needs
helm/environments/values-<env> ✅ but strip prod env files, keep non-production
Dockerfile* (image builds) ✅ if we build images in the air-gap environment; otherwise ✗
README.md ✅ useful context
CHANGELOG.md ✅ useful context
.gitignore ✅
What we remove and why
Category 1 — Application source code (src/, tests/, alembic/)¶
| Path | Why removed |
|---|---|
src/ |
The compiled/containerised image is what runs; Kubernetes never touches source. Pushing it exposes business logic unnecessarily. |
tests/ |
Test infrastructure has no meaning in a cluster repo. Adds confusion and misleads about what "should work" in-cluster. |
alembic/ + alembic.ini |
The Dockerfile copies these into the image (COPY alembic/ ./alembic/) so they are available inside the container. The CMD does not run migrations automatically — they are triggered separately via scripts/apply_migrations.sh or a Kubernetes Job/init-container. Either way, the cluster repo never needs the raw migration files: they're in the image. |
Category 2 — Dev tooling and CI metadata¶
| Path | Why removed |
|---|---|
.pre-commit-config.yaml |
Pre-commit hooks are a developer local tool. No meaning in a cluster repo. |
PRE_COMMIT_SETUP.md |
Paired with pre-commit config — irrelevant. |
.coveragerc |
Python test coverage config — irrelevant. |
.tool-versions |
asdf version manager pins for local dev — irrelevant. |
pyproject.toml + uv.lock |
Python package metadata and dependency lock — the image is already built; nobody re-installs packages from the cluster repo. |
requirements-docs.txt |
MkDocs build requirements — irrelevant. |
sonar-project.properties |
Contains sonar.organization=ai71, internal Sonar project key, and the ADO branch name — exposes internal org structure. |
mkdocs.yml + docs/ + site/ |
The app's own developer documentation. Exposing it is harmless but irrelevant; site/ contains built HTML (~10 MB of noise). |
templates/ |
Commitizen changelog Jinja templates — dev tooling. |
.cruft.json |
High risk — contains the ADO SSH URL (git@ssh.dev.azure.com:v3/VentureOne/AI71/ai71_python_template), internal author name and email, and the Azure DevOps project structure. Remove unconditionally. |
Category 3 — CI/CD and security pipelines¶
| Path | Why removed |
|---|---|
security/ |
Contains Azure DevOps pipeline YAML (autoupdate-pipeline.yml, security-pipeline.yml, gitleaks-pipeline.yml, zap-framework.yml). Exposes internal CI/CD structure; this environment doesn't use ADO. |
scripts/download_env_from_ado.sh |
Downloads secrets from Azure DevOps — leaks ADO org name, project path, variable group names. |
scripts/upload_env_to_ado.sh |
Same — writes secrets back to ADO. |
scripts/fetch_sample_dump.py |
Fetches a prod DB dump — exposes prod DB access pattern. |
scripts/publish_gitlab_release.py |
Publishes to the upstream GitLab.com org (ai71) — leaks internal release process. |
scripts/release_config_changes.py |
Same concern. |
scripts/run_integration_tests.sh |
References internal service endpoints. |
scripts/test_bulk_ops_e2e.sh |
Same. |
Scripts we CAN keep
scripts/apply_migrations.sh, scripts/init_db.sh, scripts/make_migrations.sh, scripts/verify_migrations.py, scripts/validate_env.py — these are cluster-relevant DB bootstrap helpers. Keep them if the deployment uses them; otherwise they're still harmless.
Category 4 — Internal tooling metadata¶
| Path | Why removed |
|---|---|
.claude/ + CLAUDE.md (repo root) |
AI assistant configuration and internal notes about the codebase. Not for customer/air-gap. |
helm/CLAUDE.md |
Same — remove from inside the chart too. |
.env.example |
Key names are safe (they're @format {env[...]} references), but the surrounding comments reveal secret categories. Replace with a clean environment-specific version or omit. |
Category 5 — Helm chart: production environment files¶
The helm/environments/ directory contains one values file per production deployment:
| File | Why removed |
|---|---|
values-ai71-uaen-prod-mufti.yaml |
Azure Postgres host, Azure ACR image refs, Azure Redis host (redis.azure.net), Azure storage account name, real external domains (sso.ask.mufti.ai71services.ai), Azure ingress class (webapprouting.kubernetes.azure.com), real org/user IDs in seedData. |
values-ai71-uaen-stg-ask71.yaml |
Azure Postgres host, staging domain names, Azure CDN URL, Azure storage account. |
values-ai71-uaen-dev-ask71.yaml |
Dev environment Azure endpoints. |
values-ai71-mec1-prod-mufti.yaml |
MEC region production endpoints. |
values-ai71-mec1-stg-ask71.yaml |
MEC region staging endpoints. |
values-ai71-pp-auh1-prod-ask.yaml |
Abu Dhabi production endpoints. |
All of these are removed. We create a new values-ai71-uaen-prod-modgpt.yaml that references our CNPG Postgres, Harbor registry, internal domains, and Vault-sourced secrets.
Category 6 — Helm values.yaml: cloud-specific defaults to override (not remove)¶
values.yaml stays (it's the chart's own defaults), but the air-gap values file overrides these Azure-specific defaults:
| Default value | Issue | Air-gap override |
|---|---|---|
image.repository: ai71uaenprodask71acr01.azurecr.io/core-service |
Unreachable ACR | harbor.cl1.sq4.aegis.internal/ask/core-service |
service.type: LoadBalancer |
No cloud LB | ClusterIP |
nodeSelector: node.kubernetes.io/role: ask71 |
Label doesn't exist on the nodes → pod Pending | {} (clear it) |
tolerations: [{key: node.kubernetes.io/role, value: ask71}] |
Paired with above | [] (clear it) |
storage.type: azure + Azure account refs |
No Azure | stub/Ceph S3 |
| Worker image repos (ACR) | Unreachable | Harbor registry |
Step-by-step process (per repo)
Step 0 — Understand the repo¶
Before touching anything, map it: what does it deploy? What's in helm/ or equivalent? Which env files exist? Run:
find . -maxdepth 2 -not -path "./.git/*" | sort # full structure
ls helm/environments/ 2>/dev/null # env files
grep -rn "azure\|acr\|ado\|ssh.dev.azure" helm/ 2>/dev/null | grep -v ".git" # cloud refs
Step 1 — Create the sanitized repo in GitLab¶
export VAULT_ADDR=https://vault.cl1.sq4.aegis.internal
GLT=$(vault kv get -field=token secret/gitlab/migration-token)
# get the modgpt group ID
GID=$(curl -sk -H "PRIVATE-TOKEN: $GLT" https://gitlab.cl1.sq4.aegis.internal/api/v4/groups/modgpt \
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
# create (idempotent)
curl -sk --request POST -H "PRIVATE-TOKEN: $GLT" \
"https://gitlab.cl1.sq4.aegis.internal/api/v4/projects" \
--form "name=<repo-name>" \
--form "namespace_id=$GID" \
--form "visibility=private" | python3 -c "import sys,json;d=json.load(sys.stdin);print('id:',d.get('id','error:',d))"
Step 2 — Create a sanitized working copy (never modify the dev repo)¶
Always work from a copy. The dev repo is the source of truth; changes go back there via the normal dev process.
Step 3 — Remove irrelevant/sensitive content¶
Apply the category table above. For ask-core-service:
# Category 1 — source code
rm -rf src/ tests/ alembic/ alembic.ini
# Category 2 — dev tooling
rm -f .pre-commit-config.yaml PRE_COMMIT_SETUP.md .coveragerc .tool-versions
rm -f pyproject.toml uv.lock requirements-docs.txt sonar-project.properties
rm -rf docs/ site/ templates/ mkdocs.yml .env.example
# Category 3 — CI/CD pipelines and ADO scripts
rm -rf security/
rm -f scripts/download_env_from_ado.sh scripts/upload_env_to_ado.sh \
scripts/fetch_sample_dump.py scripts/publish_gitlab_release.py \
scripts/release_config_changes.py scripts/run_integration_tests.sh \
scripts/test_bulk_ops_e2e.sh
# Category 4 — internal tooling metadata
rm -rf .claude/
rm -f CLAUDE.md helm/CLAUDE.md .cruft.json
# Category 5 — production environment files (ALL of them)
rm -f helm/environments/values-ai71-*.yaml
# Category 6 — Dockerfiles (only needed if building images in the air-gap environment; omit if mirroring)
# rm -f Dockerfile Dockerfile.* config/ ← decide based on whether you build or mirror
Verify before committing
After removal, run a quick scan for leaked internal refs:
Zero results expected. Any hit = investigate before pushing.Step 4 — Create the air-gap values file¶
Replace all production env files with a single helm/environments/values-ai71-uaen-prod-modgpt.yaml. Use the production-mufti file as the structural template (it has the most complete zitadel + postgresInit config), but substitute every external value:
# helm/environments/values-ai71-uaen-prod-modgpt.yaml
# Production environment overrides — Ceph/CNPG, Harbor registry, internal domains
# Secrets are NEVER in this file — they come from Vault via VSO
extraEnv:
- name: APP__ENV
value: "prod"
# --- Image overrides (ACR → Harbor registry) ---
image:
repository: harbor.cl1.sq4.aegis.internal/ask/core-service
tag: "<tag>" # pin; never use latest in GitOps
pullPolicy: IfNotPresent
imagePullSecrets:
- name: harbor-pull-secret
# --- Remove cloud scheduling constraints ---
nodeSelector: {}
tolerations: []
# --- Service: ClusterIP (no cloud LB) ---
service:
type: ClusterIP
port: 80
# --- Ingress ---
ingress:
enabled: true
className: rke2-nginx
annotations:
cert-manager.io/cluster-issuer: internal-ca-issuer
hosts:
- host: core-service.ask.mod.auh1.dev.dir
paths: [{ path: /, pathType: Prefix }]
tls:
- secretName: core-service-tls
hosts: [core-service.ask.mod.auh1.dev.dir]
# --- Workers: off for v1 ---
bulkOperationsWorker: { enabled: false }
billingMeteringWorker: { enabled: false }
cronSchedulerWorker: { enabled: false }
jobRunWorker: { enabled: false }
# --- Storage: stub (Ceph S3 wired in v2) ---
envConf:
configToml: |
[storage]
type = "stub"
# --- Postgres DB init (runs as PreSync ArgoCD hook) ---
postgresInit:
enabled: true
namespace: ask
connection:
host: ask-pg-rw.ask.svc.cluster.local # CNPG service
port: "5432"
adminUsername: postgres
db:
name: core-service
user: core-service
secrets:
adminPasswordSecretName: zitadel-psql-secret # shared PG admin — created by VSO
adminPasswordKey: db-admin-password
userPasswordSecretName: core-service-secret
userPasswordKey: DATABASE__PASSWORD
job:
image: harbor.cl1.sq4.aegis.internal/dockerhub/postgres
tag: "16-alpine"
backoffLimit: 4
ttlSecondsAfterFinished: 300
pgSslMode: disable # CNPG internal; TLS configured at CNPG level
argocd:
enabled: true
connectionConfigMapAnnotations:
argocd.argoproj.io/hook: PreSync
argocd.argoproj.io/sync-wave: "-2"
argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
jobAnnotations:
argocd.argoproj.io/hook: PreSync
argocd.argoproj.io/sync-wave: "-1"
argocd.argoproj.io/hook-delete-policy: BeforeHookCreation,HookSucceeded
# --- Zitadel (enabled; vendored in Chart.yaml) ---
zitadel:
enabled: true
zitadel:
replicaCount: 1 # v1: 1 replica
fullnameOverride: ask-core-service-zitadel
tolerations: []
nodeSelector: {}
service: { type: ClusterIP, port: 8080 }
ingress:
enabled: true
className: rke2-nginx
annotations:
cert-manager.io/cluster-issuer: internal-ca-issuer
nginx.ingress.kubernetes.io/backend-protocol: "GRPC"
hosts:
- host: sso.ask.mod.auh1.dev.dir
paths: [{ path: /, pathType: Prefix }]
tls:
- secretName: zitadel-tls
hosts: [sso.ask.mod.auh1.dev.dir]
zitadel:
masterkeySecretName: zitadel-secret-kv
configmapConfig:
ExternalSecure: true
ExternalDomain: sso.ask.mod.auh1.dev.dir
TLS: { Enabled: false }
Database:
Postgres:
Host: ask-pg-rw.ask.svc.cluster.local
Port: 5432
Database: zitadel
User: { Username: zitadel, SSL: { Mode: disable } }
Admin: { Username: postgres, SSL: { Mode: disable } }
env:
- name: ZITADEL_DATABASE_POSTGRES_USER_PASSWORD
valueFrom: { secretKeyRef: { name: zitadel-secret-kv, key: DATABASE__PASSWORD } }
- name: ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD
valueFrom: { secretKeyRef: { name: zitadel-psql-secret, key: db-admin-password } }
- name: ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED
value: "false"
- name: ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD
valueFrom: { secretKeyRef: { name: zitadel-secret-kv, key: ADMIN_PASSWORD } }
login:
enabled: true
replicaCount: 1
fullnameOverride: ask-core-service-zitadel-login
ingress:
enabled: true
className: rke2-nginx
hosts:
- host: sso.ask.mod.auh1.dev.dir
paths: [{ path: /ui/v2/login, pathType: Prefix }]
zitadelPostgresInit:
enabled: true
namespace: ask
connection:
host: ask-pg-rw.ask.svc.cluster.local
port: "5432"
adminUsername: postgres
secrets:
adminPasswordSecretName: zitadel-psql-secret
adminPasswordKey: db-admin-password
userPasswordSecretName: zitadel-secret-kv
userPasswordKey: DATABASE__PASSWORD
job:
image: harbor.cl1.sq4.aegis.internal/dockerhub/postgres
tag: "16-alpine"
backoffLimit: 4
ttlSecondsAfterFinished: 300
pgSslMode: disable
argocd:
enabled: true
connectionConfigMapAnnotations:
argocd.argoproj.io/hook: PreSync
argocd.argoproj.io/sync-wave: "-2"
argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
jobAnnotations:
argocd.argoproj.io/hook: PreSync
argocd.argoproj.io/sync-wave: "-1"
argocd.argoproj.io/hook-delete-policy: BeforeHookCreation,HookSucceeded
Step 5 — Init git and push¶
cd /tmp/<repo-name>-airgap
git init && git checkout -b main
git add .
git status # review one more time before committing
git commit -m "feat: initial sanitized air-gap deployment repo"
GLT=$(vault kv get -field=token secret/gitlab/migration-token)
git remote add origin "https://root:${GLT}@gitlab.cl1.sq4.aegis.internal/modgpt/<repo-name>.git"
git -c http.sslVerify=false push -u origin main
Step 6 — Verify the push is clean¶
# confirm no production refs landed in GitLab
curl -sk -H "PRIVATE-TOKEN: $GLT" \
"https://gitlab.cl1.sq4.aegis.internal/api/v4/projects/modgpt%2F<repo-name>/repository/tree?recursive=true" \
| python3 -c "import sys,json;[print(f['path']) for f in json.load(sys.stdin)]"
# visually confirm: only helm/, README.md, CHANGELOG.md, .gitignore present
Per-repo checklist
Copy this for each repo. Check every box before git push.
PRE-PUSH SANITIZATION CHECKLIST — <repo-name> date: ____
REMOVED:
[ ] src/ (application source code)
[ ] tests/ (test suite)
[ ] alembic/ + alembic.ini (DB migrations)
[ ] pyproject.toml + uv.lock / requirements*.txt
[ ] .pre-commit-config.yaml + PRE_COMMIT_SETUP.md + .coveragerc + .tool-versions
[ ] mkdocs.yml + docs/ + site/ + templates/
[ ] sonar-project.properties
[ ] .cruft.json
[ ] security/ (CI pipeline YAML)
[ ] scripts/download_env_from_ado.sh + upload_env_to_ado.sh
[ ] scripts/fetch_sample_dump.py + publish_gitlab_release.py + release_config_changes.py
[ ] scripts/run_integration_tests.sh + test_bulk_ops_e2e.sh
[ ] .claude/ + CLAUDE.md (root) + helm/CLAUDE.md
[ ] .env.example
[ ] helm/environments/values-ai71-*.yaml (ALL production env files)
ADDED:
[ ] helm/environments/values-ai71-uaen-prod-modgpt.yaml (air-gap override values, no secrets)
VERIFIED CLEAN (grep scan output = zero hits):
[ ] grep -rn "azure.com\|azurecr\.io\|ssh.dev.azure\|ai71\.ai\|sonar\.organization\|ai71_python_template" .
DECISIONS RECORDED:
[ ] Dockerfiles: included ☐ excluded ☐ reason: _______________
[ ] config/*.toml: included ☐ excluded ☐ reason: _______________
[ ] scripts remaining: ______________________________
Keeping the air-gap repo in sync with upstream changes
When the dev team ships a new version:
- Re-run Step 2 (fresh copy of the updated dev repo).
- Re-apply Step 3 removals.
- Do not overwrite
helm/environments/values-ai71-uaen-prod-modgpt.yaml— it's non-production. Compare the updatedhelm/values.yamlfor any new keys that need air-gap overrides. - Bump the image
taginvalues-ai71-uaen-prod-modgpt.yamlto the new version. - Commit, push. ArgoCD picks it up within ~3 min.
Repo inventory
| Repo | Status | Air-gap project | Notes |
|---|---|---|---|
ask-core-service |
🔄 In progress | modgpt/ask-core-service |
Worked example above |
| (repo 2) | ⬜ Pending | ||
| (repo 3) | ⬜ Pending | ||
| (repo 4) | ⬜ Pending | ||
| (repo 5) | ⬜ Pending | ||
| (repo 6) | ⬜ Pending |
Migration toggle in values-ai71-uaen-prod-modgpt.yaml¶
The migration Job is off by default in values.yaml (safe for local dev / first install before a DB exists). Enable it in values-ai71-uaen-prod-modgpt.yaml so every ArgoCD sync automatically runs alembic upgrade head as a PreSync hook at sync-wave 0 — after the DB init Jobs (wave −1) and before any workload starts:
# helm/environments/values-ai71-uaen-prod-modgpt.yaml (append to the file)
# ─── Migrations ─────────────────────────────────────────────────────────────
# enabled: true → PreSync Job runs alembic upgrade head on every sync
# enabled: false → no Job; use only for emergency rollbacks (manual step)
migrations:
enabled: true
Wave ordering enforced by the chart:
wave -2 → zitadelPostgresInit ConfigMaps / postgresInit ConfigMaps
wave -1 → zitadelPostgresInit Job + postgresInit Job (DB + roles created)
wave 0 → migrations Job (alembic upgrade head — schema applied)
wave 5 → zitadel Deployment, core-service Deployment, … (workloads start)
Disable the toggle (enabled: false) only during an emergency rollback where you want to redeploy a previous image without re-running migrations. In all other cases leave it true.
➡️ Related: Core Service Deployment Runbook · Add an Add-on Application · Create a New App Group