Skip to content
Open
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.3.0
version: 3.6.4
appVersion: "3.1.1"

home: https://plane.so/
Expand Down
285 changes: 285 additions & 0 deletions charts/plane-enterprise/README.md

Large diffs are not rendered by default.

73 changes: 73 additions & 0 deletions charts/plane-enterprise/examples/external-secrets/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# External Secrets Operator examples

Ready-to-adapt manifests for feeding the `plane-enterprise` chart from a cloud secret store.

The chart consumes plain Kubernetes Secrets, so nothing here is chart-specific plumbing — it is ordinary External Secrets Operator configuration. Pick the file for your provider:

| File | Provider |
| --- | --- |
| [`aws-secrets-manager.yaml`](aws-secrets-manager.yaml) | AWS Secrets Manager (RDS / Amazon MQ / ElastiCache) |
| [`gcp-secret-manager.yaml`](gcp-secret-manager.yaml) | GCP Secret Manager (CloudSQL / Memorystore) |
| [`azure-key-vault.yaml`](azure-key-vault.yaml) | Azure Key Vault (Flexible Server / Cache for Redis) |
| [`rotation-runbook.md`](rotation-runbook.md) | How to rotate without dropping requests |

## The idea in one paragraph

A managed-rotation secret from RDS or CloudSQL contains only `{"username": "...", "password": "..."}`. Mirror it into the cluster **verbatim** with a plain `dataFrom.extract` — no `rewrite`, no `template` — and tell the chart which keys hold the username and password. The chart wires those keys into the pods as `POSTGRES_USER` / `POSTGRES_PASSWORD` and supplies the non-secret endpoint from `values.yaml`. The application composes its own connection URL from the parts, so **a rotation never requires recomposing a URL and there is only one secret to watch**.

```yaml
# values.yaml
external_secrets:
database:
secretName: plane-rds # the mirrored secret
usernameKey: username # keys as they appear inside it
passwordKey: password
env:
pgdb_host: plane.abc123.eu-west-1.rds.amazonaws.com
pgdb_name: plane
```

## Prerequisites

```bash
# External Secrets Operator
helm repo add external-secrets https://charts.external-secrets.io
helm install external-secrets external-secrets/external-secrets \
-n external-secrets --create-namespace

# Stakater Reloader — restarts pods when a synced Secret changes.
# Without this a rotated credential never reaches a running pod.
helm repo add stakater https://stakater.github.io/stakater-charts
helm install reloader stakater/reloader -n reloader --create-namespace
```

Then in the chart's values:

```yaml
reloader:
enabled: true
```

## Choosing refreshInterval

`refreshInterval` bounds how long a rotated credential stays unnoticed, and each interval costs one API call per `ExternalSecret` per provider.

- **`1h`** — the sensible default for secrets you rotate on a schedule and where you use the two-valid-credentials pattern from the runbook, so the window is harmless.
- **`1m`–`5m`** — when a single credential is swapped in place and the failure window must be short.

