Phase 1 (author-only): Flux + SOPS bootstrap + interface wiring (T-1.4)

bootstrap/sops (age-keygen refusing in-repo, .sops.yaml, install-age-key.sh
glue installing the sops-age Secret) + bootstrap/flux (bootstrap.sh: flux
install -> apply -k clusters/dev; decryption/url patches). PM wiring: place
.sops.yaml at repo root (SOPS walks to root); inline the 3 flux patches into
clusters/dev (avoids kustomize ../ load-restriction). clusters/dev builds with
decryption enabled; full CI gate PASS. No key material; nothing executed.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
jakman85andClaude Opus 4.8 committed 2026-08-16 01:21:01 -05:00
1 parent 0e901f135c
commit 84a2d762f0
12 files changed
+573 -4

No files matched your search

+36
View File
@@ -0,0 +1,36 @@
# .sops.yaml — SOPS creation rules for the platform repo (ADR-0007, R-SEC-01).
#
# ─────────────────────────────────────────────────────────────────────────────
# EFFECTIVE LOCATION: this file must live at the REPOSITORY ROOT to govern
# encryption of secrets under platform/** and apps/**. It is AUTHORED here in
# bootstrap/sops/ (the security engineer's subtree); the PM/clusters owner
# installs it at the repo root (copy or symlink). See bootstrap/sops/README.md
# "Interface / handoff". SOPS discovers .sops.yaml by walking UP from the file
# being encrypted to the repo root, so it only takes effect from root.
# ─────────────────────────────────────────────────────────────────────────────
#
# HOW ENCRYPTION WORKS HERE:
# - Only committed secrets are encrypted; plaintext never enters git.
# - `encrypted_regex` encrypts ONLY the value fields (data/stringData), leaving
# keys and structure in cleartext → readable, reviewable diffs (ADR-0007).
# - `age` recipient below is the PUBLIC key from age-keygen.sh. Filling it is a
# bootstrap step; the matching PRIVATE key is installed in-cluster as the
# `sops-age` Secret (age-key-secret.md) so Flux can decrypt at reconcile.
#
# TO ENCRYPT A SECRET (operator, not agents):
# sops --encrypt --in-place path/to/secret.sops.yaml
# # picks up the age recipient + rules below automatically.
creation_rules:
# Platform + app Secrets. Convention: encrypted files are named `*.sops.yaml`
# or live under a `secrets/` directory anywhere in the tree.
- path_regex: (^|/)(platform|apps|catalog)/.*(secret|sops)\.ya?ml$
encrypted_regex: "^(data|stringData)$"
# >>> REPLACE-ME <<< paste the age PUBLIC recipient printed by age-keygen.sh.
# Example shape (NOT a real key): age1qy...placeholder...0abcd
age: "REPLACE-ME-AGE-PUBLIC-RECIPIENT"
- path_regex: (^|/)secrets/.*\.ya?ml$
encrypted_regex: "^(data|stringData)$"
# >>> REPLACE-ME <<< same age PUBLIC recipient as above.
age: "REPLACE-ME-AGE-PUBLIC-RECIPIENT"
+36 -2
View File
@@ -1,3 +1,37 @@
# bootstrap/sops
# bootstrap/sops — SOPS + age key material & decryption wiring (Phase 1, T-1.4)
_Phase 1 (T-1.x) — to be authored on execution go. See infra/PHASE1-SCOPE.md._
Generates the age keypair, declares SOPS creation rules, and documents how the age **private** key
is installed into the cluster so Flux can decrypt SOPS at reconcile.
Spec: [ADR-0007 (SOPS+age)](../../../home-network-k3s/decisions/adr-0007-secrets-sops-age.md),
[docs/07](../../../home-network-k3s/docs/07-security-and-secrets.md) · Requirement: **R-SEC-01**
(secrets encrypted at rest in git, decrypted only in-cluster).
> **AUTHOR-ONLY. Nothing here has been executed. No key or secret material exists in this output.**
> No `age-keygen`, `sops`, or `kubectl` was run. The age keypair is generated later by the operator;
> `.sops.yaml` carries only a **placeholder** for the public recipient.
## Files
| File | Purpose |
|---|---|
| `age-keygen.sh` | Operator-run script: generates the age keypair to a path **outside the repo**, prints only the **public** recipient. **Refuses** to write inside the repo or clobber an existing key. |
| `.sops.yaml` | SOPS creation rules (encrypt only `data`/`stringData` → readable diffs). `age:` recipient is a clearly-marked `REPLACE-ME` placeholder filled at bootstrap. **Effective home is the repo root** (see below). |
| `age-key-secret.md` | Runbook + `kubectl create secret` template installing the age **private** key as the `sops-age` Secret in `flux-system` (the sanctioned out-of-band step). Rotation + DR custody included. |
## How this satisfies R-SEC-01 / ADR-0007
- Secrets are committed **encrypted** (`sops --encrypt`) using the age **public** recipient in
`.sops.yaml`; plaintext never enters git (`.gitignore` guards `age.key`, `*.key`, `*.dec.yaml`).
- Flux decrypts **in-cluster** using the age **private** key held in the `sops-age` Secret — the one
documented out-of-band bootstrap action (docs/07, ADR-0007 Consequences).
- `decryption.provider: sops` + `secretRef: sops-age` on the `platform`/`apps` Flux Kustomizations is
wired by `../flux/patches/` (realising the commented TODOs in `clusters/base`).
- No key material appears in scripts, docs, PRs, or logs (AGENTS.md §5).
## Interface / handoff
- **`.sops.yaml` belongs at the REPOSITORY ROOT.** SOPS discovers it by walking up from the file
being encrypted, so it only governs `platform/**` and `apps/**` from root. It is authored here (in
this worker's subtree); the **PM/clusters owner installs it at the repo root** (copy or symlink).
Flagged as an escalation in the T-1.4 report.
- **Fill two placeholders at bootstrap:** the `age:` recipient in `.sops.yaml` (from
`age-keygen.sh`'s output) — same value in both creation rules.
- **Order** (see `../flux/README.md`): `age-keygen.sh` → install `sops-age` Secret (`age-key-secret.md`)
→ `../flux/bootstrap.sh`. The Flux entrypoint fails fast if the Secret is missing.
+69
View File
@@ -0,0 +1,69 @@
# Installing the age private key as the `sops-age` Secret (out-of-band)
**Phase 1, T-1.4 · ADR-0007 (SOPS+age) · R-SEC-01 · spec docs/07 "Secrets management".**
This is the **one sanctioned non-GitOps action** in the secrets model (ADR-0007 Consequences,
docs/07): the age **private** key is bootstrapped into the cluster out-of-band as a Kubernetes
Secret that Flux's kustomize-controller reads to decrypt SOPS at reconcile time. The key is **never**
committed (`.gitignore` guards `age.key`, `*.key`); no key material appears in git, PRs, or logs
(AGENTS.md §5).
> **AUTHOR-ONLY runbook.** Nothing here has been executed. The operator runs these steps once, after
> the age key exists (`age-keygen.sh`) and Flux controllers are installed (`../flux/bootstrap.sh`).
## Prerequisites
1. `age-keygen.sh` has been run → private key at `~/.config/sops/age/keys.txt` (outside the repo).
2. The `flux-system` namespace exists (created by `flux install` / `flux bootstrap`).
3. `kubectl` context points at the dev cluster.
## Create the Secret (command template — NOT run)
Flux discovers age keys inside the `sops-age` Secret by the **`.agekey` filename suffix**, so the
data key MUST end in `.agekey`:
```bash
# Reads the private key file and stores it under data key "age.agekey".
cat ~/.config/sops/age/keys.txt | kubectl create secret generic sops-age \
--namespace=flux-system \
--from-file=age.agekey=/dev/stdin
```
Idempotent re-apply variant (avoids "already exists" on re-runs):
```bash
kubectl create secret generic sops-age \
--namespace=flux-system \
--from-file=age.agekey="$HOME/.config/sops/age/keys.txt" \
--dry-run=client -o yaml | kubectl apply -f -
```
- Secret name `sops-age` and namespace `flux-system` MUST match the `decryption.secretRef.name` on
the `platform`/`apps` Flux Kustomizations (see `../flux/patches/`).
- This Secret is created imperatively and is **NOT** in git — it holds the private key, so it must
never be committed, encrypted or otherwise. (It is the trust anchor, not a managed resource.)
## Verify (read-only — NOT run)
```bash
kubectl -n flux-system get secret sops-age # exists
kubectl -n flux-system get secret sops-age \
-o jsonpath='{.data.age\.agekey}' | base64 -d | age-keygen -y # prints PUBLIC recipient only
```
The printed public recipient MUST equal the `age:` value in `.sops.yaml`. (This command derives the
public key from the private key; it never exposes the private key.)
## Rotation (documented per ADR-0007 "Key custody/rotation")
1. Generate a NEW key with `age-keygen.sh` (to a fresh path; the script refuses to clobber).
2. Add the new **public** recipient to `.sops.yaml` **alongside** the old (multi-recipient), then
`sops updatekeys` every encrypted file so both keys can decrypt.
3. Update the `sops-age` Secret to contain **both** private keys (two `*.agekey` data entries) so
Flux can still decrypt existing files during the transition.
4. Once all files are re-encrypted to the new recipient, remove the old recipient from `.sops.yaml`,
`sops updatekeys` again, and drop the old key from the Secret.
## Key custody / DR (ADR-0007: "losing it blocks decryption")
- The age **private** key is DR-critical: without it Flux cannot decrypt any committed Secret.
- Keep an **off-cluster** backup of `keys.txt` in the operator's password manager / offline store
(NOT in this repo, NOT on the cluster it protects). This mirrors the off-cluster-mirror posture for
Gitea (docs/05 "Circularity & DR").
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
#
# age-keygen.sh — generate the SOPS age keypair for the platform.
#
# Phase 1, T-1.4 (ADR-0007 SOPS+age, R-SEC-01). AUTHORED, NOT RUN.
# =============================================================================
# *** THE OPERATOR RUNS THIS, ONCE, AT BOOTSTRAP. AGENTS MUST NOT RUN IT. ***
# =============================================================================
#
# What it does:
# - Generates an age keypair to a path OUTSIDE this git repo (default:
# ~/.config/sops/age/keys.txt — SOPS's conventional location, honoured by the
# SOPS_AGE_KEY_FILE default lookup).
# - Prints ONLY the PUBLIC recipient (age1...) to stdout. The public recipient
# is safe to paste into bootstrap/sops/.sops.yaml. The PRIVATE key stays in
# the file and is NEVER printed, NEVER committed (see .gitignore: age.key,
# *.key). Spec docs/07 + AGENTS.md §5: no secret material in logs/PR/output.
#
# Hard guarantees:
# - REFUSES to write anywhere inside this repository (private key must never
# land where git could track it).
# - REFUSES to overwrite an existing key file (rotation is deliberate — see
# age-key-secret.md "Rotation").
#
# Next step after running this: install the private key into the cluster as the
# `sops-age` Secret — see age-key-secret.md. Then paste the printed recipient
# into .sops.yaml.
#
set -euo pipefail
# --- resolve target path (override with AGE_KEY_FILE) -------------------------
KEY_FILE="${AGE_KEY_FILE:-${HOME}/.config/sops/age/keys.txt}"
# --- refuse to write inside the repo -----------------------------------------
# Repo root = git toplevel if available, else two levels up from this script
# (bootstrap/sops/ -> repo root).
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if repo_root="$(git -C "${script_dir}" rev-parse --show-toplevel 2>/dev/null)"; then
:
else
repo_root="$(cd "${script_dir}/../.." && pwd)"
fi
abspath() { # portable realpath for a possibly-not-yet-existing path
local p="$1"
local d b
d="$(dirname "$p")"
b="$(basename "$p")"
mkdir -p "$d"
printf '%s/%s\n' "$(cd "$d" && pwd)" "$b"
}
key_abs="$(abspath "${KEY_FILE}")"
case "${key_abs}/" in
"${repo_root}/"*)
echo "ERROR: refusing to write the age private key inside the repo:" >&2
echo " ${key_abs}" >&2
echo " repo root: ${repo_root}" >&2
echo " Set AGE_KEY_FILE to a path OUTSIDE the repo (default is fine)." >&2
exit 1
;;
esac
# --- refuse to clobber an existing key ---------------------------------------
if [[ -e "${key_abs}" ]]; then
echo "ERROR: ${key_abs} already exists — refusing to overwrite." >&2
echo " Rotating? Move the old key aside deliberately (see age-key-secret.md)." >&2
exit 1
fi
command -v age-keygen >/dev/null 2>&1 || {
echo "ERROR: age-keygen not found. Use the pinned dev container" >&2
echo " (infra/devcontainer, AGE_VERSION in its versions.env)." >&2
exit 1
}
# --- generate ----------------------------------------------------------------
umask 077 # 0600 key file
age-keygen -o "${key_abs}" >/dev/null
echo "age private key written (0600) to: ${key_abs}"
echo "This file is OUTSIDE the repo and must stay that way — do NOT commit it."
echo
echo "Public recipient (safe to share — paste into bootstrap/sops/.sops.yaml):"
age-keygen -y "${key_abs}" # prints ONLY the public recipient (age1...)
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# bootstrap/sops/install-age-key.sh — install the age PRIVATE key into the cluster as the
# `sops-age` Secret in flux-system, so Flux can decrypt SOPS-encrypted manifests (ADR-0007,
# R-SEC-01). This is the scripted form of the runbook in age-key-secret.md, and the entrypoint
# the Ansible flux_bootstrap role calls (flux_bootstrap_sops_cmd) BEFORE bootstrap.sh.
#
# The private key NEVER lives in git. It is read from AGE_KEY_FILE (default: the standard sops
# age path) and passed to kubectl from a file (not argv). Run at bootstrap by the OPERATOR — not
# by any agent, and not during author-only Phase 1.
set -euo pipefail
AGE_KEY_FILE="${AGE_KEY_FILE:-${HOME}/.config/sops/age/keys.txt}"
NAMESPACE="${FLUX_NAMESPACE:-flux-system}"
SECRET_NAME="${SOPS_SECRET_NAME:-sops-age}"
[ -f "$AGE_KEY_FILE" ] || { echo "ERROR: age key not found at ${AGE_KEY_FILE} — run age-keygen.sh first." >&2; exit 1; }
grep -q 'AGE-SECRET-KEY-' "$AGE_KEY_FILE" || { echo "ERROR: ${AGE_KEY_FILE} contains no AGE-SECRET-KEY." >&2; exit 1; }
kubectl get namespace "$NAMESPACE" >/dev/null 2>&1 || kubectl create namespace "$NAMESPACE"
# Flux reads keys ending in `.agekey` from the sops-age Secret. Idempotent create-or-update;
# the key is streamed from the file, never placed on the command line / in shell history.
kubectl create secret generic "$SECRET_NAME" \
--namespace "$NAMESPACE" \
--from-file=age.agekey="$AGE_KEY_FILE" \
--dry-run=client -o yaml | kubectl apply -f -
echo "Installed Secret ${NAMESPACE}/${SECRET_NAME} for Flux SOPS decryption."