Phase 4: SSO scaffolding, RBAC enforcement, tenant-scoped dashboards, audit logging, K8s deployment

RBAC (api/internal/authz) is live on /query and /dashboards, backed by a
new enterprise/ module (session issuance, audit logging, RBAC storage,
OIDC/SAML protocol wiring) that core never imports -- only calls over
HTTP. Found and fixed a real cross-tenant vulnerability in dashboards
(no tenant_id filtering at all) while writing the threat model doc.

Two things are explicitly NOT done, documented rather than hidden:
tenant isolation for log data itself (/query still shares one ClickHouse
connection and Tantivy index across every tenant -- RBAC controls who
can query, not what a query can see), and human SSO login (protocol
wiring exists, no HTTP handler calls it yet). See
docs/security/threat-model.md and docs/phase-4-runbook.md.

Also adds deploy/ (Go Operator + Helm chart, validated offline only --
no cluster was reachable in this environment).
This commit is contained in:
2026-08-13 22:16:59 -07:00
parent 9435115ab7
commit 3eb0f4c589
116 changed files with 8589 additions and 126 deletions
+12
View File
@@ -0,0 +1,12 @@
apiVersion: v2
name: sentry
description: >-
Sentry: Kubernetes-native distributed log aggregation. Phase 4 adds
multi-tenant-aware deployment (per-tenant ClickHouse credential Secrets
via the tenant-operator, optional enterprise-auth) on top of the same
single-cluster topology Phases 0-3 ran under docker-compose -- see
deploy/README.md for what "multi-tenant-aware" does and doesn't mean
here.
type: application
version: 0.4.0
appVersion: "phase-4"
+85
View File
@@ -0,0 +1,85 @@
# deploy/helm/sentry
A Helm chart covering every `docker-compose.yml` service (Redpanda,
ClickHouse, Postgres, ingest, search, api, alerting, web) plus, when
`enterprise.enabled: true`: enterprise-auth, the `deploy/operator`
tenant-operator, and `Tenant` CRs from `values.tenants`. See
`/deploy/README.md` for what "multi-tenant-aware" does and doesn't mean
at this layer, and its verification-status section before trusting this
against a real cluster.
This chart never builds images -- push every image its `values.yaml`
references to a registry the cluster can pull from first, same division
of labor as `docker compose build` vs. `docker compose up`.
## Startup ordering
`docker-compose.yml` uses `depends_on: condition: service_healthy` /
`service_completed_successfully` to sequence startup (e.g. `api` waits
for `clickhouse-migrate` to actually finish, not just for `clickhouse` to
be reachable). This chart approximates that more loosely:
- Migration Jobs (`clickhouse-migrate`, `metadata-migrate`,
`redpanda-provision`) are plain `Job` resources (not Helm hooks --
making the StatefulSets they depend on into hooks too, to get
ordering, would break `helm upgrade`/`helm uninstall`'s normal
ownership tracking of stateful resources, a worse tradeoff), with
`backoffLimit: 6` so they retry a few times if their dependency isn't
up yet.
- App Deployments get an `initContainer` that busy-waits for their
dependency's **TCP port**, not for a specific Job's completion (see
`templates/_helpers.tpl`'s `sentry.waitForTCP`) -- this covers "is
ClickHouse/Postgres/Redpanda up" but not "has the migration Job
actually finished."
- The gap that leaves (a pod starts before its migration has completed)
is covered by every Go service here already calling `os.Exit(1)` on a
failed startup DB ping (see e.g. `api/cmd/api/main.go`) --
Kubernetes' pod restart policy retries with backoff until the schema
is ready. This is a real, working, but *looser* guarantee than
docker-compose's explicit ordering -- documented here rather than
implied to be equivalent.
## Trying the two-tenant example
```sh
# Quote each --set value -- zsh globs an unquoted tenants[0] as a
# pattern and fails with "no matches found."
helm install sentry . --include-crds \
--set enterprise.enabled=true \
--set tenantOperator.enabled=true \
--set 'tenants[0].name=acme' --set 'tenants[0].displayName=Acme Corp' \
--set 'tenants[1].name=globex' --set 'tenants[1].displayName=Globex Corporation'
kubectl get tenants
kubectl get secret sentry-tenant-acme-clickhouse sentry-tenant-globex-clickhouse
```
This proves the K8s-side half of Phase 4's "two tenants... with their
own users, roles, dashboards" exit criteria (`/CLAUDE.md`) -- a real
per-tenant credential Secret exists for each. It does **not** by itself
give either tenant a working login, dashboard, or ClickHouse database:
those need the OIDC/SAML login handlers, `internal/tenantprovision`, and
`internal/rbacstore` wiring the Phase 4 task 5 summary names as deferred.
## `web`'s image needs rebuilding per environment
`web` is a static SvelteKit build (`adapter-static`) -- its three API
base URLs (`VITE_API_BASE_URL`/`VITE_ALERTING_API_BASE_URL`/
`VITE_ENTERPRISE_AUTH_BASE_URL`) are baked in at **image build time**
(`web/Dockerfile`'s build args), not read from the container's
environment at runtime. `values.yaml`'s `web.builtWithApiBaseURL` etc.
document what the image you point `web.image` at needs to have been
built with (an Ingress hostname, a LoadBalancer IP, etc.) -- this chart
has no Ingress resources and can't itself act on those values; rebuild
`web`'s image with the right build args for wherever this release is
actually reachable from a browser before pointing real users at it.
## Validating without a cluster
```sh
helm lint .
helm template sentry . --include-crds > /tmp/rendered.yaml
```
See `/deploy/README.md`'s verification section for what was actually
checked this way (and what wasn't -- no live cluster was available).
@@ -0,0 +1,93 @@
# Hand-written, not `controller-gen crd` output -- see
# api/v1alpha1/groupversion_info.go's doc comment. Kept in sync with
# api/v1alpha1/tenant_types.go by hand; api/v1alpha1/api_test.go's
# round-trip tests catch a Go/YAML drift in the *shape* of the types,
# but not a drift in this file's field descriptions/validation rules --
# review both together when either changes.
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: tenants.sentry.io
spec:
group: sentry.io
names:
kind: Tenant
listKind: TenantList
plural: tenants
singular: tenant
scope: Namespaced
versions:
- name: v1alpha1
served: true
storage: true
subresources:
status: {}
additionalPrinterColumns:
- name: Phase
type: string
jsonPath: .status.phase
- name: Age
type: date
jsonPath: .metadata.creationTimestamp
schema:
openAPIV3Schema:
type: object
description: >-
Tenant is the K8s-native representation of one Sentry tenant's
deployment-topology state -- see
deploy/operator/internal/controller/tenant_controller.go's doc
comment for what the controller does and does not manage.
properties:
apiVersion:
type: string
kind:
type: string
metadata:
type: object
spec:
type: object
required: [displayName]
properties:
displayName:
type: string
description: Human-readable only -- the object's own metadata.name is the stable identifier.
suspended:
type: boolean
description: Admin-facing lever for the Suspended phase.
default: false
status:
type: object
properties:
phase:
type: string
enum: [Provisioning, Active, Suspended, Deprovisioning]
clickHouseDatabaseName:
type: string
clickHouseSecretRef:
type: string
tantivyIndexPath:
type: string
observedGeneration:
type: integer
format: int64
conditions:
type: array
items:
type: object
required: [type, status]
properties:
type:
type: string
status:
type: string
enum: ["True", "False", "Unknown"]
reason:
type: string
message:
type: string
observedGeneration:
type: integer
format: int64
lastTransitionTime:
type: string
format: date-time
+55
View File
@@ -0,0 +1,55 @@
{{/*
Standard labels applied to every resource this chart renders.
*/}}
{{- define "sentry.labels" -}}
app.kubernetes.io/part-of: sentry
app.kubernetes.io/managed-by: {{ .Release.Service }}
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }}
{{- end -}}
{{/*
Per-component selector labels -- usage:
{{ include "sentry.selectorLabels" (list $ "api") }}
A plain string arg (the old shape this started with) can't reach
$.Release from inside the defined template -- `include`'s argument
becomes the template's entire root context, so a bare "api" string
leaves no way to get back to the chart root. A two-element list carries
both.
*/}}
{{- define "sentry.selectorLabels" -}}
{{- $root := index . 0 -}}
{{- $name := index . 1 -}}
app.kubernetes.io/name: sentry-{{ $name }}
app.kubernetes.io/instance: {{ $root.Release.Name }}
{{- end -}}
{{/*
An initContainer that busy-waits for a TCP host:port to accept
connections -- usage: {{ include "sentry.waitForTCP" (list "name-suffix" "host" "port") }}
This approximates docker-compose.yml's `depends_on: condition:
service_healthy` (waits for the dependency's process to be reachable),
but NOT `condition: service_completed_successfully` (waits for a
one-shot Job, like clickhouse-migrate, to have actually finished). That
second guarantee doesn't have a lightweight equivalent here without
giving every app pod's ServiceAccount RBAC to read Job status, which is
a lot of privilege for a startup-ordering nicety -- see
deploy/helm/sentry/README.md's "Startup ordering" section. The gap it
leaves (a pod starts before its migration Job has finished) is covered
by the app's own crash-and-restart-on-connect/schema failure: every Go
service here already os.Exit(1)s on a failed Postgres/ClickHouse ping at
startup (see e.g. api/cmd/api/main.go), so Kubernetes' restart policy
naturally retries until the schema is ready. Documented as a real,
accepted tradeoff, not implied to be a hard ordering guarantee.
*/}}
{{- define "sentry.waitForTCP" -}}
{{- $name := index . 0 -}}
{{- $host := index . 1 -}}
{{- $port := index . 2 -}}
- name: wait-for-{{ $name }}
image: busybox:1.36
command:
- sh
- -c
- until nc -z -w2 {{ $host }} {{ $port }}; do echo "waiting for {{ $host }}:{{ $port }}"; sleep 2; done
{{- end -}}
@@ -0,0 +1,81 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}-alerting
labels:
{{- include "sentry.labels" . | nindent 4 }}
{{- include "sentry.selectorLabels" (list $ "alerting") | nindent 4 }}
spec:
# See values.yaml's comment: replicas is not a real knob here yet.
replicas: {{ .Values.alerting.replicas }}
selector:
matchLabels:
{{- include "sentry.selectorLabels" (list $ "alerting") | nindent 6 }}
template:
metadata:
labels:
{{- include "sentry.selectorLabels" (list $ "alerting") | nindent 8 }}
spec:
initContainers:
{{- include "sentry.waitForTCP" (list "postgres" (printf "%s-postgres" .Release.Name) "5432") | nindent 8 }}
{{- include "sentry.waitForTCP" (list "api" (printf "%s-api" .Release.Name) "8080") | nindent 8 }}
containers:
- name: alerting
image: "{{ .Values.alerting.image.repository }}:{{ .Values.alerting.image.tag }}"
imagePullPolicy: {{ .Values.global.imagePullPolicy }}
env:
- name: POSTGRES_ADDR
value: "{{ .Release.Name }}-postgres:5432"
- name: POSTGRES_DATABASE
value: sentry_metadata
- name: POSTGRES_USERNAME
value: sentry
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-postgres
key: password
- name: API_QUERY_URL
value: "http://{{ .Release.Name }}-api:8080"
{{- if .Values.enterprise.enabled }}
# RoleService credential for POST /query, once api's
# ENTERPRISE_AUTH_URL enforcement is on -- see
# /docs/phase-4-isolation-design.md's alerting<->api gap and
# alerting/internal/queryclient's doc comment. NOT generated
# by this chart: mint one with
# `enterprise-auth -mint-service-token=alerting` (see
# enterprise/README.md) and supply it via
# --set-string alerting.apiServiceToken=... or a values
# override backed by a Secret you manage -- a chart
# generating its own long-lived service credential and
# storing it in the same release's values would defeat the
# point of it being a distinct, revocable credential.
{{- if .Values.alerting.apiServiceToken }}
- name: API_SERVICE_TOKEN
value: {{ .Values.alerting.apiServiceToken | quote }}
{{- end }}
{{- end }}
ports:
- name: http
containerPort: 8081
readinessProbe:
exec:
command: ["/alerting", "-healthcheck"]
initialDelaySeconds: 5
periodSeconds: 5
resources:
{{- toYaml .Values.alerting.resources | nindent 12 }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}-alerting
labels:
{{- include "sentry.labels" . | nindent 4 }}
{{- include "sentry.selectorLabels" (list $ "alerting") | nindent 4 }}
spec:
selector:
{{- include "sentry.selectorLabels" (list $ "alerting") | nindent 4 }}
ports:
- name: http
port: 8081
+79
View File
@@ -0,0 +1,79 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}-api
labels:
{{- include "sentry.labels" . | nindent 4 }}
{{- include "sentry.selectorLabels" (list $ "api") | nindent 4 }}
spec:
replicas: {{ .Values.api.replicas }}
selector:
matchLabels:
{{- include "sentry.selectorLabels" (list $ "api") | nindent 6 }}
template:
metadata:
labels:
{{- include "sentry.selectorLabels" (list $ "api") | nindent 8 }}
spec:
initContainers:
{{- include "sentry.waitForTCP" (list "clickhouse" (printf "%s-clickhouse" .Release.Name) "9000") | nindent 8 }}
{{- include "sentry.waitForTCP" (list "postgres" (printf "%s-postgres" .Release.Name) "5432") | nindent 8 }}
{{- include "sentry.waitForTCP" (list "search" (printf "%s-search" .Release.Name) "50052") | nindent 8 }}
containers:
- name: api
image: "{{ .Values.api.image.repository }}:{{ .Values.api.image.tag }}"
imagePullPolicy: {{ .Values.global.imagePullPolicy }}
env:
- name: CLICKHOUSE_ADDR
value: "{{ .Release.Name }}-clickhouse:9000"
- name: CLICKHOUSE_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-clickhouse
key: password
- name: SEARCH_GRPC_ADDR
value: "{{ .Release.Name }}-search:50052"
- name: POSTGRES_ADDR
value: "{{ .Release.Name }}-postgres:5432"
- name: POSTGRES_DATABASE
value: sentry_metadata
- name: POSTGRES_USERNAME
value: sentry
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-postgres
key: password
{{- if .Values.enterprise.enabled }}
# Turns on authz.RequireRole*/RequireRoleOrService enforcement
# on /query and /dashboards -- see api/internal/authz and
# /docs/phase-4-rbac-design.md. Off (unset) when
# enterprise.enabled is false, matching every nil-authorizer
# no-op default in this codebase.
- name: ENTERPRISE_AUTH_URL
value: "http://{{ .Release.Name }}-enterprise-auth:8082"
{{- end }}
ports:
- name: http
containerPort: 8080
readinessProbe:
exec:
command: ["/api", "-healthcheck"]
initialDelaySeconds: 5
periodSeconds: 5
resources:
{{- toYaml .Values.api.resources | nindent 12 }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}-api
labels:
{{- include "sentry.labels" . | nindent 4 }}
{{- include "sentry.selectorLabels" (list $ "api") | nindent 4 }}
spec:
selector:
{{- include "sentry.selectorLabels" (list $ "api") | nindent 4 }}
ports:
- name: http
port: 8080
@@ -0,0 +1,103 @@
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ .Release.Name }}-clickhouse
labels:
{{- include "sentry.labels" . | nindent 4 }}
{{- include "sentry.selectorLabels" (list $ "clickhouse") | nindent 4 }}
spec:
serviceName: {{ .Release.Name }}-clickhouse
replicas: 1
selector:
matchLabels:
{{- include "sentry.selectorLabels" (list $ "clickhouse") | nindent 6 }}
template:
metadata:
labels:
{{- include "sentry.selectorLabels" (list $ "clickhouse") | nindent 8 }}
spec:
containers:
- name: clickhouse
image: "{{ .Values.clickhouse.image.repository }}:{{ .Values.clickhouse.image.tag }}"
imagePullPolicy: {{ .Values.global.imagePullPolicy }}
env:
# Required to avoid the official image's network lockdown of
# the implicit `default` user -- see values.yaml's comment on
# this password and docker-compose.yml's original.
- name: CLICKHOUSE_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-clickhouse
key: password
ports:
- name: http
containerPort: 8123
- name: native
containerPort: 9000
volumeMounts:
- name: data
mountPath: /var/lib/clickhouse
readinessProbe:
httpGet:
path: /ping
port: http
initialDelaySeconds: 5
periodSeconds: 5
resources:
{{- toYaml .Values.clickhouse.resources | nindent 12 }}
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: {{ .Values.clickhouse.persistence.size }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}-clickhouse
labels:
{{- include "sentry.labels" . | nindent 4 }}
{{- include "sentry.selectorLabels" (list $ "clickhouse") | nindent 4 }}
spec:
clusterIP: None
selector:
{{- include "sentry.selectorLabels" (list $ "clickhouse") | nindent 4 }}
ports:
- name: http
port: 8123
- name: native
port: 9000
---
# One-shot: applies /storage/migrations/*.sql -- same image
# storage/Dockerfile builds for docker-compose.yml's clickhouse-migrate
# service. Plain Job, not a Helm hook -- see redpanda.yaml's comment and
# deploy/helm/sentry/README.md's "Startup ordering" section.
apiVersion: batch/v1
kind: Job
metadata:
name: {{ .Release.Name }}-clickhouse-migrate
labels:
{{- include "sentry.labels" . | nindent 4 }}
spec:
backoffLimit: 6
template:
metadata:
labels:
{{- include "sentry.selectorLabels" (list $ "clickhouse-migrate") | nindent 8 }}
spec:
restartPolicy: OnFailure
containers:
- name: clickhouse-migrate
image: "{{ .Values.clickhouse.migrateImage.repository }}:{{ .Values.clickhouse.migrateImage.tag }}"
imagePullPolicy: {{ .Values.global.imagePullPolicy }}
env:
- name: CLICKHOUSE_HTTP
value: "http://{{ .Release.Name }}-clickhouse:8123"
- name: CLICKHOUSE_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-clickhouse
key: password
@@ -0,0 +1,74 @@
{{- if .Values.enterprise.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}-enterprise-auth
labels:
{{- include "sentry.labels" . | nindent 4 }}
{{- include "sentry.selectorLabels" (list $ "enterprise-auth") | nindent 4 }}
spec:
replicas: {{ .Values.enterprise.replicas }}
selector:
matchLabels:
{{- include "sentry.selectorLabels" (list $ "enterprise-auth") | nindent 6 }}
template:
metadata:
labels:
{{- include "sentry.selectorLabels" (list $ "enterprise-auth") | nindent 8 }}
spec:
containers:
- name: enterprise-auth
image: "{{ .Values.enterprise.image.repository }}:{{ .Values.enterprise.image.tag }}"
imagePullPolicy: {{ .Values.global.imagePullPolicy }}
env:
- name: ENTERPRISE_SESSION_SIGNING_KEY
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-enterprise-auth
key: sessionSigningKey
{{- if .Values.enterprise.oidc.issuerURL }}
- name: OIDC_ISSUER_URL
value: {{ .Values.enterprise.oidc.issuerURL | quote }}
- name: OIDC_CLIENT_ID
value: {{ .Values.enterprise.oidc.clientID | quote }}
- name: OIDC_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-enterprise-auth
key: oidcClientSecret
- name: OIDC_REDIRECT_URL
value: {{ .Values.enterprise.oidc.redirectURL | quote }}
{{- end }}
{{- if .Values.enterprise.saml.idpMetadataURL }}
- name: SAML_ENTITY_ID
value: {{ .Values.enterprise.saml.entityID | quote }}
- name: SAML_ACS_URL
value: {{ .Values.enterprise.saml.acsURL | quote }}
- name: SAML_IDP_METADATA_URL
value: {{ .Values.enterprise.saml.idpMetadataURL | quote }}
{{- end }}
ports:
- name: http
containerPort: 8082
readinessProbe:
exec:
command: ["/enterprise-auth", "-healthcheck"]
initialDelaySeconds: 5
periodSeconds: 5
resources:
{{- toYaml .Values.enterprise.resources | nindent 12 }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}-enterprise-auth
labels:
{{- include "sentry.labels" . | nindent 4 }}
{{- include "sentry.selectorLabels" (list $ "enterprise-auth") | nindent 4 }}
spec:
selector:
{{- include "sentry.selectorLabels" (list $ "enterprise-auth") | nindent 4 }}
ports:
- name: http
port: 8082
{{- end }}
+65
View File
@@ -0,0 +1,65 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}-ingest
labels:
{{- include "sentry.labels" . | nindent 4 }}
{{- include "sentry.selectorLabels" (list $ "ingest") | nindent 4 }}
spec:
replicas: {{ .Values.ingest.replicas }}
selector:
matchLabels:
{{- include "sentry.selectorLabels" (list $ "ingest") | nindent 6 }}
template:
metadata:
labels:
{{- include "sentry.selectorLabels" (list $ "ingest") | nindent 8 }}
spec:
initContainers:
{{- include "sentry.waitForTCP" (list "redpanda" (printf "%s-redpanda" .Release.Name) "9092") | nindent 8 }}
{{- include "sentry.waitForTCP" (list "clickhouse" (printf "%s-clickhouse" .Release.Name) "9000") | nindent 8 }}
containers:
- name: ingest
image: "{{ .Values.ingest.image.repository }}:{{ .Values.ingest.image.tag }}"
imagePullPolicy: {{ .Values.global.imagePullPolicy }}
env:
- name: REDPANDA_BROKERS
value: "{{ .Release.Name }}-redpanda:9092"
- name: CLICKHOUSE_ADDR
value: "{{ .Release.Name }}-clickhouse:9000"
- name: CLICKHOUSE_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-clickhouse
key: password
ports:
- name: grpc
containerPort: 4317
{{- if .Values.ingest.tlsSecretName }}
volumeMounts:
- name: tls
mountPath: /etc/sentry-ingest
readOnly: true
{{- end }}
resources:
{{- toYaml .Values.ingest.resources | nindent 12 }}
{{- if .Values.ingest.tlsSecretName }}
volumes:
- name: tls
secret:
secretName: {{ .Values.ingest.tlsSecretName }}
{{- end }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}-ingest
labels:
{{- include "sentry.labels" . | nindent 4 }}
{{- include "sentry.selectorLabels" (list $ "ingest") | nindent 4 }}
spec:
selector:
{{- include "sentry.selectorLabels" (list $ "ingest") | nindent 4 }}
ports:
- name: grpc
port: 4317
+110
View File
@@ -0,0 +1,110 @@
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ .Release.Name }}-postgres
labels:
{{- include "sentry.labels" . | nindent 4 }}
{{- include "sentry.selectorLabels" (list $ "postgres") | nindent 4 }}
spec:
serviceName: {{ .Release.Name }}-postgres
replicas: 1
selector:
matchLabels:
{{- include "sentry.selectorLabels" (list $ "postgres") | nindent 6 }}
template:
metadata:
labels:
{{- include "sentry.selectorLabels" (list $ "postgres") | nindent 8 }}
spec:
containers:
- name: postgres
image: "{{ .Values.postgres.image.repository }}:{{ .Values.postgres.image.tag }}"
imagePullPolicy: {{ .Values.global.imagePullPolicy }}
env:
- name: POSTGRES_DB
value: sentry_metadata
- name: POSTGRES_USER
value: sentry
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-postgres
key: password
ports:
- name: postgres
containerPort: 5432
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
readinessProbe:
exec:
command: ["pg_isready", "-U", "sentry", "-d", "sentry_metadata"]
initialDelaySeconds: 5
periodSeconds: 5
resources:
{{- toYaml .Values.postgres.resources | nindent 12 }}
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: {{ .Values.postgres.persistence.size }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}-postgres
labels:
{{- include "sentry.labels" . | nindent 4 }}
{{- include "sentry.selectorLabels" (list $ "postgres") | nindent 4 }}
spec:
clusterIP: None
selector:
{{- include "sentry.selectorLabels" (list $ "postgres") | nindent 4 }}
ports:
- name: postgres
port: 5432
---
# One-shot: applies /metadata/migrations/*.sql (including Phase 4's
# tenants/users/tenant_memberships/audit_log schema) -- same image
# metadata/Dockerfile builds for docker-compose.yml's metadata-migrate
# service. Plain Job, not a Helm hook -- see redpanda.yaml's comment.
apiVersion: batch/v1
kind: Job
metadata:
name: {{ .Release.Name }}-metadata-migrate
labels:
{{- include "sentry.labels" . | nindent 4 }}
spec:
backoffLimit: 6
template:
metadata:
labels:
{{- include "sentry.selectorLabels" (list $ "metadata-migrate") | nindent 8 }}
spec:
restartPolicy: OnFailure
containers:
- name: metadata-migrate
image: "{{ .Values.postgres.migrateImage.repository }}:{{ .Values.postgres.migrateImage.tag }}"
imagePullPolicy: {{ .Values.global.imagePullPolicy }}
env:
- name: POSTGRES_HOST
value: "{{ .Release.Name }}-postgres"
- name: POSTGRES_PORT
value: "5432"
- name: POSTGRES_USER
value: sentry
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-postgres
key: password
- name: POSTGRES_DATABASE
value: sentry_metadata
- name: AUDIT_WRITER_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-postgres
key: auditWriterPassword
+108
View File
@@ -0,0 +1,108 @@
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ .Release.Name }}-redpanda
labels:
{{- include "sentry.labels" . | nindent 4 }}
{{- include "sentry.selectorLabels" (list $ "redpanda") | nindent 4 }}
spec:
serviceName: {{ .Release.Name }}-redpanda
replicas: 1
selector:
matchLabels:
{{- include "sentry.selectorLabels" (list $ "redpanda") | nindent 6 }}
template:
metadata:
labels:
{{- include "sentry.selectorLabels" (list $ "redpanda") | nindent 8 }}
spec:
containers:
- name: redpanda
image: "{{ .Values.redpanda.image.repository }}:{{ .Values.redpanda.image.tag }}"
imagePullPolicy: {{ .Values.global.imagePullPolicy }}
args:
- redpanda
- start
- --smp=1
- --memory=1G
- --reserve-memory=0M
- --overprovisioned
- --node-id=0
- --check=false
- --kafka-addr=PLAINTEXT://0.0.0.0:9092
- --advertise-kafka-addr=PLAINTEXT://{{ .Release.Name }}-redpanda:9092
ports:
- name: kafka
containerPort: 9092
- name: admin
containerPort: 9644
volumeMounts:
- name: data
mountPath: /var/lib/redpanda/data
readinessProbe:
exec:
command: ["rpk", "cluster", "health", "--exit-when-healthy"]
initialDelaySeconds: 5
periodSeconds: 5
resources:
{{- toYaml .Values.redpanda.resources | nindent 12 }}
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: {{ .Values.redpanda.persistence.size }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}-redpanda
labels:
{{- include "sentry.labels" . | nindent 4 }}
{{- include "sentry.selectorLabels" (list $ "redpanda") | nindent 4 }}
spec:
clusterIP: None
selector:
{{- include "sentry.selectorLabels" (list $ "redpanda") | nindent 4 }}
ports:
- name: kafka
port: 9092
- name: admin
port: 9644
---
# One-shot: creates the sentry.logs.raw topic. Same image
# transport/Dockerfile builds for docker-compose.yml's redpanda-provision
# service. Deliberately a plain Job, not a Helm hook -- see
# deploy/helm/sentry/README.md's "Startup ordering" section for why
# (StatefulSets-as-hooks breaks helm upgrade/uninstall's ownership
# tracking of stateful resources). backoffLimit gives it room to retry
# until redpanda's StatefulSet is actually ready; ingest/search's own
# crash-and-restart-on-connect-failure covers the rest of the ordering,
# same as every dependency in this chart.
apiVersion: batch/v1
kind: Job
metadata:
name: {{ .Release.Name }}-redpanda-provision
labels:
{{- include "sentry.labels" . | nindent 4 }}
spec:
backoffLimit: 6
template:
metadata:
labels:
{{- include "sentry.selectorLabels" (list $ "redpanda-provision") | nindent 8 }}
spec:
restartPolicy: OnFailure
containers:
- name: redpanda-provision
image: "{{ .Values.redpanda.provisionImage.repository }}:{{ .Values.redpanda.provisionImage.tag }}"
imagePullPolicy: {{ .Values.global.imagePullPolicy }}
env:
- name: REDPANDA_BROKERS
value: "{{ .Release.Name }}-redpanda:9092"
- name: REDPANDA_ADMIN_HOSTS
value: "{{ .Release.Name }}-redpanda:9644"
- name: REDPANDA_TOPIC_PARTITIONS
value: "6"
+71
View File
@@ -0,0 +1,71 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}-search
labels:
{{- include "sentry.labels" . | nindent 4 }}
{{- include "sentry.selectorLabels" (list $ "search") | nindent 4 }}
spec:
# See values.yaml's comment: replicas is not a real knob here yet.
replicas: {{ .Values.search.replicas }}
strategy:
type: Recreate # single PVC below (ReadWriteOnce) -- avoid two pods racing to mount it during a rollout
selector:
matchLabels:
{{- include "sentry.selectorLabels" (list $ "search") | nindent 6 }}
template:
metadata:
labels:
{{- include "sentry.selectorLabels" (list $ "search") | nindent 8 }}
spec:
initContainers:
{{- include "sentry.waitForTCP" (list "redpanda" (printf "%s-redpanda" .Release.Name) "9092") | nindent 8 }}
containers:
- name: search
image: "{{ .Values.search.image.repository }}:{{ .Values.search.image.tag }}"
imagePullPolicy: {{ .Values.global.imagePullPolicy }}
env:
- name: REDPANDA_BROKERS
value: "{{ .Release.Name }}-redpanda:9092"
- name: REDPANDA_TOPIC_PARTITIONS
value: "6"
- name: RUST_LOG
value: "info"
ports:
- name: grpc
containerPort: 50052
volumeMounts:
- name: index-data
mountPath: /var/lib/sentry-search
resources:
{{- toYaml .Values.search.resources | nindent 12 }}
volumes:
- name: index-data
persistentVolumeClaim:
claimName: {{ .Release.Name }}-search-index
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ .Release.Name }}-search-index
labels:
{{- include "sentry.labels" . | nindent 4 }}
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: {{ .Values.search.persistence.size }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}-search
labels:
{{- include "sentry.labels" . | nindent 4 }}
{{- include "sentry.selectorLabels" (list $ "search") | nindent 4 }}
spec:
selector:
{{- include "sentry.selectorLabels" (list $ "search") | nindent 4 }}
ports:
- name: grpc
port: 50052
+81
View File
@@ -0,0 +1,81 @@
{{/*
Shared control-plane secrets -- the cluster-wide passwords
docker-compose.yml hardcodes as "sentry-dev-only"/etc (see its
clickhouse/metadata-postgres/metadata-migrate comments) become real
generated-or-supplied Secrets here. Each follows the same pattern: a
values override wins if set, otherwise a value is generated once and
kept stable across `helm upgrade` via `lookup` (so upgrades don't
silently rotate a live credential out from under a running Deployment --
same "never rotate a live credential without coordinating the
consumer-side change" reasoning as
deploy/operator/internal/controller/tenant_controller.go's
reconcileSecret). `lookup` returns nothing under `helm template`
(no live cluster) -- expected; see deploy/README.md's verification
section for what that means for this file specifically.
*/}}
{{- define "sentry.stableSecretValue" -}}
{{- $ns := index . 0 -}}
{{- $name := index . 1 -}}
{{- $key := index . 2 -}}
{{- $override := index . 3 -}}
{{- $existing := lookup "v1" "Secret" $ns $name -}}
{{- if $override -}}
{{ $override }}
{{- else if $existing -}}
{{ index $existing.data $key | b64dec }}
{{- else -}}
{{ randAlphaNum 40 }}
{{- end -}}
{{- end -}}
---
apiVersion: v1
kind: Secret
metadata:
name: {{ .Release.Name }}-clickhouse
labels:
{{- include "sentry.labels" . | nindent 4 }}
type: Opaque
stringData:
# The official clickhouse-server image locks down *network* access
# entirely for the implicit `default` user unless this is genuinely
# non-empty -- see docker-compose.yml's clickhouse service comment.
# Not a substitute for task 2's per-tenant credentials (still unbuilt
# -- see deploy/operator's Tenant controller); this is the shared
# admin/migration credential only.
password: {{ include "sentry.stableSecretValue" (list .Release.Namespace (printf "%s-clickhouse" .Release.Name) "password" .Values.clickhouse.password) }}
---
apiVersion: v1
kind: Secret
metadata:
name: {{ .Release.Name }}-postgres
labels:
{{- include "sentry.labels" . | nindent 4 }}
type: Opaque
stringData:
password: {{ include "sentry.stableSecretValue" (list .Release.Namespace (printf "%s-postgres" .Release.Name) "password" .Values.postgres.password) }}
# Restricted audit_writer Postgres role (Phase 4 task 4) -- INSERT+SELECT
# only, via its own pool, never the shared role above. See
# /docs/phase-4-isolation-design.md's audit-logging section and
# metadata/README.md.
auditWriterPassword: {{ include "sentry.stableSecretValue" (list .Release.Namespace (printf "%s-postgres" .Release.Name) "auditWriterPassword" .Values.postgres.auditWriterPassword) }}
{{- if .Values.enterprise.enabled }}
---
apiVersion: v1
kind: Secret
metadata:
name: {{ .Release.Name }}-enterprise-auth
labels:
{{- include "sentry.labels" . | nindent 4 }}
type: Opaque
stringData:
# Must be >= 32 bytes -- see enterprise/internal/config.Load and
# enterprise/internal/session.MinSigningKeyBytes. Rotating this
# invalidates every outstanding session/service token -- same
# "don't rotate a live credential silently" reasoning as above,
# which is why it's kept stable via the lookup above rather than
# regenerated on every `helm upgrade`.
sessionSigningKey: {{ include "sentry.stableSecretValue" (list .Release.Namespace (printf "%s-enterprise-auth" .Release.Name) "sessionSigningKey" .Values.enterprise.sessionSigningKey) }}
{{- if .Values.enterprise.oidc.clientSecret }}
oidcClientSecret: {{ .Values.enterprise.oidc.clientSecret | quote }}
{{- end }}
{{- end }}
@@ -0,0 +1,92 @@
{{- if and .Values.enterprise.enabled .Values.tenantOperator.enabled }}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ .Release.Name }}-tenant-operator
labels:
{{- include "sentry.labels" . | nindent 4 }}
---
# ClusterRole, not Role: Tenant is cluster-scoped-CRD-but-namespaced-object
# (see crds/sentry.io_tenants.yaml's scope: Namespaced), and this chart
# doesn't assume it's the only namespace the operator might one day watch
# -- narrowed to exactly the two resource types
# deploy/operator/internal/controller/tenant_controller.go's
# +kubebuilder:rbac markers name (tenants, tenants/status, secrets), not
# a wildcard grant.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: {{ .Release.Name }}-tenant-operator
labels:
{{- include "sentry.labels" . | nindent 4 }}
rules:
- apiGroups: ["sentry.io"]
resources: ["tenants"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["sentry.io"]
resources: ["tenants/status"]
verbs: ["get", "update", "patch"]
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: {{ .Release.Name }}-tenant-operator
labels:
{{- include "sentry.labels" . | nindent 4 }}
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: {{ .Release.Name }}-tenant-operator
subjects:
- kind: ServiceAccount
name: {{ .Release.Name }}-tenant-operator
namespace: {{ .Release.Namespace }}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}-tenant-operator
labels:
{{- include "sentry.labels" . | nindent 4 }}
{{- include "sentry.selectorLabels" (list $ "tenant-operator") | nindent 4 }}
spec:
# One replica -- see deploy/operator/cmd/tenant-operator/main.go's
# comment: no leader election yet, a second replica could
# double-generate a Secret.
replicas: 1
selector:
matchLabels:
{{- include "sentry.selectorLabels" (list $ "tenant-operator") | nindent 6 }}
template:
metadata:
labels:
{{- include "sentry.selectorLabels" (list $ "tenant-operator") | nindent 8 }}
spec:
serviceAccountName: {{ .Release.Name }}-tenant-operator
containers:
- name: tenant-operator
image: "{{ .Values.tenantOperator.image.repository }}:{{ .Values.tenantOperator.image.tag }}"
imagePullPolicy: {{ .Values.global.imagePullPolicy }}
ports:
- name: metrics
containerPort: 8080
- name: probes
containerPort: 8081
readinessProbe:
httpGet:
path: /readyz
port: probes
initialDelaySeconds: 5
periodSeconds: 5
livenessProbe:
httpGet:
path: /healthz
port: probes
initialDelaySeconds: 10
periodSeconds: 10
resources:
{{- toYaml .Values.tenantOperator.resources | nindent 12 }}
{{- end }}
+14
View File
@@ -0,0 +1,14 @@
{{- if .Values.enterprise.enabled }}
{{- range .Values.tenants }}
---
apiVersion: sentry.io/v1alpha1
kind: Tenant
metadata:
name: {{ .name }}
labels:
{{- include "sentry.labels" $ | nindent 4 }}
spec:
displayName: {{ .displayName | default .name | quote }}
suspended: {{ .suspended | default false }}
{{- end }}
{{- end }}
+43
View File
@@ -0,0 +1,43 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}-web
labels:
{{- include "sentry.labels" . | nindent 4 }}
{{- include "sentry.selectorLabels" (list $ "web") | nindent 4 }}
spec:
replicas: {{ .Values.web.replicas }}
selector:
matchLabels:
{{- include "sentry.selectorLabels" (list $ "web") | nindent 6 }}
template:
metadata:
labels:
{{- include "sentry.selectorLabels" (list $ "web") | nindent 8 }}
spec:
containers:
- name: web
# No env vars here -- see values.yaml's web.builtWith* comment:
# this is a static build, its API base URLs are baked into the
# image, not configurable at the Deployment level.
image: "{{ .Values.web.image.repository }}:{{ .Values.web.image.tag }}"
imagePullPolicy: {{ .Values.global.imagePullPolicy }}
ports:
- name: http
containerPort: 3000
resources:
{{- toYaml .Values.web.resources | nindent 12 }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}-web
labels:
{{- include "sentry.labels" . | nindent 4 }}
{{- include "sentry.selectorLabels" (list $ "web") | nindent 4 }}
spec:
selector:
{{- include "sentry.selectorLabels" (list $ "web") | nindent 4 }}
ports:
- name: http
port: 3000
+180
View File
@@ -0,0 +1,180 @@
# Default values for the sentry chart. See deploy/helm/sentry/README.md
# for the multi-tenant-specific values (enterprise.*, tenants) and what
# "multi-tenant-aware" does and doesn't mean at this layer.
#
# Image repositories default to locally-built tags matching each
# service's docker-compose.yml container_name, minus the "sentry-"
# container_name prefix duplication -- push these to a registry this
# cluster can actually pull from before installing; this chart never
# builds images itself (same division of labor as docker-compose.yml:
# `docker compose build` vs. `docker compose up`).
global:
imagePullPolicy: IfNotPresent
redpanda:
image:
repository: docker.redpanda.com/redpandadata/redpanda
tag: v24.2.7
persistence:
size: 10Gi
resources: {}
# Built from ./transport (docker-compose.yml's redpanda-provision
# service) -- the one-shot topic-creation Job below.
provisionImage:
repository: sentry-redpanda-provision
tag: latest
clickhouse:
image:
repository: clickhouse/clickhouse-server
tag: "24.8"
persistence:
size: 20Gi
resources: {}
# Leave empty to auto-generate and persist across upgrades -- see
# templates/secrets.yaml's stableSecretValue helper.
password: ""
# Built from ./storage (docker-compose.yml's clickhouse-migrate
# service) -- the one-shot schema-migration Job.
migrateImage:
repository: sentry-clickhouse-migrate
tag: latest
postgres:
image:
repository: postgres
tag: 16-alpine
persistence:
size: 10Gi
resources: {}
password: ""
auditWriterPassword: ""
# Built from ./metadata (docker-compose.yml's metadata-migrate
# service) -- the one-shot schema-migration Job.
migrateImage:
repository: sentry-metadata-migrate
tag: latest
ingest:
image:
repository: sentry-ingest
tag: latest
replicas: 1
resources: {}
# mTLS server cert/key/CA -- see hack/dev-certs/generate.sh for the
# dev equivalent of what this Secret must contain
# (server.pem/server-key.pem/ca.pem) in a real deployment. Unlike
# docker-compose.yml's bind-mounted ./hack/dev-certs/out, a cluster
# deployment supplies this as a real Secret -- named here, not
# generated by this chart (cert issuance is out of scope, same
# "boring, well-understood" preference as everywhere else in this
# repo -- use cert-manager or an equivalent, don't hand-roll it here).
tlsSecretName: ""
search:
image:
repository: sentry-search
tag: latest
# Pinned to 1: search consumes the same Redpanda topic ingest's
# consumer does with its own offset tracking (see search/README.md).
# A second replica would form a second, independent consumer instance
# against the same partitions with no coordination -- correctness,
# not just resource waste, is the reason this isn't a `replicas` knob
# yet. Matches CLAUDE.md's Phase 4 non-goal: "no general multi-cluster
# orchestration."
replicas: 1
resources: {}
persistence:
size: 20Gi
api:
image:
repository: sentry-api
tag: latest
replicas: 2
resources: {}
alerting:
image:
repository: sentry-alerting
tag: latest
# Pinned to 1 for the same reason as search: rulestore.ClaimDueRules
# has no leader-election/partitioning story for multiple evaluator
# replicas yet -- two would both try to claim and evaluate the same
# due rules. Named explicitly rather than silently defaulted, since
# it's the kind of knob someone reasonably expects to just work.
replicas: 1
resources: {}
# See templates/alerting.yaml's comment -- only meaningful when
# enterprise.enabled is true. Empty by default.
apiServiceToken: ""
web:
image:
repository: sentry-web
tag: latest
replicas: 2
resources: {}
# NOT wired to any Deployment env var -- web is a static SvelteKit
# build (adapter-static, see web/package.json), and VITE_API_BASE_URL/
# VITE_ALERTING_API_BASE_URL/VITE_ENTERPRISE_AUTH_BASE_URL are baked in
# at *image build time* (docker-compose.yml's web.build.args), not
# read at container runtime. Deploying this chart into a real cluster
# means rebuilding the web image with these three build args pointed
# at wherever api/alerting/enterprise-auth are actually reachable from
# a browser (an Ingress host, a LoadBalancer IP, etc.) -- this section
# exists to document that requirement, not because the chart can act
# on it.
builtWithApiBaseURL: "http://localhost:8080"
builtWithAlertingApiBaseURL: "http://localhost:8081"
builtWithEnterpriseAuthBaseURL: "http://localhost:8082"
# enterprise-auth (commercial license) + the tenant-operator that
# reconciles the Tenant CRD -- both off by default, matching
# docker-compose.yml's own "included, not wired into enforcement by
# default" stance (see its enterprise-auth service comment) and
# enterprise/README.md's "Status" section on what's built vs. deferred.
enterprise:
enabled: false
image:
repository: sentry-enterprise-auth
tag: latest
replicas: 1
resources: {}
# Leave empty to auto-generate (>= 32 bytes) and persist across
# upgrades -- see templates/secrets.yaml.
sessionSigningKey: ""
oidc:
issuerURL: ""
clientID: ""
clientSecret: ""
redirectURL: ""
saml:
entityID: ""
acsURL: ""
idpMetadataURL: ""
# Installs deploy/operator (the Tenant CRD controller) alongside this
# chart. Only meaningful when enterprise.enabled is also true --
# gated on that, not a separate flag, since a Tenant CR with no
# enterprise-auth deployed to consume its Secret has nothing to do.
tenantOperator:
enabled: false
image:
repository: sentry-tenant-operator
tag: latest
resources: {}
# One entry per tenant to provision -- rendered as Tenant CRs
# (templates/tenants.yaml), reconciled by the tenant-operator into a
# per-tenant ClickHouse credential Secret. See
# deploy/operator/internal/controller/tenant_controller.go's doc comment
# for exactly what that does and doesn't set up. Empty by default; a
# real two-tenant deployment (Phase 4's exit criteria) sets e.g.:
# tenants:
# - name: acme
# displayName: "Acme Corp"
# - name: globex
# displayName: "Globex Corporation"
tenants: []