# GitOps Workflow (ArgoCD) — Current Practice **This is the actual, current deployment workflow.** Every other doc in this directory (`infra-practice.md`, `core-cli-tools.md`, etc.) describes an older `helmfile apply` + `core iam`/`core secrets` CLI-driven workflow that has been retired. This file exists to be the accurate replacement for "how do I actually deploy/change something" until those older docs get a full rewrite. ## The Loop ``` 1. Edit files under k8s/ 2. Validate locally (see "Validation" below — do not skip this) 3. git add -A && git commit -m "..." && git push 4. ArgoCD (automated sync + selfHeal on almost every Application) picks it up on its next reconciliation cycle (default ~3 min, or immediately if you trigger a refresh) 5. Confirm: kubectl -n argocd get app -o jsonpath='{.status.sync.revision}' matches your new commit hash - not just that status.sync.status says "Synced" (a stale hook resource can hide behind an otherwise-current app, see "ArgoCD Hooks" below) ``` There is no `helmfile apply`, no `core iam create-app`, no manual `helm install` in the current workflow. If you find yourself reaching for any of those, stop — figure out the ArgoCD-native equivalent instead. ## App-of-Apps Structure ``` k8s/argocd/root/homelab-root.yaml # the one Application ArgoCD bootstraps by hand → source: k8s/argocd/apps/ # directory of child Application manifests 00-substrate.yaml # sync-wave 0: cert-manager, ingress-nginx, CRDs 00-secrets.yaml # sync-wave 0: SOPS secrets plugin 05-networking.yaml # sync-wave... etc, ascending 10-storage-observability.yaml 20-logging.yaml 30-security.yaml # iam (Authentik, Vault) 40-data.yaml # CNPG postgres 50-messaging.yaml # Kafka/sqs 60-applications.yaml # end-user workloads (Temporal, Portainer, etc.) ``` Each child `Application` either: - Points at a **remote Helm chart** with a **second git source** (`ref: values`) supplying just the values file — lets you pin the chart version independently while your values live under normal review/history, e.g.: ```yaml spec: sources: - repoURL: https://example.com/helm-charts chart: some-chart targetRevision: "1.2.3" helm: valueFiles: - $values/k8s/applications/foo/foo-values.yaml - repoURL: https://forgejo.riotpiao.com/riotpiao.com/homelab.git targetRevision: main ref: values ``` - Or points at a **plain git directory** of raw manifests, optionally with its own `kustomization.yaml`. `argocd.argoproj.io/sync-wave: "N"` on the Application's own metadata controls ordering — lower N syncs first. Within a single Application, resource-level sync-wave annotations further order that app's own resources. ## Validation Before Every Push Pick whichever applies: ```bash # Plain manifests, no kustomization.yaml in the directory kubectl apply --dry-run=client -f path/to/file.yaml # Directory has a kustomization.yaml - THIS is what ArgoCD actually builds, # a plain `apply --dry-run` on individual files will NOT catch a resource # missing from the resources: allowlist kubectl kustomize path/to/dir/ # Helm-sourced Application - render against the EXACT pinned chart version, # not whatever `helm repo` happens to have cached, and not the chart's # current main-branch example values (schemas can differ between versions) git clone --depth 1 /tmp/chart-check cd /tmp/chart-check && git fetch --tags --depth 1 && git checkout helm dependency build # if the chart has subchart dependencies helm template . --values /path/to/your/values.yaml --namespace ``` ## ArgoCD Hooks (PreSync / PostSync / Sync) Used for one-shot Jobs that need to run at a specific point in an Application's sync (schema migrations, secret-copy jobs, provisioning scripts). Two properties that bite people: 1. **Ordering is PreSync → Sync (normal resources, by sync-wave) → PostSync.** A PreSync-hooked Job that depends on a ServiceAccount/RBAC defined as *plain* (non-hook) resources in the same Application will deadlock — those get created during the Sync phase, which is *after* PreSync. Symptom: the Job sits "Running" indefinitely, `kubectl describe job` shows `FailedCreate ... serviceaccount ... not found` repeating forever. **If your hook needs resources from its own Application, make it PostSync, not PreSync.** 2. **Hooks are not continuously reconciled by `selfHeal`.** A completed or failed hook Job only gets deleted+recreated (per `hook-delete-policy: BeforeHookCreation`) during an *actual new Sync operation* — not from ambient drift detection. If you fix a broken hook's spec (image, RBAC, command) and push, the Application's `status.sync.revision` can update to show "caught up" (because the *normal*, non-hook resources genuinely match) while the *live* hook Job is still running the old broken spec, because no fresh sync operation actually re-executed it. **To force it, in order of preference:** ```bash # 1. Just delete the stuck/failed job - if a legitimate future sync # happens for any other reason, this clears the way for it kubectl -n delete job # 2. If it's stuck "Terminating" (ArgoCD's hook-finalizer blocking # deletion because the operation tracking it is itself stuck): kubectl -n get job -o jsonpath='{.metadata.finalizers}' kubectl -n patch job --type json \ -p '[{"op":"remove","path":"/metadata/finalizers"}]' # 3. If deleting the job alone doesn't trigger a genuinely fresh sync # (check: does a new job appear using your LATEST commit's spec? # compare kubectl -n get job -o jsonpath='{.spec.template.spec.containers[0].image}' # against what your latest commit says), the Application's own # operation state is stuck. Confirm it has no cascade finalizer first # (Applications don't carry one by default - only delete if this is # empty): kubectl -n argocd get app -o jsonpath='{.metadata.finalizers}' # then: kubectl -n argocd delete application kubectl apply -f k8s/argocd/apps/.yaml ``` Step 3 re-reads current git HEAD from scratch and starts a genuinely new operation — this is the reliable way to actually pick up a hook fix when steps 1-2 don't unstick it. ## kustomization.yaml Pitfalls - **`resources:` is an explicit allowlist, not a directory scan.** Add a new manifest file to a directory that has a `kustomization.yaml`, forget to list it under `resources:`, and ArgoCD will silently never apply it — no error, `Synced/Healthy` shown regardless. Always `kubectl kustomize /` locally before pushing to see exactly what will be built. - **A top-level `namespace:` transformer rewrites `metadata.namespace` on every resource in the build**, including RBAC bindings that deliberately target a *different* namespace (e.g. a RoleBinding granting cross- namespace Secret access for a sync job's ServiceAccount). If any manifest in the directory needs to live in a different namespace than the transformer specifies, either remove the transformer (safe if every manifest already sets its own explicit `namespace:`) or move that manifest to its own directory/Application entirely. ## Cross-Namespace Secrets Kubernetes Secrets are strictly namespace-scoped — a Deployment in namespace A cannot reference a Secret living in namespace B via `secretKeyRef`, full stop. If a Secret is generated in one namespace (e.g. CNPG auto-generates DB credentials in its own operator namespace) but a consumer lives in a different namespace, you need an explicit copy mechanism. Pattern used in this repo: a small PostSync-hooked Job (see `k8s/applications/temporal/db-secret-sync/copy-job.yaml` for a worked example) with a dedicated ServiceAccount + ClusterRole + two RoleBindings (one per namespace involved) that reads the source Secret and re-creates it in the target namespace. Give this its own Application/sync-wave (earlier than whatever consumes the copied Secret) rather than folding it into an existing Application that has a namespace-transforming `kustomization.yaml` (see above). ## Common Failure: Everything Shows "Unknown" Sync Status At Once If *every* Application (not just one) suddenly shows `Unknown` sync status simultaneously, check the Application controller's logs for the actual `repoURL` fetch error before assuming something is wrong with any individual app's manifests: ```bash kubectl -n argocd logs argocd-application-controller-0 --tail=50 | grep -i "failed to list refs\|context deadline" ``` Common cause in a homelab with CoreDNS rewriting your git host to the ingress controller for internal traffic: if `repoURL` uses a non-standard port (`http://forgejo.example.com:3000/...`), and CoreDNS rewrites that hostname to the ingress controller service (which only listens on 80/443), every fetch attempt times out. Fix: use the default-port HTTPS URL (`https://forgejo.example.com/...`) so it actually reaches the ingress controller correctly. ## See Also - `CLAUDE.md` § GitOps / ArgoCD Gotchas — condensed version of the above, cross-referenced from the main project reference - Root `TROUBLESHOOTING.md` — generic Kubernetes SRE methodology, still applicable regardless of deployment mechanism