diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b5e9d4962f..25773c574d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1901,8 +1901,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 052c8878a0..e96c1f3f6e 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)" @@ -22,7 +24,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() { @@ -36,30 +41,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" @@ -77,14 +91,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' @@ -131,17 +146,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'