--- # Crafty Controller pod = TWO containers: # 1. crafty — the panel + every Minecraft server it creates (Java + Bedrock) run as # SUBPROCESSES inside THIS container. Serves its web UI over HTTPS (self-signed) # on :8443. Never exposed directly — only the sidecar and the game-port LB reach it. # 2. oauth2-proxy — a per-pod REVERSE-PROXY (Option B, platform/oauth2-proxy/README.md). The shared # Gateway routes crafty.home.keithjackson.dev -> this sidecar (:4180); the sidecar # authenticates the browser against Keycloak (homelab realm, group platform-admins) # and only THEN proxies to the co-located Crafty UI at https://127.0.0.1:8443. This # is how Crafty's UI (which has NO native OIDC) is Keycloak-gated without ext_authz # on the shared Gateway (impossible on Cilium 1.16.5 — see the oauth2-proxy README). # # Rendered by its OWN non-gating post-platform Flux Kustomization (clusters/base/crafty.yaml, # dependsOn platform-config, wait:false) — a leaf UI must NEVER sit in the gating `platform` tier # (DECISIONS-LOG: "Headlamp gating stall"). apiVersion: apps/v1 kind: Deployment metadata: name: crafty namespace: crafty labels: app.kubernetes.io/name: crafty spec: replicas: 1 # Crafty owns a single RWO Longhorn volume with live SQLite + running server subprocesses; a second # replica would double-mount/corrupt it. Recreate (not RollingUpdate) so the old pod releases the RWO # PVC before the new one attaches. strategy: type: Recreate revisionHistoryLimit: 3 selector: matchLabels: app.kubernetes.io/name: crafty template: metadata: labels: app.kubernetes.io/name: crafty spec: automountServiceAccountToken: false # ── IDENTITY / STORAGE PERMISSIONS (the seerr fsGroup lesson, applied) ─────────────────────── # The Crafty image runs as the non-root user `crafty` (UID 1000) whose PRIMARY GROUP is root # (GID 0): its Dockerfile does `useradd -g root -M crafty` + `chown -R crafty:root /crafty` and # makes /crafty group-writable — the OpenShift/K8s "arbitrary-UID, root-group-perms" pattern the # Crafty docs call out. A fresh Longhorn PVC is root:root and NOT group-writable, so the crafty # process (GID 0) could not create /crafty/servers/* etc. -> CrashLoop (same failure seerr hit as # UID 1000 on a root:root PVC). fsGroup=0 makes the kubelet group-own the mounted volume to GID 0, # mark it group-writable + SGID, and add GID 0 as a supplemental group, so the crafty user can # write — Longhorn's CSIDriver fsGroupPolicy: ReadWriteOnceWithFSType applies fsGroup to this RWO # filesystem volume. runAsUser/runAsGroup pin the image's own crafty:root identity; runAsNonRoot # asserts it. (HUMAN runtime check at bring-up: confirm the pod writes the DB and starts a server; # if the image's crafty UID is not 1000, adjust runAsUser — see README.) securityContext: runAsNonRoot: true runAsUser: 1000 runAsGroup: 0 fsGroup: 0 seccompProfile: type: RuntimeDefault containers: # ─────────────────────────────── Crafty panel + MC servers ────────────────────────────────── - name: crafty # registry.gitlab.com/crafty-controller/crafty-4 — official image. Pinned to the current # stable release (4.10.8, 2026-07-29; tags are bare semver, NO `v` prefix). Renovate maintains. image: registry.gitlab.com/crafty-controller/crafty-4:4.10.8 imagePullPolicy: IfNotPresent env: - name: TZ value: "America/Chicago" ports: - { name: https-ui, containerPort: 8443, protocol: TCP } # Starter game ports the operator assigns to servers created in the panel. Declared here for # documentation + to back the game-port LoadBalancer (service-l4.yaml). Add more as needed. - { name: mc-java-0, containerPort: 25565, protocol: TCP } - { name: mc-java-1, containerPort: 25566, protocol: TCP } - { name: mc-java-2, containerPort: 25567, protocol: TCP } - { name: mc-bedr-0, containerPort: 19132, protocol: UDP } - { name: mc-bedr-1, containerPort: 19133, protocol: UDP } - { name: mc-bedr-2, containerPort: 19134, protocol: UDP } resources: requests: cpu: 250m # Raised 1Gi -> 3Gi when the first Minecraft server ("homelab", Paper 26.2) was created: # that JVM runs with -Xms2000M -Xmx2000M, so the container's steady-state floor is now the # panel (~100Mi) PLUS a permanently-committed 2 GiB heap plus JVM overhead. Measured # cgroup memory.current with the server idle-but-running: 2.86Gi. A 1Gi request understated # that by ~3x, which both misleads the scheduler and makes this Burstable pod an early # eviction candidate under node memory pressure (kubelet ranks by usage-over-request). memory: 3Gi # Minecraft JVMs are memory-hungry; cap generously for a starter set of small servers. # Headroom check for the 4Gi cap: 2 GiB heap + ~0.5 GiB JVM non-heap (metaspace, code cache, # GC structures, thread stacks, mmap'd region files) + ~0.1 GiB panel = ~2.9Gi observed, # leaving ~1.2Gi. A 3 GiB heap would have landed near 4Gi and risked an OOMKill, which is # why the server was sized at a 2 GiB heap rather than larger. Raise BOTH this limit and the # server's own memory in Crafty together if a bigger heap is ever wanted. limits: memory: 4Gi securityContext: allowPrivilegeEscalation: false capabilities: drop: - ALL # readOnlyRootFilesystem intentionally UNSET: Crafty's launcher writes runtime state under # /crafty (app cache, per-server working dirs) beyond the mounted subPaths; hardening it # needs writable emptyDirs and is a deferred, runtime-verified follow-up (as headlamp notes). volumeMounts: # ONE Longhorn PVC mounted five times by subPath — exactly the dirs Crafty's docker-compose # maps. Everything else under /crafty is read-only image content. - { name: crafty-data, mountPath: /crafty/servers, subPath: servers } - { name: crafty-data, mountPath: /crafty/backups, subPath: backups } - { name: crafty-data, mountPath: /crafty/logs, subPath: logs } - { name: crafty-data, mountPath: /crafty/import, subPath: import } - { name: crafty-data, mountPath: /crafty/app/config, subPath: config } # Crafty inits its SQLite DB + Tornado server on first boot (can take ~30-60s). TCP-probe the # HTTPS UI port (an HTTPS GET would fight the self-signed cert). Generous startup budget. startupProbe: tcpSocket: port: https-ui periodSeconds: 10 failureThreshold: 30 livenessProbe: tcpSocket: port: https-ui periodSeconds: 20 failureThreshold: 6 readinessProbe: tcpSocket: port: https-ui periodSeconds: 15 # ─────────────────────────── oauth2-proxy reverse-proxy sidecar ───────────────────────────── - name: oauth2-proxy # quay.io/oauth2-proxy/oauth2-proxy — same authenticator family as platform/oauth2-proxy, but # here in per-app REVERSE-PROXY mode (--upstream is the co-located Crafty UI, not static://202). image: quay.io/oauth2-proxy/oauth2-proxy:v7.8.1 imagePullPolicy: IfNotPresent args: - --http-address=0.0.0.0:4180 - --reverse-proxy=true - --provider=oidc - --oidc-issuer-url=https://keycloak.${BASE_DOMAIN}/realms/homelab # Gate the co-located Crafty UI. Crafty serves HTTPS with a SELF-SIGNED cert, so skip verify # on this in-pod (127.0.0.1) hop; the browser-facing TLS is the trusted wildcard at the Gateway. - --upstream=https://127.0.0.1:8443 - --ssl-upstream-insecure-skip-verify=true - --email-domain=* - --scope=openid email profile - --oidc-groups-claim=groups # R-IDP-06: a valid Keycloak session is NOT enough — the user must ALSO be in platform-admins, # else /oauth2 returns 403. (Matches the platform oauth2-proxy floor.) - --allowed-group=platform-admins # Single provider => 302 straight to Keycloak, no oauth2-proxy interstitial button. - --skip-provider-button=true - --redirect-url=https://crafty.${BASE_DOMAIN}/oauth2/callback - --cookie-domain=.${BASE_DOMAIN} - --whitelist-domain=.${BASE_DOMAIN} - --cookie-secure=true - --pass-access-token=true - --set-xauthrequest=true env: # client-id / client-secret MUST match the Keycloak `crafty` client # (keycloak-realm-clients secret, crafty-client-secret). cookie-secret is a random 32-byte # value. All three from the SOPS-encrypted crafty-oauth2-proxy Secret, by name only (R-IDP-04). - name: OAUTH2_PROXY_CLIENT_ID valueFrom: secretKeyRef: { name: crafty-oauth2-proxy, key: client-id } - name: OAUTH2_PROXY_CLIENT_SECRET valueFrom: secretKeyRef: { name: crafty-oauth2-proxy, key: client-secret } - name: OAUTH2_PROXY_COOKIE_SECRET valueFrom: secretKeyRef: { name: crafty-oauth2-proxy, key: cookie-secret } ports: - { name: http, containerPort: 4180, protocol: TCP } resources: requests: cpu: 25m memory: 64Mi limits: memory: 128Mi securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true capabilities: drop: - ALL livenessProbe: httpGet: path: /ping port: http initialDelaySeconds: 5 periodSeconds: 15 readinessProbe: httpGet: path: /ping port: http initialDelaySeconds: 5 periodSeconds: 15 volumes: - name: crafty-data persistentVolumeClaim: claimName: crafty-data