Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions charts/streaming-hub/.helmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Patterns to ignore when building packages.
.DS_Store
# Common VCS dirs
.git/
.gitignore
.bzr/
.bzrignore
.hg/
.hgignore
.svn/
# Common backup files
*.swp
*.bak
*.tmp
*.orig
*~
# Various IDEs
.project
.idea/
*.tmproj
.vscode/
# Chart docs are not part of the packaged artifact.
docs/
README.md
36 changes: 36 additions & 0 deletions charts/streaming-hub/Chart.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
apiVersion: v2
name: streaming-hub-helm
description: >-
Lerian streaming-hub — consumes lib-streaming CloudEvents from Kafka/Redpanda
and fans them out to per-tenant SaaS/BYOC subscribers. Multi-component chart
with a runtime STREAMING_HUB_ROLE model (mode: all | split).
type: application
# version is a PLACEHOLDER. semantic-release in the `helm` repo owns the real
# chart version on merge — do NOT hand-pick a final number here.

version: 1.0.0-beta.4
appVersion: "1.0.0"

annotations:
lerian.studio/chart-type: multi-component
home: https://github.com/LerianStudio/streaming-hub
sources:
- https://github.com/LerianStudio/helm/tree/main/charts/streaming-hub
- https://github.com/LerianStudio/streaming-hub
maintainers:
- name: "Lerian Studio"
email: "support@lerian.studio"
keywords:
- streaming
- cloudevents
- kafka
- redpanda
- fan-out
- lerian
icon: https://avatars.githubusercontent.com/u/148895005?s=200&v=4

# NO dependencies block by deliberate design (deviation from reporter/midaz):
# OTEL is env-wired via the OTEL_EXPORTER_OTLP_ENDPOINT downward-API override,
# not bundled as an otel-collector-lerian subchart. Declaring it would force an
# OCI pull on every `helm lint` / `helm template`. Kafka/Redpanda and Postgres
# are shared external infra, never subcharts here.
220 changes: 220 additions & 0 deletions charts/streaming-hub/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
# streaming-hub Helm chart

