Reorganize the brownfield repository, remove retired and generated artifacts, harden ignore rules, and record the GitOps/IaC redesign.
186 lines
11 KiB
Markdown
186 lines
11 KiB
Markdown
# CI/CD — what we are building
|
||
|
||
Status: **design, partly built.** Stage 1 is live. The substrate is undecided.
|
||
Started 2026-07-28.
|
||
|
||
## Goal
|
||
|
||
Every infrastructure change goes through a pipeline that tests it, **and that
|
||
pipeline is the only path to production.**
|
||
|
||
The second half is the point. Tests you can bypass with `helm upgrade` are not
|
||
controls, they are suggestions. The cluster already shows what that looks like:
|
||
`authelia` is on revision **14** and `e5renew` on revision **28**, all hand-run.
|
||
Nothing is wrong with those releases — but nothing proves anything about them
|
||
either, and two of the thirteen deployed Helm releases (`e5renew`, `rustfs`)
|
||
have no representation in this repo at all.
|
||
|
||
So the aim is not "add CI". It is to make the tested path the *only* path, and
|
||
to make drift between this repo and reality visible when it happens.
|
||
|
||
## Current state
|
||
|
||
| | |
|
||
|---|---|
|
||
| git | History since 2026-07-28. Four commits, **no remote yet** |
|
||
| stage 1 | Live and green — `yamllint`, `ansible-lint`, `terraform fmt`/`validate`. Configs tuned against a real run |
|
||
| gitea | 1.25.5, Actions **enabled**, `DEFAULT_ACTIONS_URL=github`. **No runner deployed**, so nothing executes |
|
||
| ansible | 33 roles across `infrastructure/proxmox/`, `infrastructure/samba-ad/`, `infrastructure/openbao/` |
|
||
| terraform | 4 roots, **local state**, each with **different interactive auth** (`bao login -method=oidc`, `az login`) |
|
||
| k8s | ~13 Helm releases, all deployed by hand |
|
||
| secrets | 4 config files are gitignored because they embed live secrets, so their contents are **not** version controlled |
|
||
|
||
## The four stages
|
||
|
||
**Stage 1 — static. Built.**
|
||
`yamllint`, `ansible-lint`, `terraform fmt -check` / `validate -backend=false`.
|
||
No cluster, no credentials, no mutation, so it is safe on every push. It already
|
||
found a real defect: `infrastructure/proxmox/ansible/` had no `requirements.yml` at all, so a
|
||
fresh checkout could not reproduce its collections.
|
||
|
||
**Stage 2 — server-side dry-run.**
|
||
`kubectl apply --dry-run=server`. This is the one that is usually skipped and
|
||
matters most here: the cluster runs Gateway API, cert-manager, the
|
||
VictoriaMetrics operator and Envoy `SecurityPolicy` CRDs. Offline schema
|
||
validation cannot see any of them; server-side dry-run validates against the
|
||
real CRDs and admission webhooks. Needs cluster credentials, so it lives in a
|
||
separate workflow from stage 1 — a credential problem there must never be able
|
||
to block the static gate.
|
||
|
||
**Stage 3 — ephemeral integration.**
|
||
- **Ansible: Molecule.** Its default scenario asserts *idempotence*, which is
|
||
already this repo's written acceptance test ("a second run must report
|
||
`changed=0`"). The standard exists; Molecule just makes something other than a
|
||
human enforce it.
|
||
- **Kubernetes: a throwaway `k3d` cluster** — apply everything, assert it
|
||
converges. Catches ordering and cross-resource problems a dry-run cannot.
|
||
|
||
Realistic coverage: roughly a third of the 33 roles. `vyos_router` (no Python
|
||
interpreter), `pve_cluster`, `pve_linstor` and the Windows-join roles need real
|
||
hardware or nested VMs on Proxmox. Those stay `--check --diff` against live.
|
||
|
||
**Stage 4 — deploy and drift.**
|
||
Flux reconciles Kubernetes from git. Terraform and Ansible get **scheduled drift
|
||
detection**, not auto-apply: `terraform plan -detailed-exitcode` and
|
||
`ansible-playbook --check --diff` on a timer, alerting into the VictoriaMetrics
|
||
stack that already exists. That is where most of the value is and almost none of
|
||
the risk.
|
||
|
||
## What enforces what
|
||
|
||
There are only two enforcement mechanisms available, and this lab has both:
|
||
|
||
- **Kubernetes → reconciliation.** Flux is the only writer. A hand-run
|
||
`helm upgrade` gets reverted on the next interval. Enforcement is free.
|
||
- **Ansible and Terraform → credentials.** No reconciler exists for them and one
|
||
should not be invented; nothing stops someone SSHing to a PVE node and running
|
||
`pveum` by hand. The lever is **OpenBao's SSH CA**: if humans hold no standing
|
||
SSH access and the pipeline is the only identity that can get a signed cert,
|
||
the pipeline becomes the only path by credential control. Same shape for
|
||
Terraform — keep the AppRole and the Azure service principal only in CI.
|
||
|
||
Credential lockdown comes **last**. It is the disruptive step and is only worth
|
||
doing once the pipeline is trustworthy. A documented break-glass path must
|
||
survive it: the playbooks still have to run by hand from the laptop during an
|
||
incident, or enforcement locks you out exactly when you need in.
|
||
|
||
## Substrate — open
|
||
|
||
**Requirement:** a light always-on orchestrator with ephemeral workers. Not a VM
|
||
or LXC parked idle waiting for work. Something must always listen — that is
|
||
inherent to event-driven CI — but it should be a controller, not a pet.
|
||
|
||
| option | verdict |
|
||
|---|---|
|
||
| **Woodpecker CI** | **Front-runner.** Kubernetes backend runs each step as a standalone Pod; first-class Gitea (OAuth2 + auto-created webhooks); server + agent, both light; stable. Costs a different pipeline syntax, which for shell-step lint jobs is ~20 lines |
|
||
| `act_runner` (Gitea native) | Docker or host execution **only** — no Kubernetes executor. Confirmed in source: `labels.go` has just `SchemeDocker`/`SchemeHost`, and `run_context.go` branches only to `startHostEnvironment` or `startJobContainer`. Cheapest (one pod) but leaves a persistent worker |
|
||
| Gitea ARC | Real pod-per-job operator, but **Enterprise Edition only** |
|
||
| GARM + `garm-provider-k8s` | Right shape. GARM supports Gitea from 1.24, but latest is **v0.2.0-beta1** and the provider documents GitHub runners only — the Gitea pairing is unverified |
|
||
| Write a runner from zero | The protocol (`actions-proto-go`) is approachable; reimplementing execution is not — `act_runner` delegates that to a vendored `nektos/act`. A shim spawning one-shot pods is blocked on ephemeral registration (`go-apps/gitea/gitea#32461`) |
|
||
|
||
**Where runners run** is a separate axis. Argument for Proxmox: pve1–3 have clean
|
||
egress, while the k3s node carries `openvpn-client@naist`, whose 58 split-tunnel
|
||
routes blackhole Cloudflare, Fastly and Microsoft ranges whenever the tunnel dies
|
||
— and pods inherit the host routing table. CI on the laptop will fail
|
||
mid-build in confusing ways every time that happens. Argument against: no GARM
|
||
Proxmox provider exists, so ephemeral Proxmox VMs would mean writing one.
|
||
|
||
## Prerequisites
|
||
|
||
Blocking, in order:
|
||
|
||
1. **Externalise secrets.** `apps/gitea/gitea-values.yaml`, `apps/authelia/values.yaml`,
|
||
`infrastructure/cloudflared/cloudflared.yaml` and `litellm-gateway/docker-compose.yml` are
|
||
gitignored because they embed live credentials. GitOps requires the opposite —
|
||
a reconciler can only apply what is in git. OpenBao + External Secrets is the
|
||
natural fit; `openbao_bootstrap/tasks/auth_kubernetes.yml` already exists.
|
||
2. **A git remote.** Gitea is the obvious host, but it runs *on* the cluster it
|
||
would deploy, so keep an off-cluster mirror for disaster recovery.
|
||
3. **Terraform: shared state.** All four roots use local `*.tfstate`. Any
|
||
pipeline that applies needs shared state — the S3-compatible SeaweedFS/rustfs
|
||
already on the cluster is a candidate, with the caveat that cluster state
|
||
living on the cluster is the same circularity as hosting git there.
|
||
|
||
**Not a blocker: non-interactive auth.** An earlier draft of this document
|
||
called `bao login -method=oidc` and `az login` hard blockers that would widen the
|
||
blast radius. That was wrong on both counts.
|
||
|
||
- **OpenBao** — the Kubernetes auth backend is **already bootstrapped**
|
||
(`infrastructure/openbao/ansible/roles/openbao_bootstrap/tasks/auth_kubernetes.yml`). A CI pod
|
||
authenticates with its ServiceAccount JWT and stores **no credential at all**.
|
||
AppRole is the equivalent for anything running off-cluster.
|
||
- **Azure** — the provider supports service principal with a client secret,
|
||
service principal with a **client certificate**, managed identity, and **OIDC
|
||
workload identity federation** (`use_oidc = true` with `oidc_token_file_path`,
|
||
which explicitly covers generic OIDC providers, not just GitHub/ADO).
|
||
HashiCorp's own guidance is to use a service principal or MSI *specifically*
|
||
when running non-interactively in CI, and reserve the Azure CLI for local runs.
|
||
|
||
It also **narrows** privilege rather than widening it. The current path needs an
|
||
`az login` as Global Admin / Privileged Role Admin — because that is what
|
||
creating app registrations and granting admin consent requires. A scoped service
|
||
principal managing steady state is dramatically less privileged than the human
|
||
identity in use today. Same for OpenBao: a per-root AppRole or Kubernetes role
|
||
can be scoped to exactly the paths that root owns, whereas an interactive OIDC
|
||
admin login is not.
|
||
|
||
The blast-radius concern in CLAUDE.md is about **merging the roots**, not about
|
||
machine identities. Per-root scoped credentials preserve that separation and
|
||
reduce privilege at the same time.
|
||
|
||
The honest caveat is a split, not a blocker: app-registration creation and admin
|
||
consent are genuinely high-privilege **bootstrap** operations and should stay
|
||
manual. CI should hold an identity that manages steady state only. The cleanest
|
||
assembly with what already exists: keep the Azure SP certificate in OpenBao, and
|
||
let the CI pod fetch it via Kubernetes auth — so nothing long-lived is stored in
|
||
the CI system itself.
|
||
|
||
## Non-goals
|
||
|
||
- **High availability.** One node. Nothing to fail over to. GitOps here buys
|
||
drift elimination and rebuild-from-scratch, not uptime.
|
||
- **Autoscaling.** Nothing to scale across.
|
||
- **Untrusted-workload isolation.** One author. This changes the day it stops
|
||
being true, and the runner's trust model must be revisited then.
|
||
- **Terraform or Ansible under a Kubernetes reconciler.** More machinery on the
|
||
single node that is also NFS server, libvirt host and netboot appliance makes
|
||
recovery harder, not easier.
|
||
- **Merging the Terraform roots.** Already considered and rejected; see CLAUDE.md.
|
||
|
||
## Sequencing
|
||
|
||
1. Externalise secrets — unblocks everything, valuable on its own
|
||
2. Capture `e5renew` and `rustfs` into the repo (`helm get values`)
|
||
3. Git remote
|
||
4. Pick the substrate; stand it up in an isolated namespace
|
||
5. Stage 2, then stage 3 on **one** container-friendly role first
|
||
6. Flux on one low-stakes namespace (`http-echo` or `marker`)
|
||
7. Drift detection for Terraform and Ansible — scoped machine identities
|
||
(OpenBao Kubernetes auth / AppRole, a steady-state Azure SP) land here, and
|
||
are a privilege *reduction* on the admin logins used today
|
||
8. Credential lockdown — remove standing human access, with break-glass
|
||
documented. Last, and only once the pipeline has earned trust
|
||
|
||
Steps 1, 2 and 7 are worth doing even if the CI substrate is never chosen — they
|
||
are just "the repo should describe reality".
|