9029d48584
- 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
168 lines
7.2 KiB
YAML
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
|
|
|
|
|