From 28498f109973889611fd37f786fa5d76758bdf13 Mon Sep 17 00:00:00 2001 From: Keith Jackson Date: Wed, 26 Aug 2026 02:00:45 -0500 Subject: [PATCH] =?UTF-8?q?fix(apps):=20semaphore=20fsGroup=20=E2=80=94=20?= =?UTF-8?q?schema=201.3.0=20podSecurityContext=20for=20non-root=20PVC=20wr?= =?UTF-8?q?ite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The semaphoreui/semaphore image runs as non-root UID 1001 / GID 0 and writes its generated config.json into the config dir on boot. A mounted Longhorn PVC is root:root, so UID 1001 got "permission denied" and the pod crash-looped. First-class the fix into the render contract (same mechanism the retired gitea-bootstrap-patch was replaced by): schema 1.3.0 adds an optional pod-level `podSecurityContext`, emitted verbatim onto spec.template.spec.securityContext by render.py. Absent => every other app renders byte-identical (verified). semaphore declares fsGroup=1001 (kubelet group-owns the PVC + adds the supplemental group so UID 1001 can write) plus runAsUser/runAsGroup/runAsNonRoot pinning the image identity. render.py selftest extended (verbatim render + absence). L0 (schema+kyverno 17/17), L1 3/3, lint all green. Co-Authored-By: Claude Opus 4.8 --- apps/semaphore.yaml | 5 +++++ catalog/render.py | 26 ++++++++++++++++++++++++-- catalog/semaphore.yaml | 10 ++++++++++ schemas/servicedefinition.schema.json | 16 +++++++++++++++- 4 files changed, 54 insertions(+), 3 deletions(-) diff --git a/apps/semaphore.yaml b/apps/semaphore.yaml index d7dd09d..f750f94 100644 --- a/apps/semaphore.yaml +++ b/apps/semaphore.yaml @@ -119,6 +119,11 @@ spec: volumeMounts: - name: config mountPath: /etc/semaphore + securityContext: + runAsUser: 1001 + runAsGroup: 0 + runAsNonRoot: true + fsGroup: 1001 volumes: - name: config persistentVolumeClaim: diff --git a/catalog/render.py b/catalog/render.py index 431d0df..a125531 100644 --- a/catalog/render.py +++ b/catalog/render.py @@ -251,6 +251,14 @@ def r_deployment(s: dict) -> dict: container["lifecycle"] = s["lifecycle"] pod_spec = {"containers": [container]} + + # podSecurityContext (schema-optional): the k8s PodSecurityContext is emitted VERBATIM onto the + # pod spec. Its primary use is `fsGroup`, which makes a mounted Longhorn PVC group-writable by a + # non-root image (e.g. semaphore runs as UID 1001 and cannot otherwise write its config PVC). + # Absent => the key is never set, so apps that declare none render byte-identical. + if s.get("podSecurityContext"): + pod_spec["securityContext"] = s["podSecurityContext"] + if volumes: pod_spec["volumes"] = volumes @@ -1132,11 +1140,17 @@ def _selftest() -> int: "lifecycle": { "postStart": {"exec": {"command": ["/bin/sh", "-c", "echo hi"]}} }, + "podSecurityContext": {"fsGroup": 1001, "runAsUser": 1001}, } ) - ec = next( + envd_pod = next( d[1] for d in envd_docs if d[1]["kind"] == "Deployment" - )["spec"]["template"]["spec"]["containers"][0] + )["spec"]["template"]["spec"] + check( + envd_pod.get("securityContext") == {"fsGroup": 1001, "runAsUser": 1001}, + "podSecurityContext must render verbatim onto the pod spec", + ) + ec = envd_pod["containers"][0] check( ec.get("env") == [ {"name": "PLAIN", "value": "literal"}, @@ -1164,6 +1178,14 @@ def _selftest() -> int: and "lifecycle" not in plain_c, "an SD without env/envFrom/lifecycle must not gain those container keys", ) + plain_pod = next( + d[1] for d in render({"name": "plain2", "source": {"image": "a:1"}}) + if d[1]["kind"] == "Deployment" + )["spec"]["template"]["spec"] + check( + "securityContext" not in plain_pod, + "an SD without podSecurityContext must not gain a pod securityContext", + ) if failures: for f in failures: diff --git a/catalog/semaphore.yaml b/catalog/semaphore.yaml index 511c280..eb78054 100644 --- a/catalog/semaphore.yaml +++ b/catalog/semaphore.yaml @@ -66,6 +66,16 @@ env: secretKeyRef: name: semaphore-bootstrap key: access-key-encryption +# The semaphoreui/semaphore image runs as the non-root user UID 1001 / GID 0 (tini -> server-wrapper), +# and on boot the wrapper writes its generated config.json into the config dir. A mounted Longhorn PVC +# is root:root, so UID 1001 cannot write it -> the pod panics ("permission denied"). fsGroup=1001 makes +# the kubelet group-own the mounted PVC to 1001 and add it as a supplemental group so the process can +# write; runAsUser/runAsGroup pin the image's own identity, runAsNonRoot asserts it (R-STATE-01/02). +podSecurityContext: + runAsUser: 1001 + runAsGroup: 0 + runAsNonRoot: true + fsGroup: 1001 exposure: internal # LAN-only (internal DNS + Gateway on LAN); not edge-forwarded (docs/09; R-DNS/R-NET) egress: internet # normal WAN egress (docs/03; R-NET-05) — clones repos, installs Galaxy roles storage: diff --git a/schemas/servicedefinition.schema.json b/schemas/servicedefinition.schema.json index 9f839d7..0504ec9 100644 --- a/schemas/servicedefinition.schema.json +++ b/schemas/servicedefinition.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://home.keithjackson.dev/schemas/servicedefinition.schema.json", "title": "ServiceDefinition", - "description": "Operator-facing onboarding contract for the home k3s + Longhorn platform. One ServiceDefinition per file under catalog/. Captures INTENT; the platform compiles it into concrete resources (see schemas/renderer-contract.md). Canonical source of truth: spec docs/13-service-catalog.md. Serves R-GITOPS-02; ties into R-NET-*, R-STATE-*, R-TLS-*, R-DNS-*, R-OBS-*, R-UPD-*, R-IDP-*. Schema version: 1.2.0 (adds optional per-container env/envFrom/lifecycle for the container kind — k8s EnvVar/EnvFromSource/Lifecycle shapes emitted verbatim onto the app container; unblocks headless-install/DB-backed apps e.g. gitea/nextcloud/semaphore/unifi/minecraft). 1.1.0 added the shape invariant exposure:public => auth.mode!=none (docs/13 v1.1 / docs/15 / R-IDP-02).", + "description": "Operator-facing onboarding contract for the home k3s + Longhorn platform. One ServiceDefinition per file under catalog/. Captures INTENT; the platform compiles it into concrete resources (see schemas/renderer-contract.md). Canonical source of truth: spec docs/13-service-catalog.md. Serves R-GITOPS-02; ties into R-NET-*, R-STATE-*, R-TLS-*, R-DNS-*, R-OBS-*, R-UPD-*, R-IDP-*. Schema version: 1.3.0 (adds optional pod-level podSecurityContext for the container kind — k8s PodSecurityContext emitted verbatim onto the pod spec; its fsGroup unblocks non-root images e.g. semaphore/UID-1001 writing a mounted Longhorn PVC). 1.2.0 added optional per-container env/envFrom/lifecycle for the container kind — k8s EnvVar/EnvFromSource/Lifecycle shapes emitted verbatim onto the app container; unblocks headless-install/DB-backed apps e.g. gitea/nextcloud/semaphore/unifi/minecraft. 1.1.0 added the shape invariant exposure:public => auth.mode!=none (docs/13 v1.1 / docs/15 / R-IDP-02).", "type": "object", "additionalProperties": false, "required": ["name", "source"], @@ -125,6 +125,20 @@ "preStop": { "$ref": "#/$defs/execHandler" } } }, + "podSecurityContext": { + "type": "object", + "description": "Optional pod-level SecurityContext for the container kind (docs/13). Mirrors the k8s PodSecurityContext shape and is emitted verbatim onto the pod spec (spec.template.spec.securityContext). Its primary use is `fsGroup`, which makes a mounted Longhorn PVC group-writable by a non-root image (e.g. semaphore runs as UID 1001 and cannot otherwise write its config PVC). Absent => no pod securityContext (existing apps render byte-identical). Ignored for kind=vm.", + "additionalProperties": false, + "minProperties": 1, + "properties": { + "runAsUser": { "type": "integer", "description": "UID the container processes run as." }, + "runAsGroup": { "type": "integer", "description": "Primary GID the container processes run as." }, + "runAsNonRoot": { "type": "boolean", "description": "Require the container to run as a non-root UID (validated at admission)." }, + "fsGroup": { "type": "integer", "description": "Supplemental group applied to mounted volumes so they are group-owned + group-writable (chowned by the kubelet), letting a non-root image write a PVC." }, + "fsGroupChangePolicy": { "type": "string", "enum": ["Always", "OnRootMismatch"], "description": "When the kubelet recursively applies fsGroup ownership to a volume." }, + "supplementalGroups": { "type": "array", "items": { "type": "integer" }, "description": "Additional GIDs added to the first process in each container." } + } + }, "exposure": { "type": "string", "description": "internal = LAN-only (internal DNS + Gateway on LAN); public = additionally edge port-forwarded 80/443 (docs/09; R-DNS/R-NET).",