From 4003e774801a07b620f960a3808eef5daf549add Mon Sep 17 00:00:00 2001 From: Eric Kotler Date: Wed, 2 Sep 2026 11:27:45 -0700 Subject: [PATCH] Updated cpln-advisor template --- CHANGELOG.md | 1 - briefings/cpln-advisor.md | 80 ++- cpln-advisor/versions/1.0.0/.helmignore | 23 + cpln-advisor/versions/1.0.0/Chart.yaml | 7 +- cpln-advisor/versions/1.0.0/README.md | 114 ++-- .../versions/1.0.0/templates/_helpers.tpl | 46 +- .../versions/1.0.0/templates/gvc.yaml | 22 - .../versions/1.0.0/templates/identity.yaml | 2 +- .../versions/1.0.0/templates/policy.yaml | 5 +- .../1.0.0/templates/workload-api.yaml | 31 +- .../1.0.0/templates/workload-redis.yaml | 8 +- .../1.0.0/templates/workload-scheduler.yaml | 9 +- .../1.0.0/templates/workload-web.yaml | 6 +- .../1.0.0/templates/workload-worker.yaml | 9 +- cpln-advisor/versions/1.0.0/values.yaml | 298 ++++----- cpln-advisor/versions/2.0.0/Chart.yaml | 35 -- cpln-advisor/versions/2.0.0/README.md | 346 ----------- .../versions/2.0.0/templates/_helpers.tpl | 580 ------------------ .../versions/2.0.0/templates/identity.yaml | 7 - .../versions/2.0.0/templates/policy-gvc.yaml | 25 - .../versions/2.0.0/templates/policy.yaml | 26 - .../2.0.0/templates/secret-startup.yaml | 22 - .../2.0.0/templates/workload-api.yaml | 159 ----- .../2.0.0/templates/workload-redis.yaml | 93 --- .../2.0.0/templates/workload-scheduler.yaml | 93 --- .../2.0.0/templates/workload-web.yaml | 104 ---- .../2.0.0/templates/workload-worker.yaml | 110 ---- cpln-advisor/versions/2.0.0/values.yaml | 264 -------- 28 files changed, 253 insertions(+), 2272 deletions(-) create mode 100644 cpln-advisor/versions/1.0.0/.helmignore delete mode 100644 cpln-advisor/versions/1.0.0/templates/gvc.yaml delete mode 100644 cpln-advisor/versions/2.0.0/Chart.yaml delete mode 100644 cpln-advisor/versions/2.0.0/README.md delete mode 100644 cpln-advisor/versions/2.0.0/templates/_helpers.tpl delete mode 100644 cpln-advisor/versions/2.0.0/templates/identity.yaml delete mode 100644 cpln-advisor/versions/2.0.0/templates/policy-gvc.yaml delete mode 100644 cpln-advisor/versions/2.0.0/templates/policy.yaml delete mode 100644 cpln-advisor/versions/2.0.0/templates/secret-startup.yaml delete mode 100644 cpln-advisor/versions/2.0.0/templates/workload-api.yaml delete mode 100644 cpln-advisor/versions/2.0.0/templates/workload-redis.yaml delete mode 100644 cpln-advisor/versions/2.0.0/templates/workload-scheduler.yaml delete mode 100644 cpln-advisor/versions/2.0.0/templates/workload-web.yaml delete mode 100644 cpln-advisor/versions/2.0.0/templates/workload-worker.yaml delete mode 100644 cpln-advisor/versions/2.0.0/values.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 63f2e90d..10369416 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,6 @@ High-level, user-facing catalog changes by month: new templates and notable vers ## 2026-08 - **Every template now deploys into a GVC you already have.** Eleven templates used to create their own; none do. This closes a data-loss path: a chart that creates a GVC and later stops declaring it makes `helm upgrade` prune it — and deleting a GVC takes **every workload, volumeset and identity inside**, in about six seconds, while printing `upgraded successfully`. Each converted template now refuses that upgrade at render time. **If you run any 1.x/2.x release listed below, do not `helm upgrade` onto the new major** — install it as a new release against an existing GVC, move your data across, then remove the old release. Each README carries the migration steps -- **cpln-advisor 2.0.0** — no longer creates a GVC. This one mattered most: 1.0.0 named the GVC it created after the one you installed into, so it **adopted your existing GVC** — and `helm uninstall` would then have deleted it along with every unrelated workload in it. Also fixes placement: on a GVC with more than one location, 1.0.0 would have run a second scheduler firing every scan twice and a second, independent database. Note the bundled Postgres still cannot be pinned to one location, so a multi-location GVC gives it one empty database per extra location — harmless today because the service DNS is location-local, but changing `location` later repoints the app at the empty one - **mongodb-cluster 2.0.0** — no longer creates a GVC, and **the shipped default could never fully work**. It defaulted to nine members, but MongoDB allows at most seven voters: the eighth and ninth joined nothing, ran a healthy-looking `mongod`, reported ready, and were never in the replica set. The default is now one location × three members and a roster above seven is refused. `backup.mode: physical` is **removed** — its restore could never run, because Percona Backup for MongoDB must execute `mongod` and the agent image does not contain it, so it wrote real-looking snapshots that could never be restored and failed silently doing it. The logical restore is verified end to end, and the documented steps used to point at an address that cannot resolve from your machine - **grafana-multi-location 2.0.0** — no longer creates a GVC, and picks up the converted `postgres-multi-location` and `redis-multi-location`. Losing the location named in `alerting.location` still stops alert evaluation while dashboards look perfectly healthy — set `alerting.highAvailability.enabled: true` if that matters, and note a fresh install now fails loudly rather than half-working - **airflow 2.0.0** — no longer creates a GVC. **KEDA autoscaling is now opt-in and off by default**, because it is a GVC-level setting a chart cannot turn on for you: enabling it against a GVC without KEDA is accepted silently and leaves the workers dead at zero replicas with no error anywhere. Turn KEDA on for your GVC first, then set `keda.enabled: true`. Also fixes a placement bug where a multi-location GVC would have run one scheduler and one broker **per location** diff --git a/briefings/cpln-advisor.md b/briefings/cpln-advisor.md index a7c53f87..9413b6b1 100644 --- a/briefings/cpln-advisor.md +++ b/briefings/cpln-advisor.md @@ -1,9 +1,7 @@ # CPLN Advisor — Maintainer Briefing -*Current as of 2.0.0 (the GVC conversion). Reconciled against the shipped `values.yaml`.* - ## What it is -- A **first-party Control Plane tool**: it monitors the workloads in the org it is installed into, and turns their metrics, logs and *billed* cost into tuning suggestions generated by an LLM grounded in vendored Control Plane runbooks. +- A **first-party Control Plane tool**: it monitors the workloads in the org it is installed into, and turns their metrics, logs and *billed* cost into tuning suggestions generated by an LLM. - **Autopilot** applies qualifying suggestions during a scan — per workload, off until enabled. Each apply patches and redeploys a live workload and is recorded in Activity with a one-click revert. - Source lives in the `cpln-advisor` repo; images are built by its own CI and published to GHCR as **public** packages, so no pull secret is needed. Both ship `:latest`; every build also publishes `:sha-`. @@ -16,64 +14,56 @@ | Resource | Purpose | |---|---| | workload `{release}-web` (serverless, 1–3, :3000) | Next.js dashboard. **The only public workload.** Holds the session key and API token so the browser never does | -| workload `{release}-api` (standard, **1 replica**, :8000) | FastAPI. Runs the boot guards, then `alembic upgrade head`, then uvicorn. Verifies the login. Internal; reachable only from web | +| workload `{release}-api` (standard, **1 replica**, :8000) | FastAPI. Runs `alembic upgrade head` at startup and verifies the login. Internal; reachable only from web | | workload `{release}-worker` (standard, **1 replica**) | Scans, Autopilot applies, Slack digest. Same image as api, different command | | workload `{release}-scheduler` (standard, **1 replica**) | `taskiq scheduler workers.worker:scheduler` — fires the crons that CREATE the scans | -| workload `{release}-redis` (standard, 1 replica, :6379) | Broker + cache. Not persistent, **not authenticated** — the firewall is the whole access control. No identityLink | +| workload `{release}-redis` (standard, 1 replica, :6379) | Broker + cache. Not persistent, **not authenticated** — the firewall is the whole access control | | **subchart `postgres` 3.4.1** | `{release}-postgres` (stateful) + `-pg-vs`, `-pg-identity`, `-pg-policy`. Reads its credentials from a prerequisite secret; creates none | -| secret `{release}-startup` | The API's boot script. Holds no credential; it is a secret because that is the platform's file-mount vehicle | -| `{release}-identity`, `-policy`, `-gvc-policy` | `reveal` on exactly two secrets (the user's `auth.secretName` + the boot script); `view` on exactly one GVC. Never `target: all` | +| `{release}-identity` + `-policy` | `reveal` on **exactly one** secret — the user's `auth.secretName`. Never `target: all` | -- **NO GVC is created (2.0.0).** `createsGvc: false`; the chart deploys into `global.cpln.gvc`, which the platform injects. -- **The chart creates NO credential secret and takes NO credential as a value.** The regression check is that `helm template --set global.cpln.gvc=X` with nothing else succeeds. -- Firewalls: web is public and takes **no** internal traffic; api ← web only; redis ← api/worker/scheduler only; worker and scheduler take no inbound; postgres is `same-gvc`. All links come from `cpln-advisor.workloadLink`, so no link is hand-written. +- **No GVC is created.** `createsGvc: false`; the chart deploys into `global.cpln.gvc`, which the install tooling injects from `--gvc`. Eleven resources, no `kind: gvc`. +- **The chart creates NO secret and takes NO credential as a value.** The regression check: `helm template --set global.cpln.gvc=X` with nothing else must succeed. +- Firewalls: web is public and takes **no** internal traffic; api ← web only; redis ← api/worker/scheduler only; worker and scheduler take no inbound; postgres is `same-gvc`. ## Key knobs | Knob | Default | Meaning | |---|---|---| -| `location` | `aws-us-east-1` | The ONE location of your GVC the advisor runs in. Was `gvc.locations` in 1.0.0 | | `auth.secretName` | `advisor-config` | PREREQUISITE `dictionary` secret. Keys are the app's own env var names: `ADVISOR_API_TOKEN`, `ADVISOR_SECRET_KEY`, `ADVISOR_SESSION_SECRET`, `ADVISOR_USERNAME`, `ADVISOR_PASSWORD`, `DATABASE_URL` | | `postgres.config.credentialsSecretName` | `advisor-db-credentials` | PREREQUISITE `dictionary` secret with exactly `username`, `password`, `database` | | `images.backend` / `images.web` | `:latest` | Same backend image for api/worker/scheduler — a scan is executed by one and served by another, so they must never drift | | `appUrl` | `""` | Override only for a custom domain; otherwise derived. Never `*` (validated) | | `session.hours` / `.rememberDays` | 12 / 30 | Idle windows, sliding, under a hard ceiling the app enforces | | `logLevel` | `INFO` | `DEBUG` adds per-query Control Plane detail and is very noisy | -| `web`/`api`/`scheduler`/`postgres` `.resources` | min+max | `minCpu`/`maxCpu`/`minMemory`/`maxMemory` | -| `worker`/`redis` `.resources` | limit only | bare `cpu`/`memory` — **renamed in 2.0.0**, clean break, no fallback | -| `redis.maxmemory` | `100mb` | Redis' own cache cap, below the container's `memory` (128Mi) on purpose | +| per-workload `.resources` | see below | `minCpu`/`maxCpu`/`minMemory`/`maxMemory` throughout, including `worker` and `redis` | +| `redis.maxmemory` | `100mb` | Redis' own cache cap, below the container's `maxMemory` (128Mi) on purpose | | `postgres.backup.enabled` | **`false`** | Needs a bucket + cloud account first. Push users to turn it on | -There is deliberately **no** `publicAccess` or `internalAccess` knob on this chart's own workloads. - -## The three-layer single-location defence (2.0.0) -1. **`defaultOptions.minScale/maxScale: 0` + a complete `localOptions` entry** on all five of this chart's workloads. An undeclared GVC location starts nothing; its deployment reads `This workload location is deactivated because maxScale is set to 0`. -2. **Guard A, a boot-time location check**, rendered inline into api / worker / scheduler / redis from one helper (`cpln-advisor.locationGuard`). FATAL — exits 1. **Not on `web`:** that is the only tier whose entrypoint this chart does not already own (the Node image's `docker-entrypoint.sh` + `node server.js`), and a stray dashboard replica is stateless and harmless. -3. **A boot-time GVC read** in the API's startup script, scoped by `-gvc-policy` (`view` on that one GVC). WARNING only — see below. +There is deliberately **no** GVC, location, `publicAccess` or `internalAccess` knob on this chart's own workloads. -## Troubleshooting / considerations -- **`helm upgrade` from 1.0.0 is destructive, and this is the sharpest case in the whole conversion batch.** 1.0.0 rendered `kind: gvc` with `name: {{ .Values.global.cpln.gvc }}` — the GVC you installed *into*. So every 1.0.0 release **adopted** the user's GVC and Helm has owned it since. Upgrading in place drops `kind: gvc` and Helm prunes it, taking every workload, volumeset and identity in that GVC, including unrelated ones. `validateNoLegacyGvc` fires on the `gvc` values key, which covers an upgrade that passes the old values file; **an upgrade with no values at all cannot be caught**, which is why the README has a Migrating section. -- **The bundled Postgres is NOT pinned to a location, and cannot be.** `postgres` 3.4.1 has no `location` knob and renders no `localOptions`; a subchart's values cannot be templated, so the parent has nothing to hand it. In a multi-location GVC it runs one independent, empty database per location, on the same `{release}-postgres.{gvc}` service DNS name the app connects to — so a failover of that name is a silent split-brain, not just wasted spend. The API warns at boot; nothing prevents it. **Closing this by construction needs a `postgres` version with a `location` knob** (see Open items). -- **`postgres.internalAccess: same-gvc` got wider in 2.0.0.** In 1.0.0 the GVC was dedicated, so `same-gvc` meant "the advisor only". It now means "anything in the user's GVC". The README gives the `--set postgres.internalAccess.type=workload-list` recipe, which is the only way to narrow it because the subchart cannot be handed templated names. -- **The GVC check warns, it never fails.** Every advisor tier is stateless (all state is in Postgres), so there is no fresh-vs-initialised discriminator to key severity off, and `.Release.IsInstall` was rejected — it renders a different container argument on install than on upgrade, which is permanent drift. Severity therefore splits by cost, as `grafana-multi-location` 2.0.0 does. Crash-looping the API over a topology mismatch would take the dashboard down and fix nothing. -- **The scheduler is the one people delete.** It looks redundant next to the worker and is not: the worker runs tasks, the scheduler fires the cron that creates them. Without it nothing is scanned on a schedule, retention never prunes and Autopilot never runs — while every workload still reports healthy. "Scans stopped but everything is green" is this, every time. -- **The two prerequisite secrets must agree, and nothing cross-checks them.** The username/password/database inside `DATABASE_URL` must match `advisor-db-credentials`. A mismatch installs cleanly and fails to authenticate at runtime. Helm cannot read secret values at render time, so the chart cannot validate it. -- **A missing prerequisite secret wedges the install silently** — the workloads never start and `cpln logs` returns nothing. `status.versions[].message` from `cpln workload get-deployments` is the only place it is named. -- **The Control Plane API token is NOT in the secret and cannot be.** `CPLN_*` is a reserved env prefix, so the token is entered in the UI (Configuration → Control Plane) and stored encrypted in the database. Note the boot guard *does* use the injected `CPLN_TOKEN` — that is the workload identity's own credential, which is a different thing. -- **`ADVISOR_WEB_WORKLOAD` is load-bearing.** The app derives its public URL (Slack links + CORS origin) as `https://{ADVISOR_WEB_WORKLOAD}-{gvc alias}.cpln.app` and defaults that setting to the literal `"web"`. Deliberately not built from `CPLN_GLOBAL_ENDPOINT`, which is the host of whichever workload reads it — the API, which is internal. -- **Losing `ADVISOR_SECRET_KEY` loses every credential entered in the UI** (Anthropic/OpenAI/Slack), unrecoverably. It also has to survive a migration to a new release, or the restored rows cannot be decrypted. -- **Rotating any `cpln://` secret needs `cpln workload force-redeployment`.** References resolve at replica start and are never re-resolved. -- **Backups: `pg_dumpall | gzip` to `postgres-.sql.gz`** (read out of `backup.sh` in the pinned `postgres-backup:18.1.0` image, 2026-08-30). **The restore has never been executed against this template** — the README says so explicitly rather than presenting a plausible procedure as tested. -- **Images are `:latest` with `supportDynamicTags: false`.** The tag resolves at DEPLOY time only, so a new push does not reach a running install until something redeploys it. -- **`helm uninstall` no longer deletes a GVC** (2.0.0 never owns one). It does delete the database volume set and all scan history. Both prerequisite secrets survive. -- Known gaps: no per-user accounts (one login for everyone); no CIDR knob for the dashboard; Redis has no volume set, so notification baselines do not survive a restart (at worst one repeated digest). +## Availability posture +| Workload | Capacity AI | cpu (min→max) | memory (min→max) | replicas | +|---|---|---|---|---| +| `web` | on | 75m→250m | 128Mi→512Mi | 1–3, concurrency | +| `api` | on | 75m→256m | 128Mi→512Mi | **1** | +| `scheduler` | on | 25m→50m | 64Mi→128Mi | **1** | +| `worker` | off | →100m | →256Mi | **1** | +| `redis` | off | →50m | →128Mi | 1 | +| `postgres` | off | 150m→512m | 256Mi→512Mi | 1 (stateful) | -## Measured while building 2.0.0 (2026-08-30) -- **A `serverless` workload accepts and stores `localOptions` verbatim**, `metric: concurrency` / `target` / `scaleToZeroDelay` included. There was no precedent in the catalog; settled with one throwaway `cpln apply`. -- **Backfill on a `standard` workload with a partial `defaultOptions`:** `maxConcurrency 0`, `scaleToZeroDelay 300`, **`target 95`**, `debug false`, `suspend false`, `timeoutSeconds 5`. No `rolloutOptions` and no `firewallConfig` are invented when omitted entirely. The chart renders `target: 95` for `metric: disabled` blocks so the stored spec matches what 1.0.0 already produced. -- **The pinned backend image has NO curl and NO wget** (Debian 13; digest `sha256:7336e4d4…`). It has python3 3.13, whose `urllib` speaks **HTTP/1.1** — which is what matters, because `$CPLN_ENDPOINT` is behind istio-envoy and answers HTTP/1.0 with `426 Upgrade Required`. The web image is Alpine with busybox `wget` and no python3. -- **The GVC read is bounded at ~22 s** (3 attempts × 6 s socket timeout + 2 × 2 s backoff), measured in the real image against a **blackholed address** (23 s) and an **accept-never-respond server** (22 s), with an unbounded control against the same server still hanging at 30 s. `urlopen(timeout=)` is a socket timeout, so it bounds the connect phase and a hung read; an NXDOMAIN control returns instantly and proves neither. -- The API's `livenessProbe.failureThreshold` was raised 3 → 5 to fit that 22 s worst case plus `alembic upgrade head` inside the restart budget. +- **Capacity AI is on for web, api and scheduler; off for worker and redis** — a long scan should not be resized underneath itself, and a broker should not be resized under load. Standard/serverless/cron enable it by default, so the two `false` values are doing real work. `minCpu`/`minMemory` are set only where it is on, plus postgres (`stateful` respects them either way). -## Open items for the maintainer -- **`postgres` needs a `location` knob** (defaultOptions 0/0 + `localOptions`) before any parent chart can confine it. That is a `postgres` version bump, and it cannot ship in this PR because the dependency resolves from the OCI registry — an unpublished version cannot be pulled at install time. Until then, "install into a single-location GVC" is documentation, not construction. -- **`cpln-advisor` is not in `GVC_GUARD_REQUIRED` in `template-automator/scripts/template-lint.py`.** That set is meant to be permanent and never shrink; it lists the original eleven and predates this template's conversion, so R16 currently skips the render-time-guard check for this chart. Adding it would make the lint actually enforce the guard. +## Troubleshooting / considerations +- **THE ONE-LOCATION REQUIREMENT IS UNENFORCED.** The GVC must have exactly one location, and nothing checks it: a chart cannot see a pre-existing GVC's locations at render time. +- **The two prerequisite secrets must agree, and nothing cross-checks them.** The username/password/database inside `DATABASE_URL` must match `advisor-db-credentials`. A mismatch installs cleanly and fails to authenticate at runtime. Helm cannot read secret values at render time. +- **`DATABASE_URL`'s host is release- and GVC-dependent** — `{release}-postgres.{gvc}.cpln.local:5432`. Users copying the example verbatim without substituting both will point at a host that does not resolve. +- **The Control Plane API token is NOT in the secret** The token is entered in the UI (Configuration → Control Plane) and stored encrypted in the database. +- **`ADVISOR_WEB_WORKLOAD` is load-bearing.** The app derives its public URL (Slack links + CORS origin) as `https://{ADVISOR_WEB_WORKLOAD}-{gvc alias}.cpln.app` and defaults it to the literal `"web"`. This chart names the dashboard `{release}-web`, so it must pass the real name or every derived link points at a workload that does not exist. Deliberately not built from `CPLN_GLOBAL_ENDPOINT`, which is the host of whichever workload reads it — the API, which is internal. +- **Losing `ADVISOR_SECRET_KEY` loses every credential entered in the UI** (Anthropic/OpenAI/Slack), unrecoverably. It is an HKDF passphrase for a Fernet key, so any length works, but it must be stable. +- **The database password is first-boot only.** Postgres reads it when the data directory initializes; rotating it in the secret afterwards does not change the running server. +- **postgres is pinned at 3.4.1 and the pin matters.** From 3.4.0 that chart reads its credentials from a prerequisite secret instead of taking them inline — that is what lets this chart bundle a database and still keep every credential out of values. **Do not downgrade the pin.** +- **`postgres.internalAccess: same-gvc` is wider than it looks.** The GVC is no longer dedicated to the advisor, so `same-gvc` means every workload in a possibly shared GVC can reach the database. Narrow it when the GVC is shared. +- **The dashboard is public and unconditional** — `inboundAllowCIDR: [0.0.0.0/0]` hardcoded, no values knob. Deliberate: it is the only way into the app and nothing internal calls it. +- **Backups are off by default** (`postgres.backup.enabled`) because they need a bucket plus a cloud account. Nothing else protects the data, so pushing users to turn them on is the single most useful thing to say about this template. `backup.image` is **coupled to** `postgres.image` — 18.1.0 backs up Postgres 18. +- **Redis is unauthenticated on purpose.** Everything in it is derived or transient. The `workload-list` firewall is the only thing protecting it, so widening it is a real exposure. +- **Autopilot redeploys production workloads.** Off until enabled, per-workload, with revert — but the redeploy is real. `workload: edit` on the service-account token is what unlocks it; without it the advisor is read-only. +- Known gaps: no per-user accounts (one login, Activity attributes every change to "user"); no CIDR knob for the dashboard; Redis has no volume set, so notification baselines do not survive a restart (at worst one repeated digest). diff --git a/cpln-advisor/versions/1.0.0/.helmignore b/cpln-advisor/versions/1.0.0/.helmignore new file mode 100644 index 00000000..0e8a0eb3 --- /dev/null +++ b/cpln-advisor/versions/1.0.0/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/cpln-advisor/versions/1.0.0/Chart.yaml b/cpln-advisor/versions/1.0.0/Chart.yaml index 0e4a2f6a..c4e94717 100644 --- a/cpln-advisor/versions/1.0.0/Chart.yaml +++ b/cpln-advisor/versions/1.0.0/Chart.yaml @@ -8,19 +8,20 @@ appVersion: "1.0.0" annotations: created: "2026-08-17" - lastModified: "2026-08-17" + lastModified: "2026-09-01" category: "observability" - createsGvc: true + createsGvc: false dependencies: - name: cpln-common version: 1.0.0 repository: "oci://ghcr.io/controlplane-com/templates" + # The advisor's database. Bundled so a single install brings up everything: # the chart arrives with a readiness probe, a private firewall, its own # identity and policy, and scheduled backups behind a switch. # - # 3.4.1, NOT 3.3.0: from 3.4.0 the template reads its username/password/database + # Version 3.4.1 of the postgres template reads its username/password/database # from a prerequisite secret instead of taking them inline, which is what lets # this chart bundle a database and still keep every credential out of values. - name: postgres diff --git a/cpln-advisor/versions/1.0.0/README.md b/cpln-advisor/versions/1.0.0/README.md index 9e1f6f5d..00d91e46 100644 --- a/cpln-advisor/versions/1.0.0/README.md +++ b/cpln-advisor/versions/1.0.0/README.md @@ -1,16 +1,15 @@ # CPLN Advisor -CPLN Advisor watches the workloads in your org, tracks CPU, memory, replica counts and error rates, and turns what it finds into concrete tuning suggestions — memory limits, autoscaling thresholds, replica counts — generated by an LLM grounded in Control Plane runbooks. Suggestions appear in a dashboard, and **Autopilot** can apply the qualifying ones for you, each with a one-click revert. +CPLN Advisor watches the workloads in your org, tracks CPU, memory, replica counts and error rates, and turns what it finds into concrete tuning suggestions — memory limits, autoscaling thresholds, replica counts — generated by an LLM. Suggestions appear in a dashboard, and **Autopilot** can apply the qualifying ones for you, each with a one-click revert. It reports on the org it runs in. Control Plane injects `CPLN_ORG` into every workload, so the advisor knows where it lives without being told. ## Architecture -One install brings up everything. The chart creates its own GVC and bundles the database, and **creates no secret and takes no credential as a value** — it reads two secrets you create first. +One install brings up everything except the GVC. The chart deploys into a GVC you already have and bundles its own database, and **creates no secret and takes no credential as a value** — it reads two secrets you create first. | Resource | Type | Purpose | |---|---|---| -| `advisor` | GVC | Created by this chart, in **exactly one** location. Everything below lives in it. | | `{release}-web` | serverless | The Next.js dashboard, **public**. The only workload exposed to the internet, and the only one holding a session key. Scales 1–3. | | `{release}-api` | standard | FastAPI backend on port 8000. Runs `alembic upgrade head` at startup. Internal; reachable only by the dashboard. **1 replica.** | | `{release}-worker` | standard | Runs scans, applies Autopilot changes, posts the Slack digest. **1 replica.** | @@ -23,17 +22,32 @@ One install brings up everything. The chart creates its own GVC and bundles the ### Why the database is bundled -Three workloads write it concurrently — the API serves the dashboard, the worker records scan results and Autopilot changes, and the scheduler writes cron state. That needs a database server, and one that is backed up. +Three workloads write it concurrently — the API serves the dashboard, the worker records scan results and Autopilot changes, and the scheduler writes cron state. That needs a database server, and one that is (optionally) backed up. It comes from the catalog's `postgres` template rather than being hand-written here, because a hand-rolled workload + volume set + firewall stack routinely ships without backups, with a public database, or single-replica. That chart arrives with a `pg_isready` readiness probe, a private-by-default firewall, its own identity and policy, and scheduled backups behind a switch. ## Prerequisites -**Two `dictionary` secrets must exist BEFORE you install.** Neither the chart nor its values ever hold a credential — `helm template` succeeds with no values at all, which is the check that this stays true. +**Two `dictionary` secrets must exist BEFORE you install.** Neither the chart nor its values ever hold a credential. -If either secret is missing the deployment **wedges silently**: the workloads never start and `cpln logs` returns nothing to say why. +If either secret is missing the deployment **fails silently**. -### 1. The advisor's credentials +### 1. The database's credentials + +From version 3.4.0 the bundled `postgres` template reads its credentials from a secret instead of taking them inline — which is what lets this chart bundle a database and still keep every credential out of values. Exactly three keys: + +```bash +DB_PASS="$(openssl rand -hex 32)" + +cpln secret create-dictionary --name advisor-db-credentials --org YOUR_ORG \ + --entry username=advisor \ + --entry password="$DB_PASS" \ + --entry database=advisor +``` + +> **These three values must match `DATABASE_URL` below in step 2, and nothing cross-checks them.** A mismatch is not a render error — it is an authentication failure at runtime, after everything appears to install cleanly. Set `DB_PASS` once and use it in both commands, as shown. + +### 2. The advisor's credentials Keys are the app's own environment-variable names, so a secret built for a hand-applied deployment works here unchanged. @@ -44,9 +58,11 @@ cpln secret create-dictionary --name advisor-config --org YOUR_ORG \ --entry ADVISOR_SESSION_SECRET="$(openssl rand -hex 32)" \ --entry ADVISOR_USERNAME=admin \ --entry ADVISOR_PASSWORD='YOUR-STRONG-PASSPHRASE' \ - --entry DATABASE_URL="postgresql+asyncpg://advisor:$DB_PASS@advisor-postgres.advisor.cpln.local:5432/advisor" + --entry DATABASE_URL="postgresql+asyncpg://advisor:$DB_PASS@RELEASE-postgres.GVC.cpln.local:5432/advisor" ``` +Substitute `RELEASE` with your Helm release name and `GVC` with the GVC you install into — the bundled database workload is named `{release}-postgres` and resolves on internal DNS inside that GVC. Installing release `advisor` into GVC `platform` gives `advisor-postgres.platform.cpln.local`. + | Key | What it is | |---|---| | `ADVISOR_API_TOKEN` | Shared between the dashboard and the API. The API **fails closed**: unset, it refuses every route except `/health`. | @@ -55,30 +71,8 @@ cpln secret create-dictionary --name advisor-config --org YOUR_ORG \ | `ADVISOR_USERNAME` / `ADVISOR_PASSWORD` | The web-UI login. A human types the password, so prefer a long passphrase. | | `DATABASE_URL` | `postgresql+asyncpg://` — **not** `postgres://`: SQLAlchemy needs the driver named. Host is `{release}-postgres.{gvc}.cpln.local:5432`. | -### 2. The database's credentials - -From version 3.4.0 the bundled `postgres` template reads its credentials from a secret instead of taking them inline — which is what lets this chart bundle a database and still keep every credential out of values. Exactly three keys: - -```bash -DB_PASS="$(openssl rand -hex 32)" - -cpln secret create-dictionary --name advisor-db-credentials --org YOUR_ORG \ - --entry username=advisor \ - --entry password="$DB_PASS" \ - --entry database=advisor -``` - -> **These three values must match `DATABASE_URL` above, and nothing cross-checks them.** A mismatch is not a render error — it is an authentication failure at runtime, after everything appears to install cleanly. Set `DB_PASS` once and use it in both commands, as shown. - Then install. The only values either secret name appears in are names: -```bash -cpln helm install advisor ./cpln-advisor/versions/1.0.0 \ - --org YOUR_ORG --dependency-update --wait --timeout 600 -``` - -A secret needs three things and two fail silently: the identity linked to the workload, a policy granting **`reveal`** (not `view`), and the `cpln://secret/NAME.KEY` reference. The chart wires all three — the advisor's identity gets `reveal` on `advisor-config` only, and the database's own identity on `advisor-db-credentials` only, so neither can read the other's. - ### A Control Plane service-account token — *after* install, not before The advisor's own Control Plane token is **deliberately not a value and not in the secret**. You set it once in the UI after signing in (**Configuration → Control Plane**), where it is stored encrypted in the database. @@ -98,19 +92,14 @@ Create a service account whose token can, at minimum: ### GVC -```yaml -global: - cpln: - gvc: advisor # this chart CREATES this GVC +There is no GVC value to set. The chart installs into whichever GVC you select at install time, and every resource — including the bundled database — lands there. -gvc: - locations: - - name: aws-us-east-1 # exactly one; see Prerequisites -``` +Two requirements, neither of which the chart can check for you: -The name lives under `global` so every resource is tagged with it and the bundled `postgres` subchart lands in the same GVC — the catalog's convention for a chart that owns its GVC. +- **The GVC must already exist.** This chart does not create one. +- **It must have exactly one location.** -The GVC **must not already exist**: Helm adopts one that does, and `helm uninstall` would then delete it and everything in it. Workload names are prefixed with the Helm release name, so two releases can coexist only if you also give each its own `global.cpln.gvc`. +Workload names are prefixed with the release name, so two releases can share a GVC as long as their release names differ. ### Images @@ -120,20 +109,7 @@ images: web: ghcr.io/controlplane-com/cpln-advisor/advisor-web:latest ``` -The API, worker and scheduler all run the **same** backend image with different commands — a scan is executed by the worker and served by the API, so they must never drift. Both packages are public, so Control Plane pulls them with no credential and the GVC needs no `pullSecretLinks`. - -`:latest` follows the project's main branch. It is resolved when the workload is **deployed**, not continuously: these workloads set `supportDynamicTags: false`, so a later push to the tag is not picked up until something triggers a redeploy. (Control Plane can watch a tag and redeploy within five minutes if you set that field to `true` — the chart leaves it off, because auto-redeploying the API and worker mid-scan is not a thing you want happening unattended.) - -Two consequences worth knowing before you rely on this: - -- **Two installs a week apart can be running different code**, with nothing in the release to say so. -- **There is nothing to roll back to.** Every build also publishes a `:sha-` tag, so pin one on both images before you depend on being able to go back: - - ```yaml - images: - backend: ghcr.io/controlplane-com/cpln-advisor/advisor-backend:sha-441e70b6a899f0b7045b25825d28770c9650ab70 - web: ghcr.io/controlplane-com/cpln-advisor/advisor-web:sha-441e70b6a899f0b7045b25825d28770c9650ab70 - ``` +The API, worker and scheduler all run the **same** backend image with different commands. Both packages are public, so Control Plane pulls them with no credential and the GVC needs no `pullSecretLinks`. ### Login @@ -150,14 +126,6 @@ The login is verified by the **API**, never by the dashboard, so one workload ho There is nothing to configure. **The dashboard is public**, and the other six workloads never are. -That is not a toggle because it is not really a choice: the dashboard is the only way into this app, and nothing inside the GVC calls it, so an internal-only dashboard would be unreachable by the people it exists for. - -What stands between the internet and your fleet is the login: - -- the session cookie is HMAC-signed with `ADVISOR_SESSION_SECRET`, so it cannot be forged -- the API **fails closed** — without `ADVISOR_API_TOKEN` it refuses every route except `/health` -- both credentials come from a secret you create, so there is **no shipped default password** to look up - Everything else is closed to the internet, and internal traffic is admitted only from real callers: | Workload | Public | Reachable inside the GVC by | @@ -180,8 +148,6 @@ appUrl: "" # e.g. https://advisor.example.com Note that the derivation is deliberately **not** `CPLN_GLOBAL_ENDPOINT`: that variable is the host of whichever workload reads it, which here is the API — internal, and not where any browser goes. Setting `appUrl` overrides the derivation, which is the only way to point at a custom domain. -CORS is close to inert in this topology — the browser only ever talks to the dashboard, which calls the API server-side — but it matters the moment anything else calls the API from a page. It must never be `*`: combined with credentials, a wildcard makes the server echo back whichever origin asked. - ### Sessions ```yaml @@ -240,7 +206,7 @@ postgres: Passed straight through to the bundled [`postgres`](https://github.com/controlplane-com/templates/tree/main/postgres) chart, pinned at **3.4.1**. Memory is deliberately above that chart's defaults — a fleet scan writes in bursts. -`internalAccess.type` is `same-gvc` rather than a workload list because this chart creates a **dedicated** GVC, so `same-gvc` already means "the advisor and nothing else". A subchart's values cannot be templated, so a workload list would have to hardcode release-prefixed names. +`internalAccess.type` is `same-gvc` rather than a workload list because this chart installs into the given GVC, so `same-gvc` already means "the advisor and any other workload in this gvc". A subchart's values cannot be templated, so a workload list would have to hardcode release-prefixed names. **Turn backups on.** They are off only because they need a bucket and a cloud account you create first. A volume is not a backup — losing it loses every scan, score and Autopilot record: @@ -263,7 +229,7 @@ postgres: ### Scaling past one replica -The API, worker and scheduler are each pinned to one replica, and Postgres is no longer what stops you raising them: +The API, worker and scheduler are each pinned to one replica. - **API** — two replicas would race on the same `alembic upgrade head` at startup. Lifting this means moving the migration out of the container's start command into its own step. - **Worker** — a fleet scan's Control Plane and LLM calls are rate-limited upstream, so a second worker mostly buys more 429s. Raise it deliberately. @@ -287,22 +253,20 @@ The API, worker and scheduler are each pinned to one replica, and Postgres is no ## Important Notes -- **Create both secrets before installing.** A missing one wedges the deployment silently — the workloads never start and `cpln logs` returns nothing to say why. -- **No credential passes through this chart.** It creates no secret and takes none as a value, so nothing sensitive reaches the Helm release. `helm template` succeeds with no values at all — that is the check that this stays true. +- **Create both secrets before installing.** A missing one fails the deployment silently. +- **No credential passes through this chart.** It creates no secret and takes none as a value, so nothing sensitive reaches the Helm release. - **`DATABASE_URL` and the database credentials secret must agree, and nothing cross-checks them.** A mismatch installs cleanly and then fails to authenticate at runtime. -- **A secret needs three things, and two of them fail silently.** An identity linked to the workload, a policy granting **`reveal`** (not `view`, which exposes metadata only), and the `cpln://secret/NAME.KEY` reference. The chart wires all three, but if you point it at a secret in another org or misname a key, the container starts happily with an empty string and behaves as though nothing were configured. If the app reports a credential missing, check the policy before you check the app. -- **Losing `ADVISOR_SECRET_KEY` loses every credential entered in the UI.** They are unrecoverable and must be re-entered. Keep it somewhere durable before you deploy. -- **The dashboard is public and there is no values knob to close it.** The login is the boundary — a signed session cookie, an API that fails closed, and no shipped default password. Narrow `inboundAllowCIDR` on the `{release}-web` workload after installing if you want it tighter. -- **The GVC must have exactly one location**, and the chart enforces it. A workload runs in every location and scale bounds are per-location, so a second location means a second scheduler firing every cron twice. The bundled Postgres would also get a second, independent database rather than a replica. -- **The GVC must not already exist.** Helm adopts one that does, and `helm uninstall` would then delete it and everything in it. +- **Losing `ADVISOR_SECRET_KEY` loses every credential entered in the UI.** They are unrecoverable and must be re-entered. +- **The dashboard is public.** Narrow `inboundAllowCIDR` on the `{release}-web` workload after installing if you want it tighter. +- **Install into a GVC that already exists and has exactly one location.** +- **`uninstall` leaves the GVC alone.** It removes only what this release created; both prerequisite secrets survive too, since neither is release-managed. - **Backups are OFF by default and you should turn them on.** They need a bucket and a cloud account you create first. A volume is not a backup — see [Database](#database). - **A Postgres password is first-boot only.** It is read when the data directory initializes. Rotating it in the secret afterwards does not change the running server — you must also change it in Postgres itself, and update `DATABASE_URL` to match. - **Rotating a credential means updating the secret and redeploying.** The workloads read `cpln://secret/…` references at start, so a changed secret reaches them on the next deployment, not immediately. - **Redis is not persistent and not authenticated.** Everything in it is derived or transient, so a restart at worst skips one scan and repeats one digest. Its firewall admits exactly the three workloads that use it, and that is the whole access control — do not widen it. -- **Both images track `:latest`, so an install is not reproducible.** The tag resolves at deploy time, and there is no earlier build to fall back to. Pin `:sha-` on both images before this matters to you — see [Images](#images). - **Autopilot redeploys your workloads.** Each applied suggestion patches a live workload and restarts it. It is per-workload and off until you enable it, every change is recorded in Activity, and every change has a one-click revert — but the redeploy itself is real. - **The advisor's token is as powerful as you make it.** Grant `workload: edit` only if you want Autopilot and one-click apply; without it the advisor runs read-only. -- **`uninstall` deletes the GVC, the database volume set and the secret**, taking the scan history and every stored credential with it. +- **`uninstall` deletes the database volume set**, taking all scan history with it. Back up first if you care about it. ## Links diff --git a/cpln-advisor/versions/1.0.0/templates/_helpers.tpl b/cpln-advisor/versions/1.0.0/templates/_helpers.tpl index 4865f2ec..f375bb58 100644 --- a/cpln-advisor/versions/1.0.0/templates/_helpers.tpl +++ b/cpln-advisor/versions/1.0.0/templates/_helpers.tpl @@ -50,16 +50,8 @@ Advisor Policy Name {{- end }} {{/* -Bundled Postgres workload name. The `postgres` subchart names it -`{{ .Release.Name }}-postgres`, and as a subchart that Release.Name is OURS — so -this must track the subchart's own helper. A rename there breaks this silently. -*/}} -{{- define "cpln-advisor.postgres.name" -}} -{{- printf "%s-postgres" .Release.Name }} -{{- end }} - -{{/* -Internal address of Redis. Plain redis:// is correct — the sidecar adds mTLS. +Internal address of Redis, in the GVC this release is installed into. Plain +redis:// is correct — the sidecar adds mTLS. */}} {{- define "cpln-advisor.redis.url" -}} {{- printf "redis://%s.%s.cpln.local:6379" (include "cpln-advisor.redis.name" .) .Values.global.cpln.gvc }} @@ -72,15 +64,6 @@ Internal address of the API, on the CONTAINER port (8000), not 443. {{- printf "http://%s.%s.cpln.local:8000" (include "cpln-advisor.api.name" .) .Values.global.cpln.gvc }} {{- end }} -{{/* -Every credential the advisor reads, by key, out of the ONE prerequisite dictionary -secret. Nothing sensitive passes through values, so nothing sensitive lands in the -Helm release. Key names match the app's own environment variable names. -*/}} -{{- define "cpln-advisor.secretRef" -}} -{{- printf "cpln://secret/%s.%s" .name .key }} -{{- end }} - {{/* Resource ratio guard */}} {{/* @@ -170,18 +153,10 @@ Call with (dict "who" "api" "r" .Values.api.resources). {{- define "cpln-advisor.validate" -}} {{- if not .Values.global.cpln.gvc -}} -{{- fail "cpln-advisor: global.cpln.gvc is required — the name of the GVC this chart creates, e.g. 'advisor'. It lives under `global` so cpln-common tags every resource with it, and so a subchart would inherit it." -}} -{{- end -}} -{{- if not .Values.gvc.locations -}} -{{- fail "cpln-advisor: gvc.locations must contain exactly one location, e.g. `locations:` / ` - name: aws-us-east-1`. Run `cpln location get` to list the ones available to your org." -}} -{{- end -}} -{{- if ne (len .Values.gvc.locations) 1 -}} -{{- fail (printf "cpln-advisor: gvc.locations must contain EXACTLY ONE location, got %d. A workload runs in every location of its GVC and minScale/maxScale are per-location, so a second location silently doubles the API, worker and scheduler — a second scheduler would fire every cron twice. The bundled Postgres is a single stateful workload on a read-write-once volume, so a second location would also give it a second, independent database rather than a replica." (len .Values.gvc.locations)) -}} -{{- end -}} -{{- range .Values.gvc.locations -}} -{{- if not .name -}} -{{- fail "cpln-advisor: every entry in gvc.locations needs a `name`, e.g. `- name: aws-us-east-1`" -}} +{{- fail "cpln-advisor: global.cpln.gvc is empty. This chart installs into an EXISTING GVC and does not create one — the install tooling supplies this from the GVC you select (`cpln helm install ... --gvc YOUR_GVC`), so an empty value usually means no GVC was selected. It is read here to build internal service addresses and to tag every resource, and the bundled postgres subchart inherits it." -}} {{- end -}} +{{- if .Values.gvc -}} +{{- fail "cpln-advisor: the `gvc` values key is no longer used and has been ignored. This chart installs into an EXISTING GVC selected at install time (`--gvc YOUR_GVC`) rather than creating one, so `gvc.locations` no longer controls anything — it is a prerequisite instead: the GVC you install into must already exist and must have exactly ONE location. Remove the `gvc` block from your values file. See README > GVC." -}} {{- end -}} {{- if not .Values.auth.secretName -}} {{- fail "cpln-advisor: auth.secretName is required — the name of a `dictionary` secret that MUST EXIST BEFORE INSTALL, holding the keys ADVISOR_API_TOKEN, ADVISOR_SECRET_KEY, ADVISOR_SESSION_SECRET, ADVISOR_USERNAME, ADVISOR_PASSWORD and DATABASE_URL. This chart creates no secret and accepts no credential as a value. See README Prerequisites." -}} @@ -200,13 +175,6 @@ Call with (dict "who" "api" "r" .Values.api.resources). {{/* Labeling */}} -{{/* -Create chart name and version as used by the chart label. -*/}} -{{- define "cpln-advisor.chart" -}} -{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} -{{- end }} - {{/* Common labels */}} @@ -214,7 +182,3 @@ Common labels {{- include "cpln-common.tags" . }} {{- end }} -{{- define "cpln-advisor.selectorLabels" -}} -app.cpln.io/name: {{ .Release.Name }} -app.cpln.io/instance: {{ .Release.Name }} -{{- end }} diff --git a/cpln-advisor/versions/1.0.0/templates/gvc.yaml b/cpln-advisor/versions/1.0.0/templates/gvc.yaml deleted file mode 100644 index ed545acc..00000000 --- a/cpln-advisor/versions/1.0.0/templates/gvc.yaml +++ /dev/null @@ -1,22 +0,0 @@ -{{- include "cpln-advisor.validate" . }} ---- -kind: gvc -name: {{ .Values.global.cpln.gvc }} -description: CPLN Advisor -tags: {{- include "cpln-advisor.tags" . | nindent 4 }} -spec: - endpointNamingFormat: org - # EXACTLY ONE LOCATION, and the chart refuses to render with more. - # - # A workload runs in EVERY location of its GVC, and minScale/maxScale are - # per-location — so a second location silently doubles the API, the worker and - # the scheduler, no matter that each is pinned to one replica. That means a - # second scheduler firing every cron twice, and a second writer on the - # database. The bundled Postgres is a single stateful workload on a - # read-write-once volume, so a second location would give it a second, - # INDEPENDENT database rather than a replica. - staticPlacement: - locationLinks: - {{- range .Values.gvc.locations }} - - //location/{{ .name }} - {{- end }} diff --git a/cpln-advisor/versions/1.0.0/templates/identity.yaml b/cpln-advisor/versions/1.0.0/templates/identity.yaml index 55af2157..12d677bf 100644 --- a/cpln-advisor/versions/1.0.0/templates/identity.yaml +++ b/cpln-advisor/versions/1.0.0/templates/identity.yaml @@ -1,4 +1,4 @@ ---- +{{- include "cpln-advisor.validate" . -}} kind: identity gvc: {{ .Values.global.cpln.gvc }} name: {{ include "cpln-advisor.identity.name" . }} diff --git a/cpln-advisor/versions/1.0.0/templates/policy.yaml b/cpln-advisor/versions/1.0.0/templates/policy.yaml index 4ab8c564..e6ff8fa6 100644 --- a/cpln-advisor/versions/1.0.0/templates/policy.yaml +++ b/cpln-advisor/versions/1.0.0/templates/policy.yaml @@ -1,12 +1,9 @@ ---- +{{- include "cpln-advisor.validate" . -}} kind: policy name: {{ include "cpln-advisor.policy.name" . }} description: CPLN Advisor policy tags: {{- include "cpln-advisor.tags" . | nindent 4 }} bindings: - # `reveal`, NOT `view` — view exposes metadata only, and the cpln://secret/… - # reference then resolves to an empty string. The containers start happily and - # behave as though nothing were configured, so this failure is silent. - permissions: - reveal principalLinks: diff --git a/cpln-advisor/versions/1.0.0/templates/workload-api.yaml b/cpln-advisor/versions/1.0.0/templates/workload-api.yaml index 54911885..c48ad706 100644 --- a/cpln-advisor/versions/1.0.0/templates/workload-api.yaml +++ b/cpln-advisor/versions/1.0.0/templates/workload-api.yaml @@ -1,4 +1,4 @@ ---- +{{- include "cpln-advisor.validate" . -}} kind: workload name: {{ include "cpln-advisor.api.name" . }} description: CPLN Advisor API @@ -25,29 +25,12 @@ spec: - -c - alembic upgrade head && uvicorn main:app --host 0.0.0.0 --port 8000 env: - # Nothing here may be named CPLN_* — that prefix is RESERVED and a - # workload setting one is rejected. The platform injects CPLN_ORG, - # CPLN_GVC, CPLN_TOKEN and friends itself, which is where the app reads - # its org from. The Control Plane API token cannot be injected for the - # same reason: set it once on the Configuration page, where it is stored - # encrypted in the database. - name: LOG_LEVEL value: {{ .Values.logLevel | quote }} - name: REDIS_URL value: {{ include "cpln-advisor.redis.url" . | quote }} - # Postgres, from the bundled `postgres` subchart. The URL carries the - # password, so it is a key in YOUR credentials secret rather than a value - # here. Its username/password/database must match the database's own - # prerequisite secret — nothing cross-checks the two. - name: DATABASE_URL value: cpln://secret/{{ .Values.auth.secretName }}.DATABASE_URL - # The app derives the dashboard's public URL — the Slack "View in Advisor" - # links and the CORS origin — from Control Plane's built-in env vars, as - # `https://{ADVISOR_WEB_WORKLOAD}-{gvc alias}.cpln.app`. It defaults to - # the literal "web", so it MUST be told this chart's actual dashboard - # workload name or every derived link points at a workload that does not - # exist. It is NOT built from CPLN_GLOBAL_ENDPOINT, which is the host of - # whichever workload reads it — internal here, and not where a browser goes. - name: ADVISOR_WEB_WORKLOAD value: {{ include "cpln-advisor.web.name" . | quote }} {{- if .Values.appUrl }} @@ -81,20 +64,27 @@ spec: httpGet: path: /health # the only unauthenticated route, and it leaks nothing port: 8000 + scheme: HTTP initialDelaySeconds: 5 periodSeconds: 10 failureThreshold: 3 + successThreshold: 1 + timeoutSeconds: 2 livenessProbe: httpGet: path: /health port: 8000 + scheme: HTTP initialDelaySeconds: 20 periodSeconds: 30 failureThreshold: 3 + successThreshold: 1 + timeoutSeconds: 2 defaultOptions: capacityAI: true timeoutSeconds: 120 # a scan's Control Plane and LLM calls can take a while autoscaling: + maxConcurrency: 0 metric: disabled minScale: 1 # ONE replica, because two would race on the same startup migration. That @@ -102,9 +92,8 @@ spec: # raising this is a question of moving `alembic upgrade head` out of the # container start command, not of the database. maxScale: 1 - # Non-root. `filesystemGroupId` is gone with the shared volume it existed for. - # (Never 1337: that is the mesh proxy's UID, and a container running as it - # bypasses the Envoy redirect.) + scaleToZeroDelay: 300 + target: 95 securityOptions: runAsUser: 10001 firewallConfig: diff --git a/cpln-advisor/versions/1.0.0/templates/workload-redis.yaml b/cpln-advisor/versions/1.0.0/templates/workload-redis.yaml index 86a212ac..1dca66ee 100644 --- a/cpln-advisor/versions/1.0.0/templates/workload-redis.yaml +++ b/cpln-advisor/versions/1.0.0/templates/workload-redis.yaml @@ -1,4 +1,4 @@ ---- +{{- include "cpln-advisor.validate" . -}} kind: workload name: {{ include "cpln-advisor.redis.name" . }} description: Task broker and cache for the advisor @@ -40,12 +40,18 @@ spec: initialDelaySeconds: 5 periodSeconds: 10 failureThreshold: 3 + successThreshold: 1 + timeoutSeconds: 2 defaultOptions: capacityAI: false # a broker should not be resized under load + timeoutSeconds: 5 autoscaling: + maxConcurrency: 0 metric: disabled minScale: 1 maxScale: 1 # ONE replica: two brokers means tasks split between them + scaleToZeroDelay: 300 + target: 95 firewallConfig: external: inboundAllowCIDR: [] # no inbound from the internet diff --git a/cpln-advisor/versions/1.0.0/templates/workload-scheduler.yaml b/cpln-advisor/versions/1.0.0/templates/workload-scheduler.yaml index 626f3ed2..0bab305b 100644 --- a/cpln-advisor/versions/1.0.0/templates/workload-scheduler.yaml +++ b/cpln-advisor/versions/1.0.0/templates/workload-scheduler.yaml @@ -1,4 +1,4 @@ ---- +{{- include "cpln-advisor.validate" . -}} kind: workload name: {{ include "cpln-advisor.scheduler.name" . }} description: CPLN Advisor cron scheduler (fires scheduled scans and retention) @@ -48,13 +48,14 @@ spec: value: cpln://secret/{{ .Values.auth.secretName }}.ADVISOR_SECRET_KEY defaultOptions: capacityAI: true # the scheduler only enqueues; let it be sized down + timeoutSeconds: 5 autoscaling: + maxConcurrency: 0 + scaleToZeroDelay: 300 + target: 95 metric: disabled minScale: 1 maxScale: 1 # two schedulers would fire every cron twice - # Non-root. `filesystemGroupId` is gone with the shared volume it existed for. - # (Never 1337: that is the mesh proxy's UID, and a container running as it - # bypasses the Envoy redirect.) securityOptions: runAsUser: 10001 firewallConfig: diff --git a/cpln-advisor/versions/1.0.0/templates/workload-web.yaml b/cpln-advisor/versions/1.0.0/templates/workload-web.yaml index f1410211..5597720a 100644 --- a/cpln-advisor/versions/1.0.0/templates/workload-web.yaml +++ b/cpln-advisor/versions/1.0.0/templates/workload-web.yaml @@ -1,4 +1,4 @@ ---- +{{- include "cpln-advisor.validate" . -}} kind: workload name: {{ include "cpln-advisor.web.name" . }} description: CPLN Advisor dashboard @@ -45,9 +45,12 @@ spec: httpGet: path: /login # the one page that renders without a session port: 3000 + scheme: HTTP initialDelaySeconds: 5 periodSeconds: 10 failureThreshold: 3 + successThreshold: 1 + timeoutSeconds: 2 defaultOptions: capacityAI: true timeoutSeconds: 120 # the proxy waits on the API, and a scan is not quick @@ -57,6 +60,7 @@ spec: minScale: {{ .Values.web.replicas.min }} maxScale: {{ .Values.web.replicas.max }} scaleToZeroDelay: 300 + maxConcurrency: 0 firewallConfig: external: # PUBLIC, deliberately and unconditionally: the dashboard is the only way diff --git a/cpln-advisor/versions/1.0.0/templates/workload-worker.yaml b/cpln-advisor/versions/1.0.0/templates/workload-worker.yaml index 8382f85a..687e8ad1 100644 --- a/cpln-advisor/versions/1.0.0/templates/workload-worker.yaml +++ b/cpln-advisor/versions/1.0.0/templates/workload-worker.yaml @@ -1,4 +1,4 @@ ---- +{{- include "cpln-advisor.validate" . -}} kind: workload name: {{ include "cpln-advisor.worker.name" . }} description: CPLN Advisor background worker @@ -58,6 +58,7 @@ spec: value: cpln://secret/{{ .Values.auth.secretName }}.ADVISOR_SECRET_KEY defaultOptions: capacityAI: false # a long scan should not be resized underneath itself + timeoutSeconds: 5 autoscaling: metric: disabled minScale: 1 @@ -66,9 +67,9 @@ spec: # LLM calls are rate-limited upstream, and a second worker mostly buys # more 429s. Raise it deliberately, not by default. maxScale: 1 - # Non-root. `filesystemGroupId` is gone with the shared volume it existed for. - # (Never 1337: that is the mesh proxy's UID, and a container running as it - # bypasses the Envoy redirect.) + maxConcurrency: 0 + scaleToZeroDelay: 300 + target: 95 securityOptions: runAsUser: 10001 firewallConfig: diff --git a/cpln-advisor/versions/1.0.0/values.yaml b/cpln-advisor/versions/1.0.0/values.yaml index 05726652..94dcf1b5 100644 --- a/cpln-advisor/versions/1.0.0/values.yaml +++ b/cpln-advisor/versions/1.0.0/values.yaml @@ -1,237 +1,165 @@ -# ─── GVC ────────────────────────────────────────────────────────────────────── -# This chart CREATES this GVC. It must NOT already exist: Helm adopts one that -# does, and `helm uninstall` would then delete it and everything in it. +# ═══ BEFORE YOU INSTALL ═══════════════════════════════════════════════════════ # -# The name lives under `global` so every resource is tagged with it and any -# subchart would inherit it — the catalog's convention for a chart that owns -# its own GVC. -global: - cpln: - gvc: advisor - -gvc: - # EXACTLY ONE location, and the chart refuses to render with more. A workload - # runs in EVERY location of its GVC and minScale/maxScale are per-location, so - # a second location silently gives you a second scheduler firing every cron - # twice. The bundled Postgres is a single stateful workload on a - # read-write-once volume, so a second location would also give it a second, - # independent database rather than a replica. - locations: - - name: aws-us-east-1 - -# ─── Images ─────────────────────────────────────────────────────────────────── -# The API, worker and scheduler all run the SAME backend image — same code, -# different command. A scan is executed by the worker and served by the API, so -# they must never drift apart. Both packages are public on GHCR, so Control Plane -# pulls them anonymously and the GVC needs no pull secret. -# -# `:latest` tracks the project's main branch, and is resolved when the workload -# is DEPLOYED — a later push to the tag is NOT picked up until the next deploy, -# because these workloads set `supportDynamicTags: false`. Two consequences: +# 1. THE GVC MUST ALREADY EXIST. This template installs into the GVC you select +# above; it does not create one. # -# * Two installs a week apart can be running different code. -# * There is nothing to roll back TO. Every build also publishes a -# `:sha-` tag — pin one before you depend on going back. -images: - backend: ghcr.io/controlplane-com/cpln-advisor/advisor-backend:latest - web: ghcr.io/controlplane-com/cpln-advisor/advisor-web:latest - -# ─── Credentials (PREREQUISITE SECRET) ──────────────────────────────────────── -# This chart creates NO secret and takes NO credential as a value, so nothing -# sensitive passes through Helm or lands in the release. It reads everything out -# of one `dictionary` secret that must EXIST BEFORE YOU INSTALL — the workloads -# wedge waiting on it otherwise. +# That GVC must have EXACTLY ONE LOCATION. A workload runs in every location +# of its GVC, so a second location gives you a second scheduler firing every +# cron twice, and a second, independent database rather than a replica. # -# Create it with the six keys below (see README Prerequisites for the commands): +# 2. TWO SECRETS MUST ALREADY EXIST. Create both BEFORE installing — if either +# is missing the deployment fails silently. Nothing sensitive is set in this +# file; the chart only ever reads these two secrets by name. # -# ADVISOR_API_TOKEN shared dashboard <-> API token; the API fails closed without it -# ADVISOR_SECRET_KEY encrypts the AI and Slack credentials stored in the database -# ADVISOR_SESSION_SECRET signs the dashboard session cookie -# ADVISOR_USERNAME the web-UI login name, e.g. admin -# ADVISOR_PASSWORD the web-UI login password -# DATABASE_URL postgresql+asyncpg://USER:PASS@RELEASE-postgres.GVC.cpln.local:5432/DB +# Use ONE password for the database in both commands — the URL in the first +# secret must match the credentials in the second, and nothing cross-checks +# them. A mismatch installs cleanly and fails to authenticate at runtime. # -# The key names are the app's own environment variable names, so a secret built -# for a hand-applied deployment works here unchanged. +# DB_PASS="$(openssl rand -hex 32)" # -# DATABASE_URL must carry the SAME username/password/database as the database's -# own secret (postgres.config.credentialsSecretName below). NOTHING CROSS-CHECKS -# THE TWO — a mismatch is an authentication failure at runtime, not a render -# error. The host is `{release}-postgres.{gvc}.cpln.local:5432`. -auth: - secretName: advisor-config # MUST EXIST BEFORE INSTALL - -# ─── Access ─────────────────────────────────────────────────────────────────── -# The dashboard is PUBLIC, and that is not configurable. It is the only way in — -# nothing inside the GVC calls it, so an internal-only dashboard would be -# unreachable by the people it exists for. +# # a) the database's credentials -> postgres.config.credentialsSecretName +# cpln secret create-dictionary --name advisor-db-credentials --org YOUR_ORG \ +# --entry username=advisor \ +# --entry password="$DB_PASS" \ +# --entry database=advisor # -# What stands between the internet and your fleet is the login: the session -# cookie is HMAC-signed, the API fails closed without its token, and both -# credentials come from a secret you create. There is no shipped default -# password to find. +# # b) the advisor's credentials -> auth.secretName +# # RELEASE below is the Helm release name; GVC is the one you install into. +# cpln secret create-dictionary --name advisor-config --org YOUR_ORG \ +# --entry ADVISOR_API_TOKEN="$(openssl rand -hex 32)" \ +# --entry ADVISOR_SECRET_KEY="$(openssl rand -hex 32)" \ +# --entry ADVISOR_SESSION_SECRET="$(openssl rand -hex 32)" \ +# --entry ADVISOR_USERNAME=admin \ +# --entry ADVISOR_PASSWORD='YOUR-STRONG-PASSPHRASE' \ +# --entry DATABASE_URL="postgresql+asyncpg://advisor:$DB_PASS@RELEASE-postgres.GVC.cpln.local:5432/advisor" # -# The API, worker, scheduler, Redis and the database are never public, and their -# internal firewalls admit only their real callers. +# Keep ADVISOR_SECRET_KEY somewhere durable: it encrypts the AI and Slack +# credentials you later enter in the UI, and losing it loses them. # -# To narrow the dashboard to an office or VPN range, edit `inboundAllowCIDR` on -# the `{release}-web` workload after installing. +# ══════════════════════════════════════════════════════════════════════════════ + +# ─── Database (subchart: postgres) ──────────────────────────────────────────── +postgres: + config: + # Secret (a) above. MUST EXIST BEFORE INSTALL. + credentialsSecretName: advisor-db-credentials + + image: postgres:18 # 17+ is required for backups + + resources: + minCpu: 150m + maxCpu: 512m + minMemory: 256Mi + maxMemory: 512Mi + + volumeset: + capacity: 10 # GiB, 10 is the minimum. Volumes never shrink. + autoscaling: + enabled: true + maxCapacity: 50 + minFreePercentage: 20 + scalingFactor: 1.5 + + # Who inside the GVC may reach the database. Narrow this if the GVC is shared. + internalAccess: + type: same-gvc + + # Connection pooling. The advisor holds few connections, so it is off. + pgbouncer: + enabled: false + + # OFF because it needs a bucket and a cloud account you create first. + # TURN IT ON — a volume is not a backup, and nothing else protects your data. + # backup.image is COUPLED to image above: 18.1.0 backs up Postgres 18. + backup: + enabled: false + image: ghcr.io/controlplane-com/backup-images/postgres-backup:18.1.0 + schedule: "0 2 * * *" # daily, 02:00 UTC + provider: aws # aws, gcp or minio + aws: + bucket: my-backup-bucket + region: us-east-1 + cloudAccountName: my-backup-cloudaccount + policyName: my-backup-policy + prefix: advisor/backups + +# ─── Advisor credentials ────────────────────────────────────────────────────── +auth: + # Secret (b) above. MUST EXIST BEFORE INSTALL. + secretName: advisor-config -# OPTIONAL override for the dashboard's public URL, used for the Slack "View in -# Advisor" links and the CORS allowlist. +# ─── Images ─────────────────────────────────────────────────────────────────── +# The API, worker and scheduler all run the SAME backend image with different +# commands, so they must never drift. Both packages are public — no pull secret. # -# Leave it empty: the app derives `https://{web workload}-{gvc alias}.cpln.app` -# from Control Plane's built-in env vars, and the chart tells it this release's -# web workload name. Set this only for a CUSTOM DOMAIN, which cannot be derived. -# Never "*". +# `:latest` resolves when a workload is DEPLOYED, not continuously. Two installs +# a week apart can differ, and there is nothing to roll back to. Every build also +# publishes a `:sha-` tag — pin one before you depend on going back. +images: + backend: ghcr.io/controlplane-com/cpln-advisor/advisor-backend:latest + web: ghcr.io/controlplane-com/cpln-advisor/advisor-web:latest + +# ─── Dashboard URL ──────────────────────────────────────────────────────────── +# Leave empty. The app derives its own public URL, and the chart tells it this +# release's dashboard workload name. Set it ONLY for a custom domain, which +# cannot be derived. Never "*" — it is also the CORS allowlist. appUrl: "" # ─── Sessions ───────────────────────────────────────────────────────────────── -# Idle windows: how long WITHOUT activity before you must sign in again. Both -# slide forward while you work, under a hard ceiling enforced by the app. +# Idle windows: how long without activity before you must sign in again. session: hours: 12 rememberDays: 30 -# INFO logs one line per scan plus notable events. DEBUG adds per-query detail -# for every Control Plane call and is very noisy. +# INFO logs one line per scan. DEBUG is very noisy. logLevel: INFO # ─── Workloads ──────────────────────────────────────────────────────────────── -# `maxCpu`/`maxMemory` are the container LIMITS, and every workload has them. -# `minCpu`/`minMemory` are the floor Capacity AI scales from; where they are not -# set, Control Plane supplies its own default floor. +# `maxCpu`/`maxMemory` are the limits. `minCpu`/`minMemory` are the floor +# Capacity AI scales from, and are set only where it is enabled. # -# Where a min IS set, max:min must stay under 4:1 on cpu (Control Plane rejects -# the workload otherwise) and at or under 4:1 on memory. The chart checks both at -# render time — the rule is in no JSON schema and would otherwise surface as a -# 400 partway through an install. +# Where both are set, max:min must stay under 4:1 on cpu — Control Plane rejects +# the workload otherwise. The chart checks this at render time. -# The dashboard (Next.js). The only workload that can be made public. +# The dashboard. The only workload reachable from the internet. web: replicas: - min: 1 # set 0 to scale to zero when idle, at the cost of a cold start + min: 1 # 0 scales to zero when idle, at the cost of a cold start max: 3 - resources: # Capacity AI ON + resources: minCpu: 75m maxCpu: 250m minMemory: 128Mi maxMemory: 512Mi -# The API (FastAPI). Internal only — the browser never talks to it directly, the -# dashboard proxies server-side and attaches the token. Runs the database -# migration at startup, which is one reason it is pinned to a single replica. +# The API. Internal only; runs the database migration at startup. api: - resources: # Capacity AI ON - # minCpu is 75m, not the 50m the upstream .cpln manifest carries: 256m/50m is - # a 5.12:1 spread and Control Plane rejects it. 75m keeps the same ceiling at - # 3.41:1. + resources: minCpu: 75m maxCpu: 256m minMemory: 128Mi maxMemory: 512Mi -# Runs the scans, applies autopilot changes and sends the Slack digest. +# Runs the scans, applies Autopilot changes, sends the Slack digest. worker: - resources: # Capacity AI off — a long scan should not be resized underneath itself + resources: maxCpu: 100m maxMemory: 256Mi # Fires the crons that CREATE the scans. Without it nothing is ever scanned on a -# schedule, retention never prunes and autopilot never runs — while everything -# still looks healthy. +# schedule, while everything still looks healthy. scheduler: - resources: # Capacity AI ON + resources: minCpu: 25m maxCpu: 50m minMemory: 64Mi maxMemory: 128Mi -# Task broker and cache. NOT persistent: everything in it is derived or -# transient, so a restart at worst skips one scan and repeats one digest. +# Task broker and cache. Not persistent; not authenticated — its firewall admits +# only the three workloads that use it. redis: image: redis:7-alpine - resources: # Capacity AI off — a broker should not be resized under load + resources: maxCpu: 50m maxMemory: 128Mi - # Cap the cache so a runaway key cannot get the container OOM-killed mid-scan. - # Below maxMemory on purpose (100 MB vs 128 MiB). - maxmemory: 100mb - -# ─── Database (subchart: postgres 3.4.1) ────────────────────────────────────── -# Bundled, so one install brings up everything. Values here are passed to the -# catalog's `postgres` chart, which creates the workload, its volume set, its own -# identity and a policy granting `reveal` on the credentials secret below. -# -# Three workloads write this database concurrently — the API serves the -# dashboard, the worker records scan results and Autopilot changes, and the -# scheduler writes cron state. That needs a database server, and one that is -# backed up. -postgres: - image: postgres:18 # 17+ is required for the backup feature below - - config: - # SECOND PREREQUISITE SECRET — CREATE IT BEFORE YOU INSTALL. A `dictionary` - # secret with exactly three keys: `username`, `password`, `database`. From - # 3.4.0 the template reads them from here instead of taking them inline, - # which is what keeps every credential out of this file. - # - # If it does not exist at install time the deployment WEDGES SILENTLY — - # `cpln logs` returns nothing at all to tell you why. - # - # Its three values must match DATABASE_URL in the advisor secret above. - # Nothing cross-checks them. - credentialsSecretName: advisor-db-credentials - - # The advisor's database is small — scores, suggestions and activity, not - # telemetry. Deliberately above the template's defaults on memory: a fleet scan - # writes in bursts. Postgres is `stateful`, which respects minCpu/minMemory - # whether or not Capacity AI is on. 3.4:1 and 2:1. - resources: - minCpu: 150m - minMemory: 256Mi - maxCpu: 512m - maxMemory: 512Mi - - volumeset: - capacity: 10 # GiB — 10 is the minimum - # One-way (volumes never shrink), so maxCapacity is the real cost ceiling. - autoscaling: - enabled: true - maxCapacity: 50 - minFreePercentage: 20 - scalingFactor: 1.5 - - # `same-gvc` rather than a workload list: this chart creates a dedicated GVC, - # so same-gvc already means "the advisor and nothing else". A subchart's values - # cannot be templated, so a workload list would have to hardcode - # release-prefixed names. Narrow it by hand if you add anything to this GVC. - internalAccess: - type: same-gvc - - # Connection pooling. Off because this app holds few connections — three - # workloads, one replica each. Turn it on if you ever raise those counts. - pgbouncer: - enabled: false - - # OFF because it needs a bucket and a cloud account you create first. TURN IT - # ON: a volume is not a backup, and the reason this template runs Postgres at - # all is that a database was lost. - # - # `backup.image` IS COUPLED TO `image` ABOVE — tag 18.1.0 backs up Postgres 18, - # 17.1.0 backs up Postgres 17. Change one and change the other, or pg_dump - # meets a server it cannot read. - # - # `provider: minio` needs its OWN prerequisite dictionary secret holding - # `accessKey` and `secretKey`, named in backup.minio.credentialsSecretName. - backup: - enabled: false - image: ghcr.io/controlplane-com/backup-images/postgres-backup:18.1.0 - schedule: "0 2 * * *" # daily, 02:00 UTC - provider: aws # options: aws, gcp, minio - aws: - bucket: my-backup-bucket - region: us-east-1 - cloudAccountName: my-backup-cloudaccount - policyName: my-backup-policy - prefix: advisor/backups + maxmemory: 100mb # Redis' own cache cap, below maxMemory on purpose diff --git a/cpln-advisor/versions/2.0.0/Chart.yaml b/cpln-advisor/versions/2.0.0/Chart.yaml deleted file mode 100644 index dbf2926b..00000000 --- a/cpln-advisor/versions/2.0.0/Chart.yaml +++ /dev/null @@ -1,35 +0,0 @@ -apiVersion: v2 -name: cpln-advisor -description: AI advisor that right-sizes your workloads from their own metrics, logs and billed cost - -type: application -version: 2.0.0 -appVersion: "1.0.0" - -annotations: - created: "2026-08-17" - lastModified: "2026-08-30" - category: "observability" - # 2.0.0 stopped creating a GVC. 1.0.0 named the GVC it created after the one - # you installed into, so Helm ADOPTED your GVC — never `helm upgrade` a 1.0.0 - # release onto this version; see `Migrating from 1.0.0` in the README. - createsGvc: false - -dependencies: - - name: cpln-common - version: 1.0.0 - repository: "oci://ghcr.io/controlplane-com/templates" - # The advisor's database. Bundled so a single install brings up everything: - # the chart arrives with a readiness probe, a private firewall, its own - # identity and policy, and scheduled backups behind a switch. - # - # 3.4.1, NOT 3.3.0: from 3.4.0 the template reads its username/password/database - # from a prerequisite secret instead of taking them inline, which is what lets - # this chart bundle a database and still keep every credential out of values. - # - # 3.4.1 does NOT create a GVC, so this pin needed no bump for the conversion. - # It also has no `location` knob, which is why it is the one tier this chart - # cannot pin — see `Database` in the README and the values note below it. - - name: postgres - version: 3.4.1 - repository: "oci://ghcr.io/controlplane-com/templates" diff --git a/cpln-advisor/versions/2.0.0/README.md b/cpln-advisor/versions/2.0.0/README.md deleted file mode 100644 index 5ad198c1..00000000 --- a/cpln-advisor/versions/2.0.0/README.md +++ /dev/null @@ -1,346 +0,0 @@ -# CPLN Advisor - -CPLN Advisor watches the workloads in your org, tracks CPU, memory, replica counts and error rates, and turns what it finds into concrete tuning suggestions — memory limits, autoscaling thresholds, replica counts — generated by an LLM grounded in Control Plane runbooks. Suggestions appear in a dashboard, and **Autopilot** can apply the qualifying ones for you, each with a one-click revert. - -It reports on the org it runs in. Control Plane injects `CPLN_ORG` into every workload, so the advisor knows where it lives without being told. - -> **Upgrading from 1.0.0? Do not `helm upgrade`.** 1.0.0 created a GVC, and it named that GVC after the one you installed into — so Helm **adopted your GVC** and has owned it ever since. This version stops declaring it, and Helm deletes what a chart no longer declares. See [Migrating from 1.0.0](#migrating-from-100). - -## Architecture - -One install brings up everything. The chart deploys into **an existing GVC**, bundles the database, and **creates no credential secret and takes no credential as a value** — it reads two secrets you create first. - -| Resource | Type | Purpose | -|---|---|---| -| `{release}-web` | serverless | The Next.js dashboard, **public**. The only workload exposed to the internet, and the only one holding a session key. Scales 1–3. | -| `{release}-api` | standard | FastAPI backend on port 8000. Runs the boot guards, then `alembic upgrade head`. Internal; reachable only by the dashboard. **1 replica.** | -| `{release}-worker` | standard | Runs scans, applies Autopilot changes, posts the Slack digest. **1 replica.** | -| `{release}-scheduler` | standard | Fires the crons that *create* the scans. **1 replica.** | -| `{release}-redis` | standard | Task broker and cache. Not persistent, and unauthenticated — the firewall is its whole access control. | -| `{release}-postgres` + `-pg-vs`, `-pg-identity`, `-pg-policy` | subchart | The database, from the catalog's `postgres` chart **3.4.1**: a stateful workload on its own volume set, with a `pg_isready` probe and scheduled backups behind a switch. It creates no secret — it reads your database credentials secret. | -| `{release}-startup` | secret | The API's boot script (no credential in it). | -| `{release}-identity`, `-policy`, `-gvc-policy` | | `reveal` on **exactly two** secrets — the advisor credentials you created and the boot script — plus `view` on **exactly one** GVC, the one you installed into. | - -Every workload is pinned to the single `location` you configure: `defaultOptions.minScale/maxScale` are `0` and a `localOptions` entry carries the real replica counts, so a GVC location this release did not ask for starts nothing. The one exception is the bundled Postgres — see [Database](#database). - -**The scheduler is not optional.** The worker runs tasks; the scheduler fires the cron that creates them. Without it nothing is ever scanned on a schedule, retention never prunes and Autopilot never runs — while every workload still reports healthy. This has been mistaken for a broken worker more than once. - -### Why the database is bundled - -Three workloads write it concurrently — the API serves the dashboard, the worker records scan results and Autopilot changes, and the scheduler writes cron state. That needs a database server, and one that is backed up. - -It comes from the catalog's `postgres` template rather than being hand-written here, because a hand-rolled workload + volume set + firewall stack routinely ships without backups, with a public database, or single-replica. That chart arrives with a `pg_isready` readiness probe, a private-by-default firewall, its own identity and policy, and scheduled backups behind a switch. - -## Prerequisites - -- **An existing GVC with exactly one location.** This chart does not create one. Extra locations are not merely unused here — see [Database](#database). -- **Two `dictionary` secrets, created BEFORE you install.** Neither the chart nor its values ever hold a credential — `helm template` succeeds with no values at all, which is the check that this stays true. - -If either secret is missing the deployment **wedges silently**: the workloads never start and `cpln logs` returns nothing to say why. The one place the cause is named is `status.versions[].message` in `cpln workload get-deployments --gvc -o yaml`. - -### 1. The advisor's credentials - -Keys are the app's own environment-variable names, so a secret built for a hand-applied deployment works here unchanged. - -```bash -cpln secret create-dictionary --name advisor-config --org YOUR_ORG \ - --entry ADVISOR_API_TOKEN="$(openssl rand -hex 32)" \ - --entry ADVISOR_SECRET_KEY="$(openssl rand -hex 32)" \ - --entry ADVISOR_SESSION_SECRET="$(openssl rand -hex 32)" \ - --entry ADVISOR_USERNAME=admin \ - --entry ADVISOR_PASSWORD='YOUR-STRONG-PASSPHRASE' \ - --entry DATABASE_URL="postgresql+asyncpg://advisor:$DB_PASS@RELEASE-postgres.YOUR_GVC.cpln.local:5432/advisor" -``` - -| Key | What it is | -|---|---| -| `ADVISOR_API_TOKEN` | Shared between the dashboard and the API. The API **fails closed**: unset, it refuses every route except `/health`. | -| `ADVISOR_SECRET_KEY` | Encrypts the AI and Slack credentials stored in the database. **Losing or changing it loses those credentials** — unrecoverable, and the app reports them as "not set" rather than erroring. | -| `ADVISOR_SESSION_SECRET` | Signs the dashboard's session cookie. Rotating it is safe; it signs everyone out. | -| `ADVISOR_USERNAME` / `ADVISOR_PASSWORD` | The web-UI login. A human types the password, so prefer a long passphrase. | -| `DATABASE_URL` | `postgresql+asyncpg://` — **not** `postgres://`: SQLAlchemy needs the driver named. Host is `{release}-postgres.{gvc}.cpln.local:5432`, always fully qualified. | - -### 2. The database's credentials - -From version 3.4.0 the bundled `postgres` template reads its credentials from a secret instead of taking them inline — which is what lets this chart bundle a database and still keep every credential out of values. Exactly three keys: - -```bash -DB_PASS="$(openssl rand -hex 32)" - -cpln secret create-dictionary --name advisor-db-credentials --org YOUR_ORG \ - --entry username=advisor \ - --entry password="$DB_PASS" \ - --entry database=advisor -``` - -> **These three values must match `DATABASE_URL` above, and nothing cross-checks them.** A mismatch is not a render error — it is an authentication failure at runtime, after everything appears to install cleanly. Set `DB_PASS` once and use it in both commands, as shown. - -A secret needs three things and two fail silently: the identity linked to the workload, a policy granting **`reveal`** (not `view`), and the `cpln://secret/NAME.KEY` reference. The chart wires all three — the advisor's identity gets `reveal` on `advisor-config` and its own boot script, and the database's own identity on `advisor-db-credentials`, so neither can read the other's. - -### A Control Plane service-account token — *after* install, not before - -The advisor's own Control Plane token is **deliberately not a value and not in the secret**. You set it once in the UI after signing in (**Configuration → Control Plane**), where it is stored encrypted in the database. - -Create a service account whose token can, at minimum: - -| Permission | Why | -|---|---| -| `gvc: view` | List GVCs and their workloads | -| `workload: view` | Read specs, deployments, events | -| `org: readLogs` | Classify errors from workload logs | -| `org: readMetrics` | CPU, memory, latency, request metrics | -| `org: readUsage` | Real billed cost per workload, rather than an estimate | -| `workload: edit` | **Only** to apply or revert a suggestion — it patches and redeploys. Omit it to run the advisor read-only. | - -## Configuration - -### Location - -```yaml -location: aws-us-east-1 # ONE location of the GVC you install into -``` - -`global.cpln.gvc` is injected by the platform at install time and is never declared in values. There is no `gvc` key — the chart refuses to render if it finds one. - -The advisor runs in **exactly one location**, and this names it. Every workload gets `defaultOptions.minScale/maxScale: 0` plus a `localOptions` entry for this location, so an undeclared GVC location starts nothing and its deployment reads `This workload location is deactivated because maxScale is set to 0`. The API, worker, scheduler and Redis also refuse to start outside it, which is the last line if that ever fails. - -Nothing validates the other direction — a `location` the GVC does *not* have is accepted and stored, and simply runs nothing anywhere — so the API reads the GVC at boot and says so in its log. That check only warns; it never stops the dashboard from being served. - -### Images - -```yaml -images: - backend: ghcr.io/controlplane-com/cpln-advisor/advisor-backend:latest - web: ghcr.io/controlplane-com/cpln-advisor/advisor-web:latest -``` - -The API, worker and scheduler all run the **same** backend image with different commands — a scan is executed by the worker and served by the API, so they must never drift. Both packages are public, so Control Plane pulls them with no credential. - -`:latest` follows the project's main branch. It is resolved when the workload is **deployed**, not continuously: these workloads set `supportDynamicTags: false`, so a later push to the tag is not picked up until something triggers a redeploy. Two consequences worth knowing before you rely on this: - -- **Two installs a week apart can be running different code**, with nothing in the release to say so. -- **There is nothing to roll back to.** Every build also publishes a `:sha-` tag; pin the same one on both images with `--set images.backend=…:sha- --set images.web=…:sha-` before you depend on being able to go back. - -### Login - -```yaml -auth: - secretName: advisor-config # a NAME, not a credential — see Prerequisites -``` - -The login name and password are keys in that secret (`ADVISOR_USERNAME`, `ADVISOR_PASSWORD`), not values here. - -The login is verified by the **API**, never by the dashboard, so one workload holds the credentials. One account for everyone: change history and activity attribute everything to a single user, and there is no per-person audit trail. - -### Access - -There is nothing to configure. **The dashboard is public**, and the other five workloads never are. - -That is not a toggle because it is not really a choice: the dashboard is the only way into this app, and nothing inside the GVC calls it, so an internal-only dashboard would be unreachable by the people it exists for. - -What stands between the internet and your fleet is the login: - -- the session cookie is HMAC-signed with `ADVISOR_SESSION_SECRET`, so it cannot be forged -- the API **fails closed** — without `ADVISOR_API_TOKEN` it refuses every route except `/health` -- both credentials come from a secret you create, so there is **no shipped default password** to look up - -Everything else is closed to the internet, and internal traffic is admitted only from real callers: - -| Workload | Public | Reachable inside the GVC by | -|---|---|---| -| `{release}-web` | **yes** | nothing — it calls out, never in | -| `{release}-api` | no | the dashboard only | -| `{release}-redis` | no | the API, worker and scheduler only | -| `{release}-worker`, `-scheduler` | no | nothing | -| `{release}-postgres` | no | anything in the GVC (`same-gvc`) — see [Database](#database) | - -To narrow the dashboard to an office or VPN range, edit `inboundAllowCIDR` on the `{release}-web` workload after installing. A firewall change takes roughly 30 seconds to several minutes to take effect. - -### Public URL - -```yaml -appUrl: "" # e.g. https://advisor.example.com -``` - -**Leave this empty unless you use a custom domain.** The app derives its own public URL from Control Plane's built-in environment variables as `https://{web workload}-{gvc alias}.cpln.app`, and the chart passes it this release's actual dashboard workload name (`ADVISOR_WEB_WORKLOAD`) so the derivation resolves to the right workload. Both the Slack "View in Advisor" links and the CORS allowlist come from it. - -Note that the derivation is deliberately **not** `CPLN_GLOBAL_ENDPOINT`: that variable is the host of whichever workload reads it, which here is the API — internal, and not where any browser goes. Setting `appUrl` overrides the derivation, which is the only way to point at a custom domain. - -CORS is close to inert in this topology — the browser only ever talks to the dashboard, which calls the API server-side — but it matters the moment anything else calls the API from a page. It must never be `*`: combined with credentials, a wildcard makes the server echo back whichever origin asked. - -### Sessions - -```yaml -session: - hours: 12 # idle timeout - rememberDays: 30 # idle timeout with "Keep me signed in" -``` - -Both are *idle* windows and slide forward while you work, under a hard ceiling the app enforces, so an abandoned tab cannot stay authenticated forever. - -### Logging - -```yaml -logLevel: INFO # DEBUG adds per-query detail for every Control Plane call, and is very noisy -``` - -### Resources - -```yaml -web: # Capacity AI ON - replicas: { min: 1, max: 3 } # min 0 scales to zero, at the cost of a cold start - resources: { minCpu: 75m, maxCpu: 250m, minMemory: 128Mi, maxMemory: 512Mi } -api: # Capacity AI ON - resources: { minCpu: 75m, maxCpu: 256m, minMemory: 128Mi, maxMemory: 512Mi } -worker: { resources: { cpu: 100m, memory: 256Mi } } # Capacity AI off -scheduler: { resources: { minCpu: 25m, maxCpu: 50m, minMemory: 64Mi, maxMemory: 128Mi } } # Capacity AI ON -redis: - image: redis:7-alpine - resources: { cpu: 50m, memory: 128Mi } # Capacity AI off - maxmemory: 100mb # Redis' own cache cap, below `memory` on purpose -``` - -A block that exposes both a floor and a limit names them `minCpu`/`maxCpu` and `minMemory`/`maxMemory`. A block that exposes only the limit uses the API's own field names, bare `cpu`/`memory`. **2.0.0 renamed `worker.resources` and `redis.resources` accordingly** — they were `maxCpu`/`maxMemory` in 1.0.0, with no floor to contrast against. - -`minCpu`/`minMemory` are the floor [Capacity AI](https://docs.controlplane.com/reference/workload/capacity) scales up from, and the three workloads that enable it — the dashboard, the API and the scheduler — each set one. The worker and Redis run with Capacity AI **off** (a long scan should not be resized underneath itself, and a broker should not be resized under load), so a floor there would be inert and is not set. The bundled Postgres is `stateful`, which respects its floor either way. - -Two limits the chart checks at render time, because Control Plane enforces both but publishes neither in a JSON schema — so each would otherwise surface as a 400 *partway through an install*: - -- **`maxCpu`/`minCpu` must be strictly under 4:1** (`The ratio between cpu and minCpu must be less than 4:1`). Memory is bounded the same way, at 4:1 inclusive. -- **Unit typos.** `cpu` and `memory` are typed as bare strings with no numeric bound, so `512Gi` written for `512Mi` is *accepted* and the workload then never schedules. - -Raising a `maxCpu` or `maxMemory` on `web`, `api`, `scheduler` or `postgres` means raising its matching min too. - -### Database - -```yaml -postgres: - image: postgres:18 # 17+ required for backups - config: - credentialsSecretName: advisor-db-credentials # a NAME — see Prerequisites - resources: { minCpu: 150m, maxCpu: 512m, minMemory: 256Mi, maxMemory: 512Mi } - volumeset: - capacity: 10 # GiB, 10 is the minimum - autoscaling: { enabled: true, maxCapacity: 50, minFreePercentage: 20, scalingFactor: 1.5 } - internalAccess: - type: same-gvc - pgbouncer: - enabled: false # few connections: three workloads, one replica each - backup: - enabled: false # turn this on — see below -``` - -Passed straight through to the bundled [`postgres`](https://github.com/controlplane-com/templates/tree/main/postgres) chart, pinned at **3.4.1**. Memory is deliberately above that chart's defaults — a fleet scan writes in bursts. - -**The database is the one tier this chart cannot pin to a location.** `postgres` 3.4.1 has no `location` knob and renders no `localOptions`, and a subchart's values cannot be templated, so the parent has nothing to hand it. In a GVC with more than one location it runs **one independent, empty database per location**, each on its own volume — billed, and answering the same `{release}-postgres.{gvc}` service DNS name this release connects to. **Install the advisor into a single-location GVC.** The API warns about extra locations in its startup log; it cannot prevent them. - -**`same-gvc` is wider than it was in 1.0.0.** That version created a dedicated GVC, so `same-gvc` meant "the advisor and nothing else". The GVC is now yours and may hold unrelated workloads, any of which can open port 5432. If that GVC is shared, narrow it at install time with the real names: - -```bash ---set postgres.internalAccess.type=workload-list \ ---set postgres.internalAccess.workloads[0]=//gvc/YOUR_GVC/workload/RELEASE-api \ ---set postgres.internalAccess.workloads[1]=//gvc/YOUR_GVC/workload/RELEASE-worker \ ---set postgres.internalAccess.workloads[2]=//gvc/YOUR_GVC/workload/RELEASE-scheduler -``` - -Add `RELEASE-postgres-backup` to that list if you turn backups on. - -**Turn backups on.** They are off only because they need a bucket and a cloud account you create first. A volume is not a backup — losing it loses every scan, score and Autopilot record: - -```yaml -postgres: - backup: - enabled: true - image: ghcr.io/controlplane-com/backup-images/postgres-backup:18.1.0 - schedule: "0 2 * * *" - provider: aws # or gcp, minio - aws: - bucket: my-backup-bucket - region: us-east-1 - cloudAccountName: my-backup-cloudaccount - policyName: my-backup-policy - prefix: advisor/backups -``` - -`backup.image` is **coupled to `image`** — tag `18.1.0` backs up Postgres 18, `17.1.0` backs up Postgres 17. Change one and change the other, or `pg_dump` meets a server it cannot read. `provider: minio` needs its own prerequisite dictionary secret holding `accessKey` and `secretKey`. The bucket, cloud account and IAM policy steps are the `postgres` template's — see its **Storage setup** section, which this chart passes through unchanged. - -#### Restoring - -Each run writes **one gzipped `pg_dumpall` plain-SQL file**, `postgres-.sql.gz`, under `//` (read out of the pinned backup image's `/usr/local/bin/backup.sh`). `pg_dumpall` is a whole-cluster script including `CREATE ROLE` and `CREATE DATABASE`, so it restores into an empty server; it is not a merge into a running one. - -The shape of a restore is: download the object with your own cloud tooling (the Postgres container has no `aws` or `gsutil`), reach the database with `cpln port-forward {release}-postgres 5432:5432 --gvc `, and pipe it in with `gunzip -c postgres-….sql.gz | psql -v ON_ERROR_STOP=1 -h 127.0.0.1 -p 5432 -U -d postgres`. Stop the API, worker and scheduler first, or they will be writing while you restore. - -> **This restore was executed end to end against this template** (2026-08-30): a real backup was taken and verified (10,377 B gzipped → 90,072 B, 8 tables with actual scan rows), the database dropped, the dump restored, and the application confirmed working on the restored data. -> -> **Use a `psql` of version 18 or newer, and keep `-v ON_ERROR_STOP=1`.** The dump comes from `postgres:18`, whose `pg_dumpall` emits `\restrict` / `\unrestrict` directives older clients do not understand. Without `ON_ERROR_STOP` an older `psql` prints `invalid command \restrict` for each one and still **exits 0** — measured, and the restore happened to succeed anyway only because those lines were not load-bearing. A silently-successful restore is the dangerous case. - -### Scaling past one replica - -The API, worker and scheduler are each pinned to one replica, and Postgres is no longer what stops you raising them: - -- **API** — two replicas would race on the same `alembic upgrade head` at startup. Lifting this means moving the migration out of the container's start command into its own step. -- **Worker** — a fleet scan's Control Plane and LLM calls are rate-limited upstream, so a second worker mostly buys more 429s. Raise it deliberately. -- **Scheduler** — two would fire every cron twice. This one is a hard no. - -Note these are *per-location* counts on top of a single configured location. Adding a second location is not how you get a second replica; it is how you get a second, unaware copy of everything. - -## Connecting - -| Path | Address | Notes | -|---|---|---| -| Dashboard | `https://` | Always public. Read it from `status.canonicalEndpoint` in `cpln workload get -web -o yaml` — never hand-build it. | -| API | `http://-api..cpln.local:8000` | Internal, and reachable only from the dashboard. Plain `http` is correct — the sidecar adds mTLS. | -| Database | `-postgres..cpln.local:5432` | Internal. Credentials are in your database secret. | - -## After the first deploy - -1. Open the dashboard and sign in with the `ADVISOR_USERNAME` and `ADVISOR_PASSWORD` from your credentials secret. -2. Go to **Configuration → Control Plane**, paste the service-account token, and press **Test connection**. -3. Add an Anthropic or OpenAI key on the same page, and a Slack bot token if you want digests. These are stored encrypted in the database using `ADVISOR_SECRET_KEY` — which is why losing that key loses them. -4. Enable **Scan** on the workloads you want watched, then **Scan now**. -5. Check the API's log for `GVC location check OK`, and the scheduler's for it firing `run_scan`. - -## Migrating from 1.0.0 - -**1.0.0 created a GVC named after the one you installed into.** Helm therefore *adopted* your GVC and has owned it since the first install. 2.0.0 does not declare a GVC at all, and Helm deletes what a chart no longer declares — so an in-place upgrade destroys that GVC and **every workload, volumeset and identity inside it**, including anything unrelated you keep there, while printing `upgraded successfully`. This is not hypothetical: the same adoption path deleted a shared GVC in testing, despite a `helm.sh/resource-policy: keep` annotation on it. - -The chart refuses to render when it sees the old `gvc` values key, so an upgrade that passes your 1.0.0 values file fails safely. **An upgrade run with no values at all sees only 2.0.0's defaults, and cannot be caught** — which is why this section exists. - -Migrate to a **new release** instead: - -1. Back up the database. If `postgres.backup` is off, turn it on and let one run complete, or take a manual `pg_dumpall` — see [Restoring](#restoring). -2. Install 2.0.0 as a **new release** into an existing single-location GVC, with a new `DATABASE_URL` in a new credentials secret pointing at the new release's Postgres. Both prerequisite secrets are not release-managed, so you can reuse the advisor credentials secret if you also point `DATABASE_URL` at the new host. -3. Load the dump into the new database, then sign in and confirm the scan history and stored credentials are there. `ADVISOR_SECRET_KEY` must be the **same value** as the old release used, or the AI and Slack credentials in the restored rows cannot be decrypted and will read as "not set". -4. Only then `cpln helm uninstall` the old release. Be aware that this deletes the GVC it adopted. - -Also changed in 2.0.0, and both are clean breaks with no fallback: - -- `gvc.locations` (a list) became the top-level `location` (a single name). -- `worker.resources.maxCpu`/`maxMemory` and `redis.resources.maxCpu`/`maxMemory` became bare `cpu`/`memory`. - -## Important Notes - -- **Never `helm upgrade` a 1.0.0 release onto 2.0.0.** It deletes the GVC 1.0.0 adopted and everything in it. See [Migrating from 1.0.0](#migrating-from-100). -- **Install into a single-location GVC.** Every advisor workload is pinned to `location`, but the bundled Postgres cannot be, and a second GVC location gives it a second independent database on the same service DNS name. -- **Create both secrets before installing.** A missing one wedges the deployment silently; the only place it is named is `status.versions[].message` from `cpln workload get-deployments`. -- **No credential passes through this chart.** It creates no credential secret and takes none as a value, so nothing sensitive reaches the Helm release. `helm template` succeeds with no values at all — that is the check that this stays true. -- **`DATABASE_URL` and the database credentials secret must agree, and nothing cross-checks them.** A mismatch installs cleanly and then fails to authenticate at runtime. -- **Losing `ADVISOR_SECRET_KEY` loses every credential entered in the UI.** They are unrecoverable and must be re-entered. Keep it somewhere durable before you deploy. -- **The dashboard is public and there is no values knob to close it.** The login is the boundary — a signed session cookie, an API that fails closed, and no shipped default password. Narrow `inboundAllowCIDR` on the `{release}-web` workload after installing if you want it tighter. -- **The bundled database admits any workload in your GVC.** If the GVC is shared, narrow `postgres.internalAccess` — see [Database](#database). -- **Backups are OFF by default and you should turn them on.** A volume is not a backup, and the restore path is documented but unrehearsed — see [Restoring](#restoring). -- **A Postgres password is first-boot only.** It is read when the data directory initializes. Rotating it in the secret afterwards does not change the running server — you must also change it in Postgres itself, and update `DATABASE_URL` to match. -- **Rotating any secret requires a forced redeployment.** `cpln://secret/…` references resolve when a replica starts and are never re-resolved while it lives, so a rotated secret keeps the old value indefinitely with everything reporting healthy. Run `cpln workload force-redeployment --gvc ` for each affected workload. -- **Redis is not persistent and not authenticated.** Everything in it is derived or transient, so a restart at worst skips one scan and repeats one digest. Its firewall admits exactly the three workloads that use it, and that is the whole access control — do not widen it. -- **Both images track `:latest`, so an install is not reproducible.** The tag resolves at deploy time, and there is no earlier build to fall back to. Pin `:sha-` on both images before this matters to you. -- **Autopilot redeploys your workloads.** Each applied suggestion patches a live workload and restarts it. It is per-workload and off until you enable it, every change is recorded in Activity with a one-click revert — but the redeploy itself is real. -- **The advisor's token is as powerful as you make it.** Grant `workload: edit` only if you want Autopilot and one-click apply; without it the advisor runs read-only. -- **`uninstall` deletes the database volume set and the scan history with it.** It does **not** delete the GVC — 2.0.0 never owned it — and it does not delete your two prerequisite secrets. - -## Links - -- [Control Plane documentation](https://docs.controlplane.com) -- [Creating a service account](https://docs.controlplane.com/guides/create-service-account) -- [Volume sets](https://docs.controlplane.com/reference/volumeset) -- [Workload firewall](https://docs.controlplane.com/reference/workload/firewall) -- [Capacity AI](https://docs.controlplane.com/reference/workload/capacity) diff --git a/cpln-advisor/versions/2.0.0/templates/_helpers.tpl b/cpln-advisor/versions/2.0.0/templates/_helpers.tpl deleted file mode 100644 index 42374a91..00000000 --- a/cpln-advisor/versions/2.0.0/templates/_helpers.tpl +++ /dev/null @@ -1,580 +0,0 @@ -{{/* Resource Naming */}} - -{{/* -Advisor Web Workload Name (the dashboard) -*/}} -{{- define "cpln-advisor.web.name" -}} -{{- printf "%s-web" .Release.Name }} -{{- end }} - -{{/* -Advisor API Workload Name -*/}} -{{- define "cpln-advisor.api.name" -}} -{{- printf "%s-api" .Release.Name }} -{{- end }} - -{{/* -Advisor Worker Workload Name -*/}} -{{- define "cpln-advisor.worker.name" -}} -{{- printf "%s-worker" .Release.Name }} -{{- end }} - -{{/* -Advisor Scheduler Workload Name -*/}} -{{- define "cpln-advisor.scheduler.name" -}} -{{- printf "%s-scheduler" .Release.Name }} -{{- end }} - -{{/* -Advisor Redis Workload Name -*/}} -{{- define "cpln-advisor.redis.name" -}} -{{- printf "%s-redis" .Release.Name }} -{{- end }} - -{{/* -Advisor Identity Name -*/}} -{{- define "cpln-advisor.identity.name" -}} -{{- printf "%s-identity" .Release.Name }} -{{- end }} - -{{/* -Secret-reveal Policy Name. - -A policy name is org-wide and already derived from the release name alone, so -the GVC conversion does not change it. -*/}} -{{- define "cpln-advisor.policy.name" -}} -{{- printf "%s-policy" .Release.Name }} -{{- end }} - -{{/* -GVC-read Policy Name — new in 2.0.0, for the boot-time GVC reconciliation. -*/}} -{{- define "cpln-advisor.gvcPolicy.name" -}} -{{- printf "%s-gvc-policy" .Release.Name }} -{{- end }} - -{{/* -Startup-script Secret Name. Holds the boot guard the three backend tiers run -before their real command; see cpln-advisor.startupScript below. -*/}} -{{- define "cpln-advisor.startupScript.name" -}} -{{- printf "%s-startup" .Release.Name }} -{{- end }} - -{{/* -Bundled Postgres workload name. The `postgres` subchart names it -`{{ .Release.Name }}-postgres`, and as a subchart that Release.Name is OURS — so -this must track the subchart's own helper. A rename there breaks this silently. -*/}} -{{- define "cpln-advisor.postgres.name" -}} -{{- printf "%s-postgres" .Release.Name }} -{{- end }} - -{{/* -Internal address of Redis. Plain redis:// is correct — the sidecar adds mTLS. -*/}} -{{- define "cpln-advisor.redis.url" -}} -{{- printf "redis://%s.%s.cpln.local:6379" (include "cpln-advisor.redis.name" .) .Values.global.cpln.gvc }} -{{- end }} - -{{/* -Internal address of the API, on the CONTAINER port (8000), not 443. -*/}} -{{- define "cpln-advisor.api.url" -}} -{{- printf "http://%s.%s.cpln.local:8000" (include "cpln-advisor.api.name" .) .Values.global.cpln.gvc }} -{{- end }} - - -{{/* Topology — internal firewall rosters */}} - -{{/* -One workload link, built in ONE place. - -Every internal-firewall entry in this chart goes through this helper. Before -2.0.0 the links were hand-written `//gvc/{{ .Values.global.cpln.gvc }}/workload/…` -strings in two different workload files; that is the shape that drifted twice -elsewhere in this batch, because a renamed tier only breaks the copy nobody -edited. Call as: - - {{ include "cpln-advisor.workloadLink" (dict "root" . "name" (include "cpln-advisor.web.name" .)) }} -*/}} -{{- define "cpln-advisor.workloadLink" -}} -{{- printf "//gvc/%s/workload/%s" .root.Values.global.cpln.gvc .name -}} -{{- end -}} - -{{/* -Every workload this RELEASE creates, as firewall links — the roster the -`workload-list` self-inclusion rule is about. - -It is deliberately NOT what the two rosters below use. The internal firewall -list governs ALL inbound internal traffic including tier-to-tier, so a list -naming only outside callers cuts a release off from itself; but this chart's -call graph is small, fixed and fully known, so each callee admits exactly its -real callers rather than the whole release: - - web <- nothing. The browser's entry point; it calls out, never in. - api <- web only. The dashboard proxies server-side and attaches the token. - worker <- nothing. It pulls its work from Redis. - scheduler <- nothing. It only enqueues into Redis. - redis <- api, worker, scheduler. - postgres <- api, worker, scheduler (subchart; see the note in values.yaml — - its firewall is `same-gvc` because a subchart's values cannot be - templated, so it cannot be handed release-prefixed names). - -This helper exists so that roster is written down once and can be diffed against -the two live lists in review. Nothing renders it directly. -*/}} -{{- define "cpln-advisor.ownWorkloadLinks" -}} -- {{ include "cpln-advisor.workloadLink" (dict "root" . "name" (include "cpln-advisor.web.name" .)) }} -- {{ include "cpln-advisor.workloadLink" (dict "root" . "name" (include "cpln-advisor.api.name" .)) }} -- {{ include "cpln-advisor.workloadLink" (dict "root" . "name" (include "cpln-advisor.worker.name" .)) }} -- {{ include "cpln-advisor.workloadLink" (dict "root" . "name" (include "cpln-advisor.scheduler.name" .)) }} -- {{ include "cpln-advisor.workloadLink" (dict "root" . "name" (include "cpln-advisor.redis.name" .)) }} -- {{ include "cpln-advisor.workloadLink" (dict "root" . "name" (include "cpln-advisor.postgres.name" .)) }} -{{- end -}} - -{{/* -Who may open the API on 8000: the dashboard, and nothing else. -*/}} -{{- define "cpln-advisor.api.callers" -}} -- {{ include "cpln-advisor.workloadLink" (dict "root" . "name" (include "cpln-advisor.web.name" .)) }} -{{- end -}} - -{{/* -Who may open Redis on 6379. Redis is unauthenticated, so this list IS its whole -access control. All three are unconditional — this chart has no optional tier. -*/}} -{{- define "cpln-advisor.redis.callers" -}} -- {{ include "cpln-advisor.workloadLink" (dict "root" . "name" (include "cpln-advisor.api.name" .)) }} -- {{ include "cpln-advisor.workloadLink" (dict "root" . "name" (include "cpln-advisor.worker.name" .)) }} -- {{ include "cpln-advisor.workloadLink" (dict "root" . "name" (include "cpln-advisor.scheduler.name" .)) }} -{{- end -}} - - -{{/* Placement */}} - -{{/* -The scale bounds for one options entry. - -`scale` is the replica count to pin to — 0 for defaultOptions, the real count in -localOptions for the ONE configured location. - -defaultOptions at minScale/maxScale 0 is what keeps the single-location -invariant after this chart stopped owning its GVC. Before 2.0.0 the chart -guaranteed one location by CREATING a one-location GVC; now the GVC belongs to -the user and may have several, and a workload runs in EVERY location of its GVC. -At 0/0 a location this release did not ask for starts nothing, and its -deployment reads `This workload location is deactivated because maxScale is set -to 0`. - -Every field is sent in BOTH entries. A localOptions entry is not a patch onto -defaultOptions: the API completes a PARTIAL block from its OWN platform -defaults, so an omitted key falls through to a platform value rather than to -defaultOptions'. Send the whole block or none of it. -*/}} -{{- define "cpln-advisor.autoscaling" -}} -{{- $s := .scale -}} -maxConcurrency: 0 -maxScale: {{ $s.max }} -metric: {{ .metric | default "disabled" }} -minScale: {{ $s.min }} -scaleToZeroDelay: {{ .scaleToZeroDelay | default 300 }} -{{- /* - 95, not 100: measured 2026-08-30 with a `cpln apply` of a standard workload - carrying a PARTIAL defaultOptions, the API backfilled - maxConcurrency 0 / scaleToZeroDelay 300 / target 95 / debug false / - suspend false / timeoutSeconds 5. 1.0.0 sent a partial block and got exactly - those, so rendering them keeps the stored spec identical to what this chart - already produced — the point of declaring them is that a COMPLETE block is - stored verbatim while a partial one is completed from platform defaults. - `target` is inert under `metric: disabled`; it is sent so the block is whole. -*/}} -target: {{ .target | default 95 }} -{{- end -}} - - -{{/* Boot guards */}} - -{{/* -GUARD A — never run outside the ONE configured location. Rendered inline into -every tier whose command this chart already controls. - -`defaultOptions.minScale/maxScale` are 0 on every workload here, so the platform -should never place a replica outside `location`. This is the last line if it -ever does, and it is FATAL unconditionally: killing a replica the release never -asked for cannot reduce capacity in the location it did ask for. A second -scheduler fires every cron twice; a second Redis splits the task queue between -two brokers; a second API runs `alembic upgrade head` against whatever Postgres -its own location resolves to. - -POSIX sh only — this is rendered into redis:7-alpine (busybox ash) as well as -into the Debian backend image. -*/}} -{{- define "cpln-advisor.locationGuard" -}} -# ── Guard A: never run outside the one configured location (see chart notes) ── -_loc="${CPLN_LOCATION##*/}" -_want="{{ .Values.location }}" -if [ "${_loc}" != "${_want}" ]; then - echo "[advisor] FATAL: this replica is running in location '${_loc}', but the release is configured for '${_want}'." >&2 - echo "[advisor] Every tier of this release is pinned to one location: a second scheduler fires every cron twice, a second Redis splits the task queue, and a second API migrates and writes whichever Postgres its own location resolves to." >&2 - echo "[advisor] Set 'location' in your values to the location you want, or remove '${_loc}' from GVC '${CPLN_GVC}'." >&2 - exit 1 -fi -{{- end -}} - -{{/* -The startup script the API runs before booting: Guard A, then the GVC -reconciliation, then Alembic and Uvicorn. - -WHY A SCRIPT SECRET rather than an inline `-c`: the GVC check is Python, and -nesting Python inside a shell string inside a YAML scalar inside a Helm template -is exactly how a quoting bug ships. The catalog already delivers boot scripts -this way (airflow, tidb, pgedge, mongodb-cluster). A `cpln://secret` FILE mount -lands `-rwxr--r-- root root`, so the non-root API container (uid 10001) can read -it with no `filesystemGroupId` (measured 2026-08-25). - -WHY PYTHON and not curl: probed 2026-08-30 against the pinned -`advisor-backend:latest` (Debian 13, digest sha256:7336e4d4…) — it has NO curl -and NO wget. It has python3 3.13, bash and coreutils `timeout`. urllib speaks -HTTP/1.1 (`http.client.HTTPConnection._http_vsn_str` == "HTTP/1.1", read out of -that image), which is the thing that matters: $CPLN_ENDPOINT is behind -istio-envoy, and an HTTP/1.0 request comes back `426 Upgrade Required`. - -WHY THE GVC CHECK ONLY WARNS: both directions of the topology mismatch are -real, but this chart has no fresh-vs-initialised discriminator to key severity -off — every advisor tier is stateless, all state is in the bundled Postgres, so -every boot looks fresh. `.Release.IsInstall` was rejected: it renders a -different container argument on install than on upgrade, which is permanent -drift on the first no-op upgrade. So severity splits by COST, as -grafana-multi-location 2.0.0 does: FATAL for Guard A, which keys only off this -replica's OWN location and can cost nothing; WARNING for anything derived from -the GVC read, because the API is the only thing that serves the dashboard and -crash-looping it over a topology mismatch turns a degraded install into an -outage — while fixing nothing, since an extra Postgres in another location -exists whether or not this container runs. -*/}} -{{- define "cpln-advisor.startupScript" -}} -#!/bin/sh -set -eu - -{{ include "cpln-advisor.locationGuard" . }} - -# ── GVC reconciliation: ask the GVC which locations it actually has ────────── -# The platform validates NEITHER direction of the topology this chart is given: -# * a localOptions entry naming a location the GVC does NOT have is accepted, -# stored verbatim and simply inert — this release would run NOTHING, -# anywhere, with no failed deployment to observe; -# * a GVC location this release did not declare starts nothing of ours -# (minScale/maxScale 0) — but the bundled `postgres` subchart is NOT pinned -# and DOES run there, one independent database per location, on its own -# volume. See the note in values.yaml. -# Helm cannot see a live GVC at render time, so the container asks the GVC. -# -# WARNING-only, and it must never be the reason the API refuses to serve. -if command -v python3 >/dev/null 2>&1; then - ADVISOR_LOCATION="{{ .Values.location }}" python3 - <<'PYEOF' || true -import json -import os -import sys -import time -import urllib.error -import urllib.request - -want = os.environ.get("ADVISOR_LOCATION", "") -gvc = os.environ.get("CPLN_GVC", "") -org = os.environ.get("CPLN_ORG", "") -endpoint = os.environ.get("CPLN_ENDPOINT") or "http://api.cpln.io" -token = os.environ.get("CPLN_TOKEN", "") - - -def warn(msg): - print("[advisor] " + msg, file=sys.stderr) - - -if not (gvc and org and token): - warn("WARNING: CPLN_GVC/CPLN_ORG/CPLN_TOKEN are not all set — skipping the GVC location check.") - raise SystemExit(0) - -url = "{}/org/{}/gvc/{}".format(endpoint.rstrip("/"), org, gvc) -body = None -last = "no response" - -# urlopen(timeout=) is a SOCKET timeout, so it bounds the connect phase AND a -# server that accepts and then never answers — the slow arm an NXDOMAIN control -# would never exercise. Worst case for the loop is 3*6 + 2*2 = 22s. -for attempt in (1, 2, 3): - try: - req = urllib.request.Request( - url, headers={"Authorization": token, "Accept": "application/json"} - ) - with urllib.request.urlopen(req, timeout=6) as resp: - if resp.status == 200: - body = resp.read() - break - last = "HTTP {}".format(resp.status) - except urllib.error.HTTPError as exc: - # A 403 body is valid, non-empty JSON. Read on it and the parse finds no - # locationLinks, and a missing `view` grant gets reported to the user as - # "your GVC has no locations". Treat any non-200 as "we do not know". - last = "HTTP {}".format(exc.code) - except Exception as exc: # noqa: BLE001 - transport, DNS, timeout, bad JSON - last = "{}: {}".format(type(exc).__name__, exc) - warn("GVC read failed ({}) — attempt {}/3".format(last, attempt)) - if attempt < 3: - time.sleep(2) - -if body is None: - # A control-plane hiccup, or a missing `view` grant, must never be the reason - # the advisor refuses to start. Guard A above still applies. - warn( - "WARNING: could not read GVC '{}' ({}) — skipping the GVC location check. " - "If this persists, check that the identity has 'view' on the GVC via this " - "chart's GVC policy.".format(gvc, last) - ) - raise SystemExit(0) - -try: - spec = json.loads(body).get("spec") or {} - links = (spec.get("staticPlacement") or {}).get("locationLinks") or [] - locs = [str(link).rsplit("/", 1)[-1] for link in links] -except Exception as exc: # noqa: BLE001 - warn("WARNING: could not parse GVC '{}' ({}) — skipping the GVC location check.".format(gvc, exc)) - raise SystemExit(0) - -if not locs: - warn("WARNING: GVC '{}' reported no locations — skipping the GVC location check.".format(gvc)) - raise SystemExit(0) - -if want not in locs: - warn( - "WARNING: location '{}' is not a location of GVC '{}' (it has: {}). The platform " - "stores a localOptions entry for a location that does not exist without any error, " - "so this release runs NOTHING, anywhere, and no deployment reports a failure. Add " - "the location to the GVC, or set 'location' to one the GVC already has.".format( - want, gvc, " ".join(locs) - ) - ) -else: - print("[advisor] GVC location check OK — '{}' has '{}'".format(gvc, want)) - -extra = [loc for loc in locs if loc != want] -if extra: - warn( - "WARNING: GVC '{}' also has locations this release does not use: {}. No advisor tier " - "runs there (minScale/maxScale are 0 outside localOptions), but the BUNDLED POSTGRES " - "IS NOT PINNED and runs one independent, empty database per location, each on its own " - "volume — billed, and reachable on the same service DNS name this release connects to. " - "Install the advisor into a single-location GVC.".format(gvc, " ".join(extra)) - ) -PYEOF -fi - -# ── Application boot ───────────────────────────────────────────────────────── -# Migrations run before the server starts, so a deploy that adds a column cannot -# serve traffic against the old schema. `exec` matters: without it this shell -# stays PID 1 and swallows SIGTERM, so the container is killed on the timeout -# instead of shutting down. -alembic upgrade head -exec uvicorn main:app --host 0.0.0.0 --port 8000 -{{- end -}} - - -{{/* Resource ratio guard */}} - -{{/* -CPU quantity -> millicores. "250m" -> 250, "1" -> 1000, "0.5" -> 500. -*/}} -{{- define "cpln-advisor.cpu2m" -}} -{{- $v := . | toString -}} -{{- if hasSuffix "m" $v -}} -{{- float64 (trimSuffix "m" $v) -}} -{{- else -}} -{{- mulf (float64 $v) 1000.0 -}} -{{- end -}} -{{- end }} - -{{/* -Memory quantity -> Mi. "1Gi" -> 1024, "512Mi" -> 512. -*/}} -{{- define "cpln-advisor.mem2mi" -}} -{{- $v := . | toString -}} -{{- if hasSuffix "Gi" $v -}} -{{- mulf (float64 (trimSuffix "Gi" $v)) 1024.0 -}} -{{- else if hasSuffix "Mi" $v -}} -{{- float64 (trimSuffix "Mi" $v) -}} -{{- else if hasSuffix "G" $v -}} -{{- mulf (float64 (trimSuffix "G" $v)) 1000.0 -}} -{{- else if hasSuffix "M" $v -}} -{{- float64 (trimSuffix "M" $v) -}} -{{- else -}} -{{- float64 $v -}} -{{- end -}} -{{- end }} - -{{/* -Guard the two limits Control Plane enforces on containers but publishes in no JSON -schema, so both otherwise surface as a 400 partway through an install: - - 1. RATIO. cpu/minCpu must be strictly under 4:1 — that is the API's own wording, - "The ratio between cpu and minCpu must be less than 4:1". Memory is bounded the - same way but treated as <= 4:1, because the reference deployment ships the - dashboard at exactly 128Mi -> 512Mi and it is accepted. - 2. UNITS. `cpu` and `memory` are typed as plain STRINGS with no numeric bound, so - `512Gi` written for `512Mi` is ACCEPTED and the workload then never schedules. - -`cpuKey`/`memKey` name the keys in the caller's block, because 2.0.0 renamed the -two limit-only blocks (worker, redis) to bare `cpu`/`memory` — a block with -nothing to disambiguate against uses the API's own field names. The ratio arms -simply do not run for those two: there is no min to compare against. - -Call with (dict "who" "api" "r" .Values.api.resources "cpuKey" "maxCpu" "memKey" "maxMemory"). -*/}} -{{- define "cpln-advisor.checkResources" -}} -{{- $who := .who -}} -{{- $r := .r -}} -{{- $cpuKey := .cpuKey | default "maxCpu" -}} -{{- $memKey := .memKey | default "maxMemory" -}} -{{- $maxCpuV := get $r $cpuKey -}} -{{- $maxMemV := get $r $memKey -}} -{{- $maxC := float64 (include "cpln-advisor.cpu2m" $maxCpuV) -}} -{{- $maxM := float64 (include "cpln-advisor.mem2mi" $maxMemV) -}} -{{- if gt $maxC 16000.0 -}} -{{- fail (printf "cpln-advisor: %s.resources.%s is %v (%.0f cores) — almost certainly a unit typo. Control Plane types cpu as a bare string with no bound, so it is accepted and the workload then never schedules." $who $cpuKey $maxCpuV (divf $maxC 1000.0)) -}} -{{- end -}} -{{- if gt $maxM 65536.0 -}} -{{- fail (printf "cpln-advisor: %s.resources.%s is %v (%.0f GiB) — almost certainly a unit typo (Mi written as Gi). Control Plane types memory as a bare string with no bound, so it is accepted and the workload then never schedules." $who $memKey $maxMemV (divf $maxM 1024.0)) -}} -{{- end -}} -{{- if $r.minCpu -}} -{{- $minC := float64 (include "cpln-advisor.cpu2m" $r.minCpu) -}} -{{- if le $minC 0.0 -}} -{{- fail (printf "cpln-advisor: %s.resources.minCpu must be greater than zero, got %v" $who $r.minCpu) -}} -{{- end -}} -{{- if lt $maxC $minC -}} -{{- fail (printf "cpln-advisor: %s.resources.%s (%v) is below minCpu (%v)" $who $cpuKey $maxCpuV $r.minCpu) -}} -{{- end -}} -{{- if ge (divf $maxC $minC) 4.0 -}} -{{- fail (printf "cpln-advisor: %s.resources is %v/%v cpu, a %.2f:1 spread. Control Plane requires cpu/minCpu to be UNDER 4:1 and rejects the workload with \"The ratio between cpu and minCpu must be less than 4:1\". Raise minCpu above %.0fm, or lower %s." $who $maxCpuV $r.minCpu (divf $maxC $minC) (divf $maxC 4.0) $cpuKey) -}} -{{- end -}} -{{- end -}} -{{- if $r.minMemory -}} -{{- $minM := float64 (include "cpln-advisor.mem2mi" $r.minMemory) -}} -{{- if le $minM 0.0 -}} -{{- fail (printf "cpln-advisor: %s.resources.minMemory must be greater than zero, got %v" $who $r.minMemory) -}} -{{- end -}} -{{- if lt $maxM $minM -}} -{{- fail (printf "cpln-advisor: %s.resources.%s (%v) is below minMemory (%v)" $who $memKey $maxMemV $r.minMemory) -}} -{{- end -}} -{{- if gt (divf $maxM $minM) 4.0 -}} -{{- fail (printf "cpln-advisor: %s.resources is %v/%v memory, a %.2f:1 spread. Control Plane bounds memory/minMemory at 4:1 and rejects the workload. Raise minMemory to at least %.0fMi, or lower %s." $who $maxMemV $r.minMemory (divf $maxM $minM) (divf $maxM 4.0) $memKey) -}} -{{- end -}} -{{- end -}} -{{- end }} - - -{{/* Validation */}} - -{{/* -Top-level validation — included by every rendered resource. - -The legacy-GVC check runs FIRST: a 1.0.0 values file has no top-level -`location`, so any other check would fire first and report a confusing -missing-location error instead of the destructive-upgrade refusal. -*/}} -{{- define "cpln-advisor.validate" -}} -{{- include "cpln-advisor.validateNoLegacyGvc" . -}} -{{- if not .Values.global.cpln.gvc -}} -{{- fail "cpln-advisor: global.cpln.gvc is missing. It is INJECTED by the platform at install time and must never be declared in values.yaml — it names the EXISTING GVC this chart deploys into. If you are running `helm template` by hand, pass --set global.cpln.gvc=YOUR_GVC." -}} -{{- end -}} -{{- include "cpln-advisor.validateLocation" . -}} -{{- if not .Values.auth.secretName -}} -{{- fail "cpln-advisor: auth.secretName is required — the name of a `dictionary` secret that MUST EXIST BEFORE INSTALL, holding the keys ADVISOR_API_TOKEN, ADVISOR_SECRET_KEY, ADVISOR_SESSION_SECRET, ADVISOR_USERNAME, ADVISOR_PASSWORD and DATABASE_URL. This chart creates no credential secret and accepts no credential as a value. See README Prerequisites." -}} -{{- end -}} -{{- if and .Values.appUrl (not (or (hasPrefix "http://" .Values.appUrl) (hasPrefix "https://" .Values.appUrl))) -}} -{{- fail (printf "cpln-advisor: appUrl must be a full URL including the scheme, e.g. https://advisor.example.com, got '%s'" .Values.appUrl) -}} -{{- end -}} -{{- if eq .Values.appUrl "*" -}} -{{- fail "cpln-advisor: appUrl must not be '*' — it becomes the CORS allowlist, and a wildcard combined with credentials makes the API echo back whichever origin asked" -}} -{{- end -}} -{{- range $who := (list "web" "api" "scheduler" "postgres") -}} -{{- include "cpln-advisor.checkResources" (dict "who" $who "r" (get $.Values $who).resources) -}} -{{- end -}} -{{- range $who := (list "worker" "redis") -}} -{{- include "cpln-advisor.checkResources" (dict "who" $who "r" (get $.Values $who).resources "cpuKey" "cpu" "memKey" "memory") -}} -{{- end -}} -{{- end }} - -{{/* -The chart stopped creating a GVC in 2.0.0. Refuse to render if the values still -carry the 1.0.0 `gvc` key. - -This one is sharper than the rest of the batch. 1.0.0 rendered -`kind: gvc` with `name: {{ .Values.global.cpln.gvc }}` — the GVC it installed -INTO. So a 1.0.0 release did not merely create a GVC, it ADOPTED the user's, and -Helm has owned it ever since. An in-place `helm upgrade` onto 2.0.0 drops -`kind: gvc` from the manifest, and Helm deletes what a chart no longer declares -— taking that GVC and every workload, volumeset and identity inside it, -including anything unrelated the user put there. Measured on another template: -6 seconds, while printing `upgraded successfully`. The same adoption path -destroyed a shared test GVC on 2026-08-07, despite a -`helm.sh/resource-policy: keep` annotation. - -One hole is not closable here: an upgrade run with NO values at all sees this -chart's defaults, the key is absent, and the guard cannot fire. That is why the -README and the briefing both carry the migration prose. -*/}} -{{- define "cpln-advisor.validateNoLegacyGvc" -}} -{{- if hasKey .Values "gvc" -}} -{{- fail "cpln-advisor 2.0.0: the `gvc` values key was REMOVED. This chart no longer creates a GVC — it deploys into the GVC you install into, and `gvc.locations` became the single top-level `location`. DO NOT `helm upgrade` a 1.0.0 release onto 2.0.0: 1.0.0 created a GVC NAMED AFTER THE ONE YOU INSTALLED INTO, so Helm ADOPTED your GVC and owns it. The upgrade drops `kind: gvc` from the manifest and Helm deletes what a chart no longer declares, which DESTROYS that GVC and every workload, volumeset and identity inside it — your advisor database included, and anything else you happen to keep there. Install 2.0.0 as a NEW release against an existing GVC, move the database across, then uninstall the old release. See `Migrating from 1.0.0` in the README." -}} -{{- end -}} -{{- end -}} - -{{/* -The advisor runs in exactly ONE location, and `location` names it. - -This is not a simplification of a multi-location design; there is no -multi-location design available. Scale bounds are per-location, so a second -location means a second scheduler firing every cron twice, a second Redis -splitting the task queue, and a second API running the same startup migration. -The bundled Postgres is a single stateful workload on a read-write-once volume, -so a second location gives it a second, INDEPENDENT database rather than a -replica. -*/}} -{{- define "cpln-advisor.validateLocation" -}} -{{- if not .Values.location -}} -{{- fail "cpln-advisor: `location` is required — it names the ONE location of your GVC that the advisor runs in (it was `gvc.locations` in 1.0.0). Every workload is pinned there. Run `cpln location get` to list the ones available to your org." -}} -{{- end -}} -{{- if not (kindIs "string" .Values.location) -}} -{{- fail "cpln-advisor: `location` must be a single location NAME, e.g. `location: aws-us-east-1`. The advisor runs in exactly one location — scale bounds are per-location, so a second one would give you a second scheduler firing every cron twice and a second, independent database." -}} -{{- end -}} -{{- if hasKey .Values "locations" -}} -{{- fail "cpln-advisor: `locations` (plural) is not a key of this chart. The advisor runs in exactly ONE location — use the singular `location`, e.g. `location: aws-us-east-1`." -}} -{{- end -}} -{{- end -}} - - -{{/* Labeling */}} - -{{/* -Create chart name and version as used by the chart label. -*/}} -{{- define "cpln-advisor.chart" -}} -{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} -{{- end }} - -{{/* -Common labels -*/}} -{{- define "cpln-advisor.tags" -}} -{{- include "cpln-common.tags" . }} -{{- end }} - -{{- define "cpln-advisor.selectorLabels" -}} -app.cpln.io/name: {{ .Release.Name }} -app.cpln.io/instance: {{ .Release.Name }} -{{- end }} diff --git a/cpln-advisor/versions/2.0.0/templates/identity.yaml b/cpln-advisor/versions/2.0.0/templates/identity.yaml deleted file mode 100644 index 12d677bf..00000000 --- a/cpln-advisor/versions/2.0.0/templates/identity.yaml +++ /dev/null @@ -1,7 +0,0 @@ -{{- include "cpln-advisor.validate" . -}} -kind: identity -gvc: {{ .Values.global.cpln.gvc }} -name: {{ include "cpln-advisor.identity.name" . }} -description: CPLN Advisor identity -tags: - {{- include "cpln-advisor.tags" . | nindent 4 }} diff --git a/cpln-advisor/versions/2.0.0/templates/policy-gvc.yaml b/cpln-advisor/versions/2.0.0/templates/policy-gvc.yaml deleted file mode 100644 index 85ec26de..00000000 --- a/cpln-advisor/versions/2.0.0/templates/policy-gvc.yaml +++ /dev/null @@ -1,25 +0,0 @@ -{{- include "cpln-advisor.validate" . -}} -{{- /* - New in 2.0.0. The API reads its own GVC at boot to confirm the GVC really has - the location this release is pinned to, and to report any EXTRA locations — - which matter here more than in most converted templates, because the bundled - `postgres` subchart cannot be pinned and runs one independent database in each - of them. The platform validates neither direction: a localOptions entry naming - a location the GVC lacks is stored verbatim and is simply inert. Helm cannot - see a live GVC at render time, so the container asks the GVC itself. - - Scoped to the ONE install GVC; never `target: all`, which would grant view on - every GVC in the org. -*/}} -kind: policy -name: {{ include "cpln-advisor.gvcPolicy.name" . }} -description: CPLN Advisor GVC read policy -tags: {{- include "cpln-advisor.tags" . | nindent 4 }} -bindings: - - permissions: - - view - principalLinks: - - //gvc/{{ .Values.global.cpln.gvc }}/identity/{{ include "cpln-advisor.identity.name" . }} -targetKind: gvc -targetLinks: - - //gvc/{{ .Values.global.cpln.gvc }} diff --git a/cpln-advisor/versions/2.0.0/templates/policy.yaml b/cpln-advisor/versions/2.0.0/templates/policy.yaml deleted file mode 100644 index 75858be4..00000000 --- a/cpln-advisor/versions/2.0.0/templates/policy.yaml +++ /dev/null @@ -1,26 +0,0 @@ -{{- include "cpln-advisor.validate" . -}} -kind: policy -name: {{ include "cpln-advisor.policy.name" . }} -description: CPLN Advisor policy -tags: {{- include "cpln-advisor.tags" . | nindent 4 }} -bindings: - # `reveal`, NOT `view` — view exposes metadata only, and the cpln://secret/… - # reference then resolves to an empty string. The containers start happily and - # behave as though nothing were configured, so this failure is silent. - - permissions: - - reveal - principalLinks: - - //gvc/{{ .Values.global.cpln.gvc }}/identity/{{ include "cpln-advisor.identity.name" . }} -targetKind: secret -targetLinks: - # Exactly two secrets — never `target: all`, which would grant reveal on every - # secret in the org. The bundled postgres subchart brings its own policy for - # its own credentials secret, so this identity never gets reveal on the - # database's. - # - # 1. The advisor credentials secret YOU created. - - //secret/{{ .Values.auth.secretName }} - # 2. The startup script this chart creates and mounts into the API container. - # A file-mounted secret is still a secret: without reveal the mount is - # empty and the container cannot start. - - //secret/{{ include "cpln-advisor.startupScript.name" . }} diff --git a/cpln-advisor/versions/2.0.0/templates/secret-startup.yaml b/cpln-advisor/versions/2.0.0/templates/secret-startup.yaml deleted file mode 100644 index 8504ebe8..00000000 --- a/cpln-advisor/versions/2.0.0/templates/secret-startup.yaml +++ /dev/null @@ -1,22 +0,0 @@ -{{- include "cpln-advisor.validate" . -}} -{{- /* - The API's boot script: Guard A (never run outside the configured location), - the GVC reconciliation, then Alembic and Uvicorn. Delivered as a secret and - mounted as a file because the GVC check is Python, and nesting Python inside a - shell string inside a YAML scalar is how a quoting bug ships. Same shape as - airflow, tidb, pgedge and mongodb-cluster. - - It holds no credential. It is a secret because that is the platform's file - mount; the identity needs `reveal` on it (see policy.yaml) or the mount is - empty and the container never starts. -*/}} -kind: secret -name: {{ include "cpln-advisor.startupScript.name" . }} -description: CPLN Advisor API startup script -tags: - {{- include "cpln-advisor.tags" . | nindent 4 }} -type: opaque -data: - encoding: plain - payload: |- - {{- include "cpln-advisor.startupScript" . | nindent 4 }} diff --git a/cpln-advisor/versions/2.0.0/templates/workload-api.yaml b/cpln-advisor/versions/2.0.0/templates/workload-api.yaml deleted file mode 100644 index b95958b0..00000000 --- a/cpln-advisor/versions/2.0.0/templates/workload-api.yaml +++ /dev/null @@ -1,159 +0,0 @@ -{{- include "cpln-advisor.validate" . -}} -kind: workload -name: {{ include "cpln-advisor.api.name" . }} -description: CPLN Advisor API -gvc: {{ .Values.global.cpln.gvc }} -tags: {{- include "cpln-advisor.tags" . | nindent 4 }} -spec: - type: standard - identityLink: //gvc/{{ .Values.global.cpln.gvc }}/identity/{{ include "cpln-advisor.identity.name" . }} - containers: - - name: api - image: {{ .Values.images.backend }} - inheritEnv: false - minCpu: {{ .Values.api.resources.minCpu | quote }} - minMemory: {{ .Values.api.resources.minMemory | quote }} - cpu: {{ .Values.api.resources.maxCpu | quote }} - memory: {{ .Values.api.resources.maxMemory | quote }} - ports: - - number: 8000 - protocol: http - # The boot script runs Guard A and the GVC reconciliation, then - # `alembic upgrade head` and uvicorn — migrations before the server, so a - # deploy that adds a column cannot serve traffic against the old schema. - # The API is the tier that carries the GVC check: it is always on, its - # image is the only one with an HTTP client (python3 — probed 2026-08-30: - # no curl, no wget), and it is the only tier with `outboundAllowCIDR` - # open, which the call to $CPLN_ENDPOINT needs. - command: /bin/sh - args: - - /scripts/advisor-startup.sh - volumes: - - path: /scripts/advisor-startup.sh - recoveryPolicy: retain # API backfill, declared so rendered == stored - uri: cpln://secret/{{ include "cpln-advisor.startupScript.name" . }}.payload - env: - # Nothing here may be named CPLN_* — that prefix is RESERVED and a - # workload setting one is rejected. The platform injects CPLN_ORG, - # CPLN_GVC, CPLN_TOKEN and friends itself, which is where the app reads - # its org from — and what the boot script's GVC read uses. The Control - # Plane API token cannot be injected for the same reason: set it once on - # the Configuration page, where it is stored encrypted in the database. - - name: LOG_LEVEL - value: {{ .Values.logLevel | quote }} - - name: REDIS_URL - value: {{ include "cpln-advisor.redis.url" . | quote }} - # Postgres, from the bundled `postgres` subchart. The URL carries the - # password, so it is a key in YOUR credentials secret rather than a value - # here. Its username/password/database must match the database's own - # prerequisite secret — nothing cross-checks the two. - - name: DATABASE_URL - value: cpln://secret/{{ .Values.auth.secretName }}.DATABASE_URL - # The app derives the dashboard's public URL — the Slack "View in Advisor" - # links and the CORS origin — from Control Plane's built-in env vars, as - # `https://{ADVISOR_WEB_WORKLOAD}-{gvc alias}.cpln.app`. It defaults to - # the literal "web", so it MUST be told this chart's actual dashboard - # workload name or every derived link points at a workload that does not - # exist. It is NOT built from CPLN_GLOBAL_ENDPOINT, which is the host of - # whichever workload reads it — internal here, and not where a browser goes. - - name: ADVISOR_WEB_WORKLOAD - value: {{ include "cpln-advisor.web.name" . | quote }} - {{- if .Values.appUrl }} - # Explicit override, required only for a CUSTOM DOMAIN — that cannot be - # derived. Setting it wins over the derivation above for both the Slack - # links and the CORS allowlist. - - name: APP_URL - value: {{ .Values.appUrl | quote }} - # CORS allowlist. Close to inert in this topology — the browser only - # ever talks to the dashboard, which calls the API server-side — but it - # matters the moment anything else calls the API from a page. - - name: ADVISOR_ALLOWED_ORIGINS - value: {{ .Values.appUrl | quote }} - {{- end }} - # Shared with the dashboard, which attaches it to every forwarded - # request. The API FAILS CLOSED: with it unset every route except - # /health is refused. - - name: ADVISOR_API_TOKEN - value: cpln://secret/{{ .Values.auth.secretName }}.ADVISOR_API_TOKEN - # Decrypts the AI and Slack credentials held in the database. Change it - # and those become unreadable — the app reports them as "not set". - - name: ADVISOR_SECRET_KEY - value: cpln://secret/{{ .Values.auth.secretName }}.ADVISOR_SECRET_KEY - # The web login, verified HERE and never on the dashboard, so one - # workload holds the credentials. Both unset ⇒ nobody can log in. - - name: ADVISOR_USERNAME - value: cpln://secret/{{ .Values.auth.secretName }}.ADVISOR_USERNAME - - name: ADVISOR_PASSWORD - value: cpln://secret/{{ .Values.auth.secretName }}.ADVISOR_PASSWORD - readinessProbe: - httpGet: - path: /health # the only unauthenticated route, and it leaks nothing - port: 8000 - initialDelaySeconds: 5 - periodSeconds: 10 - failureThreshold: 3 - livenessProbe: - httpGet: - path: /health - port: 8000 - initialDelaySeconds: 20 - periodSeconds: 30 - # 5, not 3: the boot script's GVC read is bounded at 22s worst case (3 - # attempts x 6s socket timeout + 2 x 2s backoff, measured in this image - # against a blackholed address and an accept-never-respond server), and - # `alembic upgrade head` runs after it. At 3 the container is restarted - # 80s after start, which a slow migration plus an unreachable control - # plane can exceed. 5 gives 170s. The platform cap is 20. - failureThreshold: 5 - {{- /* - ONE replica, in the one configured location. Two would race on the same - startup migration. That is now the only reason — Postgres handles concurrent - writers fine — so raising it is a question of moving `alembic upgrade head` - out of the container start command, not of the database. - */}} - defaultOptions: - autoscaling: - {{- include "cpln-advisor.autoscaling" (dict "scale" (dict "min" 0 "max" 0)) | nindent 6 }} - capacityAI: true - debug: false - suspend: false - timeoutSeconds: 120 # a scan's Control Plane and LLM calls can take a while - localOptions: - - autoscaling: - {{- include "cpln-advisor.autoscaling" (dict "scale" (dict "min" 1 "max" 1)) | nindent 8 }} - capacityAI: true - debug: false - location: //location/{{ .Values.location }} - suspend: false - timeoutSeconds: 120 - # Non-root. `filesystemGroupId` is gone with the shared volume it existed for, - # and the startup script does not need it: a cpln://secret FILE mount lands - # world-readable (`-rwxr--r-- root root`), which uid 10001 can read. - # (Never 1337: that is the mesh proxy's UID, and a container running as it - # bypasses the Envoy redirect.) - securityOptions: - runAsUser: 10001 - firewallConfig: - external: - inboundAllowCIDR: [] # never exposed publicly - inboundBlockedCIDR: [] - # The advisor reads Control Plane's API, MCP and billing services, and - # calls out to the configured LLM provider and Slack. The boot script's - # GVC read goes to $CPLN_ENDPOINT over this path too. - outboundAllowCIDR: - - 0.0.0.0/0 - outboundAllowHostname: [] - outboundAllowPort: [] - outboundBlockedCIDR: [] - internal: - # Only the dashboard calls the API. Rendered from the roster helper so no - # workload link is hand-written anywhere in this chart. - inboundAllowType: workload-list - inboundAllowWorkload: - {{- include "cpln-advisor.api.callers" . | nindent 8 }} - loadBalancer: - direct: - enabled: false - ports: [] - replicaDirect: false - supportDynamicTags: false diff --git a/cpln-advisor/versions/2.0.0/templates/workload-redis.yaml b/cpln-advisor/versions/2.0.0/templates/workload-redis.yaml deleted file mode 100644 index a40c349e..00000000 --- a/cpln-advisor/versions/2.0.0/templates/workload-redis.yaml +++ /dev/null @@ -1,93 +0,0 @@ -{{- include "cpln-advisor.validate" . -}} -kind: workload -name: {{ include "cpln-advisor.redis.name" . }} -description: Task broker and cache for the advisor -gvc: {{ .Values.global.cpln.gvc }} -tags: {{- include "cpln-advisor.tags" . | nindent 4 }} -spec: - type: standard - containers: - - name: redis - image: {{ .Values.redis.image }} - inheritEnv: false - cpu: {{ .Values.redis.resources.maxCpu | quote }} - memory: {{ .Values.redis.resources.maxMemory | quote }} - ports: - - number: 6379 - protocol: tcp - # Run through a shell so Guard A runs first and so the empty `--save ''` - # stays INSIDE one argument. Passed as its own array element it is - # rejected outright: "args[2] is not allowed to be empty". - # - # `--save ''` disables RDB snapshots, which are pointless here — there is - # no volume, so a snapshot would be written to ephemeral storage and lost - # anyway while still paying the fork pause. `maxmemory` keeps a runaway - # cache from being OOM-killed mid-scan, and allkeys-lru evicts rather than - # refusing writes. - # - # `exec` matters: without it the shell stays PID 1 and swallows SIGTERM, - # so the container is killed on the timeout instead of shutting down. - # - # This container has NO identityLink, so the guard is rendered inline - # rather than mounted from the startup-script secret — a workload with no - # identity cannot reveal a secret at all. It is the same helper the other - # tiers use, and it is POSIX sh so busybox ash runs it unchanged. - command: /bin/sh - args: - - -c - - | - set -eu - {{- include "cpln-advisor.locationGuard" . | nindent 10 }} - exec redis-server --save '' --maxmemory {{ .Values.redis.maxmemory }} --maxmemory-policy allkeys-lru - readinessProbe: - exec: - command: - - sh - - -c - - redis-cli ping | grep -q PONG - initialDelaySeconds: 5 - periodSeconds: 10 - failureThreshold: 3 - {{- /* - ONE broker, in the one configured location. Two brokers means the task queue - is split between them: the scheduler enqueues into one and the worker polls - the other, so scans are created and never run, with every workload healthy. - */}} - defaultOptions: - autoscaling: - {{- include "cpln-advisor.autoscaling" (dict "scale" (dict "min" 0 "max" 0)) | nindent 6 }} - capacityAI: false # a broker should not be resized under load - debug: false - suspend: false - timeoutSeconds: 5 - localOptions: - - autoscaling: - {{- include "cpln-advisor.autoscaling" (dict "scale" (dict "min" 1 "max" 1)) | nindent 8 }} - capacityAI: false - debug: false - location: //location/{{ .Values.location }} - suspend: false - timeoutSeconds: 5 - firewallConfig: - external: - inboundAllowCIDR: [] # no inbound from the internet - inboundBlockedCIDR: [] - outboundAllowCIDR: [] # and it calls nothing - outboundAllowHostname: [] - outboundAllowPort: [] - outboundBlockedCIDR: [] - internal: - # Exactly the three workloads that use it, and nothing else. Redis is not - # authenticated, so this list is the whole access control — and from 2.0.0 - # the GVC may hold unrelated workloads, which is why it is a list and not - # `same-gvc`. Rendered from the roster helper so no workload link is - # hand-written anywhere in this chart. - inboundAllowType: workload-list - inboundAllowWorkload: - {{- include "cpln-advisor.redis.callers" . | nindent 8 }} - loadBalancer: - direct: - enabled: false - ports: [] - replicaDirect: false - supportDynamicTags: false diff --git a/cpln-advisor/versions/2.0.0/templates/workload-scheduler.yaml b/cpln-advisor/versions/2.0.0/templates/workload-scheduler.yaml deleted file mode 100644 index 349938bc..00000000 --- a/cpln-advisor/versions/2.0.0/templates/workload-scheduler.yaml +++ /dev/null @@ -1,93 +0,0 @@ -{{- include "cpln-advisor.validate" . -}} -kind: workload -name: {{ include "cpln-advisor.scheduler.name" . }} -description: CPLN Advisor cron scheduler (fires scheduled scans and retention) -gvc: {{ .Values.global.cpln.gvc }} -tags: {{- include "cpln-advisor.tags" . | nindent 4 }} -spec: - type: standard - identityLink: //gvc/{{ .Values.global.cpln.gvc }}/identity/{{ include "cpln-advisor.identity.name" . }} - containers: - - name: scheduler - image: {{ .Values.images.backend }} - inheritEnv: false - minCpu: {{ .Values.scheduler.resources.minCpu | quote }} - minMemory: {{ .Values.scheduler.resources.minMemory | quote }} - cpu: {{ .Values.scheduler.resources.maxCpu | quote }} - memory: {{ .Values.scheduler.resources.maxMemory | quote }} - # Wrapped in a shell so Guard A runs first — this is the tier where a - # replica in a second location does the most damage, because two - # schedulers fire every cron twice. - # - # `workers.worker:scheduler`, NOT `:broker`. TaskIQ's scheduler CLI needs - # a TaskiqScheduler instance; pointed at the broker it exits immediately - # with "Imported scheduler is not a subclass of TaskiqScheduler". - # - # This process is NOT optional. The worker runs tasks; the scheduler fires - # the cron that CREATES them. Without it nothing is scanned on a schedule, - # retention never prunes and autopilot never runs — and everything still - # looks healthy. - command: /bin/sh - args: - - -c - - | - set -eu - {{- include "cpln-advisor.locationGuard" . | nindent 10 }} - exec taskiq scheduler workers.worker:scheduler - env: - # Nothing here may be named CPLN_* — see the note on the API workload. - - name: LOG_LEVEL - value: {{ .Values.logLevel | quote }} - - name: REDIS_URL - value: {{ include "cpln-advisor.redis.url" . | quote }} - # The SAME database the API and worker use. - # Postgres, from the bundled `postgres` subchart. The URL carries the - # password, so it is a key in YOUR credentials secret rather than a value - # here. Its username/password/database must match the database's own - # prerequisite secret — nothing cross-checks the two. - - name: DATABASE_URL - value: cpln://secret/{{ .Values.auth.secretName }}.DATABASE_URL - # Present for parity with the other two; the scheduler only enqueues - # into Redis. - - name: ADVISOR_API_TOKEN - value: cpln://secret/{{ .Values.auth.secretName }}.ADVISOR_API_TOKEN - - name: ADVISOR_SECRET_KEY - value: cpln://secret/{{ .Values.auth.secretName }}.ADVISOR_SECRET_KEY - {{- /* ONE scheduler, in the one configured location — two fire every cron twice. */}} - defaultOptions: - autoscaling: - {{- include "cpln-advisor.autoscaling" (dict "scale" (dict "min" 0 "max" 0)) | nindent 6 }} - capacityAI: true # the scheduler only enqueues; let it be sized down - debug: false - suspend: false - timeoutSeconds: 5 - localOptions: - - autoscaling: - {{- include "cpln-advisor.autoscaling" (dict "scale" (dict "min" 1 "max" 1)) | nindent 8 }} - capacityAI: true - debug: false - location: //location/{{ .Values.location }} - suspend: false - timeoutSeconds: 5 - # Non-root. `filesystemGroupId` is gone with the shared volume it existed for. - # (Never 1337: that is the mesh proxy's UID, and a container running as it - # bypasses the Envoy redirect.) - securityOptions: - runAsUser: 10001 - firewallConfig: - external: - inboundAllowCIDR: [] - inboundBlockedCIDR: [] - outboundAllowCIDR: [] # it only enqueues into Redis - outboundAllowHostname: [] - outboundAllowPort: [] - outboundBlockedCIDR: [] - internal: - inboundAllowType: none - inboundAllowWorkload: [] # API backfill, declared so rendered == stored - loadBalancer: - direct: - enabled: false - ports: [] - replicaDirect: false - supportDynamicTags: false diff --git a/cpln-advisor/versions/2.0.0/templates/workload-web.yaml b/cpln-advisor/versions/2.0.0/templates/workload-web.yaml deleted file mode 100644 index 79066b03..00000000 --- a/cpln-advisor/versions/2.0.0/templates/workload-web.yaml +++ /dev/null @@ -1,104 +0,0 @@ -{{- include "cpln-advisor.validate" . -}} -kind: workload -name: {{ include "cpln-advisor.web.name" . }} -description: CPLN Advisor dashboard -gvc: {{ .Values.global.cpln.gvc }} -tags: {{- include "cpln-advisor.tags" . | nindent 4 }} -spec: - # Serverless suits an internal dashboard nobody looks at overnight. Set - # web.replicas.min to 0 to scale to zero, at the cost of a cold start on the - # first request of the day. - type: serverless - identityLink: //gvc/{{ .Values.global.cpln.gvc }}/identity/{{ include "cpln-advisor.identity.name" . }} - containers: - - name: web - image: {{ .Values.images.web }} - inheritEnv: false - minCpu: {{ .Values.web.resources.minCpu | quote }} - minMemory: {{ .Values.web.resources.minMemory | quote }} - cpu: {{ .Values.web.resources.maxCpu | quote }} - memory: {{ .Values.web.resources.maxMemory | quote }} - ports: - - number: 3000 - protocol: http - env: - # The API on its CONTAINER port (8000), not 443. Plain http is correct — - # the sidecar adds mTLS. The browser never sees this address; it calls - # this workload same-origin and the Next.js server forwards. - - name: ADVISOR_API_URL - value: {{ include "cpln-advisor.api.url" . | quote }} - # Attached to every forwarded request, so the browser never holds a - # token. Server-only on purpose: a NEXT_PUBLIC_* name would be inlined - # into the client bundle and shipped to every visitor. - - name: ADVISOR_API_TOKEN - value: cpln://secret/{{ .Values.auth.secretName }}.ADVISOR_API_TOKEN - # Signs the session cookie (HMAC-SHA256). Unset ⇒ nobody can log in, - # because an unsigned cookie would be forgeable. Rotating it is safe and - # simply signs everyone out. - - name: ADVISOR_SESSION_SECRET - value: cpln://secret/{{ .Values.auth.secretName }}.ADVISOR_SESSION_SECRET - - name: ADVISOR_SESSION_HOURS - value: {{ .Values.session.hours | quote }} - - name: ADVISOR_SESSION_REMEMBER_DAYS - value: {{ .Values.session.rememberDays | quote }} - readinessProbe: - httpGet: - path: /login # the one page that renders without a session - port: 3000 - initialDelaySeconds: 5 - periodSeconds: 10 - failureThreshold: 3 - {{- /* - The dashboard is the ONE tier whose command this chart does not own: the - image's entrypoint is the Node one and its command is `node server.js`, so - adding a boot guard would mean replacing an entrypoint we have never - replaced, on the only workload a user can reach. It carries no guard. - Nothing is lost that matters: it is stateless, and a stray replica in an - undeclared location would simply serve the same dashboard. minScale/maxScale - 0 below is what actually prevents one. - */}} - defaultOptions: - autoscaling: - {{- include "cpln-advisor.autoscaling" (dict "scale" (dict "min" 0 "max" 0) "metric" "concurrency" "target" 20) | nindent 6 }} - capacityAI: true - debug: false - suspend: false - timeoutSeconds: 120 # the proxy waits on the API, and a scan is not quick - localOptions: - - autoscaling: - {{- include "cpln-advisor.autoscaling" (dict "scale" (dict "min" .Values.web.replicas.min "max" .Values.web.replicas.max) "metric" "concurrency" "target" 20) | nindent 8 }} - capacityAI: true - debug: false - location: //location/{{ .Values.location }} - suspend: false - timeoutSeconds: 120 - firewallConfig: - external: - # PUBLIC, deliberately and unconditionally: the dashboard is the only way - # into this app, and nothing inside the GVC calls it, so an internal-only - # dashboard would be unreachable by the people it exists for. - # - # The login is the boundary — an HMAC-signed session cookie, an API that - # fails closed without its token, and credentials that come from a secret - # you create, so there is no shipped default password to find. - # - # Narrow this to an office or VPN range after installing if you want to. - inboundAllowCIDR: - - 0.0.0.0/0 - inboundBlockedCIDR: [] - outboundAllowCIDR: - - 0.0.0.0/0 - outboundAllowHostname: [] - outboundAllowPort: [] - outboundBlockedCIDR: [] - internal: - # Nothing internal calls the dashboard. It is the browser's entry point and - # it talks OUT to the API, never the other way round. - inboundAllowType: none - inboundAllowWorkload: [] # API backfill, declared so rendered == stored - loadBalancer: - direct: - enabled: false - ports: [] - replicaDirect: false - supportDynamicTags: false diff --git a/cpln-advisor/versions/2.0.0/templates/workload-worker.yaml b/cpln-advisor/versions/2.0.0/templates/workload-worker.yaml deleted file mode 100644 index 87ca2906..00000000 --- a/cpln-advisor/versions/2.0.0/templates/workload-worker.yaml +++ /dev/null @@ -1,110 +0,0 @@ -{{- include "cpln-advisor.validate" . -}} -kind: workload -name: {{ include "cpln-advisor.worker.name" . }} -description: CPLN Advisor background worker -gvc: {{ .Values.global.cpln.gvc }} -tags: {{- include "cpln-advisor.tags" . | nindent 4 }} -spec: - type: standard - identityLink: //gvc/{{ .Values.global.cpln.gvc }}/identity/{{ include "cpln-advisor.identity.name" . }} - containers: - - name: worker - # The SAME image as the API, with a different command. It must be the - # same build: a scan is executed here and served there. - image: {{ .Values.images.backend }} - inheritEnv: false - cpu: {{ .Values.worker.resources.maxCpu | quote }} - memory: {{ .Values.worker.resources.maxMemory | quote }} - # Wrapped in a shell so Guard A runs first. Module paths are relative to - # the app directory inside the image: `workers.worker:broker`, never - # `backend.workers.worker:broker`. `exec` matters: without it the shell - # stays PID 1 and swallows SIGTERM, so the container is killed on the - # timeout instead of shutting down. - command: /bin/sh - args: - - -c - - | - set -eu - {{- include "cpln-advisor.locationGuard" . | nindent 10 }} - exec taskiq worker workers.worker:broker - env: - # Nothing here may be named CPLN_* — see the note on the API workload. - - name: LOG_LEVEL - value: {{ .Values.logLevel | quote }} - - name: REDIS_URL - value: {{ include "cpln-advisor.redis.url" . | quote }} - # Postgres, from the bundled `postgres` subchart. The URL carries the - # password, so it is a key in YOUR credentials secret rather than a value - # here. Its username/password/database must match the database's own - # prerequisite secret — nothing cross-checks the two. - - name: DATABASE_URL - value: cpln://secret/{{ .Values.auth.secretName }}.DATABASE_URL - # The app derives the dashboard's public URL — the Slack "View in Advisor" - # links and the CORS origin — from Control Plane's built-in env vars, as - # `https://{ADVISOR_WEB_WORKLOAD}-{gvc alias}.cpln.app`. It defaults to - # the literal "web", so it MUST be told this chart's actual dashboard - # workload name or every derived link points at a workload that does not - # exist. It is NOT built from CPLN_GLOBAL_ENDPOINT, which is the host of - # whichever workload reads it — internal here, and not where a browser goes. - - name: ADVISOR_WEB_WORKLOAD - value: {{ include "cpln-advisor.web.name" . | quote }} - {{- if .Values.appUrl }} - # Explicit override for a custom domain. Set it here as well as on the - # API — separate containers sharing nothing but the GVC. - - name: APP_URL - value: {{ .Values.appUrl | quote }} - {{- end }} - # Present for parity with the API; the worker reaches the database and - # Control Plane directly rather than through the API. - - name: ADVISOR_API_TOKEN - value: cpln://secret/{{ .Values.auth.secretName }}.ADVISOR_API_TOKEN - # Decrypts the AI and Slack credentials in the database — the worker - # needs it to call the LLM and to post the scan digest. - - name: ADVISOR_SECRET_KEY - value: cpln://secret/{{ .Values.auth.secretName }}.ADVISOR_SECRET_KEY - {{- /* - ONE worker, in the one configured location. Postgres would take the - concurrent writes, so this is no longer a database limit — it is that a - fleet scan's Control Plane and LLM calls are rate-limited upstream, and a - second worker mostly buys more 429s. Raise it deliberately, not by default. - */}} - defaultOptions: - autoscaling: - {{- include "cpln-advisor.autoscaling" (dict "scale" (dict "min" 0 "max" 0)) | nindent 6 }} - capacityAI: false # a long scan should not be resized underneath itself - debug: false - suspend: false - timeoutSeconds: 5 - localOptions: - - autoscaling: - {{- include "cpln-advisor.autoscaling" (dict "scale" (dict "min" 1 "max" 1)) | nindent 8 }} - capacityAI: false - debug: false - location: //location/{{ .Values.location }} - suspend: false - timeoutSeconds: 5 - # Non-root. `filesystemGroupId` is gone with the shared volume it existed for. - # (Never 1337: that is the mesh proxy's UID, and a container running as it - # bypasses the Envoy redirect.) - securityOptions: - runAsUser: 10001 - firewallConfig: - external: - inboundAllowCIDR: [] # no inbound at all - inboundBlockedCIDR: [] - # Control Plane's API, MCP and billing services, the LLM provider, Slack. - outboundAllowCIDR: - - 0.0.0.0/0 - outboundAllowHostname: [] - outboundAllowPort: [] - outboundBlockedCIDR: [] - internal: - # Nothing calls the worker; it pulls its work from Redis. - inboundAllowType: none - inboundAllowWorkload: [] # API backfill, declared so rendered == stored - loadBalancer: - direct: - enabled: false - ports: [] - replicaDirect: false - supportDynamicTags: false diff --git a/cpln-advisor/versions/2.0.0/values.yaml b/cpln-advisor/versions/2.0.0/values.yaml deleted file mode 100644 index b23dcf84..00000000 --- a/cpln-advisor/versions/2.0.0/values.yaml +++ /dev/null @@ -1,264 +0,0 @@ -# ─── Location ───────────────────────────────────────────────────────────────── -# This chart deploys into the GVC you install into — it does NOT create one, and -# there is no `gvc` key any more. `global.cpln.gvc` is injected by the platform -# at install time, so it is never declared here. -# -# DO NOT `helm upgrade` a 1.0.0 release onto 2.0.0. 1.0.0 created a GVC named -# after the one you installed into, so Helm ADOPTED your GVC; this version stops -# declaring it, and Helm deletes what a chart no longer declares — the GVC and -# everything in it. The chart refuses to render if the old `gvc` key is present, -# but a values-less upgrade sees only these defaults and cannot be caught. See -# `Migrating from 1.0.0` in the README. -# -# The advisor runs in EXACTLY ONE location, and this names it. It must already -# be a location of that GVC — the API reads the GVC at boot and warns loudly if -# it is not. -# -# One location is not a simplification. Scale bounds are per-location, so a -# second location means a second scheduler firing every cron twice, a second -# Redis splitting the task queue, and a second API running the same startup -# migration. Every workload in this chart is pinned here by -# defaultOptions.minScale/maxScale 0 plus a localOptions entry — but see the -# Database section: the bundled `postgres` subchart is the one tier that cannot -# be pinned, so install the advisor into a SINGLE-LOCATION GVC. -location: aws-us-east-1 - -# ─── Images ─────────────────────────────────────────────────────────────────── -# The API, worker and scheduler all run the SAME backend image — same code, -# different command. A scan is executed by the worker and served by the API, so -# they must never drift apart. Both packages are public on GHCR, so Control Plane -# pulls them anonymously and the GVC needs no pull secret. -# -# `:latest` tracks the project's main branch, and is resolved when the workload -# is DEPLOYED — a later push to the tag is NOT picked up until the next deploy, -# because these workloads set `supportDynamicTags: false`. Two consequences: -# -# * Two installs a week apart can be running different code. -# * There is nothing to roll back TO. Every build also publishes a -# `:sha-` tag — pin one before you depend on going back. -images: - backend: ghcr.io/controlplane-com/cpln-advisor/advisor-backend:latest - web: ghcr.io/controlplane-com/cpln-advisor/advisor-web:latest - -# ─── Credentials (PREREQUISITE SECRET) ──────────────────────────────────────── -# This chart creates NO credential secret and takes NO credential as a value, so -# nothing sensitive passes through Helm or lands in the release. It reads -# everything out of one `dictionary` secret that must EXIST BEFORE YOU INSTALL — -# the workloads wedge waiting on it otherwise. -# -# Create it with the six keys below (see README Prerequisites for the commands): -# -# ADVISOR_API_TOKEN shared dashboard <-> API token; the API fails closed without it -# ADVISOR_SECRET_KEY encrypts the AI and Slack credentials stored in the database -# ADVISOR_SESSION_SECRET signs the dashboard session cookie -# ADVISOR_USERNAME the web-UI login name, e.g. admin -# ADVISOR_PASSWORD the web-UI login password -# DATABASE_URL postgresql+asyncpg://USER:PASS@RELEASE-postgres.GVC.cpln.local:5432/DB -# -# The key names are the app's own environment variable names, so a secret built -# for a hand-applied deployment works here unchanged. -# -# DATABASE_URL must carry the SAME username/password/database as the database's -# own secret (postgres.config.credentialsSecretName below). NOTHING CROSS-CHECKS -# THE TWO — a mismatch is an authentication failure at runtime, not a render -# error. The host is `{release}-postgres.{gvc}.cpln.local:5432`. -auth: - secretName: advisor-config # MUST EXIST BEFORE INSTALL - -# ─── Access ─────────────────────────────────────────────────────────────────── -# The dashboard is PUBLIC, and that is not configurable. It is the only way in — -# nothing inside the GVC calls it, so an internal-only dashboard would be -# unreachable by the people it exists for. -# -# What stands between the internet and your fleet is the login: the session -# cookie is HMAC-signed, the API fails closed without its token, and both -# credentials come from a secret you create. There is no shipped default -# password to find. -# -# The API, worker, scheduler, Redis and the database are never public, and their -# internal firewalls admit only their real callers. -# -# To narrow the dashboard to an office or VPN range, edit `inboundAllowCIDR` on -# the `{release}-web` workload after installing. - -# OPTIONAL override for the dashboard's public URL, used for the Slack "View in -# Advisor" links and the CORS allowlist. -# -# Leave it empty: the app derives `https://{web workload}-{gvc alias}.cpln.app` -# from Control Plane's built-in env vars, and the chart tells it this release's -# web workload name. Set this only for a CUSTOM DOMAIN, which cannot be derived. -# Never "*". -appUrl: "" - -# ─── Sessions ───────────────────────────────────────────────────────────────── -# Idle windows: how long WITHOUT activity before you must sign in again. Both -# slide forward while you work, under a hard ceiling enforced by the app. -session: - hours: 12 - rememberDays: 30 - -# INFO logs one line per scan plus notable events. DEBUG adds per-query detail -# for every Control Plane call and is very noisy. -logLevel: INFO - -# ─── Workloads ──────────────────────────────────────────────────────────────── -# A block that exposes BOTH a reservation and a limit names them -# `minCpu`/`maxCpu` and `minMemory`/`maxMemory`. A block that exposes only the -# disambiguate against. (2.0.0 renamed `worker` and `redis` accordingly; the -# version bump is the migration path, there is no fallback.) -# -# `minCpu`/`minMemory` are the floor Capacity AI scales from; where they are not -# set, Control Plane supplies its own default floor. -# -# Where a min IS set, max:min must stay under 4:1 on cpu (Control Plane rejects -# the workload otherwise) and at or under 4:1 on memory. The chart checks both at -# render time — the rule is in no JSON schema and would otherwise surface as a -# 400 partway through an install. - -# The dashboard (Next.js). The only workload that can be made public. -web: - replicas: - min: 1 # set 0 to scale to zero when idle, at the cost of a cold start - max: 3 - resources: # Capacity AI ON - minCpu: 75m - maxCpu: 250m - minMemory: 128Mi - maxMemory: 512Mi - -# The API (FastAPI). Internal only — the browser never talks to it directly, the -# dashboard proxies server-side and attaches the token. Runs the database -# migration at startup, which is one reason it is pinned to a single replica. -api: - resources: # Capacity AI ON - # minCpu is 75m, not the 50m the upstream .cpln manifest carries: 256m/50m is - # a 5.12:1 spread and Control Plane rejects it. 75m keeps the same ceiling at - # 3.41:1. - minCpu: 75m - maxCpu: 256m - minMemory: 128Mi - maxMemory: 512Mi - -# Runs the scans, applies autopilot changes and sends the Slack digest. -worker: - resources: # Capacity AI off — a long scan should not be resized underneath itself - maxCpu: 100m - maxMemory: 256Mi - -# Fires the crons that CREATE the scans. Without it nothing is ever scanned on a -# schedule, retention never prunes and autopilot never runs — while everything -# still looks healthy. -scheduler: - resources: # Capacity AI ON - minCpu: 25m - maxCpu: 50m - minMemory: 64Mi - maxMemory: 128Mi - -# Task broker and cache. NOT persistent: everything in it is derived or -# transient, so a restart at worst skips one scan and repeats one digest. -redis: - image: redis:7-alpine - resources: # Capacity AI off — a broker should not be resized under load - maxCpu: 50m - maxMemory: 128Mi - # Cap the cache so a runaway key cannot get the container OOM-killed mid-scan. - # Below `memory` on purpose (100 MB vs 128 MiB). - maxmemory: 100mb - -# ─── Database (subchart: postgres 3.4.1) ────────────────────────────────────── -# Bundled, so one install brings up everything. Values here are passed to the -# catalog's `postgres` chart, which creates the workload, its volume set, its own -# identity and a policy granting `reveal` on the credentials secret below. -# -# Three workloads write this database concurrently — the API serves the -# dashboard, the worker records scan results and Autopilot changes, and the -# scheduler writes cron state. That needs a database server, and one that is -# backed up. -# -# THE ONE TIER THIS CHART CANNOT PIN TO A LOCATION. postgres 3.4.1 has no -# `location` knob and renders no `localOptions`, and a subchart's values cannot -# be templated, so the parent cannot supply one. In a GVC with more than one -# location it therefore runs ONE INDEPENDENT, EMPTY DATABASE PER LOCATION, each -# on its own volume — billed, and answering the same `{release}-postgres.{gvc}` -# service DNS name this release connects to. INSTALL THE ADVISOR INTO A -# SINGLE-LOCATION GVC. The API warns about extra locations at boot; it cannot -# prevent them. -postgres: - image: postgres:18 # 17+ is required for the backup feature below - - config: - # SECOND PREREQUISITE SECRET — CREATE IT BEFORE YOU INSTALL. A `dictionary` - # secret with exactly three keys: `username`, `password`, `database`. From - # 3.4.0 the template reads them from here instead of taking them inline, - # which is what keeps every credential out of this file. - # - # If it does not exist at install time the deployment WEDGES SILENTLY — - # `cpln logs` returns nothing at all to tell you why. - # - # Its three values must match DATABASE_URL in the advisor secret above. - # Nothing cross-checks them. - credentialsSecretName: advisor-db-credentials - - # The advisor's database is small — scores, suggestions and activity, not - # telemetry. Deliberately above the template's defaults on memory: a fleet scan - # writes in bursts. Postgres is `stateful`, which respects minCpu/minMemory - # whether or not Capacity AI is on. 3.4:1 and 2:1. - resources: - minCpu: 150m - minMemory: 256Mi - maxCpu: 512m - maxMemory: 512Mi - - volumeset: - capacity: 10 # GiB — 10 is the minimum - # One-way (volumes never shrink), so maxCapacity is the real cost ceiling. - autoscaling: - enabled: true - maxCapacity: 50 - minFreePercentage: 20 - scalingFactor: 1.5 - - # `same-gvc`, because a subchart's values cannot be templated and a workload - # list would have to hardcode release-prefixed names. - # - # From 2.0.0 the GVC is YOURS and may hold unrelated workloads, so `same-gvc` - # now means "anything in your GVC may open port 5432" rather than "the advisor - # and nothing else". If that GVC is shared, narrow it at install time with the - # real names, e.g.: - # - # --set postgres.internalAccess.type=workload-list \ - # --set postgres.internalAccess.workloads[0]=//gvc/YOUR_GVC/workload/RELEASE-api \ - # --set postgres.internalAccess.workloads[1]=//gvc/YOUR_GVC/workload/RELEASE-worker \ - # --set postgres.internalAccess.workloads[2]=//gvc/YOUR_GVC/workload/RELEASE-scheduler - # - # Add the backup workload too if you turn backups on. - internalAccess: - type: same-gvc - - # Connection pooling. Off because this app holds few connections — three - # workloads, one replica each. Turn it on if you ever raise those counts. - pgbouncer: - enabled: false - - # OFF because it needs a bucket and a cloud account you create first. TURN IT - # ON: a volume is not a backup, and the reason this template runs Postgres at - # all is that a database was lost. - # - # `backup.image` IS COUPLED TO `image` ABOVE — tag 18.1.0 backs up Postgres 18, - # 17.1.0 backs up Postgres 17. Change one and change the other, or pg_dump - # meets a server it cannot read. - # - # `provider: minio` needs its OWN prerequisite dictionary secret holding - # `accessKey` and `secretKey`, named in backup.minio.credentialsSecretName. - backup: - enabled: false - image: ghcr.io/controlplane-com/backup-images/postgres-backup:18.1.0 - schedule: "0 2 * * *" # daily, 02:00 UTC - provider: aws # options: aws, gcp, minio - aws: - bucket: my-backup-bucket - region: us-east-1 - cloudAccountName: my-backup-cloudaccount - policyName: my-backup-policy - prefix: advisor/backups