Deploys **streaming-hub** — the Lerian service that consumes
[`lib-streaming`](https://github.com/LerianStudio) CloudEvents from
Kafka/Redpanda and fans them out to per-tenant SaaS/BYOC subscribers.

This is a **multi-component** chart built on one image and one binary, selected
at runtime into a role via `STREAMING_HUB_ROLE`. There are **no** database,
broker, or OTEL subcharts — Kafka/Redpanda and PostgreSQL are shared external
infra, and OTEL is env-wired (see [External dependencies](#external-dependencies)).

---

## Chart Contract

- Chart type: `multi-component`
- Required secrets: `secrets.STREAMING_HUB_POSTGRES_DSN` (the DSN carries the DB password); `secrets.STREAMING_HUB_KAFKA_SCRAM_USERNAME` + `secrets.STREAMING_HUB_KAFKA_SCRAM_PASSWORD` (when the broker requires SASL/SCRAM); `secrets.STREAMING_HUB_KEK_REF` (KEK reference for the F10 envelope crypto — `secrets.STREAMING_HUB_DEV_KEK` for dev only). SaaS-only, required when `STREAMING_HUB_MULTI_TENANT_ENABLED=true`: `secrets.STREAMING_HUB_TENANT_MANAGER_SERVICE_API_KEY` and `secrets.STREAMING_HUB_MULTI_TENANT_REDIS_PASSWORD`. All blank by default — provide inline or set `streamingHub.useExistingSecret` + `streamingHub.existingSecretName` (the path GitOps uses, with a Vault-injected Secret).
- Dependency notes: **No bundled subcharts.** Kafka/Redpanda and PostgreSQL are shared external infra — supply `common.configmap.STREAMING_HUB_KAFKA_BROKERS` and `secrets.STREAMING_HUB_POSTGRES_DSN`. OTEL is env-wired to a node-local collector (`HOST_IP:4317`, gated on `streamingHub.telemetry.enabled`) — there is no OTEL collector subchart. The optional `bootstrap-postgres` Job provisions the hub's single database when `global.externalPostgresDefinitions.enabled=true`.
- Migrations: the hub applies its schema **out of band** (the app never migrates itself). Enable the migration Job with `streamingHub.migrations.enabled=true` (default off). It is a PreSync hook that runs the stock `migrate/migrate` toolchain (image `ghcr.io/lerianstudio/streaming-hub-migrations`) BEFORE the app rolls out. The hook chain is **`bootstrap-postgres` (sync-wave -10, role+db) → `migration-secret` (-5, the DSN) → `migrations` Job (-1, the schema) → app Deployment (main Sync)**. The Job's only env is `STREAMING_HUB_POSTGRES_DSN` (from the migration-secret, or `migrations.existingSecretName` when `migrations.useExistingSecret=true`). Without it, ingest/dispatcher/partition workers crash on `relation "event_inbox"/"delivery_jobs" does not exist (42P01)`. **When `migrations.useExistingSecret=true`, the named secret must already exist in the namespace before the PreSync phase runs** — do not point it at the chart-managed application Secret (`streamingHub.existingSecretName` / inline `secrets.*`), which is created later in the main Sync phase and is therefore absent when the hook fires; the chart provisions its own PreSync `migration-secret` precisely to close that ordering gap.
- Production overrides: choose `streamingHub.mode` (`all` vs `split`); size per-role `replicaCount` / `autoscaling` / `resources` and the Postgres pool (`poolMaxOpenConns` / `poolMaxIdleConns`) honoring **Σ(replicas × poolMaxOpenConns) ≤ Postgres `max_connections`**; set `image.tag`, `ingress`, and the secrets (inline or `useExistingSecret`).
- Source/license: Source is in `github.com/LerianStudio/helm`; license is Apache-2.0.

---

## Installing the Chart

```bash
helm install streaming-hub oci://registry-1.docker.io/lerianstudio/streaming-hub-helm --version <version> -n streaming-hub --create-namespace
```

With a custom values file:

```bash
helm install streaming-hub oci://registry-1.docker.io/lerianstudio/streaming-hub-helm --version <version> -n streaming-hub -f my-values.yaml
```

The chart is mirrored to `oci://ghcr.io/lerianstudio/streaming-hub-helm` as well.

## Uninstalling the Chart

```bash
helm uninstall streaming-hub -n streaming-hub
```

---

## The `mode` switch (read this first)

`streamingHub.mode` decides the topology. It is an **either/or**:

| `mode` | Renders | `STREAMING_HUB_ROLE` | When |
|---------|---------|----------------------|------|
| `all` (default) | ONE Deployment + Service (+ HPA/PDB) | `all` | Single co-resident deployment. The dev-st target. Byte-equivalent to the historical single binary. |
| `split` | TWO Deployments + Services (ingest + delivery), each with its own HPA/PDB | `ingest` / `delivery` | Ingest and delivery scaled independently as N + M replicas. |

### > **DOUBLE-CONSUME HAZARD — the load-bearing rule**

> **NEVER run a `mode: all` release AND a `mode: split` release against the same
> Kafka/Redpanda cluster.** All three roles (`all`, `ingest`) join the **one**
> ingest consumer group. An `all` pod and an `ingest` pod consuming together
> means **every event is double-consumed and double-delivered.** The `mode`
> switch enforces either/or within a single release — do not defeat it by
> deploying two releases that overlap.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

### Worked example — `mode: all` (dev-st)

```yaml
streamingHub:
mode: all
all:
replicaCount: 1
poolMaxOpenConns: 25 # role=all holds both planes' connections
poolMaxIdleConns: 12
```

Renders: `streaming-hub-all` Deployment + `streaming-hub-all` Service, plus the
shared `streaming-hub` ConfigMap + Secret + ServiceAccount. One consumer-group
member set — no double-consume possible.

### Worked example — `mode: split` (independent scaling)

```yaml
streamingHub:
mode: split
ingest:
replicaCount: 3
poolMaxOpenConns: 8 # consume-poll + inbox tx + partition-cron
poolMaxIdleConns: 4
autoscaling: { enabled: true, minReplicas: 3, maxReplicas: 4 }
delivery:
replicaCount: 2
poolMaxOpenConns: 16 # 8 dispatch workers + reclaim + dlq + delivery /readyz
poolMaxIdleConns: 10
autoscaling: { enabled: true, minReplicas: 2, maxReplicas: 4 }
```

Renders `streaming-hub-ingest` and `streaming-hub-delivery` Deployments +
Services + HPAs, sharing the one ConfigMap + Secret. Each Deployment gets its
own `STREAMING_HUB_ROLE` and pool sizing as **explicit per-Deployment env**
(which wins over the shared `envFrom`).

---

## Connection-budget invariant

streaming-hub owns **ONE** shared PostgreSQL database. Every open connection on
every pod of every role draws from that single `max_connections` budget.

> **Σ over all running pods of `(replicas × poolMaxOpenConns)` + headroom ≤ PostgreSQL `max_connections`.**
> Under HPA, use `maxReplicas` (not `replicaCount`) in the sum.

Worked example (`max_connections = 100`, `mode: split`):

| Role | replicas | poolMaxOpenConns | connections |
|------|----------|------------------|-------------|
| ingest | 3 | 8 | 24 |
| delivery | 4 | 16 | 64 |
| **total** | | | **88** (leaves 12 for admin/migrations) ✅ |

Adding an `all` pod (25) → `88 + 25 = 113 > 100` ❌. The `mode` either/or already
forbids that combination; the budget math is why it also matters operationally.
See `streaming-hub/.env.reference` (`STREAMING_HUB_POSTGRES_MAX_OPEN_CONNS`) for
the per-role rationale (all 25/12, ingest 8/4, delivery 16/10).

---

## Health, drain, and termination

- **Probes** (verified against the hub source / Dockerfile, on the `http` port `8080`):
- `livenessProbe`: `GET /healthz` — stays `200` throughout drain.
- `readinessProbe`: `GET /readyz` — flips `NotReady` (503) **first** on SIGTERM so the endpoints controller drains the pod from the Service.
- **No `preStop` hook.** The hub **self-drains on SIGTERM**: the exec-form
ENTRYPOINT delivers SIGTERM straight to PID 1, and the lib-commons Launcher
runs the HTTP → consumer → dispatcher teardown.
- **`terminationGracePeriodSeconds` defaults to `80` — the hub's derived SIGTERM
drain ceiling — and MUST stay at or above it.** At the shipped
`STREAMING_HUB_SHUTDOWN_TIMEOUT=30s` + `STREAMING_HUB_PRE_STOP_DRAIN_TIMEOUT=5s`
the ceiling is `30s` (HTTP) + `5s` (consumer final-commit) + `30s` (dispatcher,
`min(ShutdownTimeout, 55s)`) + `10s` slack = `75s`, plus the `5s` pre-stop wait
= **`80s`**. If you tune those knobs up, recompute and raise this knob to match,
or the orchestrator SIGKILLs a still-draining replica (risking a duplicate
delivery). See `.env.reference`.

---

## Secrets sourcing

Two mutually-exclusive paths:

| `useExistingSecret` | Behavior |
|---------------------|----------|
| `false` (default) | The chart renders `templates/secret.yaml` from `streamingHub.secrets` (base64-encoded; **empty values are skipped**, so unset SaaS/dev keys never ship blank). |
| `true` | **No** Secret is rendered. Deployments reference `existingSecretName`. This is the **gitops/Vault path** (an external secret is projected into the named Secret) and is the production default. |
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

Sensitive keys (all in `streamingHub.secrets`, all default `""`):
`STREAMING_HUB_POSTGRES_DSN`, `STREAMING_HUB_KAFKA_SCRAM_USERNAME`,
`STREAMING_HUB_KAFKA_SCRAM_PASSWORD`, `STREAMING_HUB_KEK_REF`,
`STREAMING_HUB_DEV_KEK` (dev only), `STREAMING_HUB_TENANT_MANAGER_SERVICE_API_KEY`,
`STREAMING_HUB_MULTI_TENANT_REDIS_PASSWORD`.

---

## External dependencies

This chart provisions **none** of the following — they live outside it:

- **Kafka / Redpanda** — the CloudEvents bus the hub consumes. Point
`STREAMING_HUB_KAFKA_BROKERS` at the cluster; supply SASL/SCRAM creds via the
Secret when `STREAMING_HUB_KAFKA_SCRAM_MECHANISM` is set.
- **PostgreSQL** — the single hub-owned database (`tenant_id` is a column, not a
per-tenant DB). Either point `STREAMING_HUB_POSTGRES_DSN` at a pre-provisioned
managed host, **or** enable `global.externalPostgresDefinitions.enabled` to run
the bootstrap Job that creates the hub's one DB + role on a shared host.
- **OTEL collector** — **not** a subchart (deliberate; declaring it would force
an OCI pull on `helm lint`/`template`). When `streamingHub.telemetry.enabled=true`
(a chart-level toggle, not an app env var), the Deployment injects `HOST_IP` via
the downward API and sets `OTEL_EXPORTER_OTLP_ENDPOINT=$(HOST_IP):4317`
(node-local DaemonSet collector).

Comment thread
coderabbitai[bot] marked this conversation as resolved.
---

## Top-level values

| Key | Default | Description |
|-----|---------|-------------|
| `streamingHub.mode` | `all` | Topology switch: `all` \| `split`. Schema-enforced enum. |
| `streamingHub.image.repository` | `ghcr.io/lerianstudio/streaming-hub` | Image repo. |
| `streamingHub.image.tag` | `""` | Empty falls back to `Chart.appVersion`. |
| `streamingHub.image.pullPolicy` | `IfNotPresent` | |
| `streamingHub.imagePullSecrets` | `[{name: ghcr-credential}]` | Private registry pull secrets. |
| `streamingHub.service.type` | `ClusterIP` | Lerian convention (Ingress fronts external). |
| `streamingHub.service.port` | `8080` | Control-plane HTTP port. |
| `streamingHub.ingress.enabled` | `false` | Control-plane API ingress (opt-in per env). |
| `streamingHub.serviceAccount.create` | `true` | |
| `streamingHub.terminationGracePeriodSeconds` | `80` | Defaults to the ~80s drain ceiling; keep at or above it. |
| `streamingHub.securityContext` | nonroot 65532, drop ALL, RO rootfs, RuntimeDefault | Distroless:nonroot. |
| `streamingHub.useExistingSecret` | `false` | `true` = Vault/gitops path. |
| `streamingHub.existingSecretName` | `""` | Required when `useExistingSecret`. |
| `streamingHub.common.configmap` | (documented set) | Shared non-sensitive env. Every key is verbatim in `.env.reference`. |
| `streamingHub.secrets` | (all `""`) | Shared sensitive env. Empty values skipped. |
| `streamingHub.<role>.replicaCount` | `1` | Per role: `all` / `ingest` / `delivery`. |
| `streamingHub.<role>.poolMaxOpenConns` | all `25` / ingest `8` / delivery `16` | Postgres pool (connection-budget invariant). |
| `streamingHub.<role>.poolMaxIdleConns` | all `12` / ingest `4` / delivery `10` | |
| `streamingHub.<role>.autoscaling.enabled` | `false` | HPA per role (`autoscaling/v2`, CPU+memory). |
| `streamingHub.<role>.pdb.enabled` | `false` | PodDisruptionBudget per role (`policy/v1`). |
| `global.externalPostgresDefinitions.enabled` | `false` | Bootstrap Job for the hub's one DB/role (PreSync, sync-wave -10). |
| `streamingHub.migrations.enabled` | `false` | Out-of-band schema migration Job (PreSync, sync-wave -1). |
| `streamingHub.migrations.image.repository` | `ghcr.io/lerianstudio/streaming-hub-migrations` | Migrations image (`FROM migrate/migrate` + the hub's `migrations/`). |
| `streamingHub.migrations.image.tag` | `""` | Empty falls back to `streamingHub.image.tag`, then `Chart.appVersion`. |
| `streamingHub.migrations.image.digest` | `""` | Pin by digest; wins over tag. |
| `streamingHub.migrations.useExistingSecret` | `false` | `true` = Job reads `STREAMING_HUB_POSTGRES_DSN` from `existingSecretName`. |
| `streamingHub.migrations.existingSecretName` | `""` | Required when `migrations.useExistingSecret`. Must already exist before the PreSync phase — not the main-Sync app Secret. |
| `streamingHub.migrations.backoffLimit` | `3` | Job retry cap. |
| `streamingHub.migrations.activeDeadlineSeconds` | `600` | Job wall-clock cap. |
| `streamingHub.migrations.ttlSecondsAfterFinished` | `600` | Finished-Job GC TTL. |

For the full env contract (defaults, required-in-SaaS markers, the F4 tenant
caution, KEK source vars), see `streaming-hub/.env.reference`.

See [`docs/TOPOLOGY.md`](docs/TOPOLOGY.md) for the role model in depth.
55 changes: 55 additions & 0 deletions charts/streaming-hub/docs/TOPOLOGY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# streaming-hub deployment topology

streaming-hub ships as **one image, one binary**, selected into a role at
**runtime** via `STREAMING_HUB_ROLE` ∈ `{all, ingest, delivery}`. There is no
build-time split — no `cmd/ingest`/`cmd/delivery`, no `ARG ROLE`. This chart
exposes that runtime choice through the single `streamingHub.mode` switch.

## The role model

Every role serves the **full** Fiber control plane on `:8080`
(`/healthz`, `/readyz`, `/metrics`, `/v1`, `/admin`). The role gates **which
background Launcher Apps register** and **which Kafka clients dial** — not which
HTTP routes mount.

| Role | Background apps | Kafka clients | Postgres pool (open/idle) |
|------|-----------------|---------------|---------------------------|
| `all` | every app (ingest + delivery co-resident) | all three | 25 / 12 |
| `ingest` | consumer + manifest-refresh + partition-cron + idempotency-reaper | ingest consumer only | 8 / 4 |
| `delivery` | dispatcher + dlq-consumer + dlq-prune + topic-reconciler | DLQ + reconciler-admin | 16 / 10 |

## How the chart maps mode → workloads

- **`mode: all`** → `templates/all/*` render: one `streaming-hub-all` Deployment
with `STREAMING_HUB_ROLE=all`, plus its Service (and HPA/PDB when enabled).
- **`mode: split`** → `templates/ingest/*` and `templates/delivery/*` render:
two Deployments (`streaming-hub-ingest`, `streaming-hub-delivery`) with the
matching role env, plus their Services (and per-role HPA/PDB).

The shared singletons (`templates/configmap.yaml`, `templates/secret.yaml`,
`templates/serviceaccount.yaml`) are role-invariant and rendered exactly once
regardless of mode. The Deployment/Service/HPA/PDB bodies are a single
parameterized partial (`templates/_deployment.tpl`) invoked by thin
mode-gated wrappers — no per-role body duplication.

### Why the role-specific vars are explicit `env`, not in the ConfigMap

`STREAMING_HUB_ROLE` and the Postgres pool sizes differ per role. Kubernetes env
precedence is **`env` > `envFrom`**, so the chart injects those three vars as
explicit per-Deployment `env:` while everything role-invariant stays in the one
shared ConfigMap consumed via `envFrom`. A role's pool size therefore cleanly
overrides any shared default, and the ConfigMap never has to fork per role.

## Independent scaling and the consumer group

In `mode: split`, ingest and delivery scale independently as **N + M** replicas
in one ingest consumer group; delivery's DLQ consumer is a structurally-disjoint
second group. Background singleton crons stay singleton across replicas via the
hub's `internal/shared/dblock` advisory-lock registry — the chart does not need
to pin them to a single replica.

> **Never overlap `mode: all` with `mode: split` on the same Kafka cluster.**
> `all` and `ingest` join the same consumer group → double-consume. See the
> README's double-consume hazard callout.

For the full architecture, see `streaming-hub/docs/architecture.md`.
Loading
Loading