deployment.yaml 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  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, and the Postgres/Redis passwords are projected from
  20. # `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/d-oauth/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. # Liveness is dependency-free by construction (`/health/live`
  107. # touches no Postgres/Redis), so a dependency outage never
  108. # triggers a restart storm — readiness absorbs it instead.
  109. startupProbe:
  110. httpGet:
  111. path: /health/live
  112. port: 8081
  113. failureThreshold: 30
  114. periodSeconds: 2
  115. livenessProbe:
  116. httpGet:
  117. path: /health/live
  118. port: 8081
  119. periodSeconds: 10
  120. timeoutSeconds: 2
  121. failureThreshold: 3
  122. # Fail-closed: 503 while Postgres/Redis are unreachable OR the
  123. # `_sqlx_migrations` version does not match the binary (i.e. the
  124. # migrate Job has not run yet on a fresh/rolling deploy).
  125. readinessProbe:
  126. httpGet:
  127. path: /health/ready
  128. port: 8081
  129. periodSeconds: 5
  130. timeoutSeconds: 2
  131. failureThreshold: 3
  132. successThreshold: 1
  133. resources:
  134. requests:
  135. cpu: 100m
  136. memory: 128Mi
  137. limits:
  138. cpu: "1"
  139. memory: 512Mi
  140. securityContext:
  141. allowPrivilegeEscalation: false
  142. readOnlyRootFilesystem: true
  143. capabilities:
  144. drop:
  145. - ALL
  146. volumeMounts:
  147. - name: certs
  148. mountPath: /certs
  149. readOnly: true
  150. - name: secrets
  151. mountPath: /run/secrets
  152. readOnly: true
  153. volumes:
  154. - name: certs
  155. secret:
  156. secretName: d-oauth-tls
  157. defaultMode: 0o400
  158. # tmpfs-backed secret volume. `defaultMode: 0400` plus `fsGroup:
  159. # 65532` leaves the projected files readable only by the runtime
  160. # user/group. Every secret is projected here; nothing is injected
  161. # through a `secretKeyRef` env.
  162. - name: secrets
  163. secret:
  164. secretName: d-oauth-secrets
  165. defaultMode: 0o400
  166. items:
  167. - key: database-password
  168. path: database-password
  169. - key: redis-password
  170. path: redis-password
  171. - key: token-pepper
  172. path: token-pepper
  173. - key: totp-encryption-key
  174. path: totp-encryption-key
  175. topologySpreadConstraints:
  176. - maxSkew: 1
  177. topologyKey: topology.kubernetes.io/zone
  178. whenUnsatisfiable: ScheduleAnyway
  179. labelSelector:
  180. matchLabels:
  181. app.kubernetes.io/name: d-oauth-server
  182. - maxSkew: 1
  183. topologyKey: kubernetes.io/hostname
  184. whenUnsatisfiable: ScheduleAnyway
  185. labelSelector:
  186. matchLabels:
  187. app.kubernetes.io/name: d-oauth-server