feat: Add cluster age key and document #46

Merged
martin merged 3 commits from secrets into main 2026-07-31 21:34:36 +00:00
Owner
No description provided.
martin self-assigned this 2026-07-31 20:57:52 +00:00
feat: Add cluster age key and document
All checks were successful
ci/woodpecker/pr/pr-review Pipeline was successful
ci/woodpecker/pr/switch Pipeline was successful
7f4b60b043
Collaborator

Review Summary

This PR introduces the full SOPS secret-management workflow for the cluster: a new cluster age key added as a recipient in .sops.yaml, the sops-secrets-operator in dev is wired to a sops-age-key-file Secret via secretsAsFiles + SOPS_AGE_KEY_FILE env, the justfile gains parameterized sops-edit/sops-encrypt/sops-decrypt and a sops-new scaffold (backed by scripts/sops-new.sh), and AGENTS.md/README are updated to document it. I verified the generated manifests/ files match the sources (env and manifests argocd-secrets.sops.yaml are byte-identical, both re-encrypted to the same blob), and that the chart values (secretsAsFiles, extraEnv) render correctly into the Deployment.

Overall the change is sound and consistent: --indent 2 keeps sops output 2-space formatted, the re-encryption adds the new recipient without leaking values, and the workflow docs are accurate.

Non-blocking items worth addressing:

  • Key-name dependency: SOPS_AGE_KEY_FILE=/etc/sops-age-key-file/key only works if the out-of-band sops-age-key-file Secret has a data key named exactly key. This cannot be verified from the repo; if the name differs or the Secret is missing, the operator pod fails to start or silently can't decrypt. Worth documenting the required key name and adding a sops-status check for it.
  • Cross-environment key scope: .sops.yaml creation rules apply to all env/**/*.sops.yaml, but the cluster private key only lives in dev. Future infra/prod secrets would therefore be decryptable by the dev cluster. Consider per-env recipients/path-scoped rules.
  • Minor: just sops-status lists every pod in dev (noise); filter by the operator's app.kubernetes.io/name label. sops-new.sh performs no K8s-name validation.

*🤖 Review by opencode (opencode/deepseek-v4-flash-free) — verdict: comment

# Review Summary This PR introduces the full SOPS secret-management workflow for the cluster: a new `cluster` age key added as a recipient in `.sops.yaml`, the `sops-secrets-operator` in `dev` is wired to a `sops-age-key-file` Secret via `secretsAsFiles` + `SOPS_AGE_KEY_FILE` env, the `justfile` gains parameterized `sops-edit`/`sops-encrypt`/`sops-decrypt` and a `sops-new` scaffold (backed by `scripts/sops-new.sh`), and AGENTS.md/README are updated to document it. I verified the generated `manifests/` files match the sources (env and manifests `argocd-secrets.sops.yaml` are byte-identical, both re-encrypted to the same blob), and that the chart values (`secretsAsFiles`, `extraEnv`) render correctly into the Deployment. Overall the change is sound and consistent: `--indent 2` keeps sops output 2-space formatted, the re-encryption adds the new recipient without leaking values, and the workflow docs are accurate. Non-blocking items worth addressing: - **Key-name dependency**: `SOPS_AGE_KEY_FILE=/etc/sops-age-key-file/key` only works if the out-of-band `sops-age-key-file` Secret has a data key named exactly `key`. This cannot be verified from the repo; if the name differs or the Secret is missing, the operator pod fails to start or silently can't decrypt. Worth documenting the required key name and adding a `sops-status` check for it. - **Cross-environment key scope**: `.sops.yaml` creation rules apply to all `env/**/*.sops.yaml`, but the `cluster` private key only lives in `dev`. Future `infra`/`prod` secrets would therefore be decryptable by the dev cluster. Consider per-env recipients/path-scoped rules. - Minor: `just sops-status` lists every pod in `dev` (noise); filter by the operator's `app.kubernetes.io/name` label. `sops-new.sh` performs no K8s-name validation. --- *🤖 Review by opencode (opencode/deepseek-v4-flash-free) — verdict: **comment**
clank-bot left a comment

