Files
home-network-k3s-impl/platform/crafty/deployment.yaml
T
2026-11-08 06:45:31 +00:00

221 lines
13 KiB
YAML

---
# 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.11.0
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.15.6
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 ISOLATION (bug fix 2026-09-29) ────────────────────────────────────────────
# This sidecar and the SHARED forward-auth oauth2-proxy (platform/oauth2-proxy) are
# INDEPENDENT instances holding DIFFERENT cookie-secrets (verified in-cluster: the two
# `cookie-secret` values differ, so neither can decrypt the other's cookie). Both used
# to emit oauth2-proxy's DEFAULT cookie names (`_oauth2_proxy` and `_oauth2_proxy_csrf`)
# scoped to `.${BASE_DOMAIN}`, so the browser kept ONE pair of cookies for BOTH proxies:
# whichever authenticated last overwrote the other's session, and the loser then logged
# "No valid authentication in request" and bounced to Keycloak -- which, thanks to the
# live Keycloak SSO session, silently redirected back and overwrote the cookie again.
# That mutual eviction presents EXACTLY as the reported symptom: "I signed in to
# Keycloak but I am still not signed in to the app."
# Two independent fixes, both applied here (only this side changes -- renaming the
# SHARED proxy's cookie would sign every other gated app out):
# 1. --cookie-name: a unique name, so the two sessions cannot alias at all.
# 2. --cookie-domain: HOST-scoped. A per-app reverse proxy has no reason to broadcast
# its session to every other host in the zone; the shared proxy keeps the wide
# scope because cross-host forward-auth is precisely its job.
# --whitelist-domain deliberately stays zone-wide: it governs permitted post-login
# REDIRECT targets, not cookie scope.
- --cookie-name=_oauth2_proxy_crafty
- --cookie-domain=crafty.${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