diff --git a/.sops.yaml b/.sops.yaml new file mode 100644 index 0000000..359bbd8 --- /dev/null +++ b/.sops.yaml @@ -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" diff --git a/bootstrap/flux/README.md b/bootstrap/flux/README.md index 37f222e..d9f7e45 100644 --- a/bootstrap/flux/README.md +++ b/bootstrap/flux/README.md @@ -1,3 +1,92 @@ -# bootstrap/flux +# bootstrap/flux — Flux bootstrap → `clusters/dev` (Phase 1, T-1.4) -_Phase 1 (T-1.x) — to be authored on execution go. See infra/PHASE1-SCOPE.md._ +Points Flux at the authored **`clusters/dev`** entrypoint and turns on SOPS decryption. +Spec: [ADR-0004 (Flux)](../../../home-network-k3s/decisions/adr-0004-gitops-flux.md), +[docs/05](../../../home-network-k3s/docs/05-gitops-and-delivery.md) · Requirements: **R-GITOPS-01** +(pull-based reconcile), **R-GITOPS-03** (drift correction), **R-SEC-01** (SOPS decryption). + +> **AUTHOR-ONLY. Nothing in this directory has been executed.** No `flux`, `kubectl`, key-gen, or +> cluster contact was run. These are files + runbooks the PM/operator validates and runs. + +## Files +| File | Purpose | +|---|---| +| `bootstrap.sh` | **The single bootstrap entrypoint** the Infra worker's final Ansible play calls. `flux install` → verify `sops-age` Secret → `kubectl apply -k clusters/dev`. | +| `patches/platform-sops-decryption.yaml` | Strategic-merge patch enabling `decryption.provider: sops` + `secretRef: sops-age` on the `platform` Kustomization (realises the base TODO). | +| `patches/apps-sops-decryption.yaml` | Same, for the `apps` Kustomization. | +| `patches/flux-system-source-url.yaml` | Overrides the `flux-system` GitRepository `url` placeholder with the real bootstrap source (template — fill at bootstrap). | + +## Why `flux install` + apply, not `flux bootstrap git` (Phase 1) + +The reconciliation graph is **already authored** in `clusters/base` (read-only to this worker): +`flux-system.yaml` is the `flux-system` **GitRepository**; `platform.yaml`/`apps.yaml` are the Flux +**Kustomizations**; `clusters/dev` is the overlay that patches `cluster-vars` (R-ENV-02). + +`flux bootstrap git` **generates and owns its own** `gotk-sync` GitRepository + Kustomization named +`flux-system` and commits them into the repo — which would **duplicate/conflict** with the authored +base. It also requires a **writable remote git host**, which does not exist in Phase 1 (spec docs/05 +"Source-of-truth", CONTRIBUTING.md: bring-up is from a local/out-of-band bootstrap source; Gitea +arrives in Phase 6). So Phase 1 uses `flux install` (controllers only) + `kubectl apply -k +clusters/dev` (materialise the authored graph). This **reconciles with** `clusters/base` instead of +regenerating it. + +## Bootstrap order (what runs, in sequence) + +``` +tofu apply → cloud-init → Ansible (k3s + Cilium) [Infra worker, T-1.1..1.3] + │ + ▼ (final Ansible play calls the entrypoints below) +1. bootstrap/sops/age-keygen.sh # operator generates age key OUTSIDE repo +2. bootstrap/sops/age-key-secret.md # operator installs private key as sops-age Secret +3. bootstrap/flux/bootstrap.sh # flux install → verify sops-age → apply -k clusters/dev + │ + ▼ +Flux reconciles: GitRepository(flux-system) → Kustomization(platform) → Kustomization(apps), +with SOPS decryption enabled (patches/) and cluster-vars overlay (R-ENV-02). +``` + +## Interface note — for the Infra worker (T-1.3 Ansible) + +**Single entrypoint your final play calls:** `bootstrap/flux/bootstrap.sh` (run inside the +`infra/devcontainer` toolchain so pinned `flux`/`kubectl` are on PATH; `FLUX_VERSION`/`SOPS_VERSION` +live in `infra/devcontainer/versions.env`). + +**Order your play must enforce** (the age key is a hard dependency of a working decrypt): +1. `bootstrap/sops/age-keygen.sh` (operator step — generates key outside repo). +2. Install the `sops-age` Secret per `bootstrap/sops/age-key-secret.md` (the `kubectl create secret` + template). `bootstrap.sh` **fails fast** if this Secret is absent. +3. `bootstrap/flux/bootstrap.sh` (this entrypoint). + +`bootstrap.sh` is idempotent-friendly (`flux install` and `kubectl apply -k` re-run safely) and +never handles key material — it only *verifies* the Secret exists. + +## Handoff — clusters owner / PM (files this worker may not edit) + +`clusters/` is out of this worker's write scope, so three wirings are delivered as patches here and +must be applied into the `clusters/dev` overlay (`clusters/dev/kustomization.yaml` `patches:`): + +```yaml +patches: + - path: ../../bootstrap/flux/patches/flux-system-source-url.yaml # real source URL + - path: ../../bootstrap/flux/patches/platform-sops-decryption.yaml # SOPS on platform + - path: ../../bootstrap/flux/patches/apps-sops-decryption.yaml # SOPS on apps +``` + +Alternatively the base TODOs in `clusters/base/{platform,apps}.yaml` and the `url` in +`clusters/base/flux-system.yaml` can be filled in directly (same effect) — the clusters owner's call. +Either way the `sops-age` `secretRef` name and `flux-system` source name are kept in lockstep with +the authored base. + +## Phase-6 cutover (WS-GITOPS — NOT now) + +Once Gitea is stable, migrate the source to Gitea (docs/05, CONTRIBUTING.md). At that point +`flux bootstrap git` becomes appropriate (there is a writable remote to own): + +```bash +# ILLUSTRATIVE — Phase 6, not run in Phase 1. +flux bootstrap git \ + --url=https://gitea.home.keithjackson.dev//home-network-k3s-impl.git \ + --branch=main \ + --path=clusters/dev +``` +Keep an off-cluster mirror so a source-of-truth outlives the cluster (docs/05 "Circularity & DR"). diff --git a/bootstrap/flux/bootstrap.sh b/bootstrap/flux/bootstrap.sh new file mode 100755 index 0000000..d1e8b87 --- /dev/null +++ b/bootstrap/flux/bootstrap.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# +# bootstrap.sh — the SINGLE Flux + SOPS bootstrap entrypoint for the dev cluster. +# +# Phase 1, T-1.4 · ADR-0004 (Flux) · ADR-0007 (SOPS+age) · R-GITOPS-01 · R-SEC-01. +# ============================================================================= +# *** AUTHORED, NOT RUN. The Infra worker's FINAL Ansible play invokes this *** +# *** (interface contract, see ../README.md). Agents must not execute it. *** +# ============================================================================= +# +# WHAT THIS DOES (Phase 1, bootstrap-source method — reconciles from the LOCAL repo, no remote +# git host exists yet per spec docs/05 + CONTRIBUTING.md): +# +# 1. `flux install` — deploy the Flux controllers ONLY (source, kustomize, helm controllers). +# We do NOT use `flux bootstrap git` in Phase 1 because that command commits +# and OWNS its own gotk-sync GitRepository+Kustomization named `flux-system`. +# Our entrypoints are ALREADY authored in clusters/base (flux-system.yaml = +# the GitRepository; platform.yaml/apps.yaml = the Kustomizations). Letting +# bootstrap regenerate them would DUPLICATE/conflict. `flux install` + +# applying the authored clusters/dev overlay is the reconciling path. +# 2. sops-age Secret — the operator installs the age PRIVATE key out-of-band BEFORE reconcile so +# decryption works on first pass (../sops/age-key-secret.md). This script +# only VERIFIES it exists; it never handles key material. +# 3. `kubectl apply -k clusters/dev` — materialises the authored reconciliation graph: +# GitRepository `flux-system` (source) -> Kustomization `platform` -> +# Kustomization `apps` (dependsOn platform), with cluster-vars overlay +# (R-ENV-02) and SOPS decryption enabled via the patches in ./patches/. +# +# PREREQUISITES the operator/PM resolves before this runs (see ../README.md "Handoff"): +# - clusters/base/flux-system.yaml url placeholder is overridden — wire ./patches/flux-system- +# source-url.yaml into clusters/dev/kustomization.yaml (or set the URL) so the source is real. +# - ./patches/platform-sops-decryption.yaml + apps-sops-decryption.yaml are wired into +# clusters/dev/kustomization.yaml `patches:` so decryption is enabled at reconcile. +# - .sops.yaml (from ../sops) is installed at the repo root with the real age recipient. +# +# PHASE 6 (WS-GITOPS, later — NOT Phase 1): once Gitea is up, cut the source over with +# `flux bootstrap git` against gitea.home.keithjackson.dev — see ../README.md "Phase-6 cutover". +# +set -euo pipefail + +# --- config (env-overridable) ------------------------------------------------ +FLUX_ENV_PATH="${FLUX_ENV_PATH:-clusters/dev}" # dev is the ONLY Phase-1 build target +FLUX_NAMESPACE="${FLUX_NAMESPACE:-flux-system}" +SOPS_SECRET_NAME="${SOPS_SECRET_NAME:-sops-age}" + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(git -C "${script_dir}" rev-parse --show-toplevel 2>/dev/null || cd "${script_dir}/../.." && pwd)" + +echo "==> Flux + SOPS bootstrap (dev) — path=${FLUX_ENV_PATH}" + +# --- 0. tooling present (from infra/devcontainer; versions pinned there) ------ +for bin in flux kubectl; do + command -v "$bin" >/dev/null 2>&1 || { echo "ERROR: '$bin' not found (use infra/devcontainer)." >&2; exit 1; } +done + +# --- 1. install Flux controllers --------------------------------------------- +echo "==> [1/3] flux install (controllers only)" +flux install --namespace="${FLUX_NAMESPACE}" + +# --- 2. verify the out-of-band age key Secret exists ------------------------- +# We DO NOT create it here (it holds the private key). The operator installs it per +# ../sops/age-key-secret.md. Fail fast if missing so decryption can't silently no-op. +echo "==> [2/3] verifying ${SOPS_SECRET_NAME} Secret exists in ${FLUX_NAMESPACE}" +if ! kubectl -n "${FLUX_NAMESPACE}" get secret "${SOPS_SECRET_NAME}" >/dev/null 2>&1; then + echo "ERROR: Secret ${FLUX_NAMESPACE}/${SOPS_SECRET_NAME} not found." >&2 + echo " Install the age private key first: bootstrap/sops/age-key-secret.md" >&2 + exit 1 +fi + +# --- 3. apply the authored dev entrypoint ------------------------------------ +echo "==> [3/3] kubectl apply -k ${FLUX_ENV_PATH}" +kubectl apply -k "${repo_root}/${FLUX_ENV_PATH}" + +echo +echo "==> Bootstrap applied. Watch reconciliation with (read-only):" +echo " flux get kustomizations --watch" +echo " flux get sources git -n ${FLUX_NAMESPACE}" +echo " 'platform' should go Ready, then 'apps' (dependsOn platform)." diff --git a/bootstrap/flux/patches/apps-sops-decryption.yaml b/bootstrap/flux/patches/apps-sops-decryption.yaml new file mode 100644 index 0000000..408e8d7 --- /dev/null +++ b/bootstrap/flux/patches/apps-sops-decryption.yaml @@ -0,0 +1,21 @@ +--- +# Enable SOPS decryption on the `apps` Flux Kustomization (ADR-0007, R-SEC-01). +# +# REALISES the commented TODO in clusters/base/apps.yaml (same pattern as the platform patch). +# Strategic-merge patch — adds the decryption stanza to the base `apps` Kustomization without +# forking it (R-ENV-02). `sops-age` Secret referenced BY NAME ONLY (created out-of-band). +# +# WIRING (clusters owner / PM): add to clusters/dev/kustomization.yaml `patches:` alongside the +# platform patch, e.g. +# patches: +# - path: ../../bootstrap/flux/patches/apps-sops-decryption.yaml +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: apps + namespace: flux-system +spec: + decryption: + provider: sops + secretRef: + name: sops-age diff --git a/bootstrap/flux/patches/flux-system-source-url.yaml b/bootstrap/flux/patches/flux-system-source-url.yaml new file mode 100644 index 0000000..2a18f7b --- /dev/null +++ b/bootstrap/flux/patches/flux-system-source-url.yaml @@ -0,0 +1,26 @@ +--- +# Set the reconciliation source URL on the `flux-system` GitRepository (R-GITOPS-01). +# +# clusters/base/flux-system.yaml ships a deliberate placeholder: +# url: https://REPLACE-ME.invalid/home-network-k3s-impl.git +# because the bootstrap-source URL is environment-specific and unknown at authoring time (Phase 1 +# has no remote git host yet — spec docs/05 "Source-of-truth", CONTRIBUTING.md). +# +# This strategic-merge patch overrides that URL at bootstrap WITHOUT editing clusters/base +# (R-ENV-02). Fill spec.url with the actual bootstrap source, then wire this file into +# clusters/dev/kustomization.yaml `patches:` (clusters owner / PM). See ../README.md. +# +# PHASE 6 CUTOVER (WS-GITOPS): change this URL to +# https://gitea.home.keithjackson.dev//home-network-k3s-impl.git +# and keep an off-cluster mirror (docs/05 "Circularity & DR"). +apiVersion: source.toolkit.fluxcd.io/v1 +kind: GitRepository +metadata: + name: flux-system + namespace: flux-system +spec: + # >>> REPLACE-ME <<< actual bootstrap source URL (how the local repo is served to the cluster: + # git-over-ssh, a git daemon on the workstation NAT gateway, etc. — set by WS-INFRA/operator). + url: https://REPLACE-ME.invalid/home-network-k3s-impl.git + ref: + branch: main diff --git a/bootstrap/flux/patches/platform-sops-decryption.yaml b/bootstrap/flux/patches/platform-sops-decryption.yaml new file mode 100644 index 0000000..87108c0 --- /dev/null +++ b/bootstrap/flux/patches/platform-sops-decryption.yaml @@ -0,0 +1,29 @@ +--- +# Enable SOPS decryption on the `platform` Flux Kustomization (ADR-0007, R-SEC-01). +# +# This REALISES the commented TODO in clusters/base/platform.yaml: +# # decryption: +# # provider: sops +# # secretRef: +# # name: sops-age +# It is a strategic-merge patch (matched by apiVersion/kind/name/namespace) that ADDS the +# decryption stanza to the existing base Kustomization WITHOUT forking it (R-ENV-02). +# +# The `sops-age` Secret is created out-of-band (../../sops/age-key-secret.md); it is referenced +# BY NAME ONLY here — no key material in git. +# +# WIRING (clusters owner / PM — outside the security engineer's subtree): add this file to +# clusters/dev/kustomization.yaml `patches:` (path-relative), e.g. +# patches: +# - path: ../../bootstrap/flux/patches/platform-sops-decryption.yaml +# so the dev overlay reconciles platform manifests with decryption enabled. See ../README.md. +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: platform + namespace: flux-system +spec: + decryption: + provider: sops + secretRef: + name: sops-age diff --git a/bootstrap/sops/.sops.yaml b/bootstrap/sops/.sops.yaml new file mode 100644 index 0000000..359bbd8 --- /dev/null +++ b/bootstrap/sops/.sops.yaml @@ -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" diff --git a/bootstrap/sops/README.md b/bootstrap/sops/README.md index 5ece3a6..5cd3ad6 100644 --- a/bootstrap/sops/README.md +++ b/bootstrap/sops/README.md @@ -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. diff --git a/bootstrap/sops/age-key-secret.md b/bootstrap/sops/age-key-secret.md new file mode 100644 index 0000000..47def03 --- /dev/null +++ b/bootstrap/sops/age-key-secret.md @@ -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"). diff --git a/bootstrap/sops/age-keygen.sh b/bootstrap/sops/age-keygen.sh new file mode 100755 index 0000000..420819b --- /dev/null +++ b/bootstrap/sops/age-keygen.sh @@ -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...) diff --git a/bootstrap/sops/install-age-key.sh b/bootstrap/sops/install-age-key.sh new file mode 100755 index 0000000..ecb36b9 --- /dev/null +++ b/bootstrap/sops/install-age-key.sh @@ -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." diff --git a/clusters/dev/kustomization.yaml b/clusters/dev/kustomization.yaml index 2598897..d23fa11 100644 --- a/clusters/dev/kustomization.yaml +++ b/clusters/dev/kustomization.yaml @@ -9,6 +9,45 @@ kind: Kustomization resources: - ../base patches: + # --- SOPS decryption + bootstrap source (PM-inlined from bootstrap/flux/patches/, 2026-08-16) --- + # Inlined (not cross-dir file refs) to stay within kustomize's default load restrictions. + # Realises the commented decryption TODOs in clusters/base and sets the real source URL. + # The `sops-age` Secret is created out-of-band (bootstrap/sops/install-age-key.sh) — name only. + - patch: |- + apiVersion: kustomize.toolkit.fluxcd.io/v1 + kind: Kustomization + metadata: + name: platform + namespace: flux-system + spec: + decryption: + provider: sops + secretRef: + name: sops-age + - patch: |- + apiVersion: kustomize.toolkit.fluxcd.io/v1 + kind: Kustomization + metadata: + name: apps + namespace: flux-system + spec: + decryption: + provider: sops + secretRef: + name: sops-age + - patch: |- + apiVersion: source.toolkit.fluxcd.io/v1 + kind: GitRepository + metadata: + name: flux-system + namespace: flux-system + spec: + # >>> REPLACE-ME at bootstrap <<< how the local repo is served to the cluster + # (git-over-ssh / a git daemon on the NAT gateway, etc. — WS-INFRA/operator, OQ). + url: https://REPLACE-ME.invalid/home-network-k3s-impl.git + ref: + branch: main + # --- dev-scoped cluster-vars (R-ENV-02) --- - target: kind: ConfigMap name: cluster-vars