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:
1 parent
77496f9a8e
commit
28498f1099
4 files changed
+54
-3
No files matched your search
@@ -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
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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).",
|
||||
|
||||
Reference in new issue
Block a user