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]>
250 lines
15 KiB
Markdown
250 lines
15 KiB
Markdown
# 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`) plus
|
||
`EXTRA_UNITS`; only files actually present are placed (not-yet-onboarded SDs are skipped).
|
||
- `--emit-waves` — (re)generates every `apps/wave-<N>/kustomization.yaml` and
|
||
`clusters/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>.yaml` directly.
|
||
- 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 `apps` Kustomization to an
|
||
> `apps-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:
|
||
|
||
1. **The live `apps` Kustomization has `prune: true`.** This PR removes `apps.yaml` from
|
||
`clusters/base`. The root Kustomization (`flux-system`, `./clusters/dev`, `prune: true`)
|
||
will then **delete the `apps` Kustomization object**. Deleting a Flux Kustomization that
|
||
has `prune: true` runs its finalizer, which **garbage-collects every resource it owns** —
|
||
all ~130 app resources, **including the `nextcloud-db` / `semaphore-db` CNPG Clusters**.
|
||
Deleting a CNPG `Cluster` cascade-deletes its PVCs → **the databases are destroyed**, even
|
||
though the new waves were about to adopt them.
|
||
2. **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` → `apps` is `Ready`. 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 the `nextcloud-db` and `semaphore-db`
|
||
namespaces (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 apps` reconciles with the new spec
|
||
(`kubectl -n flux-system get kustomization apps -o jsonpath='{.spec.prune}'` → `false`).
|
||
- Effect: when `apps` is 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`:
|
||
- `apps` is gone from the build → root prunes the **`apps` Kustomization object**. Because
|
||
`apps` now has `prune: false` (from PR 1), **no app resources are deleted**; they are
|
||
orphaned, still running.
|
||
- `apps-wave-0/1/2` are created. Each does a server-side apply of the **same, byte-identical**
|
||
manifests, **adopting** the live objects (matched by GVK + namespace + name) and relabeling
|
||
`kustomize.toolkit.fluxcd.io/name` to its wave. Because content is unchanged, SSA is a no-op
|
||
on `spec` — **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: false` before 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
|
||
|
||
1. **The GC hazard above is the whole ball game.** A plain merge of this PR onto a live cluster
|
||
whose `apps` Kustomization still has `prune: true` will destroy the CNPG databases. §5.2 /
|
||
§5.3 must be followed. (In dev this has never been applied — prototype only.)
|
||
2. **`prune` blast radius within a wave is unchanged.** Splitting reduces cross-wave coupling,
|
||
but a wave is still `wait: true` — a crashloop in `gitea-actions` (wave 1) will block wave 2
|
||
(`seerr`) until it recovers or its `timeout` elapses. That is the intended linear-wave
|
||
trade-off; if the PM wants `gitea-actions` (flaky privileged DinD) to not gate `seerr`,
|
||
consider giving CI plumbing its own non-gating Kustomization branch (future work, not in
|
||
this PR).
|
||
3. **New-deepest-wave onboarding needs two one-line manual edits** (`clusters/base/kustomization.yaml`
|
||
+ a `clusters/dev` decryption patch). `render.py --emit-waves` generates 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.
|
||
4. **Image automation.** `platform/image-automation/image-update-automation.yaml` scans
|
||
`path: ./apps` (recursive) for `$imagepolicy` setters — `apps/wave-<N>/` is under `./apps`,
|
||
so per-service image markers are still covered. No change needed; noted so it isn't a
|
||
surprise.
|
||
5. **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/dev` patch 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-<N> has a decryption patch" would be a
|
||
good hardening follow-up.
|
||
6. **`EXTRA_UNITS` is a small hand-maintained table** (only `gitea-actions` today). Any future
|
||
non-catalog app-tier unit must be added there or it will not be placed into a wave.
|