Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion charts/plane-enterprise/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
135 changes: 135 additions & 0 deletions charts/plane-enterprise/questions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <release>-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"
29 changes: 28 additions & 1 deletion charts/plane-enterprise/templates/NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}

────────────────────────────────────────────────────────────────────────────────
Expand Down
131 changes: 131 additions & 0 deletions charts/plane-enterprise/templates/_helpers.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 -}}

{{/*
Expand Down Expand Up @@ -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 -}}

{{/*
Expand Down Expand Up @@ -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 -}}

{{/*
Expand Down Expand Up @@ -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 -}}
18 changes: 15 additions & 3 deletions charts/plane-enterprise/templates/config-secrets/app-env.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 }}

Expand Down
Loading