diff --git a/charts/plane-enterprise/Chart.yaml b/charts/plane-enterprise/Chart.yaml index ee450fb7..76880543 100644 --- a/charts/plane-enterprise/Chart.yaml +++ b/charts/plane-enterprise/Chart.yaml @@ -5,7 +5,7 @@ description: Meet Plane. An Enterprise software development tool to manage issue type: application -version: 3.4.0 +version: 3.5.0 appVersion: "3.1.0" home: https://plane.so/ diff --git a/charts/plane-enterprise/questions.yml b/charts/plane-enterprise/questions.yml index e7a6bafd..59a46ae2 100644 --- a/charts/plane-enterprise/questions.yml +++ b/charts/plane-enterprise/questions.yml @@ -1793,3 +1793,138 @@ questions: type: string default: "" group: "External Secrets" + +# --- Credential mirrors ------------------------------------------------------- +# A Secret that MIRRORS the managed one your cloud already rotates — the RDS secret +# created with the instance, an ElastiCache auth-token secret, an Amazon MQ secret. +# The chart reads the parts out of it and never composes a connection string, so a +# rotation lands with nothing to recompute. That is why these are separate from the +# whole-Secret replacements above, which pin a DSN the rotation can never reach. +- variable: external_secrets.database.secretName + label: "Postgres Credential Mirror Secret" + description: "A Secret carrying the Postgres username and password as separate keys — point it at a verbatim mirror of your RDS/Cloud SQL secret so a rotation needs no second copy." + type: string + default: "" + group: "Credential Mirrors" +- variable: external_secrets.database.usernameKey + label: "Postgres Username Key" + type: string + default: "username" + group: "Credential Mirrors" + show_if: "external_secrets.database.secretName!=" +- variable: external_secrets.database.passwordKey + label: "Postgres Password Key" + type: string + default: "password" + group: "Credential Mirrors" + show_if: "external_secrets.database.secretName!=" +- variable: external_secrets.database.hostKey + label: "Postgres Host Key (optional)" + description: "Only when the mirrored Secret carries the endpoint too. Otherwise the host comes from env.pgdb_remote_host." + type: string + default: "" + group: "Credential Mirrors" + show_if: "external_secrets.database.secretName!=" +- variable: external_secrets.rabbitmq.secretName + label: "RabbitMQ Credential Mirror Secret" + description: "For Amazon MQ also set env.rabbitmq_ssl=true and env.rabbitmq_port=5671 — the parts path has no URL scheme to carry TLS, and Amazon MQ refuses plaintext." + type: string + default: "" + group: "Credential Mirrors" +- variable: external_secrets.redis.secretName + label: "Redis Credential Mirror Secret" + description: "For ElastiCache the auth token goes in the password key; also set env.redis_ssl=true." + type: string + default: "" + group: "Credential Mirrors" +- variable: external_secrets.storage.secretName + label: "Object Storage Credential Secret" + description: "Leave empty and annotate the ServiceAccount instead — a pod identity beats a stored access key. Only the key names you fill in below are emitted." + type: string + default: "" + group: "Credential Mirrors" +- variable: external_secrets.storage.accessKeyIdKey + label: "Storage Access Key ID Key" + type: string + default: "" + group: "Credential Mirrors" + show_if: "external_secrets.storage.secretName!=" +- variable: external_secrets.storage.secretAccessKeyKey + label: "Storage Secret Access Key Key" + type: string + default: "" + group: "Credential Mirrors" + show_if: "external_secrets.storage.secretName!=" +- variable: external_secrets.storage.gcsCredentialsJsonKey + label: "GCS Credentials JSON Key" + type: string + default: "" + group: "Credential Mirrors" + show_if: "external_secrets.storage.secretName!=" +- variable: external_secrets.opensearch.secretName + label: "OpenSearch Credential Mirror Secret" + description: "For an AWS-managed domain, leave this empty and let the pods authenticate with SigV4 through their cloud identity." + type: string + default: "" + group: "Credential Mirrors" + +# --- Shared key groups -------------------------------------------------------- +# One Secret carrying one group of keys, mirrored into as many namespaces as you like. +# The chart stops rendering those keys, which is what makes the sharing work: an empty +# string under envFrom does not defer to another Secret, it overwrites it. +- variable: external_secrets.app_keys_existingSecret + label: "Signing Keys Secret" + description: "SECRET_KEY, AES_SECRET_KEY, AES_SALT, LIVE_SERVER_SECRET_KEY, SILO_HMAC_SECRET_KEY, RUNNER_HMAC_SECRET_KEY. These encrypt stored data: never share them between environments and never rotate them." + type: string + default: "" + group: "Shared Secrets" +- variable: external_secrets.ai_providers_existingSecret + label: "AI Provider Keys Secret" + description: "OPENAI / CLAUDE / GEMINI / COHERE / CUSTOM_LLM keys. One AI account normally serves every deployment, so this is the group worth sharing." + type: string + default: "" + group: "Shared Secrets" +- variable: external_secrets.silo_connectors_existingSecret + label: "Silo Connector Secrets Secret" + description: "Connector OAuth client secrets and the GitHub App private key. An OAuth app is registered once, so its secret is the same wherever the connector is enabled." + type: string + default: "" + group: "Shared Secrets" +- variable: external_secrets.ssl_token_existingSecret + label: "DNS-01 Token Secret" + description: "cert-manager DNS provider API token, under the key api-token." + type: string + default: "" + group: "Shared Secrets" + +# --- Cloud identity ----------------------------------------------------------- +- variable: serviceAccount.create + label: "Create the ServiceAccount" + description: "Turn off to run as a ServiceAccount managed outside this chart — one created by Terraform or Crossplane and already bound to a cloud role." + type: boolean + default: true + group: "Service Account" +- variable: serviceAccount.name + label: "ServiceAccount Name" + description: "Defaults to -srv-account." + type: string + default: "" + group: "Service Account" +- variable: serviceAccount.annotations + label: "ServiceAccount Annotations" + description: "Where a workload identity binding goes: eks.amazonaws.com/role-arn for IRSA, iam.gke.io/gcp-service-account for GKE, azure.workload.identity/client-id for Azure. EKS Pod Identity needs no annotation — it binds by name." + type: string + default: "" + group: "Service Account" +- variable: serviceAccount.cloudIdentity + label: "ServiceAccount Has a Cloud Identity" + description: "Advisory. A Pod Identity association is an EKS API object invisible to the pod spec, so the chart cannot detect it; setting this turns on the install notes that catch static credentials shadowing the identity." + type: boolean + default: false + group: "Service Account" +- variable: env.requireExplicitSecrets + label: "Refuse to Render Default Signing Keys" + description: "Fails the install instead of falling back to the published defaults for SECRET_KEY and friends. Worth turning on once the keys come from a Secret." + type: boolean + default: false + group: "Service Account" diff --git a/charts/plane-enterprise/templates/NOTES.txt b/charts/plane-enterprise/templates/NOTES.txt index 83dd2d9b..6e667333 100644 --- a/charts/plane-enterprise/templates/NOTES.txt +++ b/charts/plane-enterprise/templates/NOTES.txt @@ -89,10 +89,37 @@ different values of a key that has to match. {{- end }} {{- /* Static object-storage keys where a cloud identity would do. */}} -{{- if and .Values.env.aws_access_key (not (include "plane.minioEnabled" .)) (not .Values.serviceAccount.annotations) }} +{{- if and .Values.env.aws_access_key (not (include "plane.minioEnabled" .)) (not .Values.serviceAccount.annotations) (not .Values.serviceAccount.cloudIdentity) }} {{- $warnings = append $warnings "Object storage is configured with a static AWS access key. Prefer a cloud identity:\n annotate the ServiceAccount (serviceAccount.annotations) for IRSA / EKS Pod Identity\n / GKE or Azure Workload Identity and clear env.aws_access_key and\n env.aws_secret_access_key — the chart then omits those env vars so the SDK's default\n credential chain picks up the pod's role." }} {{- end }} +{{- /* +An attached cloud identity reaches every container on the ServiceAccount, so it changes +behaviour beyond object storage. The chart cannot detect the association — it is an EKS +API object keyed on cluster + namespace + service account — hence the declared flag. +*/}} +{{- $hasIdentity := or .Values.serviceAccount.cloudIdentity .Values.serviceAccount.annotations }} +{{- if $hasIdentity }} + +{{- /* A static key beats the identity in the SDK credential chain. */}} +{{- if and .Values.env.aws_access_key (not (include "plane.minioEnabled" .)) }} +{{- $warnings = append $warnings "A cloud identity is configured, but env.aws_access_key is also set. Environment\n credentials take precedence over the identity in every AWS SDK's chain, so the\n static key is what will be used. Clear env.aws_access_key and\n env.aws_secret_access_key to let the identity apply." }} +{{- end }} + +{{- if .Values.services.pi.ai_providers.embedding_model.aws_access_key }} +{{- $warnings = append $warnings "A cloud identity is configured, but\n services.pi.ai_providers.embedding_model.aws_access_key is also set. Plane AI falls\n back to the identity when the BR_AWS_* keys are absent, so clear them to use it." }} +{{- end }} + +{{- /* +The one that is genuinely surprising: an identity attached for S3 silently changes how +OpenSearch is authenticated, because both services read "AWS credentials are present" +as "sign with SigV4". +*/}} +{{- if and .Values.env.opensearch_remote_url (not (include "plane.externalOpensearch" .)) (not .Values.env.opensearch_remote_username) }} +{{- $warnings = append $warnings "A cloud identity is configured and the remote OpenSearch has no credentials, so the\n API and Plane AI will authenticate to it with SigV4 — an identity injects AWS\n credentials into every container, and both read that as a request to sign.\n That is correct for Amazon OpenSearch Service and wrong for any other OpenSearch.\n Either set external_secrets.opensearch.secretName, or set OPENSEARCH_AUTH_MODE=basic\n through extraEnv (needs planeVersion v3.2.0 or newer)." }} +{{- end }} +{{- end }} + {{- if $warnings }} ──────────────────────────────────────────────────────────────────────────────── diff --git a/charts/plane-enterprise/templates/_helpers.tpl b/charts/plane-enterprise/templates/_helpers.tpl index e1801eb8..9330447a 100644 --- a/charts/plane-enterprise/templates/_helpers.tpl +++ b/charts/plane-enterprise/templates/_helpers.tpl @@ -404,6 +404,47 @@ Indentation is baked in for the container envFrom position, so call it bare: {{- end }} {{- end -}} +{{/* +envFrom entry for the Secret carrying the AI/LLM provider keys. Renders nothing +unless external_secrets.ai_providers_existingSecret is set. + +Separate from the Plane AI Secret because provider accounts are shared across +environments while everything else in that Secret is per-environment. Mounted on the +Plane AI workloads and on live (whose AI_OPENAI_API_KEY has no values key at all). + +Placed before the chart's own Secret so an operator who has already externalized +pi_api_env keeps that precedence. While this is set the chart emits none of these +keys itself — including the empty-string branches, which would otherwise overwrite +this Secret's values, since envFrom resolves later-source-wins. + +Indentation is baked in for the container envFrom position, so call it bare. +*/}} +{{- define "plane.aiProvidersSecretRef" -}} +{{- with .Values.external_secrets.ai_providers_existingSecret }} + - secretRef: + name: {{ . }} + optional: false +{{- end }} +{{- end -}} + +{{/* +envFrom entry for the Secret carrying the silo connector credentials. Renders nothing +unless external_secrets.silo_connectors_existingSecret is set. + +Mounted on every workload that mounts silo-secrets today, not just silo: the Django +auth adapter reads GITHUB_CLIENT_ID/GITHUB_CLIENT_SECRET from that Secret on the api +family, so mounting this only on silo would drop those variables there. + +Indentation is baked in for the container envFrom position, so call it bare. +*/}} +{{- define "plane.siloConnectorsSecretRef" -}} +{{- with .Values.external_secrets.silo_connectors_existingSecret }} + - secretRef: + name: {{ . }} + optional: false +{{- end }} +{{- end -}} + {{/* Returns "true" when an externally managed Secret supplies the Postgres credentials, in which case the chart must not render a composed DATABASE_URL that would take @@ -503,6 +544,7 @@ Caller must indent to the correct depth (env list items). {{- include "plane.rabbitmqCredsEnv" . }} {{- include "plane.redisCredsEnv" . }} {{- include "plane.opensearchCredsEnv" . }} +{{- include "plane.storageCredsEnv" . }} {{- end -}} {{/* @@ -531,6 +573,93 @@ the live server needs Redis and nothing else. {{- include "plane.secretKeyEnv" (dict "name" "POSTGRES_DB" "secret" $db.secretName "key" .) }} {{- end }} {{- end }} +{{- include "plane.postgresReadReplicaCredsEnv" . }} +{{- end -}} + +{{/* +Returns "true" when object-storage credentials come from an externally managed Secret. +Never true while the bundled MinIO is deployed — that supplies its own credentials, and +overriding them would break the in-cluster client. +*/}} +{{- define "plane.externalStorage" -}} +{{- if and .Values.external_secrets.storage.secretName (not (include "plane.minioEnabled" .)) -}} +true +{{- end -}} +{{- end -}} + +{{/* +Object-storage credentials as explicit env entries, so they win over the doc-store +Secret mounted via envFrom. + +Only the keys the operator names are emitted: an S3 deployment sets the two access-key +keys, a GCS deployment sets gcsCredentialsJsonKey, and a deployment using a pod identity +sets none of them and relies on the SDK credential chain. + +Caller must indent to the correct depth (env list items). +*/}} +{{- define "plane.storageCredsEnv" -}} +{{- $st := .Values.external_secrets.storage -}} +{{- if include "plane.externalStorage" . }} +{{- with $st.accessKeyIdKey }} +{{- include "plane.secretKeyEnv" (dict "name" "AWS_ACCESS_KEY_ID" "secret" $st.secretName "key" .) }} +{{- end }} +{{- with $st.secretAccessKeyKey }} +{{- include "plane.secretKeyEnv" (dict "name" "AWS_SECRET_ACCESS_KEY" "secret" $st.secretName "key" .) }} +{{- end }} +{{- with $st.gcsCredentialsJsonKey }} +{{- include "plane.secretKeyEnv" (dict "name" "GCS_CREDENTIALS_JSON" "secret" $st.secretName "key" .) }} +{{- end }} +{{- end }} +{{- end -}} + +{{/* +Returns "true" when the read replica's credentials come from an externally managed +Secret. Falls back to the primary's Secret, since a replica normally accepts the same +credential — set readReplica.secretName only when it has its own user. +*/}} +{{- define "plane.externalReadReplica" -}} +{{- if .Values.services.postgres.read_replica.enabled -}} +{{- if or .Values.external_secrets.database.readReplica.secretName .Values.external_secrets.database.secretName -}} +true +{{- end -}} +{{- end -}} +{{- end -}} + +{{/* +Read-replica credentials as discrete parts. + +services.postgres.read_replica.remote_url is a DSN carrying the password, so a managed +rotation can never update it. The API reads POSTGRES_READ_REPLICA_* natively — Django +takes the parts straight into a config dict, so nothing composes a URL — which makes +this a chart-only change. + +Caller must indent to the correct depth (env list items). +*/}} +{{- define "plane.postgresReadReplicaCredsEnv" -}} +{{- $db := .Values.external_secrets.database -}} +{{- $rr := $db.readReplica -}} +{{- $secret := $rr.secretName | default $db.secretName -}} +{{/* The newline after this `if` is deliberate: call sites use a left-trim marker, so + the output has to open with one to keep this entry off the previous line. */}} +{{- if include "plane.externalReadReplica" . }} +- name: POSTGRES_READ_REPLICA_HOST + value: {{ .Values.env.pgdb_read_replica_host | quote }} +- name: POSTGRES_READ_REPLICA_PORT + value: {{ .Values.env.pgdb_read_replica_port | default "5432" | quote }} +- name: POSTGRES_READ_REPLICA_DB + value: {{ .Values.env.pgdb_read_replica_name | default .Values.env.pgdb_name | default "plane" | quote }} +{{- include "plane.secretKeyEnv" (dict "name" "POSTGRES_READ_REPLICA_USER" "secret" $secret "key" ($rr.usernameKey | default $db.usernameKey | default "username")) }} +{{- include "plane.secretKeyEnv" (dict "name" "POSTGRES_READ_REPLICA_PASSWORD" "secret" $secret "key" ($rr.passwordKey | default $db.passwordKey | default "password")) }} +{{- with $rr.hostKey }} +{{- include "plane.secretKeyEnv" (dict "name" "POSTGRES_READ_REPLICA_HOST" "secret" $secret "key" .) }} +{{- end }} +{{- with $rr.portKey }} +{{- include "plane.secretKeyEnv" (dict "name" "POSTGRES_READ_REPLICA_PORT" "secret" $secret "key" .) }} +{{- end }} +{{- with $rr.dbNameKey }} +{{- include "plane.secretKeyEnv" (dict "name" "POSTGRES_READ_REPLICA_DB" "secret" $secret "key" .) }} +{{- end }} +{{- end }} {{- end -}} {{/* @@ -615,6 +744,7 @@ Caller must indent to the correct depth (env list items). {{- include "plane.postgresCredsEnv" . }} {{- include "plane.rabbitmqCredsEnv" . }} {{- include "plane.redisCredsEnv" . }} +{{- include "plane.storageCredsEnv" . }} {{- end -}} {{/* @@ -661,4 +791,5 @@ Caller must indent to the correct depth (env list items). {{- end }} {{- include "plane.redisCredsEnv" . }} {{- include "plane.opensearchCredsEnv" . }} +{{- include "plane.storageCredsEnv" . }} {{- end -}} diff --git a/charts/plane-enterprise/templates/config-secrets/app-env.yaml b/charts/plane-enterprise/templates/config-secrets/app-env.yaml index 6e598c6b..822eec00 100644 --- a/charts/plane-enterprise/templates/config-secrets/app-env.yaml +++ b/charts/plane-enterprise/templates/config-secrets/app-env.yaml @@ -1,5 +1,11 @@ -{{- if and .Values.services.postgres.read_replica.enabled (not .Values.services.postgres.read_replica.remote_url) (empty .Values.external_secrets.app_env_existingSecret) }} -{{- fail "read_replica.enabled is true but read_replica.remote_url is empty" }} +{{/* The replica needs a source of connection details: a DSN in values, a Secret the + operator owns, or the discrete-parts mirror. Without one of those, enabling it + would silently produce a replica entry with no host. */}} +{{- if and .Values.services.postgres.read_replica.enabled + (not .Values.services.postgres.read_replica.remote_url) + (empty .Values.external_secrets.app_env_existingSecret) + (not (include "plane.externalReadReplica" .)) }} +{{- fail "read_replica.enabled is true but no source of replica credentials is configured — set services.postgres.read_replica.remote_url, or external_secrets.database.readReplica.secretName (or external_secrets.database.secretName, which it falls back to) together with env.pgdb_read_replica_host" }} {{- end }} {{- if empty .Values.external_secrets.app_env_existingSecret}} apiVersion: v1 @@ -45,7 +51,13 @@ stringData: DATABASE_URL: "" {{- end }} - {{- if and .Values.services.postgres.read_replica.enabled .Values.services.postgres.read_replica.remote_url }} + {{- if include "plane.externalReadReplica" . }} + {{/* Credentials come from the readReplica mirror as discrete + POSTGRES_READ_REPLICA_* parts. Left empty on purpose: the API prefers + DATABASE_READ_REPLICA_URL when set, so a composed URL here would shadow the + rotated password. */}} + DATABASE_READ_REPLICA_URL: "" + {{- else if and .Values.services.postgres.read_replica.enabled .Values.services.postgres.read_replica.remote_url }} DATABASE_READ_REPLICA_URL: {{ .Values.services.postgres.read_replica.remote_url | quote }} {{- end }} diff --git a/charts/plane-enterprise/templates/config-secrets/doc-store.yaml b/charts/plane-enterprise/templates/config-secrets/doc-store.yaml index c6e06602..32d3bfd0 100644 --- a/charts/plane-enterprise/templates/config-secrets/doc-store.yaml +++ b/charts/plane-enterprise/templates/config-secrets/doc-store.yaml @@ -22,9 +22,11 @@ stringData: {{- with .Values.env.gcs_credentials_path }} GCS_CREDENTIALS_PATH: {{ . | quote }} {{- end }} + {{- if not (include "plane.externalStorage" .) }} {{- with .Values.env.gcs_credentials_json }} GCS_CREDENTIALS_JSON: {{ . | quote }} {{- end }} + {{- end }} {{- else if .Values.services.minio.local_setup }} USE_MINIO: "1" MINIO_ROOT_USER: {{ .Values.services.minio.root_user | default "admin" | quote }} @@ -46,8 +48,18 @@ stringData: {{- with .Values.env.aws_secret_access_key }} AWS_SECRET_ACCESS_KEY: {{ . | quote }} {{- end }} - AWS_REGION: {{ .Values.env.aws_region | default "" | quote }} - AWS_S3_ENDPOINT_URL: {{ .Values.env.aws_s3_endpoint_url | quote }} + {{/* Also omitted rather than blank, for a subtler reason than the keys above. When a + pod identity is attached and OpenSearch has no basic auth, the API signs its + OpenSearch requests with SigV4 — and it reads the region as + os.environ.get("AWS_REGION") or a default. An empty string is *present*, so the + default never applies and every signed request goes out with an empty region and + is rejected. Absent is the only safe representation of "unset". */}} + {{- with .Values.env.aws_region }} + AWS_REGION: {{ . | quote }} + {{- end }} + {{- with .Values.env.aws_s3_endpoint_url }} + AWS_S3_ENDPOINT_URL: {{ . | quote }} + {{- end }} {{- end }} {{- end }} --- diff --git a/charts/plane-enterprise/templates/config-secrets/pi-api-env.yaml b/charts/plane-enterprise/templates/config-secrets/pi-api-env.yaml index 513ebff1..4b4a33e3 100644 --- a/charts/plane-enterprise/templates/config-secrets/pi-api-env.yaml +++ b/charts/plane-enterprise/templates/config-secrets/pi-api-env.yaml @@ -57,6 +57,11 @@ stringData: CELERY_BROKER_URL: {{ .Values.env.remote_redis_url | default "" | quote }} {{- end }} + {{- if empty .Values.external_secrets.ai_providers_existingSecret }} + {{/* Suppressed wholesale when the AI-providers Secret supplies these. The + empty-string branches below matter as much as the populated ones: envFrom + resolves later-source-wins, so a rendered COHERE_API_KEY: "" would overwrite + the value coming from that Secret. */}} {{- if .Values.services.pi.ai_providers.openai.enabled }} OPENAI_API_KEY: {{ .Values.services.pi.ai_providers.openai.api_key | default "" | quote }} {{- end }} @@ -91,6 +96,7 @@ stringData: BR_AWS_SESSION_TOKEN: {{ . | quote }} {{- end }} {{- end }} + {{- end }} {{- if empty .Values.external_secrets.app_keys_existingSecret }} AES_SECRET_KEY: {{ include "plane.secretValue" (dict "context" $ "name" "env.silo_envs.aes_secret_key" "value" .Values.env.silo_envs.aes_secret_key "fallback" "dsOdt7YrvxsTIFJ37pOaEVvLxN8KGBCr") | quote }} diff --git a/charts/plane-enterprise/templates/config-secrets/silo.yaml b/charts/plane-enterprise/templates/config-secrets/silo.yaml index 289aee67..fdfa0a19 100644 --- a/charts/plane-enterprise/templates/config-secrets/silo.yaml +++ b/charts/plane-enterprise/templates/config-secrets/silo.yaml @@ -51,6 +51,9 @@ stringData: AMQP_URL: "" {{- end }} + {{- if empty .Values.external_secrets.silo_connectors_existingSecret }} + {{/* Suppressed wholesale when the connectors Secret supplies these — client ids and + app names included, so the connector surface has a single owner. */}} {{- if .Values.services.silo.connectors.slack.enabled }} SLACK_CLIENT_SECRET: {{ .Values.services.silo.connectors.slack.client_secret | default "" | quote }} SLACK_CLIENT_ID: {{ .Values.services.silo.connectors.slack.client_id | default "" | quote }} @@ -85,6 +88,7 @@ stringData: HUBSPOT_CLIENT_ID: {{ .Values.services.silo.connectors.hubspot.client_id | default "" | quote }} HUBSPOT_CLIENT_SECRET: {{ .Values.services.silo.connectors.hubspot.client_secret | default "" | quote }} {{- end }} + {{- end }} {{- end }} --- {{- if .Values.services.silo.enabled }} diff --git a/charts/plane-enterprise/templates/workloads/agent-consumer.deployment.yaml b/charts/plane-enterprise/templates/workloads/agent-consumer.deployment.yaml index ceb5afef..9e41976e 100644 --- a/charts/plane-enterprise/templates/workloads/agent-consumer.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/agent-consumer.deployment.yaml @@ -52,6 +52,7 @@ spec: - ./bin/docker-entrypoint-agent-consumer.sh envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-agent-consumer-vars optional: false diff --git a/charts/plane-enterprise/templates/workloads/api.deployment.yaml b/charts/plane-enterprise/templates/workloads/api.deployment.yaml index ff27b60a..696d78fe 100644 --- a/charts/plane-enterprise/templates/workloads/api.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/api.deployment.yaml @@ -79,6 +79,7 @@ spec: exec ./bin/docker-entrypoint-api-ee.sh envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-app-vars optional: false diff --git a/charts/plane-enterprise/templates/workloads/beat-worker.deployment.yaml b/charts/plane-enterprise/templates/workloads/beat-worker.deployment.yaml index 2d422d24..4fc520f6 100644 --- a/charts/plane-enterprise/templates/workloads/beat-worker.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/beat-worker.deployment.yaml @@ -39,6 +39,7 @@ spec: - ./bin/docker-entrypoint-beat.sh envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-app-vars optional: false diff --git a/charts/plane-enterprise/templates/workloads/external-api.deployment.yaml b/charts/plane-enterprise/templates/workloads/external-api.deployment.yaml index a9623155..04bf8e1d 100644 --- a/charts/plane-enterprise/templates/workloads/external-api.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/external-api.deployment.yaml @@ -80,6 +80,7 @@ spec: exec ./bin/docker-entrypoint-api-ee.sh envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-app-vars optional: false diff --git a/charts/plane-enterprise/templates/workloads/live.deployment.yaml b/charts/plane-enterprise/templates/workloads/live.deployment.yaml index 768c12d1..aae6e76d 100644 --- a/charts/plane-enterprise/templates/workloads/live.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/live.deployment.yaml @@ -93,6 +93,7 @@ spec: {{- end }} envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.aiProvidersSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-live-vars optional: false diff --git a/charts/plane-enterprise/templates/workloads/migrator.job.yaml b/charts/plane-enterprise/templates/workloads/migrator.job.yaml index 4e5d3638..ef386418 100644 --- a/charts/plane-enterprise/templates/workloads/migrator.job.yaml +++ b/charts/plane-enterprise/templates/workloads/migrator.job.yaml @@ -27,6 +27,7 @@ spec: imagePullPolicy: {{ .Values.services.api.pullPolicy | default "Always" }} envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-app-vars optional: false diff --git a/charts/plane-enterprise/templates/workloads/pi-api.deployment.yaml b/charts/plane-enterprise/templates/workloads/pi-api.deployment.yaml index 68d56e03..62328478 100644 --- a/charts/plane-enterprise/templates/workloads/pi-api.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/pi-api.deployment.yaml @@ -79,6 +79,7 @@ spec: exec ./bin/entrypoint-api.sh envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.aiProvidersSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-pi-api-vars optional: false diff --git a/charts/plane-enterprise/templates/workloads/pi-beat.deployment.yaml b/charts/plane-enterprise/templates/workloads/pi-beat.deployment.yaml index e6ee62d8..b0380aad 100644 --- a/charts/plane-enterprise/templates/workloads/pi-beat.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/pi-beat.deployment.yaml @@ -54,6 +54,7 @@ spec: exec ./bin/entrypoint-celery-beat.sh envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.aiProvidersSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-pi-api-vars optional: false diff --git a/charts/plane-enterprise/templates/workloads/pi-migrator.job.yaml b/charts/plane-enterprise/templates/workloads/pi-migrator.job.yaml index d20bf46c..9c280af2 100644 --- a/charts/plane-enterprise/templates/workloads/pi-migrator.job.yaml +++ b/charts/plane-enterprise/templates/workloads/pi-migrator.job.yaml @@ -43,6 +43,7 @@ spec: exec ./bin/entrypoint-migrator.sh envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.aiProvidersSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-pi-api-vars optional: false diff --git a/charts/plane-enterprise/templates/workloads/pi-worker.deployment.yaml b/charts/plane-enterprise/templates/workloads/pi-worker.deployment.yaml index c59f9b17..5dbea10a 100644 --- a/charts/plane-enterprise/templates/workloads/pi-worker.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/pi-worker.deployment.yaml @@ -55,6 +55,7 @@ spec: exec ./bin/entrypoint-celery-worker.sh envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.aiProvidersSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-pi-api-vars optional: false diff --git a/charts/plane-enterprise/templates/workloads/silo.deployment.yaml b/charts/plane-enterprise/templates/workloads/silo.deployment.yaml index cdea5153..eae6d9b8 100644 --- a/charts/plane-enterprise/templates/workloads/silo.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/silo.deployment.yaml @@ -116,6 +116,7 @@ spec: {{- end }} envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-silo-vars optional: false diff --git a/charts/plane-enterprise/templates/workloads/webhook-consumer.deployment.yaml b/charts/plane-enterprise/templates/workloads/webhook-consumer.deployment.yaml index 1f2e6d04..c39fd5d3 100644 --- a/charts/plane-enterprise/templates/workloads/webhook-consumer.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/webhook-consumer.deployment.yaml @@ -52,6 +52,7 @@ spec: - ./bin/docker-entrypoint-webhook-consumer.sh envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-webhook-consumer-vars optional: false diff --git a/charts/plane-enterprise/templates/workloads/worker-importers.deployment.yaml b/charts/plane-enterprise/templates/workloads/worker-importers.deployment.yaml index a6f831fe..8ff85938 100644 --- a/charts/plane-enterprise/templates/workloads/worker-importers.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/worker-importers.deployment.yaml @@ -56,6 +56,7 @@ spec: exec ./bin/docker-entrypoint-worker.sh celery.importer envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-app-vars optional: false diff --git a/charts/plane-enterprise/templates/workloads/worker.deployment.yaml b/charts/plane-enterprise/templates/workloads/worker.deployment.yaml index b8a5eb35..e4bef8e8 100644 --- a/charts/plane-enterprise/templates/workloads/worker.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/worker.deployment.yaml @@ -55,6 +55,7 @@ spec: exec ./bin/docker-entrypoint-worker.sh envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-app-vars optional: false diff --git a/charts/plane-enterprise/values.yaml b/charts/plane-enterprise/values.yaml index c683f89c..a9a20470 100644 --- a/charts/plane-enterprise/values.yaml +++ b/charts/plane-enterprise/values.yaml @@ -599,6 +599,15 @@ serviceAccount: # Extra pod-template labels (Azure Workload Identity requires one). podLabels: {} + # Declares that this ServiceAccount is bound to a cloud identity configured OUT OF + # BAND — an EKS Pod Identity association, a GKE Workload Identity binding. The chart + # cannot detect that on its own: a Pod Identity association is an EKS API object keyed + # on cluster + namespace + service account, and nothing about it appears in the pod + # spec. Setting this changes no rendered output; it only lets `helm upgrade` warn + # about the interactions an attached identity creates, chiefly that credentials now + # reach every container on this ServiceAccount. + cloudIdentity: false + # ============================================================ # Externalized secrets # ============================================================ @@ -638,6 +647,23 @@ external_secrets: portKey: '' dbNameKey: '' + # Read replica. services.postgres.read_replica.remote_url is a DSN with the + # password inside it, which a managed rotation can never update — so this is the + # replica's equivalent of the block above. The API reads + # POSTGRES_READ_REPLICA_{DB,USER,PASSWORD,HOST,PORT} natively (Django takes the + # parts as a config dict; nothing composes a URL), so no application change is + # needed to use it. + # + # secretName defaults to the primary's, since a replica normally accepts the same + # credential. Set it only when the replica has its own user. + readReplica: + secretName: '' + usernameKey: '' # defaults to database.usernameKey + passwordKey: '' # defaults to database.passwordKey + hostKey: '' + portKey: '' + dbNameKey: '' + rabbitmq: # Amazon MQ shape. Set env.rabbitmq_ssl: true and env.rabbitmq_port: '5671' — # Amazon MQ refuses plaintext AMQP. @@ -656,6 +682,27 @@ external_secrets: hostKey: '' portKey: '' + storage: + # Object-storage credentials. + # + # On a cloud, leave this unset and give the pod an identity instead — annotate the + # ServiceAccount (see serviceAccount.annotations) or attach an EKS Pod Identity + # association. With env.aws_access_key / aws_secret_access_key empty the chart omits + # those variables entirely, which is what lets the SDK's credential chain reach the + # pod's role. An empty value would beat the chain and fail every request. + # + # Use this for an S3-compatible backend with no workload identity (Ceph, DO Spaces, + # an external MinIO), or for GCS with a service-account key. Ignored while the + # bundled MinIO is deployed, which supplies its own credentials. + # + # gcsCredentialsJsonKey carries the whole service-account JSON, private key + # included — the single highest-value blob in a GCS deployment, and the reason it + # should never sit in values.yaml. + secretName: '' + accessKeyIdKey: '' # -> AWS_ACCESS_KEY_ID + secretAccessKeyKey: '' # -> AWS_SECRET_ACCESS_KEY + gcsCredentialsJsonKey: '' # -> GCS_CREDENTIALS_JSON + opensearch: # Credentials for a REMOTE OpenSearch. Ignored when # services.opensearch.local_setup is true — the chart owns both sides of the @@ -695,6 +742,48 @@ external_secrets: # others, leaving two services with different values of a key that must match. app_keys_existingSecret: '' + # AI/LLM provider keys ONLY. + # + # pi_api_env_existingSecret below replaces the WHOLE Plane AI Secret, which forces + # you to own PLANE_PI_DATABASE_URL, FOLLOWER_POSTGRES_URI, AMQP_URL, + # CELERY_BROKER_URL and the signing keys as well. Those are per-environment, so it + # cannot express "one provider account shared by every environment". This hook + # carries nothing environment-specific, so a single Secret can be mirrored into as + # many namespaces as share the account — while a customer deployment can just as + # well point it at a per-environment Secret. The chart only takes a name. + # + # Keys read (all optional; an omitted key is simply an absent env var): + # OPENAI_API_KEY CLAUDE_API_KEY GROQ_API_KEY COHERE_API_KEY + # CUSTOM_LLM_API_KEY + # BR_AWS_ACCESS_KEY_ID BR_AWS_SECRET_ACCESS_KEY BR_AWS_SESSION_TOKEN + # Bedrock embeddings. On AWS omit all three and let the pod's identity sign — + # Plane AI falls back to AWS_ACCESS_KEY_ID and then to boto3's own chain. + # AI_OPENAI_API_KEY + # The live server's OpenAI-compatible proxy. The chart has no values key for + # this at all, so this hook is the only way to supply it. + # + # Do NOT put base URLs in this Secret. They are not secret, they belong in + # services.pi.ai_providers.*.base_url, and the live server validates + # AI_OPENAI_BASE_URL as a URL and exits on an empty string — which is exactly what + # a `dataFrom.extract` of a JSON secret containing an empty base URL would deliver. + ai_providers_existingSecret: '' + + # Silo connector app credentials (OAuth client secrets, the GitHub App private key, + # webhook secrets). The silo Secret also carries DATABASE_URL / AMQP_URL / + # REDIS_URL, so silo_env_existingSecret cannot externalize just the connectors. + # + # When set the chart emits none of the connector keys and this Secret owns the whole + # connector surface — client ids and app names included. They are not secret, but a + # half-external, half-rendered connector block is a configuration people get wrong. + # + # Naturally per-environment: a connector's OAuth callback URL is per-host, so the + # app registration differs per environment. + # SLACK_CLIENT_ID/SECRET, SLACK_BASE_URL + # GITHUB_CLIENT_ID/SECRET, GITHUB_APP_NAME, GITHUB_APP_ID, GITHUB_PRIVATE_KEY + # GITLAB_CLIENT_ID/SECRET + # SENTRY_CLIENT_ID/SECRET, SENTRY_BASE_URL, SENTRY_INTEGRATION_SLUG + silo_connectors_existingSecret: '' + # DNS-01 API token for the cert-manager Issuer (ssl.issuer cloudflare/digitalocean). # Must contain the key `api-token`. ssl_token_existingSecret: '' @@ -773,6 +862,13 @@ env: pgdb_host: '' pgdb_port: '5432' + # Read-replica endpoint, used with external_secrets.database.readReplica (the + # credentials come from that Secret). The database name defaults to pgdb_name, since + # a replica serves the same database under a different endpoint. + pgdb_read_replica_host: '' + pgdb_read_replica_port: '5432' + pgdb_read_replica_name: '' + # RabbitMQ endpoint used with external_secrets.rabbitmq (credentials come from # that Secret). Ignored when services.rabbitmq.local_setup is true. rabbitmq_host: '' diff --git a/hack/assert-secrets.py b/hack/assert-secrets.py new file mode 100755 index 00000000..367a911e --- /dev/null +++ b/hack/assert-secrets.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Assertions over a rendered Helm release, for the secret-externalization work. + +Two of these encode the properties the whole exercise exists to guarantee: + + --no-plaintext-secrets nothing that looks like a live credential is in the render + --no-dsn no connection string carries an embedded password + +The second is the one that proves rotation is actually possible. A DSN in a Secret is +a rotation dead-end: when a managed database rotates the password, nothing can track +it, because the password is baked into a string somebody has to recompose. Discrete +credential parts are what make a verbatim mirror of a rotation secret work. + +Usage: + assert-secrets.py rendered.yaml --no-plaintext-secrets --no-dsn + assert-secrets.py rendered.yaml --absent AWS_ACCESS_KEY_ID,AWS_REGION + assert-secrets.py rendered.yaml --no-key-in-secrets OPENAI_API_KEY,CLAUDE_API_KEY + assert-secrets.py rendered.yaml --order 'Deployment/t-pi-api-wl/t-pi-api:plane-ai=plane-pi' + +Exits non-zero and prints every violation. +""" + +import argparse +import re +import sys + +import yaml + +# Credential shapes worth failing a build over. Deliberately provider-prefix based: +# matching on entropy produces false positives on image digests and checksums. +SECRET_PATTERNS = { + "OpenAI key": re.compile(r"sk-(proj-|svcacct-)?[A-Za-z0-9_-]{20,}"), + "Anthropic key": re.compile(r"sk-ant-[A-Za-z0-9_-]{20,}"), + "Groq key": re.compile(r"gsk_[A-Za-z0-9]{20,}"), + "Docker Hub token": re.compile(r"dckr_(oat|pat)_[A-Za-z0-9_-]{10,}"), + "AWS access key id": re.compile(r"\b(AKIA|ASIA)[0-9A-Z]{16}\b"), + "GitLab OAuth token": re.compile(r"gloas-[A-Za-z0-9_-]{20,}"), + "Google OAuth secret": re.compile(r"GOCSPX-[A-Za-z0-9_-]{10,}"), + "private key block": re.compile(r"BEGIN [A-Z ]*PRIVATE KEY"), +} + +# A connection string whose authority section carries a password. +DSN_WITH_PASSWORD = re.compile(r"\b(postgresql|postgres|amqps?|rediss?|mongodb)://[^\s:/@]+:[^\s@]+@") + + +def load(path): + with open(path) as fh: + return [d for d in yaml.safe_load_all(fh) if isinstance(d, dict)] + + +def iter_values(docs, kinds=("Secret", "ConfigMap")): + """Yield (kind, name, key, value) for every data entry in the render.""" + for doc in docs: + kind = doc.get("kind") + if kind not in kinds: + continue + name = (doc.get("metadata") or {}).get("name", "") + for field in ("stringData", "data"): + for k, v in (doc.get(field) or {}).items(): + if isinstance(v, str): + yield kind, name, k, v + + +def iter_container_env(docs): + """Yield (workload, container, env_entry) for explicit env lists.""" + for doc in docs: + if doc.get("kind") not in ("Deployment", "StatefulSet", "DaemonSet", "Job"): + continue + name = (doc.get("metadata") or {}).get("name", "") + pod = (((doc.get("spec") or {}).get("template") or {}).get("spec") or {}) + for c in (pod.get("initContainers") or []) + (pod.get("containers") or []): + for e in c.get("env") or []: + yield name, c.get("name", "?"), e + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("rendered") + ap.add_argument("--no-plaintext-secrets", action="store_true") + ap.add_argument("--no-dsn", action="store_true") + ap.add_argument("--absent", default="", help="env names that must not appear anywhere") + ap.add_argument("--no-key-in-secrets", default="", help="keys no rendered Secret may contain, even empty") + ap.add_argument( + "--order", + action="append", + default=[], + metavar="WORKLOAD/CONTAINER:EARLIER=LATER", + help="assert one envFrom secretRef precedes another", + ) + args = ap.parse_args() + + docs = load(args.rendered) + problems = [] + + if args.no_plaintext_secrets: + for kind, name, key, value in iter_values(docs): + for label, pat in SECRET_PATTERNS.items(): + if pat.search(value): + problems.append(f"plaintext {label} in {kind}/{name} key {key}") + for wl, cname, entry in iter_container_env(docs): + v = entry.get("value") + if isinstance(v, str): + for label, pat in SECRET_PATTERNS.items(): + if pat.search(v): + problems.append(f"plaintext {label} in {wl}/{cname} env {entry.get('name')}") + + if args.no_dsn: + for kind, name, key, value in iter_values(docs): + if DSN_WITH_PASSWORD.search(value): + problems.append( + f"connection string with an embedded password in {kind}/{name} key {key} " + "— a rotated credential can never reach this; use the discrete credential parts" + ) + + if args.absent: + wanted = {k for k in args.absent.split(",") if k} + for kind, name, key, _ in iter_values(docs): + if key in wanted: + problems.append(f"{key} must be absent but is set in {kind}/{name}") + for wl, cname, entry in iter_container_env(docs): + if entry.get("name") in wanted: + problems.append(f"{entry['name']} must be absent but is set on {wl}/{cname}") + + if args.no_key_in_secrets: + wanted = {k for k in args.no_key_in_secrets.split(",") if k} + for kind, name, key, value in iter_values(docs, kinds=("Secret",)): + if key in wanted: + shown = "empty string" if value == "" else "a value" + problems.append( + f"Secret/{name} still renders {key} ({shown}) — envFrom resolves " + "later-source-wins, so this overwrites the external Secret" + ) + + for spec in args.order: + target, _, pair = spec.partition(":") + earlier, _, later = pair.partition("=") + found = False + for doc in docs: + if doc.get("kind") not in ("Deployment", "StatefulSet", "Job"): + continue + name = (doc.get("metadata") or {}).get("name", "") + pod = (((doc.get("spec") or {}).get("template") or {}).get("spec") or {}) + for c in pod.get("containers") or []: + if f"{doc['kind']}/{name}/{c.get('name')}" != target: + continue + found = True + names = [s["secretRef"]["name"] for s in (c.get("envFrom") or []) if "secretRef" in s] + if earlier not in names or later not in names: + problems.append(f"{target}: expected both {earlier} and {later} in envFrom, got {names}") + elif names.index(earlier) > names.index(later): + problems.append(f"{target}: {earlier} must precede {later}, got {names}") + if not found: + problems.append(f"{target}: container not found in render") + + if problems: + print(f"FAIL — {len(problems)} violation(s):", file=sys.stderr) + for p in problems: + print(f" - {p}", file=sys.stderr) + return 1 + print("PASS") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/hack/resolve-env.py b/hack/resolve-env.py new file mode 100755 index 00000000..4d440d94 --- /dev/null +++ b/hack/resolve-env.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Resolve the effective environment of every container in a rendered Helm release. + +Reading a chart diff tells you what the templates changed. It does not tell you what +the *pods* end up with, which is the only thing that matters when secrets move between +Secrets: `envFrom` resolves in list order with later sources winning, and explicit +`env` entries beat all of them. A key that moves from one Secret to another, or that a +suppression forgot to remove, shows up here and nowhere else. + +Emits sorted JSON: {"kind/name/container": {"ENV_NAME": ""}} where the +resolution is the literal value, or a marker: + + valueFrom.secretKeyRef + valueFrom.configMapKeyRef + valueFrom.fieldRef + envFrom referenced a Secret this release never renders + (expected for operator-supplied external Secrets) + +Usage: + resolve-env.py rendered.yaml [--external NAME=KEY1,KEY2 ...] > effective.json + +--external declares a Secret that lives outside the chart along with the keys it +carries, so its contribution can be modelled instead of reported as missing. +""" + +import argparse +import json +import re +import sys + +import yaml + +WORKLOAD_KINDS = {"Deployment", "StatefulSet", "DaemonSet", "Job", "CronJob"} +# Job names carry a render timestamp; strip it so runs are comparable. +TIMESTAMP_SUFFIX = re.compile(r"-\d{8}-\d{6}$") + + +def load_docs(path): + with open(path) as fh: + return [d for d in yaml.safe_load_all(fh) if isinstance(d, dict)] + + +def collect_sources(docs, external): + """Map Secret/ConfigMap name -> {key: value}. External Secrets get sentinel values.""" + secrets, configmaps = dict(external), {} + for doc in docs: + kind = doc.get("kind") + name = (doc.get("metadata") or {}).get("name", "") + if kind == "Secret": + merged = dict(doc.get("stringData") or {}) + # data is base64; the value is irrelevant for comparison, the key is not. + for k in (doc.get("data") or {}): + merged.setdefault(k, "") + secrets[name] = merged + elif kind == "ConfigMap": + configmaps[name] = dict(doc.get("data") or {}) + return secrets, configmaps + + +def pod_templates(doc): + """Yield (container_dict, is_init) for every container in a workload.""" + kind = doc.get("kind") + spec = doc.get("spec") or {} + if kind == "CronJob": + spec = ((spec.get("jobTemplate") or {}).get("spec") or {}) + pod = ((spec.get("template") or {}).get("spec") or {}) + for c in pod.get("initContainers") or []: + yield c, True + for c in pod.get("containers") or []: + yield c, False + + +def resolve_container(container, secrets, configmaps): + env = {} + # envFrom first, in order: later sources overwrite earlier ones. + for source in container.get("envFrom") or []: + if "secretRef" in source: + name = source["secretRef"].get("name", "") + if name in secrets: + env.update(secrets[name]) + else: + env[f""] = "" + elif "configMapRef" in source: + name = source["configMapRef"].get("name", "") + if name in configmaps: + env.update(configmaps[name]) + else: + env[f""] = "" + # Explicit env always wins over envFrom. + for entry in container.get("env") or []: + name = entry.get("name") + if not name: + continue + if "value" in entry: + env[name] = entry["value"] + continue + vf = entry.get("valueFrom") or {} + if "secretKeyRef" in vf: + ref = vf["secretKeyRef"] + env[name] = f"" + elif "configMapKeyRef" in vf: + ref = vf["configMapKeyRef"] + env[name] = f"" + elif "fieldRef" in vf: + env[name] = f"" + else: + env[name] = "" + return env + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("rendered") + ap.add_argument( + "--external", + action="append", + default=[], + metavar="NAME=KEY1,KEY2", + help="model an operator-supplied Secret and the keys it carries", + ) + args = ap.parse_args() + + external = {} + for spec in args.external: + name, _, keys = spec.partition("=") + external[name] = {k: f"" for k in keys.split(",") if k} + + docs = load_docs(args.rendered) + secrets, configmaps = collect_sources(docs, external) + + out = {} + for doc in docs: + if doc.get("kind") not in WORKLOAD_KINDS: + continue + name = TIMESTAMP_SUFFIX.sub("", (doc.get("metadata") or {}).get("name", "")) + for container, is_init in pod_templates(doc): + cname = container.get("name", "?") + key = f"{doc['kind']}/{name}/{'init:' if is_init else ''}{cname}" + out[key] = dict(sorted(resolve_container(container, secrets, configmaps).items())) + + json.dump(dict(sorted(out.items())), sys.stdout, indent=2) + sys.stdout.write("\n") + + +if __name__ == "__main__": + main()