Replaces the single `apps` Flux Kustomization with dependency-ordered waves
(apps-wave-0..2) so a crashlooping workload isolates to its wave + downstream
instead of stalling the entire app tier. Parallel within a wave, ordered across.
- catalog/render.py: compute waves by longest dependency-path depth over the
ServiceDefinition `dependencies:` edges (a `<svc>-db` is a leaf, so its
consumer lands strictly higher — the "DB below consumer" rule falls out of
longest-path, no special case). `--emit-waves` generates the per-wave
kustomizations + Flux CRs; `--wave-layout` places renders into the right dir.
wave 0: pihole gitea jackett plex qbittorrent nextcloud-db semaphore-db
wave 1: sonarr radarr nextcloud semaphore gitea-actions
wave 2: seerr
- apps/*.yaml -> apps/wave-{0,1,2}/ (byte-identical moves; SOPS files intact).
- clusters/base: apps.yaml removed; apps-wave-{0,1,2}.yaml added (dependsOn
chain: platform-config -> wave-0 -> wave-1 -> wave-2, prune+wait each).
- clusters/dev: one SOPS decryption patch per wave (load-bearing).
- DESIGN-waves.md: wave table, topology, cutover runbook.
Cutover: preceded by disabling prune on `apps` (1bfb0db) so removing that
object here does not GC its resources; the wave Kustomizations SSA-adopt the
byte-identical manifests in place (no restart, no PVC churn).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
15 KiB
Wave-based app-tier delivery (PROTOTYPE — for PM review)
Problem. Today one Flux Kustomization apps (clusters/base/apps.yaml, dependsOn: platform-config, wait: true, prune: true) reconciles all of apps/ — ~10 services + CNPG
databases + SOPS secrets. Because wait: true health-gates the entire set, a single
crashlooping workload blocks all apps and every new revision (this has bitten twice).
Solution. Split the tier into dependency-ordered waves. Each wave is its own Flux
Kustomization apps-wave-<N> that dependsOn wave N-1 (wave 0 dependsOn platform-config),
with wait: true + prune: true and path: ./apps/wave-<N>. wait: true now health-gates
only that wave, so a crashloop isolates to its own wave and everything downstream, while
services within a wave still reconcile in parallel. Wave assignment is computed by
catalog/render.py from the declared dependency graph, so future onboarding auto-places a
service in the correct wave.
1. Wave assignment algorithm
wave(node) = longest dependency-path depth over the catalog dependencies: edges
(service → service). A node with no dependencies is wave 0; a node that depends on a wave-k node
is wave ≥ k+1. Implemented in catalog/render.py :: compute_waves().
Co-located CNPG database rule. A service <svc> that declares a dependency to: <svc>-db
(e.g. nextcloud → nextcloud-db, semaphore → semaphore-db) has a hard DB prerequisite.
The <svc>-db node carries no ServiceDefinition, so it is a leaf (wave 0); because the
consumer declares a dependency on it, the consumer lands in a strictly higher wave. The
"DB strictly below its consumer" guarantee therefore falls straight out of the longest-path
rule — no special case. The DB manifest (<svc>-db.yaml) and its CNPG initdb owner secret
(<svc>-db-app.sops.yaml) are placed in the DB's (lower) wave; the app manifest and its
<svc>-bootstrap-secret.sops.yaml are placed in the consumer's wave.
Non-catalog units. A few app-tier files carry no ServiceDefinition (compute_waves can't
see them). They are anchored explicitly in render.py :: EXTRA_UNITS. Today the only one is
gitea-actions (the in-cluster act_runner + its DinD sidecar), anchored to gitea with
above: true — the runner registers against and clones from Gitea, so Gitea must be healthy
first → it lands one wave above gitea.
Cycle detection: compute_waves raises on any dependency cycle.
2. Computed WAVE TABLE
Verify with python3 catalog/render.py --print-waves (nodes) and --emit-waves (file plan).
| Wave | File | Kind | Reason (dependency path) |
|---|---|---|---|
| 0 | pihole.yaml |
app | no dependencies (leaf) |
| 0 | gitea.yaml (+ gitea-bootstrap-secret.sops.yaml) |
app + secret | no dependencies (leaf) |
| 0 | jackett.yaml |
app | no dependencies (leaf) |
| 0 | plex.yaml |
app | no dependencies (leaf) |
| 0 | qbittorrent.yaml |
app | no dependencies (leaf) |
| 0 | nextcloud-db.yaml (+ nextcloud-db-app.sops.yaml) |
CNPG DB + secret | co-located DB leaf; strictly below nextcloud |
| 0 | semaphore-db.yaml (+ semaphore-db-app.sops.yaml) |
CNPG DB + secret | co-located DB leaf; strictly below semaphore |
| 1 | sonarr.yaml |
app | → qbittorrent (w0), jackett (w0) |
| 1 | radarr.yaml |
app | → qbittorrent (w0), jackett (w0) |
| 1 | nextcloud.yaml (+ nextcloud-bootstrap-secret.sops.yaml) |
app + secret | → nextcloud-db (w0) |
| 1 | semaphore.yaml (+ semaphore-bootstrap-secret.sops.yaml) |
app + secret | → semaphore-db (w0) |
| 1 | gitea-actions.yaml (+ gitea-actions-runner-secret.sops.yaml) |
CI runner + secret | EXTRA_UNITS: anchor gitea (w0), above → one wave up |
| 2 | seerr.yaml |
app | → sonarr (w1), radarr (w1) |
Catalog services not yet onboarded (no committed manifest) are computed but not placed:
keycloak (w1, → keycloak-db w0) and vm-demo (w0). They join a wave automatically when
onboarded.
Depth check: 62 (wave 0) + 55 (wave 1) + 13 (wave 2) = 130 resources, byte-for-byte the
same set the monolithic apps Kustomization built before the split (validated with
kubectl kustomize).
3. Flux Kustomization topology
platform (controllers/CRDs)
│ healthy
▼
platform-config (cluster CRs)
│ healthy
▼
apps-wave-0 → ./apps/wave-0 dependsOn platform-config wait:true prune:true (leaves + CNPG DBs)
│ healthy
▼
apps-wave-1 → ./apps/wave-1 dependsOn apps-wave-0 wait:true prune:true (arr, DB-backed apps, gitea-actions)
│ healthy
▼
apps-wave-2 → ./apps/wave-2 dependsOn apps-wave-1 wait:true prune:true (seerr)
Every wave Kustomization is identical to the retired apps one except name, path, and
dependsOn: same interval/retryInterval/timeout, prune: true, wait: true,
postBuild.substituteFrom cluster-vars, and the same SOPS decryption (added per-environment
in clusters/dev/kustomization.yaml — one inlined patch per apps-wave-<N>, referencing the
out-of-band sops-age Secret by name only, exactly as the single apps Kustomization did).
Failure isolation gained: a crashloop in seerr (wave 2) no longer blocks apps-wave-0
or apps-wave-1, nor their new revisions. A crashloop in wave 1 blocks only wave 2 (its
downstream), never wave 0. Before, any one crashloop stalled the whole tier.
4. What render.py now does
compute_waves(sds)— longest-path wave per node (services + DB leaf nodes); cycle-safe.plan_apps_waves(sds, present_files)— maps the committed app file set →{wave: [files]}by naming convention (<svc>,<svc>-bootstrap-secret,<svc>-db,<svc>-db-app) plusEXTRA_UNITS; only files actually present are placed (not-yet-onboarded SDs are skipped).--emit-waves— (re)generates everyapps/wave-<N>/kustomization.yamlandclusters/base/apps-wave-<N>.yaml. Idempotent; never rewrites a service manifest (only placement changes, done in git), so rendered bytes — and the L1 goldens — are untouched.--print-waves— prints the node→wave table.-o <dir> --wave-layout— future onboarding writes<dir>/wave-<N>/<svc>.yamldirectly.- Self-test extended (
--selftest): longest-path depth, CNPG-DB-below-consumer, cycle detection, sidecar/EXTRA_UNITS placement, and the generated CR wiring (dependsOn/prune/wait).
Onboarding a new service now: add catalog/<svc>.yaml, render it
(render.py catalog/<svc>.yaml -o apps --wave-layout [--no-image-automation]), then
render.py --emit-waves. If it lands in an existing wave, no clusters/base edit is needed. If
it creates a new deepest wave, add one line to clusters/base/kustomization.yaml and one
decryption patch to clusters/dev/kustomization.yaml (both flagged in-file).
5. CUTOVER RUNBOOK — Flux resource adoption without data loss
Read this before merge. Moving a manifest from the
appsKustomization to anapps-wave-<N>Kustomization is a Flux ownership transfer (adoption). Done wrong, one Kustomization can prune (delete) a resource another is adopting — and for the CNPG databases that is data loss.
5.1 Why the naive "just merge" is dangerous
Flux marks every resource it applies with kustomize.toolkit.fluxcd.io/name +
…/namespace, and with prune: true it garbage-collects any owned resource that leaves
its build. Two hazards on a plain merge of this PR:
- The live
appsKustomization hasprune: true. This PR removesapps.yamlfromclusters/base. The root Kustomization (flux-system,./clusters/dev,prune: true) will then delete theappsKustomization object. Deleting a Flux Kustomization that hasprune: trueruns its finalizer, which garbage-collects every resource it owns — all ~130 app resources, including thenextcloud-db/semaphore-dbCNPG Clusters. Deleting a CNPGClustercascade-deletes its PVCs → the databases are destroyed, even though the new waves were about to adopt them. - Ordering race. Even setting that aside, if the old Kustomization's prune and the new waves' adoption interleave, a resource can be deleted-then-recreated — a needless restart for stateless apps, and unacceptable for the stateful DBs.
The fix is to make the old Kustomization release its resources (orphan-alive, no GC) so the waves adopt them by identity — never a delete/recreate window.
5.2 Preferred sequence — pure GitOps, two merges (no data loss, no downtime)
Split the change into two PRs so the release happens before the split, entirely through git.
Pre-flight (no changes):
flux get kustomizations→appsisReady. Cluster healthy.- Record the stateful state you must not lose:
kubectl get cluster -A,kubectl get pvc -n nextcloud-db,kubectl get pvc -n semaphore-db(note names +AGE). Take a Longhorn/Velero backup of thenextcloud-dbandsemaphore-dbnamespaces (defense in depth).
PR 1 — neutralize GC on the old apps Kustomization (one-line change):
- Edit
clusters/base/apps.yaml:prune: true→prune: false. Merge. - Wait until
flux get kustomization appsreconciles with the new spec (kubectl -n flux-system get kustomization apps -o jsonpath='{.spec.prune}'→false). - Effect: when
appsis later deleted, its finalizer will not garbage-collect its resources — they survive as orphaned-but-alive objects.
PR 2 — this wave-split PR. On merge, the root Kustomization reconciles ./clusters/dev:
appsis gone from the build → root prunes theappsKustomization object. Becauseappsnow hasprune: false(from PR 1), no app resources are deleted; they are orphaned, still running.apps-wave-0/1/2are created. Each does a server-side apply of the same, byte-identical manifests, adopting the live objects (matched by GVK + namespace + name) and relabelingkustomize.toolkit.fluxcd.io/nameto its wave. Because content is unchanged, SSA is a no-op onspec— no pod restart, no PVC churn, no DB re-init. Same field manager (kustomize-controller) → no ownership conflict.- Decryption is already wired on every wave (this PR), so the SOPS Secrets re-apply decrypted and identical — critical: a wave WITHOUT decryption would apply the encrypted YAML and corrupt the Secret.
Verify:
flux get kustomizations # apps-wave-0/1/2 Ready; apps absent
kubectl get cluster -A # DBs present, ORIGINAL age (not recreated)
kubectl get pvc -n nextcloud-db -n semaphore-db # same PVCs, same age
kubectl get deploy -A -o wide # apps not restarted (AGE/restarts unchanged)
kubectl get <any-app-deploy> -n <ns> \
-o jsonpath='{.metadata.labels.kustomize\.toolkit\.fluxcd\.io/name}' # == apps-wave-<N>
5.3 Single-PR variant (if a live pre-step is acceptable instead of PR 1)
If you would rather merge this PR alone, perform PR 1's effect out-of-band first, then merge:
flux -n flux-system suspend kustomization apps # stop it racing the waves
kubectl -n flux-system patch kustomization apps --type=merge -p '{"spec":{"prune":false}}'
Belt-and-suspenders for the databases (a per-object opt-out Flux always honors, regardless of which Kustomization ends up owning them during the window):
kubectl -n nextcloud-db annotate cluster/nextcloud-db kustomize.toolkit.fluxcd.io/prune=disabled
kubectl -n semaphore-db annotate cluster/semaphore-db kustomize.toolkit.fluxcd.io/prune=disabled
Then merge this PR and verify as in 5.2. Cleanup once the waves own the DBs (restore normal prune semantics):
kubectl -n nextcloud-db annotate cluster/nextcloud-db kustomize.toolkit.fluxcd.io/prune-
kubectl -n semaphore-db annotate cluster/semaphore-db kustomize.toolkit.fluxcd.io/prune-
5.4 Rollback
Nothing is ever deleted, so rollback is safe at every step. If a wave fails to adopt: the
resources are still alive (orphaned). flux suspend the waves, revert the merge (re-creating
apps, which re-adopts by identity), and investigate. No data is lost because the cutover
never issues a delete.
5.5 Why adoption is safe here (summary of the invariant)
- The old owner releases without GC (
prune: falsebefore it is deleted) → no delete. - The new owners adopt by identity via SSA on byte-identical manifests → no recreate.
- There is no window in which a resource is owned by nothing and pruned: it is
continuously alive, transitioning label ownership from
apps→apps-wave-<N>.
6. Risks / open items for the PM
- The GC hazard above is the whole ball game. A plain merge of this PR onto a live cluster
whose
appsKustomization still hasprune: truewill destroy the CNPG databases. §5.2 / §5.3 must be followed. (In dev this has never been applied — prototype only.) pruneblast radius within a wave is unchanged. Splitting reduces cross-wave coupling, but a wave is stillwait: true— a crashloop ingitea-actions(wave 1) will block wave 2 (seerr) until it recovers or itstimeoutelapses. That is the intended linear-wave trade-off; if the PM wantsgitea-actions(flaky privileged DinD) to not gateseerr, consider giving CI plumbing its own non-gating Kustomization branch (future work, not in this PR).- New-deepest-wave onboarding needs two one-line manual edits (
clusters/base/kustomization.yaml- a
clusters/devdecryption patch).render.py --emit-wavesgenerates the CR but does not edit those hand-maintained, comment-carrying files; both spots are flagged in-file. Landing in an existing wave is fully automatic.
- a
- Image automation.
platform/image-automation/image-update-automation.yamlscanspath: ./apps(recursive) for$imagepolicysetters —apps/wave-<N>/is under./apps, so per-service image markers are still covered. No change needed; noted so it isn't a surprise. - Decryption drift. Each wave must carry the SOPS decryption patch (done in this PR for
waves 0–2). If a future wave is added and the
clusters/devpatch is forgotten, that wave will apply encrypted SOPS files verbatim and corrupt its Secrets. The in-file reminders and §4 call this out; a lint check that "every apps-wave- has a decryption patch" would be a good hardening follow-up. EXTRA_UNITSis a small hand-maintained table (onlygitea-actionstoday). Any future non-catalog app-tier unit must be added there or it will not be placed into a wave.