diff --git a/charts/plane-enterprise/Chart.yaml b/charts/plane-enterprise/Chart.yaml index e34a30a7..76880543 100644 --- a/charts/plane-enterprise/Chart.yaml +++ b/charts/plane-enterprise/Chart.yaml @@ -5,7 +5,7 @@ description: Meet Plane. An Enterprise software development tool to manage issue type: application -version: 3.3.0 +version: 3.5.0 appVersion: "3.1.0" home: https://plane.so/ diff --git a/charts/plane-enterprise/README.md b/charts/plane-enterprise/README.md index eafdc50d..46768645 100644 --- a/charts/plane-enterprise/README.md +++ b/charts/plane-enterprise/README.md @@ -998,11 +998,13 @@ If your rotation secret happens to carry the endpoint too (RDS non-master rotati Whenever one of these is set, the chart stops emitting the corresponding composed `DATABASE_URL` / `AMQP_URL` / `REDIS_URL` in its own Secrets — the application prefers a URL when one is present, so a stale URL would silently shadow the rotated credential. **If you also supply `app_env_existingSecret`, make sure it does not contain those URL keys.** -Applies to the Django services: api, external-api, worker, importer worker, beat-worker, webhook and automation consumers, outbox poller, migrator. OpenSearch credentials additionally reach the Plane AI workloads, since they query the same cluster. +**Every service reads discrete parts from planeVersion v3.2.0 onward** — the Django family (api, external-api, worker, importer worker, beat-worker, webhook and automation consumers, outbox poller, migrator) plus silo, live and Plane AI. Below v3.2.0 only the Django family does; `helm upgrade` warns when your `planeVersion` predates the support you have configured. -**Silo, live and Plane AI still need a connection URL** for the database, broker and cache — they do not read discrete parts. For those, keep using `silo_env_existingSecret` / `live_env_existingSecret` / `pi_api_env_existingSecret` and let ESO compose the DSN with a `template` block (see `examples/external-secrets/`). `helm upgrade` prints a warning when you have configured a combination that would leave one of them without connection details. +Each workload receives only the credentials it uses. Live gets Redis and nothing else; silo gets Postgres, RabbitMQ and Redis but not OpenSearch; Plane AI gets its own `PLANE_PI_POSTGRES_*` and `FOLLOWER_POSTGRES_*` names plus Redis and OpenSearch, and deliberately no `RabbitMQ` — Plane AI prefers an AMQP broker over a Redis one, so sending it RabbitMQ parts would quietly move its queue off Redis. -OpenSearch is the exception: `external_secrets.opensearch` covers the Plane AI workloads too, so search credentials rotate for the whole deployment at once. +Plane AI's two databases both come from the same `external_secrets.database` Secret, which is the shape this chart provisions (one managed instance, two databases). If yours have genuinely separate credentials, set `env.pi_envs.follower_postgres_uri` — it still takes precedence — or use `pi_api_env_existingSecret`. + +For **planeVersion below v3.2.0**, supply silo/live/Plane AI DSNs through `silo_env_existingSecret` / `live_env_existingSecret` / `pi_api_env_existingSecret` and let ESO compose them with a `template` block (see `examples/external-secrets/`). ### 3. Shared signing keys in one Secret @@ -1015,6 +1017,10 @@ external_secrets: The Secret should carry `SECRET_KEY`, `AES_SECRET_KEY`, `AES_SALT`, `LIVE_SERVER_SECRET_KEY`, `PI_INTERNAL_SECRET`, `SILO_HMAC_SECRET_KEY`, `CURSOR_WEBHOOK_SECRET`. While it is set, the chart stops emitting those keys in its own Secrets. It is mounted first in `envFrom`, so a key you already externalized through one of the older `*_existingSecret` groups still wins — don't define the same key in both. +The Secret must carry every key your deployment uses — a missing key is not a render error, just an absent env var. `SECRET_KEY` matters most: the API falls back to a per-pod random value when it is absent, so JWTs stop verifying across replicas and encrypted instance-configuration rows become unreadable, with no error. `RUNNER_HMAC_SECRET_KEY` belongs here too when the runner is deployed. + +Do not also define any of these keys in one of the `*_existingSecret` groups. Those Secrets are mounted after this one, so a duplicate wins on the workloads mounting that group and loses everywhere else — leaving two services disagreeing on a key that has to match. `helm upgrade` warns when it sees both set. + The trade-off: because it is one Secret, every service that mounts it sees all of its keys — the live server's pods get `SECRET_KEY` in their environment even though only the API uses it. These are all first-party Plane services in one namespace, so this is the same trust boundary the duplicated copies already shared. If you need the keys separated per service, keep using the per-group `*_existingSecret` mechanism and take on keeping the shared values in step yourself. > **Never rotate `SECRET_KEY`, `AES_SECRET_KEY` or `AES_SALT` on a running instance.** `SECRET_KEY` derives the Fernet key that encrypts the instance-configuration rows (SMTP password, OAuth client secrets, LLM keys); the AES pair protects stored OAuth application secrets, MCP connections and desktop handoff tokens. Changing either makes existing ciphertext undecryptable, and the failure is silent — values come back empty. Keep them in a secret with no rotation schedule. diff --git a/charts/plane-enterprise/examples/external-secrets/README.md b/charts/plane-enterprise/examples/external-secrets/README.md index b81f5dd6..57faa832 100644 --- a/charts/plane-enterprise/examples/external-secrets/README.md +++ b/charts/plane-enterprise/examples/external-secrets/README.md @@ -57,6 +57,17 @@ reloader: If your provider can push on rotation (an AWS Lambda rotation hook that annotates the `ExternalSecret`, or ESO's `PushSecret`/webhook paths), prefer that over polling frequently. +## Composed DSNs are only for older app versions + +From **planeVersion v3.2.0** every service — including silo, live and Plane AI — reads +discrete credential parts, so the `template:` blocks in the provider examples that build +a `DATABASE_URL`/`REDIS_URL`/`AMQP_URL` are no longer needed. Point +`external_secrets.database` / `rabbitmq` / `redis` at the mirrored secret and let each +app compose its own URL. + +Keep using the templated DSN sections (`plane-silo-env` and friends) only when pinned +below v3.2.0, or when a service genuinely needs different credentials from the primary. + ## What must never rotate Do not put `SECRET_KEY`, `AES_SECRET_KEY` or `AES_SALT` in a secret with a rotation policy. `SECRET_KEY` derives the Fernet key encrypting the instance-configuration rows, and the AES pair protects stored OAuth/MCP tokens; changing either makes existing ciphertext undecryptable, silently. Keep them in a separate, static secret — that is what `app_keys_existingSecret` is for. diff --git a/charts/plane-enterprise/examples/external-secrets/rotation-runbook.md b/charts/plane-enterprise/examples/external-secrets/rotation-runbook.md index 67ad707f..f3d13241 100644 --- a/charts/plane-enterprise/examples/external-secrets/rotation-runbook.md +++ b/charts/plane-enterprise/examples/external-secrets/rotation-runbook.md @@ -80,7 +80,15 @@ Both fail **silently**: decryption errors are swallowed and the values come back **Database-resident secrets do not rotate through the environment.** With the default `SKIP_ENV_VAR=1`, the SMTP password, OAuth client secrets, `LLM_API_KEY` and `LDAP_BIND_PASSWORD` live in the instance-configuration table, seeded from the environment only on first boot. Set `env.skip_env_var: '0'` to make the environment the source of truth on every restart — at the cost of god-mode admin UI edits to those settings being overwritten. -**Silo, live and Plane AI read connection URLs, not discrete parts.** Their DSN has to be composed by ESO with a `template` block (see the provider examples). That template embeds the password, so it must use `urlEncode`. +**Silo, live and Plane AI rotate like the API from planeVersion v3.2.0** — they read +discrete parts, so nothing recomposes a DSN and a Reloader restart is all that is needed. +Below v3.2.0 their DSN has to be composed by ESO with a `template` block (see the provider +examples), and that template embeds the password, so it must use `urlEncode`. + +**Plane AI's follower connection re-reads its credentials at call time.** After an +authentication failure it refetches without waiting for a restart, so a remounted Secret +can take effect mid-process. Its own database and broker still resolve at import, so those +rely on the Reloader restart like everything else. ## Verifying a rotation diff --git a/charts/plane-enterprise/questions.yml b/charts/plane-enterprise/questions.yml index e7a6bafd..59a46ae2 100644 --- a/charts/plane-enterprise/questions.yml +++ b/charts/plane-enterprise/questions.yml @@ -1793,3 +1793,138 @@ questions: type: string default: "" group: "External Secrets" + +# --- Credential mirrors ------------------------------------------------------- +# A Secret that MIRRORS the managed one your cloud already rotates — the RDS secret +# created with the instance, an ElastiCache auth-token secret, an Amazon MQ secret. +# The chart reads the parts out of it and never composes a connection string, so a +# rotation lands with nothing to recompute. That is why these are separate from the +# whole-Secret replacements above, which pin a DSN the rotation can never reach. +- variable: external_secrets.database.secretName + label: "Postgres Credential Mirror Secret" + description: "A Secret carrying the Postgres username and password as separate keys — point it at a verbatim mirror of your RDS/Cloud SQL secret so a rotation needs no second copy." + type: string + default: "" + group: "Credential Mirrors" +- variable: external_secrets.database.usernameKey + label: "Postgres Username Key" + type: string + default: "username" + group: "Credential Mirrors" + show_if: "external_secrets.database.secretName!=" +- variable: external_secrets.database.passwordKey + label: "Postgres Password Key" + type: string + default: "password" + group: "Credential Mirrors" + show_if: "external_secrets.database.secretName!=" +- variable: external_secrets.database.hostKey + label: "Postgres Host Key (optional)" + description: "Only when the mirrored Secret carries the endpoint too. Otherwise the host comes from env.pgdb_remote_host." + type: string + default: "" + group: "Credential Mirrors" + show_if: "external_secrets.database.secretName!=" +- variable: external_secrets.rabbitmq.secretName + label: "RabbitMQ Credential Mirror Secret" + description: "For Amazon MQ also set env.rabbitmq_ssl=true and env.rabbitmq_port=5671 — the parts path has no URL scheme to carry TLS, and Amazon MQ refuses plaintext." + type: string + default: "" + group: "Credential Mirrors" +- variable: external_secrets.redis.secretName + label: "Redis Credential Mirror Secret" + description: "For ElastiCache the auth token goes in the password key; also set env.redis_ssl=true." + type: string + default: "" + group: "Credential Mirrors" +- variable: external_secrets.storage.secretName + label: "Object Storage Credential Secret" + description: "Leave empty and annotate the ServiceAccount instead — a pod identity beats a stored access key. Only the key names you fill in below are emitted." + type: string + default: "" + group: "Credential Mirrors" +- variable: external_secrets.storage.accessKeyIdKey + label: "Storage Access Key ID Key" + type: string + default: "" + group: "Credential Mirrors" + show_if: "external_secrets.storage.secretName!=" +- variable: external_secrets.storage.secretAccessKeyKey + label: "Storage Secret Access Key Key" + type: string + default: "" + group: "Credential Mirrors" + show_if: "external_secrets.storage.secretName!=" +- variable: external_secrets.storage.gcsCredentialsJsonKey + label: "GCS Credentials JSON Key" + type: string + default: "" + group: "Credential Mirrors" + show_if: "external_secrets.storage.secretName!=" +- variable: external_secrets.opensearch.secretName + label: "OpenSearch Credential Mirror Secret" + description: "For an AWS-managed domain, leave this empty and let the pods authenticate with SigV4 through their cloud identity." + type: string + default: "" + group: "Credential Mirrors" + +# --- Shared key groups -------------------------------------------------------- +# One Secret carrying one group of keys, mirrored into as many namespaces as you like. +# The chart stops rendering those keys, which is what makes the sharing work: an empty +# string under envFrom does not defer to another Secret, it overwrites it. +- variable: external_secrets.app_keys_existingSecret + label: "Signing Keys Secret" + description: "SECRET_KEY, AES_SECRET_KEY, AES_SALT, LIVE_SERVER_SECRET_KEY, SILO_HMAC_SECRET_KEY, RUNNER_HMAC_SECRET_KEY. These encrypt stored data: never share them between environments and never rotate them." + type: string + default: "" + group: "Shared Secrets" +- variable: external_secrets.ai_providers_existingSecret + label: "AI Provider Keys Secret" + description: "OPENAI / CLAUDE / GEMINI / COHERE / CUSTOM_LLM keys. One AI account normally serves every deployment, so this is the group worth sharing." + type: string + default: "" + group: "Shared Secrets" +- variable: external_secrets.silo_connectors_existingSecret + label: "Silo Connector Secrets Secret" + description: "Connector OAuth client secrets and the GitHub App private key. An OAuth app is registered once, so its secret is the same wherever the connector is enabled." + type: string + default: "" + group: "Shared Secrets" +- variable: external_secrets.ssl_token_existingSecret + label: "DNS-01 Token Secret" + description: "cert-manager DNS provider API token, under the key api-token." + type: string + default: "" + group: "Shared Secrets" + +# --- Cloud identity ----------------------------------------------------------- +- variable: serviceAccount.create + label: "Create the ServiceAccount" + description: "Turn off to run as a ServiceAccount managed outside this chart — one created by Terraform or Crossplane and already bound to a cloud role." + type: boolean + default: true + group: "Service Account" +- variable: serviceAccount.name + label: "ServiceAccount Name" + description: "Defaults to -srv-account." + type: string + default: "" + group: "Service Account" +- variable: serviceAccount.annotations + label: "ServiceAccount Annotations" + description: "Where a workload identity binding goes: eks.amazonaws.com/role-arn for IRSA, iam.gke.io/gcp-service-account for GKE, azure.workload.identity/client-id for Azure. EKS Pod Identity needs no annotation — it binds by name." + type: string + default: "" + group: "Service Account" +- variable: serviceAccount.cloudIdentity + label: "ServiceAccount Has a Cloud Identity" + description: "Advisory. A Pod Identity association is an EKS API object invisible to the pod spec, so the chart cannot detect it; setting this turns on the install notes that catch static credentials shadowing the identity." + type: boolean + default: false + group: "Service Account" +- variable: env.requireExplicitSecrets + label: "Refuse to Render Default Signing Keys" + description: "Fails the install instead of falling back to the published defaults for SECRET_KEY and friends. Worth turning on once the keys come from a Secret." + type: boolean + default: false + group: "Service Account" diff --git a/charts/plane-enterprise/templates/NOTES.txt b/charts/plane-enterprise/templates/NOTES.txt index 9b8198db..6e667333 100644 --- a/charts/plane-enterprise/templates/NOTES.txt +++ b/charts/plane-enterprise/templates/NOTES.txt @@ -13,6 +13,8 @@ Plane Enterprise {{ .Chart.Version }} — release {{ .Release.Name }} in namespa {{- if or (empty .Values.env.live_server_secret_key) (eq .Values.env.live_server_secret_key "htbqvBJAgpm9bzvf3r4urJer0ENReatceh") }}{{ $publicDefaults = append $publicDefaults "LIVE_SERVER_SECRET_KEY" }}{{ end }} {{- if or (empty .Values.env.silo_envs.aes_secret_key) (eq .Values.env.silo_envs.aes_secret_key "dsOdt7YrvxsTIFJ37pOaEVvLxN8KGBCr") }}{{ $publicDefaults = append $publicDefaults "AES_SECRET_KEY" }}{{ end }} {{- if or (empty .Values.env.pi_envs.internal_secret) (eq .Values.env.pi_envs.internal_secret "tyfvfqvBJAgpm9bzvf3r4urJer0Ehfdubk") }}{{ $publicDefaults = append $publicDefaults "PI_INTERNAL_SECRET" }}{{ end }} +{{- if and .Values.services.silo.enabled (or (empty .Values.env.silo_envs.hmac_secret_key) (eq .Values.env.silo_envs.hmac_secret_key "gzb7MRLr0FoN129NyWARZEs84P9LzQ")) }}{{ $publicDefaults = append $publicDefaults "SILO_HMAC_SECRET_KEY" }}{{ end }} +{{- if and .Values.services.silo.enabled (or (empty .Values.env.silo_envs.cursor_webhook_secret) (eq .Values.env.silo_envs.cursor_webhook_secret "TTqazTcoBajYKzIAeIKFZeTX9czAoUsG")) }}{{ $publicDefaults = append $publicDefaults "CURSOR_WEBHOOK_SECRET" }}{{ end }} {{- if $publicDefaults }} {{- $warnings = append $warnings (printf "Using the chart's PUBLIC example values for: %s.\n These are published in the chart and are not secret. Supply your own through\n external_secrets.app_keys_existingSecret, and set env.requireExplicitSecrets=true\n to make a missing value fail the render instead of falling back." (join ", " $publicDefaults)) }} {{- end }} @@ -23,15 +25,32 @@ Plane Enterprise {{ .Chart.Version }} — release {{ .Release.Name }} in namespa {{- $warnings = append $warnings "Credentials come from an external Secret, but reloader.enabled is false.\n Pods will keep the values they started with, so a rotated credential will not\n take effect until something restarts them. Install Stakater Reloader and set\n reloader.enabled=true." }} {{- end }} -{{- /* Services that cannot consume discrete credential parts yet. */}} +{{- /* +Version gate. 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 $externalInfra }} +{{- if and $externalInfra $preServiceParts }} {{- $urlServices := list }} {{- if and .Values.services.silo.enabled (empty .Values.external_secrets.silo_env_existingSecret) }}{{ $urlServices = append $urlServices "silo" }}{{ end }} {{- if and .Values.services.pi.enabled (empty .Values.external_secrets.pi_api_env_existingSecret) }}{{ $urlServices = append $urlServices "pi" }}{{ end }} {{- if and .Values.external_secrets.redis.secretName (empty .Values.external_secrets.live_env_existingSecret) }}{{ $urlServices = append $urlServices "live" }}{{ end }} {{- if $urlServices }} -{{- $warnings = append $warnings (printf "These services read a connection URL rather than discrete credential parts: %s.\n They will start without working connection details. Supply their DSN through the\n matching *_existingSecret with an ESO template block — see\n examples/external-secrets/ — or disable them." (join ", " $urlServices)) }} +{{- $warnings = append $warnings (printf "These services read discrete credential parts only from planeVersion v3.2.0\n onward, and this release runs %s: %s.\n On an older image they will start without working connection details. Either\n upgrade, or supply their DSN through the matching *_existingSecret with an ESO\n template block — see examples/external-secrets/." .Values.planeVersion (join ", " $urlServices)) }} {{- end }} {{- end }} @@ -41,20 +60,66 @@ Plane Enterprise {{ .Chart.Version }} — release {{ .Release.Name }} in namespa {{- end }} {{- /* Redis parts need app support. */}} -{{- if .Values.external_secrets.redis.secretName }} +{{- 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) }} +{{- if and .Values.env.aws_access_key (not (include "plane.minioEnabled" .)) (not .Values.serviceAccount.annotations) (not .Values.serviceAccount.cloudIdentity) }} {{- $warnings = append $warnings "Object storage is configured with a static AWS access key. Prefer a cloud identity:\n annotate the ServiceAccount (serviceAccount.annotations) for IRSA / EKS Pod Identity\n / GKE or Azure Workload Identity and clear env.aws_access_key and\n env.aws_secret_access_key — the chart then omits those env vars so the SDK's default\n credential chain picks up the pod's role." }} {{- end }} +{{- /* +An attached cloud identity reaches every container on the ServiceAccount, so it changes +behaviour beyond object storage. The chart cannot detect the association — it is an EKS +API object keyed on cluster + namespace + service account — hence the declared flag. +*/}} +{{- $hasIdentity := or .Values.serviceAccount.cloudIdentity .Values.serviceAccount.annotations }} +{{- if $hasIdentity }} + +{{- /* A static key beats the identity in the SDK credential chain. */}} +{{- if and .Values.env.aws_access_key (not (include "plane.minioEnabled" .)) }} +{{- $warnings = append $warnings "A cloud identity is configured, but env.aws_access_key is also set. Environment\n credentials take precedence over the identity in every AWS SDK's chain, so the\n static key is what will be used. Clear env.aws_access_key and\n env.aws_secret_access_key to let the identity apply." }} +{{- end }} + +{{- if .Values.services.pi.ai_providers.embedding_model.aws_access_key }} +{{- $warnings = append $warnings "A cloud identity is configured, but\n services.pi.ai_providers.embedding_model.aws_access_key is also set. Plane AI falls\n back to the identity when the BR_AWS_* keys are absent, so clear them to use it." }} +{{- end }} + +{{- /* +The one that is genuinely surprising: an identity attached for S3 silently changes how +OpenSearch is authenticated, because both services read "AWS credentials are present" +as "sign with SigV4". +*/}} +{{- if and .Values.env.opensearch_remote_url (not (include "plane.externalOpensearch" .)) (not .Values.env.opensearch_remote_username) }} +{{- $warnings = append $warnings "A cloud identity is configured and the remote OpenSearch has no credentials, so the\n API and Plane AI will authenticate to it with SigV4 — an identity injects AWS\n credentials into every container, and both read that as a request to sign.\n That is correct for Amazon OpenSearch Service and wrong for any other OpenSearch.\n Either set external_secrets.opensearch.secretName, or set OPENSEARCH_AUTH_MODE=basic\n through extraEnv (needs planeVersion v3.2.0 or newer)." }} +{{- end }} +{{- end }} + {{- if $warnings }} ──────────────────────────────────────────────────────────────────────────────── diff --git a/charts/plane-enterprise/templates/_helpers.tpl b/charts/plane-enterprise/templates/_helpers.tpl index 8e0beefe..9330447a 100644 --- a/charts/plane-enterprise/templates/_helpers.tpl +++ b/charts/plane-enterprise/templates/_helpers.tpl @@ -404,6 +404,47 @@ Indentation is baked in for the container envFrom position, so call it bare: {{- end }} {{- end -}} +{{/* +envFrom entry for the Secret carrying the AI/LLM provider keys. Renders nothing +unless external_secrets.ai_providers_existingSecret is set. + +Separate from the Plane AI Secret because provider accounts are shared across +environments while everything else in that Secret is per-environment. Mounted on the +Plane AI workloads and on live (whose AI_OPENAI_API_KEY has no values key at all). + +Placed before the chart's own Secret so an operator who has already externalized +pi_api_env keeps that precedence. While this is set the chart emits none of these +keys itself — including the empty-string branches, which would otherwise overwrite +this Secret's values, since envFrom resolves later-source-wins. + +Indentation is baked in for the container envFrom position, so call it bare. +*/}} +{{- define "plane.aiProvidersSecretRef" -}} +{{- with .Values.external_secrets.ai_providers_existingSecret }} + - secretRef: + name: {{ . }} + optional: false +{{- end }} +{{- end -}} + +{{/* +envFrom entry for the Secret carrying the silo connector credentials. Renders nothing +unless external_secrets.silo_connectors_existingSecret is set. + +Mounted on every workload that mounts silo-secrets today, not just silo: the Django +auth adapter reads GITHUB_CLIENT_ID/GITHUB_CLIENT_SECRET from that Secret on the api +family, so mounting this only on silo would drop those variables there. + +Indentation is baked in for the container envFrom position, so call it bare. +*/}} +{{- define "plane.siloConnectorsSecretRef" -}} +{{- with .Values.external_secrets.silo_connectors_existingSecret }} + - secretRef: + name: {{ . }} + optional: false +{{- end }} +{{- end -}} + {{/* Returns "true" when an externally managed Secret supplies the Postgres credentials, in which case the chart must not render a composed DATABASE_URL that would take @@ -499,6 +540,19 @@ 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 @@ -519,6 +573,99 @@ Caller must indent to the correct depth (env list items). {{- 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 @@ -543,6 +690,13 @@ Caller must indent to the correct depth (env list items). {{- include "plane.secretKeyEnv" (dict "name" "RABBITMQ_VHOST" "secret" $mq.secretName "key" .) }} {{- end }} {{- end }} +{{- end -}} + +{{/* +Redis credentials from an externally managed Secret. Consumed on its own by the +live server, and by Plane AI, whose Celery broker is Redis in Helm deployments. +*/}} +{{- define "plane.redisCredsEnv" -}} {{- $redis := .Values.external_secrets.redis -}} {{- if $redis.secretName }} - name: REDIS_HOST @@ -559,7 +713,6 @@ Caller must indent to the correct depth (env list items). {{- include "plane.secretKeyEnv" (dict "name" "REDIS_PORT" "secret" $redis.secretName "key" .) }} {{- end }} {{- end }} -{{- include "plane.opensearchCredsEnv" . }} {{- end -}} {{/* @@ -580,3 +733,63 @@ Caller must indent to the correct depth (env list items). {{- include "plane.secretKeyEnv" (dict "name" "OPENSEARCH_PASSWORD" "secret" $os.secretName "key" ($os.passwordKey | default "password")) }} {{- end }} {{- end -}} + +{{/* +Infrastructure credentials for silo: Postgres, RabbitMQ and Redis, using the same +env var names as the Django services. Not OpenSearch — silo never queries it. + +Caller must indent to the correct depth (env list items). +*/}} +{{- define "plane.siloInfraCredsEnv" -}} +{{- include "plane.postgresCredsEnv" . }} +{{- include "plane.rabbitmqCredsEnv" . }} +{{- include "plane.redisCredsEnv" . }} +{{- 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/config-secrets/app-env.yaml b/charts/plane-enterprise/templates/config-secrets/app-env.yaml index 79c74e4b..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 @@ -14,6 +20,11 @@ stringData: {{- if empty .Values.external_secrets.app_keys_existingSecret }} SECRET_KEY: {{ include "plane.secretValue" (dict "context" $ "name" "env.secret_key" "value" .Values.env.secret_key "fallback" "60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5") | quote }} AES_SECRET_KEY: {{ include "plane.secretValue" (dict "context" $ "name" "env.silo_envs.aes_secret_key" "value" .Values.env.silo_envs.aes_secret_key "fallback" "dsOdt7YrvxsTIFJ37pOaEVvLxN8KGBCr") | quote }} + {{- with .Values.env.silo_envs.aes_salt }} + {{/* Rendered only when set: api, silo and pi all default to "aes-salt" internally, + so emitting an empty value would break decryption on existing instances. */}} + AES_SALT: {{ . | quote }} + {{- end }} LIVE_SERVER_SECRET_KEY: {{ include "plane.secretValue" (dict "context" $ "name" "env.live_server_secret_key" "value" .Values.env.live_server_secret_key "fallback" "htbqvBJAgpm9bzvf3r4urJer0ENReatceh") | quote }} PI_INTERNAL_SECRET: {{ include "plane.secretValue" (dict "context" $ "name" "env.pi_envs.internal_secret" "value" .Values.env.pi_envs.internal_secret "fallback" "tyfvfqvBJAgpm9bzvf3r4urJer0Ehfdubk") | quote }} {{- end }} @@ -40,7 +51,13 @@ stringData: DATABASE_URL: "" {{- end }} - {{- if and .Values.services.postgres.read_replica.enabled .Values.services.postgres.read_replica.remote_url }} + {{- if include "plane.externalReadReplica" . }} + {{/* Credentials come from the readReplica mirror as discrete + POSTGRES_READ_REPLICA_* parts. Left empty on purpose: the API prefers + DATABASE_READ_REPLICA_URL when set, so a composed URL here would shadow the + rotated password. */}} + DATABASE_READ_REPLICA_URL: "" + {{- else if and .Values.services.postgres.read_replica.enabled .Values.services.postgres.read_replica.remote_url }} DATABASE_READ_REPLICA_URL: {{ .Values.services.postgres.read_replica.remote_url | quote }} {{- end }} diff --git a/charts/plane-enterprise/templates/config-secrets/doc-store.yaml b/charts/plane-enterprise/templates/config-secrets/doc-store.yaml index c6e06602..32d3bfd0 100644 --- a/charts/plane-enterprise/templates/config-secrets/doc-store.yaml +++ b/charts/plane-enterprise/templates/config-secrets/doc-store.yaml @@ -22,9 +22,11 @@ stringData: {{- with .Values.env.gcs_credentials_path }} GCS_CREDENTIALS_PATH: {{ . | quote }} {{- end }} + {{- if not (include "plane.externalStorage" .) }} {{- with .Values.env.gcs_credentials_json }} GCS_CREDENTIALS_JSON: {{ . | quote }} {{- end }} + {{- end }} {{- else if .Values.services.minio.local_setup }} USE_MINIO: "1" MINIO_ROOT_USER: {{ .Values.services.minio.root_user | default "admin" | quote }} @@ -46,8 +48,18 @@ stringData: {{- with .Values.env.aws_secret_access_key }} AWS_SECRET_ACCESS_KEY: {{ . | quote }} {{- end }} - AWS_REGION: {{ .Values.env.aws_region | default "" | quote }} - AWS_S3_ENDPOINT_URL: {{ .Values.env.aws_s3_endpoint_url | quote }} + {{/* Also omitted rather than blank, for a subtler reason than the keys above. When a + pod identity is attached and OpenSearch has no basic auth, the API signs its + OpenSearch requests with SigV4 — and it reads the region as + os.environ.get("AWS_REGION") or a default. An empty string is *present*, so the + default never applies and every signed request goes out with an empty region and + is rejected. Absent is the only safe representation of "unset". */}} + {{- with .Values.env.aws_region }} + AWS_REGION: {{ . | quote }} + {{- end }} + {{- with .Values.env.aws_s3_endpoint_url }} + AWS_S3_ENDPOINT_URL: {{ . | quote }} + {{- end }} {{- end }} {{- end }} --- diff --git a/charts/plane-enterprise/templates/config-secrets/live-env.yaml b/charts/plane-enterprise/templates/config-secrets/live-env.yaml index c1a3dee5..bcdaec55 100644 --- a/charts/plane-enterprise/templates/config-secrets/live-env.yaml +++ b/charts/plane-enterprise/templates/config-secrets/live-env.yaml @@ -11,7 +11,12 @@ stringData: {{- if empty .Values.external_secrets.app_keys_existingSecret }} LIVE_SERVER_SECRET_KEY: {{ include "plane.secretValue" (dict "context" $ "name" "env.live_server_secret_key" "value" .Values.env.live_server_secret_key "fallback" "htbqvBJAgpm9bzvf3r4urJer0ENReatceh") | quote }} {{- end }} - {{- if .Values.services.redis.local_setup }} + {{- if include "plane.externalRedis" . }} + {{/* Credentials come from external_secrets.redis as discrete REDIS_* parts. + Left empty on purpose: live prefers a URL when one is set, so a composed URL + here would shadow the rotated password. */}} + REDIS_URL: "" + {{- else if .Values.services.redis.local_setup }} REDIS_URL: "redis://{{ .Release.Name }}-redis.{{ .Release.Namespace }}.svc.{{ .Values.env.default_cluster_domain | default "cluster.local" }}:6379/" {{- else }} REDIS_URL: {{ .Values.env.remote_redis_url | default "" | quote }} diff --git a/charts/plane-enterprise/templates/config-secrets/pi-api-env.yaml b/charts/plane-enterprise/templates/config-secrets/pi-api-env.yaml index eb4777bc..4b4a33e3 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 }} @@ -75,9 +96,15 @@ stringData: BR_AWS_SESSION_TOKEN: {{ . | quote }} {{- end }} {{- end }} + {{- end }} {{- if empty .Values.external_secrets.app_keys_existingSecret }} AES_SECRET_KEY: {{ include "plane.secretValue" (dict "context" $ "name" "env.silo_envs.aes_secret_key" "value" .Values.env.silo_envs.aes_secret_key "fallback" "dsOdt7YrvxsTIFJ37pOaEVvLxN8KGBCr") | quote }} + {{- with .Values.env.silo_envs.aes_salt }} + {{/* Rendered only when set: api, silo and pi all default to "aes-salt" internally, + so emitting an empty value would break decryption on existing instances. */}} + AES_SALT: {{ . | quote }} + {{- end }} PI_INTERNAL_SECRET: {{ include "plane.secretValue" (dict "context" $ "name" "env.pi_envs.internal_secret" "value" .Values.env.pi_envs.internal_secret "fallback" "tyfvfqvBJAgpm9bzvf3r4urJer0Ehfdubk") | quote }} diff --git a/charts/plane-enterprise/templates/config-secrets/runner-env.yaml b/charts/plane-enterprise/templates/config-secrets/runner-env.yaml index daa6d80d..f2c06b0a 100644 --- a/charts/plane-enterprise/templates/config-secrets/runner-env.yaml +++ b/charts/plane-enterprise/templates/config-secrets/runner-env.yaml @@ -8,7 +8,11 @@ metadata: labels: {{- include "plane.commonLabels" $ | nindent 4 }} stringData: - RUNNER_HMAC_SECRET_KEY: {{ .Values.env.runner_envs.hmac_secret_key | default "" | quote }} + {{- if empty .Values.external_secrets.app_keys_existingSecret }} + {{/* The API is the verifying side of this HMAC, so this Secret is mounted on the + api-family workloads too — see runner.deployment.yaml and api.deployment.yaml. */}} + RUNNER_HMAC_SECRET_KEY: {{ include "plane.secretValue" (dict "context" $ "name" "env.runner_envs.hmac_secret_key" "value" .Values.env.runner_envs.hmac_secret_key "fallback" "") | quote }} + {{- end }} {{- end }} --- {{- if .Values.services.runner.enabled }} diff --git a/charts/plane-enterprise/templates/config-secrets/silo.yaml b/charts/plane-enterprise/templates/config-secrets/silo.yaml index e6d00b59..fdfa0a19 100644 --- a/charts/plane-enterprise/templates/config-secrets/silo.yaml +++ b/charts/plane-enterprise/templates/config-secrets/silo.yaml @@ -12,24 +12,38 @@ stringData: {{- if empty .Values.external_secrets.app_keys_existingSecret }} SILO_HMAC_SECRET_KEY: {{ include "plane.secretValue" (dict "context" $ "name" "env.silo_envs.hmac_secret_key" "value" .Values.env.silo_envs.hmac_secret_key "fallback" "gzb7MRLr0FoN129NyWARZEs84P9LzQ") | quote }} AES_SECRET_KEY: {{ include "plane.secretValue" (dict "context" $ "name" "env.silo_envs.aes_secret_key" "value" .Values.env.silo_envs.aes_secret_key "fallback" "dsOdt7YrvxsTIFJ37pOaEVvLxN8KGBCr") | quote }} + {{- with .Values.env.silo_envs.aes_salt }} + {{/* Rendered only when set: api, silo and pi all default to "aes-salt" internally, + so emitting an empty value would break decryption on existing instances. */}} + AES_SALT: {{ . | quote }} + {{- end }} CURSOR_WEBHOOK_SECRET: {{ include "plane.secretValue" (dict "context" $ "name" "env.silo_envs.cursor_webhook_secret" "value" .Values.env.silo_envs.cursor_webhook_secret "fallback" "TTqazTcoBajYKzIAeIKFZeTX9czAoUsG") | quote }} {{- end }} - {{- if .Values.services.postgres.local_setup }} + {{- if include "plane.externalDatabase" . }} + {{/* Credentials come from external_secrets.database as discrete POSTGRES_* parts. + Left empty on purpose: silo prefers a URL when one is set, so a composed URL + here would shadow the rotated password. */}} + DATABASE_URL: "" + {{- else if .Values.services.postgres.local_setup }} DATABASE_URL: "postgresql://{{ .Values.env.pgdb_username }}:{{ .Values.env.pgdb_password }}@{{ .Release.Name }}-pgdb.{{ .Release.Namespace }}.svc.{{ .Values.env.default_cluster_domain | default "cluster.local" }}/{{ .Values.env.pgdb_name }}" {{- else if .Values.env.pgdb_remote_url }} DATABASE_URL: {{ .Values.env.pgdb_remote_url}} - {{ else }} + {{- else }} DATABASE_URL: "" - {{ end }} + {{- end }} - {{- if .Values.services.redis.local_setup }} + {{- if include "plane.externalRedis" . }} + REDIS_URL: "" + {{- else if .Values.services.redis.local_setup }} REDIS_URL: "redis://{{ .Release.Name }}-redis.{{ .Release.Namespace }}.svc.{{ .Values.env.default_cluster_domain | default "cluster.local" }}:6379/" {{- else if .Values.env.remote_redis_url }} REDIS_URL: {{ .Values.env.remote_redis_url | default "" | quote }} {{- end }} - {{- if .Values.services.rabbitmq.local_setup }} + {{- if include "plane.externalRabbitmq" . }} + AMQP_URL: "" + {{- else if .Values.services.rabbitmq.local_setup }} AMQP_URL: "amqp://{{ .Values.services.rabbitmq.default_user}}:{{ .Values.services.rabbitmq.default_password}}@{{ .Release.Name }}-rabbitmq.{{ .Release.Namespace }}.svc.{{ .Values.env.default_cluster_domain | default "cluster.local" }}/" {{- else if .Values.services.rabbitmq.external_rabbitmq_url }} AMQP_URL: {{ .Values.services.rabbitmq.external_rabbitmq_url}} @@ -37,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 }} @@ -71,6 +88,7 @@ stringData: HUBSPOT_CLIENT_ID: {{ .Values.services.silo.connectors.hubspot.client_id | default "" | quote }} HUBSPOT_CLIENT_SECRET: {{ .Values.services.silo.connectors.hubspot.client_secret | default "" | quote }} {{- end }} + {{- end }} {{- end }} --- {{- if .Values.services.silo.enabled }} diff --git a/charts/plane-enterprise/templates/workloads/agent-consumer.deployment.yaml b/charts/plane-enterprise/templates/workloads/agent-consumer.deployment.yaml index baa4a769..9e41976e 100644 --- a/charts/plane-enterprise/templates/workloads/agent-consumer.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/agent-consumer.deployment.yaml @@ -52,6 +52,7 @@ spec: - ./bin/docker-entrypoint-agent-consumer.sh envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-agent-consumer-vars optional: false @@ -72,6 +73,13 @@ 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 }} {{- if or .Values.extraEnv (include "plane.infraCredsEnv" .) }} env: {{- with (include "plane.infraCredsEnv" .) }} diff --git a/charts/plane-enterprise/templates/workloads/api.deployment.yaml b/charts/plane-enterprise/templates/workloads/api.deployment.yaml index 0fb3aa4c..696d78fe 100644 --- a/charts/plane-enterprise/templates/workloads/api.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/api.deployment.yaml @@ -79,6 +79,7 @@ spec: exec ./bin/docker-entrypoint-api-ee.sh envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-app-vars optional: false @@ -96,6 +97,14 @@ spec: name: {{ if not (empty .Values.external_secrets.silo_env_existingSecret) }}{{ .Values.external_secrets.silo_env_existingSecret }}{{ else }}{{ .Release.Name }}-silo-secrets{{ end }} optional: false {{- end }} + {{- if .Values.services.runner.enabled }} + {{/* The API verifies the runner's HMAC signature, so it needs the same key + the runner signs with. Delivered here for the same reason silo-secrets + is above: the verifying side lives in this workload family. */}} + - secretRef: + name: {{ if not (empty .Values.external_secrets.runner_env_existingSecret) }}{{ .Values.external_secrets.runner_env_existingSecret }}{{ else }}{{ .Release.Name }}-runner-secrets{{ end }} + optional: false + {{- end }} {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.infraCredsEnv" .) }} env: diff --git a/charts/plane-enterprise/templates/workloads/beat-worker.deployment.yaml b/charts/plane-enterprise/templates/workloads/beat-worker.deployment.yaml index d0609270..4fc520f6 100644 --- a/charts/plane-enterprise/templates/workloads/beat-worker.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/beat-worker.deployment.yaml @@ -39,6 +39,7 @@ spec: - ./bin/docker-entrypoint-beat.sh envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-app-vars optional: false @@ -56,6 +57,14 @@ spec: name: {{ if not (empty .Values.external_secrets.silo_env_existingSecret) }}{{ .Values.external_secrets.silo_env_existingSecret }}{{ else }}{{ .Release.Name }}-silo-secrets{{ end }} optional: false {{- end }} + {{- if .Values.services.runner.enabled }} + {{/* The API verifies the runner's HMAC signature, so it needs the same key + the runner signs with. Delivered here for the same reason silo-secrets + is above: the verifying side lives in this workload family. */}} + - secretRef: + name: {{ if not (empty .Values.external_secrets.runner_env_existingSecret) }}{{ .Values.external_secrets.runner_env_existingSecret }}{{ else }}{{ .Release.Name }}-runner-secrets{{ end }} + optional: false + {{- end }} {{- if or .Values.extraEnv (include "plane.infraCredsEnv" .) }} env: {{- with (include "plane.infraCredsEnv" .) }} diff --git a/charts/plane-enterprise/templates/workloads/external-api.deployment.yaml b/charts/plane-enterprise/templates/workloads/external-api.deployment.yaml index e93ec9c4..04bf8e1d 100644 --- a/charts/plane-enterprise/templates/workloads/external-api.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/external-api.deployment.yaml @@ -80,6 +80,7 @@ spec: exec ./bin/docker-entrypoint-api-ee.sh envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-app-vars optional: false @@ -97,6 +98,14 @@ spec: name: {{ if not (empty .Values.external_secrets.silo_env_existingSecret) }}{{ .Values.external_secrets.silo_env_existingSecret }}{{ else }}{{ .Release.Name }}-silo-secrets{{ end }} optional: false {{- end }} + {{- if .Values.services.runner.enabled }} + {{/* The API verifies the runner's HMAC signature, so it needs the same key + the runner signs with. Delivered here for the same reason silo-secrets + is above: the verifying side lives in this workload family. */}} + - secretRef: + name: {{ if not (empty .Values.external_secrets.runner_env_existingSecret) }}{{ .Values.external_secrets.runner_env_existingSecret }}{{ else }}{{ .Release.Name }}-runner-secrets{{ end }} + optional: false + {{- end }} {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.infraCredsEnv" .) }} env: diff --git a/charts/plane-enterprise/templates/workloads/live.deployment.yaml b/charts/plane-enterprise/templates/workloads/live.deployment.yaml index 91caea2c..aae6e76d 100644 --- a/charts/plane-enterprise/templates/workloads/live.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/live.deployment.yaml @@ -93,16 +93,20 @@ spec: {{- end }} envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.aiProvidersSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-live-vars optional: false - secretRef: name: {{ if not (empty .Values.external_secrets.live_env_existingSecret) }}{{ .Values.external_secrets.live_env_existingSecret }}{{ else }}{{ .Release.Name }}-live-secrets{{ end }} optional: false - {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) }} + {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.redisCredsEnv" .) }} env: {{- with (include "plane.s3CANodeEnvVars" .) }} {{ . | indent 10 }} + {{- end }} + {{- with (include "plane.redisCredsEnv" .) }} +{{ trim . | indent 10 }} {{- end }} {{- if .Values.extraEnv }} {{- toYaml .Values.extraEnv | nindent 10 }} diff --git a/charts/plane-enterprise/templates/workloads/migrator.job.yaml b/charts/plane-enterprise/templates/workloads/migrator.job.yaml index 5d9407e6..ef386418 100644 --- a/charts/plane-enterprise/templates/workloads/migrator.job.yaml +++ b/charts/plane-enterprise/templates/workloads/migrator.job.yaml @@ -27,6 +27,7 @@ spec: imagePullPolicy: {{ .Values.services.api.pullPolicy | default "Always" }} envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-app-vars optional: false @@ -44,6 +45,14 @@ spec: name: {{ if not (empty .Values.external_secrets.silo_env_existingSecret) }}{{ .Values.external_secrets.silo_env_existingSecret }}{{ else }}{{ .Release.Name }}-silo-secrets{{ end }} optional: false {{- end }} + {{- if .Values.services.runner.enabled }} + {{/* The API verifies the runner's HMAC signature, so it needs the same key + the runner signs with. Delivered here for the same reason silo-secrets + is above: the verifying side lives in this workload family. */}} + - secretRef: + name: {{ if not (empty .Values.external_secrets.runner_env_existingSecret) }}{{ .Values.external_secrets.runner_env_existingSecret }}{{ else }}{{ .Release.Name }}-runner-secrets{{ end }} + optional: false + {{- end }} {{- if or .Values.extraEnv (include "plane.infraCredsEnv" .) }} env: {{- with (include "plane.infraCredsEnv" .) }} diff --git a/charts/plane-enterprise/templates/workloads/pi-api.deployment.yaml b/charts/plane-enterprise/templates/workloads/pi-api.deployment.yaml index 0efd03fc..62328478 100644 --- a/charts/plane-enterprise/templates/workloads/pi-api.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/pi-api.deployment.yaml @@ -79,6 +79,7 @@ spec: exec ./bin/entrypoint-api.sh envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.aiProvidersSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-pi-api-vars optional: false @@ -91,12 +92,12 @@ spec: - secretRef: name: {{ if not (empty .Values.external_secrets.opensearch_existingSecret) }}{{ .Values.external_secrets.opensearch_existingSecret }}{{ else }}{{ .Release.Name }}-opensearch-secrets{{ end }} optional: false - {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.opensearchCredsEnv" .) }} + {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.piInfraCredsEnv" .) }} env: {{- with (include "plane.s3CAEnvVars" .) }} {{ . | indent 10 }} {{- end }} - {{- with (include "plane.opensearchCredsEnv" .) }} + {{- with (include "plane.piInfraCredsEnv" .) }} {{ trim . | indent 10 }} {{- end }} {{- if .Values.extraEnv }} diff --git a/charts/plane-enterprise/templates/workloads/pi-beat.deployment.yaml b/charts/plane-enterprise/templates/workloads/pi-beat.deployment.yaml index 3acdddd1..b0380aad 100644 --- a/charts/plane-enterprise/templates/workloads/pi-beat.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/pi-beat.deployment.yaml @@ -54,6 +54,7 @@ spec: exec ./bin/entrypoint-celery-beat.sh envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.aiProvidersSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-pi-api-vars optional: false @@ -66,12 +67,12 @@ spec: - secretRef: name: {{ if not (empty .Values.external_secrets.opensearch_existingSecret) }}{{ .Values.external_secrets.opensearch_existingSecret }}{{ else }}{{ .Release.Name }}-opensearch-secrets{{ end }} optional: false - {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.opensearchCredsEnv" .) }} + {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.piInfraCredsEnv" .) }} env: {{- with (include "plane.s3CAEnvVars" .) }} {{ . | indent 10 }} {{- end }} - {{- with (include "plane.opensearchCredsEnv" .) }} + {{- with (include "plane.piInfraCredsEnv" .) }} {{ trim . | indent 10 }} {{- end }} {{- if .Values.extraEnv }} diff --git a/charts/plane-enterprise/templates/workloads/pi-migrator.job.yaml b/charts/plane-enterprise/templates/workloads/pi-migrator.job.yaml index 64b007aa..9c280af2 100644 --- a/charts/plane-enterprise/templates/workloads/pi-migrator.job.yaml +++ b/charts/plane-enterprise/templates/workloads/pi-migrator.job.yaml @@ -43,6 +43,7 @@ spec: exec ./bin/entrypoint-migrator.sh envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.aiProvidersSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-pi-api-vars optional: false @@ -55,12 +56,12 @@ spec: - secretRef: name: {{ if not (empty .Values.external_secrets.opensearch_existingSecret) }}{{ .Values.external_secrets.opensearch_existingSecret }}{{ else }}{{ .Release.Name }}-opensearch-secrets{{ end }} optional: false - {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.opensearchCredsEnv" .) }} + {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.piInfraCredsEnv" .) }} env: {{- with (include "plane.s3CAEnvVars" .) }} {{ . | indent 10 }} {{- end }} - {{- with (include "plane.opensearchCredsEnv" .) }} + {{- with (include "plane.piInfraCredsEnv" .) }} {{ trim . | indent 10 }} {{- end }} {{- if .Values.extraEnv }} diff --git a/charts/plane-enterprise/templates/workloads/pi-worker.deployment.yaml b/charts/plane-enterprise/templates/workloads/pi-worker.deployment.yaml index 9eae2687..5dbea10a 100644 --- a/charts/plane-enterprise/templates/workloads/pi-worker.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/pi-worker.deployment.yaml @@ -55,6 +55,7 @@ spec: exec ./bin/entrypoint-celery-worker.sh envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.aiProvidersSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-pi-api-vars optional: false @@ -67,12 +68,12 @@ spec: - secretRef: name: {{ if not (empty .Values.external_secrets.opensearch_existingSecret) }}{{ .Values.external_secrets.opensearch_existingSecret }}{{ else }}{{ .Release.Name }}-opensearch-secrets{{ end }} optional: false - {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.opensearchCredsEnv" .) }} + {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.piInfraCredsEnv" .) }} env: {{- with (include "plane.s3CAEnvVars" .) }} {{ . | indent 10 }} {{- end }} - {{- with (include "plane.opensearchCredsEnv" .) }} + {{- with (include "plane.piInfraCredsEnv" .) }} {{ trim . | indent 10 }} {{- end }} {{- if .Values.extraEnv }} diff --git a/charts/plane-enterprise/templates/workloads/runner.deployment.yaml b/charts/plane-enterprise/templates/workloads/runner.deployment.yaml index 4d096ee2..df4c4a1d 100644 --- a/charts/plane-enterprise/templates/workloads/runner.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/runner.deployment.yaml @@ -69,6 +69,7 @@ spec: successThreshold: 1 timeoutSeconds: 1 envFrom: + {{- include "plane.appKeysSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-runner-vars optional: false diff --git a/charts/plane-enterprise/templates/workloads/silo.deployment.yaml b/charts/plane-enterprise/templates/workloads/silo.deployment.yaml index 3612bb05..eae6d9b8 100644 --- a/charts/plane-enterprise/templates/workloads/silo.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/silo.deployment.yaml @@ -116,6 +116,7 @@ spec: {{- end }} envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-silo-vars optional: false @@ -125,10 +126,13 @@ spec: - secretRef: name: {{ if not (empty .Values.external_secrets.doc_store_existingSecret) }}{{ .Values.external_secrets.doc_store_existingSecret }}{{ else }}{{ .Release.Name }}-doc-store-secrets{{ end }} optional: false - {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) }} + {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.siloInfraCredsEnv" .) }} env: {{- with (include "plane.s3CANodeEnvVars" .) }} {{ . | indent 10 }} + {{- end }} + {{- with (include "plane.siloInfraCredsEnv" .) }} +{{ trim . | indent 10 }} {{- end }} {{- if .Values.extraEnv }} {{- toYaml .Values.extraEnv | nindent 10 }} diff --git a/charts/plane-enterprise/templates/workloads/webhook-consumer.deployment.yaml b/charts/plane-enterprise/templates/workloads/webhook-consumer.deployment.yaml index b619242f..c39fd5d3 100644 --- a/charts/plane-enterprise/templates/workloads/webhook-consumer.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/webhook-consumer.deployment.yaml @@ -52,6 +52,7 @@ spec: - ./bin/docker-entrypoint-webhook-consumer.sh envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-webhook-consumer-vars optional: false @@ -72,6 +73,14 @@ spec: name: {{ if not (empty .Values.external_secrets.silo_env_existingSecret) }}{{ .Values.external_secrets.silo_env_existingSecret }}{{ else }}{{ .Release.Name }}-silo-secrets{{ end }} optional: false {{- end }} + {{- if .Values.services.runner.enabled }} + {{/* The API verifies the runner's HMAC signature, so it needs the same key + the runner signs with. Delivered here for the same reason silo-secrets + is above: the verifying side lives in this workload family. */}} + - secretRef: + name: {{ if not (empty .Values.external_secrets.runner_env_existingSecret) }}{{ .Values.external_secrets.runner_env_existingSecret }}{{ else }}{{ .Release.Name }}-runner-secrets{{ end }} + optional: false + {{- end }} {{- if or .Values.extraEnv (include "plane.infraCredsEnv" .) }} env: {{- with (include "plane.infraCredsEnv" .) }} diff --git a/charts/plane-enterprise/templates/workloads/worker-importers.deployment.yaml b/charts/plane-enterprise/templates/workloads/worker-importers.deployment.yaml index 2c5e31bc..8ff85938 100644 --- a/charts/plane-enterprise/templates/workloads/worker-importers.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/worker-importers.deployment.yaml @@ -56,6 +56,7 @@ spec: exec ./bin/docker-entrypoint-worker.sh celery.importer envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-app-vars optional: false @@ -73,6 +74,14 @@ spec: name: {{ if not (empty .Values.external_secrets.silo_env_existingSecret) }}{{ .Values.external_secrets.silo_env_existingSecret }}{{ else }}{{ .Release.Name }}-silo-secrets{{ end }} optional: false {{- end }} + {{- if .Values.services.runner.enabled }} + {{/* The API verifies the runner's HMAC signature, so it needs the same key + the runner signs with. Delivered here for the same reason silo-secrets + is above: the verifying side lives in this workload family. */}} + - secretRef: + name: {{ if not (empty .Values.external_secrets.runner_env_existingSecret) }}{{ .Values.external_secrets.runner_env_existingSecret }}{{ else }}{{ .Release.Name }}-runner-secrets{{ end }} + optional: false + {{- end }} {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.infraCredsEnv" .) }} env: diff --git a/charts/plane-enterprise/templates/workloads/worker.deployment.yaml b/charts/plane-enterprise/templates/workloads/worker.deployment.yaml index ff49195d..e4bef8e8 100644 --- a/charts/plane-enterprise/templates/workloads/worker.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/worker.deployment.yaml @@ -55,6 +55,7 @@ spec: exec ./bin/docker-entrypoint-worker.sh envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-app-vars optional: false @@ -72,6 +73,14 @@ spec: name: {{ if not (empty .Values.external_secrets.silo_env_existingSecret) }}{{ .Values.external_secrets.silo_env_existingSecret }}{{ else }}{{ .Release.Name }}-silo-secrets{{ end }} optional: false {{- end }} + {{- if .Values.services.runner.enabled }} + {{/* The API verifies the runner's HMAC signature, so it needs the same key + the runner signs with. Delivered here for the same reason silo-secrets + is above: the verifying side lives in this workload family. */}} + - secretRef: + name: {{ if not (empty .Values.external_secrets.runner_env_existingSecret) }}{{ .Values.external_secrets.runner_env_existingSecret }}{{ else }}{{ .Release.Name }}-runner-secrets{{ end }} + optional: false + {{- end }} {{- if or .Values.extraEnv (include "plane.s3CAEnabled" .) (include "plane.infraCredsEnv" .) }} env: diff --git a/charts/plane-enterprise/values.yaml b/charts/plane-enterprise/values.yaml index c7f90b65..a9a20470 100644 --- a/charts/plane-enterprise/values.yaml +++ b/charts/plane-enterprise/values.yaml @@ -599,6 +599,15 @@ serviceAccount: # Extra pod-template labels (Azure Workload Identity requires one). podLabels: {} + # Declares that this ServiceAccount is bound to a cloud identity configured OUT OF + # BAND — an EKS Pod Identity association, a GKE Workload Identity binding. The chart + # cannot detect that on its own: a Pod Identity association is an EKS API object keyed + # on cluster + namespace + service account, and nothing about it appears in the pod + # spec. Setting this changes no rendered output; it only lets `helm upgrade` warn + # about the interactions an attached identity creates, chiefly that credentials now + # reach every container on this ServiceAccount. + cloudIdentity: false + # ============================================================ # Externalized secrets # ============================================================ @@ -638,6 +647,23 @@ external_secrets: portKey: '' dbNameKey: '' + # Read replica. services.postgres.read_replica.remote_url is a DSN with the + # password inside it, which a managed rotation can never update — so this is the + # replica's equivalent of the block above. The API reads + # POSTGRES_READ_REPLICA_{DB,USER,PASSWORD,HOST,PORT} natively (Django takes the + # parts as a config dict; nothing composes a URL), so no application change is + # needed to use it. + # + # secretName defaults to the primary's, since a replica normally accepts the same + # credential. Set it only when the replica has its own user. + readReplica: + secretName: '' + usernameKey: '' # defaults to database.usernameKey + passwordKey: '' # defaults to database.passwordKey + hostKey: '' + portKey: '' + dbNameKey: '' + rabbitmq: # Amazon MQ shape. Set env.rabbitmq_ssl: true and env.rabbitmq_port: '5671' — # Amazon MQ refuses plaintext AMQP. @@ -656,6 +682,27 @@ external_secrets: hostKey: '' portKey: '' + storage: + # Object-storage credentials. + # + # On a cloud, leave this unset and give the pod an identity instead — annotate the + # ServiceAccount (see serviceAccount.annotations) or attach an EKS Pod Identity + # association. With env.aws_access_key / aws_secret_access_key empty the chart omits + # those variables entirely, which is what lets the SDK's credential chain reach the + # pod's role. An empty value would beat the chain and fail every request. + # + # Use this for an S3-compatible backend with no workload identity (Ceph, DO Spaces, + # an external MinIO), or for GCS with a service-account key. Ignored while the + # bundled MinIO is deployed, which supplies its own credentials. + # + # gcsCredentialsJsonKey carries the whole service-account JSON, private key + # included — the single highest-value blob in a GCS deployment, and the reason it + # should never sit in values.yaml. + secretName: '' + accessKeyIdKey: '' # -> AWS_ACCESS_KEY_ID + secretAccessKeyKey: '' # -> AWS_SECRET_ACCESS_KEY + gcsCredentialsJsonKey: '' # -> GCS_CREDENTIALS_JSON + opensearch: # Credentials for a REMOTE OpenSearch. Ignored when # services.opensearch.local_setup is true — the chart owns both sides of the @@ -666,17 +713,77 @@ external_secrets: usernameKey: username passwordKey: password - # Shared signing/encryption keys (SECRET_KEY, AES_SECRET_KEY, AES_SALT, - # LIVE_SERVER_SECRET_KEY, PI_INTERNAL_SECRET, SILO_HMAC_SECRET_KEY, - # CURSOR_WEBHOOK_SECRET). One Secret for every service that needs them, so a - # rotation can never leave two services disagreeing. + # Shared signing/encryption keys. One Secret for every service that needs them, so + # a rotation can never leave two services disagreeing. While this is set, the chart + # stops emitting these keys in its own Secrets, making yours the only source. + # + # The Secret MUST contain every key your deployment uses — a missing key is not a + # render error, it is an absent env var: + # SECRET_KEY REQUIRED. The API falls back to a per-pod random value + # when absent, so JWTs stop verifying across replicas and + # encrypted instance-configuration rows become unreadable. + # AES_SECRET_KEY REQUIRED. api/silo/pi all derive from it. + # AES_SALT Optional; all three services default to "aes-salt". + # Set it only if you set it from the start. + # LIVE_SERVER_SECRET_KEY Required when live is deployed (api <-> live <-> pi). + # PI_INTERNAL_SECRET Required when Plane AI is deployed. + # SILO_HMAC_SECRET_KEY Required when silo is deployed (silo signs, API verifies). + # CURSOR_WEBHOOK_SECRET Required for silo's Cursor agent integration. + # RUNNER_HMAC_SECRET_KEY Required when the runner is deployed. # # WARNING — SECRET_KEY, AES_SECRET_KEY and AES_SALT are key-encryption keys: # SECRET_KEY protects the encrypted instance-configuration rows and the AES pair # protects stored OAuth/MCP tokens. Changing them makes existing ciphertext - # undecryptable. Store them in a secret that is NOT on a rotation schedule. + # undecryptable, and it fails silently. Store them in a secret that is NOT on a + # rotation schedule. + # + # Do NOT also define these keys in one of the *_existingSecret groups below. Those + # are mounted after this Secret, so a duplicate wins on some workloads and not + # others, leaving two services with different values of a key that must match. app_keys_existingSecret: '' + # 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: '' @@ -755,6 +862,13 @@ env: pgdb_host: '' pgdb_port: '5432' + # Read-replica endpoint, used with external_secrets.database.readReplica (the + # credentials come from that Secret). The database name defaults to pgdb_name, since + # a replica serves the same database under a different endpoint. + pgdb_read_replica_host: '' + pgdb_read_replica_port: '5432' + pgdb_read_replica_name: '' + # RabbitMQ endpoint used with external_secrets.rabbitmq (credentials come from # that Secret). Ignored when services.rabbitmq.local_setup is true. rabbitmq_host: '' @@ -861,6 +975,11 @@ env: # aes_secret_key encrypts stored connector tokens: NEVER ROTATE on a live instance. hmac_secret_key: 'gzb7MRLr0FoN129NyWARZEs84P9LzQ' aes_secret_key: 'dsOdt7YrvxsTIFJ37pOaEVvLxN8KGBCr' + # Salt paired with aes_secret_key. Shared by api, silo and pi, which all default + # to "aes-salt" internally — leave empty to keep that default. Setting it on an + # instance that already has encrypted data makes that data undecryptable, exactly + # like changing aes_secret_key. NEVER ROTATE. + aes_salt: '' cors_allowed_origins: '' jira_server_issues_page_size: 50 jira_server_issues_parallel_pages: 1 diff --git a/hack/assert-secrets.py b/hack/assert-secrets.py new file mode 100755 index 00000000..367a911e --- /dev/null +++ b/hack/assert-secrets.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Assertions over a rendered Helm release, for the secret-externalization work. + +Two of these encode the properties the whole exercise exists to guarantee: + + --no-plaintext-secrets nothing that looks like a live credential is in the render + --no-dsn no connection string carries an embedded password + +The second is the one that proves rotation is actually possible. A DSN in a Secret is +a rotation dead-end: when a managed database rotates the password, nothing can track +it, because the password is baked into a string somebody has to recompose. Discrete +credential parts are what make a verbatim mirror of a rotation secret work. + +Usage: + assert-secrets.py rendered.yaml --no-plaintext-secrets --no-dsn + assert-secrets.py rendered.yaml --absent AWS_ACCESS_KEY_ID,AWS_REGION + assert-secrets.py rendered.yaml --no-key-in-secrets OPENAI_API_KEY,CLAUDE_API_KEY + assert-secrets.py rendered.yaml --order 'Deployment/t-pi-api-wl/t-pi-api:plane-ai=plane-pi' + +Exits non-zero and prints every violation. +""" + +import argparse +import re +import sys + +import yaml + +# Credential shapes worth failing a build over. Deliberately provider-prefix based: +# matching on entropy produces false positives on image digests and checksums. +SECRET_PATTERNS = { + "OpenAI key": re.compile(r"sk-(proj-|svcacct-)?[A-Za-z0-9_-]{20,}"), + "Anthropic key": re.compile(r"sk-ant-[A-Za-z0-9_-]{20,}"), + "Groq key": re.compile(r"gsk_[A-Za-z0-9]{20,}"), + "Docker Hub token": re.compile(r"dckr_(oat|pat)_[A-Za-z0-9_-]{10,}"), + "AWS access key id": re.compile(r"\b(AKIA|ASIA)[0-9A-Z]{16}\b"), + "GitLab OAuth token": re.compile(r"gloas-[A-Za-z0-9_-]{20,}"), + "Google OAuth secret": re.compile(r"GOCSPX-[A-Za-z0-9_-]{10,}"), + "private key block": re.compile(r"BEGIN [A-Z ]*PRIVATE KEY"), +} + +# A connection string whose authority section carries a password. +DSN_WITH_PASSWORD = re.compile(r"\b(postgresql|postgres|amqps?|rediss?|mongodb)://[^\s:/@]+:[^\s@]+@") + + +def load(path): + with open(path) as fh: + return [d for d in yaml.safe_load_all(fh) if isinstance(d, dict)] + + +def iter_values(docs, kinds=("Secret", "ConfigMap")): + """Yield (kind, name, key, value) for every data entry in the render.""" + for doc in docs: + kind = doc.get("kind") + if kind not in kinds: + continue + name = (doc.get("metadata") or {}).get("name", "") + for field in ("stringData", "data"): + for k, v in (doc.get(field) or {}).items(): + if isinstance(v, str): + yield kind, name, k, v + + +def iter_container_env(docs): + """Yield (workload, container, env_entry) for explicit env lists.""" + for doc in docs: + if doc.get("kind") not in ("Deployment", "StatefulSet", "DaemonSet", "Job"): + continue + name = (doc.get("metadata") or {}).get("name", "") + pod = (((doc.get("spec") or {}).get("template") or {}).get("spec") or {}) + for c in (pod.get("initContainers") or []) + (pod.get("containers") or []): + for e in c.get("env") or []: + yield name, c.get("name", "?"), e + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("rendered") + ap.add_argument("--no-plaintext-secrets", action="store_true") + ap.add_argument("--no-dsn", action="store_true") + ap.add_argument("--absent", default="", help="env names that must not appear anywhere") + ap.add_argument("--no-key-in-secrets", default="", help="keys no rendered Secret may contain, even empty") + ap.add_argument( + "--order", + action="append", + default=[], + metavar="WORKLOAD/CONTAINER:EARLIER=LATER", + help="assert one envFrom secretRef precedes another", + ) + args = ap.parse_args() + + docs = load(args.rendered) + problems = [] + + if args.no_plaintext_secrets: + for kind, name, key, value in iter_values(docs): + for label, pat in SECRET_PATTERNS.items(): + if pat.search(value): + problems.append(f"plaintext {label} in {kind}/{name} key {key}") + for wl, cname, entry in iter_container_env(docs): + v = entry.get("value") + if isinstance(v, str): + for label, pat in SECRET_PATTERNS.items(): + if pat.search(v): + problems.append(f"plaintext {label} in {wl}/{cname} env {entry.get('name')}") + + if args.no_dsn: + for kind, name, key, value in iter_values(docs): + if DSN_WITH_PASSWORD.search(value): + problems.append( + f"connection string with an embedded password in {kind}/{name} key {key} " + "— a rotated credential can never reach this; use the discrete credential parts" + ) + + if args.absent: + wanted = {k for k in args.absent.split(",") if k} + for kind, name, key, _ in iter_values(docs): + if key in wanted: + problems.append(f"{key} must be absent but is set in {kind}/{name}") + for wl, cname, entry in iter_container_env(docs): + if entry.get("name") in wanted: + problems.append(f"{entry['name']} must be absent but is set on {wl}/{cname}") + + if args.no_key_in_secrets: + wanted = {k for k in args.no_key_in_secrets.split(",") if k} + for kind, name, key, value in iter_values(docs, kinds=("Secret",)): + if key in wanted: + shown = "empty string" if value == "" else "a value" + problems.append( + f"Secret/{name} still renders {key} ({shown}) — envFrom resolves " + "later-source-wins, so this overwrites the external Secret" + ) + + for spec in args.order: + target, _, pair = spec.partition(":") + earlier, _, later = pair.partition("=") + found = False + for doc in docs: + if doc.get("kind") not in ("Deployment", "StatefulSet", "Job"): + continue + name = (doc.get("metadata") or {}).get("name", "") + pod = (((doc.get("spec") or {}).get("template") or {}).get("spec") or {}) + for c in pod.get("containers") or []: + if f"{doc['kind']}/{name}/{c.get('name')}" != target: + continue + found = True + names = [s["secretRef"]["name"] for s in (c.get("envFrom") or []) if "secretRef" in s] + if earlier not in names or later not in names: + problems.append(f"{target}: expected both {earlier} and {later} in envFrom, got {names}") + elif names.index(earlier) > names.index(later): + problems.append(f"{target}: {earlier} must precede {later}, got {names}") + if not found: + problems.append(f"{target}: container not found in render") + + if problems: + print(f"FAIL — {len(problems)} violation(s):", file=sys.stderr) + for p in problems: + print(f" - {p}", file=sys.stderr) + return 1 + print("PASS") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/hack/resolve-env.py b/hack/resolve-env.py new file mode 100755 index 00000000..4d440d94 --- /dev/null +++ b/hack/resolve-env.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Resolve the effective environment of every container in a rendered Helm release. + +Reading a chart diff tells you what the templates changed. It does not tell you what +the *pods* end up with, which is the only thing that matters when secrets move between +Secrets: `envFrom` resolves in list order with later sources winning, and explicit +`env` entries beat all of them. A key that moves from one Secret to another, or that a +suppression forgot to remove, shows up here and nowhere else. + +Emits sorted JSON: {"kind/name/container": {"ENV_NAME": ""}} where the +resolution is the literal value, or a marker: + + valueFrom.secretKeyRef + valueFrom.configMapKeyRef + valueFrom.fieldRef + envFrom referenced a Secret this release never renders + (expected for operator-supplied external Secrets) + +Usage: + resolve-env.py rendered.yaml [--external NAME=KEY1,KEY2 ...] > effective.json + +--external declares a Secret that lives outside the chart along with the keys it +carries, so its contribution can be modelled instead of reported as missing. +""" + +import argparse +import json +import re +import sys + +import yaml + +WORKLOAD_KINDS = {"Deployment", "StatefulSet", "DaemonSet", "Job", "CronJob"} +# Job names carry a render timestamp; strip it so runs are comparable. +TIMESTAMP_SUFFIX = re.compile(r"-\d{8}-\d{6}$") + + +def load_docs(path): + with open(path) as fh: + return [d for d in yaml.safe_load_all(fh) if isinstance(d, dict)] + + +def collect_sources(docs, external): + """Map Secret/ConfigMap name -> {key: value}. External Secrets get sentinel values.""" + secrets, configmaps = dict(external), {} + for doc in docs: + kind = doc.get("kind") + name = (doc.get("metadata") or {}).get("name", "") + if kind == "Secret": + merged = dict(doc.get("stringData") or {}) + # data is base64; the value is irrelevant for comparison, the key is not. + for k in (doc.get("data") or {}): + merged.setdefault(k, "") + secrets[name] = merged + elif kind == "ConfigMap": + configmaps[name] = dict(doc.get("data") or {}) + return secrets, configmaps + + +def pod_templates(doc): + """Yield (container_dict, is_init) for every container in a workload.""" + kind = doc.get("kind") + spec = doc.get("spec") or {} + if kind == "CronJob": + spec = ((spec.get("jobTemplate") or {}).get("spec") or {}) + pod = ((spec.get("template") or {}).get("spec") or {}) + for c in pod.get("initContainers") or []: + yield c, True + for c in pod.get("containers") or []: + yield c, False + + +def resolve_container(container, secrets, configmaps): + env = {} + # envFrom first, in order: later sources overwrite earlier ones. + for source in container.get("envFrom") or []: + if "secretRef" in source: + name = source["secretRef"].get("name", "") + if name in secrets: + env.update(secrets[name]) + else: + env[f""] = "" + elif "configMapRef" in source: + name = source["configMapRef"].get("name", "") + if name in configmaps: + env.update(configmaps[name]) + else: + env[f""] = "" + # Explicit env always wins over envFrom. + for entry in container.get("env") or []: + name = entry.get("name") + if not name: + continue + if "value" in entry: + env[name] = entry["value"] + continue + vf = entry.get("valueFrom") or {} + if "secretKeyRef" in vf: + ref = vf["secretKeyRef"] + env[name] = f"" + elif "configMapKeyRef" in vf: + ref = vf["configMapKeyRef"] + env[name] = f"" + elif "fieldRef" in vf: + env[name] = f"" + else: + env[name] = "" + return env + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("rendered") + ap.add_argument( + "--external", + action="append", + default=[], + metavar="NAME=KEY1,KEY2", + help="model an operator-supplied Secret and the keys it carries", + ) + args = ap.parse_args() + + external = {} + for spec in args.external: + name, _, keys = spec.partition("=") + external[name] = {k: f"" for k in keys.split(",") if k} + + docs = load_docs(args.rendered) + secrets, configmaps = collect_sources(docs, external) + + out = {} + for doc in docs: + if doc.get("kind") not in WORKLOAD_KINDS: + continue + name = TIMESTAMP_SUFFIX.sub("", (doc.get("metadata") or {}).get("name", "")) + for container, is_init in pod_templates(doc): + cname = container.get("name", "?") + key = f"{doc['kind']}/{name}/{'init:' if is_init else ''}{cname}" + out[key] = dict(sorted(resolve_container(container, secrets, configmaps).items())) + + json.dump(dict(sorted(out.items())), sys.stdout, indent=2) + sys.stdout.write("\n") + + +if __name__ == "__main__": + main()