If your provider can push on rotation (an AWS Lambda rotation hook that annotates the `ExternalSecret`, or ESO's `PushSecret`/webhook paths), prefer that over polling frequently.

## Composed DSNs are only for older app versions

From **planeVersion v3.2.0** every service — including silo, live and Plane AI — reads
discrete credential parts, so the `template:` blocks in the provider examples that build
a `DATABASE_URL`/`REDIS_URL`/`AMQP_URL` are no longer needed. Point
`external_secrets.database` / `rabbitmq` / `redis` at the mirrored secret and let each
app compose its own URL.

Keep using the templated DSN sections (`plane-silo-env` and friends) only when pinned
below v3.2.0, or when a service genuinely needs different credentials from the primary.

## What must never rotate

Do not put `SECRET_KEY`, `AES_SECRET_KEY` or `AES_SALT` in a secret with a rotation policy. `SECRET_KEY` derives the Fernet key encrypting the instance-configuration rows, and the AES pair protects stored OAuth/MCP tokens; changing either makes existing ciphertext undecryptable, silently. Keep them in a separate, static secret — that is what `app_keys_existingSecret` is for.
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
# External Secrets Operator -> AWS Secrets Manager, for the plane-enterprise chart.
#
# Replace: NAMESPACE, REGION, ACCOUNT_ID, the secret names, and the release name in
# the ServiceAccount reference.
#
# Authentication uses EKS Pod Identity or IRSA on the External Secrets Operator's own
# ServiceAccount — no static keys. The IAM role needs
# secretsmanager:GetSecretValue + DescribeSecret on the secrets referenced below.
---
apiVersion: external-secrets.io/v1
kind: SecretStore
metadata:
name: aws-secrets-manager
namespace: NAMESPACE
spec:
provider:
aws:
service: SecretsManager
region: REGION
auth:
jwt:
serviceAccountRef:
# ESO's ServiceAccount, annotated with eks.amazonaws.com/role-arn
name: external-secrets
namespace: external-secrets
---
# 1. DATABASE — mirror the RDS-managed secret verbatim.
#
# An RDS managed-rotation secret contains only {"username", "password"}; a secret
# created by RDS for a non-master user also carries host/port/dbname. Either way,
# dataFrom.extract copies whatever keys exist straight through: no rewrite, no
# template, nothing to recompose when the password rotates.
#
# Chart side:
# external_secrets.database.secretName: plane-rds
# external_secrets.database.usernameKey: username
# external_secrets.database.passwordKey: password
# env.pgdb_host / pgdb_port / pgdb_name <- endpoint (not secret)
# If your secret carries the endpoint too, set hostKey/portKey/dbNameKey instead.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: plane-rds
namespace: NAMESPACE
spec:
refreshInterval: 1h
secretStoreRef:
name: aws-secrets-manager
kind: SecretStore
target:
name: plane-rds
creationPolicy: Owner
dataFrom:
- extract:
# The secret RDS created with the instance, e.g. rds!db-1234abcd-...
key: rds!db-REPLACE-ME
---
# 2. RABBITMQ (Amazon MQ) — same pattern.
#
# Chart side:
# external_secrets.rabbitmq.secretName: plane-amazonmq
# env.rabbitmq_host
# env.rabbitmq_port: '5671'
# env.rabbitmq_ssl: true <- REQUIRED: Amazon MQ refuses plaintext AMQP, and the
# discrete-parts path has no URL scheme to imply TLS.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: plane-amazonmq
namespace: NAMESPACE
spec:
refreshInterval: 1h
secretStoreRef:
name: aws-secrets-manager
kind: SecretStore
target:
name: plane-amazonmq
creationPolicy: Owner
dataFrom:
- extract:
key: plane/amazonmq
---
# 3. REDIS (ElastiCache auth token).
#
# Chart side (needs planeVersion v3.1.0+):
# external_secrets.redis.secretName: plane-elasticache
# external_secrets.redis.passwordKey: password
# env.redis_host, env.redis_ssl: true
#
# ElastiCache supports two simultaneously valid auth tokens — use that for
# zero-window rotation (see rotation-runbook.md).
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: plane-elasticache
namespace: NAMESPACE
spec:
refreshInterval: 1h
secretStoreRef:
name: aws-secrets-manager
kind: SecretStore
target:
name: plane-elasticache
creationPolicy: Owner
dataFrom:
- extract:
key: plane/elasticache
---
# 4. OPENSEARCH — only needed when the domain uses basic auth.
#
# On AWS the better option is usually no secret at all: leave
# env.opensearch_remote_username / _password empty and external_secrets.opensearch
# unset, and the API authenticates to the domain with SigV4 using the pod's IAM role
# (serviceAccount.annotations). Use this only for a domain with the internal user
# database enabled.
#
# Chart side:
# external_secrets.opensearch.secretName: plane-opensearch
# env.opensearch_remote_url: https://search-plane.REGION.es.amazonaws.com
#
# Applies to the API workloads and the Plane AI workloads, which query the same domain.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: plane-opensearch
namespace: NAMESPACE
spec:
refreshInterval: 1h
secretStoreRef:
name: aws-secrets-manager
kind: SecretStore
target:
name: plane-opensearch
creationPolicy: Owner
dataFrom:
- extract:
key: plane/opensearch # {"username": "...", "password": "..."}
---
# 5. SHARED SIGNING KEYS — external_secrets.app_keys_existingSecret: plane-app-keys
#
# NEVER attach a rotation policy to this secret: SECRET_KEY and AES_SECRET_KEY/AES_SALT
# are key-encryption keys. Rotating them makes existing ciphertext undecryptable, and
# the failure is silent.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: plane-app-keys
namespace: NAMESPACE
spec:
refreshInterval: 1h
secretStoreRef:
name: aws-secrets-manager
kind: SecretStore
target:
name: plane-app-keys
creationPolicy: Owner
data:
- secretKey: SECRET_KEY
remoteRef: { key: plane/app-keys, property: SECRET_KEY }
- secretKey: AES_SECRET_KEY
remoteRef: { key: plane/app-keys, property: AES_SECRET_KEY }
- secretKey: AES_SALT
remoteRef: { key: plane/app-keys, property: AES_SALT }
- secretKey: LIVE_SERVER_SECRET_KEY
remoteRef: { key: plane/app-keys, property: LIVE_SERVER_SECRET_KEY }
- secretKey: PI_INTERNAL_SECRET
remoteRef: { key: plane/app-keys, property: PI_INTERNAL_SECRET }
- secretKey: SILO_HMAC_SECRET_KEY
remoteRef: { key: plane/app-keys, property: SILO_HMAC_SECRET_KEY }
- secretKey: CURSOR_WEBHOOK_SECRET
remoteRef: { key: plane/app-keys, property: CURSOR_WEBHOOK_SECRET }
---
# 6. SILO — the one place a composed DSN is still needed.
#
# Silo, live and Plane AI read a connection URL rather than discrete parts, so here ESO
# builds the URL from the mirrored RDS secret with a template. Note urlEncode on the
# password: a generated password containing @ : / # would otherwise break the URL.
#
# Chart side: external_secrets.silo_env_existingSecret: plane-silo-env
# This Secret replaces the chart's silo Secret entirely, so it must also carry the
# connector OAuth secrets you use (GITHUB_CLIENT_SECRET, SLACK_CLIENT_SECRET, ...).
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: plane-silo-env
namespace: NAMESPACE
spec:
refreshInterval: 1h
secretStoreRef:
name: aws-secrets-manager
kind: SecretStore
target:
name: plane-silo-env
creationPolicy: Owner
template:
engine: v2
data:
DATABASE_URL: >-
postgresql://{{ .username }}:{{ .password | urlEncode }}@plane.abc123.REGION.rds.amazonaws.com:5432/plane?sslmode=require
AMQP_URL: >-
amqps://{{ .mq_username }}:{{ .mq_password | urlEncode }}@b-1.plane.mq.REGION.amazonaws.com:5671/
REDIS_URL: >-
rediss://:{{ .redis_password | urlEncode }}@plane.abc.cache.amazonaws.com:6379
GITHUB_CLIENT_SECRET: '{{ .github_client_secret }}'
data:
- secretKey: username
remoteRef: { key: rds!db-REPLACE-ME, property: username }
- secretKey: password
remoteRef: { key: rds!db-REPLACE-ME, property: password }
- secretKey: mq_username
remoteRef: { key: plane/amazonmq, property: username }
- secretKey: mq_password
remoteRef: { key: plane/amazonmq, property: password }
- secretKey: redis_password
remoteRef: { key: plane/elasticache, property: password }
- secretKey: github_client_secret
remoteRef: { key: plane/connectors, property: github_client_secret }
Loading