Create staging builds to validate Flux deploys #49
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#49
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?
One-time prerequisites
Put the CI identity where the pipeline pods actually run. Woodpecker's agent sets WOODPECKER_BACKEND_K8S_NAMESPACE: woodpecker-runtime, so pipeline pods land there, and a
pod's serviceAccountName must resolve in its own namespace. So create preview-deployer in woodpecker-runtime, and grant it RoleBindings in the target namespaces — a
RoleBinding may reference an SA from another namespace, so cross-namespace binding works even though the SA can't move.
Scope that identity tightly. ClusterRole granting only namespaces: [get,list,create,delete] (namespace names can't be wildcarded in RBAC, so keep the cluster-scoped grant
to that one resource), plus a Role in preview-system covering gitrepositories, kustomizations, and configmaps. No write verbs anywhere else. The design point: CI creates
intent, Flux applies workloads — so CI never needs write access to apps or the platform namespaces.
Attach the identity to the pipeline. Preferred: backend_options.kubernetes.serviceAccountName: preview-deployer in .woodpecker.yaml (confirm the field name for your
Woodpecker version). Fallback: mint a token Secret for the SA, build a kubeconfig from it, store that as a Woodpecker repo secret, and use from_secret. The SA route avoids
exporting a cluster credential entirely, which is why it's preferred.
Add a reaper before you add previews. A CronJob in preview-system that deletes any preview Kustomization/GitRepository/Namespace whose milner.dev/preview-expires annotation
is in the past. Teardown must not depend on a PR-close webhook arriving — Forgejo being off-tailnet makes that a real failure mode.
Repo layout
Make apps/base environment-agnostic. No hardcoded namespace, and placeholders for everything that differs: ${registry}, ${image_tag}, ${domain}, ${replicas},
${storage_class}. This is what lets one manifest tree serve prod and previews without forking.
Add apps/prod as a Kustomization with postBuild.substituteFrom pointing at a cluster-config-prod ConfigMap. That Kustomization is now the unit flux build renders and flux
diff diffs — so keeping all env variance in substitution, not in patches, is what keeps the pipeline simple.
Add apps/preview as an overlay that de-fangs side effects: PVC storageClassName: ephemeral (your Delete tier, so teardown leaves no retained PV on the data disk), the
Tailscale Ingress removed (a preview should not mint tailnet nodes), and replicas: 1.
L0 — offline render
Render every Kustomization the PR touches. flux build kustomization --path=./apps/... --kustomization-file= for each, plus a plain kustomize build apps/prod.
This catches bad patches, malformed Kustomizations, and undefined substitution variables with no cluster involved.
Note the gap, because it will bite you. flux build renders Kustomizations; it does not render HelmRelease chart output. If the app layer uses HelmReleases, add flux-local
build (third-party) for offline chart rendering — otherwise L0 silently validates nothing about the chart.
L1 — schema and policy
Run kubeconform with a CRD schema location. The default schema set has no Flux, KEDA, or VSO types, so without pointing at the CRDs catalog every custom resource is
skipped rather than checked — a passing L1 that means nothing. Use -strict so unknown fields fail instead of being ignored.
Keep policy to two or three rules, not a framework. conftest with checks for no :latest tag, resource limits present, and no privileged containers. Anything more elaborate
than that will be bypassed within a month at this scale.
L2 — server-side dry run (the highest-value gate)
Diff each touched Kustomization against the live API server. flux diff kustomization apps-prod --path=./apps/prod, or the explicit fallback kustomize build apps/prod |
kubectl diff -f -. This runs through real CRDs, real admission, real PSA labels, and Kubernetes' immutable-field rules, and persists nothing — it's the direct answer to
"will this work before we deploy it."
Accept the RBAC consequence deliberately. Server-side dry-run authorizes as an apply, so the CI identity needs namespaced write Roles in the namespaces it diffs. That's
namespaced-only and cannot escalate to cluster scope, so it's acceptable — but if you'd rather not, run L2 for prod from a maintainer's dev shell and keep CI at L0+L1 for
prod while L2 runs freely against preview-system.
L3 — real Flux preview
Build and push the PR's image first, tagged by commit SHA. Otherwise you preview the previous image and learn nothing. With the registry still deferred, L3 currently
validates manifest wiring and Flux reconciliation only.
Have CI create four objects, then let Flux do the work. Namespace preview-, ConfigMap preview--values, GitRepository milner-pr- (branch = PR head, reusing the
main repo's secretRef), and the Kustomization:
Copy prod's PSA labels onto the preview namespace. An unlabeled namespace defaults to privileged, so it would happily admit exactly what prod rejects — the preview would
pass while the deploy fails. pod-security.kubernetes.io/enforce: baseline must be set at namespace creation.
Wait on healthChecks, not on pods existing. kubectl -n preview-system wait --for=condition=Ready kustomization/milner-pr-123 --timeout=10m, and on failure dump kubectl
describe kustomization, the namespace events, and container logs. The health check is the actual assertion that Flux deployed it; a Running pod is not.
Give previews their own Vault path, or none at all. If the app syncs secrets through VSO, the preview namespace needs its own Vault role bound to its ServiceAccount, or
pods hang in ContainerCreating and you'll blame Flux. Cheaper alternative: have the preview overlay reference a pre-created Secret so previews never talk to Vault.
Cap the blast radius, because you have none structurally. Previews share the single 20 GB worker with production, and Flannel doesn't enforce NetworkPolicy, so namespace
boundaries are bookkeeping rather than isolation. Set a ResourceQuota and LimitRange in every preview namespace, and give previews a lower PriorityClass so they're evicted
before prod under memory pressure.
Teardown in the right order. Delete the Kustomization (prune collects the workloads), then the namespace, then the GitRepository. Set milner.dev/preview-expires on all
three so the reaper from step 4 catches anything a closed PR misses.
Where this stops working
L3 belongs to the apps/ layer only. For vault/, woodpecker/, pocket-id/, CRDs, and operators, L0–L2 is the ceiling — you can't meaningfully run a second Vault in a preview
namespace, and targetNamespace cannot apply cluster-scoped resources at all. And since a preview starts from nothing, it never exercises the Helm upgrade path from the
currently-running release, which is where most real breakage lives; wait: true on the prod Kustomization plus a fast rollback is your only cover for that.
Done when: a PR touching apps/ produces a rendered diff, a kubeconform pass, a server-side dry-run against Milner, and a preview- namespace that reaches Kustomization
Ready=True with health checks passing — and closing the PR (or letting the reaper fire) leaves no orphaned namespace, no GitRepository, and no retained PV behind.
Naina:
Overview
Milner requires a multi-layer CI/CD validation pipeline and GitOps preview workflow for Kubernetes manifests. This implementation introduces a 4-tier Woodpecker validation pipeline (L0 offline render, L1 schema/policy, L2 server-side dry-run, L3 live preview) alongside automated preview lifecycle management and resource isolation on the shared worker.
Technical Approach
flake.nix1woodpecker/values.yaml2(Permits Woodpecker pipeline steps to bind step-level ServiceAccounts3)
preview-system/(Pipeline pods execute in
woodpecker-runtimenamespace4)preview-system/apps/base,apps/prod, andapps/previewoverlays(Ephemeral StorageClass uses
reclaimPolicy: Deleteon/var/mnt/data/ephemeral5)policy/k8s.rego6.woodpecker.yaml(CRD schemas fetched from Datree catalog7; step ServiceAccount specified in
backend_options3)Implementation Details
flake.nix: Addfluxcd,kubeconform, andconftesttodevShells.default.buildInputs.1woodpecker/values.yaml: AddWOODPECKER_BACKEND_K8S_SERVICE_ACCOUNT_NAME_ALLOW_FROM_STEP: "true".2preview-system/: Add RBAC (rbac.yaml), reaper CronJob (reaper.yaml), and previewResourceQuota/LimitRangetemplates.apps/base/,apps/prod/,apps/preview/: Environment-agnostic base app and overlay manifests.policy/k8s.rego: Conftest Rego checks (no:latest, memory/CPU limits required, non-privileged).6.woodpecker.yaml: 4-gate CI validation workflow (l0-render,l1-validate,l2-dry-run,l3-preview).tests/test_static.py: Guard tests validating PSA baseline labels, ephemeral storage class on previews, and reaper annotations.8fluxcd(v2.8.x)9,kubeconform(v0.7.x)10,conftest(v0.63.x)11, Datree CRD catalog schema registry.7preview-deployeris confined towoodpecker-runtimewith strict namespace-scoped Roles and cluster-scoped namespace CRUD only.4storageClassName: ephemeralto prevent orphaned local-path volumes on/var/mnt/data/persistent.12Acceptance Criteria
flake.nixprovidesfluxcd,kubeconform, andconftestin the dev shell.woodpecker/values.yamlconfigures the agent withWOODPECKER_BACKEND_K8S_SERVICE_ACCOUNT_NAME_ALLOW_FROM_STEP: "true".preview-deployerinwoodpecker-runtimescoped topreview-systemand namespace CRUD.preview-systemmanifests include a TTL reaper CronJob deleting expired preview resources bymilner.dev/preview-expires.apps/base,apps/prod, andapps/previewoverlays isolate env variance and enforcestorageClassName: ephemeraland no Tailscale Ingress on previews.policy/k8s.regoenforces no:latesttags, defined resource limits, and non-privileged containers..woodpecker.yamlimplements L0 offline render, L1 kubeconform/conftest validation, L2 dry-run diffing, and L3 live preview wait.tests/test_static.pyvalidate preview storage tier and PSA labels.Testing Plan
pytest tests/test_static.py: Verify new yamllint, PSA baseline labeling, and preview storage class guard tests.conftest test /tmp/*.yaml --policy policy/: Verify pass on valid overlays and failure when inserting a:latesttag or privileged container.kubeconform -strict -ignore-missing-schemas -schema-location default -schema-location 'https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json': Verify schema validation against Flux and Kubernetes CRDs.kustomize build apps/prodandkustomize build apps/previewrender valid manifests without undefined substitution variables.Related Issues/PRs
Research Findings
pkgs.fluxcd,pkgs.kubeconform, andpkgs.conftestare available in nixpkgs foraarch64-linuxand installable viaflake.nix.1kubeconformto the Datree CRDs catalog schema location.7WOODPECKER_BACKEND_K8S_SERVICE_ACCOUNT_NAME_ALLOW_FROM_STEP=trueon agents to permit pipeline steps to bindpreview-deployer.3postBuild.substituteFromrequires substitutionConfigMapresources to reside in the same namespace as theKustomizationobject (preview-system).14PriorityClassandResourceQuotalimits to avoid resource starvation on the single 20GB shared worker node.15Pre-Mortem
preview-deployerServiceAccount may lack cluster-level namespace permissions, or the Woodpecker agent may reject step-level ServiceAccount overrides. This would cause L2 server-side dry-run and L3 preview lifecycle stages to fail immediately in CI.