Code review findings from opencode.

Code review findings from opencode.
@ -5,3 +6,4 @@
encrypted_regex: ^(data|stringData)$
age:
- *nidaros
- *cluster
Collaborator

[security] The cluster recipient's private key is stored only in the dev cluster (per AGENTS.md), but this creation rule (path_regex: \.sops\.yaml$) applies to every env/**/*.sops.yaml. Any future secret added under env/infra/ or env/prod/ will be encrypted for the dev-cluster key, meaning a compromise of dev would expose infra/prod secrets. Consider per-environment recipients with path-scoped rules (e.g. separate rules for env/dev/ vs env/prod/).

[security] The `cluster` recipient's private key is stored only in the `dev` cluster (per AGENTS.md), but this creation rule (`path_regex: \.sops\.yaml$`) applies to every `env/**/*.sops.yaml`. Any future secret added under `env/infra/` or `env/prod/` will be encrypted for the dev-cluster key, meaning a compromise of dev would expose infra/prod secrets. Consider per-environment recipients with path-scoped rules (e.g. separate rules for `env/dev/` vs `env/prod/`).
@ -19,0 +20,4 @@
{
name = "sops-age-key-file";
mountPath = "/etc/sops-age-key-file";
secretName = "sops-age-key-file";
Collaborator

[bug] SOPS_AGE_KEY_FILE=/etc/sops-age-key-file/key requires the out-of-band sops-age-key-file Secret to contain a data key literally named key (the mount exposes the Secret's data keys as files). This can't be verified from the repo, and if the key is named differently the operator pod won't start (mount error) or will silently fail to decrypt, breaking the entire secret workflow this PR introduces. Please confirm/document the exact data key name, and consider adding an existence check (e.g. to just sops-status).

[bug] `SOPS_AGE_KEY_FILE=/etc/sops-age-key-file/key` requires the out-of-band `sops-age-key-file` Secret to contain a data key literally named `key` (the mount exposes the Secret's data keys as files). This can't be verified from the repo, and if the key is named differently the operator pod won't start (mount error) or will silently fail to decrypt, breaking the entire secret workflow this PR introduces. Please confirm/document the exact data key name, and consider adding an existence check (e.g. to `just sops-status`).
justfile Outdated
@ -84,3 +84,3 @@
[group("Secrets")]
sops-status:
kubectl get pods -n sops-secrets-operator
kubectl get pods -n dev
Collaborator

[style] kubectl get pods -n dev returns every pod in the dev namespace, which can drown out the operator status and produce false alarms when unrelated apps are down. Filter by the operator's label instead, e.g. kubectl get pods -n dev -l app.kubernetes.io/name=sops-secrets-operator (the Deployment carries that label).

[style] `kubectl get pods -n dev` returns every pod in the dev namespace, which can drown out the operator status and produce false alarms when unrelated apps are down. Filter by the operator's label instead, e.g. `kubectl get pods -n dev -l app.kubernetes.io/name=sops-secrets-operator` (the Deployment carries that label).
@ -0,0 +3,4 @@
path="$1"
namespace="$2"
secret_name="$3"
Collaborator

[style] No validation is done on namespace/secret_name against Kubernetes naming rules, so a typo produces an invalid SopsSecret that fails later in the pipeline. A simple regex check (or a kubectl create --dry-run=client validation) in the scaffold would fail fast. Also note the scaffold writes a plaintext replace-me value — fine as a template, but make sure the file is encrypted via just sops-edit/just sops-encrypt before committing.

[style] No validation is done on `namespace`/`secret_name` against Kubernetes naming rules, so a typo produces an invalid SopsSecret that fails later in the pipeline. A simple regex check (or a `kubectl create --dry-run=client` validation) in the scaffold would fail fast. Also note the scaffold writes a plaintext `replace-me` value — fine as a template, but make sure the file is encrypted via `just sops-edit`/`just sops-encrypt` before committing.
fix(secrets): validate sops-new input, enforce age-key
All checks were successful
ci/woodpecker/pr/pr-review Pipeline was successful
ci/woodpecker/pr/switch Pipeline was successful
ea4c44bc7d
data-key name, filter sops-status
Collaborator

Review Summary

This PR adds first-class SOPS secret management to the cluster: a cluster-wide age recipient key (age1zy4…cecdy), wires the sops-age-key-file Secret + SOPS_AGE_KEY_FILE env var into the sops-secrets-operator (via secretsAsFiles/extraEnv), re-encrypts existing secrets for the new recipient, and adds the just sops-* command suite with a scripts/sops-new.sh scaffolder. Documentation (AGENTS.md/README.md) is thorough and consistent.

Strengths:

  • Operator config and generated manifests agree; the age-key mount (/etc/sops-age-key-file/key) matches SOPS_AGE_KEY_FILE, and the bootstrap recipe always uses data key key.
  • sops-status correctly uses the app.kubernetes.io/name selector (matches the Helm-generated pod labels) and validates the secret's data key.
  • sops-new.sh validates k8s name rules (63 chars, DNS-1123), refuses existing files, requires the .sops.yaml suffix, and cleans up on failed validation.

Main concern — security: sops-new.sh scaffolds a plaintext SopsSecret containing the placeholder value replace-me, and encryption depends entirely on the user remembering to run just sops-edit. Nothing (build, just switch, CI, git) guards against committing/syncing the plaintext placeholder; since nixidy copies the file verbatim into manifests/, it would be deployed to the cluster as a literal replace-me secret with no failure. Recommend hardening the workflow.

Minor notes: kubectl's "no matches for kind" check in sops-new.sh is a fragile message-based match (CRD-not-installed and API-down both pass), and the sops-status/bootstrap namespace is hardcoded to dev (documented, so acceptable).


*🤖 Review by opencode (opencode/deepseek-v4-flash-free) — verdict: changes

# Review Summary This PR adds first-class SOPS secret management to the cluster: a cluster-wide age recipient key (`age1zy4…cecdy`), wires the `sops-age-key-file` Secret + `SOPS_AGE_KEY_FILE` env var into the sops-secrets-operator (via `secretsAsFiles`/`extraEnv`), re-encrypts existing secrets for the new recipient, and adds the `just sops-*` command suite with a `scripts/sops-new.sh` scaffolder. Documentation (AGENTS.md/README.md) is thorough and consistent. Strengths: - Operator config and generated manifests agree; the age-key mount (`/etc/sops-age-key-file/key`) matches `SOPS_AGE_KEY_FILE`, and the bootstrap recipe always uses data key `key`. - `sops-status` correctly uses the `app.kubernetes.io/name` selector (matches the Helm-generated pod labels) and validates the secret's data key. - `sops-new.sh` validates k8s name rules (63 chars, DNS-1123), refuses existing files, requires the `.sops.yaml` suffix, and cleans up on failed validation. Main concern — security: `sops-new.sh` scaffolds a **plaintext** SopsSecret containing the placeholder value `replace-me`, and encryption depends entirely on the user remembering to run `just sops-edit`. Nothing (build, `just switch`, CI, git) guards against committing/syncing the plaintext placeholder; since nixidy copies the file verbatim into `manifests/`, it would be deployed to the cluster as a literal `replace-me` secret with no failure. Recommend hardening the workflow. Minor notes: kubectl's "no matches for kind" check in sops-new.sh is a fragile message-based match (CRD-not-installed and API-down both pass), and the `sops-status`/bootstrap namespace is hardcoded to `dev` (documented, so acceptable). --- *🤖 Review by opencode (opencode/deepseek-v4-flash-free) — verdict: **changes**
clank-bot left a comment

Code review findings from opencode.

Code review findings from opencode.
@ -0,0 +46,4 @@
- name: ${secret_name}
type: Opaque
stringData:
${secret_name}: replace-me
Collaborator

[security] The scaffolder writes a plaintext SopsSecret with a dummy value replace-me. Encryption only happens later via just sops-edit, so the file is a live secret-in-plaintext until then. Because nixidy copies the file verbatim into manifests/ (the operator decrypts in-cluster), running just switch before encrypting would deploy a literal replace-me Secret with no error. Nothing guards against this (no build/CI check that the file is actually encrypted). Suggest encrypting the placeholder immediately with sops -e -i inside sops-new.sh, and/or adding a CI/build check that every *.sops.yaml committed is encrypted (e.g. grep for an ENC[AES256_GCM data value or a valid mac:).

[security] The scaffolder writes a plaintext SopsSecret with a dummy value `replace-me`. Encryption only happens later via `just sops-edit`, so the file is a live secret-in-plaintext until then. Because nixidy copies the file verbatim into `manifests/` (the operator decrypts in-cluster), running `just switch` before encrypting would deploy a literal `replace-me` Secret with no error. Nothing guards against this (no build/CI check that the file is actually encrypted). Suggest encrypting the placeholder immediately with `sops -e -i` inside sops-new.sh, and/or adding a CI/build check that every `*.sops.yaml` committed is encrypted (e.g. grep for an `ENC[AES256_GCM` data value or a valid `mac:`).
@ -0,0 +54,4 @@
echo "kubectl validation: OK"
else
err=$(kubectl create --dry-run=client -f "$path" -o yaml 2>&1 || true)
if echo "$err" | grep -q 'no matches for kind'; then
Collaborator

[style] Determining whether kubectl validation should be skipped by grepping stderr for the literal 'no matches for kind' is fragile: an unreachable/unauthorized API server, expired context, or other schema errors that still contain that substring will silently downgrade to a warning and keep a file that failed validation. Consider checking the error for other CRD/API failures, or running the validation against a local schema (e.g. kubectl apply --dry-run=client --validate=false won't help either; simplest is to require the CRD before relying on the check).

[style] Determining whether kubectl validation should be skipped by grepping stderr for the literal 'no matches for kind' is fragile: an unreachable/unauthorized API server, expired context, or other schema errors that still contain that substring will silently downgrade to a warning and keep a file that failed validation. Consider checking the error for other CRD/API failures, or running the validation against a local schema (e.g. `kubectl apply --dry-run=client --validate=false` won't help either; simplest is to require the CRD before relying on the check).
chore(secrets):
All checks were successful
ci/woodpecker/pr/switch Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
8989eb1684
remove sops-new scaffolder, template copy instead
Collaborator

Review Summary

This PR wires the sops-secrets-operator to decrypt secrets in-cluster using a dedicated cluster age key:

  • What's good: The .sops.yaml recipient change and the re-encrypted argocd-secrets.sops.yaml are consistent across env/ and manifests/ (identical ciphertexts, MAC, version: 3.13.3). The secretsAsFiles + extraEnv Helm values render correctly into the Deployment (volume, read-only mount, SOPS_AGE_KEY_FILE=/etc/sops-age-key-file/key). The justfile additions are solid: sops-bootstrap-age-key validates the keyfile (AGE-SECRET-KEY-1) and always names the data key key, matching what SOPS_AGE_KEY_FILE requires. Docs (README/AGENTS) are thorough and consistent, and the sops-status namespace fix (dev vs the old sops-secrets-operator) matches the actual Deployment namespace. The chicken-and-egg bootstrap ordering is correctly documented.

  • Concerns: (1) The new docs state a SopsSecret's namespace must match the application's namespace, but the flagship template argocd-secrets.sops.yaml declares namespace: dev while ArgoCD runs in argocd — the resulting argocd-secret would land in dev where ArgoCD won't read it (no targetNamespace). Worth verifying. (2) The mounted age private key defaults to mode 0644 in the container; consider defaultMode: 0600. (3) The cluster key is now a recipient for every environment's .sops.yaml (global creation_rules), and its private key sits in a plaintext Secret in dev — broadens blast radius if dev is compromised. (4) The operator Deployment hard-depends on the out-of-band secret; until bootstrapped the pod stays in CreateContainerConfigError and nothing decrypts, and a key rotation won't trigger a Deployment roll (no checksum annotation).

Net: the core change is correct, well-tested by the rendered manifests, and well-documented. Remaining items are pre-existing concerns or hardening suggestions rather than blockers.


*🤖 Review by opencode (opencode/deepseek-v4-flash-free) — verdict: comment

# Review Summary This PR wires the `sops-secrets-operator` to decrypt secrets in-cluster using a dedicated cluster age key: - **What's good:** The `.sops.yaml` recipient change and the re-encrypted `argocd-secrets.sops.yaml` are consistent across `env/` and `manifests/` (identical ciphertexts, MAC, `version: 3.13.3`). The `secretsAsFiles` + `extraEnv` Helm values render correctly into the Deployment (volume, read-only mount, `SOPS_AGE_KEY_FILE=/etc/sops-age-key-file/key`). The `justfile` additions are solid: `sops-bootstrap-age-key` validates the keyfile (`AGE-SECRET-KEY-1`) and always names the data key `key`, matching what `SOPS_AGE_KEY_FILE` requires. Docs (README/AGENTS) are thorough and consistent, and the `sops-status` namespace fix (`dev` vs the old `sops-secrets-operator`) matches the actual Deployment namespace. The chicken-and-egg bootstrap ordering is correctly documented. - **Concerns:** (1) The new docs state a SopsSecret's `namespace` must match the application's namespace, but the flagship template `argocd-secrets.sops.yaml` declares `namespace: dev` while ArgoCD runs in `argocd` — the resulting `argocd-secret` would land in `dev` where ArgoCD won't read it (no `targetNamespace`). Worth verifying. (2) The mounted age private key defaults to mode 0644 in the container; consider `defaultMode: 0600`. (3) The cluster key is now a recipient for *every* environment's `.sops.yaml` (global `creation_rules`), and its private key sits in a plaintext Secret in `dev` — broadens blast radius if `dev` is compromised. (4) The operator Deployment hard-depends on the out-of-band secret; until bootstrapped the pod stays in `CreateContainerConfigError` and nothing decrypts, and a key rotation won't trigger a Deployment roll (no checksum annotation). Net: the core change is correct, well-tested by the rendered manifests, and well-documented. Remaining items are pre-existing concerns or hardening suggestions rather than blockers. --- *🤖 Review by opencode (opencode/deepseek-v4-flash-free) — verdict: **comment**
clank-bot left a comment

Code review findings from opencode.

Code review findings from opencode.
@ -5,3 +6,4 @@
encrypted_regex: ^(data|stringData)$
age:
- *nidaros
- *cluster
Collaborator

[security] Adding the cluster key to the global creation_rules means every .sops.yaml in every environment is now encrypted to the in-cluster key, whose private key lives in a plaintext Secret in the dev namespace. A compromise of dev (anyone with secrets read there) yields the master key for all environments, including prod. This is the inherent sops-secrets-operator tradeoff, but consider documenting the blast radius / restricting RBAC on sops-age-key-file, or using per-environment recipients if prod must not be decryptable by the dev cluster.

[security] Adding the cluster key to the global `creation_rules` means every `.sops.yaml` in every environment is now encrypted to the in-cluster key, whose private key lives in a plaintext Secret in the `dev` namespace. A compromise of `dev` (anyone with `secrets` read there) yields the master key for all environments, including `prod`. This is the inherent sops-secrets-operator tradeoff, but consider documenting the blast radius / restricting RBAC on `sops-age-key-file`, or using per-environment recipients if prod must not be decryptable by the dev cluster.
@ -312,0 +329,4 @@
- Files must end in `.sops.yaml` so the creation rule in `.sops.yaml`
(`encrypted_regex: ^(data|stringData)$`) applies.
- The `namespace:` field of the `SopsSecret` decides where the `Secret` is created — it must match the
Collaborator

[bug] The new guidance says the SopsSecret namespace field "must match the application's namespace", but the template/example referenced everywhere is env/dev/argocd-secrets.sops.yaml, which declares namespace: dev while the ArgoCD application runs in argocd (env/dev/argocd.nix:11). Since the operator creates the resulting Secret in the SopsSecret's own namespace (no targetNamespace), argocd-secret would be created in dev and ArgoCD would never read admin.password/oidc.keycloak.clientSecret from it. This appears pre-existing, but this PR's docs codify it as the reference workflow — please verify the secret actually reaches the argocd namespace (or fix the example's namespace), otherwise the documented "must match" rule contradicts the very template it recommends.

[bug] The new guidance says the SopsSecret `namespace` field "must match the application's namespace", but the template/example referenced everywhere is `env/dev/argocd-secrets.sops.yaml`, which declares `namespace: dev` while the ArgoCD application runs in `argocd` (`env/dev/argocd.nix:11`). Since the operator creates the resulting Secret in the SopsSecret's own namespace (no `targetNamespace`), `argocd-secret` would be created in `dev` and ArgoCD would never read `admin.password`/`oidc.keycloak.clientSecret` from it. This appears pre-existing, but this PR's docs codify it as the reference workflow — please verify the secret actually reaches the `argocd` namespace (or fix the example's namespace), otherwise the documented "must match" rule contradicts the very template it recommends.
@ -19,0 +19,4 @@
# Requires the out-of-band Secret `sops-age-key-file` in `dev` to contain a data key
# literally named `key` (exposed as the file mounted at `/etc/sops-age-key-file/key`).
# Bootstrap/replace with: just sops-bootstrap-age-key <keyfile>
secretsAsFiles = [
Collaborator

[security] The mounted sops-age-key-file volume renders without a defaultMode (see the generated Deployment), so the private age key file is world-readable (0644) inside the container. Consider setting defaultMode: 0600 if the chart's secretsAsFiles items support it (or via a post-render/kustomize transform).

[security] The mounted `sops-age-key-file` volume renders without a `defaultMode` (see the generated Deployment), so the private age key file is world-readable (0644) inside the container. Consider setting `defaultMode: 0600` if the chart's `secretsAsFiles` items support it (or via a post-render/kustomize transform).
Collaborator

[style] Operational ordering: the Deployment now hard-depends on the out-of-band sops-age-key-file Secret. Until it is bootstrapped (manually), the operator pod is stuck in CreateContainerConfigError and no SopsSecret decrypts — including argocd-secrets, so bootstrap must happen before/at the first just switch. Also, rotating the key via just sops-bootstrap-age-key won't trigger a Deployment rollout (no checksum annotation on the secret); kubelet will eventually refresh the mounted file, but if the operator caches the key at startup a manual kubectl rollout restart may be needed. The docs hint at this; making it explicit would help.

[style] Operational ordering: the Deployment now hard-depends on the out-of-band `sops-age-key-file` Secret. Until it is bootstrapped (manually), the operator pod is stuck in `CreateContainerConfigError` and no SopsSecret decrypts — including `argocd-secrets`, so bootstrap must happen before/at the first `just switch`. Also, rotating the key via `just sops-bootstrap-age-key` won't trigger a Deployment rollout (no checksum annotation on the secret); kubelet will eventually refresh the mounted file, but if the operator caches the key at startup a manual `kubectl rollout restart` may be needed. The docs hint at this; making it explicit would help.
martin merged commit fad33fff3e into main 2026-07-31 21:34:36 +00:00
martin deleted branch secrets 2026-07-31 21:34:36 +00:00
Sign in to join this conversation.
No reviewers
No labels
automerge
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Reference
martin/cluster!46
No description provided.