From ba1967debc26f3ab1afcee7d96cc39a9ee2ef2b9 Mon Sep 17 00:00:00 2001 From: Pratapa Lakshmi Date: Tue, 4 Aug 2026 22:22:39 +0530 Subject: [PATCH 1/2] feat(plane-enterprise): extend discrete credential parts to silo, live and Plane AI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 wired the Django services to consume a rotating credential as discrete parts. Silo, live and Plane AI still needed a composed DSN, so a deployment backed by a managed rotation secret had to maintain hand-built URLs for them alongside the secret itself. With the companion app release those services read parts too, and this wires them up. Also fixes the shared-key defects a cross-service audit turned up. Per-workload least privilege. plane.infraCredsEnv is split into postgresCredsEnv / rabbitmqCredsEnv / redisCredsEnv (+ the existing opensearchCredsEnv) and recomposed from them, so the eleven existing callers render byte-identically. Live then takes Redis and nothing else; silo takes Postgres, RabbitMQ and Redis but not OpenSearch. Plane AI gets its own plane.piInfraCredsEnv because it reads different variable names: PLANE_PI_POSTGRES_* for its own database and FOLLOWER_POSTGRES_* for its read path into the main Plane database, both from the same external_secrets.database Secret — one managed instance hosting two databases is the shape this chart provisions. It deliberately receives no RABBITMQ_*: Plane AI resolves an AMQP broker ahead of a Redis one, and Redis is its broker here, so emitting RabbitMQ parts would silently move its queue. An explicit env.pi_envs.follower_postgres_uri still wins, as the escape hatch for a follower with genuinely separate credentials. Composed DSNs are blanked in silo/live/pi-api Secrets whenever the matching external_secrets block is set, for the same reason as the API: each app prefers a URL when one is present, so a stale composed URL would shadow the rotated password. Audit fixes: RUNNER_HMAC_SECRET_KEY was delivered only to the runner, which has no code that reads it, while the API — the only service that verifies the signature — never received it, so runner HMAC auth could not succeed. It is now mounted on the seven api-family workloads that already mount silo-secrets, for the same reason: the verifying side lives there. It was also the one shared-key render bypassing plane.secretValue, so requireExplicitSecrets did not cover it, and runner.deployment was the only workload missing appKeysSecretRef. Both fixed. Making the runner actually sign its requests is a product decision and stays out of scope. AES_SALT was advertised as an app-keys member but had no values key and was never rendered — api, silo and pi worked only because all three share the same hardcoded default. env.silo_envs.aes_salt now renders into all three Secrets, and only when non-empty, so that default still applies to existing instances. app_keys_existingSecret combined with a legacy *_existingSecret carrying the same key diverges per service: app-keys is mounted first, so a duplicate wins on the workloads mounting that group and loses elsewhere, leaving two services disagreeing on a key that has to match. The chart cannot see inside an operator-supplied Secret, so NOTES.txt now warns when both are set and lists the keys that must not be duplicated. The public-default warning covered four of the six app keys; SILO_HMAC_SECRET_KEY — the API's inbound-auth key for the whole silo surface — and CURSOR_WEBHOOK_SECRET were missing. Both added. values.yaml now documents which keys the app-keys Secret must contain, SECRET_KEY foremost: omitting it gives each API pod a different random signing key, silently. Version gating. The "these services read connection URLs" and "redis needs parts support" warnings are gated on planeVersion (v3.2.0 and v3.1.0 respectively) rather than always firing. planeVersion is operator-supplied and need not be semver, so an unparsable value warns instead of failing the render — semverCompare would error. Verified additive over Phase 1: 28 containers, no env var lost anywhere; the only change on default values is RUNNER_HMAC_SECRET_KEY reaching the api family, which is the fix. helm lint clean and ten configurations render valid YAML, including runner with and without app-keys, the legacy groups alone, and the four version gates. Requires the companion app release for silo/live/pi parts support; the chart warns when planeVersion predates it. Co-Authored-By: Claude Opus 5 (1M context) --- charts/plane-enterprise/Chart.yaml | 2 +- charts/plane-enterprise/README.md | 12 ++- .../examples/external-secrets/README.md | 11 +++ .../external-secrets/rotation-runbook.md | 10 ++- charts/plane-enterprise/templates/NOTES.txt | 43 ++++++++-- .../plane-enterprise/templates/_helpers.tpl | 84 ++++++++++++++++++- .../templates/config-secrets/app-env.yaml | 5 ++ .../templates/config-secrets/live-env.yaml | 7 +- .../templates/config-secrets/pi-api-env.yaml | 29 ++++++- .../templates/config-secrets/runner-env.yaml | 6 +- .../templates/config-secrets/silo.yaml | 24 ++++-- .../templates/workloads/api.deployment.yaml | 8 ++ .../workloads/beat-worker.deployment.yaml | 8 ++ .../workloads/external-api.deployment.yaml | 8 ++ .../templates/workloads/live.deployment.yaml | 5 +- .../templates/workloads/migrator.job.yaml | 8 ++ .../workloads/pi-api.deployment.yaml | 4 +- .../workloads/pi-beat.deployment.yaml | 4 +- .../templates/workloads/pi-migrator.job.yaml | 4 +- .../workloads/pi-worker.deployment.yaml | 4 +- .../workloads/runner.deployment.yaml | 1 + .../templates/workloads/silo.deployment.yaml | 5 +- .../webhook-consumer.deployment.yaml | 8 ++ .../worker-importers.deployment.yaml | 8 ++ .../workloads/worker.deployment.yaml | 8 ++ charts/plane-enterprise/values.yaml | 33 ++++++-- 26 files changed, 312 insertions(+), 37 deletions(-) diff --git a/charts/plane-enterprise/Chart.yaml b/charts/plane-enterprise/Chart.yaml index e1eb826e..54537214 100644 --- a/charts/plane-enterprise/Chart.yaml +++ b/charts/plane-enterprise/Chart.yaml @@ -5,7 +5,7 @@ description: Meet Plane. An Enterprise software development tool to manage issue type: application -version: 3.1.0 +version: 3.2.0 appVersion: "3.0.0" home: https://plane.so/ diff --git a/charts/plane-enterprise/README.md b/charts/plane-enterprise/README.md index 788c9326..ed4d8f6b 100644 --- a/charts/plane-enterprise/README.md +++ b/charts/plane-enterprise/README.md @@ -949,11 +949,13 @@ If your rotation secret happens to carry the endpoint too (RDS non-master rotati Whenever one of these is set, the chart stops emitting the corresponding composed `DATABASE_URL` / `AMQP_URL` / `REDIS_URL` in its own Secrets — the application prefers a URL when one is present, so a stale URL would silently shadow the rotated credential. **If you also supply `app_env_existingSecret`, make sure it does not contain those URL keys.** -Applies to the Django services: api, external-api, worker, importer worker, beat-worker, webhook and automation consumers, outbox poller, migrator. OpenSearch credentials additionally reach the Plane AI workloads, since they query the same cluster. +**Every service reads discrete parts from planeVersion v3.2.0 onward** — the Django family (api, external-api, worker, importer worker, beat-worker, webhook and automation consumers, outbox poller, migrator) plus silo, live and Plane AI. Below v3.2.0 only the Django family does; `helm upgrade` warns when your `planeVersion` predates the support you have configured. -**Silo, live and Plane AI still need a connection URL** for the database, broker and cache — they do not read discrete parts. For those, keep using `silo_env_existingSecret` / `live_env_existingSecret` / `pi_api_env_existingSecret` and let ESO compose the DSN with a `template` block (see `examples/external-secrets/`). `helm upgrade` prints a warning when you have configured a combination that would leave one of them without connection details. +Each workload receives only the credentials it uses. Live gets Redis and nothing else; silo gets Postgres, RabbitMQ and Redis but not OpenSearch; Plane AI gets its own `PLANE_PI_POSTGRES_*` and `FOLLOWER_POSTGRES_*` names plus Redis and OpenSearch, and deliberately no `RabbitMQ` — Plane AI prefers an AMQP broker over a Redis one, so sending it RabbitMQ parts would quietly move its queue off Redis. -OpenSearch is the exception: `external_secrets.opensearch` covers the Plane AI workloads too, so search credentials rotate for the whole deployment at once. +Plane AI's two databases both come from the same `external_secrets.database` Secret, which is the shape this chart provisions (one managed instance, two databases). If yours have genuinely separate credentials, set `env.pi_envs.follower_postgres_uri` — it still takes precedence — or use `pi_api_env_existingSecret`. + +For **planeVersion below v3.2.0**, supply silo/live/Plane AI DSNs through `silo_env_existingSecret` / `live_env_existingSecret` / `pi_api_env_existingSecret` and let ESO compose them with a `template` block (see `examples/external-secrets/`). ### 3. Shared signing keys in one Secret @@ -966,6 +968,10 @@ external_secrets: The Secret should carry `SECRET_KEY`, `AES_SECRET_KEY`, `AES_SALT`, `LIVE_SERVER_SECRET_KEY`, `PI_INTERNAL_SECRET`, `SILO_HMAC_SECRET_KEY`, `CURSOR_WEBHOOK_SECRET`. While it is set, the chart stops emitting those keys in its own Secrets. It is mounted first in `envFrom`, so a key you already externalized through one of the older `*_existingSecret` groups still wins — don't define the same key in both. +The Secret must carry every key your deployment uses — a missing key is not a render error, just an absent env var. `SECRET_KEY` matters most: the API falls back to a per-pod random value when it is absent, so JWTs stop verifying across replicas and encrypted instance-configuration rows become unreadable, with no error. `RUNNER_HMAC_SECRET_KEY` belongs here too when the runner is deployed. + +Do not also define any of these keys in one of the `*_existingSecret` groups. Those Secrets are mounted after this one, so a duplicate wins on the workloads mounting that group and loses everywhere else — leaving two services disagreeing on a key that has to match. `helm upgrade` warns when it sees both set. + The trade-off: because it is one Secret, every service that mounts it sees all of its keys — the live server's pods get `SECRET_KEY` in their environment even though only the API uses it. These are all first-party Plane services in one namespace, so this is the same trust boundary the duplicated copies already shared. If you need the keys separated per service, keep using the per-group `*_existingSecret` mechanism and take on keeping the shared values in step yourself. > **Never rotate `SECRET_KEY`, `AES_SECRET_KEY` or `AES_SALT` on a running instance.** `SECRET_KEY` derives the Fernet key that encrypts the instance-configuration rows (SMTP password, OAuth client secrets, LLM keys); the AES pair protects stored OAuth application secrets, MCP connections and desktop handoff tokens. Changing either makes existing ciphertext undecryptable, and the failure is silent — values come back empty. Keep them in a secret with no rotation schedule. diff --git a/charts/plane-enterprise/examples/external-secrets/README.md b/charts/plane-enterprise/examples/external-secrets/README.md index b81f5dd6..57faa832 100644 --- a/charts/plane-enterprise/examples/external-secrets/README.md +++ b/charts/plane-enterprise/examples/external-secrets/README.md @@ -57,6 +57,17 @@ reloader: If your provider can push on rotation (an AWS Lambda rotation hook that annotates the `ExternalSecret`, or ESO's `PushSecret`/webhook paths), prefer that over polling frequently. +## Composed DSNs are only for older app versions + +From **planeVersion v3.2.0** every service — including silo, live and Plane AI — reads +discrete credential parts, so the `template:` blocks in the provider examples that build +a `DATABASE_URL`/`REDIS_URL`/`AMQP_URL` are no longer needed. Point +`external_secrets.database` / `rabbitmq` / `redis` at the mirrored secret and let each +app compose its own URL. + +Keep using the templated DSN sections (`plane-silo-env` and friends) only when pinned +below v3.2.0, or when a service genuinely needs different credentials from the primary. + ## What must never rotate Do not put `SECRET_KEY`, `AES_SECRET_KEY` or `AES_SALT` in a secret with a rotation policy. `SECRET_KEY` derives the Fernet key encrypting the instance-configuration rows, and the AES pair protects stored OAuth/MCP tokens; changing either makes existing ciphertext undecryptable, silently. Keep them in a separate, static secret — that is what `app_keys_existingSecret` is for. diff --git a/charts/plane-enterprise/examples/external-secrets/rotation-runbook.md b/charts/plane-enterprise/examples/external-secrets/rotation-runbook.md index 67ad707f..f3d13241 100644 --- a/charts/plane-enterprise/examples/external-secrets/rotation-runbook.md +++ b/charts/plane-enterprise/examples/external-secrets/rotation-runbook.md @@ -80,7 +80,15 @@ Both fail **silently**: decryption errors are swallowed and the values come back **Database-resident secrets do not rotate through the environment.** With the default `SKIP_ENV_VAR=1`, the SMTP password, OAuth client secrets, `LLM_API_KEY` and `LDAP_BIND_PASSWORD` live in the instance-configuration table, seeded from the environment only on first boot. Set `env.skip_env_var: '0'` to make the environment the source of truth on every restart — at the cost of god-mode admin UI edits to those settings being overwritten. -**Silo, live and Plane AI read connection URLs, not discrete parts.** Their DSN has to be composed by ESO with a `template` block (see the provider examples). That template embeds the password, so it must use `urlEncode`. +**Silo, live and Plane AI rotate like the API from planeVersion v3.2.0** — they read +discrete parts, so nothing recomposes a DSN and a Reloader restart is all that is needed. +Below v3.2.0 their DSN has to be composed by ESO with a `template` block (see the provider +examples), and that template embeds the password, so it must use `urlEncode`. + +**Plane AI's follower connection re-reads its credentials at call time.** After an +authentication failure it refetches without waiting for a restart, so a remounted Secret +can take effect mid-process. Its own database and broker still resolve at import, so those +rely on the Reloader restart like everything else. ## Verifying a rotation diff --git a/charts/plane-enterprise/templates/NOTES.txt b/charts/plane-enterprise/templates/NOTES.txt index 01d3fde4..30daa47d 100644 --- a/charts/plane-enterprise/templates/NOTES.txt +++ b/charts/plane-enterprise/templates/NOTES.txt @@ -13,6 +13,8 @@ Plane Enterprise {{ .Chart.Version }} — release {{ .Release.Name }} in namespa {{- if or (empty .Values.env.live_server_secret_key) (eq .Values.env.live_server_secret_key "htbqvBJAgpm9bzvf3r4urJer0ENReatceh") }}{{ $publicDefaults = append $publicDefaults "LIVE_SERVER_SECRET_KEY" }}{{ end }} {{- if or (empty .Values.env.silo_envs.aes_secret_key) (eq .Values.env.silo_envs.aes_secret_key "dsOdt7YrvxsTIFJ37pOaEVvLxN8KGBCr") }}{{ $publicDefaults = append $publicDefaults "AES_SECRET_KEY" }}{{ end }} {{- if or (empty .Values.env.pi_envs.internal_secret) (eq .Values.env.pi_envs.internal_secret "tyfvfqvBJAgpm9bzvf3r4urJer0Ehfdubk") }}{{ $publicDefaults = append $publicDefaults "PI_INTERNAL_SECRET" }}{{ end }} +{{- if and .Values.services.silo.enabled (or (empty .Values.env.silo_envs.hmac_secret_key) (eq .Values.env.silo_envs.hmac_secret_key "gzb7MRLr0FoN129NyWARZEs84P9LzQ")) }}{{ $publicDefaults = append $publicDefaults "SILO_HMAC_SECRET_KEY" }}{{ end }} +{{- if and .Values.services.silo.enabled (or (empty .Values.env.silo_envs.cursor_webhook_secret) (eq .Values.env.silo_envs.cursor_webhook_secret "TTqazTcoBajYKzIAeIKFZeTX9czAoUsG")) }}{{ $publicDefaults = append $publicDefaults "CURSOR_WEBHOOK_SECRET" }}{{ end }} {{- if $publicDefaults }} {{- $warnings = append $warnings (printf "Using the chart's PUBLIC example values for: %s.\n These are published in the chart and are not secret. Supply your own through\n external_secrets.app_keys_existingSecret, and set env.requireExplicitSecrets=true\n to make a missing value fail the render instead of falling back." (join ", " $publicDefaults)) }} {{- end }} @@ -23,15 +25,27 @@ Plane Enterprise {{ .Chart.Version }} — release {{ .Release.Name }} in namespa {{- $warnings = append $warnings "Credentials come from an external Secret, but reloader.enabled is false.\n Pods will keep the values they started with, so a rotated credential will not\n take effect until something restarts them. Install Stakater Reloader and set\n reloader.enabled=true." }} {{- end }} -{{- /* Services that cannot consume discrete credential parts yet. */}} +{{- /* +Version gate. Discrete credential parts landed in two stages: the Django services in +v3.1.0, and silo/live/pi in v3.2.0. A deployment pinned below the relevant version +gets the warnings that follow. planeVersion is operator-supplied and need not be +semver ("stable", a git sha), so an unparsable value warns rather than failing the +render — semverCompare would error on it. +*/}} +{{- $version := trimPrefix "v" (.Values.planeVersion | toString) }} +{{- $parsable := regexMatch "^[0-9]+\\.[0-9]+\\.[0-9]+" $version }} +{{- $preRedisParts := or (not $parsable) (semverCompare "< 3.1.0" $version) }} +{{- $preServiceParts := or (not $parsable) (semverCompare "< 3.2.0" $version) }} + +{{- /* Services that cannot consume discrete credential parts on this app version. */}} {{- $externalInfra := or .Values.external_secrets.database.secretName .Values.external_secrets.rabbitmq.secretName .Values.external_secrets.redis.secretName }} -{{- if $externalInfra }} +{{- if and $externalInfra $preServiceParts }} {{- $urlServices := list }} {{- if and .Values.services.silo.enabled (empty .Values.external_secrets.silo_env_existingSecret) }}{{ $urlServices = append $urlServices "silo" }}{{ end }} {{- if and .Values.services.pi.enabled (empty .Values.external_secrets.pi_api_env_existingSecret) }}{{ $urlServices = append $urlServices "pi" }}{{ end }} {{- if and .Values.external_secrets.redis.secretName (empty .Values.external_secrets.live_env_existingSecret) }}{{ $urlServices = append $urlServices "live" }}{{ end }} {{- if $urlServices }} -{{- $warnings = append $warnings (printf "These services read a connection URL rather than discrete credential parts: %s.\n They will start without working connection details. Supply their DSN through the\n matching *_existingSecret with an ESO template block — see\n examples/external-secrets/ — or disable them." (join ", " $urlServices)) }} +{{- $warnings = append $warnings (printf "These services read discrete credential parts only from planeVersion v3.2.0\n onward, and this release runs %s: %s.\n On an older image they will start without working connection details. Either\n upgrade, or supply their DSN through the matching *_existingSecret with an ESO\n template block — see examples/external-secrets/." .Values.planeVersion (join ", " $urlServices)) }} {{- end }} {{- end }} @@ -41,8 +55,27 @@ Plane Enterprise {{ .Chart.Version }} — release {{ .Release.Name }} in namespa {{- end }} {{- /* Redis parts need app support. */}} -{{- if .Values.external_secrets.redis.secretName }} -{{- $warnings = append $warnings (printf "external_secrets.redis requires discrete REDIS_HOST/PORT/PASSWORD/SSL support in\n the application, added in planeVersion v3.1.0. This release runs %s — verify it is\n v3.1.0 or newer, or keep supplying REDIS_URL instead." .Values.planeVersion) }} +{{- if and .Values.external_secrets.redis.secretName $preRedisParts }} +{{- $warnings = append $warnings (printf "external_secrets.redis requires discrete REDIS_HOST/PORT/PASSWORD/SSL support in\n the application, added in planeVersion v3.1.0. This release runs %s — upgrade, or\n keep supplying REDIS_URL instead." .Values.planeVersion) }} +{{- end }} + +{{- /* +Duplicate shared keys across two Secrets. The chart suppresses its own copies when +app_keys_existingSecret is set, but it cannot see inside an operator-supplied Secret. +Because app-keys is mounted first, a duplicate in a legacy group wins on the workloads +that mount that group and loses everywhere else — so two services end up with +different values of a key that has to match. +*/}} +{{- if .Values.external_secrets.app_keys_existingSecret }} +{{- $legacyGroups := list }} +{{- with .Values.external_secrets.app_env_existingSecret }}{{ $legacyGroups = append $legacyGroups (printf "app_env (%s)" .) }}{{ end }} +{{- with .Values.external_secrets.silo_env_existingSecret }}{{ $legacyGroups = append $legacyGroups (printf "silo_env (%s)" .) }}{{ end }} +{{- with .Values.external_secrets.live_env_existingSecret }}{{ $legacyGroups = append $legacyGroups (printf "live_env (%s)" .) }}{{ end }} +{{- with .Values.external_secrets.pi_api_env_existingSecret }}{{ $legacyGroups = append $legacyGroups (printf "pi_api_env (%s)" .) }}{{ end }} +{{- with .Values.external_secrets.runner_env_existingSecret }}{{ $legacyGroups = append $legacyGroups (printf "runner_env (%s)" .) }}{{ end }} +{{- if $legacyGroups }} +{{- $warnings = append $warnings (printf "app_keys_existingSecret is set alongside: %s.\n Those Secrets must NOT contain SECRET_KEY, AES_SECRET_KEY, AES_SALT,\n LIVE_SERVER_SECRET_KEY, PI_INTERNAL_SECRET, SILO_HMAC_SECRET_KEY,\n CURSOR_WEBHOOK_SECRET or RUNNER_HMAC_SECRET_KEY. A duplicate wins on the workloads\n mounting that group and loses on the rest, leaving two services disagreeing on a\n key that must match — which surfaces as auth failures or undecryptable data." (join ", " $legacyGroups)) }} +{{- end }} {{- end }} {{- /* Secrets that env rotation cannot reach. */}} diff --git a/charts/plane-enterprise/templates/_helpers.tpl b/charts/plane-enterprise/templates/_helpers.tpl index 01567ae2..c5e1e719 100644 --- a/charts/plane-enterprise/templates/_helpers.tpl +++ b/charts/plane-enterprise/templates/_helpers.tpl @@ -499,6 +499,18 @@ propagates with no URL rewriting anywhere in the chain. Caller must indent to the correct depth (env list items). */}} {{- define "plane.infraCredsEnv" -}} +{{- include "plane.postgresCredsEnv" . }} +{{- include "plane.rabbitmqCredsEnv" . }} +{{- include "plane.redisCredsEnv" . }} +{{- include "plane.opensearchCredsEnv" . }} +{{- end -}} + +{{/* +Postgres credentials from an externally managed Secret. Split out of +plane.infraCredsEnv so a workload can take only the backends it actually uses — +the live server needs Redis and nothing else. +*/}} +{{- define "plane.postgresCredsEnv" -}} {{- $db := .Values.external_secrets.database -}} {{- if $db.secretName }} - name: POSTGRES_HOST @@ -519,6 +531,12 @@ Caller must indent to the correct depth (env list items). {{- include "plane.secretKeyEnv" (dict "name" "POSTGRES_DB" "secret" $db.secretName "key" .) }} {{- end }} {{- end }} +{{- end -}} + +{{/* +RabbitMQ credentials from an externally managed Secret. +*/}} +{{- define "plane.rabbitmqCredsEnv" -}} {{- $mq := .Values.external_secrets.rabbitmq -}} {{- if $mq.secretName }} - name: RABBITMQ_HOST @@ -543,6 +561,13 @@ Caller must indent to the correct depth (env list items). {{- include "plane.secretKeyEnv" (dict "name" "RABBITMQ_VHOST" "secret" $mq.secretName "key" .) }} {{- end }} {{- end }} +{{- end -}} + +{{/* +Redis credentials from an externally managed Secret. Consumed on its own by the +live server, and by Plane AI, whose Celery broker is Redis in Helm deployments. +*/}} +{{- define "plane.redisCredsEnv" -}} {{- $redis := .Values.external_secrets.redis -}} {{- if $redis.secretName }} - name: REDIS_HOST @@ -559,7 +584,6 @@ Caller must indent to the correct depth (env list items). {{- include "plane.secretKeyEnv" (dict "name" "REDIS_PORT" "secret" $redis.secretName "key" .) }} {{- end }} {{- end }} -{{- include "plane.opensearchCredsEnv" . }} {{- end -}} {{/* @@ -580,3 +604,61 @@ Caller must indent to the correct depth (env list items). {{- include "plane.secretKeyEnv" (dict "name" "OPENSEARCH_PASSWORD" "secret" $os.secretName "key" ($os.passwordKey | default "password")) }} {{- end }} {{- end -}} + +{{/* +Infrastructure credentials for silo: Postgres, RabbitMQ and Redis, using the same +env var names as the Django services. Not OpenSearch — silo never queries it. + +Caller must indent to the correct depth (env list items). +*/}} +{{- define "plane.siloInfraCredsEnv" -}} +{{- include "plane.postgresCredsEnv" . }} +{{- include "plane.rabbitmqCredsEnv" . }} +{{- include "plane.redisCredsEnv" . }} +{{- end -}} + +{{/* +Database credentials for Plane AI, which reads its own env var names rather than +the POSTGRES_* set: PLANE_PI_POSTGRES_* for its own database and +FOLLOWER_POSTGRES_* for its read path into the main Plane database. Both come from +the same external_secrets.database Secret — one managed instance hosting two +databases is the shape the chart provisions. A deployment with genuinely separate +credentials per database should use pi_api_env_existingSecret instead. + +Redis is included because Plane AI's Celery broker is Redis in Helm deployments. +RabbitMQ deliberately is NOT: pi resolves an AMQP broker ahead of a Redis one, so +emitting RABBITMQ_* here would silently move its queue off Redis. + +Caller must indent to the correct depth (env list items). +*/}} +{{- define "plane.piInfraCredsEnv" -}} +{{- $db := .Values.external_secrets.database -}} +{{- if $db.secretName }} +- name: PLANE_PI_POSTGRES_HOST + value: {{ include "plane.postgresHost" . | quote }} +- name: PLANE_PI_POSTGRES_PORT + value: {{ .Values.env.pgdb_port | default "5432" | quote }} +- name: PLANE_PI_POSTGRES_DB + value: {{ .Values.env.pg_pi_db_name | default "plane_pi" | quote }} +{{- include "plane.secretKeyEnv" (dict "name" "PLANE_PI_POSTGRES_USER" "secret" $db.secretName "key" ($db.usernameKey | default "username")) }} +{{- include "plane.secretKeyEnv" (dict "name" "PLANE_PI_POSTGRES_PASSWORD" "secret" $db.secretName "key" ($db.passwordKey | default "password")) }} +- name: FOLLOWER_POSTGRES_HOST + value: {{ include "plane.postgresHost" . | quote }} +- name: FOLLOWER_POSTGRES_PORT + value: {{ .Values.env.pgdb_port | default "5432" | quote }} +- name: FOLLOWER_POSTGRES_DB + value: {{ .Values.env.pgdb_name | default "plane" | quote }} +{{- include "plane.secretKeyEnv" (dict "name" "FOLLOWER_POSTGRES_USER" "secret" $db.secretName "key" ($db.usernameKey | default "username")) }} +{{- include "plane.secretKeyEnv" (dict "name" "FOLLOWER_POSTGRES_PASSWORD" "secret" $db.secretName "key" ($db.passwordKey | default "password")) }} +{{- with $db.hostKey }} +{{- include "plane.secretKeyEnv" (dict "name" "PLANE_PI_POSTGRES_HOST" "secret" $db.secretName "key" .) }} +{{- include "plane.secretKeyEnv" (dict "name" "FOLLOWER_POSTGRES_HOST" "secret" $db.secretName "key" .) }} +{{- end }} +{{- with $db.portKey }} +{{- include "plane.secretKeyEnv" (dict "name" "PLANE_PI_POSTGRES_PORT" "secret" $db.secretName "key" .) }} +{{- include "plane.secretKeyEnv" (dict "name" "FOLLOWER_POSTGRES_PORT" "secret" $db.secretName "key" .) }} +{{- end }} +{{- end }} +{{- include "plane.redisCredsEnv" . }} +{{- include "plane.opensearchCredsEnv" . }} +{{- end -}} diff --git a/charts/plane-enterprise/templates/config-secrets/app-env.yaml b/charts/plane-enterprise/templates/config-secrets/app-env.yaml index 90777dcc..1f1ffd84 100644 --- a/charts/plane-enterprise/templates/config-secrets/app-env.yaml +++ b/charts/plane-enterprise/templates/config-secrets/app-env.yaml @@ -14,6 +14,11 @@ stringData: {{- if empty .Values.external_secrets.app_keys_existingSecret }} SECRET_KEY: {{ include "plane.secretValue" (dict "context" $ "name" "env.secret_key" "value" .Values.env.secret_key "fallback" "60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5") | quote }} AES_SECRET_KEY: {{ include "plane.secretValue" (dict "context" $ "name" "env.silo_envs.aes_secret_key" "value" .Values.env.silo_envs.aes_secret_key "fallback" "dsOdt7YrvxsTIFJ37pOaEVvLxN8KGBCr") | quote }} + {{- with .Values.env.silo_envs.aes_salt }} + {{/* Rendered only when set: api, silo and pi all default to "aes-salt" internally, + so emitting an empty value would break decryption on existing instances. */}} + AES_SALT: {{ . | quote }} + {{- end }} LIVE_SERVER_SECRET_KEY: {{ include "plane.secretValue" (dict "context" $ "name" "env.live_server_secret_key" "value" .Values.env.live_server_secret_key "fallback" "htbqvBJAgpm9bzvf3r4urJer0ENReatceh") | quote }} PI_INTERNAL_SECRET: {{ include "plane.secretValue" (dict "context" $ "name" "env.pi_envs.internal_secret" "value" .Values.env.pi_envs.internal_secret "fallback" "tyfvfqvBJAgpm9bzvf3r4urJer0Ehfdubk") | quote }} {{- end }} diff --git a/charts/plane-enterprise/templates/config-secrets/live-env.yaml b/charts/plane-enterprise/templates/config-secrets/live-env.yaml index 284d71e2..471fe988 100644 --- a/charts/plane-enterprise/templates/config-secrets/live-env.yaml +++ b/charts/plane-enterprise/templates/config-secrets/live-env.yaml @@ -11,7 +11,12 @@ stringData: {{- if empty .Values.external_secrets.app_keys_existingSecret }} LIVE_SERVER_SECRET_KEY: {{ include "plane.secretValue" (dict "context" $ "name" "env.live_server_secret_key" "value" .Values.env.live_server_secret_key "fallback" "htbqvBJAgpm9bzvf3r4urJer0ENReatceh") | quote }} {{- end }} - {{- if .Values.services.redis.local_setup }} + {{- if include "plane.externalRedis" . }} + {{/* Credentials come from external_secrets.redis as discrete REDIS_* parts. + Left empty on purpose: live prefers a URL when one is set, so a composed URL + here would shadow the rotated password. */}} + REDIS_URL: "" + {{- else if .Values.services.redis.local_setup }} REDIS_URL: "redis://{{ .Release.Name }}-redis.{{ .Release.Namespace }}.svc.{{ .Values.env.default_cluster_domain | default "cluster.local" }}:6379/" {{- else }} REDIS_URL: {{ .Values.env.remote_redis_url | default "" | quote }} diff --git a/charts/plane-enterprise/templates/config-secrets/pi-api-env.yaml b/charts/plane-enterprise/templates/config-secrets/pi-api-env.yaml index 7bef95f9..9d93a5c3 100644 --- a/charts/plane-enterprise/templates/config-secrets/pi-api-env.yaml +++ b/charts/plane-enterprise/templates/config-secrets/pi-api-env.yaml @@ -9,7 +9,13 @@ metadata: labels: {{- include "plane.commonLabels" $ | nindent 4 }} stringData: - {{- if .Values.services.postgres.local_setup }} + {{- if include "plane.externalDatabase" . }} + {{/* Credentials come from external_secrets.database as discrete + PLANE_PI_POSTGRES_* / FOLLOWER_POSTGRES_* parts. Left empty on purpose: pi + prefers a URL when one is set, so a composed URL here would shadow the + rotated password. */}} + PLANE_PI_DATABASE_URL: "" + {{- else if .Values.services.postgres.local_setup }} PLANE_PI_DATABASE_URL: "postgresql://{{ .Values.env.pgdb_username }}:{{ .Values.env.pgdb_password }}@{{ .Release.Name }}-pgdb.{{ .Release.Namespace }}.svc.{{ .Values.env.default_cluster_domain | default "cluster.local" }}/{{ .Values.env.pg_pi_db_name }}" {{- else if .Values.env.pg_pi_db_remote_url }} PLANE_PI_DATABASE_URL: {{ .Values.env.pg_pi_db_remote_url}} @@ -17,17 +23,23 @@ stringData: PLANE_PI_DATABASE_URL: "" {{- end }} + {{/* An explicit follower_postgres_uri still wins — it is the escape hatch for a + follower with genuinely separate credentials from the primary. */}} {{- if .Values.env.pi_envs.follower_postgres_uri }} FOLLOWER_POSTGRES_URI: {{ .Values.env.pi_envs.follower_postgres_uri | quote }} + {{- else if include "plane.externalDatabase" . }} + FOLLOWER_POSTGRES_URI: "" {{- else if .Values.services.postgres.local_setup }} FOLLOWER_POSTGRES_URI: "postgresql://{{ .Values.env.pgdb_username }}:{{ .Values.env.pgdb_password }}@{{ .Release.Name }}-pgdb.{{ .Release.Namespace }}.svc.{{ .Values.env.default_cluster_domain | default "cluster.local" }}/{{ .Values.env.pgdb_name }}" {{- else if .Values.env.pgdb_remote_url }} FOLLOWER_POSTGRES_URI: {{ .Values.env.pgdb_remote_url | quote }} - {{- else }} + {{- else }} FOLLOWER_POSTGRES_URI: "" {{- end }} - {{- if .Values.services.rabbitmq.local_setup }} + {{- if include "plane.externalRabbitmq" . }} + AMQP_URL: "" + {{- else if .Values.services.rabbitmq.local_setup }} AMQP_URL: "amqp://{{ .Values.services.rabbitmq.default_user }}:{{ .Values.services.rabbitmq.default_password }}@{{ .Release.Name }}-rabbitmq.{{ .Release.Namespace }}.svc.{{ .Values.env.default_cluster_domain | default "cluster.local" }}/" {{- else if .Values.services.rabbitmq.external_rabbitmq_url }} AMQP_URL: {{ .Values.services.rabbitmq.external_rabbitmq_url | default "" | quote }} @@ -35,7 +47,11 @@ stringData: AMQP_URL: "" {{- end }} - {{- if .Values.services.redis.local_setup }} + {{/* Redis is pi's Celery broker in this chart. CELERY_BROKER_URL takes precedence + over AMQP_URL in pi, so leaving it empty is what lets the REDIS_* parts win. */}} + {{- if include "plane.externalRedis" . }} + CELERY_BROKER_URL: "" + {{- else if .Values.services.redis.local_setup }} CELERY_BROKER_URL: "redis://{{ .Release.Name }}-redis.{{ .Release.Namespace }}.svc.{{ .Values.env.default_cluster_domain | default "cluster.local" }}:6379/" {{- else }} CELERY_BROKER_URL: {{ .Values.env.remote_redis_url | default "" | quote }} @@ -78,6 +94,11 @@ stringData: {{- if empty .Values.external_secrets.app_keys_existingSecret }} AES_SECRET_KEY: {{ include "plane.secretValue" (dict "context" $ "name" "env.silo_envs.aes_secret_key" "value" .Values.env.silo_envs.aes_secret_key "fallback" "dsOdt7YrvxsTIFJ37pOaEVvLxN8KGBCr") | quote }} + {{- with .Values.env.silo_envs.aes_salt }} + {{/* Rendered only when set: api, silo and pi all default to "aes-salt" internally, + so emitting an empty value would break decryption on existing instances. */}} + AES_SALT: {{ . | quote }} + {{- end }} PI_INTERNAL_SECRET: {{ include "plane.secretValue" (dict "context" $ "name" "env.pi_envs.internal_secret" "value" .Values.env.pi_envs.internal_secret "fallback" "tyfvfqvBJAgpm9bzvf3r4urJer0Ehfdubk") | quote }} diff --git a/charts/plane-enterprise/templates/config-secrets/runner-env.yaml b/charts/plane-enterprise/templates/config-secrets/runner-env.yaml index daa6d80d..f2c06b0a 100644 --- a/charts/plane-enterprise/templates/config-secrets/runner-env.yaml +++ b/charts/plane-enterprise/templates/config-secrets/runner-env.yaml @@ -8,7 +8,11 @@ metadata: labels: {{- include "plane.commonLabels" $ | nindent 4 }} stringData: - RUNNER_HMAC_SECRET_KEY: {{ .Values.env.runner_envs.hmac_secret_key | default "" | quote }} + {{- if empty .Values.external_secrets.app_keys_existingSecret }} + {{/* The API is the verifying side of this HMAC, so this Secret is mounted on the + api-family workloads too — see runner.deployment.yaml and api.deployment.yaml. */}} + RUNNER_HMAC_SECRET_KEY: {{ include "plane.secretValue" (dict "context" $ "name" "env.runner_envs.hmac_secret_key" "value" .Values.env.runner_envs.hmac_secret_key "fallback" "") | quote }} + {{- end }} {{- end }} --- {{- if .Values.services.runner.enabled }} diff --git a/charts/plane-enterprise/templates/config-secrets/silo.yaml b/charts/plane-enterprise/templates/config-secrets/silo.yaml index e4e2b973..cf841221 100644 --- a/charts/plane-enterprise/templates/config-secrets/silo.yaml +++ b/charts/plane-enterprise/templates/config-secrets/silo.yaml @@ -12,24 +12,38 @@ stringData: {{- if empty .Values.external_secrets.app_keys_existingSecret }} SILO_HMAC_SECRET_KEY: {{ include "plane.secretValue" (dict "context" $ "name" "env.silo_envs.hmac_secret_key" "value" .Values.env.silo_envs.hmac_secret_key "fallback" "gzb7MRLr0FoN129NyWARZEs84P9LzQ") | quote }} AES_SECRET_KEY: {{ include "plane.secretValue" (dict "context" $ "name" "env.silo_envs.aes_secret_key" "value" .Values.env.silo_envs.aes_secret_key "fallback" "dsOdt7YrvxsTIFJ37pOaEVvLxN8KGBCr") | quote }} + {{- with .Values.env.silo_envs.aes_salt }} + {{/* Rendered only when set: api, silo and pi all default to "aes-salt" internally, + so emitting an empty value would break decryption on existing instances. */}} + AES_SALT: {{ . | quote }} + {{- end }} CURSOR_WEBHOOK_SECRET: {{ include "plane.secretValue" (dict "context" $ "name" "env.silo_envs.cursor_webhook_secret" "value" .Values.env.silo_envs.cursor_webhook_secret "fallback" "TTqazTcoBajYKzIAeIKFZeTX9czAoUsG") | quote }} {{- end }} - {{- if .Values.services.postgres.local_setup }} + {{- if include "plane.externalDatabase" . }} + {{/* Credentials come from external_secrets.database as discrete POSTGRES_* parts. + Left empty on purpose: silo prefers a URL when one is set, so a composed URL + here would shadow the rotated password. */}} + DATABASE_URL: "" + {{- else if .Values.services.postgres.local_setup }} DATABASE_URL: "postgresql://{{ .Values.env.pgdb_username }}:{{ .Values.env.pgdb_password }}@{{ .Release.Name }}-pgdb.{{ .Release.Namespace }}.svc.{{ .Values.env.default_cluster_domain | default "cluster.local" }}/{{ .Values.env.pgdb_name }}" {{- else if .Values.env.pgdb_remote_url }} DATABASE_URL: {{ .Values.env.pgdb_remote_url}} - {{ else }} + {{- else }} DATABASE_URL: "" - {{ end }} + {{- end }} - {{- if .Values.services.redis.local_setup }} + {{- if include "plane.externalRedis" . }} + REDIS_URL: "" + {{- else if .Values.services.redis.local_setup }} REDIS_URL: "redis://{{ .Release.Name }}-redis.{{ .Release.Namespace }}.svc.{{ .Values.env.default_cluster_domain | default "cluster.local" }}:6379/" {{- else if .Values.env.remote_redis_url }} REDIS_URL: {{ .Values.env.remote_redis_url | default "" | quote }} {{- end }} - {{- if .Values.services.rabbitmq.local_setup }} + {{- if include "plane.externalRabbitmq" . }} + AMQP_URL: "" + {{- else if .Values.services.rabbitmq.local_setup }} AMQP_URL: "amqp://{{ .Values.services.rabbitmq.default_user}}:{{ .Values.services.rabbitmq.default_password}}@{{ .Release.Name }}-rabbitmq.{{ .Release.Namespace }}.svc.{{ .Values.env.default_cluster_domain | default "cluster.local" }}/" {{- else if .Values.services.rabbitmq.external_rabbitmq_url }} AMQP_URL: {{ .Values.services.rabbitmq.external_rabbitmq_url}} diff --git a/charts/plane-enterprise/templates/workloads/api.deployment.yaml b/charts/plane-enterprise/templates/workloads/api.deployment.yaml index 0fb3aa4c..ff27b60a 100644 --- a/charts/plane-enterprise/templates/workloads/api.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/api.deployment.yaml @@ -96,6 +96,14 @@ spec: name: {{ if not (empty .Values.external_secrets.silo_env_existingSecret) }}{{ .Values.external_secrets.silo_env_existingSecret }}{{ else }}{{ .Release.Name }}-silo-secrets{{ end }} optional: false {{- end }} + {{- if .Values.services.runner.enabled }} + {{/* The API verifies the runner's HMAC signature, so it needs the same key + the runner signs with. Delivered here for the same reason silo-secrets + is above: the verifying side lives in this workload family. */}} + - secretRef: + name: {{ if not (empty .Values.external_secrets.runner_env_existingSecret) }}{{ .Values.external_secrets.runner_env_existingSecret }}{{ else }}{{ .Release.Name }}-runner-secrets{{ end }} + optional: false + {{- end }} {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.infraCredsEnv" .) }} env: diff --git a/charts/plane-enterprise/templates/workloads/beat-worker.deployment.yaml b/charts/plane-enterprise/templates/workloads/beat-worker.deployment.yaml index d0609270..2d422d24 100644 --- a/charts/plane-enterprise/templates/workloads/beat-worker.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/beat-worker.deployment.yaml @@ -56,6 +56,14 @@ spec: name: {{ if not (empty .Values.external_secrets.silo_env_existingSecret) }}{{ .Values.external_secrets.silo_env_existingSecret }}{{ else }}{{ .Release.Name }}-silo-secrets{{ end }} optional: false {{- end }} + {{- if .Values.services.runner.enabled }} + {{/* The API verifies the runner's HMAC signature, so it needs the same key + the runner signs with. Delivered here for the same reason silo-secrets + is above: the verifying side lives in this workload family. */}} + - secretRef: + name: {{ if not (empty .Values.external_secrets.runner_env_existingSecret) }}{{ .Values.external_secrets.runner_env_existingSecret }}{{ else }}{{ .Release.Name }}-runner-secrets{{ end }} + optional: false + {{- end }} {{- if or .Values.extraEnv (include "plane.infraCredsEnv" .) }} env: {{- with (include "plane.infraCredsEnv" .) }} diff --git a/charts/plane-enterprise/templates/workloads/external-api.deployment.yaml b/charts/plane-enterprise/templates/workloads/external-api.deployment.yaml index e93ec9c4..a9623155 100644 --- a/charts/plane-enterprise/templates/workloads/external-api.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/external-api.deployment.yaml @@ -97,6 +97,14 @@ spec: name: {{ if not (empty .Values.external_secrets.silo_env_existingSecret) }}{{ .Values.external_secrets.silo_env_existingSecret }}{{ else }}{{ .Release.Name }}-silo-secrets{{ end }} optional: false {{- end }} + {{- if .Values.services.runner.enabled }} + {{/* The API verifies the runner's HMAC signature, so it needs the same key + the runner signs with. Delivered here for the same reason silo-secrets + is above: the verifying side lives in this workload family. */}} + - secretRef: + name: {{ if not (empty .Values.external_secrets.runner_env_existingSecret) }}{{ .Values.external_secrets.runner_env_existingSecret }}{{ else }}{{ .Release.Name }}-runner-secrets{{ end }} + optional: false + {{- end }} {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.infraCredsEnv" .) }} env: diff --git a/charts/plane-enterprise/templates/workloads/live.deployment.yaml b/charts/plane-enterprise/templates/workloads/live.deployment.yaml index 91caea2c..768c12d1 100644 --- a/charts/plane-enterprise/templates/workloads/live.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/live.deployment.yaml @@ -99,10 +99,13 @@ spec: - secretRef: name: {{ if not (empty .Values.external_secrets.live_env_existingSecret) }}{{ .Values.external_secrets.live_env_existingSecret }}{{ else }}{{ .Release.Name }}-live-secrets{{ end }} optional: false - {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) }} + {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.redisCredsEnv" .) }} env: {{- with (include "plane.s3CANodeEnvVars" .) }} {{ . | indent 10 }} + {{- end }} + {{- with (include "plane.redisCredsEnv" .) }} +{{ trim . | indent 10 }} {{- end }} {{- if .Values.extraEnv }} {{- toYaml .Values.extraEnv | nindent 10 }} diff --git a/charts/plane-enterprise/templates/workloads/migrator.job.yaml b/charts/plane-enterprise/templates/workloads/migrator.job.yaml index 5d9407e6..4e5d3638 100644 --- a/charts/plane-enterprise/templates/workloads/migrator.job.yaml +++ b/charts/plane-enterprise/templates/workloads/migrator.job.yaml @@ -44,6 +44,14 @@ spec: name: {{ if not (empty .Values.external_secrets.silo_env_existingSecret) }}{{ .Values.external_secrets.silo_env_existingSecret }}{{ else }}{{ .Release.Name }}-silo-secrets{{ end }} optional: false {{- end }} + {{- if .Values.services.runner.enabled }} + {{/* The API verifies the runner's HMAC signature, so it needs the same key + the runner signs with. Delivered here for the same reason silo-secrets + is above: the verifying side lives in this workload family. */}} + - secretRef: + name: {{ if not (empty .Values.external_secrets.runner_env_existingSecret) }}{{ .Values.external_secrets.runner_env_existingSecret }}{{ else }}{{ .Release.Name }}-runner-secrets{{ end }} + optional: false + {{- end }} {{- if or .Values.extraEnv (include "plane.infraCredsEnv" .) }} env: {{- with (include "plane.infraCredsEnv" .) }} diff --git a/charts/plane-enterprise/templates/workloads/pi-api.deployment.yaml b/charts/plane-enterprise/templates/workloads/pi-api.deployment.yaml index 0efd03fc..68d56e03 100644 --- a/charts/plane-enterprise/templates/workloads/pi-api.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/pi-api.deployment.yaml @@ -91,12 +91,12 @@ spec: - secretRef: name: {{ if not (empty .Values.external_secrets.opensearch_existingSecret) }}{{ .Values.external_secrets.opensearch_existingSecret }}{{ else }}{{ .Release.Name }}-opensearch-secrets{{ end }} optional: false - {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.opensearchCredsEnv" .) }} + {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.piInfraCredsEnv" .) }} env: {{- with (include "plane.s3CAEnvVars" .) }} {{ . | indent 10 }} {{- end }} - {{- with (include "plane.opensearchCredsEnv" .) }} + {{- with (include "plane.piInfraCredsEnv" .) }} {{ trim . | indent 10 }} {{- end }} {{- if .Values.extraEnv }} diff --git a/charts/plane-enterprise/templates/workloads/pi-beat.deployment.yaml b/charts/plane-enterprise/templates/workloads/pi-beat.deployment.yaml index 3acdddd1..e6ee62d8 100644 --- a/charts/plane-enterprise/templates/workloads/pi-beat.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/pi-beat.deployment.yaml @@ -66,12 +66,12 @@ spec: - secretRef: name: {{ if not (empty .Values.external_secrets.opensearch_existingSecret) }}{{ .Values.external_secrets.opensearch_existingSecret }}{{ else }}{{ .Release.Name }}-opensearch-secrets{{ end }} optional: false - {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.opensearchCredsEnv" .) }} + {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.piInfraCredsEnv" .) }} env: {{- with (include "plane.s3CAEnvVars" .) }} {{ . | indent 10 }} {{- end }} - {{- with (include "plane.opensearchCredsEnv" .) }} + {{- with (include "plane.piInfraCredsEnv" .) }} {{ trim . | indent 10 }} {{- end }} {{- if .Values.extraEnv }} diff --git a/charts/plane-enterprise/templates/workloads/pi-migrator.job.yaml b/charts/plane-enterprise/templates/workloads/pi-migrator.job.yaml index 64b007aa..d20bf46c 100644 --- a/charts/plane-enterprise/templates/workloads/pi-migrator.job.yaml +++ b/charts/plane-enterprise/templates/workloads/pi-migrator.job.yaml @@ -55,12 +55,12 @@ spec: - secretRef: name: {{ if not (empty .Values.external_secrets.opensearch_existingSecret) }}{{ .Values.external_secrets.opensearch_existingSecret }}{{ else }}{{ .Release.Name }}-opensearch-secrets{{ end }} optional: false - {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.opensearchCredsEnv" .) }} + {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.piInfraCredsEnv" .) }} env: {{- with (include "plane.s3CAEnvVars" .) }} {{ . | indent 10 }} {{- end }} - {{- with (include "plane.opensearchCredsEnv" .) }} + {{- with (include "plane.piInfraCredsEnv" .) }} {{ trim . | indent 10 }} {{- end }} {{- if .Values.extraEnv }} diff --git a/charts/plane-enterprise/templates/workloads/pi-worker.deployment.yaml b/charts/plane-enterprise/templates/workloads/pi-worker.deployment.yaml index 9eae2687..c59f9b17 100644 --- a/charts/plane-enterprise/templates/workloads/pi-worker.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/pi-worker.deployment.yaml @@ -67,12 +67,12 @@ spec: - secretRef: name: {{ if not (empty .Values.external_secrets.opensearch_existingSecret) }}{{ .Values.external_secrets.opensearch_existingSecret }}{{ else }}{{ .Release.Name }}-opensearch-secrets{{ end }} optional: false - {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.opensearchCredsEnv" .) }} + {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.piInfraCredsEnv" .) }} env: {{- with (include "plane.s3CAEnvVars" .) }} {{ . | indent 10 }} {{- end }} - {{- with (include "plane.opensearchCredsEnv" .) }} + {{- with (include "plane.piInfraCredsEnv" .) }} {{ trim . | indent 10 }} {{- end }} {{- if .Values.extraEnv }} diff --git a/charts/plane-enterprise/templates/workloads/runner.deployment.yaml b/charts/plane-enterprise/templates/workloads/runner.deployment.yaml index 56250f59..2e9c7ad1 100644 --- a/charts/plane-enterprise/templates/workloads/runner.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/runner.deployment.yaml @@ -68,6 +68,7 @@ spec: successThreshold: 1 timeoutSeconds: 1 envFrom: + {{- include "plane.appKeysSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-runner-vars optional: false diff --git a/charts/plane-enterprise/templates/workloads/silo.deployment.yaml b/charts/plane-enterprise/templates/workloads/silo.deployment.yaml index 3612bb05..cdea5153 100644 --- a/charts/plane-enterprise/templates/workloads/silo.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/silo.deployment.yaml @@ -125,10 +125,13 @@ spec: - secretRef: name: {{ if not (empty .Values.external_secrets.doc_store_existingSecret) }}{{ .Values.external_secrets.doc_store_existingSecret }}{{ else }}{{ .Release.Name }}-doc-store-secrets{{ end }} optional: false - {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) }} + {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.siloInfraCredsEnv" .) }} env: {{- with (include "plane.s3CANodeEnvVars" .) }} {{ . | indent 10 }} + {{- end }} + {{- with (include "plane.siloInfraCredsEnv" .) }} +{{ trim . | indent 10 }} {{- end }} {{- if .Values.extraEnv }} {{- toYaml .Values.extraEnv | nindent 10 }} diff --git a/charts/plane-enterprise/templates/workloads/webhook-consumer.deployment.yaml b/charts/plane-enterprise/templates/workloads/webhook-consumer.deployment.yaml index b619242f..1f2e6d04 100644 --- a/charts/plane-enterprise/templates/workloads/webhook-consumer.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/webhook-consumer.deployment.yaml @@ -72,6 +72,14 @@ spec: name: {{ if not (empty .Values.external_secrets.silo_env_existingSecret) }}{{ .Values.external_secrets.silo_env_existingSecret }}{{ else }}{{ .Release.Name }}-silo-secrets{{ end }} optional: false {{- end }} + {{- if .Values.services.runner.enabled }} + {{/* The API verifies the runner's HMAC signature, so it needs the same key + the runner signs with. Delivered here for the same reason silo-secrets + is above: the verifying side lives in this workload family. */}} + - secretRef: + name: {{ if not (empty .Values.external_secrets.runner_env_existingSecret) }}{{ .Values.external_secrets.runner_env_existingSecret }}{{ else }}{{ .Release.Name }}-runner-secrets{{ end }} + optional: false + {{- end }} {{- if or .Values.extraEnv (include "plane.infraCredsEnv" .) }} env: {{- with (include "plane.infraCredsEnv" .) }} diff --git a/charts/plane-enterprise/templates/workloads/worker-importers.deployment.yaml b/charts/plane-enterprise/templates/workloads/worker-importers.deployment.yaml index 2c5e31bc..a6f831fe 100644 --- a/charts/plane-enterprise/templates/workloads/worker-importers.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/worker-importers.deployment.yaml @@ -73,6 +73,14 @@ spec: name: {{ if not (empty .Values.external_secrets.silo_env_existingSecret) }}{{ .Values.external_secrets.silo_env_existingSecret }}{{ else }}{{ .Release.Name }}-silo-secrets{{ end }} optional: false {{- end }} + {{- if .Values.services.runner.enabled }} + {{/* The API verifies the runner's HMAC signature, so it needs the same key + the runner signs with. Delivered here for the same reason silo-secrets + is above: the verifying side lives in this workload family. */}} + - secretRef: + name: {{ if not (empty .Values.external_secrets.runner_env_existingSecret) }}{{ .Values.external_secrets.runner_env_existingSecret }}{{ else }}{{ .Release.Name }}-runner-secrets{{ end }} + optional: false + {{- end }} {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.infraCredsEnv" .) }} env: diff --git a/charts/plane-enterprise/templates/workloads/worker.deployment.yaml b/charts/plane-enterprise/templates/workloads/worker.deployment.yaml index ff49195d..b8a5eb35 100644 --- a/charts/plane-enterprise/templates/workloads/worker.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/worker.deployment.yaml @@ -72,6 +72,14 @@ spec: name: {{ if not (empty .Values.external_secrets.silo_env_existingSecret) }}{{ .Values.external_secrets.silo_env_existingSecret }}{{ else }}{{ .Release.Name }}-silo-secrets{{ end }} optional: false {{- end }} + {{- if .Values.services.runner.enabled }} + {{/* The API verifies the runner's HMAC signature, so it needs the same key + the runner signs with. Delivered here for the same reason silo-secrets + is above: the verifying side lives in this workload family. */}} + - secretRef: + name: {{ if not (empty .Values.external_secrets.runner_env_existingSecret) }}{{ .Values.external_secrets.runner_env_existingSecret }}{{ else }}{{ .Release.Name }}-runner-secrets{{ end }} + optional: false + {{- end }} {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.infraCredsEnv" .) }} env: diff --git a/charts/plane-enterprise/values.yaml b/charts/plane-enterprise/values.yaml index 73cb2fc9..a9c0eaf1 100644 --- a/charts/plane-enterprise/values.yaml +++ b/charts/plane-enterprise/values.yaml @@ -624,15 +624,33 @@ external_secrets: usernameKey: username passwordKey: password - # Shared signing/encryption keys (SECRET_KEY, AES_SECRET_KEY, AES_SALT, - # LIVE_SERVER_SECRET_KEY, PI_INTERNAL_SECRET, SILO_HMAC_SECRET_KEY, - # CURSOR_WEBHOOK_SECRET). One Secret for every service that needs them, so a - # rotation can never leave two services disagreeing. + # Shared signing/encryption keys. One Secret for every service that needs them, so + # a rotation can never leave two services disagreeing. While this is set, the chart + # stops emitting these keys in its own Secrets, making yours the only source. + # + # The Secret MUST contain every key your deployment uses — a missing key is not a + # render error, it is an absent env var: + # SECRET_KEY REQUIRED. The API falls back to a per-pod random value + # when absent, so JWTs stop verifying across replicas and + # encrypted instance-configuration rows become unreadable. + # AES_SECRET_KEY REQUIRED. api/silo/pi all derive from it. + # AES_SALT Optional; all three services default to "aes-salt". + # Set it only if you set it from the start. + # LIVE_SERVER_SECRET_KEY Required when live is deployed (api <-> live <-> pi). + # PI_INTERNAL_SECRET Required when Plane AI is deployed. + # SILO_HMAC_SECRET_KEY Required when silo is deployed (silo signs, API verifies). + # CURSOR_WEBHOOK_SECRET Required for silo's Cursor agent integration. + # RUNNER_HMAC_SECRET_KEY Required when the runner is deployed. # # WARNING — SECRET_KEY, AES_SECRET_KEY and AES_SALT are key-encryption keys: # SECRET_KEY protects the encrypted instance-configuration rows and the AES pair # protects stored OAuth/MCP tokens. Changing them makes existing ciphertext - # undecryptable. Store them in a secret that is NOT on a rotation schedule. + # undecryptable, and it fails silently. Store them in a secret that is NOT on a + # rotation schedule. + # + # Do NOT also define these keys in one of the *_existingSecret groups below. Those + # are mounted after this Secret, so a duplicate wins on some workloads and not + # others, leaving two services with different values of a key that must match. app_keys_existingSecret: '' # DNS-01 API token for the cert-manager Issuer (ssl.issuer cloudflare/digitalocean). @@ -804,6 +822,11 @@ env: # aes_secret_key encrypts stored connector tokens: NEVER ROTATE on a live instance. hmac_secret_key: 'gzb7MRLr0FoN129NyWARZEs84P9LzQ' aes_secret_key: 'dsOdt7YrvxsTIFJ37pOaEVvLxN8KGBCr' + # Salt paired with aes_secret_key. Shared by api, silo and pi, which all default + # to "aes-salt" internally — leave empty to keep that default. Setting it on an + # instance that already has encrypted data makes that data undecryptable, exactly + # like changing aes_secret_key. NEVER ROTATE. + aes_salt: '' cors_allowed_origins: '' jira_server_issues_page_size: 50 jira_server_issues_parallel_pages: 1 From bd99749b59f08adb6207087d842b0025f8218561 Mon Sep 17 00:00:00 2001 From: pratapalakshmi <137189067+pratapalakshmi@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:39:13 +0530 Subject: [PATCH 2/2] feat(plane-enterprise): hooks for shared secrets, storage and the read replica (3.5.0) (#284) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(plane-enterprise): dedicated hooks for AI provider keys and silo connectors Both of these could previously only be externalized through a whole-Secret hook, which made the one thing people actually need impossible. AI/LLM provider keys lived in the Plane AI Secret alongside PLANE_PI_DATABASE_URL, FOLLOWER_POSTGRES_URI, AMQP_URL, CELERY_BROKER_URL and the signing keys. Adopting pi_api_env_existingSecret therefore meant owning all of those too — and they are per-environment, while a provider account is not. So "one provider key shared by every environment" had no expression. ai_providers_existingSecret carries nothing environment-specific, so one Secret can be mirrored into as many namespaces as share the account, while a customer can point the same values key at a per-environment Secret. The chart only takes a name; the granularity is the secret store's business. The suppression had to cover the empty-string branches, not just the populated ones. The chart rendered COHERE_API_KEY: "" and CUSTOM_LLM_API_KEY: "" whenever those providers were disabled, and envFrom resolves later-source-wins — so a shared Secret mounted earlier would have been silently overwritten with empty strings. There is now an assertion for exactly this, and it fails on the pre-change render. Silo connector credentials had the same shape of problem: the silo Secret also carries DATABASE_URL, AMQP_URL and REDIS_URL. silo_connectors_existingSecret takes the whole connector surface, client ids and app names included, so it has a single owner rather than a half-external state. It is mounted on all nine workloads that mount silo-secrets, not just silo: the Django auth adapter reads GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET from that Secret on the api family, so scoping it to silo alone would have dropped those variables there. There is an assertion for that too. Also adds hack/resolve-env.py and hack/assert-secrets.py. The first resolves what each container's environment actually becomes — envFrom in list order, then explicit env on top — because a chart diff cannot show whether a key that moved between Secrets is still reachable. The second carries the assertions this work turns on, including a no-DSN check: a connection string with an embedded password is a rotation dead-end, since nothing can track a rotated password baked into a string. Verified byte-identical resolved environments across all workloads when no new hook is set. Co-Authored-By: Claude Opus 5 (1M context) * feat(plane-enterprise): read-replica credential mirror, and omit an unset AWS region services.postgres.read_replica.remote_url was the last password-bearing connection string in this chart with no discrete-parts alternative, so a managed rotation could never reach the replica. external_secrets.database.readReplica now supplies POSTGRES_READ_REPLICA_{USER,PASSWORD} from a mirrored Secret with the endpoint in values, and DATABASE_READ_REPLICA_URL is blanked in that mode so a stale composed URL cannot shadow the rotated credential. No application change was needed: Django takes the replica's parts straight into its DATABASES dict, so nothing composes a URL at all. secretName defaults to the primary's, since a replica normally accepts the same credential. The fail guard that demanded remote_url now accepts the mirror as a source too, and says what the alternatives are rather than naming only one of them — otherwise enabling a replica keylessly was impossible by construction. AWS_REGION and AWS_S3_ENDPOINT_URL are now omitted when unset rather than rendered empty. The keys next to them were already treated this way because an empty AWS_ACCESS_KEY_ID derails boto3's credential chain; the region is subtler. When a pod identity is attached and OpenSearch has no basic auth, the API signs OpenSearch requests with SigV4 and reads the region as `os.environ.get("AWS_REGION") or default` — an empty string is present, so the default never applies, and every signed request goes out with an empty region and is rejected. Absent is the only safe encoding of "unset". The companion app change makes the same value safe on older charts. Verified with hack/: the resolved environment of every container is byte-identical when no new hook is set; a fully externalized render (database, rabbitmq, redis, replica, external object storage) now contains zero connection strings with embedded passwords; and in the keyless case AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_REGION are absent from every container rather than empty. Co-Authored-By: Claude Opus 5 (1M context) * feat(plane-enterprise): storage credential mirror and a cloud-identity marker (3.5.0) external_secrets.storage covers the object-storage credentials that a pod identity cannot replace: an S3-compatible backend with no workload identity, or GCS with a service-account key. That key is the highest-value blob in a GCS deployment — the whole service-account JSON, private key included — and it had no hook at all, so the only way to keep it out of values.yaml was to take over the entire doc-store Secret and own USE_MINIO, the bucket name and the size limit along with it. The mirror is wired through the three composite credential helpers rather than per-workload, since those already reach every workload that mounts the doc-store Secret. It is inert while the bundled MinIO is deployed, which supplies its own credentials. serviceAccount.cloudIdentity declares that an identity is attached out of band. It changes no rendered output, and it cannot be inferred: an EKS Pod Identity association is an API object keyed on cluster, namespace and service account, and nothing about it shows up in the pod spec. What it buys is three warnings for the interactions an attached identity creates — a static env.aws_access_key silently beating the identity in the SDK chain, the same for Plane AI's Bedrock keys, and the one that is genuinely surprising: a remote OpenSearch with no credentials will be authenticated with SigV4, because both the API and Plane AI read "AWS credentials are present" as "sign requests" — and an identity puts credentials in every container on the ServiceAccount. Correct for Amazon OpenSearch Service, wrong for any other. The warning names both fixes. Verified with hack/: the resolved environment of every container is byte-identical when no new hook is set; ten configurations render valid YAML including all hooks at once and the legacy groups; and the acceptance case — every hook in use, requireExplicitSecrets on, all signing keys blanked — renders with no credential pattern, no connection string carrying a password, and AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_REGION absent from every container. What remains in the chart's own Secrets is empty URL placeholders and non-secret configuration. Co-Authored-By: Claude Opus 5 (1M context) * feat(plane-enterprise): expose the 3.3.0–3.5.0 secret hooks in questions.yml None of the credential mirrors, shared key groups or ServiceAccount fields were reachable from the Rancher UI, which is how most operators configure this chart — a hook nobody can find is a hook nobody uses. Three new groups. Credential Mirrors covers external_secrets.{database,rabbitmq,redis, storage,opensearch}, kept apart from the existing whole-Secret replacements because they answer a different question: a mirror is read as parts and never composed into a DSN, so a managed rotation lands with nothing to recompute. Shared Secrets covers the four key groups. Service Account covers create/name/annotations/cloudIdentity plus env.requireExplicitSecrets. The descriptions carry the operational traps rather than restating the field names: Amazon MQ needs rabbitmq_ssl and port 5671 because the parts path has no URL scheme to carry TLS; ElastiCache puts its auth token in the password key; the signing keys must never be shared or rotated; EKS Pod Identity needs no annotation because it binds by ServiceAccount name. --------- Co-authored-by: Claude Opus 5 (1M context) --- charts/plane-enterprise/Chart.yaml | 2 +- charts/plane-enterprise/questions.yml | 135 ++++++++++++++ charts/plane-enterprise/templates/NOTES.txt | 29 ++- .../plane-enterprise/templates/_helpers.tpl | 131 ++++++++++++++ .../templates/config-secrets/app-env.yaml | 18 +- .../templates/config-secrets/doc-store.yaml | 16 +- .../templates/config-secrets/pi-api-env.yaml | 6 + .../templates/config-secrets/silo.yaml | 4 + .../workloads/agent-consumer.deployment.yaml | 1 + .../templates/workloads/api.deployment.yaml | 1 + .../workloads/beat-worker.deployment.yaml | 1 + .../workloads/external-api.deployment.yaml | 1 + .../templates/workloads/live.deployment.yaml | 1 + .../templates/workloads/migrator.job.yaml | 1 + .../workloads/pi-api.deployment.yaml | 1 + .../workloads/pi-beat.deployment.yaml | 1 + .../templates/workloads/pi-migrator.job.yaml | 1 + .../workloads/pi-worker.deployment.yaml | 1 + .../templates/workloads/silo.deployment.yaml | 1 + .../webhook-consumer.deployment.yaml | 1 + .../worker-importers.deployment.yaml | 1 + .../workloads/worker.deployment.yaml | 1 + charts/plane-enterprise/values.yaml | 96 ++++++++++ hack/assert-secrets.py | 165 ++++++++++++++++++ hack/resolve-env.py | 146 ++++++++++++++++ 25 files changed, 755 insertions(+), 7 deletions(-) create mode 100755 hack/assert-secrets.py create mode 100755 hack/resolve-env.py diff --git a/charts/plane-enterprise/Chart.yaml b/charts/plane-enterprise/Chart.yaml index ee450fb7..76880543 100644 --- a/charts/plane-enterprise/Chart.yaml +++ b/charts/plane-enterprise/Chart.yaml @@ -5,7 +5,7 @@ description: Meet Plane. An Enterprise software development tool to manage issue type: application -version: 3.4.0 +version: 3.5.0 appVersion: "3.1.0" home: https://plane.so/ diff --git a/charts/plane-enterprise/questions.yml b/charts/plane-enterprise/questions.yml index e7a6bafd..59a46ae2 100644 --- a/charts/plane-enterprise/questions.yml +++ b/charts/plane-enterprise/questions.yml @@ -1793,3 +1793,138 @@ questions: type: string default: "" group: "External Secrets" + +# --- Credential mirrors ------------------------------------------------------- +# A Secret that MIRRORS the managed one your cloud already rotates — the RDS secret +# created with the instance, an ElastiCache auth-token secret, an Amazon MQ secret. +# The chart reads the parts out of it and never composes a connection string, so a +# rotation lands with nothing to recompute. That is why these are separate from the +# whole-Secret replacements above, which pin a DSN the rotation can never reach. +- variable: external_secrets.database.secretName + label: "Postgres Credential Mirror Secret" + description: "A Secret carrying the Postgres username and password as separate keys — point it at a verbatim mirror of your RDS/Cloud SQL secret so a rotation needs no second copy." + type: string + default: "" + group: "Credential Mirrors" +- variable: external_secrets.database.usernameKey + label: "Postgres Username Key" + type: string + default: "username" + group: "Credential Mirrors" + show_if: "external_secrets.database.secretName!=" +- variable: external_secrets.database.passwordKey + label: "Postgres Password Key" + type: string + default: "password" + group: "Credential Mirrors" + show_if: "external_secrets.database.secretName!=" +- variable: external_secrets.database.hostKey + label: "Postgres Host Key (optional)" + description: "Only when the mirrored Secret carries the endpoint too. Otherwise the host comes from env.pgdb_remote_host." + type: string + default: "" + group: "Credential Mirrors" + show_if: "external_secrets.database.secretName!=" +- variable: external_secrets.rabbitmq.secretName + label: "RabbitMQ Credential Mirror Secret" + description: "For Amazon MQ also set env.rabbitmq_ssl=true and env.rabbitmq_port=5671 — the parts path has no URL scheme to carry TLS, and Amazon MQ refuses plaintext." + type: string + default: "" + group: "Credential Mirrors" +- variable: external_secrets.redis.secretName + label: "Redis Credential Mirror Secret" + description: "For ElastiCache the auth token goes in the password key; also set env.redis_ssl=true." + type: string + default: "" + group: "Credential Mirrors" +- variable: external_secrets.storage.secretName + label: "Object Storage Credential Secret" + description: "Leave empty and annotate the ServiceAccount instead — a pod identity beats a stored access key. Only the key names you fill in below are emitted." + type: string + default: "" + group: "Credential Mirrors" +- variable: external_secrets.storage.accessKeyIdKey + label: "Storage Access Key ID Key" + type: string + default: "" + group: "Credential Mirrors" + show_if: "external_secrets.storage.secretName!=" +- variable: external_secrets.storage.secretAccessKeyKey + label: "Storage Secret Access Key Key" + type: string + default: "" + group: "Credential Mirrors" + show_if: "external_secrets.storage.secretName!=" +- variable: external_secrets.storage.gcsCredentialsJsonKey + label: "GCS Credentials JSON Key" + type: string + default: "" + group: "Credential Mirrors" + show_if: "external_secrets.storage.secretName!=" +- variable: external_secrets.opensearch.secretName + label: "OpenSearch Credential Mirror Secret" + description: "For an AWS-managed domain, leave this empty and let the pods authenticate with SigV4 through their cloud identity." + type: string + default: "" + group: "Credential Mirrors" + +# --- Shared key groups -------------------------------------------------------- +# One Secret carrying one group of keys, mirrored into as many namespaces as you like. +# The chart stops rendering those keys, which is what makes the sharing work: an empty +# string under envFrom does not defer to another Secret, it overwrites it. +- variable: external_secrets.app_keys_existingSecret + label: "Signing Keys Secret" + description: "SECRET_KEY, AES_SECRET_KEY, AES_SALT, LIVE_SERVER_SECRET_KEY, SILO_HMAC_SECRET_KEY, RUNNER_HMAC_SECRET_KEY. These encrypt stored data: never share them between environments and never rotate them." + type: string + default: "" + group: "Shared Secrets" +- variable: external_secrets.ai_providers_existingSecret + label: "AI Provider Keys Secret" + description: "OPENAI / CLAUDE / GEMINI / COHERE / CUSTOM_LLM keys. One AI account normally serves every deployment, so this is the group worth sharing." + type: string + default: "" + group: "Shared Secrets" +- variable: external_secrets.silo_connectors_existingSecret + label: "Silo Connector Secrets Secret" + description: "Connector OAuth client secrets and the GitHub App private key. An OAuth app is registered once, so its secret is the same wherever the connector is enabled." + type: string + default: "" + group: "Shared Secrets" +- variable: external_secrets.ssl_token_existingSecret + label: "DNS-01 Token Secret" + description: "cert-manager DNS provider API token, under the key api-token." + type: string + default: "" + group: "Shared Secrets" + +# --- Cloud identity ----------------------------------------------------------- +- variable: serviceAccount.create + label: "Create the ServiceAccount" + description: "Turn off to run as a ServiceAccount managed outside this chart — one created by Terraform or Crossplane and already bound to a cloud role." + type: boolean + default: true + group: "Service Account" +- variable: serviceAccount.name + label: "ServiceAccount Name" + description: "Defaults to -srv-account." + type: string + default: "" + group: "Service Account" +- variable: serviceAccount.annotations + label: "ServiceAccount Annotations" + description: "Where a workload identity binding goes: eks.amazonaws.com/role-arn for IRSA, iam.gke.io/gcp-service-account for GKE, azure.workload.identity/client-id for Azure. EKS Pod Identity needs no annotation — it binds by name." + type: string + default: "" + group: "Service Account" +- variable: serviceAccount.cloudIdentity + label: "ServiceAccount Has a Cloud Identity" + description: "Advisory. A Pod Identity association is an EKS API object invisible to the pod spec, so the chart cannot detect it; setting this turns on the install notes that catch static credentials shadowing the identity." + type: boolean + default: false + group: "Service Account" +- variable: env.requireExplicitSecrets + label: "Refuse to Render Default Signing Keys" + description: "Fails the install instead of falling back to the published defaults for SECRET_KEY and friends. Worth turning on once the keys come from a Secret." + type: boolean + default: false + group: "Service Account" diff --git a/charts/plane-enterprise/templates/NOTES.txt b/charts/plane-enterprise/templates/NOTES.txt index 83dd2d9b..6e667333 100644 --- a/charts/plane-enterprise/templates/NOTES.txt +++ b/charts/plane-enterprise/templates/NOTES.txt @@ -89,10 +89,37 @@ different values of a key that has to match. {{- end }} {{- /* Static object-storage keys where a cloud identity would do. */}} -{{- if and .Values.env.aws_access_key (not (include "plane.minioEnabled" .)) (not .Values.serviceAccount.annotations) }} +{{- if and .Values.env.aws_access_key (not (include "plane.minioEnabled" .)) (not .Values.serviceAccount.annotations) (not .Values.serviceAccount.cloudIdentity) }} {{- $warnings = append $warnings "Object storage is configured with a static AWS access key. Prefer a cloud identity:\n annotate the ServiceAccount (serviceAccount.annotations) for IRSA / EKS Pod Identity\n / GKE or Azure Workload Identity and clear env.aws_access_key and\n env.aws_secret_access_key — the chart then omits those env vars so the SDK's default\n credential chain picks up the pod's role." }} {{- end }} +{{- /* +An attached cloud identity reaches every container on the ServiceAccount, so it changes +behaviour beyond object storage. The chart cannot detect the association — it is an EKS +API object keyed on cluster + namespace + service account — hence the declared flag. +*/}} +{{- $hasIdentity := or .Values.serviceAccount.cloudIdentity .Values.serviceAccount.annotations }} +{{- if $hasIdentity }} + +{{- /* A static key beats the identity in the SDK credential chain. */}} +{{- if and .Values.env.aws_access_key (not (include "plane.minioEnabled" .)) }} +{{- $warnings = append $warnings "A cloud identity is configured, but env.aws_access_key is also set. Environment\n credentials take precedence over the identity in every AWS SDK's chain, so the\n static key is what will be used. Clear env.aws_access_key and\n env.aws_secret_access_key to let the identity apply." }} +{{- end }} + +{{- if .Values.services.pi.ai_providers.embedding_model.aws_access_key }} +{{- $warnings = append $warnings "A cloud identity is configured, but\n services.pi.ai_providers.embedding_model.aws_access_key is also set. Plane AI falls\n back to the identity when the BR_AWS_* keys are absent, so clear them to use it." }} +{{- end }} + +{{- /* +The one that is genuinely surprising: an identity attached for S3 silently changes how +OpenSearch is authenticated, because both services read "AWS credentials are present" +as "sign with SigV4". +*/}} +{{- if and .Values.env.opensearch_remote_url (not (include "plane.externalOpensearch" .)) (not .Values.env.opensearch_remote_username) }} +{{- $warnings = append $warnings "A cloud identity is configured and the remote OpenSearch has no credentials, so the\n API and Plane AI will authenticate to it with SigV4 — an identity injects AWS\n credentials into every container, and both read that as a request to sign.\n That is correct for Amazon OpenSearch Service and wrong for any other OpenSearch.\n Either set external_secrets.opensearch.secretName, or set OPENSEARCH_AUTH_MODE=basic\n through extraEnv (needs planeVersion v3.2.0 or newer)." }} +{{- end }} +{{- end }} + {{- if $warnings }} ──────────────────────────────────────────────────────────────────────────────── diff --git a/charts/plane-enterprise/templates/_helpers.tpl b/charts/plane-enterprise/templates/_helpers.tpl index e1801eb8..9330447a 100644 --- a/charts/plane-enterprise/templates/_helpers.tpl +++ b/charts/plane-enterprise/templates/_helpers.tpl @@ -404,6 +404,47 @@ Indentation is baked in for the container envFrom position, so call it bare: {{- end }} {{- end -}} +{{/* +envFrom entry for the Secret carrying the AI/LLM provider keys. Renders nothing +unless external_secrets.ai_providers_existingSecret is set. + +Separate from the Plane AI Secret because provider accounts are shared across +environments while everything else in that Secret is per-environment. Mounted on the +Plane AI workloads and on live (whose AI_OPENAI_API_KEY has no values key at all). + +Placed before the chart's own Secret so an operator who has already externalized +pi_api_env keeps that precedence. While this is set the chart emits none of these +keys itself — including the empty-string branches, which would otherwise overwrite +this Secret's values, since envFrom resolves later-source-wins. + +Indentation is baked in for the container envFrom position, so call it bare. +*/}} +{{- define "plane.aiProvidersSecretRef" -}} +{{- with .Values.external_secrets.ai_providers_existingSecret }} + - secretRef: + name: {{ . }} + optional: false +{{- end }} +{{- end -}} + +{{/* +envFrom entry for the Secret carrying the silo connector credentials. Renders nothing +unless external_secrets.silo_connectors_existingSecret is set. + +Mounted on every workload that mounts silo-secrets today, not just silo: the Django +auth adapter reads GITHUB_CLIENT_ID/GITHUB_CLIENT_SECRET from that Secret on the api +family, so mounting this only on silo would drop those variables there. + +Indentation is baked in for the container envFrom position, so call it bare. +*/}} +{{- define "plane.siloConnectorsSecretRef" -}} +{{- with .Values.external_secrets.silo_connectors_existingSecret }} + - secretRef: + name: {{ . }} + optional: false +{{- end }} +{{- end -}} + {{/* Returns "true" when an externally managed Secret supplies the Postgres credentials, in which case the chart must not render a composed DATABASE_URL that would take @@ -503,6 +544,7 @@ Caller must indent to the correct depth (env list items). {{- include "plane.rabbitmqCredsEnv" . }} {{- include "plane.redisCredsEnv" . }} {{- include "plane.opensearchCredsEnv" . }} +{{- include "plane.storageCredsEnv" . }} {{- end -}} {{/* @@ -531,6 +573,93 @@ the live server needs Redis and nothing else. {{- include "plane.secretKeyEnv" (dict "name" "POSTGRES_DB" "secret" $db.secretName "key" .) }} {{- end }} {{- end }} +{{- include "plane.postgresReadReplicaCredsEnv" . }} +{{- end -}} + +{{/* +Returns "true" when object-storage credentials come from an externally managed Secret. +Never true while the bundled MinIO is deployed — that supplies its own credentials, and +overriding them would break the in-cluster client. +*/}} +{{- define "plane.externalStorage" -}} +{{- if and .Values.external_secrets.storage.secretName (not (include "plane.minioEnabled" .)) -}} +true +{{- end -}} +{{- end -}} + +{{/* +Object-storage credentials as explicit env entries, so they win over the doc-store +Secret mounted via envFrom. + +Only the keys the operator names are emitted: an S3 deployment sets the two access-key +keys, a GCS deployment sets gcsCredentialsJsonKey, and a deployment using a pod identity +sets none of them and relies on the SDK credential chain. + +Caller must indent to the correct depth (env list items). +*/}} +{{- define "plane.storageCredsEnv" -}} +{{- $st := .Values.external_secrets.storage -}} +{{- if include "plane.externalStorage" . }} +{{- with $st.accessKeyIdKey }} +{{- include "plane.secretKeyEnv" (dict "name" "AWS_ACCESS_KEY_ID" "secret" $st.secretName "key" .) }} +{{- end }} +{{- with $st.secretAccessKeyKey }} +{{- include "plane.secretKeyEnv" (dict "name" "AWS_SECRET_ACCESS_KEY" "secret" $st.secretName "key" .) }} +{{- end }} +{{- with $st.gcsCredentialsJsonKey }} +{{- include "plane.secretKeyEnv" (dict "name" "GCS_CREDENTIALS_JSON" "secret" $st.secretName "key" .) }} +{{- end }} +{{- end }} +{{- end -}} + +{{/* +Returns "true" when the read replica's credentials come from an externally managed +Secret. Falls back to the primary's Secret, since a replica normally accepts the same +credential — set readReplica.secretName only when it has its own user. +*/}} +{{- define "plane.externalReadReplica" -}} +{{- if .Values.services.postgres.read_replica.enabled -}} +{{- if or .Values.external_secrets.database.readReplica.secretName .Values.external_secrets.database.secretName -}} +true +{{- end -}} +{{- end -}} +{{- end -}} + +{{/* +Read-replica credentials as discrete parts. + +services.postgres.read_replica.remote_url is a DSN carrying the password, so a managed +rotation can never update it. The API reads POSTGRES_READ_REPLICA_* natively — Django +takes the parts straight into a config dict, so nothing composes a URL — which makes +this a chart-only change. + +Caller must indent to the correct depth (env list items). +*/}} +{{- define "plane.postgresReadReplicaCredsEnv" -}} +{{- $db := .Values.external_secrets.database -}} +{{- $rr := $db.readReplica -}} +{{- $secret := $rr.secretName | default $db.secretName -}} +{{/* The newline after this `if` is deliberate: call sites use a left-trim marker, so + the output has to open with one to keep this entry off the previous line. */}} +{{- if include "plane.externalReadReplica" . }} +- name: POSTGRES_READ_REPLICA_HOST + value: {{ .Values.env.pgdb_read_replica_host | quote }} +- name: POSTGRES_READ_REPLICA_PORT + value: {{ .Values.env.pgdb_read_replica_port | default "5432" | quote }} +- name: POSTGRES_READ_REPLICA_DB + value: {{ .Values.env.pgdb_read_replica_name | default .Values.env.pgdb_name | default "plane" | quote }} +{{- include "plane.secretKeyEnv" (dict "name" "POSTGRES_READ_REPLICA_USER" "secret" $secret "key" ($rr.usernameKey | default $db.usernameKey | default "username")) }} +{{- include "plane.secretKeyEnv" (dict "name" "POSTGRES_READ_REPLICA_PASSWORD" "secret" $secret "key" ($rr.passwordKey | default $db.passwordKey | default "password")) }} +{{- with $rr.hostKey }} +{{- include "plane.secretKeyEnv" (dict "name" "POSTGRES_READ_REPLICA_HOST" "secret" $secret "key" .) }} +{{- end }} +{{- with $rr.portKey }} +{{- include "plane.secretKeyEnv" (dict "name" "POSTGRES_READ_REPLICA_PORT" "secret" $secret "key" .) }} +{{- end }} +{{- with $rr.dbNameKey }} +{{- include "plane.secretKeyEnv" (dict "name" "POSTGRES_READ_REPLICA_DB" "secret" $secret "key" .) }} +{{- end }} +{{- end }} {{- end -}} {{/* @@ -615,6 +744,7 @@ Caller must indent to the correct depth (env list items). {{- include "plane.postgresCredsEnv" . }} {{- include "plane.rabbitmqCredsEnv" . }} {{- include "plane.redisCredsEnv" . }} +{{- include "plane.storageCredsEnv" . }} {{- end -}} {{/* @@ -661,4 +791,5 @@ Caller must indent to the correct depth (env list items). {{- end }} {{- include "plane.redisCredsEnv" . }} {{- include "plane.opensearchCredsEnv" . }} +{{- include "plane.storageCredsEnv" . }} {{- end -}} diff --git a/charts/plane-enterprise/templates/config-secrets/app-env.yaml b/charts/plane-enterprise/templates/config-secrets/app-env.yaml index 6e598c6b..822eec00 100644 --- a/charts/plane-enterprise/templates/config-secrets/app-env.yaml +++ b/charts/plane-enterprise/templates/config-secrets/app-env.yaml @@ -1,5 +1,11 @@ -{{- if and .Values.services.postgres.read_replica.enabled (not .Values.services.postgres.read_replica.remote_url) (empty .Values.external_secrets.app_env_existingSecret) }} -{{- fail "read_replica.enabled is true but read_replica.remote_url is empty" }} +{{/* The replica needs a source of connection details: a DSN in values, a Secret the + operator owns, or the discrete-parts mirror. Without one of those, enabling it + would silently produce a replica entry with no host. */}} +{{- if and .Values.services.postgres.read_replica.enabled + (not .Values.services.postgres.read_replica.remote_url) + (empty .Values.external_secrets.app_env_existingSecret) + (not (include "plane.externalReadReplica" .)) }} +{{- fail "read_replica.enabled is true but no source of replica credentials is configured — set services.postgres.read_replica.remote_url, or external_secrets.database.readReplica.secretName (or external_secrets.database.secretName, which it falls back to) together with env.pgdb_read_replica_host" }} {{- end }} {{- if empty .Values.external_secrets.app_env_existingSecret}} apiVersion: v1 @@ -45,7 +51,13 @@ stringData: DATABASE_URL: "" {{- end }} - {{- if and .Values.services.postgres.read_replica.enabled .Values.services.postgres.read_replica.remote_url }} + {{- if include "plane.externalReadReplica" . }} + {{/* Credentials come from the readReplica mirror as discrete + POSTGRES_READ_REPLICA_* parts. Left empty on purpose: the API prefers + DATABASE_READ_REPLICA_URL when set, so a composed URL here would shadow the + rotated password. */}} + DATABASE_READ_REPLICA_URL: "" + {{- else if and .Values.services.postgres.read_replica.enabled .Values.services.postgres.read_replica.remote_url }} DATABASE_READ_REPLICA_URL: {{ .Values.services.postgres.read_replica.remote_url | quote }} {{- end }} diff --git a/charts/plane-enterprise/templates/config-secrets/doc-store.yaml b/charts/plane-enterprise/templates/config-secrets/doc-store.yaml index c6e06602..32d3bfd0 100644 --- a/charts/plane-enterprise/templates/config-secrets/doc-store.yaml +++ b/charts/plane-enterprise/templates/config-secrets/doc-store.yaml @@ -22,9 +22,11 @@ stringData: {{- with .Values.env.gcs_credentials_path }} GCS_CREDENTIALS_PATH: {{ . | quote }} {{- end }} + {{- if not (include "plane.externalStorage" .) }} {{- with .Values.env.gcs_credentials_json }} GCS_CREDENTIALS_JSON: {{ . | quote }} {{- end }} + {{- end }} {{- else if .Values.services.minio.local_setup }} USE_MINIO: "1" MINIO_ROOT_USER: {{ .Values.services.minio.root_user | default "admin" | quote }} @@ -46,8 +48,18 @@ stringData: {{- with .Values.env.aws_secret_access_key }} AWS_SECRET_ACCESS_KEY: {{ . | quote }} {{- end }} - AWS_REGION: {{ .Values.env.aws_region | default "" | quote }} - AWS_S3_ENDPOINT_URL: {{ .Values.env.aws_s3_endpoint_url | quote }} + {{/* Also omitted rather than blank, for a subtler reason than the keys above. When a + pod identity is attached and OpenSearch has no basic auth, the API signs its + OpenSearch requests with SigV4 — and it reads the region as + os.environ.get("AWS_REGION") or a default. An empty string is *present*, so the + default never applies and every signed request goes out with an empty region and + is rejected. Absent is the only safe representation of "unset". */}} + {{- with .Values.env.aws_region }} + AWS_REGION: {{ . | quote }} + {{- end }} + {{- with .Values.env.aws_s3_endpoint_url }} + AWS_S3_ENDPOINT_URL: {{ . | quote }} + {{- end }} {{- end }} {{- end }} --- diff --git a/charts/plane-enterprise/templates/config-secrets/pi-api-env.yaml b/charts/plane-enterprise/templates/config-secrets/pi-api-env.yaml index 513ebff1..4b4a33e3 100644 --- a/charts/plane-enterprise/templates/config-secrets/pi-api-env.yaml +++ b/charts/plane-enterprise/templates/config-secrets/pi-api-env.yaml @@ -57,6 +57,11 @@ stringData: CELERY_BROKER_URL: {{ .Values.env.remote_redis_url | default "" | quote }} {{- end }} + {{- if empty .Values.external_secrets.ai_providers_existingSecret }} + {{/* Suppressed wholesale when the AI-providers Secret supplies these. The + empty-string branches below matter as much as the populated ones: envFrom + resolves later-source-wins, so a rendered COHERE_API_KEY: "" would overwrite + the value coming from that Secret. */}} {{- if .Values.services.pi.ai_providers.openai.enabled }} OPENAI_API_KEY: {{ .Values.services.pi.ai_providers.openai.api_key | default "" | quote }} {{- end }} @@ -91,6 +96,7 @@ stringData: BR_AWS_SESSION_TOKEN: {{ . | quote }} {{- end }} {{- end }} + {{- end }} {{- if empty .Values.external_secrets.app_keys_existingSecret }} AES_SECRET_KEY: {{ include "plane.secretValue" (dict "context" $ "name" "env.silo_envs.aes_secret_key" "value" .Values.env.silo_envs.aes_secret_key "fallback" "dsOdt7YrvxsTIFJ37pOaEVvLxN8KGBCr") | quote }} diff --git a/charts/plane-enterprise/templates/config-secrets/silo.yaml b/charts/plane-enterprise/templates/config-secrets/silo.yaml index 289aee67..fdfa0a19 100644 --- a/charts/plane-enterprise/templates/config-secrets/silo.yaml +++ b/charts/plane-enterprise/templates/config-secrets/silo.yaml @@ -51,6 +51,9 @@ stringData: AMQP_URL: "" {{- end }} + {{- if empty .Values.external_secrets.silo_connectors_existingSecret }} + {{/* Suppressed wholesale when the connectors Secret supplies these — client ids and + app names included, so the connector surface has a single owner. */}} {{- if .Values.services.silo.connectors.slack.enabled }} SLACK_CLIENT_SECRET: {{ .Values.services.silo.connectors.slack.client_secret | default "" | quote }} SLACK_CLIENT_ID: {{ .Values.services.silo.connectors.slack.client_id | default "" | quote }} @@ -85,6 +88,7 @@ stringData: HUBSPOT_CLIENT_ID: {{ .Values.services.silo.connectors.hubspot.client_id | default "" | quote }} HUBSPOT_CLIENT_SECRET: {{ .Values.services.silo.connectors.hubspot.client_secret | default "" | quote }} {{- end }} + {{- end }} {{- end }} --- {{- if .Values.services.silo.enabled }} diff --git a/charts/plane-enterprise/templates/workloads/agent-consumer.deployment.yaml b/charts/plane-enterprise/templates/workloads/agent-consumer.deployment.yaml index ceb5afef..9e41976e 100644 --- a/charts/plane-enterprise/templates/workloads/agent-consumer.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/agent-consumer.deployment.yaml @@ -52,6 +52,7 @@ spec: - ./bin/docker-entrypoint-agent-consumer.sh envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-agent-consumer-vars optional: false diff --git a/charts/plane-enterprise/templates/workloads/api.deployment.yaml b/charts/plane-enterprise/templates/workloads/api.deployment.yaml index ff27b60a..696d78fe 100644 --- a/charts/plane-enterprise/templates/workloads/api.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/api.deployment.yaml @@ -79,6 +79,7 @@ spec: exec ./bin/docker-entrypoint-api-ee.sh envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-app-vars optional: false diff --git a/charts/plane-enterprise/templates/workloads/beat-worker.deployment.yaml b/charts/plane-enterprise/templates/workloads/beat-worker.deployment.yaml index 2d422d24..4fc520f6 100644 --- a/charts/plane-enterprise/templates/workloads/beat-worker.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/beat-worker.deployment.yaml @@ -39,6 +39,7 @@ spec: - ./bin/docker-entrypoint-beat.sh envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-app-vars optional: false diff --git a/charts/plane-enterprise/templates/workloads/external-api.deployment.yaml b/charts/plane-enterprise/templates/workloads/external-api.deployment.yaml index a9623155..04bf8e1d 100644 --- a/charts/plane-enterprise/templates/workloads/external-api.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/external-api.deployment.yaml @@ -80,6 +80,7 @@ spec: exec ./bin/docker-entrypoint-api-ee.sh envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-app-vars optional: false diff --git a/charts/plane-enterprise/templates/workloads/live.deployment.yaml b/charts/plane-enterprise/templates/workloads/live.deployment.yaml index 768c12d1..aae6e76d 100644 --- a/charts/plane-enterprise/templates/workloads/live.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/live.deployment.yaml @@ -93,6 +93,7 @@ spec: {{- end }} envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.aiProvidersSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-live-vars optional: false diff --git a/charts/plane-enterprise/templates/workloads/migrator.job.yaml b/charts/plane-enterprise/templates/workloads/migrator.job.yaml index 4e5d3638..ef386418 100644 --- a/charts/plane-enterprise/templates/workloads/migrator.job.yaml +++ b/charts/plane-enterprise/templates/workloads/migrator.job.yaml @@ -27,6 +27,7 @@ spec: imagePullPolicy: {{ .Values.services.api.pullPolicy | default "Always" }} envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-app-vars optional: false diff --git a/charts/plane-enterprise/templates/workloads/pi-api.deployment.yaml b/charts/plane-enterprise/templates/workloads/pi-api.deployment.yaml index 68d56e03..62328478 100644 --- a/charts/plane-enterprise/templates/workloads/pi-api.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/pi-api.deployment.yaml @@ -79,6 +79,7 @@ spec: exec ./bin/entrypoint-api.sh envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.aiProvidersSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-pi-api-vars optional: false diff --git a/charts/plane-enterprise/templates/workloads/pi-beat.deployment.yaml b/charts/plane-enterprise/templates/workloads/pi-beat.deployment.yaml index e6ee62d8..b0380aad 100644 --- a/charts/plane-enterprise/templates/workloads/pi-beat.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/pi-beat.deployment.yaml @@ -54,6 +54,7 @@ spec: exec ./bin/entrypoint-celery-beat.sh envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.aiProvidersSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-pi-api-vars optional: false diff --git a/charts/plane-enterprise/templates/workloads/pi-migrator.job.yaml b/charts/plane-enterprise/templates/workloads/pi-migrator.job.yaml index d20bf46c..9c280af2 100644 --- a/charts/plane-enterprise/templates/workloads/pi-migrator.job.yaml +++ b/charts/plane-enterprise/templates/workloads/pi-migrator.job.yaml @@ -43,6 +43,7 @@ spec: exec ./bin/entrypoint-migrator.sh envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.aiProvidersSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-pi-api-vars optional: false diff --git a/charts/plane-enterprise/templates/workloads/pi-worker.deployment.yaml b/charts/plane-enterprise/templates/workloads/pi-worker.deployment.yaml index c59f9b17..5dbea10a 100644 --- a/charts/plane-enterprise/templates/workloads/pi-worker.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/pi-worker.deployment.yaml @@ -55,6 +55,7 @@ spec: exec ./bin/entrypoint-celery-worker.sh envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.aiProvidersSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-pi-api-vars optional: false diff --git a/charts/plane-enterprise/templates/workloads/silo.deployment.yaml b/charts/plane-enterprise/templates/workloads/silo.deployment.yaml index cdea5153..eae6d9b8 100644 --- a/charts/plane-enterprise/templates/workloads/silo.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/silo.deployment.yaml @@ -116,6 +116,7 @@ spec: {{- end }} envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-silo-vars optional: false diff --git a/charts/plane-enterprise/templates/workloads/webhook-consumer.deployment.yaml b/charts/plane-enterprise/templates/workloads/webhook-consumer.deployment.yaml index 1f2e6d04..c39fd5d3 100644 --- a/charts/plane-enterprise/templates/workloads/webhook-consumer.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/webhook-consumer.deployment.yaml @@ -52,6 +52,7 @@ spec: - ./bin/docker-entrypoint-webhook-consumer.sh envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-webhook-consumer-vars optional: false diff --git a/charts/plane-enterprise/templates/workloads/worker-importers.deployment.yaml b/charts/plane-enterprise/templates/workloads/worker-importers.deployment.yaml index a6f831fe..8ff85938 100644 --- a/charts/plane-enterprise/templates/workloads/worker-importers.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/worker-importers.deployment.yaml @@ -56,6 +56,7 @@ spec: exec ./bin/docker-entrypoint-worker.sh celery.importer envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-app-vars optional: false diff --git a/charts/plane-enterprise/templates/workloads/worker.deployment.yaml b/charts/plane-enterprise/templates/workloads/worker.deployment.yaml index b8a5eb35..e4bef8e8 100644 --- a/charts/plane-enterprise/templates/workloads/worker.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/worker.deployment.yaml @@ -55,6 +55,7 @@ spec: exec ./bin/docker-entrypoint-worker.sh envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-app-vars optional: false diff --git a/charts/plane-enterprise/values.yaml b/charts/plane-enterprise/values.yaml index c683f89c..a9a20470 100644 --- a/charts/plane-enterprise/values.yaml +++ b/charts/plane-enterprise/values.yaml @@ -599,6 +599,15 @@ serviceAccount: # Extra pod-template labels (Azure Workload Identity requires one). podLabels: {} + # Declares that this ServiceAccount is bound to a cloud identity configured OUT OF + # BAND — an EKS Pod Identity association, a GKE Workload Identity binding. The chart + # cannot detect that on its own: a Pod Identity association is an EKS API object keyed + # on cluster + namespace + service account, and nothing about it appears in the pod + # spec. Setting this changes no rendered output; it only lets `helm upgrade` warn + # about the interactions an attached identity creates, chiefly that credentials now + # reach every container on this ServiceAccount. + cloudIdentity: false + # ============================================================ # Externalized secrets # ============================================================ @@ -638,6 +647,23 @@ external_secrets: portKey: '' dbNameKey: '' + # Read replica. services.postgres.read_replica.remote_url is a DSN with the + # password inside it, which a managed rotation can never update — so this is the + # replica's equivalent of the block above. The API reads + # POSTGRES_READ_REPLICA_{DB,USER,PASSWORD,HOST,PORT} natively (Django takes the + # parts as a config dict; nothing composes a URL), so no application change is + # needed to use it. + # + # secretName defaults to the primary's, since a replica normally accepts the same + # credential. Set it only when the replica has its own user. + readReplica: + secretName: '' + usernameKey: '' # defaults to database.usernameKey + passwordKey: '' # defaults to database.passwordKey + hostKey: '' + portKey: '' + dbNameKey: '' + rabbitmq: # Amazon MQ shape. Set env.rabbitmq_ssl: true and env.rabbitmq_port: '5671' — # Amazon MQ refuses plaintext AMQP. @@ -656,6 +682,27 @@ external_secrets: hostKey: '' portKey: '' + storage: + # Object-storage credentials. + # + # On a cloud, leave this unset and give the pod an identity instead — annotate the + # ServiceAccount (see serviceAccount.annotations) or attach an EKS Pod Identity + # association. With env.aws_access_key / aws_secret_access_key empty the chart omits + # those variables entirely, which is what lets the SDK's credential chain reach the + # pod's role. An empty value would beat the chain and fail every request. + # + # Use this for an S3-compatible backend with no workload identity (Ceph, DO Spaces, + # an external MinIO), or for GCS with a service-account key. Ignored while the + # bundled MinIO is deployed, which supplies its own credentials. + # + # gcsCredentialsJsonKey carries the whole service-account JSON, private key + # included — the single highest-value blob in a GCS deployment, and the reason it + # should never sit in values.yaml. + secretName: '' + accessKeyIdKey: '' # -> AWS_ACCESS_KEY_ID + secretAccessKeyKey: '' # -> AWS_SECRET_ACCESS_KEY + gcsCredentialsJsonKey: '' # -> GCS_CREDENTIALS_JSON + opensearch: # Credentials for a REMOTE OpenSearch. Ignored when # services.opensearch.local_setup is true — the chart owns both sides of the @@ -695,6 +742,48 @@ external_secrets: # others, leaving two services with different values of a key that must match. app_keys_existingSecret: '' + # AI/LLM provider keys ONLY. + # + # pi_api_env_existingSecret below replaces the WHOLE Plane AI Secret, which forces + # you to own PLANE_PI_DATABASE_URL, FOLLOWER_POSTGRES_URI, AMQP_URL, + # CELERY_BROKER_URL and the signing keys as well. Those are per-environment, so it + # cannot express "one provider account shared by every environment". This hook + # carries nothing environment-specific, so a single Secret can be mirrored into as + # many namespaces as share the account — while a customer deployment can just as + # well point it at a per-environment Secret. The chart only takes a name. + # + # Keys read (all optional; an omitted key is simply an absent env var): + # OPENAI_API_KEY CLAUDE_API_KEY GROQ_API_KEY COHERE_API_KEY + # CUSTOM_LLM_API_KEY + # BR_AWS_ACCESS_KEY_ID BR_AWS_SECRET_ACCESS_KEY BR_AWS_SESSION_TOKEN + # Bedrock embeddings. On AWS omit all three and let the pod's identity sign — + # Plane AI falls back to AWS_ACCESS_KEY_ID and then to boto3's own chain. + # AI_OPENAI_API_KEY + # The live server's OpenAI-compatible proxy. The chart has no values key for + # this at all, so this hook is the only way to supply it. + # + # Do NOT put base URLs in this Secret. They are not secret, they belong in + # services.pi.ai_providers.*.base_url, and the live server validates + # AI_OPENAI_BASE_URL as a URL and exits on an empty string — which is exactly what + # a `dataFrom.extract` of a JSON secret containing an empty base URL would deliver. + ai_providers_existingSecret: '' + + # Silo connector app credentials (OAuth client secrets, the GitHub App private key, + # webhook secrets). The silo Secret also carries DATABASE_URL / AMQP_URL / + # REDIS_URL, so silo_env_existingSecret cannot externalize just the connectors. + # + # When set the chart emits none of the connector keys and this Secret owns the whole + # connector surface — client ids and app names included. They are not secret, but a + # half-external, half-rendered connector block is a configuration people get wrong. + # + # Naturally per-environment: a connector's OAuth callback URL is per-host, so the + # app registration differs per environment. + # SLACK_CLIENT_ID/SECRET, SLACK_BASE_URL + # GITHUB_CLIENT_ID/SECRET, GITHUB_APP_NAME, GITHUB_APP_ID, GITHUB_PRIVATE_KEY + # GITLAB_CLIENT_ID/SECRET + # SENTRY_CLIENT_ID/SECRET, SENTRY_BASE_URL, SENTRY_INTEGRATION_SLUG + silo_connectors_existingSecret: '' + # DNS-01 API token for the cert-manager Issuer (ssl.issuer cloudflare/digitalocean). # Must contain the key `api-token`. ssl_token_existingSecret: '' @@ -773,6 +862,13 @@ env: pgdb_host: '' pgdb_port: '5432' + # Read-replica endpoint, used with external_secrets.database.readReplica (the + # credentials come from that Secret). The database name defaults to pgdb_name, since + # a replica serves the same database under a different endpoint. + pgdb_read_replica_host: '' + pgdb_read_replica_port: '5432' + pgdb_read_replica_name: '' + # RabbitMQ endpoint used with external_secrets.rabbitmq (credentials come from # that Secret). Ignored when services.rabbitmq.local_setup is true. rabbitmq_host: '' diff --git a/hack/assert-secrets.py b/hack/assert-secrets.py new file mode 100755 index 00000000..367a911e --- /dev/null +++ b/hack/assert-secrets.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Assertions over a rendered Helm release, for the secret-externalization work. + +Two of these encode the properties the whole exercise exists to guarantee: + + --no-plaintext-secrets nothing that looks like a live credential is in the render + --no-dsn no connection string carries an embedded password + +The second is the one that proves rotation is actually possible. A DSN in a Secret is +a rotation dead-end: when a managed database rotates the password, nothing can track +it, because the password is baked into a string somebody has to recompose. Discrete +credential parts are what make a verbatim mirror of a rotation secret work. + +Usage: + assert-secrets.py rendered.yaml --no-plaintext-secrets --no-dsn + assert-secrets.py rendered.yaml --absent AWS_ACCESS_KEY_ID,AWS_REGION + assert-secrets.py rendered.yaml --no-key-in-secrets OPENAI_API_KEY,CLAUDE_API_KEY + assert-secrets.py rendered.yaml --order 'Deployment/t-pi-api-wl/t-pi-api:plane-ai=plane-pi' + +Exits non-zero and prints every violation. +""" + +import argparse +import re +import sys + +import yaml + +# Credential shapes worth failing a build over. Deliberately provider-prefix based: +# matching on entropy produces false positives on image digests and checksums. +SECRET_PATTERNS = { + "OpenAI key": re.compile(r"sk-(proj-|svcacct-)?[A-Za-z0-9_-]{20,}"), + "Anthropic key": re.compile(r"sk-ant-[A-Za-z0-9_-]{20,}"), + "Groq key": re.compile(r"gsk_[A-Za-z0-9]{20,}"), + "Docker Hub token": re.compile(r"dckr_(oat|pat)_[A-Za-z0-9_-]{10,}"), + "AWS access key id": re.compile(r"\b(AKIA|ASIA)[0-9A-Z]{16}\b"), + "GitLab OAuth token": re.compile(r"gloas-[A-Za-z0-9_-]{20,}"), + "Google OAuth secret": re.compile(r"GOCSPX-[A-Za-z0-9_-]{10,}"), + "private key block": re.compile(r"BEGIN [A-Z ]*PRIVATE KEY"), +} + +# A connection string whose authority section carries a password. +DSN_WITH_PASSWORD = re.compile(r"\b(postgresql|postgres|amqps?|rediss?|mongodb)://[^\s:/@]+:[^\s@]+@") + + +def load(path): + with open(path) as fh: + return [d for d in yaml.safe_load_all(fh) if isinstance(d, dict)] + + +def iter_values(docs, kinds=("Secret", "ConfigMap")): + """Yield (kind, name, key, value) for every data entry in the render.""" + for doc in docs: + kind = doc.get("kind") + if kind not in kinds: + continue + name = (doc.get("metadata") or {}).get("name", "") + for field in ("stringData", "data"): + for k, v in (doc.get(field) or {}).items(): + if isinstance(v, str): + yield kind, name, k, v + + +def iter_container_env(docs): + """Yield (workload, container, env_entry) for explicit env lists.""" + for doc in docs: + if doc.get("kind") not in ("Deployment", "StatefulSet", "DaemonSet", "Job"): + continue + name = (doc.get("metadata") or {}).get("name", "") + pod = (((doc.get("spec") or {}).get("template") or {}).get("spec") or {}) + for c in (pod.get("initContainers") or []) + (pod.get("containers") or []): + for e in c.get("env") or []: + yield name, c.get("name", "?"), e + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("rendered") + ap.add_argument("--no-plaintext-secrets", action="store_true") + ap.add_argument("--no-dsn", action="store_true") + ap.add_argument("--absent", default="", help="env names that must not appear anywhere") + ap.add_argument("--no-key-in-secrets", default="", help="keys no rendered Secret may contain, even empty") + ap.add_argument( + "--order", + action="append", + default=[], + metavar="WORKLOAD/CONTAINER:EARLIER=LATER", + help="assert one envFrom secretRef precedes another", + ) + args = ap.parse_args() + + docs = load(args.rendered) + problems = [] + + if args.no_plaintext_secrets: + for kind, name, key, value in iter_values(docs): + for label, pat in SECRET_PATTERNS.items(): + if pat.search(value): + problems.append(f"plaintext {label} in {kind}/{name} key {key}") + for wl, cname, entry in iter_container_env(docs): + v = entry.get("value") + if isinstance(v, str): + for label, pat in SECRET_PATTERNS.items(): + if pat.search(v): + problems.append(f"plaintext {label} in {wl}/{cname} env {entry.get('name')}") + + if args.no_dsn: + for kind, name, key, value in iter_values(docs): + if DSN_WITH_PASSWORD.search(value): + problems.append( + f"connection string with an embedded password in {kind}/{name} key {key} " + "— a rotated credential can never reach this; use the discrete credential parts" + ) + + if args.absent: + wanted = {k for k in args.absent.split(",") if k} + for kind, name, key, _ in iter_values(docs): + if key in wanted: + problems.append(f"{key} must be absent but is set in {kind}/{name}") + for wl, cname, entry in iter_container_env(docs): + if entry.get("name") in wanted: + problems.append(f"{entry['name']} must be absent but is set on {wl}/{cname}") + + if args.no_key_in_secrets: + wanted = {k for k in args.no_key_in_secrets.split(",") if k} + for kind, name, key, value in iter_values(docs, kinds=("Secret",)): + if key in wanted: + shown = "empty string" if value == "" else "a value" + problems.append( + f"Secret/{name} still renders {key} ({shown}) — envFrom resolves " + "later-source-wins, so this overwrites the external Secret" + ) + + for spec in args.order: + target, _, pair = spec.partition(":") + earlier, _, later = pair.partition("=") + found = False + for doc in docs: + if doc.get("kind") not in ("Deployment", "StatefulSet", "Job"): + continue + name = (doc.get("metadata") or {}).get("name", "") + pod = (((doc.get("spec") or {}).get("template") or {}).get("spec") or {}) + for c in pod.get("containers") or []: + if f"{doc['kind']}/{name}/{c.get('name')}" != target: + continue + found = True + names = [s["secretRef"]["name"] for s in (c.get("envFrom") or []) if "secretRef" in s] + if earlier not in names or later not in names: + problems.append(f"{target}: expected both {earlier} and {later} in envFrom, got {names}") + elif names.index(earlier) > names.index(later): + problems.append(f"{target}: {earlier} must precede {later}, got {names}") + if not found: + problems.append(f"{target}: container not found in render") + + if problems: + print(f"FAIL — {len(problems)} violation(s):", file=sys.stderr) + for p in problems: + print(f" - {p}", file=sys.stderr) + return 1 + print("PASS") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/hack/resolve-env.py b/hack/resolve-env.py new file mode 100755 index 00000000..4d440d94 --- /dev/null +++ b/hack/resolve-env.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Resolve the effective environment of every container in a rendered Helm release. + +Reading a chart diff tells you what the templates changed. It does not tell you what +the *pods* end up with, which is the only thing that matters when secrets move between +Secrets: `envFrom` resolves in list order with later sources winning, and explicit +`env` entries beat all of them. A key that moves from one Secret to another, or that a +suppression forgot to remove, shows up here and nowhere else. + +Emits sorted JSON: {"kind/name/container": {"ENV_NAME": ""}} where the +resolution is the literal value, or a marker: + + valueFrom.secretKeyRef + valueFrom.configMapKeyRef + valueFrom.fieldRef + envFrom referenced a Secret this release never renders + (expected for operator-supplied external Secrets) + +Usage: + resolve-env.py rendered.yaml [--external NAME=KEY1,KEY2 ...] > effective.json + +--external declares a Secret that lives outside the chart along with the keys it +carries, so its contribution can be modelled instead of reported as missing. +""" + +import argparse +import json +import re +import sys + +import yaml + +WORKLOAD_KINDS = {"Deployment", "StatefulSet", "DaemonSet", "Job", "CronJob"} +# Job names carry a render timestamp; strip it so runs are comparable. +TIMESTAMP_SUFFIX = re.compile(r"-\d{8}-\d{6}$") + + +def load_docs(path): + with open(path) as fh: + return [d for d in yaml.safe_load_all(fh) if isinstance(d, dict)] + + +def collect_sources(docs, external): + """Map Secret/ConfigMap name -> {key: value}. External Secrets get sentinel values.""" + secrets, configmaps = dict(external), {} + for doc in docs: + kind = doc.get("kind") + name = (doc.get("metadata") or {}).get("name", "") + if kind == "Secret": + merged = dict(doc.get("stringData") or {}) + # data is base64; the value is irrelevant for comparison, the key is not. + for k in (doc.get("data") or {}): + merged.setdefault(k, "") + secrets[name] = merged + elif kind == "ConfigMap": + configmaps[name] = dict(doc.get("data") or {}) + return secrets, configmaps + + +def pod_templates(doc): + """Yield (container_dict, is_init) for every container in a workload.""" + kind = doc.get("kind") + spec = doc.get("spec") or {} + if kind == "CronJob": + spec = ((spec.get("jobTemplate") or {}).get("spec") or {}) + pod = ((spec.get("template") or {}).get("spec") or {}) + for c in pod.get("initContainers") or []: + yield c, True + for c in pod.get("containers") or []: + yield c, False + + +def resolve_container(container, secrets, configmaps): + env = {} + # envFrom first, in order: later sources overwrite earlier ones. + for source in container.get("envFrom") or []: + if "secretRef" in source: + name = source["secretRef"].get("name", "") + if name in secrets: + env.update(secrets[name]) + else: + env[f""] = "" + elif "configMapRef" in source: + name = source["configMapRef"].get("name", "") + if name in configmaps: + env.update(configmaps[name]) + else: + env[f""] = "" + # Explicit env always wins over envFrom. + for entry in container.get("env") or []: + name = entry.get("name") + if not name: + continue + if "value" in entry: + env[name] = entry["value"] + continue + vf = entry.get("valueFrom") or {} + if "secretKeyRef" in vf: + ref = vf["secretKeyRef"] + env[name] = f"" + elif "configMapKeyRef" in vf: + ref = vf["configMapKeyRef"] + env[name] = f"" + elif "fieldRef" in vf: + env[name] = f"" + else: + env[name] = "" + return env + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("rendered") + ap.add_argument( + "--external", + action="append", + default=[], + metavar="NAME=KEY1,KEY2", + help="model an operator-supplied Secret and the keys it carries", + ) + args = ap.parse_args() + + external = {} + for spec in args.external: + name, _, keys = spec.partition("=") + external[name] = {k: f"" for k in keys.split(",") if k} + + docs = load_docs(args.rendered) + secrets, configmaps = collect_sources(docs, external) + + out = {} + for doc in docs: + if doc.get("kind") not in WORKLOAD_KINDS: + continue + name = TIMESTAMP_SUFFIX.sub("", (doc.get("metadata") or {}).get("name", "")) + for container, is_init in pod_templates(doc): + cname = container.get("name", "?") + key = f"{doc['kind']}/{name}/{'init:' if is_init else ''}{cname}" + out[key] = dict(sorted(resolve_container(container, secrets, configmaps).items())) + + json.dump(dict(sorted(out.items())), sys.stdout, indent=2) + sys.stdout.write("\n") + + +if __name__ == "__main__": + main()