fix(apps): semaphore fsGroup — schema 1.3.0 podSecurityContext for non-root PVC write

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 <[email protected]>
This commit is contained in:
jakman85andClaude Opus 4.8 committed 2026-08-26 02:00:45 -05:00
1 parent 77496f9a8e
commit 28498f1099
4 files changed
+54 -3

No files matched your search

+5
View File
@@ -119,6 +119,11 @@ spec:
volumeMounts:
- name: config
mountPath: /etc/semaphore
securityContext:
runAsUser: 1001
runAsGroup: 0
runAsNonRoot: true
fsGroup: 1001
volumes:
- name: config
persistentVolumeClaim:
+24 -2
View File
@@ -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:
+10
View File
@@ -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:
+15 -1
View File
@@ -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).",