f344bb707b
Each alert carries a generatorURL pointing at the Prometheus rule that fired it. The server now parses that link, queries the same Prometheus for the rule's expression and draws it under the alert, with the current value beside it. The browser never talks to Prometheus, so the CSP stays at connect-src 'self'. The URL comes out of a webhook payload, so whoever holds an integration key chooses it. Querying it is therefore opt-in and bounded by an allowlist, TERDUT_PROMETHEUS_ALLOWED_HOSTS (names, "*" as one label, a leading "**" as several, IPs, CIDRs). The check runs on the address the dialer is about to use, a name that resolves to loopback or link-local is refused even when it matches, and redirects and proxy variables are ignored. A worker takes a snapshot when an incident opens and another when it closes, so the graph outlives Prometheus' retention. It finds its work in the database rather than being triggered from the webhook, so ingestion is untouched and a restart loses nothing. While an incident is open the metrics endpoint also queries live, cached for 15 seconds. The rule expression keeps its threshold, so Prometheus returns data only while the alert condition holds; "no data" for the current value means the condition cleared. Stripping the threshold needs a PromQL parser and is left out. Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
176 lines
7.7 KiB
YAML
176 lines
7.7 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.prometheus.allowedHosts }}
|
|
- name: TERDUT_PROMETHEUS_ALLOWED_HOSTS
|
|
value: {{ join "," .Values.prometheus.allowedHosts | quote }}
|
|
- name: TERDUT_PROMETHEUS_PREROLL
|
|
value: {{ .Values.prometheus.preroll | quote }}
|
|
- name: TERDUT_PROMETHEUS_TIMEOUT
|
|
value: {{ .Values.prometheus.timeout | quote }}
|
|
{{- end }}
|
|
{{- 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
|
|
|
|
|