From 05a5be51ad385c9d77e795ca30e1e5a30c0fa7ad Mon Sep 17 00:00:00 2001 From: macjayz Date: Thu, 29 Oct 2026 18:31:52 +0100 Subject: [PATCH] feat: add Helm chart, production dashboard image, and Kubernetes CI/CD Helm chart (deploy/helm/heightproof) - API, worker and dashboard Deployments; optional bundled Postgres and Redis StatefulSets with PVCs, or external URLs. - Secrets generated once and reused across upgrades (lookup), or an existingSecret. - Migrations from infra/migrations (symlinked, single source) applied by an init container under a Postgres advisory lock. - Workers use the pod name as Redis consumer name: the default worker- is worker-1 in every container, so replicas collided. - API startup and liveness probes use /health; readiness uses /ready, which answers 503 when the pod cannot reach Postgres or Redis and ignores the worker's backlog, so a slow worker never takes API pods out of rotation. Alert on /health/pipeline. - Non-root (explicit UID: images use USER node), RuntimeDefault seccomp, read-only root fs, dropped caps, no SA token. - Optional ingress, HPA, PDB; Prometheus scrape annotations; helm test. Production dashboard image: static Vite build on unprivileged nginx that proxies /v1, /ready, /health and the /live WebSocket on the same origin. An empty VITE_HEIGHTPROOF_API_URL now means same-origin. Workflows - kubernetes.yml: chart lint/render, then a kind e2e install that runs helm test and checks events flow API -> Redis -> worker -> Postgres. - release.yml: multi-arch GHCR images with SBOM/provenance on main and tags; chart pushed as an OCI artifact on v* tags. --- .github/workflows/kubernetes.yml | 54 ++++++ .github/workflows/release.yml | 97 +++++++++++ CHANGELOG.md | 7 + README.md | 19 +++ apps/dashboard/src/api.ts | 6 +- deploy/helm/heightproof/.helmignore | 5 + deploy/helm/heightproof/Chart.yaml | 16 ++ deploy/helm/heightproof/README.md | 87 ++++++++++ deploy/helm/heightproof/files/migrations | 1 + deploy/helm/heightproof/templates/NOTES.txt | 23 +++ .../helm/heightproof/templates/_helpers.tpl | 135 +++++++++++++++ deploy/helm/heightproof/templates/api.yaml | 127 +++++++++++++++ .../templates/configmap-migrations.yaml | 13 ++ .../helm/heightproof/templates/dashboard.yaml | 84 ++++++++++ deploy/helm/heightproof/templates/hpa.yaml | 27 +++ .../helm/heightproof/templates/ingress.yaml | 43 +++++ deploy/helm/heightproof/templates/pdb.yaml | 14 ++ .../heightproof/templates/postgresql.yaml | 95 +++++++++++ deploy/helm/heightproof/templates/redis.yaml | 80 +++++++++ deploy/helm/heightproof/templates/secret.yaml | 19 +++ .../heightproof/templates/serviceaccount.yaml | 13 ++ .../templates/tests/test-connection.yaml | 40 +++++ deploy/helm/heightproof/templates/worker.yaml | 70 ++++++++ deploy/helm/heightproof/values.yaml | 154 ++++++++++++++++++ deploy/kind/e2e-values.yaml | 17 ++ deploy/kind/e2e.sh | 49 ++++++ infra/dashboard.prod.Dockerfile | 18 ++ infra/nginx/dashboard.conf.template | 40 +++++ 28 files changed, 1352 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/kubernetes.yml create mode 100644 .github/workflows/release.yml create mode 100644 deploy/helm/heightproof/.helmignore create mode 100644 deploy/helm/heightproof/Chart.yaml create mode 100644 deploy/helm/heightproof/README.md create mode 120000 deploy/helm/heightproof/files/migrations create mode 100644 deploy/helm/heightproof/templates/NOTES.txt create mode 100644 deploy/helm/heightproof/templates/_helpers.tpl create mode 100644 deploy/helm/heightproof/templates/api.yaml create mode 100644 deploy/helm/heightproof/templates/configmap-migrations.yaml create mode 100644 deploy/helm/heightproof/templates/dashboard.yaml create mode 100644 deploy/helm/heightproof/templates/hpa.yaml create mode 100644 deploy/helm/heightproof/templates/ingress.yaml create mode 100644 deploy/helm/heightproof/templates/pdb.yaml create mode 100644 deploy/helm/heightproof/templates/postgresql.yaml create mode 100644 deploy/helm/heightproof/templates/redis.yaml create mode 100644 deploy/helm/heightproof/templates/secret.yaml create mode 100644 deploy/helm/heightproof/templates/serviceaccount.yaml create mode 100644 deploy/helm/heightproof/templates/tests/test-connection.yaml create mode 100644 deploy/helm/heightproof/templates/worker.yaml create mode 100644 deploy/helm/heightproof/values.yaml create mode 100644 deploy/kind/e2e-values.yaml create mode 100755 deploy/kind/e2e.sh create mode 100644 infra/dashboard.prod.Dockerfile create mode 100644 infra/nginx/dashboard.conf.template diff --git a/.github/workflows/kubernetes.yml b/.github/workflows/kubernetes.yml new file mode 100644 index 0000000..9bb6752 --- /dev/null +++ b/.github/workflows/kubernetes.yml @@ -0,0 +1,54 @@ +name: Kubernetes + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + chart-lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Helm + uses: azure/setup-helm@v4 + + - name: Lint chart + run: helm lint deploy/helm/heightproof --strict + + - name: Render default and production-shaped values + run: | + helm template hp deploy/helm/heightproof > /dev/null + helm template hp deploy/helm/heightproof \ + --set postgresql.enabled=false,externalDatabase.url=postgres://u:p@db:5432/hp \ + --set redis.enabled=false,externalRedis.url=redis://cache:6379 \ + --set ingress.enabled=true,api.autoscaling.enabled=true,api.podDisruptionBudget.enabled=true \ + > /dev/null + + kind-e2e: + needs: chart-lint + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Helm + uses: azure/setup-helm@v4 + + - name: Create kind cluster + uses: helm/kind-action@v1 + with: + cluster_name: heightproof + + - name: Build, install and verify + run: deploy/kind/e2e.sh heightproof + + - name: Diagnostics + if: failure() + run: | + kubectl -n heightproof get all,pvc + kubectl -n heightproof describe pods + kubectl -n heightproof logs --all-containers --prefix -l app.kubernetes.io/instance=hp --tail=100 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0db2e1d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,97 @@ +name: Release + +# main publishes :main and :sha- images; a v* tag publishes :X.Y.Z / :X.Y images and the +# Helm chart (as an OCI artifact) at the same version. +on: + push: + branches: [main] + tags: ["v*"] + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + images: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + include: + - component: api + dockerfile: infra/node.Dockerfile + workspace: apps/api + - component: worker + dockerfile: infra/node.Dockerfile + workspace: apps/worker + - component: dashboard + dockerfile: infra/dashboard.prod.Dockerfile + workspace: "" + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup QEMU + uses: docker/setup-qemu-action@v3 + + - name: Setup Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Image metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository_owner }}/heightproof-${{ matrix.component }} + tags: | + type=ref,event=branch + type=sha,format=short + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: ${{ matrix.dockerfile }} + build-args: WORKSPACE=${{ matrix.workspace }} + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=${{ matrix.component }} + cache-to: type=gha,mode=max,scope=${{ matrix.component }} + provenance: true + sbom: true + + chart: + if: startsWith(github.ref, 'refs/tags/v') + needs: images + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Helm + uses: azure/setup-helm@v4 + + - name: Package and push chart + env: + VERSION: ${{ github.ref_name }} + run: | + version="${VERSION#v}" + echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io -u "${{ github.actor }}" --password-stdin + helm package deploy/helm/heightproof --version "$version" --app-version "$version" -d dist + helm push "dist/heightproof-$version.tgz" "oci://ghcr.io/${{ github.repository_owner }}/charts" diff --git a/CHANGELOG.md b/CHANGELOG.md index e19985f..92bbfd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to Heightproof will be documented in this file. +## Unreleased + +- Added a Helm chart (`deploy/helm/heightproof`) for the API, worker and dashboard, with optional bundled Postgres and Redis StatefulSets, generated-and-persisted secrets, advisory-locked migration init containers, optional ingress/HPA/PDB, hardened pod security defaults, and a `helm test` smoke test. +- Added a production dashboard image (`infra/dashboard.prod.Dockerfile`): a static Vite build served by unprivileged nginx that proxies the API and `/live` WebSocket on the same origin. An empty `VITE_HEIGHTPROOF_API_URL` now means "same origin". +- Workers deployed on Kubernetes use the pod name as their Redis consumer name, so replicas no longer collide on `worker-1`. +- Added a Kubernetes CI workflow (chart lint plus a kind end-to-end install that proves events flow API → Redis → worker → Postgres) and a release workflow that publishes multi-arch images with SBOM and provenance to GHCR, and the chart as an OCI artifact on `v*` tags. + ## 0.1.0-alpha Initial alpha release. diff --git a/README.md b/README.md index 500ad6c..e80e3c7 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,25 @@ your own account. The `demo` project is seeded with realistic sample traffic on The dashboard runs at `http://localhost:5173`, the API at `http://localhost:8080`. +### On Kubernetes + +A Helm chart lives in [`deploy/helm/heightproof`](deploy/helm/heightproof). Images are published to +GHCR on every push to `main`, and tagged releases also publish the chart as an OCI artifact. + +```bash +helm install hp oci://ghcr.io/macjayz/charts/heightproof --version \ + --namespace heightproof --create-namespace +``` + +To try it from a checkout on a local [kind](https://kind.sigs.k8s.io) cluster (this is also what CI runs): + +```bash +deploy/kind/e2e.sh +``` + +See the [chart README](deploy/helm/heightproof/README.md) for production values, external +Postgres and Redis, ingress, and scaling notes. + ## How it works ```text diff --git a/apps/dashboard/src/api.ts b/apps/dashboard/src/api.ts index 4e4401e..187067d 100644 --- a/apps/dashboard/src/api.ts +++ b/apps/dashboard/src/api.ts @@ -1,4 +1,8 @@ -export const apiBase = import.meta.env.VITE_HEIGHTPROOF_API_URL ?? "http://localhost:8080"; +// An empty VITE_HEIGHTPROOF_API_URL means the API is proxied on the dashboard's own origin +// (infra/dashboard.prod.Dockerfile); unset keeps the local-dev default. +const configuredApiUrl = import.meta.env.VITE_HEIGHTPROOF_API_URL; +export const apiBase = + configuredApiUrl === "" ? window.location.origin : (configuredApiUrl ?? "http://localhost:8080"); export const demoMode = import.meta.env.VITE_HEIGHTPROOF_DEMO_MODE === "true"; diff --git a/deploy/helm/heightproof/.helmignore b/deploy/helm/heightproof/.helmignore new file mode 100644 index 0000000..a4fc33c --- /dev/null +++ b/deploy/helm/heightproof/.helmignore @@ -0,0 +1,5 @@ +.DS_Store +*.swp +*.bak +*.tmp +ci/ diff --git a/deploy/helm/heightproof/Chart.yaml b/deploy/helm/heightproof/Chart.yaml new file mode 100644 index 0000000..ecc90d9 --- /dev/null +++ b/deploy/helm/heightproof/Chart.yaml @@ -0,0 +1,16 @@ +apiVersion: v2 +name: heightproof +description: Self-hosted EVM RPC reliability monitoring — API, worker, dashboard, and optional bundled Postgres and Redis. +type: application +version: 0.1.0 +appVersion: "0.1.0" +home: https://github.com/macjayz/heightproof +sources: + - https://github.com/macjayz/heightproof +keywords: + - ethereum + - json-rpc + - observability +maintainers: + - name: Mangai Machen John + url: https://github.com/macjayz diff --git a/deploy/helm/heightproof/README.md b/deploy/helm/heightproof/README.md new file mode 100644 index 0000000..62e4e2d --- /dev/null +++ b/deploy/helm/heightproof/README.md @@ -0,0 +1,87 @@ +# Heightproof Helm chart + +Deploys the Heightproof API, worker and dashboard, with optional bundled Postgres and Redis. + +```text + Ingress (optional) + /v1 /live /ready /health │ / + ┌───────────────┴──────────────┐ + ▼ ▼ + api (Deployment) ◄──── proxy ──── dashboard (nginx, static build) + │ │ + │ └─► Redis stream ──► worker (Deployment, N consumers) + ▼ │ + Postgres ◄─────────────────────────────┘ +``` + +## Install + +```bash +helm install hp oci://ghcr.io/macjayz/charts/heightproof --version \ + --namespace heightproof --create-namespace +kubectl -n heightproof port-forward svc/hp-heightproof-dashboard 8080:80 +``` + +Without overrides the chart generates the ingestion API key, admin password and Postgres password +once, stores them in the release Secret, and reuses them on every upgrade. `helm install` prints +the commands that read them back. + +## Production values + +```yaml +auth: + existingSecret: heightproof # keys: heightproof-api-key, admin-password, database-url +postgresql: + enabled: false # use a managed database +redis: + enabled: false +externalRedis: + url: redis://redis.internal:6379 +api: + replicaCount: 2 + podDisruptionBudget: + enabled: true +worker: + autoscaling: + enabled: true +ingress: + enabled: true + className: nginx + host: heightproof.example.com + tls: + - secretName: heightproof-tls + hosts: [heightproof.example.com] +``` + +With `auth.existingSecret` the chart creates no Secret, so `database-url` must be in yours even +when the bundled Postgres is off. + +## How it behaves + +- **Migrations.** `infra/migrations` is mounted from a ConfigMap and applied by an init container + on every API and worker pod. The SQL is idempotent and the run holds a Postgres advisory lock, + so rolling several pods at once is safe. The first migration runs `create extension pgcrypto`, + so the database user needs permission to create it (or create it beforehand). +- **Workers scale horizontally.** Each worker joins the Redis consumer group under its pod name. + The app's default, `worker-`, is `worker-1` in every container, and replicas would collide. +- **The API is stateless except `/live`.** The WebSocket hub is in-process, so with several API + replicas a dashboard only gets live pushes for events ingested by its own pod. Polled views are + correct at any replica count. Fanning out over Redis pub/sub would remove this limitation. +- **Probes.** Startup and liveness use `/health` (the process is up). Readiness uses `/ready`, which + answers 503 when the pod cannot reach Postgres or Redis. It deliberately ignores the worker's + backlog, so a slow worker never takes API pods out of rotation. Alert on `/health/pipeline` + instead: it answers 503 when the oldest unprocessed event is over 60s old or no worker heartbeat + is present. Queue depth and backlog age are also on `/metrics`, and the pods carry + `prometheus.io/*` scrape annotations. +- **Hardening.** All pods run as non-root with `RuntimeDefault` seccomp, no capabilities and no + privilege escalation. The app containers also have a read-only root filesystem, and the service + account token is not mounted. +- **Bundled Postgres and Redis** are single-pod StatefulSets with PVCs, for evaluation. They have + no replication or backups. + +## Verify + +```bash +helm test hp -n heightproof # API health/readiness, dashboard, and dashboard → API proxy +deploy/kind/e2e.sh # full build + install + pipeline check on a local kind cluster +``` diff --git a/deploy/helm/heightproof/files/migrations b/deploy/helm/heightproof/files/migrations new file mode 120000 index 0000000..576ca24 --- /dev/null +++ b/deploy/helm/heightproof/files/migrations @@ -0,0 +1 @@ +../../../../infra/migrations \ No newline at end of file diff --git a/deploy/helm/heightproof/templates/NOTES.txt b/deploy/helm/heightproof/templates/NOTES.txt new file mode 100644 index 0000000..e627212 --- /dev/null +++ b/deploy/helm/heightproof/templates/NOTES.txt @@ -0,0 +1,23 @@ +Heightproof {{ .Chart.AppVersion }} is deployed as release "{{ .Release.Name }}". + +{{- if .Values.ingress.enabled }} + +Open: http{{ if .Values.ingress.tls }}s{{ end }}://{{ .Values.ingress.host }} +{{- else }} + +Port-forward the dashboard (it proxies the API on the same origin): + kubectl -n {{ .Release.Namespace }} port-forward svc/{{ include "heightproof.fullname" . }}-dashboard 8080:{{ .Values.dashboard.service.port }} + open http://localhost:8080 +{{- end }} + +Admin login: {{ .Values.auth.adminEmail }}{{ if not .Values.demo.bootstrapUser }} (requires demo.bootstrapUser=true, or sign up){{ end }} + kubectl -n {{ .Release.Namespace }} get secret {{ include "heightproof.secretName" . }} -o jsonpath='{.data.admin-password}' | base64 -d + +Ingestion API key for the SDK: + kubectl -n {{ .Release.Namespace }} get secret {{ include "heightproof.secretName" . }} -o jsonpath='{.data.heightproof-api-key}' | base64 -d + +Smoke test: helm test {{ .Release.Name }} -n {{ .Release.Namespace }} +{{- if .Values.postgresql.enabled }} + +NOTE: the bundled Postgres is a single pod without backups. Use externalDatabase.url in production. +{{- end }} diff --git a/deploy/helm/heightproof/templates/_helpers.tpl b/deploy/helm/heightproof/templates/_helpers.tpl new file mode 100644 index 0000000..8dff69f --- /dev/null +++ b/deploy/helm/heightproof/templates/_helpers.tpl @@ -0,0 +1,135 @@ +{{- define "heightproof.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "heightproof.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{- define "heightproof.labels" -}} +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }} +app.kubernetes.io/name: {{ include "heightproof.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* Usage: include "heightproof.selectorLabels" (dict "ctx" $ "component" "api") */}} +{{- define "heightproof.selectorLabels" -}} +app.kubernetes.io/name: {{ include "heightproof.name" .ctx }} +app.kubernetes.io/instance: {{ .ctx.Release.Name }} +app.kubernetes.io/component: {{ .component }} +{{- end }} + +{{- define "heightproof.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "heightproof.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} + +{{/* Usage: include "heightproof.image" (dict "ctx" $ "component" "api") */}} +{{- define "heightproof.image" -}} +{{- $img := .ctx.Values.image -}} +{{- printf "%s/%s-%s:%s" $img.registry $img.repository .component (default .ctx.Chart.AppVersion $img.tag) }} +{{- end }} + +{{- define "heightproof.secretName" -}} +{{- default (include "heightproof.fullname" .) .Values.auth.existingSecret }} +{{- end }} + +{{- define "heightproof.postgresHost" -}} +{{- printf "%s-postgresql" (include "heightproof.fullname" .) }} +{{- end }} + +{{- define "heightproof.redisUrl" -}} +{{- if .Values.redis.enabled }} +{{- printf "redis://%s-redis:6379" (include "heightproof.fullname" .) }} +{{- else }} +{{- required "externalRedis.url is required when redis.enabled=false" .Values.externalRedis.url }} +{{- end }} +{{- end }} + +{{/* +Reuse a value already stored in the release Secret so generated credentials stay stable across +upgrades. Usage: include "heightproof.secretValue" (dict "ctx" $ "key" "admin-password" "value" "...") +*/}} +{{- define "heightproof.secretValue" -}} +{{- if .value }} +{{- .value }} +{{- else }} +{{- $existing := lookup "v1" "Secret" .ctx.Release.Namespace (include "heightproof.fullname" .ctx) }} +{{- if and $existing (hasKey $existing.data .key) }} +{{- index $existing.data .key | b64dec }} +{{- else }} +{{- randAlphaNum 32 }} +{{- end }} +{{- end }} +{{- end }} + +{{- define "heightproof.sharedEnv" -}} +- name: DATABASE_URL + valueFrom: + secretKeyRef: + name: {{ include "heightproof.secretName" . }} + key: database-url +- name: REDIS_URL + value: {{ include "heightproof.redisUrl" . | quote }} +- name: HEIGHTPROOF_STREAM + value: {{ .Values.queue.streamName | quote }} +- name: HEIGHTPROOF_GROUP + value: {{ .Values.queue.groupName | quote }} +{{- end }} + +{{/* Writable scratch space for containers running with a read-only root filesystem. */}} +{{- define "heightproof.tmpVolume" -}} +- name: tmp + emptyDir: {} +{{- end }} + +{{- define "heightproof.migrateInitContainer" -}} +{{- if .Values.migrations.enabled }} +- name: migrate + image: {{ .Values.migrations.image }} + imagePullPolicy: IfNotPresent + securityContext: + {{- toYaml .Values.securityContext | nindent 4 }} + runAsUser: 70 + env: + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: {{ include "heightproof.secretName" . }} + key: database-url + command: + - sh + - -ec + - | + until pg_isready -d "$DATABASE_URL" -q; do echo "waiting for postgres"; sleep 2; done + { echo "select pg_advisory_lock(424242);"; cat /migrations/*.sql; } \ + | psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -q + echo "migrations applied" + volumeMounts: + - name: migrations + mountPath: /migrations + readOnly: true +{{- end }} +{{- end }} + +{{- define "heightproof.migrationsVolume" -}} +{{- if .Values.migrations.enabled }} +- name: migrations + configMap: + name: {{ include "heightproof.fullname" . }}-migrations +{{- end }} +{{- end }} diff --git a/deploy/helm/heightproof/templates/api.yaml b/deploy/helm/heightproof/templates/api.yaml new file mode 100644 index 0000000..6d16141 --- /dev/null +++ b/deploy/helm/heightproof/templates/api.yaml @@ -0,0 +1,127 @@ +{{- $c := dict "ctx" $ "component" "api" }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "heightproof.fullname" . }}-api + labels: + {{- include "heightproof.labels" . | nindent 4 }} + app.kubernetes.io/component: api +spec: + {{- if not .Values.api.autoscaling.enabled }} + replicas: {{ .Values.api.replicaCount }} + {{- end }} + selector: + matchLabels: + {{- include "heightproof.selectorLabels" $c | nindent 6 }} + template: + metadata: + labels: + {{- include "heightproof.selectorLabels" $c | nindent 8 }} + annotations: + checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }} + checksum/migrations: {{ include (print $.Template.BasePath "/configmap-migrations.yaml") . | sha256sum }} + {{- if .Values.api.metrics.podAnnotations }} + prometheus.io/scrape: "true" + prometheus.io/port: {{ .Values.api.port | quote }} + prometheus.io/path: /metrics + {{- end }} + spec: + serviceAccountName: {{ include "heightproof.serviceAccountName" . }} + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + initContainers: + {{- include "heightproof.migrateInitContainer" . | nindent 8 }} + containers: + - name: api + image: {{ include "heightproof.image" $c }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + ports: + - name: http + containerPort: {{ .Values.api.port }} + env: + {{- include "heightproof.sharedEnv" . | nindent 12 }} + - name: PORT + value: {{ .Values.api.port | quote }} + - name: HEIGHTPROOF_API_KEY + valueFrom: + secretKeyRef: + name: {{ include "heightproof.secretName" . }} + key: heightproof-api-key + - name: HEIGHTPROOF_ADMIN_EMAIL + value: {{ .Values.auth.adminEmail | quote }} + - name: HEIGHTPROOF_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "heightproof.secretName" . }} + key: admin-password + - name: HEIGHTPROOF_ALLOW_DEV_FALLBACK_KEY + value: {{ .Values.demo.allowDevFallbackKey | quote }} + - name: HEIGHTPROOF_BOOTSTRAP_DEMO_USER + value: {{ .Values.demo.bootstrapUser | quote }} + - name: HEIGHTPROOF_SEED_DEMO_EVENTS + value: {{ .Values.demo.seedEvents | quote }} + {{- with .Values.api.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + # /health: the process is up (startup, liveness). /ready: it can serve, i.e. Postgres and + # Redis are reachable; it answers 503 otherwise, so traffic stops reaching a pod that would + # only return errors. /ready ignores the worker's backlog on purpose (see + # /health/pipeline), so a slow worker never takes API pods out of rotation. + startupProbe: + httpGet: { path: /health, port: http } + periodSeconds: 3 + failureThreshold: 40 + livenessProbe: + httpGet: { path: /health, port: http } + periodSeconds: 10 + readinessProbe: + httpGet: { path: /ready, port: http } + periodSeconds: 5 + failureThreshold: 3 + lifecycle: + # Let endpoint removal propagate before Node exits on SIGTERM. + preStop: + exec: + command: ["sleep", "5"] + resources: + {{- toYaml .Values.api.resources | nindent 12 }} + volumeMounts: + - name: tmp + mountPath: /tmp + volumes: + {{- include "heightproof.tmpVolume" . | nindent 8 }} + {{- include "heightproof.migrationsVolume" . | nindent 8 }} + {{- with .Values.api.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.api.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.api.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "heightproof.fullname" . }}-api + labels: + {{- include "heightproof.labels" . | nindent 4 }} + app.kubernetes.io/component: api +spec: + type: {{ .Values.api.service.type }} + selector: + {{- include "heightproof.selectorLabels" $c | nindent 4 }} + ports: + - name: http + port: {{ .Values.api.service.port }} + targetPort: http diff --git a/deploy/helm/heightproof/templates/configmap-migrations.yaml b/deploy/helm/heightproof/templates/configmap-migrations.yaml new file mode 100644 index 0000000..ddf8682 --- /dev/null +++ b/deploy/helm/heightproof/templates/configmap-migrations.yaml @@ -0,0 +1,13 @@ +{{- if .Values.migrations.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "heightproof.fullname" . }}-migrations + labels: + {{- include "heightproof.labels" . | nindent 4 }} +data: +{{- range $path, $_ := .Files.Glob "files/migrations/*.sql" }} + {{ base $path }}: |- +{{ $.Files.Get $path | indent 4 }} +{{- end }} +{{- end }} diff --git a/deploy/helm/heightproof/templates/dashboard.yaml b/deploy/helm/heightproof/templates/dashboard.yaml new file mode 100644 index 0000000..8fd5c3a --- /dev/null +++ b/deploy/helm/heightproof/templates/dashboard.yaml @@ -0,0 +1,84 @@ +{{- if .Values.dashboard.enabled }} +{{- $c := dict "ctx" $ "component" "dashboard" }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "heightproof.fullname" . }}-dashboard + labels: + {{- include "heightproof.labels" . | nindent 4 }} + app.kubernetes.io/component: dashboard +spec: + replicas: {{ .Values.dashboard.replicaCount }} + selector: + matchLabels: + {{- include "heightproof.selectorLabels" $c | nindent 6 }} + template: + metadata: + labels: + {{- include "heightproof.selectorLabels" $c | nindent 8 }} + spec: + serviceAccountName: {{ include "heightproof.serviceAccountName" . }} + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: dashboard + image: {{ include "heightproof.image" $c }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + env: + - name: API_UPSTREAM + value: {{ printf "http://%s-api:%v" (include "heightproof.fullname" .) .Values.api.service.port | quote }} + ports: + - name: http + containerPort: {{ .Values.dashboard.port }} + livenessProbe: + httpGet: { path: /healthz, port: http } + readinessProbe: + httpGet: { path: /healthz, port: http } + periodSeconds: 5 + resources: + {{- toYaml .Values.dashboard.resources | nindent 12 }} + # nginx renders its config template into conf.d at startup and needs scratch space. + volumeMounts: + - name: tmp + mountPath: /tmp + - name: nginx-conf + mountPath: /etc/nginx/conf.d + volumes: + {{- include "heightproof.tmpVolume" . | nindent 8 }} + - name: nginx-conf + emptyDir: {} + {{- with .Values.dashboard.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.dashboard.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.dashboard.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "heightproof.fullname" . }}-dashboard + labels: + {{- include "heightproof.labels" . | nindent 4 }} + app.kubernetes.io/component: dashboard +spec: + type: {{ .Values.dashboard.service.type }} + selector: + {{- include "heightproof.selectorLabels" $c | nindent 4 }} + ports: + - name: http + port: {{ .Values.dashboard.service.port }} + targetPort: http +{{- end }} diff --git a/deploy/helm/heightproof/templates/hpa.yaml b/deploy/helm/heightproof/templates/hpa.yaml new file mode 100644 index 0000000..f2670b8 --- /dev/null +++ b/deploy/helm/heightproof/templates/hpa.yaml @@ -0,0 +1,27 @@ +{{- range $component := list "api" "worker" }} +{{- $cfg := index $.Values $component }} +{{- if $cfg.autoscaling.enabled }} +--- +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "heightproof.fullname" $ }}-{{ $component }} + labels: + {{- include "heightproof.labels" $ | nindent 4 }} + app.kubernetes.io/component: {{ $component }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "heightproof.fullname" $ }}-{{ $component }} + minReplicas: {{ $cfg.autoscaling.minReplicas }} + maxReplicas: {{ $cfg.autoscaling.maxReplicas }} + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ $cfg.autoscaling.targetCPUUtilizationPercentage }} +{{- end }} +{{- end }} diff --git a/deploy/helm/heightproof/templates/ingress.yaml b/deploy/helm/heightproof/templates/ingress.yaml new file mode 100644 index 0000000..59038b6 --- /dev/null +++ b/deploy/helm/heightproof/templates/ingress.yaml @@ -0,0 +1,43 @@ +{{- if .Values.ingress.enabled }} +{{- $fullname := include "heightproof.fullname" . }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ $fullname }} + labels: + {{- include "heightproof.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .Values.ingress.className }} + ingressClassName: {{ . }} + {{- end }} + {{- with .Values.ingress.tls }} + tls: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + - host: {{ .Values.ingress.host | quote }} + http: + paths: + {{- range list "/v1" "/live" "/ready" "/health" }} + - path: {{ . }} + pathType: Prefix + backend: + service: + name: {{ $fullname }}-api + port: + name: http + {{- end }} + {{- if .Values.dashboard.enabled }} + - path: / + pathType: Prefix + backend: + service: + name: {{ $fullname }}-dashboard + port: + name: http + {{- end }} +{{- end }} diff --git a/deploy/helm/heightproof/templates/pdb.yaml b/deploy/helm/heightproof/templates/pdb.yaml new file mode 100644 index 0000000..829f71d --- /dev/null +++ b/deploy/helm/heightproof/templates/pdb.yaml @@ -0,0 +1,14 @@ +{{- if .Values.api.podDisruptionBudget.enabled }} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "heightproof.fullname" . }}-api + labels: + {{- include "heightproof.labels" . | nindent 4 }} + app.kubernetes.io/component: api +spec: + minAvailable: {{ .Values.api.podDisruptionBudget.minAvailable }} + selector: + matchLabels: + {{- include "heightproof.selectorLabels" (dict "ctx" $ "component" "api") | nindent 6 }} +{{- end }} diff --git a/deploy/helm/heightproof/templates/postgresql.yaml b/deploy/helm/heightproof/templates/postgresql.yaml new file mode 100644 index 0000000..0e12af3 --- /dev/null +++ b/deploy/helm/heightproof/templates/postgresql.yaml @@ -0,0 +1,95 @@ +{{- if .Values.postgresql.enabled }} +{{- $c := dict "ctx" $ "component" "postgresql" }} +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ include "heightproof.postgresHost" . }} + labels: + {{- include "heightproof.labels" . | nindent 4 }} + app.kubernetes.io/component: postgresql +spec: + serviceName: {{ include "heightproof.postgresHost" . }} + replicas: 1 + selector: + matchLabels: + {{- include "heightproof.selectorLabels" $c | nindent 6 }} + template: + metadata: + labels: + {{- include "heightproof.selectorLabels" $c | nindent 8 }} + spec: + automountServiceAccountToken: false + securityContext: + runAsNonRoot: true + runAsUser: 70 + fsGroup: 70 + seccompProfile: + type: RuntimeDefault + containers: + - name: postgresql + image: {{ .Values.postgresql.image }} + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + env: + - name: POSTGRES_USER + value: {{ .Values.postgresql.username | quote }} + - name: POSTGRES_DB + value: {{ .Values.postgresql.database | quote }} + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "heightproof.secretName" . }} + key: postgres-password + - name: PGDATA + value: /var/lib/postgresql/data/pgdata + ports: + - name: postgres + containerPort: 5432 + readinessProbe: + exec: + command: ["sh", "-c", "pg_isready -U \"$POSTGRES_USER\" -d \"$POSTGRES_DB\""] + periodSeconds: 5 + livenessProbe: + exec: + command: ["sh", "-c", "pg_isready -U \"$POSTGRES_USER\" -d \"$POSTGRES_DB\""] + initialDelaySeconds: 20 + periodSeconds: 10 + resources: + {{- toYaml .Values.postgresql.resources | nindent 12 }} + volumeMounts: + - name: data + mountPath: /var/lib/postgresql/data + - name: run + mountPath: /var/run/postgresql + volumes: + - name: run + emptyDir: {} + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: ["ReadWriteOnce"] + {{- with .Values.postgresql.persistence.storageClass }} + storageClassName: {{ . }} + {{- end }} + resources: + requests: + storage: {{ .Values.postgresql.persistence.size }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "heightproof.postgresHost" . }} + labels: + {{- include "heightproof.labels" . | nindent 4 }} + app.kubernetes.io/component: postgresql +spec: + selector: + {{- include "heightproof.selectorLabels" $c | nindent 4 }} + ports: + - name: postgres + port: 5432 + targetPort: postgres +{{- end }} diff --git a/deploy/helm/heightproof/templates/redis.yaml b/deploy/helm/heightproof/templates/redis.yaml new file mode 100644 index 0000000..858fa00 --- /dev/null +++ b/deploy/helm/heightproof/templates/redis.yaml @@ -0,0 +1,80 @@ +{{- if .Values.redis.enabled }} +{{- $c := dict "ctx" $ "component" "redis" }} +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ include "heightproof.fullname" . }}-redis + labels: + {{- include "heightproof.labels" . | nindent 4 }} + app.kubernetes.io/component: redis +spec: + serviceName: {{ include "heightproof.fullname" . }}-redis + replicas: 1 + selector: + matchLabels: + {{- include "heightproof.selectorLabels" $c | nindent 6 }} + template: + metadata: + labels: + {{- include "heightproof.selectorLabels" $c | nindent 8 }} + spec: + automountServiceAccountToken: false + securityContext: + runAsNonRoot: true + runAsUser: 999 + fsGroup: 999 + seccompProfile: + type: RuntimeDefault + containers: + - name: redis + image: {{ .Values.redis.image }} + args: ["--appendonly", "yes", "--dir", "/data"] + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + ports: + - name: redis + containerPort: 6379 + readinessProbe: + exec: + command: ["redis-cli", "ping"] + periodSeconds: 5 + livenessProbe: + exec: + command: ["redis-cli", "ping"] + initialDelaySeconds: 10 + periodSeconds: 10 + resources: + {{- toYaml .Values.redis.resources | nindent 12 }} + volumeMounts: + - name: data + mountPath: /data + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: ["ReadWriteOnce"] + {{- with .Values.redis.persistence.storageClass }} + storageClassName: {{ . }} + {{- end }} + resources: + requests: + storage: {{ .Values.redis.persistence.size }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "heightproof.fullname" . }}-redis + labels: + {{- include "heightproof.labels" . | nindent 4 }} + app.kubernetes.io/component: redis +spec: + selector: + {{- include "heightproof.selectorLabels" $c | nindent 4 }} + ports: + - name: redis + port: 6379 + targetPort: redis +{{- end }} diff --git a/deploy/helm/heightproof/templates/secret.yaml b/deploy/helm/heightproof/templates/secret.yaml new file mode 100644 index 0000000..3d0abff --- /dev/null +++ b/deploy/helm/heightproof/templates/secret.yaml @@ -0,0 +1,19 @@ +{{- if not .Values.auth.existingSecret }} +{{- $pgPassword := include "heightproof.secretValue" (dict "ctx" $ "key" "postgres-password" "value" .Values.postgresql.password) }} +{{- $databaseUrl := .Values.externalDatabase.url }} +{{- if .Values.postgresql.enabled }} +{{- $databaseUrl = printf "postgres://%s:%s@%s:5432/%s" .Values.postgresql.username $pgPassword (include "heightproof.postgresHost" .) .Values.postgresql.database }} +{{- end }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "heightproof.fullname" . }} + labels: + {{- include "heightproof.labels" . | nindent 4 }} +type: Opaque +stringData: + heightproof-api-key: {{ include "heightproof.secretValue" (dict "ctx" $ "key" "heightproof-api-key" "value" .Values.auth.apiKey) | quote }} + admin-password: {{ include "heightproof.secretValue" (dict "ctx" $ "key" "admin-password" "value" .Values.auth.adminPassword) | quote }} + postgres-password: {{ $pgPassword | quote }} + database-url: {{ required "externalDatabase.url is required when postgresql.enabled=false" $databaseUrl | quote }} +{{- end }} diff --git a/deploy/helm/heightproof/templates/serviceaccount.yaml b/deploy/helm/heightproof/templates/serviceaccount.yaml new file mode 100644 index 0000000..07ad1e1 --- /dev/null +++ b/deploy/helm/heightproof/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.serviceAccount.create }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "heightproof.serviceAccountName" . }} + labels: + {{- include "heightproof.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: false +{{- end }} diff --git a/deploy/helm/heightproof/templates/tests/test-connection.yaml b/deploy/helm/heightproof/templates/tests/test-connection.yaml new file mode 100644 index 0000000..07e43e7 --- /dev/null +++ b/deploy/helm/heightproof/templates/tests/test-connection.yaml @@ -0,0 +1,40 @@ +apiVersion: v1 +kind: Pod +metadata: + name: {{ include "heightproof.fullname" . }}-test + labels: + {{- include "heightproof.labels" . | nindent 4 }} + annotations: + helm.sh/hook: test + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded +spec: + restartPolicy: Never + automountServiceAccountToken: false + securityContext: + runAsNonRoot: true + runAsUser: 65534 + seccompProfile: + type: RuntimeDefault + containers: + - name: smoke + image: busybox:1.36 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + command: + - sh + - -ec + - | + api=http://{{ include "heightproof.fullname" . }}-api:{{ .Values.api.service.port }} + wget -qO- "$api/health" + wget -qO- "$api/ready" | tee /dev/stderr | grep -q '"status":"ready"' + {{- if .Values.dashboard.enabled }} + dash=http://{{ include "heightproof.fullname" . }}-dashboard:{{ .Values.dashboard.service.port }} + wget -qO- "$dash/healthz" + wget -qO- "$dash/" | grep -qi '
, and PID is 1 in every container, so + # replicas would collide in the consumer group. The pod name is unique. + - name: HEIGHTPROOF_CONSUMER + valueFrom: + fieldRef: + fieldPath: metadata.name + {{- with .Values.worker.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + resources: + {{- toYaml .Values.worker.resources | nindent 12 }} + volumeMounts: + - name: tmp + mountPath: /tmp + volumes: + {{- include "heightproof.tmpVolume" . | nindent 8 }} + {{- include "heightproof.migrationsVolume" . | nindent 8 }} + {{- with .Values.worker.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.worker.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.worker.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/deploy/helm/heightproof/values.yaml b/deploy/helm/heightproof/values.yaml new file mode 100644 index 0000000..b13b92b --- /dev/null +++ b/deploy/helm/heightproof/values.yaml @@ -0,0 +1,154 @@ +# Images are published to GHCR by .github/workflows/release.yml. An empty tag uses the chart appVersion. +image: + registry: ghcr.io + repository: macjayz/heightproof + tag: "" + pullPolicy: IfNotPresent +imagePullSecrets: [] + +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + create: true + name: "" + annotations: {} + +# Secrets. Leave empty to have the chart generate them once and keep them across upgrades, +# or point existingSecret at a Secret you manage (keys: heightproof-api-key, admin-password, +# database-url). +auth: + existingSecret: "" + apiKey: "" + adminEmail: owner@heightproof.local + adminPassword: "" + +# Demo conveniences mirrored from docker-compose.yml. Keep them off for anything public. +demo: + allowDevFallbackKey: false + bootstrapUser: false + seedEvents: false + +queue: + streamName: heightproof:events + groupName: heightproof-workers + +# Applies infra/migrations (idempotent SQL) from an init container on API and worker pods, +# serialised with a Postgres advisory lock so concurrent rollouts don't race. +migrations: + enabled: true + image: postgres:16-alpine + +# The images declare USER node (non-numeric), so the UID must be explicit for runAsNonRoot to verify. +podSecurityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + seccompProfile: + type: RuntimeDefault + +securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + +api: + # The /live WebSocket hub is in-process, so with more than one replica a dashboard only sees + # live pushes for events ingested by the pod it is connected to. Polling views are unaffected. + replicaCount: 1 + port: 8080 + service: + type: ClusterIP + port: 8080 + resources: + requests: { cpu: 100m, memory: 128Mi } + limits: { memory: 512Mi } + autoscaling: + enabled: false + minReplicas: 2 + maxReplicas: 6 + targetCPUUtilizationPercentage: 70 + podDisruptionBudget: + enabled: false + minAvailable: 1 + # Adds prometheus.io/* scrape annotations for the API's /metrics endpoint. + metrics: + podAnnotations: true + extraEnv: [] + nodeSelector: {} + tolerations: [] + affinity: {} + +worker: + # Each pod joins the Redis consumer group under its own pod name, so replicas share the stream. + replicaCount: 1 + resources: + requests: { cpu: 100m, memory: 128Mi } + limits: { memory: 512Mi } + autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 4 + targetCPUUtilizationPercentage: 70 + extraEnv: [] + nodeSelector: {} + tolerations: [] + affinity: {} + +dashboard: + enabled: true + replicaCount: 1 + port: 8080 + service: + type: ClusterIP + port: 80 + resources: + requests: { cpu: 25m, memory: 32Mi } + limits: { memory: 128Mi } + nodeSelector: {} + tolerations: [] + affinity: {} + +# Routes /v1, /live, /ready and /health to the API and everything else to the dashboard. +ingress: + enabled: false + className: "" + annotations: {} + host: heightproof.local + tls: [] + # - secretName: heightproof-tls + # hosts: [heightproof.local] + +# Bundled single-node Postgres for evaluation. For production set enabled: false and supply +# externalDatabase.url (or database-url in auth.existingSecret). +postgresql: + enabled: true + image: postgres:16-alpine + username: heightproof + database: heightproof + password: "" + persistence: + size: 8Gi + storageClass: "" + resources: + requests: { cpu: 100m, memory: 256Mi } + limits: { memory: 1Gi } + +externalDatabase: + url: "" + +# Bundled Redis with append-only persistence so queued events survive a restart. +redis: + enabled: true + image: redis:7-alpine + persistence: + size: 2Gi + storageClass: "" + resources: + requests: { cpu: 50m, memory: 64Mi } + limits: { memory: 256Mi } + +externalRedis: + url: "" diff --git a/deploy/kind/e2e-values.yaml b/deploy/kind/e2e-values.yaml new file mode 100644 index 0000000..b0d9fbd --- /dev/null +++ b/deploy/kind/e2e-values.yaml @@ -0,0 +1,17 @@ +# Values for the kind end-to-end test: locally built images, demo data on, small footprint. +image: + registry: heightproof.local + repository: e2e + tag: e2e + pullPolicy: Never +demo: + bootstrapUser: true + seedEvents: true +worker: + replicaCount: 2 +postgresql: + persistence: + size: 1Gi +redis: + persistence: + size: 512Mi diff --git a/deploy/kind/e2e.sh b/deploy/kind/e2e.sh new file mode 100755 index 0000000..aa1f843 --- /dev/null +++ b/deploy/kind/e2e.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Builds the three images, loads them into a kind cluster, installs the chart and proves the +# ingest pipeline works end to end (API -> Redis stream -> worker -> Postgres). +# Usage: deploy/kind/e2e.sh [cluster-name] (creates the cluster if it doesn't exist) +set -euo pipefail + +cluster="${1:-heightproof}" +ns=heightproof +release=hp +root="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$root" + +if ! kind get clusters | grep -qx "$cluster"; then + kind create cluster --name "$cluster" --wait 120s +fi +kubectl config use-context "kind-$cluster" >/dev/null + +for component in api worker; do + docker build -f infra/node.Dockerfile --build-arg WORKSPACE="apps/$component" \ + -t "heightproof.local/e2e-$component:e2e" . +done +docker build -f infra/dashboard.prod.Dockerfile -t heightproof.local/e2e-dashboard:e2e . + +for component in api worker dashboard; do + kind load docker-image --name "$cluster" "heightproof.local/e2e-$component:e2e" +done + +helm upgrade --install "$release" deploy/helm/heightproof \ + --namespace "$ns" --create-namespace \ + -f deploy/kind/e2e-values.yaml \ + --wait --timeout 5m + +helm test "$release" -n "$ns" --logs + +echo "Waiting for the worker to persist seeded events..." +for _ in $(seq 1 30); do + count=$(kubectl -n "$ns" exec "statefulset/$release-heightproof-postgresql" -- \ + sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc "select count(*) from api_events"') + if [ "${count:-0}" -gt 0 ]; then + echo "Pipeline OK: $count events persisted" + kubectl -n "$ns" get pods -o wide + exit 0 + fi + sleep 2 +done + +echo "No events reached Postgres" >&2 +kubectl -n "$ns" logs -l app.kubernetes.io/component=worker --tail=50 >&2 || true +exit 1 diff --git a/infra/dashboard.prod.Dockerfile b/infra/dashboard.prod.Dockerfile new file mode 100644 index 0000000..5d4e88f --- /dev/null +++ b/infra/dashboard.prod.Dockerfile @@ -0,0 +1,18 @@ +# Production dashboard image: static Vite build served by unprivileged nginx, which also proxies +# the API on the same origin. infra/dashboard.Dockerfile remains the dev-server image for compose. +FROM node:22-alpine AS build +WORKDIR /app +COPY package.json package-lock.json* tsconfig.json ./ +COPY packages ./packages +COPY apps/dashboard ./apps/dashboard +RUN npm install +# Empty means "same origin": the dashboard calls /v1, /ready and /live on whatever host serves it. +ENV VITE_HEIGHTPROOF_API_URL="" +RUN npm run build -w apps/dashboard + +FROM nginxinc/nginx-unprivileged:1.27-alpine +# Upstream the proxy forwards to; the Helm chart points it at the API Service. +ENV API_UPSTREAM=http://api:8080 +COPY infra/nginx/dashboard.conf.template /etc/nginx/templates/default.conf.template +COPY --from=build /app/apps/dashboard/dist /usr/share/nginx/html +EXPOSE 8080 diff --git a/infra/nginx/dashboard.conf.template b/infra/nginx/dashboard.conf.template new file mode 100644 index 0000000..c5e195a --- /dev/null +++ b/infra/nginx/dashboard.conf.template @@ -0,0 +1,40 @@ +map $http_upgrade $connection_upgrade { + default upgrade; + "" close; +} + +server { + listen 8080; + server_name _; + root /usr/share/nginx/html; + + location = /healthz { + access_log off; + return 200 "ok\n"; + } + + location ~ ^/(v1/|ready$|health$) { + proxy_pass ${API_UPSTREAM}; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location = /live { + proxy_pass ${API_UPSTREAM}; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_read_timeout 1h; + } + + location /assets/ { + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } + + location / { + try_files $uri /index.html; + } +}