From a83478b438507d1193d053dd025ec0f07d0b3343 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sun, 16 Aug 2026 20:37:36 +0200 Subject: [PATCH 1/5] feat(homepage): declare Infrastructure as Code and enforce declared groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Homepage takes a group's order and icon from the layout block here, but the group NAME from an annotation on a route — often in another repository. Nothing bound the two, and an undeclared group is accepted silently: it renders below every declared group, bookmarks included, with a generic icon. Crossview's "Infrastructure as Code" and doggy-countdown's "Fun" both landed there. Declares Infrastructure as Code ahead of Cloud and Network, the two providers Crossplane actually declares. The icon is mdi-file-cog because simple-icons has no crossplane slug (verified 404). Adds restrict-homepage-service-groups, which rejects a route opting into discovery while naming a group the layout does not declare. Homepage offers no native way to filter discovery by group, so the binding has to be at admission. It covers HTTPRoute and Ingress, applies to platform-owned routes too — the Crossview one is how this was found — and excludes bookmark group names, which would otherwise paint a bookmark heading twice. The allow-list is duplicated rather than read from the ConfigMap: a Kyverno context lookup would make every annotated route un-appliable if that ConfigMap became unreadable, trading a silent cosmetic bug for a reconciliation outage. Both files now cross-reference each other so the pair moves in one PR. --- k8s/bases/apps/homepage/config-map.yaml | 26 +++- .../restrict-homepage-service-groups.yaml | 104 +++++++++++++++ .../cluster-policies/kustomization.yaml | 1 + .../kyverno-test.yaml | 47 +++++++ .../resources.yaml | 121 ++++++++++++++++++ 5 files changed, 296 insertions(+), 3 deletions(-) create mode 100644 k8s/bases/infrastructure/cluster-policies/best-practices/restrict-homepage-service-groups.yaml create mode 100644 tests/restrict-homepage-service-groups/kyverno-test.yaml create mode 100644 tests/restrict-homepage-service-groups/resources.yaml diff --git a/k8s/bases/apps/homepage/config-map.yaml b/k8s/bases/apps/homepage/config-map.yaml index 1430b80fa..fc7e37ba6 100644 --- a/k8s/bases/apps/homepage/config-map.yaml +++ b/k8s/bases/apps/homepage/config-map.yaml @@ -57,9 +57,22 @@ data: target: _blank # Layout order also controls render order on the page. Groups not listed # here still appear (Homepage falls back to alphabetical) but get a generic - # icon — keep this in sync with the gethomepage.dev/group annotations on - # the HTTPRoutes under k8s/bases/apps/** and k8s/**/infrastructure/** (incl. - # provider overlays, e.g. Longhorn on Hetzner) and the service entries below. + # icon AND sort below every listed group — bookmarks included — so an + # undeclared group silently lands at the bottom of the page. + # + # The SERVICE groups below are enforced at admission by the Kyverno policy + # cluster-policies/best-practices/restrict-homepage-service-groups.yaml: a + # route annotated gethomepage.dev/enabled="true" may only name a group + # declared here. Adding a service group means adding it there too, in the + # same PR — that policy carries the matching allow-list. Homepage itself has + # no way to filter discovery by group, which is why the binding lives in + # admission rather than in this file. + # + # Still keep this in sync with the gethomepage.dev/group annotations on the + # HTTPRoutes under k8s/bases/apps/** and k8s/**/infrastructure/** (incl. + # provider overlays, e.g. Longhorn on Hetzner) and the service entries below + # — and note tenant repositories carry their own routes, so a group can be + # claimed from outside this repository. layout: # First on the page: the Internal Developer Platform (Backstage and any # future self-service portals). Keep this entry first so the IDP is the @@ -77,6 +90,13 @@ data: icon: mdi-stethoscope Device Management: icon: mdi-cellphone-cog + # Sits immediately before the two providers it declares: Crossplane + # provisions the Hetzner cloud and the UniFi network, so "declare it" reads + # ahead of "here is the provider console". + Infrastructure as Code: + # `crossplane` is not in simple-icons (verified 404); use an MDI icon, as + # the Observability entry above already has to. + icon: mdi-file-cog Cloud: icon: si-hetzner Network: diff --git a/k8s/bases/infrastructure/cluster-policies/best-practices/restrict-homepage-service-groups.yaml b/k8s/bases/infrastructure/cluster-policies/best-practices/restrict-homepage-service-groups.yaml new file mode 100644 index 000000000..b726aa5e2 --- /dev/null +++ b/k8s/bases/infrastructure/cluster-policies/best-practices/restrict-homepage-service-groups.yaml @@ -0,0 +1,104 @@ +# Keeps every Homepage-discovered service inside a group the dashboard actually +# declares. +# +# Why: Homepage's group ORDER, icon and position come from the `layout` block in +# k8s/bases/apps/homepage/config-map.yaml, but the group NAME is chosen by an +# annotation on a route — often in another repository entirely. Nothing bound the +# two surfaces, so a route could invent a group, and Homepage would silently +# accept it: an undeclared group still renders, but falls to the BOTTOM of the +# page (after every declared group, bookmarks included) with a generic icon. +# The failure is cosmetic-looking and completely silent, which is why it went +# unnoticed in two places at once — `Fun` and `Infrastructure as Code` both +# rendered below the bookmark rail because neither was declared. +# +# Homepage has no native way to restrict discovery to declared groups (its +# Kubernetes config offers only `gethomepage.dev/instance` filtering), so the +# binding has to happen at admission. This is that binding: a route may only +# name a group the dashboard declares. +# +# Scope: every namespace, and deliberately NOT limited to tenant-applied +# resources — the platform's own routes are just as able to invent a group, and +# `Infrastructure as Code` (a platform route, in this repo) is exactly how this +# was found. There is no userInfo matching here, so background scanning stays on +# and existing drift surfaces in the PolicyReports rather than only at admission. +# +# SERVICE groups only. The bookmark groups in the same layout block (GitHub, +# Media, Banking, …) are intentionally absent: Homepage renders services and +# bookmarks as separate sections, so the config-map keeps the two name sets +# distinct, and a service claiming a bookmark group's name would paint that +# heading twice with different contents under each. +# +# KEEP IN SYNC with the `layout` block in k8s/bases/apps/homepage/config-map.yaml +# — adding a group there means adding it here, in the same PR. That is a +# reviewable, fail-loud edit: a route naming an undeclared group is rejected at +# admission with the list below, instead of silently sinking to the bottom of the +# dashboard. The list is duplicated rather than read from the ConfigMap on +# purpose: a Kyverno context lookup would make every annotated route in the +# cluster un-appliable if that ConfigMap ever became unreadable, trading a silent +# cosmetic bug for a reconciliation outage. +apiVersion: kyverno.io/v1 +kind: ClusterPolicy +metadata: + name: restrict-homepage-service-groups + annotations: + policies.kyverno.io/title: Restrict Homepage Service Groups + policies.kyverno.io/category: Best Practices + policies.kyverno.io/severity: low + policies.kyverno.io/subject: HTTPRoute, Ingress + policies.kyverno.io/minversion: 1.9.0 + policies.kyverno.io/description: >- + Requires every route that opts into Homepage discovery + (gethomepage.dev/enabled: "true") to name a service group declared in the + dashboard's layout, so a discovered service cannot invent a group and sink + to the bottom of the page with a generic icon. +spec: + validationFailureAction: Enforce + rules: + - name: declared-service-group + match: + any: + - resources: + kinds: + # HTTPRoute is what this platform actually uses. Ingress is + # Homepage's other discovery source and is included so a future + # Ingress cannot bypass the guard. + - HTTPRoute + - Ingress + preconditions: + all: + # Only routes that opt into the dashboard. Everything else is none of + # this policy's business. + - key: "{{ request.object.metadata.annotations.\"gethomepage.dev/enabled\" || 'false' }}" + operator: Equals + value: "true" + validate: + message: >- + A route with gethomepage.dev/enabled="true" must set + gethomepage.dev/group to a service group declared in the Homepage + layout (k8s/bases/apps/homepage/config-map.yaml): Internal Developer + Platform, Kubernetes, Observability, Security, Diagnostics, Device + Management, Infrastructure as Code, Cloud, Network, Finance, Analytics, + Personal Sites, or Customer Sites. An undeclared group still renders, + but sorts below every declared group with a generic icon. Bookmark + group names are deliberately not accepted here. + deny: + conditions: + any: + # The 'MISSING' sentinel is not in the allow-list, so an absent + # annotation is denied by the same condition as a misspelled one. + - key: "{{ request.object.metadata.annotations.\"gethomepage.dev/group\" || 'MISSING' }}" + operator: NotIn + value: + - Internal Developer Platform + - Kubernetes + - Observability + - Security + - Diagnostics + - Device Management + - Infrastructure as Code + - Cloud + - Network + - Finance + - Analytics + - Personal Sites + - Customer Sites diff --git a/k8s/bases/infrastructure/cluster-policies/kustomization.yaml b/k8s/bases/infrastructure/cluster-policies/kustomization.yaml index ed2c293ce..2885fbbbb 100644 --- a/k8s/bases/infrastructure/cluster-policies/kustomization.yaml +++ b/k8s/bases/infrastructure/cluster-policies/kustomization.yaml @@ -12,6 +12,7 @@ resources: - best-practices/disable-default-sa-automount.yaml - best-practices/disallow-latest-tag.yaml - best-practices/propagate-reloader-to-flagger-primary.yaml + - best-practices/restrict-homepage-service-groups.yaml - best-practices/restrict-tenant-issuer-refs.yaml - best-practices/restrict-tenant-route-hostnames.yaml - best-practices/restrict-tenant-secret-stores.yaml diff --git a/tests/restrict-homepage-service-groups/kyverno-test.yaml b/tests/restrict-homepage-service-groups/kyverno-test.yaml new file mode 100644 index 000000000..d537a73e9 --- /dev/null +++ b/tests/restrict-homepage-service-groups/kyverno-test.yaml @@ -0,0 +1,47 @@ +--- +apiVersion: cli.kyverno.io/v1alpha1 +kind: Test +metadata: + name: restrict-homepage-service-groups +policies: + - >- + ../../k8s/bases/infrastructure/cluster-policies/best-practices/restrict-homepage-service-groups.yaml +resources: + - resources.yaml +results: + # --- declared service groups pass --- + - policy: restrict-homepage-service-groups + rule: declared-service-group + resources: + - observability/declared-group + - crossview/infrastructure-as-code-group + kind: HTTPRoute + result: pass + + # --- undeclared, missing, bookmark-named and mis-cased groups are denied --- + - policy: restrict-homepage-service-groups + rule: declared-service-group + resources: + - doggy-countdown/undeclared-group + - whoami/missing-group + - whoami/bookmark-group-name + - whoami/wrong-case-group + kind: HTTPRoute + result: fail + + # --- the precondition's negative control: routes that never opted in --- + - policy: restrict-homepage-service-groups + rule: declared-service-group + resources: + - whoami/not-opted-in + - whoami/discovery-disabled + kind: HTTPRoute + result: skip + + # --- Ingress is guarded identically --- + - policy: restrict-homepage-service-groups + rule: declared-service-group + resources: + - whoami/ingress-undeclared-group + kind: Ingress + result: fail diff --git a/tests/restrict-homepage-service-groups/resources.yaml b/tests/restrict-homepage-service-groups/resources.yaml new file mode 100644 index 000000000..10258638d --- /dev/null +++ b/tests/restrict-homepage-service-groups/resources.yaml @@ -0,0 +1,121 @@ +--- +# A declared service group on a platform-owned route — the ordinary pass case. +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: declared-group + namespace: observability + annotations: + gethomepage.dev/enabled: "true" + gethomepage.dev/name: Coroot + gethomepage.dev/group: Observability +spec: + hostnames: + - observability.platform.devantler.tech +--- +# The group this change adds. Fails before the layout entry exists, passes after. +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: infrastructure-as-code-group + namespace: crossview + annotations: + gethomepage.dev/enabled: "true" + gethomepage.dev/name: Crossview + gethomepage.dev/group: Infrastructure as Code +spec: + hostnames: + - crossview.platform.devantler.tech +--- +# The exact drift that motivated the policy: an undeclared group, which Homepage +# accepts silently and renders below the bookmark rail. +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: undeclared-group + namespace: doggy-countdown + annotations: + gethomepage.dev/enabled: "true" + gethomepage.dev/name: Simba Countdown + gethomepage.dev/group: Fun +spec: + hostnames: + - simba.platform.devantler.tech +--- +# Opted into discovery but named no group at all. +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: missing-group + namespace: whoami + annotations: + gethomepage.dev/enabled: "true" + gethomepage.dev/name: Whoami +spec: + hostnames: + - whoami.platform.devantler.tech +--- +# A BOOKMARK group name used for a service. Declared in the layout, but under the +# bookmark half — accepting it would paint that heading twice. +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: bookmark-group-name + namespace: whoami + annotations: + gethomepage.dev/enabled: "true" + gethomepage.dev/name: Not A Service Group + gethomepage.dev/group: Banking +spec: + hostnames: + - bookmark.platform.devantler.tech +--- +# Case matters: Homepage matches the layout key exactly. +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: wrong-case-group + namespace: whoami + annotations: + gethomepage.dev/enabled: "true" + gethomepage.dev/group: personal sites +spec: + hostnames: + - case.platform.devantler.tech +--- +# Not opted into the dashboard — the policy must not touch it, even though it +# carries no group at all. This is the precondition's negative control. +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: not-opted-in + namespace: whoami +spec: + hostnames: + - internal.platform.devantler.tech +--- +# Explicitly disabled discovery, with a bogus group. Still none of our business. +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: discovery-disabled + namespace: whoami + annotations: + gethomepage.dev/enabled: "false" + gethomepage.dev/group: Nonsense +spec: + hostnames: + - disabled.platform.devantler.tech +--- +# Homepage's other discovery source, guarded the same way. +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-undeclared-group + namespace: whoami + annotations: + gethomepage.dev/enabled: "true" + gethomepage.dev/group: Fun +spec: + rules: + - host: ingress.platform.devantler.tech From 1d75f393306c8194d418de23f1bd1131e270b303 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sun, 16 Aug 2026 20:54:47 +0200 Subject: [PATCH 2/5] fix(policies): use validate.failureAction and pin the allow-list to the layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kyverno's v1 CRD on the deployed v1.18.2 calls spec.validationFailureAction "Deprecated, use validationFailureAction under the validate rule instead." It is still honoured — the live restrict-tenant-route-hostnames keeps Enforce and reports Ready — so this is debt rather than breakage, but a new policy should not add to it. The eleven existing policies are migrated separately in #3176. Adds a static guard, wired into the cluster-policy CI job. Beyond asserting the rule enforces, it pins the policy's allow-list to the Homepage layout's service groups, derived structurally as layout keys minus bookmark group names rather than by reading the section comment — so rewording that comment cannot change what the test believes. It also rejects a bookmark group name being accepted as a service group, and fails closed if either side extracts zero groups. That makes the duplicated allow-list a tested invariant instead of a documented convention, which is the better answer to the trade-off the policy comment describes. --- .github/workflows/ci.yaml | 5 ++ .../restrict-homepage-service-groups.yaml | 10 ++- .../test-restrict-homepage-service-groups.sh | 79 +++++++++++++++++++ 3 files changed, 92 insertions(+), 2 deletions(-) create mode 100755 scripts/tests/test-restrict-homepage-service-groups.sh diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0e8119e54..fe25c543c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -118,6 +118,9 @@ jobs: - 'scripts/tests/test-headlamp-plugin-removal.sh' - 'scripts/tests/test-github-config-role-activation-parity.sh' - 'scripts/tests/test-restrict-tenant-secret-stores.sh' + # The Homepage layout and the policy's allow-list are one contract: + # either side changing alone is the drift this test exists to catch. + - 'scripts/tests/test-restrict-homepage-service-groups.sh' - 'scripts/tests/test-kyverno-umami-mutation-rbac.sh' - 'scripts/tests/test-kyverno-admission-vpa.sh' - 'scripts/tests/kyverno-admission-vpa-rules.yaml' @@ -436,6 +439,8 @@ jobs: kyverno test ./tests shellcheck scripts/tests/test-restrict-tenant-secret-stores.sh bash scripts/tests/test-restrict-tenant-secret-stores.sh + shellcheck scripts/tests/test-restrict-homepage-service-groups.sh + bash scripts/tests/test-restrict-homepage-service-groups.sh shellcheck scripts/tests/test-kyverno-umami-mutation-rbac.sh bash scripts/tests/test-kyverno-umami-mutation-rbac.sh shellcheck scripts/tests/test-actual-budget-auth-route.sh diff --git a/k8s/bases/infrastructure/cluster-policies/best-practices/restrict-homepage-service-groups.yaml b/k8s/bases/infrastructure/cluster-policies/best-practices/restrict-homepage-service-groups.yaml index b726aa5e2..8a7f63930 100644 --- a/k8s/bases/infrastructure/cluster-policies/best-practices/restrict-homepage-service-groups.yaml +++ b/k8s/bases/infrastructure/cluster-policies/best-practices/restrict-homepage-service-groups.yaml @@ -45,14 +45,19 @@ metadata: policies.kyverno.io/category: Best Practices policies.kyverno.io/severity: low policies.kyverno.io/subject: HTTPRoute, Ingress - policies.kyverno.io/minversion: 1.9.0 + policies.kyverno.io/minversion: 1.13.0 policies.kyverno.io/description: >- Requires every route that opts into Homepage discovery (gethomepage.dev/enabled: "true") to name a service group declared in the dashboard's layout, so a discovered service cannot invent a group and sink to the bottom of the page with a generic icon. spec: - validationFailureAction: Enforce + # Rule-level `validate.failureAction` below, not the spec-level + # `validationFailureAction`: Kyverno's own v1 CRD describes that one as + # "Deprecated, use validationFailureAction under the validate rule instead." + # The eleven older policies here still use the deprecated form — it is honoured + # on the deployed v1.18.2 — and migrating them is tracked separately; a new + # policy should not add to that debt. rules: - name: declared-service-group match: @@ -72,6 +77,7 @@ spec: operator: Equals value: "true" validate: + failureAction: Enforce message: >- A route with gethomepage.dev/enabled="true" must set gethomepage.dev/group to a service group declared in the Homepage diff --git a/scripts/tests/test-restrict-homepage-service-groups.sh b/scripts/tests/test-restrict-homepage-service-groups.sh new file mode 100755 index 000000000..b9dda669e --- /dev/null +++ b/scripts/tests/test-restrict-homepage-service-groups.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# The Homepage layout and the Kyverno allow-list are two lists that must agree. +# The policy exists because an undeclared group fails silently; a duplicated +# allow-list that drifts would fail just as silently, so this pins them together. +# +# Service groups are derived STRUCTURALLY — every layout key that is not a +# bookmark group — rather than by reading the section comment, so moving or +# rewording that comment cannot quietly change what this test believes. +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "${script_dir}/../.." && pwd)" +config_map="${repo_root}/k8s/bases/apps/homepage/config-map.yaml" +policy="${repo_root}/k8s/bases/infrastructure/cluster-policies/best-practices/restrict-homepage-service-groups.yaml" + +for f in "${config_map}" "${policy}"; do + [ -r "$f" ] || { echo "::error::missing or unreadable: $f"; exit 1; } +done + +# --- 1. the rule must ENFORCE, via the non-deprecated field ------------------- +action="$(yq -r '.spec.rules[] | select(.name == "declared-service-group") | .validate.failureAction // "unset"' "${policy}")" +if [ "${action}" != "Enforce" ]; then + echo "::error::declared-service-group must set validate.failureAction: Enforce (found: ${action})" + exit 1 +fi + +# Kyverno's v1 CRD calls the spec-level field deprecated. A new policy using it +# would still work, but this keeps the modern form from silently regressing. +if [ "$(yq -r '.spec.validationFailureAction // "unset"' "${policy}")" != "unset" ]; then + echo "::error::policy uses the deprecated spec.validationFailureAction; use validate.failureAction" + exit 1 +fi + +# --- 2. allow-list == the layout's SERVICE groups ---------------------------- +settings="$(yq -r '.data."settings.yaml"' "${config_map}")" +bookmarks="$(yq -r '.data."bookmarks.yaml"' "${config_map}")" +[ -n "${settings}" ] && [ "${settings}" != "null" ] || { echo "::error::settings.yaml missing from the ConfigMap"; exit 1; } +[ -n "${bookmarks}" ] && [ "${bookmarks}" != "null" ] || { echo "::error::bookmarks.yaml missing from the ConfigMap"; exit 1; } + +layout_groups="$(printf '%s\n' "${settings}" | yq -r '.layout | keys | .[]' | sort -u)" +bookmark_groups="$(printf '%s\n' "${bookmarks}" | yq -r '.[] | keys | .[0]' | sort -u)" +service_groups="$(comm -23 <(printf '%s\n' "${layout_groups}") <(printf '%s\n' "${bookmark_groups}"))" + +policy_groups="$(yq -r ' + .spec.rules[] | select(.name == "declared-service-group") + | .validate.deny.conditions.any[] | .value[]' "${policy}" | sort -u)" + +# Proof-of-life: an empty side would make the comparison pass vacuously. +for pair in "layout:${layout_groups}" "bookmark:${bookmark_groups}" "service:${service_groups}" "policy:${policy_groups}"; do + name="${pair%%:*}"; body="${pair#*:}" + n="$(printf '%s\n' "${body}" | grep -c . || true)" + if [ "${n}" -lt 1 ]; then + echo "::error::extracted zero ${name} groups — the parse broke, so this check would pass vacuously" + exit 1 + fi +done + +if ! diff_out="$(diff <(printf '%s\n' "${service_groups}") <(printf '%s\n' "${policy_groups}"))"; then + echo "::error::Homepage layout service groups and the Kyverno allow-list disagree." + echo " '<' = declared in the layout but not allowed by the policy" + echo " '>' = allowed by the policy but not declared in the layout" + printf '%s\n' "${diff_out}" + exit 1 +fi + +# A bookmark group must never be accepted for a service: Homepage renders the two +# as separate sections, so a shared name paints one heading twice. +while IFS= read -r b; do + [ -n "$b" ] || continue + if printf '%s\n' "${policy_groups}" | grep -qxF -- "$b"; then + echo "::error::policy allows bookmark group '${b}' as a service group" + exit 1 + fi +done < Date: Sun, 16 Aug 2026 19:03:03 +0000 Subject: [PATCH 3/5] chore: Apply megalinter fixes --- .../test-restrict-homepage-service-groups.sh | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/scripts/tests/test-restrict-homepage-service-groups.sh b/scripts/tests/test-restrict-homepage-service-groups.sh index b9dda669e..b180c8b68 100755 --- a/scripts/tests/test-restrict-homepage-service-groups.sh +++ b/scripts/tests/test-restrict-homepage-service-groups.sh @@ -14,7 +14,10 @@ config_map="${repo_root}/k8s/bases/apps/homepage/config-map.yaml" policy="${repo_root}/k8s/bases/infrastructure/cluster-policies/best-practices/restrict-homepage-service-groups.yaml" for f in "${config_map}" "${policy}"; do - [ -r "$f" ] || { echo "::error::missing or unreadable: $f"; exit 1; } + [ -r "$f" ] || { + echo "::error::missing or unreadable: $f" + exit 1 + } done # --- 1. the rule must ENFORCE, via the non-deprecated field ------------------- @@ -34,8 +37,14 @@ fi # --- 2. allow-list == the layout's SERVICE groups ---------------------------- settings="$(yq -r '.data."settings.yaml"' "${config_map}")" bookmarks="$(yq -r '.data."bookmarks.yaml"' "${config_map}")" -[ -n "${settings}" ] && [ "${settings}" != "null" ] || { echo "::error::settings.yaml missing from the ConfigMap"; exit 1; } -[ -n "${bookmarks}" ] && [ "${bookmarks}" != "null" ] || { echo "::error::bookmarks.yaml missing from the ConfigMap"; exit 1; } +[ -n "${settings}" ] && [ "${settings}" != "null" ] || { + echo "::error::settings.yaml missing from the ConfigMap" + exit 1 +} +[ -n "${bookmarks}" ] && [ "${bookmarks}" != "null" ] || { + echo "::error::bookmarks.yaml missing from the ConfigMap" + exit 1 +} layout_groups="$(printf '%s\n' "${settings}" | yq -r '.layout | keys | .[]' | sort -u)" bookmark_groups="$(printf '%s\n' "${bookmarks}" | yq -r '.[] | keys | .[0]' | sort -u)" @@ -47,7 +56,8 @@ policy_groups="$(yq -r ' # Proof-of-life: an empty side would make the comparison pass vacuously. for pair in "layout:${layout_groups}" "bookmark:${bookmark_groups}" "service:${service_groups}" "policy:${policy_groups}"; do - name="${pair%%:*}"; body="${pair#*:}" + name="${pair%%:*}" + body="${pair#*:}" n="$(printf '%s\n' "${body}" | grep -c . || true)" if [ "${n}" -lt 1 ]; then echo "::error::extracted zero ${name} groups — the parse broke, so this check would pass vacuously" From f988bfbe6fd1d8de2e67fb91742b8f12e57cabe3 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sun, 16 Aug 2026 21:11:25 +0200 Subject: [PATCH 4/5] fix(tests): write the ConfigMap guards as explicit if-statements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's shellcheck flags `A && B || C` (SC2015) on the two ConfigMap presence checks; the local shellcheck 0.11.0 emits nothing for the same file and exits 0, so this passed locally and failed in CI. The finding is right regardless of version — in `A && B || C` the C branch also runs when A succeeds and B fails, which is not the intended if-then-else. Written as explicit if-statements both versions agree, and the ablation still fires. --- scripts/tests/test-restrict-homepage-service-groups.sh | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/scripts/tests/test-restrict-homepage-service-groups.sh b/scripts/tests/test-restrict-homepage-service-groups.sh index b9dda669e..04b24262d 100755 --- a/scripts/tests/test-restrict-homepage-service-groups.sh +++ b/scripts/tests/test-restrict-homepage-service-groups.sh @@ -34,8 +34,14 @@ fi # --- 2. allow-list == the layout's SERVICE groups ---------------------------- settings="$(yq -r '.data."settings.yaml"' "${config_map}")" bookmarks="$(yq -r '.data."bookmarks.yaml"' "${config_map}")" -[ -n "${settings}" ] && [ "${settings}" != "null" ] || { echo "::error::settings.yaml missing from the ConfigMap"; exit 1; } -[ -n "${bookmarks}" ] && [ "${bookmarks}" != "null" ] || { echo "::error::bookmarks.yaml missing from the ConfigMap"; exit 1; } +if [ -z "${settings}" ] || [ "${settings}" = "null" ]; then + echo "::error::settings.yaml missing from the ConfigMap" + exit 1 +fi +if [ -z "${bookmarks}" ] || [ "${bookmarks}" = "null" ]; then + echo "::error::bookmarks.yaml missing from the ConfigMap" + exit 1 +fi layout_groups="$(printf '%s\n' "${settings}" | yq -r '.layout | keys | .[]' | sort -u)" bookmark_groups="$(printf '%s\n' "${bookmarks}" | yq -r '.[] | keys | .[0]' | sort -u)" From 9d3e72cbdb34bb14d3e9c06e8b7b963b6de6b2c4 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sun, 16 Aug 2026 21:24:13 +0200 Subject: [PATCH 5/5] test(policies): pin the deny message to the allow-list too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rule's message names the valid groups so a rejected apply need not go read the layout. That made three copies of the list — layout, allow-list, message — and only the first two were pinned to each other. A stale message goes wrong exactly when a group is added, which is when someone is most likely to be reading it. Ablation: removing one group from the message while leaving the allow-list intact now fails. --- .../test-restrict-homepage-service-groups.sh | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/scripts/tests/test-restrict-homepage-service-groups.sh b/scripts/tests/test-restrict-homepage-service-groups.sh index 8aa06f3ec..0c3931362 100755 --- a/scripts/tests/test-restrict-homepage-service-groups.sh +++ b/scripts/tests/test-restrict-homepage-service-groups.sh @@ -85,5 +85,25 @@ done <