Skip to content

Commit 9be44b2

Browse files
feat(plane-enterprise): consume credentials from external Secrets, with keyless cloud identity (3.6.2)
Rebased onto master, which has since gained opt-in OpenTelemetry (#248) and the v3.1.1 release. The two touch the same regions of every workload — master adds OTel env, this branch adds credential env — so the resolution keeps both: one `env:` per container, one guard carrying both conditions, and OTel's envFrom entry back in the envFrom position where it belongs. Verified rather than eyeballed. Every workload template parses; the default, all-services, OTel and externalized-credentials renders all succeed; and both features coexist — the api container comes out with OTEL_SERVICE_NAME=api, POSTGRES_PASSWORD from the operator's Secret, and otel-vars alongside the credential Secrets in envFrom. Resolved environments are identical to pre-rebase for all 22 containers except APP_VERSION, which moves 3.1.0 -> 3.1.1 because that is master's release. hack/assert-secrets.py --no-dsn still passes. What this branch does, in the order it was built: - Credentials come from Secrets the operator owns, as discrete parts rather than a DSN, so a rotated password can actually reach the app. Postgres, RabbitMQ, Redis, OpenSearch and storage, plus whole-Secret and key-group hooks for the rest. - The same contract extended to silo, live and Plane AI, which each read a different subset. - live's AMQP_URL guarded so the RabbitMQ mirror is not silently inert for live. - live-exporter's ServiceAccount (it was the only workload hardcoding the release-scoped name), and a NOTES warning that the MQ mirror does not reach live's export queue. - Keyless S3: the chart omits AWS_ACCESS_KEY_ID rather than rendering it empty, because an empty credential is found first in boto3's chain and shadows the pod's identity. That Secret now renders base64 `data`, so a key the chart stops rendering is a deletion Helm can express — without which switching an existing release from MinIO to S3 fails as InvalidClientTokenId while the configuration looks correct. - Bedrock credentials, keyed (AWS_BEARER_TOKEN_BEDROCK) or keyless via the pod's identity, with the profile ARN and region outside the provider-key suppression group because they are identifiers. The chart version stays at 3.6.2, above master's 3.3.0; appVersion takes master's 3.1.1. Rebasing replayed as a single commit: master's OTel change collides with all eight of the original commits in the same few regions, and resolving the same conflict eight times invites exactly the silent mangling this diff is meant to avoid — two of the intermediate resolutions had already produced duplicate `env:` keys before being caught. The original commit messages are preserved in the PR history.
1 parent 917cf83 commit 9be44b2

51 files changed

Lines changed: 3091 additions & 167 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

charts/plane-enterprise/Chart.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ description: Meet Plane. An Enterprise software development tool to manage issue
55

66
type: application
77

8-
version: 3.3.0
8+
version: 3.6.2
99
appVersion: "3.1.1"
1010

1111
home: https://plane.so/

charts/plane-enterprise/README.md

Lines changed: 285 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# External Secrets Operator examples
2+
3+
Ready-to-adapt manifests for feeding the `plane-enterprise` chart from a cloud secret store.
4+
5+
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:
6+
7+
| File | Provider |
8+
| --- | --- |
9+
| [`aws-secrets-manager.yaml`](aws-secrets-manager.yaml) | AWS Secrets Manager (RDS / Amazon MQ / ElastiCache) |
10+
| [`gcp-secret-manager.yaml`](gcp-secret-manager.yaml) | GCP Secret Manager (CloudSQL / Memorystore) |
11+
| [`azure-key-vault.yaml`](azure-key-vault.yaml) | Azure Key Vault (Flexible Server / Cache for Redis) |
12+
| [`rotation-runbook.md`](rotation-runbook.md) | How to rotate without dropping requests |
13+
14+
## The idea in one paragraph
15+
16+
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**.
17+
18+
```yaml
19+
# values.yaml
20+
external_secrets:
21+
database:
22+
secretName: plane-rds # the mirrored secret
23+
usernameKey: username # keys as they appear inside it
24+
passwordKey: password
25+
env:
26+
pgdb_host: plane.abc123.eu-west-1.rds.amazonaws.com
27+
pgdb_name: plane
28+
```
29+
30+
## Prerequisites
31+
32+
```bash
33+
# External Secrets Operator
34+
helm repo add external-secrets https://charts.external-secrets.io
35+
helm install external-secrets external-secrets/external-secrets \
36+
-n external-secrets --create-namespace
37+
38+
# Stakater Reloader — restarts pods when a synced Secret changes.
39+
# Without this a rotated credential never reaches a running pod.
40+
helm repo add stakater https://stakater.github.io/stakater-charts
41+
helm install reloader stakater/reloader -n reloader --create-namespace
42+
```
43+
44+
Then in the chart's values:
45+
46+
```yaml
47+
reloader:
48+
enabled: true
49+
```
50+
51+
## Choosing refreshInterval
52+
53+
`refreshInterval` bounds how long a rotated credential stays unnoticed, and each interval costs one API call per `ExternalSecret` per provider.
54+
55+
- **`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.
56+
- **`1m`–`5m`** — when a single credential is swapped in place and the failure window must be short.
57+
58+
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.
59+
60+
## Composed DSNs are only for older app versions
61+
62+
From **planeVersion v3.2.0** every service — including silo, live and Plane AI — reads
63+
discrete credential parts, so the `template:` blocks in the provider examples that build
64+
a `DATABASE_URL`/`REDIS_URL`/`AMQP_URL` are no longer needed. Point
65+
`external_secrets.database` / `rabbitmq` / `redis` at the mirrored secret and let each
66+
app compose its own URL.
67+
68+
Keep using the templated DSN sections (`plane-silo-env` and friends) only when pinned
69+
below v3.2.0, or when a service genuinely needs different credentials from the primary.
70+
71+
## What must never rotate
72+
73+
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.
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
# External Secrets Operator -> AWS Secrets Manager, for the plane-enterprise chart.
2+
#
3+
# Replace: NAMESPACE, REGION, ACCOUNT_ID, the secret names, and the release name in
4+
# the ServiceAccount reference.
5+
#
6+
# Authentication uses EKS Pod Identity or IRSA on the External Secrets Operator's own
7+
# ServiceAccount — no static keys. The IAM role needs
8+
# secretsmanager:GetSecretValue + DescribeSecret on the secrets referenced below.
9+
---
10+
apiVersion: external-secrets.io/v1
11+
kind: SecretStore
12+
metadata:
13+
name: aws-secrets-manager
14+
namespace: NAMESPACE
15+
spec:
16+
provider:
17+
aws:
18+
service: SecretsManager
19+
region: REGION
20+
auth:
21+
jwt:
22+
serviceAccountRef:
23+
# ESO's ServiceAccount, annotated with eks.amazonaws.com/role-arn
24+
name: external-secrets
25+
namespace: external-secrets
26+
---
27+
# 1. DATABASE — mirror the RDS-managed secret verbatim.
28+
#
29+
# An RDS managed-rotation secret contains only {"username", "password"}; a secret
30+
# created by RDS for a non-master user also carries host/port/dbname. Either way,
31+
# dataFrom.extract copies whatever keys exist straight through: no rewrite, no
32+
# template, nothing to recompose when the password rotates.
33+
#
34+
# Chart side:
35+
# external_secrets.database.secretName: plane-rds
36+
# external_secrets.database.usernameKey: username
37+
# external_secrets.database.passwordKey: password
38+
# env.pgdb_host / pgdb_port / pgdb_name <- endpoint (not secret)
39+
# If your secret carries the endpoint too, set hostKey/portKey/dbNameKey instead.
40+
apiVersion: external-secrets.io/v1
41+
kind: ExternalSecret
42+
metadata:
43+
name: plane-rds
44+
namespace: NAMESPACE
45+
spec:
46+
refreshInterval: 1h
47+
secretStoreRef:
48+
name: aws-secrets-manager
49+
kind: SecretStore
50+
target:
51+
name: plane-rds
52+
creationPolicy: Owner
53+
dataFrom:
54+
- extract:
55+
# The secret RDS created with the instance, e.g. rds!db-1234abcd-...
56+
key: rds!db-REPLACE-ME
57+
---
58+
# 2. RABBITMQ (Amazon MQ) — same pattern.
59+
#
60+
# Chart side:
61+
# external_secrets.rabbitmq.secretName: plane-amazonmq
62+
# env.rabbitmq_host
63+
# env.rabbitmq_port: '5671'
64+
# env.rabbitmq_ssl: true <- REQUIRED: Amazon MQ refuses plaintext AMQP, and the
65+
# discrete-parts path has no URL scheme to imply TLS.
66+
apiVersion: external-secrets.io/v1
67+
kind: ExternalSecret
68+
metadata:
69+
name: plane-amazonmq
70+
namespace: NAMESPACE
71+
spec:
72+
refreshInterval: 1h
73+
secretStoreRef:
74+
name: aws-secrets-manager
75+
kind: SecretStore
76+
target:
77+
name: plane-amazonmq
78+
creationPolicy: Owner
79+
dataFrom:
80+
- extract:
81+
key: plane/amazonmq
82+
---
83+
# 3. REDIS (ElastiCache auth token).
84+
#
85+
# Chart side (needs planeVersion v3.1.0+):
86+
# external_secrets.redis.secretName: plane-elasticache
87+
# external_secrets.redis.passwordKey: password
88+
# env.redis_host, env.redis_ssl: true
89+
#
90+
# ElastiCache supports two simultaneously valid auth tokens — use that for
91+
# zero-window rotation (see rotation-runbook.md).
92+
apiVersion: external-secrets.io/v1
93+
kind: ExternalSecret
94+
metadata:
95+
name: plane-elasticache
96+
namespace: NAMESPACE
97+
spec:
98+
refreshInterval: 1h
99+
secretStoreRef:
100+
name: aws-secrets-manager
101+
kind: SecretStore
102+
target:
103+
name: plane-elasticache
104+
creationPolicy: Owner
105+
dataFrom:
106+
- extract:
107+
key: plane/elasticache
108+
---
109+
# 4. OPENSEARCH — only needed when the domain uses basic auth.
110+
#
111+
# On AWS the better option is usually no secret at all: leave
112+
# env.opensearch_remote_username / _password empty and external_secrets.opensearch
113+
# unset, and the API authenticates to the domain with SigV4 using the pod's IAM role
114+
# (serviceAccount.annotations). Use this only for a domain with the internal user
115+
# database enabled.
116+
#
117+
# Chart side:
118+
# external_secrets.opensearch.secretName: plane-opensearch
119+
# env.opensearch_remote_url: https://search-plane.REGION.es.amazonaws.com
120+
#
121+
# Applies to the API workloads and the Plane AI workloads, which query the same domain.
122+
apiVersion: external-secrets.io/v1
123+
kind: ExternalSecret
124+
metadata:
125+
name: plane-opensearch
126+
namespace: NAMESPACE
127+
spec:
128+
refreshInterval: 1h
129+
secretStoreRef:
130+
name: aws-secrets-manager
131+
kind: SecretStore
132+
target:
133+
name: plane-opensearch
134+
creationPolicy: Owner
135+
dataFrom:
136+
- extract:
137+
key: plane/opensearch # {"username": "...", "password": "..."}
138+
---
139+
# 5. SHARED SIGNING KEYS — external_secrets.app_keys_existingSecret: plane-app-keys
140+
#
141+
# NEVER attach a rotation policy to this secret: SECRET_KEY and AES_SECRET_KEY/AES_SALT
142+
# are key-encryption keys. Rotating them makes existing ciphertext undecryptable, and
143+
# the failure is silent.
144+
apiVersion: external-secrets.io/v1
145+
kind: ExternalSecret
146+
metadata:
147+
name: plane-app-keys
148+
namespace: NAMESPACE
149+
spec:
150+
refreshInterval: 1h
151+
secretStoreRef:
152+
name: aws-secrets-manager
153+
kind: SecretStore
154+
target:
155+
name: plane-app-keys
156+
creationPolicy: Owner
157+
data:
158+
- secretKey: SECRET_KEY
159+
remoteRef: { key: plane/app-keys, property: SECRET_KEY }
160+
- secretKey: AES_SECRET_KEY
161+
remoteRef: { key: plane/app-keys, property: AES_SECRET_KEY }
162+
- secretKey: AES_SALT
163+
remoteRef: { key: plane/app-keys, property: AES_SALT }
164+
- secretKey: LIVE_SERVER_SECRET_KEY
165+
remoteRef: { key: plane/app-keys, property: LIVE_SERVER_SECRET_KEY }
166+
- secretKey: PI_INTERNAL_SECRET
167+
remoteRef: { key: plane/app-keys, property: PI_INTERNAL_SECRET }
168+
- secretKey: SILO_HMAC_SECRET_KEY
169+
remoteRef: { key: plane/app-keys, property: SILO_HMAC_SECRET_KEY }
170+
- secretKey: CURSOR_WEBHOOK_SECRET
171+
remoteRef: { key: plane/app-keys, property: CURSOR_WEBHOOK_SECRET }
172+
---
173+
# 6. SILO — the one place a composed DSN is still needed.
174+
#
175+
# Silo, live and Plane AI read a connection URL rather than discrete parts, so here ESO
176+
# builds the URL from the mirrored RDS secret with a template. Note urlEncode on the
177+
# password: a generated password containing @ : / # would otherwise break the URL.
178+
#
179+
# Chart side: external_secrets.silo_env_existingSecret: plane-silo-env
180+
# This Secret replaces the chart's silo Secret entirely, so it must also carry the
181+
# connector OAuth secrets you use (GITHUB_CLIENT_SECRET, SLACK_CLIENT_SECRET, ...).
182+
apiVersion: external-secrets.io/v1
183+
kind: ExternalSecret
184+
metadata:
185+
name: plane-silo-env
186+
namespace: NAMESPACE
187+
spec:
188+
refreshInterval: 1h
189+
secretStoreRef:
190+
name: aws-secrets-manager
191+
kind: SecretStore
192+
target:
193+
name: plane-silo-env
194+
creationPolicy: Owner
195+
template:
196+
engine: v2
197+
data:
198+
DATABASE_URL: >-
199+
postgresql://{{ .username }}:{{ .password | urlEncode }}@plane.abc123.REGION.rds.amazonaws.com:5432/plane?sslmode=require
200+
AMQP_URL: >-
201+
amqps://{{ .mq_username }}:{{ .mq_password | urlEncode }}@b-1.plane.mq.REGION.amazonaws.com:5671/
202+
REDIS_URL: >-
203+
rediss://:{{ .redis_password | urlEncode }}@plane.abc.cache.amazonaws.com:6379
204+
GITHUB_CLIENT_SECRET: '{{ .github_client_secret }}'
205+
data:
206+
- secretKey: username
207+
remoteRef: { key: rds!db-REPLACE-ME, property: username }
208+
- secretKey: password
209+
remoteRef: { key: rds!db-REPLACE-ME, property: password }
210+
- secretKey: mq_username
211+
remoteRef: { key: plane/amazonmq, property: username }
212+
- secretKey: mq_password
213+
remoteRef: { key: plane/amazonmq, property: password }
214+
- secretKey: redis_password
215+
remoteRef: { key: plane/elasticache, property: password }
216+
- secretKey: github_client_secret
217+
remoteRef: { key: plane/connectors, property: github_client_secret }

0 commit comments

Comments
 (0)