diff --git a/.github/workflows/helm-chart.yml b/.github/workflows/helm-chart.yml new file mode 100644 index 000000000..4d6d47617 --- /dev/null +++ b/.github/workflows/helm-chart.yml @@ -0,0 +1,97 @@ +name: helm chart + +on: + workflow_dispatch: + push: + branches: [main] + paths: + - "deploy/charts/buzz/**" + - ".github/workflows/helm-chart.yml" + - "ct.yaml" + pull_request: + paths: + - "deploy/charts/buzz/**" + - ".github/workflows/helm-chart.yml" + - "ct.yaml" + +jobs: + lint-and-unittest: + name: lint + unittest + render matrix + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Helm + uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1 + with: + version: v3.16.4 + + - name: Install helm-unittest plugin + run: helm plugin install --version 0.8.2 https://github.com/helm-unittest/helm-unittest + + - name: Set up Python (for chart-testing) + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Set up chart-testing + uses: helm/chart-testing-action@0d28d3144d3a25ea2cc349d6e59901c4ff469b3b # v2.7.0 + + - name: Build chart dependencies + run: helm dependency build deploy/charts/buzz + + - name: ct lint + run: ct lint --config ct.yaml --all + + - name: helm-unittest + run: helm unittest deploy/charts/buzz + + - name: helm template (render every fixture) + run: | + set -euo pipefail + for f in deploy/charts/buzz/ci/*-values.yaml deploy/charts/buzz/tests/fixtures/*-values.yaml; do + echo "::group::render $f" + helm template buzz deploy/charts/buzz -f "$f" + echo "::endgroup::" + done + + install-on-kind: + # Full end-to-end install requires the public ghcr.io/block/buzz image to + # exist and to embed Max's startup migrations. Runs only after Sami's + # image PR merges (`workflow_dispatch`) or on a schedule once main carries + # both prerequisites. Render/lint above is the per-PR signal. + name: install on kind (gated) + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + needs: lint-and-unittest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Helm + uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1 + with: + version: v3.16.4 + + - name: Set up Python (for chart-testing) + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Set up chart-testing + uses: helm/chart-testing-action@0d28d3144d3a25ea2cc349d6e59901c4ff469b3b # v2.7.0 + + - name: Create kind cluster + uses: helm/kind-action@0025e74a8c7512023d06dc019c617aa3cf561fde # v1.10.0 + with: + version: v0.24.0 + node_image: kindest/node:v1.31.0 + + - name: Build chart dependencies + run: helm dependency build deploy/charts/buzz + + - name: ct install (quickstart profile) + run: ct install --config ct.yaml --charts deploy/charts/buzz --helm-extra-args "--timeout 600s" diff --git a/.gitignore b/.gitignore index 9a7436439..3fdd5d7c3 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,6 @@ identity.key # mesh-llm build cache .cache/ + +# Helm dependency tarballs — regenerable from Chart.lock via `helm dependency build` +deploy/charts/*/charts/*.tgz diff --git a/ct.yaml b/ct.yaml new file mode 100644 index 000000000..23d1467a1 --- /dev/null +++ b/ct.yaml @@ -0,0 +1,9 @@ +chart-dirs: + - deploy/charts +chart-repos: [] +helm-extra-args: --timeout 600s +target-branch: main +# Skip OCI subchart fetches that need docker.io anonymous access — ct +# resolves these locally via `helm dependency build` in the workflow. +remote: origin +validate-maintainers: false diff --git a/deploy/charts/buzz/Chart.lock b/deploy/charts/buzz/Chart.lock new file mode 100644 index 000000000..9987bcf85 --- /dev/null +++ b/deploy/charts/buzz/Chart.lock @@ -0,0 +1,9 @@ +dependencies: +- name: postgres + repository: oci://registry-1.docker.io/cloudpirates + version: 0.19.5 +- name: redis + repository: oci://registry-1.docker.io/cloudpirates + version: 0.30.3 +digest: sha256:9c0df32008f782064104ecec8552f348841f120852d49ace66966a56ba904348 +generated: "2026-06-11T15:45:41.764379-04:00" diff --git a/deploy/charts/buzz/Chart.yaml b/deploy/charts/buzz/Chart.yaml new file mode 100644 index 000000000..0a1a32d3c --- /dev/null +++ b/deploy/charts/buzz/Chart.yaml @@ -0,0 +1,42 @@ +apiVersion: v2 +name: buzz +description: | + Buzz — a Nostr-based messaging platform for human–agent collaboration. + + A single relay binary serving WebSocket + REST + web UI, backed by + PostgreSQL, Redis, and Typesense. Configurable for single-node evaluation + (subcharts on) and HA production (external services, existingSecret). +type: application +version: 0.1.0 +appVersion: "0.1.0" +home: https://github.com/block/buzz +sources: + - https://github.com/block/buzz +keywords: + - nostr + - relay + - messaging + - websocket + - chat +maintainers: + - name: Block + url: https://github.com/block +annotations: + artifacthub.io/changes: | + - kind: added + description: Initial chart for Buzz relay. + artifacthub.io/license: Apache-2.0 + +# Optional eval-only subcharts. Production deploys disable both and point +# externalPostgresql / externalRedis (or secrets.existingSecret) at managed +# services. +dependencies: + - name: postgres + version: "0.19.x" + repository: oci://registry-1.docker.io/cloudpirates + condition: postgresql.enabled + alias: postgresql + - name: redis + version: "0.30.x" + repository: oci://registry-1.docker.io/cloudpirates + condition: redis.enabled diff --git a/deploy/charts/buzz/README.md b/deploy/charts/buzz/README.md new file mode 100644 index 000000000..8f692e976 --- /dev/null +++ b/deploy/charts/buzz/README.md @@ -0,0 +1,97 @@ +# Buzz Helm Chart + +[Buzz](https://github.com/block/buzz) is a Nostr-based messaging platform for human–agent collaboration: a single relay binary serving WebSocket + REST + web UI, backed by PostgreSQL, Redis, Typesense, and S3-compatible object storage. + +This chart has two operating profiles selected by values: + +| Profile | When | What you get | +|---|---|---| +| **Production** (default) | Self-hosted multi-tenant, regulated, or GitOps-managed | External managed Postgres/Redis/Typesense/S3, `secrets.existingSecret:`, no chart-side autogen, HA-capable (`replicaCount ≥ 2`) | +| **Quickstart** (`--set quickstart=true`) | Eval, single-node, one-off demo | In-cluster Postgres + Redis subcharts ([CloudPirates](https://github.com/cloudpirates)), chart auto-generates relay secrets, single replica | + +## Quickstart (eval only) + +```sh +helm install buzz oci://ghcr.io/block/buzz/charts/buzz --version 0.1.0 \ + --create-namespace --namespace buzz \ + --set quickstart=true \ + --set postgresql.enabled=true \ + --set redis.enabled=true \ + --set relayUrl=wss://buzz.example.com \ + --set ownerPubkey=<64-char-hex-pubkey> \ + --set typesense.url=http://typesense.buzz.svc.cluster.local:8108 \ + --set typesense.apiKey= +``` + +Quickstart still requires an externally managed Typesense in v1; bring up a minimal Typesense Pod/StatefulSet in your namespace, or set `typesense.url` and `typesense.apiKey` to a hosted instance. See the open question in `OPEN_QUESTIONS` at the bottom of this README. + +## Production (GitOps) + +The chart is designed for ArgoCD and Flux. Both render charts with `helm template`, in which mode Helm's `lookup` function returns empty — any chart-side `randAlphaNum` call would regenerate secrets on every sync. The chart-managed Secret path is **only** safe for `helm install` / `helm upgrade`. + +Production deploys MUST use `secrets.existingSecret:`. The Secret is consumed for any keys present and ignored for keys missing — extras are harmless. + +See: + +- [`examples/argocd-app.yaml`](examples/argocd-app.yaml) — ArgoCD Application +- [`examples/flux-helmrelease.yaml`](examples/flux-helmrelease.yaml) — Flux HelmRelease v2 +- [`examples/secret-sample.yaml`](examples/secret-sample.yaml) — Secret schema + +## Required inputs + +| Key | What | When required | +|---|---|---| +| `relayUrl` | Public `wss://` URL clients connect to | Always | +| `ownerPubkey` | 64-char lowercase hex Nostr pubkey of the relay operator | When `relay.requireRelayMembership=true` (default) | +| `secrets.existingSecret` | Name of pre-created Secret | Production / GitOps | +| `externalPostgresql.url` / `externalRedis.url` / `typesense.url` | External service URLs | When the matching subchart is disabled (default) | + +The chart fails at `helm install` / `helm template` time with a clear message if any of these are missing or malformed (see `templates/_validate.tpl`). + +## HA (production) + +`replicaCount > 1` hard-requires both: + +- Redis (`redis.enabled=true`, `externalRedis.url`, or `REDIS_URL` in `existingSecret`) — for `buzz-pubsub` fan-out +- ReadWriteMany git PVC — `persistence.git.accessMode: ReadWriteMany` with a RWX storage class (e.g. `efs-sc` on AWS, `azurefile-csi` on Azure) + +The chart **template-fails** if either invariant is broken. No silent degradation. + +## Upgrades + +Schema migrations are embedded in the relay binary via `sqlx::migrate!` and run at startup, gated by `BUZZ_AUTO_MIGRATE` (default `true`). Multiple replicas race-safely behind a Postgres advisory lock. `helm upgrade` is the entire upgrade procedure. + +If you prefer decoupling migrations from serving, set `migrate.autoMigrate=false`. **In that mode the chart does not run migrations for you** — you own running `buzz-admin migrate` (separate Pod / one-shot Job) against the database before every `helm install` / `helm upgrade`. Readiness probes only verify DB connectivity, not schema freshness, so a pod will appear healthy against an unmigrated schema and fail under load. A pre-upgrade Helm Job for this is on the chart roadmap; the values knob `migrate.preUpgradeJob.enabled` is reserved. + +## Backups + +Save these. Losing any of them is data loss. See NOTES.txt printed by `helm install` for the live list: + +1. `BUZZ_RELAY_PRIVATE_KEY` — relay identity. Rotating it = new identity (federation peers will not recognize the relay). +2. PostgreSQL database — the canonical event store. +3. S3 bucket — media blobs (chart default bucket: `buzz-media`). +4. Git PVC — repo on-disk state served by the relay's git endpoint. +5. Owner private key — held by the operator, not by this chart. Restore by re-installing with the same `ownerPubkey`. + +## Honest limitations (v1) + +- **Typesense has no in-chart subchart.** Bring your own Typesense; the chart wires it via `typesense.url` + `typesense.apiKey` (or `TYPESENSE_URL` / `TYPESENSE_API_KEY` in `existingSecret`). The roadmap depends on either an upstream community chart hitting our quality bar or a minimal in-chart StatefulSet behind a quickstart flag. +- **Minimal-mode is not yet supported.** The relay's `BUZZ_PUBSUB=local` / `BUZZ_SEARCH=pg` / filesystem media paths are upstream work in progress. Until then, "quickstart" still needs Typesense. +- **OCI publish to GHCR + cosign signing** is a follow-up PR. For now, install the chart from source: `helm install buzz ./deploy/charts/buzz` after cloning the repo. + +## Development + +```sh +# Render every fixture +for f in ci/*-values.yaml tests/fixtures/*-values.yaml; do + helm template buzz . -f "$f" >/dev/null && echo "ok: $f" +done + +# Unit tests +helm plugin install https://github.com/helm-unittest/helm-unittest +helm unittest . + +# Lint +helm dependency build . +ct lint --config ../../../ct.yaml --charts . +``` diff --git a/deploy/charts/buzz/ci/quickstart-values.yaml b/deploy/charts/buzz/ci/quickstart-values.yaml new file mode 100644 index 000000000..ea5815d29 --- /dev/null +++ b/deploy/charts/buzz/ci/quickstart-values.yaml @@ -0,0 +1,19 @@ +# Quickstart / eval: subcharts on, autogen secrets, single replica. +# This is the scenario `ct install` exercises against a kind cluster — it +# spins up postgres + redis in-cluster so the relay can actually start. +quickstart: true +postgresql: + enabled: true +redis: + enabled: true +relayUrl: wss://buzz.test.local +ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000001" +typesense: + url: "http://typesense.default.svc.cluster.local:8108" + apiKey: "ci-fake-key" +relay: + # Don't enforce membership in CI — we're testing the chart renders and the + # Pod starts, not relay business logic. + requireRelayMembership: false +podDisruptionBudget: + enabled: false diff --git a/deploy/charts/buzz/examples/argocd-app.yaml b/deploy/charts/buzz/examples/argocd-app.yaml new file mode 100644 index 000000000..612f6e4b2 --- /dev/null +++ b/deploy/charts/buzz/examples/argocd-app.yaml @@ -0,0 +1,76 @@ +# ArgoCD Application — GitOps-safe Buzz install. +# +# Prerequisite: a Secret named `buzz-secrets` in namespace `buzz` containing +# (any subset of) the keys consumed by `secrets.existingSecret`. See +# `secret-sample.yaml` for the schema. +# +# Why `existingSecret` and not chart autogen: ArgoCD renders manifests with +# `helm template`, in which mode Helm's `lookup` function returns empty and +# any chart-side `randAlphaNum` call regenerates on every sync. The +# chart-managed Secret path is for `helm install` / `helm upgrade` only. +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: buzz + namespace: argocd +spec: + project: default + source: + repoURL: oci://ghcr.io/block/buzz/charts + chart: buzz + targetRevision: 0.1.0 + helm: + releaseName: buzz + values: | + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" # replace + replicaCount: 3 + + secrets: + existingSecret: buzz-secrets + + externalPostgresql: + # DATABASE_URL also lives in buzz-secrets; this is here only if you + # prefer the URL stored in values vs. the Secret. Pick one. + url: "" + + externalRedis: + url: "" + + typesense: + url: "http://typesense.buzz.svc.cluster.local:8108" + # apiKey lives in buzz-secrets + + s3: + endpoint: "https://s3.us-east-1.amazonaws.com" + bucket: "buzz-media" + # accessKey / secretKey live in buzz-secrets + + persistence: + git: + enabled: true + accessMode: ReadWriteMany # required: replicaCount > 1 + storageClass: efs-sc # provider-specific RWX class + size: 50Gi + + ingress: + enabled: true + className: nginx + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + # WebSocket: long-lived; raise proxy timeouts. + nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" + tls: + - hosts: [buzz.example.com] + secretName: buzz-tls + destination: + server: https://kubernetes.default.svc + namespace: buzz + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true + - ServerSideApply=true diff --git a/deploy/charts/buzz/examples/flux-helmrelease.yaml b/deploy/charts/buzz/examples/flux-helmrelease.yaml new file mode 100644 index 000000000..a6b6276c5 --- /dev/null +++ b/deploy/charts/buzz/examples/flux-helmrelease.yaml @@ -0,0 +1,64 @@ +# Flux HelmRelease — GitOps-safe Buzz install. +# +# Prerequisite: a Secret named `buzz-secrets` in namespace `buzz`. See +# `secret-sample.yaml`. Flux renders the chart server-side via the +# helm-controller, which (like ArgoCD) treats chart-side `randAlphaNum` +# autogen as non-idempotent. `existingSecret` is the only safe path. +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: HelmRepository +metadata: + name: buzz + namespace: buzz +spec: + type: oci + url: oci://ghcr.io/block/buzz/charts + interval: 10m +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: buzz + namespace: buzz +spec: + interval: 10m + chart: + spec: + chart: buzz + version: "0.1.0" + sourceRef: + kind: HelmRepository + name: buzz + values: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" # replace + replicaCount: 3 + + secrets: + existingSecret: buzz-secrets + + typesense: + url: "http://typesense.buzz.svc.cluster.local:8108" + # apiKey lives in buzz-secrets + + s3: + endpoint: "https://s3.us-east-1.amazonaws.com" + bucket: "buzz-media" + + persistence: + git: + enabled: true + accessMode: ReadWriteMany + storageClass: efs-sc + size: 50Gi + + ingress: + enabled: true + className: nginx + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" + tls: + - hosts: [buzz.example.com] + secretName: buzz-tls diff --git a/deploy/charts/buzz/examples/ingress-cert-manager.yaml b/deploy/charts/buzz/examples/ingress-cert-manager.yaml new file mode 100644 index 000000000..e2df24796 --- /dev/null +++ b/deploy/charts/buzz/examples/ingress-cert-manager.yaml @@ -0,0 +1,69 @@ +# Ingress + automatic TLS via cert-manager (Let's Encrypt HTTP-01). +# +# This file is BOTH: +# 1. A values fragment for the chart (the `ingress:` block) — pass with `-f`. +# 2. A ClusterIssuer manifest at the bottom — apply ONCE per cluster with +# `kubectl apply -f`. The chart does not manage it. +# +# Helm's value-file parser reads ONLY the first YAML document; the second +# document (the ClusterIssuer) is ignored by Helm. That is intentional — the +# ClusterIssuer is cluster-scoped and outlives any single release. +# +# Prerequisite: cert-manager installed in the cluster. The chart does not +# depend on it — that is a cluster operator decision. +# +# helm install cert-manager cert-manager \ +# --repo https://charts.jetstack.io \ +# --namespace cert-manager --create-namespace \ +# --set crds.enabled=true +# +# Apply this file in two passes: +# +# # 1. Install the cluster-scoped ClusterIssuer (idempotent): +# kubectl apply -f deploy/charts/buzz/examples/ingress-cert-manager.yaml +# +# # 2. Install/upgrade the chart with the values fragment: +# helm upgrade --install buzz ./deploy/charts/buzz \ +# -f values-production.yaml \ +# -f deploy/charts/buzz/examples/ingress-cert-manager.yaml \ +# --set relayUrl=wss://buzz.example.com +# +# Why HTTP-01: works for any public-DNS host without DNS-API credentials. +# Switch to DNS-01 if your relay is on a private/split-DNS host or you want +# wildcard certs. + +# ── Values fragment (Helm reads this document) ─────────────────────────────── +ingress: + enabled: true + className: nginx + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + # Long-lived WebSocket connections — generous timeouts. + nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" + hosts: + - host: buzz.example.com # replace; must match relayUrl host + paths: + - path: / + pathType: Prefix + tls: + - hosts: + - buzz.example.com # replace + secretName: buzz-tls # cert-manager creates this Secret + +--- +# ── ClusterIssuer (kubectl apply, NOT consumed by Helm) ────────────────────── +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: letsencrypt-prod +spec: + acme: + server: https://acme-v02.api.letsencrypt.org/directory + email: ops@example.com # replace + privateKeySecretRef: + name: letsencrypt-prod-account-key + solvers: + - http01: + ingress: + class: nginx # match your ingress controller's ingressClass diff --git a/deploy/charts/buzz/examples/secret-sample.yaml b/deploy/charts/buzz/examples/secret-sample.yaml new file mode 100644 index 000000000..c842038b4 --- /dev/null +++ b/deploy/charts/buzz/examples/secret-sample.yaml @@ -0,0 +1,30 @@ +# Sample Secret matching `secrets.existingSecret: buzz-secrets`. +# +# Manage this via SealedSecrets / SOPS / External Secrets / Vault — anything +# that keeps the unencrypted form out of git. The chart reads keys it finds +# and leaves the rest as Pod env vars marked `optional: true`. +# +# Keys consumed by the relay (all optional unless required by relay config): +# BUZZ_RELAY_PRIVATE_KEY — 64-char hex; relay identity (do NOT rotate) +# BUZZ_GIT_HOOK_HMAC_SECRET — 32+ chars; required when replicaCount > 1 +# DATABASE_URL — postgres://... +# REDIS_URL — redis://... (required when replicaCount > 1) +# TYPESENSE_URL +# TYPESENSE_API_KEY +# BUZZ_S3_ACCESS_KEY +# BUZZ_S3_SECRET_KEY +apiVersion: v1 +kind: Secret +metadata: + name: buzz-secrets + namespace: buzz +type: Opaque +stringData: + BUZZ_RELAY_PRIVATE_KEY: "REPLACE_WITH_64_HEX" + BUZZ_GIT_HOOK_HMAC_SECRET: "REPLACE_WITH_RANDOM_64_CHARS" + DATABASE_URL: "postgres://buzz:REPLACE@postgres.buzz.svc.cluster.local:5432/buzz?sslmode=require" + REDIS_URL: "redis://:REPLACE@redis.buzz.svc.cluster.local:6379" + TYPESENSE_URL: "http://typesense.buzz.svc.cluster.local:8108" + TYPESENSE_API_KEY: "REPLACE" + BUZZ_S3_ACCESS_KEY: "REPLACE" + BUZZ_S3_SECRET_KEY: "REPLACE" diff --git a/deploy/charts/buzz/templates/NOTES.txt b/deploy/charts/buzz/templates/NOTES.txt new file mode 100644 index 000000000..d9f54a938 --- /dev/null +++ b/deploy/charts/buzz/templates/NOTES.txt @@ -0,0 +1,96 @@ +══════════════════════════════════════════════════════════════════════════════ + Buzz {{ .Chart.AppVersion }} — release "{{ .Release.Name }}" (namespace {{ .Release.Namespace }}) +══════════════════════════════════════════════════════════════════════════════ + +▶ Relay URL + {{ .Values.relayUrl }} + +▶ Owner pubkey + {{ .Values.ownerPubkey }} + {{- if not .Values.ownerPubkey }} + ⚠ ownerPubkey is empty — this is only valid when relay.requireRelayMembership=false. + {{- end }} + +▶ Health + kubectl -n {{ .Release.Namespace }} port-forward svc/{{ include "buzz.fullname" . }} 8080:{{ .Values.service.healthPort }} + curl http://localhost:8080/_readiness + +{{ if not .Values.ingress.enabled }}{{ if not .Values.httproute.enabled }} +▶ Networking + Neither ingress nor Gateway API HTTPRoute is enabled. Expose the relay + through your own gateway, then ensure clients reach .Values.relayUrl + ({{ .Values.relayUrl }}) over wss://. Long-lived WebSocket connections + require generous proxy read/send timeouts (≥ 1h). +{{ end }}{{ end }} + +────────────────────────────────────────────────────────────────────────────── + Profile +────────────────────────────────────────────────────────────────────────────── +{{ if or .Values.postgresql.enabled .Values.redis.enabled }} +⚠ QUICKSTART / EVALUATION PROFILE + {{ if .Values.postgresql.enabled }}- In-cluster Postgres subchart (CloudPirates){{ end }} + {{ if .Values.redis.enabled }}- In-cluster Redis subchart (CloudPirates){{ end }} + - Chart auto-generates secrets via the `lookup` pattern. This is NOT + GitOps-safe — secrets will silently rotate under ArgoCD/Flux. For + production, see examples/argocd-app.yaml or examples/flux-helmrelease.yaml. + +{{ else }} +✓ PRODUCTION PROFILE + External Postgres, Redis (if enabled), Typesense, S3. + {{ if .Values.secrets.existingSecret }}- Secrets sourced from: {{ .Values.secrets.existingSecret }}{{ end }} +{{ end }} + +────────────────────────────────────────────────────────────────────────────── + Backups — save these +────────────────────────────────────────────────────────────────────────────── + 1. BUZZ_RELAY_PRIVATE_KEY — relay identity. Rotating it = identity change; + federation peers will treat the relay as a new identity. + 2. PostgreSQL database{{ if .Values.postgresql.enabled }} ({{ .Release.Name }}-postgresql PVC){{ end }} + 3. S3 bucket "{{ .Values.s3.bucket }}" — media blobs + 4. Git PVC ({{ include "buzz.fullname" . }}-git) — repo on-disk state + 5. Owner private key (held by the operator, NOT the chart) — restore by + re-installing with the same ownerPubkey. + +────────────────────────────────────────────────────────────────────────────── + Degradation warnings +────────────────────────────────────────────────────────────────────────────── +{{- if not .Values.relay.requireAuthToken }} + ⚠ relay.requireAuthToken=false — REST API bypasses token auth. Production + should set this to true. +{{- end }} +{{- if not .Values.relay.requireRelayMembership }} + ⚠ relay.requireRelayMembership=false — relay is OPEN. Anyone can publish. +{{- end }} +{{- if not .Values.migrate.autoMigrate }} + ⚠ migrate.autoMigrate=false — relay startup will NOT run sqlx migrations. + You must run `buzz-admin migrate` against the database before every + `helm install` / `helm upgrade`, or pods will start against an unmigrated + schema. Readiness probes only verify DB connectivity, not schema freshness. +{{- end }} +{{- if or .Values.secrets.relayPrivateKey .Values.secrets.gitHookHmacSecret }} + ⚠ Inline secret values are set in values.yaml + ({{ if .Values.secrets.relayPrivateKey }}secrets.relayPrivateKey{{ end }}{{ if and .Values.secrets.relayPrivateKey .Values.secrets.gitHookHmacSecret }}, {{ end }}{{ if .Values.secrets.gitHookHmacSecret }}secrets.gitHookHmacSecret{{ end }}). + Inline overrides leak secrets into git history and CI logs. Move them to a + Kubernetes Secret and reference it via secrets.existingSecret — see + examples/secret-sample.yaml. +{{- end }} +{{- if and (gt (.Values.replicaCount | int) 1) (eq .Values.persistence.git.accessMode "ReadWriteOnce") }} + ⚠ replicaCount > 1 with ReadWriteOnce git PVC will fail at template time + (this message should never appear — file a bug). +{{- end }} +{{- if not .Values.secrets.existingSecret }} +{{- if not (or .Values.postgresql.enabled .Values.redis.enabled) }} + ⚠ Chart-managed Secret is in use (no secrets.existingSecret). This is fine + for `helm install` / `helm upgrade` but NOT safe under GitOps tools that + `helm template` to render manifests — the `lookup` function returns empty + in that mode and secrets will silently rotate. Use existingSecret for + ArgoCD / Flux. +{{- end }} +{{- end }} + +────────────────────────────────────────────────────────────────────────────── + Useful commands +────────────────────────────────────────────────────────────────────────────── + kubectl -n {{ .Release.Namespace }} get pods -l app.kubernetes.io/instance={{ .Release.Name }} + kubectl -n {{ .Release.Namespace }} logs -l app.kubernetes.io/instance={{ .Release.Name }} --tail=200 + kubectl -n {{ .Release.Namespace }} rollout status deployment/{{ include "buzz.fullname" . }} diff --git a/deploy/charts/buzz/templates/_helpers.tpl b/deploy/charts/buzz/templates/_helpers.tpl new file mode 100644 index 000000000..e6774252e --- /dev/null +++ b/deploy/charts/buzz/templates/_helpers.tpl @@ -0,0 +1,86 @@ +{{/* Standard naming/labels helpers. */}} + +{{- define "buzz.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "buzz.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- if contains $name .Release.Name -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} +{{- end -}} + +{{- define "buzz.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "buzz.labels" -}} +helm.sh/chart: {{ include "buzz.chart" . }} +{{ include "buzz.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +app.kubernetes.io/part-of: buzz +{{- end -}} + +{{- define "buzz.selectorLabels" -}} +app.kubernetes.io/name: {{ include "buzz.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end -}} + +{{- define "buzz.serviceAccountName" -}} +{{- if .Values.serviceAccount.create -}} +{{- default (include "buzz.fullname" .) .Values.serviceAccount.name -}} +{{- else -}} +{{- default "default" .Values.serviceAccount.name -}} +{{- end -}} +{{- end -}} + +{{- define "buzz.image" -}} +{{- $tag := default .Chart.AppVersion .Values.image.tag -}} +{{- printf "%s:%s" .Values.image.repository $tag -}} +{{- end -}} + +{{/* +Name of the chart-managed Secret holding relay-identity material and any +chart-composed connection strings. +*/}} +{{- define "buzz.chartSecretName" -}} +{{- printf "%s-relay" (include "buzz.fullname" .) -}} +{{- end -}} + +{{/* +The Secret name the relay should pull env from. If the operator supplied +secrets.existingSecret, use that. Otherwise use the chart-managed one. +*/}} +{{- define "buzz.envSecretName" -}} +{{- if .Values.secrets.existingSecret -}} +{{- .Values.secrets.existingSecret -}} +{{- else -}} +{{- include "buzz.chartSecretName" . -}} +{{- end -}} +{{- end -}} + +{{/* Host derived from relayUrl, used as ingress default + media domain. */}} +{{- define "buzz.relayHost" -}} +{{- $url := required "relayUrl is required: set --set relayUrl=wss://your.domain" .Values.relayUrl -}} +{{- $stripped := $url | replace "wss://" "" | replace "ws://" "" | replace "https://" "" | replace "http://" "" -}} +{{- first (splitList "/" $stripped) -}} +{{- end -}} + +{{/* Default media base URL: https:///media derived from relayUrl. */}} +{{- define "buzz.mediaBaseUrl" -}} +{{- if .Values.mediaBaseUrl -}} +{{- .Values.mediaBaseUrl -}} +{{- else -}} +{{- printf "https://%s/media" (include "buzz.relayHost" .) -}} +{{- end -}} +{{- end -}} diff --git a/deploy/charts/buzz/templates/_validate.tpl b/deploy/charts/buzz/templates/_validate.tpl new file mode 100644 index 000000000..e314cfb60 --- /dev/null +++ b/deploy/charts/buzz/templates/_validate.tpl @@ -0,0 +1,58 @@ +{{/* +Hard fail guards. Included from every rendered template so misconfigs +surface at template time regardless of which manifest helm renders first. +*/}} + +{{- define "buzz.validate" -}} + +{{/* relayUrl is required */}} +{{- if not .Values.relayUrl -}} + {{- fail "relayUrl is required: set --set relayUrl=wss://your.domain" -}} +{{- end -}} + +{{/* replicaCount > 1 requires Redis */}} +{{- if gt (.Values.replicaCount | int) 1 -}} + {{- if and (not .Values.redis.enabled) (not .Values.externalRedis.url) (not .Values.secrets.existingSecret) -}} + {{- fail (printf "replicaCount=%d requires Redis for buzz-pubsub. Enable redis.enabled=true, set externalRedis.url, or provide secrets.existingSecret with key REDIS_URL." (.Values.replicaCount | int)) -}} + {{- end -}} +{{- end -}} + +{{/* replicaCount > 1 requires ReadWriteMany git storage */}} +{{- if gt (.Values.replicaCount | int) 1 -}} + {{- if and .Values.persistence.git.enabled (not .Values.persistence.git.existingClaim) -}} + {{- if ne .Values.persistence.git.accessMode "ReadWriteMany" -}} + {{- fail (printf "replicaCount=%d requires persistence.git.accessMode=ReadWriteMany (got %q). The relay's git on-disk state must be shared across replicas." (.Values.replicaCount | int) .Values.persistence.git.accessMode) -}} + {{- end -}} + {{- end -}} +{{- end -}} + +{{/* Owner pubkey required when requireRelayMembership */}} +{{- if .Values.relay.requireRelayMembership -}} + {{- if not .Values.ownerPubkey -}} + {{- fail "ownerPubkey is required when relay.requireRelayMembership=true. Set ownerPubkey to the 64-char lowercase hex Nostr pubkey of the relay operator, or set relay.requireRelayMembership=false for an open relay." -}} + {{- end -}} +{{- end -}} + +{{/* ownerPubkey format check */}} +{{- if .Values.ownerPubkey -}} + {{- if not (regexMatch "^[0-9a-f]{64}$" .Values.ownerPubkey) -}} + {{- fail (printf "ownerPubkey must be 64 lowercase hex characters (got %d chars; must match ^[0-9a-f]{64}$)." (len .Values.ownerPubkey)) -}} + {{- end -}} +{{- end -}} + +{{/* ingress + httproute mutually exclusive */}} +{{- if and .Values.ingress.enabled .Values.httproute.enabled -}} + {{- fail "ingress.enabled and httproute.enabled cannot both be true — choose one." -}} +{{- end -}} + +{{/* Postgres source must exist somewhere */}} +{{- if not (or .Values.postgresql.enabled .Values.externalPostgresql.url .Values.secrets.existingSecret) -}} + {{- fail "Postgres source missing: enable postgresql.enabled=true, set externalPostgresql.url, or provide secrets.existingSecret with key DATABASE_URL." -}} +{{- end -}} + +{{/* Typesense source must exist somewhere */}} +{{- if not (or .Values.typesense.url .Values.secrets.existingSecret) -}} + {{- fail "Typesense source missing: set typesense.url + typesense.apiKey, or provide secrets.existingSecret with keys TYPESENSE_URL + TYPESENSE_API_KEY." -}} +{{- end -}} + +{{- end -}} diff --git a/deploy/charts/buzz/templates/deployment.yaml b/deploy/charts/buzz/templates/deployment.yaml new file mode 100644 index 000000000..a07dd18e3 --- /dev/null +++ b/deploy/charts/buzz/templates/deployment.yaml @@ -0,0 +1,183 @@ +{{- include "buzz.validate" . -}} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "buzz.fullname" . }} + labels: + {{- include "buzz.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + selector: + matchLabels: + {{- include "buzz.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "buzz.selectorLabels" . | nindent 8 }} + {{- with .Values.relay.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + annotations: + # Roll pods when the chart-managed Secret changes. + checksum/secret: {{ include (print $.Template.BasePath "/secret-chart.yaml") . | sha256sum }} + {{- with .Values.relay.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + serviceAccountName: {{ include "buzz.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.relay.securityContext | nindent 8 }} + terminationGracePeriodSeconds: {{ .Values.relay.terminationGracePeriodSeconds }} + {{- with .Values.image.pullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.relay.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.relay.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.relay.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.relay.topologySpreadConstraints }} + topologySpreadConstraints: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: relay + image: {{ include "buzz.image" . }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + securityContext: + {{- toYaml .Values.relay.containerSecurityContext | nindent 12 }} + ports: + - { name: app, containerPort: 3000, protocol: TCP } + - { name: health, containerPort: {{ .Values.service.healthPort }}, protocol: TCP } + - { name: metrics, containerPort: {{ .Values.service.metricsPort }}, protocol: TCP } + env: + # ── Networking ─────────────────────────────────────────── + - { name: BUZZ_BIND_ADDR, value: {{ .Values.relay.bindAddr | quote }} } + - { name: BUZZ_HEALTH_PORT, value: {{ .Values.service.healthPort | quote }} } + - { name: BUZZ_METRICS_PORT, value: {{ .Values.service.metricsPort | quote }} } + - { name: RELAY_URL, value: {{ .Values.relayUrl | quote }} } + - { name: BUZZ_MEDIA_BASE_URL, value: {{ include "buzz.mediaBaseUrl" . | quote }} } + - { name: BUZZ_MEDIA_SERVER_DOMAIN, value: {{ include "buzz.relayHost" . | quote }} } + + # ── Behavior ───────────────────────────────────────────── + - { name: BUZZ_MAX_CONNECTIONS, value: {{ .Values.relay.maxConnections | quote }} } + - { name: BUZZ_MAX_CONCURRENT_HANDLERS, value: {{ .Values.relay.maxConcurrentHandlers | quote }} } + - { name: BUZZ_SEND_BUFFER, value: {{ .Values.relay.sendBuffer | quote }} } + - { name: BUZZ_REQUIRE_AUTH_TOKEN, value: {{ .Values.relay.requireAuthToken | quote }} } + - { name: BUZZ_REQUIRE_RELAY_MEMBERSHIP, value: {{ .Values.relay.requireRelayMembership | quote }} } + - { name: BUZZ_ALLOW_NIP_OA_AUTH, value: {{ .Values.relay.allowNipOaAuth | quote }} } + - { name: BUZZ_PUBKEY_ALLOWLIST, value: {{ .Values.relay.pubkeyAllowlist | quote }} } + {{- if .Values.relay.corsOrigins }} + - { name: BUZZ_CORS_ORIGINS, value: {{ join "," .Values.relay.corsOrigins | quote }} } + {{- end }} + {{- if gt (.Values.relay.ephemeralTtlOverride | int) 0 }} + - { name: BUZZ_EPHEMERAL_TTL_OVERRIDE, value: {{ .Values.relay.ephemeralTtlOverride | quote }} } + {{- end }} + + # ── Owner ──────────────────────────────────────────────── + - { name: RELAY_OWNER_PUBKEY, value: {{ .Values.ownerPubkey | quote }} } + + # ── Migrations ─────────────────────────────────────────── + - { name: BUZZ_AUTO_MIGRATE, value: {{ .Values.migrate.autoMigrate | quote }} } + + # ── Git ────────────────────────────────────────────────── + - { name: BUZZ_GIT_REPO_PATH, value: {{ .Values.persistence.git.mountPath | quote }} } + - { name: BUZZ_GIT_MAX_PACK_BYTES, value: {{ .Values.git.maxPackBytes | quote }} } + - { name: BUZZ_GIT_MAX_REPOS_PER_PUBKEY, value: {{ .Values.git.maxReposPerPubkey | quote }} } + - { name: BUZZ_GIT_MAX_CONCURRENT_OPS, value: {{ .Values.git.maxConcurrentOps | quote }} } + + # ── S3 (non-secret) ────────────────────────────────────── + {{- if .Values.s3.endpoint }} + - { name: BUZZ_S3_ENDPOINT, value: {{ .Values.s3.endpoint | quote }} } + {{- end }} + - { name: BUZZ_S3_BUCKET, value: {{ .Values.s3.bucket | quote }} } + + # ── Secrets (from chart-managed or existing) ───────────── + - name: BUZZ_RELAY_PRIVATE_KEY + valueFrom: + secretKeyRef: + name: {{ include "buzz.envSecretName" . }} + key: BUZZ_RELAY_PRIVATE_KEY + optional: true + - name: BUZZ_GIT_HOOK_HMAC_SECRET + valueFrom: + secretKeyRef: + name: {{ include "buzz.envSecretName" . }} + key: BUZZ_GIT_HOOK_HMAC_SECRET + optional: true + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: {{ include "buzz.envSecretName" . }} + key: DATABASE_URL + - name: REDIS_URL + valueFrom: + secretKeyRef: + name: {{ include "buzz.envSecretName" . }} + key: REDIS_URL + optional: {{ and (eq (.Values.replicaCount | int) 1) (not .Values.redis.enabled) (not .Values.externalRedis.url) }} + - name: TYPESENSE_URL + valueFrom: + secretKeyRef: + name: {{ include "buzz.envSecretName" . }} + key: TYPESENSE_URL + - name: TYPESENSE_API_KEY + valueFrom: + secretKeyRef: + name: {{ include "buzz.envSecretName" . }} + key: TYPESENSE_API_KEY + - name: BUZZ_S3_ACCESS_KEY + valueFrom: + secretKeyRef: + name: {{ include "buzz.envSecretName" . }} + key: BUZZ_S3_ACCESS_KEY + optional: true + - name: BUZZ_S3_SECRET_KEY + valueFrom: + secretKeyRef: + name: {{ include "buzz.envSecretName" . }} + key: BUZZ_S3_SECRET_KEY + optional: true + + {{- with .Values.relay.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.relay.extraEnvFrom }} + envFrom: + {{- toYaml . | nindent 12 }} + {{- end }} + + livenessProbe: + {{- toYaml .Values.relay.livenessProbe | nindent 12 }} + readinessProbe: + {{- toYaml .Values.relay.readinessProbe | nindent 12 }} + startupProbe: + {{- toYaml .Values.relay.startupProbe | nindent 12 }} + + resources: + {{- toYaml .Values.relay.resources | nindent 12 }} + + volumeMounts: + {{- if .Values.persistence.git.enabled }} + - { name: git-repos, mountPath: {{ .Values.persistence.git.mountPath | quote }} } + {{- end }} + + volumes: + {{- if .Values.persistence.git.enabled }} + - name: git-repos + persistentVolumeClaim: + claimName: {{ default (printf "%s-git" (include "buzz.fullname" .)) .Values.persistence.git.existingClaim }} + {{- end }} diff --git a/deploy/charts/buzz/templates/httproute.yaml b/deploy/charts/buzz/templates/httproute.yaml new file mode 100644 index 000000000..2c0b77d69 --- /dev/null +++ b/deploy/charts/buzz/templates/httproute.yaml @@ -0,0 +1,28 @@ +{{- include "buzz.validate" . -}} +{{- if .Values.httproute.enabled -}} +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: {{ include "buzz.fullname" . }} + labels: + {{- include "buzz.labels" . | nindent 4 }} +spec: + parentRefs: + {{- toYaml .Values.httproute.parentRefs | nindent 4 }} + {{- with .Values.httproute.hostnames }} + hostnames: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + {{- if .Values.httproute.rules }} + {{- toYaml .Values.httproute.rules | nindent 4 }} + {{- else }} + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: {{ include "buzz.fullname" . }} + port: {{ .Values.service.port }} + {{- end }} +{{- end }} diff --git a/deploy/charts/buzz/templates/ingress.yaml b/deploy/charts/buzz/templates/ingress.yaml new file mode 100644 index 000000000..4f678eb41 --- /dev/null +++ b/deploy/charts/buzz/templates/ingress.yaml @@ -0,0 +1,43 @@ +{{- include "buzz.validate" . -}} +{{- if .Values.ingress.enabled -}} +{{- $fullName := include "buzz.fullname" . -}} +{{- $svcPort := .Values.service.port -}} +{{- $defaultHost := include "buzz.relayHost" . -}} +{{- $hosts := .Values.ingress.hosts -}} +{{- if not $hosts -}} +{{- $hosts = list (dict "host" $defaultHost "paths" (list (dict "path" "/" "pathType" "Prefix"))) -}} +{{- end -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ $fullName }} + labels: + {{- include "buzz.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.ingress.className }} + ingressClassName: {{ .Values.ingress.className }} + {{- end }} + {{- with .Values.ingress.tls }} + tls: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + {{- range $hosts }} + - host: {{ .host | default $defaultHost | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path | default "/" }} + pathType: {{ .pathType | default "Prefix" }} + backend: + service: + name: {{ $fullName }} + port: + number: {{ $svcPort }} + {{- end }} + {{- end }} +{{- end }} diff --git a/deploy/charts/buzz/templates/pdb.yaml b/deploy/charts/buzz/templates/pdb.yaml new file mode 100644 index 000000000..335e8e46c --- /dev/null +++ b/deploy/charts/buzz/templates/pdb.yaml @@ -0,0 +1,18 @@ +{{- include "buzz.validate" . -}} +{{- if and .Values.podDisruptionBudget.enabled (gt (.Values.replicaCount | int) 1) -}} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "buzz.fullname" . }} + labels: + {{- include "buzz.labels" . | nindent 4 }} +spec: + selector: + matchLabels: + {{- include "buzz.selectorLabels" . | nindent 6 }} + {{- if .Values.podDisruptionBudget.minAvailable }} + minAvailable: {{ .Values.podDisruptionBudget.minAvailable }} + {{- else if .Values.podDisruptionBudget.maxUnavailable }} + maxUnavailable: {{ .Values.podDisruptionBudget.maxUnavailable }} + {{- end }} +{{- end }} diff --git a/deploy/charts/buzz/templates/pvc-git.yaml b/deploy/charts/buzz/templates/pvc-git.yaml new file mode 100644 index 000000000..81b7ba9f2 --- /dev/null +++ b/deploy/charts/buzz/templates/pvc-git.yaml @@ -0,0 +1,22 @@ +{{- include "buzz.validate" . -}} +{{- if and .Values.persistence.git.enabled (not .Values.persistence.git.existingClaim) -}} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "buzz.fullname" . }}-git + labels: + {{- include "buzz.labels" . | nindent 4 }} + {{- with .Values.persistence.git.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + accessModes: + - {{ .Values.persistence.git.accessMode }} + resources: + requests: + storage: {{ .Values.persistence.git.size }} + {{- if .Values.persistence.git.storageClass }} + storageClassName: {{ .Values.persistence.git.storageClass | quote }} + {{- end }} +{{- end -}} diff --git a/deploy/charts/buzz/templates/secret-chart.yaml b/deploy/charts/buzz/templates/secret-chart.yaml new file mode 100644 index 000000000..6824575a4 --- /dev/null +++ b/deploy/charts/buzz/templates/secret-chart.yaml @@ -0,0 +1,91 @@ +{{- include "buzz.validate" . -}} +{{- /* +Chart-managed Secret. + +Renders only when at least one chart-managed value is needed (no +secrets.existingSecret provided OR in-cluster Postgres composes DATABASE_URL +here). Persists across upgrades via the `lookup` pattern. Not GitOps-safe — +ArgoCD/Flux users should provide secrets.existingSecret instead. +*/ -}} + +{{- if not .Values.secrets.existingSecret -}} +{{- $existing := (lookup "v1" "Secret" .Release.Namespace (include "buzz.chartSecretName" .)) | default dict -}} +{{- $existingData := (get $existing "data") | default dict -}} + +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "buzz.chartSecretName" . }} + labels: + {{- include "buzz.labels" . | nindent 4 }} + annotations: + helm.sh/resource-policy: keep +type: Opaque +data: + + {{- /* Relay private key (relay identity; rotation = identity change) */}} + {{- if .Values.secrets.relayPrivateKey }} + BUZZ_RELAY_PRIVATE_KEY: {{ .Values.secrets.relayPrivateKey | b64enc | quote }} + {{- else if (index $existingData "BUZZ_RELAY_PRIVATE_KEY") }} + BUZZ_RELAY_PRIVATE_KEY: {{ index $existingData "BUZZ_RELAY_PRIVATE_KEY" | quote }} + {{- else }} + BUZZ_RELAY_PRIVATE_KEY: {{ randAlphaNum 64 | lower | b64enc | quote }} + {{- end }} + + {{- /* Git hook HMAC (required when replicaCount > 1) */}} + {{- if .Values.secrets.gitHookHmacSecret }} + BUZZ_GIT_HOOK_HMAC_SECRET: {{ .Values.secrets.gitHookHmacSecret | b64enc | quote }} + {{- else if (index $existingData "BUZZ_GIT_HOOK_HMAC_SECRET") }} + BUZZ_GIT_HOOK_HMAC_SECRET: {{ index $existingData "BUZZ_GIT_HOOK_HMAC_SECRET" | quote }} + {{- else }} + BUZZ_GIT_HOOK_HMAC_SECRET: {{ randAlphaNum 64 | b64enc | quote }} + {{- end }} + + {{- /* In-cluster Postgres: compose DATABASE_URL + postgres-password */}} + {{- if .Values.postgresql.enabled }} + {{- $pgHost := printf "%s-postgresql" .Release.Name }} + {{- $pgDb := .Values.postgresql.auth.database }} + {{- $pgUser := .Values.postgresql.auth.username }} + {{- $pgPass := "" }} + {{- if (index $existingData "postgres-password") }} + {{- $pgPass = index $existingData "postgres-password" | b64dec }} + {{- else }} + {{- $pgPass = randAlphaNum 24 }} + {{- end }} + postgres-password: {{ $pgPass | b64enc | quote }} + DATABASE_URL: {{ printf "postgres://%s:%s@%s:5432/%s" $pgUser $pgPass $pgHost $pgDb | b64enc | quote }} + {{- else if .Values.externalPostgresql.url }} + DATABASE_URL: {{ .Values.externalPostgresql.url | b64enc | quote }} + {{- end }} + + {{- /* In-cluster Redis: compose REDIS_URL */}} + {{- if .Values.redis.enabled }} + {{- $redisHost := printf "%s-redis-master" .Release.Name }} + {{- $redisPass := "" }} + {{- if (index $existingData "redis-password") }} + {{- $redisPass = index $existingData "redis-password" | b64dec }} + {{- else }} + {{- $redisPass = randAlphaNum 24 }} + {{- end }} + redis-password: {{ $redisPass | b64enc | quote }} + REDIS_URL: {{ printf "redis://:%s@%s:6379" $redisPass $redisHost | b64enc | quote }} + {{- else if .Values.externalRedis.url }} + REDIS_URL: {{ .Values.externalRedis.url | b64enc | quote }} + {{- end }} + + {{- /* Typesense — pass through values (no subchart) */}} + {{- if .Values.typesense.url }} + TYPESENSE_URL: {{ .Values.typesense.url | b64enc | quote }} + {{- end }} + {{- if .Values.typesense.apiKey }} + TYPESENSE_API_KEY: {{ .Values.typesense.apiKey | b64enc | quote }} + {{- end }} + + {{- /* S3 creds */}} + {{- if .Values.s3.accessKey }} + BUZZ_S3_ACCESS_KEY: {{ .Values.s3.accessKey | b64enc | quote }} + {{- end }} + {{- if .Values.s3.secretKey }} + BUZZ_S3_SECRET_KEY: {{ .Values.s3.secretKey | b64enc | quote }} + {{- end }} +{{- end -}} diff --git a/deploy/charts/buzz/templates/service.yaml b/deploy/charts/buzz/templates/service.yaml new file mode 100644 index 000000000..88834bb53 --- /dev/null +++ b/deploy/charts/buzz/templates/service.yaml @@ -0,0 +1,19 @@ +{{- include "buzz.validate" . -}} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "buzz.fullname" . }} + labels: + {{- include "buzz.labels" . | nindent 4 }} + {{- with .Values.service.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + type: {{ .Values.service.type }} + selector: + {{- include "buzz.selectorLabels" . | nindent 4 }} + ports: + - { name: app, port: {{ .Values.service.port }}, targetPort: app, protocol: TCP } + - { name: health, port: {{ .Values.service.healthPort }}, targetPort: health, protocol: TCP } + - { name: metrics, port: {{ .Values.service.metricsPort }}, targetPort: metrics, protocol: TCP } diff --git a/deploy/charts/buzz/templates/serviceaccount.yaml b/deploy/charts/buzz/templates/serviceaccount.yaml new file mode 100644 index 000000000..60be80038 --- /dev/null +++ b/deploy/charts/buzz/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- include "buzz.validate" . -}} +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "buzz.serviceAccountName" . }} + labels: + {{- include "buzz.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/deploy/charts/buzz/tests/fixtures/ha-values.yaml b/deploy/charts/buzz/tests/fixtures/ha-values.yaml new file mode 100644 index 000000000..c1e014ae1 --- /dev/null +++ b/deploy/charts/buzz/tests/fixtures/ha-values.yaml @@ -0,0 +1,22 @@ +# HA shape: replicas=3 + Redis + RWX. Render-only check. +relayUrl: wss://buzz.example.com +ownerPubkey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" +replicaCount: 3 +secrets: + existingSecret: buzz-secrets +externalPostgresql: + url: "postgres://buzz:pw@postgres.example.com:5432/buzz" +externalRedis: + url: "redis://:pw@redis.example.com:6379" +typesense: + url: "https://typesense.example.com" +s3: + bucket: "buzz-media" +persistence: + git: + enabled: true + accessMode: ReadWriteMany + size: 50Gi +podDisruptionBudget: + enabled: true + minAvailable: 2 diff --git a/deploy/charts/buzz/tests/fixtures/production-existing-secret-values.yaml b/deploy/charts/buzz/tests/fixtures/production-existing-secret-values.yaml new file mode 100644 index 000000000..c875c84e9 --- /dev/null +++ b/deploy/charts/buzz/tests/fixtures/production-existing-secret-values.yaml @@ -0,0 +1,27 @@ +# Production / GitOps shape: external services, existingSecret. Renders only; +# `ct install` is not asked to satisfy the external services. +relayUrl: wss://buzz.example.com +ownerPubkey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" +secrets: + existingSecret: buzz-secrets +externalPostgresql: + url: "postgres://buzz:pw@postgres.example.com:5432/buzz" +externalRedis: + url: "redis://:pw@redis.example.com:6379" +typesense: + url: "https://typesense.example.com" +s3: + endpoint: "https://s3.us-east-1.amazonaws.com" + bucket: "buzz-media" +persistence: + git: + enabled: true + accessMode: ReadWriteMany + size: 50Gi +ingress: + enabled: true + className: nginx + annotations: + nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" +podDisruptionBudget: + enabled: false diff --git a/deploy/charts/buzz/tests/networking_test.yaml b/deploy/charts/buzz/tests/networking_test.yaml new file mode 100644 index 000000000..c5ea969e0 --- /dev/null +++ b/deploy/charts/buzz/tests/networking_test.yaml @@ -0,0 +1,75 @@ +suite: networking +templates: + - templates/ingress.yaml + - templates/httproute.yaml + - templates/service.yaml +tests: + - it: Service exposes app/health/metrics ports + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + externalPostgresql.url: postgres://u:p@h:5432/d + typesense.url: http://ts:8108 + typesense.apiKey: k + asserts: + - equal: + path: spec.ports[0].name + value: app + template: templates/service.yaml + - equal: + path: spec.ports[0].port + value: 3000 + template: templates/service.yaml + + - it: Ingress disabled by default + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + externalPostgresql.url: postgres://u:p@h:5432/d + typesense.url: http://ts:8108 + typesense.apiKey: k + asserts: + - hasDocuments: + count: 0 + template: templates/ingress.yaml + - hasDocuments: + count: 0 + template: templates/httproute.yaml + + - it: Ingress renders with derived host when relayUrl provided + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + externalPostgresql.url: postgres://u:p@h:5432/d + typesense.url: http://ts:8108 + typesense.apiKey: k + ingress.enabled: true + ingress.className: nginx + asserts: + - hasDocuments: + count: 1 + template: templates/ingress.yaml + - equal: + path: spec.ingressClassName + value: nginx + template: templates/ingress.yaml + + - it: HTTPRoute renders when enabled + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + externalPostgresql.url: postgres://u:p@h:5432/d + typesense.url: http://ts:8108 + typesense.apiKey: k + httproute.enabled: true + httproute.parentRefs: + - name: my-gateway + namespace: gateway-system + asserts: + - hasDocuments: + count: 1 + template: templates/httproute.yaml + - equal: + path: kind + value: HTTPRoute + template: templates/httproute.yaml diff --git a/deploy/charts/buzz/tests/render_test.yaml b/deploy/charts/buzz/tests/render_test.yaml new file mode 100644 index 000000000..42758b075 --- /dev/null +++ b/deploy/charts/buzz/tests/render_test.yaml @@ -0,0 +1,51 @@ +suite: production render +# Multi-template scope: needed so $.Template.BasePath lookups in deployment.yaml +# (e.g. checksum/secret include of secret-chart.yaml) resolve at render time. +templates: + - templates/deployment.yaml + - templates/secret-chart.yaml + - templates/serviceaccount.yaml + - templates/service.yaml + - templates/pvc-git.yaml +tests: + - it: renders cleanly in production profile (external pg/redis/typesense) + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + externalPostgresql.url: postgres://u:p@h:5432/d + externalRedis.url: redis://h:6379 + typesense.url: http://ts:8108 + typesense.apiKey: k + asserts: + - equal: + path: kind + value: Deployment + template: templates/deployment.yaml + - equal: + path: kind + value: ServiceAccount + template: templates/serviceaccount.yaml + - equal: + path: kind + value: Service + template: templates/service.yaml + + - it: renders HA cleanly with replicaCount=3 + RWX + Redis + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + externalPostgresql.url: postgres://u:p@h:5432/d + externalRedis.url: redis://h:6379 + typesense.url: http://ts:8108 + typesense.apiKey: k + replicaCount: 3 + persistence.git.accessMode: ReadWriteMany + asserts: + - equal: + path: spec.replicas + value: 3 + template: templates/deployment.yaml + - equal: + path: spec.accessModes[0] + value: ReadWriteMany + template: templates/pvc-git.yaml diff --git a/deploy/charts/buzz/tests/secrets_test.yaml b/deploy/charts/buzz/tests/secrets_test.yaml new file mode 100644 index 000000000..124565fd5 --- /dev/null +++ b/deploy/charts/buzz/tests/secrets_test.yaml @@ -0,0 +1,92 @@ +suite: secrets wiring +templates: + - templates/secret-chart.yaml + - templates/deployment.yaml +tests: + - it: chart-managed Secret is rendered when existingSecret is empty + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + externalPostgresql.url: postgres://u:p@h:5432/d + typesense.url: http://ts:8108 + typesense.apiKey: k + asserts: + - hasDocuments: + count: 1 + template: templates/secret-chart.yaml + - equal: + path: kind + value: Secret + template: templates/secret-chart.yaml + - equal: + path: metadata.annotations["helm.sh/resource-policy"] + value: keep + template: templates/secret-chart.yaml + + - it: chart-managed Secret is NOT rendered when existingSecret is set + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + externalPostgresql.url: postgres://u:p@h:5432/d + typesense.url: http://ts:8108 + typesense.apiKey: k + secrets.existingSecret: "buzz-secrets" + asserts: + - hasDocuments: + count: 0 + template: templates/secret-chart.yaml + + - it: Deployment env points BUZZ_RELAY_PRIVATE_KEY at existingSecret when set + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + externalPostgresql.url: postgres://u:p@h:5432/d + typesense.url: http://ts:8108 + typesense.apiKey: k + secrets.existingSecret: "buzz-secrets" + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: BUZZ_RELAY_PRIVATE_KEY + valueFrom: + secretKeyRef: + name: buzz-secrets + key: BUZZ_RELAY_PRIVATE_KEY + optional: true + template: templates/deployment.yaml + + - it: RELAY_OWNER_PUBKEY env is set (not BUZZ_RELAY_OWNER_PUBKEY) + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + externalPostgresql.url: postgres://u:p@h:5432/d + typesense.url: http://ts:8108 + typesense.apiKey: k + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: RELAY_OWNER_PUBKEY + value: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + template: templates/deployment.yaml + - notContains: + path: spec.template.spec.containers[0].env + content: + name: BUZZ_RELAY_OWNER_PUBKEY + template: templates/deployment.yaml + + - it: BUZZ_AUTO_MIGRATE defaults to "true" + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + externalPostgresql.url: postgres://u:p@h:5432/d + typesense.url: http://ts:8108 + typesense.apiKey: k + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: BUZZ_AUTO_MIGRATE + value: "true" + template: templates/deployment.yaml diff --git a/deploy/charts/buzz/tests/validation_test.yaml b/deploy/charts/buzz/tests/validation_test.yaml new file mode 100644 index 000000000..89302be8b --- /dev/null +++ b/deploy/charts/buzz/tests/validation_test.yaml @@ -0,0 +1,94 @@ +suite: validation +templates: + - templates/deployment.yaml +tests: + - it: fails when relayUrl is missing + set: + relayUrl: "" + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + externalPostgresql.url: postgres://u:p@h:5432/d + typesense.url: http://ts:8108 + typesense.apiKey: k + asserts: + - failedTemplate: + errorMessage: "relayUrl is required: set --set relayUrl=wss://your.domain" + + - it: fails when ownerPubkey is missing and requireRelayMembership is true + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "" + externalPostgresql.url: postgres://u:p@h:5432/d + typesense.url: http://ts:8108 + typesense.apiKey: k + asserts: + - failedTemplate: + errorPattern: "ownerPubkey is required when relay.requireRelayMembership=true" + + - it: fails when ownerPubkey is not 64 lowercase hex (schema-level) + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "NOTAHEX" + externalPostgresql.url: postgres://u:p@h:5432/d + typesense.url: http://ts:8108 + typesense.apiKey: k + asserts: + - failedTemplate: + errorPattern: "ownerPubkey: Does not match pattern" + + - it: fails when replicaCount>1 without Redis + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + externalPostgresql.url: postgres://u:p@h:5432/d + typesense.url: http://ts:8108 + typesense.apiKey: k + replicaCount: 3 + asserts: + - failedTemplate: + errorPattern: "replicaCount=3 requires Redis" + + - it: fails when replicaCount>1 with RWO git PVC + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + externalPostgresql.url: postgres://u:p@h:5432/d + externalRedis.url: redis://h:6379 + typesense.url: http://ts:8108 + typesense.apiKey: k + replicaCount: 3 + persistence.git.accessMode: ReadWriteOnce + asserts: + - failedTemplate: + errorPattern: "requires persistence.git.accessMode=ReadWriteMany" + + - it: fails when ingress and httproute both enabled + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + externalPostgresql.url: postgres://u:p@h:5432/d + typesense.url: http://ts:8108 + typesense.apiKey: k + ingress.enabled: true + httproute.enabled: true + asserts: + - failedTemplate: + errorPattern: "cannot both be true" + + - it: fails when Postgres source is missing + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + typesense.url: http://ts:8108 + typesense.apiKey: k + asserts: + - failedTemplate: + errorPattern: "Postgres source missing" + + - it: fails when Typesense source is missing + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + externalPostgresql.url: postgres://u:p@h:5432/d + asserts: + - failedTemplate: + errorPattern: "Typesense source missing" diff --git a/deploy/charts/buzz/values.schema.json b/deploy/charts/buzz/values.schema.json new file mode 100644 index 000000000..eb4e8142a --- /dev/null +++ b/deploy/charts/buzz/values.schema.json @@ -0,0 +1,224 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Buzz Helm chart values", + "description": "Schema for values.yaml. Catches misconfiguration at `helm install` time before _validate.tpl runtime fails. Both layers are intentional: schema rejects malformed inputs; templates reject inconsistent combinations.", + "type": "object", + "additionalProperties": true, + "properties": { + "quickstart": { + "type": "boolean", + "description": "Master toggle for evaluation profile (enables postgresql + redis subcharts and chart-side autogen). Not GitOps-safe." + }, + "image": { + "type": "object", + "additionalProperties": false, + "properties": { + "repository": { "type": "string", "minLength": 1 }, + "tag": { "type": "string" }, + "pullPolicy": { "type": "string", "enum": ["Always", "IfNotPresent", "Never"] }, + "pullSecrets": { + "type": "array", + "items": { "type": "object", "required": ["name"], "properties": { "name": { "type": "string" } } } + } + }, + "required": ["repository", "pullPolicy"] + }, + "replicaCount": { + "type": "integer", + "minimum": 1, + "description": "Replica count for the relay Deployment. replicaCount > 1 requires Redis (for buzz-pubsub) and ReadWriteMany git storage — enforced by _validate.tpl." + }, + "relayUrl": { + "type": "string", + "pattern": "^(wss?://.+)?$", + "description": "Public wss:// URL clients connect to. Required (validated by _validate.tpl). Drives RELAY_URL, default mediaBaseUrl, default ingress host." + }, + "mediaBaseUrl": { + "type": "string", + "pattern": "^(https?://.+)?$" + }, + "ownerPubkey": { + "type": "string", + "pattern": "^([0-9a-f]{64})?$", + "description": "64-char lowercase hex Nostr pubkey of the relay operator. Required when relay.requireRelayMembership=true." + }, + "secrets": { + "type": "object", + "additionalProperties": false, + "properties": { + "existingSecret": { "type": "string", "description": "Name of an externally managed Secret. Production / GitOps path." }, + "relayPrivateKey": { "type": "string" }, + "gitHookHmacSecret": { "type": "string" } + } + }, + "relay": { + "type": "object", + "additionalProperties": true, + "properties": { + "bindAddr": { "type": "string", "minLength": 1 }, + "maxConnections": { "type": "integer", "minimum": 1 }, + "maxConcurrentHandlers": { "type": "integer", "minimum": 1 }, + "sendBuffer": { "type": "integer", "minimum": 1 }, + "requireAuthToken": { "type": "boolean" }, + "requireRelayMembership": { "type": "boolean" }, + "allowNipOaAuth": { "type": "boolean" }, + "pubkeyAllowlist": { "type": "boolean" }, + "corsOrigins": { + "type": "array", + "items": { "type": "string" } + }, + "ephemeralTtlOverride": { "type": "integer", "minimum": 0 } + } + }, + "service": { + "type": "object", + "additionalProperties": true, + "properties": { + "type": { "type": "string", "enum": ["ClusterIP", "NodePort", "LoadBalancer"] }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "healthPort": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "metricsPort": { "type": "integer", "minimum": 1, "maximum": 65535 } + } + }, + "serviceAccount": { + "type": "object", + "additionalProperties": false, + "properties": { + "create": { "type": "boolean" }, + "name": { "type": "string" }, + "annotations": { "type": "object" } + } + }, + "podDisruptionBudget": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "minAvailable": { "oneOf": [{ "type": "integer" }, { "type": "string" }] }, + "maxUnavailable": { "oneOf": [{ "type": "integer" }, { "type": "string" }] } + } + }, + "ingress": { + "type": "object", + "additionalProperties": true, + "properties": { + "enabled": { "type": "boolean" }, + "className": { "type": "string" }, + "annotations": { "type": "object" }, + "hosts": { "type": "array" }, + "tls": { "type": "array" } + } + }, + "httproute": { + "type": "object", + "additionalProperties": true, + "properties": { + "enabled": { "type": "boolean" }, + "parentRefs": { "type": "array" }, + "hostnames": { "type": "array", "items": { "type": "string" } }, + "rules": { "type": "array" } + } + }, + "persistence": { + "type": "object", + "additionalProperties": false, + "properties": { + "git": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "mountPath": { "type": "string", "minLength": 1 }, + "storageClass": { "type": "string" }, + "accessMode": { "type": "string", "enum": ["ReadWriteOnce", "ReadWriteMany", "ReadOnlyMany", "ReadWriteOncePod"] }, + "size": { "type": "string", "pattern": "^[0-9]+(\\.[0-9]+)?(E|P|T|G|M|K|Ei|Pi|Ti|Gi|Mi|Ki)?$" }, + "annotations": { "type": "object" }, + "existingClaim": { "type": "string" } + } + } + } + }, + "postgresql": { + "type": "object", + "additionalProperties": true, + "properties": { "enabled": { "type": "boolean" } } + }, + "externalPostgresql": { + "type": "object", + "additionalProperties": false, + "properties": { + "url": { "type": "string", "pattern": "^(postgres(ql)?://.+)?$" } + } + }, + "redis": { + "type": "object", + "additionalProperties": true, + "properties": { "enabled": { "type": "boolean" } } + }, + "externalRedis": { + "type": "object", + "additionalProperties": false, + "properties": { + "url": { "type": "string", "pattern": "^(rediss?://.+)?$" } + } + }, + "typesense": { + "type": "object", + "additionalProperties": false, + "properties": { + "url": { "type": "string", "pattern": "^(https?://.+)?$" }, + "apiKey": { "type": "string" } + } + }, + "s3": { + "type": "object", + "additionalProperties": false, + "properties": { + "endpoint": { "type": "string", "pattern": "^(https?://.+)?$" }, + "bucket": { "type": "string", "minLength": 1 }, + "accessKey": { "type": "string" }, + "secretKey": { "type": "string" } + } + }, + "git": { + "type": "object", + "additionalProperties": false, + "properties": { + "maxPackBytes": { "type": "integer", "minimum": 1 }, + "maxReposPerPubkey": { "type": "integer", "minimum": 1 }, + "maxConcurrentOps": { "type": "integer", "minimum": 1 } + } + }, + "migrate": { + "type": "object", + "additionalProperties": false, + "properties": { + "autoMigrate": { "type": "boolean" }, + "preUpgradeJob": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "resources": { "type": "object" }, + "backoffLimit": { "type": "integer", "minimum": 0 }, + "activeDeadlineSeconds": { "type": "integer", "minimum": 1 } + } + } + } + }, + "serviceMonitor": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "namespace": { "type": "string" }, + "interval": { "type": "string" }, + "scrapeTimeout": { "type": "string" }, + "labels": { "type": "object" } + } + }, + "extraManifests": { + "type": "array" + } + } +} diff --git a/deploy/charts/buzz/values.yaml b/deploy/charts/buzz/values.yaml new file mode 100644 index 000000000..fb2fb95ec --- /dev/null +++ b/deploy/charts/buzz/values.yaml @@ -0,0 +1,238 @@ +# Default values for buzz. +# +# Two supported tiers: +# +# PRODUCTION (default) — external Postgres/Redis/Typesense/S3, existingSecret +# refs everywhere, no chart-side autogeneration, GitOps-safe (ArgoCD/Flux). +# HA-ready: replicaCount >= 2 (requires Redis and RWX storage for git). +# +# QUICKSTART (`--set quickstart=true`) — enables in-cluster Postgres + Redis +# subcharts, chart auto-generates relay secrets via the `lookup` pattern +# (NOT GitOps-safe — see README), single replica, evaluation only. +# +# See examples/argocd-app.yaml and examples/flux-helmrelease.yaml for the +# canonical GitOps configurations. + +# Master toggle for the evaluation profile. Equivalent to setting +# postgresql.enabled=true and redis.enabled=true. +quickstart: false + +# ── Image ──────────────────────────────────────────────────────────────────── +image: + repository: ghcr.io/block/buzz + tag: "" # empty → .Chart.AppVersion + pullPolicy: IfNotPresent + pullSecrets: [] + +# ── Topology ──────────────────────────────────────────────────────────────── +# replicaCount > 1 hard-requires: +# - Redis for buzz-pubsub (in-cluster or external) +# - ReadWriteMany storage for the git PVC (or shared FS via existingClaim) +replicaCount: 1 + +# ── Public URL ─────────────────────────────────────────────────────────────── +# Required. The wss:// URL clients use to connect. Drives: +# - RELAY_URL env (relay-side) +# - Default mediaBaseUrl (https:///media) +# - Default ingress host +relayUrl: "" +mediaBaseUrl: "" + +# ── Owner ──────────────────────────────────────────────────────────────────── +# 64-char lowercase hex Nostr pubkey of the relay operator. Required when +# relay.requireRelayMembership=true (the production default). +ownerPubkey: "" + +# ── Chart-managed secrets ──────────────────────────────────────────────────── +# Production / GitOps path: create a Secret out-of-band with these keys and +# point `secrets.existingSecret` at it. Any key omitted from the existing +# Secret falls back to chart-side autogen (only effective at first install). +# +# Expected keys (all optional unless required by relay config): +# BUZZ_RELAY_PRIVATE_KEY — 64-char hex; relay identity (rotation = identity change) +# BUZZ_GIT_HOOK_HMAC_SECRET — 32+ chars; required when replicaCount > 1 +# DATABASE_URL — full Postgres URL (preferred over externalPostgresql.url) +# REDIS_URL — full Redis URL with auth +# TYPESENSE_URL — Typesense base URL +# TYPESENSE_API_KEY — Typesense API key +# BUZZ_S3_ACCESS_KEY — S3 access key +# BUZZ_S3_SECRET_KEY — S3 secret key +secrets: + existingSecret: "" + # Inline overrides (NOT recommended for production; they land in values). + relayPrivateKey: "" + gitHookHmacSecret: "" + +# ── Relay behavior ─────────────────────────────────────────────────────────── +relay: + bindAddr: "0.0.0.0:3000" + maxConnections: 10000 + maxConcurrentHandlers: 1024 + sendBuffer: 1000 + requireAuthToken: true + requireRelayMembership: true + allowNipOaAuth: true + pubkeyAllowlist: false + corsOrigins: [] + ephemeralTtlOverride: 0 + + livenessProbe: + httpGet: + path: /_liveness + port: health + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /_readiness + port: health + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + startupProbe: + httpGet: + path: /_liveness + port: health + failureThreshold: 60 + periodSeconds: 2 + + resources: + requests: + cpu: "500m" + memory: "512Mi" + limits: + cpu: "2" + memory: "2Gi" + + podAnnotations: {} + podLabels: {} + nodeSelector: {} + tolerations: [] + affinity: {} + topologySpreadConstraints: [] + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + fsGroup: 65532 + seccompProfile: + type: RuntimeDefault + containerSecurityContext: + allowPrivilegeEscalation: false + capabilities: + drop: [ALL] + readOnlyRootFilesystem: false # git writes need a writable repo path + terminationGracePeriodSeconds: 60 + + extraEnv: [] + extraEnvFrom: [] + +# ── Service ────────────────────────────────────────────────────────────────── +service: + type: ClusterIP + port: 3000 + healthPort: 8080 + metricsPort: 9102 + annotations: {} + +serviceAccount: + create: true + name: "" + annotations: {} + +podDisruptionBudget: + enabled: true + minAvailable: 1 + maxUnavailable: "" + +# ── Ingress (classic) ──────────────────────────────────────────────────────── +# Mutually exclusive with httproute.enabled. +ingress: + enabled: false + className: "" + annotations: {} + hosts: [] # empty → derived from relayUrl + tls: [] # [{hosts: [...], secretName: "..."}] + +# ── Gateway API (HTTPRoute) ────────────────────────────────────────────────── +httproute: + enabled: false + parentRefs: [] + hostnames: [] + rules: [] # empty → default match-all → service + +# ── Git on-disk state ──────────────────────────────────────────────────────── +persistence: + git: + enabled: true + mountPath: /var/lib/buzz/git + storageClass: "" + accessMode: ReadWriteOnce # MUST be ReadWriteMany if replicaCount > 1 + size: 10Gi + annotations: {} + existingClaim: "" + +# ── Postgres ───────────────────────────────────────────────────────────────── +postgresql: + enabled: false + auth: + database: buzz + username: buzz + primary: + persistence: + enabled: true + size: 10Gi +externalPostgresql: + url: "" # postgres://user:pass@host:5432/db + +# ── Redis ──────────────────────────────────────────────────────────────────── +redis: + enabled: false + master: + persistence: + enabled: true + size: 4Gi +externalRedis: + url: "" # redis://:pass@host:6379 + +# ── Typesense ──────────────────────────────────────────────────────────────── +typesense: + url: "" + apiKey: "" + +# ── S3 / object storage (media) ────────────────────────────────────────────── +s3: + endpoint: "" + bucket: "buzz-media" + accessKey: "" + secretKey: "" + +# ── Git server config ──────────────────────────────────────────────────────── +git: + maxPackBytes: 524288000 # 500 MiB + maxReposPerPubkey: 100 + maxConcurrentOps: 20 + +# ── Migrations ─────────────────────────────────────────────────────────────── +# Relay runs sqlx migrations at startup via BUZZ_AUTO_MIGRATE=true. +migrate: + autoMigrate: true + preUpgradeJob: + enabled: false + resources: {} + backoffLimit: 3 + activeDeadlineSeconds: 600 + +# ── Monitoring ─────────────────────────────────────────────────────────────── +serviceMonitor: + enabled: false + namespace: "" + interval: 30s + scrapeTimeout: 10s + labels: {} + +# ── Free-form extra manifests ──────────────────────────────────────────────── +extraManifests: []