deployment.yaml 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189
  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 and
  19. # TOTP key are projected from `d-oauth-secrets` into a tmpfs volume at
  20. # `/run/secrets` (`defaultMode: 0400`, group 65532 via `fsGroup`) and read
  21. # through `D_OAUTH_TOKEN_PEPPER_FILE` / `D_OAUTH_TOTP_ENCRYPTION_KEY_FILE`.
  22. # The inline `D_OAUTH_TOKEN_PEPPER` / `D_OAUTH_TOTP_ENCRYPTION_KEY` forms
  23. # remain supported and take precedence when both are set
  24. # (`src/config/build.rs`), but this deployment never sets them, so no key
  25. # material enters the process environment (`/proc/self/environ`, children,
  26. # crash dumps, `docker inspect`).
  27. # - The image is digest-pinned. The all-zero digest below is a deliberate
  28. # placeholder: the `pin-digest` job in `.github/workflows/supply-chain.yml`
  29. # writes the real signed-build digest into the `images` entry of
  30. # `kustomization.yaml`, which overrides this field on every workload.
  31. # `:latest` is never a shippable reference.
  32. apiVersion: apps/v1
  33. kind: Deployment
  34. metadata:
  35. name: d-oauth-server
  36. namespace: d-oauth
  37. labels:
  38. app.kubernetes.io/name: d-oauth-server
  39. app.kubernetes.io/part-of: d-oauth
  40. spec:
  41. replicas: 3
  42. strategy:
  43. type: RollingUpdate
  44. rollingUpdate:
  45. # Keep every old replica serving while the migration Job + new
  46. # replicas converge (expand/contract upgrades).
  47. maxUnavailable: 0
  48. maxSurge: 1
  49. selector:
  50. matchLabels:
  51. app.kubernetes.io/name: d-oauth-server
  52. template:
  53. metadata:
  54. labels:
  55. app.kubernetes.io/name: d-oauth-server
  56. app.kubernetes.io/part-of: d-oauth
  57. spec:
  58. terminationGracePeriodSeconds: 45
  59. # The app never talks to the Kubernetes API; the auto-mounted service
  60. # account token is pure attack surface, so it stays disabled.
  61. automountServiceAccountToken: false
  62. securityContext:
  63. runAsNonRoot: true
  64. runAsUser: 65532
  65. runAsGroup: 65532
  66. # Secret volumes are tmpfs; fsGroup makes the 0400 files readable by
  67. # the runtime group so the pepper/TOTP keys can be mounted as files.
  68. fsGroup: 65532
  69. fsGroupChangePolicy: OnRootMismatch
  70. seccompProfile:
  71. type: RuntimeDefault
  72. containers:
  73. - name: server
  74. # Digest-pinned reference. The all-zero digest
  75. # is a deliberate placeholder, not a shippable reference: it fails
  76. # closed instead of resolving a mutable tag. The `pin-digest` job in
  77. # `.github/workflows/supply-chain.yml` writes the real signed-build
  78. # digest into the `images` entry of `kustomization.yaml`; kustomize
  79. # then applies that digest to this container and the migrate Job.
  80. image: ghcr.io/d-oauth/d-oauth-server@sha256:0000000000000000000000000000000000000000000000000000000000000000
  81. imagePullPolicy: IfNotPresent
  82. ports:
  83. - name: public-tls
  84. containerPort: 8080
  85. protocol: TCP
  86. - name: health
  87. containerPort: 8081
  88. protocol: TCP
  89. envFrom:
  90. - configMapRef:
  91. name: d-oauth-config
  92. env:
  93. - name: D_OAUTH_DATABASE__URL
  94. valueFrom:
  95. secretKeyRef:
  96. name: d-oauth-secrets
  97. key: database-url
  98. - name: D_OAUTH_REDIS__URL
  99. valueFrom:
  100. secretKeyRef:
  101. name: d-oauth-secrets
  102. key: redis-url
  103. # File-mounted key material: the values stay
  104. # in the tmpfs secret volume, never in the process environment.
  105. # The inline D_OAUTH_TOKEN_PEPPER / D_OAUTH_TOTP_ENCRYPTION_KEY
  106. # forms are still supported and take precedence when both are set
  107. # (`src/config/build.rs`); this deployment deliberately uses files.
  108. - name: D_OAUTH_TOKEN_PEPPER_FILE
  109. value: /run/secrets/token-pepper
  110. - name: D_OAUTH_TOTP_ENCRYPTION_KEY_FILE
  111. value: /run/secrets/totp-encryption-key
  112. # Liveness is dependency-free by construction (`/health/live`
  113. # touches no Postgres/Redis), so a dependency outage never
  114. # triggers a restart storm — readiness absorbs it instead.
  115. startupProbe:
  116. httpGet:
  117. path: /health/live
  118. port: 8081
  119. failureThreshold: 30
  120. periodSeconds: 2
  121. livenessProbe:
  122. httpGet:
  123. path: /health/live
  124. port: 8081
  125. periodSeconds: 10
  126. timeoutSeconds: 2
  127. failureThreshold: 3
  128. # Fail-closed: 503 while Postgres/Redis are unreachable OR the
  129. # `_sqlx_migrations` version does not match the binary (i.e. the
  130. # migrate Job has not run yet on a fresh/rolling deploy).
  131. readinessProbe:
  132. httpGet:
  133. path: /health/ready
  134. port: 8081
  135. periodSeconds: 5
  136. timeoutSeconds: 2
  137. failureThreshold: 3
  138. successThreshold: 1
  139. resources:
  140. requests:
  141. cpu: 100m
  142. memory: 128Mi
  143. limits:
  144. cpu: "1"
  145. memory: 512Mi
  146. securityContext:
  147. allowPrivilegeEscalation: false
  148. readOnlyRootFilesystem: true
  149. capabilities:
  150. drop:
  151. - ALL
  152. volumeMounts:
  153. - name: certs
  154. mountPath: /certs
  155. readOnly: true
  156. - name: secrets
  157. mountPath: /run/secrets
  158. readOnly: true
  159. volumes:
  160. - name: certs
  161. secret:
  162. secretName: d-oauth-tls
  163. defaultMode: 0o400
  164. # tmpfs-backed secret volume. `defaultMode:
  165. # 0400` plus `fsGroup: 65532` leaves the projected files readable only
  166. # by the runtime user/group. Only the two key files are projected;
  167. # database-url and redis-url remain env secretKeyRefs by design.
  168. - name: secrets
  169. secret:
  170. secretName: d-oauth-secrets
  171. defaultMode: 0o400
  172. items:
  173. - key: token-pepper
  174. path: token-pepper
  175. - key: totp-encryption-key
  176. path: totp-encryption-key
  177. topologySpreadConstraints:
  178. - maxSkew: 1
  179. topologyKey: topology.kubernetes.io/zone
  180. whenUnsatisfiable: ScheduleAnyway
  181. labelSelector:
  182. matchLabels:
  183. app.kubernetes.io/name: d-oauth-server
  184. - maxSkew: 1
  185. topologyKey: kubernetes.io/hostname
  186. whenUnsatisfiable: ScheduleAnyway
  187. labelSelector:
  188. matchLabels:
  189. app.kubernetes.io/name: d-oauth-server