From f71156ab09d5dbd1d7dc169154f16996d14681bf Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Tue, 3 Nov 2026 23:37:45 +0100 Subject: [PATCH] refactor(actual-budget): make user namespaces permanent and retire the pilot component The Actual Budget user-namespace pilot passed its runtime verdict on #3604. Replace its temporary component with two ordinary production patches, the namespace label and the hostUsers post-renderer, that render the same objects byte for byte. Adapt the render regression to the permanent setting while keeping its rollback and storage checks, and document the setting next to the patches. Fixes #3605 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yaml | 4 +- k8s/bases/apps/actual-budget/README.md | 17 ++-- .../hetzner/apps/actual-budget/README.md | 61 ++++++++++++++ .../components/user-namespaces/README.md | 84 ------------------- .../user-namespaces/kustomization.yaml | 36 -------- .../patches/disable-host-users.yaml | 19 +++++ .../patches/enable-user-namespaces.yaml | 11 +++ k8s/providers/hetzner/apps/kustomization.yaml | 18 ++-- .../test-actual-budget-user-namespaces.sh | 53 +++++++----- 9 files changed, 150 insertions(+), 153 deletions(-) create mode 100644 k8s/providers/hetzner/apps/actual-budget/README.md delete mode 100644 k8s/providers/hetzner/apps/actual-budget/components/user-namespaces/README.md delete mode 100644 k8s/providers/hetzner/apps/actual-budget/components/user-namespaces/kustomization.yaml create mode 100644 k8s/providers/hetzner/apps/actual-budget/patches/disable-host-users.yaml create mode 100644 k8s/providers/hetzner/apps/actual-budget/patches/enable-user-namespaces.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6a0a734606..9ccc1e5eb8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1896,8 +1896,8 @@ jobs: shellcheck scripts/tests/test-production-authorization-rules.sh bash scripts/tests/test-production-authorization-rules.sh - - name: 🚦 Validate Actual Budget user-namespace pilot - # Exercise the active component and its rollback on both the PR and the + - name: 🚦 Validate Actual Budget user namespaces + # Exercise the permanent setting and its rollback on both the PR and the # speculative merge-group revision before deploy-prod can publish it. timeout-minutes: 5 run: | diff --git a/k8s/bases/apps/actual-budget/README.md b/k8s/bases/apps/actual-budget/README.md index 1577842ed1..e8c4ec5382 100644 --- a/k8s/bases/apps/actual-budget/README.md +++ b/k8s/bases/apps/actual-budget/README.md @@ -8,13 +8,16 @@ app with an optional self-hosted sync server. ## User namespaces -Production runs a [stateful user-namespace pilot](../../../providers/hetzner/apps/actual-budget/components/user-namespaces/README.md) -whose runtime verdict is recorded on -[#3604](https://github.com/devantler-tech/platform/issues/3604). Its component -pairs the pod setting with the existing admission policy and preserves the -database volume, both containers' identities and mounts, and the single-writer -deployment strategy. The guide covers offline validation, the runtime verdict, -and rollback. Local app opt-in keeps the shared defaults. +In production the pod runs in its own user namespace (`hostUsers: false`). The +stateful pilot passed on +[#3604](https://github.com/devantler-tech/platform/issues/3604) and is permanent +since [#3605](https://github.com/devantler-tech/platform/issues/3605). Two +production patches pair the pod setting with the existing admission policy. They +preserve the database volume, both containers' identities and mounts, and the +single-writer deployment strategy. The +[production guide](../../../providers/hetzner/apps/actual-budget/README.md) +covers offline validation and rollback. Local app opt-in keeps the shared +defaults. ## Bank sync (Enable Banking) diff --git a/k8s/providers/hetzner/apps/actual-budget/README.md b/k8s/providers/hetzner/apps/actual-budget/README.md new file mode 100644 index 0000000000..e0de6ba625 --- /dev/null +++ b/k8s/providers/hetzner/apps/actual-budget/README.md @@ -0,0 +1,61 @@ +# Actual Budget: user namespaces in production + +In production the Actual Budget pod runs in its own user namespace +(`hostUsers: false`): container IDs map to unprivileged host IDs. Actual Budget +is the one stateful workload that does this. The setting started as a pilot. +Its runtime verdict passed on +[#3604](https://github.com/devantler-tech/platform/issues/3604), and it was made +permanent in [#3605](https://github.com/devantler-tech/platform/issues/3605). + +Two patches in [`../kustomization.yaml`](../kustomization.yaml) make up the +setting, and they always change together: + +- [`patches/enable-user-namespaces.yaml`](patches/enable-user-namespaces.yaml) + labels the namespace for the existing Kyverno `add-user-namespaces` rule. +- [`patches/disable-host-users.yaml`](patches/disable-host-users.yaml) appends a + Helm post-renderer that sets `hostUsers: false` on the chart's Deployment. It + goes last, so the base strategy, startup-probe and OIDC post-renderers keep + their order. The chart's Deployment has no `metadata.namespace`, so the inner + target names it exactly. Helm scopes it to the release namespace. + +The sync server and the `enablebanking-seed` sidecar keep their user and group +IDs, their mounts and their shared database volume. The Deployment keeps its +single-replica `Recreate` strategy, so each rollout of this pod brings a brief +outage. The local Docker provider keeps the shared defaults. + +## Validate without deploying + +From the repository root, run: + +```bash +bash scripts/tests/test-actual-budget-user-namespaces.sh +ksail workload validate +ksail --config ksail.prod.yaml workload validate +``` + +The regression needs `kubectl`, Helm, `jq` and `yq` v4. It needs no cluster and +no secrets. It checks the following: + +- Production references each patch exactly once, and the pod patch targets only + the Actual Budget HelmRelease. +- It downloads the chart version the HelmRelease pins, then renders it with + every post-renderer, with and without the two patches. Only `hostUsers` may + differ in the chart's workload, and the PVC and both containers must stay + identical. +- The local overlay is unaffected, and removing both patches and adding them + back reproduces the committed render exactly. + +[`scripts/read-actual-budget-userns-maps.sh`](../../../../scripts/read-actual-budget-userns-maps.sh) +reads the live id maps of both containers. It is read-only. + +## Roll back without touching storage + +Remove both patch entries from `../kustomization.yaml` in one pull request. +That removes the namespace label and the appended post-renderer together. After +deployment, verify that both are gone, the replacement pod is Ready, the same +claim and volume are still bound, and budget access and the sidecar's +reconciliation work again. + +Do not delete or recreate the PVC, reinstall the chart, change file ownership +recursively, or remove the namespace label on its own. None of those reverses +this setting. diff --git a/k8s/providers/hetzner/apps/actual-budget/components/user-namespaces/README.md b/k8s/providers/hetzner/apps/actual-budget/components/user-namespaces/README.md deleted file mode 100644 index e803b048a1..0000000000 --- a/k8s/providers/hetzner/apps/actual-budget/components/user-namespaces/README.md +++ /dev/null @@ -1,84 +0,0 @@ -# Actual Budget user-namespace pilot - -This production-only component is **active**; its runtime verdict is recorded -on [#3604](https://github.com/devantler-tech/platform/issues/3604). It -sets `hostUsers: false` on the Actual Budget Deployment and labels its namespace -for the existing Kyverno user-namespace admission policy. The sync server and -`enablebanking-seed` sidecar keep their existing user/group IDs, mounts, and -shared database volume. The deployment retains its single-replica `Recreate` -strategy, so each rollout of this pod entails a brief outage. - -The pod setting is applied through an appended Helm post-renderer. The chart's -Deployment has no `metadata.namespace`, so the inner target uses its exact -name and API kind; the outer patch targets only the Actual Budget HelmRelease -in its namespace. Existing post-renderers remain in order, including the -authentication, startup-probe, and deployment-strategy patches. - -Kubernetes user namespaces remap container IDs to different host IDs. Stateful -pods also require idmapped mounts on the underlying filesystem. A successful -render proves the configuration, while the live pilot must establish that this -application can still read and write its existing data. See the -[Kubernetes user-namespace documentation](https://kubernetes.io/docs/concepts/workloads/pods/user-namespaces/). - -## Validate without deploying - -From the repository root, run: - -```bash -bash scripts/tests/test-actual-budget-user-namespaces.sh -ksail workload validate -ksail --config ksail.prod.yaml workload validate -``` - -The regression script requires `kubectl`, Helm, `jq`, and `yq` v4. It checks -that production references the component exactly once, then builds the disabled -state by removing that reference in a temporary copy of the manifests. It -downloads the chart version pinned by the HelmRelease, renders all its -post-renderers, and compares both states. Only `hostUsers` may change in the -chart's workload; the complete PVC and container definitions must remain -identical. It also checks the full production overlay, local isolation, exact -rollback, and that re-enabling reproduces the committed render. It needs no -cluster or secrets. - -## Runtime verdict - -The pre-rollout Deployment revision, pod UID, PVC UID, PV name and storage -volume identity are recorded privately, so post-rollout verification can confirm -that the same storage objects remain bound. A different healthy volume is not -evidence that the original data was preserved. - -The verdict requires all the following: - -- The new deployed revision is the reviewed one. The Deployment is Available - 1/1, its replacement pod is Ready 2/2, and the pod spec contains - `hostUsers: false`. -- Both actual containers show non-zero subordinate host IDs in - `/proc/self/uid_map` and `/proc/self/gid_map`. A disposable smoke pod proves - its own mapping only; it does not replace this check. -- Existing budgets open and sync through the authenticated route. The main - container and bank-sync sidecar retain access to their existing database, - and the sidecar completes a reconciliation. Keep budget data, credentials, - and raw application logs out of public issue comments. -- The same PVC/PV/storage volume remains bound and healthy. No new mount, - permission, or warning events appear. -- The app stays healthy for at least 30 minutes, including a sidecar interval. - Record the observation window and an aggregate verdict on #3604, with detailed - evidence kept private. - -After a passing verdict, the short-lived component is retired and the proven -setting made permanent through a separate reviewed change, tracked by -[#3605](https://github.com/devantler-tech/platform/issues/3605). - -## Roll back without touching storage - -Remove the component reference from `apps/kustomization.yaml` in a rollback PR. -This removes the namespace enforcement label and the appended pod patch -together. After deployment, verify that both are absent, the replacement pod -is Ready, the same storage identities remain bound, and budget access and -sidecar reconciliation work again. - -Do not delete or recreate PVCs, reinstall the chart, change ownership -recursively, or disable namespace policy independently as part of rollback. -Those actions are not the inverse of this pilot. The regression test proves -that removing the single reference drops only the namespace label and the -appended post-renderer. diff --git a/k8s/providers/hetzner/apps/actual-budget/components/user-namespaces/kustomization.yaml b/k8s/providers/hetzner/apps/actual-budget/components/user-namespaces/kustomization.yaml deleted file mode 100644 index abad403373..0000000000 --- a/k8s/providers/hetzner/apps/actual-budget/components/user-namespaces/kustomization.yaml +++ /dev/null @@ -1,36 +0,0 @@ -# Enable only for the separately reviewed stateful pilot (platform#1807). -# Append a renderer so the existing strategy, probes and OIDC patches survive. -apiVersion: kustomize.config.k8s.io/v1alpha1 -kind: Component -patches: - - target: - group: helm.toolkit.fluxcd.io - version: v2 - kind: HelmRelease - name: actual-budget - namespace: actual-budget - patch: | - - op: add - path: /spec/postRenderers/- - value: - kustomize: - patches: - - target: - group: apps - version: v1 - kind: Deployment - name: actual-budget-actualbudget - # The chart omits metadata.namespace; Helm scopes its release. - patch: | - - op: add - path: /spec/template/spec/hostUsers - value: false - # Pair the authored pod setting with the existing admission enforce rule. - - target: - version: v1 - kind: Namespace - name: actual-budget - patch: | - - op: add - path: /metadata/labels/pod-security.devantler.tech~1user-namespaces - value: enabled diff --git a/k8s/providers/hetzner/apps/actual-budget/patches/disable-host-users.yaml b/k8s/providers/hetzner/apps/actual-budget/patches/disable-host-users.yaml new file mode 100644 index 0000000000..0ce8506c7c --- /dev/null +++ b/k8s/providers/hetzner/apps/actual-budget/patches/disable-host-users.yaml @@ -0,0 +1,19 @@ +# Run the Actual Budget pod in its own user namespace (`hostUsers: false`). +# The stateful pilot passed (#3604) and is permanent since #3605. Appended as +# the last Helm post-renderer so the base strategy, probe and OIDC renderers +# survive in order. The chart's Deployment has no metadata.namespace, so the +# inner target uses its exact name; Helm scopes it to the release namespace. +- op: add + path: /spec/postRenderers/- + value: + kustomize: + patches: + - target: + group: apps + version: v1 + kind: Deployment + name: actual-budget-actualbudget + patch: |- + - op: add + path: /spec/template/spec/hostUsers + value: false diff --git a/k8s/providers/hetzner/apps/actual-budget/patches/enable-user-namespaces.yaml b/k8s/providers/hetzner/apps/actual-budget/patches/enable-user-namespaces.yaml new file mode 100644 index 0000000000..723be3a060 --- /dev/null +++ b/k8s/providers/hetzner/apps/actual-budget/patches/enable-user-namespaces.yaml @@ -0,0 +1,11 @@ +# Opt Actual Budget into the label-gated `add-user-namespaces` Kyverno rule, +# paired with the authored `hostUsers: false` in disable-host-users.yaml. The +# stateful pilot passed (#3604) and is permanent since #3605. The label lives +# only in the Hetzner overlay, so the local Docker provider keeps the defaults. +--- +apiVersion: v1 +kind: Namespace +metadata: + name: actual-budget + labels: + pod-security.devantler.tech/user-namespaces: enabled diff --git a/k8s/providers/hetzner/apps/kustomization.yaml b/k8s/providers/hetzner/apps/kustomization.yaml index b64b89cd0d..e7532c1948 100644 --- a/k8s/providers/hetzner/apps/kustomization.yaml +++ b/k8s/providers/hetzner/apps/kustomization.yaml @@ -46,10 +46,6 @@ components: # bases/components/helmrelease-drift-detection. - ../../../bases/components/helmrelease-drift-detection - ../../../bases/components/helmrelease-flux-defaults - # Stateful user-namespace pilot, active for verification (#3604). The runtime - # verdict is recorded on #3604; rollback removes this one reference, retaining - # data. Making the setting permanent is a separate change (#3605). - - actual-budget/components/user-namespaces # Strategic-merge patch files self-identify (apiVersion/kind/name/namespace), so # they need only a path — no redundant `target:` selector. JSON6902 patches have # no embedded identity and keep an explicit target. @@ -95,8 +91,20 @@ patches: # User namespaces (hostUsers: false) — enabled for the proven prod apps. These # namespace-label patches opt proven-stateless workloads into the # add-security-context Kyverno rule. Local/CI stays out, and so do stateful - # workloads apart from the Actual Budget pilot component above; in mixed + # workloads apart from Actual Budget below; in mixed # namespaces (umami) the rule itself excludes the # CNPG database pods via their cnpg.io/cluster label. - path: whoami/patches/enable-user-namespaces.yaml - path: homepage/patches/enable-user-namespaces.yaml + # Actual Budget is the one stateful workload in its own user namespace. Its + # pilot passed (#3604) and is permanent since #3605: the namespace label pairs + # with the authored hostUsers: false on its pod. Rolling back removes both + # entries together and leaves the volume and its data untouched. + - path: actual-budget/patches/enable-user-namespaces.yaml + - target: + group: helm.toolkit.fluxcd.io + version: v2 + kind: HelmRelease + name: actual-budget + namespace: actual-budget + path: actual-budget/patches/disable-host-users.yaml diff --git a/scripts/tests/test-actual-budget-user-namespaces.sh b/scripts/tests/test-actual-budget-user-namespaces.sh index 44b4f0834d..346d7468a8 100755 --- a/scripts/tests/test-actual-budget-user-namespaces.sh +++ b/scripts/tests/test-actual-budget-user-namespaces.sh @@ -1,7 +1,9 @@ #!/usr/bin/env bash -# Exercise the deployed chart and every Flux post-renderer. A HelmRelease-only -# assertion cannot detect a chart value that never reaches its pod template. +# Actual Budget runs in its own user namespace in production (#3604, permanent +# since #3605). Exercise the deployed chart and every Flux post-renderer: a +# HelmRelease-only assertion cannot detect a chart value that never reaches its +# pod template. set -euo pipefail root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" @@ -19,7 +21,10 @@ done cp -R "${root_dir}/k8s" "${scratch_dir}/k8s" readonly apps_dir="${scratch_dir}/k8s/providers/hetzner/apps" -readonly component='actual-budget/components/user-namespaces' +# The two patch entries that together make up the setting: the namespace label +# for the admission rule and the post-renderer that sets hostUsers. +readonly label_patch='actual-budget/patches/enable-user-namespaces.yaml' +readonly pod_patch='actual-budget/patches/disable-host-users.yaml' # Render the overlay directory in $1 to a sorted JSON resource array at $2. render_overlay() { @@ -33,30 +38,39 @@ extract_release() { .metadata.namespace == "actual-budget" and .metadata.name == "actual-budget")' "$1" >"$2" } -# The pilot is active in production (#3604): the committed overlay must -# reference the component exactly once. -COMPONENT="${component}" yq -e '[.components[] | select(. == strenv(COMPONENT))] | length == 1' \ +# Production must reference each patch exactly once; the pod patch must target +# exactly the Actual Budget HelmRelease. +LABEL="${label_patch}" yq -e '[.patches[] | select(.path == strenv(LABEL))] | length == 1' \ "${apps_dir}/kustomization.yaml" >/dev/null 2>&1 || - fail 'production must reference the pilot component exactly once' + fail 'production must reference the Actual Budget namespace label patch exactly once' +POD="${pod_patch}" yq -o=json '[.patches[] | select(.path == strenv(POD))]' "${apps_dir}/kustomization.yaml" 2>/dev/null | + jq -e 'length == 1 and .[0].target == {"group": "helm.toolkit.fluxcd.io", "version": "v2", + "kind": "HelmRelease", "name": "actual-budget", "namespace": "actual-budget"}' >/dev/null || + fail 'production must reference the Actual Budget hostUsers patch exactly once, targeting only its HelmRelease' +# Keep both entries so re-enabling after the rollback below restores them as committed. +LABEL="${label_patch}" POD="${pod_patch}" yq -o=json \ + '[.patches[] | select(.path == strenv(LABEL) or .path == strenv(POD))]' \ + "${apps_dir}/kustomization.yaml" >"${scratch_dir}/entries.json" render_overlay "${apps_dir}" "${scratch_dir}/on.json" render_overlay "${scratch_dir}/k8s/providers/docker/apps" "${scratch_dir}/local-before.json" -# The pilot is production-only: the local overlay must carry neither the +# The setting is production-only: the local overlay must carry neither the # namespace enforcement label nor a post-renderer that sets hostUsers. jq -e '([.[] | select(.kind == "Namespace" and .metadata.name == "actual-budget") | .metadata.labels["pod-security.devantler.tech/user-namespaces"] | select(. != null)] | length == 0) and ([.[] | select(.kind == "HelmRelease" and .metadata.namespace == "actual-budget" and .metadata.name == "actual-budget") | .spec.postRenderers[]? | tostring | select(test("hostUsers"))] | length == 0)' \ - "${scratch_dir}/local-before.json" >/dev/null || fail 'the pilot must not reach the local overlay' + "${scratch_dir}/local-before.json" >/dev/null || fail 'the setting must not reach the local overlay' extract_release "${scratch_dir}/on.json" "${scratch_dir}/on-release.json" jq -e '[.[] | select(.kind == "Namespace" and .metadata.name == "actual-budget") | .metadata.labels["pod-security.devantler.tech/user-namespaces"]] == ["enabled"]' \ - "${scratch_dir}/on.json" >/dev/null || fail 'the active pilot must enable the namespace enforcement policy' + "${scratch_dir}/on.json" >/dev/null || fail 'production must enable the namespace enforcement policy' -# Build the disabled state by removing the single reference, which is exactly +# Build the disabled state by removing both patch entries, which is exactly # the documented rollback. -COMPONENT="${component}" yq -i '.components |= map(select(. != strenv(COMPONENT)))' \ +LABEL="${label_patch}" POD="${pod_patch}" \ + yq -i '.patches |= map(select(.path != strenv(LABEL) and .path != strenv(POD)))' \ "${apps_dir}/kustomization.yaml" render_overlay "${apps_dir}" "${scratch_dir}/off.json" extract_release "${scratch_dir}/off.json" "${scratch_dir}/off-release.json" @@ -74,14 +88,15 @@ jq -S 'map(if .kind == "Namespace" and .metadata.name == "actual-budget" then .spec.postRenderers |= .[:-1] else . end)' "${scratch_dir}/on.json" >"${scratch_dir}/normalized.json" diff -u "${scratch_dir}/off.json" "${scratch_dir}/normalized.json" || - fail 'the pilot changes resources beyond the pod and namespace pilot settings' + fail 'the setting changes resources beyond the pod and namespace user-namespace settings' render_overlay "${scratch_dir}/k8s/providers/docker/apps" "${scratch_dir}/local-after.json" cmp "${scratch_dir}/local-before.json" "${scratch_dir}/local-after.json" || - fail 'the production pilot must not affect the local overlay' + fail 'the production setting must not affect the local overlay' # Re-enabling after a rollback must reproduce the committed render exactly. -COMPONENT="${component}" yq -i '.components += [strenv(COMPONENT)]' "${apps_dir}/kustomization.yaml" +ENTRIES="$(cat "${scratch_dir}/entries.json")" yq -i '.patches += (strenv(ENTRIES) | from_json)' \ + "${apps_dir}/kustomization.yaml" render_overlay "${apps_dir}" "${scratch_dir}/reenabled.json" cmp "${scratch_dir}/on.json" "${scratch_dir}/reenabled.json" || fail 're-enabling after rollback must restore the committed resources exactly' @@ -128,17 +143,17 @@ render_workload() { render_workload off render_workload on jq -e '[.[] | select(.kind == "Deployment") | .spec.template.spec.hostUsers] == [false]' \ - "${scratch_dir}/on-workload.json" >/dev/null || fail 'the active pilot must reach the rendered Deployment' + "${scratch_dir}/on-workload.json" >/dev/null || fail 'production must reach the rendered Deployment' jq -e '[.[] | select(.kind == "Deployment") | .spec.template.spec.hostUsers] == [null]' \ "${scratch_dir}/off-workload.json" >/dev/null || fail 'rollback must restore host user namespaces' jq -S 'map(if .kind == "Deployment" then del(.spec.template.spec.hostUsers) else . end)' \ "${scratch_dir}/on-workload.json" >"${scratch_dir}/normalized-workload.json" diff -u "${scratch_dir}/off-workload.json" "${scratch_dir}/normalized-workload.json" || - fail 'the pilot must preserve the complete workload, including PVCs and both containers' + fail 'the setting must preserve the complete workload, including PVCs and both containers' jq -e '[.[] | select(.kind == "Deployment") | .spec.strategy.type == "Recreate" and .spec.replicas == 1 and (.spec.template.spec.containers | length) == 2 and ([.spec.template.spec.volumes[] | select(has("persistentVolumeClaim"))] | length) == 1] == [true]' \ - "${scratch_dir}/on-workload.json" >/dev/null || fail 'the pilot must retain its single-writer storage contract' + "${scratch_dir}/on-workload.json" >/dev/null || fail 'the setting must retain its single-writer storage contract' -printf 'PASS: Actual Budget user namespaces are active in production; the pilot changes only hostUsers and enforcement, and rollback preserves storage\n' +printf 'PASS: Actual Budget runs in its own user namespace in production; the setting changes only hostUsers and enforcement, and rollback preserves storage\n'