# Stateless multi-replica Deployment (task 46). # # - 3 replicas sharing one Postgres + one Redis: no replica holds state # another needs (tokens live in Redis under `{state}`-tagged keys). # - `serve` NEVER migrates; the separate `migrate-job.yaml` owns the schema. # Readiness (`/health/ready`) compares `_sqlx_migrations` against the # binary's embedded migrations, so new replicas stay out of traffic until # the Job has applied the schema (expand/contract keeps N-1 serving). # - Probes hit the PLAINTEXT pod-IP health listener (`health_addr`, # default `0.0.0.0:8081`); the public listener stays TLS-only. # - `terminationGracePeriodSeconds: 45` covers the server's 1s propagation # grace + 30s drain budget (`src/serve.rs`: PROPAGATION_GRACE, DRAIN_DEADLINE). # - Host hardening: non-root 65532, read-only rootfs, ALL capabilities # dropped, `allowPrivilegeEscalation: false`, `RuntimeDefault` seccomp, and # `automountServiceAccountToken: false` (the server never calls the # Kubernetes API). `networkpolicy.yaml` default-denies pod egress except DNS # + Postgres + Redis. # - Secret material is file-mounted, never env-injected: the token pepper, # TOTP key, mandatory PII key, and the Postgres/Redis passwords are projected # from `d-oauth-secrets` into a tmpfs volume at `/run/secrets` (`defaultMode: # 0400`, group 65532 via `fsGroup`) and read through their `*_FILE` # variables. The passwordless DSNs come from the ConfigMap. The inline # `D_OAUTH_TOKEN_PEPPER` / `D_OAUTH_TOTP_ENCRYPTION_KEY` forms remain # supported and take precedence when both are set (`src/config/build.rs`), # but this deployment never sets them, so no secret enters the process # environment (`/proc/self/environ`, children, crash dumps, `docker # inspect`) — a `kubectl exec` reads nothing from the environment. # - The image reference is the mutable `:latest` tag. Nodes pull it on first # use (`imagePullPolicy: IfNotPresent`); pin a digest instead when # reproducible production rollouts are required. apiVersion: apps/v1 kind: Deployment metadata: name: d-oauth-server namespace: d-oauth labels: app.kubernetes.io/name: d-oauth-server app.kubernetes.io/part-of: d-oauth spec: replicas: 3 strategy: type: RollingUpdate rollingUpdate: # Keep every old replica serving while the migration Job + new # replicas converge (expand/contract upgrades). maxUnavailable: 0 maxSurge: 1 selector: matchLabels: app.kubernetes.io/name: d-oauth-server template: metadata: labels: app.kubernetes.io/name: d-oauth-server app.kubernetes.io/part-of: d-oauth spec: terminationGracePeriodSeconds: 45 # The app never talks to the Kubernetes API; the auto-mounted service # account token is pure attack surface, so it stays disabled. automountServiceAccountToken: false securityContext: runAsNonRoot: true runAsUser: 65532 runAsGroup: 65532 # Secret volumes are tmpfs; fsGroup makes the 0400 files readable by # the runtime group so the pepper/TOTP keys can be mounted as files. fsGroup: 65532 fsGroupChangePolicy: OnRootMismatch seccompProfile: type: RuntimeDefault containers: - name: server # Mutable `:latest` reference: the newest published image is what # pods run once the node pulls it. image: ghcr.io/syavne/d-oauth-server:latest imagePullPolicy: IfNotPresent ports: - name: public-tls containerPort: 8080 protocol: TCP - name: health containerPort: 8081 protocol: TCP envFrom: - configMapRef: name: d-oauth-config env: # Every secret is a file, never an env value: code execution in # the container cannot read the DB/Redis credentials or key # material from the environment. The DSNs (non-secret) come from # the ConfigMap above. - name: D_OAUTH_DATABASE__PASSWORD_FILE value: /run/secrets/database-password - name: D_OAUTH_REDIS__PASSWORD_FILE value: /run/secrets/redis-password - name: D_OAUTH_TOKEN_PEPPER_FILE value: /run/secrets/token-pepper - name: D_OAUTH_TOTP_ENCRYPTION_KEY_FILE value: /run/secrets/totp-encryption-key - name: D_OAUTH_PII_ENCRYPTION_KEY_FILE value: /run/secrets/pii-encryption-key # Liveness is dependency-free by construction (`/health/live` # touches no Postgres/Redis), so a dependency outage never # triggers a restart storm — readiness absorbs it instead. startupProbe: httpGet: path: /health/live port: 8081 failureThreshold: 30 periodSeconds: 2 livenessProbe: httpGet: path: /health/live port: 8081 periodSeconds: 10 timeoutSeconds: 2 failureThreshold: 3 # Fail-closed: 503 while Postgres/Redis are unreachable OR the # `_sqlx_migrations` version does not match the binary (i.e. the # migrate Job has not run yet on a fresh/rolling deploy). readinessProbe: httpGet: path: /health/ready port: 8081 periodSeconds: 5 timeoutSeconds: 2 failureThreshold: 3 successThreshold: 1 resources: requests: cpu: 100m memory: 128Mi limits: cpu: "1" memory: 512Mi securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true capabilities: drop: - ALL volumeMounts: - name: certs mountPath: /certs readOnly: true - name: secrets mountPath: /run/secrets readOnly: true volumes: - name: certs secret: secretName: d-oauth-tls defaultMode: 0o400 # tmpfs-backed secret volume. `defaultMode: 0400` plus `fsGroup: # 65532` leaves the projected files readable only by the runtime # user/group. Every secret is projected here; nothing is injected # through a `secretKeyRef` env. - name: secrets secret: secretName: d-oauth-secrets defaultMode: 0o400 items: - key: database-password path: database-password - key: redis-password path: redis-password - key: token-pepper path: token-pepper - key: totp-encryption-key path: totp-encryption-key - key: pii-encryption-key path: pii-encryption-key topologySpreadConstraints: - maxSkew: 1 topologyKey: topology.kubernetes.io/zone whenUnsatisfiable: ScheduleAnyway labelSelector: matchLabels: app.kubernetes.io/name: d-oauth-server - maxSkew: 1 topologyKey: kubernetes.io/hostname whenUnsatisfiable: ScheduleAnyway labelSelector: matchLabels: app.kubernetes.io/name: d-oauth-server