Infrastructure configuration needed to host the evolved-npcs application
  • Python 59%
  • Shell 16.6%
  • HCL 9.3%
  • TypeScript 8.3%
  • Nix 5.5%
  • Other 1.2%
Find a file
2026-10-22 13:59:51 -07:00
.woodpecker docs(woodpecker-ci): the builder step's two pod fields come from the cluster 2026-10-08 09:35:57 -07:00
apps feat(images): build the console and the router as Nix derivations (#63) 2026-09-20 12:07:20 -07:00
ci Merge branch 'main' into feat/100-annia-enqueuer-source-tree-image 2026-09-25 16:42:31 -07:00
cloudflare test(cloudflare): pin the annia tunnel record in the plan tests 2026-09-22 23:32:34 +00:00
control-plane chore(k8s): declare v1.36.4, and move every client that dials it 2026-09-21 14:14:36 -07:00
data feat(woodpecker): move the CI server's state into Postgres (#40) 2026-09-19 00:54:25 -07:00
docs docs(woodpecker-ci): the builder step's two pod fields come from the cluster 2026-10-08 09:35:57 -07:00
drills feat(images): build the console and the router as Nix derivations (#63) 2026-09-20 12:07:20 -07:00
enqueuer fix(enqueuer): satisfy the widened python lint, which now reads this tree 2026-09-25 16:42:34 -07:00
gitops feat(infrastructure): give a builder pod its user namespace at admission 2026-10-08 09:35:54 -07:00
host feat(irc): let every settled run reach the phone, and every failure be read 2026-09-25 13:02:07 -07:00
kubernetes-dashboard Create Kubernetes Dashboard (#43) 2026-09-11 21:28:15 -07:00
oz feat(oz): the front door is a dropdown, at the operator's preference (#63) 2026-09-20 15:19:10 -07:00
policy Create staging builds to validate Flux deploys (#54) 2026-09-11 19:40:55 -07:00
previewlib docs(previewlib): drop a comment block the merge carried in twice 2026-09-17 06:22:34 +00:00
router feat(images): build the console and the router as Nix derivations (#63) 2026-09-20 12:07:20 -07:00
storage feat(images): build the console and the router as Nix derivations (#63) 2026-09-20 12:07:20 -07:00
tests docs(woodpecker-ci): the builder step's two pod fields come from the cluster 2026-10-08 09:35:57 -07:00
woodpecker-secret-extension feat(images): build the console and the router as Nix derivations (#63) 2026-09-20 12:07:20 -07:00
worker feat(worker): raise user.max_user_namespaces so a Dockerfile-building step gets a user namespace 2026-09-27 08:31:32 +00:00
.dockerignore feat(images): build the console and the router as Nix derivations (#63) 2026-09-20 12:07:20 -07:00
.env.example feat(irc): give the automation an account, and let a settled run say so 2026-09-25 11:14:10 -07:00
.gitignore fix(oz): name the console's build after its revision, not the clock (#63) 2026-09-20 12:42:57 -07:00
AGENTS.md docs(agents): a script asks for the operator's token, it does not take one 2026-10-22 13:19:49 -07:00
ARCHITECTURE.md docs: retract the claims the userns sysctl and the allowlist falsify 2026-09-27 08:49:31 +00:00
flake.lock Specify use of NIX for dependencies (#10) 2026-08-18 12:15:24 -07:00
flake.nix fix(flake): make the podman builder run, and build or push on demand 2026-10-07 23:04:40 -07:00
OPERATIONS.md docs(ops): index the syncthing credential and workload 2026-09-30 16:05:13 +00:00
PLAN.md feat(irc): run Ergo as a Flux app on a persistent claim, with the oper hash from Vault 2026-09-24 19:44:27 -07:00
pyproject.toml Create oz and preview service (#58) 2026-09-15 13:05:47 -07:00
README.md feat(irc): run Ergo as a Flux app on a persistent claim, with the oper hash from Vault 2026-09-24 19:44:27 -07:00
USER_STORIES.md docs(woodpecker-ci): the builder step's two pod fields come from the cluster 2026-10-08 09:35:57 -07:00

Milner

General-purpose Talos Linux cluster definition on libvirt/QEMU (aarch64). Hosts:

Getting Started

Install Nix, then enter the dev shell (terraform, talosctl, kubectl, vault, qemu, podman — everything the repo needs):

nix develop

On the libvirt host itself (Fedora Asahi, aarch64), install the system deps once. This is the repo's one dnf install: packages are nix first, always (host/README.md), and nix cannot serve this one — libvirtd has to be the host's own system service, with Fedora's udev, polkit, SELinux and kernel-module integration, and Terraform drives qemu:///system. Everything the repo uses (qemu-img, virsh, talosctl) comes from the dev shell.

# nix cannot provide libvirtd as the host's system service: udev, polkit,
# SELinux and kernel-module integration, plus the qemu:///system Terraform drives.
sudo dnf install -y edk2-aarch64 libvirt qemu-kvm
sudo usermod -a -G libvirt "$USER" && newgrp libvirt
sudo systemctl enable --now libvirtd

Terraform drives qemu:///system as your libvirt-group user — no sudo in the day-to-day workflow.

Host stability setup (once per Mac mini)

Every machine this repo runs on is an Apple Silicon Mac mini, and its only built-in network path is the Broadcom BCM4388 wifi driven by brcmfmac. That firmware can wedge mid-session (upstream AsahiLinux/linux#494 — still open): the interface keeps its IP but stops carrying traffic, so SSH, ping and the tailnet all go dark and the host looks frozen. It is not — the kernel is fine and the next reboot is clean. Confirm a wedge with journalctl -k | grep -c commonring, which climbs by ~20/min while it lasts. A second, unrelated bug makes the clock jump ~7 h forward at every boot.

Do all of this once per host, right after the libvirt deps above. In the commands, $WIFI is resolved from the active wifi connection so the same block works on any machine.

1. Prefer wired Ethernet. The mini's gigabit port (end0) is far more reliable than the wifi, and takes the radio out of the path for the host and the cluster VMs, whose NAT-bridge traffic rides the same chip. Plug it in — no configuration needed. Everything below keeps the wifi usable; this is what makes the host dependably reachable.

2. Disable wifi power save — power management is implicated in the firmware stalls:

WIFI=$(nmcli -t -f NAME,TYPE connection show --active \
        | awk -F: '$2=="802-11-wireless"{print $1; exit}')
sudo nmcli connection modify "$WIFI" 802-11-wireless.powersave 2   # 2 = off
sudo iw dev wlp1s0f0 set power_save off                            # immediate

3. Stop NetworkManager's MAC-randomisation churn. NetworkManager rewrites the MAC for every scan (the rewrite itself fails on this chip — set-hw-addr: failed …) and for every connect, hammering the firmware for nothing. Pin the profile to a fixed MAC and turn scan randomisation off:

sudo nmcli connection modify "$WIFI" 802-11-wireless.cloned-mac-address permanent
printf '[device]\nwifi.scan-rand-mac-address=no\n' \
  | sudo tee /etc/NetworkManager/conf.d/10-no-rand-mac.conf
sudo systemctl reload NetworkManager
sudo nmcli connection up "$WIFI"     # reactivate; wifi blips for a second

permanent uses the hardware MAC; use stable instead if you would rather not expose it (both stop the churn). Do not reach for the deprecated 802-11-wireless.mac-address-randomization: its enum is default(0) / never(1) / always(2), so the obvious-looking 2 sets always and drags cloned-mac-address back to random. Changing the MAC changes the DHCP lease — update any reservation that pins this host's address.

4. Install the wifi watchdog. There is one, and it lives in the brcmf repository now — this repo no longer carries its sources, because it belongs to the wifi driver rather than to the cluster. That README has the install steps (four files, sudo install, then systemctl enable --now wifi-watchdog.timer). It pings the default gateway every 30 s and escalates — reconnect, reload brcmfmac, reboot — so a wedge recovers itself instead of waiting for you.

5. Enable systemd's runtime watchdog so a genuine hard hang reboots the host instead of hanging forever (the shutdown watchdog is already armed; the runtime one is not):

sudo install -d /etc/systemd/system.conf.d          # the drop-in dir may not exist
printf '[Manager]\nRuntimeWatchdogSec=30s\n' \
  | sudo tee /etc/systemd/system.conf.d/10-watchdog.conf
sudo systemctl daemon-reexec

6. The 7-hour clock jump. If the hardware clock (macsmc-rtc) is kept in local time, the kernel still reads it as UTC at boot — so the clock starts one UTC-offset behind (25200 s here) and chronyd steps it forward a few seconds later. The clock is correct from then on; only pre-sync timestamps and last are affected. Check what the RTC actually holds:

cat /sys/class/rtc/rtc0/since_epoch; date +%s

Equal means the RTC is already UTC and there is nothing to do. If they differ by your UTC offset, the RTC holds local time; converting it is optional:

sudo timedatectl set-local-rtc 0          # declare the RTC is UTC
sudo hwclock --systohc --utc              # rewrite it in UTC
cat /sys/class/rtc/rtc0/since_epoch; date +%s   # re-check (see below)

timedatectl cannot confirm this on its own: it prints "RTC time" converted into local time, so it reads the same either way. Always compare since_epoch with date +%s.

If the write does not land, put the setting back with sudo timedatectl set-local-rtc 1 and accept the boot-time step — a RTC in local TZ: no next to a local-time RTC only makes /etc/adjtime lie about the hardware, which is what misleads hwclock/timedatectl later.

Measured on this host (M2 Mac mini, apple,j473) — the change does not stick. sudo hwclock --systohc --utc does reach the chip (--verbose ends ioctl(RTC_SET_TIME) was successful, and since_epoch momentarily equals date +%s), but the RTC is back in local time within ~1 minute, so the boot step returns. This is not chronyd: its RTC driver reports 513 RTC driver not running (despite rtcsync in /etc/chrony.conf), it logs nothing RTC-related, no systemd-hwclock.service exists, and nothing holds /dev/rtc0 open — the reversion looks like the SMC firmware reasserting its own value. The practical end state for this machine is therefore RTC in local TZ: yes (matching the hardware) plus the chronyd step at boot, which is cosmetic: the clock is exact once NTP lands, and only pre-sync timestamps and last are affected.

7. The external data disk is what the cluster runs from. Every VM disk lives on a USB drive (label Milner_Data) mounted at /mnt/data — the data_root_path pinned in control-plane/terraform.auto.tfvars points into it, and libvirt's talos-images pool is /mnt/data/milner/images/talos-kvm. The domains are autostart = true, but if the mount has not happened by the time virtstoraged starts, the pool cannot activate and both VMs stay shut off. Nothing retries, and nofail means the boot still looks clean. Diagnose it with:

findmnt /mnt/data                     # empty = not mounted
systemctl status mnt-data.mount
journalctl -b | grep -E 'mnt-data|talos-images|talos-kvm'
virsh -c qemu:///system list --all    # both domains `shut off`

On this host that was a race, not a fault: the drive enumerates at ~5.0 s and the fstab entry allowed exactly 5 s (x-systemd.device-timeout=5), so the device job timed out in the same second the disk appeared. Recover in order:

sudo systemctl start mnt-data.mount            # or: sudo mount /mnt/data
virsh -c qemu:///system pool-start talos-images
terraform -chdir=control-plane apply           # running = true starts the CP VM
terraform -chdir=worker apply                  # then the worker

Keep it from recurring by giving the USB drive time and making libvirt wait for the mount. Raise the timeout on the /mnt/data line in /etc/fstab to nofail,x-systemd.device-timeout=90, then:

for u in virtstoraged virtqemud; do
  sudo install -d /etc/systemd/system/$u.service.d
  printf '[Unit]\nRequiresMountsFor=/mnt/data\n' \
    | sudo tee /etc/systemd/system/$u.service.d/10-wait-for-data.conf
done
sudo systemctl daemon-reload

RequiresMountsFor makes the mount a hard dependency, so a missing data disk stops libvirt loudly instead of silently leaving the cluster down. The failure above is observed on this host; the ordering fix is not yet confirmed across a reboot.

Credentials

Copy the template and source it before working (never commit the result):

cp .env.example .env && chmod 600 .env
set -a && . ./.env && set +a

.env.example lists every credential used so far — Tailscale operator OAuth client, Vault runbook token (scoped, one year; root stays in your password manager — docs/vault.md §4h), Woodpecker/Forgejo OAuth app — with where each is created and used (OPERATIONS.md). .env is gitignored.

Control plane runbook

control-plane/ holds the entire Talos control plane as one Terraform graph: the libvirt NAT network, storage pool, installer-ISO and OS-disk volumes, the milner-cp-* VMs, and the Talos install/bootstrap + kubeconfig/talosconfig outputs.

control-plane/terraform.tfvars lists every tunable knob. Copy any value that must differ on a given host into the gitignored control-plane/terraform.auto.tfvars (on this host: an external data_root_path and installer_iso = false). Run everything from the repo root:

# --- 1. Fresh install (from a clean state; first apply boots the installer
#        ISO and bootstraps the cluster) ---
terraform -chdir=control-plane init
terraform -chdir=control-plane plan      # review every diff before applying
terraform -chdir=control-plane apply

# --- 2. Cluster is healthy: eject the installer ISO permanently ---
# Pin installer_iso = false in the gitignored host file (idempotent; keeps
# any other pins such as data_root_path):
touch control-plane/terraform.auto.tfvars
sed -i '/^installer_iso =/d' control-plane/terraform.auto.tfvars
echo 'installer_iso = false' >> control-plane/terraform.auto.tfvars
terraform -chdir=control-plane plan      # cdrom + ISO gone, hd boots first
terraform -chdir=control-plane apply     # node reboots once onto its disk

# --- 3. Daily use ---
./control-plane/use-talos.sh             # refresh the `milner` context in ~/.talos/config
talosctl health                          # other clusters' contexts untouched
talosctl version
./control-plane/use-kubectl.sh           # merge the current kubeconfig into ~/.kube/config
kubectl get nodes                         # (context admin@milner is made current)

# --- 4. Apply a change or patch (edit control-plane/main.tf or
#        terraform.tfvars / terraform.auto.tfvars first, then reconcile) ---
terraform -chdir=control-plane plan
terraform -chdir=control-plane apply

# --- 5. Replace a broken VM — the domain only, never its disk/network/pool ---
terraform -chdir=control-plane plan -replace='libvirt_domain.control_plane[0]'
terraform -chdir=control-plane apply -replace='libvirt_domain.control_plane[0]'

# --- 6. Full teardown (also the way back to step 1 for re-provisioning) ---
#     Order: worker -> preserve -> control-plane (docs/durable-volumes.md §11c).
./worker/cleanup.sh                      # type `destroy`: worker VM first (it
                                         # holds volumes in the pool the sweep
                                         # is about to delete)
./data/cleanup.sh                        # type `preserve`: moves the durable
                                         # qcow2s out of the pool (never deletes)
./control-plane/cleanup.sh               # type `destroy` when prompted

Re-provision from scratch (after step 6)

Reinstate the installer ISO, then fresh-install as in step 1; afterwards eject it again with step 2:

sed -i '/^installer_iso =/d' control-plane/terraform.auto.tfvars
echo 'installer_iso = true' >> control-plane/terraform.auto.tfvars
terraform -chdir=control-plane plan
terraform -chdir=control-plane apply

Worker node runbook

worker/ holds the Talos worker-large VM as one Terraform graph: the OS-disk volume, the milner-worker-1 domain attached to the control-plane NAT network, and the Talos machine config apply that joins it to the live cluster.

The libvirt network and storage pool are owned by the control-plane root — the worker root references them by name only and never re-declares them. It does declare its own installer-ISO volume (talos-metal-arm64-worker.iso, a distinct name from the CP's) so the first install and later eject stay independent of the CP root. The two durable data disks it attaches are owned by data/, which sits between the CP and worker roots.

# --- 1. Apply control-plane root first (must publish secrets + DHCP reservation) ---
terraform -chdir=control-plane apply

# --- 2. Apply the data root: the two durable disks the worker attaches ---
terraform -chdir=data init
terraform -chdir=data plan
terraform -chdir=data apply
#    (after a teardown with preserved data, `data/restore.sh` replaces this)

# --- 3. Apply worker root ---
terraform -chdir=worker init
terraform -chdir=worker plan
terraform -chdir=worker apply

# --- 4. Verify ---
./control-plane/use-kubectl.sh           # merge current kubeconfig into ~/.kube/config
kubectl get nodes -o wide                # (context admin@milner is made current)
kubectl get nodes --show-labels

# --- 5. Eject installer ISO after first install ---
touch worker/terraform.auto.tfvars
echo 'installer_iso = false' >> worker/terraform.auto.tfvars
terraform -chdir=worker plan
terraform -chdir=worker apply     # node reboots once onto its disk

# --- 6. Replace a broken worker VM ---
terraform -chdir=worker plan  -replace='libvirt_domain.worker'
terraform -chdir=worker apply -replace='libvirt_domain.worker'

# --- 7. Full teardown (worker -> preserve -> control-plane; docs/durable-volumes.md §11c) ---
./worker/cleanup.sh              # type `destroy` when prompted
./data/cleanup.sh                # type `preserve` (must precede the CP sweep)
./control-plane/cleanup.sh       # type `destroy` when prompted (sweeps the pool)

Postgres runbook

Postgres is a Flux-managed app: the CloudNativePG operator lives in gitops/clusters/milner/infrastructure/cloudnative-pg/ and the single-instance Cluster in gitops/clusters/milner/data/postgres/. Its 20 GiB PVC is served by the persistent StorageClass from the Flux-managed local-path provisioner (gitops/clusters/milner/infrastructure/local-path/), on the worker's shared data disk.

# --- 1. Worker root + storage tier (shared data disk + StorageClasses) ---
# The apply attaches the shared data disk; the VM reboots once and Talos
# mounts /var/mnt/data at boot (docs/storage.md §10b).
terraform -chdir=worker apply
# StorageClasses (persistent/ephemeral) are Flux-managed: ensure the
# infrastructure Kustomization is Ready (docs/storage.md §10c) before the
# data layer claims the PVC.

# --- 2. Postgres: applied by Flux with the data layer ---
# ./gitops/install.sh hands the tree to Flux; the operator and the Cluster are
# ordinary manifests, so there is no terraform -chdir=postgres step any more
# (docs/postgres.md §13a).

# --- 3. Verify ---
kubectl get pods,pvc -n postgres
kubectl -n postgres get secret postgres-app -o jsonpath='{.data.password}' | base64 -d

Out-of-band operations (not in Terraform)

OPERATIONS.md is the runbook and audit trail for every live change Terraform does not own: the Tailscale admin-console setup (ACL tags, OAuth client, service approval), the imperative tailscale-operator bootstrap (installed by hand because its credentials must never reach Terraform state), Vault init/unseal, and client daemon flags. Vault itself is exposed at https://vault.faun-snapper.ts.net by the operator — its namespace, Helm release, and Ingress are Terraform-owned, but the operator and the tailnet approvals that make the name routable are not.

How Changes Ship

In-cluster workloads (Vault, Pocket ID, Woodpecker, the Zot registry, storage) are managed by Flux v2 GitOps: edit a manifest under gitops/clusters/milner/, commit, and push. Flux pulls the change and reconciles within the sync interval. Rollback is git revert.

A change is finished when every story in USER_STORIES.md holds true — that file is the specification, and everything else in this repository is how it is kept.

Every pull request also gets a preview: L3 deploys it into its own preview-<n> namespace, preview.faun-snapper.ts.net serves whichever preview is selected at the root path, and the OIDC-gated console at oz.faun-snapper.ts.net selects, rebuilds and deletes them. All of it is Flux-owned under preview-system/; the runbook, the teardown paths and the preview drill are docs/preview-environment.md §11h.

Infrastructure that Flux cannot own (VMs, disks, Cloudflare) ships as reviewed Terraform deltas: one plan/apply reconciles exactly the declared change.

Layer Managed by In-place delta? Recovery
VM, disk, libvirt network/pool Terraform No -replace the domain only (runbook step 5)
Durable data volumes (data/) Terraform Never destroyed data/cleanup.sh then data/restore.sh (runbook docs/durable-volumes.md §11c)
Talos machine configuration Terraform Yes Reconcile (runbook step 4); stage config, never reset
Cloudflare Tunnel / WAF Terraform Yes Reconcile the root
In-cluster workloads Flux Yes git revert; Flux re-reconciles

Every disposable resource (VM, disk, libvirt network/pool, installer ISO) is ephemeral: the network/pool are definitions, and the volumes are a re-downloadable ISO and a disposable OS disk, so a plain terraform destroy or cleanup.sh removes them.

The two durable volumes are the exception, and they are handled by the mechanism this repo committed to before they existed: they live in their own data/ root (creation included, so a fresh install is still pure Terraform), worker/main.tf keeps removed { lifecycle { destroy = false } } blocks recording the handover, plus prevent_destroy guards that refuse a deliberate data/ destroy. Teardown never names that root; data/cleanup.sh additionally moves the qcow2s beside the pool before the pool sweep, and data/restore.sh puts them back and proves Terraform still recognises them. Runbook: docs/durable-volumes.md §11c.

control-plane/cleanup.sh has exactly one purpose — full cluster teardown — and is never used for partial recovery. Take the worker down first, then run data/cleanup.sh, before it: the CP sweep rm -rfs the pool directory, so a running worker would lose its OS-disk file and an unpreserved data volume would go with the pool. The script then runs the reviewed terraform destroy, removes leftover files under the pool directory (escalating to sudo rm -rf when the qemu:libvirt-owned directory blocks an unprivileged removal; the path comes from libvirt's live pool definition, not data_root_path) and undefines the pool.

Always:

  • Review every plan before applying; never apply without user confirmation.
  • Never commit Terraform state or secrets.
  • Never hand-run virsh/qemu-img for infrastructure lifecycle.

Origin Repos