92959cac38
Part of the same security-hardening pass as the last three commits. Neither the Dockerfile nor the chart's Deployment set any securityContext at all, so the container ran as root by default — scratch has no /etc/passwd for a USER directive to resolve against, so nobody had set one. Dockerfile now ends with USER 65532:65532 (numeric, since scratch has no user database; 65532 is the common "nonroot" convention, distroless's own uid). The chart's Deployment adds a matching pod-level securityContext (runAsNonRoot, runAsUser/runAsGroup: 65532, seccompProfile: RuntimeDefault) plus per-container hardening (allowPrivilegeEscalation: false, capabilities dropped, readOnlyRootFilesystem: true) on both the app container and the wait-for-postgres init container — neither writes anything to disk, so the root filesystem can stay read-only. Verified with helm-lint and a manual `helm template` render of both the terdut-server and terdut-demo charts. Not yet verified: an actual pod starting with these in place — readOnlyRootFilesystem is exactly where a non-obvious write (a temp file, a cache dir) would surface as a crash rather than a lint error, so that needs a real rollout to confirm. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
172 lines
7.4 KiB
YAML
172 lines
7.4 KiB
YAML
---
|
|
apiVersion: apps/v1
|
|
kind: Deployment
|
|
metadata:
|
|
name: {{ include "terdut-server.fullname" . }}
|
|
labels:
|
|
{{- include "terdut-server.labels" . | nindent 4 }}
|
|
spec:
|
|
replicas: {{ .Values.replicaCount }}
|
|
selector:
|
|
matchLabels:
|
|
{{- include "terdut-server.selectorLabels" . | nindent 6 }}
|
|
# RollingUpdate, not Recreate: the sweeper, notifier and migration runner
|
|
# each take a Postgres advisory lock around their own pass, and new-incident
|
|
# creation on the first webhook for a brand-new groupKey resolves its own
|
|
# insert conflict -- so two replicas overlapping during a rollout no longer
|
|
# double-page, race a migration, or drop a webhook payload (v0.36.0). No
|
|
# explicit maxUnavailable/maxSurge: the 25%/25% default rounds to 0/1 at
|
|
# replicaCount: 2, which is zero-downtime already.
|
|
strategy:
|
|
type: RollingUpdate
|
|
template:
|
|
metadata:
|
|
labels:
|
|
{{- include "terdut-server.selectorLabels" . | nindent 8 }}
|
|
spec:
|
|
enableServiceLinks: false
|
|
# Pod-wide default; both containers below run as this UID regardless of
|
|
# what their own image would otherwise pick (postgres:17-alpine's
|
|
# pg_isready needs no particular user, and 65532 is what the app image
|
|
# itself runs as now — see the Dockerfile's USER). seccompProfile here
|
|
# rather than per-container: there is no reason it would ever differ
|
|
# between them.
|
|
securityContext:
|
|
runAsNonRoot: true
|
|
runAsUser: 65532
|
|
runAsGroup: 65532
|
|
seccompProfile:
|
|
type: RuntimeDefault
|
|
{{- if .Values.database.waitForPostgres.enabled }}
|
|
initContainers:
|
|
- name: wait-for-postgres
|
|
image: "{{ .Values.database.waitForPostgres.image.repository }}:{{ .Values.database.waitForPostgres.image.tag }}"
|
|
imagePullPolicy: {{ .Values.database.waitForPostgres.image.pullPolicy }}
|
|
# No capability this loop needs, and nothing in it writes to disk:
|
|
# sh, pg_isready, echo and sleep all run read-only.
|
|
securityContext:
|
|
allowPrivilegeEscalation: false
|
|
readOnlyRootFilesystem: true
|
|
capabilities:
|
|
drop: ["ALL"]
|
|
env:
|
|
- name: TERDUT_DB_DSN
|
|
value: {{ required "database.dsn is required" .Values.database.dsn | quote }}
|
|
command:
|
|
- sh
|
|
- -c
|
|
- |
|
|
until pg_isready -d "$TERDUT_DB_DSN"; do
|
|
echo "wait-for-postgres: not ready yet, retrying in 2s"
|
|
sleep 2
|
|
done
|
|
{{- end }}
|
|
containers:
|
|
- name: terdut-server
|
|
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
|
|
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
|
# scratch, nothing to write: the binary keeps no local state and
|
|
# writes nothing to disk, so the root filesystem can stay read-only.
|
|
securityContext:
|
|
allowPrivilegeEscalation: false
|
|
readOnlyRootFilesystem: true
|
|
capabilities:
|
|
drop: ["ALL"]
|
|
ports:
|
|
- name: http
|
|
containerPort: {{ .Values.service.port }}
|
|
protocol: TCP
|
|
env:
|
|
- name: TERDUT_ADDR
|
|
value: ":{{ .Values.service.port }}"
|
|
- name: TERDUT_DB_DSN
|
|
value: {{ required "database.dsn is required" .Values.database.dsn | quote }}
|
|
{{- if .Values.database.passwordSecret.name }}
|
|
# The password reaches pgx through libpq's environment variable
|
|
# rather than through the DSN, so it stays out of the rendered
|
|
# manifest. pgx fills in from PG* whatever the DSN leaves out.
|
|
- name: PGPASSWORD
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: {{ .Values.database.passwordSecret.name }}
|
|
key: {{ .Values.database.passwordSecret.key }}
|
|
{{- end }}
|
|
- name: TERDUT_STALE_AFTER
|
|
value: "{{ .Values.sweeper.staleAfter }}"
|
|
- name: TERDUT_ARCHIVE_AFTER
|
|
value: "{{ .Values.sweeper.archiveAfter }}"
|
|
- name: TERDUT_DEADMAN_MATCHERS
|
|
value: "{{ .Values.deadman.matchers }}"
|
|
- name: TERDUT_DEADMAN_TIMEOUT
|
|
value: "{{ .Values.deadman.timeout }}"
|
|
- name: TERDUT_DEADMAN_SEVERITY
|
|
value: "{{ .Values.deadman.severity }}"
|
|
{{- if .Values.notify.ntfyUrl }}
|
|
- name: TERDUT_NTFY_URL
|
|
value: "{{ .Values.notify.ntfyUrl }}"
|
|
- name: TERDUT_NTFY_FALLBACK_TOPIC
|
|
value: "{{ .Values.notify.fallbackTopic }}"
|
|
- name: TERDUT_NOTIFY_REPEAT
|
|
value: "{{ .Values.notify.repeatEvery }}"
|
|
{{- if .Values.notify.tokenSecret.name }}
|
|
- name: TERDUT_NTFY_TOKEN
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: {{ .Values.notify.tokenSecret.name }}
|
|
key: {{ .Values.notify.tokenSecret.key }}
|
|
{{- end }}
|
|
{{- end }}
|
|
# Set whether or not ntfy is: single sign-on builds its redirect URI
|
|
# from it, and sessions use it to decide the cookie's Secure flag.
|
|
- name: TERDUT_PUBLIC_URL
|
|
value: "{{ .Values.notify.publicUrl | default (printf "https://%s" .Values.networking.hostname) }}"
|
|
- name: TERDUT_PASSWORD_LOGIN
|
|
value: {{ .Values.passwordLogin | quote }}
|
|
- name: TERDUT_OPERATOR_MODE
|
|
value: {{ .Values.operatorMode | quote }}
|
|
{{- if .Values.oidc.enabled }}
|
|
- name: TERDUT_OIDC_ISSUER
|
|
value: {{ required "oidc.issuer is required when oidc.enabled" .Values.oidc.issuer | quote }}
|
|
- name: TERDUT_OIDC_CLIENT_ID
|
|
value: {{ required "oidc.clientId is required when oidc.enabled" .Values.oidc.clientId | quote }}
|
|
- name: TERDUT_OIDC_CLIENT_SECRET
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: {{ required "oidc.clientSecret.name is required when oidc.enabled" .Values.oidc.clientSecret.name }}
|
|
key: {{ .Values.oidc.clientSecret.key }}
|
|
- name: TERDUT_OIDC_NAME
|
|
value: {{ .Values.oidc.name | quote }}
|
|
- name: TERDUT_OIDC_SCOPES
|
|
value: {{ .Values.oidc.scopes | quote }}
|
|
- name: TERDUT_OIDC_USERNAME_CLAIM
|
|
value: {{ .Values.oidc.usernameClaim | quote }}
|
|
- name: TERDUT_OIDC_EMAIL_CLAIM
|
|
value: {{ .Values.oidc.emailClaim | quote }}
|
|
- name: TERDUT_OIDC_GROUPS_CLAIM
|
|
value: {{ .Values.oidc.groupsClaim | quote }}
|
|
- name: TERDUT_OIDC_TRUST_EMAIL
|
|
value: {{ .Values.oidc.trustEmail | quote }}
|
|
- name: TERDUT_OIDC_SESSION_MAX_AGE
|
|
value: {{ .Values.oidc.sessionMaxAge | quote }}
|
|
{{- if .Values.oidc.allowedGroups }}
|
|
- name: TERDUT_OIDC_ALLOWED_GROUPS
|
|
value: {{ join "," .Values.oidc.allowedGroups | quote }}
|
|
{{- end }}
|
|
{{- if .Values.oidc.adminGroup }}
|
|
- name: TERDUT_OIDC_ADMIN_GROUP
|
|
value: {{ .Values.oidc.adminGroup | quote }}
|
|
{{- end }}
|
|
{{- end }}
|
|
livenessProbe:
|
|
httpGet:
|
|
path: /healthz
|
|
port: http
|
|
initialDelaySeconds: 5
|
|
readinessProbe:
|
|
httpGet:
|
|
path: /healthz
|
|
port: http
|
|
initialDelaySeconds: 5
|
|
|
|
|