deployment.yaml 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
  1. # Stateless multi-replica Deployment (task 46).
  2. #
  3. # - 3 replicas sharing one Postgres + one Redis: no replica holds state
  4. # another needs (tokens live in Redis under `{state}`-tagged keys).
  5. # - `serve` NEVER migrates; the separate `migrate-job.yaml` owns the schema.
  6. # Readiness (`/health/ready`) compares `_sqlx_migrations` against the
  7. # binary's embedded migrations, so new replicas stay out of traffic until
  8. # the Job has applied the schema (expand/contract keeps N-1 serving).
  9. # - Probes hit the PLAINTEXT pod-IP health listener (`health_addr`,
  10. # default `0.0.0.0:8081`); the public listener stays TLS-only.
  11. # - `terminationGracePeriodSeconds: 45` covers the server's 1s propagation
  12. # grace + 30s drain budget (`src/serve.rs`: PROPAGATION_GRACE, DRAIN_DEADLINE).
  13. # - Host hardening: non-root 65532, read-only rootfs, ALL capabilities
  14. # dropped, `allowPrivilegeEscalation: false`, `RuntimeDefault` seccomp, and
  15. # `automountServiceAccountToken: false` (the server never calls the
  16. # Kubernetes API). `networkpolicy.yaml` default-denies pod egress except DNS
  17. # + Postgres + Redis.
  18. # - Secret material is file-mounted, never env-injected: the token pepper,
  19. # TOTP key, mandatory PII key, and the Postgres/Redis passwords are projected
  20. # from `d-oauth-secrets` into a tmpfs volume at `/run/secrets` (`defaultMode:
  21. # 0400`, group 65532 via `fsGroup`) and read through their `*_FILE`
  22. # variables. The passwordless DSNs come from the ConfigMap. The inline
  23. # `D_OAUTH_TOKEN_PEPPER` / `D_OAUTH_TOTP_ENCRYPTION_KEY` forms remain
  24. # supported and take precedence when both are set (`src/config/build.rs`),
  25. # but this deployment never sets them, so no secret enters the process
  26. # environment (`/proc/self/environ`, children, crash dumps, `docker
  27. # inspect`) — a `kubectl exec` reads nothing from the environment.
  28. # - The image is digest-pinned. The all-zero digest below is a deliberate
  29. # placeholder: the `pin-digest` job in `.github/workflows/supply-chain.yml`
  30. # writes the real signed-build digest into the `images` entry of
  31. # `kustomization.yaml`, which overrides this field on every workload.
  32. # `:latest` is never a shippable reference.
  33. apiVersion: apps/v1
  34. kind: Deployment
  35. metadata:
  36. name: d-oauth-server
  37. namespace: d-oauth
  38. labels:
  39. app.kubernetes.io/name: d-oauth-server
  40. app.kubernetes.io/part-of: d-oauth
  41. spec:
  42. replicas: 3
  43. strategy:
  44. type: RollingUpdate
  45. rollingUpdate:
  46. # Keep every old replica serving while the migration Job + new
  47. # replicas converge (expand/contract upgrades).
  48. maxUnavailable: 0
  49. maxSurge: 1
  50. selector:
  51. matchLabels:
  52. app.kubernetes.io/name: d-oauth-server
  53. template:
  54. metadata:
  55. labels:
  56. app.kubernetes.io/name: d-oauth-server
  57. app.kubernetes.io/part-of: d-oauth
  58. spec:
  59. terminationGracePeriodSeconds: 45
  60. # The app never talks to the Kubernetes API; the auto-mounted service
  61. # account token is pure attack surface, so it stays disabled.
  62. automountServiceAccountToken: false
  63. securityContext:
  64. runAsNonRoot: true
  65. runAsUser: 65532
  66. runAsGroup: 65532
  67. # Secret volumes are tmpfs; fsGroup makes the 0400 files readable by
  68. # the runtime group so the pepper/TOTP keys can be mounted as files.
  69. fsGroup: 65532
  70. fsGroupChangePolicy: OnRootMismatch
  71. seccompProfile:
  72. type: RuntimeDefault
  73. containers:
  74. - name: server
  75. # Digest-pinned reference. The all-zero digest
  76. # is a deliberate placeholder, not a shippable reference: it fails
  77. # closed instead of resolving a mutable tag. The `pin-digest` job in
  78. # `.github/workflows/supply-chain.yml` writes the real signed-build
  79. # digest into the `images` entry of `kustomization.yaml`; kustomize
  80. # then applies that digest to this container and the migrate Job.
  81. image: ghcr.io/syavne/d-oauth-server@sha256:0000000000000000000000000000000000000000000000000000000000000000
  82. imagePullPolicy: IfNotPresent
  83. ports:
  84. - name: public-tls
  85. containerPort: 8080
  86. protocol: TCP
  87. - name: health
  88. containerPort: 8081
  89. protocol: TCP
  90. envFrom:
  91. - configMapRef:
  92. name: d-oauth-config
  93. env:
  94. # Every secret is a file, never an env value: code execution in
  95. # the container cannot read the DB/Redis credentials or key
  96. # material from the environment. The DSNs (non-secret) come from
  97. # the ConfigMap above.
  98. - name: D_OAUTH_DATABASE__PASSWORD_FILE
  99. value: /run/secrets/database-password
  100. - name: D_OAUTH_REDIS__PASSWORD_FILE
  101. value: /run/secrets/redis-password
  102. - name: D_OAUTH_TOKEN_PEPPER_FILE
  103. value: /run/secrets/token-pepper
  104. - name: D_OAUTH_TOTP_ENCRYPTION_KEY_FILE
  105. value: /run/secrets/totp-encryption-key
  106. - name: D_OAUTH_PII_ENCRYPTION_KEY_FILE
  107. value: /run/secrets/pii-encryption-key
  108. # Liveness is dependency-free by construction (`/health/live`
  109. # touches no Postgres/Redis), so a dependency outage never
  110. # triggers a restart storm — readiness absorbs it instead.
  111. startupProbe:
  112. httpGet:
  113. path: /health/live
  114. port: 8081
  115. failureThreshold: 30
  116. periodSeconds: 2
  117. livenessProbe:
  118. httpGet:
  119. path: /health/live
  120. port: 8081
  121. periodSeconds: 10
  122. timeoutSeconds: 2
  123. failureThreshold: 3
  124. # Fail-closed: 503 while Postgres/Redis are unreachable OR the
  125. # `_sqlx_migrations` version does not match the binary (i.e. the
  126. # migrate Job has not run yet on a fresh/rolling deploy).
  127. readinessProbe:
  128. httpGet:
  129. path: /health/ready
  130. port: 8081
  131. periodSeconds: 5
  132. timeoutSeconds: 2
  133. failureThreshold: 3
  134. successThreshold: 1
  135. resources:
  136. requests:
  137. cpu: 100m
  138. memory: 128Mi
  139. limits:
  140. cpu: "1"
  141. memory: 512Mi
  142. securityContext:
  143. allowPrivilegeEscalation: false
  144. readOnlyRootFilesystem: true
  145. capabilities:
  146. drop:
  147. - ALL
  148. volumeMounts:
  149. - name: certs
  150. mountPath: /certs
  151. readOnly: true
  152. - name: secrets
  153. mountPath: /run/secrets
  154. readOnly: true
  155. volumes:
  156. - name: certs
  157. secret:
  158. secretName: d-oauth-tls
  159. defaultMode: 0o400
  160. # tmpfs-backed secret volume. `defaultMode: 0400` plus `fsGroup:
  161. # 65532` leaves the projected files readable only by the runtime
  162. # user/group. Every secret is projected here; nothing is injected
  163. # through a `secretKeyRef` env.
  164. - name: secrets
  165. secret:
  166. secretName: d-oauth-secrets
  167. defaultMode: 0o400
  168. items:
  169. - key: database-password
  170. path: database-password
  171. - key: redis-password
  172. path: redis-password
  173. - key: token-pepper
  174. path: token-pepper
  175. - key: totp-encryption-key
  176. path: totp-encryption-key
  177. - key: pii-encryption-key
  178. path: pii-encryption-key
  179. topologySpreadConstraints:
  180. - maxSkew: 1
  181. topologyKey: topology.kubernetes.io/zone
  182. whenUnsatisfiable: ScheduleAnyway
  183. labelSelector:
  184. matchLabels:
  185. app.kubernetes.io/name: d-oauth-server
  186. - maxSkew: 1
  187. topologyKey: kubernetes.io/hostname
  188. whenUnsatisfiable: ScheduleAnyway
  189. labelSelector:
  190. matchLabels:
  191. app.kubernetes.io/name: d-oauth-server