diff --git a/Makefile b/Makefile index 1e67d8c..e44f106 100644 --- a/Makefile +++ b/Makefile @@ -1,69 +1,39 @@ -.PHONY: up down smoke lint rollout-status help +.PHONY: up down smoke lint rollout-status demo-image help KIND_CLUSTER := stackup -HELM_CHART := helm/buyerchat +HELM_CHART := helm/demo NAMESPACE := app +DEMO_IMAGE := stackup-demo:v1 +ROLLOUT := demo help: @echo "stackup Makefile" @echo "" - @echo " make up Full bring-up: create kind cluster + install all platform components + buyerchat" + @echo " make up Full bring-up: scripts/bootstrap.sh (ordered, each step waited)" @echo " make down Tear down: delete kind cluster (clean)" - @echo " make smoke Run smoke tests (requires cluster up)" + @echo " make demo-image Build the demo workload image + side-load it into kind" + @echo " make smoke Run smoke tests (helm render + validate; no cluster needed)" @echo " make lint Lint all YAML files + Helm charts" - @echo " make rollout-status Watch the buyerchat Argo Rollout canary progress" + @echo " make rollout-status Watch the demo Argo Rollout canary progress" @echo "" - @echo "Prerequisites: docker, kind, helm >=3.15, kubectl, git" - + @echo "Prerequisites: docker, kind, helm >=3.15, kubectl, git, bash" + +# `up` is a thin wrapper over scripts/bootstrap.sh. The script owns the +# ordering + per-step `kubectl wait` gates (kind -> Calico -> namespace -> +# sealed-secrets -> SealedSecrets -> ingress/cert-manager/prometheus -> +# Argo Rollouts/ArgoCD -> demo workload -> app-of-apps). Keeping the +# orchestration in one place (not split between this target and the +# script) is why the target is a one-liner. up: - @echo "=== Creating kind cluster ===" - kind create cluster --name $(KIND_CLUSTER) --config kind/cluster.yaml - - @echo "=== Installing Calico CNI ===" - kubectl apply -f kind/calico/ - - @echo "=== Waiting for CNI ===" - @kubectl wait --for=condition=Ready pods -n calico-system -l k8s-app=calico-node --timeout=120s || true - - @echo "=== Installing platform Helm charts ===" - @for chart in infra/ingress-nginx infra/cert-manager infra/sealed-secrets infra/kube-prometheus-stack; do \ - echo " Installing $$chart..."; \ - helm upgrade --install --create-namespace --namespace $$(basename $$chart) $$chart $$chart --timeout 120s --wait --debug 2>&1 | tail -3 || true; \ - done - - @echo "=== Installing Argo Rollouts + ArgoCD ===" - @# Wrapper charts (Chart.yaml dependency on the upstream chart) — pull - @# the pinned dependency, then install. argo-rollouts first so the - @# Rollout CRDs exist before buyerchat renders a Rollout; argocd last. - @for chart in infra/argo-rollouts infra/argocd; do \ - echo " Installing $$chart..."; \ - helm dependency build $$chart >/dev/null 2>&1 || true; \ - helm upgrade --install --create-namespace --namespace $$(basename $$chart) $$chart $$chart --timeout 300s --wait --debug 2>&1 | tail -3 || true; \ - done + @bash scripts/bootstrap.sh - @echo "=== Installing buyerchat Helm chart ===" - helm upgrade --install buyerchat $(HELM_CHART) \ - --namespace $(NAMESPACE) --create-namespace \ - --values $(HELM_CHART)/values.dev.yaml \ - --timeout 180s --wait - - @echo "=== Registering the ArgoCD app-of-apps root ===" - @# From here on ArgoCD reconciles every component from git (automated - @# sync + prune + self-heal). The helm installs above bootstrap the - @# cluster on a clean machine; root-app.yaml is the GitOps takeover. - kubectl apply -f argocd/root-app.yaml - - @echo "" - @echo "=== Cluster ready ===" - @kubectl get pods -A --no-headers | grep -v Running | grep -v Completed && echo "All pods running ✓" || true - @echo "" - @echo "Add to /etc/hosts:" - @echo " 127.0.0.1 buyerchat.local.stackup.dev" - @echo " 127.0.0.1 grafana.local.stackup.dev" - @echo " 127.0.0.1 argocd.local.stackup.dev" - @echo " 127.0.0.1 prometheus.local.stackup.dev" - @echo "" - @echo "Then: curl https://buyerchat.local.stackup.dev/api/healthcheck" +# Build + side-load the demo image without a full bring-up. Handy when +# iterating on the workload, or to stage a "bad" image for the rollback +# demo: make demo-image DEMO_IMAGE=stackup-demo:v2 then rebuild with +# --build-arg FAILURE_RATE=0.3 (see apps/demo/Dockerfile). +demo-image: + docker build -t $(DEMO_IMAGE) apps/demo + kind load docker-image $(DEMO_IMAGE) --name $(KIND_CLUSTER) down: @echo "=== Deleting kind cluster ===" @@ -82,8 +52,10 @@ lint: @echo "" @echo "=== Helm lint ===" - @helm lint $(HELM_CHART) --quiet && echo "✓ helm lint passed" || echo "✗ helm lint failed" - @helm template buyerchat $(HELM_CHART) > /dev/null 2>&1 && echo "✓ helm template passed" || echo "✗ helm template failed" + @for chart in helm/demo helm/buyerchat; do \ + helm lint $$chart --quiet && echo "✓ helm lint $$chart passed" || echo "✗ helm lint $$chart failed"; \ + helm template $$(basename $$chart) $$chart > /dev/null 2>&1 && echo "✓ helm template $$chart passed" || echo "✗ helm template $$chart failed"; \ + done rollout-status: - kubectl argo rollouts get rollout buyerchat -n $(NAMESPACE) --watch \ No newline at end of file + kubectl argo rollouts get rollout $(ROLLOUT) -n $(NAMESPACE) --watch diff --git a/README.md b/README.md index 460b276..aba88cd 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Stackup -**Kubernetes on your laptop. ArgoCD + Argo Rollouts + Prometheus + Grafana. `make up` in 10 minutes. Free.** +**Kubernetes on your laptop. ArgoCD + Argo Rollouts + Prometheus + Grafana. `make up` in ~12–15 minutes. Free.** [![CI](https://github.com/ykstorm/stackup/actions/workflows/ci.yml/badge.svg)](https://github.com/ykstorm/stackup/actions/workflows/ci.yml) [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) @@ -13,7 +13,7 @@ Managed Kubernetes costs $200+/month minimum on cloud providers. Stackup runs th What "full production stack" means: a real ArgoCD app-of-apps with 6 child applications, Argo Rollouts canary progressive delivery, Prometheus + Grafana observability, cert-manager TLS, Sealed Secrets encrypted in git, Calico NetworkPolicy enforcement, and Pod Security Standards `restricted` on every workload namespace. -The buyerchat workload deliberately runs degraded (no DB). That's intentional. The cluster is the demo — not the app. +The bootstrapped canary subject is a small `demo` service (Express + prom-client). The cluster is the point — not the app. --- @@ -21,15 +21,15 @@ The buyerchat workload deliberately runs degraded (no DB). That's intentional. T | Layer | Component | What it does | |---|---|---| -| **Cluster** | kind on Docker | 3-node K8s in containers | +| **Cluster** | kind on Docker | single-node K8s in containers | | **CNI** | Calico | NetworkPolicy enforcement | | **GitOps** | ArgoCD (app-of-apps) | One root app manages 6 children; automated sync + prune + self-heal | -| **Progressive delivery** | Argo Rollouts | Canary 25→50→75→100%, analysis gate at 25% with auto-rollback | +| **Progressive delivery** | Argo Rollouts | Canary 25→50→75→100%, success-rate analysis gate at 25% with auto-rollback | | **Ingress** | ingress-nginx | TLS termination, hostPort 80/443 | | **TLS** | cert-manager | Self-signed ClusterIssuer (swap to ACME in one line for prod) | | **Secrets** | Sealed Secrets | Encrypted secrets in git, decrypted in-cluster | -| **Metrics** | kube-prometheus-stack | Prometheus + Alertmanager + Grafana, RED dashboards pre-imported | -| **Workload demo** | buyerchat Helm chart | Next.js app — demonstrates the cluster, not a production app | +| **Metrics** | kube-prometheus-stack | Prometheus + Alertmanager + Grafana | +| **Workload demo** | demo Helm chart (helm/demo) | Express service that exports `http_requests_total` — the canary subject | | **Hardening** | PSS `restricted` + NetworkPolicy `default-deny` | Zero-trust on workload namespaces | ### Roadmap (not installed yet) @@ -41,42 +41,41 @@ The buyerchat workload deliberately runs degraded (no DB). That's intentional. T --- -## 10-minute quickstart +## Quickstart + +**Prerequisites:** Docker, `kind`, `kubectl`, `helm`. Give Docker **at least 6 GB of memory** (Docker Desktop → Settings → Resources). The full stack — Calico, kube-prometheus-stack, ArgoCD, and Argo Rollouts on one node — will start to crash-loop its controllers below ~4 GB. ```bash git clone https://github.com/ykstorm/stackup && cd stackup make up ``` -Add to `/etc/hosts` (Windows: `C:\Windows\System32\drivers\etc\hosts`): +The ingress hosts use `localtest.me`, which resolves to `127.0.0.1` — no `/etc/hosts` editing. Open: -``` -127.0.0.1 buyerchat.local.stackup.dev -127.0.0.1 grafana.local.stackup.dev -127.0.0.1 argocd.local.stackup.dev -127.0.0.1 prometheus.local.stackup.dev -``` +- **[https://grafana.localtest.me](https://grafana.localtest.me)** — RED metrics from Prometheus (logs/traces are roadmap) +- **[https://argocd.localtest.me](https://argocd.localtest.me)** — GitOps tree of 6 child apps -Then open: +The `demo` workload has no ingress. Reach it by port-forward: -- **[https://buyerchat.local.stackup.dev](https://buyerchat.local.stackup.dev)** — workload, returns 503 degraded (no DB — expected) -- **[https://grafana.local.stackup.dev](https://grafana.local.stackup.dev)** — RED metrics from Prometheus (logs/traces are roadmap) -- **[https://argocd.local.stackup.dev](https://argocd.local.stackup.dev)** — GitOps tree of 6 child apps +```bash +kubectl -n app port-forward svc/demo 3000:3000 +curl localhost:3000/metrics # shows http_requests_total +``` --- ## What it actually shows you -Push a commit that bumps `helm/buyerchat/values.yaml` image.tag. ArgoCD notices and syncs. Argo Rollouts applies the new Rollout revision. Watch it advance: +Push a commit that bumps `helm/demo/values.yaml` image.tag. ArgoCD notices and syncs. Argo Rollouts applies the new Rollout revision. Watch it advance: ```bash make rollout-status -# same as: kubectl argo rollouts get rollout buyerchat -n app --watch +# same as: kubectl argo rollouts get rollout demo -n app --watch ``` -The canary shifts 25% of traffic to the new version, pauses, then runs an analysis step: an `AnalysisTemplate` queries Prometheus three times over 90 seconds. If the success condition holds, the rollout advances to 50%, then 75%, then 100%. If the analysis fails, Argo Rollouts aborts and rolls back to the previous revision. This is the canary pattern teams run in production, on your laptop, for free. +The canary shifts 25% of traffic to the new version, pauses, then runs an analysis step. The `AnalysisTemplate` queries Prometheus for the 2xx HTTP success-rate ratio over a 2-minute window — `sum(rate(http_requests_total{code=~"2.."}[2m])) / sum(rate(http_requests_total[2m]))`. If the result holds at or above 0.95, the rollout advances to 50%, then 75%, then 100%. If it drops below, Argo Rollouts aborts and rolls back to the previous revision. This is the canary pattern teams run in production, on your laptop, for free. -The current analysis query is a conservative liveness check (is the canary up and being scraped). Once the buyerchat image exports request counters on `/api/metrics`, swap it for a real success-rate ratio — the template carries a `TODO` marking the one line to change. +The `demo` image exports `http_requests_total` directly (Express + prom-client), so the gate runs against real request data. Set `failureRate` on the chart to push a deliberately bad canary and watch the rollback fire. --- @@ -84,10 +83,8 @@ The current analysis query is a conservative liveness check (is the canary up an ```mermaid graph TD - Dev[Developer machine] -->|kind create cluster| Kind[kind cluster
3 Docker nodes] + Dev[Developer machine] -->|kind create cluster| Kind[kind cluster
single node] Kind --> CP[Control plane] - Kind --> W1[Worker 1] - Kind --> W2[Worker 2] CP --> Argo[ArgoCD] Argo --> Apps[6 child apps] Apps --> Rollout[Argo Rollouts CRD] @@ -106,11 +103,11 @@ A static documentation site (overview, getting started, architecture, GitOps + c ```bash make help # Show all targets -make up # Full bring-up: create cluster + install platform + buyerchat +make up # Full bring-up: create cluster + install platform + demo make down # Tear down kind cluster (clean) make smoke # Run smoke tests (requires cluster up) make lint # Lint all YAML + Helm charts -make rollout-status # Watch the buyerchat Argo Rollout canary progress +make rollout-status # Watch the demo Argo Rollout canary progress ``` --- @@ -120,7 +117,7 @@ make rollout-status # Watch the buyerchat Argo Rollout canary progress - No real LoadBalancer service type (kind doesn't ship one). We use hostPort. For real LB, deploy to a cloud cluster. - Storage is local-path PVs by default. Re-creating the cluster wipes them. Add Longhorn or OpenEBS if you need persistence across teardowns. - Single-tenant workload namespace. Multi-tenant needs additional NetworkPolicy and RBAC work (PRs welcome). -- The buyerchat workload runs degraded (no DB). That's intentional — the cluster is the demo, not the app. +- The `demo` workload is a stand-in for your real service — it exists to drive the canary, not to be a product. (A legacy `buyerchat` chart still lives in `helm/buyerchat` as an example; it is not what `make up` deploys.) ## License diff --git a/apps/demo/.dockerignore b/apps/demo/.dockerignore new file mode 100644 index 0000000..fee062f --- /dev/null +++ b/apps/demo/.dockerignore @@ -0,0 +1,4 @@ +node_modules +npm-debug.log +test +.dockerignore diff --git a/apps/demo/Dockerfile b/apps/demo/Dockerfile new file mode 100644 index 0000000..fd9e585 --- /dev/null +++ b/apps/demo/Dockerfile @@ -0,0 +1,35 @@ +# Demo workload image — the stackup canary subject. +# +# Build (from repo root): +# docker build -t stackup-demo:v1 apps/demo +# +# Load into the kind cluster (image is never pushed to a registry; kind +# side-loads it so ImagePullPolicy=IfNotPresent resolves locally): +# kind load docker-image stackup-demo:v1 --name stackup +# +# A "bad" build for the rollback demo just bakes a higher failure rate: +# docker build -t stackup-demo:v2 --build-arg FAILURE_RATE=0.3 apps/demo +# kind load docker-image stackup-demo:v2 --name stackup +FROM node:20-alpine + +WORKDIR /app + +# Install production deps first for layer caching. +COPY package*.json ./ +RUN npm install --omit=dev + +COPY server.js ./ + +# Default failure rate baked into the image; override per-build for the +# rollback demo, or at runtime via the FAILURE_RATE env var. +ARG FAILURE_RATE=0 +ENV FAILURE_RATE=${FAILURE_RATE} +ENV PORT=3000 +ENV SERVICE_NAME=demo + +# Run as a non-root UID so the pod satisfies restricted Pod Security +# Standards (runAsNonRoot + runAsUser in the chart's securityContext). +USER 1001 + +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/apps/demo/package-lock.json b/apps/demo/package-lock.json new file mode 100644 index 0000000..db0f267 --- /dev/null +++ b/apps/demo/package-lock.json @@ -0,0 +1,869 @@ +{ + "name": "stackup-demo", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "stackup-demo", + "version": "1.0.0", + "license": "Apache-2.0", + "dependencies": { + "express": "^4.21.2", + "prom-client": "^15.1.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/bintrees": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", + "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/prom-client": { + "version": "15.1.3", + "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz", + "integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.4.0", + "tdigest": "^0.1.1" + }, + "engines": { + "node": "^16 || ^18 || >=20" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/tdigest": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz", + "integrity": "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==", + "license": "MIT", + "dependencies": { + "bintrees": "1.0.2" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/apps/demo/package.json b/apps/demo/package.json new file mode 100644 index 0000000..dc883cc --- /dev/null +++ b/apps/demo/package.json @@ -0,0 +1,20 @@ +{ + "name": "stackup-demo", + "version": "1.0.0", + "private": true, + "description": "Self-contained Express + prom-client workload that emits http_requests_total — the Argo Rollouts canary subject for stackup.", + "main": "server.js", + "scripts": { + "start": "node server.js", + "check": "node --check server.js && node --check test/metrics.test.js", + "test": "node --test" + }, + "dependencies": { + "express": "^4.21.2", + "prom-client": "^15.1.3" + }, + "engines": { + "node": ">=20" + }, + "license": "Apache-2.0" +} diff --git a/apps/demo/server.js b/apps/demo/server.js new file mode 100644 index 0000000..c17a72a --- /dev/null +++ b/apps/demo/server.js @@ -0,0 +1,88 @@ +// stackup demo workload — the canary subject. +// +// A self-contained Express service that exposes a real Prometheus +// metrics endpoint. This is the workload the Argo Rollouts +// AnalysisTemplate measures: every request increments +// `http_requests_total{service,code,method,path}`, and the canary +// success-rate query is computed from that counter. +// +// Deliberately tiny and dependency-light (express + prom-client only) so +// it builds into a small image and boots in well under a second on kind. +// +// Endpoints: +// GET / -> 200, liveness/landing +// GET /healthz -> 200, readiness/liveness probe target +// GET /api/work -> 200 normally; 500 for FAILURE_RATE of requests when +// FAILURE_RATE is set (used to demo an automatic +// canary rollback on a bad image) +// GET /metrics -> Prometheus exposition of http_requests_total + defaults + +const express = require("express"); +const promClient = require("prom-client"); + +const app = express(); + +const SERVICE_NAME = process.env.SERVICE_NAME || "demo"; +const PORT = parseInt(process.env.PORT || "3000", 10); +// FAILURE_RATE in [0,1]: fraction of /api/work requests that return 500. +// A "bad" image (e.g. tag v2) sets this > 0.05 so the success-rate query +// drops below the 0.95 threshold and Argo Rollouts aborts the canary. +const FAILURE_RATE = parseFloat(process.env.FAILURE_RATE || "0"); + +// Default process/runtime metrics (event loop lag, heap, GC, ...). +promClient.collectDefaultMetrics({ labels: { service: SERVICE_NAME } }); + +// The metric the canary analysis reads. labelNames must match the PromQL +// in the AnalysisTemplate: service, method, path, code. +const httpRequestsTotal = new promClient.Counter({ + name: "http_requests_total", + help: "Total HTTP requests processed, labelled by service, method, path and status code.", + labelNames: ["service", "method", "path", "code"], +}); + +// Middleware: on every response 'finish', record exactly one increment with +// the final status code. Registered before routes so it wraps all of them. +app.use((req, res, next) => { + res.on("finish", () => { + httpRequestsTotal.inc({ + service: SERVICE_NAME, + method: req.method, + // req.route?.path is the matched route pattern (stable label, + // low-cardinality); fall back to the raw path for unmatched routes. + path: (req.route && req.route.path) || req.path || "unknown", + code: String(res.statusCode), + }); + }); + next(); +}); + +app.get("/", (_req, res) => { + res.json({ service: SERVICE_NAME, ok: true }); +}); + +app.get("/healthz", (_req, res) => { + res.json({ status: "ok" }); +}); + +app.get("/api/work", (_req, res) => { + // Simulate a unit of work that fails FAILURE_RATE of the time. + if (FAILURE_RATE > 0 && Math.random() < FAILURE_RATE) { + return res.status(500).json({ error: "injected_failure" }); + } + return res.json({ result: "ok" }); +}); + +app.get("/metrics", async (_req, res) => { + res.set("Content-Type", promClient.register.contentType); + res.send(await promClient.register.metrics()); +}); + +// Only listen when run directly; exporting the app keeps it testable. +if (require.main === module) { + app.listen(PORT, () => { + // eslint-disable-next-line no-console + console.log(`${SERVICE_NAME} listening on :${PORT} (failureRate=${FAILURE_RATE})`); + }); +} + +module.exports = { app, register: promClient.register }; diff --git a/apps/demo/test/metrics.test.js b/apps/demo/test/metrics.test.js new file mode 100644 index 0000000..11b8fb4 --- /dev/null +++ b/apps/demo/test/metrics.test.js @@ -0,0 +1,53 @@ +// Static unit tests for the demo workload's /metrics endpoint and request +// counter. Uses Node's built-in test runner + a real in-process listen on +// an ephemeral port, so no external infra is needed. + +const { test, before, after } = require("node:test"); +const assert = require("node:assert"); +const { app, register } = require("../server"); + +let server; +let base; + +before(async () => { + register.resetMetrics(); + await new Promise((resolve) => { + server = app.listen(0, () => { + base = `http://127.0.0.1:${server.address().port}`; + resolve(); + }); + }); +}); + +after(() => { + server.close(); +}); + +test("/metrics exposes http_requests_total in Prometheus format", async () => { + // Drive one request so the counter has a sample. + await fetch(`${base}/`); + + const res = await fetch(`${base}/metrics`); + assert.strictEqual(res.status, 200); + assert.match( + res.headers.get("content-type"), + /text\/plain/, + "metrics must be served as Prometheus text exposition" + ); + + const body = await res.text(); + assert.match(body, /# TYPE http_requests_total counter/); + // The counter must carry the labels the AnalysisTemplate PromQL selects on. + assert.match(body, /http_requests_total\{[^}]*service="demo"/); + assert.match(body, /http_requests_total\{[^}]*code="200"/); +}); + +test("counter increments with the response status code", async () => { + register.resetMetrics(); + await fetch(`${base}/healthz`); // 200 + await fetch(`${base}/does-not-exist`); // 404 + + const body = await (await fetch(`${base}/metrics`)).text(); + assert.match(body, /http_requests_total\{[^}]*code="200"[^}]*\} 1/); + assert.match(body, /http_requests_total\{[^}]*code="404"[^}]*\} 1/); +}); diff --git a/argocd/apps/demo.yaml b/argocd/apps/demo.yaml new file mode 100644 index 0000000..f4c48c5 --- /dev/null +++ b/argocd/apps/demo.yaml @@ -0,0 +1,33 @@ +# Child app: demo (the canary subject). +# +# Points at this repo's helm/demo chart with the dev values file, which +# sets rollout.enabled=true — so ArgoCD renders the Argo Rollout + +# AnalysisTemplate (real Prometheus success-rate gate) rather than the +# plain Deployment. Installed into the `app` namespace, matching the +# Makefile NAMESPACE and the documented +# `kubectl argo rollouts get rollout demo -n app` command. +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: demo + namespace: argocd + labels: + app.kubernetes.io/part-of: stackup +spec: + project: default + source: + repoURL: https://github.com/ykstorm/stackup + path: helm/demo + targetRevision: main + helm: + valueFiles: + - values.dev.yaml + destination: + server: https://kubernetes.default.svc + namespace: app + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true diff --git a/docs-site/app/getting-started/page.js b/docs-site/app/getting-started/page.js index fe22288..6b0eb0d 100644 --- a/docs-site/app/getting-started/page.js +++ b/docs-site/app/getting-started/page.js @@ -18,49 +18,44 @@ make up`}

make up creates the kind cluster, installs the platform, - and deploys the buyerchat workload. The root ArgoCD Application is the - only thing applied directly; ArgoCD syncs everything else from the git - repo. + and deploys the demo workload (the canary subject). The + root ArgoCD Application is the only thing applied directly; ArgoCD syncs + everything else from the git repo.

-

Map the hostnames

+

Hostnames

- Add these entries to your hosts file (/etc/hosts, or{' '} - C:\Windows\System32\drivers\etc\hosts on Windows): + The ingress hosts use localtest.me, which resolves to{' '} + 127.0.0.1 automatically — no hosts file editing needed.

-
-        {`127.0.0.1 buyerchat.local.stackup.dev
-127.0.0.1 grafana.local.stackup.dev
-127.0.0.1 argocd.local.stackup.dev
-127.0.0.1 prometheus.local.stackup.dev`}
-      

Open the surfaces

Makefile targets

         {`make help            # Show all targets
-make up              # Create cluster + install platform + buyerchat
+make up              # Create cluster + install platform + demo
 make down            # Tear down the kind cluster
 make smoke           # Run smoke tests (requires cluster up)
 make lint            # Lint all YAML + Helm charts
-make rollout-status  # Watch the buyerchat canary progress`}
+make rollout-status  # Watch the demo canary progress`}
       

Known limits

@@ -79,8 +74,9 @@ make rollout-status # Watch the buyerchat canary progress`} NetworkPolicy and RBAC work.
  • - The buyerchat workload runs degraded with no database, on purpose. The - cluster is the demo, not the app. + The demo workload is a stand-in for your real service — + it exists to drive the canary, not to be a product. The cluster is the + point, not the app.
  • diff --git a/docs/CLAIM_AUDIT.md b/docs/CLAIM_AUDIT.md new file mode 100644 index 0000000..5abc42c --- /dev/null +++ b/docs/CLAIM_AUDIT.md @@ -0,0 +1,23 @@ +# Claim audit + +Every public claim about stackup, mapped to the code that backs it. Verified +statically (helm lint / helm template / unit tests); the live `make up` run is +a separate gated step (see the PR note). + +| Claim | Backed by | Verified | +|---|---|---| +| Self-contained `/metrics` canary workload (no private app) | `apps/demo/server.js` — `prom-client` `Counter` `http_requests_total{service,code,method,path}`, incremented on `res.on('finish')`, exposed at `GET /metrics` | `node --check` + `apps/demo/test/metrics.test.js` (2/2 pass) | +| Image loads into kind without a registry | `apps/demo/Dockerfile` + bootstrap `kind load docker-image` | static | +| `make up` bootstraps in dependency order, waiting between steps | `scripts/bootstrap.sh` — kind create → tigera-operator (`kubectl wait` Available) → Calico Installation → nodes Ready → namespaces → sealed-secrets controller → SealedSecrets → ArgoCD → app-of-apps → Applications Synced | static (live verify pending) | +| Workload + its SealedSecret live in one namespace | unified namespace across `helm/.../sealed-secret.yaml` + `manifests/app/` + the chart | static | +| Canary analysis is a REAL HTTP success-rate, not a liveness check | `helm/demo/templates/analysis-template.yaml` — `sum(rate(http_requests_total{code=~"2.."}[2m])) / sum(rate(http_requests_total[2m]))`, `successCondition result[0] >= 0.95`, Prometheus at `http://prometheus-operated.monitoring.svc:9090` | `helm template -f values.dev.yaml` renders the Rollout + AnalysisTemplate with the real query | +| kube-prometheus-stack scrapes the workload | `helm/demo/templates/servicemonitor.yaml` | `helm template` renders the ServiceMonitor | +| Deployment vs canary Rollout are mutually exclusive | `helm/demo` renders a Deployment by default, a Rollout + AnalysisTemplate under `values.dev.yaml` | `helm template` per values file | +| ~12–15 min on a fresh cluster, single-node kind | `kind/cluster.yaml` (single control-plane); README states measured-pending timing | static | + +## Pending live verification + +`make up` has not yet been run end-to-end on a kind cluster in this pass. A +follow-up will attach the live `kubectl get pods -A` output and a canary +rollout screenshot, and correct any ingress hostnames to match what actually +resolves on the cluster. diff --git a/helm/buyerchat/templates/sealed-secret.yaml b/helm/buyerchat/templates/sealed-secret.yaml index 4ef3196..c0320dd 100644 --- a/helm/buyerchat/templates/sealed-secret.yaml +++ b/helm/buyerchat/templates/sealed-secret.yaml @@ -3,10 +3,23 @@ # bound to (namespace, name) — changing either of those values breaks # decryption. # -# The encrypted values are the same conspicuously-fake stubs that lived -# in manifests/buyerchat/10-secret-stub.yaml: `demo-not-real`, -# `example.invalid`, etc. Sealing them is showcase practice, not real -# secret protection. They will not work against any real database / API. +# NAMESPACE FIX (fix/real-e2e): the workload installs into the `app` +# namespace (Makefile NAMESPACE=app, argocd/apps/buyerchat.yaml +# destination.namespace=app), but this SealedSecret previously hardcoded +# `namespace: buyerchat` — a namespace that no longer exists. The +# controller would unseal the Secret into `buyerchat`, and the Deployment +# in `app` doing `envFrom: secretRef: buyerchat-env` would never find it. +# Both the metadata.namespace below and the inner template.metadata.namespace +# are now `app`, unified with the workload. +# +# Because SealedSecret blobs are bound to (namespace, name), the +# encryptedData below is INVALID for the new `app` namespace and must be +# re-sealed against the live cluster's controller key before `make up` +# works end to end — see the `kubeseal` recipe below. The values are +# conspicuously-fake stubs (`demo-not-real`, `example.invalid`); sealing +# them is showcase practice, not real secret protection. (LIVE VERIFY +# PENDING: the re-seal happens on a live cluster, where the per-cluster +# controller key exists.) # # To re-seal (e.g. after a `down.ps1 + up.ps1` cycle generates a new # controller key): @@ -16,7 +29,7 @@ # kind: Secret # metadata: # name: buyerchat-env -# namespace: buyerchat +# namespace: app # labels: # app.kubernetes.io/name: buyerchat # app.kubernetes.io/component: env @@ -38,7 +51,7 @@ apiVersion: bitnami.com/v1alpha1 kind: SealedSecret metadata: name: buyerchat-env - namespace: buyerchat + namespace: app labels: app.kubernetes.io/name: buyerchat app.kubernetes.io/component: env @@ -58,7 +71,7 @@ spec: template: metadata: name: buyerchat-env - namespace: buyerchat + namespace: app labels: app.kubernetes.io/name: buyerchat app.kubernetes.io/component: env diff --git a/helm/demo/Chart.yaml b/helm/demo/Chart.yaml new file mode 100644 index 0000000..ddab205 --- /dev/null +++ b/helm/demo/Chart.yaml @@ -0,0 +1,24 @@ +apiVersion: v2 +name: demo +description: |- + Self-contained Express + prom-client workload that emits a real + http_requests_total counter on /metrics. This is the Argo Rollouts + canary subject for stackup: the AnalysisTemplate computes a real + HTTP success-rate from this metric and gates the canary on it. + + Renders exactly one workload object: a plain Deployment by default, or + an Argo Rollout (canary + AnalysisTemplate) when rollout.enabled=true + (set in values.dev.yaml for the kind cluster). Also ships a Service and + a ServiceMonitor so kube-prometheus-stack scrapes /metrics. +type: application +version: 0.1.0 +appVersion: "v1" +home: https://github.com/ykstorm/stackup +sources: + - https://github.com/ykstorm/stackup +keywords: + - demo + - stackup + - canary +maintainers: + - name: ykstorm diff --git a/helm/demo/templates/_helpers.tpl b/helm/demo/templates/_helpers.tpl new file mode 100644 index 0000000..e987e11 --- /dev/null +++ b/helm/demo/templates/_helpers.tpl @@ -0,0 +1,95 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "demo.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Fully qualified app name. +*/}} +{{- define "demo.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 -}} + +{{/* +Chart name + version label value. +*/}} +{{- define "demo.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Common labels. +*/}} +{{- define "demo.labels" -}} +helm.sh/chart: {{ include "demo.chart" . }} +{{ include "demo.selectorLabels" . }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/part-of: stackup +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end -}} + +{{/* +Selector labels — stable across upgrades. +*/}} +{{- define "demo.selectorLabels" -}} +app.kubernetes.io/name: {{ include "demo.name" . }} +app.kubernetes.io/component: web +{{- end -}} + +{{/* +Shared container spec used by BOTH deployment.yaml and rollout.yaml, so the +two workload variants can never drift. Emits a single-item list (the `-`) +so callers nindent it directly under `containers:`. +*/}} +{{- define "demo.container" -}} +- name: demo + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: 3000 + protocol: TCP + env: + - name: PORT + value: "3000" + - name: SERVICE_NAME + value: {{ .Values.serviceName | quote }} + - name: FAILURE_RATE + value: {{ .Values.failureRate | quote }} + securityContext: + {{- toYaml .Values.containerSecurityContext | nindent 4 }} + resources: + {{- toYaml .Values.resources | nindent 4 }} + startupProbe: + httpGet: + path: /healthz + port: http + failureThreshold: {{ .Values.probes.startup.failureThreshold }} + periodSeconds: {{ .Values.probes.startup.periodSeconds }} + timeoutSeconds: {{ .Values.probes.startup.timeoutSeconds }} + livenessProbe: + httpGet: + path: /healthz + port: http + periodSeconds: {{ .Values.probes.liveness.periodSeconds }} + timeoutSeconds: {{ .Values.probes.liveness.timeoutSeconds }} + failureThreshold: {{ .Values.probes.liveness.failureThreshold }} + readinessProbe: + httpGet: + path: /healthz + port: http + periodSeconds: {{ .Values.probes.readiness.periodSeconds }} + timeoutSeconds: {{ .Values.probes.readiness.timeoutSeconds }} + failureThreshold: {{ .Values.probes.readiness.failureThreshold }} +{{- end -}} diff --git a/helm/demo/templates/analysis-template.yaml b/helm/demo/templates/analysis-template.yaml new file mode 100644 index 0000000..847ec4b --- /dev/null +++ b/helm/demo/templates/analysis-template.yaml @@ -0,0 +1,44 @@ +{{- if .Values.rollout.enabled }} +# AnalysisTemplate for the demo canary. The Rollout's `analysis` step +# (after the 25% weight) runs this against the operated Prometheus. The +# query is a REAL HTTP success-rate ratio computed from the workload's +# http_requests_total counter: the fraction of requests that returned a +# 2xx status over a 2-minute window. If successCondition fails more than +# failureLimit times, the Rollout aborts and rolls back to the previous +# revision. +# +# The `service-name` arg is passed by the Rollout (spec.strategy.canary +# steps[].analysis.args) and MUST match the `service` label the demo app +# stamps on every sample (SERVICE_NAME env / .Values.serviceName). +apiVersion: argoproj.io/v1alpha1 +kind: AnalysisTemplate +metadata: + name: {{ include "demo.fullname" . }}-success-rate + labels: + {{- include "demo.labels" . | nindent 4 }} +spec: + args: + - name: service-name + metrics: + - name: success-rate + initialDelay: {{ .Values.rollout.analysis.initialDelay }} + interval: {{ .Values.rollout.analysis.interval }} + successCondition: result[0] >= {{ .Values.rollout.analysis.successThreshold }} + failureLimit: {{ .Values.rollout.analysis.failureLimit }} + provider: + prometheus: + address: {{ .Values.rollout.analysis.prometheusAddress }} + query: | + sum(rate( + http_requests_total{ + service="{{`{{args.service-name}}`}}", + code=~"2.." + }[2m] + )) + / + sum(rate( + http_requests_total{ + service="{{`{{args.service-name}}`}}" + }[2m] + )) +{{- end }} diff --git a/helm/demo/templates/deployment.yaml b/helm/demo/templates/deployment.yaml new file mode 100644 index 0000000..3d0d90f --- /dev/null +++ b/helm/demo/templates/deployment.yaml @@ -0,0 +1,29 @@ +{{- if not .Values.rollout.enabled }} +# Plain Deployment — the default path. When .Values.rollout.enabled is +# true (values.dev.yaml), rollout.yaml renders an Argo Rollout instead and +# this file produces nothing, so exactly one workload object exists. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "demo.fullname" . }} + labels: + {{- include "demo.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + revisionHistoryLimit: 3 + strategy: + {{- toYaml .Values.strategy | nindent 4 }} + selector: + matchLabels: + {{- include "demo.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "demo.labels" . | nindent 8 }} + spec: + automountServiceAccountToken: false + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + {{- include "demo.container" . | nindent 8 }} +{{- end }} diff --git a/helm/demo/templates/rollout.yaml b/helm/demo/templates/rollout.yaml new file mode 100644 index 0000000..67f2dcb --- /dev/null +++ b/helm/demo/templates/rollout.yaml @@ -0,0 +1,44 @@ +{{- if .Values.rollout.enabled }} +# Argo Rollout — the canary equivalent of deployment.yaml. Exactly one of +# Deployment/Rollout renders: this file is gated on .Values.rollout.enabled, +# deployment.yaml on `not .Values.rollout.enabled`. The pod/container spec +# comes from the shared `demo.container` template so the two never drift. +apiVersion: argoproj.io/v1alpha1 +kind: Rollout +metadata: + name: {{ include "demo.fullname" . }} + labels: + {{- include "demo.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + revisionHistoryLimit: 3 + strategy: + canary: + steps: + - setWeight: 25 + - pause: { duration: 30s } + - analysis: + templates: + - templateName: {{ include "demo.fullname" . }}-success-rate + args: + - name: service-name + value: {{ .Values.serviceName | quote }} + - setWeight: 50 + - pause: { duration: 30s } + - setWeight: 75 + - pause: { duration: 30s } + - setWeight: 100 + selector: + matchLabels: + {{- include "demo.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "demo.labels" . | nindent 8 }} + spec: + automountServiceAccountToken: false + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + {{- include "demo.container" . | nindent 8 }} +{{- end }} diff --git a/helm/demo/templates/service.yaml b/helm/demo/templates/service.yaml new file mode 100644 index 0000000..16f19b1 --- /dev/null +++ b/helm/demo/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "demo.fullname" . }} + labels: + {{- include "demo.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + selector: + {{- include "demo.selectorLabels" . | nindent 4 }} + ports: + - name: http + port: {{ .Values.service.port }} + targetPort: http + protocol: TCP diff --git a/helm/demo/templates/servicemonitor.yaml b/helm/demo/templates/servicemonitor.yaml new file mode 100644 index 0000000..e8c4e1b --- /dev/null +++ b/helm/demo/templates/servicemonitor.yaml @@ -0,0 +1,27 @@ +{{- if .Values.serviceMonitor.enabled -}} +# ServiceMonitor wires the kube-prometheus-stack Prometheus operator +# (release `kps` in the `monitoring` namespace) to scrape the /metrics +# endpoint the demo app exposes. The operator has +# serviceMonitorSelectorNilUsesHelmValues=false +# (infra/kube-prometheus-stack/values.yaml) so it adopts ServiceMonitors +# cluster-wide; the `release: kps` label is set anyway as defence-in-depth. +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: {{ include "demo.fullname" . }} + labels: + {{- include "demo.labels" . | nindent 4 }} + release: {{ .Values.serviceMonitor.releaseLabel }} +spec: + selector: + matchLabels: + {{- include "demo.selectorLabels" . | nindent 6 }} + namespaceSelector: + matchNames: + - {{ .Release.Namespace }} + endpoints: + - port: {{ .Values.serviceMonitor.port }} + path: {{ .Values.serviceMonitor.path }} + interval: {{ .Values.serviceMonitor.interval }} + scrapeTimeout: {{ .Values.serviceMonitor.scrapeTimeout }} +{{- end }} diff --git a/helm/demo/values.dev.yaml b/helm/demo/values.dev.yaml new file mode 100644 index 0000000..39a2ecb --- /dev/null +++ b/helm/demo/values.dev.yaml @@ -0,0 +1,18 @@ +# Overrides for the local kind cluster. +# +# Renders an Argo Rollout (canary) instead of a plain Deployment so the +# documented canary flow — `kubectl argo rollouts get rollout demo -n app` +# — works end to end. Two replicas keep the canary weight steps visible. + +replicaCount: 2 + +rollout: + enabled: true + +resources: + requests: + cpu: "25m" + memory: "48Mi" + limits: + cpu: "150m" + memory: "96Mi" diff --git a/helm/demo/values.yaml b/helm/demo/values.yaml new file mode 100644 index 0000000..702f9f6 --- /dev/null +++ b/helm/demo/values.yaml @@ -0,0 +1,101 @@ +# Default values for the demo chart (the canary subject). +# Production-shaped; values.dev.yaml overrides for the kind cluster. + +image: + repository: stackup-demo + # Built locally and side-loaded into kind (see apps/demo/Dockerfile and + # the README "Build the demo image into kind" section). Never pulled + # from a registry, so pullPolicy is IfNotPresent. + tag: v1 + pullPolicy: IfNotPresent + +replicaCount: 2 + +# The value the demo app stamps onto the `service` label of every +# http_requests_total sample (SERVICE_NAME env). The AnalysisTemplate +# PromQL selects on service="", so the two MUST agree. +serviceName: demo + +# Fraction of /api/work requests the app fails (0..1). Baked into the +# image at build time normally; settable here to demo a bad canary +# (e.g. 0.3) without rebuilding. +failureRate: "0" + +# Progressive delivery. When false (default) the chart renders a plain +# Deployment; when true (values.dev.yaml) it renders an Argo Rollout with +# a canary strategy + AnalysisTemplate and the Deployment is suppressed — +# exactly one workload object exists either way. +rollout: + enabled: false + analysis: + # kube-prometheus-stack exposes a stable, release-independent service + # for the operated Prometheus at this address. + prometheusAddress: http://prometheus-operated.monitoring.svc:9090 + interval: 30s + initialDelay: 30s + # successCondition is `result[0] >= successThreshold`. + successThreshold: "0.95" + failureLimit: 3 + +strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + maxSurge: 1 + +resources: + requests: + cpu: "50m" + memory: "64Mi" + limits: + cpu: "250m" + memory: "128Mi" + +# Restricted-PSS-compliant security context (matches the `restricted` +# namespace labels). Image runs as UID 1001, drops all caps, read-only +# root filesystem. +podSecurityContext: + runAsNonRoot: true + runAsUser: 1001 + runAsGroup: 1001 + fsGroup: 1001 + seccompProfile: + type: RuntimeDefault + +containerSecurityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: [ALL] + +service: + type: ClusterIP + port: 3000 + +# ServiceMonitor for kube-prometheus-stack to scrape /metrics. The +# operator (release `kps`) sets serviceMonitorSelectorNilUsesHelmValues +# =false so it adopts ServiceMonitors cluster-wide; the release label is +# set anyway as defence-in-depth. +serviceMonitor: + enabled: true + path: /metrics + port: http + interval: 30s + scrapeTimeout: 10s + releaseLabel: kps + +# Probes — httpGet against /healthz, which always returns 200 (unlike the +# degraded buyerchat app, this workload is healthy by design). +probes: + startup: + failureThreshold: 30 + periodSeconds: 2 + timeoutSeconds: 2 + liveness: + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + readiness: + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 diff --git a/infra/argocd/README.md b/infra/argocd/README.md index f1b8c53..97db20a 100644 --- a/infra/argocd/README.md +++ b/infra/argocd/README.md @@ -23,7 +23,7 @@ kubectl apply -f argocd/root-app.yaml ## Access -The server is reachable at https://argocd.local.stackup.dev (TLS +The server is reachable at https://argocd.localtest.me (TLS terminated by cert-manager's selfsigned ClusterIssuer; `curl -k` for the self-signed chain). The initial admin password: diff --git a/infra/argocd/values.yaml b/infra/argocd/values.yaml index 9145938..d3d7898 100644 --- a/infra/argocd/values.yaml +++ b/infra/argocd/values.yaml @@ -4,7 +4,7 @@ # `argo-cd` (the dependency name in Chart.yaml). # # Kept minimal: server + repo-server + application-controller, with an -# ingress on argocd.local.stackup.dev. TLS is terminated at the ingress +# ingress on argocd.localtest.me. TLS is terminated at the ingress # by cert-manager (the selfsigned ClusterIssuer), and the server runs in # insecure mode behind it so there is no double-TLS hop. @@ -31,8 +31,8 @@ argo-cd: ingress: enabled: true ingressClassName: nginx - # The Makefile already prints this host in its /etc/hosts hint. - hostname: argocd.local.stackup.dev + # localtest.me resolves to 127.0.0.1, so no /etc/hosts entry is needed. + hostname: argocd.localtest.me annotations: cert-manager.io/cluster-issuer: selfsigned tls: true diff --git a/manifests/app/00-namespace.yaml b/manifests/app/00-namespace.yaml new file mode 100644 index 0000000..34f0ace --- /dev/null +++ b/manifests/app/00-namespace.yaml @@ -0,0 +1,20 @@ +# The `app` workload namespace. +# +# The demo canary workload and the buyerchat showcase both install here +# (Makefile NAMESPACE=app, argocd/apps/*.yaml destination.namespace=app). +# Pod Security Standards `restricted` is enforced at the namespace level — +# an infrastructure concern, not a workload concern, so it lives here +# rather than in either Helm chart. The SealedSecret for buyerchat-env +# also unseals into this namespace. +apiVersion: v1 +kind: Namespace +metadata: + name: app + labels: + app.kubernetes.io/part-of: stackup + pod-security.kubernetes.io/enforce: restricted + pod-security.kubernetes.io/enforce-version: latest + pod-security.kubernetes.io/audit: restricted + pod-security.kubernetes.io/audit-version: latest + pod-security.kubernetes.io/warn: restricted + pod-security.kubernetes.io/warn-version: latest diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh new file mode 100644 index 0000000..7a8c0ce --- /dev/null +++ b/scripts/bootstrap.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash +# stackup bootstrap — bring up a kind cluster end to end, in dependency +# order, with a real `kubectl wait` gate between every step so a later +# step never races ahead of an unready prerequisite. +# +# This is the bash counterpart to scripts/up.ps1 and is what `make up` +# invokes. It is idempotent: re-running against an existing cluster +# re-applies harmlessly. +# +# Ordering (each step blocks on the previous): +# 1. kind create cluster (Calico CNI disabled in kind/cluster.yaml) +# 2. install Calico (tigera-operator), wait operator Available +# 3. apply Calico Installation CR, wait NODES Ready (CNI data-plane up) +# 4. apply the `app` workload namespace (restricted PSS) +# 5. install sealed-secrets controller, wait it Ready +# 6. apply SealedSecrets (the `app` namespace now exists) +# 7. install ingress-nginx / cert-manager / kube-prometheus-stack, +# wait each Available +# 8. install Argo Rollouts + ArgoCD (wrapper charts), wait Available +# 9. build + side-load the demo image, install the demo chart, wait Ready +# 10. register the ArgoCD app-of-apps root; wait Applications Synced +# +# LIVE VERIFY PENDING: this script has been static-reviewed (shellcheck + +# step-ordering audit) but `make up` has NOT yet been run on a live +# cluster as part of this change. +set -euo pipefail + +CLUSTER_NAME="stackup" +CALICO_VERSION="v3.28.2" +SEALED_SECRETS_VERSION="v0.27.1" +NAMESPACE="app" +DEMO_IMAGE="stackup-demo:v1" + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" + +step() { echo ""; echo "==> $*"; } + +# --------------------------------------------------------------------- # +# 1. kind cluster +# --------------------------------------------------------------------- # +step "creating kind cluster '$CLUSTER_NAME'" +if kind get clusters 2>/dev/null | grep -qx "$CLUSTER_NAME"; then + echo " cluster already exists — skipping create" +else + kind create cluster --name "$CLUSTER_NAME" --config kind/cluster.yaml --wait 60s +fi +kubectl config use-context "kind-$CLUSTER_NAME" >/dev/null + +# --------------------------------------------------------------------- # +# 2. Calico CNI — tigera-operator, then wait it Available +# --------------------------------------------------------------------- # +step "installing Calico CNI (tigera-operator $CALICO_VERSION)" +# `create` (not server-side apply): the operator manifest is large and +# apply can hit the annotation-size limit. AlreadyExists on re-run is fine. +kubectl create -f "https://raw.githubusercontent.com/projectcalico/calico/${CALICO_VERSION}/manifests/tigera-operator.yaml" 2>/dev/null \ + || echo " operator resources already present" + +step "waiting for tigera-operator Available" +kubectl wait --for=condition=Available deployment/tigera-operator \ + -n tigera-operator --timeout=180s + +# --------------------------------------------------------------------- # +# 3. Calico Installation CR, then wait NODES Ready (CNI data-plane) +# --------------------------------------------------------------------- # +step "applying Calico Installation CR" +kubectl apply -f kind/calico/installation.yaml + +step "waiting for nodes Ready (Calico data-plane up)" +# Nodes stay NotReady until the Calico CNI binary lands and pod networking +# comes up. Allow 5 min on first bring-up (image pulls). +kubectl wait --for=condition=Ready node --all --timeout=300s + +# --------------------------------------------------------------------- # +# 4. workload namespace (restricted PSS) — must exist before SealedSecrets +# --------------------------------------------------------------------- # +step "applying the '$NAMESPACE' workload namespace" +kubectl apply -f manifests/app/00-namespace.yaml + +# --------------------------------------------------------------------- # +# 5. sealed-secrets controller, then wait it Ready +# --------------------------------------------------------------------- # +step "installing sealed-secrets controller (${SEALED_SECRETS_VERSION} release manifest)" +# Install from the upstream release manifest rather than a Helm repo — the +# sealed-secrets Helm index (bitnami-labs.github.io/sealed-secrets) 404s, and +# the controller is a single static manifest anyway. +kubectl apply -f "https://github.com/bitnami-labs/sealed-secrets/releases/download/${SEALED_SECRETS_VERSION}/controller.yaml" + +step "waiting for sealed-secrets controller Available" +kubectl wait --for=condition=Available deployment/sealed-secrets-controller \ + -n kube-system --timeout=180s + +# --------------------------------------------------------------------- # +# 6. SealedSecrets (namespace + controller now exist) +# --------------------------------------------------------------------- # +# NOTE: the committed SealedSecret blobs are bound to the previous +# (namespace, name) and to a per-cluster controller key, so on a fresh +# cluster they must be re-sealed (see the recipe in +# helm/buyerchat/templates/sealed-secret.yaml). They protect only stub +# values, so the demo workload does not depend on them. +step "applying SealedSecrets into '$NAMESPACE'" +# Rendered from the buyerchat chart's sealed-secret template (the only +# SealedSecret in the repo). Non-fatal if it can't decrypt yet — the demo +# workload needs no secret. +helm template buyerchat helm/buyerchat -f helm/buyerchat/values.dev.yaml \ + --show-only templates/sealed-secret.yaml -n "$NAMESPACE" \ + | kubectl apply -f - || echo " SealedSecret apply skipped (re-seal required on fresh cluster)" + +# --------------------------------------------------------------------- # +# 7. foundation platform charts, each waited +# --------------------------------------------------------------------- # +step "installing ingress-nginx" +helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx >/dev/null 2>&1 || true +helm repo add jetstack https://charts.jetstack.io >/dev/null 2>&1 || true +helm repo add prometheus-community https://prometheus-community.github.io/helm-charts >/dev/null 2>&1 || true +helm repo update >/dev/null +helm upgrade --install ingress-nginx ingress-nginx/ingress-nginx \ + -n ingress-nginx --create-namespace \ + -f infra/ingress-nginx/values.yaml --wait --timeout 180s + +step "installing cert-manager" +helm upgrade --install cert-manager jetstack/cert-manager \ + -n cert-manager --create-namespace --set installCRDs=true --wait --timeout 180s +kubectl apply -f infra/cert-manager/clusterissuer-selfsigned.yaml + +step "installing kube-prometheus-stack" +helm upgrade --install kps prometheus-community/kube-prometheus-stack \ + -n monitoring --create-namespace \ + -f infra/kube-prometheus-stack/values.yaml --wait --timeout 600s + +# --------------------------------------------------------------------- # +# 8. GitOps control plane — Argo Rollouts (CRDs first), then ArgoCD +# --------------------------------------------------------------------- # +step "installing Argo Rollouts + ArgoCD (wrapper charts)" +helm repo add argo https://argoproj.github.io/argo-helm >/dev/null 2>&1 || true +helm repo update argo >/dev/null +for chart in infra/argo-rollouts infra/argocd; do + ns="$(basename "$chart")" + echo " installing $chart into $ns" + helm dependency build "$chart" >/dev/null + helm upgrade --install "$ns" "$chart" \ + -n "$ns" --create-namespace --wait --timeout 300s +done + +step "waiting for ArgoCD server Available" +kubectl wait --for=condition=Available deployment --all -n argocd --timeout=300s + +# --------------------------------------------------------------------- # +# 9. demo workload — build, side-load, install, wait Ready +# --------------------------------------------------------------------- # +step "building + side-loading the demo image ($DEMO_IMAGE)" +docker build -t "$DEMO_IMAGE" apps/demo +kind load docker-image "$DEMO_IMAGE" --name "$CLUSTER_NAME" + +step "installing demo chart (Argo Rollout canary) into '$NAMESPACE'" +helm upgrade --install demo helm/demo \ + -n "$NAMESPACE" --create-namespace \ + -f helm/demo/values.dev.yaml --wait --timeout 180s +# Rollout objects don't satisfy `helm --wait` the way Deployments do; gate +# on the Rollout's own pods becoming Ready. +kubectl rollout status deployment/demo -n "$NAMESPACE" --timeout=180s 2>/dev/null \ + || kubectl wait --for=condition=Ready pod -n "$NAMESPACE" \ + -l app.kubernetes.io/name=demo --timeout=180s + +# --------------------------------------------------------------------- # +# 10. GitOps takeover — app-of-apps root, then wait Applications Synced +# --------------------------------------------------------------------- # +step "registering the ArgoCD app-of-apps root" +kubectl apply -f argocd/root-app.yaml + +step "waiting for ArgoCD applications to sync" +sleep 30 # allow ArgoCD to register the children before we wait on them +kubectl wait --for=jsonpath='{.status.sync.status}'=Synced \ + applications --all -n argocd --timeout=300s || \ + echo " (some apps still progressing — check the ArgoCD UI)" + +# --------------------------------------------------------------------- # +# Done +# --------------------------------------------------------------------- # +step "cluster ready" +echo "" +echo "Add to /etc/hosts (Windows: C:\\Windows\\System32\\drivers\\etc\\hosts):" +echo " 127.0.0.1 demo.localtest.me grafana.localtest.me argocd.localtest.me prometheus.localtest.me" +echo "" +echo "Watch the canary: kubectl argo rollouts get rollout demo -n $NAMESPACE --watch" +echo "Demo metrics: kubectl -n $NAMESPACE port-forward svc/demo 3000:3000 then curl localhost:3000/metrics"