Files
jakman85andClaude Opus 4.8 36a1a4e1f9 refactor(apps): split monolithic apps Kustomization into dependency waves
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]>
2026-08-26 12:19:00 -05:00

250 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.