diff --git a/.github/configs/helm-render-values/streaming-hub.yaml b/.github/configs/helm-render-values/streaming-hub.yaml new file mode 100644 index 000000000..6638411f4 --- /dev/null +++ b/.github/configs/helm-render-values/streaming-hub.yaml @@ -0,0 +1,45 @@ +# Render fixture for streaming-hub — exercises the dependency paths (Kafka mask, +# auth, secrets, bootstrap Job, migrations) with TYPE-correct values so byte-identity +# and coverage checks never render an accidentally-empty block. Used by the +# productize-chart-env render-gate + coverage.py. +global: + auth: + enabled: "true" + address: "http://plugin-access-manager-auth:4000" + datastores: + kafka: + brokers: "redpanda:9092" + saslMechanism: "SCRAM-SHA-512" + saslUsername: "streaming-hub" + tls: "true" + externalPostgresDefinitions: + enabled: true +streamingHub: + datastores: + kafka: + caCert: "" + configmap: {} + extraEnvVars: {} + telemetry: + enabled: true + useExistingSecret: false + secrets: + STREAMING_HUB_POSTGRES_DSN: "postgres://hub:hub@localhost:5432/hub?sslmode=disable" + STREAMING_HUB_KAFKA_SCRAM_PASSWORD: "changeme" + STREAMING_HUB_DEV_KEK: "DEV-ONLY-FAKE-KEK-NOT-A-REAL-KEY" + STREAMING_HUB_TENANT_MANAGER_SERVICE_API_KEY: "changeme" + STREAMING_HUB_MULTI_TENANT_REDIS_PASSWORD: "changeme" + migrations: + enabled: true + ingress: + enabled: true + hosts: + - host: "streaming-hub.lerian.net" + paths: + - path: / + pathType: Prefix + all: + autoscaling: + enabled: false + tolerations: [] + nodeSelector: {} diff --git a/charts/lerian-common/templates/_ingress.tpl b/charts/lerian-common/templates/_ingress.tpl index 75c13265f..d4f97afed 100644 --- a/charts/lerian-common/templates/_ingress.tpl +++ b/charts/lerian-common/templates/_ingress.tpl @@ -38,9 +38,13 @@ Usage (chart ingress.yaml): Inputs (dict): context (req) root context ($) — for .Capabilities.KubeVersion ingress (req) the component's `.ingress` map (className/annotations/tls/hosts) - name (req) metadata.name + backend service name (already rendered) + name (req) metadata.name (+ backend service name unless backendName is set; already rendered) labels (req) labels block already rendered (no leading indent) svcPort (req) backend service port + backendName (opt) backend Service name when it differs from metadata.name + (defaults to `name`); already rendered. Lets a chart whose + ingress routes to a differently-named Service (e.g. a role/ + component Service, not the chart's own) still use this template. global (opt) `.Values.global.ingress` — shared contract defaults subdomain (opt) host prefix used with global.ingress.domain to derive the host namespace (opt) metadata.namespace (already rendered); omitted when empty @@ -51,6 +55,7 @@ Inputs (dict): {{- $ing := .ingress -}} {{- $g := .global | default dict -}} {{- $name := .name -}} +{{- $backendName := .backendName | default .name -}} {{- $svcPort := .svcPort -}} {{- /* Presence-based (hasKey) so an explicit component className "" (deliberately no class) is honored instead of falling back to the global class. */ -}} @@ -114,11 +119,11 @@ spec: backend: {{- if semverCompare ">=1.19-0" $ctx.Capabilities.KubeVersion.GitVersion }} service: - name: {{ $name }} + name: {{ $backendName }} port: number: {{ $svcPort }} {{- else }} - serviceName: {{ $name }} + serviceName: {{ $backendName }} servicePort: {{ $svcPort }} {{- end }} {{- end }} diff --git a/charts/streaming-hub/.helmignore b/charts/streaming-hub/.helmignore new file mode 100644 index 000000000..8e2583494 --- /dev/null +++ b/charts/streaming-hub/.helmignore @@ -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 diff --git a/charts/streaming-hub/Chart.lock b/charts/streaming-hub/Chart.lock new file mode 100644 index 000000000..fcf430ae5 --- /dev/null +++ b/charts/streaming-hub/Chart.lock @@ -0,0 +1,6 @@ +dependencies: +- name: lerian-common-helm + repository: file://../lerian-common + version: 1.4.0 +digest: sha256:55cbfcfef14768a47be9e794cb86f07cecdd320cb5ce40b363527d7c465f97e6 +generated: "2026-08-04T16:17:01.689166-03:00" diff --git a/charts/streaming-hub/Chart.yaml b/charts/streaming-hub/Chart.yaml new file mode 100644 index 000000000..0d9ce5938 --- /dev/null +++ b/charts/streaming-hub/Chart.yaml @@ -0,0 +1,41 @@ +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 + +# lerian-common is the ONLY dependency: a local library chart (no OCI pull, no +# runtime objects) providing the cfgValue/globalValue config helpers the +# ConfigMap is productized onto. Deliberately NO otel-collector / Kafka / Postgres +# subcharts: OTEL is env-wired via the OTEL_EXPORTER_OTLP_ENDPOINT downward-API +# override, and Kafka/Redpanda + Postgres are shared external infra — declaring +# any of them would force an OCI pull on every `helm lint` / `helm template`. +dependencies: + - name: lerian-common-helm + version: "1.4.0" + repository: "file://../lerian-common" diff --git a/charts/streaming-hub/README.md b/charts/streaming-hub/README.md new file mode 100644 index 000000000..e4e8f3971 --- /dev/null +++ b/charts/streaming-hub/README.md @@ -0,0 +1,224 @@ +# 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 the Kafka connection via `streamingHub.datastores.kafka.brokers` (or `global.datastores.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 -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 -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.** Both the `all` and `ingest` roles 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. + +### 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/secrets.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. | + +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). +- **Image pull secret** — `streamingHub.imagePullSecrets` defaults to a secret + named `ghcr-credential`. The chart does **not** create it; it must already + exist in the release namespace or pods fail with `ImagePullBackOff`. Override + or clear `streamingHub.imagePullSecrets` when pulling from a public/mirror registry. + +--- + +## 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.configmap` | (documented set) | Shared non-sensitive env. Every key is verbatim in `.env.reference`. | +| `streamingHub.secrets` | (all `""`) | Shared sensitive env. Empty values skipped. | +| `streamingHub..replicaCount` | `1` | Per role: `all` / `ingest` / `delivery`. | +| `streamingHub..poolMaxOpenConns` | all `25` / ingest `8` / delivery `16` | Postgres pool (connection-budget invariant). | +| `streamingHub..poolMaxIdleConns` | all `12` / ingest `4` / delivery `10` | | +| `streamingHub..autoscaling.enabled` | `false` | HPA per role (`autoscaling/v2`, CPU+memory). | +| `streamingHub..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. diff --git a/charts/streaming-hub/README.params.md b/charts/streaming-hub/README.params.md new file mode 100644 index 000000000..b5ecad448 --- /dev/null +++ b/charts/streaming-hub/README.params.md @@ -0,0 +1,77 @@ +# Parameters + +## Parameters + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `nameOverride` | string | `""` | Override the chart name component of resource names. | +| `fullnameOverride` | string | `""` | Override the fully-qualified release name (wins verbatim). | +| `namespaceOverride` | string | `""` | Override the namespace (defaults to .Release.Namespace). | +| `global.externalPostgresDefinitions` | string | `{}` | Bootstrap Job for an external/shared PostgreSQL: creates the hub's ONE database + role and grants privileges. The hub owns a SINGLE database with a tenant_id column (NOT per-tenant DB). Default OFF — dev-st may instead point STREAMING_HUB_POSTGRES_DSN at a pre-provisioned managed host. | +| `global.externalPostgresDefinitions.enabled` | bool | `false` | Enable or disable the PostgreSQL bootstrap Job. | +| `global.externalPostgresDefinitions.database` | string | `"streaming-hub"` | Name of the database the Job creates (must match the DSN dbname). | +| `global.externalPostgresDefinitions.role` | string | `"streaming-hub"` | Name of the login role the Job creates (must match the DSN user). | +| `global.externalPostgresDefinitions.connection` | string | `{}` | PostgreSQL connection settings for the bootstrap Job. | +| `global.externalPostgresDefinitions.connection.host` | string | `"streaming-hub-postgresql"` | PostgreSQL host. | +| `global.externalPostgresDefinitions.connection.port` | string | `"5432"` | PostgreSQL port. | +| `global.externalPostgresDefinitions.postgresAdminLogin` | string | `{}` | Admin credentials used by the Job to create the DB/role. | +| `global.externalPostgresDefinitions.postgresAdminLogin.useExistingSecret.name` | string | `""` | Existing secret with DB_USER_ADMIN and DB_ADMIN_PASSWORD keys. | +| `global.externalPostgresDefinitions.postgresAdminLogin.username` | string | `"postgres"` | Admin username (ignored if useExistingSecret.name is set). | +| `global.externalPostgresDefinitions.postgresAdminLogin.password` | string | `""` | Admin password (ignored if useExistingSecret.name is set). | +| `global.externalPostgresDefinitions.hubCredentials` | string | `{}` | Credentials for the hub role created by the Job. | +| `global.externalPostgresDefinitions.hubCredentials.useExistingSecret.name` | string | `""` | Existing secret with DB_PASSWORD_HUB key. | +| `global.externalPostgresDefinitions.hubCredentials.password` | string | `""` | Password for the hub role (ignored if useExistingSecret.name is set). | +| `global.auth` | object | `{}` | Env-wide inbound auth (lib-auth / plugin-auth), consumed by lerian-common.globalValue. Declare once at the umbrella level; a component streamingHub.configmap.PLUGIN_AUTH_* still overrides per-service. Precedence: configmap. > global.auth. > chart default. Leave empty ({}) to keep the chart defaults. | +| `global.datastores` | object | `{}` | Env-wide SHARED datastore mask, consumed by lerian-common.datastore.value. Declare a dependency connection ONCE at the umbrella; a component streamingHub.datastores. (dedicated) or configmap. (native) still overrides it. Precedence per field: configmap. > streamingHub.datastores.. > global.datastores.. > chart default. Leave empty ({}) to keep the chart defaults. | +| `streamingHub.mode` | enum: all|split | `all` | Deployment topology switch. One of: all | split. all (default) -> ONE Deployment with STREAMING_HUB_ROLE=all (ingest + delivery co-resident; the dev-st target). Byte-equivalent to the historical single binary. split -> TWO Deployments: ingest (role=ingest) and delivery (role=delivery), each scaled independently. !!! NEVER run both an `all` Deployment AND ingest/delivery against the same Kafka cluster: they join ONE consumer group and DOUBLE-CONSUME every event. The mode switch enforces either/or — do not work around it. !!! The values.schema.json constrains this to the enum ["all","split"]. | +| `streamingHub.image.repository` | string | `ghcr.io/lerianstudio/streaming-hub` | Container image repository. | +| `streamingHub.image.pullPolicy` | string | `IfNotPresent` | Image pull policy. | +| `streamingHub.image.tag` | string | `""` | Image tag. Empty falls back to Chart.appVersion via the defaultTag helper. | +| `streamingHub.imagePullSecrets` | list | `[{name: ghcr-credential}]` | Secrets for pulling the image from a private registry. | +| `streamingHub.revisionHistoryLimit` | int | `10` | Number of old ReplicaSets to retain for rollback. | +| `streamingHub.annotations` | object | `{}` | Annotations applied to every Deployment resource. | +| `streamingHub.podAnnotations` | object | `{}` | Annotations applied to every pod. | +| `streamingHub.deploymentStrategy` | object | `{}` | Deployment update strategy (shared by all roles). | +| `streamingHub.podSecurityContext` | object | `{}` | Pod-level security context. Empty by default (the hub needs no fsGroup). | +| `streamingHub.securityContext` | object | `{}` | Container-level security context (distroless:nonroot, uid/gid 65532). | +| `streamingHub.securityContext.runAsGroup` | int | `65532` | Group ID for the process inside the container. | +| `streamingHub.securityContext.runAsUser` | int | `65532` | User ID for the process inside the container. | +| `streamingHub.securityContext.runAsNonRoot` | bool | `true` | Never run as root. | +| `streamingHub.securityContext.readOnlyRootFilesystem` | bool | `true` | Read-only root filesystem (the image carries no writable state). | +| `streamingHub.service.type` | string | `ClusterIP` | Service type. MUST be ClusterIP (Lerian convention; Ingress fronts external). | +| `streamingHub.service.port` | int | `8080` | Control-plane HTTP port (the hub listens on :8080; see Dockerfile EXPOSE). | +| `streamingHub.service.annotations` | object | `{}` | Annotations for every Service. | +| `streamingHub.ingress.enabled` | bool | `false` | Enable or disable the control-plane Ingress (opt-in per env in gitops). | +| `streamingHub.ingress.className` | string | `"nginx"` | Ingress class name. | +| `streamingHub.ingress.annotations` | object | `{}` | Additional ingress annotations. | +| `streamingHub.ingress.hosts` | list | `[]` | Hosts (default empty; the control API is served on every role). | +| `streamingHub.ingress.tls` | list | `[]` | TLS configuration. | +| `streamingHub.serviceAccount.create` | bool | `true` | Whether a ServiceAccount is created. | +| `streamingHub.serviceAccount.annotations` | object | `{}` | Annotations for the ServiceAccount (e.g. AWS IRSA role-arn). | +| `streamingHub.serviceAccount.name` | string | `""` | ServiceAccount name. Empty defaults to the chart fullname. | +| `streamingHub.serviceAccount.automountServiceAccountToken` | bool | `false` | Mount the SA API token into pods. Default false — the hub makes no in-cluster Kubernetes API calls. (IRSA's projected token is injected by the EKS webhook independently of this, so it stays functional.) | +| `streamingHub.terminationGracePeriodSeconds` | int | `80` | Graceful-shutdown window. Defaults to the hub's derived SIGTERM drain ceiling (80s) at STREAMING_HUB_SHUTDOWN_TIMEOUT=30s + STREAMING_HUB_PRE_STOP_DRAIN_TIMEOUT=5s: 30s HTTP + 5s consumer-commit + 30s dispatcher + 10s slack = 75s, + 5s pre-stop = 80s (see .env.reference). If you tune those knobs up, recompute and keep this AT OR ABOVE the new ceiling so the orchestrator never SIGKILLs a still-draining replica. NO preStop hook is used — the hub self-drains on SIGTERM (PID 1 receives it directly; exec-form ENTRYPOINT). | +| `streamingHub.livenessProbe` | object | `{}` | Liveness probe tuning (GET /healthz on the http port; stays 200 during drain). | +| `streamingHub.readinessProbe` | object | `{}` | Readiness probe tuning (GET /readyz; flips NotReady first on SIGTERM). | +| `streamingHub.nodeSelector` | object | `{}` | Shared default scheduling (per-role blocks may override). | +| `streamingHub.telemetry.enabled` | bool | `false` | Inject the per-pod OTLP endpoint override (HOST_IP downward API). | +| `streamingHub.datastores` | object | `{}` | DEDICATED datastore mask for this component's OWN dependency instance, consumed by lerian-common.datastore.value. Wins over global.datastores; native configmap. still wins over both. Leave empty ({}) to keep the chart defaults. | +| `streamingHub.configmap` | object | `{}` | Native per-key escape hatch (highest precedence, the PRIMARY override surface). Any UPPER_SNAKE app env var can be pinned here verbatim, overriding the template default (and any datastore/global mask). | +| `streamingHub.extraEnvVars` | object | `{}` | Unmodeled extra env vars appended verbatim to the ConfigMap. | +| `streamingHub.migrations.enabled` | bool | `false` | Enable or disable the migrations Job. Default false — opt-in per env (consistent with the chart's other optional features). | +| `streamingHub.migrations.useExistingSecret` | bool | `false` | Optional. When true, the Job reads STREAMING_HUB_POSTGRES_DSN from a pre-existing Secret (existingSecretName) instead of the chart-managed migration-secret hook. Independent of streamingHub.useExistingSecret; set this to point migrations at the app's existing (e.g. Vault) Secret. | +| `streamingHub.migrations.existingSecretName` | string | `""` | Name of the pre-existing Secret holding STREAMING_HUB_POSTGRES_DSN (only used when migrations.useExistingSecret=true). | +| `streamingHub.migrations.image.repository` | string | `ghcr.io/lerianstudio/streaming-hub-migrations` | Migrations image (FROM migrate/migrate + COPY migrations/ /migrations/). | +| `streamingHub.migrations.image.tag` | string | `""` | Tag for the migrations image. Empty falls back to the app image tag (streamingHub.image.tag, or the chart appVersion). An explicit tag or digest overrides this. | +| `streamingHub.migrations.image.digest` | string | `""` | Pin by digest (sha256:...) instead of tag. Wins over tag when set. | +| `streamingHub.migrations.image.pullPolicy` | string | `IfNotPresent` | Image pull policy. | +| `streamingHub.migrations.backoffLimit` | int | `3` | Maximum retries before the Job is considered failed. | +| `streamingHub.migrations.activeDeadlineSeconds` | int | `600` | Hard wall-clock cap on the Job (seconds). | +| `streamingHub.migrations.ttlSecondsAfterFinished` | int | `600` | TTL after which a finished Job is garbage-collected (seconds). | +| `streamingHub.migrations.annotations` | object | `{}` | Extra annotations on the Job (merged after the hook annotations). | +| `streamingHub.migrations.podAnnotations` | object | `{}` | Extra annotations on the migration pod. | +| `streamingHub.migrations.resources` | object | `{}` | Resource requests/limits for the migration container. | +| `streamingHub.all.autoscaling.enabled` | bool | `false` | HPA off by default; replicaCount governs. maxReplicas × poolMaxOpenConns must respect the connection-budget invariant above. | +| `streamingHub.ingest.autoscaling.maxReplicas` | int | `4` | maxReplicas × 8 (poolMaxOpenConns) must fit the connection budget. | +| `streamingHub.delivery.autoscaling.maxReplicas` | int | `4` | maxReplicas × 16 (poolMaxOpenConns) must fit the connection budget. | + diff --git a/charts/streaming-hub/docs/TOPOLOGY.md b/charts/streaming-hub/docs/TOPOLOGY.md new file mode 100644 index 000000000..d00da5877 --- /dev/null +++ b/charts/streaming-hub/docs/TOPOLOGY.md @@ -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/secrets.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`. diff --git a/charts/streaming-hub/templates/_deployment.tpl b/charts/streaming-hub/templates/_deployment.tpl new file mode 100644 index 000000000..50176ddad --- /dev/null +++ b/charts/streaming-hub/templates/_deployment.tpl @@ -0,0 +1,184 @@ +{{/* +============================================================================= +streaming-hub.deployment — the shared, component-parameterized Deployment. +Input: dict { root (the root context "."), component ("all"|"ingest"|"delivery") }. + +All three roles run the SAME image and serve the SAME full control plane on +:8080. They differ ONLY in: + - STREAMING_HUB_ROLE (the literal component) + - Postgres pool sizing (poolMaxOpenConns / poolMaxIdleConns) + - replicas / resources / scheduling + +The role-specific vars are injected as EXPLICIT per-Deployment env, which WINS +over envFrom (k8s precedence: env > envFrom). So a per-role pool size cleanly +overrides any shared default, and the shared ConfigMap deliberately omits +STREAMING_HUB_ROLE / the pool vars. +============================================================================= +*/}} +{{- define "streaming-hub.deployment" -}} +{{- $ := .root -}} +{{- $component := .component -}} +{{- $cfg := index $.Values.streamingHub $component -}} +{{- $sh := $.Values.streamingHub -}} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "streaming-hub.componentFullname" (dict "context" $ "component" $component) }} + namespace: {{ include "global.namespace" $ }} + labels: + {{- include "streaming-hub.labels" (dict "context" $ "component" $component) | nindent 4 }} + {{- with $sh.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + revisionHistoryLimit: {{ $sh.revisionHistoryLimit | default 10 }} + {{- with $sh.deploymentStrategy }} + strategy: + {{- toYaml . | nindent 4 }} + {{- end }} + {{- if not $cfg.autoscaling.enabled }} + replicas: {{ $cfg.replicaCount }} + {{- end }} + selector: + matchLabels: + {{- include "streaming-hub.componentSelectorLabels" (dict "context" $ "component" $component) | nindent 6 }} + template: + metadata: + labels: + {{- include "streaming-hub.labels" (dict "context" $ "component" $component) | nindent 8 }} + {{- with $sh.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with $sh.imagePullSecrets }} + {{- include "lerian-common.imagePullSecrets" . | nindent 6 }} + {{- end }} + serviceAccountName: {{ include "streaming-hub.serviceAccountName" $ }} + automountServiceAccountToken: {{ $sh.serviceAccount.automountServiceAccountToken | default false }} + terminationGracePeriodSeconds: {{ $sh.terminationGracePeriodSeconds | default 80 }} + {{- with $sh.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: streaming-hub + securityContext: + {{- toYaml $sh.securityContext | nindent 12 }} + image: "{{ $sh.image.repository }}:{{ include "streaming-hub.defaultTag" $ }}" + imagePullPolicy: {{ $sh.image.pullPolicy }} + ports: + - name: http + containerPort: {{ $sh.service.port }} + protocol: TCP + envFrom: + - secretRef: + name: {{ include "streaming-hub.secretName" $ }} + - configMapRef: + name: {{ include "streaming-hub.fullname" $ }} + env: + # --- role differentiator (explicit env WINS over envFrom) --- + - name: STREAMING_HUB_ROLE + value: {{ $component | quote }} + - name: STREAMING_HUB_POSTGRES_MAX_OPEN_CONNS + value: {{ $cfg.poolMaxOpenConns | quote }} + - name: STREAMING_HUB_POSTGRES_MAX_IDLE_CONNS + value: {{ $cfg.poolMaxIdleConns | quote }} + {{- if $sh.telemetry.enabled }} + # OTEL endpoint is overridden per-pod via the node host IP (DaemonSet + # collector pattern). Gated on the CHART-level streamingHub.telemetry.enabled. + - name: HOST_IP + valueFrom: + fieldRef: + fieldPath: status.hostIP + - name: OTEL_EXPORTER_OTLP_ENDPOINT + value: "$(HOST_IP):4317" + {{- end }} + {{- include "lerian-common.httpProbe" (dict + "kind" "livenessProbe" "probe" $sh.livenessProbe "port" "http" "path" "/healthz" + "initialDelay" 15 "period" 20 "timeout" 5 "success" 1 "failure" 3) | nindent 10 }} + {{- include "lerian-common.httpProbe" (dict + "kind" "readinessProbe" "probe" $sh.readinessProbe "port" "http" "path" "/readyz" + "initialDelay" 10 "period" 10 "timeout" 5 "success" 1 "failure" 3) | nindent 10 }} + resources: + {{- toYaml $cfg.resources | nindent 12 }} + {{- /* scheduling stays inline: lerian-common.scheduling emits a leading newline + that becomes a trailing-whitespace blank line under `| nindent`, and the inline + form cleanly expresses the per-role `$cfg.X | default $sh.X` fallback. */ -}} + {{- with $cfg.nodeSelector | default $sh.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with $cfg.affinity | default $sh.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with $cfg.tolerations | default $sh.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} +{{- end -}} + + +{{/* +============================================================================= +streaming-hub.service — the shared, component-parameterized ClusterIP Service. +Input: dict { root, component }. One Service per active role, selecting only +that role's pods via componentSelectorLabels. +============================================================================= +*/}} +{{- define "streaming-hub.service" -}} +{{- $ := .root -}} +{{- $component := .component -}} +{{- $sh := $.Values.streamingHub -}} +{{- include "lerian-common.service" (dict + "service" $sh.service + "name" (include "streaming-hub.componentFullname" (dict "context" $ "component" $component)) + "namespace" (include "global.namespace" $) + "labels" (include "streaming-hub.labels" (dict "context" $ "component" $component)) + "selector" (include "streaming-hub.componentSelectorLabels" (dict "context" $ "component" $component)) + ) }} +{{- end -}} + + +{{/* +============================================================================= +streaming-hub.hpa — the shared, component-parameterized HPA (autoscaling/v2). +Input: dict { root, component }. Emitted only when that role's +autoscaling.enabled. CONNECTION-BUDGET HAZARD: maxReplicas multiplies the +Postgres connection draw — Σ(maxReplicas × poolMaxOpenConns) ≤ max_connections. +============================================================================= +*/}} +{{- define "streaming-hub.hpa" -}} +{{- $ := .root -}} +{{- $component := .component -}} +{{- $cfg := index $.Values.streamingHub $component -}} +{{- include "lerian-common.hpa" (dict + "autoscaling" $cfg.autoscaling + "name" (include "streaming-hub.componentFullname" (dict "context" $ "component" $component)) + "namespace" (include "global.namespace" $) + "labels" (include "streaming-hub.labels" (dict "context" $ "component" $component)) + ) }} +{{- end -}} + + +{{/* +============================================================================= +streaming-hub.pdb — the shared, component-parameterized PDB (policy/v1). +Input: dict { root, component }. Emitted only when that role's pdb.enabled. +maxUnavailable wins over minAvailable when both are set (mirrors the template). +============================================================================= +*/}} +{{- define "streaming-hub.pdb" -}} +{{- $ := .root -}} +{{- $component := .component -}} +{{- $cfg := index $.Values.streamingHub $component -}} +{{- include "lerian-common.pdb" (dict + "pdb" $cfg.pdb + "name" (include "streaming-hub.componentFullname" (dict "context" $ "component" $component)) + "namespace" (include "global.namespace" $) + "labels" (include "streaming-hub.labels" (dict "context" $ "component" $component)) + "selector" (include "streaming-hub.componentSelectorLabels" (dict "context" $ "component" $component)) + ) }} +{{- end -}} diff --git a/charts/streaming-hub/templates/_helpers.tpl b/charts/streaming-hub/templates/_helpers.tpl new file mode 100644 index 000000000..74cb7ed6c --- /dev/null +++ b/charts/streaming-hub/templates/_helpers.tpl @@ -0,0 +1,128 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "streaming-hub.name" -}} +{{- default "streaming-hub" .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +Truncated at 63 chars because some Kubernetes name fields are limited to this +(by the DNS naming spec). When fullnameOverride is set it wins verbatim. +*/}} +{{- define "streaming-hub.fullname" -}} +{{- default (include "streaming-hub.name" .) .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "streaming-hub.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Resolve the image tag, falling back to the chart appVersion when image.tag is "". +*/}} +{{- define "streaming-hub.defaultTag" -}} +{{- default .Chart.AppVersion .Values.streamingHub.image.tag }} +{{- end -}} + +{{/* +Return a valid version label value (k8s label charset). +*/}} +{{- define "streaming-hub.versionLabelValue" -}} +{{ regexReplaceAll "[^-A-Za-z0-9_.]" (include "streaming-hub.defaultTag" .) "-" | trunc 63 | trimAll "-" | trimAll "_" | trimAll "." | quote }} +{{- end -}} + +{{/* +Component fully-qualified name: -. +Input: dict { context, component }. Truncated to 63 chars. +Used by every per-role Deployment/Service/HPA/PDB so the three role variants +never collide on a name. +*/}} +{{- define "streaming-hub.componentFullname" -}} +{{- /* Truncate the base to 54 BEFORE appending "-" so the component + suffix (longest: "delivery"=8, plus the "-") always survives the 63-char cap. + Appending first and truncating the whole would drop/shorten the suffix for a + long fullname, letting ingest/delivery collide on one name. */ -}} +{{- $fullname := include "streaming-hub.fullname" .context | trunc 54 | trimSuffix "-" -}} +{{- printf "%s-%s" $fullname .component | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Base selector labels (no component). +Input: dict { context }. +*/}} +{{- define "streaming-hub.selectorLabels" -}} +app.kubernetes.io/name: {{ include "streaming-hub.name" .context }} +app.kubernetes.io/instance: {{ .context.Release.Name }} +{{- end }} + +{{/* +Component-aware selector labels: base labels + app.kubernetes.io/component. +Input: dict { context, component }. The component label is the load-bearing +discriminator that keeps split-mode (ingest/delivery) Deployments from sharing +one ReplicaSet selector. +*/}} +{{- define "streaming-hub.componentSelectorLabels" -}} +{{ include "streaming-hub.selectorLabels" (dict "context" .context) }} +app.kubernetes.io/component: {{ .component }} +{{- end }} + +{{/* +Component-aware common labels (selector labels + chart/version/managed-by/part-of). +Input: dict { context, component }. +*/}} +{{- define "streaming-hub.labels" -}} +helm.sh/chart: {{ include "streaming-hub.chart" .context }} +{{ include "streaming-hub.componentSelectorLabels" (dict "context" .context "component" .component) }} +app.kubernetes.io/version: {{ include "streaming-hub.versionLabelValue" .context }} +app.kubernetes.io/managed-by: {{ .context.Release.Service }} +app.kubernetes.io/part-of: streaming-hub +{{- end }} + +{{/* +Create the name of the service account to use. +*/}} +{{- define "streaming-hub.serviceAccountName" -}} +{{- if .Values.streamingHub.serviceAccount.create }} +{{- default (include "streaming-hub.fullname" .) .Values.streamingHub.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.streamingHub.serviceAccount.name }} +{{- end }} +{{- end }} + +{{/* +Expand the namespace of the release. +Allows overriding it for multi-namespace deployments in combined charts. +*/}} +{{- define "global.namespace" -}} +{{- default .Release.Namespace .Values.namespaceOverride | trunc 63 | trimSuffix "-" -}} +{{- end }} + +{{/* +Resolve the shared Secret name a Deployment should reference: +existingSecretName when useExistingSecret, otherwise the shared chart Secret. +Input: root context (.). +*/}} +{{- define "streaming-hub.secretName" -}} +{{- if .Values.streamingHub.useExistingSecret -}} +{{- required "streamingHub.existingSecretName is required when streamingHub.useExistingSecret=true" .Values.streamingHub.existingSecretName -}} +{{- else -}} +{{- include "streaming-hub.fullname" . -}} +{{- end -}} +{{- end -}} + +{{/* +streaming-hub.migrationDSN — STREAMING_HUB_POSTGRES_DSN for the migration-only +Secret hook. migration-secret.yaml renders only on the chart-managed credential +path (NOT migrations.useExistingSecret), and runs as a PreSync hook BEFORE the +normal app Secret exists, so the operator MUST supply the DSN here. Kept as a +named gate helper (not an inline `required`) to mirror the bank-transfer pattern. +Input: root context (.). +*/}} +{{- define "streaming-hub.migrationDSN" -}} +{{- $secrets := get (.Values.streamingHub | default dict) "secrets" | default dict -}} +{{- required "streamingHub.secrets.STREAMING_HUB_POSTGRES_DSN is required when migrations run with a chart-managed Secret (migrations.useExistingSecret=false)" (get $secrets "STREAMING_HUB_POSTGRES_DSN") -}} +{{- end -}} diff --git a/charts/streaming-hub/templates/all/deployment.yaml b/charts/streaming-hub/templates/all/deployment.yaml new file mode 100644 index 000000000..af8c835d3 --- /dev/null +++ b/charts/streaming-hub/templates/all/deployment.yaml @@ -0,0 +1,3 @@ +{{- if eq .Values.streamingHub.mode "all" }} +{{- include "streaming-hub.deployment" (dict "root" . "component" "all") }} +{{- end }} diff --git a/charts/streaming-hub/templates/all/hpa.yaml b/charts/streaming-hub/templates/all/hpa.yaml new file mode 100644 index 000000000..c9c16ffc4 --- /dev/null +++ b/charts/streaming-hub/templates/all/hpa.yaml @@ -0,0 +1,3 @@ +{{- if and (eq .Values.streamingHub.mode "all") .Values.streamingHub.all.autoscaling.enabled }} +{{- include "streaming-hub.hpa" (dict "root" . "component" "all") }} +{{- end }} diff --git a/charts/streaming-hub/templates/all/pdb.yaml b/charts/streaming-hub/templates/all/pdb.yaml new file mode 100644 index 000000000..31a5a253f --- /dev/null +++ b/charts/streaming-hub/templates/all/pdb.yaml @@ -0,0 +1,3 @@ +{{- if and (eq .Values.streamingHub.mode "all") .Values.streamingHub.all.pdb.enabled }} +{{- include "streaming-hub.pdb" (dict "root" . "component" "all") }} +{{- end }} diff --git a/charts/streaming-hub/templates/all/service.yaml b/charts/streaming-hub/templates/all/service.yaml new file mode 100644 index 000000000..a510b3bf5 --- /dev/null +++ b/charts/streaming-hub/templates/all/service.yaml @@ -0,0 +1,3 @@ +{{- if eq .Values.streamingHub.mode "all" }} +{{- include "streaming-hub.service" (dict "root" . "component" "all") }} +{{- end }} diff --git a/charts/streaming-hub/templates/bootstrap-postgres.yaml b/charts/streaming-hub/templates/bootstrap-postgres.yaml new file mode 100644 index 000000000..56093677b --- /dev/null +++ b/charts/streaming-hub/templates/bootstrap-postgres.yaml @@ -0,0 +1,214 @@ +{{- if .Values.global.externalPostgresDefinitions.enabled }} +{{/* +Bootstrap Job for an external/shared PostgreSQL host: creates the hub's ONE +database + role and grants privileges. streaming-hub owns a SINGLE database with +a tenant_id COLUMN (NOT per-tenant DB), so exactly one DB/role is provisioned. + +Default OFF. Whether dev-st uses this Job or a pre-provisioned managed PG is a +Phase 3 decision — the chart supports both. The created DB/role name is +configurable (defaults: db "streaming-hub", role "streaming-hub") and must match +the host/dbname/user encoded in STREAMING_HUB_POSTGRES_DSN. +*/}} +{{- $defs := .Values.global.externalPostgresDefinitions -}} +{{- $sh := .Values.streamingHub | default dict -}} +{{- $dbName := $defs.database | default "streaming-hub" -}} +{{- $roleName := $defs.role | default "streaming-hub" -}} +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "streaming-hub.fullname" . }}-bootstrap-postgres + namespace: {{ include "global.namespace" . }} + labels: + {{- include "streaming-hub.labels" (dict "context" . "component" "bootstrap") | nindent 4 }} + annotations: + # PreSync hook at the FRONT of the chain (weight -10 / sync-wave -10): creates + # the hub's role+db BEFORE the migration-secret (-5), the migrations Job (-1), + # and the app Deployment (main Sync). Idempotent — it creates the role/db only + # when absent (see the script), so re-running on every sync is safe. + "helm.sh/hook": pre-install,pre-upgrade + "helm.sh/hook-weight": "-10" + "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded + "argocd.argoproj.io/hook": PreSync + "argocd.argoproj.io/sync-wave": "-10" + "argocd.argoproj.io/hook-delete-policy": BeforeHookCreation,HookSucceeded +spec: + ttlSecondsAfterFinished: 300 + completions: 1 + parallelism: 1 + backoffLimit: 3 + # Hard ceiling on the whole hook so a DB that becomes unresponsive mid-script + # (past the init TCP wait) can never hang this PreSync hook indefinitely and + # block every subsequent install/upgrade. Covers init wait (<=300s) + psql work. + activeDeadlineSeconds: 900 + template: + spec: + restartPolicy: Never + automountServiceAccountToken: false + securityContext: + seccompProfile: + type: RuntimeDefault + {{- with (get $sh "imagePullSecrets") }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + initContainers: + - name: wait-for-dependencies + image: busybox:1.37 + securityContext: + runAsNonRoot: true + runAsUser: 65534 + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + env: + - name: DB_HOST + value: {{ $defs.connection.host | quote }} + - name: DB_PORT + value: {{ $defs.connection.port | quote }} + command: + - /bin/sh + - -c + - > + TIMEOUT=300; + ELAPSED=0; + echo "Checking $DB_HOST:$DB_PORT..."; + while ! nc -z "$DB_HOST" "$DB_PORT"; do + if [ $ELAPSED -ge $TIMEOUT ]; then + echo "Timeout waiting for $DB_HOST:$DB_PORT after ${TIMEOUT}s"; + exit 1; + fi; + echo "$DB_HOST:$DB_PORT is not ready yet, waiting... (${ELAPSED}s/${TIMEOUT}s)"; + sleep 5; + ELAPSED=$((ELAPSED + 5)); + done; + echo "$DB_HOST:$DB_PORT is ready!"; + containers: + - name: psql + image: postgres:17 + securityContext: + runAsNonRoot: true + runAsUser: 999 + runAsGroup: 999 + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 250m + memory: 256Mi + env: + - name: DB_HOST + value: {{ $defs.connection.host | quote }} + - name: DB_PORT + value: {{ $defs.connection.port | quote }} + - name: DB_NAME + value: {{ $dbName | quote }} + - name: DB_ROLE + value: {{ $roleName | quote }} + - name: DB_USER_ADMIN + {{- if $defs.postgresAdminLogin.useExistingSecret.name }} + valueFrom: + secretKeyRef: + name: {{ $defs.postgresAdminLogin.useExistingSecret.name | quote }} + key: DB_USER_ADMIN + {{- else }} + value: {{ $defs.postgresAdminLogin.username | quote }} + {{- end }} + - name: DB_ADMIN_PASSWORD + {{- if $defs.postgresAdminLogin.useExistingSecret.name }} + valueFrom: + secretKeyRef: + name: {{ $defs.postgresAdminLogin.useExistingSecret.name | quote }} + key: DB_ADMIN_PASSWORD + {{- else }} + value: {{ $defs.postgresAdminLogin.password | quote }} + {{- end }} + - name: DB_PASSWORD_HUB + {{- if $defs.hubCredentials.useExistingSecret.name }} + valueFrom: + secretKeyRef: + name: {{ $defs.hubCredentials.useExistingSecret.name | quote }} + key: DB_PASSWORD_HUB + {{- else }} + value: {{ $defs.hubCredentials.password | quote }} + {{- end }} + - name: DB_DATABASE + value: postgres + command: + - /bin/sh + - -c + - | + set -euo pipefail + # Escape single quotes so values embedded in SQL string literals can't + # break the statement (a Vault-generated password legitimately may + # contain a ' — that would otherwise abort CREATE ROLE). + sql_lit() { printf "%s" "$1" | sed "s/'/''/g"; } + # Escape a double-quoted SQL IDENTIFIER (role/db name): double any embedded ". + sql_ident() { printf "%s" "$1" | sed 's/"/""/g'; } + DB_NAME_LIT="$(sql_lit "$DB_NAME")" + DB_ROLE_LIT="$(sql_lit "$DB_ROLE")" + DB_NAME_IDENT="$(sql_ident "$DB_NAME")" + DB_ROLE_IDENT="$(sql_ident "$DB_ROLE")" + DB_PASSWORD_HUB_LIT="$(sql_lit "$DB_PASSWORD_HUB")" + echo "=== streaming-hub PostgreSQL Bootstrap ===" + echo "Host: $DB_HOST:$DB_PORT Database: $DB_NAME Role: $DB_ROLE" + echo "" + + echo "Checking existing PostgreSQL objects..." + DB_EXISTS=0 + ROLE_EXISTS=0 + + if PGPASSWORD="$DB_ADMIN_PASSWORD" psql -At -v ON_ERROR_STOP=1 -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER_ADMIN" -d "$DB_DATABASE" -c "SELECT 1 FROM pg_database WHERE datname='${DB_NAME_LIT}'" | grep -q 1; then + DB_EXISTS=1 + fi + if PGPASSWORD="$DB_ADMIN_PASSWORD" psql -At -v ON_ERROR_STOP=1 -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER_ADMIN" -d "$DB_DATABASE" -c "SELECT 1 FROM pg_roles WHERE rolname='${DB_ROLE_LIT}'" | grep -q 1; then + ROLE_EXISTS=1 + fi + + # Role: create if absent, otherwise reconcile its password so a rotated + # DB_PASSWORD_HUB is applied on re-run (this hook re-runs every sync). + if [ "$ROLE_EXISTS" = "1" ]; then + echo "Role '$DB_ROLE' already exists; reconciling password." + PGPASSWORD="$DB_ADMIN_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER_ADMIN" -d "$DB_DATABASE" -c "ALTER ROLE \"${DB_ROLE_IDENT}\" WITH LOGIN PASSWORD '${DB_PASSWORD_HUB_LIT}'" + else + echo "Creating role '$DB_ROLE'..." + PGPASSWORD="$DB_ADMIN_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER_ADMIN" -d "$DB_DATABASE" -c "CREATE ROLE \"${DB_ROLE_IDENT}\" LOGIN PASSWORD '${DB_PASSWORD_HUB_LIT}'" + fi + + # Database: create if absent. + if [ "$DB_EXISTS" = "1" ]; then + echo "Database '$DB_NAME' already exists. Skipping creation." + else + echo "Creating database '$DB_NAME'..." + PGPASSWORD="$DB_ADMIN_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER_ADMIN" -d "$DB_DATABASE" -c "CREATE DATABASE \"${DB_NAME_IDENT}\" OWNER \"${DB_ROLE_IDENT}\"" + fi + + # Privileges (safe to run repeatedly) + echo "Ensuring privileges and schema permissions..." + PGPASSWORD="$DB_ADMIN_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER_ADMIN" -d "$DB_DATABASE" -c "GRANT ALL PRIVILEGES ON DATABASE \"${DB_NAME_IDENT}\" TO \"${DB_ROLE_IDENT}\"" + PGPASSWORD="$DB_ADMIN_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER_ADMIN" -d "$DB_NAME" -c "GRANT ALL ON SCHEMA public TO \"${DB_ROLE_IDENT}\"" + PGPASSWORD="$DB_ADMIN_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER_ADMIN" -d "$DB_NAME" -c "GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO \"${DB_ROLE_IDENT}\"" + PGPASSWORD="$DB_ADMIN_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER_ADMIN" -d "$DB_NAME" -c "GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO \"${DB_ROLE_IDENT}\"" + PGPASSWORD="$DB_ADMIN_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER_ADMIN" -d "$DB_NAME" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO \"${DB_ROLE_IDENT}\"" + PGPASSWORD="$DB_ADMIN_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER_ADMIN" -d "$DB_NAME" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO \"${DB_ROLE_IDENT}\"" + + echo "" + echo "=== streaming-hub PostgreSQL Bootstrap completed successfully ===" + {{- with (get $sh "nodeSelector") }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with (get $sh "affinity") }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with (get $sh "tolerations") }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} +{{- end }} diff --git a/charts/streaming-hub/templates/configmap.yaml b/charts/streaming-hub/templates/configmap.yaml new file mode 100644 index 000000000..8e9f76ced --- /dev/null +++ b/charts/streaming-hub/templates/configmap.yaml @@ -0,0 +1,199 @@ +{{/* +ONE shared ConfigMap — role-INVARIANT, non-sensitive env. Identical across all +roles, so it is NOT duplicated per role (DRY). The role-specific vars +(STREAMING_HUB_ROLE, the Postgres pool sizes) are deliberately ABSENT here — +they are injected as explicit per-Deployment env in templates//deployment.yaml +(see templates/_deployment.tpl), which wins over this envFrom layer. + +TIERED productization (the #1741 model): typed KNOBS are reserved for DEPENDENCY +CONNECTIONS; every other app knob is an escape-hatch PASSTHROUGH with its default +in the template. Concretely: + - Kafka/RedPanda connection (STREAMING_HUB_KAFKA_*) -> lerian-common.datastore.value + (type "kafka") mask: native configmap key > streamingHub.datastores.kafka. + > global.datastores.kafka. > template default. + - Auth (PLUGIN_AUTH_*) -> lerian-common.globalValue (block "auth"): native + configmap key > global.auth. > template default (env-wide endpoint). + - Everything else -> {{ $cm.KEY | default "" | quote }}: the default lives + HERE; streamingHub.configmap. overrides it (escape hatch, top + precedence); streamingHub.extraEnvVars carries any unmodeled key. + +Postgres is NOT decomposed into host/port/user/db here: the hub reaches Postgres via +a SINGLE opaque DSN (STREAMING_HUB_POSTGRES_DSN, a Secret) — so there is no +connection field in THIS ConfigMap to route through a datastore mask (the bootstrap +Job and the migration hook carry the host/port separately). MULTI_TENANT_ENABLED is +the bare lib-commons request-tenancy gate (kept inline as the knob); the hub's SaaS +tenant-manager roster uses its OWN STREAMING_HUB_MULTI_TENANT_* naming (NOT the +lib-commons multiTenant.env contract), so those stay passthrough. +*/}} +{{- $cm := .Values.streamingHub.configmap | default dict }} +{{- $ds := .Values.streamingHub.datastores | default dict }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "streaming-hub.fullname" . }} + namespace: {{ include "global.namespace" . }} + labels: + {{- include "streaming-hub.labels" (dict "context" . "component" "all") | nindent 4 }} +data: + # ===================================================================== + # APPLICATION / LIFECYCLE + # ===================================================================== + STREAMING_HUB_ENV: {{ $cm.STREAMING_HUB_ENV | default "production" | quote }} + STREAMING_HUB_LOG_LEVEL: {{ $cm.STREAMING_HUB_LOG_LEVEL | default "info" | quote }} + STREAMING_HUB_HEALTH_WINDOW: {{ $cm.STREAMING_HUB_HEALTH_WINDOW | default "720h" | quote }} + STREAMING_HUB_SWAGGER_ENABLED: {{ $cm.STREAMING_HUB_SWAGGER_ENABLED | default "false" | quote }} + STREAMING_HUB_SHUTDOWN_TIMEOUT: {{ $cm.STREAMING_HUB_SHUTDOWN_TIMEOUT | default "30s" | quote }} + STREAMING_HUB_PRE_STOP_DRAIN_TIMEOUT: {{ $cm.STREAMING_HUB_PRE_STOP_DRAIN_TIMEOUT | default "5s" | quote }} + + # ===================================================================== + # HTTP SERVER / METRICS + # ===================================================================== + STREAMING_HUB_HTTP_LISTEN_ADDR: {{ $cm.STREAMING_HUB_HTTP_LISTEN_ADDR | default ":8080" | quote }} + STREAMING_HUB_METRICS_ENABLED: {{ $cm.STREAMING_HUB_METRICS_ENABLED | default "true" | quote }} + + # ===================================================================== + # KAFKA / REDPANDA (external dependency connection). Routed through the + # lerian-common datastore mask (type "kafka") so an operator sets + # streamingHub.datastores.kafka.* (dedicated) or global.datastores.kafka.* + # (shared) instead of pinning each native key. The SCRAM password lives in + # the Secret. Defaults preserved verbatim (byte-identical when the mask is unset). + # ===================================================================== + STREAMING_HUB_KAFKA_BROKERS: {{ include "lerian-common.datastore.value" (dict "context" $ "dedicated" $ds "configmap" $cm "type" "kafka" "field" "brokers" "nativeKey" "STREAMING_HUB_KAFKA_BROKERS" "default" "localhost:19092") | quote }} + STREAMING_HUB_KAFKA_SCRAM_MECHANISM: {{ include "lerian-common.datastore.value" (dict "context" $ "dedicated" $ds "configmap" $cm "type" "kafka" "field" "saslMechanism" "nativeKey" "STREAMING_HUB_KAFKA_SCRAM_MECHANISM" "default" "") | quote }} + STREAMING_HUB_KAFKA_SCRAM_USERNAME: {{ include "lerian-common.datastore.value" (dict "context" $ "dedicated" $ds "configmap" $cm "type" "kafka" "field" "saslUsername" "nativeKey" "STREAMING_HUB_KAFKA_SCRAM_USERNAME" "default" "") | quote }} + STREAMING_HUB_KAFKA_TLS_ENABLED: {{ include "lerian-common.datastore.value" (dict "context" $ "dedicated" $ds "configmap" $cm "type" "kafka" "field" "tls" "nativeKey" "STREAMING_HUB_KAFKA_TLS_ENABLED" "default" "false") | quote }} + STREAMING_HUB_KAFKA_CA_CERT: {{ include "lerian-common.datastore.value" (dict "context" $ "dedicated" $ds "configmap" $cm "type" "kafka" "field" "caCert" "nativeKey" "STREAMING_HUB_KAFKA_CA_CERT" "default" "") | quote }} + + # ===================================================================== + # CRYPTO / KEK (source/ref are config; the material itself is a Secret). + # TODO(lerian-common 1.5.0): route via the kms.value mask once the chart pins + # >= 1.5.0 (envelope-encryption dependency domain). Passthrough until then. + # ===================================================================== + STREAMING_HUB_KEK_SOURCE: {{ $cm.STREAMING_HUB_KEK_SOURCE | default "env" | quote }} + STREAMING_HUB_KEK_REF: {{ $cm.STREAMING_HUB_KEK_REF | default "" | quote }} + + # ===================================================================== + # DISPATCH / POISON (delivery worker pool) + # ===================================================================== + STREAMING_HUB_DISPATCH_WORKERS: {{ $cm.STREAMING_HUB_DISPATCH_WORKERS | default "8" | quote }} + STREAMING_HUB_DISPATCH_CLAIM_BATCH: {{ $cm.STREAMING_HUB_DISPATCH_CLAIM_BATCH | default "16" | quote }} + STREAMING_HUB_DISPATCH_IDLE_INTERVAL_MS: {{ $cm.STREAMING_HUB_DISPATCH_IDLE_INTERVAL_MS | default "250" | quote }} + STREAMING_HUB_POISON_THRESHOLD: {{ $cm.STREAMING_HUB_POISON_THRESHOLD | default "5" | quote }} + + # ===================================================================== + # PULL RATE LIMIT (source consumption) + # ===================================================================== + STREAMING_HUB_PULL_RATE: {{ $cm.STREAMING_HUB_PULL_RATE | default "100" | quote }} + STREAMING_HUB_PULL_BURST: {{ $cm.STREAMING_HUB_PULL_BURST | default "200" | quote }} + + # ===================================================================== + # DLQ VISIBILITY + # ===================================================================== + STREAMING_HUB_DLQ_GROUP: {{ $cm.STREAMING_HUB_DLQ_GROUP | default "streaming-hub-dlq.local" | quote }} + STREAMING_HUB_DLQ_RETENTION: {{ $cm.STREAMING_HUB_DLQ_RETENTION | default "168h" | quote }} + STREAMING_HUB_DLQ_PRUNE_INTERVAL: {{ $cm.STREAMING_HUB_DLQ_PRUNE_INTERVAL | default "1h" | quote }} + + # ===================================================================== + # MANIFEST (event manifest sources) + # ===================================================================== + STREAMING_HUB_MANIFEST_SOURCES: {{ $cm.STREAMING_HUB_MANIFEST_SOURCES | default "" | quote }} + STREAMING_HUB_MANIFEST_REFRESH_INTERVAL: {{ $cm.STREAMING_HUB_MANIFEST_REFRESH_INTERVAL | default "300" | quote }} + + # ===================================================================== + # RECONCILER (topic-drift detection; dark-ship, defaults on) + # ===================================================================== + STREAMING_HUB_RECONCILER_ENABLED: {{ $cm.STREAMING_HUB_RECONCILER_ENABLED | default "true" | quote }} + STREAMING_HUB_RECONCILER_INTERVAL: {{ $cm.STREAMING_HUB_RECONCILER_INTERVAL | default "300" | quote }} + + # ===================================================================== + # PARTITION LIFECYCLE CRON + # ===================================================================== + STREAMING_HUB_PARTITION_CRON_INTERVAL: {{ $cm.STREAMING_HUB_PARTITION_CRON_INTERVAL | default "6h" | quote }} + STREAMING_HUB_PARTITION_FUTURE_BUFFER_WEEKS: {{ $cm.STREAMING_HUB_PARTITION_FUTURE_BUFFER_WEEKS | default "4" | quote }} + STREAMING_HUB_PARTITION_RETENTION_ENABLED: {{ $cm.STREAMING_HUB_PARTITION_RETENTION_ENABLED | default "false" | quote }} + STREAMING_HUB_PARTITION_RETENTION_HORIZON: {{ $cm.STREAMING_HUB_PARTITION_RETENTION_HORIZON | default "0" | quote }} + + # ===================================================================== + # IDEMPOTENCY + # ===================================================================== + STREAMING_HUB_IDEMPOTENCY_TTL: {{ $cm.STREAMING_HUB_IDEMPOTENCY_TTL | default "24h" | quote }} + STREAMING_HUB_IDEMPOTENCY_REAP_INTERVAL: {{ $cm.STREAMING_HUB_IDEMPOTENCY_REAP_INTERVAL | default "5m" | quote }} + + # ===================================================================== + # AUTO-DISABLE (subscriber failure circuit) + # ===================================================================== + STREAMING_HUB_AUTODISABLE_ENABLED: {{ $cm.STREAMING_HUB_AUTODISABLE_ENABLED | default "true" | quote }} + STREAMING_HUB_AUTODISABLE_FAILURE_WINDOW: {{ $cm.STREAMING_HUB_AUTODISABLE_FAILURE_WINDOW | default "120h" | quote }} + STREAMING_HUB_AUTODISABLE_FAILURE_SPREAD: {{ $cm.STREAMING_HUB_AUTODISABLE_FAILURE_SPREAD | default "12h" | quote }} + + # ===================================================================== + # TENANCY (F4). Bare MULTI_TENANT_ENABLED is the lib-commons request-tenancy + # toggle (kept inline as the knob/gate source); the STREAMING_HUB_MULTI_TENANT_* + # block is the hub's SaaS tenant-manager roster (a distinct concern, the hub's + # OWN naming — NOT the lib-commons multiTenant.env contract, so it stays + # passthrough). Default = BYOC single-tenant. A non-default TENANT_ID quarantines + # all other ce-tenantid events — confirm the producer. + # ===================================================================== + MULTI_TENANT_ENABLED: {{ $cm.MULTI_TENANT_ENABLED | default "false" | quote }} + STREAMING_HUB_TENANT_ID: {{ $cm.STREAMING_HUB_TENANT_ID | default "default" | quote }} + STREAMING_HUB_MULTI_TENANT_ENABLED: {{ $cm.STREAMING_HUB_MULTI_TENANT_ENABLED | default "false" | quote }} + STREAMING_HUB_MULTI_TENANT_AWS_REGION: {{ $cm.STREAMING_HUB_MULTI_TENANT_AWS_REGION | default "" | quote }} + # Source SELECTOR for tenant secrets (env|secretsmanager reference), NOT secret + # material — the `| default ""` marks it as intentional config for the standard's + # secret-in-configmap gate. The actual secrets live in the app Secret. + STREAMING_HUB_MULTI_TENANT_SECRETS_ENV: {{ $cm.STREAMING_HUB_MULTI_TENANT_SECRETS_ENV | default "" | quote }} + STREAMING_HUB_TENANT_MANAGER_URL: {{ $cm.STREAMING_HUB_TENANT_MANAGER_URL | default "" | quote }} + STREAMING_HUB_MULTI_TENANT_ALLOW_INSECURE_TM: {{ $cm.STREAMING_HUB_MULTI_TENANT_ALLOW_INSECURE_TM | default "false" | quote }} + STREAMING_HUB_MULTI_TENANT_TM_STALENESS_THRESHOLD: {{ $cm.STREAMING_HUB_MULTI_TENANT_TM_STALENESS_THRESHOLD | default "12h" | quote }} + STREAMING_HUB_MULTI_TENANT_LISTENER_STALENESS_THRESHOLD: {{ $cm.STREAMING_HUB_MULTI_TENANT_LISTENER_STALENESS_THRESHOLD | default "12h" | quote }} + STREAMING_HUB_MULTI_TENANT_RECONCILE_INTERVAL: {{ $cm.STREAMING_HUB_MULTI_TENANT_RECONCILE_INTERVAL | default "6h" | quote }} + STREAMING_HUB_MULTI_TENANT_CACHE_TTL: {{ $cm.STREAMING_HUB_MULTI_TENANT_CACHE_TTL | default "12h" | quote }} + STREAMING_HUB_TENANT_MANAGER_CB_THRESHOLD: {{ $cm.STREAMING_HUB_TENANT_MANAGER_CB_THRESHOLD | default "0" | quote }} + STREAMING_HUB_TENANT_MANAGER_CB_TIMEOUT: {{ $cm.STREAMING_HUB_TENANT_MANAGER_CB_TIMEOUT | default "30s" | quote }} + STREAMING_HUB_MULTI_TENANT_REDIS_HOST: {{ $cm.STREAMING_HUB_MULTI_TENANT_REDIS_HOST | default "" | quote }} + STREAMING_HUB_MULTI_TENANT_REDIS_PORT: {{ $cm.STREAMING_HUB_MULTI_TENANT_REDIS_PORT | default "" | quote }} + STREAMING_HUB_MULTI_TENANT_REDIS_TLS: {{ $cm.STREAMING_HUB_MULTI_TENANT_REDIS_TLS | default "false" | quote }} + STREAMING_HUB_MULTI_TENANT_REDIS_CA_CERT: {{ $cm.STREAMING_HUB_MULTI_TENANT_REDIS_CA_CERT | default "" | quote }} + ENVIRONMENT_NAME: {{ $cm.ENVIRONMENT_NAME | default "" | quote }} + + # ===================================================================== + # SECURITY POSTURE (fail-closed insecure opt-ins; MUST stay false in prod) + # ===================================================================== + STREAMING_HUB_ALLOW_INSECURE_KAFKA: {{ $cm.STREAMING_HUB_ALLOW_INSECURE_KAFKA | default "false" | quote }} + STREAMING_HUB_ALLOW_INSECURE_DB_TLS: {{ $cm.STREAMING_HUB_ALLOW_INSECURE_DB_TLS | default "false" | quote }} + STREAMING_HUB_ALLOW_PRIVATE_SINKS: {{ $cm.STREAMING_HUB_ALLOW_PRIVATE_SINKS | default "false" | quote }} + + # ===================================================================== + # AWS (SaaS setup metadata; NON-secret public ARN/URL). + # TODO(lerian-common 1.5.0): if these become part of an AWS/objectStorage + # dependency mask, adopt it once the chart pins >= 1.5.0. Passthrough until then. + # ===================================================================== + STREAMING_HUB_AWS_HUB_PRINCIPAL_ARN: {{ $cm.STREAMING_HUB_AWS_HUB_PRINCIPAL_ARN | default "" | quote }} + STREAMING_HUB_AWS_SETUP_TEMPLATE_URL: {{ $cm.STREAMING_HUB_AWS_SETUP_TEMPLATE_URL | default "" | quote }} + + # ===================================================================== + # AUTHENTICATION (lib-auth / plugin-auth JWT). ENABLED + ADDRESS resolve via + # global.auth (globalValue: configmap. > global.auth. > default) so + # the env-wide auth endpoint is shared across services. + # ===================================================================== + PLUGIN_AUTH_ENABLED: {{ include "lerian-common.globalValue" (dict "context" $ "configmap" $cm "block" "auth" "field" "enabled" "nativeKey" "PLUGIN_AUTH_ENABLED" "default" "true") | quote }} + PLUGIN_AUTH_ADDRESS: {{ include "lerian-common.globalValue" (dict "context" $ "configmap" $cm "block" "auth" "field" "address" "nativeKey" "PLUGIN_AUTH_ADDRESS" "default" "http://plugin-access-manager-auth:4000") | quote }} + + # ===================================================================== + # OBSERVABILITY (OTEL_*). OTEL_EXPORTER_OTLP_ENDPOINT is overridden per-pod with + # $(HOST_IP):4317 via the downward API when streamingHub.telemetry.enabled=true + # (a CHART-level toggle — not an app env var; see templates/_deployment.tpl). + # OTEL_LIBRARY_NAME and OTEL_RESOURCE_* are lib-observability identity passthrough. + # ===================================================================== + OTEL_LIBRARY_NAME: {{ $cm.OTEL_LIBRARY_NAME | default "github.com/LerianStudio/streaming-hub" | quote }} + OTEL_RESOURCE_SERVICE_NAME: {{ $cm.OTEL_RESOURCE_SERVICE_NAME | default "streaming-hub" | quote }} + OTEL_RESOURCE_DEPLOYMENT_ENVIRONMENT: {{ $cm.OTEL_RESOURCE_DEPLOYMENT_ENVIRONMENT | default "production" | quote }} + OTEL_EXPORTER_OTLP_ENDPOINT: {{ $cm.OTEL_EXPORTER_OTLP_ENDPOINT | default "" | quote }} + OTEL_INSECURE_EXPORTER: {{ $cm.OTEL_INSECURE_EXPORTER | default "false" | quote }} + + # ===================================================================== + # EXTRA OVERRIDES (escape hatch for optional knobs not modeled above) + # ===================================================================== + {{- range $key, $value := .Values.streamingHub.extraEnvVars }} + {{ $key }}: {{ $value | quote }} + {{- end }} diff --git a/charts/streaming-hub/templates/delivery/deployment.yaml b/charts/streaming-hub/templates/delivery/deployment.yaml new file mode 100644 index 000000000..537af7773 --- /dev/null +++ b/charts/streaming-hub/templates/delivery/deployment.yaml @@ -0,0 +1,3 @@ +{{- if eq .Values.streamingHub.mode "split" }} +{{- include "streaming-hub.deployment" (dict "root" . "component" "delivery") }} +{{- end }} diff --git a/charts/streaming-hub/templates/delivery/hpa.yaml b/charts/streaming-hub/templates/delivery/hpa.yaml new file mode 100644 index 000000000..36465e604 --- /dev/null +++ b/charts/streaming-hub/templates/delivery/hpa.yaml @@ -0,0 +1,3 @@ +{{- if and (eq .Values.streamingHub.mode "split") .Values.streamingHub.delivery.autoscaling.enabled }} +{{- include "streaming-hub.hpa" (dict "root" . "component" "delivery") }} +{{- end }} diff --git a/charts/streaming-hub/templates/delivery/pdb.yaml b/charts/streaming-hub/templates/delivery/pdb.yaml new file mode 100644 index 000000000..3edd4c638 --- /dev/null +++ b/charts/streaming-hub/templates/delivery/pdb.yaml @@ -0,0 +1,3 @@ +{{- if and (eq .Values.streamingHub.mode "split") .Values.streamingHub.delivery.pdb.enabled }} +{{- include "streaming-hub.pdb" (dict "root" . "component" "delivery") }} +{{- end }} diff --git a/charts/streaming-hub/templates/delivery/service.yaml b/charts/streaming-hub/templates/delivery/service.yaml new file mode 100644 index 000000000..2e2748864 --- /dev/null +++ b/charts/streaming-hub/templates/delivery/service.yaml @@ -0,0 +1,3 @@ +{{- if eq .Values.streamingHub.mode "split" }} +{{- include "streaming-hub.service" (dict "root" . "component" "delivery") }} +{{- end }} diff --git a/charts/streaming-hub/templates/ingest/deployment.yaml b/charts/streaming-hub/templates/ingest/deployment.yaml new file mode 100644 index 000000000..de840b891 --- /dev/null +++ b/charts/streaming-hub/templates/ingest/deployment.yaml @@ -0,0 +1,3 @@ +{{- if eq .Values.streamingHub.mode "split" }} +{{- include "streaming-hub.deployment" (dict "root" . "component" "ingest") }} +{{- end }} diff --git a/charts/streaming-hub/templates/ingest/hpa.yaml b/charts/streaming-hub/templates/ingest/hpa.yaml new file mode 100644 index 000000000..19f26aa25 --- /dev/null +++ b/charts/streaming-hub/templates/ingest/hpa.yaml @@ -0,0 +1,3 @@ +{{- if and (eq .Values.streamingHub.mode "split") .Values.streamingHub.ingest.autoscaling.enabled }} +{{- include "streaming-hub.hpa" (dict "root" . "component" "ingest") }} +{{- end }} diff --git a/charts/streaming-hub/templates/ingest/pdb.yaml b/charts/streaming-hub/templates/ingest/pdb.yaml new file mode 100644 index 000000000..0bb740ef6 --- /dev/null +++ b/charts/streaming-hub/templates/ingest/pdb.yaml @@ -0,0 +1,3 @@ +{{- if and (eq .Values.streamingHub.mode "split") .Values.streamingHub.ingest.pdb.enabled }} +{{- include "streaming-hub.pdb" (dict "root" . "component" "ingest") }} +{{- end }} diff --git a/charts/streaming-hub/templates/ingest/service.yaml b/charts/streaming-hub/templates/ingest/service.yaml new file mode 100644 index 000000000..995a93466 --- /dev/null +++ b/charts/streaming-hub/templates/ingest/service.yaml @@ -0,0 +1,3 @@ +{{- if eq .Values.streamingHub.mode "split" }} +{{- include "streaming-hub.service" (dict "root" . "component" "ingest") }} +{{- end }} diff --git a/charts/streaming-hub/templates/ingress.yaml b/charts/streaming-hub/templates/ingress.yaml new file mode 100644 index 000000000..48e95874c --- /dev/null +++ b/charts/streaming-hub/templates/ingress.yaml @@ -0,0 +1,20 @@ +{{- if .Values.streamingHub.ingress.enabled }} +{{/* +Control-plane API ingress (default OFF; opt-in per env in gitops). +Backend Service is the active role's component Service: + - mode=all -> the `all` Service. + - mode=split -> the `ingest` Service (the control API is served on EVERY role, + so ingest is an arbitrary-but-stable default; override the + backend by editing this template if delivery is preferred). +*/}} +{{- $component := ternary "all" "ingest" (eq .Values.streamingHub.mode "all") -}} +{{- include "lerian-common.ingress" (dict + "context" . + "ingress" .Values.streamingHub.ingress + "name" (include "streaming-hub.fullname" .) + "backendName" (include "streaming-hub.componentFullname" (dict "context" . "component" $component)) + "namespace" (include "global.namespace" .) + "labels" (include "streaming-hub.labels" (dict "context" . "component" $component)) + "svcPort" .Values.streamingHub.service.port + ) }} +{{- end }} diff --git a/charts/streaming-hub/templates/migration-secret.yaml b/charts/streaming-hub/templates/migration-secret.yaml new file mode 100644 index 000000000..d6a78ae7e --- /dev/null +++ b/charts/streaming-hub/templates/migration-secret.yaml @@ -0,0 +1,48 @@ +{{- $streamingHub := .Values.streamingHub | default dict }} +{{- $migrations := get $streamingHub "migrations" | default dict }} +{{- $migrationsEnabled := eq (lower (toString (get $migrations "enabled" | default false))) "true" }} +{{- $migUseExisting := eq (lower (toString (get $migrations "useExistingSecret" | default false))) "true" }} +{{- /* +Minimal, migration-only Secret carrying ONLY STREAMING_HUB_POSTGRES_DSN. + +Rendered ONLY when migrations are enabled AND a chart-managed credential is used +(migrations.useExistingSecret=false). In that case the migration Job runs as a +pre-install/pre-upgrade hook (weight -1) BEFORE the normal application Secret +(secrets.yaml, a normal main-sync resource) exists, so it needs its own credential +available early. This Secret is a PreSync hook at weight -5 / sync-wave -5 — after +bootstrap-postgres (-10), before the migration Job (-1). + +It holds ONLY the DSN; the full application Secret stays a NORMAL (non-hook) +resource so its sensitive runtime keys are never left behind as orphaned hooks on +uninstall. +*/}} +{{- if and $migrationsEnabled (not $migUseExisting) }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ printf "%s-migrations" (include "streaming-hub.fullname" .) | trunc 63 | trimSuffix "-" }} + namespace: {{ include "global.namespace" . }} + labels: + {{- include "streaming-hub.labels" (dict "context" . "component" "migrations") | nindent 4 }} + annotations: + # PreSync hook (weight -5 / sync-wave -5) so it exists before the migration Job + # (weight -1) and after bootstrap-postgres (weight -10). Holds ONLY the DSN; see + # the comment above for why the full application Secret is intentionally NOT a hook. + # + # Delete policy is before-hook-creation ONLY — deliberately NOT hook-succeeded. + # This Secret is consumed by a LATER hook (the migrations Job at weight -1); both + # Helm and ArgoCD treat a Secret as "succeeded" the moment it applies, so a + # hook-succeeded policy would delete the DSN at the end of wave -5, BEFORE the + # Job at wave -1 can read it — reintroducing the migration failure this exists to + # fix. The trade-off: the Secret lingers post-run, but it only duplicates the DSN + # already held permanently by the app's main Secret, so it adds no new exposure. + "helm.sh/hook": pre-install,pre-upgrade + "helm.sh/hook-weight": "-5" + "helm.sh/hook-delete-policy": before-hook-creation + "argocd.argoproj.io/hook": PreSync + "argocd.argoproj.io/sync-wave": "-5" + "argocd.argoproj.io/hook-delete-policy": BeforeHookCreation +type: Opaque +stringData: + STREAMING_HUB_POSTGRES_DSN: {{ include "streaming-hub.migrationDSN" . | quote }} +{{- end }} diff --git a/charts/streaming-hub/templates/migrations.yaml b/charts/streaming-hub/templates/migrations.yaml new file mode 100644 index 000000000..11d980a92 --- /dev/null +++ b/charts/streaming-hub/templates/migrations.yaml @@ -0,0 +1,124 @@ +{{- $streamingHub := .Values.streamingHub | default dict }} +{{- $migrations := get $streamingHub "migrations" | default dict }} +{{- $migrationImage := get $migrations "image" | default dict }} +{{- $defaultMigrationResources := dict "requests" (dict "cpu" "50m" "memory" "64Mi") "limits" (dict "cpu" "250m" "memory" "256Mi") }} +{{- $migrationsEnabled := eq (lower (toString (get $migrations "enabled" | default false))) "true" }} +{{- if $migrationsEnabled }} +{{- /* +OUT-OF-BAND schema migration Job. The hub never migrates itself; this Job applies +the SQL migrations BEFORE the app rolls out. PreSync hook at weight -1 — after +bootstrap-postgres (-10, creates role+db) and migration-secret (-5, carries the +DSN), before the app Deployment (main Sync). Runs ONCE regardless of +streamingHub.mode (all | split) — it is not per-role. + +The runner is the stock migrate/migrate toolchain baked with the hub's migrations/ +dir; its entrypoint runs `migrate -path=/migrations -database="$STREAMING_HUB_POSTGRES_DSN" up`, +so the ONLY required env is STREAMING_HUB_POSTGRES_DSN (secretKeyRef). +*/}} +{{- /* Secret resolution — STREAMING_HUB_POSTGRES_DSN source. */}} +{{- $migUseExisting := eq (lower (toString (get $migrations "useExistingSecret" | default false))) "true" }} +{{- $dsnSecretName := "" }} +{{- if $migUseExisting }} +{{- $dsnSecretName = required "streamingHub.migrations.existingSecretName is required when streamingHub.migrations.useExistingSecret=true" (get $migrations "existingSecretName") }} +{{- else }} +{{- $dsnSecretName = printf "%s-migrations" (include "streaming-hub.fullname" .) | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- $namespace := include "global.namespace" . }} +{{- /* Image resolution — explicit digest > explicit tag > app tag (streamingHub.image.tag) > chart appVersion. */}} +{{- $migrationTag := get $migrationImage "tag" | default "" }} +{{- $migrationDigest := get $migrationImage "digest" | default "" }} +{{- if and (empty $migrationTag) (empty $migrationDigest) }} +{{- $migrationTag = include "streaming-hub.defaultTag" . }} +{{- end }} +{{- $migrationRepository := get $migrationImage "repository" | default "ghcr.io/lerianstudio/streaming-hub-migrations" }} +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ printf "%s-migrations" (include "streaming-hub.fullname" .) | trunc 63 | trimSuffix "-" }} + namespace: {{ $namespace }} + labels: + {{- include "streaming-hub.labels" (dict "context" . "component" "migrations") | nindent 4 }} + annotations: + # External Postgres host (bootstrap-postgres provisions the role+db at PreSync + # weight -10) — run BEFORE the app rolls out (schema-first). + "helm.sh/hook": pre-install,pre-upgrade + "helm.sh/hook-weight": "-1" + "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded + "argocd.argoproj.io/hook": PreSync + "argocd.argoproj.io/sync-wave": "-1" + "argocd.argoproj.io/hook-delete-policy": BeforeHookCreation,HookSucceeded + {{- /* + Custom annotations are merged AFTER the fixed set, but the hook/wave keys are + filtered out so a user annotation can NEVER knock this Job out of PreSync or + shift its wave — that would reintroduce the empty-schema rollout this Job + exists to prevent. The fixed hook chain always wins. + */}} + {{- range $k, $v := (get $migrations "annotations") }} + {{- if or (hasPrefix "helm.sh/hook" $k) (hasPrefix "argocd.argoproj.io/hook" $k) (eq $k "argocd.argoproj.io/sync-wave") }} + {{- /* reserved hook/wave key — ignored to preserve the schema-first ordering */}} + {{- else }} + {{ $k | quote }}: {{ $v | quote }} + {{- end }} + {{- end }} +spec: + backoffLimit: {{ get $migrations "backoffLimit" | default 3 }} + activeDeadlineSeconds: {{ get $migrations "activeDeadlineSeconds" | default 600 }} + ttlSecondsAfterFinished: {{ get $migrations "ttlSecondsAfterFinished" | default 600 }} + template: + metadata: + labels: + {{- include "streaming-hub.labels" (dict "context" . "component" "migrations") | nindent 8 }} + {{- with (get $migrations "podAnnotations") }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + restartPolicy: Never + automountServiceAccountToken: false + securityContext: + seccompProfile: + type: RuntimeDefault + {{- with (get $streamingHub "imagePullSecrets") }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: migrations + {{- if $migrationDigest }} + image: "{{ $migrationRepository }}@{{ $migrationDigest }}" + {{- else }} + image: "{{ $migrationRepository }}:{{ $migrationTag }}" + {{- end }} + imagePullPolicy: {{ get $migrationImage "pullPolicy" | default "IfNotPresent" }} + env: + # The baked migrate/migrate entrypoint runs: + # migrate -path=/migrations -database="$STREAMING_HUB_POSTGRES_DSN" up + - name: STREAMING_HUB_POSTGRES_DSN + valueFrom: + secretKeyRef: + name: {{ $dsnSecretName }} + key: STREAMING_HUB_POSTGRES_DSN + securityContext: + runAsUser: 65532 + runAsGroup: 65532 + runAsNonRoot: true + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + resources: + {{- toYaml (get $migrations "resources" | default $defaultMigrationResources) | nindent 12 }} + {{- with (get $streamingHub "nodeSelector") }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with (get $streamingHub "affinity") }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with (get $streamingHub "tolerations") }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} +{{- end }} diff --git a/charts/streaming-hub/templates/secrets.yaml b/charts/streaming-hub/templates/secrets.yaml new file mode 100644 index 000000000..5796648ce --- /dev/null +++ b/charts/streaming-hub/templates/secrets.yaml @@ -0,0 +1,28 @@ +{{/* +ONE shared Opaque Secret — role-INVARIANT sensitive env. Identical across all +roles, so it is NOT duplicated per role. + +Rendered ONLY when NOT useExistingSecret. When useExistingSecret=true (the +gitops/Vault path) this emits nothing and the Deployments reference +existingSecretName instead — that branch is exercised by the chart and MUST stay +working. + +Empty values are SKIPPED so unset SaaS/dev keys never ship as blank Secret +entries. Values are base64-encoded into `data` (b64enc). +*/}} +{{- if not .Values.streamingHub.useExistingSecret }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "streaming-hub.fullname" . }} + namespace: {{ include "global.namespace" . }} + labels: + {{- include "streaming-hub.labels" (dict "context" . "component" "all") | nindent 4 }} +type: Opaque +data: + {{- range $key, $value := .Values.streamingHub.secrets }} + {{- if $value }} + {{ $key }}: {{ $value | toString | b64enc | quote }} + {{- end }} + {{- end }} +{{- end }} diff --git a/charts/streaming-hub/templates/serviceaccount.yaml b/charts/streaming-hub/templates/serviceaccount.yaml new file mode 100644 index 000000000..c94cb05fc --- /dev/null +++ b/charts/streaming-hub/templates/serviceaccount.yaml @@ -0,0 +1,8 @@ +{{- if .Values.streamingHub.serviceAccount.create }} +{{- include "lerian-common.serviceAccount" (dict + "serviceAccount" .Values.streamingHub.serviceAccount + "name" (include "streaming-hub.serviceAccountName" .) + "namespace" (include "global.namespace" .) + "labels" (include "streaming-hub.labels" (dict "context" . "component" "all")) + ) }} +{{- end }} diff --git a/charts/streaming-hub/values-quickstart.yaml b/charts/streaming-hub/values-quickstart.yaml new file mode 100644 index 000000000..ac83c50d8 --- /dev/null +++ b/charts/streaming-hub/values-quickstart.yaml @@ -0,0 +1,64 @@ +# ============================================================================= +# streaming-hub — QUICKSTART values (the layperson layer) +# ----------------------------------------------------------------------------- +# The full values.yaml is the power-user API. This file is the short list of +# DEPENDENCY-CONNECTION knobs a non-expert fills to get the hub running: where +# Kafka/RedPanda lives, the Postgres DSN, which topology to deploy, the image +# tag, the ingress host, and the operator-provided secrets. Everything else has +# a working default in the chart; reach it only via +# `streamingHub.configmap.` (the escape hatch) when you must. +# +# helm install streaming-hub charts/streaming-hub -f values-quickstart.yaml +# +# Anything with CHANGE_ME MUST be set. Kafka/RedPanda and PostgreSQL are EXTERNAL +# (no bundled subcharts) — point the hub at your cluster below. +# ============================================================================= + +global: + # -- Kafka/RedPanda connection, declared ONCE (the hub emits its native + # STREAMING_HUB_KAFKA_* env from it). Leave a field out to keep the default. + datastores: + kafka: + brokers: "redpanda:9092" # STREAMING_HUB_KAFKA_BROKERS + # saslMechanism: "SCRAM-SHA-512" # STREAMING_HUB_KAFKA_SCRAM_MECHANISM + # saslUsername: "streaming-hub" # STREAMING_HUB_KAFKA_SCRAM_USERNAME + # tls: "true" # STREAMING_HUB_KAFKA_TLS_ENABLED + # caCert: "" # STREAMING_HUB_KAFKA_CA_CERT (base64 PEM) + # -- plugin-access-manager auth, env-wide (per-service override via configmap). + auth: {} + # enabled: "true" # PLUGIN_AUTH_ENABLED + # address: "http://plugin-access-manager-auth:4000" # PLUGIN_AUTH_ADDRESS + +streamingHub: + # -- Topology: `all` (one Deployment, ingest+delivery co-resident) or `split` + # (separate ingest/delivery Deployments). NEVER run both against one cluster. + mode: all + image: + tag: "" # -- pin the hub image version (empty = chart appVersion) + ingress: + enabled: false + # hosts: [{ host: "streaming-hub.example.com", paths: [{ path: "/", pathType: Prefix }] }] + # -- OTLP telemetry to a node-local collector (HOST_IP:4317 via downward API). + telemetry: + enabled: false + # -- Common overrides / env-wide feature toggles (the escape hatch). Examples: + configmap: {} + # STREAMING_HUB_ENV: "production" + # MULTI_TENANT_ENABLED: "true" + # STREAMING_HUB_TENANT_ID: "default" # BYOC single-tenant allowlist + # -- OUT-OF-BAND schema migration Job (the hub never migrates itself). Enable + # it once so ingest/dispatcher find their tables. + migrations: + enabled: true + # -- Operator-provided secrets (or set useExistingSecret=true + existingSecretName + # to source them from Vault/gitops). Empty values are skipped at render. + secrets: + # -- REQUIRED: the single hub-owned Postgres DSN (password embedded). + STREAMING_HUB_POSTGRES_DSN: "CHANGE_ME" # postgres://user:pass@host:5432/db?sslmode=require + # -- REQUIRED when the Kafka SCRAM mechanism is set: + # STREAMING_HUB_KAFKA_SCRAM_PASSWORD: "CHANGE_ME" + # -- KEK material for envelope encryption (>= 32 bytes). Prod injects via deploy layer. + # STREAMING_HUB_DEV_KEK: "CHANGE_ME" + # -- Only when MULTI_TENANT_ENABLED=true (SaaS tenant-manager): + # STREAMING_HUB_TENANT_MANAGER_SERVICE_API_KEY: "CHANGE_ME" + # STREAMING_HUB_MULTI_TENANT_REDIS_PASSWORD: "CHANGE_ME" diff --git a/charts/streaming-hub/values-template.yaml b/charts/streaming-hub/values-template.yaml new file mode 100644 index 000000000..33d4244e5 --- /dev/null +++ b/charts/streaming-hub/values-template.yaml @@ -0,0 +1,49 @@ +# streaming-hub — operator override template. +# +# Copy this file, fill the blanks per environment, and pass it with `-f`. +# It documents only the keys an operator typically sets; every other key keeps +# the default from values.yaml. See README "Chart Contract" for the full +# secret/dependency/override contract. Secrets are blank here — provide them +# inline or via `useExistingSecret` + `existingSecretName`. + +streamingHub: + # Topology: `all` (one Deployment) or `split` (ingest + delivery). Never both + # — the roles compete for one Kafka consumer group. See README. + mode: all + + image: + tag: "" # defaults to the chart appVersion + + configmap: + STREAMING_HUB_ENV: production # local | staging | production + STREAMING_HUB_LOG_LEVEL: info + STREAMING_HUB_KAFKA_BROKERS: "" # e.g. redpanda.dev-st.lerian.net:9092 + STREAMING_HUB_KAFKA_SCRAM_MECHANISM: "" # e.g. SCRAM-SHA-256 (empty = no SASL) + STREAMING_HUB_ALLOW_INSECURE_KAFKA: "false" + STREAMING_HUB_KEK_SOURCE: "" # env | aws-kms | ... + PLUGIN_AUTH_ENABLED: "true" + PLUGIN_AUTH_ADDRESS: "" + STREAMING_HUB_TENANT_ID: default + STREAMING_HUB_MULTI_TENANT_ENABLED: "false" + + secrets: + STREAMING_HUB_POSTGRES_DSN: "" # full Postgres DSN, credentials embedded + STREAMING_HUB_KAFKA_SCRAM_USERNAME: "" + STREAMING_HUB_KAFKA_SCRAM_PASSWORD: "" + STREAMING_HUB_KEK_REF: "" # KEK reference (KMS key id / ARN / ...) + # STREAMING_HUB_DEV_KEK: "" # dev only + # SaaS-only — required when MULTI_TENANT_ENABLED=true: + # STREAMING_HUB_TENANT_MANAGER_SERVICE_API_KEY: "" + # STREAMING_HUB_MULTI_TENANT_REDIS_PASSWORD: "" + + useExistingSecret: false + existingSecretName: "" + + # Per-role sizing. Honor Σ(replicas × poolMaxOpenConns) ≤ Postgres max_connections. + all: + replicaCount: 1 + # mode: split uses these instead: + # ingest: + # replicaCount: 2 + # delivery: + # replicaCount: 2 diff --git a/charts/streaming-hub/values.schema.json b/charts/streaming-hub/values.schema.json new file mode 100644 index 000000000..4fd0cdec6 --- /dev/null +++ b/charts/streaming-hub/values.schema.json @@ -0,0 +1,774 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema#", + "title": "Values schema (generated by productize-chart-env/gen-schema.py)", + "type": "object", + "properties": { + "nameOverride": { + "type": "string", + "default": "", + "description": "Override the chart name component of resource names." + }, + "fullnameOverride": { + "type": "string", + "default": "", + "description": "Override the fully-qualified release name (wins verbatim)." + }, + "namespaceOverride": { + "type": "string", + "default": "", + "description": "Override the namespace (defaults to .Release.Namespace)." + }, + "global": { + "type": "object", + "additionalProperties": true + }, + "streamingHub": { + "type": "object", + "properties": { + "mode": { + "default": "all", + "description": "Deployment topology switch. One of: all | split.", + "enum": [ + "all", + "split" + ] + }, + "image": { + "type": "object", + "properties": { + "repository": { + "default": "ghcr.io/lerianstudio/streaming-hub", + "description": "Container image repository." + }, + "pullPolicy": { + "default": "IfNotPresent", + "description": "Image pull policy." + }, + "tag": { + "default": "", + "description": "Image tag. Empty falls back to Chart.appVersion via the defaultTag helper." + } + }, + "additionalProperties": true + }, + "imagePullSecrets": { + "type": "array", + "description": "Secrets for pulling the image from a private registry." + }, + "revisionHistoryLimit": { + "default": 10, + "description": "Number of old ReplicaSets to retain for rollback." + }, + "annotations": { + "type": "object", + "additionalProperties": true, + "description": "Annotations applied to every Deployment resource." + }, + "podAnnotations": { + "type": "object", + "additionalProperties": true, + "description": "Annotations applied to every pod." + }, + "deploymentStrategy": { + "type": "object", + "description": "Deployment update strategy (shared by all roles).", + "properties": { + "type": { + "default": "RollingUpdate" + }, + "rollingUpdate": { + "type": "object", + "properties": { + "maxSurge": { + "default": 1 + }, + "maxUnavailable": { + "default": 0 + } + }, + "additionalProperties": true + } + }, + "additionalProperties": true + }, + "podSecurityContext": { + "type": "object", + "additionalProperties": true, + "description": "Pod-level security context. Empty by default (the hub needs no fsGroup)." + }, + "securityContext": { + "type": "object", + "description": "Container-level security context (distroless:nonroot, uid/gid 65532).", + "properties": { + "runAsGroup": { + "default": 65532, + "description": "Group ID for the process inside the container." + }, + "runAsUser": { + "default": 65532, + "description": "User ID for the process inside the container." + }, + "runAsNonRoot": { + "default": true, + "description": "Never run as root." + }, + "capabilities": { + "type": "object", + "properties": { + "drop": { + "type": "array" + } + }, + "additionalProperties": true + }, + "readOnlyRootFilesystem": { + "default": true, + "description": "Read-only root filesystem (the image carries no writable state)." + }, + "allowPrivilegeEscalation": { + "default": false + }, + "seccompProfile": { + "type": "object", + "properties": { + "type": { + "default": "RuntimeDefault" + } + }, + "additionalProperties": true + } + }, + "additionalProperties": true + }, + "service": { + "type": "object", + "properties": { + "type": { + "default": "ClusterIP", + "description": "Service type. MUST be ClusterIP (Lerian convention; Ingress fronts external)." + }, + "port": { + "default": 8080, + "description": "Control-plane HTTP port (the hub listens on :8080; see Dockerfile EXPOSE)." + }, + "annotations": { + "type": "object", + "additionalProperties": true, + "description": "Annotations for every Service." + } + }, + "additionalProperties": true + }, + "ingress": { + "type": "object", + "properties": { + "enabled": { + "default": false, + "description": "Enable or disable the control-plane Ingress (opt-in per env in gitops)." + }, + "className": { + "default": "nginx", + "description": "Ingress class name." + }, + "annotations": { + "type": "object", + "additionalProperties": true, + "description": "Additional ingress annotations." + }, + "hosts": { + "type": "array", + "description": "Hosts (default empty; the control API is served on every role)." + }, + "tls": { + "type": "array", + "description": "TLS configuration." + } + }, + "additionalProperties": true + }, + "serviceAccount": { + "type": "object", + "properties": { + "create": { + "default": true, + "description": "Whether a ServiceAccount is created." + }, + "annotations": { + "type": "object", + "additionalProperties": true, + "description": "Annotations for the ServiceAccount (e.g. AWS IRSA role-arn)." + }, + "name": { + "default": "", + "description": "ServiceAccount name. Empty defaults to the chart fullname." + }, + "automountServiceAccountToken": { + "default": false, + "description": "Mount the SA API token into pods. Default false — the hub makes no" + } + }, + "additionalProperties": true + }, + "terminationGracePeriodSeconds": { + "default": 80, + "description": "Graceful-shutdown window. Defaults to the hub's derived SIGTERM drain" + }, + "livenessProbe": { + "type": "object", + "description": "Liveness probe tuning (GET /healthz on the http port; stays 200 during drain).", + "properties": { + "initialDelaySeconds": { + "default": 15 + }, + "periodSeconds": { + "default": 20 + }, + "timeoutSeconds": { + "default": 5 + }, + "successThreshold": { + "default": 1 + }, + "failureThreshold": { + "default": 3 + } + }, + "additionalProperties": true + }, + "readinessProbe": { + "type": "object", + "description": "Readiness probe tuning (GET /readyz; flips NotReady first on SIGTERM).", + "properties": { + "initialDelaySeconds": { + "default": 10 + }, + "periodSeconds": { + "default": 10 + }, + "timeoutSeconds": { + "default": 5 + }, + "successThreshold": { + "default": 1 + }, + "failureThreshold": { + "default": 3 + } + }, + "additionalProperties": true + }, + "nodeSelector": { + "type": "object", + "additionalProperties": true, + "description": "Shared default scheduling (per-role blocks may override)." + }, + "tolerations": { + "type": "array" + }, + "affinity": { + "type": "object", + "additionalProperties": true + }, + "telemetry": { + "type": "object", + "properties": { + "enabled": { + "default": false, + "description": "Inject the per-pod OTLP endpoint override (HOST_IP downward API)." + } + }, + "additionalProperties": true + }, + "useExistingSecret": { + "default": false + }, + "existingSecretName": { + "default": "" + }, + "datastores": { + "type": "object", + "additionalProperties": true, + "description": "DEDICATED datastore mask for this component's OWN dependency" + }, + "configmap": { + "type": "object", + "additionalProperties": true, + "description": "Native per-key escape hatch (highest precedence, the PRIMARY", + "propertyNames": { + "enum": [ + "AWS_REGION", + "ENV", + "ENVIRONMENT_NAME", + "ENV_NAME", + "MULTI_TENANT_ENABLED", + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_INSECURE_EXPORTER", + "OTEL_LIBRARY_NAME", + "OTEL_RESOURCE_DEPLOYMENT_ENVIRONMENT", + "OTEL_RESOURCE_SERVICE_NAME", + "PLUGIN_AUTH_ADDRESS", + "PLUGIN_AUTH_ENABLED", + "POSTGRES_DB", + "POSTGRES_HOST", + "POSTGRES_PASSWORD", + "POSTGRES_PORT", + "POSTGRES_SSLMODE", + "POSTGRES_USER", + "STREAMING_HUB_ALLOW_INSECURE_DB_TLS", + "STREAMING_HUB_ALLOW_INSECURE_KAFKA", + "STREAMING_HUB_ALLOW_PRIVATE_SINKS", + "STREAMING_HUB_AUTODISABLE_ENABLED", + "STREAMING_HUB_AUTODISABLE_FAILURE_SPREAD", + "STREAMING_HUB_AUTODISABLE_FAILURE_WINDOW", + "STREAMING_HUB_AWS_HUB_PRINCIPAL_ARN", + "STREAMING_HUB_AWS_SETUP_TEMPLATE_URL", + "STREAMING_HUB_DEV_KEK", + "STREAMING_HUB_DISPATCH_CLAIM_BATCH", + "STREAMING_HUB_DISPATCH_IDLE_INTERVAL_MS", + "STREAMING_HUB_DISPATCH_WORKERS", + "STREAMING_HUB_DLQ_GROUP", + "STREAMING_HUB_DLQ_PRUNE_INTERVAL", + "STREAMING_HUB_DLQ_RETENTION", + "STREAMING_HUB_ENV", + "STREAMING_HUB_HEALTH_WINDOW", + "STREAMING_HUB_HTTP_LISTEN_ADDR", + "STREAMING_HUB_IDEMPOTENCY_REAP_INTERVAL", + "STREAMING_HUB_IDEMPOTENCY_TTL", + "STREAMING_HUB_KAFKA_BROKERS", + "STREAMING_HUB_KAFKA_CA_CERT", + "STREAMING_HUB_KAFKA_SCRAM_MECHANISM", + "STREAMING_HUB_KAFKA_SCRAM_PASSWORD", + "STREAMING_HUB_KAFKA_SCRAM_USERNAME", + "STREAMING_HUB_KAFKA_TLS_ENABLED", + "STREAMING_HUB_KEK_REF", + "STREAMING_HUB_KEK_SOURCE", + "STREAMING_HUB_LOG_LEVEL", + "STREAMING_HUB_MANIFEST_REFRESH_INTERVAL", + "STREAMING_HUB_MANIFEST_SOURCES", + "STREAMING_HUB_METRICS_ENABLED", + "STREAMING_HUB_MULTI_TENANT_ALLOW_INSECURE_TM", + "STREAMING_HUB_MULTI_TENANT_AWS_REGION", + "STREAMING_HUB_MULTI_TENANT_CACHE_TTL", + "STREAMING_HUB_MULTI_TENANT_ENABLED", + "STREAMING_HUB_MULTI_TENANT_LISTENER_STALENESS_THRESHOLD", + "STREAMING_HUB_MULTI_TENANT_RECONCILE_INTERVAL", + "STREAMING_HUB_MULTI_TENANT_REDIS_CA_CERT", + "STREAMING_HUB_MULTI_TENANT_REDIS_HOST", + "STREAMING_HUB_MULTI_TENANT_REDIS_PORT", + "STREAMING_HUB_MULTI_TENANT_REDIS_TLS", + "STREAMING_HUB_MULTI_TENANT_SECRETS_ENV", + "STREAMING_HUB_MULTI_TENANT_TM_STALENESS_THRESHOLD", + "STREAMING_HUB_PARTITION_CRON_INTERVAL", + "STREAMING_HUB_PARTITION_FUTURE_BUFFER_WEEKS", + "STREAMING_HUB_PARTITION_RETENTION_ENABLED", + "STREAMING_HUB_PARTITION_RETENTION_HORIZON", + "STREAMING_HUB_POISON_THRESHOLD", + "STREAMING_HUB_POSTGRES_CONN_MAX_LIFETIME", + "STREAMING_HUB_POSTGRES_DSN", + "STREAMING_HUB_POSTGRES_MAX_IDLE_CONNS", + "STREAMING_HUB_POSTGRES_MAX_OPEN_CONNS", + "STREAMING_HUB_PRE_STOP_DRAIN_TIMEOUT", + "STREAMING_HUB_PULL_BURST", + "STREAMING_HUB_PULL_RATE", + "STREAMING_HUB_RECONCILER_ENABLED", + "STREAMING_HUB_RECONCILER_INTERVAL", + "STREAMING_HUB_ROLE", + "STREAMING_HUB_SHUTDOWN_TIMEOUT", + "STREAMING_HUB_SWAGGER_ENABLED", + "STREAMING_HUB_TENANT_ID", + "STREAMING_HUB_TENANT_MANAGER_CB_THRESHOLD", + "STREAMING_HUB_TENANT_MANAGER_CB_TIMEOUT", + "STREAMING_HUB_TENANT_MANAGER_URL" + ] + } + }, + "extraEnvVars": { + "type": "object", + "additionalProperties": true, + "description": "Unmodeled extra env vars appended verbatim to the ConfigMap." + }, + "secrets": { + "type": "object", + "additionalProperties": true + }, + "migrations": { + "type": "object", + "properties": { + "enabled": { + "default": false, + "description": "Enable or disable the migrations Job. Default false — opt-in per env" + }, + "useExistingSecret": { + "default": false, + "description": "Optional. When true, the Job reads STREAMING_HUB_POSTGRES_DSN from a" + }, + "existingSecretName": { + "default": "", + "description": "Name of the pre-existing Secret holding STREAMING_HUB_POSTGRES_DSN" + }, + "image": { + "type": "object", + "properties": { + "repository": { + "default": "ghcr.io/lerianstudio/streaming-hub-migrations", + "description": "Migrations image (FROM migrate/migrate + COPY migrations/ /migrations/)." + }, + "tag": { + "default": "", + "description": "Tag for the migrations image. Empty falls back to the app image tag" + }, + "digest": { + "default": "", + "description": "Pin by digest (sha256:...) instead of tag. Wins over tag when set." + }, + "pullPolicy": { + "default": "IfNotPresent", + "description": "Image pull policy." + } + }, + "additionalProperties": true + }, + "backoffLimit": { + "default": 3, + "description": "Maximum retries before the Job is considered failed." + }, + "activeDeadlineSeconds": { + "default": 600, + "description": "Hard wall-clock cap on the Job (seconds)." + }, + "ttlSecondsAfterFinished": { + "default": 600, + "description": "TTL after which a finished Job is garbage-collected (seconds)." + }, + "annotations": { + "type": "object", + "additionalProperties": true, + "description": "Extra annotations on the Job (merged after the hook annotations)." + }, + "podAnnotations": { + "type": "object", + "additionalProperties": true, + "description": "Extra annotations on the migration pod." + }, + "resources": { + "type": "object", + "description": "Resource requests/limits for the migration container.", + "properties": { + "limits": { + "type": "object", + "properties": { + "cpu": { + "default": "250m" + }, + "memory": { + "default": "256Mi" + } + }, + "additionalProperties": true + }, + "requests": { + "type": "object", + "properties": { + "cpu": { + "default": "50m" + }, + "memory": { + "default": "64Mi" + } + }, + "additionalProperties": true + } + }, + "additionalProperties": true + } + }, + "additionalProperties": true + }, + "all": { + "type": "object", + "properties": { + "replicaCount": { + "default": 1 + }, + "poolMaxOpenConns": { + "default": 25 + }, + "poolMaxIdleConns": { + "default": 12 + }, + "resources": { + "type": "object", + "properties": { + "limits": { + "type": "object", + "properties": { + "cpu": { + "default": "500m" + }, + "memory": { + "default": "512Mi" + } + }, + "additionalProperties": true + }, + "requests": { + "type": "object", + "properties": { + "cpu": { + "default": "100m" + }, + "memory": { + "default": "128Mi" + } + }, + "additionalProperties": true + } + }, + "additionalProperties": true + }, + "autoscaling": { + "type": "object", + "properties": { + "enabled": { + "default": false, + "description": "HPA off by default; replicaCount governs. maxReplicas × poolMaxOpenConns" + }, + "minReplicas": { + "default": 1 + }, + "maxReplicas": { + "default": 3 + }, + "targetCPUUtilizationPercentage": { + "default": 80 + }, + "targetMemoryUtilizationPercentage": { + "default": 80 + } + }, + "additionalProperties": true + }, + "pdb": { + "type": "object", + "properties": { + "enabled": { + "default": false + }, + "minAvailable": { + "default": 1 + }, + "annotations": { + "type": "object", + "additionalProperties": true + } + }, + "additionalProperties": true + }, + "nodeSelector": { + "type": "object", + "additionalProperties": true + }, + "tolerations": { + "type": "array" + }, + "affinity": { + "type": "object", + "additionalProperties": true + } + }, + "additionalProperties": true + }, + "ingest": { + "type": "object", + "properties": { + "replicaCount": { + "default": 1 + }, + "poolMaxOpenConns": { + "default": 8 + }, + "poolMaxIdleConns": { + "default": 4 + }, + "resources": { + "type": "object", + "properties": { + "limits": { + "type": "object", + "properties": { + "cpu": { + "default": "500m" + }, + "memory": { + "default": "512Mi" + } + }, + "additionalProperties": true + }, + "requests": { + "type": "object", + "properties": { + "cpu": { + "default": "100m" + }, + "memory": { + "default": "128Mi" + } + }, + "additionalProperties": true + } + }, + "additionalProperties": true + }, + "autoscaling": { + "type": "object", + "properties": { + "enabled": { + "default": false + }, + "minReplicas": { + "default": 1 + }, + "maxReplicas": { + "default": 4, + "description": "maxReplicas × 8 (poolMaxOpenConns) must fit the connection budget." + }, + "targetCPUUtilizationPercentage": { + "default": 80 + }, + "targetMemoryUtilizationPercentage": { + "default": 80 + } + }, + "additionalProperties": true + }, + "pdb": { + "type": "object", + "properties": { + "enabled": { + "default": false + }, + "minAvailable": { + "default": 1 + }, + "annotations": { + "type": "object", + "additionalProperties": true + } + }, + "additionalProperties": true + }, + "nodeSelector": { + "type": "object", + "additionalProperties": true + }, + "tolerations": { + "type": "array" + }, + "affinity": { + "type": "object", + "additionalProperties": true + } + }, + "additionalProperties": true + }, + "delivery": { + "type": "object", + "properties": { + "replicaCount": { + "default": 1 + }, + "poolMaxOpenConns": { + "default": 16 + }, + "poolMaxIdleConns": { + "default": 10 + }, + "resources": { + "type": "object", + "properties": { + "limits": { + "type": "object", + "properties": { + "cpu": { + "default": "500m" + }, + "memory": { + "default": "512Mi" + } + }, + "additionalProperties": true + }, + "requests": { + "type": "object", + "properties": { + "cpu": { + "default": "100m" + }, + "memory": { + "default": "128Mi" + } + }, + "additionalProperties": true + } + }, + "additionalProperties": true + }, + "autoscaling": { + "type": "object", + "properties": { + "enabled": { + "default": false + }, + "minReplicas": { + "default": 1 + }, + "maxReplicas": { + "default": 4, + "description": "maxReplicas × 16 (poolMaxOpenConns) must fit the connection budget." + }, + "targetCPUUtilizationPercentage": { + "default": 80 + }, + "targetMemoryUtilizationPercentage": { + "default": 80 + } + }, + "additionalProperties": true + }, + "pdb": { + "type": "object", + "properties": { + "enabled": { + "default": false + }, + "minAvailable": { + "default": 1 + }, + "annotations": { + "type": "object", + "additionalProperties": true + } + }, + "additionalProperties": true + }, + "nodeSelector": { + "type": "object", + "additionalProperties": true + }, + "tolerations": { + "type": "array" + }, + "affinity": { + "type": "object", + "additionalProperties": true + } + }, + "additionalProperties": true + } + }, + "additionalProperties": true + } + }, + "additionalProperties": true +} diff --git a/charts/streaming-hub/values.yaml b/charts/streaming-hub/values.yaml new file mode 100644 index 000000000..b3e80bd62 --- /dev/null +++ b/charts/streaming-hub/values.yaml @@ -0,0 +1,421 @@ +# Default values for streaming-hub. +# This is a YAML-formatted file. +# +# streaming-hub consumes lib-streaming CloudEvents from Kafka/Redpanda and fans +# them out to per-tenant subscribers. ONE image, ONE binary, RUNTIME-selected +# into a role via STREAMING_HUB_ROLE. This chart exposes that via a single +# `mode` switch (see streamingHub.mode below). +# +# External infra (provisioned OUTSIDE this chart): Kafka/Redpanda and PostgreSQL. +# There are NO database/broker/OTEL subcharts (deliberate — see Chart.yaml). + +# -- Override the chart name component of resource names. +nameOverride: "" +# -- Override the fully-qualified release name (wins verbatim). +fullnameOverride: "" +# -- Override the namespace (defaults to .Release.Namespace). +namespaceOverride: "" +global: + # -- Bootstrap Job for an external/shared PostgreSQL: creates the hub's ONE + # database + role and grants privileges. The hub owns a SINGLE database with + # a tenant_id column (NOT per-tenant DB). Default OFF — dev-st may instead + # point STREAMING_HUB_POSTGRES_DSN at a pre-provisioned managed host. + externalPostgresDefinitions: + # -- Enable or disable the PostgreSQL bootstrap Job. + enabled: false + # -- Name of the database the Job creates (must match the DSN dbname). + database: "streaming-hub" + # -- Name of the login role the Job creates (must match the DSN user). + role: "streaming-hub" + # -- PostgreSQL connection settings for the bootstrap Job. + connection: + # -- PostgreSQL host. + host: "streaming-hub-postgresql" + # -- PostgreSQL port. + port: "5432" + # -- Admin credentials used by the Job to create the DB/role. + postgresAdminLogin: + useExistingSecret: + # -- Existing secret with DB_USER_ADMIN and DB_ADMIN_PASSWORD keys. + name: "" + # -- Admin username (ignored if useExistingSecret.name is set). + username: "postgres" + # -- Admin password (ignored if useExistingSecret.name is set). + password: "" + # -- Credentials for the hub role created by the Job. + hubCredentials: + useExistingSecret: + # -- Existing secret with DB_PASSWORD_HUB key. + name: "" + # -- Password for the hub role (ignored if useExistingSecret.name is set). + password: "" + # -- Env-wide inbound auth (lib-auth / plugin-auth), consumed by + # lerian-common.globalValue. Declare once at the umbrella level; a component + # streamingHub.configmap.PLUGIN_AUTH_* still overrides per-service. + # Precedence: configmap. > global.auth. > chart default. + # Leave empty ({}) to keep the chart defaults. + auth: {} + # enabled: true # PLUGIN_AUTH_ENABLED + # address: "http://plugin-access-manager-auth:4000" # PLUGIN_AUTH_ADDRESS + # -- Env-wide SHARED datastore mask, consumed by lerian-common.datastore.value. + # Declare a dependency connection ONCE at the umbrella; a component + # streamingHub.datastores. (dedicated) or configmap. + # (native) still overrides it. Precedence per field: + # configmap. > streamingHub.datastores.. + # > global.datastores.. > chart default. + # Leave empty ({}) to keep the chart defaults. + datastores: {} + # kafka: # STREAMING_HUB_KAFKA_* connection + # brokers: "redpanda:9092" # STREAMING_HUB_KAFKA_BROKERS + # saslMechanism: "SCRAM-SHA-512" # STREAMING_HUB_KAFKA_SCRAM_MECHANISM + # saslUsername: "streaming-hub" # STREAMING_HUB_KAFKA_SCRAM_USERNAME + # tls: "true" # STREAMING_HUB_KAFKA_TLS_ENABLED + # caCert: "" # STREAMING_HUB_KAFKA_CA_CERT (base64 PEM) +# ============================================================================= +# streamingHub — the single root key for everything the hub owns. +# ============================================================================= +streamingHub: + # -- (enum: all|split) Deployment topology switch. One of: all | split. + # all (default) -> ONE Deployment with STREAMING_HUB_ROLE=all (ingest + + # delivery co-resident; the dev-st target). Byte-equivalent to the + # historical single binary. + # split -> TWO Deployments: ingest (role=ingest) and delivery + # (role=delivery), each scaled independently. + # + # !!! NEVER run both an `all` Deployment AND ingest/delivery against the same + # Kafka cluster: they join ONE consumer group and DOUBLE-CONSUME every event. + # The mode switch enforces either/or — do not work around it. !!! + # The values.schema.json constrains this to the enum ["all","split"]. + mode: all + image: + # -- Container image repository. + repository: ghcr.io/lerianstudio/streaming-hub + # -- Image pull policy. + pullPolicy: IfNotPresent + # -- Image tag. Empty falls back to Chart.appVersion via the defaultTag helper. + tag: "" + # -- (list) Secrets for pulling the image from a private registry. + # @default -- [{name: ghcr-credential}] + imagePullSecrets: + - name: ghcr-credential + # -- Number of old ReplicaSets to retain for rollback. + revisionHistoryLimit: 10 + # -- Annotations applied to every Deployment resource. + annotations: {} + # -- Annotations applied to every pod. + podAnnotations: {} + # -- (object) Deployment update strategy (shared by all roles). + deploymentStrategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + # -- Pod-level security context. Empty by default (the hub needs no fsGroup). + podSecurityContext: {} + # -- (object) Container-level security context (distroless:nonroot, uid/gid 65532). + securityContext: + # -- Group ID for the process inside the container. + runAsGroup: 65532 + # -- User ID for the process inside the container. + runAsUser: 65532 + # -- Never run as root. + runAsNonRoot: true + capabilities: + drop: + - ALL + # -- Read-only root filesystem (the image carries no writable state). + readOnlyRootFilesystem: true + allowPrivilegeEscalation: false + seccompProfile: + type: RuntimeDefault + service: + # -- Service type. MUST be ClusterIP (Lerian convention; Ingress fronts external). + type: ClusterIP + # -- Control-plane HTTP port (the hub listens on :8080; see Dockerfile EXPOSE). + port: 8080 + # -- Annotations for every Service. + annotations: {} + ingress: + # -- Enable or disable the control-plane Ingress (opt-in per env in gitops). + enabled: false + # -- Ingress class name. + className: "nginx" + # -- Additional ingress annotations. + annotations: {} + # -- Hosts (default empty; the control API is served on every role). + hosts: [] + # - host: "streaming-hub.lerian.net" + # paths: + # - path: / + # pathType: Prefix + # -- TLS configuration. + tls: [] + # - secretName: streaming-hub-tls + # hosts: + # - streaming-hub.lerian.net + serviceAccount: + # -- Whether a ServiceAccount is created. + create: true + # -- Annotations for the ServiceAccount (e.g. AWS IRSA role-arn). + annotations: {} + # -- ServiceAccount name. Empty defaults to the chart fullname. + name: "" + # -- Mount the SA API token into pods. Default false — the hub makes no + # in-cluster Kubernetes API calls. (IRSA's projected token is injected by + # the EKS webhook independently of this, so it stays functional.) + automountServiceAccountToken: false + # -- Graceful-shutdown window. Defaults to the hub's derived SIGTERM drain + # ceiling (80s) at STREAMING_HUB_SHUTDOWN_TIMEOUT=30s + + # STREAMING_HUB_PRE_STOP_DRAIN_TIMEOUT=5s: 30s HTTP + 5s consumer-commit + + # 30s dispatcher + 10s slack = 75s, + 5s pre-stop = 80s (see .env.reference). + # If you tune those knobs up, recompute and keep this AT OR ABOVE the new + # ceiling so the orchestrator never SIGKILLs a still-draining replica. NO + # preStop hook is used — the hub self-drains on SIGTERM (PID 1 receives it + # directly; exec-form ENTRYPOINT). + terminationGracePeriodSeconds: 80 + # -- (object) Liveness probe tuning (GET /healthz on the http port; stays 200 during drain). + livenessProbe: + initialDelaySeconds: 15 + periodSeconds: 20 + timeoutSeconds: 5 + successThreshold: 1 + failureThreshold: 3 + # -- (object) Readiness probe tuning (GET /readyz; flips NotReady first on SIGTERM). + readinessProbe: + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 5 + successThreshold: 1 + failureThreshold: 3 + # -- Shared default scheduling (per-role blocks may override). + nodeSelector: {} + tolerations: [] + affinity: {} + # =========================================================================== + # telemetry — CHART-LEVEL OTEL wiring toggle (NOT an app env var; never shipped + # to the container). When enabled, each Deployment injects HOST_IP via the + # downward API and sets OTEL_EXPORTER_OTLP_ENDPOINT=$(HOST_IP):4317 (node-local + # DaemonSet collector pattern; see templates/_deployment.tpl). The hub's own + # Prometheus exposition is governed by STREAMING_HUB_METRICS_ENABLED in the + # ConfigMap above — a separate, app-read knob. + # =========================================================================== + telemetry: + # -- Inject the per-pod OTLP endpoint override (HOST_IP downward API). + enabled: false + # =========================================================================== + # Secret sourcing. + # useExistingSecret=false -> the chart renders templates/secret.yaml (inline). + # useExistingSecret=true -> NO Secret is rendered; Deployments reference + # existingSecretName (the gitops/Vault path). + # =========================================================================== + useExistingSecret: false + existingSecretName: "" + # =========================================================================== + # Configuration API (TIERED productization onto lerian-common). + # + # Typed KNOBS are reserved for DEPENDENCY CONNECTIONS; every other app knob is + # an escape-hatch PASSTHROUGH whose default lives in templates/configmap.yaml. + # + # - Kafka/RedPanda connection -> streamingHub.datastores.kafka.* (dedicated) + # or global.datastores.kafka.* (shared) via lerian-common.datastore.value. + # - Auth (PLUGIN_AUTH_*) -> global.auth.* via lerian-common.globalValue. + # - Everything else -> template default, overridable via + # streamingHub.configmap. (the PRIMARY override surface) + # or streamingHub.extraEnvVars (unmodeled keys). + # + # There are NO per-group config blocks any more (app/server/kek/dispatch/pull/ + # dlq/manifest/reconciler/partition/idempotency/autodisable/multiTenant/security/ + # aws/observability): those defaults live in the template; set an override via + # streamingHub.configmap.. Postgres is a single opaque DSN Secret + # (STREAMING_HUB_POSTGRES_DSN), not host/port/user/db, so it has no datastore + # mask in the ConfigMap (the bootstrap Job carries host/port separately). + # + # DELIBERATELY ABSENT (injected per-Deployment, see the all/ingest/delivery + # blocks below): STREAMING_HUB_ROLE, STREAMING_HUB_POSTGRES_MAX_OPEN_CONNS, + # STREAMING_HUB_POSTGRES_MAX_IDLE_CONNS, STREAMING_HUB_POSTGRES_CONN_MAX_LIFETIME. + # =========================================================================== + # -- (object) DEDICATED datastore mask for this component's OWN dependency + # instance, consumed by lerian-common.datastore.value. Wins over + # global.datastores; native configmap. still wins over both. + # Leave empty ({}) to keep the chart defaults. + datastores: {} + # kafka: # STREAMING_HUB_KAFKA_* connection + # brokers: "redpanda:9092" # STREAMING_HUB_KAFKA_BROKERS + # saslMechanism: "SCRAM-SHA-512" # STREAMING_HUB_KAFKA_SCRAM_MECHANISM + # saslUsername: "streaming-hub" # STREAMING_HUB_KAFKA_SCRAM_USERNAME + # tls: "true" # STREAMING_HUB_KAFKA_TLS_ENABLED + # caCert: "" # STREAMING_HUB_KAFKA_CA_CERT (base64 PEM) + # -- (object) Native per-key escape hatch (highest precedence, the PRIMARY + # override surface). Any UPPER_SNAKE app env var can be pinned here verbatim, + # overriding the template default (and any datastore/global mask). + configmap: {} + # -- Unmodeled extra env vars appended verbatim to the ConfigMap. + extraEnvVars: {} + # =========================================================================== + # secrets — role-INVARIANT, SENSITIVE env (one shared Secret). All default "". + # Empty values are SKIPPED at render so unset SaaS/dev keys never ship blank. + # In gitops these come from Vault via useExistingSecret=true (this map is then + # unused). Every key exists verbatim in streaming-hub/.env.reference. + # =========================================================================== + secrets: + # Single hub-owned Postgres DSN (password embedded -> whole DSN is secret). + STREAMING_HUB_POSTGRES_DSN: "" + # SASL/SCRAM password (required when KAFKA_SCRAM_MECHANISM is set). The + # username + mechanism are non-sensitive config (streamingHub.kafka.*). + STREAMING_HUB_KAFKA_SCRAM_PASSWORD: "" + # DEV-ONLY KEK material holder (local BYOC). Leave empty in real deploys. + # (KEK_SOURCE/KEK_REF are non-sensitive config — streamingHub.kek.*.) + STREAMING_HUB_DEV_KEK: "" + # SaaS tenant-manager X-API-Key (required-when-SaaS). + STREAMING_HUB_TENANT_MANAGER_SERVICE_API_KEY: "" + # SaaS tenant pub/sub Redis AUTH password (optional even in SaaS). + STREAMING_HUB_MULTI_TENANT_REDIS_PASSWORD: "" + # =========================================================================== + # migrations — OUT-OF-BAND schema migration Job (Helm + ArgoCD PreSync hook). + # + # The hub NEVER migrates itself (the app refuses to). Without this Job, ingest / + # dispatcher / partition workers spin on `relation "event_inbox"/"delivery_jobs" + # does not exist (42P01)`. This Job applies the SQL migrations BEFORE the app + # rolls out. + # + # ORDERING (the load-bearing design): the chain is + # bootstrap-postgres (PreSync weight -10, creates role+db) + # -> migration-secret (PreSync weight -5, carries the DSN) + # -> migrations Job (PreSync weight -1, creates the schema) + # -> app Deployment (main Sync) + # + # The runner is the stock `migrate/migrate` toolchain baked with the hub's + # `migrations/` dir; its entrypoint runs + # migrate -path=/migrations -database="$STREAMING_HUB_POSTGRES_DSN" up + # so the Job's ONLY required env is STREAMING_HUB_POSTGRES_DSN (secretKeyRef). + # It runs ONCE regardless of streamingHub.mode (all | split) — it is not per-role. + # =========================================================================== + migrations: + # -- Enable or disable the migrations Job. Default false — opt-in per env + # (consistent with the chart's other optional features). + enabled: false + # -- Optional. When true, the Job reads STREAMING_HUB_POSTGRES_DSN from a + # pre-existing Secret (existingSecretName) instead of the chart-managed + # migration-secret hook. Independent of streamingHub.useExistingSecret; + # set this to point migrations at the app's existing (e.g. Vault) Secret. + useExistingSecret: false + # -- Name of the pre-existing Secret holding STREAMING_HUB_POSTGRES_DSN + # (only used when migrations.useExistingSecret=true). + existingSecretName: "" + image: + # -- Migrations image (FROM migrate/migrate + COPY migrations/ /migrations/). + repository: ghcr.io/lerianstudio/streaming-hub-migrations + # -- Tag for the migrations image. Empty falls back to the app image tag + # (streamingHub.image.tag, or the chart appVersion). An explicit tag + # or digest overrides this. + tag: "" + # -- Pin by digest (sha256:...) instead of tag. Wins over tag when set. + digest: "" + # -- Image pull policy. + pullPolicy: IfNotPresent + # -- Maximum retries before the Job is considered failed. + backoffLimit: 3 + # -- Hard wall-clock cap on the Job (seconds). + activeDeadlineSeconds: 600 + # -- TTL after which a finished Job is garbage-collected (seconds). + ttlSecondsAfterFinished: 600 + # -- Extra annotations on the Job (merged after the hook annotations). + annotations: {} + # -- Extra annotations on the migration pod. + podAnnotations: {} + # -- (object) Resource requests/limits for the migration container. + resources: + limits: + cpu: 250m + memory: 256Mi + requests: + cpu: 50m + memory: 64Mi + # =========================================================================== + # Per-role blocks. Each carries replicaCount, resources, autoscaling, pdb, and + # the Postgres pool sizing injected as explicit per-Deployment env. + # + # !!! CONNECTION-BUDGET INVARIANT (the load-bearing operational rule) !!! + # Σ over all running pods of (replicas × poolMaxOpenConns) + headroom + # ≤ PostgreSQL max_connections. + # Under HPA, use maxReplicas (not replicaCount) in the sum. Every open + # connection on every pod draws from the ONE shared Postgres budget. + # NEVER run mode=all alongside split — see streamingHub.mode. + # Pool defaults (from .env.reference): all 25/12, ingest 8/4, delivery 16/10. + # =========================================================================== + all: + replicaCount: 1 + poolMaxOpenConns: 25 + poolMaxIdleConns: 12 + resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 100m + memory: 128Mi + autoscaling: + # -- HPA off by default; replicaCount governs. maxReplicas × poolMaxOpenConns + # must respect the connection-budget invariant above. + enabled: false + minReplicas: 1 + maxReplicas: 3 + targetCPUUtilizationPercentage: 80 + targetMemoryUtilizationPercentage: 80 + pdb: + enabled: false + minAvailable: 1 + annotations: {} + nodeSelector: {} + tolerations: [] + affinity: {} + ingest: + replicaCount: 1 + poolMaxOpenConns: 8 + poolMaxIdleConns: 4 + resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 100m + memory: 128Mi + autoscaling: + enabled: false + minReplicas: 1 + # -- maxReplicas × 8 (poolMaxOpenConns) must fit the connection budget. + maxReplicas: 4 + targetCPUUtilizationPercentage: 80 + targetMemoryUtilizationPercentage: 80 + pdb: + enabled: false + minAvailable: 1 + annotations: {} + nodeSelector: {} + tolerations: [] + affinity: {} + delivery: + replicaCount: 1 + poolMaxOpenConns: 16 + poolMaxIdleConns: 10 + resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 100m + memory: 128Mi + autoscaling: + enabled: false + minReplicas: 1 + # -- maxReplicas × 16 (poolMaxOpenConns) must fit the connection budget. + maxReplicas: 4 + targetCPUUtilizationPercentage: 80 + targetMemoryUtilizationPercentage: 80 + pdb: + enabled: false + minAvailable: 1 + annotations: {} + nodeSelector: {} + tolerations: [] + affinity: {}