Automate Cluster Deployments with Flux #46
Labels
No labels
blocked
bug
duplicate
effort
high
effort
low
effort
medium
effort
minimal
enhancement
help wanted
invalid
pr
feedback
pr
reviewing
question
status
backlog
status
document
status
done
status
implement
status
pending
status
planning
status
reviewing
waiting
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
tepichord/milner#46
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What GitOps actually is
Your desired state lives in Git, and a controller running inside the cluster continuously reads Git and makes the cluster match it. The canonical four properties (CNCF
OpenGitOps): declarative, versioned and immutable, pulled automatically, continuously reconciled.
The load-bearing word is pulled. CI running kubectl apply is not GitOps — that's still push. When the cluster pulls:
And the reason it works for Kubernetes specifically: Kubernetes already has a declarative API and a reconciliation loop. GitOps doesn't add a second desired-state system — it
only supplies the desired state. That's precisely the redundancy Terraform introduces, which is why GitOps beats Terraform here and not in general.
It is not a wholesale Terraform replacement
This is the part to be clear about: GitOps only applies where an in-cluster controller exists. Nothing inside the cluster can create a libvirt domain or format a host disk. So
it's not a swap — it's a split by layer:
┌───────────────────────────────────────────┬─────────────────────────────────────────────┐
│ Root │ Owner after │
├───────────────────────────────────────────┼─────────────────────────────────────────────┤
│ control-plane/, worker/, data/ │ Terraform (or a script) — unchanged │
├───────────────────────────────────────────┼─────────────────────────────────────────────┤
│ cloudflare/ │ Terraform — unchanged; its genuine best fit │
├───────────────────────────────────────────┼─────────────────────────────────────────────┤
│ storage/, vault/, woodpecker/, pocket-id/ │ Flux │
├───────────────────────────────────────────┼─────────────────────────────────────────────┤
│ evolved-npcs/ + future apps │ Flux │
└───────────────────────────────────────────┴─────────────────────────────────────────────┘
The tree you'd end up with
Each app directory is a kustomization.yaml plus manifests. For the charts you already deploy, the four Terraform roots collapse into HelmRelease objects — same chart, same
values.yaml, roughly 40 lines of YAML instead of 200 lines of HCL. Only flux-system/ is special: Flux bootstraps it, then manages its own config from Git like everything else.
One deploy, before and after
Today, bumping Vault's chart: edit vault/main.tf → terraform -chdir=vault plan → apply, from a laptop on the tailnet holding a kubeconfig.
GitOps: edit the HelmRelease → commit → push. Flux notices within the sync interval and applies. Rollback is git revert. And drift is actively corrected — if you kubectl -n
vault edit deploy/vault, Flux reverts it. Under Terraform that edit is invisible until the next plan, and then it shows up as a change you didn't make.
What you actually give up
gate yourself in the pipeline. This is the real loss.
Two gotchas
PLAN.md — so VSO pulls from Vault and creates the Secret at runtime while Git holds only a reference. Flux's alternative is SOPS+age, but you already run Vault, so VSO is
the natural fit.
config and self-manages after that), or a small gitops/ Terraform root that installs the controllers and then stops.
The one-line summary: Terraform keeps the machine; Flux keeps the cluster.
(A footnote worth knowing: external-dns with the Cloudflare provider can generate DNS records from your Ingresses, which would move part of cloudflare/ into GitOps too. Not
necessary now — the Tunnel and account-level objects stay in Terraform regardless.)
Naina:
Overview
Move ownership of the four in-cluster Terraform roots (
storage/,vault/,woodpecker/,pocket-id/) to Flux v2 GitOps undergitops/clusters/milner/, whilecontrol-plane/,worker/,data/andcloudflare/stay in Terraform.Verified (in-repo): all four roots exist and are listed in the AGENTS repo map — AGENTS.md#L22flux bootstrap gitis the single one-time push; after that every cluster change is a commit and the old roots are removed from Terraform state only once Flux reports Healthy. Secrets move from hand-runkubectl create secret(OPERATIONS §7c) to Vault Secrets Operator, so git holds only Vault references.Verified (in-repo): §7c runskubectl -n woodpecker create secret generic— OPERATIONS.md#L771Technical Approach
buildInputs— flake.nix#L21kustomize.toolkit.fluxcd.io/v1andhelm.toolkit.fluxcd.io/v2are the GA APIs from Flux v2.3 — Flux 2.3 GAspec.pathdependsOninfrastructure./gitops/clusters/milner/infrastructureingress-proxiesvault./gitops/clusters/milner/vaultinfrastructuresecrets./gitops/clusters/milner/secretsvaultapps./gitops/clusters/milner/appssecretsprivileged— vault/main.tf#L75, woodpeckerbaseline— woodpecker/main.tf#L79, pocket-idbaseline— pocket-id/main.tf#L81pod-security.kubernetes.io/enforcelocal-path-storageprivilegedvaultprivilegedwoodpeckerbaselinewoodpecker-runtimeprivilegedpocket-idbaselinevault-secrets-operatorbaselinestorage/into an OCIRepository + HelmRelease (chart version pinned exactly)Verified (in-repo):storage/already pullsoci://ghcr.io/rancher/local-path-provisioner/chartsat0.0.37— storage/main.tf#L88; the OCIlayerSelectorneeds the Helm chart media type — Flux HelmRelease guidevalues.yamlfiles as-is and expose each as a ConfigMapingress-proxiesProxyGroup out ofvault/into infrastructurespec.chart.spec.sourceRef(HTTP repo, not OCI)Verified (in-repo): Vault is installed from the HTTP repohttps://helm.releases.hashicorp.com, chartvault0.34.1— vault/main.tf#L89hashicorp/vault-secrets-operatorchart is at1.5.0— VSO installation; the repo already uses thesecrets.hashicorp.com/v1beta1CRD — ARCHITECTURE.md#L200hashicorp/vault-secrets-operatorpinned to1.5.0;VaultConnectionpoints athttp://vault.vault.svc.cluster.local:8200.VaultAuth/VaultStaticSecretper Secret:woodpecker/{forgejo-oauth,grpc}andpocket-id/{encryption-key}.kubectl create secret; Vault paths already exist from §7b/§5a.Verified (in-repo):secret/woodpecker/forgejo-oauth— OPERATIONS.md#L754,secret/pocket-id/encryption-key— OPERATIONS.md#L400oci://ghcr.io/woodpecker-ci/helm3.7.3— woodpecker/main.tf#L101;oci://ghcr.io/hobbit44/pocket-id-helm2.4.0— pocket-id/main.tf#L135oci://ghcr.io/woodpecker-ci/helmwoodpecker3.7.3, namespacewoodpecker, Ingress unchanged.oci://ghcr.io/hobbit44/pocket-id-helmpocket-id2.4.0, namespacepocket-id.pocket-id/main.tf(persistent, 5Gi, no bind wait) — no chart change needed.Verified (in-repo): the claim is onpersistentwith5Giandwait_until_bound = false— pocket-id/main.tf#L101, asserted by tests/test_static.py#L246flux bootstrap gitaccepts--url,--branch,--pathand--private-key-file— flux bootstrap gitflux bootstrapwritesgitops/clusters/milner/flux-system/(committed, reviewed); patch controller node selectors there viagitops/clusters/milner/flux-system/kustomization.yaml:Verified (external): bootstrap generatesgotk-components.yaml,gotk-sync.yamlandkustomization.yamlunder the target path — Bootstrap customizationkubernetes_namespace_v1.vaultvault/main.tf#L71,helm_release.vault#L87,kubernetes_manifest.vault_proxy_group#L107,kubernetes_ingress_v1.vault#L121state rmbefore deleting the root, or a laterterraform destroywould delete Flux-owned objects.tests/test_static.pytest_vault_values_keep_data_mount_under_servergitops/clusters/milner/vault/values.yamltest_pocket_id_persists_to_the_durable_storage_class.../apps/pocket-id/values.yaml+persistentvolumeclaim.yamltest_pocket_id_external_url_matches_ingress_magicdns_name.../apps/pocket-id/ingress.yamltest_funnel_is_retired_from_the_woodpecker_root.../apps/woodpecker/ingress.yamltest_woodpecker_webhook_host_matches_cloudflare_routeghcr.io/fluxcd/flux-cli:v2.4.0publishes alinux/arm64manifest — flux-cli package; thewoodpecker-runtimenamespace already exists — woodpecker/main.tf#L84flux diff kustomization <name>needs a kubeconfig, so it stays a manual laptop-side pre-merge check documented in OPERATIONS (exit0= no drift,1= diff,>1= error) — CI keeps holding no cluster credentials, per the issue.Verified (external): documented exit status0no differences,1differences,>1error — flux diff kustomizationflux diff, VaultAuth k8s-auth enablement).Implementation Details
Created
gitops/bootstrap.shflux bootstrap gitwrappergitops/clusters/milner/{infrastructure,vault,secrets,apps}.yamlgitops/clusters/milner/infrastructure/ingress-proxiesgitops/clusters/milner/vault/values.yamlgitops/clusters/milner/secrets/gitops/clusters/milner/apps/{woodpecker,pocket-id,evolved-npcs}/gitops/clusters/milner/flux-system/tests/test_gitops.py.woodpecker.yamlModified:
flake.nix(fluxcd,kubeconform),tests/test_static.py(repoint guards, drop the four roots fromtest_terraform_fmt/test_yamllint),tests/test_terraform.py(drop the four roots),README.md,AGENTS.md,OPERATIONS.md,.env.example(deploy-key path only if needed).Verified (in-repo):test_terraform_fmt/test_yamllintcurrently reference the roots — tests/test_static.py#L22;tests/test_terraform.pyhas per-root jobs — #L46Deleted after handoff:
storage/,vault/,woodpecker/,pocket-id/(Terraform roots incl. tests/fixtures).Dependencies / constraints
0.0.37, vault0.34.1, woodpecker3.7.3, pocket-id2.4.0, VSO1.5.0.Verified (in-repo + external): storage/main.tf#L88, vault/main.tf#L91, woodpecker/main.tf#L101, pocket-id/main.tf#L135; VSO1.5.0per Hashicorp docsfluxcontrollers and CI images included.nodeSelector: {node-role: worker}, including Flux controllers.Verified (in-repo): present in the existing values — vault/values.yaml#L89, woodpecker/values.yaml#L24, pocket-id/values.yaml#L68kubectl create secretsteps retired.vaultKustomization useswait: true, so a sealed Vault blockssecrets/appsuntil §4c unseal runs — intentional, documented. unverified: depends on live reconcile behavior of the sealed StatefulSet; not confirmable offline.auth/kubernetesenabled once (OPERATIONS); static-tokenVaultAuthis the fallback.Verified (external): the Kubernetes auth method must be enabled and configured in Vault — Vault Kubernetes authOut-of-scope notes
ingress-proxiesProxyGroup moves to Flux.apps/evolved-npcs/ships as a README + empty kustomization, not wired intoapps/kustomization.yaml; no KEDA/RabbitMQ workloads here.external-dnsare not touched.Acceptance Criteria
gitops/bootstrap.shrunsflux bootstrap gitsuccessfully;flux get kustomizations -Ashowsflux-system,infrastructure,vault,secrets,appsallReady.kubectl -n pocket-id delete svc pocket-idis recreated by Flux within one interval.terraform state rmhas been run for every migrated resource andstorage/,vault/,woodpecker/,pocket-id/are deleted;terraform -chdir=control-plane plan,worker,data,cloudflarestill plan clean.git grepfinds no Vault values / tokens); Woodpecker and Pocket ID start without the OPERATIONS §7ckubectl create secretsteps.tests/test_gitops.pypasses, andpython -m pytest tests/ -vis green..woodpecker.yaml) fails on a deliberately broken manifest and passes on the committed tree.terraform -chdir=vault applyinstructions remain.Testing Plan
tests/test_gitops.py:kustomize buildevery Flux path; assert every namespace carries an explicit PSAenforcelabel; assert everyHelmReleaseishelm.toolkit.fluxcd.io/v2with a pinned version; assert noSecrethas literaldata/stringData.server:, Pocket ID PVC onpersistentwith/app/datamount, Pocket IDappUrlvs Ingresstls.hosts, Woodpecker webhook host == published Cloudflare route, notailscale.com/funnel.flux build kustomization <name> --dry-runfor all four layers +kubeconform -strict -ignore-missing-schemas.flux diff kustomization apps --path=./gitops/clusters/milner/appsreturns0on a clean tree, non-zero-but-not-error when a manifest changes.deploy/vaultand confirm Flux reverts it.flux bootstrap→ unseal Vault →flux get helmreleases -Aall Healthy.Related Issues/PRs
PLAN.mdtarget architecture (VSO, Woodpecker CI/CD),ARCHITECTURE.md§6 (VSO for evolved-npcs)Research Findings
flux bootstrap gitis Flux's recommended install path and is idempotent; Forgejo is Gitea-compatible, soflux bootstrap gitea --token-authalso works but needs a PAT (installation, gitea).helm.toolkit.fluxcd.io/v2,source.toolkit.fluxcd.io/v1,kustomize.toolkit.fluxcd.io/v1;v1beta2examples are stale (v2.3 GA)..spec.chartRefreferencesOCIRepository/HelmChart; HTTP repos keepspec.chart.spec.sourceRefwith aHelmRepository(helmreleases guide).dependsOn(ready-gated, not apply-gated) plushealthChecks/wait: true; Flux already applies Namespaces before namespaced resources, so PSA labels land before pods (kustomizations).1.5.0, CRDsecrets.hashicorp.com/v1beta1, andVaultStaticSecretsyncs Vault paths into native Secrets (VSO install).flux diff kustomizationexits0/1/>1(no diff / diff / error) and needs cluster access;flux build kustomization --dry-runrenders offline (flux diff).flux bootstrapgenerates and commitsflux-system/(gotk-components.yaml,gotk-sync.yaml,kustomization.yaml), which is the patch point for controller node selectors (bootstrap customization).Pre-Mortem
The Terraform→Flux handoff runs out of order and destroys live objects instead of transferring them. Four roots must each have
terraform state rmrun beforegit rm -rdeletes the directory, and any root still owning a namespace in state will delete that namespace — taking the Pocket ID PVC and the local-path StorageClasses with it — on the nextterraform destroy. The plan documents this ordering in prose but adds no automated guard (e.g. a pre-destroy check or a test asserting the roots are gone from state), so a forgottenstate rmis only discovered when data is already deleted.Flux fails to adopt the existing Helm releases, so the first reconcile errors or replaces workloads. Flux derives the release name and namespace from the
HelmReleaseobject and diffs its rendered values against the live release, so any divergence from what Terraform installed — re-expressing values throughvaluesFromConfigMaps instead offile("values.yaml"), a renamed Pocket ID PVC (pocket_id_data), or a namespace/PSA difference — surfaces only during the live cutover. Because the acceptance criterion requires only that Flux reportsReady, an unintended upgrade or reinstall can pass while Woodpecker and Vault are briefly unavailable.The
dependsOn/wait: truechain stalls behind manual, out-of-band steps.infrastructurestill depends on the imperatively-installed Tailscale operator for theProxyGroupCRD, andvaultiswait: truebehind a Vault that must be unsealed by hand, sosecretsandappsnever becomeReadyif either prerequisite is skipped. This reproduces on every cluster rebuild and on any Vault pod restart, leaving the cluster serving stale manifests with no automatic recovery.