From 9be44b25990a23191c0b9100b3504fe497e73bd6 Mon Sep 17 00:00:00 2001 From: Pratapa Lakshmi Date: Wed, 19 Aug 2026 16:23:05 +0530 Subject: [PATCH 01/10] feat(plane-enterprise): consume credentials from external Secrets, with keyless cloud identity (3.6.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebased onto master, which has since gained opt-in OpenTelemetry (#248) and the v3.1.1 release. The two touch the same regions of every workload — master adds OTel env, this branch adds credential env — so the resolution keeps both: one `env:` per container, one guard carrying both conditions, and OTel's envFrom entry back in the envFrom position where it belongs. Verified rather than eyeballed. Every workload template parses; the default, all-services, OTel and externalized-credentials renders all succeed; and both features coexist — the api container comes out with OTEL_SERVICE_NAME=api, POSTGRES_PASSWORD from the operator's Secret, and otel-vars alongside the credential Secrets in envFrom. Resolved environments are identical to pre-rebase for all 22 containers except APP_VERSION, which moves 3.1.0 -> 3.1.1 because that is master's release. hack/assert-secrets.py --no-dsn still passes. What this branch does, in the order it was built: - Credentials come from Secrets the operator owns, as discrete parts rather than a DSN, so a rotated password can actually reach the app. Postgres, RabbitMQ, Redis, OpenSearch and storage, plus whole-Secret and key-group hooks for the rest. - The same contract extended to silo, live and Plane AI, which each read a different subset. - live's AMQP_URL guarded so the RabbitMQ mirror is not silently inert for live. - live-exporter's ServiceAccount (it was the only workload hardcoding the release-scoped name), and a NOTES warning that the MQ mirror does not reach live's export queue. - Keyless S3: the chart omits AWS_ACCESS_KEY_ID rather than rendering it empty, because an empty credential is found first in boto3's chain and shadows the pod's identity. That Secret now renders base64 `data`, so a key the chart stops rendering is a deletion Helm can express — without which switching an existing release from MinIO to S3 fails as InvalidClientTokenId while the configuration looks correct. - Bedrock credentials, keyed (AWS_BEARER_TOKEN_BEDROCK) or keyless via the pod's identity, with the profile ARN and region outside the provider-key suppression group because they are identifiers. The chart version stays at 3.6.2, above master's 3.3.0; appVersion takes master's 3.1.1. Rebasing replayed as a single commit: master's OTel change collides with all eight of the original commits in the same few regions, and resolving the same conflict eight times invites exactly the silent mangling this diff is meant to avoid — two of the intermediate resolutions had already produced duplicate `env:` keys before being caught. The original commit messages are preserved in the PR history. --- charts/plane-enterprise/Chart.yaml | 2 +- charts/plane-enterprise/README.md | 285 ++++++++++ .../examples/external-secrets/README.md | 73 +++ .../external-secrets/aws-secrets-manager.yaml | 217 +++++++ .../external-secrets/azure-key-vault.yaml | 211 +++++++ .../external-secrets/gcp-secret-manager.yaml | 183 ++++++ .../external-secrets/rotation-runbook.md | 112 ++++ charts/plane-enterprise/questions.yml | 135 +++++ charts/plane-enterprise/templates/NOTES.txt | 158 ++++++ .../plane-enterprise/templates/_helpers.tpl | 535 +++++++++++++++++- .../templates/certs/cert-issuers.yaml | 9 +- .../templates/config-secrets/app-env.yaml | 62 +- .../templates/config-secrets/doc-store.yaml | 76 ++- .../templates/config-secrets/live-env.yaml | 19 +- .../config-secrets/opensearchdb.yaml | 4 + .../templates/config-secrets/pi-api-env.yaml | 82 ++- .../templates/config-secrets/runner-env.yaml | 6 +- .../templates/config-secrets/silo.yaml | 36 +- .../templates/service-account.yaml | 11 +- .../templates/workloads/admin.deployment.yaml | 7 +- .../workloads/agent-consumer.deployment.yaml | 22 +- .../templates/workloads/api.deployment.yaml | 23 +- .../automation-consumer.deployment.yaml | 14 +- .../workloads/beat-worker.deployment.yaml | 22 +- .../templates/workloads/email.deployment.yaml | 15 +- .../workloads/external-api.deployment.yaml | 23 +- .../workloads/iframely.deployment.yaml | 7 +- .../workloads/live-exporter.deployment.yaml | 7 +- .../templates/workloads/live.deployment.yaml | 15 +- .../templates/workloads/migrator.job.yaml | 24 +- .../templates/workloads/minio.stateful.yaml | 9 +- .../templates/workloads/monitor.stateful.yaml | 7 +- .../workloads/opensearch.stateful.yaml | 5 +- .../workloads/outbox-poller.deployment.yaml | 13 +- .../workloads/pi-api.deployment.yaml | 15 +- .../workloads/pi-beat.deployment.yaml | 14 +- .../templates/workloads/pi-migrator.job.yaml | 14 +- .../workloads/pi-worker.deployment.yaml | 15 +- .../workloads/postgres.stateful.yaml | 5 +- .../workloads/rabbitmq.stateful.yaml | 5 +- .../templates/workloads/redis.stateful.yaml | 5 +- .../workloads/runner.deployment.yaml | 9 +- .../templates/workloads/silo.deployment.yaml | 15 +- .../templates/workloads/space.deployment.yaml | 7 +- .../templates/workloads/web.deployment.yaml | 7 +- .../webhook-consumer.deployment.yaml | 23 +- .../worker-importers.deployment.yaml | 23 +- .../workloads/worker.deployment.yaml | 23 +- charts/plane-enterprise/values.yaml | 319 ++++++++++- hack/assert-secrets.py | 175 ++++++ hack/resolve-env.py | 155 +++++ 51 files changed, 3091 insertions(+), 167 deletions(-) create mode 100644 charts/plane-enterprise/examples/external-secrets/README.md create mode 100644 charts/plane-enterprise/examples/external-secrets/aws-secrets-manager.yaml create mode 100644 charts/plane-enterprise/examples/external-secrets/azure-key-vault.yaml create mode 100644 charts/plane-enterprise/examples/external-secrets/gcp-secret-manager.yaml create mode 100644 charts/plane-enterprise/examples/external-secrets/rotation-runbook.md create mode 100644 charts/plane-enterprise/templates/NOTES.txt 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 6a85f88d..e87de17d 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.3.0 +version: 3.6.2 appVersion: "3.1.1" home: https://plane.so/ diff --git a/charts/plane-enterprise/README.md b/charts/plane-enterprise/README.md index 39bc2d2d..ee663614 100644 --- a/charts/plane-enterprise/README.md +++ b/charts/plane-enterprise/README.md @@ -288,6 +288,12 @@ securityContext: | services.postgres.labels | {} | | This key allows you to set custom labels for the stateful deployment of `postgres`. This is useful for organizing and selecting resources in your Kubernetes cluster. | | services.postgres.annotations | {} | | This key allows you to set custom annotations for the stateful deployment of `postgres`. This is useful for adding metadata or configuration hints to your resources. | | env.pgdb_remote_url | | | Users can also decide to use the remote hosted database and link to Plane deployment. Ignoring all the above keys, set `services.postgres.local_setup` to `false` and set this key with remote connection url. | +| env.pgdb_host | | | Hostname of a remote Postgres, used with `external_secrets.database` instead of `env.pgdb_remote_url` — the username and password then come from the external Secret and never appear in this file. Ignored when `services.postgres.local_setup=true`. | +| env.pgdb_port | 5432 | | Port of a remote Postgres, used with `external_secrets.database`. | +| external_secrets.database.secretName | | | Name of an existing Secret holding the Postgres username and password — typically an RDS/CloudSQL managed-rotation secret mirrored verbatim into the cluster. See "Keeping credentials out of values.yaml". | +| external_secrets.database.usernameKey | username | | Key inside that Secret holding the username. The defaults match the JSON that RDS and CloudSQL produce. | +| external_secrets.database.passwordKey | password | | Key inside that Secret holding the password. | +| external_secrets.database.hostKey / portKey / dbNameKey | | | Optional. Set only when the Secret also carries the endpoint (RDS non-master rotation secrets do); those keys then override `env.pgdb_host` / `pgdb_port` / `pgdb_name`. | ### Redis/Valkey Setup @@ -305,6 +311,11 @@ securityContext: | services.redis.labels | {} | | This key allows you to set custom labels for the stateful deployment of `redis`. This is useful for organizing and selecting resources in your Kubernetes cluster. | | services.redis.annotations | {} | | This key allows you to set custom annotations for the stateful deployment of `redis`. This is useful for adding metadata or configuration hints to your resources. | | env.remote_redis_url | | | Users can also decide to use the remote hosted database and link to Plane deployment. Ignoring all the above keys, set `services.redis.local_setup` to `false` and set this key with remote connection url. | +| env.redis_host | | | Hostname of a remote Redis/Valkey, used with `external_secrets.redis` instead of `env.remote_redis_url` — the password then comes from the external Secret. Ignored when `services.redis.local_setup=true`. Requires `planeVersion` v3.2.0+. | +| env.redis_port | 6379 | | Port of a remote Redis, used with `external_secrets.redis`. | +| env.redis_ssl | false | | Set `true` to connect over TLS (`rediss://`) — required by ElastiCache with in-transit encryption and by Azure Cache for Redis. | +| external_secrets.redis.secretName | | | Name of an existing Secret holding the Redis password / auth token. See "Keeping credentials out of values.yaml". | +| external_secrets.redis.passwordKey | password | | Key inside that Secret holding the password. `hostKey` / `portKey` are also available when the Secret carries the endpoint. | ### RabbitMQ Setup @@ -325,6 +336,11 @@ securityContext: | services.rabbitmq.labels | {} | | This key allows you to set custom labels for the stateful deployment of `rabbitmq`. This is useful for organizing and selecting resources in your Kubernetes cluster. | | services.rabbitmq.annotations | {} | | This key allows you to set custom annotations for the stateful deployment of `rabbitmq`. This is useful for adding metadata or configuration hints to your resources. | | services.rabbitmq.external_rabbitmq_url | | | Users can also decide to use the remote hosted service and link to Plane deployment. Ignoring all the above keys, set `services.rabbitmq.local_setup` to `false` and set this key with remote connection url. | +| env.rabbitmq_host | | | Hostname of a remote RabbitMQ, used with `external_secrets.rabbitmq` instead of `services.rabbitmq.external_rabbitmq_url` — the credentials then come from the external Secret. Ignored when `services.rabbitmq.local_setup=true`. | +| env.rabbitmq_port | 5672 | | Port of a remote RabbitMQ, used with `external_secrets.rabbitmq`. Use `5671` for AMQPS (Amazon MQ). | +| env.rabbitmq_vhost | / | | Virtual host of a remote RabbitMQ, used with `external_secrets.rabbitmq`. | +| external_secrets.rabbitmq.secretName | | | Name of an existing Secret holding the RabbitMQ username and password. Note this is separate from `external_secrets.rabbitmq_existingSecret`, which configures the **bundled** broker. | +| external_secrets.rabbitmq.usernameKey | username | | Key inside that Secret holding the username. `passwordKey`, and optionally `hostKey` / `portKey` / `vhostKey`, work the same way. | ### OpenSearch Setup @@ -891,6 +907,269 @@ Note: When the email service is enabled, the cert-issuer will be automatically c | -------- | :-----: | :------: | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | extraEnv | [] | No | Global extra environment variables that will be applied to all workloads. This allows you to add custom environment variables to all deployments (web, api, worker, etc.). Useful for proxy settings, custom configurations, or any environment-specific variables. Some example variables are HTTP_PROXY, HTTPS_PROXY, NO_PROXY. | +## Keeping credentials out of values.yaml + +Everything in this section is opt-in and additive. A `values.yaml` that worked before keeps working unchanged; adopt these one at a time. + +The chart only ever consumes plain Kubernetes `Secret` resources. It does **not** render `ExternalSecret`, `SealedSecret`, or any provider-specific resource, which is what lets the same chart run against AWS Secrets Manager, GCP Secret Manager, Azure Key Vault, or Vault. You create the Secret (usually by pointing External Secrets Operator at your cloud secret) and tell the chart its name. + +Three mechanisms, in the order you should reach for them: + +| | What it covers | How | +| --- | --- | --- | +| **Cloud workload identity** | Object storage (S3/GCS), OpenSearch, AWS Secrets Manager | No secret at all — annotate the ServiceAccount | +| **Infrastructure credentials** | Postgres, RabbitMQ, Redis username/password | `external_secrets.{database,rabbitmq,redis}` — mirror your cloud secret, chart maps its keys | +| **Whole-Secret replacement** | Signing keys, connector OAuth secrets, LLM keys | `external_secrets.*_existingSecret` — you own every key in the Secret | + +### 1. Cloud workload identity (no credentials anywhere) + +Plane already walks each cloud SDK's default credential chain when no static keys are present, so object storage needs no secret at all. Annotate the ServiceAccount and leave `env.aws_access_key` / `env.aws_secret_access_key` / `env.gcs_credentials_json` empty — the chart then **omits those environment variables entirely** rather than setting them to empty strings, which is what allows the SDK to fall through to the pod identity. + +```yaml +serviceAccount: + annotations: + # AWS IRSA + eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/plane-s3 + # GCP Workload Identity + # iam.gke.io/gcp-service-account: plane@my-project.iam.gserviceaccount.com + # Azure Workload Identity + # azure.workload.identity/client-id: 00000000-0000-0000-0000-000000000000 + # Azure Workload Identity also needs a pod label: + # podLabels: + # azure.workload.identity/use: 'true' + +env: + aws_region: eu-west-1 + docstore_bucket: plane-uploads + aws_access_key: '' # leave empty — the pod's IAM role is used + aws_secret_access_key: '' +``` + +**EKS Pod Identity** needs no annotation at all: create the association against the ServiceAccount's name (`-srv-account`, or set `serviceAccount.name`). To reference a ServiceAccount you manage elsewhere (Terraform, Crossplane), set `serviceAccount.create: false` and `serviceAccount.name`. + +OpenSearch behaves the same way — leave `env.opensearch_remote_username` / `_password` empty and the API uses SigV4 IAM auth. + +### 2. Infrastructure credentials (database, RabbitMQ, Redis) + +The problem this solves: when RDS or CloudSQL manages rotation for you, the secret it produces contains **only** `{"username": "...", "password": "..."}` — and you do not want to maintain a second, hand-composed `DATABASE_URL` secret alongside it that has to be rewritten on every rotation. + +So the chart reads your cloud secret's keys directly. Mirror the cloud secret into the cluster **verbatim** (a plain ESO `dataFrom.extract`, no `rewrite`, no `template`), then tell the chart which keys inside it hold the username and password. The endpoint — host, port, database name — is not secret and stays in `values.yaml`. + +```yaml +external_secrets: + database: + secretName: plane-rds # the mirrored RDS secret + usernameKey: username # keys as they appear inside it + passwordKey: password + rabbitmq: + secretName: plane-amazonmq + redis: + secretName: plane-elasticache + passwordKey: password + opensearch: + secretName: plane-opensearch + +env: + pgdb_host: plane.abc123.eu-west-1.rds.amazonaws.com + pgdb_port: '5432' + pgdb_name: plane + rabbitmq_host: b-1.plane.mq.eu-west-1.amazonaws.com + rabbitmq_port: '5671' + rabbitmq_ssl: true # Amazon MQ refuses plaintext AMQP + redis_host: plane.abc.cache.amazonaws.com + redis_ssl: true # ElastiCache with in-transit encryption + opensearch_remote_url: https://search-plane.eu-west-1.es.amazonaws.com +``` + +The four services differ in what the credential looks like, so: + +| Service | What the Secret holds | Endpoint values | Notes | +| --- | --- | --- | --- | +| **Postgres** (RDS, CloudSQL, Flexible Server) | `username`, `password` | `env.pgdb_host` / `pgdb_port` / `pgdb_name` | Works on every Plane release. | +| **RabbitMQ** (Amazon MQ) | `username`, `password` | `env.rabbitmq_host` / `rabbitmq_port` / `rabbitmq_vhost` | **Set `env.rabbitmq_ssl: true` and port `5671` for Amazon MQ** — it only accepts AMQPS. Works on every release; `rabbitmq_ssl` needs v3.2.0+. | +| **Redis** (ElastiCache, Memorystore, Azure Cache) | `password` only — there is no username | `env.redis_host` / `redis_port` / `redis_ssl` | Needs **planeVersion v3.2.0+**. For ElastiCache the AUTH token goes in the password key. | +| **OpenSearch** | `username`, `password` | `env.opensearch_remote_url` | Remote domains only. On AWS, prefer leaving both unset so the pod's IAM role authenticates with SigV4. | + +`rabbitmq_ssl` and `redis_ssl` exist because the discrete-parts path has no URL scheme to carry TLS — an `amqp://` URL says "plaintext" in the string itself, but a host and port do not. Without the flag, a mirrored Amazon MQ credential produces a plaintext connection that the broker rejects. + +The chart then gives every Django workload `POSTGRES_USER` / `POSTGRES_PASSWORD` as `secretKeyRef` entries pointing straight at your mirrored Secret, with the endpoint as plain values. **The application composes its own connection URLs from those parts**, so a rotated password propagates without any URL being rewritten — in the chart, in the secret store, or anywhere else. Passwords are percent-encoded during composition, so generated passwords containing `@ : / #` are safe. + +If your rotation secret happens to carry the endpoint too (RDS non-master rotation secrets include `host`, `port`, `dbname`), point the optional `hostKey` / `portKey` / `dbNameKey` at those keys and drop the `env.*` endpoint values. + +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.** + +**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. + +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. + +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 + +`SECRET_KEY`, `AES_SECRET_KEY`, `LIVE_SERVER_SECRET_KEY`, `PI_INTERNAL_SECRET`, `SILO_HMAC_SECRET_KEY` and `CURSOR_WEBHOOK_SECRET` appear in up to four of the chart's Secrets, and several of them must match for the services to talk to each other. `external_secrets.app_keys_existingSecret` points all of them at a single Secret so they cannot drift: + +```yaml +external_secrets: + app_keys_existingSecret: plane-app-keys +``` + +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. + +The chart ships **public example values** for all of these. Set `env.requireExplicitSecrets: true` to make the render fail rather than fall back to them: + +```yaml +env: + requireExplicitSecrets: true +``` + +This will become the default in the next major version. + +### 4. Picking up a rotated credential without downtime + +A rotation only reaches a running pod if something restarts it. Install [Stakater Reloader](https://github.com/stakater/Reloader) and turn it on: + +```yaml +reloader: + enabled: true +``` + +The chart then annotates every workload with `reloader.stakater.com/auto: "true"`, and Reloader rolls a workload when any Secret or ConfigMap it references changes — including Secrets the chart does not render, which is exactly the External Secrets path. The credential-consuming Deployments also get `maxUnavailable: 0` / `maxSurge: 1`, so the restart keeps full capacity. + +The full chain: you rotate in the cloud → ESO syncs within its `refreshInterval` → Reloader rolls the pods. + +**The gap to plan for.** Between the moment the credential changes on the server and the moment the new pods are up, connections opened with the old credential fail. Plane's Django services keep no persistent database connections, so that is every new request in the window. The window is roughly `refreshInterval + rollout time`. + +To close it, rotate so that both the old and the new credential are valid at once: + +- **Postgres** — keep two users (`plane_a`, `plane_b`) with identical grants. Rotate the password of the user that is *not* in use, point the cloud secret at that user, and let ESO + Reloader roll. The credential in use is never invalidated mid-flight. Existing sessions survive a password change in Postgres regardless. +- **RabbitMQ** — same shape: create the second user first, then switch the secret. +- **ElastiCache** — supports two simultaneously valid auth tokens natively; use that. For a plain Redis, set `refreshInterval: 30s` and accept a sub-minute window (the Django cache is configured with `IGNORE_EXCEPTIONS`, so cache reads degrade rather than error). +- **On AWS**, the alternative is the in-process path: `RDS_SECRET_ARN` / `AMAZONMQ_SECRET_ARN` / `ELASTICACHE_SECRET_ARN` (via `extraEnv`), where the app refreshes from Secrets Manager itself and no restart is needed at all. + +Two things Reloader will not do: it ignores Jobs, so the migrator Job holds whatever credential it started with — don't rotate during an upgrade window, and re-run `helm upgrade` if a migration fails mid-rotation. And the bundled `local_setup` StatefulSets (postgres, rabbitmq, minio, opensearch) are outside all of this; rotation guidance assumes managed backends. + +Separately, `helm upgrade` no longer restarts every workload unconditionally. Pods carry a `checksum/config` annotation instead of a timestamp, so an upgrade rolls only what actually changed. Set `global.forceRedeploy: true` to get the old behaviour back. + +### 5. Switching an existing release over: delete the Secret Helm can no longer clean + +When you set one of these hooks on a release that is already running, the chart stops +rendering the keys the hook replaces — and Helm does **not** remove them from the live +Secret. The chart's Secret templates write `stringData`, the API server stores `data`, and +the three-way merge patches a field the live object does not have. The old key survives. + +For most groups that is untidy but harmless, because the replacement arrives as an explicit +`env` entry with a `secretKeyRef`, and an explicit `env` beats every `envFrom` source. + +**Storage is the exception, and it fails in a way that looks like something else.** Turning +`services.minio.local_setup` off makes the chart omit `AWS_ACCESS_KEY_ID` so that boto3 walks +its credential chain and finds the pod's IAM identity. If the previous revision ran bundled +MinIO, its root credentials are still in `-doc-store-secrets` — and a *present* +access key is found first in that chain, so every S3 call fails with +`InvalidClientTokenId` while `helm get manifest` shows a perfectly correct configuration. + +Once, on the upgrade that switches storage over: + +```sh +kubectl delete secret -doc-store-secrets -n +helm upgrade plane/plane-enterprise -n -f values.yaml +# then restart, because envFrom is read once at container start: +kubectl rollout restart deploy -n -l app.kubernetes.io/instance= +``` + +The rollout restart is not optional. A running pod keeps the environment it started with, so +the pods carry the old access key until they are replaced — which is why this can look like +it "did not work" after the Secret is already correct. + +From chart 3.6.1 onward that Secret is rendered as base64 `data` rather than `stringData`, so +Helm can express the deletion and this stops recurring. The one-time cleanup above is still +needed for the upgrade that crosses into 3.6.1. + +### 6. Secrets that live in the database, not the environment + +By default (`SKIP_ENV_VAR=1`) the API reads about twenty settings — SMTP password, Google/GitHub/GitLab/OIDC client secrets, `LLM_API_KEY`, `LDAP_BIND_PASSWORD`, SAML certificate — from the instance-configuration table, seeded from the environment only on first startup. **Rotating those through a Secret has no effect** while that is the case. + +Set `env.skip_env_var: '0'` to make the API re-read them from the environment on every start, which makes the external secret the source of truth: + +```yaml +env: + skip_env_var: '0' +``` + +The trade-off: edits made to those settings in the god-mode admin UI are overwritten on the next restart. + +### AI providers, including Amazon Bedrock + +`external_secrets.ai_providers_existingSecret` replaces the whole provider-key group — +`OPENAI_API_KEY`, `CLAUDE_API_KEY`, `GROQ_API_KEY`, `COHERE_API_KEY`, `CUSTOM_LLM_API_KEY` and +`AWS_BEARER_TOKEN_BEDROCK`. It is mounted with `envFrom`, so **any** key in that Secret reaches the +pi workloads and live: adding a provider is an edit in your secret store, not in this chart. + +Which keys you actually need depends on what the model is, and the answer is less obvious than it +looks. `COHERE_API_KEY` and `BR_AWS_ACCESS_KEY_ID` are consumed only when pi *creates* an OpenSearch +ML connector (`python -m pi.manage init-embedding-model`), because the credential is stored inside +the connector. Point `services.pi.ai_providers.embedding_model.model_id` at a connector that already +exists and neither is read at all — the connector carries its own credential. + +`services.pi.ai_providers.embedding_model.name` must name the model that `model_id` actually points +at. Getting this wrong is quiet: several registry entries share a dimension, so pi's dimension +consistency check passes, and the mismatch only shows up as failed embeddings at ingest — the +entries differ in `supports_batch` (Bedrock Titan accepts a single `inputText`, Cohere accepts +arrays) and in which credential they expect. + +For Bedrock there are two shapes: + +```yaml +# 1. Bedrock API key — a bearer token from the Bedrock console. botocore honours +# AWS_BEARER_TOKEN_BEDROCK natively (>= 1.39), so no application support is needed. +services: { pi: { ai_providers: { bedrock: { enabled: true, api_key: 'ABSKQmVk...' } } } } + +# 2. Keyless, preferred on AWS — no api_key at all, so boto3's chain reaches the pod's +# IRSA / EKS Pod Identity credential and nothing is stored in the cluster. +services: + pi: + ai_providers: + bedrock: + enabled: true + inference_profile_arn: 'arn:aws:bedrock:us-east-1:…:application-inference-profile/…' +``` + +The key is omitted rather than rendered empty when unset, for the reason that recurs throughout this +chart: an empty credential is *present*, and a present credential denies the chain its turn. + +### Settings reference + +| Setting | Default | Description | +| --- | :-: | --- | +| `serviceAccount.create` | true | Set `false` to reference a ServiceAccount managed outside the chart. | +| `serviceAccount.name` | | Defaults to `-srv-account`. | +| `serviceAccount.annotations` | {} | Cloud workload-identity bindings (IRSA, GKE WI, Azure WI). | +| `serviceAccount.podLabels` | {} | Extra pod-template labels; Azure Workload Identity needs `azure.workload.identity/use: 'true'`. | +| `external_secrets.database.*` | | Postgres credentials from an existing Secret — see above. | +| `external_secrets.rabbitmq.*` | | RabbitMQ credentials from an existing Secret. | +| `external_secrets.redis.*` | | Redis password from an existing Secret (needs planeVersion v3.1.0+). | +| `external_secrets.opensearch.*` | | OpenSearch username/password from an existing Secret; remote domains only. | +| `env.rabbitmq_ssl` | false | Connect to RabbitMQ over TLS (`amqps`). Required by Amazon MQ. | +| `env.redis_ssl` | false | Connect to Redis over TLS (`rediss`). Required by ElastiCache with in-transit encryption and Azure Cache. | +| `external_secrets.app_keys_existingSecret` | | One Secret for the shared signing/encryption keys. | +| `external_secrets.ssl_token_existingSecret` | | DNS-01 API token for the cert-manager Issuer; must contain the key `api-token`. | +| `reloader.enabled` | false | Annotate workloads for Stakater Reloader so a rotated Secret triggers a rolling restart. | +| `env.requireExplicitSecrets` | false | Fail the render instead of falling back to the chart's public example keys. Will default to `true` in the next major version. | +| `env.skip_env_var` | '1' | `'0'` makes the API re-read the database-resident secrets (SMTP, OAuth, LLM, LDAP) from the environment on every start. | +| `global.forceRedeploy` | false | Restart every workload on every `helm upgrade`, as versions before 3.1.0 did. Off means upgrades roll only what changed. | + +### Provider examples + +Ready-to-apply `ExternalSecret` manifests for AWS Secrets Manager, GCP Secret Manager and Azure Key Vault, plus a rotation runbook, are in [`examples/external-secrets/`](examples/external-secrets/). ### Observability (OpenTelemetry) Opt-in OpenTelemetry (traces, logs and metrics) for the backend services. Nothing is @@ -926,6 +1205,12 @@ Secret you manage yourself (External Secrets Operator, Vault, sealed-secrets, .. ## External Secrets Config +The tables below document the whole-Secret replacement groups (`*_existingSecret`): when you set one, the chart skips rendering that Secret and every workload reads yours instead, so it must carry **all** the keys listed for that group. + +> Prefer `external_secrets.database` / `rabbitmq` / `redis` for connection credentials (see above) — those need only the username and password your cloud secret already contains, and they rotate without recomposing a URL. `pgdb_existingSecret` and `rabbitmq_existingSecret` configure the **bundled** `local_setup` Postgres/RabbitMQ, not the application's connection to a managed one. +> +> `runner_env_existingSecret` (key: `RUNNER_HMAC_SECRET_KEY`) also exists and is honoured, alongside `ssl_token_existingSecret` (key: `api-token`, for the cert-manager DNS-01 issuer) and `dockerRegistry.existingSecret`. + To configure the external secrets for your application, you need to define specific environment variables for each secret category. Below is a list of the required secrets and their respective environment variables. | Secret Name | Env Var Name | Required | Description | Example Value | diff --git a/charts/plane-enterprise/examples/external-secrets/README.md b/charts/plane-enterprise/examples/external-secrets/README.md new file mode 100644 index 00000000..57faa832 --- /dev/null +++ b/charts/plane-enterprise/examples/external-secrets/README.md @@ -0,0 +1,73 @@ +# External Secrets Operator examples + +Ready-to-adapt manifests for feeding the `plane-enterprise` chart from a cloud secret store. + +The chart consumes plain Kubernetes Secrets, so nothing here is chart-specific plumbing — it is ordinary External Secrets Operator configuration. Pick the file for your provider: + +| File | Provider | +| --- | --- | +| [`aws-secrets-manager.yaml`](aws-secrets-manager.yaml) | AWS Secrets Manager (RDS / Amazon MQ / ElastiCache) | +| [`gcp-secret-manager.yaml`](gcp-secret-manager.yaml) | GCP Secret Manager (CloudSQL / Memorystore) | +| [`azure-key-vault.yaml`](azure-key-vault.yaml) | Azure Key Vault (Flexible Server / Cache for Redis) | +| [`rotation-runbook.md`](rotation-runbook.md) | How to rotate without dropping requests | + +## The idea in one paragraph + +A managed-rotation secret from RDS or CloudSQL contains only `{"username": "...", "password": "..."}`. Mirror it into the cluster **verbatim** with a plain `dataFrom.extract` — no `rewrite`, no `template` — and tell the chart which keys hold the username and password. The chart wires those keys into the pods as `POSTGRES_USER` / `POSTGRES_PASSWORD` and supplies the non-secret endpoint from `values.yaml`. The application composes its own connection URL from the parts, so **a rotation never requires recomposing a URL and there is only one secret to watch**. + +```yaml +# values.yaml +external_secrets: + database: + secretName: plane-rds # the mirrored secret + usernameKey: username # keys as they appear inside it + passwordKey: password +env: + pgdb_host: plane.abc123.eu-west-1.rds.amazonaws.com + pgdb_name: plane +``` + +## Prerequisites + +```bash +# External Secrets Operator +helm repo add external-secrets https://charts.external-secrets.io +helm install external-secrets external-secrets/external-secrets \ + -n external-secrets --create-namespace + +# Stakater Reloader — restarts pods when a synced Secret changes. +# Without this a rotated credential never reaches a running pod. +helm repo add stakater https://stakater.github.io/stakater-charts +helm install reloader stakater/reloader -n reloader --create-namespace +``` + +Then in the chart's values: + +```yaml +reloader: + enabled: true +``` + +## Choosing refreshInterval + +`refreshInterval` bounds how long a rotated credential stays unnoticed, and each interval costs one API call per `ExternalSecret` per provider. + +- **`1h`** — the sensible default for secrets you rotate on a schedule and where you use the two-valid-credentials pattern from the runbook, so the window is harmless. +- **`1m`–`5m`** — when a single credential is swapped in place and the failure window must be short. + +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/aws-secrets-manager.yaml b/charts/plane-enterprise/examples/external-secrets/aws-secrets-manager.yaml new file mode 100644 index 00000000..0fa55c6c --- /dev/null +++ b/charts/plane-enterprise/examples/external-secrets/aws-secrets-manager.yaml @@ -0,0 +1,217 @@ +# External Secrets Operator -> AWS Secrets Manager, for the plane-enterprise chart. +# +# Replace: NAMESPACE, REGION, ACCOUNT_ID, the secret names, and the release name in +# the ServiceAccount reference. +# +# Authentication uses EKS Pod Identity or IRSA on the External Secrets Operator's own +# ServiceAccount — no static keys. The IAM role needs +# secretsmanager:GetSecretValue + DescribeSecret on the secrets referenced below. +--- +apiVersion: external-secrets.io/v1 +kind: SecretStore +metadata: + name: aws-secrets-manager + namespace: NAMESPACE +spec: + provider: + aws: + service: SecretsManager + region: REGION + auth: + jwt: + serviceAccountRef: + # ESO's ServiceAccount, annotated with eks.amazonaws.com/role-arn + name: external-secrets + namespace: external-secrets +--- +# 1. DATABASE — mirror the RDS-managed secret verbatim. +# +# An RDS managed-rotation secret contains only {"username", "password"}; a secret +# created by RDS for a non-master user also carries host/port/dbname. Either way, +# dataFrom.extract copies whatever keys exist straight through: no rewrite, no +# template, nothing to recompose when the password rotates. +# +# Chart side: +# external_secrets.database.secretName: plane-rds +# external_secrets.database.usernameKey: username +# external_secrets.database.passwordKey: password +# env.pgdb_host / pgdb_port / pgdb_name <- endpoint (not secret) +# If your secret carries the endpoint too, set hostKey/portKey/dbNameKey instead. +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: plane-rds + namespace: NAMESPACE +spec: + refreshInterval: 1h + secretStoreRef: + name: aws-secrets-manager + kind: SecretStore + target: + name: plane-rds + creationPolicy: Owner + dataFrom: + - extract: + # The secret RDS created with the instance, e.g. rds!db-1234abcd-... + key: rds!db-REPLACE-ME +--- +# 2. RABBITMQ (Amazon MQ) — same pattern. +# +# Chart side: +# external_secrets.rabbitmq.secretName: plane-amazonmq +# env.rabbitmq_host +# env.rabbitmq_port: '5671' +# env.rabbitmq_ssl: true <- REQUIRED: Amazon MQ refuses plaintext AMQP, and the +# discrete-parts path has no URL scheme to imply TLS. +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: plane-amazonmq + namespace: NAMESPACE +spec: + refreshInterval: 1h + secretStoreRef: + name: aws-secrets-manager + kind: SecretStore + target: + name: plane-amazonmq + creationPolicy: Owner + dataFrom: + - extract: + key: plane/amazonmq +--- +# 3. REDIS (ElastiCache auth token). +# +# Chart side (needs planeVersion v3.1.0+): +# external_secrets.redis.secretName: plane-elasticache +# external_secrets.redis.passwordKey: password +# env.redis_host, env.redis_ssl: true +# +# ElastiCache supports two simultaneously valid auth tokens — use that for +# zero-window rotation (see rotation-runbook.md). +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: plane-elasticache + namespace: NAMESPACE +spec: + refreshInterval: 1h + secretStoreRef: + name: aws-secrets-manager + kind: SecretStore + target: + name: plane-elasticache + creationPolicy: Owner + dataFrom: + - extract: + key: plane/elasticache +--- +# 4. OPENSEARCH — only needed when the domain uses basic auth. +# +# On AWS the better option is usually no secret at all: leave +# env.opensearch_remote_username / _password empty and external_secrets.opensearch +# unset, and the API authenticates to the domain with SigV4 using the pod's IAM role +# (serviceAccount.annotations). Use this only for a domain with the internal user +# database enabled. +# +# Chart side: +# external_secrets.opensearch.secretName: plane-opensearch +# env.opensearch_remote_url: https://search-plane.REGION.es.amazonaws.com +# +# Applies to the API workloads and the Plane AI workloads, which query the same domain. +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: plane-opensearch + namespace: NAMESPACE +spec: + refreshInterval: 1h + secretStoreRef: + name: aws-secrets-manager + kind: SecretStore + target: + name: plane-opensearch + creationPolicy: Owner + dataFrom: + - extract: + key: plane/opensearch # {"username": "...", "password": "..."} +--- +# 5. SHARED SIGNING KEYS — external_secrets.app_keys_existingSecret: plane-app-keys +# +# NEVER attach a rotation policy to this secret: SECRET_KEY and AES_SECRET_KEY/AES_SALT +# are key-encryption keys. Rotating them makes existing ciphertext undecryptable, and +# the failure is silent. +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: plane-app-keys + namespace: NAMESPACE +spec: + refreshInterval: 1h + secretStoreRef: + name: aws-secrets-manager + kind: SecretStore + target: + name: plane-app-keys + creationPolicy: Owner + data: + - secretKey: SECRET_KEY + remoteRef: { key: plane/app-keys, property: SECRET_KEY } + - secretKey: AES_SECRET_KEY + remoteRef: { key: plane/app-keys, property: AES_SECRET_KEY } + - secretKey: AES_SALT + remoteRef: { key: plane/app-keys, property: AES_SALT } + - secretKey: LIVE_SERVER_SECRET_KEY + remoteRef: { key: plane/app-keys, property: LIVE_SERVER_SECRET_KEY } + - secretKey: PI_INTERNAL_SECRET + remoteRef: { key: plane/app-keys, property: PI_INTERNAL_SECRET } + - secretKey: SILO_HMAC_SECRET_KEY + remoteRef: { key: plane/app-keys, property: SILO_HMAC_SECRET_KEY } + - secretKey: CURSOR_WEBHOOK_SECRET + remoteRef: { key: plane/app-keys, property: CURSOR_WEBHOOK_SECRET } +--- +# 6. SILO — the one place a composed DSN is still needed. +# +# Silo, live and Plane AI read a connection URL rather than discrete parts, so here ESO +# builds the URL from the mirrored RDS secret with a template. Note urlEncode on the +# password: a generated password containing @ : / # would otherwise break the URL. +# +# Chart side: external_secrets.silo_env_existingSecret: plane-silo-env +# This Secret replaces the chart's silo Secret entirely, so it must also carry the +# connector OAuth secrets you use (GITHUB_CLIENT_SECRET, SLACK_CLIENT_SECRET, ...). +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: plane-silo-env + namespace: NAMESPACE +spec: + refreshInterval: 1h + secretStoreRef: + name: aws-secrets-manager + kind: SecretStore + target: + name: plane-silo-env + creationPolicy: Owner + template: + engine: v2 + data: + DATABASE_URL: >- + postgresql://{{ .username }}:{{ .password | urlEncode }}@plane.abc123.REGION.rds.amazonaws.com:5432/plane?sslmode=require + AMQP_URL: >- + amqps://{{ .mq_username }}:{{ .mq_password | urlEncode }}@b-1.plane.mq.REGION.amazonaws.com:5671/ + REDIS_URL: >- + rediss://:{{ .redis_password | urlEncode }}@plane.abc.cache.amazonaws.com:6379 + GITHUB_CLIENT_SECRET: '{{ .github_client_secret }}' + data: + - secretKey: username + remoteRef: { key: rds!db-REPLACE-ME, property: username } + - secretKey: password + remoteRef: { key: rds!db-REPLACE-ME, property: password } + - secretKey: mq_username + remoteRef: { key: plane/amazonmq, property: username } + - secretKey: mq_password + remoteRef: { key: plane/amazonmq, property: password } + - secretKey: redis_password + remoteRef: { key: plane/elasticache, property: password } + - secretKey: github_client_secret + remoteRef: { key: plane/connectors, property: github_client_secret } diff --git a/charts/plane-enterprise/examples/external-secrets/azure-key-vault.yaml b/charts/plane-enterprise/examples/external-secrets/azure-key-vault.yaml new file mode 100644 index 00000000..8eefef3a --- /dev/null +++ b/charts/plane-enterprise/examples/external-secrets/azure-key-vault.yaml @@ -0,0 +1,211 @@ +# External Secrets Operator -> Azure Key Vault, for the plane-enterprise chart. +# +# Replace: NAMESPACE, VAULT_NAME, and the secret names. +# +# Authentication uses Azure Workload Identity on the External Secrets Operator's own +# ServiceAccount — no static keys. The bound managed identity needs "Key Vault Secrets +# User" (or a get/list access policy) on the vault. +# +# Note on naming: Key Vault secret names allow only alphanumerics and hyphens, so the +# key names below use hyphens and are mapped onto the env var names the chart expects. +--- +apiVersion: external-secrets.io/v1 +kind: SecretStore +metadata: + name: azure-key-vault + namespace: NAMESPACE +spec: + provider: + azurekv: + authType: WorkloadIdentity + vaultUrl: https://VAULT_NAME.vault.azure.net + serviceAccountRef: + # ESO's ServiceAccount, annotated with azure.workload.identity/client-id + name: external-secrets + namespace: external-secrets +--- +# 1. DATABASE (Azure Database for PostgreSQL Flexible Server). +# +# Key Vault secrets are single values, so map each one to a key. Storing the username +# and password as two secrets keeps the mapping explicit; a rotation replaces only the +# password secret's version and nothing here changes. +# +# Chart side: +# external_secrets.database.secretName: plane-postgres +# external_secrets.database.usernameKey: username +# external_secrets.database.passwordKey: password +# env.pgdb_host: plane.postgres.database.azure.com +# env.pgdb_name: plane +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: plane-postgres + namespace: NAMESPACE +spec: + refreshInterval: 1h + secretStoreRef: + name: azure-key-vault + kind: SecretStore + target: + name: plane-postgres + creationPolicy: Owner + data: + - secretKey: username + remoteRef: + key: plane-postgres-username + - secretKey: password + remoteRef: + key: plane-postgres-password +--- +# Alternative: if you store the credential as a single JSON secret +# ({"username": "...", "password": "..."}), extract it verbatim instead: +# +# dataFrom: +# - extract: +# key: plane-postgres +--- +# 2. RABBITMQ. +# +# Chart side: +# external_secrets.rabbitmq.secretName: plane-rabbitmq +# env.rabbitmq_host / rabbitmq_port / rabbitmq_vhost +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: plane-rabbitmq + namespace: NAMESPACE +spec: + refreshInterval: 1h + secretStoreRef: + name: azure-key-vault + kind: SecretStore + target: + name: plane-rabbitmq + creationPolicy: Owner + data: + - secretKey: username + remoteRef: + key: plane-rabbitmq-username + - secretKey: password + remoteRef: + key: plane-rabbitmq-password +--- +# 3. REDIS (Azure Cache for Redis access key). +# +# Azure Cache exposes a primary and a secondary access key, both valid at once — that +# is the two-credential pattern the rotation runbook describes. Rotate the secondary, +# point this secret at it, let Reloader roll, then rotate the primary. +# +# Chart side (needs planeVersion v3.1.0+): +# external_secrets.redis.secretName: plane-redis +# external_secrets.redis.passwordKey: password +# env.redis_host: plane.redis.cache.windows.net +# env.redis_port: '6380' +# env.redis_ssl: true +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: plane-redis + namespace: NAMESPACE +spec: + refreshInterval: 1h + secretStoreRef: + name: azure-key-vault + kind: SecretStore + target: + name: plane-redis + creationPolicy: Owner + data: + - secretKey: password + remoteRef: + key: plane-redis-primary-key +--- +# 4. SHARED SIGNING KEYS — external_secrets.app_keys_existingSecret: plane-app-keys +# +# NEVER rotate this secret: SECRET_KEY and AES_SECRET_KEY/AES_SALT are key-encryption +# keys, and changing them makes existing ciphertext undecryptable, silently. Disable +# any Key Vault rotation policy on these. +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: plane-app-keys + namespace: NAMESPACE +spec: + refreshInterval: 1h + secretStoreRef: + name: azure-key-vault + kind: SecretStore + target: + name: plane-app-keys + creationPolicy: Owner + data: + - secretKey: SECRET_KEY + remoteRef: { key: plane-secret-key } + - secretKey: AES_SECRET_KEY + remoteRef: { key: plane-aes-secret-key } + - secretKey: AES_SALT + remoteRef: { key: plane-aes-salt } + - secretKey: LIVE_SERVER_SECRET_KEY + remoteRef: { key: plane-live-server-secret-key } + - secretKey: PI_INTERNAL_SECRET + remoteRef: { key: plane-pi-internal-secret } + - secretKey: SILO_HMAC_SECRET_KEY + remoteRef: { key: plane-silo-hmac-secret-key } + - secretKey: CURSOR_WEBHOOK_SECRET + remoteRef: { key: plane-cursor-webhook-secret } +--- +# 5. OBJECT STORAGE +# +# Plane's storage layer speaks S3 and GCS. On Azure, point it at an S3-compatible +# gateway or use an S3-compatible object store, and deliver its keys through +# doc_store_existingSecret (that Secret replaces the chart's doc-store Secret, so it +# must also carry USE_MINIO "0", AWS_S3_BUCKET_NAME, AWS_S3_ENDPOINT_URL, +# STORAGE_PROVIDER and FILE_SIZE_LIMIT). +# +# Azure Workload Identity for the Plane pods themselves (used by any Azure SDK calls) +# needs both an annotation and a pod label: +# +# serviceAccount: +# annotations: +# azure.workload.identity/client-id: 00000000-0000-0000-0000-000000000000 +# podLabels: +# azure.workload.identity/use: 'true' +--- +# 6. SILO / LIVE / PLANE AI — these read a connection URL, not discrete parts, so ESO +# composes the DSN here. urlEncode matters: Azure Redis access keys end in '=' and +# generated passwords often contain @ : / #, any of which would break the URL. +# +# Chart side: external_secrets.silo_env_existingSecret: plane-silo-env +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: plane-silo-env + namespace: NAMESPACE +spec: + refreshInterval: 1h + secretStoreRef: + name: azure-key-vault + kind: SecretStore + target: + name: plane-silo-env + creationPolicy: Owner + template: + engine: v2 + data: + DATABASE_URL: >- + postgresql://{{ .username }}:{{ .password | urlEncode }}@plane.postgres.database.azure.com:5432/plane?sslmode=require + REDIS_URL: >- + rediss://:{{ .redis_password | urlEncode }}@plane.redis.cache.windows.net:6380 + AMQP_URL: >- + amqp://{{ .mq_username }}:{{ .mq_password | urlEncode }}@rabbitmq.internal:5672/ + data: + - secretKey: username + remoteRef: { key: plane-postgres-username } + - secretKey: password + remoteRef: { key: plane-postgres-password } + - secretKey: redis_password + remoteRef: { key: plane-redis-primary-key } + - secretKey: mq_username + remoteRef: { key: plane-rabbitmq-username } + - secretKey: mq_password + remoteRef: { key: plane-rabbitmq-password } diff --git a/charts/plane-enterprise/examples/external-secrets/gcp-secret-manager.yaml b/charts/plane-enterprise/examples/external-secrets/gcp-secret-manager.yaml new file mode 100644 index 00000000..c1903ffb --- /dev/null +++ b/charts/plane-enterprise/examples/external-secrets/gcp-secret-manager.yaml @@ -0,0 +1,183 @@ +# External Secrets Operator -> GCP Secret Manager, for the plane-enterprise chart. +# +# Replace: NAMESPACE, PROJECT_ID, the GKE clusterLocation/clusterName in the +# SecretStore below, and the secret names. +# +# Authentication uses GKE Workload Identity on the External Secrets Operator's own +# ServiceAccount — no static keys. The bound Google service account needs +# roles/secretmanager.secretAccessor on the secrets referenced below. +--- +apiVersion: external-secrets.io/v1 +kind: SecretStore +metadata: + name: gcp-secret-manager + namespace: NAMESPACE +spec: + provider: + gcpsm: + projectID: PROJECT_ID + auth: + workloadIdentity: + clusterLocation: europe-west1 + clusterName: plane + serviceAccountRef: + # ESO's ServiceAccount, annotated with iam.gke.io/gcp-service-account + name: external-secrets + namespace: external-secrets +--- +# 1. DATABASE (CloudSQL) — mirror the credential secret verbatim. +# +# Store the secret in Secret Manager as JSON so extract can split it into keys: +# {"username": "plane", "password": "..."} +# Nothing here needs changing when the password rotates — only the secret version does. +# +# Chart side: +# external_secrets.database.secretName: plane-cloudsql +# external_secrets.database.usernameKey: username +# external_secrets.database.passwordKey: password +# env.pgdb_host: 10.x.x.x (private IP, or 127.0.0.1 with the auth proxy) +# env.pgdb_name: plane +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: plane-cloudsql + namespace: NAMESPACE +spec: + refreshInterval: 1h + secretStoreRef: + name: gcp-secret-manager + kind: SecretStore + target: + name: plane-cloudsql + creationPolicy: Owner + dataFrom: + - extract: + key: plane-cloudsql # JSON: {"username": "...", "password": "..."} +--- +# 2. RABBITMQ — a self-managed or third-party broker on GCP. +# +# Chart side: +# external_secrets.rabbitmq.secretName: plane-rabbitmq +# env.rabbitmq_host / rabbitmq_port / rabbitmq_vhost +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: plane-rabbitmq + namespace: NAMESPACE +spec: + refreshInterval: 1h + secretStoreRef: + name: gcp-secret-manager + kind: SecretStore + target: + name: plane-rabbitmq + creationPolicy: Owner + dataFrom: + - extract: + key: plane-rabbitmq # JSON: {"username": "...", "password": "..."} +--- +# 3. REDIS (Memorystore AUTH string). +# +# Memorystore stores the AUTH string as a plain value rather than JSON, so map it to a +# single key with `data` instead of extracting. +# +# Chart side (needs planeVersion v3.1.0+): +# external_secrets.redis.secretName: plane-memorystore +# external_secrets.redis.passwordKey: password +# env.redis_host, env.redis_ssl: true (true when in-transit encryption is on) +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: plane-memorystore + namespace: NAMESPACE +spec: + refreshInterval: 1h + secretStoreRef: + name: gcp-secret-manager + kind: SecretStore + target: + name: plane-memorystore + creationPolicy: Owner + data: + - secretKey: password + remoteRef: + key: plane-memorystore-auth +--- +# 4. SHARED SIGNING KEYS — external_secrets.app_keys_existingSecret: plane-app-keys +# +# NEVER rotate this secret: SECRET_KEY and AES_SECRET_KEY/AES_SALT are key-encryption +# keys, and changing them makes existing ciphertext undecryptable, silently. +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: plane-app-keys + namespace: NAMESPACE +spec: + refreshInterval: 1h + secretStoreRef: + name: gcp-secret-manager + kind: SecretStore + target: + name: plane-app-keys + creationPolicy: Owner + dataFrom: + - extract: + # JSON holding SECRET_KEY, AES_SECRET_KEY, AES_SALT, + # LIVE_SERVER_SECRET_KEY, PI_INTERNAL_SECRET, SILO_HMAC_SECRET_KEY, + # CURSOR_WEBHOOK_SECRET + key: plane-app-keys +--- +# 5. OBJECT STORAGE — prefer no secret at all. +# +# With GKE Workload Identity, leave env.gcs_credentials_json empty and annotate the +# chart's ServiceAccount; the API then uses Application Default Credentials: +# +# serviceAccount: +# annotations: +# iam.gke.io/gcp-service-account: plane@PROJECT_ID.iam.gserviceaccount.com +# env: +# storage_provider: GCS +# gcs_bucket_name: plane-uploads +# gcs_project_id: PROJECT_ID +# use_storage_proxy: true # browser uploads proxied; bucket needs no CORS +# +# Only if you cannot use Workload Identity, deliver a service-account key through +# doc_store_existingSecret with a GCS_CREDENTIALS_JSON key. That Secret replaces the +# chart's doc-store Secret entirely, so it must also carry STORAGE_PROVIDER, +# GCS_BUCKET_NAME, USE_MINIO ("0"), AWS_S3_BUCKET_NAME and FILE_SIZE_LIMIT. +--- +# 6. SILO / LIVE / PLANE AI — these read a connection URL, not discrete parts, so ESO +# composes the DSN here. urlEncode matters: a generated password containing @ : / # +# would otherwise break the URL. +# +# Chart side: external_secrets.silo_env_existingSecret: plane-silo-env +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: plane-silo-env + namespace: NAMESPACE +spec: + refreshInterval: 1h + secretStoreRef: + name: gcp-secret-manager + kind: SecretStore + target: + name: plane-silo-env + creationPolicy: Owner + template: + engine: v2 + data: + DATABASE_URL: 'postgresql://{{ .username }}:{{ .password | urlEncode }}@10.0.0.3:5432/plane?sslmode=require' + REDIS_URL: 'rediss://:{{ .redis_password | urlEncode }}@10.0.0.4:6378' + AMQP_URL: 'amqp://{{ .mq_username }}:{{ .mq_password | urlEncode }}@rabbitmq.internal:5672/' + data: + - secretKey: username + remoteRef: { key: plane-cloudsql, property: username } + - secretKey: password + remoteRef: { key: plane-cloudsql, property: password } + - secretKey: redis_password + remoteRef: { key: plane-memorystore-auth } + - secretKey: mq_username + remoteRef: { key: plane-rabbitmq, property: username } + - secretKey: mq_password + remoteRef: { key: plane-rabbitmq, property: password } diff --git a/charts/plane-enterprise/examples/external-secrets/rotation-runbook.md b/charts/plane-enterprise/examples/external-secrets/rotation-runbook.md new file mode 100644 index 00000000..f3d13241 --- /dev/null +++ b/charts/plane-enterprise/examples/external-secrets/rotation-runbook.md @@ -0,0 +1,112 @@ +# Credential rotation runbook + +How to rotate Plane's credentials without dropping requests, and which ones must never be rotated at all. + +## How a rotation reaches a running pod + +``` +rotate in cloud secret store + ↓ ESO polls, bounded by refreshInterval +Kubernetes Secret updated + ↓ Reloader sees the change (needs reloader.enabled: true) +rolling restart, maxUnavailable: 0 / maxSurge: 1 + ↓ +pods running with the new credential +``` + +Nothing in this chain is instant. Between the credential changing on the server and the new pods being ready, anything that opens a connection with the old credential fails — and Plane's Django services keep no persistent database connections, so in that window that is every request. Budget roughly `refreshInterval + rollout time`. + +The fix is not a shorter interval. It is making **both the old and the new credential valid at the same time**, so the window stops mattering. + +## Postgres — two-user alternation (zero window) + +One-time setup: + +```sql +CREATE ROLE plane_a LOGIN PASSWORD ''; +CREATE ROLE plane_b LOGIN PASSWORD ''; + +-- identical grants for both +GRANT ALL PRIVILEGES ON DATABASE plane TO plane_a, plane_b; +GRANT ALL ON SCHEMA public TO plane_a, plane_b; +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT ALL ON TABLES TO plane_a, plane_b; +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT ALL ON SEQUENCES TO plane_a, plane_b; +``` + +To rotate, say the pods are currently on `plane_a`: + +1. Change the password of the **idle** user: `ALTER ROLE plane_b PASSWORD '';` +2. Update the cloud secret to `{"username": "plane_b", "password": ""}`. +3. Wait for ESO to sync, then let Reloader roll the pods. `plane_a` stays valid throughout, so in-flight and newly opened connections keep working until the last old pod exits. +4. Confirm the rollout completed (`kubectl rollout status deploy/-api-wl`), then optionally scramble `plane_a`'s password. It becomes the idle user for the next rotation. + +Postgres does not terminate existing sessions when a role's password changes, so even a single-user rotation leaves established connections alive — but every *new* connection fails until the pods restart. The alternation is what removes that. + +## RabbitMQ — second user (zero window) + +Same shape. Create a second user with identical permissions on the vhost, rotate the idle one, switch the secret, let Reloader roll. + +```bash +rabbitmqctl add_user plane_b '' +rabbitmqctl set_permissions -p / plane_b '.*' '.*' '.*' +``` + +On Amazon MQ, add the second user through the broker's user configuration; on a bundled `local_setup` broker this does not apply (see below). + +## Redis + +- **ElastiCache** — supports two simultaneously valid auth tokens. Use `ROTATE` to add the new token while the old one still works, point the secret at the new one, let Reloader roll, then `SET` to drop the old one. +- **Azure Cache for Redis** — the primary and secondary access keys are both always valid. Point the secret at the secondary, roll, then regenerate the primary. +- **Plain Redis / Valkey** — only one password exists, so a window is unavoidable. Use `refreshInterval: 30s` and rotate during low traffic. Django's cache is configured with `IGNORE_EXCEPTIONS`, so cache operations degrade instead of erroring, but throttling counters and the raw client paths will fail during the window. + +## Credentials that must never be rotated + +| Key | Why | +| --- | --- | +| `SECRET_KEY` | Derives the Fernet key encrypting the instance-configuration rows — SMTP password, OAuth client secrets, LLM keys, LDAP bind password. Rotating it makes every one of those rows undecryptable. | +| `AES_SECRET_KEY`, `AES_SALT` | Key material for the AES-256-GCM encryption protecting stored OAuth application secrets, MCP connections and desktop handoff tokens. | + +Both fail **silently**: decryption errors are swallowed and the values come back empty, so the damage surfaces later as "SMTP stopped working" or "the GitHub integration lost its credentials". Keep them in a secret with no rotation policy — that is what `external_secrets.app_keys_existingSecret` is for. Changing them safely requires decrypting and re-encrypting the affected rows, which is a migration, not a rotation. + +`LIVE_SERVER_SECRET_KEY`, `PI_INTERNAL_SECRET` and `SILO_HMAC_SECRET_KEY` *are* rotatable, but every service that uses one must change at the same time — which is why they belong in the single `app_keys` Secret rather than being duplicated across four. + +## Things that will surprise you + +**Jobs are not reloaded.** Reloader only restarts Deployments, StatefulSets and DaemonSets. The migrator Job runs with whatever credential existed when it started, so avoid rotating during an upgrade window; if a migration fails mid-rotation, re-run `helm upgrade`. + +**The bundled `local_setup` infrastructure is out of scope.** The chart's own postgres/rabbitmq/minio/opensearch StatefulSets are intended for local and evaluation use; rotating their credentials means changing the password inside the container as well, which the chart does not orchestrate. Everything here assumes managed backends. + +**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 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 + +```bash +# 1. ESO actually synced +kubectl get externalsecret -n plane-rds \ + -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}{"\n"}' + +# 2. The Secret holds the new value +kubectl get secret -n plane-rds -o jsonpath='{.data.password}' | base64 -d + +# 3. Reloader rolled the workloads +kubectl rollout status -n deploy/-api-wl +kubectl get pods -n -l app.name=--api + +# 4. Nothing is failing to authenticate +kubectl logs -n deploy/-api-wl --tail=100 \ + | grep -i "authentication failed\|OperationalError\|NOAUTH\|ACCESS_REFUSED" +``` + +If pods sit in `CreateContainerConfigError`, the Secret named in values does not exist yet — the chart references credential Secrets with `optional: false` deliberately, so a missing or not-yet-synced Secret fails loudly instead of starting a pod with no credentials. diff --git a/charts/plane-enterprise/questions.yml b/charts/plane-enterprise/questions.yml index 30e436a2..71410050 100644 --- a/charts/plane-enterprise/questions.yml +++ b/charts/plane-enterprise/questions.yml @@ -1793,6 +1793,141 @@ 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" - variable: external_secrets.otel_env_existingSecret label: "OpenTelemetry Envs Secrets File Name" description: "Supplies OTEL_EXPORTER_OTLP_HEADERS. Leave blank to let the chart create the Secret from the OTLP Exporter Headers value." diff --git a/charts/plane-enterprise/templates/NOTES.txt b/charts/plane-enterprise/templates/NOTES.txt new file mode 100644 index 00000000..3c1d45fd --- /dev/null +++ b/charts/plane-enterprise/templates/NOTES.txt @@ -0,0 +1,158 @@ +Plane Enterprise {{ .Chart.Version }} — release {{ .Release.Name }} in namespace {{ .Release.Namespace }} + + App version: {{ .Values.planeVersion }} + URL: {{ if or .Values.ssl.tls_secret_name (and .Values.ssl.createIssuer .Values.ssl.generateCerts) }}https{{ else }}http{{ end }}://{{ .Values.license.licenseDomain }} + Admin setup: {{ if or .Values.ssl.tls_secret_name (and .Values.ssl.createIssuer .Values.ssl.generateCerts) }}https{{ else }}http{{ end }}://{{ .Values.license.licenseDomain }}/god-mode/ + +{{- $warnings := list }} + +{{- /* Credentials still living in values.yaml. */}} +{{- if and (empty .Values.external_secrets.app_keys_existingSecret) (not .Values.env.requireExplicitSecrets) }} +{{- $publicDefaults := list }} +{{- if or (empty .Values.env.secret_key) (eq .Values.env.secret_key "60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5") }}{{ $publicDefaults = append $publicDefaults "SECRET_KEY" }}{{ end }} +{{- 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 }} +{{- end }} + +{{- /* Externalized credentials with no way to pick up a rotation. */}} +{{- if and (not .Values.reloader.enabled) (or .Values.external_secrets.database.secretName .Values.external_secrets.rabbitmq.secretName .Values.external_secrets.redis.secretName .Values.external_secrets.app_keys_existingSecret) }} +{{- $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 }} + +{{- /* live's export queue has no parts path at all, on any app version. live reads only + AMQP_URL — as a full URL, or as a bare host:port paired with AMAZONMQ_SECRET_ARN + (apps/live/src/env.ts, lib/amqp-secrets.ts). It has Redis parts; it has no broker + parts. So when the RabbitMQ mirror is set, this chart correctly stops rendering a + DSN into live-secrets and live is then left with no broker. Not fatal — live boots + and only exports fail, lazily — but it fails quietly, which is worse. */}} +{{- if and .Values.external_secrets.rabbitmq.secretName (empty .Values.external_secrets.live_env_existingSecret) }} +{{- $warnings = append $warnings "The RabbitMQ mirror does not reach live. live reads only AMQP_URL and has no\n broker parts, so with external_secrets.rabbitmq.secretName set its export queue is\n unconfigured: live still serves collaborative editing, but exports fail. Supply\n external_secrets.live_env_existingSecret with a templated AMQP_URL, or keep the\n broker credential in services.rabbitmq.external_rabbitmq_url until live learns parts." }} +{{- end }} + +{{- /* +Version gate. A deployment pinned below the app release that carries a given piece of +discrete-parts support gets the warnings that follow. + +Both app changes — the API's REDIS_* support and silo/live/pi's — are expected in +v3.2.0, hence the identical bounds. They are kept as separate variables because they +come from separate app PRs: if one lands in a later release than the other, bump only +that bound. + +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.2.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 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 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 }} + +{{- /* A composed URL would shadow the discrete parts. */}} +{{- if and .Values.external_secrets.app_env_existingSecret $externalInfra }} +{{- $warnings = append $warnings (printf "Both external_secrets.app_env_existingSecret (%s) and the discrete credential\n parts are set. Make sure that Secret does NOT contain DATABASE_URL, AMQP_URL or\n REDIS_URL: the application prefers a URL when one is present, so a stale URL there\n would silently shadow the rotated credential." .Values.external_secrets.app_env_existingSecret) }} +{{- end }} + +{{- /* Redis parts need app support. */}} +{{- 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.2.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. */}} +{{- if and (eq (.Values.env.skip_env_var | default "1" | toString) "1") (or .Values.external_secrets.app_keys_existingSecret $externalInfra) }} +{{- $warnings = append $warnings "env.skip_env_var is '1', so the SMTP password, OAuth client secrets, LLM_API_KEY\n and LDAP_BIND_PASSWORD are read from the instance-configuration table in the\n database, seeded from the environment only on first startup. Rotating those through\n a Secret will have no effect. Set env.skip_env_var='0' to make the environment the\n source of truth on every restart." }} +{{- 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) (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 }} + +{{/* Keyless storage on an upgrade: the chart omits AWS_ACCESS_KEY_ID so boto3 finds the + pod identity, but Helm cannot delete a key it has stopped rendering (manifests write + stringData, live Secrets store data), so a release that previously ran bundled MinIO + keeps its root credentials in the Secret — and a present access key wins the + credential chain. Fires only when this is a keyless external-storage configuration, + which is exactly when the leftover is fatal rather than cosmetic. */}} +{{- if and (not .Values.services.minio.local_setup) (ne (.Values.env.storage_provider | default "S3" | upper) "GCS") (not .Values.env.aws_access_key) (empty .Values.external_secrets.doc_store_existingSecret) }} +{{- $warnings = append $warnings (printf "Storage is keyless (no access key), which relies on AWS_ACCESS_KEY_ID being ABSENT.\n If this release EVER ran with services.minio.local_setup=true, its MinIO root\n credentials are still in %s-doc-store-secrets: Helm cannot remove a key it no longer\n renders, and a present access key is found first in boto3's chain, so every S3 call\n fails with InvalidClientTokenId while the config looks correct. Once:\n kubectl delete secret %s-doc-store-secrets -n %s && helm upgrade ...\n kubectl rollout restart deploy -n %s -l app.kubernetes.io/instance=%s\n The restart matters — envFrom is read once at container start. Fresh installs and\n releases already on chart 3.6.1+ are unaffected." .Release.Name .Release.Name .Release.Namespace .Release.Namespace .Release.Name) }} +{{- end }} + +{{- if $warnings }} + +──────────────────────────────────────────────────────────────────────────────── +{{ len $warnings }} configuration {{ if eq (len $warnings) 1 }}warning{{ else }}warnings{{ end }}: +{{ range $i, $w := $warnings }} + {{ add1 $i }}. {{ $w }} +{{ end }} +See the "Keeping credentials out of values.yaml" section of the chart README. +──────────────────────────────────────────────────────────────────────────────── +{{- end }} + +{{- if .Values.global.forceRedeploy }} + +Note: global.forceRedeploy is on, so every workload restarts on every `helm upgrade`. +Turn it off to have upgrades roll only the workloads whose configuration changed. +{{- end }} diff --git a/charts/plane-enterprise/templates/_helpers.tpl b/charts/plane-enterprise/templates/_helpers.tpl index de139b01..9ee4ecf2 100644 --- a/charts/plane-enterprise/templates/_helpers.tpl +++ b/charts/plane-enterprise/templates/_helpers.tpl @@ -80,7 +80,17 @@ Call with a dict carrying the root context and the component values: {{- with .values.labels }} {{- toYaml . | nindent 4 }} {{- end }} - {{- with .values.annotations }} + {{- /* + Reloader reads its annotation from the workload resource, not the pod template, + so it is merged in here rather than emitted alongside the per-component + annotations — a second literal `annotations:` key would silently shadow one of + the two. A component-level annotation of the same name still wins. + */}} + {{- $annotations := deepCopy (default dict .values.annotations) }} + {{- if .context.Values.reloader.enabled }} + {{- $annotations = merge $annotations (dict "reloader.stakater.com/auto" "true") }} + {{- end }} + {{- with $annotations }} annotations: {{ toYaml . | nindent 4 }} {{- end }} {{- end }} @@ -292,3 +302,526 @@ reports its own service.name). Call with a dict and nindent, e.g. value: {{ .service | quote }} {{- end -}} {{- end -}} + +{{/* +Name of the ServiceAccount every workload runs as. Defaults to the release-scoped +account the chart creates; override with serviceAccount.name to run as a +ServiceAccount you manage yourself (e.g. one created by Crossplane/Terraform and +already bound to a cloud IAM role, or an EKS Pod Identity association target). +*/}} +{{- define "plane.serviceAccountName" -}} +{{- .Values.serviceAccount.name | default (printf "%s-srv-account" .Release.Name) -}} +{{- end -}} + +{{/* +Returns "true" when the chart should render the ServiceAccount itself. +Skipped when serviceAccount.create is false — i.e. the account is managed outside +the chart (GitOps, Terraform) and only referenced here. +*/}} +{{- define "plane.createServiceAccount" -}} +{{- if .Values.serviceAccount.create -}} +true +{{- end -}} +{{- end -}} + +{{/* +Pod-template labels required by some workload-identity implementations +(notably Azure Workload Identity, which needs azure.workload.identity/use: "true" +on the pod). Indentation is baked in for the pod-template label position, so call it +bare: {{- include "plane.serviceAccountPodLabels" . }} +*/}} +{{- define "plane.serviceAccountPodLabels" -}} +{{- with .Values.serviceAccount.podLabels }} +{{- toYaml . | nindent 8 }} +{{- end }} +{{- end -}} + +{{/* +Aggregate sha256 over every Secret/ConfigMap the chart renders from values. +Used as a pod-template annotation so `helm upgrade` rolls workloads when — and +only when — chart-rendered configuration actually changed. Secrets that live +outside the chart (External Secrets Operator, sealed secrets, manual) are not +visible here by design: rotation of those is Reloader's job, see +plane.reloaderAnnotations. + +The hash intentionally covers all config-secret templates rather than a per-workload +subset: several keys (AES_SECRET_KEY, LIVE_SERVER_SECRET_KEY, PI_INTERNAL_SECRET) +must stay in lockstep across services, so a shared trigger is the safe default. +*/}} +{{- define "plane.configChecksum" -}} +{{- $ctx := . -}} +{{- $acc := "" -}} +{{- range $f := list "app-env" "pgdb" "rabbitmqdb" "doc-store" "opensearchdb" "live-env" "silo" "pi-api-env" "runner-env" "email-env" "monitor" "outbox-poller" "webhook-consumer" "automations-consumer" "agent-consumer" -}} +{{- $acc = print $acc (include (print $ctx.Template.BasePath "/config-secrets/" $f ".yaml") $ctx) -}} +{{- end -}} +{{- $acc | sha256sum -}} +{{- end -}} + +{{/* +Pod-template annotations shared by every workload. + + checksum/config always — rolls the pod when chart-rendered config changes + timestamp only when global.forceRedeploy — restores the pre-3.1 behaviour + of rolling every workload on every upgrade + +The Reloader annotation is NOT here: Reloader watches the workload resource's own +annotations, so it is merged into plane.labelsAndAnnotations instead. + +Call with the root context. Caller must nindent to the correct depth. +*/}} +{{- define "plane.podAnnotations" -}} +checksum/config: {{ include "plane.configChecksum" . | quote }} +{{- if .Values.global.forceRedeploy }} +timestamp: {{ now | quote }} +{{- end }} +{{- end -}} + +{{/* +Rolling-update strategy that keeps full capacity during a rollout, so a +Reloader-triggered restart after a credential rotation never drops requests. +Rendered only for replicated Deployments (surge needs room to schedule). +Caller must nindent to the correct depth. +*/}} +{{- define "plane.rollingUpdateStrategy" -}} +strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + maxSurge: 1 +{{- end -}} + +{{/* +Resolve a secret value with an optional insecure fallback. + +Returns .value when set. Otherwise fails the render when env.requireExplicitSecrets +is true, and falls back to .fallback when it is false (the pre-3.1 behaviour, kept +so existing installs keep working). The fallback values shipped by this chart are +PUBLIC CONSTANTS — any production install must supply its own. + +Call with a dict: (dict "context" $ "name" "SECRET_KEY" "value" .Values.env.secret_key "fallback" "...") +*/}} +{{- define "plane.secretValue" -}} +{{- if .value -}} +{{- .value -}} +{{- else if .context.Values.env.requireExplicitSecrets -}} +{{- required (printf "%s has no value. Set it in values.yaml, or supply it through external_secrets.app_keys_existingSecret, or set env.requireExplicitSecrets=false to fall back to the chart's insecure default." .name) nil -}} +{{- else -}} +{{- .fallback -}} +{{- end -}} +{{- end -}} + +{{/* +envFrom entry for the Secret carrying the 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). Renders nothing unless +external_secrets.app_keys_existingSecret is set. + +These keys are duplicated across the api, live, silo and pi Secrets, and several of +them must agree for those services to talk to each other. Pointing all of them at +one Secret makes that agreement structural instead of something an operator has to +remember to update in four places. When it is in use the chart stops emitting those +keys in its own Secrets, so this is their only source. + +Placed first in envFrom so that a key you have already externalized through one of +the older *_existingSecret groups keeps taking precedence. + +Indentation is baked in for the container envFrom position, so call it bare: +{{- include "plane.appKeysSecretRef" . }} +*/}} +{{- define "plane.appKeysSecretRef" -}} +{{- with .Values.external_secrets.app_keys_existingSecret }} + - secretRef: + name: {{ . }} + optional: false +{{- 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 +precedence over the discrete POSTGRES_* parts. +*/}} +{{- define "plane.externalDatabase" -}} +{{- if .Values.external_secrets.database.secretName -}} +true +{{- end -}} +{{- end -}} + +{{- define "plane.externalRabbitmq" -}} +{{- if .Values.external_secrets.rabbitmq.secretName -}} +true +{{- end -}} +{{- end -}} + +{{- define "plane.externalRedis" -}} +{{- if .Values.external_secrets.redis.secretName -}} +true +{{- end -}} +{{- end -}} + +{{/* +Only meaningful for a remote OpenSearch: when the chart runs the bundled cluster it +owns those credentials on both sides, so externalizing just the application's half +would leave the two disagreeing. Use opensearch_existingSecret for that case. +*/}} +{{- define "plane.externalOpensearch" -}} +{{- if and .Values.external_secrets.opensearch.secretName (not .Values.services.opensearch.local_setup) -}} +true +{{- end -}} +{{- end -}} + +{{/* +Postgres host/port/database the app should connect to in externalized-credential +mode. Endpoint details are not secret, so they come from values (or from the +mirrored cloud secret when it happens to carry them — see hostKey/portKey/dbNameKey). +*/}} +{{- define "plane.postgresHost" -}} +{{- if .Values.services.postgres.local_setup -}} +{{- printf "%s-pgdb.%s.svc.%s" .Release.Name .Release.Namespace (.Values.env.default_cluster_domain | default "cluster.local") -}} +{{- else -}} +{{- .Values.env.pgdb_host -}} +{{- end -}} +{{- end -}} + +{{- define "plane.rabbitmqHost" -}} +{{- if .Values.services.rabbitmq.local_setup -}} +{{- printf "%s-rabbitmq.%s.svc.%s" .Release.Name .Release.Namespace (.Values.env.default_cluster_domain | default "cluster.local") -}} +{{- else -}} +{{- .Values.env.rabbitmq_host -}} +{{- end -}} +{{- end -}} + +{{- define "plane.redisHost" -}} +{{- if .Values.services.redis.local_setup -}} +{{- printf "%s-redis.%s.svc.%s" .Release.Name .Release.Namespace (.Values.env.default_cluster_domain | default "cluster.local") -}} +{{- else -}} +{{- .Values.env.redis_host -}} +{{- end -}} +{{- end -}} + +{{/* +Emit one env entry sourced from a key inside an externally managed Secret. +Call with a dict: (dict "name" "POSTGRES_USER" "secret" $name "key" $key) + +Emits a leading newline so that call sites can use a left-trim marker +({{- include ... }}) without swallowing the separator from the previous entry. +*/}} +{{- define "plane.secretKeyEnv" }} +- name: {{ .name }} + valueFrom: + secretKeyRef: + name: {{ .secret }} + key: {{ .key }} +{{- end -}} + +{{/* +Discrete infrastructure credentials for the Django services (api, workers, +consumers, poller, migrator). Renders nothing unless at least one of +external_secrets.{database,redis,rabbitmq}.secretName is set. + +Design: the cluster Secret is a verbatim mirror of the cloud secret — an RDS or +CloudSQL managed-rotation secret holds only {"username","password"} — so the chart +maps whatever keys that secret happens to use onto the env var names the app reads, +and takes the non-secret endpoint (host/port/database) from values. Nothing needs +templating or recomposing on rotation, and there is exactly one secret to watch. + +The app composes its own connection URLs from these parts, so a rotated password +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" . }} +{{- include "plane.storageCredsEnv" . }} +{{- 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 + value: {{ include "plane.postgresHost" . | quote }} +- name: POSTGRES_PORT + value: {{ .Values.env.pgdb_port | default "5432" | quote }} +- name: POSTGRES_DB + value: {{ .Values.env.pgdb_name | default "plane" | quote }} +{{- include "plane.secretKeyEnv" (dict "name" "POSTGRES_USER" "secret" $db.secretName "key" ($db.usernameKey | default "username")) }} +{{- include "plane.secretKeyEnv" (dict "name" "POSTGRES_PASSWORD" "secret" $db.secretName "key" ($db.passwordKey | default "password")) }} +{{- with $db.hostKey }} +{{- include "plane.secretKeyEnv" (dict "name" "POSTGRES_HOST" "secret" $db.secretName "key" .) }} +{{- end }} +{{- with $db.portKey }} +{{- include "plane.secretKeyEnv" (dict "name" "POSTGRES_PORT" "secret" $db.secretName "key" .) }} +{{- end }} +{{- with $db.dbNameKey }} +{{- 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 -}} + +{{/* +RabbitMQ credentials from an externally managed Secret. +*/}} +{{- define "plane.rabbitmqCredsEnv" -}} +{{- $mq := .Values.external_secrets.rabbitmq -}} +{{- if $mq.secretName }} +- name: RABBITMQ_HOST + value: {{ include "plane.rabbitmqHost" . | quote }} +- name: RABBITMQ_PORT + value: {{ .Values.env.rabbitmq_port | default "5672" | quote }} +- name: RABBITMQ_VHOST + value: {{ .Values.env.rabbitmq_vhost | default "/" | quote }} +- name: RABBITMQ_SSL + {{/* The parts path has no URL scheme to carry TLS, so it needs an explicit flag. + Amazon MQ for RabbitMQ listens on 5671 and refuses plaintext. */}} + value: {{ .Values.env.rabbitmq_ssl | default false | ternary "1" "0" | quote }} +{{- include "plane.secretKeyEnv" (dict "name" "RABBITMQ_USER" "secret" $mq.secretName "key" ($mq.usernameKey | default "username")) }} +{{- include "plane.secretKeyEnv" (dict "name" "RABBITMQ_PASSWORD" "secret" $mq.secretName "key" ($mq.passwordKey | default "password")) }} +{{- with $mq.hostKey }} +{{- include "plane.secretKeyEnv" (dict "name" "RABBITMQ_HOST" "secret" $mq.secretName "key" .) }} +{{- end }} +{{- with $mq.portKey }} +{{- include "plane.secretKeyEnv" (dict "name" "RABBITMQ_PORT" "secret" $mq.secretName "key" .) }} +{{- end }} +{{- with $mq.vhostKey }} +{{- 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 + value: {{ include "plane.redisHost" . | quote }} +- name: REDIS_PORT + value: {{ .Values.env.redis_port | default "6379" | quote }} +- name: REDIS_SSL + value: {{ .Values.env.redis_ssl | default false | ternary "1" "0" | quote }} +{{- include "plane.secretKeyEnv" (dict "name" "REDIS_PASSWORD" "secret" $redis.secretName "key" ($redis.passwordKey | default "password")) }} +{{- with $redis.hostKey }} +{{- include "plane.secretKeyEnv" (dict "name" "REDIS_HOST" "secret" $redis.secretName "key" .) }} +{{- end }} +{{- with $redis.portKey }} +{{- include "plane.secretKeyEnv" (dict "name" "REDIS_PORT" "secret" $redis.secretName "key" .) }} +{{- end }} +{{- end }} +{{- end -}} + +{{/* +OpenSearch credentials from an externally managed Secret. Kept separate from +plane.infraCredsEnv (which includes it) because Plane AI's workloads need these +without the database/broker/cache parts. + +The application reads OPENSEARCH_USERNAME and OPENSEARCH_PASSWORD directly, so there +is no URL to recompose here either. Leaving both unset on AWS makes the API use SigV4 +IAM auth instead, which is the better option when the domain supports it. + +Caller must indent to the correct depth (env list items). +*/}} +{{- define "plane.opensearchCredsEnv" -}} +{{- $os := .Values.external_secrets.opensearch -}} +{{- if include "plane.externalOpensearch" . }} +{{- include "plane.secretKeyEnv" (dict "name" "OPENSEARCH_USERNAME" "secret" $os.secretName "key" ($os.usernameKey | default "username")) }} +{{- 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" . }} +{{- include "plane.storageCredsEnv" . }} +{{- 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" . }} +{{- include "plane.storageCredsEnv" . }} +{{- end -}} diff --git a/charts/plane-enterprise/templates/certs/cert-issuers.yaml b/charts/plane-enterprise/templates/certs/cert-issuers.yaml index 850b75bc..e9811b4b 100644 --- a/charts/plane-enterprise/templates/certs/cert-issuers.yaml +++ b/charts/plane-enterprise/templates/certs/cert-issuers.yaml @@ -1,4 +1,6 @@ {{- if and .Values.ingress.enabled (or .Values.services.email_service.enabled .Values.ssl.createIssuer) (empty .Values.ssl.tls_secret_name) }} +{{- $issuerTokenSecret := .Values.external_secrets.ssl_token_existingSecret | default (printf "%s-issuer-api-token-secret" .Release.Name) }} +{{- if empty .Values.external_secrets.ssl_token_existingSecret }} apiVersion: v1 kind: Secret @@ -10,8 +12,9 @@ metadata: type: Opaque stringData: api-token: {{ .Values.ssl.token | default "default-api-token" | quote }} - + --- +{{- end }} apiVersion: cert-manager.io/v1 kind: Issuer metadata: @@ -30,14 +33,14 @@ spec: - dns01: cloudflare: apiTokenSecretRef: - name: {{ .Release.Name }}-issuer-api-token-secret + name: {{ $issuerTokenSecret }} key: api-token {{- end }} {{- if eq .Values.ssl.issuer "digitalocean" }} - dns01: digitalocean: tokenSecretRef: - name: {{ .Release.Name }}-issuer-api-token-secret + name: {{ $issuerTokenSecret }} key: api-token {{- end }} {{- if eq .Values.ssl.issuer "http" }} diff --git a/charts/plane-enterprise/templates/config-secrets/app-env.yaml b/charts/plane-enterprise/templates/config-secrets/app-env.yaml index 7675020d..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 @@ -11,36 +17,60 @@ metadata: labels: {{- include "plane.commonLabels" $ | nindent 4 }} stringData: - SECRET_KEY: {{ .Values.env.secret_key | default "60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5" | quote }} - AES_SECRET_KEY: {{ .Values.env.silo_envs.aes_secret_key | default "dsOdt7YrvxsTIFJ37pOaEVvLxN8KGBCr" | quote }} - LIVE_SERVER_SECRET_KEY: {{ .Values.env.live_server_secret_key | default "htbqvBJAgpm9bzvf3r4urJer0ENReatceh" | quote }} - PI_INTERNAL_SECRET: {{ .Values.env.pi_envs.internal_secret | default "tyfvfqvBJAgpm9bzvf3r4urJer0Ehfdubk" | quote }} + {{- 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 }} - {{- if .Values.services.redis.local_setup }} + {{- if include "plane.externalRedis" . }} + {{/* Credentials come from external_secrets.redis as discrete REDIS_* parts. + Left empty on purpose: the API prefers REDIS_URL when it 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 }} {{- end }} - {{ if .Values.services.postgres.local_setup }} + {{- if include "plane.externalDatabase" . }} + {{/* Credentials come from external_secrets.database as discrete POSTGRES_* parts. */}} + 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 }} + {{- else if .Values.env.pgdb_remote_url }} DATABASE_URL: {{ .Values.env.pgdb_remote_url}} - {{ else }} + {{- else }} DATABASE_URL: "" - {{ end }} + {{- 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 }} - {{- if .Values.services.rabbitmq.local_setup }} + {{- if include "plane.externalRabbitmq" . }} + {{/* Credentials come from external_secrets.rabbitmq as discrete RABBITMQ_* parts. */}} + 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}} {{- else }} AMQP_URL: "" - {{ end }} + {{- end }} {{- if include "plane.s3CAEnabled" . }} AWS_CA_BUNDLE: "/etc/ssl/certs/ca-certificates.crt" @@ -64,6 +94,10 @@ data: FEATURE_FLAG_SERVER_BASE_URL: "http://{{ .Release.Name }}-monitor.{{ .Release.Namespace }}.svc.{{ .Values.env.default_cluster_domain | default "cluster.local" }}:8080/" DEFAULT_API_RATE_LIMIT: {{ .Values.env.api_key_rate_limit | default "120/minute" | quote }} + {{- /* '0' makes the API re-read SMTP/OAuth/LLM/LDAP secrets from the environment on + every start instead of from the instance-configuration table, which is what + lets an external secret store rotate them. See values.yaml env.skip_env_var. */}} + SKIP_ENV_VAR: {{ .Values.env.skip_env_var | default "1" | quote }} MINIO_ENDPOINT_SSL: {{ .Values.services.minio.env.minio_endpoint_ssl | default false | ternary "1" "0" | quote }} USE_STORAGE_PROXY: {{ .Values.env.use_storage_proxy | default false | ternary "1" "0" | quote }} ALLOW_ALL_ATTACHMENT_TYPES: {{ .Values.env.allow_all_attachment_types | default false | ternary "1" "0" | quote }} diff --git a/charts/plane-enterprise/templates/config-secrets/doc-store.yaml b/charts/plane-enterprise/templates/config-secrets/doc-store.yaml index 28c0f702..9f91e7d5 100644 --- a/charts/plane-enterprise/templates/config-secrets/doc-store.yaml +++ b/charts/plane-enterprise/templates/config-secrets/doc-store.yaml @@ -7,37 +7,73 @@ metadata: name: {{ .Release.Name }}-doc-store-secrets labels: {{- include "plane.commonLabels" $ | nindent 4 }} -stringData: - FILE_SIZE_LIMIT: {{ .Values.env.doc_upload_size_limit | default "20971520" | quote }} - AWS_S3_BUCKET_NAME: {{ .Values.env.docstore_bucket | default "" | quote }} - STORAGE_PROVIDER: {{ .Values.env.storage_provider | default "S3" | upper | quote }} +{{/* `data` with b64enc, not `stringData`. This is the one Secret in the chart whose key + SET changes between valid configurations — bundled MinIO renders MINIO_ROOT_USER, + AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_S3_ENDPOINT_URL, and external + storage renders none of them. Helm cannot remove a key it stops rendering when the + manifest writes `stringData` and the live object stores `data`: the three-way merge + patches stringData, the API server keeps data, and the old key survives the upgrade. + For this Secret that is not cosmetic — a leftover AWS_ACCESS_KEY_ID is found first in + boto3's credential chain and shadows the pod's IAM identity, so every S3 call fails + with InvalidClientTokenId while the configuration looks correct. Writing `data` + directly makes the removal a real deletion. + One-time note for releases upgrading FROM a chart version that used stringData: the + already-stale keys still have to go once, with + kubectl delete secret -doc-store-secrets + followed by `helm upgrade`, which recreates it. Afterwards this never recurs. */}} +data: + FILE_SIZE_LIMIT: {{ .Values.env.doc_upload_size_limit | default "20971520" | toString | b64enc | quote }} + AWS_S3_BUCKET_NAME: {{ .Values.env.docstore_bucket | default "" | toString | b64enc | quote }} + STORAGE_PROVIDER: {{ .Values.env.storage_provider | default "S3" | upper | b64enc | quote }} {{- if eq (.Values.env.storage_provider | default "S3" | upper) "GCS" }} # Google Cloud Storage (GCS) native mode. Browser uploads are proxied through # the API (set env.use_storage_proxy=true), so the bucket needs no CORS config. - USE_MINIO: "0" - GCS_BUCKET_NAME: {{ .Values.env.gcs_bucket_name | default .Values.env.docstore_bucket | default "" | quote }} + USE_MINIO: {{ "0" | b64enc | quote }} + GCS_BUCKET_NAME: {{ .Values.env.gcs_bucket_name | default .Values.env.docstore_bucket | default "" | toString | b64enc | quote }} {{- with .Values.env.gcs_project_id }} - GCS_PROJECT_ID: {{ . | quote }} + GCS_PROJECT_ID: {{ . | b64enc | quote }} {{- end }} {{- with .Values.env.gcs_credentials_path }} - GCS_CREDENTIALS_PATH: {{ . | quote }} + GCS_CREDENTIALS_PATH: {{ . | b64enc | quote }} {{- end }} + {{- if not (include "plane.externalStorage" .) }} {{- with .Values.env.gcs_credentials_json }} - GCS_CREDENTIALS_JSON: {{ . | quote }} + GCS_CREDENTIALS_JSON: {{ . | b64enc | quote }} + {{- end }} {{- end }} {{- else if .Values.services.minio.local_setup }} - USE_MINIO: "1" - MINIO_ROOT_USER: {{ .Values.services.minio.root_user | default "admin" | quote }} - MINIO_ROOT_PASSWORD: {{ .Values.services.minio.root_password | default "password" | quote}} - AWS_ACCESS_KEY_ID: {{ .Values.services.minio.root_user | default "admin" | quote }} - AWS_SECRET_ACCESS_KEY: {{ .Values.services.minio.root_password | default "password" | quote }} - AWS_S3_ENDPOINT_URL: http://{{ .Release.Name }}-minio.{{ .Release.Namespace }}.svc.{{ .Values.env.default_cluster_domain | default "cluster.local" }}:9000 + USE_MINIO: {{ "1" | b64enc | quote }} + MINIO_ROOT_USER: {{ .Values.services.minio.root_user | default "admin" | b64enc | quote }} + MINIO_ROOT_PASSWORD: {{ .Values.services.minio.root_password | default "password" | b64enc | quote }} + AWS_ACCESS_KEY_ID: {{ .Values.services.minio.root_user | default "admin" | b64enc | quote }} + AWS_SECRET_ACCESS_KEY: {{ .Values.services.minio.root_password | default "password" | b64enc | quote }} + AWS_S3_ENDPOINT_URL: {{ printf "http://%s-minio.%s.svc.%s:9000" .Release.Name .Release.Namespace (.Values.env.default_cluster_domain | default "cluster.local") | b64enc | quote }} {{- else }} - USE_MINIO: "0" - AWS_ACCESS_KEY_ID: {{ .Values.env.aws_access_key | default "" | quote }} - AWS_SECRET_ACCESS_KEY: {{ .Values.env.aws_secret_access_key | default "" | quote }} - AWS_REGION: {{ .Values.env.aws_region | default "" | quote }} - AWS_S3_ENDPOINT_URL: {{ .Values.env.aws_s3_endpoint_url | quote }} + USE_MINIO: {{ "0" | b64enc | quote }} + {{/* Static access keys are rendered only when supplied. Leaving them unset is the + preferred configuration on a cloud: boto3 then walks its default credential + chain and picks up the pod's identity (IRSA, EKS Pod Identity, GKE/Azure + Workload Identity) — see serviceAccount.annotations in values.yaml. An empty + AWS_ACCESS_KEY_ID would derail that chain, so the key is omitted rather than + rendered blank. */}} + {{- with .Values.env.aws_access_key }} + AWS_ACCESS_KEY_ID: {{ . | b64enc | quote }} + {{- end }} + {{- with .Values.env.aws_secret_access_key }} + AWS_SECRET_ACCESS_KEY: {{ . | b64enc | quote }} + {{- end }} + {{/* 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: {{ . | b64enc | quote }} + {{- end }} + {{- with .Values.env.aws_s3_endpoint_url }} + AWS_S3_ENDPOINT_URL: {{ . | b64enc | quote }} + {{- end }} {{- end }} {{- end }} --- diff --git a/charts/plane-enterprise/templates/config-secrets/live-env.yaml b/charts/plane-enterprise/templates/config-secrets/live-env.yaml index 5f249ce6..af010391 100644 --- a/charts/plane-enterprise/templates/config-secrets/live-env.yaml +++ b/charts/plane-enterprise/templates/config-secrets/live-env.yaml @@ -8,13 +8,26 @@ metadata: labels: {{- include "plane.commonLabels" $ | nindent 4 }} stringData: - LIVE_SERVER_SECRET_KEY: {{ .Values.env.live_server_secret_key | default "htbqvBJAgpm9bzvf3r4urJer0ENReatceh" | quote }} - {{- if .Values.services.redis.local_setup }} + {{- 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 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 }} {{- end }} - {{- if .Values.services.rabbitmq.local_setup }} + {{- if include "plane.externalRabbitmq" . }} + {{/* Emptied so the RABBITMQ_* parts take over, the same way REDIS_URL above does. live's + isSet() treats "" as unset. Without this the mirror is inert for live and + live-exporter: the URL wins over the parts, so a rotated broker password never + reaches them. */}} + 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 | quote }} diff --git a/charts/plane-enterprise/templates/config-secrets/opensearchdb.yaml b/charts/plane-enterprise/templates/config-secrets/opensearchdb.yaml index 22dcc93b..30484157 100644 --- a/charts/plane-enterprise/templates/config-secrets/opensearchdb.yaml +++ b/charts/plane-enterprise/templates/config-secrets/opensearchdb.yaml @@ -19,8 +19,12 @@ stringData: {{- else if .Values.env.opensearch_remote_url }} OPENSEARCH_ENABLED: "1" OPENSEARCH_URL: {{ .Values.env.opensearch_remote_url | quote }} + {{- if not (include "plane.externalOpensearch" .) }} + {{- /* Omitted when external_secrets.opensearch supplies them, so the externally + managed Secret is the only source and the two cannot drift. */}} OPENSEARCH_USERNAME: {{ .Values.env.opensearch_remote_username | default "" | quote }} OPENSEARCH_PASSWORD: {{ .Values.env.opensearch_remote_password | default "" | quote }} + {{- end }} OPENSEARCH_INDEX_PREFIX: {{ .Values.env.opensearch_index_prefix | default "" | quote }} OPENSEARCH_EMBEDDING_DIMENSION: {{ .Values.env.opensearch_embedding_dimension | default 1536 | quote }} {{- else }} 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 6a9fff9a..0bddaed3 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,12 +47,21 @@ 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 }} {{- 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 }} @@ -64,16 +85,41 @@ stringData: CUSTOM_LLM_API_KEY: "" {{- end }} + {{- /* Amazon Bedrock. AWS_BEARER_TOKEN_BEDROCK is the Bedrock API key, which botocore reads + natively (>= 1.39) — so this needs no application support. Omitted when unset so the + credential chain reaches the pod's identity instead; an empty bearer token is present, + and present denies the chain its turn. The profile ARN/ID are identifiers rather than + credentials, but they belong here because this is the Secret the pi workloads mount. */ -}} + {{- if .Values.services.pi.ai_providers.bedrock.enabled }} + {{- with .Values.services.pi.ai_providers.bedrock.api_key }} + AWS_BEARER_TOKEN_BEDROCK: {{ . | quote }} + {{- end }} + {{- end }} {{- if .Values.services.pi.ai_providers.embedding_model.enabled }} - BR_AWS_SECRET_ACCESS_KEY: {{ .Values.services.pi.ai_providers.embedding_model.aws_secret_access_key | default "" | quote }} - BR_AWS_SESSION_TOKEN: {{ .Values.services.pi.ai_providers.embedding_model.aws_session_token | default "" | quote }} + {{- with .Values.services.pi.ai_providers.embedding_model.aws_access_key }} + BR_AWS_ACCESS_KEY_ID: {{ . | quote }} + {{- end }} + {{- with .Values.services.pi.ai_providers.embedding_model.aws_secret_access_key }} + BR_AWS_SECRET_ACCESS_KEY: {{ . | quote }} + {{- end }} + {{- with .Values.services.pi.ai_providers.embedding_model.aws_session_token }} + BR_AWS_SESSION_TOKEN: {{ . | quote }} + {{- end }} + {{- end }} {{- end }} - AES_SECRET_KEY: {{ .Values.env.silo_envs.aes_secret_key | default "dsOdt7YrvxsTIFJ37pOaEVvLxN8KGBCr" | quote }} + {{- 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: {{ .Values.env.pi_envs.internal_secret | default "tyfvfqvBJAgpm9bzvf3r4urJer0Ehfdubk" | 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 }} - LIVE_SERVER_SECRET_KEY: {{ .Values.env.live_server_secret_key | default "htbqvBJAgpm9bzvf3r4urJer0ENReatceh" | quote }} + 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 }} {{- end }} --- @@ -150,11 +196,27 @@ data: CUSTOM_LLM_AWS_REGION: "" {{- end }} + {{- /* Bedrock's profile ARN/ID and region are identifiers, not credentials, so they sit + OUTSIDE the ai_providers suppression group — same split as OPENSEARCH_ML_MODEL_ID below. + Putting them inside would make a pod-identity Bedrock setup unconfigurable the moment + ai_providers_existingSecret is set, because that group is all-or-nothing. Only + AWS_BEARER_TOKEN_BEDROCK is a credential and only it is suppressed. */ -}} + {{- if .Values.services.pi.ai_providers.bedrock.enabled }} + {{- with .Values.services.pi.ai_providers.bedrock.inference_profile_arn }} + BEDROCK_INFERENCE_PROFILE_ARN: {{ . | quote }} + {{- end }} + {{- with .Values.services.pi.ai_providers.bedrock.inference_profile_id }} + BEDROCK_INFERENCE_PROFILE_ID: {{ . | quote }} + {{- end }} + BEDROCK_REGION: {{ .Values.services.pi.ai_providers.bedrock.region | default .Values.env.aws_region | default "us-east-1" | quote }} + {{- end }} + {{- if .Values.services.pi.ai_providers.embedding_model.enabled }} EMBEDDING_MODEL: {{ .Values.services.pi.ai_providers.embedding_model.name | default "" | quote }} OPENSEARCH_ML_MODEL_ID: {{ .Values.services.pi.ai_providers.embedding_model.model_id | default "" | quote }} OPENSEARCH_EMBEDDING_DIMENSION: {{ .Values.services.pi.ai_providers.embedding_model.embedding_dimension | default 1536 | quote }} - BR_AWS_ACCESS_KEY_ID: {{ .Values.services.pi.ai_providers.embedding_model.aws_access_key | default "" | quote }} + {{/* BR_AWS_ACCESS_KEY_ID lives in the Secret alongside BR_AWS_SECRET_ACCESS_KEY. + Both are omitted when unset so Bedrock falls back to the pod's cloud identity. */}} BR_AWS_REGION: {{ .Values.services.pi.ai_providers.embedding_model.aws_region | default "us-east-1" | quote }} {{- end }} 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 7fdfc50a..fdfa0a19 100644 --- a/charts/plane-enterprise/templates/config-secrets/silo.yaml +++ b/charts/plane-enterprise/templates/config-secrets/silo.yaml @@ -9,25 +9,41 @@ metadata: labels: {{- include "plane.commonLabels" $ | nindent 4 }} stringData: - SILO_HMAC_SECRET_KEY: {{ .Values.env.silo_envs.hmac_secret_key | default "gzb7MRLr0FoN129NyWARZEs84P9LzQ" | quote }} - AES_SECRET_KEY: {{ .Values.env.silo_envs.aes_secret_key | default "dsOdt7YrvxsTIFJ37pOaEVvLxN8KGBCr" | quote }} - CURSOR_WEBHOOK_SECRET: {{ .Values.env.silo_envs.cursor_webhook_secret | default "TTqazTcoBajYKzIAeIKFZeTX9czAoUsG" | quote }} + {{- 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}} @@ -35,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 }} @@ -69,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/service-account.yaml b/charts/plane-enterprise/templates/service-account.yaml index 3894387a..c9a02ef3 100644 --- a/charts/plane-enterprise/templates/service-account.yaml +++ b/charts/plane-enterprise/templates/service-account.yaml @@ -1,11 +1,17 @@ +{{- if include "plane.createServiceAccount" . }} apiVersion: v1 automountServiceAccountToken: true kind: ServiceAccount metadata: namespace: {{ .Release.Namespace }} - name: {{ .Release.Name }}-srv-account + name: {{ include "plane.serviceAccountName" . }} labels: {{- include "plane.commonLabels" $ | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + {{/* Cloud workload-identity bindings go here — eks.amazonaws.com/role-arn (IRSA), + iam.gke.io/gcp-service-account, azure.workload.identity/client-id. */}} + annotations: {{ toYaml . | nindent 4 }} + {{- end }} {{- if .Values.dockerRegistry.enabled }} imagePullSecrets: {{- if .Values.dockerRegistry.existingSecret }} @@ -13,4 +19,5 @@ imagePullSecrets: {{- else }} - name: {{ .Release.Name }}-docker-registry-credentials {{- end }} -{{- end }} \ No newline at end of file +{{- end }} +{{- end }} diff --git a/charts/plane-enterprise/templates/workloads/admin.deployment.yaml b/charts/plane-enterprise/templates/workloads/admin.deployment.yaml index fdc8ab40..a847d0ac 100644 --- a/charts/plane-enterprise/templates/workloads/admin.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/admin.deployment.yaml @@ -39,8 +39,9 @@ spec: labels: app.name: {{ .Release.Namespace }}-{{ .Release.Name }}-admin {{- include "plane.commonLabels" $ | nindent 8 }} + {{- include "plane.serviceAccountPodLabels" . }} annotations: - timestamp: {{ now | quote }} + {{- include "plane.podAnnotations" . | nindent 8 }} spec: {{- include "plane.podScheduling" .Values.services.admin }} {{- include "plane.podSecurityContext" . }} @@ -62,6 +63,6 @@ spec: env: {{- toYaml .Values.extraEnv | nindent 10 }} {{- end }} - serviceAccount: {{ .Release.Name }}-srv-account - serviceAccountName: {{ .Release.Name }}-srv-account + serviceAccount: {{ include "plane.serviceAccountName" . }} + serviceAccountName: {{ include "plane.serviceAccountName" . }} --- \ No newline at end of file diff --git a/charts/plane-enterprise/templates/workloads/agent-consumer.deployment.yaml b/charts/plane-enterprise/templates/workloads/agent-consumer.deployment.yaml index aea217f9..a27f201c 100644 --- a/charts/plane-enterprise/templates/workloads/agent-consumer.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/agent-consumer.deployment.yaml @@ -7,6 +7,7 @@ metadata: {{- include "plane.labelsAndAnnotations" (dict "context" $ "values" .Values.services.agent_consumer) }} spec: replicas: {{ .Values.services.agent_consumer.replicas | default 1 }} + {{- include "plane.rollingUpdateStrategy" . | nindent 2 }} selector: matchLabels: app.name: {{ .Release.Namespace }}-{{ .Release.Name }}-agent-consumer @@ -16,8 +17,9 @@ spec: labels: app.name: {{ .Release.Namespace }}-{{ .Release.Name }}-agent-consumer {{- include "plane.commonLabels" $ | nindent 8 }} + {{- include "plane.serviceAccountPodLabels" . }} annotations: - timestamp: {{ now | quote }} + {{- include "plane.podAnnotations" . | nindent 8 }} spec: {{- include "plane.podScheduling" .Values.services.agent_consumer }} {{- include "plane.podSecurityContext" . }} @@ -49,6 +51,8 @@ spec: command: - ./bin/docker-entrypoint-agent-consumer.sh envFrom: + {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-agent-consumer-vars optional: false @@ -69,16 +73,26 @@ 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 — same reason silo-secrets is above. */}} + - 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 }} {{- with (include "plane.otel.envFrom" $) }}{{ . | nindent 10 }}{{- end }} - {{- if or .Values.extraEnv (eq (include "plane.otel.enabled" .) "true") }} + {{- if or .Values.extraEnv (eq (include "plane.otel.enabled" .) "true") (include "plane.infraCredsEnv" .) }} env: {{- with (include "plane.otel.serviceEnv" (dict "ctx" $ "service" "agent-consumer")) }}{{ . | nindent 10 }}{{- end }} + {{- with (include "plane.infraCredsEnv" .) }} +{{ trim . | indent 10 }} + {{- end }} {{- if .Values.extraEnv }} {{- toYaml .Values.extraEnv | nindent 10 }} {{- end }} {{- end }} - serviceAccount: {{ .Release.Name }}-srv-account - serviceAccountName: {{ .Release.Name }}-srv-account + serviceAccount: {{ include "plane.serviceAccountName" . }} + serviceAccountName: {{ include "plane.serviceAccountName" . }} --- {{- end }} diff --git a/charts/plane-enterprise/templates/workloads/api.deployment.yaml b/charts/plane-enterprise/templates/workloads/api.deployment.yaml index 4b1b27a8..65ef990b 100644 --- a/charts/plane-enterprise/templates/workloads/api.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/api.deployment.yaml @@ -30,6 +30,7 @@ metadata: {{- include "plane.labelsAndAnnotations" (dict "context" $ "values" .Values.services.api) }} spec: replicas: {{ .Values.services.api.replicas | default 1}} + {{- include "plane.rollingUpdateStrategy" . | nindent 2 }} selector: matchLabels: app.name: {{ .Release.Namespace }}-{{ .Release.Name }}-api @@ -39,8 +40,9 @@ spec: labels: app.name: {{ .Release.Namespace }}-{{ .Release.Name }}-api {{- include "plane.commonLabels" $ | nindent 8 }} + {{- include "plane.serviceAccountPodLabels" . }} annotations: - timestamp: {{ now | quote }} + {{- include "plane.podAnnotations" . | nindent 8 }} spec: {{- include "plane.podScheduling" .Values.services.api }} {{- include "plane.podSecurityContext" . }} @@ -76,6 +78,8 @@ spec: # Start the API exec ./bin/docker-entrypoint-api-ee.sh envFrom: + {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-app-vars optional: false @@ -93,12 +97,23 @@ 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 }} {{- with (include "plane.otel.envFrom" $) }}{{ . | nindent 10 }}{{- end }} - {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (eq (include "plane.otel.enabled" .) "true") }} + {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.infraCredsEnv" .) (eq (include "plane.otel.enabled" .) "true") }} env: {{- with (include "plane.s3CAEnvVars" .) }} {{ . | indent 10 }} + {{- end }} + {{- with (include "plane.infraCredsEnv" .) }} +{{ trim . | indent 10 }} {{- end }} {{- with (include "plane.otel.serviceEnv" (dict "ctx" $ "service" "api")) }}{{ . | nindent 10 }}{{- end }} {{- if .Values.extraEnv }} @@ -115,6 +130,6 @@ spec: successThreshold: 1 timeoutSeconds: 1 - serviceAccount: {{ .Release.Name }}-srv-account - serviceAccountName: {{ .Release.Name }}-srv-account + serviceAccount: {{ include "plane.serviceAccountName" . }} + serviceAccountName: {{ include "plane.serviceAccountName" . }} --- diff --git a/charts/plane-enterprise/templates/workloads/automation-consumer.deployment.yaml b/charts/plane-enterprise/templates/workloads/automation-consumer.deployment.yaml index c9e9641b..299439f2 100644 --- a/charts/plane-enterprise/templates/workloads/automation-consumer.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/automation-consumer.deployment.yaml @@ -7,6 +7,7 @@ metadata: {{- include "plane.labelsAndAnnotations" (dict "context" $ "values" .Values.services.automation_consumer) }} spec: replicas: {{ .Values.services.automation_consumer.replicas | default 1}} + {{- include "plane.rollingUpdateStrategy" . | nindent 2 }} selector: matchLabels: app.name: {{ .Release.Namespace }}-{{ .Release.Name }}-automation-consumer @@ -16,8 +17,9 @@ spec: labels: app.name: {{ .Release.Namespace }}-{{ .Release.Name }}-automation-consumer {{- include "plane.commonLabels" $ | nindent 8 }} + {{- include "plane.serviceAccountPodLabels" . }} annotations: - timestamp: {{ now | quote }} + {{- include "plane.podAnnotations" . | nindent 8 }} spec: {{- include "plane.podScheduling" .Values.services.automation_consumer }} {{- include "plane.podSecurityContext" . }} @@ -38,6 +40,7 @@ spec: command: - ./bin/docker-entrypoint-automation-consumer.sh envFrom: + {{- include "plane.appKeysSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-automation-consumer-vars optional: false @@ -54,14 +57,17 @@ spec: name: {{ if not (empty .Values.external_secrets.opensearch_existingSecret) }}{{ .Values.external_secrets.opensearch_existingSecret }}{{ else }}{{ .Release.Name }}-opensearch-secrets{{ end }} optional: false {{- with (include "plane.otel.envFrom" $) }}{{ . | nindent 10 }}{{- end }} - {{- if or .Values.extraEnv (eq (include "plane.otel.enabled" .) "true") }} + {{- if or .Values.extraEnv (eq (include "plane.otel.enabled" .) "true") (include "plane.infraCredsEnv" .) }} env: {{- with (include "plane.otel.serviceEnv" (dict "ctx" $ "service" "automation-consumer")) }}{{ . | nindent 10 }}{{- end }} + {{- with (include "plane.infraCredsEnv" .) }} +{{ trim . | indent 10 }} + {{- end }} {{- if .Values.extraEnv }} {{- toYaml .Values.extraEnv | nindent 10 }} {{- end }} {{- end }} - serviceAccount: {{ .Release.Name }}-srv-account - serviceAccountName: {{ .Release.Name }}-srv-account + serviceAccount: {{ include "plane.serviceAccountName" . }} + serviceAccountName: {{ include "plane.serviceAccountName" . }} {{- end }} \ No newline at end of file diff --git a/charts/plane-enterprise/templates/workloads/beat-worker.deployment.yaml b/charts/plane-enterprise/templates/workloads/beat-worker.deployment.yaml index 752dc54f..8a1592dd 100644 --- a/charts/plane-enterprise/templates/workloads/beat-worker.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/beat-worker.deployment.yaml @@ -15,8 +15,9 @@ spec: labels: app.name: {{ .Release.Namespace }}-{{ .Release.Name }}-beat-worker {{- include "plane.commonLabels" $ | nindent 8 }} + {{- include "plane.serviceAccountPodLabels" . }} annotations: - timestamp: {{ now | quote }} + {{- include "plane.podAnnotations" . | nindent 8 }} spec: {{- include "plane.podScheduling" .Values.services.beatworker }} {{- include "plane.podSecurityContext" . }} @@ -37,6 +38,8 @@ spec: command: - ./bin/docker-entrypoint-beat.sh envFrom: + {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-app-vars optional: false @@ -54,15 +57,26 @@ 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 }} {{- with (include "plane.otel.envFrom" $) }}{{ . | nindent 10 }}{{- end }} - {{- if or .Values.extraEnv (eq (include "plane.otel.enabled" .) "true") }} + {{- if or .Values.extraEnv (eq (include "plane.otel.enabled" .) "true") (include "plane.infraCredsEnv" .) }} env: {{- with (include "plane.otel.serviceEnv" (dict "ctx" $ "service" "beat-worker")) }}{{ . | nindent 10 }}{{- end }} + {{- with (include "plane.infraCredsEnv" .) }} +{{ trim . | indent 10 }} + {{- end }} {{- if .Values.extraEnv }} {{- toYaml .Values.extraEnv | nindent 10 }} {{- end }} {{- end }} - serviceAccount: {{ .Release.Name }}-srv-account - serviceAccountName: {{ .Release.Name }}-srv-account + serviceAccount: {{ include "plane.serviceAccountName" . }} + serviceAccountName: {{ include "plane.serviceAccountName" . }} --- \ No newline at end of file diff --git a/charts/plane-enterprise/templates/workloads/email.deployment.yaml b/charts/plane-enterprise/templates/workloads/email.deployment.yaml index eaaab8ff..a424ab75 100644 --- a/charts/plane-enterprise/templates/workloads/email.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/email.deployment.yaml @@ -32,8 +32,10 @@ kind: Deployment metadata: name: {{ .Release.Name }}-email-app namespace: {{ .Release.Namespace }} - annotations: - reloader.stakater.com/auto: "true" + {{/* The Reloader annotation now comes from plane.labelsAndAnnotations (gated on + reloader.enabled). It used to be a literal key here, which was silently + dropped whenever services.email_service.annotations was also set, because the + helper emits its own `annotations:` key. */}} {{- include "plane.labelsAndAnnotations" (dict "context" $ "values" .Values.services.email_service) }} spec: replicas: {{ .Values.services.email_service.replicas | default 1 }} @@ -46,8 +48,9 @@ spec: labels: app.name: {{ .Release.Namespace }}-{{ .Release.Name }}-email-app {{- include "plane.commonLabels" $ | nindent 8 }} - annotations: - timestamp: {{ now | quote }} + {{- include "plane.serviceAccountPodLabels" . }} + annotations: + {{- include "plane.podAnnotations" . | nindent 8 }} spec: {{- include "plane.podScheduling" .Values.services.email_service }} containers: @@ -106,7 +109,7 @@ spec: - key: tls.key path: key.pem optional: false - serviceAccount: {{ .Release.Name }}-srv-account - serviceAccountName: {{ .Release.Name }}-srv-account + serviceAccount: {{ include "plane.serviceAccountName" . }} + serviceAccountName: {{ include "plane.serviceAccountName" . }} --- {{- end }} \ No newline at end of file diff --git a/charts/plane-enterprise/templates/workloads/external-api.deployment.yaml b/charts/plane-enterprise/templates/workloads/external-api.deployment.yaml index 813712bb..4a498b37 100644 --- a/charts/plane-enterprise/templates/workloads/external-api.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/external-api.deployment.yaml @@ -31,6 +31,7 @@ metadata: {{- include "plane.labelsAndAnnotations" (dict "context" $ "values" .Values.services.external_api) }} spec: replicas: {{ .Values.services.external_api.replicas | default 1}} + {{- include "plane.rollingUpdateStrategy" . | nindent 2 }} selector: matchLabels: app.name: {{ .Release.Namespace }}-{{ .Release.Name }}-external-api @@ -40,8 +41,9 @@ spec: labels: app.name: {{ .Release.Namespace }}-{{ .Release.Name }}-external-api {{- include "plane.commonLabels" $ | nindent 8 }} + {{- include "plane.serviceAccountPodLabels" . }} annotations: - timestamp: {{ now | quote }} + {{- include "plane.podAnnotations" . | nindent 8 }} spec: {{- include "plane.podScheduling" .Values.services.external_api }} {{- include "plane.podSecurityContext" . }} @@ -77,6 +79,8 @@ spec: # Start the external API exec ./bin/docker-entrypoint-api-ee.sh envFrom: + {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-app-vars optional: false @@ -94,12 +98,23 @@ 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 }} {{- with (include "plane.otel.envFrom" $) }}{{ . | nindent 10 }}{{- end }} - {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (eq (include "plane.otel.enabled" .) "true") }} + {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.infraCredsEnv" .) (eq (include "plane.otel.enabled" .) "true") }} env: {{- with (include "plane.s3CAEnvVars" .) }} {{ . | indent 10 }} + {{- end }} + {{- with (include "plane.infraCredsEnv" .) }} +{{ trim . | indent 10 }} {{- end }} {{- with (include "plane.otel.serviceEnv" (dict "ctx" $ "service" "external-api")) }}{{ . | nindent 10 }}{{- end }} {{- if .Values.extraEnv }} @@ -116,6 +131,6 @@ spec: successThreshold: 1 timeoutSeconds: 1 - serviceAccount: {{ .Release.Name }}-srv-account - serviceAccountName: {{ .Release.Name }}-srv-account + serviceAccount: {{ include "plane.serviceAccountName" . }} + serviceAccountName: {{ include "plane.serviceAccountName" . }} {{- end }} diff --git a/charts/plane-enterprise/templates/workloads/iframely.deployment.yaml b/charts/plane-enterprise/templates/workloads/iframely.deployment.yaml index 190a47bc..a38eb0ae 100644 --- a/charts/plane-enterprise/templates/workloads/iframely.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/iframely.deployment.yaml @@ -36,8 +36,9 @@ spec: labels: app.name: {{ .Release.Namespace }}-{{ .Release.Name }}-iframely {{- include "plane.commonLabels" $ | nindent 8 }} + {{- include "plane.serviceAccountPodLabels" . }} annotations: - timestamp: {{ now | quote }} + {{- include "plane.podAnnotations" . | nindent 8 }} spec: {{- include "plane.podScheduling" .Values.services.iframely }} {{- include "plane.podSecurityContext" . }} @@ -63,6 +64,6 @@ spec: env: {{- toYaml .Values.extraEnv | nindent 10 }} {{- end }} - serviceAccount: {{ .Release.Name }}-srv-account - serviceAccountName: {{ .Release.Name }}-srv-account + serviceAccount: {{ include "plane.serviceAccountName" . }} + serviceAccountName: {{ include "plane.serviceAccountName" . }} {{- end }} diff --git a/charts/plane-enterprise/templates/workloads/live-exporter.deployment.yaml b/charts/plane-enterprise/templates/workloads/live-exporter.deployment.yaml index d750468a..c3ab0de8 100644 --- a/charts/plane-enterprise/templates/workloads/live-exporter.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/live-exporter.deployment.yaml @@ -17,8 +17,9 @@ spec: labels: app.name: {{ .Release.Namespace }}-{{ .Release.Name }}-live-exporter {{- include "plane.commonLabels" $ | nindent 8 }} + {{- include "plane.serviceAccountPodLabels" . }} annotations: - timestamp: {{ now | quote }} + {{- include "plane.podAnnotations" . | nindent 8 }} spec: {{- include "plane.podScheduling" .Values.services.live_exporter }} {{- include "plane.podSecurityContext" . }} @@ -51,6 +52,6 @@ spec: {{- if .Values.extraEnv }} {{- toYaml .Values.extraEnv | nindent 10 }} {{- end }} - serviceAccount: {{ .Release.Name }}-srv-account - serviceAccountName: {{ .Release.Name }}-srv-account + serviceAccount: {{ include "plane.serviceAccountName" . }} + serviceAccountName: {{ include "plane.serviceAccountName" . }} {{- end }} diff --git a/charts/plane-enterprise/templates/workloads/live.deployment.yaml b/charts/plane-enterprise/templates/workloads/live.deployment.yaml index b1759894..41ec2006 100644 --- a/charts/plane-enterprise/templates/workloads/live.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/live.deployment.yaml @@ -30,6 +30,7 @@ metadata: {{- include "plane.labelsAndAnnotations" (dict "context" $ "values" .Values.services.live) }} spec: replicas: {{ .Values.services.live.replicas | default 1}} + {{- include "plane.rollingUpdateStrategy" . | nindent 2 }} selector: matchLabels: app.name: {{ .Release.Namespace }}-{{ .Release.Name }}-live @@ -39,8 +40,9 @@ spec: labels: app.name: {{ .Release.Namespace }}-{{ .Release.Name }}-live {{- include "plane.commonLabels" $ | nindent 8 }} + {{- include "plane.serviceAccountPodLabels" . }} annotations: - timestamp: {{ now | quote }} + {{- include "plane.podAnnotations" . | nindent 8 }} spec: {{- include "plane.podScheduling" .Values.services.live }} {{- include "plane.podSecurityContext" . }} @@ -90,6 +92,8 @@ spec: {{ . | indent 8 }} {{- end }} envFrom: + {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.aiProvidersSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-live-vars optional: false @@ -97,16 +101,19 @@ spec: name: {{ if not (empty .Values.external_secrets.live_env_existingSecret) }}{{ .Values.external_secrets.live_env_existingSecret }}{{ else }}{{ .Release.Name }}-live-secrets{{ end }} optional: false {{- with (include "plane.otel.envFrom" $) }}{{ . | nindent 10 }}{{- end }} - {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (eq (include "plane.otel.enabled" .) "true") }} + {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.redisCredsEnv" .) (eq (include "plane.otel.enabled" .) "true") }} env: {{- with (include "plane.s3CANodeEnvVars" .) }} {{ . | indent 10 }} + {{- end }} + {{- with (include "plane.redisCredsEnv" .) }} +{{ trim . | indent 10 }} {{- end }} {{- with (include "plane.otel.serviceEnv" (dict "ctx" $ "service" "live")) }}{{ . | nindent 10 }}{{- end }} {{- if .Values.extraEnv }} {{- toYaml .Values.extraEnv | nindent 10 }} {{- end }} {{- end }} - serviceAccount: {{ .Release.Name }}-srv-account - serviceAccountName: {{ .Release.Name }}-srv-account + serviceAccount: {{ include "plane.serviceAccountName" . }} + serviceAccountName: {{ include "plane.serviceAccountName" . }} --- diff --git a/charts/plane-enterprise/templates/workloads/migrator.job.yaml b/charts/plane-enterprise/templates/workloads/migrator.job.yaml index fda7526b..ef386418 100644 --- a/charts/plane-enterprise/templates/workloads/migrator.job.yaml +++ b/charts/plane-enterprise/templates/workloads/migrator.job.yaml @@ -12,8 +12,9 @@ spec: labels: app.name: {{ .Release.Namespace }}-{{ .Release.Name }}-api-migrate {{- include "plane.commonLabels" $ | nindent 8 }} + {{- include "plane.serviceAccountPodLabels" . }} annotations: - timestamp: {{ now | quote }} + {{- include "plane.podAnnotations" . | nindent 8 }} spec: {{- include "plane.podScheduling" .Values.services.api }} {{- include "plane.podSecurityContext" . }} @@ -25,6 +26,8 @@ spec: - ./bin/docker-entrypoint-migrator.sh imagePullPolicy: {{ .Values.services.api.pullPolicy | default "Always" }} envFrom: + {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-app-vars optional: false @@ -42,11 +45,24 @@ 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.extraEnv }} + {{- 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" .) }} +{{ trim . | indent 10 }} + {{- end }} + {{- if .Values.extraEnv }} {{- toYaml .Values.extraEnv | nindent 10 }} + {{- end }} {{- end }} restartPolicy: OnFailure - serviceAccount: {{ .Release.Name }}-srv-account - serviceAccountName: {{ .Release.Name }}-srv-account + serviceAccount: {{ include "plane.serviceAccountName" . }} + serviceAccountName: {{ include "plane.serviceAccountName" . }} --- \ No newline at end of file diff --git a/charts/plane-enterprise/templates/workloads/minio.stateful.yaml b/charts/plane-enterprise/templates/workloads/minio.stateful.yaml index 09a8c38b..9e64d301 100644 --- a/charts/plane-enterprise/templates/workloads/minio.stateful.yaml +++ b/charts/plane-enterprise/templates/workloads/minio.stateful.yaml @@ -41,6 +41,7 @@ spec: labels: app.name: {{ .Release.Namespace }}-{{ .Release.Name }}-minio {{- include "plane.commonLabels" $ | nindent 8 }} + {{- include "plane.serviceAccountPodLabels" . }} spec: {{- include "plane.podScheduling" .Values.services.minio }} containers: @@ -63,8 +64,8 @@ spec: - mountPath: /data name: pvc-{{ .Release.Name }}-minio-vol subPath: '' - serviceAccount: {{ .Release.Name }}-srv-account - serviceAccountName: {{ .Release.Name }}-srv-account + serviceAccount: {{ include "plane.serviceAccountName" . }} + serviceAccountName: {{ include "plane.serviceAccountName" . }} volumeClaimTemplates: - apiVersion: v1 kind: PersistentVolumeClaim @@ -123,7 +124,7 @@ spec: env: {{- toYaml .Values.extraEnv | nindent 10 }} {{- end }} - serviceAccount: {{ .Release.Name }}-srv-account - serviceAccountName: {{ .Release.Name }}-srv-account + serviceAccount: {{ include "plane.serviceAccountName" . }} + serviceAccountName: {{ include "plane.serviceAccountName" . }} terminationGracePeriodSeconds: 120 {{- end }} \ No newline at end of file diff --git a/charts/plane-enterprise/templates/workloads/monitor.stateful.yaml b/charts/plane-enterprise/templates/workloads/monitor.stateful.yaml index 83194f91..9b076cf4 100644 --- a/charts/plane-enterprise/templates/workloads/monitor.stateful.yaml +++ b/charts/plane-enterprise/templates/workloads/monitor.stateful.yaml @@ -36,8 +36,9 @@ spec: labels: app.name: {{ .Release.Namespace }}-{{ .Release.Name }}-monitor {{- include "plane.commonLabels" $ | nindent 8 }} + {{- include "plane.serviceAccountPodLabels" . }} annotations: - timestamp: {{ now | quote }} + {{- include "plane.podAnnotations" . | nindent 8 }} spec: {{- include "plane.podScheduling" .Values.services.monitor }} {{- include "plane.podSecurityContext" . }} @@ -73,8 +74,8 @@ spec: - mountPath: /app name: pvc-{{ .Release.Name }}-monitor-vol subPath: '' - serviceAccount: {{ .Release.Name }}-srv-account - serviceAccountName: {{ .Release.Name }}-srv-account + serviceAccount: {{ include "plane.serviceAccountName" . }} + serviceAccountName: {{ include "plane.serviceAccountName" . }} volumeClaimTemplates: - apiVersion: v1 kind: PersistentVolumeClaim diff --git a/charts/plane-enterprise/templates/workloads/opensearch.stateful.yaml b/charts/plane-enterprise/templates/workloads/opensearch.stateful.yaml index a8f4c848..2f3ceb06 100644 --- a/charts/plane-enterprise/templates/workloads/opensearch.stateful.yaml +++ b/charts/plane-enterprise/templates/workloads/opensearch.stateful.yaml @@ -37,6 +37,7 @@ spec: labels: app.name: {{ .Release.Namespace }}-{{ .Release.Name }}-opensearch {{- include "plane.commonLabels" $ | nindent 8 }} + {{- include "plane.serviceAccountPodLabels" . }} spec: {{- include "plane.podScheduling" .Values.services.opensearch }} securityContext: @@ -83,8 +84,8 @@ spec: subPath: '' - mountPath: /docker-entrypoint-init.d name: {{.Release.Name}}-opensearch-init-script - serviceAccount: {{ .Release.Name }}-srv-account - serviceAccountName: {{ .Release.Name }}-srv-account + serviceAccount: {{ include "plane.serviceAccountName" . }} + serviceAccountName: {{ include "plane.serviceAccountName" . }} volumes: - name: {{.Release.Name}}-opensearch-init-script configMap: diff --git a/charts/plane-enterprise/templates/workloads/outbox-poller.deployment.yaml b/charts/plane-enterprise/templates/workloads/outbox-poller.deployment.yaml index 839fce04..d0f2f386 100644 --- a/charts/plane-enterprise/templates/workloads/outbox-poller.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/outbox-poller.deployment.yaml @@ -16,8 +16,9 @@ spec: labels: app.name: {{ .Release.Namespace }}-{{ .Release.Name }}-outbox-poller {{- include "plane.commonLabels" $ | nindent 8 }} + {{- include "plane.serviceAccountPodLabels" . }} annotations: - timestamp: {{ now | quote }} + {{- include "plane.podAnnotations" . | nindent 8 }} spec: {{- include "plane.podScheduling" .Values.services.outbox_poller }} {{- include "plane.podSecurityContext" . }} @@ -38,6 +39,7 @@ spec: command: - ./bin/docker-entrypoint-outbox-poller.sh envFrom: + {{- include "plane.appKeysSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-outbox-poller-vars optional: false @@ -48,14 +50,17 @@ spec: name: {{ if not (empty .Values.external_secrets.app_env_existingSecret) }}{{ .Values.external_secrets.app_env_existingSecret }}{{ else }}{{ .Release.Name }}-app-secrets{{ end }} optional: false {{- with (include "plane.otel.envFrom" $) }}{{ . | nindent 10 }}{{- end }} - {{- if or .Values.extraEnv (eq (include "plane.otel.enabled" .) "true") }} + {{- if or .Values.extraEnv (eq (include "plane.otel.enabled" .) "true") (include "plane.infraCredsEnv" .) }} env: {{- with (include "plane.otel.serviceEnv" (dict "ctx" $ "service" "outbox-poller")) }}{{ . | nindent 10 }}{{- end }} + {{- with (include "plane.infraCredsEnv" .) }} +{{ trim . | indent 10 }} + {{- end }} {{- if .Values.extraEnv }} {{- toYaml .Values.extraEnv | nindent 10 }} {{- end }} {{- end }} - serviceAccount: {{ .Release.Name }}-srv-account - serviceAccountName: {{ .Release.Name }}-srv-account + serviceAccount: {{ include "plane.serviceAccountName" . }} + serviceAccountName: {{ include "plane.serviceAccountName" . }} {{- end }} \ No newline at end of file diff --git a/charts/plane-enterprise/templates/workloads/pi-api.deployment.yaml b/charts/plane-enterprise/templates/workloads/pi-api.deployment.yaml index 0eaf47ac..80d5bf46 100644 --- a/charts/plane-enterprise/templates/workloads/pi-api.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/pi-api.deployment.yaml @@ -31,6 +31,7 @@ metadata: {{- include "plane.labelsAndAnnotations" (dict "context" $ "values" .Values.services.pi) }} spec: replicas: {{ .Values.services.pi.replicas | default 1 }} + {{- include "plane.rollingUpdateStrategy" . | nindent 2 }} selector: matchLabels: app.name: {{ .Release.Namespace }}-{{ .Release.Name }}-pi-api @@ -40,8 +41,9 @@ spec: labels: app.name: {{ .Release.Namespace }}-{{ .Release.Name }}-pi-api {{- include "plane.commonLabels" $ | nindent 8 }} + {{- include "plane.serviceAccountPodLabels" . }} annotations: - timestamp: {{ now | quote }} + {{- include "plane.podAnnotations" . | nindent 8 }} spec: {{- include "plane.podScheduling" .Values.services.pi }} {{- include "plane.podSecurityContext" . }} @@ -76,6 +78,8 @@ spec: exec ./bin/entrypoint-api.sh envFrom: + {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.aiProvidersSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-pi-api-vars optional: false @@ -89,10 +93,13 @@ spec: name: {{ if not (empty .Values.external_secrets.opensearch_existingSecret) }}{{ .Values.external_secrets.opensearch_existingSecret }}{{ else }}{{ .Release.Name }}-opensearch-secrets{{ end }} optional: false {{- with (include "plane.otel.envFrom" $) }}{{ . | nindent 10 }}{{- end }} - {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (eq (include "plane.otel.enabled" .) "true") }} + {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.piInfraCredsEnv" .) (eq (include "plane.otel.enabled" .) "true") }} env: {{- with (include "plane.s3CAEnvVars" .) }} {{ . | indent 10 }} + {{- end }} + {{- with (include "plane.piInfraCredsEnv" .) }} +{{ trim . | indent 10 }} {{- end }} {{- with (include "plane.otel.serviceEnv" (dict "ctx" $ "service" "pi-api")) }}{{ . | nindent 10 }}{{- end }} {{- if .Values.extraEnv }} @@ -108,8 +115,8 @@ spec: periodSeconds: 10 successThreshold: 1 timeoutSeconds: 1 - serviceAccount: {{ .Release.Name }}-srv-account - serviceAccountName: {{ .Release.Name }}-srv-account + serviceAccount: {{ include "plane.serviceAccountName" . }} + serviceAccountName: {{ include "plane.serviceAccountName" . }} --- {{- end }} diff --git a/charts/plane-enterprise/templates/workloads/pi-beat.deployment.yaml b/charts/plane-enterprise/templates/workloads/pi-beat.deployment.yaml index f882ba13..21bd52ea 100644 --- a/charts/plane-enterprise/templates/workloads/pi-beat.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/pi-beat.deployment.yaml @@ -16,8 +16,9 @@ spec: labels: app.name: {{ .Release.Namespace }}-{{ .Release.Name }}-pi-beat {{- include "plane.commonLabels" $ | nindent 8 }} + {{- include "plane.serviceAccountPodLabels" . }} annotations: - timestamp: {{ now | quote }} + {{- include "plane.podAnnotations" . | nindent 8 }} spec: {{- include "plane.podScheduling" .Values.services.pi_beat_worker }} {{- include "plane.podSecurityContext" . }} @@ -52,6 +53,8 @@ spec: exec ./bin/entrypoint-celery-beat.sh envFrom: + {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.aiProvidersSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-pi-api-vars optional: false @@ -65,17 +68,20 @@ spec: name: {{ if not (empty .Values.external_secrets.opensearch_existingSecret) }}{{ .Values.external_secrets.opensearch_existingSecret }}{{ else }}{{ .Release.Name }}-opensearch-secrets{{ end }} optional: false {{- with (include "plane.otel.envFrom" $) }}{{ . | nindent 10 }}{{- end }} - {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (eq (include "plane.otel.enabled" .) "true") }} + {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.piInfraCredsEnv" .) (eq (include "plane.otel.enabled" .) "true") }} env: {{- with (include "plane.s3CAEnvVars" .) }} {{ . | indent 10 }} + {{- end }} + {{- with (include "plane.piInfraCredsEnv" .) }} +{{ trim . | indent 10 }} {{- end }} {{- with (include "plane.otel.serviceEnv" (dict "ctx" $ "service" "pi-beat")) }}{{ . | nindent 10 }}{{- end }} {{- if .Values.extraEnv }} {{- toYaml .Values.extraEnv | nindent 10 }} {{- end }} {{- end }} - serviceAccount: {{ .Release.Name }}-srv-account - serviceAccountName: {{ .Release.Name }}-srv-account + serviceAccount: {{ include "plane.serviceAccountName" . }} + serviceAccountName: {{ include "plane.serviceAccountName" . }} --- {{- end }} diff --git a/charts/plane-enterprise/templates/workloads/pi-migrator.job.yaml b/charts/plane-enterprise/templates/workloads/pi-migrator.job.yaml index 56bb2423..9c280af2 100644 --- a/charts/plane-enterprise/templates/workloads/pi-migrator.job.yaml +++ b/charts/plane-enterprise/templates/workloads/pi-migrator.job.yaml @@ -14,8 +14,9 @@ spec: labels: app.name: {{ .Release.Namespace }}-{{ .Release.Name }}-pi-api-migrate {{- include "plane.commonLabels" $ | nindent 8 }} + {{- include "plane.serviceAccountPodLabels" . }} annotations: - timestamp: {{ now | quote }} + {{- include "plane.podAnnotations" . | nindent 8 }} spec: {{- include "plane.podScheduling" .Values.services.pi }} {{- include "plane.podSecurityContext" . }} @@ -41,6 +42,8 @@ spec: exec ./bin/entrypoint-migrator.sh envFrom: + {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.aiProvidersSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-pi-api-vars optional: false @@ -53,18 +56,21 @@ 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" .) }} + {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.piInfraCredsEnv" .) }} env: {{- with (include "plane.s3CAEnvVars" .) }} {{ . | indent 10 }} + {{- end }} + {{- with (include "plane.piInfraCredsEnv" .) }} +{{ trim . | indent 10 }} {{- end }} {{- if .Values.extraEnv }} {{- toYaml .Values.extraEnv | nindent 10 }} {{- end }} {{- end }} restartPolicy: OnFailure - serviceAccount: {{ .Release.Name }}-srv-account - serviceAccountName: {{ .Release.Name }}-srv-account + serviceAccount: {{ include "plane.serviceAccountName" . }} + serviceAccountName: {{ include "plane.serviceAccountName" . }} --- {{- end }} diff --git a/charts/plane-enterprise/templates/workloads/pi-worker.deployment.yaml b/charts/plane-enterprise/templates/workloads/pi-worker.deployment.yaml index aef0d344..104f9eda 100644 --- a/charts/plane-enterprise/templates/workloads/pi-worker.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/pi-worker.deployment.yaml @@ -7,6 +7,7 @@ metadata: {{- include "plane.labelsAndAnnotations" (dict "context" $ "values" .Values.services.pi_worker) }} spec: replicas: {{ .Values.services.pi_worker.replicas | default 1}} + {{- include "plane.rollingUpdateStrategy" . | nindent 2 }} selector: matchLabels: app.name: {{ .Release.Namespace }}-{{ .Release.Name }}-pi-worker @@ -16,8 +17,9 @@ spec: labels: app.name: {{ .Release.Namespace }}-{{ .Release.Name }}-pi-worker {{- include "plane.commonLabels" $ | nindent 8 }} + {{- include "plane.serviceAccountPodLabels" . }} annotations: - timestamp: {{ now | quote }} + {{- include "plane.podAnnotations" . | nindent 8 }} spec: {{- include "plane.podScheduling" .Values.services.pi_worker }} {{- include "plane.podSecurityContext" . }} @@ -52,6 +54,8 @@ spec: exec ./bin/entrypoint-celery-worker.sh envFrom: + {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.aiProvidersSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-pi-api-vars optional: false @@ -65,17 +69,20 @@ spec: name: {{ if not (empty .Values.external_secrets.opensearch_existingSecret) }}{{ .Values.external_secrets.opensearch_existingSecret }}{{ else }}{{ .Release.Name }}-opensearch-secrets{{ end }} optional: false {{- with (include "plane.otel.envFrom" $) }}{{ . | nindent 10 }}{{- end }} - {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (eq (include "plane.otel.enabled" .) "true") }} + {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.piInfraCredsEnv" .) (eq (include "plane.otel.enabled" .) "true") }} env: {{- with (include "plane.s3CAEnvVars" .) }} {{ . | indent 10 }} + {{- end }} + {{- with (include "plane.piInfraCredsEnv" .) }} +{{ trim . | indent 10 }} {{- end }} {{- with (include "plane.otel.serviceEnv" (dict "ctx" $ "service" "pi-worker")) }}{{ . | nindent 10 }}{{- end }} {{- if .Values.extraEnv }} {{- toYaml .Values.extraEnv | nindent 10 }} {{- end }} {{- end }} - serviceAccount: {{ .Release.Name }}-srv-account - serviceAccountName: {{ .Release.Name }}-srv-account + serviceAccount: {{ include "plane.serviceAccountName" . }} + serviceAccountName: {{ include "plane.serviceAccountName" . }} --- {{- end }} diff --git a/charts/plane-enterprise/templates/workloads/postgres.stateful.yaml b/charts/plane-enterprise/templates/workloads/postgres.stateful.yaml index 0f62ea52..524dc0d8 100644 --- a/charts/plane-enterprise/templates/workloads/postgres.stateful.yaml +++ b/charts/plane-enterprise/templates/workloads/postgres.stateful.yaml @@ -37,6 +37,7 @@ spec: labels: app.name: {{ .Release.Namespace }}-{{ .Release.Name }}-pgdb {{- include "plane.commonLabels" $ | nindent 8 }} + {{- include "plane.serviceAccountPodLabels" . }} spec: {{- include "plane.podScheduling" .Values.services.postgres }} containers: @@ -61,8 +62,8 @@ spec: subPath: '' - mountPath: /docker-entrypoint-initdb.d name: {{.Release.Name}}-init-script - serviceAccount: {{ .Release.Name }}-srv-account - serviceAccountName: {{ .Release.Name }}-srv-account + serviceAccount: {{ include "plane.serviceAccountName" . }} + serviceAccountName: {{ include "plane.serviceAccountName" . }} volumes: - name: {{.Release.Name}}-init-script configMap: diff --git a/charts/plane-enterprise/templates/workloads/rabbitmq.stateful.yaml b/charts/plane-enterprise/templates/workloads/rabbitmq.stateful.yaml index a333a1ea..c1bde567 100644 --- a/charts/plane-enterprise/templates/workloads/rabbitmq.stateful.yaml +++ b/charts/plane-enterprise/templates/workloads/rabbitmq.stateful.yaml @@ -41,6 +41,7 @@ spec: labels: app.name: {{ .Release.Namespace }}-{{ .Release.Name }}-rabbitmq {{- include "plane.commonLabels" $ | nindent 8 }} + {{- include "plane.serviceAccountPodLabels" . }} spec: {{- include "plane.podScheduling" .Values.services.rabbitmq }} containers: @@ -68,8 +69,8 @@ spec: periodSeconds: 30 timeoutSeconds: 30 failureThreshold: 50 - serviceAccount: {{ .Release.Name }}-srv-account - serviceAccountName: {{ .Release.Name }}-srv-account + serviceAccount: {{ include "plane.serviceAccountName" . }} + serviceAccountName: {{ include "plane.serviceAccountName" . }} volumeClaimTemplates: - apiVersion: v1 kind: PersistentVolumeClaim diff --git a/charts/plane-enterprise/templates/workloads/redis.stateful.yaml b/charts/plane-enterprise/templates/workloads/redis.stateful.yaml index 0423ebeb..b035adc5 100644 --- a/charts/plane-enterprise/templates/workloads/redis.stateful.yaml +++ b/charts/plane-enterprise/templates/workloads/redis.stateful.yaml @@ -40,6 +40,7 @@ spec: labels: app.name: {{ .Release.Namespace }}-{{ .Release.Name }}-redis {{- include "plane.commonLabels" $ | nindent 8 }} + {{- include "plane.serviceAccountPodLabels" . }} spec: {{- include "plane.podScheduling" .Values.services.redis }} containers: @@ -56,8 +57,8 @@ spec: - mountPath: /data name: pvc-{{ .Release.Name }}-redis-vol subPath: '' - serviceAccount: {{ .Release.Name }}-srv-account - serviceAccountName: {{ .Release.Name }}-srv-account + serviceAccount: {{ include "plane.serviceAccountName" . }} + serviceAccountName: {{ include "plane.serviceAccountName" . }} volumeClaimTemplates: - apiVersion: v1 kind: PersistentVolumeClaim diff --git a/charts/plane-enterprise/templates/workloads/runner.deployment.yaml b/charts/plane-enterprise/templates/workloads/runner.deployment.yaml index 10922285..df4c4a1d 100644 --- a/charts/plane-enterprise/templates/workloads/runner.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/runner.deployment.yaml @@ -31,6 +31,7 @@ metadata: {{- include "plane.labelsAndAnnotations" (dict "context" $ "values" .Values.services.runner) }} spec: replicas: {{ .Values.services.runner.replicas | default 1}} + {{- include "plane.rollingUpdateStrategy" . | nindent 2 }} selector: matchLabels: app.name: {{ .Release.Namespace }}-{{ .Release.Name }}-runner @@ -40,8 +41,9 @@ spec: labels: app.name: {{ .Release.Namespace }}-{{ .Release.Name }}-runner {{- include "plane.commonLabels" $ | nindent 8 }} + {{- include "plane.serviceAccountPodLabels" . }} annotations: - timestamp: {{ now | quote }} + {{- include "plane.podAnnotations" . | nindent 8 }} spec: {{- include "plane.podScheduling" .Values.services.runner }} {{- include "plane.podSecurityContext" . }} @@ -67,6 +69,7 @@ spec: successThreshold: 1 timeoutSeconds: 1 envFrom: + {{- include "plane.appKeysSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-runner-vars optional: false @@ -77,7 +80,7 @@ spec: env: {{- toYaml .Values.extraEnv | nindent 10 }} {{- end }} - serviceAccount: {{ .Release.Name }}-srv-account - serviceAccountName: {{ .Release.Name }}-srv-account + serviceAccount: {{ include "plane.serviceAccountName" . }} + serviceAccountName: {{ include "plane.serviceAccountName" . }} --- {{- end }} diff --git a/charts/plane-enterprise/templates/workloads/silo.deployment.yaml b/charts/plane-enterprise/templates/workloads/silo.deployment.yaml index 44a6eb56..e16ffec5 100644 --- a/charts/plane-enterprise/templates/workloads/silo.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/silo.deployment.yaml @@ -31,6 +31,7 @@ metadata: {{- include "plane.labelsAndAnnotations" (dict "context" $ "values" .Values.services.silo) }} spec: replicas: {{ .Values.services.silo.replicas | default 1}} + {{- include "plane.rollingUpdateStrategy" . | nindent 2 }} selector: matchLabels: app.name: {{ .Release.Namespace }}-{{ .Release.Name }}-silo @@ -40,8 +41,9 @@ spec: labels: app.name: {{ .Release.Namespace }}-{{ .Release.Name }}-silo {{- include "plane.commonLabels" $ | nindent 8 }} + {{- include "plane.serviceAccountPodLabels" . }} annotations: - timestamp: {{ now | quote }} + {{- include "plane.podAnnotations" . | nindent 8 }} spec: {{- include "plane.podScheduling" .Values.services.silo }} {{- include "plane.podSecurityContext" . }} @@ -113,6 +115,8 @@ spec: {{ . | indent 8 }} {{- end }} envFrom: + {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-silo-vars optional: false @@ -123,17 +127,20 @@ spec: 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 {{- with (include "plane.otel.envFrom" $) }}{{ . | nindent 10 }}{{- end }} - {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (eq (include "plane.otel.enabled" .) "true") }} + {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.siloInfraCredsEnv" .) (eq (include "plane.otel.enabled" .) "true") }} env: {{- with (include "plane.s3CANodeEnvVars" .) }} {{ . | indent 10 }} + {{- end }} + {{- with (include "plane.siloInfraCredsEnv" .) }} +{{ trim . | indent 10 }} {{- end }} {{- with (include "plane.otel.serviceEnv" (dict "ctx" $ "service" "silo")) }}{{ . | nindent 10 }}{{- end }} {{- if .Values.extraEnv }} {{- toYaml .Values.extraEnv | nindent 10 }} {{- end }} {{- end }} - serviceAccount: {{ .Release.Name }}-srv-account - serviceAccountName: {{ .Release.Name }}-srv-account + serviceAccount: {{ include "plane.serviceAccountName" . }} + serviceAccountName: {{ include "plane.serviceAccountName" . }} --- {{- end }} \ No newline at end of file diff --git a/charts/plane-enterprise/templates/workloads/space.deployment.yaml b/charts/plane-enterprise/templates/workloads/space.deployment.yaml index 1a023890..ce211ac8 100644 --- a/charts/plane-enterprise/templates/workloads/space.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/space.deployment.yaml @@ -39,8 +39,9 @@ spec: labels: app.name: {{ .Release.Namespace }}-{{ .Release.Name }}-space {{- include "plane.commonLabels" $ | nindent 8 }} + {{- include "plane.serviceAccountPodLabels" . }} annotations: - timestamp: {{ now | quote }} + {{- include "plane.podAnnotations" . | nindent 8 }} spec: {{- include "plane.podScheduling" .Values.services.space }} {{- include "plane.podSecurityContext" . }} @@ -69,6 +70,6 @@ spec: {{- toYaml .Values.extraEnv | nindent 10 }} {{- end }} {{- end }} - serviceAccount: {{ .Release.Name }}-srv-account - serviceAccountName: {{ .Release.Name }}-srv-account + serviceAccount: {{ include "plane.serviceAccountName" . }} + serviceAccountName: {{ include "plane.serviceAccountName" . }} --- \ No newline at end of file diff --git a/charts/plane-enterprise/templates/workloads/web.deployment.yaml b/charts/plane-enterprise/templates/workloads/web.deployment.yaml index 2160be98..ea8dd4f2 100644 --- a/charts/plane-enterprise/templates/workloads/web.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/web.deployment.yaml @@ -39,8 +39,9 @@ spec: labels: app.name: {{ .Release.Namespace }}-{{ .Release.Name }}-web {{- include "plane.commonLabels" $ | nindent 8 }} + {{- include "plane.serviceAccountPodLabels" . }} annotations: - timestamp: {{ now | quote }} + {{- include "plane.podAnnotations" . | nindent 8 }} spec: {{- include "plane.podScheduling" .Values.services.web }} {{- include "plane.podSecurityContext" . }} @@ -62,5 +63,5 @@ spec: env: {{- toYaml .Values.extraEnv | nindent 10 }} {{- end }} - serviceAccount: {{ .Release.Name }}-srv-account - serviceAccountName: {{ .Release.Name }}-srv-account \ No newline at end of file + serviceAccount: {{ include "plane.serviceAccountName" . }} + serviceAccountName: {{ include "plane.serviceAccountName" . }} \ No newline at end of file diff --git a/charts/plane-enterprise/templates/workloads/webhook-consumer.deployment.yaml b/charts/plane-enterprise/templates/workloads/webhook-consumer.deployment.yaml index 5375c6a5..813da867 100644 --- a/charts/plane-enterprise/templates/workloads/webhook-consumer.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/webhook-consumer.deployment.yaml @@ -7,6 +7,7 @@ metadata: {{- include "plane.labelsAndAnnotations" (dict "context" $ "values" .Values.services.webhook_consumer) }} spec: replicas: {{ .Values.services.webhook_consumer.replicas | default 1 }} + {{- include "plane.rollingUpdateStrategy" . | nindent 2 }} selector: matchLabels: app.name: {{ .Release.Namespace }}-{{ .Release.Name }}-webhook-consumer @@ -16,8 +17,9 @@ spec: labels: app.name: {{ .Release.Namespace }}-{{ .Release.Name }}-webhook-consumer {{- include "plane.commonLabels" $ | nindent 8 }} + {{- include "plane.serviceAccountPodLabels" . }} annotations: - timestamp: {{ now | quote }} + {{- include "plane.podAnnotations" . | nindent 8 }} spec: {{- include "plane.podScheduling" .Values.services.webhook_consumer }} {{- include "plane.podSecurityContext" . }} @@ -49,6 +51,8 @@ spec: command: - ./bin/docker-entrypoint-webhook-consumer.sh envFrom: + {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-webhook-consumer-vars optional: false @@ -69,16 +73,27 @@ 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 }} {{- with (include "plane.otel.envFrom" $) }}{{ . | nindent 10 }}{{- end }} - {{- if or .Values.extraEnv (eq (include "plane.otel.enabled" .) "true") }} + {{- if or .Values.extraEnv (eq (include "plane.otel.enabled" .) "true") (include "plane.infraCredsEnv" .) }} env: {{- with (include "plane.otel.serviceEnv" (dict "ctx" $ "service" "webhook-consumer")) }}{{ . | nindent 10 }}{{- end }} + {{- with (include "plane.infraCredsEnv" .) }} +{{ trim . | indent 10 }} + {{- end }} {{- if .Values.extraEnv }} {{- toYaml .Values.extraEnv | nindent 10 }} {{- end }} {{- end }} - serviceAccount: {{ .Release.Name }}-srv-account - serviceAccountName: {{ .Release.Name }}-srv-account + serviceAccount: {{ include "plane.serviceAccountName" . }} + serviceAccountName: {{ include "plane.serviceAccountName" . }} --- {{- end }} diff --git a/charts/plane-enterprise/templates/workloads/worker-importers.deployment.yaml b/charts/plane-enterprise/templates/workloads/worker-importers.deployment.yaml index 636ec2ae..4d870094 100644 --- a/charts/plane-enterprise/templates/workloads/worker-importers.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/worker-importers.deployment.yaml @@ -7,6 +7,7 @@ metadata: {{- include "plane.labelsAndAnnotations" (dict "context" $ "values" .Values.services.worker_importers) }} spec: replicas: {{ .Values.services.worker_importers.replicas | default 1 }} + {{- include "plane.rollingUpdateStrategy" . | nindent 2 }} selector: matchLabels: app.name: {{ .Release.Namespace }}-{{ .Release.Name }}-worker-importers @@ -16,8 +17,9 @@ spec: labels: app.name: {{ .Release.Namespace }}-{{ .Release.Name }}-worker-importers {{- include "plane.commonLabels" $ | nindent 8 }} + {{- include "plane.serviceAccountPodLabels" . }} annotations: - timestamp: {{ now | quote }} + {{- include "plane.podAnnotations" . | nindent 8 }} spec: {{- include "plane.podScheduling" .Values.services.worker_importers }} {{- include "plane.podSecurityContext" . }} @@ -53,6 +55,8 @@ spec: # Start the importer worker exec ./bin/docker-entrypoint-worker.sh celery.importer envFrom: + {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-app-vars optional: false @@ -70,12 +74,23 @@ 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 }} {{- with (include "plane.otel.envFrom" $) }}{{ . | nindent 10 }}{{- end }} - {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (eq (include "plane.otel.enabled" .) "true") }} + {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.infraCredsEnv" .) (eq (include "plane.otel.enabled" .) "true") }} env: {{- with (include "plane.s3CAEnvVars" .) }} {{ . | indent 10 }} + {{- end }} + {{- with (include "plane.infraCredsEnv" .) }} +{{ trim . | indent 10 }} {{- end }} {{- with (include "plane.otel.serviceEnv" (dict "ctx" $ "service" "worker-importers")) }}{{ . | nindent 10 }}{{- end }} {{- if .Values.extraEnv }} @@ -94,6 +109,6 @@ spec: successThreshold: 1 timeoutSeconds: 5 - serviceAccount: {{ .Release.Name }}-srv-account - serviceAccountName: {{ .Release.Name }}-srv-account + serviceAccount: {{ include "plane.serviceAccountName" . }} + serviceAccountName: {{ include "plane.serviceAccountName" . }} {{- end }} diff --git a/charts/plane-enterprise/templates/workloads/worker.deployment.yaml b/charts/plane-enterprise/templates/workloads/worker.deployment.yaml index 5a548d14..5fce40dd 100644 --- a/charts/plane-enterprise/templates/workloads/worker.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/worker.deployment.yaml @@ -6,6 +6,7 @@ metadata: {{- include "plane.labelsAndAnnotations" (dict "context" $ "values" .Values.services.worker) }} spec: replicas: {{ .Values.services.worker.replicas | default 1 }} + {{- include "plane.rollingUpdateStrategy" . | nindent 2 }} selector: matchLabels: app.name: {{ .Release.Namespace }}-{{ .Release.Name }}-worker @@ -15,8 +16,9 @@ spec: labels: app.name: {{ .Release.Namespace }}-{{ .Release.Name }}-worker {{- include "plane.commonLabels" $ | nindent 8 }} + {{- include "plane.serviceAccountPodLabels" . }} annotations: - timestamp: {{ now | quote }} + {{- include "plane.podAnnotations" . | nindent 8 }} spec: {{- include "plane.podScheduling" .Values.services.worker }} {{- include "plane.podSecurityContext" . }} @@ -52,6 +54,8 @@ spec: # Start the worker exec ./bin/docker-entrypoint-worker.sh envFrom: + {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-app-vars optional: false @@ -69,12 +73,23 @@ 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 }} {{- with (include "plane.otel.envFrom" $) }}{{ . | nindent 10 }}{{- end }} - {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (eq (include "plane.otel.enabled" .) "true") }} + {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.infraCredsEnv" .) (eq (include "plane.otel.enabled" .) "true") }} env: {{- with (include "plane.s3CAEnvVars" .) }} {{ . | indent 10 }} + {{- end }} + {{- with (include "plane.infraCredsEnv" .) }} +{{ trim . | indent 10 }} {{- end }} {{- with (include "plane.otel.serviceEnv" (dict "ctx" $ "service" "worker")) }}{{ . | nindent 10 }}{{- end }} {{- if .Values.extraEnv }} @@ -82,6 +97,6 @@ spec: {{- end }} {{- end }} - serviceAccount: {{ .Release.Name }}-srv-account - serviceAccountName: {{ .Release.Name }}-srv-account + serviceAccount: {{ include "plane.serviceAccountName" . }} + serviceAccountName: {{ include "plane.serviceAccountName" . }} --- diff --git a/charts/plane-enterprise/values.yaml b/charts/plane-enterprise/values.yaml index 56f03792..e0bd64e0 100644 --- a/charts/plane-enterprise/values.yaml +++ b/charts/plane-enterprise/values.yaml @@ -419,7 +419,11 @@ services: tolerations: [] affinity: {} labels: {} - annotations: {} + # The email service mounts a cert-manager TLS Secret, so it has always been + # annotated for Reloader independently of the chart-wide reloader.enabled flag + # (it needs a restart when the certificate is renewed). + annotations: + reloader.stakater.com/auto: 'true' outbox_poller: enabled: false @@ -510,6 +514,24 @@ services: enabled: false base_url: '' api_key: '' + # Amazon Bedrock. Two ways in, and on AWS the second is the better one: + # + # api_key -> AWS_BEARER_TOKEN_BEDROCK, the Bedrock API key (a bearer token issued in + # the Bedrock console). botocore honours that env var natively from 1.39 + # onward, so nothing in the application has to change to use it. Useful off + # AWS, or where a scoped Bedrock key is easier to hand out than an IAM role. + # nothing -> the pod's own identity. With no key set, boto3 walks its credential chain + # to IRSA / EKS Pod Identity and Bedrock is reached with no credential in + # the cluster at all. Pair it with inference_profile_arn. + # + # As everywhere else in this chart the key is OMITTED rather than rendered empty when + # unset: an empty credential is *present*, and present beats the credential chain. + bedrock: + enabled: false + api_key: '' # -> AWS_BEARER_TOKEN_BEDROCK + region: '' # -> BEDROCK_REGION; falls back to env.aws_region + inference_profile_arn: '' # -> BEDROCK_INFERENCE_PROFILE_ARN + inference_profile_id: '' # -> BEDROCK_INFERENCE_PROFILE_ID custom_llm: enabled: false api_key: '' @@ -569,7 +591,221 @@ services: labels: {} annotations: {} +# ============================================================ +# ServiceAccount / cloud workload identity +# ============================================================ +# All Plane workloads run as one ServiceAccount. Annotate it to give the pods a +# cloud identity, which is the preferred way to reach object storage, OpenSearch +# and (on AWS) Secrets Manager — no access keys anywhere in the cluster: +# +# AWS IRSA: eks.amazonaws.com/role-arn: arn:aws:iam:::role/ +# AWS EKS Pod Identity: no annotation — create the association against this +# ServiceAccount's name instead +# GCP Workload Identity: iam.gke.io/gcp-service-account: @.iam.gserviceaccount.com +# Azure Workload Id: azure.workload.identity/client-id: +# plus podLabels: { azure.workload.identity/use: "true" } +# +# With an identity attached, leave env.aws_access_key / aws_secret_access_key (and +# gcs_credentials_json) empty: the chart then omits those env vars entirely so the +# cloud SDK's default credential chain picks up the pod identity. +serviceAccount: + # Set false to reference a ServiceAccount managed outside the chart. + create: true + # Defaults to "-srv-account". + name: '' + annotations: {} + # 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 +# ============================================================ +# Two ways to keep credentials out of this file. Both consume plain Kubernetes +# Secrets, so any backend works — External Secrets Operator against AWS Secrets +# Manager / GCP Secret Manager / Azure Key Vault / Vault, sealed-secrets, or a +# Secret you create by hand. The chart never renders ExternalSecret resources +# itself, which is what keeps it cloud-agnostic. +# +# 1. Infrastructure credentials (database / rabbitmq / redis) — recommended. +# Point `secretName` at a Secret that MIRRORS your cloud secret verbatim: an +# RDS or CloudSQL managed-rotation secret contains only {"username","password"}, +# so a plain ESO `dataFrom.extract` is all you need — no templating, and only +# ONE secret to watch and rotate. The key names below tell the chart which keys +# inside that secret to read; the non-secret endpoint (host/port/database) comes +# from env.* below. The app composes its own connection URLs from these parts, +# so a rotated password needs no URL rewriting anywhere. +# +# Requires the Django services to read the discrete parts. POSTGRES_* and +# RABBITMQ_* are supported by all releases; REDIS_HOST/PORT/PASSWORD/SSL needs +# planeVersion v3.2.0 or newer. +# +# Set `reloader.enabled: true` (and install Stakater Reloader) so pods restart +# when the mirrored Secret changes — that is what makes rotation take effect. +# +# 2. Whole-Secret replacement (*_existingSecret) — the chart skips rendering that +# Secret and every workload reads yours instead. You own all of its keys. external_secrets: + database: + # Secret mirroring your managed-database credential (e.g. the RDS secret). + secretName: '' + usernameKey: username + passwordKey: password + # Optional: set only if your secret also carries the endpoint. When empty the + # host/port/database come from env.pgdb_host / pgdb_port / pgdb_name. + hostKey: '' + 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. + secretName: '' + usernameKey: username + passwordKey: password + hostKey: '' + portKey: '' + vhostKey: '' + + redis: + # Requires planeVersion v3.2.0+ (discrete REDIS_* support in the API). + # For ElastiCache the auth token goes in passwordKey; set env.redis_ssl: true. + secretName: '' + passwordKey: password + 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 + # bundled cluster's credentials, so use opensearch_existingSecret for that. + # On AWS, prefer leaving these unset entirely and letting the pod's IAM identity + # authenticate with SigV4 (see serviceAccount.annotations). + secretName: '' + usernameKey: username + passwordKey: password + + # 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, 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: '' + + # 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: '' + # Name of the existing Kubernetes Secret resource; see README for more details rabbitmq_existingSecret: '' pgdb_existingSecret: '' @@ -584,23 +820,86 @@ external_secrets: # observability.otel.enabled is set; leave blank to let the chart create it. otel_env_existingSecret: '' +# ============================================================ +# Stakater Reloader +# ============================================================ +# Adds reloader.stakater.com/auto to every workload that consumes credentials, so +# a rolling restart happens automatically when one of the Secrets or ConfigMaps it +# references changes — including Secrets the chart does not render, which is how a +# rotated cloud credential reaches a running pod. Requires Reloader to be +# installed in the cluster; the chart does not install it. +reloader: + enabled: false + +global: + # Pre-3.1 behaviour: stamp every pod template with the current timestamp so that + # `helm upgrade` restarts every workload whether or not anything changed. The + # chart now annotates pods with a checksum of the config it renders instead, so + # upgrades roll only what actually changed. Set true to force a full restart. + forceRedeploy: false + env: storageClass: '' + # Fail the render instead of falling back to this chart's public example values + # for SECRET_KEY, AES_SECRET_KEY, LIVE_SERVER_SECRET_KEY, PI_INTERNAL_SECRET, + # SILO_HMAC_SECRET_KEY and CURSOR_WEBHOOK_SECRET. Recommended for production; + # defaults to false so existing installs keep rendering. Will default to true in + # the next major version. + requireExplicitSecrets: false + + # SKIP_ENV_VAR=1 (the default) makes the API read SMTP, OAuth-provider, LLM and + # LDAP secrets from the instance-configuration table in the database, seeded from + # the environment only on first startup — so rotating those through a Secret has + # no effect. Set '0' when an external secret store is the source of truth: the + # API then re-seeds them from the environment on every start. Trade-off: edits + # made in the god-mode admin UI for those settings get overwritten on restart. + skip_env_var: '1' + # Kubernetes internal cluster domain used to build in-cluster service URLs default_cluster_domain: 'cluster.local' # REDIS remote_redis_url: '' #INCASE OF REMOTE REDIS ONLY + # Redis endpoint used with external_secrets.redis (credentials come from that + # Secret). Ignored when services.redis.local_setup is true. + redis_host: '' + redis_port: '6379' + redis_ssl: false + # POSTGRES DB VALUES pgdb_username: plane + # DEPRECATED: a password in this file ends up in the Helm release Secret and in + # any values file you commit. Prefer external_secrets.database. pgdb_password: plane pgdb_name: plane pg_pi_db_name: plane_pi pgdb_remote_url: '' #INCASE OF REMOTE PG DB URL ONLY pg_pi_db_remote_url: '' #INCASE OF REMOTE Plane AI (PI) PG DB URL ONLY + # Postgres endpoint used with external_secrets.database (username/password come + # from that Secret). Ignored when services.postgres.local_setup is true. + 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: '' + rabbitmq_port: '5672' + rabbitmq_vhost: '/' + # Connect over TLS (amqps). Required by Amazon MQ for RabbitMQ, which listens on + # 5671 and refuses plaintext. Only applies to the discrete-parts path — a URL in + # services.rabbitmq.external_rabbitmq_url carries its own scheme. + rabbitmq_ssl: false + # DATA STORE docstore_bucket: uploads doc_upload_size_limit: '5242880' # 5MB @@ -640,6 +939,12 @@ env: opensearch_embedding_dimension: 1536 #API KEYS + # DEPRECATED as a place to keep the real value — this is a PUBLIC example key. + # Supply it through external_secrets.app_keys_existingSecret instead. + # + # NEVER ROTATE once an instance is running: SECRET_KEY derives the Fernet key that + # encrypts the instance-configuration rows (SMTP password, OAuth client secrets, + # LLM keys). Changing it makes every one of those rows undecryptable. secret_key: "60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5" api_key_rate_limit: "60/minute" @@ -668,6 +973,8 @@ env: live_sentry_dsn: "" live_sentry_environment: "" live_sentry_traces_sample_rate: "" + # DEPRECATED as a place to keep the real value — PUBLIC example key. + # Supply through external_secrets.app_keys_existingSecret. live_server_secret_key: "htbqvBJAgpm9bzvf3r4urJer0ENReatceh" # Background PDF/DOCX export worker queue. @@ -684,8 +991,16 @@ env: mq_prefetch_count: 1 request_interval: 400 importers_queue_name: 'celery' + # DEPRECATED as a place to keep the real values — these are PUBLIC example keys. + # Supply them through external_secrets.app_keys_existingSecret. + # 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 @@ -738,6 +1053,8 @@ env: plane_api_host: '' follower_postgres_uri: '' cors_allowed_origins: '' + # DEPRECATED as a place to keep the real value — PUBLIC example key. + # Supply through external_secrets.app_keys_existingSecret. internal_secret: 'tyfvfqvBJAgpm9bzvf3r4urJer0Ehfdubk' log_level: 'DEBUG' diff --git a/hack/assert-secrets.py b/hack/assert-secrets.py new file mode 100755 index 00000000..e6e5724c --- /dev/null +++ b/hack/assert-secrets.py @@ -0,0 +1,175 @@ +#!/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 base64 +import binascii +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 not isinstance(v, str): + continue + if field == "data": + # Secret `data` is base64. Yielding it raw would make every content + # check — the DSN scan above all — look at gibberish and pass. + try: + v = base64.b64decode(v, validate=True).decode("utf-8") + except (binascii.Error, UnicodeDecodeError, ValueError): + pass + 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..c28918c7 --- /dev/null +++ b/hack/resolve-env.py @@ -0,0 +1,155 @@ +#!/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 base64 +import binascii +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, and the decoded value is what the container actually + # sees. Substituting a placeholder here would report two renders as + # different whenever a chart moves a key between the two fields, and would + # hide the value from every caller that compares resolved environments. + for k, v in (doc.get("data") or {}).items(): + try: + decoded = base64.b64decode(v, validate=True).decode("utf-8") + except (binascii.Error, UnicodeDecodeError, ValueError, TypeError): + decoded = "" + merged.setdefault(k, decoded) + 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() From 74a2e19a8c5ea947b7487dc72396db4cd728957f Mon Sep 17 00:00:00 2001 From: Pratapa Lakshmi Date: Wed, 19 Aug 2026 19:17:33 +0530 Subject: [PATCH 02/10] feat(plane-enterprise): give live and live-exporter the broker parts (3.6.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chart already blanked live's AMQP_URL so the RabbitMQ mirror's parts would take over — but it never injected any parts into live, and live-exporter had no credential helper at all. So the mirror reached every service except the two that own the export queue, and it failed in the quietest way available: live boots, serves collaborative editing, and only exports are dead. live now gets plane.rabbitmqCredsEnv alongside the Redis parts it already had. live-exporter gets both — it is the export worker, so the broker credential is the entire point for it, and Redis comes too because it shares live's RedisManager for job state. Paired with the app change that teaches apps/live to compose AMQP_URL from RABBITMQ_HOST/PORT/USER/PASSWORD/VHOST/SSL (makeplane/plane-ee#8747). Verified against the ext-secret-test values: both workloads resolve AMQP_URL empty with the five parts present, the credentials arriving by secretKeyRef from the operator's Secret, RABBITMQ_SSL=1 and port 5671 for Amazon MQ. The NOTES warning is reworded rather than deleted. It was stated as a permanent gap in live; it is actually a version constraint, and an operator on an older planeVersion still needs to hear it — with the specific note that only exports fail, because that is what makes it easy to miss. Additive as usual: with no external_secrets set, all 18 containers resolve an identical environment to 3.6.2. --- charts/plane-enterprise/Chart.yaml | 2 +- charts/plane-enterprise/templates/NOTES.txt | 2 +- .../templates/workloads/live-exporter.deployment.yaml | 10 ++++++++++ .../templates/workloads/live.deployment.yaml | 8 +++++++- 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/charts/plane-enterprise/Chart.yaml b/charts/plane-enterprise/Chart.yaml index e87de17d..ca27cc00 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.6.2 +version: 3.6.3 appVersion: "3.1.1" home: https://plane.so/ diff --git a/charts/plane-enterprise/templates/NOTES.txt b/charts/plane-enterprise/templates/NOTES.txt index 3c1d45fd..67642b5b 100644 --- a/charts/plane-enterprise/templates/NOTES.txt +++ b/charts/plane-enterprise/templates/NOTES.txt @@ -32,7 +32,7 @@ Plane Enterprise {{ .Chart.Version }} — release {{ .Release.Name }} in namespa DSN into live-secrets and live is then left with no broker. Not fatal — live boots and only exports fail, lazily — but it fails quietly, which is worse. */}} {{- if and .Values.external_secrets.rabbitmq.secretName (empty .Values.external_secrets.live_env_existingSecret) }} -{{- $warnings = append $warnings "The RabbitMQ mirror does not reach live. live reads only AMQP_URL and has no\n broker parts, so with external_secrets.rabbitmq.secretName set its export queue is\n unconfigured: live still serves collaborative editing, but exports fail. Supply\n external_secrets.live_env_existingSecret with a templated AMQP_URL, or keep the\n broker credential in services.rabbitmq.external_rabbitmq_url until live learns parts." }} +{{- $warnings = append $warnings "The RabbitMQ mirror reaches live only from the app release that added discrete\n broker parts to it. Before that, live read only AMQP_URL, so with\n external_secrets.rabbitmq.secretName set its export queue is unconfigured — live still\n serves collaborative editing and only exports fail, which is easy to miss. On an older\n planeVersion, supply external_secrets.live_env_existingSecret with a templated AMQP_URL,\n or keep the broker credential in services.rabbitmq.external_rabbitmq_url." }} {{- end }} {{- /* diff --git a/charts/plane-enterprise/templates/workloads/live-exporter.deployment.yaml b/charts/plane-enterprise/templates/workloads/live-exporter.deployment.yaml index c3ab0de8..a9e5479e 100644 --- a/charts/plane-enterprise/templates/workloads/live-exporter.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/live-exporter.deployment.yaml @@ -48,6 +48,16 @@ spec: env: - name: LIVE_MODE value: "exporter" + {{/* The export worker is the whole reason the broker credential matters here, and it + had no credential helper at all — it inherited whatever AMQP_URL the live Secret + carried, which the mirror blanks on purpose. Redis comes too: the exporter shares + live's RedisManager for job state. */}} + {{- with (include "plane.rabbitmqCredsEnv" .) }} +{{ trim . | indent 10 }} + {{- end }} + {{- with (include "plane.redisCredsEnv" .) }} +{{ trim . | indent 10 }} + {{- end }} {{- with (include "plane.otel.serviceEnv" (dict "ctx" $ "service" "live-exporter")) }}{{ . | nindent 10 }}{{- end }} {{- if .Values.extraEnv }} {{- toYaml .Values.extraEnv | nindent 10 }} diff --git a/charts/plane-enterprise/templates/workloads/live.deployment.yaml b/charts/plane-enterprise/templates/workloads/live.deployment.yaml index 41ec2006..aee9d6ce 100644 --- a/charts/plane-enterprise/templates/workloads/live.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/live.deployment.yaml @@ -101,10 +101,16 @@ spec: name: {{ if not (empty .Values.external_secrets.live_env_existingSecret) }}{{ .Values.external_secrets.live_env_existingSecret }}{{ else }}{{ .Release.Name }}-live-secrets{{ end }} optional: false {{- with (include "plane.otel.envFrom" $) }}{{ . | nindent 10 }}{{- end }} - {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.redisCredsEnv" .) (eq (include "plane.otel.enabled" .) "true") }} + {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.redisCredsEnv" .) (include "plane.rabbitmqCredsEnv" .) (eq (include "plane.otel.enabled" .) "true") }} env: {{- with (include "plane.s3CANodeEnvVars" .) }} {{ . | indent 10 }} + {{- end }} + {{/* live reads the broker as discrete parts from the release that added + composeAmqpUrlFromParts(); before that it read only AMQP_URL, so the mirror + reached every service except this one and only exports failed. */}} + {{- with (include "plane.rabbitmqCredsEnv" .) }} +{{ trim . | indent 10 }} {{- end }} {{- with (include "plane.redisCredsEnv" .) }} {{ trim . | indent 10 }} From 6bffecc2a5731d63b5e01cd4de1db33cf2c5368c Mon Sep 17 00:00:00 2001 From: Pratapa Lakshmi Date: Wed, 19 Aug 2026 20:29:20 +0530 Subject: [PATCH 03/10] feat(plane-enterprise): extraObjects, so an env's ESO wiring can live with its release (3.6.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything a Plane environment needs in order to STOP holding credentials — an ESO SecretStore, the ExternalSecrets that produce the Secrets this chart reads by name — is not part of this chart and should not be. But it also has nowhere to live when a GitOps tool treats one directory as one unit: Fleet's dependsOn orders one bundle against another, so expressing "Secrets before workloads" forced a second bundle to exist purely to be depended on. extraObjects removes that. Rendered with toYaml and NOT tpl, deliberately. What people put here is usually an ExternalSecret whose target.template contains ESO's own {{ }} placeholders; tpl would try to evaluate those as Helm expressions and either fail or silently resolve them to empty. Helm does not template values, so this passes them through untouched — verified by rendering an ExternalSecret whose template references {{ .CLOUDFLARE_KEY }} and confirming it survives. Ordering is the caller's to choose with the standard Helm hook annotations, and the template's comment shows the shape, because it is the whole point: this chart's Secret references are optional: false, so a pod that starts before its Secret exists sits in CreateContainerConfigError until it appears. A pre-install/pre-upgrade hook with a negative weight is applied before the workloads. Inert when unset — extraObjects defaults to [] and the default render is unchanged. --- charts/plane-enterprise/Chart.yaml | 2 +- .../templates/extra-objects.yaml | 38 +++++++++++++++++++ charts/plane-enterprise/values.yaml | 14 +++++++ 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 charts/plane-enterprise/templates/extra-objects.yaml diff --git a/charts/plane-enterprise/Chart.yaml b/charts/plane-enterprise/Chart.yaml index ca27cc00..72a129ce 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.6.3 +version: 3.6.4 appVersion: "3.1.1" home: https://plane.so/ diff --git a/charts/plane-enterprise/templates/extra-objects.yaml b/charts/plane-enterprise/templates/extra-objects.yaml new file mode 100644 index 00000000..307893c3 --- /dev/null +++ b/charts/plane-enterprise/templates/extra-objects.yaml @@ -0,0 +1,38 @@ +{{/* +Arbitrary extra objects, supplied by the operator through values. + +The reason this exists: everything a Plane environment needs to STOP holding credentials — +an ESO SecretStore, the ExternalSecrets that produce the Secrets this chart reads by name — +is not part of this chart and never should be, but it also has nowhere else to live when the +release is delivered by a GitOps tool that treats one directory as one unit. Without this, +those objects need a second bundle purely to exist, and a second bundle is a second thing to +order correctly. + +Rendered with toYaml and NOT tpl, deliberately. The objects people put here are usually +ExternalSecrets whose `target.template` contains ESO's own {{ }} placeholders; tpl would try +to evaluate those as Helm expressions and fail, or worse, silently resolve them to empty. +Values are not templated by Helm, so writing them here passes them through untouched. + +Ordering: give an object the standard Helm hook annotations to have it applied before the +workloads, which is what an ExternalSecret wants — the chart's Secret references are +optional: false, so a pod that starts before its Secret exists sits in +CreateContainerConfigError until it appears. + + extraObjects: + - apiVersion: external-secrets.io/v1 + kind: SecretStore + metadata: + name: aws-secrets-manager + annotations: + helm.sh/hook: pre-install,pre-upgrade + helm.sh/hook-weight: "-10" + helm.sh/hook-delete-policy: before-hook-creation + spec: { ... } + +Namespace is left to the object: most callers want the release namespace, which is the +default when it is omitted, and a few legitimately want another. +*/}} +{{- range .Values.extraObjects }} +--- +{{ toYaml . }} +{{- end }} diff --git a/charts/plane-enterprise/values.yaml b/charts/plane-enterprise/values.yaml index e0bd64e0..b507b378 100644 --- a/charts/plane-enterprise/values.yaml +++ b/charts/plane-enterprise/values.yaml @@ -1071,6 +1071,20 @@ env: # Global extra environment variables that will be applied to all workloads extraEnv: [] + +# Arbitrary extra Kubernetes objects rendered as part of this release. Intended for the +# things that let an environment hold no credentials — an ESO SecretStore and the +# ExternalSecrets producing the Secrets referenced by external_secrets.* above — which +# belong to the operator rather than to this chart, but have nowhere to live when a GitOps +# tool treats one directory as one unit. +# +# Passed through verbatim (toYaml, not tpl) so that ESO's own {{ }} placeholders inside a +# target.template survive. Add the standard Helm hook annotations to have an object applied +# BEFORE the workloads; the Secret references in this chart are optional: false, so a pod +# that starts before its Secret exists waits in CreateContainerConfigError until it appears. +# +# See the "Keeping credentials out of values.yaml" section of the README for a worked example. +extraObjects: [] # - name: HTTP_PROXY # value: "http://proxy.example.com:8080" # - name: HTTPS_PROXY From 21a7bad8fec23c3e5900d7bf0ab4c2b4aedd74b2 Mon Sep 17 00:00:00 2001 From: Pratapa Lakshmi Date: Mon, 24 Aug 2026 12:21:14 +0530 Subject: [PATCH 04/10] fix(plane-enterprise): address the remaining CodeRabbit review on the secret contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from the review on #278, plus one declined. NOTES.txt — the SemVer probe was a prefix match, so "3.2.0.1" (a realistic four-part vendor version) was treated as parsable, reached semverCompare and aborted the render with "Invalid Semantic Version" instead of falling through to the compatibility warning it exists to print. Anchored, with optional prerelease and build metadata so real SemVer still compares. app-env.yaml — externalized credentials describe a remote backend, but nothing stopped them being paired with a bundled one. The chart would then hand the app the operator's credential and the in-cluster endpoint, which the bundled service never authenticates; for Redis it also blanks REDIS_URL, leaving an older image with no route at all. Now a render-time failure for postgres/redis/rabbitmq, matching what plane.externalOpensearch already did for its own backend. app-env.yaml — the read-replica guard proved credentials existed but not an endpoint, so replica-enabled plus a database Secret and no pgdb_read_replica_host rendered POSTGRES_READ_REPLICA_HOST="". The API deliberately does not fall back to the primary there, because that would send every "replica" read to the writer with no error, so an empty host is a broken replica rather than a slow one. The contract is now enforced and documented in values.yaml. Declined: removing plane.siloConnectorsSecretRef from the migrator. The migrator already mounts -silo-secrets on master, and silo.yaml suppresses the connector keys from it when silo_connectors_existingSecret is set — so dropping the ref would give the migrator strictly fewer variables on the externalized path than on the default one, which is the divergence this PR exists to avoid. --- charts/plane-enterprise/templates/NOTES.txt | 6 +++- .../templates/config-secrets/app-env.yaml | 28 +++++++++++++++++++ charts/plane-enterprise/values.yaml | 5 ++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/charts/plane-enterprise/templates/NOTES.txt b/charts/plane-enterprise/templates/NOTES.txt index 67642b5b..4837c39d 100644 --- a/charts/plane-enterprise/templates/NOTES.txt +++ b/charts/plane-enterprise/templates/NOTES.txt @@ -48,7 +48,11 @@ planeVersion is operator-supplied and need not be semver ("stable", a git sha), 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 }} +{{/* Anchored on purpose. A prefix-only match accepts "3.2.0.1" and "3.2.0foo", + which then reach semverCompare and abort the render with a parse error instead + of falling through to the warning below. Four-part vendor versions are the + realistic case. */}} +{{- $parsable := regexMatch "^[0-9]+\\.[0-9]+\\.[0-9]+(-[0-9A-Za-z.-]+)?(\\+[0-9A-Za-z.-]+)?$" $version }} {{- $preRedisParts := or (not $parsable) (semverCompare "< 3.2.0" $version) }} {{- $preServiceParts := or (not $parsable) (semverCompare "< 3.2.0" $version) }} diff --git a/charts/plane-enterprise/templates/config-secrets/app-env.yaml b/charts/plane-enterprise/templates/config-secrets/app-env.yaml index 9b5c1809..1813cd03 100644 --- a/charts/plane-enterprise/templates/config-secrets/app-env.yaml +++ b/charts/plane-enterprise/templates/config-secrets/app-env.yaml @@ -1,3 +1,20 @@ +{{/* Externalized credentials describe a REMOTE backend. Paired with a bundled one the + chart would hand the app a credential from the operator's Secret and the in-cluster + endpoint from plane.{postgres,redis,rabbitmq}Host — and the bundled service never + authenticates that credential. For redis it is worse: plane.externalRedis also blanks + REDIS_URL, so an older image is left with no route to Redis at all. Fail at render + time rather than at runtime. plane.externalOpensearch already refuses this pairing; + these three did not. */}} +{{- if and .Values.external_secrets.database.secretName .Values.services.postgres.local_setup }} +{{- fail "external_secrets.database.secretName is set while services.postgres.local_setup is true — externalized credentials cannot authenticate against the bundled Postgres. Set services.postgres.local_setup: false and point env.pgdb_host at the remote database, or drop external_secrets.database.secretName" }} +{{- end }} +{{- if and .Values.external_secrets.redis.secretName .Values.services.redis.local_setup }} +{{- fail "external_secrets.redis.secretName is set while services.redis.local_setup is true — externalized credentials cannot authenticate against the bundled Redis, and REDIS_URL is blanked. Set services.redis.local_setup: false and point env.redis_host at the remote cache, or drop external_secrets.redis.secretName" }} +{{- end }} +{{- if and .Values.external_secrets.rabbitmq.secretName .Values.services.rabbitmq.local_setup }} +{{- fail "external_secrets.rabbitmq.secretName is set while services.rabbitmq.local_setup is true — externalized credentials cannot authenticate against the bundled RabbitMQ. Set services.rabbitmq.local_setup: false and point env.rabbitmq_host at the remote broker, or drop external_secrets.rabbitmq.secretName" }} +{{- end }} + {{/* 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. */}} @@ -7,6 +24,17 @@ (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 }} +{{/* Credentials are only half of it. plane.postgresReadReplicaCredsEnv renders + POSTGRES_READ_REPLICA_HOST from env.pgdb_read_replica_host, so with that unset and no + hostKey in the mirrored secret the replica gets an empty host. The API deliberately + does NOT fall back to the primary there (it would send every "replica" read to the + writer with no error), so an empty host is a broken replica, not a slow one. */}} +{{- if and (include "plane.externalReadReplica" .) + (not .Values.services.postgres.read_replica.remote_url) + (not .Values.env.pgdb_read_replica_host) + (not .Values.external_secrets.database.readReplica.hostKey) }} +{{- fail "read_replica.enabled is true with externalized credentials but no replica endpoint — set env.pgdb_read_replica_host, or external_secrets.database.readReplica.hostKey if the mirrored secret carries the replica host" }} +{{- end }} {{- if empty .Values.external_secrets.app_env_existingSecret}} apiVersion: v1 kind: Secret diff --git a/charts/plane-enterprise/values.yaml b/charts/plane-enterprise/values.yaml index 5d4eaec7..00d042ff 100644 --- a/charts/plane-enterprise/values.yaml +++ b/charts/plane-enterprise/values.yaml @@ -796,6 +796,11 @@ external_secrets: # # secretName defaults to the primary's, since a replica normally accepts the same # credential. Set it only when the replica has its own user. + # + # env.pgdb_read_replica_host is MANDATORY unless hostKey below names a key in the + # mirrored secret that carries the replica host. The chart fails the render if + # neither is set: the API never falls back to the primary's endpoint, so an empty + # replica host would send every read to the writer silently. readReplica: secretName: '' usernameKey: '' # defaults to database.usernameKey From 1f4baa62027e7d74cd9740df5eb123aaa785d00c Mon Sep 17 00:00:00 2001 From: Pratapa Lakshmi Date: Mon, 24 Aug 2026 12:27:54 +0530 Subject: [PATCH 05/10] chore(plane-enterprise): ship the whole secret contract as one 3.6.0 bump The branch had walked 3.6.1 through 3.6.5 as it grew. Nothing above 3.5.2 was ever published, so collapse it: one version for one PR, and the base64 `data` note in README/NOTES now points at the release that actually carries it. --- charts/plane-enterprise/Chart.yaml | 2 +- charts/plane-enterprise/README.md | 4 ++-- charts/plane-enterprise/templates/NOTES.txt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/charts/plane-enterprise/Chart.yaml b/charts/plane-enterprise/Chart.yaml index a582d2b4..8390df7d 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.6.5 +version: 3.6.0 appVersion: "3.1.2" home: https://plane.so/ diff --git a/charts/plane-enterprise/README.md b/charts/plane-enterprise/README.md index 79705c3a..6f6baa64 100644 --- a/charts/plane-enterprise/README.md +++ b/charts/plane-enterprise/README.md @@ -1370,9 +1370,9 @@ The rollout restart is not optional. A running pod keeps the environment it star the pods carry the old access key until they are replaced — which is why this can look like it "did not work" after the Secret is already correct. -From chart 3.6.1 onward that Secret is rendered as base64 `data` rather than `stringData`, so +From chart 3.6.0 onward that Secret is rendered as base64 `data` rather than `stringData`, so Helm can express the deletion and this stops recurring. The one-time cleanup above is still -needed for the upgrade that crosses into 3.6.1. +needed for the upgrade that crosses into 3.6.0. ### 6. Secrets that live in the database, not the environment diff --git a/charts/plane-enterprise/templates/NOTES.txt b/charts/plane-enterprise/templates/NOTES.txt index 4837c39d..832a03d7 100644 --- a/charts/plane-enterprise/templates/NOTES.txt +++ b/charts/plane-enterprise/templates/NOTES.txt @@ -141,7 +141,7 @@ as "sign with SigV4". credential chain. Fires only when this is a keyless external-storage configuration, which is exactly when the leftover is fatal rather than cosmetic. */}} {{- if and (not .Values.services.minio.local_setup) (ne (.Values.env.storage_provider | default "S3" | upper) "GCS") (not .Values.env.aws_access_key) (empty .Values.external_secrets.doc_store_existingSecret) }} -{{- $warnings = append $warnings (printf "Storage is keyless (no access key), which relies on AWS_ACCESS_KEY_ID being ABSENT.\n If this release EVER ran with services.minio.local_setup=true, its MinIO root\n credentials are still in %s-doc-store-secrets: Helm cannot remove a key it no longer\n renders, and a present access key is found first in boto3's chain, so every S3 call\n fails with InvalidClientTokenId while the config looks correct. Once:\n kubectl delete secret %s-doc-store-secrets -n %s && helm upgrade ...\n kubectl rollout restart deploy -n %s -l app.kubernetes.io/instance=%s\n The restart matters — envFrom is read once at container start. Fresh installs and\n releases already on chart 3.6.1+ are unaffected." .Release.Name .Release.Name .Release.Namespace .Release.Namespace .Release.Name) }} +{{- $warnings = append $warnings (printf "Storage is keyless (no access key), which relies on AWS_ACCESS_KEY_ID being ABSENT.\n If this release EVER ran with services.minio.local_setup=true, its MinIO root\n credentials are still in %s-doc-store-secrets: Helm cannot remove a key it no longer\n renders, and a present access key is found first in boto3's chain, so every S3 call\n fails with InvalidClientTokenId while the config looks correct. Once:\n kubectl delete secret %s-doc-store-secrets -n %s && helm upgrade ...\n kubectl rollout restart deploy -n %s -l app.kubernetes.io/instance=%s\n The restart matters — envFrom is read once at container start. Fresh installs and\n releases already on chart 3.6.0+ are unaffected." .Release.Name .Release.Name .Release.Namespace .Release.Namespace .Release.Name) }} {{- end }} {{- if $warnings }} From d3d41a7fae0570279ed49ceed013bf0fcc178114 Mon Sep 17 00:00:00 2001 From: Pratapa Lakshmi Date: Wed, 26 Aug 2026 11:23:03 +0530 Subject: [PATCH 06/10] fix(plane-enterprise): roll workloads when the OTEL config changes plane.configChecksum hashed every config-secret template except otel.yaml, so retuning observability.otel.* rewrote -otel-vars / -otel-secrets but left the checksum untouched. envFrom is read once at container start, so the running pods kept the old exporter config until something else rolled them. Only reachable since this branch replaced the unconditional `timestamp` pod annotation with a content checksum -- on 3.5.2 every upgrade rolled everything, which hid it. Folded into the same 3.6.0 bump; nothing is released yet. docker-registry and cert-issuers stay out of the hash: neither is pod env, so hashing them would roll every workload for a change no container can observe. Also moves the extraEnv HTTP_PROXY example back under extraEnv, where it sat before extraObjects was inserted above it. Co-Authored-By: Claude Opus 5 (1M context) --- charts/plane-enterprise/templates/_helpers.tpl | 6 +++++- charts/plane-enterprise/values.yaml | 12 ++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/charts/plane-enterprise/templates/_helpers.tpl b/charts/plane-enterprise/templates/_helpers.tpl index 12030b20..a80784b5 100644 --- a/charts/plane-enterprise/templates/_helpers.tpl +++ b/charts/plane-enterprise/templates/_helpers.tpl @@ -347,11 +347,15 @@ plane.reloaderAnnotations. The hash intentionally covers all config-secret templates rather than a per-workload subset: several keys (AES_SECRET_KEY, LIVE_SERVER_SECRET_KEY, PI_INTERNAL_SECRET) must stay in lockstep across services, so a shared trigger is the safe default. + +The list is every config-secret template a workload mounts via envFrom. docker-registry +and cert-issuers are excluded on purpose: neither is pod env, so hashing them would roll +every workload for a change no running container can observe. */}} {{- define "plane.configChecksum" -}} {{- $ctx := . -}} {{- $acc := "" -}} -{{- range $f := list "app-env" "pgdb" "rabbitmqdb" "doc-store" "opensearchdb" "live-env" "silo" "pi-api-env" "runner-env" "email-env" "monitor" "outbox-poller" "webhook-consumer" "automations-consumer" "agent-consumer" -}} +{{- range $f := list "app-env" "pgdb" "rabbitmqdb" "doc-store" "opensearchdb" "live-env" "silo" "pi-api-env" "runner-env" "email-env" "monitor" "outbox-poller" "webhook-consumer" "automations-consumer" "agent-consumer" "otel" -}} {{- $acc = print $acc (include (print $ctx.Template.BasePath "/config-secrets/" $f ".yaml") $ctx) -}} {{- end -}} {{- $acc | sha256sum -}} diff --git a/charts/plane-enterprise/values.yaml b/charts/plane-enterprise/values.yaml index 00d042ff..935fcc75 100644 --- a/charts/plane-enterprise/values.yaml +++ b/charts/plane-enterprise/values.yaml @@ -1199,6 +1199,12 @@ env: # Global extra environment variables that will be applied to all workloads extraEnv: [] + # - name: HTTP_PROXY + # value: "http://proxy.example.com:8080" + # - name: HTTPS_PROXY + # value: "http://proxy.example.com:8080" + # - name: NO_PROXY + # value: "localhost,127.0.0.1,.example.com" # Arbitrary extra Kubernetes objects rendered as part of this release. Intended for the # things that let an environment hold no credentials — an ESO SecretStore and the @@ -1213,12 +1219,6 @@ extraEnv: [] # # See the "Keeping credentials out of values.yaml" section of the README for a worked example. extraObjects: [] - # - name: HTTP_PROXY - # value: "http://proxy.example.com:8080" - # - name: HTTPS_PROXY - # value: "http://proxy.example.com:8080" - # - name: NO_PROXY - # value: "localhost,127.0.0.1,.example.com" # OpenTelemetry (traces + logs + metrics). Off by default. When enabled, a shared # ConfigMap (-otel-vars) is mounted via envFrom into the backend From ba61673b7239e13f70175c998bf4f7de07d6b304 Mon Sep 17 00:00:00 2001 From: Pratapa Lakshmi Date: Wed, 26 Aug 2026 13:37:33 +0530 Subject: [PATCH 07/10] chore: drop the hack/ verification scripts from the chart repo assert-secrets.py and resolve-env.py were written to check this branch's work -- that no rendered Secret still carries a credential, and that each container resolves the env it did before. Useful while building it, but they are not chart deliverables and they sat at the repo root of a published charts repo. Nothing references them: no README, NOTES.txt, or the rotation runbook. Both were added on this branch, so removing them leaves no trace in master's history. Co-Authored-By: Claude Opus 5 (1M context) --- hack/assert-secrets.py | 175 ----------------------------------------- hack/resolve-env.py | 155 ------------------------------------ 2 files changed, 330 deletions(-) delete mode 100755 hack/assert-secrets.py delete mode 100755 hack/resolve-env.py diff --git a/hack/assert-secrets.py b/hack/assert-secrets.py deleted file mode 100755 index e6e5724c..00000000 --- a/hack/assert-secrets.py +++ /dev/null @@ -1,175 +0,0 @@ -#!/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 base64 -import binascii -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 not isinstance(v, str): - continue - if field == "data": - # Secret `data` is base64. Yielding it raw would make every content - # check — the DSN scan above all — look at gibberish and pass. - try: - v = base64.b64decode(v, validate=True).decode("utf-8") - except (binascii.Error, UnicodeDecodeError, ValueError): - pass - 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 deleted file mode 100755 index c28918c7..00000000 --- a/hack/resolve-env.py +++ /dev/null @@ -1,155 +0,0 @@ -#!/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 base64 -import binascii -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, and the decoded value is what the container actually - # sees. Substituting a placeholder here would report two renders as - # different whenever a chart moves a key between the two fields, and would - # hide the value from every caller that compares resolved environments. - for k, v in (doc.get("data") or {}).items(): - try: - decoded = base64.b64decode(v, validate=True).decode("utf-8") - except (binascii.Error, UnicodeDecodeError, ValueError, TypeError): - decoded = "" - merged.setdefault(k, decoded) - 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() From b8e61574a42d9dce2dadee2ea063356f502de27c Mon Sep 17 00:00:00 2001 From: Pratapa Lakshmi Date: Wed, 26 Aug 2026 13:41:37 +0530 Subject: [PATCH 08/10] chore(plane-enterprise): do not enforce the Reloader annotation on email Back to annotations: {}, so nothing is imposed on the workload. Operators who want the restart set reloader.enabled, or add the annotation themselves. Note this differs from master, where the annotation was a literal in email.deployment.yaml and so applied to every deployment. That literal sat above the plane.labelsAndAnnotations include, which emits its own annotations: key -- so any operator who set services.email_service.annotations turned it into a duplicate YAML key and silently lost the Reloader one. Routing everything through the helper fixes that: annotations now compose instead of clobbering. The trade-off is stated in values.yaml rather than left implicit -- this service mounts a cert-manager TLS Secret, so without the annotation it will not pick up a renewed certificate until something else restarts it. Co-Authored-By: Claude Opus 5 (1M context) --- charts/plane-enterprise/values.yaml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/charts/plane-enterprise/values.yaml b/charts/plane-enterprise/values.yaml index 935fcc75..ee6f66ff 100644 --- a/charts/plane-enterprise/values.yaml +++ b/charts/plane-enterprise/values.yaml @@ -541,11 +541,10 @@ services: tolerations: [] affinity: {} labels: {} - # The email service mounts a cert-manager TLS Secret, so it has always been - # annotated for Reloader independently of the chart-wide reloader.enabled flag - # (it needs a restart when the certificate is renewed). - annotations: - reloader.stakater.com/auto: 'true' + # This service mounts a cert-manager TLS Secret, so it needs a restart when the + # certificate is renewed. Set reloader.enabled, or add + # reloader.stakater.com/auto here, if you want that to happen automatically. + annotations: {} outbox_poller: enabled: false From 66496daa65abe1b7fdcf189cf455e1e4f09c05aa Mon Sep 17 00:00:00 2001 From: Pratapa Lakshmi Date: Thu, 27 Aug 2026 16:35:26 +0530 Subject: [PATCH 09/10] fix(plane-enterprise): let the key-group Secrets win in envFrom The same ordering bug review found in plane-ce, which this chart shares: the key-group includes were FIRST in envFrom on every workload that uses them. envFrom is later-source-wins, so an operator setting app_keys_existingSecret alongside app_env_existingSecret -- or silo_connectors_existingSecret alongside silo_env_existingSecret -- would have the whole-Secret hook override the key the group hook owns. That defeats the group hook's purpose. SECRET_KEY and LIVE_SERVER_SECRET_KEY are rendered into both the app and live Secrets and must agree; an override landing on the api but not on live leaves the two with different signing keys and live-server auth fails with nothing logged. SECRET_KEY also derives the key encrypting the instance-configuration rows. NOTES.txt already warned about this interaction at install time. A warning is weaker than correct ordering: it fires only if the operator reads it, and says nothing about which of the two Secrets wins. 17 envFrom blocks across 17 workloads. Two needed more than a straight move -- api carries both appKeysSecretRef and siloConnectorsSecretRef stacked together, and silo has two envFrom blocks with the include leading the second. Verified by rendering with both the group and whole-Secret hooks set and checking every container: 10 containers receive a group Secret and in all 10 it now sorts after every other source. The default render is unchanged, which it must be -- with no hook set the includes emit nothing, so order cannot matter. Co-Authored-By: Claude Opus 5 (1M context) --- .../templates/workloads/agent-consumer.deployment.yaml | 9 +++++++-- .../templates/workloads/api.deployment.yaml | 9 +++++++-- .../workloads/automation-consumer.deployment.yaml | 7 ++++++- .../templates/workloads/beat-worker.deployment.yaml | 9 +++++++-- .../templates/workloads/external-api.deployment.yaml | 9 +++++++-- .../templates/workloads/live.deployment.yaml | 7 ++++++- .../templates/workloads/migrator.job.yaml | 9 +++++++-- .../templates/workloads/outbox-poller.deployment.yaml | 7 ++++++- .../templates/workloads/pi-api.deployment.yaml | 7 ++++++- .../templates/workloads/pi-beat.deployment.yaml | 7 ++++++- .../templates/workloads/pi-migrator.job.yaml | 7 ++++++- .../templates/workloads/pi-worker.deployment.yaml | 7 ++++++- .../templates/workloads/runner.deployment.yaml | 7 ++++++- .../templates/workloads/silo.deployment.yaml | 9 +++++++-- .../templates/workloads/webhook-consumer.deployment.yaml | 9 +++++++-- .../templates/workloads/worker-importers.deployment.yaml | 9 +++++++-- .../templates/workloads/worker.deployment.yaml | 9 +++++++-- 17 files changed, 111 insertions(+), 26 deletions(-) diff --git a/charts/plane-enterprise/templates/workloads/agent-consumer.deployment.yaml b/charts/plane-enterprise/templates/workloads/agent-consumer.deployment.yaml index 5f27556a..69b79668 100644 --- a/charts/plane-enterprise/templates/workloads/agent-consumer.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/agent-consumer.deployment.yaml @@ -51,8 +51,6 @@ spec: command: - ./bin/docker-entrypoint-agent-consumer.sh envFrom: - {{- include "plane.appKeysSecretRef" . }} - {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-agent-consumer-vars optional: false @@ -81,6 +79,13 @@ spec: optional: false {{- end }} {{- with (include "plane.otel.envFrom" $) }}{{ . | nindent 10 }}{{- end }} + {{- /* LAST in envFrom, deliberately: envFrom is later-source-wins, so these must + follow app_env/silo_env/live_env. Those may carry their own copy of a key a + group hook owns, and first would let the stale copy win on one workload but + not another -- the app and live copies of LIVE_SERVER_SECRET_KEY must agree + or live-server auth fails with nothing logged. */}} + {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} {{- if or .Values.extraEnv (eq (include "plane.otel.enabled" .) "true") (include "plane.infraCredsEnv" .) }} env: {{- with (include "plane.otel.serviceEnv" (dict "ctx" $ "service" "agent-consumer")) }}{{ . | nindent 10 }}{{- end }} diff --git a/charts/plane-enterprise/templates/workloads/api.deployment.yaml b/charts/plane-enterprise/templates/workloads/api.deployment.yaml index b50ea7c2..076e0145 100644 --- a/charts/plane-enterprise/templates/workloads/api.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/api.deployment.yaml @@ -78,8 +78,6 @@ spec: # Start the API exec ./bin/docker-entrypoint-api-ee.sh envFrom: - {{- include "plane.appKeysSecretRef" . }} - {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-app-vars optional: false @@ -105,6 +103,13 @@ spec: 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 }} + {{- /* LAST in envFrom, deliberately: envFrom is later-source-wins, so these must + follow app_env/silo_env/live_env. Those may carry their own copy of a key a + group hook owns, and first would let the stale copy win on one workload but + not another -- the app and live copies of LIVE_SERVER_SECRET_KEY must agree + or live-server auth fails with nothing logged. */}} + {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} {{- with (include "plane.otel.envFrom" $) }}{{ . | nindent 10 }}{{- end }} {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.infraCredsEnv" .) (eq (include "plane.otel.enabled" .) "true") }} diff --git a/charts/plane-enterprise/templates/workloads/automation-consumer.deployment.yaml b/charts/plane-enterprise/templates/workloads/automation-consumer.deployment.yaml index 054bfcff..51a2dee4 100644 --- a/charts/plane-enterprise/templates/workloads/automation-consumer.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/automation-consumer.deployment.yaml @@ -40,7 +40,6 @@ spec: command: - ./bin/docker-entrypoint-automation-consumer.sh envFrom: - {{- include "plane.appKeysSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-automation-consumer-vars optional: false @@ -57,6 +56,12 @@ spec: name: {{ if not (empty .Values.external_secrets.opensearch_existingSecret) }}{{ .Values.external_secrets.opensearch_existingSecret }}{{ else }}{{ .Release.Name }}-opensearch-secrets{{ end }} optional: false {{- with (include "plane.otel.envFrom" $) }}{{ . | nindent 10 }}{{- end }} + {{- /* LAST in envFrom, deliberately: envFrom is later-source-wins, so these must + follow app_env/silo_env/live_env. Those may carry their own copy of a key a + group hook owns, and first would let the stale copy win on one workload but + not another -- the app and live copies of LIVE_SERVER_SECRET_KEY must agree + or live-server auth fails with nothing logged. */}} + {{- include "plane.appKeysSecretRef" . }} {{- if or .Values.extraEnv (eq (include "plane.otel.enabled" .) "true") (include "plane.infraCredsEnv" .) }} env: {{- with (include "plane.otel.serviceEnv" (dict "ctx" $ "service" "automation-consumer")) }}{{ . | nindent 10 }}{{- end }} diff --git a/charts/plane-enterprise/templates/workloads/beat-worker.deployment.yaml b/charts/plane-enterprise/templates/workloads/beat-worker.deployment.yaml index bb9f3ec9..b9b5f85a 100644 --- a/charts/plane-enterprise/templates/workloads/beat-worker.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/beat-worker.deployment.yaml @@ -38,8 +38,6 @@ spec: command: - ./bin/docker-entrypoint-beat.sh envFrom: - {{- include "plane.appKeysSecretRef" . }} - {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-app-vars optional: false @@ -66,6 +64,13 @@ spec: optional: false {{- end }} {{- with (include "plane.otel.envFrom" $) }}{{ . | nindent 10 }}{{- end }} + {{- /* LAST in envFrom, deliberately: envFrom is later-source-wins, so these must + follow app_env/silo_env/live_env. Those may carry their own copy of a key a + group hook owns, and first would let the stale copy win on one workload but + not another -- the app and live copies of LIVE_SERVER_SECRET_KEY must agree + or live-server auth fails with nothing logged. */}} + {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} {{- if or .Values.extraEnv (eq (include "plane.otel.enabled" .) "true") (include "plane.infraCredsEnv" .) }} env: {{- with (include "plane.otel.serviceEnv" (dict "ctx" $ "service" "beat-worker")) }}{{ . | nindent 10 }}{{- end }} diff --git a/charts/plane-enterprise/templates/workloads/external-api.deployment.yaml b/charts/plane-enterprise/templates/workloads/external-api.deployment.yaml index 448b0496..47513e11 100644 --- a/charts/plane-enterprise/templates/workloads/external-api.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/external-api.deployment.yaml @@ -79,8 +79,6 @@ spec: # Start the external API exec ./bin/docker-entrypoint-api-ee.sh envFrom: - {{- include "plane.appKeysSecretRef" . }} - {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-app-vars optional: false @@ -106,6 +104,13 @@ spec: 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 }} + {{- /* LAST in envFrom, deliberately: envFrom is later-source-wins, so these must + follow app_env/silo_env/live_env. Those may carry their own copy of a key a + group hook owns, and first would let the stale copy win on one workload but + not another -- the app and live copies of LIVE_SERVER_SECRET_KEY must agree + or live-server auth fails with nothing logged. */}} + {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} {{- with (include "plane.otel.envFrom" $) }}{{ . | nindent 10 }}{{- end }} {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.infraCredsEnv" .) (eq (include "plane.otel.enabled" .) "true") }} diff --git a/charts/plane-enterprise/templates/workloads/live.deployment.yaml b/charts/plane-enterprise/templates/workloads/live.deployment.yaml index 1657008c..5d8bc19a 100644 --- a/charts/plane-enterprise/templates/workloads/live.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/live.deployment.yaml @@ -92,7 +92,6 @@ spec: {{ . | indent 8 }} {{- end }} envFrom: - {{- include "plane.appKeysSecretRef" . }} {{- include "plane.aiProvidersSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-live-vars @@ -101,6 +100,12 @@ spec: name: {{ if not (empty .Values.external_secrets.live_env_existingSecret) }}{{ .Values.external_secrets.live_env_existingSecret }}{{ else }}{{ .Release.Name }}-live-secrets{{ end }} optional: false {{- with (include "plane.otel.envFrom" $) }}{{ . | nindent 10 }}{{- end }} + {{- /* LAST in envFrom, deliberately: envFrom is later-source-wins, so these must + follow app_env/silo_env/live_env. Those may carry their own copy of a key a + group hook owns, and first would let the stale copy win on one workload but + not another -- the app and live copies of LIVE_SERVER_SECRET_KEY must agree + or live-server auth fails with nothing logged. */}} + {{- include "plane.appKeysSecretRef" . }} {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.redisCredsEnv" .) (include "plane.rabbitmqCredsEnv" .) (eq (include "plane.otel.enabled" .) "true") }} env: {{- with (include "plane.s3CANodeEnvVars" .) }} diff --git a/charts/plane-enterprise/templates/workloads/migrator.job.yaml b/charts/plane-enterprise/templates/workloads/migrator.job.yaml index ef386418..eec42941 100644 --- a/charts/plane-enterprise/templates/workloads/migrator.job.yaml +++ b/charts/plane-enterprise/templates/workloads/migrator.job.yaml @@ -26,8 +26,6 @@ spec: - ./bin/docker-entrypoint-migrator.sh imagePullPolicy: {{ .Values.services.api.pullPolicy | default "Always" }} envFrom: - {{- include "plane.appKeysSecretRef" . }} - {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-app-vars optional: false @@ -53,6 +51,13 @@ spec: 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 }} + {{- /* LAST in envFrom, deliberately: envFrom is later-source-wins, so these must + follow app_env/silo_env/live_env. Those may carry their own copy of a key a + group hook owns, and first would let the stale copy win on one workload but + not another -- the app and live copies of LIVE_SERVER_SECRET_KEY must agree + or live-server auth fails with nothing logged. */}} + {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} {{- if or .Values.extraEnv (include "plane.infraCredsEnv" .) }} env: {{- with (include "plane.infraCredsEnv" .) }} diff --git a/charts/plane-enterprise/templates/workloads/outbox-poller.deployment.yaml b/charts/plane-enterprise/templates/workloads/outbox-poller.deployment.yaml index 1ebcc857..044ed54c 100644 --- a/charts/plane-enterprise/templates/workloads/outbox-poller.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/outbox-poller.deployment.yaml @@ -39,7 +39,6 @@ spec: command: - ./bin/docker-entrypoint-outbox-poller.sh envFrom: - {{- include "plane.appKeysSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-outbox-poller-vars optional: false @@ -50,6 +49,12 @@ spec: name: {{ if not (empty .Values.external_secrets.app_env_existingSecret) }}{{ .Values.external_secrets.app_env_existingSecret }}{{ else }}{{ .Release.Name }}-app-secrets{{ end }} optional: false {{- with (include "plane.otel.envFrom" $) }}{{ . | nindent 10 }}{{- end }} + {{- /* LAST in envFrom, deliberately: envFrom is later-source-wins, so these must + follow app_env/silo_env/live_env. Those may carry their own copy of a key a + group hook owns, and first would let the stale copy win on one workload but + not another -- the app and live copies of LIVE_SERVER_SECRET_KEY must agree + or live-server auth fails with nothing logged. */}} + {{- include "plane.appKeysSecretRef" . }} {{- if or .Values.extraEnv (eq (include "plane.otel.enabled" .) "true") (include "plane.infraCredsEnv" .) }} env: {{- with (include "plane.otel.serviceEnv" (dict "ctx" $ "service" "outbox-poller")) }}{{ . | nindent 10 }}{{- end }} diff --git a/charts/plane-enterprise/templates/workloads/pi-api.deployment.yaml b/charts/plane-enterprise/templates/workloads/pi-api.deployment.yaml index 2ab76eb1..3cdc58b1 100644 --- a/charts/plane-enterprise/templates/workloads/pi-api.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/pi-api.deployment.yaml @@ -78,7 +78,6 @@ spec: exec ./bin/entrypoint-api.sh envFrom: - {{- include "plane.appKeysSecretRef" . }} {{- include "plane.aiProvidersSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-pi-api-vars @@ -93,6 +92,12 @@ spec: name: {{ if not (empty .Values.external_secrets.opensearch_existingSecret) }}{{ .Values.external_secrets.opensearch_existingSecret }}{{ else }}{{ .Release.Name }}-opensearch-secrets{{ end }} optional: false {{- with (include "plane.otel.envFrom" $) }}{{ . | nindent 10 }}{{- end }} + {{- /* LAST in envFrom, deliberately: envFrom is later-source-wins, so these must + follow app_env/silo_env/live_env. Those may carry their own copy of a key a + group hook owns, and first would let the stale copy win on one workload but + not another -- the app and live copies of LIVE_SERVER_SECRET_KEY must agree + or live-server auth fails with nothing logged. */}} + {{- include "plane.appKeysSecretRef" . }} {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.piInfraCredsEnv" .) (eq (include "plane.otel.enabled" .) "true") }} env: {{- with (include "plane.s3CAEnvVars" .) }} diff --git a/charts/plane-enterprise/templates/workloads/pi-beat.deployment.yaml b/charts/plane-enterprise/templates/workloads/pi-beat.deployment.yaml index 4a17bb8f..f6f35e73 100644 --- a/charts/plane-enterprise/templates/workloads/pi-beat.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/pi-beat.deployment.yaml @@ -53,7 +53,6 @@ spec: exec ./bin/entrypoint-celery-beat.sh envFrom: - {{- include "plane.appKeysSecretRef" . }} {{- include "plane.aiProvidersSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-pi-api-vars @@ -68,6 +67,12 @@ spec: name: {{ if not (empty .Values.external_secrets.opensearch_existingSecret) }}{{ .Values.external_secrets.opensearch_existingSecret }}{{ else }}{{ .Release.Name }}-opensearch-secrets{{ end }} optional: false {{- with (include "plane.otel.envFrom" $) }}{{ . | nindent 10 }}{{- end }} + {{- /* LAST in envFrom, deliberately: envFrom is later-source-wins, so these must + follow app_env/silo_env/live_env. Those may carry their own copy of a key a + group hook owns, and first would let the stale copy win on one workload but + not another -- the app and live copies of LIVE_SERVER_SECRET_KEY must agree + or live-server auth fails with nothing logged. */}} + {{- include "plane.appKeysSecretRef" . }} {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.piInfraCredsEnv" .) (eq (include "plane.otel.enabled" .) "true") }} env: {{- with (include "plane.s3CAEnvVars" .) }} diff --git a/charts/plane-enterprise/templates/workloads/pi-migrator.job.yaml b/charts/plane-enterprise/templates/workloads/pi-migrator.job.yaml index 9c280af2..05e8040e 100644 --- a/charts/plane-enterprise/templates/workloads/pi-migrator.job.yaml +++ b/charts/plane-enterprise/templates/workloads/pi-migrator.job.yaml @@ -42,7 +42,6 @@ spec: exec ./bin/entrypoint-migrator.sh envFrom: - {{- include "plane.appKeysSecretRef" . }} {{- include "plane.aiProvidersSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-pi-api-vars @@ -56,6 +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 + {{- /* LAST in envFrom, deliberately: envFrom is later-source-wins, so these must + follow app_env/silo_env/live_env. Those may carry their own copy of a key a + group hook owns, and first would let the stale copy win on one workload but + not another -- the app and live copies of LIVE_SERVER_SECRET_KEY must agree + or live-server auth fails with nothing logged. */}} + {{- include "plane.appKeysSecretRef" . }} {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.piInfraCredsEnv" .) }} env: {{- with (include "plane.s3CAEnvVars" .) }} diff --git a/charts/plane-enterprise/templates/workloads/pi-worker.deployment.yaml b/charts/plane-enterprise/templates/workloads/pi-worker.deployment.yaml index 37835972..4a688f14 100644 --- a/charts/plane-enterprise/templates/workloads/pi-worker.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/pi-worker.deployment.yaml @@ -54,7 +54,6 @@ spec: exec ./bin/entrypoint-celery-worker.sh envFrom: - {{- include "plane.appKeysSecretRef" . }} {{- include "plane.aiProvidersSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-pi-api-vars @@ -69,6 +68,12 @@ spec: name: {{ if not (empty .Values.external_secrets.opensearch_existingSecret) }}{{ .Values.external_secrets.opensearch_existingSecret }}{{ else }}{{ .Release.Name }}-opensearch-secrets{{ end }} optional: false {{- with (include "plane.otel.envFrom" $) }}{{ . | nindent 10 }}{{- end }} + {{- /* LAST in envFrom, deliberately: envFrom is later-source-wins, so these must + follow app_env/silo_env/live_env. Those may carry their own copy of a key a + group hook owns, and first would let the stale copy win on one workload but + not another -- the app and live copies of LIVE_SERVER_SECRET_KEY must agree + or live-server auth fails with nothing logged. */}} + {{- include "plane.appKeysSecretRef" . }} {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.piInfraCredsEnv" .) (eq (include "plane.otel.enabled" .) "true") }} env: {{- with (include "plane.s3CAEnvVars" .) }} diff --git a/charts/plane-enterprise/templates/workloads/runner.deployment.yaml b/charts/plane-enterprise/templates/workloads/runner.deployment.yaml index df4c4a1d..068f2f1c 100644 --- a/charts/plane-enterprise/templates/workloads/runner.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/runner.deployment.yaml @@ -69,13 +69,18 @@ spec: successThreshold: 1 timeoutSeconds: 1 envFrom: - {{- include "plane.appKeysSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-runner-vars optional: false - 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 + {{- /* LAST in envFrom, deliberately: envFrom is later-source-wins, so these must + follow app_env/silo_env/live_env. Those may carry their own copy of a key a + group hook owns, and first would let the stale copy win on one workload but + not another -- the app and live copies of LIVE_SERVER_SECRET_KEY must agree + or live-server auth fails with nothing logged. */}} + {{- include "plane.appKeysSecretRef" . }} {{- if .Values.extraEnv }} env: {{- toYaml .Values.extraEnv | nindent 10 }} diff --git a/charts/plane-enterprise/templates/workloads/silo.deployment.yaml b/charts/plane-enterprise/templates/workloads/silo.deployment.yaml index ce2b2b40..dd10168f 100644 --- a/charts/plane-enterprise/templates/workloads/silo.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/silo.deployment.yaml @@ -115,8 +115,6 @@ spec: {{ . | indent 8 }} {{- end }} envFrom: - {{- include "plane.appKeysSecretRef" . }} - {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-silo-vars optional: false @@ -127,6 +125,13 @@ spec: 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 {{- with (include "plane.otel.envFrom" $) }}{{ . | nindent 10 }}{{- end }} + {{- /* LAST in envFrom, deliberately: envFrom is later-source-wins, so these must + follow app_env/silo_env/live_env. Those may carry their own copy of a key a + group hook owns, and first would let the stale copy win on one workload but + not another -- the app and live copies of LIVE_SERVER_SECRET_KEY must agree + or live-server auth fails with nothing logged. */}} + {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.siloInfraCredsEnv" .) (eq (include "plane.otel.enabled" .) "true") }} env: {{- with (include "plane.s3CANodeEnvVars" .) }} diff --git a/charts/plane-enterprise/templates/workloads/webhook-consumer.deployment.yaml b/charts/plane-enterprise/templates/workloads/webhook-consumer.deployment.yaml index 2c2b5ba8..1c2cee05 100644 --- a/charts/plane-enterprise/templates/workloads/webhook-consumer.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/webhook-consumer.deployment.yaml @@ -51,8 +51,6 @@ spec: command: - ./bin/docker-entrypoint-webhook-consumer.sh envFrom: - {{- include "plane.appKeysSecretRef" . }} - {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-webhook-consumer-vars optional: false @@ -82,6 +80,13 @@ spec: optional: false {{- end }} {{- with (include "plane.otel.envFrom" $) }}{{ . | nindent 10 }}{{- end }} + {{- /* LAST in envFrom, deliberately: envFrom is later-source-wins, so these must + follow app_env/silo_env/live_env. Those may carry their own copy of a key a + group hook owns, and first would let the stale copy win on one workload but + not another -- the app and live copies of LIVE_SERVER_SECRET_KEY must agree + or live-server auth fails with nothing logged. */}} + {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} {{- if or .Values.extraEnv (eq (include "plane.otel.enabled" .) "true") (include "plane.infraCredsEnv" .) }} env: {{- with (include "plane.otel.serviceEnv" (dict "ctx" $ "service" "webhook-consumer")) }}{{ . | nindent 10 }}{{- end }} diff --git a/charts/plane-enterprise/templates/workloads/worker-importers.deployment.yaml b/charts/plane-enterprise/templates/workloads/worker-importers.deployment.yaml index 71e22740..83661719 100644 --- a/charts/plane-enterprise/templates/workloads/worker-importers.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/worker-importers.deployment.yaml @@ -55,8 +55,6 @@ spec: # Start the importer worker exec ./bin/docker-entrypoint-worker.sh celery.importer envFrom: - {{- include "plane.appKeysSecretRef" . }} - {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-app-vars optional: false @@ -82,6 +80,13 @@ spec: 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 }} + {{- /* LAST in envFrom, deliberately: envFrom is later-source-wins, so these must + follow app_env/silo_env/live_env. Those may carry their own copy of a key a + group hook owns, and first would let the stale copy win on one workload but + not another -- the app and live copies of LIVE_SERVER_SECRET_KEY must agree + or live-server auth fails with nothing logged. */}} + {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} {{- with (include "plane.otel.envFrom" $) }}{{ . | nindent 10 }}{{- end }} {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.infraCredsEnv" .) (eq (include "plane.otel.enabled" .) "true") }} diff --git a/charts/plane-enterprise/templates/workloads/worker.deployment.yaml b/charts/plane-enterprise/templates/workloads/worker.deployment.yaml index 3fb3ade7..1b202457 100644 --- a/charts/plane-enterprise/templates/workloads/worker.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/worker.deployment.yaml @@ -54,8 +54,6 @@ spec: # Start the worker exec ./bin/docker-entrypoint-worker.sh envFrom: - {{- include "plane.appKeysSecretRef" . }} - {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-app-vars optional: false @@ -81,6 +79,13 @@ spec: 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 }} + {{- /* LAST in envFrom, deliberately: envFrom is later-source-wins, so these must + follow app_env/silo_env/live_env. Those may carry their own copy of a key a + group hook owns, and first would let the stale copy win on one workload but + not another -- the app and live copies of LIVE_SERVER_SECRET_KEY must agree + or live-server auth fails with nothing logged. */}} + {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} {{- with (include "plane.otel.envFrom" $) }}{{ . | nindent 10 }}{{- end }} {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.infraCredsEnv" .) (eq (include "plane.otel.enabled" .) "true") }} From 3c0435e4843b1ea50bd86592f5f950621565a8e1 Mon Sep 17 00:00:00 2001 From: Pratapa Lakshmi Date: Thu, 27 Aug 2026 16:53:18 +0530 Subject: [PATCH 10/10] refactor(plane-enterprise): drop the reloader flag, keep the annotation route This chart is public, and a Reloader annotation is a deployment decision rather than something a chart should offer a switch for -- which workloads must restart when a credential rotates depends on the install, and the datastores generally should not. services..annotations already reaches the WORKLOAD resource, which is where Reloader looks, so nothing is lost: an operator sets services: api: annotations: reloader.stakater.com/auto: "true" and gets exactly what the flag did, for the workloads they choose. Removing the values key means NOTES.txt could no longer dereference .Values.reloader.enabled -- that would have been a nil-pointer failure on every install, not a silent no-op, so the warning is rewritten. It still fires whenever an external-Secret hook is set, and now names the annotation to add instead of a flag to flip. README, the ESO examples and the rotation runbook follow. email.deployment.yaml keeps taking its annotations from the helper. The literal it used to carry is still gone, which is what was asked for earlier: the chart imposes nothing. No default-render change -- the flag defaulted false, so 0 lines differ. Renders 44 documents, NOTES.txt renders under --dry-run with and without a hook set, a per-workload annotation lands on the workload and not the pod template, and lint is clean. Co-Authored-By: Claude Opus 5 (1M context) --- charts/plane-enterprise/README.md | 14 ++++++++++---- .../examples/external-secrets/README.md | 6 ++++-- .../external-secrets/rotation-runbook.md | 2 +- charts/plane-enterprise/templates/NOTES.txt | 4 ++-- .../plane-enterprise/templates/_helpers.tpl | 19 ++++++++----------- .../templates/workloads/email.deployment.yaml | 7 +++---- charts/plane-enterprise/values.yaml | 14 +++----------- 7 files changed, 31 insertions(+), 35 deletions(-) diff --git a/charts/plane-enterprise/README.md b/charts/plane-enterprise/README.md index 478bec34..a3fae271 100644 --- a/charts/plane-enterprise/README.md +++ b/charts/plane-enterprise/README.md @@ -1319,11 +1319,18 @@ This will become the default in the next major version. A rotation only reaches a running pod if something restarts it. Install [Stakater Reloader](https://github.com/stakater/Reloader) and turn it on: ```yaml -reloader: - enabled: true +services: + api: + annotations: + reloader.stakater.com/auto: "true" + worker: + annotations: + reloader.stakater.com/auto: "true" ``` -The chart then annotates every workload with `reloader.stakater.com/auto: "true"`, and Reloader rolls a workload when any Secret or ConfigMap it references changes — including Secrets the chart does not render, which is exactly the External Secrets path. The credential-consuming Deployments also get `maxUnavailable: 0` / `maxSurge: 1`, so the restart keeps full capacity. +Annotate the workloads that read the externally managed Secret. Reloader reads the annotation from the **workload** resource, and the chart emits `services..annotations` there, so it rolls that workload when any Secret or ConfigMap it references changes — including Secrets the chart does not render, which is exactly the External Secrets path. The chart does not set the annotation for you: which workloads should restart is a deployment decision, and the datastores generally should not. + +The credential-consuming Deployments also get `maxUnavailable: 0` / `maxSurge: 1`, so the restart keeps full capacity. The full chain: you rotate in the cloud → ESO syncs within its `refreshInterval` → Reloader rolls the pods. @@ -1442,7 +1449,6 @@ chart: an empty credential is *present*, and a present credential denies the cha | `env.redis_ssl` | false | Connect to Redis over TLS (`rediss`). Required by ElastiCache with in-transit encryption and Azure Cache. | | `external_secrets.app_keys_existingSecret` | | One Secret for the shared signing/encryption keys. | | `external_secrets.ssl_token_existingSecret` | | DNS-01 API token for the cert-manager Issuer; must contain the key `api-token`. | -| `reloader.enabled` | false | Annotate workloads for Stakater Reloader so a rotated Secret triggers a rolling restart. | | `env.requireExplicitSecrets` | false | Fail the render instead of falling back to the chart's public example keys. Will default to `true` in the next major version. | | `env.skip_env_var` | '1' | `'0'` makes the API re-read the database-resident secrets (SMTP, OAuth, LLM, LDAP) from the environment on every start. | | `global.forceRedeploy` | false | Restart every workload on every `helm upgrade`, as versions before 3.1.0 did. Off means upgrades roll only what changed. | diff --git a/charts/plane-enterprise/examples/external-secrets/README.md b/charts/plane-enterprise/examples/external-secrets/README.md index 57faa832..6d646e2e 100644 --- a/charts/plane-enterprise/examples/external-secrets/README.md +++ b/charts/plane-enterprise/examples/external-secrets/README.md @@ -44,8 +44,10 @@ helm install reloader stakater/reloader -n reloader --create-namespace Then in the chart's values: ```yaml -reloader: - enabled: true +services: + api: + annotations: + reloader.stakater.com/auto: "true" ``` ## Choosing refreshInterval diff --git a/charts/plane-enterprise/examples/external-secrets/rotation-runbook.md b/charts/plane-enterprise/examples/external-secrets/rotation-runbook.md index f3d13241..409a57c2 100644 --- a/charts/plane-enterprise/examples/external-secrets/rotation-runbook.md +++ b/charts/plane-enterprise/examples/external-secrets/rotation-runbook.md @@ -8,7 +8,7 @@ How to rotate Plane's credentials without dropping requests, and which ones must rotate in cloud secret store ↓ ESO polls, bounded by refreshInterval Kubernetes Secret updated - ↓ Reloader sees the change (needs reloader.enabled: true) + ↓ Reloader sees the change (needs the reloader.stakater.com/auto annotation) rolling restart, maxUnavailable: 0 / maxSurge: 1 ↓ pods running with the new credential diff --git a/charts/plane-enterprise/templates/NOTES.txt b/charts/plane-enterprise/templates/NOTES.txt index 832a03d7..e183be8b 100644 --- a/charts/plane-enterprise/templates/NOTES.txt +++ b/charts/plane-enterprise/templates/NOTES.txt @@ -21,8 +21,8 @@ Plane Enterprise {{ .Chart.Version }} — release {{ .Release.Name }} in namespa {{- end }} {{- /* Externalized credentials with no way to pick up a rotation. */}} -{{- if and (not .Values.reloader.enabled) (or .Values.external_secrets.database.secretName .Values.external_secrets.rabbitmq.secretName .Values.external_secrets.redis.secretName .Values.external_secrets.app_keys_existingSecret) }} -{{- $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." }} +{{- if or .Values.external_secrets.database.secretName .Values.external_secrets.rabbitmq.secretName .Values.external_secrets.redis.secretName .Values.external_secrets.app_keys_existingSecret }} +{{- $warnings = append $warnings "Credentials come from an external Secret. Whatever refreshes that Secret does not\n restart anything, so pods keep the values they started with and a rotated\n credential does not take effect until something rolls them. Install Stakater\n Reloader and annotate the workloads that read it:\n services..annotations:\n reloader.stakater.com/auto: \"true\"" }} {{- end }} {{- /* live's export queue has no parts path at all, on any app version. live reads only diff --git a/charts/plane-enterprise/templates/_helpers.tpl b/charts/plane-enterprise/templates/_helpers.tpl index a80784b5..ac76b888 100644 --- a/charts/plane-enterprise/templates/_helpers.tpl +++ b/charts/plane-enterprise/templates/_helpers.tpl @@ -81,16 +81,13 @@ Call with a dict carrying the root context and the component values: {{- toYaml . | nindent 4 }} {{- end }} {{- /* - Reloader reads its annotation from the workload resource, not the pod template, - so it is merged in here rather than emitted alongside the per-component - annotations — a second literal `annotations:` key would silently shadow one of - the two. A component-level annotation of the same name still wins. + Emitted on the WORKLOAD resource, not the pod template. That matters for anything + keyed on the workload -- Stakater Reloader reads its annotation there -- so a + deployment wanting a rotated Secret to trigger a restart sets + services..annotations."reloader.stakater.com/auto": "true" here rather than the + chart imposing it. */}} - {{- $annotations := deepCopy (default dict .values.annotations) }} - {{- if .context.Values.reloader.enabled }} - {{- $annotations = merge $annotations (dict "reloader.stakater.com/auto" "true") }} - {{- end }} - {{- with $annotations }} + {{- with .values.annotations }} annotations: {{ toYaml . | nindent 4 }} {{- end }} {{- end }} @@ -341,8 +338,8 @@ Aggregate sha256 over every Secret/ConfigMap the chart renders from values. Used as a pod-template annotation so `helm upgrade` rolls workloads when — and only when — chart-rendered configuration actually changed. Secrets that live outside the chart (External Secrets Operator, sealed secrets, manual) are not -visible here by design: rotation of those is Reloader's job, see -plane.reloaderAnnotations. +visible here by design: rotation of those is Reloader's job, driven by a +reloader.stakater.com/auto annotation set through services..annotations. The hash intentionally covers all config-secret templates rather than a per-workload subset: several keys (AES_SECRET_KEY, LIVE_SERVER_SECRET_KEY, PI_INTERNAL_SECRET) diff --git a/charts/plane-enterprise/templates/workloads/email.deployment.yaml b/charts/plane-enterprise/templates/workloads/email.deployment.yaml index 3a0fa58c..9ada0843 100644 --- a/charts/plane-enterprise/templates/workloads/email.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/email.deployment.yaml @@ -32,10 +32,9 @@ kind: Deployment metadata: name: {{ .Release.Name }}-email-app namespace: {{ .Release.Namespace }} - {{/* The Reloader annotation now comes from plane.labelsAndAnnotations (gated on - reloader.enabled). It used to be a literal key here, which was silently - dropped whenever services.email_service.annotations was also set, because the - helper emits its own `annotations:` key. */}} + {{/* Annotations come from services.email_service.annotations via the helper. A Reloader + annotation used to be a literal key here, which was silently dropped whenever that + value was also set, because the helper emits its own `annotations:` key. */}} {{- include "plane.labelsAndAnnotations" (dict "context" $ "values" .Values.services.email_service) }} spec: replicas: {{ .Values.services.email_service.replicas | default 1 }} diff --git a/charts/plane-enterprise/values.yaml b/charts/plane-enterprise/values.yaml index 8d5fed1e..162f31e8 100644 --- a/charts/plane-enterprise/values.yaml +++ b/charts/plane-enterprise/values.yaml @@ -542,8 +542,8 @@ services: affinity: {} labels: {} # This service mounts a cert-manager TLS Secret, so it needs a restart when the - # certificate is renewed. Set reloader.enabled, or add - # reloader.stakater.com/auto here, if you want that to happen automatically. + # certificate is renewed. Add reloader.stakater.com/auto: "true" here (and install + # Stakater Reloader) if you want that to happen automatically. annotations: {} outbox_poller: @@ -769,7 +769,7 @@ serviceAccount: # RABBITMQ_* are supported by all releases; REDIS_HOST/PORT/PASSWORD/SSL needs # planeVersion v3.2.0 or newer. # -# Set `reloader.enabled: true` (and install Stakater Reloader) so pods restart +# Annotate the workloads with reloader.stakater.com/auto (and install Reloader) so pods restart # when the mirrored Secret changes — that is what makes rotation take effect. # # 2. Whole-Secret replacement (*_existingSecret) — the chart skips rendering that @@ -949,14 +949,6 @@ external_secrets: # ============================================================ # Stakater Reloader # ============================================================ -# Adds reloader.stakater.com/auto to every workload that consumes credentials, so -# a rolling restart happens automatically when one of the Secrets or ConfigMaps it -# references changes — including Secrets the chart does not render, which is how a -# rotated cloud credential reaches a running pod. Requires Reloader to be -# installed in the cluster; the chart does not install it. -reloader: - enabled: false - global: # Pre-3.1 behaviour: stamp every pod template with the current timestamp so that # `helm upgrade` restarts every workload whether or not anything changed. The