From 8e24260cd08538259e59972647019ecd19ada2a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Dolph=20=F0=9F=90=BA?= Date: Mon, 28 Sep 2026 09:40:16 +0100 Subject: [PATCH] feat: add Helm chart for self-hosting (#2784) * feat: add Helm chart for self-hosting Runs the docker-compose.github-packages.yaml stack on Kubernetes and automates the self-hosting guide's Tinybird setup (Part 2) and private-location health cron (Part 4). * feat(chart): pin images by digest * feat(chart): support private sources for the Tinybird bootstrap * fix(chart): address review feedback - gate apps on their compose depends_on services with wait-for init containers - liveness is a TCP check; the health endpoints also check the database - restart only Deployments not yet on the current token, so retries finish the job - scope the bootstrap Role to this release's Deployments - roll pods when the generated Secret changes - leave imagePullPolicy to Kubernetes by default - keep component names within 63 characters * fix(chart): keep the bootstrap token same-origin and validate dependsOn - drop Authorization on cross-host redirects and require https when a token is set - fail the render when an enabled service depends on a disabled one --------- Co-authored-by: David A. Symons <1227896+o6uoq@users.noreply.github.com> --- charts/openstatus/.helmignore | 2 + charts/openstatus/Chart.yaml | 13 ++ charts/openstatus/README.md | 48 ++++ charts/openstatus/templates/NOTES.txt | 7 + charts/openstatus/templates/_helpers.tpl | 86 +++++++ charts/openstatus/templates/apps.yaml | 128 +++++++++++ charts/openstatus/templates/configmap.yaml | 31 +++ charts/openstatus/templates/cronjobs.yaml | 37 +++ charts/openstatus/templates/libsql.yaml | 78 +++++++ charts/openstatus/templates/probe.yaml | 39 ++++ charts/openstatus/templates/secret.yaml | 17 ++ .../templates/tinybird-bootstrap.yaml | 196 ++++++++++++++++ charts/openstatus/templates/tinybird.yaml | 91 ++++++++ charts/openstatus/values.yaml | 210 ++++++++++++++++++ 14 files changed, 983 insertions(+) create mode 100644 charts/openstatus/.helmignore create mode 100644 charts/openstatus/Chart.yaml create mode 100644 charts/openstatus/README.md create mode 100644 charts/openstatus/templates/NOTES.txt create mode 100644 charts/openstatus/templates/_helpers.tpl create mode 100644 charts/openstatus/templates/apps.yaml create mode 100644 charts/openstatus/templates/configmap.yaml create mode 100644 charts/openstatus/templates/cronjobs.yaml create mode 100644 charts/openstatus/templates/libsql.yaml create mode 100644 charts/openstatus/templates/probe.yaml create mode 100644 charts/openstatus/templates/secret.yaml create mode 100644 charts/openstatus/templates/tinybird-bootstrap.yaml create mode 100644 charts/openstatus/templates/tinybird.yaml create mode 100644 charts/openstatus/values.yaml diff --git a/charts/openstatus/.helmignore b/charts/openstatus/.helmignore new file mode 100644 index 00000000..478d0a4f --- /dev/null +++ b/charts/openstatus/.helmignore @@ -0,0 +1,2 @@ +.DS_Store +*.tgz diff --git a/charts/openstatus/Chart.yaml b/charts/openstatus/Chart.yaml new file mode 100644 index 00000000..9989bf82 --- /dev/null +++ b/charts/openstatus/Chart.yaml @@ -0,0 +1,13 @@ +apiVersion: v2 +name: openstatus +description: Self-hosted openstatus — the docker-compose.github-packages.yaml stack as a Helm chart +type: application +version: 0.1.0 +appVersion: "latest" +home: https://www.openstatus.dev +sources: + - https://github.com/openstatusHQ/openstatus +keywords: + - monitoring + - uptime + - status-page diff --git a/charts/openstatus/README.md b/charts/openstatus/README.md new file mode 100644 index 00000000..7061a5d6 --- /dev/null +++ b/charts/openstatus/README.md @@ -0,0 +1,48 @@ +# openstatus Helm chart + +Runs the self-hosted stack from [`docker-compose.github-packages.yaml`](../../docker-compose.github-packages.yaml) +on Kubernetes. It follows the [self-hosting guide](https://www.openstatus.dev/docs/guides/self-hosting-openstatus) +step for step; read that first. + +| Compose service | Kubernetes | +|---|---| +| `libsql` | StatefulSet + PVC | +| `tinybird-local` | StatefulSet + PVCs | +| `db-migrate` | init container of `workflows` (idempotent) | +| `workflows`, `server`, `private-location`, `checker`, `dashboard`, `status-page` | Deployment + Service | +| Part 2: `tb --local deploy` and the token | `tinybird-bootstrap` post-install/upgrade Job | +| Part 4: `/cron/private-location-health` | CronJob | +| Probe (`private-location` image) | optional Deployment (`probe.enabled`) | + +## Install + +```bash +helm install openstatus ./charts/openstatus -n openstatus --create-namespace \ + --set urls.dashboard=https://openstatus.example.com \ + --set urls.server=https://api.openstatus.example.com +``` + +`.env.docker` values go in `env` (plain) and `secrets.extra` or `secrets.existingSecret` +(sensitive). `AUTH_SECRET` and `CRON_SECRET` are generated on first install and kept on upgrade. + +Expose the dashboard, status page and API with your own Ingress or Gateway; the chart creates +ClusterIP Services only. + +## After install + +1. Sign in. With `SELF_HOST=true` the magic link is printed in the dashboard log: + `kubectl -n openstatus logs deploy/-openstatus-dashboard | grep "Magic Link"`. +2. Set the workspace limits (guide step 9) against the libSQL Service. +3. Create a private location, store its key in a Secret as `OPENSTATUS_KEY`, then enable the probe: + `--set probe.enabled=true --set probe.existingSecret=`. + +## Values worth knowing + +| Key | Default | Notes | +|---|---|---| +| `image.tag` | `latest` | Upstream publishes `latest`, `main` and short-SHA tags. | +| `image.digests` | `{}` | Per-image digest pins (`openstatus-server: sha256:...`). Images are rebuilt only when their app changes, so one SHA tag rarely covers every image. Third-party images take `.image.digest`. | +| `tinybird.bootstrap.sourceUrl` | `main` tarball | Pin to the same commit as `image.tag`. | +| `tinybird.bootstrap.sourceTokenSecret` | unset | Secret with a GitHub token, for a private fork's `api.github.com/.../tarball/` URL. | +| `tinybird.enabled` | `true` | Set `false` for Tinybird Cloud; provide `TINYBIRD_URL` and `tinybird.existingSecret`. | +| `env.AUTH_OIDC_ISSUER` | unset | Generic OIDC login (`AUTH_OIDC_ID`, `AUTH_OIDC_NAME`; `AUTH_OIDC_SECRET` via `secrets.extra`). | diff --git a/charts/openstatus/templates/NOTES.txt b/charts/openstatus/templates/NOTES.txt new file mode 100644 index 00000000..fb90a93a --- /dev/null +++ b/charts/openstatus/templates/NOTES.txt @@ -0,0 +1,7 @@ +openstatus is starting. Dashboard: {{ .Values.urls.dashboard }} + +Sign-in magic links are printed in the dashboard log: + kubectl -n {{ .Release.Namespace }} logs deploy/{{ include "openstatus.component" (dict "ctx" $ "component" "dashboard") }} | grep "Magic Link" + +Then follow the self-hosting guide from step 9 (workspace limits): + https://www.openstatus.dev/docs/guides/self-hosting-openstatus diff --git a/charts/openstatus/templates/_helpers.tpl b/charts/openstatus/templates/_helpers.tpl new file mode 100644 index 00000000..4fbf196d --- /dev/null +++ b/charts/openstatus/templates/_helpers.tpl @@ -0,0 +1,86 @@ +{{- define "openstatus.fullname" -}} +{{- if contains .Chart.Name .Release.Name -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} + +{{- define "openstatus.labels" -}} +app.kubernetes.io/name: {{ .Chart.Name }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }} +{{- end -}} + +{{/* selector labels: call with (dict "ctx" $ "component" "server") */}} +{{- define "openstatus.selectorLabels" -}} +app.kubernetes.io/name: {{ .ctx.Chart.Name }} +app.kubernetes.io/instance: {{ .ctx.Release.Name }} +app.kubernetes.io/component: {{ .component }} +{{- end -}} + +{{- define "openstatus.component" -}} +{{- $base := include "openstatus.fullname" .ctx | trunc (int (sub 62 (len .component))) | trimSuffix "-" -}} +{{- printf "%s-%s" $base .component -}} +{{- end -}} + +{{/* openstatus image: call with (dict "ctx" $ "name" "openstatus-server") */}} +{{- define "openstatus.image" -}} +{{- $ref := printf "%s/%s:%s" .ctx.Values.image.registry .name .ctx.Values.image.tag -}} +{{- with get .ctx.Values.image.digests .name -}}{{- $ref = printf "%s@%s" $ref . -}}{{- end -}} +{{- $ref -}} +{{- end -}} + +{{/* third-party image: call with an `image` values block (repository, tag, digest) */}} +{{- define "openstatus.externalImage" -}} +{{- $ref := printf "%s:%s" .repository .tag -}} +{{- with .digest -}}{{- $ref = printf "%s@%s" $ref . -}}{{- end -}} +{{- $ref -}} +{{- end -}} + +{{- define "openstatus.secretName" -}} +{{- default (printf "%s-env" (include "openstatus.fullname" .)) .Values.secrets.existingSecret -}} +{{- end -}} + +{{- define "openstatus.tinybirdSecretName" -}} +{{- default (printf "%s-tinybird-token" (include "openstatus.fullname" .)) .Values.tinybird.existingSecret -}} +{{- end -}} + +{{- define "openstatus.url" -}} +{{- printf "http://%s:%v" (include "openstatus.component" (dict "ctx" .ctx "component" .component)) .port -}} +{{- end -}} + +{{/* envFrom shared by every app: the chart's `.env.docker` equivalent */}} +{{- define "openstatus.envFrom" -}} +- configMapRef: + name: {{ include "openstatus.fullname" . }}-env +- secretRef: + name: {{ include "openstatus.secretName" . }} +- secretRef: + name: {{ include "openstatus.tinybirdSecretName" . }} + optional: true +{{- end -}} + +{{- define "openstatus.scheduling" -}} +{{- with .Values.nodeSelector }} +nodeSelector: + {{- toYaml . | nindent 2 }} +{{- end }} +{{- with .Values.tolerations }} +tolerations: + {{- toYaml . | nindent 2 }} +{{- end }} +{{- with .Values.affinity }} +affinity: + {{- toYaml . | nindent 2 }} +{{- end }} +{{- with .Values.imagePullSecrets }} +imagePullSecrets: + {{- toYaml . | nindent 2 }} +{{- end }} +{{- with .Values.podSecurityContext }} +securityContext: + {{- toYaml . | nindent 2 }} +{{- end }} +{{- end -}} diff --git a/charts/openstatus/templates/apps.yaml b/charts/openstatus/templates/apps.yaml new file mode 100644 index 00000000..574cc672 --- /dev/null +++ b/charts/openstatus/templates/apps.yaml @@ -0,0 +1,128 @@ +{{- range $svc, $cfg := .Values.services }} +{{- if $cfg.enabled }} +{{- range $dep := $cfg.dependsOn }} +{{- if not (get $.Values.services $dep).enabled }} +{{- fail (printf "services.%s depends on services.%s, which is disabled" $svc $dep) }} +{{- end }} +{{- end }} +{{- $name := include "openstatus.component" (dict "ctx" $ "component" $svc) }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ $name }} + labels: + {{- include "openstatus.labels" $ | nindent 4 }} +spec: + selector: + {{- include "openstatus.selectorLabels" (dict "ctx" $ "component" $svc) | nindent 4 }} + ports: + - name: http + port: {{ $cfg.port }} + targetPort: http +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ $name }} + labels: + {{- include "openstatus.labels" $ | nindent 4 }} + app.kubernetes.io/component: {{ $svc }} +spec: + replicas: {{ $cfg.replicas | default 1 }} + selector: + matchLabels: + {{- include "openstatus.selectorLabels" (dict "ctx" $ "component" $svc) | nindent 6 }} + template: + metadata: + labels: + {{- include "openstatus.selectorLabels" (dict "ctx" $ "component" $svc) | nindent 8 }} + annotations: + checksum/env: {{ include (print $.Template.BasePath "/configmap.yaml") $ | sha256sum }} + {{- if not $.Values.secrets.existingSecret }} + checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") $ | sha256sum }} + {{- end }} + spec: + {{- include "openstatus.scheduling" $ | nindent 6 }} + {{- if or (eq $svc "workflows") $cfg.dependsOn }} + initContainers: + {{- if eq $svc "workflows" }} + # compose runs the idempotent db-migrate before workflows, and every other + # app waits on workflows, so migrations always land first. + - name: db-migrate + image: {{ include "openstatus.image" (dict "ctx" $ "name" $.Values.dbMigrate.image) }} + {{- with $.Values.image.pullPolicy }} + imagePullPolicy: {{ . }} + {{- end }} + envFrom: + {{- include "openstatus.envFrom" $ | nindent 12 }} + {{- end }} + {{- range $dep := $cfg.dependsOn }} + {{- $d := get $.Values.services $dep }} + # compose `depends_on: condition: service_healthy` + - name: wait-for-{{ $dep }} + image: {{ include "openstatus.externalImage" $.Values.cron.image | quote }} + {{- with $.Values.image.pullPolicy }} + imagePullPolicy: {{ . }} + {{- end }} + command: ["sh", "-c"] + args: + - {{ printf "until curl -fsS -o /dev/null %s%s; do sleep 2; done" (include "openstatus.url" (dict "ctx" $ "component" $dep "port" $d.port)) $d.healthPath | quote }} + {{- end }} + {{- end }} + containers: + - name: {{ $svc }} + image: {{ include "openstatus.image" (dict "ctx" $ "name" $cfg.image) }} + {{- with $.Values.image.pullPolicy }} + imagePullPolicy: {{ . }} + {{- end }} + envFrom: + {{- include "openstatus.envFrom" $ | nindent 12 }} + env: + {{- if eq $svc "dashboard" }} + # Auth.js otherwise builds callback and magic-link URLs from the bind address (0.0.0.0:3000). + - name: AUTH_URL + value: {{ $.Values.urls.dashboard | quote }} + {{- end }} + {{- range $k, $v := $cfg.env }} + - name: {{ $k }} + value: {{ $v | quote }} + {{- end }} + ports: + - name: http + containerPort: {{ $cfg.port }} + readinessProbe: + httpGet: + path: {{ $cfg.healthPath }} + port: http + periodSeconds: 15 + timeoutSeconds: 10 + startupProbe: + httpGet: + path: {{ $cfg.healthPath }} + port: http + periodSeconds: 5 + failureThreshold: 36 + # Process-only: the health endpoints also check the database, and an + # outage there should drop readiness, not restart the container. + livenessProbe: + tcpSocket: + port: http + periodSeconds: 15 + timeoutSeconds: 10 + failureThreshold: 3 + resources: + {{- toYaml $cfg.resources | nindent 12 }} + {{- if eq $svc "workflows" }} + volumeMounts: + - name: data + mountPath: /app/data + {{- end }} + {{- if eq $svc "workflows" }} + volumes: + # Local libSQL replica cache; rebuilt from the primary on start. + - name: data + emptyDir: {} + {{- end }} +{{- end }} +{{- end }} diff --git a/charts/openstatus/templates/configmap.yaml b/charts/openstatus/templates/configmap.yaml new file mode 100644 index 00000000..d02582e1 --- /dev/null +++ b/charts/openstatus/templates/configmap.yaml @@ -0,0 +1,31 @@ +{{- $libsql := include "openstatus.url" (dict "ctx" $ "component" "libsql" "port" 8080) -}} +{{- $defaults := dict + "DATABASE_URL" $libsql + "DB_URL" $libsql + "WORKFLOWS_URL" (include "openstatus.url" (dict "ctx" $ "component" "workflows" "port" 3000)) + "OPENSTATUS_API_URL" (include "openstatus.url" (dict "ctx" $ "component" "server" "port" 3000)) + "CHECKER_URL" (include "openstatus.url" (dict "ctx" $ "component" "checker" "port" 8080)) + "NODE_ENV" "production" + "SELF_HOST" "true" + "FLY_REGION" "self-hosted" + "NEXT_PUBLIC_URL" .Values.urls.dashboard + "DASHBOARD_URL" .Values.urls.dashboard + "OAUTH_ISSUER" .Values.urls.server + "RESEND_API_KEY" "re_self_hosted_placeholder" + "UPSTASH_REDIS_REST_URL" "http://localhost:6379" + "UPSTASH_REDIS_REST_TOKEN" "placeholder" + "STRIPE_SECRET_KEY" "sk_self_hosted_placeholder" +-}} +{{- if .Values.tinybird.enabled -}} +{{- $_ := set $defaults "TINYBIRD_URL" (include "openstatus.url" (dict "ctx" $ "component" "tinybird-local" "port" 7181)) -}} +{{- end -}} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "openstatus.fullname" . }}-env + labels: + {{- include "openstatus.labels" . | nindent 4 }} +data: + {{- range $k, $v := merge (deepCopy .Values.env) $defaults }} + {{ $k }}: {{ $v | toString | quote }} + {{- end }} diff --git a/charts/openstatus/templates/cronjobs.yaml b/charts/openstatus/templates/cronjobs.yaml new file mode 100644 index 00000000..5083b820 --- /dev/null +++ b/charts/openstatus/templates/cronjobs.yaml @@ -0,0 +1,37 @@ +{{- $workflows := include "openstatus.url" (dict "ctx" $ "component" "workflows" "port" 3000) }} +{{- range $job, $cfg := .Values.cron.jobs }} +--- +apiVersion: batch/v1 +kind: CronJob +metadata: + name: {{ include "openstatus.component" (dict "ctx" $ "component" $job) }} + labels: + {{- include "openstatus.labels" $ | nindent 4 }} +spec: + schedule: {{ $cfg.schedule | quote }} + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 1 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + backoffLimit: 1 + template: + spec: + {{- include "openstatus.scheduling" $ | nindent 10 }} + restartPolicy: Never + containers: + - name: curl + image: {{ include "openstatus.externalImage" $.Values.cron.image | quote }} + {{- with $.Values.image.pullPolicy }} + imagePullPolicy: {{ . }} + {{- end }} + env: + - name: CRON_SECRET + valueFrom: + secretKeyRef: + name: {{ include "openstatus.secretName" $ }} + key: CRON_SECRET + command: ["sh", "-c"] + args: + - {{ printf "curl -fsS -H \"Authorization: $CRON_SECRET\" %s%s" $workflows $cfg.path | quote }} +{{- end }} diff --git a/charts/openstatus/templates/libsql.yaml b/charts/openstatus/templates/libsql.yaml new file mode 100644 index 00000000..047497dd --- /dev/null +++ b/charts/openstatus/templates/libsql.yaml @@ -0,0 +1,78 @@ +{{- $name := include "openstatus.component" (dict "ctx" $ "component" "libsql") }} +apiVersion: v1 +kind: Service +metadata: + name: {{ $name }} + labels: + {{- include "openstatus.labels" . | nindent 4 }} +spec: + selector: + {{- include "openstatus.selectorLabels" (dict "ctx" $ "component" "libsql") | nindent 4 }} + ports: + - name: http + port: 8080 + targetPort: http + - name: grpc + port: 5001 + targetPort: grpc +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ $name }} + labels: + {{- include "openstatus.labels" . | nindent 4 }} + app.kubernetes.io/component: libsql +spec: + serviceName: {{ $name }} + replicas: 1 + selector: + matchLabels: + {{- include "openstatus.selectorLabels" (dict "ctx" $ "component" "libsql") | nindent 6 }} + template: + metadata: + labels: + {{- include "openstatus.selectorLabels" (dict "ctx" $ "component" "libsql") | nindent 8 }} + spec: + {{- include "openstatus.scheduling" . | nindent 6 }} + containers: + - name: libsql + image: {{ include "openstatus.externalImage" .Values.libsql.image | quote }} + {{- with $.Values.image.pullPolicy }} + imagePullPolicy: {{ . }} + {{- end }} + env: + {{- range $k, $v := .Values.libsql.env }} + - name: {{ $k }} + value: {{ $v | quote }} + {{- end }} + ports: + - name: http + containerPort: 8080 + - name: grpc + containerPort: 5001 + readinessProbe: + tcpSocket: + port: http + periodSeconds: 10 + livenessProbe: + tcpSocket: + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + resources: + {{- toYaml .Values.libsql.resources | nindent 12 }} + volumeMounts: + - name: data + mountPath: /var/lib/sqld + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: ["ReadWriteOnce"] + {{- with .Values.libsql.persistence.storageClass }} + storageClassName: {{ . }} + {{- end }} + resources: + requests: + storage: {{ .Values.libsql.persistence.size }} diff --git a/charts/openstatus/templates/probe.yaml b/charts/openstatus/templates/probe.yaml new file mode 100644 index 00000000..7e15f79f --- /dev/null +++ b/charts/openstatus/templates/probe.yaml @@ -0,0 +1,39 @@ +{{- if .Values.probe.enabled }} +{{- if not .Values.probe.existingSecret }} +{{- fail "probe.existingSecret is required: a Secret with OPENSTATUS_KEY from Settings > Private Locations" }} +{{- end }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "openstatus.component" (dict "ctx" $ "component" "probe") }} + labels: + {{- include "openstatus.labels" . | nindent 4 }} + app.kubernetes.io/component: probe +spec: + replicas: 1 + selector: + matchLabels: + {{- include "openstatus.selectorLabels" (dict "ctx" $ "component" "probe") | nindent 6 }} + template: + metadata: + labels: + {{- include "openstatus.selectorLabels" (dict "ctx" $ "component" "probe") | nindent 8 }} + spec: + {{- include "openstatus.scheduling" . | nindent 6 }} + containers: + - name: probe + image: {{ include "openstatus.image" (dict "ctx" $ "name" .Values.probe.image) }} + {{- with $.Values.image.pullPolicy }} + imagePullPolicy: {{ . }} + {{- end }} + env: + - name: OPENSTATUS_INGEST_URL + value: {{ .Values.probe.ingestUrl | default (include "openstatus.url" (dict "ctx" $ "component" "private-location" "port" 8080)) | quote }} + - name: OPENSTATUS_KEY + valueFrom: + secretKeyRef: + name: {{ .Values.probe.existingSecret }} + key: OPENSTATUS_KEY + resources: + {{- toYaml .Values.probe.resources | nindent 12 }} +{{- end }} diff --git a/charts/openstatus/templates/secret.yaml b/charts/openstatus/templates/secret.yaml new file mode 100644 index 00000000..eecc3a6b --- /dev/null +++ b/charts/openstatus/templates/secret.yaml @@ -0,0 +1,17 @@ +{{- if not .Values.secrets.existingSecret }} +{{- $name := include "openstatus.secretName" . }} +{{- $existing := (lookup "v1" "Secret" .Release.Namespace $name).data | default dict }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ $name }} + labels: + {{- include "openstatus.labels" . | nindent 4 }} +type: Opaque +data: + AUTH_SECRET: {{ get $existing "AUTH_SECRET" | default (randAlphaNum 48 | b64enc) }} + CRON_SECRET: {{ get $existing "CRON_SECRET" | default (randAlphaNum 32 | b64enc) }} + {{- range $k, $v := .Values.secrets.extra }} + {{ $k }}: {{ $v | toString | b64enc }} + {{- end }} +{{- end }} diff --git a/charts/openstatus/templates/tinybird-bootstrap.yaml b/charts/openstatus/templates/tinybird-bootstrap.yaml new file mode 100644 index 00000000..60881837 --- /dev/null +++ b/charts/openstatus/templates/tinybird-bootstrap.yaml @@ -0,0 +1,196 @@ +{{- if and .Values.tinybird.enabled .Values.tinybird.bootstrap.enabled }} +{{- $name := include "openstatus.component" (dict "ctx" $ "component" "tinybird-bootstrap") }} +{{- $b := .Values.tinybird.bootstrap }} +{{- $deployments := list }} +{{- range $svc, $cfg := .Values.services }} +{{- if $cfg.enabled }} +{{- $deployments = append $deployments (include "openstatus.component" (dict "ctx" $ "component" $svc)) }} +{{- end }} +{{- end }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ $name }} + labels: + {{- include "openstatus.labels" . | nindent 4 }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ $name }} + labels: + {{- include "openstatus.labels" . | nindent 4 }} +rules: + # `create` cannot be scoped by resourceNames; everything else is. + - apiGroups: [""] + resources: ["secrets"] + verbs: ["create"] + - apiGroups: [""] + resources: ["secrets"] + resourceNames: [{{ include "openstatus.tinybirdSecretName" . | quote }}] + verbs: ["get", "update"] + - apiGroups: ["apps"] + resources: ["deployments"] + resourceNames: {{ toJson $deployments }} + verbs: ["get", "patch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ $name }} + labels: + {{- include "openstatus.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ $name }} +subjects: + - kind: ServiceAccount + name: {{ $name }} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ $name }} + labels: + {{- include "openstatus.labels" . | nindent 4 }} +data: + bootstrap.sh: | + set -eu + pip install --quiet --root-user-action=ignore "tinybird=={{ $b.cliVersion }}" + # tb names the local workspace after the project path, so keep it fixed: + # the same path always resolves to the same workspace and token. + python3 /scripts/fetch.py "$SOURCE_URL" /work + cd /work/packages/tinybird + tb --local deploy + python3 /scripts/publish.py + fetch.py: | + import io, os, sys, tarfile, urllib.parse, urllib.request + url, dest = sys.argv[1], sys.argv[2] + token = os.environ.get("SOURCE_TOKEN") + + class SameOriginAuth(urllib.request.HTTPRedirectHandler): + # Like curl: never forward the token to another host (GitHub's codeload + # redirect is pre-signed and needs none). + def redirect_request(self, req, fp, code, msg, headers, newurl): + new = super().redirect_request(req, fp, code, msg, headers, newurl) + if new and urllib.parse.urlsplit(newurl).netloc != urllib.parse.urlsplit(req.full_url).netloc: + new.remove_header("Authorization") + return new + + req = urllib.request.Request(url) + if token: + if urllib.parse.urlsplit(url).scheme != "https": + sys.exit("sourceTokenSecret requires an https sourceUrl") + req.add_header("Authorization", "Bearer " + token) + body = urllib.request.build_opener(SameOriginAuth).open(req).read() + tar = tarfile.open(fileobj=io.BytesIO(body)) + members = [] + for m in tar.getmembers(): + parts = m.name.split("/", 1) + if len(parts) == 2 and parts[1].startswith("packages/tinybird/"): + m.name = parts[1] + members.append(m) + tar.extractall(dest, members=members, filter="data") + publish.py: | + # Guide steps 6-7: read the workspace token with `tb --local info`, store it + # under both names the apps read, and restart every app not yet running it. + import base64, hashlib, json, os, ssl, subprocess, urllib.error, urllib.request + + info = subprocess.run(["tb", "--local", "info"], capture_output=True, text=True, check=True).stdout + section = info.split("Tinybird Local", 1)[1].split("» Project", 1)[0] + token = next(l.split(":", 1)[1].strip() for l in section.splitlines() if l.startswith("token:")) + + sa = "/var/run/secrets/kubernetes.io/serviceaccount" + ns = open(f"{sa}/namespace").read().strip() + auth = {"Authorization": "Bearer " + open(f"{sa}/token").read().strip()} + tls = ssl.create_default_context(cafile=f"{sa}/ca.crt") + api = "https://kubernetes.default.svc" + + def call(method, path, body=None, ctype="application/json"): + req = urllib.request.Request(api + path, method=method, headers={**auth, "Content-Type": ctype}, + data=json.dumps(body).encode() if body is not None else None) + try: + with urllib.request.urlopen(req, context=tls) as r: + return r.status, json.load(r) + except urllib.error.HTTPError as e: + if e.code == 404: + return 404, None + raise + + name = os.environ["SECRET_NAME"] + enc = base64.b64encode(token.encode()).decode() + secret = {"apiVersion": "v1", "kind": "Secret", "type": "Opaque", + "metadata": {"name": name, "labels": json.loads(os.environ["LABELS"])}, + "data": {"TINY_BIRD_API_KEY": enc, "TINYBIRD_TOKEN": enc}} + status, current = call("GET", f"/api/v1/namespaces/{ns}/secrets/{name}") + if status == 404: + call("POST", f"/api/v1/namespaces/{ns}/secrets", secret) + elif current.get("data") != secret["data"]: + secret["metadata"]["resourceVersion"] = current["metadata"]["resourceVersion"] + call("PUT", f"/api/v1/namespaces/{ns}/secrets/{name}", secret) + + # Each Deployment records the token it was restarted for, so a retry after a + # partial failure finishes the restarts instead of skipping them. + key = "openstatus.dev/tinybird-token-sha256" + digest = hashlib.sha256(token.encode()).hexdigest() + patch = {"spec": {"template": {"metadata": {"annotations": {key: digest}}}}} + for dep in json.loads(os.environ["DEPLOYMENTS"]): + _, d = call("GET", f"/apis/apps/v1/namespaces/{ns}/deployments/{dep}") + if (d["spec"]["template"]["metadata"].get("annotations") or {}).get(key) != digest: + call("PATCH", f"/apis/apps/v1/namespaces/{ns}/deployments/{dep}", patch, + "application/strategic-merge-patch+json") + print("restarted", dep) +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ $name }} + labels: + {{- include "openstatus.labels" . | nindent 4 }} + annotations: + helm.sh/hook: post-install,post-upgrade + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded +spec: + backoffLimit: {{ $b.backoffLimit }} + template: + metadata: + labels: + {{- include "openstatus.selectorLabels" (dict "ctx" $ "component" "tinybird-bootstrap") | nindent 8 }} + spec: + {{- include "openstatus.scheduling" . | nindent 6 }} + serviceAccountName: {{ $name }} + restartPolicy: Never + containers: + - name: bootstrap + image: {{ include "openstatus.externalImage" $b.image | quote }} + {{- with $.Values.image.pullPolicy }} + imagePullPolicy: {{ . }} + {{- end }} + command: ["sh", "/scripts/bootstrap.sh"] + env: + - name: TB_LOCAL_HOST + value: {{ include "openstatus.component" (dict "ctx" $ "component" "tinybird-local") }} + - name: SOURCE_URL + value: {{ $b.sourceUrl | quote }} + {{- with $b.sourceTokenSecret.name }} + - name: SOURCE_TOKEN + valueFrom: + secretKeyRef: + name: {{ . }} + key: {{ $b.sourceTokenSecret.key }} + {{- end }} + - name: SECRET_NAME + value: {{ include "openstatus.tinybirdSecretName" . }} + - name: DEPLOYMENTS + value: {{ toJson $deployments | quote }} + - name: LABELS + value: {{ include "openstatus.labels" . | fromYaml | toJson | quote }} + volumeMounts: + - name: scripts + mountPath: /scripts + volumes: + - name: scripts + configMap: + name: {{ $name }} +{{- end }} diff --git a/charts/openstatus/templates/tinybird.yaml b/charts/openstatus/templates/tinybird.yaml new file mode 100644 index 00000000..56a0108f --- /dev/null +++ b/charts/openstatus/templates/tinybird.yaml @@ -0,0 +1,91 @@ +{{- if .Values.tinybird.enabled }} +{{- $name := include "openstatus.component" (dict "ctx" $ "component" "tinybird-local") }} +{{- $p := .Values.tinybird.persistence }} +apiVersion: v1 +kind: Service +metadata: + name: {{ $name }} + labels: + {{- include "openstatus.labels" . | nindent 4 }} +spec: + selector: + {{- include "openstatus.selectorLabels" (dict "ctx" $ "component" "tinybird-local") | nindent 4 }} + ports: + - name: http + port: 7181 + targetPort: http +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ $name }} + labels: + {{- include "openstatus.labels" . | nindent 4 }} + app.kubernetes.io/component: tinybird-local +spec: + serviceName: {{ $name }} + replicas: 1 + selector: + matchLabels: + {{- include "openstatus.selectorLabels" (dict "ctx" $ "component" "tinybird-local") | nindent 6 }} + template: + metadata: + labels: + {{- include "openstatus.selectorLabels" (dict "ctx" $ "component" "tinybird-local") | nindent 8 }} + spec: + {{- include "openstatus.scheduling" . | nindent 6 }} + containers: + - name: tinybird-local + image: {{ include "openstatus.externalImage" .Values.tinybird.image | quote }} + {{- with $.Values.image.pullPolicy }} + imagePullPolicy: {{ . }} + {{- end }} + env: + {{- range $k, $v := .Values.tinybird.env }} + - name: {{ $k }} + value: {{ $v | quote }} + {{- end }} + ports: + - name: http + containerPort: 7181 + readinessProbe: + httpGet: + path: / + port: http + periodSeconds: 15 + timeoutSeconds: 5 + startupProbe: + httpGet: + path: / + port: http + periodSeconds: 10 + failureThreshold: 60 + resources: + {{- toYaml .Values.tinybird.resources | nindent 12 }} + volumeMounts: + - name: clickhouse + mountPath: /var/lib/clickhouse + - name: redis + mountPath: /redis-data + volumeClaimTemplates: + - metadata: + name: clickhouse + spec: + accessModes: ["ReadWriteOnce"] + {{- with $p.storageClass }} + storageClassName: {{ . }} + {{- end }} + resources: + requests: + storage: {{ $p.clickhouse.size }} + - metadata: + name: redis + spec: + accessModes: ["ReadWriteOnce"] + {{- with $p.storageClass }} + storageClassName: {{ . }} + {{- end }} + resources: + requests: + storage: {{ $p.redis.size }} +{{- end }} diff --git a/charts/openstatus/values.yaml b/charts/openstatus/values.yaml new file mode 100644 index 00000000..ea3d83e5 --- /dev/null +++ b/charts/openstatus/values.yaml @@ -0,0 +1,210 @@ +# Mirrors docker-compose.github-packages.yaml and .env.docker.example. +# Service names, ports, env and resource limits match the compose file. + +image: + registry: ghcr.io/openstatushq + # Upstream publishes `latest`, `main` and short-SHA tags. Each image is only + # rebuilt when its app changes, so one SHA tag rarely exists for every image; + # pin per image with `digests` instead. + tag: latest + # Empty: Kubernetes decides (Always for `latest`, IfNotPresent for tags and digests). + pullPolicy: "" + # Image name -> digest, e.g. `openstatus-server: sha256:...`. + digests: {} + +imagePullSecrets: [] + +# Public origins. Equivalent to NEXT_PUBLIC_URL / OAUTH_ISSUER / DASHBOARD_URL in .env.docker. +urls: + dashboard: http://localhost:3002 + server: http://localhost:3001 + +# The `.env.docker` file: shared by every app, like compose `env_file`. +# Keys set here override the chart defaults in templates/configmap.yaml. +env: {} + +# Secret half of `.env.docker`. Either reference an existing Secret holding +# AUTH_SECRET and CRON_SECRET (plus any optional secrets such as AUTH_OIDC_SECRET), +# or let the chart generate one. Generated values survive upgrades. +secrets: + existingSecret: "" + # Extra keys added to the generated Secret. + extra: {} + +libsql: + image: + repository: ghcr.io/tursodatabase/libsql-server + tag: latest + digest: "" + env: + SQLD_NODE: primary + SQLD_MAX_CONCURRENT_CONNECTIONS: "512" + SQLD_MAX_CONCURRENT_REQUESTS: "1024" + persistence: + size: 5Gi + storageClass: "" + resources: + requests: + memory: 256Mi + limits: + memory: 512Mi + +tinybird: + # Run the tinybird-local container. Disable to use Tinybird Cloud + # (set TINYBIRD_URL in `env` and provide the token via `tinybird.existingSecret`). + enabled: true + image: + repository: tinybirdco/tinybird-local + tag: latest + digest: "" + env: + COMPATIBILITY_MODE: "1" + persistence: + clickhouse: + size: 10Gi + redis: + size: 1Gi + storageClass: "" + resources: {} + # Secret holding TINY_BIRD_API_KEY and TINYBIRD_TOKEN. When bootstrap is + # enabled the chart writes this Secret itself. + existingSecret: "" + # Automates Part 2 of the self-hosting guide: `tb --local deploy`, promote, + # `tb --local info` -> token -> Secret, then restarts the apps that read it. + bootstrap: + enabled: true + image: + repository: python + tag: "3.12-slim" + digest: "" + # Tinybird CLI version (pip package `tinybird`). + cliVersion: "4.6.21" + # Source tarball whose packages/tinybird is deployed. Pin the same commit + # as image.tag, e.g. .../tar.gz/. + sourceUrl: https://codeload.github.com/openstatusHQ/openstatus/tar.gz/main + # For a private fork: a Secret holding a GitHub token, sent as a Bearer token. + # Use the API form, https://api.github.com/repos///tarball/. + sourceTokenSecret: + name: "" + key: token + backoffLimit: 6 + +# `dependsOn` mirrors compose `depends_on: service_healthy`: an init container +# waits for each listed service's health endpoint. +services: + workflows: + enabled: true + image: openstatus-workflows + port: 3000 + healthPath: /ping + env: + PORT: "3000" + resources: + requests: + memory: 256Mi + limits: + memory: 512Mi + server: + enabled: true + dependsOn: [workflows] + image: openstatus-server + port: 3000 + healthPath: /ping + env: + PORT: "3000" + resources: + requests: + memory: 256Mi + limits: + memory: 512Mi + private-location: + enabled: true + dependsOn: [server] + image: openstatus-private-location + port: 8080 + healthPath: /health + env: + GIN_MODE: release + PORT: "8080" + resources: + requests: + memory: 128Mi + limits: + memory: 256Mi + checker: + enabled: true + dependsOn: [server] + image: openstatus-checker + port: 8080 + healthPath: /health + env: + PORT: "8080" + resources: + requests: + memory: 128Mi + limits: + memory: 256Mi + dashboard: + enabled: true + dependsOn: [workflows, server] + image: openstatus-dashboard + port: 3000 + healthPath: /api/health + env: + PORT: "3000" + HOSTNAME: 0.0.0.0 + AUTH_TRUST_HOST: "true" + resources: + requests: + memory: 256Mi + limits: + memory: 512Mi + status-page: + enabled: true + dependsOn: [workflows, server] + image: openstatus-status-page + port: 3000 + healthPath: /api/health + env: + PORT: "3000" + HOSTNAME: 0.0.0.0 + AUTH_TRUST_HOST: "true" + resources: + requests: + memory: 256Mi + limits: + memory: 512Mi + +dbMigrate: + image: openstatus-db-migrate + +# Part 4 of the self-hosting guide: external cron against the workflows app. +cron: + image: + repository: curlimages/curl + tag: latest + digest: "" + jobs: + private-location-health: + schedule: "*/5 * * * *" + path: /cron/private-location-health + +# Optional in-cluster probe (ghcr.io/openstatushq/private-location). Create a +# private location in the dashboard, then store its key in `probe.existingSecret` +# under OPENSTATUS_KEY. +probe: + enabled: false + image: private-location + existingSecret: "" + # Defaults to the in-cluster ingest service. + ingestUrl: "" + resources: + requests: + memory: 64Mi + limits: + memory: 128Mi + +nodeSelector: {} +tolerations: [] +affinity: {} +podSecurityContext: {} -- 2.51.2