feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled

Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
2026-08-13 18:34:25 +08:00
parent 61c3fa1df9
commit 9dfa06ffee
3785 changed files with 1085458 additions and 2 deletions
@@ -0,0 +1,8 @@
apiVersion: v2
# Published to oci://ghcr.io/block/buzz/charts via push-chart-release/<version>
# branches (see docs/push-gateway-deployment.md, "Gateway chart release").
name: buzz-push-gateway
description: Public capability-gated APNs last-hop gateway for Buzz
version: 0.1.0
appVersion: "0.1.0"
type: application
@@ -0,0 +1,13 @@
{{- define "push.name" -}}{{ .Release.Name }}-buzz-push-gateway{{- end }}
{{- define "push.labels" -}}
app.kubernetes.io/name: buzz-push-gateway
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{- define "push.runtimeLabels" -}}
{{ include "push.labels" . }}
app.kubernetes.io/component: runtime
{{- end }}
{{- define "push.migrationLabels" -}}
{{ include "push.labels" . }}
app.kubernetes.io/component: migration
{{- end }}
@@ -0,0 +1,65 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "push.name" . }}
labels: {{- include "push.runtimeLabels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
strategy: { type: RollingUpdate, rollingUpdate: { maxSurge: 1, maxUnavailable: 0 } }
selector:
matchLabels: {{- include "push.runtimeLabels" . | nindent 6 }}
template:
metadata:
labels: {{- include "push.runtimeLabels" . | nindent 8 }}
spec:
automountServiceAccountToken: false
terminationGracePeriodSeconds: 60
securityContext: { runAsNonRoot: true, runAsUser: 65532, runAsGroup: 65532, fsGroup: 65532, seccompProfile: { type: RuntimeDefault } }
{{- with .Values.image.pullSecrets }}
imagePullSecrets: {{ toYaml . | nindent 8 }}
{{- end }}
containers:
- name: gateway
image: "{{ .Values.image.repository }}{{ if .Values.image.digest }}@{{ .Values.image.digest }}{{ else }}:{{ required "image.tag or image.digest is required" .Values.image.tag }}{{ end }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
securityContext: { allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, capabilities: { drop: [ALL] } }
ports:
- { name: public, containerPort: 8080 }
- { name: health, containerPort: 8081 }
env:
- { name: BUZZ_PUSH_BIND_ADDR, value: "0.0.0.0:8080" }
- { name: BUZZ_PUSH_HEALTH_ADDR, value: "0.0.0.0:8081" }
- { name: BUZZ_PUSH_PUBLIC_DELIVERY_URL, value: {{ .Values.publicDeliveryUrl | quote }} }
- { name: BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS, value: {{ .Values.maxGrantLifetimeSeconds | quote }} }
- { name: BUZZ_PUSH_ENABLED_PROFILES, value: {{ .Values.enabledProfiles | quote }} }
- { name: BUZZ_PUSH_APP_ATTEST_APP_ID, value: {{ .Values.appAttestAppId | quote }} }
- { name: BUZZ_PUSH_APP_ATTEST_ROOT_CERT_PATH, value: /run/buzz/app-attest/root.pem }
- { name: BUZZ_PUSH_APNS_KEY_PATH, value: /run/buzz/apns/provider.p8 }
{{- range $name := list "DATABASE_URL" "BUZZ_PUSH_APNS_KEY_ID" "BUZZ_PUSH_APNS_TEAM_ID" "BUZZ_PUSH_APNS_TOPIC" "BUZZ_PUSH_GRANT_KEYS" "BUZZ_PUSH_TOKEN_KEYS" }}
- name: {{ $name }}
valueFrom: { secretKeyRef: { name: {{ $.Values.existingSecret }}, key: {{ $name }} } }
{{- end }}
volumeMounts:
- { name: app-attest-root, mountPath: /run/buzz/app-attest, readOnly: true }
- { name: apns-key, mountPath: /run/buzz/apns, readOnly: true }
livenessProbe: { httpGet: { path: /_liveness, port: health }, periodSeconds: 10, timeoutSeconds: 3, failureThreshold: 3 }
readinessProbe: { httpGet: { path: /_readiness, port: health }, periodSeconds: 5, timeoutSeconds: 3, failureThreshold: 3 }
startupProbe: { httpGet: { path: /_liveness, port: health }, periodSeconds: 2, failureThreshold: 60 }
resources: {{- toYaml .Values.resources | nindent 12 }}
volumes:
- name: app-attest-root
secret: { secretName: {{ .Values.appAttestRoot.secretName }}, items: [{ key: {{ .Values.appAttestRoot.secretKey }}, path: root.pem }] }
- name: apns-key
secret: { secretName: {{ .Values.apnsKey.secretName }}, items: [{ key: {{ .Values.apnsKey.secretKey }}, path: provider.p8 }] }
{{- with .Values.nodeSelector }}
nodeSelector: {{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations: {{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity: {{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.topologySpreadConstraints }}
topologySpreadConstraints: {{- toYaml . | nindent 8 }}
{{- end }}
@@ -0,0 +1,14 @@
{{- if .Values.httpRoute.enabled }}
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {{ include "push.name" . }}
spec:
parentRefs: {{- toYaml .Values.httpRoute.parentRefs | nindent 4 }}
hostnames: {{- toYaml .Values.httpRoute.hostnames | nindent 4 }}
rules:
- matches:
- path: { type: PathPrefix, value: / }
backendRefs:
- { name: {{ include "push.name" . }}, port: {{ .Values.service.port }} }
{{- end }}
@@ -0,0 +1,33 @@
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "push.name" . }}-migrate
labels: {{- include "push.migrationLabels" . | nindent 4 }}
annotations:
helm.sh/hook: pre-install,pre-upgrade
helm.sh/hook-weight: "-5"
helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded
spec:
backoffLimit: 3
template:
metadata:
labels: {{- include "push.migrationLabels" . | nindent 8 }}
spec:
restartPolicy: Never
automountServiceAccountToken: false
securityContext: { runAsNonRoot: true, runAsUser: 65532, runAsGroup: 65532, fsGroup: 65532, seccompProfile: { type: RuntimeDefault } }
{{- with .Values.image.pullSecrets }}
imagePullSecrets: {{ toYaml . | nindent 8 }}
{{- end }}
containers:
- name: migrate
image: "{{ .Values.image.repository }}{{ if .Values.image.digest }}@{{ .Values.image.digest }}{{ else }}:{{ required "image.tag or image.digest is required" .Values.image.tag }}{{ end }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
args: ["--migrate-only"]
securityContext: { allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, capabilities: { drop: [ALL] } }
env:
- name: BUZZ_PUSH_RUNTIME_DATABASE_ROLE
value: {{ .Values.migration.runtimeDatabaseRole | quote }}
- name: DATABASE_URL
valueFrom: { secretKeyRef: { name: {{ .Values.migration.existingSecret }}, key: {{ .Values.migration.databaseUrlKey }} } }
resources: {{- toYaml .Values.migration.resources | nindent 12 }}
@@ -0,0 +1,28 @@
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: {{ include "push.name" . }}-migration
labels: {{- include "push.migrationLabels" . | nindent 4 }}
annotations:
# Hooks precede ordinary manifests. Keep this policy alive until the next
# release's before-hook-creation cleanup so it covers the later Job hook.
helm.sh/hook: pre-install,pre-upgrade
helm.sh/hook-weight: "-10"
helm.sh/hook-delete-policy: before-hook-creation
spec:
podSelector:
matchLabels: {{- include "push.migrationLabels" . | nindent 6 }}
policyTypes: [Ingress, Egress]
ingress: []
egress:
- to:
- namespaceSelector:
matchLabels: {{- toYaml .Values.networkPolicy.dns.namespaceSelector | nindent 14 }}
podSelector:
matchLabels: {{- toYaml .Values.networkPolicy.dns.podSelector | nindent 14 }}
ports: [{ port: 53, protocol: UDP }, { port: 53, protocol: TCP }]
- to:
{{- range .Values.networkPolicy.postgresEgressCidrs }}
- ipBlock: { cidr: {{ . }} }
{{- end }}
ports: [{ port: 5432, protocol: TCP }]
@@ -0,0 +1,42 @@
{{- if .Values.networkPolicy.enabled }}
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: {{ include "push.name" . }}
spec:
podSelector:
matchLabels: {{- include "push.runtimeLabels" . | nindent 6 }}
policyTypes: [Ingress, Egress]
ingress:
- ports: [{ port: 8080, protocol: TCP }]
{{- if .Values.networkPolicy.monitoring.enabled }}
{{- if or (not .Values.networkPolicy.monitoring.namespaceSelector) (not .Values.networkPolicy.monitoring.podSelector) }}
{{- fail "networkPolicy.monitoring.enabled requires non-empty namespaceSelector and podSelector so 8081 scrape ingress is scoped, never blanket" }}
{{- end }}
# Scoped scrape access to the private health/metrics port. Off by default so
# 8081 has no pod ingress; when enabled the operator names their scraper.
- from:
- namespaceSelector:
matchLabels: {{- toYaml .Values.networkPolicy.monitoring.namespaceSelector | nindent 14 }}
podSelector:
matchLabels: {{- toYaml .Values.networkPolicy.monitoring.podSelector | nindent 14 }}
ports: [{ port: 8081, protocol: TCP }]
{{- end }}
egress:
- to:
- namespaceSelector:
matchLabels: {{- toYaml .Values.networkPolicy.dns.namespaceSelector | nindent 14 }}
podSelector:
matchLabels: {{- toYaml .Values.networkPolicy.dns.podSelector | nindent 14 }}
ports: [{ port: 53, protocol: UDP }, { port: 53, protocol: TCP }]
- to:
{{- range .Values.networkPolicy.apnsEgressCidrs }}
- ipBlock: { cidr: {{ . }} }
{{- end }}
ports: [{ port: 443, protocol: TCP }]
- to:
{{- range .Values.networkPolicy.postgresEgressCidrs }}
- ipBlock: { cidr: {{ . }} }
{{- end }}
ports: [{ port: 5432, protocol: TCP }]
{{- end }}
@@ -0,0 +1,10 @@
{{- if .Values.podDisruptionBudget.enabled }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "push.name" . }}
spec:
minAvailable: {{ .Values.podDisruptionBudget.minAvailable }}
selector:
matchLabels: {{- include "push.runtimeLabels" . | nindent 6 }}
{{- end }}
@@ -0,0 +1,19 @@
{{- if .Values.podMonitor.enabled }}
apiVersion: monitoring.coreos.com/v1
kind: PodMonitor
metadata:
name: {{ include "push.name" . }}
labels: {{- include "push.runtimeLabels" . | nindent 4 }}
{{- with .Values.podMonitor.labels }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
selector:
matchLabels: {{- include "push.runtimeLabels" . | nindent 6 }}
podMetricsEndpoints:
# Scrape the private health port only; /metrics is never on the public Service.
- port: health
path: /metrics
interval: {{ .Values.podMonitor.interval }}
scrapeTimeout: {{ .Values.podMonitor.scrapeTimeout }}
{{- end }}
@@ -0,0 +1,89 @@
{{- if .Values.prometheusRule.enabled }}
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: {{ include "push.name" . }}
labels: {{- include "push.labels" . | nindent 4 }}
{{- with .Values.prometheusRule.labels }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
groups:
- name: buzz-push-gateway
rules:
# Sustained configuration faults mean the provider credential/topic is
# unhealthy; no endpoint is being invalidated but nothing is delivering.
- alert: PushGatewayConfigurationFault
expr: |
sum(rate(push_gateway_apns_deliveries_total{outcome="configuration_fault"}[5m])) > 0
for: 10m
labels: { severity: critical }
annotations:
summary: Push gateway APNs configuration faults
description: >-
APNs is returning configuration faults (bad/expired provider token
or topic). Deliveries are failing without invalidating endpoints.
See runbook: check the APNs .p8 key, key id, team id, and topic.
# Authority store unavailable at admission = durable dependency is down.
- alert: PushGatewayAdmissionUnavailable
expr: |
sum(rate(push_gateway_admissions_total{result="unavailable"}[5m])) > 0
for: 5m
labels: { severity: critical }
annotations:
summary: Push gateway authority store unavailable
description: >-
authorize_delivery is returning Unavailable — the PostgreSQL
authority store is unreachable or failing. Check DB connectivity
and the pod's postgres egress NetworkPolicy.
# Readiness failing on the authority cause = the pod will be pulled from
# rotation; alert before all replicas drop out.
- alert: PushGatewayReadinessAuthorityFailing
expr: |
sum(rate(push_gateway_readiness_failures_total{cause="authority"}[5m])) > 0
for: 5m
labels: { severity: warning }
annotations:
summary: Push gateway readiness failing on authority
description: >-
Readiness probes are failing because the authority store check
fails. Replicas will be removed from the Service. Investigate DB
health before capacity drops below the PodDisruptionBudget.
# The retention reaper sweeps expired rows every 5m; a single transient
# failure self-heals on the next tick. Alert on repeated failure —
# at least two sweeps failing within ~30m (six ticks) — which grows the
# bounded crash-before-release window and leaks storage.
- alert: PushGatewayReaperFailing
expr: |
sum(increase(push_gateway_reaper_failures_total[30m])) >= 2
for: 5m
labels: { severity: warning }
annotations:
summary: Push gateway retention reaper failing
description: >-
The retention reaper has failed at least twice within 30m (it runs
every 5m). Expired delivery reservations are not being swept,
growing the bounded-until-expiry window. Check DB write availability.
# High sustained fraction of retryable APNs outcomes indicates APNs
# throttling or degradation. The ratio is a true fraction over the
# window (increase = counts, not per-second rate), gated by a minimum
# sample count so a couple of retries at trivial volume cannot trip it.
- alert: PushGatewayHighApnsRetryRate
expr: |
(
sum(increase(push_gateway_apns_deliveries_total{outcome="retry"}[10m]))
/ sum(increase(push_gateway_apns_deliveries_total[10m]))
> {{ .Values.prometheusRule.apnsRetryRatioThreshold }}
)
and
sum(increase(push_gateway_apns_deliveries_total[10m])) >= {{ .Values.prometheusRule.apnsRetryMinSamples }}
for: 15m
labels: { severity: warning }
annotations:
summary: Push gateway high APNs retry ratio
description: >-
The retryable fraction of APNs attempts over a 10m window
(429/500/503), above a minimum sample count, has exceeded the
configured threshold continuously for 15m. APNs may be throttling
or degraded; deliveries are delayed but not lost.
{{- end }}
@@ -0,0 +1,9 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "push.name" . }}
labels: {{- include "push.runtimeLabels" . | nindent 4 }}
spec:
selector: {{- include "push.runtimeLabels" . | nindent 4 }}
ports:
- { name: https, port: {{ .Values.service.port }}, targetPort: public }
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
import yaml
auto_path = Path('.github/workflows/auto-tag-on-release-pr-merge.yml')
publish_path = Path('.github/workflows/push-gateway-helm-chart.yml')
auto_text = auto_path.read_text()
publish_text = publish_path.read_text()
# Parse first, then pin the cross-workflow strings whose agreement makes this a
# reachable lane rather than an orphan publisher.
yaml.safe_load(auto_text)
yaml.safe_load(publish_text)
for needle in (
'push-chart-release/*)',
'VERSION="${BRANCH#push-chart-release/}"',
'TAG_PREFIX="push-chart-v"',
'DISPATCH="push-gateway-helm-chart"',
'push-gateway-helm-chart) WORKFLOW="push-gateway-helm-chart.yml"',
):
assert needle in auto_text, f'missing auto-tag gateway chart contract: {needle}'
for needle in (
'tags: ["push-chart-v[0-9]*"]',
'version="${INPUT_VERSION:-${REF_NAME#push-chart-v}}"',
'refs/tags/push-chart-v${version}^{commit}',
'deploy/charts/buzz-push-gateway',
):
assert needle in publish_text, f'missing gateway chart publisher contract: {needle}'
PY
@@ -0,0 +1,150 @@
#!/usr/bin/env bash
set -euo pipefail
out=$(mktemp); production_out=$(mktemp)
trap 'rm -f "$out" "$production_out"' EXIT
# Defaults must lint and render without parameter injection.
helm lint deploy/charts/buzz-push-gateway >/dev/null
helm template push deploy/charts/buzz-push-gateway >"$out"
# Production values must attach push.buzz.xyz to an explicit Gateway.
production_args=(
-f deploy/charts/buzz-push-gateway/values-production.yaml
--set 'image.digest=sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
--set 'appAttestAppId=REALTEAM.xyz.buzz'
--set 'httpRoute.parentRefs[0].name=production-gateway'
--set 'httpRoute.parentRefs[0].namespace=gateway-system'
--set 'networkPolicy.postgresEgressCidrs[0]=10.42.0.0/16'
)
helm lint deploy/charts/buzz-push-gateway "${production_args[@]}" >/dev/null
helm template push deploy/charts/buzz-push-gateway "${production_args[@]}" >"$production_out"
python3 - "$out" "$production_out" <<'PY'
import sys,yaml
xs=list(yaml.safe_load_all(open(sys.argv[1])))
svc=next(x for x in xs if x and x.get('kind')=='Service')
assert [p['targetPort'] for p in svc['spec']['ports']]==['public']
d=next(x for x in xs if x and x.get('kind')=='Deployment')
j=next(x for x in xs if x and x.get('kind')=='Job')
runtime={'app.kubernetes.io/name':'buzz-push-gateway','app.kubernetes.io/instance':'push','app.kubernetes.io/component':'runtime'}
migration={**runtime,'app.kubernetes.io/component':'migration'}
assert svc['spec']['selector']==runtime
assert d['spec']['selector']['matchLabels']==runtime
assert d['spec']['template']['metadata']['labels']==runtime
assert j['spec']['template']['metadata']['labels']==migration
assert svc['spec']['selector'] != j['spec']['template']['metadata']['labels']
jenv={e['name']:e for e in j['spec']['template']['spec']['containers'][0]['env']}
assert jenv['BUZZ_PUSH_RUNTIME_DATABASE_ROLE']['value']=='buzz_push_gateway_runtime'
assert 'valueFrom' in jenv['DATABASE_URL']
assert j['spec']['template']['spec']['containers'][0]['args']==['--migrate-only']
assert j['metadata']['annotations']=={
'helm.sh/hook':'pre-install,pre-upgrade',
'helm.sh/hook-weight':'-5',
'helm.sh/hook-delete-policy':'before-hook-creation,hook-succeeded',
}
env={e['name'] for e in d['spec']['template']['spec']['containers'][0]['env']}
required={'DATABASE_URL','BUZZ_PUSH_APNS_KEY_ID','BUZZ_PUSH_APNS_TEAM_ID','BUZZ_PUSH_APNS_TOPIC','BUZZ_PUSH_GRANT_KEYS','BUZZ_PUSH_TOKEN_KEYS','BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS'}
assert required <= env
assert d['spec']['replicas'] >= 2
assert not any(x and x.get('kind')=='HTTPRoute' for x in xs)
# Observability is opt-in: default render exposes no scrape CRDs and 8081 stays
# free of pod ingress (only 8080 is reachable).
assert not any(x and x.get('kind') in ('PodMonitor','PrometheusRule') for x in xs)
nps=[x for x in xs if x and x.get('kind')=='NetworkPolicy']
np=next(x for x in nps if x['metadata']['name']=='push-buzz-push-gateway')
migration_np=next(x for x in nps if x['metadata']['name']=='push-buzz-push-gateway-migration')
assert np['spec']['podSelector']['matchLabels']==runtime
assert migration_np['spec']['podSelector']['matchLabels']==migration
assert migration_np['metadata']['annotations']=={
'helm.sh/hook':'pre-install,pre-upgrade',
'helm.sh/hook-weight':'-10',
'helm.sh/hook-delete-policy':'before-hook-creation',
}
assert int(migration_np['metadata']['annotations']['helm.sh/hook-weight']) < int(j['metadata']['annotations']['helm.sh/hook-weight'])
assert migration_np['spec']['ingress']==[]
assert migration_np['spec']['policyTypes']==['Ingress','Egress']
migration_ports={p['port'] for rule in migration_np['spec']['egress'] for p in rule.get('ports',[])}
assert migration_ports=={53,5432}, migration_ports
assert all(p['port'] != 443 for rule in migration_np['spec']['egress'] for p in rule.get('ports',[]))
ingress_ports={p['port'] for rule in np['spec']['ingress'] for p in rule.get('ports',[])}
assert ingress_ports=={8080}, ingress_ports
production=list(yaml.safe_load_all(open(sys.argv[2])))
route=next(x for x in production if x and x.get('kind')=='HTTPRoute')
assert route['spec']['parentRefs']
assert 'push.buzz.xyz' in route['spec']['hostnames']
PY
# Enabling a route without a Gateway attachment must fail schema validation.
if helm template push deploy/charts/buzz-push-gateway --set httpRoute.enabled=true >/dev/null 2>&1; then
echo 'expected httpRoute.enabled=true without parentRefs to fail' >&2
exit 1
fi
# The checked-in production contract is intentionally undeployable until CI or
# the release system supplies an immutable digest and environment-owned values.
if helm template push deploy/charts/buzz-push-gateway -f deploy/charts/buzz-push-gateway/values-production.yaml >/dev/null 2>&1; then
echo 'expected uninjected production values to fail' >&2
exit 1
fi
# Enabling observability renders the scrape CRDs and adds a scoped 8081 ingress
# keyed to the named monitoring source — never a blanket 8081 rule.
monitoring_out=$(mktemp); trap 'rm -f "$out" "$production_out" "$monitoring_out"' EXIT
helm template push deploy/charts/buzz-push-gateway \
--set podMonitor.enabled=true \
--set prometheusRule.enabled=true \
--set networkPolicy.monitoring.enabled=true \
--set 'networkPolicy.monitoring.namespaceSelector.kubernetes\.io/metadata\.name=monitoring' \
--set 'networkPolicy.monitoring.podSelector.app\.kubernetes\.io/name=prometheus' \
>"$monitoring_out"
python3 - "$monitoring_out" <<'PY'
import sys,yaml
xs=list(yaml.safe_load_all(open(sys.argv[1])))
pm=next(x for x in xs if x and x.get('kind')=='PodMonitor')
ep=pm['spec']['podMetricsEndpoints'][0]
assert ep['port']=='health' and ep['path']=='/metrics', ep
assert next(x for x in xs if x and x.get('kind')=='PrometheusRule')['spec']['groups']
np=next(x for x in xs if x and x.get('kind')=='NetworkPolicy' and x['metadata']['name']=='push-buzz-push-gateway')
mon=[r for r in np['spec']['ingress'] if {p['port'] for p in r.get('ports',[])}=={8081}]
assert len(mon)==1, 'exactly one scoped 8081 ingress rule'
frm=mon[0]['from'][0]
# 8081 ingress must be scoped by both selectors, never empty/blanket.
assert frm['namespaceSelector']['matchLabels'] and frm['podSelector']['matchLabels'], frm
PY
# Negative: monitoring enabled with default empty selectors must fail (would
# otherwise render a blanket 8081 rule matching all namespaces/pods).
if helm template push deploy/charts/buzz-push-gateway \
--set podMonitor.enabled=true \
--set networkPolicy.monitoring.enabled=true >/dev/null 2>&1; then
echo 'expected monitoring.enabled with empty selectors to fail' >&2
exit 1
fi
# Negative: scrape flags must be coupled. PodMonitor without ingress = an
# unreachable scraper; ingress without a PodMonitor = an open hole with no
# scraper. Both mismatches must fail schema validation.
if helm template push deploy/charts/buzz-push-gateway \
--set podMonitor.enabled=true \
--set 'networkPolicy.monitoring.namespaceSelector.kubernetes\.io/metadata\.name=monitoring' \
--set 'networkPolicy.monitoring.podSelector.app\.kubernetes\.io/name=prometheus' \
>/dev/null 2>&1; then
echo 'expected podMonitor.enabled without monitoring ingress to fail' >&2
exit 1
fi
if helm template push deploy/charts/buzz-push-gateway \
--set networkPolicy.monitoring.enabled=true \
--set 'networkPolicy.monitoring.namespaceSelector.kubernetes\.io/metadata\.name=monitoring' \
--set 'networkPolicy.monitoring.podSelector.app\.kubernetes\.io/name=prometheus' \
>/dev/null 2>&1; then
echo 'expected monitoring ingress without podMonitor.enabled to fail' >&2
exit 1
fi
# Negative: retry-ratio threshold is a fraction; a value > 1 must fail schema.
if helm template push deploy/charts/buzz-push-gateway \
--set prometheusRule.enabled=true \
--set prometheusRule.apnsRetryRatioThreshold=2 >/dev/null 2>&1; then
echo 'expected apnsRetryRatioThreshold=2 to fail' >&2
exit 1
fi
@@ -0,0 +1,15 @@
# Required environment-owned values are deliberately invalid/empty here. A
# production renderer must inject all of them; CI proves omission fails.
image:
tag: ""
digest: ""
appAttestAppId: ""
httpRoute:
enabled: true
parentRefs: []
hostnames:
- push.buzz.xyz
networkPolicy:
apnsEgressCidrs:
- 0.0.0.0/0
postgresEgressCidrs: []
@@ -0,0 +1,334 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": true,
"properties": {
"replicaCount": {
"type": "integer",
"minimum": 2
},
"existingSecret": {
"type": "string",
"minLength": 1
},
"publicDeliveryUrl": {
"const": "https://push.buzz.xyz/v1/deliveries/apns"
},
"maxGrantLifetimeSeconds": {
"type": "integer",
"minimum": 1,
"maximum": 31536000
},
"appAttestAppId": {
"type": "string",
"minLength": 1
},
"httpRoute": {
"type": "object",
"required": [
"enabled",
"parentRefs",
"hostnames"
],
"properties": {
"enabled": {
"type": "boolean"
},
"parentRefs": {
"type": "array"
},
"hostnames": {
"type": "array",
"contains": {
"const": "push.buzz.xyz"
}
}
},
"allOf": [
{
"if": {
"properties": {
"enabled": {
"const": true
}
},
"required": [
"enabled"
]
},
"then": {
"properties": {
"parentRefs": {
"minItems": 1
}
}
}
}
]
},
"networkPolicy": {
"type": "object",
"required": [
"enabled",
"apnsEgressCidrs",
"postgresEgressCidrs",
"dns"
],
"properties": {
"enabled": {
"const": true
},
"apnsEgressCidrs": {
"type": "array",
"minItems": 1
},
"postgresEgressCidrs": {
"type": "array",
"minItems": 1
},
"dns": {
"type": "object",
"required": [
"namespaceSelector",
"podSelector"
]
},
"monitoring": {
"type": "object",
"required": [
"enabled",
"namespaceSelector",
"podSelector"
],
"properties": {
"enabled": {
"type": "boolean"
},
"namespaceSelector": {
"type": "object"
},
"podSelector": {
"type": "object"
}
},
"allOf": [
{
"if": {
"properties": {
"enabled": {
"const": true
}
},
"required": [
"enabled"
]
},
"then": {
"properties": {
"namespaceSelector": {
"minProperties": 1
},
"podSelector": {
"minProperties": 1
}
}
}
}
]
}
}
},
"podMonitor": {
"type": "object",
"required": [
"enabled"
],
"properties": {
"enabled": {
"type": "boolean"
}
}
},
"prometheusRule": {
"type": "object",
"required": [
"enabled"
],
"properties": {
"enabled": {
"type": "boolean"
},
"apnsRetryRatioThreshold": {
"type": "number",
"exclusiveMinimum": 0,
"maximum": 1
},
"apnsRetryMinSamples": {
"type": "integer",
"minimum": 1
}
}
},
"image": {
"type": "object",
"required": [
"repository"
],
"properties": {
"repository": {
"type": "string",
"minLength": 1
},
"tag": {
"type": "string"
},
"digest": {
"type": "string",
"pattern": "^$|^sha256:[0-9a-f]{64}$"
}
},
"anyOf": [
{
"properties": {
"tag": {
"minLength": 1
}
}
},
{
"properties": {
"digest": {
"pattern": "^sha256:[0-9a-f]{64}$"
}
}
}
]
},
"migration": {
"type": "object",
"additionalProperties": false,
"required": [
"existingSecret",
"databaseUrlKey",
"runtimeDatabaseRole",
"resources"
],
"properties": {
"existingSecret": {
"type": "string",
"minLength": 1
},
"databaseUrlKey": {
"type": "string",
"minLength": 1
},
"runtimeDatabaseRole": {
"type": "string",
"pattern": "^[a-zA-Z_][a-zA-Z0-9_]{0,62}$"
},
"resources": {
"type": "object"
}
}
}
},
"required": [
"replicaCount",
"existingSecret",
"publicDeliveryUrl",
"maxGrantLifetimeSeconds",
"appAttestAppId",
"httpRoute",
"image",
"migration"
],
"allOf": [
{
"$comment": "Scraping opt-in is coupled: a PodMonitor and its scoped 8081 ingress must be enabled together, so we never render a scraper that cannot reach the port nor an ingress hole with no scraper.",
"if": {
"properties": {
"podMonitor": {
"properties": {
"enabled": {
"const": true
}
},
"required": [
"enabled"
]
}
},
"required": [
"podMonitor"
]
},
"then": {
"properties": {
"networkPolicy": {
"properties": {
"monitoring": {
"properties": {
"enabled": {
"const": true
}
},
"required": [
"enabled"
]
}
},
"required": [
"monitoring"
]
}
},
"required": [
"networkPolicy"
]
}
},
{
"if": {
"properties": {
"networkPolicy": {
"properties": {
"monitoring": {
"properties": {
"enabled": {
"const": true
}
},
"required": [
"enabled"
]
}
},
"required": [
"monitoring"
]
}
},
"required": [
"networkPolicy"
]
},
"then": {
"properties": {
"podMonitor": {
"properties": {
"enabled": {
"const": true
}
},
"required": [
"enabled"
]
}
},
"required": [
"podMonitor"
]
}
}
]
}
@@ -0,0 +1,92 @@
replicaCount: 2
image:
repository: ghcr.io/block/buzz-push-gateway
# `main` is published by the push-gateway lane on every main push.
tag: main
digest: ""
pullPolicy: IfNotPresent
pullSecrets: []
existingSecret: buzz-push-gateway
# DDL-capable credentials are used only by the pre-install/pre-upgrade migration
# Job. Runtime DATABASE_URL in existingSecret should have DML-only privileges.
migration:
existingSecret: buzz-push-gateway-migrations
databaseUrlKey: DATABASE_URL
# Existing LOGIN role used by runtime DATABASE_URL. Migrations grant it only
# CONNECT plus DML on the six gateway tables in this dedicated database.
runtimeDatabaseRole: buzz_push_gateway_runtime
resources:
requests: {cpu: 50m, memory: 64Mi}
limits: {cpu: 250m, memory: 128Mi}
publicDeliveryUrl: https://push.buzz.xyz/v1/deliveries/apns
maxGrantLifetimeSeconds: 2592000
enabledProfiles: buzz-ios-production
# Example App Attest identifier. Production MUST override this with the exact
# Apple TEAMID.bundle-id value (see values-production.yaml).
appAttestAppId: TEAMID.xyz.buzz
appAttestRoot:
secretName: buzz-push-gateway
secretKey: app-attest-root.pem
apnsKey:
secretName: buzz-push-gateway
secretKey: apns-provider.p8
service:
port: 8080
httpRoute:
# Disabled by default so a generic install cannot claim an unattached route.
# Production enables this with an explicit Gateway parentRef.
enabled: false
parentRefs: []
hostnames: [push.buzz.xyz]
resources:
requests: {cpu: 100m, memory: 128Mi}
limits: {cpu: "1", memory: 512Mi}
podDisruptionBudget:
enabled: true
minAvailable: 1
networkPolicy:
enabled: true
# Kubernetes NetworkPolicy cannot allow DNS names. Production operators must
# narrow these CIDRs to their PostgreSQL/NAT destinations where supported.
apnsEgressCidrs: [0.0.0.0/0]
# Override with the actual database network. This example private range is
# intentionally separate from broad APNs HTTPS egress.
postgresEgressCidrs: [10.0.0.0/8]
dns:
namespaceSelector:
kubernetes.io/metadata.name: kube-system
podSelector:
k8s-app: kube-dns
# Scoped ingress to the private metrics port (8081). Off by default so 8081
# has no pod ingress at all; enable only alongside podMonitor and name the
# scraper's namespace/pod so reachability stays narrow.
monitoring:
enabled: false
namespaceSelector: {}
podSelector: {}
# Prometheus-operator PodMonitor scraping the private /metrics on port 8081.
# Off by default; requires networkPolicy.monitoring to also be enabled.
podMonitor:
enabled: false
interval: 30s
scrapeTimeout: 10s
labels: {}
# Prometheus-operator alerting rules. Off by default.
prometheusRule:
enabled: false
labels: {}
# Retryable-outcome fraction (0..1] that fires PushGatewayHighApnsRetryRate.
apnsRetryRatioThreshold: 0.25
# Minimum APNs attempts in the 10m window before the retry-ratio alert can
# fire, so a couple of retries at trivial volume cannot trip it.
apnsRetryMinSamples: 20
nodeSelector: {}
tolerations: []
affinity: {}
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app.kubernetes.io/name: buzz-push-gateway
+9
View File
@@ -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"
+42
View File
@@ -0,0 +1,42 @@
apiVersion: v2
name: buzz
description: |
Buzz — a Nostr-based messaging platform for humanagent collaboration.
A single relay binary serving WebSocket + REST + web UI, backed by
PostgreSQL and Redis. Configurable for single-node evaluation
(subcharts on) and HA production (external services, existingSecret).
type: application
version: 0.1.7
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: Generic init-container, volume, volume-mount, command, and args extension points for the relay Pod.
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
+261
View File
@@ -0,0 +1,261 @@
# Buzz Helm Chart
[Buzz](https://github.com/block/buzz) is a Nostr-based messaging platform for humanagent collaboration: a single relay binary serving WebSocket + REST + web UI, backed by PostgreSQL, Redis, 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/S3, `secrets.existingSecret:`, no chart-side autogen, HA-capable (`replicaCount ≥ 2`) |
| **Quickstart** (eval) | Eval, single-node, one-off demo | In-cluster Postgres + Redis + MinIO subcharts/Deployments, chart auto-generates relay + service secrets, single replica |
## Quickstart (eval only)
```sh
helm install buzz oci://ghcr.io/block/buzz/charts/buzz --version 0.1.7 \
--create-namespace --namespace buzz \
--set quickstart=true \
--set postgresql.enabled=true \
--set redis.enabled=true \
--set minio.enabled=true \
--set relayUrl=wss://buzz.example.com \
--set ownerPubkey=<64-char-hex-pubkey>
```
This brings up **everything in-cluster** — Postgres, Redis, and MinIO (with
its bucket created by a post-install Job) — and composes the relay's
`BUZZ_S3_ENDPOINT` plus autogenerated credentials automatically. No external services required. The `quickstart=true` flag is an
intent marker surfaced in NOTES.txt; the bundled services are opted in via the
four `*.enabled` flags above (see `ci/quickstart-values.yaml` for the exact set
CI installs). Eval-only: every bundled service is a single replica with no HA.
## 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` / `s3.endpoint` | External service URLs | Production — when the matching bundled service is disabled (the 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`).
## S3 URL addressing
Buzz uses one URL style for both media and Git/CAS object-store requests:
| `s3.addressingStyle` | Request shape | Use for |
|---|---|---|
| `path` (default) | `https://endpoint/bucket/key` | Bundled MinIO and endpoints whose DNS does not resolve bucket subdomains |
| `virtual` | `https://bucket.endpoint/key` | AWS-style providers and new Railway Storage Buckets |
The chart always renders `s3.addressingStyle` as
`BUZZ_S3_ADDRESSING_STYLE`. It renders `s3.region` as `BUZZ_S3_REGION` only
when explicitly set, preserving the relay's existing `AWS_REGION` fallback for
upgrades. Only `path` and `virtual` addressing styles are accepted; invalid
values fail chart rendering and relay startup. The bundled MinIO quickstart
deliberately keeps `path` because its Service DNS resolves one endpoint
hostname, not arbitrary `<bucket>.<service>` names.
For a Railway Storage Bucket, map its variables to chart values in the service
or generated Helm configuration:
```yaml
s3:
endpoint: "${{Object Storage.ENDPOINT}}"
bucket: "${{Object Storage.BUCKET}}"
region: "${{Object Storage.REGION}}"
addressingStyle: virtual
```
Store `BUZZ_S3_ACCESS_KEY=${{Object Storage.ACCESS_KEY_ID}}` and
`BUZZ_S3_SECRET_KEY=${{Object Storage.SECRET_ACCESS_KEY}}` in the Secret named by
`secrets.existingSecret`. Railway's Credentials tab is authoritative for older
buckets, which may still require `path`. The setting changes request routing and
SigV4 signing, so do not put the bucket into `s3.endpoint`; pass Railway's base
`ENDPOINT` and `BUCKET` separately.
Object storage is contacted during relay startup only when
`BUZZ_GIT_CONFORMANCE_PROBE` is enabled (the relay default). A probe failure is
startup-fatal, so Kubernetes readiness never opens. If an operator explicitly
disables that probe through `relay.extraEnv`, `/_readiness` does not test object
storage; configuration is still parsed strictly, but reachability and addressing
errors surface on the first storage operation.
## Relay Pod extensions
The chart exposes narrow extension points for init containers, volumes, relay
volume mounts, and image command/argument overrides. `extraManifests` creates
independent Kubernetes resources but cannot modify the chart-managed relay
Deployment. These extension values insert fields into that Deployment, avoiding
duplication of its environment, probes, security context, secrets, and
chart-owned volumes.
For example, an init container can copy a wrapper binary into a shared volume
and make that wrapper the relay entrypoint:
```yaml
extraInitContainers:
- name: install-wrapper
image: example.com/wrapper-init:v1
args: [/opt/wrapper/wrapper]
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
resources:
requests:
cpu: 10m
memory: 16Mi
volumeMounts:
- name: wrapper
mountPath: /opt/wrapper
extraVolumes:
- name: wrapper
emptyDir: {}
relay:
command: [/opt/wrapper/wrapper]
args: [/usr/local/bin/buzz-relay]
extraVolumeMounts:
- name: wrapper
mountPath: /opt/wrapper
```
These values are raw Kubernetes fragments rendered with `toYaml`, not `tpl`.
The chart does not validate cross-field relationships: extension names must not
collide with chart-owned containers or volumes, mounts must reference existing
volumes, and each init container must define an appropriate security context
and resources. Empty `relay.command` and `relay.args` arrays preserve the image
defaults; non-empty values override its entrypoint and arguments respectively.
## Device pairing relay
The chart can run Buzz's stateless pairing WebSocket relay as an independent
Deployment and Service using the same image as the main relay:
```yaml
pairingRelay:
enabled: true
url: wss://pairing.example.com
```
`pairingRelay.url` is advertised in the main relay's NIP-11 document so Buzz
clients connect directly to the dedicated endpoint. The chart does not create
an Ingress or HTTPRoute for the pairing Service; route the public hostname to
`<release>-buzz-pairing:5000` with your platform's ingress configuration.
## HA (production)
`replicaCount > 1` hard-requires Redis:
- Redis (`redis.enabled=true`, `externalRedis.url`, or `REDIS_URL` in `existingSecret`) — for `buzz-pubsub` fan-out
It does **not** require ReadWriteMany git storage. Git ref/object state is object-store-backed (each request hydrates an ephemeral repo from S3-compatible storage; writer serialization is the object-store pointer CAS — see `docs/git-on-object-storage.md`), and repo-name uniqueness lives in Postgres. Each replica can use its own `ReadWriteOnce` volume; no shared filesystem is needed.
The chart **template-fails** if the Redis invariant is broken at `replicaCount > 1`. No silent degradation.
### Relay autoscaling
The optional HPA scales the relay on the larger recommendation from CPU or
average active WebSockets per pod:
```yaml
autoscaling:
enabled: true
minReplicas: 5
maxReplicas: 15
targetCPUUtilizationPercentage: 65
websocketMetricEnabled: true
websocketMetricName: buzz_ws_connections_active
targetWebsocketConnections: 5000
```
CPU scaling requires Kubernetes Metrics Server. Set `websocketMetricEnabled: false`
for a CPU-only HPA. WebSocket scaling additionally requires a custom-metrics
adapter (for example Prometheus Adapter) configured to expose the relay's
`buzz_ws_connections_active` gauge as a pod metric with the name in
`websocketMetricName`. The chart creates the HPA but deliberately does not
install or configure a cluster-wide metrics adapter. Scale-down is gradual by
default so long-lived WebSocket connections have time to drain.
## 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)
- **Bundled MinIO is eval-only.** The quickstart profile runs an in-cluster
MinIO (single replica, no HA, `lookup`-autogenerated credentials) so the
relay starts with zero external object storage. Production leaves
`minio.enabled` off and points `s3.endpoint` (or `BUZZ_S3_*` in
`existingSecret`) at managed S3-compatible storage. The bundled Deployment is
not GitOps-safe and is not intended for production traffic.
- **Minimal-mode is not yet supported.** The relay's `BUZZ_PUBSUB=local` /
filesystem media paths are upstream work in progress — even quickstart
currently stands up real Redis and S3 rather than the relay's single-node
fallbacks. (Full-text search already runs in Postgres, so no separate search
service is provisioned.)
- **Cosign signing of the published chart** is a follow-up (the relay image is
attested via `actions/attest-build-provenance`; the chart is not yet). The
chart itself is published to GHCR — see [Releasing](#releasing).
## Releasing
The chart is published to GHCR as an OCI artifact at
`oci://ghcr.io/block/buzz/charts/buzz` by the `helm chart` workflow
(`.github/workflows/helm-chart.yml`), versioned independently of the desktop app
and the relay image via its own `chart-v*` tags. Every PR/`main` push still
lints, unit-tests, and render-checks the chart; only a `chart-v*` tag publishes,
so an in-progress `main` can never overwrite a released version.
To cut a release, push a `chart-release/<version>` branch whose `<version>`
matches `Chart.yaml`'s `version`; merging it auto-tags `chart-v<version>` and
dispatches the publish job (same lane machinery as the desktop and relay
releases — see `.github/workflows/auto-tag-on-release-pr-merge.yml`). The publish
job fails loudly if the tag version and `Chart.yaml` version disagree.
## 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 .
```
@@ -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 + minio in-cluster so the relay can
# actually start and pass its S3 conformance probe.
quickstart: true
postgresql:
enabled: true
redis:
enabled: true
minio:
enabled: true
relayUrl: wss://buzz.test.local
ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000001"
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
@@ -0,0 +1,80 @@
# 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:
# Argo CD >= 3.1 native OCI sources: repoURL must be the FULL chart
# artifact path — with the `repoURL: …/charts` + `chart: buzz` split
# form, the `chart` field is ignored for oci:// URLs and the fetch
# fails with a 403 (`repository:block/buzz/charts:pull` denied). The
# spec validator still requires `path`; use "." for OCI sources.
repoURL: oci://ghcr.io/block/buzz/charts/buzz
path: .
targetRevision: 0.1.7
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: ""
s3:
endpoint: "https://s3.us-east-1.amazonaws.com"
bucket: "buzz-media"
region: "us-east-1"
addressingStyle: virtual
# accessKey / secretKey live in buzz-secrets
persistence:
git:
enabled: true
accessMode: ReadWriteOnce # RWO is fine at any replicaCount (object-store-backed git)
storageClass: "" # any RWO class; no shared/RWX filesystem needed
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
@@ -0,0 +1,63 @@
# 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
s3:
endpoint: "https://s3.us-east-1.amazonaws.com"
bucket: "buzz-media"
region: "us-east-1"
addressingStyle: virtual
persistence:
git:
enabled: true
accessMode: ReadWriteOnce # RWO is fine at any replicaCount (object-store-backed git)
storageClass: "" # any RWO class; no shared/RWX filesystem needed
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
@@ -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
@@ -0,0 +1,27 @@
# 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://...
# READ_DATABASE_URL — postgres://... (optional read-replica; omit to disable read routing)
# REDIS_URL — redis://... (required when replicaCount > 1)
# 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"
BUZZ_S3_ACCESS_KEY: "REPLACE"
BUZZ_S3_SECRET_KEY: "REPLACE"
+93
View File
@@ -0,0 +1,93 @@
══════════════════════════════════════════════════════════════════════════════
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 .Values.minio.enabled }}
⚠ QUICKSTART / EVALUATION PROFILE
{{ if .Values.postgresql.enabled }}- In-cluster Postgres subchart (CloudPirates){{ end }}
{{ if .Values.redis.enabled }}- In-cluster Redis subchart (CloudPirates){{ end }}
{{ if .Values.minio.enabled }}- In-cluster MinIO (eval-only, single replica; bucket "{{ .Values.s3.bucket }}" created by a bundled init Job){{ 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), 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 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" . }}
+137
View File
@@ -0,0 +1,137 @@
{{/* 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 -}}
{{/* Relay-specific selector: scopes the relay Deployment + Service so they do
not also match the quickstart MinIO pods, which share the base
selectorLabels but carry their own component label. */}}
{{- define "buzz.relaySelectorLabels" -}}
{{ include "buzz.selectorLabels" . }}
app.kubernetes.io/component: relay
{{- 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://<host>/media derived from relayUrl. */}}
{{- define "buzz.mediaBaseUrl" -}}
{{- if .Values.mediaBaseUrl -}}
{{- .Values.mediaBaseUrl -}}
{{- else -}}
{{- printf "https://%s/media" (include "buzz.relayHost" .) -}}
{{- end -}}
{{- end -}}
{{/* Quickstart-only in-cluster service hostnames (eval profile). */}}
{{- define "buzz.minioFullname" -}}
{{- printf "%s-minio" (include "buzz.fullname" .) -}}
{{- end -}}
{{/* In-cluster MinIO endpoint, used when minio.enabled and s3.endpoint unset. */}}
{{- define "buzz.minioEndpoint" -}}
{{- printf "http://%s.%s.svc.cluster.local:9000" (include "buzz.minioFullname" .) .Release.Namespace -}}
{{- end -}}
{{/* Minimum number of relay replicas the release can run. */}}
{{- define "buzz.minimumReplicas" -}}
{{- if .Values.autoscaling.enabled -}}
{{- .Values.autoscaling.minReplicas -}}
{{- else -}}
{{- .Values.replicaCount -}}
{{- end -}}
{{- end -}}
{{/* Effective huddle-audio availability. Nil means safe chart default: on for
one replica, off for multi-pod until an SFU/shared-room story exists. */}}
{{- define "buzz.huddleAudioAvailable" -}}
{{- if kindIs "invalid" .Values.relay.huddleAudioAvailable -}}
{{- if gt (include "buzz.minimumReplicas" . | int) 1 -}}false{{- else -}}true{{- end -}}
{{- else -}}
{{- .Values.relay.huddleAudioAvailable -}}
{{- end -}}
{{- end -}}
{{/* Effective S3 endpoint: explicit s3.endpoint wins, else bundled MinIO. */}}
{{- define "buzz.s3Endpoint" -}}
{{- if .Values.s3.endpoint -}}
{{- .Values.s3.endpoint -}}
{{- else if .Values.minio.enabled -}}
{{- include "buzz.minioEndpoint" . -}}
{{- end -}}
{{- end -}}
{{- define "buzz.pairingRelaySelectorLabels" -}}
{{ include "buzz.selectorLabels" . }}
app.kubernetes.io/component: pairing-relay
{{- end -}}
@@ -0,0 +1,86 @@
{{/*
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 -}}
{{/* Multiple replicas require Redis, whether fixed or autoscaled. */}}
{{- $minimumReplicas := include "buzz.minimumReplicas" . | int -}}
{{- if gt $minimumReplicas 1 -}}
{{- if and (not .Values.redis.enabled) (not .Values.externalRedis.url) (not .Values.secrets.existingSecret) -}}
{{- fail (printf "minimum replica count %d requires Redis for buzz-pubsub. Enable redis.enabled=true, set externalRedis.url, or provide secrets.existingSecret with key REDIS_URL." $minimumReplicas) -}}
{{- end -}}
{{- end -}}
{{/* Multiple replicas do NOT require ReadWriteMany git storage.
Git ref/object state is object-store-backed: every read and write hydrates
an ephemeral bare repo from S3-compatible storage per request, and writer
serialization is the object-store pointer CAS
(docs/git-on-object-storage.md, Inv_NoFork). No persistent git state lives
on the PVC, so replicas do not need a shared ReadWriteMany volume to agree
on refs. Repo-name uniqueness the last shared-state need now lives in
Postgres (git_repo_names), not on local disk.
The prior hard-fail requiring persistence.git.accessMode=ReadWriteMany was
removed here: its stated reason ("git on-disk state must be shared across
replicas") is no longer true. Redis (validated above) remains the real
multi-pod requirement for buzz-pubsub. */}}
{{/* Autoscaling bounds must be coherent. */}}
{{- if .Values.autoscaling.enabled -}}
{{- if lt (.Values.autoscaling.minReplicas | int) 1 -}}
{{- fail "autoscaling.minReplicas must be at least 1" -}}
{{- end -}}
{{- if lt (.Values.autoscaling.maxReplicas | int) (.Values.autoscaling.minReplicas | int) -}}
{{- fail "autoscaling.maxReplicas must be greater than or equal to autoscaling.minReplicas" -}}
{{- end -}}
{{- if and .Values.autoscaling.websocketMetricEnabled (not .Values.autoscaling.websocketMetricName) -}}
{{- fail "autoscaling.websocketMetricName is required when WebSocket scaling is enabled" -}}
{{- 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 -}}
{{/* Pairing relay deployment must have an advertised public URL. */}}
{{- if and .Values.pairingRelay.enabled (not .Values.pairingRelay.url) -}}
{{- fail "pairingRelay.url is required when pairingRelay.enabled=true" -}}
{{- 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 -}}
{{/* S3 / object-storage source must exist somewhere. With the default
BUZZ_GIT_CONFORMANCE_PROBE behavior, an unreachable bucket is detected
before the relay opens its listener; operators can explicitly disable that
startup gate. */}}
{{- if not (or .Values.minio.enabled .Values.s3.endpoint .Values.secrets.existingSecret) -}}
{{- fail "S3/object-storage source missing: enable minio.enabled=true (quickstart in-cluster), set s3.endpoint + s3.bucket + credentials, or provide secrets.existingSecret with keys BUZZ_S3_ACCESS_KEY + BUZZ_S3_SECRET_KEY. By default the relay runs a startup S3 conformance probe and exits if storage is unreachable; disabling BUZZ_GIT_CONFORMANCE_PROBE also removes that startup storage check." -}}
{{- end -}}
{{- end -}}
@@ -0,0 +1,263 @@
{{- include "buzz.validate" . -}}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "buzz.fullname" . }}
labels:
{{- include "buzz.labels" . | nindent 4 }}
spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
{{- end }}
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
{{- include "buzz.relaySelectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "buzz.relaySelectorLabels" . | 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 }}
{{- if or .Values.minio.enabled .Values.extraInitContainers }}
initContainers:
{{- if .Values.minio.enabled }}
# Quickstart only: the bundled MinIO bucket is created by a concurrent
# init Job (templates/quickstart-minio-init.yaml). The relay's A3 S3
# conformance probe is startup-fatal, so without this gate the relay Pods
# CrashLoopBackOff (with growing backoff) until the bucket appears. Block
# relay start until the bucket exists — deterministic, no crash-loops.
- name: wait-for-bucket
image: {{ .Values.minio.mcImage | quote }}
securityContext:
{{- toYaml .Values.relay.containerSecurityContext | nindent 12 }}
env:
- name: S3_ACCESS_KEY
valueFrom:
secretKeyRef:
name: {{ include "buzz.chartSecretName" . }}
key: BUZZ_S3_ACCESS_KEY
- name: S3_SECRET_KEY
valueFrom:
secretKeyRef:
name: {{ include "buzz.chartSecretName" . }}
key: BUZZ_S3_SECRET_KEY
command: ["/bin/sh", "-c"]
args:
- |
set -e
export MC_CONFIG_DIR=/tmp/.mc
until mc alias set local {{ include "buzz.minioEndpoint" . }} "$S3_ACCESS_KEY" "$S3_SECRET_KEY" >/dev/null 2>&1; do
echo "waiting for MinIO..."; sleep 2
done
until mc stat local/{{ .Values.s3.bucket }} >/dev/null 2>&1; do
echo "waiting for bucket {{ .Values.s3.bucket }}..."; sleep 2
done
echo "bucket {{ .Values.s3.bucket }} present"
{{- end }}
{{- with .Values.extraInitContainers }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}
containers:
- name: relay
image: {{ include "buzz.image" . }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
securityContext:
{{- toYaml .Values.relay.containerSecurityContext | nindent 12 }}
{{- with .Values.relay.command }}
command:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.relay.args }}
args:
{{- toYaml . | nindent 12 }}
{{- end }}
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 }} }
{{- if .Values.pairingRelay.url }}
- { name: BUZZ_PAIRING_RELAY_URL, value: {{ .Values.pairingRelay.url | quote }} }
{{- end }}
- { name: BUZZ_MEDIA_BASE_URL, value: {{ include "buzz.mediaBaseUrl" . | 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_DRAIN_JITTER_MS, value: {{ .Values.relay.drainJitterMs | 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 }}
{{- if .Values.relay.uploadRecords }}
- { name: BUZZ_MEDIA_UPLOAD_RECORDS, value: "true" }
{{- end }}
{{- if .Values.relay.uploadIpHeader }}
- { name: BUZZ_MEDIA_UPLOAD_IP_HEADER, value: {{ .Values.relay.uploadIpHeader | quote }} }
{{- end }}
{{- if .Values.relay.uploadPortHeader }}
- { name: BUZZ_MEDIA_UPLOAD_PORT_HEADER, value: {{ .Values.relay.uploadPortHeader | 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_PACK_CACHE_PATH, value: {{ .Values.git.packCachePath | quote }} }
- { name: BUZZ_GIT_PACK_CACHE_MAX_BYTES, value: {{ .Values.git.packCacheMaxBytes | quote }} }
- { name: BUZZ_GIT_PACK_CACHE_MAX_CONCURRENT_POPULATIONS, value: {{ .Values.git.packCacheMaxConcurrentPopulations | 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) ──────────────────────────────────────
{{- $s3Endpoint := include "buzz.s3Endpoint" . }}
{{- if $s3Endpoint }}
- { name: BUZZ_S3_ENDPOINT, value: {{ $s3Endpoint | quote }} }
{{- end }}
- { name: BUZZ_S3_BUCKET, value: {{ .Values.s3.bucket | quote }} }
{{- if .Values.s3.region }}
- { name: BUZZ_S3_REGION, value: {{ .Values.s3.region | quote }} }
{{- end }}
- { name: BUZZ_S3_ADDRESSING_STYLE, value: {{ .Values.s3.addressingStyle | 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: READ_DATABASE_URL
valueFrom:
secretKeyRef:
name: {{ include "buzz.envSecretName" . }}
key: READ_DATABASE_URL
optional: true
- name: REDIS_URL
valueFrom:
secretKeyRef:
name: {{ include "buzz.envSecretName" . }}
key: REDIS_URL
optional: {{ and (eq (include "buzz.minimumReplicas" . | int) 1) (not .Values.redis.enabled) (not .Values.externalRedis.url) }}
- 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
- name: BUZZ_HUDDLE_AUDIO_AVAILABLE
value: {{ include "buzz.huddleAudioAvailable" . | quote }}
{{- 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:
- { name: git-repos, mountPath: {{ .Values.persistence.git.mountPath | quote }} }
- { name: git-pack-cache, mountPath: {{ .Values.git.packCachePath | quote }} }
{{- with .Values.relay.extraVolumeMounts }}
{{- toYaml . | nindent 12 }}
{{- end }}
volumes:
- name: git-repos
{{- if .Values.persistence.git.enabled }}
persistentVolumeClaim:
claimName: {{ default (printf "%s-git" (include "buzz.fullname" .)) .Values.persistence.git.existingClaim }}
{{- else }}
emptyDir:
sizeLimit: {{ .Values.persistence.git.size | quote }}
{{- end }}
- name: git-pack-cache
emptyDir:
sizeLimit: {{ .Values.git.packCacheVolumeSize | quote }}
{{- with .Values.extraVolumes }}
{{- toYaml . | nindent 8 }}
{{- end }}
@@ -0,0 +1,10 @@
{{- /*
Free-form extra manifests. Each entry in .Values.extraManifests is rendered
through `tpl` (so entries may reference release values / helpers) and emitted
as its own document. Lets operators ship companion objects (e.g. Istio mesh
glue, NetworkPolicies) alongside the relay without forking the chart.
*/ -}}
{{- range .Values.extraManifests }}
---
{{ tpl (toYaml .) $ }}
{{- end }}
+34
View File
@@ -0,0 +1,34 @@
{{- include "buzz.validate" . -}}
{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "buzz.fullname" . }}
labels:
{{- include "buzz.labels" . | nindent 4 }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "buzz.fullname" . }}
minReplicas: {{ .Values.autoscaling.minReplicas }}
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
behavior:
{{- toYaml .Values.autoscaling.behavior | nindent 4 }}
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
{{- if .Values.autoscaling.websocketMetricEnabled }}
- type: Pods
pods:
metric:
name: {{ .Values.autoscaling.websocketMetricName }}
target:
type: AverageValue
averageValue: {{ .Values.autoscaling.targetWebsocketConnections | quote }}
{{- end }}
{{- end }}
@@ -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 }}
+43
View File
@@ -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 }}
@@ -0,0 +1,72 @@
{{- include "buzz.validate" . -}}
{{- if .Values.pairingRelay.enabled -}}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "buzz.fullname" . }}-pairing
labels:
{{- include "buzz.labels" . | nindent 4 }}
app.kubernetes.io/component: pairing-relay
spec:
replicas: {{ .Values.pairingRelay.replicaCount }}
selector:
matchLabels:
{{- include "buzz.pairingRelaySelectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "buzz.pairingRelaySelectorLabels" . | nindent 8 }}
{{- with .Values.pairingRelay.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.pairingRelay.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
securityContext:
{{- toYaml .Values.relay.securityContext | nindent 8 }}
containers:
- name: pairing-relay
image: {{ include "buzz.image" . }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
command: ["/usr/local/bin/buzz-pair-relay"]
securityContext:
{{- toYaml .Values.relay.containerSecurityContext | nindent 12 }}
env:
- name: BUZZ_PAIR_RELAY_BIND_ADDR
value: "0.0.0.0:{{ .Values.pairingRelay.service.port }}"
ports:
- name: websocket
containerPort: {{ .Values.pairingRelay.service.port }}
protocol: TCP
readinessProbe:
tcpSocket:
port: websocket
livenessProbe:
tcpSocket:
port: websocket
resources:
{{- toYaml .Values.pairingRelay.resources | nindent 12 }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "buzz.fullname" . }}-pairing
labels:
{{- include "buzz.labels" . | nindent 4 }}
app.kubernetes.io/component: pairing-relay
{{- with .Values.pairingRelay.service.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.pairingRelay.service.type }}
selector:
{{- include "buzz.pairingRelaySelectorLabels" . | nindent 4 }}
ports:
- name: websocket
port: {{ .Values.pairingRelay.service.port }}
targetPort: websocket
protocol: TCP
{{- end }}
+18
View File
@@ -0,0 +1,18 @@
{{- include "buzz.validate" . -}}
{{- if and .Values.podDisruptionBudget.enabled (gt (include "buzz.minimumReplicas" . | int) 1) -}}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "buzz.fullname" . }}
labels:
{{- include "buzz.labels" . | nindent 4 }}
spec:
selector:
matchLabels:
{{- include "buzz.relaySelectorLabels" . | nindent 6 }}
{{- if .Values.podDisruptionBudget.minAvailable }}
minAvailable: {{ .Values.podDisruptionBudget.minAvailable }}
{{- else if .Values.podDisruptionBudget.maxUnavailable }}
maxUnavailable: {{ .Values.podDisruptionBudget.maxUnavailable }}
{{- end }}
{{- end }}
+22
View File
@@ -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 -}}
@@ -0,0 +1,64 @@
{{- /*
Creates the media bucket in the bundled MinIO. Mirrors the docker-compose
`minio-init` step (which depends_on minio and runs concurrently, not as a
lifecycle hook).
This is a NORMAL resource, deliberately NOT a helm hook. The relay's A3
object-store conformance probe is startup-fatal and does not create the bucket
itself, so the bucket must exist for a relay Pod to pass readiness. A
`post-install` hook cannot satisfy that: `helm install --wait` blocks on the
main resources (the relay Deployment) becoming Ready *before* post-install
hooks run, but the relay can never become Ready without the bucket — a
deadlock (relays CrashLoopBackOff forever). A `pre-install` hook deadlocks the
other way: it would run to completion before MinIO (a normal resource) is
created. Running the Job as a normal resource lets helm create MinIO, this Job,
and the relay Deployment together; the Job's `until` loop waits for MinIO, and
`--wait` waits for the Job to complete alongside the Deployment.
The name carries the release revision so `helm upgrade` creates a fresh Job
(a completed Job's spec is immutable); `ttlSecondsAfterFinished` GCs it.
Quickstart-only.
*/ -}}
{{- if .Values.minio.enabled -}}
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "buzz.minioFullname" . }}-init-{{ .Release.Revision }}
labels:
{{- include "buzz.labels" . | nindent 4 }}
app.kubernetes.io/component: minio-init
spec:
backoffLimit: 10
ttlSecondsAfterFinished: 120
template:
metadata:
labels:
{{- include "buzz.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: minio-init
spec:
restartPolicy: OnFailure
containers:
- name: mc
image: {{ .Values.minio.mcImage | quote }}
env:
- name: S3_ACCESS_KEY
valueFrom:
secretKeyRef:
name: {{ include "buzz.chartSecretName" . }}
key: BUZZ_S3_ACCESS_KEY
- name: S3_SECRET_KEY
valueFrom:
secretKeyRef:
name: {{ include "buzz.chartSecretName" . }}
key: BUZZ_S3_SECRET_KEY
command: ["/bin/sh", "-c"]
args:
- |
set -e
until mc alias set local {{ include "buzz.minioEndpoint" . }} "$S3_ACCESS_KEY" "$S3_SECRET_KEY"; do
echo "waiting for MinIO..."; sleep 3
done
mc mb --ignore-existing local/{{ .Values.s3.bucket }}
mc anonymous set none local/{{ .Values.s3.bucket }}
echo "bucket {{ .Values.s3.bucket }} ready"
{{- end -}}
@@ -0,0 +1,99 @@
{{- /*
Eval-only in-cluster MinIO for the quickstart profile. NOT for production —
single replica, no TLS, credentials from the chart-managed Secret. Production
deploys leave minio.enabled=false and point s3.* at managed object storage.
*/ -}}
{{- if .Values.minio.enabled -}}
{{- if .Values.secrets.existingSecret -}}
{{- fail "minio.enabled=true (quickstart) is incompatible with secrets.existingSecret. Quickstart autogenerates MinIO credentials in the chart-managed Secret; for external S3 set minio.enabled=false and provide BUZZ_S3_ACCESS_KEY/BUZZ_S3_SECRET_KEY." -}}
{{- end -}}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "buzz.minioFullname" . }}
labels:
{{- include "buzz.labels" . | nindent 4 }}
app.kubernetes.io/component: minio
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
{{- include "buzz.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: minio
template:
metadata:
labels:
{{- include "buzz.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: minio
spec:
containers:
- name: minio
image: {{ .Values.minio.image | quote }}
args: ["server", "/data", "--console-address", ":9001"]
env:
- name: MINIO_ROOT_USER
valueFrom:
secretKeyRef:
name: {{ include "buzz.chartSecretName" . }}
key: BUZZ_S3_ACCESS_KEY
- name: MINIO_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "buzz.chartSecretName" . }}
key: BUZZ_S3_SECRET_KEY
ports:
- { name: api, containerPort: 9000 }
- { name: console, containerPort: 9001 }
readinessProbe:
httpGet: { path: /minio/health/ready, port: api }
initialDelaySeconds: 3
periodSeconds: 5
livenessProbe:
httpGet: { path: /minio/health/live, port: api }
initialDelaySeconds: 5
periodSeconds: 10
volumeMounts:
- { name: data, mountPath: /data }
volumes:
- name: data
{{- if .Values.minio.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ include "buzz.minioFullname" . }}
{{- else }}
emptyDir: {}
{{- end }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "buzz.minioFullname" . }}
labels:
{{- include "buzz.labels" . | nindent 4 }}
app.kubernetes.io/component: minio
spec:
selector:
{{- include "buzz.selectorLabels" . | nindent 4 }}
app.kubernetes.io/component: minio
ports:
- { name: api, port: 9000, targetPort: api }
- { name: console, port: 9001, targetPort: console }
{{- if .Values.minio.persistence.enabled }}
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "buzz.minioFullname" . }}
labels:
{{- include "buzz.labels" . | nindent 4 }}
app.kubernetes.io/component: minio
annotations:
helm.sh/resource-policy: keep
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: {{ .Values.minio.persistence.size | quote }}
{{- end }}
{{- end -}}
@@ -0,0 +1,105 @@
{{- 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 | sha256sum | 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" .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 }}
{{- /* S3 creds — bundled MinIO (quickstart) autogenerates; else pass
through external values. */}}
{{- if .Values.minio.enabled }}
{{- $s3Access := "" }}
{{- if (index $existingData "BUZZ_S3_ACCESS_KEY") }}
{{- $s3Access = index $existingData "BUZZ_S3_ACCESS_KEY" | b64dec }}
{{- else if .Values.s3.accessKey }}
{{- $s3Access = .Values.s3.accessKey }}
{{- else }}
{{- $s3Access = printf "buzz-%s" (randAlphaNum 12 | lower) }}
{{- end }}
{{- $s3Secret := "" }}
{{- if (index $existingData "BUZZ_S3_SECRET_KEY") }}
{{- $s3Secret = index $existingData "BUZZ_S3_SECRET_KEY" | b64dec }}
{{- else if .Values.s3.secretKey }}
{{- $s3Secret = .Values.s3.secretKey }}
{{- else }}
{{- $s3Secret = randAlphaNum 32 }}
{{- end }}
BUZZ_S3_ACCESS_KEY: {{ $s3Access | b64enc | quote }}
BUZZ_S3_SECRET_KEY: {{ $s3Secret | b64enc | quote }}
{{- else }}
{{- 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 }}
{{- end -}}
+19
View File
@@ -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.relaySelectorLabels" . | 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 }
@@ -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 }}
@@ -0,0 +1,31 @@
{{- /*
Prometheus Operator ServiceMonitor for the relay's /metrics endpoint.
Opt-in (serviceMonitor.enabled). Selects the relay Service by the chart's
selector labels (which the Service carries in its own metadata.labels) and
scrapes the named `metrics` port.
*/ -}}
{{- if .Values.serviceMonitor.enabled -}}
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: {{ include "buzz.fullname" . }}
{{- with .Values.serviceMonitor.namespace }}
namespace: {{ . | quote }}
{{- end }}
labels:
{{- include "buzz.labels" . | nindent 4 }}
{{- with .Values.serviceMonitor.labels }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
selector:
matchLabels:
{{- include "buzz.selectorLabels" . | nindent 6 }}
endpoints:
- port: metrics
interval: {{ .Values.serviceMonitor.interval | quote }}
scrapeTimeout: {{ .Values.serviceMonitor.scrapeTimeout | quote }}
namespaceSelector:
matchNames:
- {{ .Release.Namespace }}
{{- end -}}
+70
View File
@@ -0,0 +1,70 @@
suite: extraManifests + serviceMonitor renderers
templates:
- templates/extramanifests.yaml
- templates/servicemonitor.yaml
set:
relayUrl: wss://buzz.example.com
ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
externalPostgresql.url: postgres://u:p@h:5432/d
externalRedis.url: redis://h:6379
s3.endpoint: http://minio:9000
s3.accessKey: a
s3.secretKey: s
tests:
- it: emits nothing by default (extraManifests empty)
template: templates/extramanifests.yaml
asserts:
- hasDocuments:
count: 0
- it: renders each extraManifests entry, tpl-resolving release values
template: templates/extramanifests.yaml
set:
extraManifests:
- apiVersion: v1
kind: ConfigMap
metadata:
name: '{{ include "buzz.fullname" . }}-extra'
data:
host: '{{ .Values.relayUrl }}'
asserts:
- hasDocuments:
count: 1
- equal:
path: kind
value: ConfigMap
- equal:
path: metadata.name
value: RELEASE-NAME-buzz-extra
- equal:
path: data.host
value: wss://buzz.example.com
- it: omits the ServiceMonitor unless enabled
template: templates/servicemonitor.yaml
asserts:
- hasDocuments:
count: 0
- it: renders a ServiceMonitor scraping the metrics port when enabled
template: templates/servicemonitor.yaml
set:
serviceMonitor.enabled: true
serviceMonitor.labels.release: kube-prometheus-stack
asserts:
- hasDocuments:
count: 1
- equal:
path: kind
value: ServiceMonitor
- equal:
path: spec.endpoints[0].port
value: metrics
- equal:
path: spec.selector.matchLabels["app.kubernetes.io/name"]
value: buzz
- notExists:
path: spec.selector.matchLabels["app.kubernetes.io/component"]
- equal:
path: metadata.labels.release
value: kube-prometheus-stack
+20
View File
@@ -0,0 +1,20 @@
# HA shape: replicas=3 + Redis + RWO git (object-store-backed; no RWM). 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"
s3:
bucket: "buzz-media"
persistence:
git:
enabled: true
accessMode: ReadWriteOnce
size: 50Gi
podDisruptionBudget:
enabled: true
minAvailable: 2
@@ -0,0 +1,25 @@
# 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"
s3:
endpoint: "https://s3.us-east-1.amazonaws.com"
bucket: "buzz-media"
persistence:
git:
enabled: true
accessMode: ReadWriteOnce
size: 50Gi
ingress:
enabled: true
className: nginx
annotations:
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
podDisruptionBudget:
enabled: false
+79
View File
@@ -0,0 +1,79 @@
suite: relay horizontal pod autoscaler
templates:
- templates/hpa.yaml
- templates/deployment.yaml
- templates/secret-chart.yaml
set:
relayUrl: wss://buzz.example.com
ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
externalPostgresql.url: postgres://u:p@h:5432/d
externalRedis.url: redis://h:6379
s3.endpoint: http://minio:9000
s3.accessKey: a
s3.secretKey: s
tests:
- it: omits the HPA by default
template: templates/hpa.yaml
asserts:
- hasDocuments:
count: 0
- it: scales the relay on either CPU or average pod WebSockets
template: templates/hpa.yaml
set:
autoscaling.enabled: true
asserts:
- isAPIVersion:
of: autoscaling/v2
- isKind:
of: HorizontalPodAutoscaler
- equal:
path: spec.minReplicas
value: 5
- equal:
path: spec.maxReplicas
value: 15
- equal:
path: spec.scaleTargetRef.name
value: RELEASE-NAME-buzz
- equal:
path: spec.metrics[0].resource.target.averageUtilization
value: 65
- equal:
path: spec.metrics[1].pods.metric.name
value: buzz_ws_connections_active
- equal:
path: spec.metrics[1].pods.target.averageValue
value: "5000"
- equal:
path: spec.behavior.scaleDown.stabilizationWindowSeconds
value: 600
- it: supports CPU-only autoscaling without a custom metrics adapter
template: templates/hpa.yaml
set:
autoscaling.enabled: true
autoscaling.websocketMetricEnabled: false
asserts:
- lengthEqual:
path: spec.metrics
count: 1
- equal:
path: spec.metrics[0].type
value: Resource
- equal:
path: spec.metrics[0].resource.name
value: cpu
- it: leaves Deployment replicas to the HPA and disables in-process huddle audio
template: templates/deployment.yaml
set:
autoscaling.enabled: true
asserts:
- notExists:
path: spec.replicas
- contains:
path: spec.template.spec.containers[0].env
content:
name: BUZZ_HUDDLE_AUDIO_AVAILABLE
value: "false"
@@ -0,0 +1,79 @@
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
s3.endpoint: http://minio:9000
s3.accessKey: a
s3.secretKey: s
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
s3.endpoint: http://minio:9000
s3.accessKey: a
s3.secretKey: s
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
s3.endpoint: http://minio:9000
s3.accessKey: a
s3.secretKey: s
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
s3.endpoint: http://minio:9000
s3.accessKey: a
s3.secretKey: s
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
@@ -0,0 +1,54 @@
suite: optional pairing relay
templates:
- templates/deployment.yaml
- templates/secret-chart.yaml
- templates/pairing-relay.yaml
- templates/serviceaccount.yaml
tests:
- it: does not render the pairing relay by default
set:
relayUrl: wss://buzz.example.com
ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
externalPostgresql.url: postgres://u:p@h:5432/d
externalRedis.url: redis://h:6379
s3.endpoint: http://minio:9000
s3.accessKey: a
s3.secretKey: s
asserts:
- hasDocuments:
count: 0
template: templates/pairing-relay.yaml
- notContains:
path: spec.template.spec.containers[0].env
content:
name: BUZZ_PAIRING_RELAY_URL
template: templates/deployment.yaml
- it: renders and advertises the configured pairing relay
set:
relayUrl: wss://buzz.example.com
ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
externalPostgresql.url: postgres://u:p@h:5432/d
externalRedis.url: redis://h:6379
s3.endpoint: http://minio:9000
s3.accessKey: a
s3.secretKey: s
pairingRelay.enabled: true
pairingRelay.url: wss://pairing.buzz.xyz
asserts:
- equal:
path: kind
value: Deployment
documentIndex: 0
template: templates/pairing-relay.yaml
- equal:
path: kind
value: Service
documentIndex: 1
template: templates/pairing-relay.yaml
- contains:
path: spec.template.spec.containers[0].env
content:
name: BUZZ_PAIRING_RELAY_URL
value: wss://pairing.buzz.xyz
template: templates/deployment.yaml
@@ -0,0 +1,90 @@
suite: quickstart bundled services
# The dev quickstart must stand up MinIO in-cluster so the relay's
# startup S3 conformance probe passes with zero external dependencies.
templates:
- templates/quickstart-minio.yaml
- templates/quickstart-minio-init.yaml
- templates/deployment.yaml
- templates/secret-chart.yaml
- templates/service.yaml
tests:
- it: renders the in-cluster MinIO Deployment when minio.enabled
release:
name: rel
revision: 1
set:
relayUrl: wss://buzz.example.com
ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
postgresql.enabled: true
redis.enabled: true
minio.enabled: true
asserts:
- containsDocument:
kind: Deployment
apiVersion: apps/v1
name: rel-buzz-minio
template: templates/quickstart-minio.yaml
documentIndex: 0
# The bucket-init Job is a NORMAL resource (revision-suffixed name), not a
# helm hook: a post-install hook deadlocks against `--wait` because the
# relay's startup-fatal S3 probe can't pass until the bucket exists.
- containsDocument:
kind: Job
apiVersion: batch/v1
name: rel-buzz-minio-init-1
template: templates/quickstart-minio-init.yaml
- notExists:
path: metadata.annotations["helm.sh/hook"]
template: templates/quickstart-minio-init.yaml
- it: relay S3 endpoint resolves to the bundled MinIO Service
release:
name: rel
set:
relayUrl: wss://buzz.example.com
ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
postgresql.enabled: true
redis.enabled: true
minio.enabled: true
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: BUZZ_S3_ENDPOINT
value: "http://rel-buzz-minio.NAMESPACE.svc.cluster.local:9000"
template: templates/deployment.yaml
- it: chart Secret composes the bundled MinIO credentials the relay reads
release:
name: rel
set:
relayUrl: wss://buzz.example.com
ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
postgresql.enabled: true
redis.enabled: true
minio.enabled: true
asserts:
# MinIO creds the relay reads must exist for the conformance probe.
- isNotNullOrEmpty:
path: data.BUZZ_S3_ACCESS_KEY
template: templates/secret-chart.yaml
documentIndex: 0
- isNotNullOrEmpty:
path: data.BUZZ_S3_SECRET_KEY
template: templates/secret-chart.yaml
documentIndex: 0
- it: relay Service selector is scoped to component=relay (must NOT match bundled MinIO pods)
release:
name: rel
set:
relayUrl: wss://buzz.example.com
ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
postgresql.enabled: true
redis.enabled: true
minio.enabled: true
asserts:
- equal:
path: spec.selector["app.kubernetes.io/component"]
value: relay
template: templates/service.yaml
@@ -0,0 +1,17 @@
suite: quickstart guards
# Quickstart autogenerates MinIO creds in the chart-managed Secret,
# so it is mutually exclusive with secrets.existingSecret. Isolated single-
# template suites so the fail-guard is the only document under assertion.
tests:
- it: minio.enabled is incompatible with existingSecret
templates:
- templates/quickstart-minio.yaml
set:
relayUrl: wss://buzz.example.com
ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
postgresql.enabled: true
minio.enabled: true
secrets.existingSecret: "buzz-secrets"
asserts:
- failedTemplate:
errorPattern: "incompatible with secrets.existingSecret"
+291
View File
@@ -0,0 +1,291 @@
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)
set:
relayUrl: wss://buzz.example.com
ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
externalPostgresql.url: postgres://u:p@h:5432/d
externalRedis.url: redis://h:6379
s3.endpoint: http://minio:9000
s3.accessKey: a
s3.secretKey: s
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
- notContains:
path: spec.template.spec.containers[0].env
content:
name: BUZZ_S3_REGION
any: true
template: templates/deployment.yaml
- contains:
path: spec.template.spec.containers[0].env
content:
name: BUZZ_S3_ADDRESSING_STYLE
value: "path"
template: templates/deployment.yaml
- contains:
path: spec.template.spec.containers[0].env
content:
name: BUZZ_HUDDLE_AUDIO_AVAILABLE
value: "true"
template: templates/deployment.yaml
- it: renders virtual-hosted S3 addressing for providers that require it
set:
relayUrl: wss://buzz.example.com
ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
externalPostgresql.url: postgres://u:p@h:5432/d
externalRedis.url: redis://h:6379
s3.endpoint: https://storage.railway.app
s3.bucket: buzz-media-example
s3.region: auto
s3.addressingStyle: virtual
s3.accessKey: a
s3.secretKey: s
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: BUZZ_S3_REGION
value: "auto"
template: templates/deployment.yaml
- contains:
path: spec.template.spec.containers[0].env
content:
name: BUZZ_S3_ADDRESSING_STYLE
value: "virtual"
template: templates/deployment.yaml
- it: lets an explicit value disable huddle audio in a single-replica render
set:
relayUrl: wss://buzz.example.com
ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
externalPostgresql.url: postgres://u:p@h:5432/d
externalRedis.url: redis://h:6379
s3.endpoint: http://minio:9000
s3.accessKey: a
s3.secretKey: s
relay.huddleAudioAvailable: false
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: BUZZ_HUDDLE_AUDIO_AVAILABLE
value: "false"
template: templates/deployment.yaml
- it: lets an explicit value enable huddle audio in HA when the operator owns external audio behavior
set:
relayUrl: wss://buzz.example.com
ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
externalPostgresql.url: postgres://u:p@h:5432/d
externalRedis.url: redis://h:6379
s3.endpoint: http://minio:9000
s3.accessKey: a
s3.secretKey: s
replicaCount: 3
persistence.git.accessMode: ReadWriteOnce
relay.huddleAudioAvailable: true
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: BUZZ_HUDDLE_AUDIO_AVAILABLE
value: "true"
template: templates/deployment.yaml
- it: renders HA cleanly with replicaCount=3 + RWO git + Redis (no RWM needed)
set:
relayUrl: wss://buzz.example.com
ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
externalPostgresql.url: postgres://u:p@h:5432/d
externalRedis.url: redis://h:6379
s3.endpoint: http://minio:9000
s3.accessKey: a
s3.secretKey: s
replicaCount: 3
persistence.git.accessMode: ReadWriteOnce
asserts:
- equal:
path: spec.replicas
value: 3
template: templates/deployment.yaml
- equal:
path: spec.accessModes[0]
value: ReadWriteOnce
template: templates/pvc-git.yaml
- contains:
path: spec.template.spec.containers[0].env
content:
name: BUZZ_HUDDLE_AUDIO_AVAILABLE
value: "false"
template: templates/deployment.yaml
- it: mounts a per-pod emptyDir git scratch volume when persistence is disabled
set:
relayUrl: wss://buzz.example.com
ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
externalPostgresql.url: postgres://u:p@h:5432/d
externalRedis.url: redis://h:6379
s3.endpoint: http://minio:9000
s3.accessKey: a
s3.secretKey: s
replicaCount: 5
persistence.git.enabled: false
asserts:
- equal:
path: spec.replicas
value: 5
template: templates/deployment.yaml
# git-repos is still mounted (BUZZ_GIT_REPO_PATH must be a real dir)…
- contains:
path: spec.template.spec.containers[0].volumeMounts
content:
name: git-repos
mountPath: /var/lib/buzz/git
template: templates/deployment.yaml
# …but as a per-pod emptyDir, not a shared PVC (no multi-attach).
- contains:
path: spec.template.spec.volumes
content:
name: git-repos
emptyDir:
sizeLimit: 10Gi
template: templates/deployment.yaml
# No PVC is rendered at all when persistence is disabled.
- hasDocuments:
count: 0
template: templates/pvc-git.yaml
- it: preserves image defaults when Pod extensions are empty
set:
relayUrl: wss://buzz.example.com
ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
externalPostgresql.url: postgres://u:p@h:5432/d
externalRedis.url: redis://h:6379
s3.endpoint: http://minio:9000
s3.accessKey: a
s3.secretKey: s
asserts:
- notExists:
path: spec.template.spec.initContainers
template: templates/deployment.yaml
- notExists:
path: spec.template.spec.containers[0].command
template: templates/deployment.yaml
- notExists:
path: spec.template.spec.containers[0].args
template: templates/deployment.yaml
- it: appends generic Pod extensions and overrides the relay command
set:
relayUrl: wss://buzz.example.com
ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
externalPostgresql.url: postgres://u:p@h:5432/d
externalRedis.url: redis://h:6379
s3.endpoint: http://minio:9000
s3.accessKey: a
s3.secretKey: s
relay.command:
- /opt/wrapper/wrapper
relay.args:
- /usr/local/bin/buzz-relay
relay.extraVolumeMounts:
- name: wrapper
mountPath: /opt/wrapper
extraInitContainers:
- name: install-wrapper
image: example.com/wrapper-init:v1
args:
- /opt/wrapper/wrapper
env:
- name: LITERAL_TEMPLATE
value: '{{ .Release.Name }}'
securityContext:
runAsNonRoot: true
resources:
requests:
cpu: 10m
memory: 16Mi
volumeMounts:
- name: wrapper
mountPath: /opt/wrapper
extraVolumes:
- name: wrapper
emptyDir: {}
asserts:
- equal:
path: spec.template.spec.initContainers[0].name
value: install-wrapper
template: templates/deployment.yaml
- equal:
path: spec.template.spec.initContainers[0].securityContext.runAsNonRoot
value: true
template: templates/deployment.yaml
# Extension fragments are deliberately rendered with toYaml, not tpl.
- equal:
path: spec.template.spec.initContainers[0].env[0].value
value: '{{ .Release.Name }}'
template: templates/deployment.yaml
- equal:
path: spec.template.spec.containers[0].command
value:
- /opt/wrapper/wrapper
template: templates/deployment.yaml
- equal:
path: spec.template.spec.containers[0].args
value:
- /usr/local/bin/buzz-relay
template: templates/deployment.yaml
- contains:
path: spec.template.spec.containers[0].volumeMounts
content:
name: wrapper
mountPath: /opt/wrapper
template: templates/deployment.yaml
- contains:
path: spec.template.spec.volumes
content:
name: wrapper
emptyDir: {}
template: templates/deployment.yaml
- it: appends generic init containers after the bundled MinIO readiness gate
release:
name: rel
set:
relayUrl: wss://buzz.example.com
ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
postgresql.enabled: true
redis.enabled: true
minio.enabled: true
extraInitContainers:
- name: install-wrapper
image: example.com/wrapper-init:v1
asserts:
- equal:
path: spec.template.spec.initContainers[0].name
value: wait-for-bucket
template: templates/deployment.yaml
- equal:
path: spec.template.spec.initContainers[1].name
value: install-wrapper
template: templates/deployment.yaml
+180
View File
@@ -0,0 +1,180 @@
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
s3.endpoint: http://minio:9000
s3.accessKey: a
s3.secretKey: s
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
s3.endpoint: http://minio:9000
s3.accessKey: a
s3.secretKey: s
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
s3.endpoint: http://minio:9000
s3.accessKey: a
s3.secretKey: s
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: Deployment env points READ_DATABASE_URL at existingSecret as optional
set:
relayUrl: wss://buzz.example.com
ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
externalPostgresql.url: postgres://u:p@h:5432/d
s3.endpoint: http://minio:9000
s3.accessKey: a
s3.secretKey: s
secrets.existingSecret: "buzz-secrets"
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: READ_DATABASE_URL
valueFrom:
secretKeyRef:
name: buzz-secrets
key: READ_DATABASE_URL
optional: true
template: templates/deployment.yaml
- it: READ_DATABASE_URL stays optional against the chart-managed Secret
set:
relayUrl: wss://buzz.example.com
ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
externalPostgresql.url: postgres://u:p@h:5432/d
s3.endpoint: http://minio:9000
s3.accessKey: a
s3.secretKey: s
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: READ_DATABASE_URL
valueFrom:
secretKeyRef:
name: RELEASE-NAME-buzz-relay
key: READ_DATABASE_URL
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
s3.endpoint: http://minio:9000
s3.accessKey: a
s3.secretKey: s
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
s3.endpoint: http://minio:9000
s3.accessKey: a
s3.secretKey: s
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: BUZZ_AUTO_MIGRATE
value: "true"
template: templates/deployment.yaml
- it: quickstart composes DATABASE_URL/REDIS_URL at the actual subchart Service hosts
release:
name: rel
set:
relayUrl: wss://buzz.example.com
ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
postgresql.enabled: true
redis.enabled: true
s3.endpoint: http://minio:9000
s3.accessKey: a
s3.secretKey: s
asserts:
# Postgres Service is "<release>-postgresql"; Redis (standalone) is
# "<release>-redis" — NOT "-redis-master". A host mismatch here is the
# connection-refused class of bug this guards against.
- matchRegex:
path: data.DATABASE_URL
decodeBase64: true
pattern: "@rel-postgresql:5432/buzz$"
template: templates/secret-chart.yaml
- matchRegex:
path: data.REDIS_URL
decodeBase64: true
pattern: "@rel-redis:6379$"
template: templates/secret-chart.yaml
documentIndex: 0
- notMatchRegex:
path: data.REDIS_URL
decodeBase64: true
pattern: "redis-master"
template: templates/secret-chart.yaml
documentIndex: 0
# Autogenerated relay key MUST be a valid Nostr secret key: 64 lowercase
# hex chars. randAlphaNum produces letters g-z that fail nostr::Keys::parse
# and crash relay startup with "invalid BUZZ_RELAY_PRIVATE_KEY".
- matchRegex:
path: data.BUZZ_RELAY_PRIVATE_KEY
decodeBase64: true
pattern: "^[0-9a-f]{64}$"
template: templates/secret-chart.yaml
documentIndex: 0
@@ -0,0 +1,79 @@
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
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
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
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
replicaCount: 3
asserts:
- failedTemplate:
errorPattern: "minimum replica count 3 requires Redis"
- it: fails when ingress and httproute both enabled
set:
relayUrl: wss://buzz.example.com
ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
externalPostgresql.url: postgres://u:p@h:5432/d
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"
asserts:
- failedTemplate:
errorPattern: "Postgres source missing"
- it: rejects an invalid S3 addressing style
set:
relayUrl: wss://buzz.example.com
ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
externalPostgresql.url: postgres://u:p@h:5432/d
s3.endpoint: http://minio:9000
s3.addressingStyle: auto
asserts:
- failedTemplate:
errorPattern: "s3.addressingStyle: s3.addressingStyle must be one of the following:.*path.*virtual"
- it: fails when S3/object-storage source is missing
set:
relayUrl: wss://buzz.example.com
ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
externalPostgresql.url: postgres://u:p@h:5432/d
asserts:
- failedTemplate:
errorPattern: "S3/object-storage source missing"
+301
View File
@@ -0,0 +1,301 @@
{
"$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) — enforced by _validate.tpl. Git storage does NOT need ReadWriteMany: git state is object-store-backed and repo names live in Postgres, so ReadWriteOnce is fine per replica."
},
"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 },
"drainJitterMs": { "type": "integer", "minimum": 0 },
"requireAuthToken": { "type": "boolean" },
"requireRelayMembership": { "type": "boolean" },
"allowNipOaAuth": { "type": "boolean" },
"huddleAudioAvailable": {
"type": ["boolean", "null"],
"description": "Whether relay huddle audio is available. null means chart default: true for one replica, false for replicaCount > 1. Explicit true with replicaCount > 1 means the operator accepts/owns external multi-pod audio/SFU behavior."
},
"pubkeyAllowlist": { "type": "boolean" },
"corsOrigins": {
"type": "array",
"items": { "type": "string" }
},
"ephemeralTtlOverride": { "type": "integer", "minimum": 0 },
"command": {
"type": "array",
"items": { "type": "string" },
"description": "Optional relay container entrypoint override. Empty preserves the image default."
},
"args": {
"type": "array",
"items": { "type": "string" },
"description": "Optional relay container arguments override. Empty preserves the image default."
},
"extraVolumeMounts": {
"type": "array",
"items": { "type": "object" },
"description": "Raw Kubernetes volumeMount fragments appended to the relay container."
}
}
},
"extraInitContainers": {
"type": "array",
"items": { "type": "object" },
"description": "Raw Kubernetes init-container fragments appended to the relay Pod."
},
"extraVolumes": {
"type": "array",
"items": { "type": "object" },
"description": "Raw Kubernetes volume fragments appended to the relay Pod."
},
"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?://.+)?$" }
}
},
"s3": {
"type": "object",
"additionalProperties": false,
"properties": {
"endpoint": { "type": "string", "pattern": "^(https?://.+)?$" },
"bucket": { "type": "string", "minLength": 1 },
"region": {
"type": "string",
"description": "Optional S3 region used for SigV4 signing. When empty, BUZZ_S3_REGION is omitted so the relay can use AWS_REGION or its own default."
},
"addressingStyle": {
"type": "string",
"enum": ["path", "virtual"],
"description": "S3 URL style shared by media and Git/CAS clients. Defaults to path for bundled MinIO compatibility."
},
"accessKey": { "type": "string" },
"secretKey": { "type": "string" }
}
},
"minio": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"image": { "type": "string", "minLength": 1 },
"mcImage": { "type": "string", "minLength": 1 },
"persistence": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"size": { "type": "string", "pattern": "^[0-9]+(\\.[0-9]+)?(E|P|T|G|M|K|Ei|Pi|Ti|Gi|Mi|Ki)?$" }
}
}
}
},
"git": {
"type": "object",
"additionalProperties": false,
"properties": {
"maxPackBytes": { "type": "integer", "minimum": 1 },
"packCachePath": { "type": "string", "minLength": 1 },
"packCacheMaxBytes": { "type": "integer", "minimum": 0 },
"packCacheMaxConcurrentPopulations": { "type": "integer", "minimum": 1 },
"packCacheVolumeSize": { "type": "string", "pattern": "^[0-9]+(\\.[0-9]+)?(E|P|T|G|M|K|Ei|Pi|Ti|Gi|Mi|Ki)?$" },
"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" }
}
},
"pairingRelay": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"url": {
"type": "string",
"pattern": "^(wss?://.+)?$",
"description": "Public WebSocket URL advertised to clients for NIP-AB device pairing."
},
"replicaCount": { "type": "integer", "minimum": 1 },
"service": {
"type": "object",
"additionalProperties": false,
"properties": {
"type": { "type": "string", "enum": ["ClusterIP", "NodePort", "LoadBalancer"] },
"port": { "type": "integer", "minimum": 1, "maximum": 65535 },
"annotations": { "type": "object" }
}
},
"podAnnotations": { "type": "object" },
"podLabels": { "type": "object" },
"resources": { "type": "object" }
}
},
"extraManifests": {
"type": "array"
}
}
}
+393
View File
@@ -0,0 +1,393 @@
# Default values for buzz.
#
# Two supported tiers:
#
# PRODUCTION (default) — external Postgres/Redis/S3, existingSecret
# refs everywhere, no chart-side autogeneration, GitOps-safe (ArgoCD/Flux).
# HA-ready: replicaCount >= 2 (requires Redis; git state is object-store-
# backed, so no ReadWriteMany volume is needed — RWO per replica is fine).
#
# QUICKSTART — bundles in-cluster Postgres + Redis + MinIO and
# auto-generates relay secrets via the `lookup` pattern (NOT GitOps-safe —
# see README), single replica, evaluation only. Opt in by enabling each
# bundled service: postgresql.enabled, redis.enabled, minio.enabled.
# See ci/quickstart-values.yaml and the README.
#
# See examples/argocd-app.yaml and examples/flux-helmrelease.yaml for the
# canonical GitOps configurations.
# Intent marker for the evaluation profile, surfaced in NOTES.txt. It does NOT
# by itself enable any bundled service — set the per-service .enabled flags
# (postgresql / redis / minio) to bring them up in-cluster.
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).
# It does NOT require ReadWriteMany git storage: git ref/object state is
# object-store-backed (each request hydrates an ephemeral repo from S3; writer
# serialization is the object-store pointer CAS), and repo-name uniqueness lives
# in Postgres. Each replica can use its own ReadWriteOnce volume (or none).
replicaCount: 1
# ── Autoscaling ──────────────────────────────────────────────────────────────
# Requires Metrics Server for CPU. Optional WebSocket scaling additionally
# requires a custom-metrics adapter exposing its pod-level Prometheus gauge.
# Kubernetes HPA uses the larger replica recommendation from enabled metrics.
autoscaling:
enabled: false
minReplicas: 5
maxReplicas: 15
targetCPUUtilizationPercentage: 65
websocketMetricEnabled: true
websocketMetricName: buzz_ws_connections_active
targetWebsocketConnections: 5000
behavior:
scaleUp:
stabilizationWindowSeconds: 0
policies:
- type: Percent
value: 100
periodSeconds: 60
- type: Pods
value: 4
periodSeconds: 60
selectPolicy: Max
scaleDown:
stabilizationWindowSeconds: 600
policies:
- type: Pods
value: 1
periodSeconds: 120
selectPolicy: Min
# ── Public URL ───────────────────────────────────────────────────────────────
# Required. The wss:// URL clients use to connect. Drives:
# - RELAY_URL env (relay-side)
# - Default mediaBaseUrl (https://<host>/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)
# READ_DATABASE_URL — optional Postgres read-replica URL; omit to keep all reads on the writer
# REDIS_URL — full Redis URL with auth
# 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
# Graceful-shutdown reconnect jitter. On SIGTERM the relay closes every live
# WebSocket with a 1012 Service Restart frame; with a rolling deploy this can
# release a whole pod's sockets at once and stampede reconnects into the DB
# pool. A positive value (milliseconds) spreads each close over a per-socket
# random delay in [1, drainJitterMs], smoothing the reconnect herd. 0 (the
# default) closes all sockets at once, preserving the previous behavior.
# Values above 20000 are capped to 20000, leaving close-frame delivery
# headroom under the relay's 30s hard-drain timeout (itself inside the 60s
# terminationGracePeriodSeconds below).
drainJitterMs: 0
requireAuthToken: true
requireRelayMembership: true
allowNipOaAuth: true
pubkeyAllowlist: false
corsOrigins: []
# Huddle audio is safe only for single-pod relay deployments until an SFU
# exists. null lets the chart render false automatically when
# replicaCount > 1. Explicit true with replicaCount > 1 means the operator
# accepts/owns the external multi-pod audio/SFU behavior.
huddleAudioAvailable: null
ephemeralTtlOverride: 0
# Per-upload-event records (`_uploads/` moderation side channel). Off by
# default. Operators hosting communities for other people may have legal
# obligations (e.g. NCMEC reporting for US-serving providers) that require
# recording the network address of an upload; Buzz never collects IPs unless
# uploadIpHeader is set. When set (e.g. "cf-connecting-ip"), the connecting
# address reported by YOUR trusted edge is stored in the per-event record
# only — never served to clients, never in event data. uploadRecords must be
# true for uploadIpHeader to be valid (startup-checked).
uploadRecords: false
uploadIpHeader: ""
uploadPortHeader: ""
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
# Optional image entrypoint/arguments overrides. Empty arrays preserve the
# relay image's defaults. Consumers own compatibility with the selected image.
command: []
args: []
# Appended to the chart-owned relay mounts. Names must match extraVolumes (or
# another volume supplied by the platform) and must not collide with built-ins.
extraVolumeMounts: []
extraEnv: []
extraEnvFrom: []
# ── Pod extensions ──────────────────────────────────────────────────────────
# Raw Kubernetes fragments appended to the relay Pod. They are rendered with
# toYaml, not tpl. Init containers must define their own securityContext and
# resources; names must not collide with chart-owned containers or volumes.
extraInitContainers: []
extraVolumes: []
# ── Device pairing relay ─────────────────────────────────────────────────────
# Optional, stateless NIP-AB relay. When enabled, the main relay advertises
# pairingRelay.url in NIP-11 and Buzz clients use it instead of the legacy
# same-host /pair convention.
pairingRelay:
enabled: false
url: ""
replicaCount: 1
service:
type: ClusterIP
port: 5000
annotations: {}
podAnnotations: {}
podLabels: {}
resources:
requests:
cpu: "50m"
memory: "32Mi"
limits:
cpu: "250m"
memory: "128Mi"
# ── 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 scratch volume ───────────────────────────────────────────────────────
# Ephemeral working space only. No persistent git state lives here — reads/writes
# hydrate ephemeral repos from object storage per request, and repo-name
# uniqueness lives in Postgres.
#
# enabled: true → mount a PVC at mountPath (durable across pod restarts, but a
# single ReadWriteOnce PVC binds to one node, so it does NOT support multi-pod
# scheduling across nodes on a Deployment).
# enabled: false → mount a per-pod emptyDir at mountPath (pure scratch), bounded
# by size. This is the correct choice for multi-replica HA: each pod gets its
# own local working space, nothing is shared, and there is no volume to
# multi-attach. Safe because the object store + Postgres are the sources of
# truth, not this disk.
persistence:
git:
enabled: true
mountPath: /var/lib/buzz/git
storageClass: ""
accessMode: ReadWriteOnce
size: 10Gi # PVC capacity or emptyDir sizeLimit
annotations: {}
existingClaim: ""
# ── Postgres ─────────────────────────────────────────────────────────────────
# Eval-only CloudPirates subchart. The relay's DATABASE_URL is composed in the
# chart-managed Secret with a chart-generated password; auth.existingSecret
# points this subchart at that same Secret/key so server and client agree.
postgresql:
enabled: false
auth:
database: buzz
username: buzz
existingSecret: '{{ if contains "buzz" .Release.Name }}{{ .Release.Name }}-relay{{ else }}{{ .Release.Name }}-buzz-relay{{ end }}'
secretKeys:
adminPasswordKey: postgres-password
persistence:
enabled: true
size: 10Gi
externalPostgresql:
url: "" # postgres://user:pass@host:5432/db — placeholder example, sadscan:disable np.postgres.1
# ── Redis ────────────────────────────────────────────────────────────────────
# Eval-only CloudPirates subchart (standalone). REDIS_URL is composed in the
# chart-managed Secret; auth.existingSecret points the subchart at that Secret
# so the server password matches the URL the relay dials.
redis:
enabled: false
auth:
existingSecret: '{{ if contains "buzz" .Release.Name }}{{ .Release.Name }}-relay{{ else }}{{ .Release.Name }}-buzz-relay{{ end }}'
existingSecretPasswordKey: redis-password
persistence:
enabled: true
size: 4Gi
externalRedis:
url: "" # redis://:pass@host:6379
# ── S3 / object storage (media) ──────────────────────────────────────────────
# Production: point endpoint/bucket at an external S3-compatible service and
# supply credentials (inline below or via secrets.existingSecret).
# Quickstart (`minio.enabled: true`): the chart runs an in-cluster, eval-only
# MinIO Deployment, creates the bucket via a post-install Job, and composes
# the endpoint + autogenerated credentials automatically.
#
# Storage metrics (hourly bucket sweep, BUZZ_STORAGE_METRICS — see env docs):
# the credentials above must additionally grant `s3:ListBucket` on the bucket
# ARN itself (bucket-level; distinct from the object-level GetObject/
# PutObject/DeleteObject perms already required for media). Without it the
# first sweep fails AccessDenied and buzz_storage_sweep_ok stays 0 — no other
# media functionality is affected. Set BUZZ_STORAGE_METRICS=off to disable
# the sweep entirely on a deployment that can't grant it.
# Note: buzz_storage_sweep_failures is a process-local gauge — on leader
# failover it resets to the new leader's local count, not a global total.
# Note: on a failed sweep attempt, the next retry fires on the next usage tick
# (default 300 s BUZZ_USAGE_METRICS_INTERVAL_SECS), not at sweep-interval
# cadence — so a permanently missing s3:ListBucket yields one cheap LIST call
# per tick until the permission is added.
s3:
endpoint: ""
bucket: "buzz-media"
# Optional SigV4 signing region. Leave empty to preserve the relay's
# AWS_REGION fallback; set the provider's credential value when needed.
region: ""
# path: https://endpoint/bucket/key (bundled MinIO-compatible default)
# virtual: https://bucket.endpoint/key (standard S3; required by new Railway buckets)
addressingStyle: path
accessKey: ""
secretKey: ""
# In-cluster MinIO for the quickstart profile only. Production deploys leave
# this disabled and use s3.* (or secrets.existingSecret) against managed S3.
minio:
enabled: false # quickstart: set true for bundled in-cluster MinIO
image: minio/minio:RELEASE.2025-09-07T16-13-09Z
mcImage: minio/mc:RELEASE.2025-08-13T08-35-41Z
persistence:
enabled: true
size: 10Gi
# ── Git server config ────────────────────────────────────────────────────────
git:
maxPackBytes: 524288000 # 500 MiB
packCachePath: /var/cache/buzz/git-packs
packCacheMaxBytes: 5368709120 # 5 GiB
packCacheMaxConcurrentPopulations: 2
packCacheVolumeSize: 7Gi # per-pod emptyDir; includes cold-population staging
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: []
+57
View File
@@ -0,0 +1,57 @@
# Buzz production Docker Compose environment.
# Copy to .env and replace every CHANGE_ME value before running.
# The bootstrap script should generate this file for normal users.
# Image published by the public image pipeline. Use `:main` for pre-release testing. Pin `:sha-<7>` or a semver release tag for production.
BUZZ_IMAGE=ghcr.io/block/buzz:main
# Public host name. Used by compose.caddy.yml and URL-derived settings below.
BUZZ_DOMAIN=buzz.example.com
RELAY_URL=wss://buzz.example.com
BUZZ_MEDIA_BASE_URL=https://buzz.example.com/media
BUZZ_MEDIA_SERVER_DOMAIN=buzz.example.com
BUZZ_CORS_ORIGINS=https://buzz.example.com
# Production defaults. Closed relay mode requires RELAY_OWNER_PUBKEY and a stable relay key.
BUZZ_REQUIRE_AUTH_TOKEN=true
BUZZ_REQUIRE_RELAY_MEMBERSHIP=true
BUZZ_ALLOW_NIP_OA_AUTH=true
BUZZ_AUTO_MIGRATE=true
BUZZ_GIT_CONFORMANCE_PROBE=true
RUST_LOG=buzz_relay=info,buzz_db=info,buzz_auth=info,buzz_pubsub=info,tower_http=info
# Owner identity. Set to a 64-character hex Nostr pubkey.
RELAY_OWNER_PUBKEY=CHANGE_ME_OWNER_PUBKEY_HEX
# Stable secrets. Generate once, keep in .env, and back up securely.
BUZZ_RELAY_PRIVATE_KEY=CHANGE_ME_64_HEX_PRIVATE_KEY
BUZZ_GIT_HOOK_HMAC_SECRET=CHANGE_ME_RANDOM_64_HEX
POSTGRES_DB=buzz
POSTGRES_USER=buzz
POSTGRES_PASSWORD=CHANGE_ME_RANDOM_PASSWORD
REDIS_PASSWORD=CHANGE_ME_RANDOM_PASSWORD
BUZZ_S3_ACCESS_KEY=CHANGE_ME_RANDOM_ACCESS_KEY
BUZZ_S3_SECRET_KEY=CHANGE_ME_RANDOM_SECRET_KEY
BUZZ_S3_BUCKET=buzz-media
# Bundled MinIO uses path-style URLs; deploy/compose/compose.yml pins this.
BUZZ_S3_ADDRESSING_STYLE=path
# Optional host ports. Base compose publishes the relay directly on BUZZ_HTTP_PORT.
BUZZ_HTTP_PORT=3000
# Optional custom Web UI. Setting this to an absolute directory containing
# index.html makes run.sh automatically include compose.web.yml. Leave unset to
# use the Web UI bundled in BUZZ_IMAGE.
# BUZZ_WEB_HOST_DIR=/opt/buzz/web/dist
# Caddy host ports. Only used with compose.caddy.yml.
CADDY_HTTP_PORT=80
CADDY_HTTPS_PORT=443
# Dev override ports. Only used with compose.dev.yml.
POSTGRES_PORT=5432
REDIS_PORT=6379
MINIO_API_PORT=9000
MINIO_CONSOLE_PORT=9001
ADMINER_PORT=8082
PROMETHEUS_PORT=9090
+5
View File
@@ -0,0 +1,5 @@
{$BUZZ_DOMAIN} {
encode zstd gzip
reverse_proxy relay:3000
}
+61
View File
@@ -0,0 +1,61 @@
# Buzz Docker Compose deployment
This is the single-node/VPS deployment bundle. It is intentionally separate from
the root `docker-compose.yml`, which remains local development infrastructure.
## Quick start
```bash
cd deploy/compose
cp .env.example .env
$EDITOR .env # replace every CHANGE_ME value
./run.sh start
```
For a public VPS with automatic Let's Encrypt certificates:
```bash
cd deploy/compose
BUZZ_COMPOSE_TLS=true ./run.sh start
```
The bootstrap script should eventually replace manual `.env` editing for normal
users. It is responsible for generating stable secrets and, optionally, an owner
keypair.
## Production notes
- Requires Docker Compose v2.24.4 or newer; the TLS override uses Compose's
`!reset` tag to remove the direct relay port when Caddy terminates HTTPS.
- Default `BUZZ_IMAGE` tracks `ghcr.io/block/buzz:main` for early testing. Pin it to `ghcr.io/block/buzz:sha-<7>` or a semver release tag for production once available.
- Keep `BUZZ_RELAY_PRIVATE_KEY`, `BUZZ_GIT_HOOK_HMAC_SECRET`, database/Redis,
and S3 secrets stable across restarts.
- `RELAY_OWNER_PUBKEY` is intentionally not prefixed with `BUZZ_`; it must be a
64-character hex Nostr pubkey when closed relay mode is enabled.
- `BUZZ_AUTO_MIGRATE` is opt-in. Set `BUZZ_AUTO_MIGRATE=true` or run
`buzz-admin migrate` before starting the relay when bootstrapping a fresh
database. Auto-migration requires an image that includes embedded SQLx
migrations.
- The stack uses Postgres, Redis, MinIO, and a git data volume because
those are real Buzz dependencies today. Minimal mode can simplify this later.
- The bundled Compose stack fixes the relay endpoint to `http://minio:9000` and
`BUZZ_S3_ADDRESSING_STYLE=path`: Docker DNS resolves `minio`, not
`<bucket>.minio`. It is not configurable for an external S3 provider through
`.env`; use the Helm chart or a custom Compose configuration for providers
such as new Railway Storage Buckets that require `virtual` addressing.
Run `./run.sh backup-hint` for the backup checklist.
## Validation
Before sharing an install link publicly, verify a fresh install with:
```bash
cd deploy/compose
cp .env.example .env
$EDITOR .env
./run.sh config
./run.sh start
curl -fsS "http://127.0.0.1:$(grep -E '^BUZZ_HTTP_PORT=' .env | cut -d= -f2-)/_liveness"
./run.sh status
```
+29
View File
@@ -0,0 +1,29 @@
services:
relay:
ports: !reset []
caddy:
image: caddy:2-alpine
depends_on:
relay:
condition: service_healthy
environment:
BUZZ_DOMAIN: ${BUZZ_DOMAIN:?set BUZZ_DOMAIN}
ports:
- "${CADDY_HTTP_PORT:-80}:80"
- "${CADDY_HTTPS_PORT:-443}:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- buzz-caddy-data:/data
- buzz-caddy-config:/config
restart: unless-stopped
networks:
- buzz-net
volumes:
buzz-caddy-data:
labels:
com.buzz.volume: caddy-data
buzz-caddy-config:
labels:
com.buzz.volume: caddy-config
+46
View File
@@ -0,0 +1,46 @@
services:
postgres:
ports:
- "${POSTGRES_PORT:-5432}:5432"
redis:
ports:
- "${REDIS_PORT:-6379}:6379"
minio:
ports:
- "${MINIO_API_PORT:-9000}:9000"
- "${MINIO_CONSOLE_PORT:-9001}:9001"
adminer:
image: adminer:latest
container_name: buzz-adminer
depends_on:
postgres:
condition: service_healthy
environment:
ADMINER_DEFAULT_SERVER: postgres
ports:
- "${ADMINER_PORT:-8082}:8080"
restart: unless-stopped
networks:
- buzz-net
prometheus:
image: prom/prometheus:latest
container_name: buzz-prometheus
volumes:
- ../../prometheus.yml:/etc/prometheus/prometheus.yml:ro
- buzz-prometheus-data:/prometheus
ports:
- "${PROMETHEUS_PORT:-9090}:9090"
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
networks:
- buzz-net
volumes:
buzz-prometheus-data:
labels:
com.buzz.volume: prometheus
+12
View File
@@ -0,0 +1,12 @@
services:
relay:
environment:
BUZZ_SERVE_GIT_WEB_GUI: "true"
BUZZ_WEB_DIR: /srv/buzz/web
volumes:
- type: bind
source: ${BUZZ_WEB_HOST_DIR:?set BUZZ_WEB_HOST_DIR to an absolute web dist directory}
target: /srv/buzz/web
read_only: true
bind:
create_host_path: false
+142
View File
@@ -0,0 +1,142 @@
name: buzz-prod
services:
relay:
image: ${BUZZ_IMAGE:-ghcr.io/block/buzz:main}
env_file:
- .env
environment:
BUZZ_BIND_ADDR: 0.0.0.0:3000
BUZZ_HEALTH_PORT: "8080"
BUZZ_METRICS_PORT: "9102"
DATABASE_URL: postgres://${POSTGRES_USER:-buzz}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-buzz}
REDIS_URL: redis://:${REDIS_PASSWORD:?set REDIS_PASSWORD}@redis:6379
BUZZ_S3_ENDPOINT: http://minio:9000
# Docker DNS resolves `minio`, not arbitrary `<bucket>.minio` hosts.
BUZZ_S3_ADDRESSING_STYLE: path
BUZZ_S3_ACCESS_KEY: ${BUZZ_S3_ACCESS_KEY:?set BUZZ_S3_ACCESS_KEY}
BUZZ_S3_SECRET_KEY: ${BUZZ_S3_SECRET_KEY:?set BUZZ_S3_SECRET_KEY}
BUZZ_S3_BUCKET: ${BUZZ_S3_BUCKET:-buzz-media}
BUZZ_GIT_REPO_PATH: /data/git
BUZZ_AUTO_MIGRATE: ${BUZZ_AUTO_MIGRATE:-false}
BUZZ_GIT_CONFORMANCE_PROBE: ${BUZZ_GIT_CONFORMANCE_PROBE:-true}
ports:
- "${BUZZ_HTTP_PORT:-3000}:3000"
volumes:
- buzz-git-data:/data/git
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
minio:
condition: service_healthy
minio-init:
condition: service_completed_successfully
# Probe /_readiness over /dev/tcp because the runtime image has bash but no curl/wget/socat.
healthcheck:
test:
[
"CMD-SHELL",
"bash -ec 'exec 3<>/dev/tcp/127.0.0.1/8080; printf \"GET /_readiness HTTP/1.1\\r\\nHost: 127.0.0.1\\r\\nConnection: close\\r\\n\\r\\n\" >&3; grep -q \"200 OK\" <&3'",
]
interval: 10s
timeout: 3s
retries: 12
start_period: 30s
restart: unless-stopped
networks:
- buzz-net
postgres:
image: postgres:17-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-buzz}
POSTGRES_USER: ${POSTGRES_USER:-buzz}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
PGDATA: /var/lib/postgresql/data/pgdata
volumes:
- buzz-postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 12
start_period: 10s
restart: unless-stopped
networks:
- buzz-net
redis:
image: redis:7-alpine
command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD:?set REDIS_PASSWORD}"]
environment:
REDIS_PASSWORD: ${REDIS_PASSWORD:?set REDIS_PASSWORD}
volumes:
- buzz-redis-data:/data
healthcheck:
test: ["CMD-SHELL", "redis-cli -a \"$${REDIS_PASSWORD}\" ping | grep -q PONG"]
interval: 5s
timeout: 3s
retries: 12
start_period: 5s
restart: unless-stopped
networks:
- buzz-net
minio:
image: minio/minio:RELEASE.2025-09-07T16-13-09Z
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${BUZZ_S3_ACCESS_KEY:?set BUZZ_S3_ACCESS_KEY}
MINIO_ROOT_PASSWORD: ${BUZZ_S3_SECRET_KEY:?set BUZZ_S3_SECRET_KEY}
volumes:
- buzz-minio-data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:9000/minio/health/live"]
interval: 5s
timeout: 5s
retries: 12
start_period: 10s
restart: unless-stopped
networks:
- buzz-net
minio-init:
image: minio/mc:RELEASE.2025-08-13T08-35-41Z
depends_on:
minio:
condition: service_healthy
environment:
BUZZ_S3_ACCESS_KEY: ${BUZZ_S3_ACCESS_KEY:?set BUZZ_S3_ACCESS_KEY}
BUZZ_S3_SECRET_KEY: ${BUZZ_S3_SECRET_KEY:?set BUZZ_S3_SECRET_KEY}
BUZZ_S3_BUCKET: ${BUZZ_S3_BUCKET:-buzz-media}
entrypoint: >
/bin/sh -euc '
mc alias set local http://minio:9000 "$${BUZZ_S3_ACCESS_KEY}" "$${BUZZ_S3_SECRET_KEY}"
mc mb --ignore-existing "local/$${BUZZ_S3_BUCKET}"
mc anonymous set none "local/$${BUZZ_S3_BUCKET}"
'
restart: "no"
networks:
- buzz-net
volumes:
buzz-postgres-data:
labels:
com.buzz.volume: postgres
buzz-redis-data:
labels:
com.buzz.volume: redis
buzz-minio-data:
labels:
com.buzz.volume: minio
buzz-git-data:
labels:
com.buzz.volume: git
networks:
buzz-net:
driver: bridge
labels:
com.buzz.network: production
+191
View File
@@ -0,0 +1,191 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "${SCRIPT_DIR}"
COMPOSE_FILES=(-f compose.yml)
if [[ "${BUZZ_COMPOSE_TLS:-false}" == "true" ]]; then
COMPOSE_FILES+=(-f compose.caddy.yml)
fi
if [[ "${BUZZ_COMPOSE_DEV:-false}" == "true" ]]; then
COMPOSE_FILES+=(-f compose.dev.yml)
fi
WEB_OVERLAY_ENABLED=false
if [[ "${BUZZ_WEB_HOST_DIR+x}" == "x" ]] || {
[[ -f .env ]] && grep -Eq '^[[:space:]]*(export[[:space:]]+)?BUZZ_WEB_HOST_DIR[[:space:]]*=' .env
}; then
COMPOSE_FILES+=(-f compose.web.yml)
WEB_OVERLAY_ENABLED=true
fi
compose() {
docker compose --env-file .env "${COMPOSE_FILES[@]}" "$@"
}
require_env() {
if [[ ! -f .env ]]; then
cat >&2 <<'MSG'
Missing deploy/compose/.env.
Copy .env.example to .env and replace every CHANGE_ME value, or run the bootstrap
script once it lands. Do not start production with generated secrets missing.
MSG
exit 1
fi
if grep -Eq '^[[:space:]]*[A-Za-z_][A-Za-z0-9_]*=.*CHANGE_ME' .env; then
cat >&2 <<'MSG'
deploy/compose/.env still contains CHANGE_ME placeholders.
Generate stable secrets first; these values must not rotate on restart.
MSG
exit 1
fi
}
read_web_host_dir() {
if [[ "${BUZZ_WEB_HOST_DIR+x}" == "x" ]]; then
printf '%s' "${BUZZ_WEB_HOST_DIR}"
return
fi
local value
value="$({
sed -nE 's/^[[:space:]]*(export[[:space:]]+)?BUZZ_WEB_HOST_DIR[[:space:]]*=[[:space:]]*(.*)$/\2/p' .env || true
} | tail -n 1)"
value="${value%$'\r'}"
value="${value#\"}"
value="${value%\"}"
value="${value#\'}"
value="${value%\'}"
printf '%s' "${value}"
}
validate_web_overlay() {
[[ "${WEB_OVERLAY_ENABLED}" == "true" ]] || return 0
local host_dir
host_dir="$(read_web_host_dir)"
if [[ -z "${host_dir}" ]]; then
echo "BUZZ_WEB_HOST_DIR is set but empty." >&2
exit 1
fi
if [[ "${host_dir}" != /* ]]; then
echo "BUZZ_WEB_HOST_DIR must be an absolute path." >&2
exit 1
fi
if [[ ! -d "${host_dir}" ]]; then
echo "BUZZ_WEB_HOST_DIR does not exist or is not a directory: ${host_dir}" >&2
exit 1
fi
if [[ ! -r "${host_dir}/index.html" ]]; then
echo "BUZZ_WEB_HOST_DIR must contain a readable index.html: ${host_dir}" >&2
exit 1
fi
}
validate_config() {
require_env
validate_web_overlay
# Quiet mode validates the merged model without printing resolved secrets.
compose config --quiet
}
backup_hint() {
cat <<'MSG'
Back up these before upgrades and on a regular schedule:
- deploy/compose/.env, especially BUZZ_RELAY_PRIVATE_KEY, DB/Redis/S3 secrets, and BUZZ_GIT_HOOK_HMAC_SECRET
- The owner private key if bootstrap generated one for RELAY_OWNER_PUBKEY
- Postgres data (prefer pg_dump or a quiesced volume snapshot)
- MinIO/S3 bucket contents for media and git objects
- buzz-git-data volume (BUZZ_GIT_REPO_PATH=/data/git)
- Caddy data/config volumes if using compose.caddy.yml
Keep Postgres + object/git state snapshots from the same maintenance window.
MSG
}
case "${1:-help}" in
start|up)
validate_config
compose up -d --wait
;;
stop|down)
compose down
;;
restart)
validate_config
compose up -d --wait --force-recreate relay
;;
pull)
validate_config
compose pull
;;
upgrade)
validate_config
compose pull
compose up -d --wait
backup_hint
;;
logs)
shift || true
compose logs -f "${@:-relay}"
;;
status|ps)
compose ps
;;
config|validate)
validate_config
echo "Compose configuration is valid."
;;
backup-hint)
backup_hint
;;
add-member)
docker compose exec relay /usr/local/bin/buzz-admin add-member --pubkey "${2:?Usage: ./run.sh add-member <npub-or-hex> [--role member|admin]}" "${@:3}"
;;
remove-member)
docker compose exec relay /usr/local/bin/buzz-admin remove-member --pubkey "${2:?Usage: ./run.sh remove-member <npub-or-hex> [--role member|admin]}" "${@:3}"
;;
list-members)
docker compose exec relay /usr/local/bin/buzz-admin list-members
;;
help|-h|--help)
cat <<'MSG'
Usage: ./run.sh <command>
Commands:
start Start Buzz with docker compose up -d --wait
stop Stop containers without deleting volumes
restart Recreate the relay after env/image changes
pull Pull configured images
upgrade Pull and restart, then print backup reminders
logs [svc] Follow logs (default: relay)
status Show compose service status
config Validate merged Compose config without printing secrets
validate Alias for config
backup-hint Print the production backup checklist
add-member <npub-or-hex> [--role member|admin]
Add a relay member (default role: member)
remove-member <npub-or-hex> [--role member|admin]
Remove a relay member
list-members List all relay members
Note: when adding multiple members in a loop, add `sleep 1` between
invocations to avoid same-second timestamp collisions in the kind:13534
roster event. Do not use parallel adds (e.g. xargs -P).
Environment switches:
BUZZ_COMPOSE_TLS=true Include compose.caddy.yml for automatic HTTPS
BUZZ_COMPOSE_DEV=true Include compose.dev.yml for local admin ports/tools
BUZZ_WEB_HOST_DIR=/abs Mount a custom web dist via compose.web.yml (read-only)
MSG
;;
*)
echo "Unknown command: $1" >&2
echo "Run ./run.sh help" >&2
exit 1
;;
esac
+3
View File
@@ -0,0 +1,3 @@
# Corporate-proxy CA is machine/environment-specific and often internal.
# Regenerate locally (see build-and-deploy.sh); never commit.
proxy-ca.pem
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env bash
# Local docker-desktop k8s testbed for the Buzz relay mesh.
#
# Repeatable path: build image -> helm dep build -> helm install (quickstart HA,
# 3 replicas) -> wait 3/3 Ready -> probe /_readiness on every pod. This is the
# baseline every mesh build redeploys onto (mesh lane).
#
# Prereqs: docker-desktop k8s context Ready, helm >= 3.14, kubectl, docker.
# docker-desktop shares the docker image store with k8s, so a locally-built
# tag + pullPolicy: IfNotPresent needs no registry push or `kind load`.
#
# Usage:
# deploy/local/build-and-deploy.sh # full: build + deploy
# SKIP_BUILD=1 deploy/local/build-and-deploy.sh # redeploy existing image
# IMAGE_TAG=mesh-abc1234 deploy/local/build-and-deploy.sh
set -euo pipefail
# ── config ──────────────────────────────────────────────────────────────────
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
NS="${NS:-buzz-mesh}"
RELEASE="${RELEASE:-buzz}"
IMAGE_REPO="${IMAGE_REPO:-buzz-relay}"
IMAGE_TAG="${IMAGE_TAG:-mesh-local}"
CHART="${REPO_ROOT}/deploy/charts/buzz"
VALUES="${REPO_ROOT}/deploy/local/quickstart-ha-values.yaml"
CA_PEM="${REPO_ROOT}/deploy/local/proxy-ca.pem"
EXPECT_CTX="docker-desktop"
REPLICAS=3
EVID="${EVID:-/tmp/mesh-build/deploy-evidence-$(date +%Y%m%d-%H%M%S)}"
mkdir -p "$EVID"
log() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; }
die() { printf '\033[1;31mFAIL: %s\033[0m\n' "$*" >&2; exit 1; }
# ── 0. guardrails ─────────────────────────────────────────────────────────────
CTX="$(kubectl config current-context)"
[ "$CTX" = "$EXPECT_CTX" ] || die "kube context is '$CTX', expected '$EXPECT_CTX' (refusing to touch a non-local cluster)"
log "context: $CTX"; kubectl get nodes | tee "$EVID/nodes.txt"
# ── 1. corporate-proxy CA + npm mirror (TLS-intercepting gateway) ────────────
# Two stacked blocks on Block's network: (a) the gateway re-signs TLS with
# internal CAs the build container doesn't trust; (b) public registry.npmjs.org
# is policy-blocked (Dependency Confusion mitigation), so npm/corepack must use
# the Artifactory mirror. Both no-op on a normal network (build-args stay unset).
CA_ARG=()
REG_ARG=()
# (a) Build a complete internal-CA bundle from the macOS System keychain.
if [ ! -f "$CA_PEM" ] && command -v security >/dev/null 2>&1; then
log "exporting Block internal CA bundle from System keychain"
: > "$CA_PEM"
for name in "Cloudflare Gateway CA" \
"Service To Service AWS Native CA production G0" \
"Corp Systems AWS Native CA production G0" \
"Block, Inc CA G1" \
"Square Primary Certificate Authority - G2"; do
security find-certificate -a -c "$name" -p /Library/Keychains/System.keychain >> "$CA_PEM" 2>/dev/null || true
done
fi
if [ -f "$CA_PEM" ] && grep -q 'BEGIN CERTIFICATE' "$CA_PEM"; then
CA_ARG=(--build-arg "EXTRA_CA_CERTS=deploy/local/proxy-ca.pem")
log "using proxy CA bundle ($(grep -c 'BEGIN CERTIFICATE' "$CA_PEM") certs)"
fi
# (b) Use the host's configured npm registry (Artifactory) if it isn't public.
HOST_NPM_REG="$(pnpm config get registry 2>/dev/null || echo '')"
if [ -n "$HOST_NPM_REG" ] && ! echo "$HOST_NPM_REG" | grep -q 'registry.npmjs.org'; then
REG_ARG=(--build-arg "NPM_REGISTRY=${HOST_NPM_REG}")
log "using npm registry mirror: $HOST_NPM_REG"
fi
# ── 2. build image ────────────────────────────────────────────────────────────
if [ "${SKIP_BUILD:-0}" != "1" ]; then
log "building ${IMAGE_REPO}:${IMAGE_TAG} (HEAD $(git -C "$REPO_ROOT" rev-parse --short HEAD))"
git -C "$REPO_ROOT" rev-parse HEAD > "$EVID/build-sha.txt"
docker build "${CA_ARG[@]}" "${REG_ARG[@]}" \
-t "${IMAGE_REPO}:${IMAGE_TAG}" \
-f "${REPO_ROOT}/Dockerfile" "${REPO_ROOT}" 2>&1 | tee "$EVID/build.log"
else
log "SKIP_BUILD=1 — reusing ${IMAGE_REPO}:${IMAGE_TAG}"
fi
docker image inspect "${IMAGE_REPO}:${IMAGE_TAG}" --format '{{.Id}} {{.Size}}' | tee "$EVID/image-id.txt"
# ── 3. chart deps + install ───────────────────────────────────────────────────
log "helm dependency build"
helm dependency build "$CHART" 2>&1 | tee "$EVID/helm-dep.txt"
log "helm upgrade --install $RELEASE (ns=$NS, replicas=$REPLICAS)"
# No --wait here: the relay's A3 S3 probe is startup-fatal, so relays
# CrashLoopBackOff a few times until the concurrent init Job creates the bucket.
# helm --wait races that transient and can bail early; instead we own readiness
# gating below (rollout status + per-pod probe), which tolerates the restarts.
helm upgrade --install "$RELEASE" "$CHART" \
--namespace "$NS" --create-namespace \
--values "$VALUES" \
--set image.repository="$IMAGE_REPO" \
--set image.tag="$IMAGE_TAG" \
--timeout 5m 2>&1 | tee "$EVID/helm-install.txt"
helm_rc=${PIPESTATUS[0]}
if [ "$helm_rc" != 0 ]; then
kubectl -n "$NS" get pods -o wide | tee "$EVID/pods-onfail.txt"
kubectl -n "$NS" describe pods -l app.kubernetes.io/name=buzz | tee "$EVID/describe-onfail.txt"
kubectl -n "$NS" logs -l app.kubernetes.io/name=buzz --tail=100 --all-containers | tee "$EVID/logs-onfail.txt"
die "helm install returned rc=$helm_rc"
fi
# ── 4. verify 3/3 Ready ───────────────────────────────────────────────────────
# Find the relay Deployment: everything under this release named "buzz" except
# the bundled "*-minio" Deployment. (The chart fullname collapses
# "<release>-<chart>" to "<release>" when the release name already contains the
# chart name, so the name isn't always "<release>-buzz".)
DEPLOY=""
for d in $(kubectl -n "$NS" get deploy -l "app.kubernetes.io/instance=$RELEASE" \
-o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}'); do
case "$d" in *-minio) continue;; esac
DEPLOY="$d"; break
done
[ -n "$DEPLOY" ] || die "could not locate the relay Deployment"
log "waiting for $REPLICAS relay pods Ready (deployment: $DEPLOY)"
kubectl -n "$NS" rollout status deployment/"$DEPLOY" --timeout=4m | tee "$EVID/rollout.txt"
kubectl -n "$NS" get pods -o wide | tee "$EVID/pods.txt"
READY=$(kubectl -n "$NS" get deploy "$DEPLOY" -o jsonpath='{.status.readyReplicas}')
[ "${READY:-0}" = "$REPLICAS" ] || die "readyReplicas=$READY, expected $REPLICAS"
log "deployment reports $READY/$REPLICAS Ready"
# ── 5. probe /_readiness on EVERY relay pod (not just the deployment aggregate)
# The bundled MinIO + init pods share app.kubernetes.io/name=buzz, so select by
# the relay Deployment's own pod-template hash to hit only relay pods.
log "probing /_readiness on each relay pod individually"
: > "$EVID/readiness.txt"
FAIL=0
RELAY_PODS=$(kubectl -n "$NS" get pods \
-l "app.kubernetes.io/name=buzz,app.kubernetes.io/instance=$RELEASE" \
-o jsonpath='{range .items[*]}{.metadata.name}{" "}{.metadata.labels.app\.kubernetes\.io/component}{"\n"}{end}' \
| awk '$2 != "minio" && $2 != "minio-init" {print $1}')
for pod in $RELAY_PODS; do
body=$(kubectl -n "$NS" exec "$pod" -- \
sh -c 'curl -sS --max-time 5 http://127.0.0.1:8080/_readiness' 2>/dev/null || echo '<curl-failed>')
echo "$pod -> $body" | tee -a "$EVID/readiness.txt"
echo "$body" | grep -q '"status":"ready"' || FAIL=1
done
[ "$FAIL" = 0 ] || die "at least one pod is not reporting ready (see $EVID/readiness.txt)"
log "ALL $REPLICAS PODS READY — baseline up. Evidence: $EVID"
echo "namespace=$NS release=$RELEASE image=${IMAGE_REPO}:${IMAGE_TAG}" | tee "$EVID/SUMMARY.txt"
+44
View File
@@ -0,0 +1,44 @@
# Local docker-desktop HA testbed: quickstart bundled services + 3 replicas.
#
# This is the baseline every mesh build redeploys onto (mesh lane, thread
# fa90bba8 / kickoff 5a760d8d). It layers the HA shape on top of the CI
# quickstart set (ci/quickstart-values.yaml): bundled Postgres/Redis/MinIO,
# chart-autogenerated secrets, membership enforcement off.
#
# Two lines carry HA:
# replicaCount: 3 — chart hard-requires Redis for buzz-pubsub
# (quickstart provides it); the git-hook HMAC
# secret autogenerates chart-side (secret-chart.yaml).
# persistence.git.enabled — false → per-pod emptyDir. The default (true) is a
# single ReadWriteOnce PVC that cannot multi-attach
# across 3 pods on one node; it would wedge 2/3.
# Object store + Postgres are the source of truth
# for git state, not local disk (values.yaml docs
# emptyDir as the correct multi-replica choice).
quickstart: true
postgresql:
enabled: true
redis:
enabled: true
minio:
enabled: true
relayUrl: wss://buzz.test.local
ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000001"
relay:
# Testbed, not business-logic validation — same rationale as CI quickstart.
requireRelayMembership: false
podDisruptionBudget:
enabled: false
# ── HA shape ──────────────────────────────────────────────────────────────
replicaCount: 3
persistence:
git:
enabled: false
# Locally-built image; docker-desktop k8s shares the docker image store, so
# IfNotPresent resolves the tag with no push / `kind load` step.
image:
repository: buzz-relay
tag: mesh-local
pullPolicy: IfNotPresent