Files
terdut-server/charts/terdut-server/templates/deployment.yaml
T
Niklas Ye 9029d48584
CI / chart (pull_request) Successful in 2s
CI / security (pull_request) Failing after 19s
CI / test (pull_request) Successful in 5m34s
Let an operator authenticate with a seeded key, and reset the schema
- TERDUT_OPERATOR_KEY creates or re-keys the instance-scoped service account
  "terdut-operator" at every start, so terdut-operator needs no bootstrap
  handshake. An instance-scoped account now acts as owner of every team's
  configuration, but is not a member of any team.
- POST /api/teams takes an external_id (instance service accounts only) and
  is idempotent on it, so automation finds its own team again after a crash
  instead of adopting by display name. GET /api/teams?name= is removed.
- Integration and dead man's switch names are unique per team (409). The
  escalation PUT accepts usernames and resolves them itself.
- The 18 migrations are squashed into 001_schema.sql, with no Default team.
  TERDUT_DEADMAN_* and the env seeding of switches are removed: teams carry
  their own. Existing development databases must be recreated.

Security and robustness:
- GET /api/users no longer returns other people's email or ntfy topic to
  non-admins.
- The access log records the route pattern, so integration keys and ack
  tokens in the path are not written to the log. Server errors are logged.
- Rate limits take the client address TERDUT_TRUSTED_PROXIES hops from the
  right of X-Forwarded-For instead of trusting the first, forgeable entry.
- /api/bootstrap runs in a transaction under an advisory lock, so two
  concurrent calls cannot both create an administrator.
- API key last_used_at is written at most every five minutes.

Cleanup: remove GET /api/incidents/{id}/alerts, unused exports, SQLite
remnants in comments and config.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
2026-10-09 14:56:13 +02:00

168 lines
7.2 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 }}"
{{- 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_TRUSTED_PROXIES
value: {{ .Values.trustedProxies | 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