A smithy is a work place that contains a forge, a kind of crucible. A place where one might forge weld several layers of steel into a single piece.
A reference implementation of infrastructure as code for provisioning and deprovisioning VMs. Built for repeatable software-test environments: the environment around the software under test stays fixed — same image, same package versions. The VMs are designed to be ephemeral.
Core components for VM management:
- A golden image carries what has to exist before first boot (the guest agent)
- OpenTofu drives the ProxMox VE hypervisor (bpg/proxmox provider) and its native cloud-init settings — identity, keys, addressing
- Ansible owns everything after boot — re-appliable at any time without touching the VM lifecycle.
Persistent backend components:
- A grafana/otel-lgtm container provides an OpenTelemetry endpoint to capture and store Claude Code telemetry from agents running during testing.
- A windmill-labs/windmill container is being evaluated as an orchestrator for the overall testing workflow.
- One YAML file per VM. Provisioning is adding a file to
inventory/; deprovisioning is deleting one, or moving it to the destroy subdirectory. Onlyvm_idis required — everything else inherits a typedoptional()default from thespeccontract inmodules/vm-pve/variables.tofu. - Repeatable environments. Per-VM
archive_snapshot:pins apt to snapshot.ubuntu.com at a chosen instant, so package installs resolve identically forever;package_upgradedefaults to false. Third-party repos (e.g. docker) are covered by explicit version pins in the ansible role defaults instead. - Composable post-boot software. A spec lists ansible roles, for example,
(
ansible_roles: [nats_server, bun, claude, docker, metafactory_arc, assay_env]) andansible-playbook ansible/site.yamlapplies them — idempotently, with every download verified and every version pinned. A dynamic inventory reads the tofu state, so there is no hosts file to maintain, and changing ansible content never diffs the plan or recreates a VM. - Guardrails for pre-existing VMs. A VMID floor, a named protected list,
and a live check that refuses to plan against any VM on the node not tagged
opentofu— with validations that prevent the guards themselves from being quietly weakened, and a fail-closed check if the API token can no longer see the whole node. - Encrypted state and plans (pbkdf2 + AES-GCM,
enforced = true), secrets via sops/age, and a.gitignorethat covers the sharp edges (crash.logcontains a TRACE-level dump including the API token, regardless of TF_LOG). - Real validation gates.
scripts/check-ansible.shasserts the dynamic inventory's shape — every hostvar the roles rely on, every declared role backed by a real directory — and syntax-checks and lints the playbook.scripts/build-image.shverifies the upstream image against its publishedSHA256SUMS, asserts inside the built image that the build-time apt pin did not leak, and has the node verify the upload's checksum.
├── CLAUDE.md # notes for coding agents; AGENTS.md is a symlink
├── versions.tofu
├── providers.tofu
├── encryption.tofu
├── main.tofu # sops secrets, node data source
├── guards.tofu # protected VMIDs + live foreign-VM lookup [EDIT]
├── vms.tofu # inventory/ -> module.vm-pve, for_each
├── variables.tofu # fleet-wide defaults [EDIT]
├── outputs.tofu
├── modules/vm-pve/ # the contract: what a VM is
├── ansible/ # post-boot software
│ ├── ansible.cfg
│ ├── requirements.yml # pinned collections (the only source)
│ ├── site.yaml # base, then each VM's declared roles
│ ├── inventory/tofu.py # dynamic inventory from tofu output
│ └── roles/<role>/ # base (every VM), then nats_server, bun,
│ # claude, docker, metafactory_arc,
│ # assay_env (last: fingerprint + interchange)
├── inventory/ # one YAML file per VM [EDIT]
├── scripts/ # build-image.sh, check-ansible.sh,
│ # install-collections.sh, vm-fingerprint.sh
├── otel-lgtm/ # grafana/otel-lgtm observability stack [EDIT]
│ # (docker compose; see its README.md)
└── windmill/ # Windmill workflow engine (docker compose; [EDIT]
# see its README.md; local-dev/ is the wmill
# CLI workspace)
The two container directories are deployments that run on a persistent backend system NOT on a test VM. In the reference implementation they are running on the same system that is running opentofu and ansible, but that is not required.
Download and install on PATH, for example ~/.local/bin
-
OpenTofu, https://github.com/opentofu/opentofu/releases/tag/v1.12.6
-
libguestfs-tools(Debian/Ubuntu package name) if you build the golden image there (see "Building the golden image"). On Arch Linux the package name islibguestfs. -
A ProxMox VE node (built against 9.x) with:
- a datastore that allows the
importcontent type for the golden image (localby default;isowill not do — PVE only imports a disk fromimagesorimportcontent), - a golden image uploaded (see "Building the golden image"), and
- an API token for provisioning, with the usual
VM.*/Datastore.*/SDN.Useprivileges. That is the only credential this project needs — nothing here has, or wants, SSH to the node.
- a datastore that allows the
-
Age key + secrets. Generate a key, put its public half in
.sops.yaml(see the EDIT comment there — consider a second offline recipient), then:age-keygen -o ~/.config/sops/age/keys.txt cp secrets.enc.json.example secrets.enc.json # fill in, then: sops -e -i secrets.enc.json
secrets.enc.jsonholds the ProxMox API token and the state passphrase (16 characters minimum). Once encrypted it is safe to commit. -
API user, role, and token. As root on the node:
pveum user add opentofu-prov@pve --comment "OpenTofu provisioning (API token only)" pveum role add OpenTofuProv -privs "Datastore.AllocateSpace,Datastore.AllocateTemplate,Datastore.Audit,Pool.Allocate,Pool.Audit,SDN.Audit,SDN.Use,Sys.AccessNetwork,Sys.Audit,Sys.Console,Sys.Modify,VM.Allocate,VM.Audit,VM.Clone,VM.Config.CDROM,VM.Config.CPU,VM.Config.Cloudinit,VM.Config.Disk,VM.Config.HWType,VM.Config.Memory,VM.Config.Network,VM.Config.Options,VM.GuestAgent.Unrestricted,VM.Migrate,VM.PowerMgmt" pveum acl modify / -user opentofu-prov@pve -role OpenTofuProv # privsep=0: the token inherits the user's ACLs. The secret prints ONCE - # it goes into secrets.enc.json (proxmox.api_token_secret). pveum user token add opentofu-prov@pve provisioning --privsep 0
Datastore.AllocateTemplateis what lets the same token upload the golden image, so no second identity is needed. -
Golden image. Build and upload one before the first
tofu apply— see "Building the golden image". A stock cloud image will not do: it has no guest agent, and apply blocks for the full 30-minute timeout waiting for an address that never arrives.
Every VM's disk is imported from a golden image: an upstream Ubuntu cloud image
with qemu-guest-agent baked in. The agent is the one thing ansible cannot
install — tofu apply blocks until the agent reports an address, and the
dynamic inventory reads that same address to connect at all — so it has to be
present before first boot.
scripts/build-image.sh does the whole job. On the machine that builds:
sudo apt install libguestfs-tools
# libguestfs boots its appliance with the host kernel, which ships mode 0600
sudo dpkg-statoverride --update --add root root 0644 /boot/vmlinuz-$(uname -r)
sudo usermod -aG kvm "$USER" # then log out and back inThe script's preflight checks each of those and names the fix if one is
missing; without kvm it still works, just slowly under emulation. Then:
./scripts/build-image.sh --uploadwhich downloads the dated upstream serial, verifies it against that
directory's SHA256SUMS, installs the agent with virt-customize, and uploads
the result through the storage API as import content — the same token as
everything else, no scp. It prints the volid to put in
cloud_image_file_id. Build without --upload
first if you want to inspect the image (virt-df, guestfish --ro).
Options: --serial YYYYMMDD picks the upstream build (default is the one this
repo currently pins), --snapshot YYYYMMDDTHHMMSSZ the snapshot.ubuntu.com
instant the agent is installed from, and --outdir where to work (default
private/images/, gitignored; budget ~2.5 GB).
Rebuild when the upstream serial should move, then repoint
cloud_image_file_id at the new file name. Never overwrite an uploaded
image: the disk source is create-only, so a same-name replacement silently
changes what a running VM was built from without any plan diff. Build a new
name, repoint, and delete the old volume once nothing references it.
Note: Edit all the copied files with your specific data.
cp .sops.yaml.example .sops.yamlcp guards.tofu.example guards.tofucp variables.tofu.example variables.tofucp inventory-example.yaml inventory/<vm_name>.yamluv venv --python 3.14 --managed-pythonuv pip install -r requirements.txtsource tofu.env# this also sources .venv/bin/activate./scripts/install-collections.sh# after tofu.env, which names the pathtofu init# the first init installs the providerstofu plan# verify the output looks like what you expect
Note: The ssh_public_keys in terraform.tfvars are the keys that reach the
VMs. Override any fleet defaults there too.
cd otel-lgtmcp .env.example .envdocker compose up -d
cd windmillcp .env.example .envdocker compose up -d
Windmill local development Windmill CLI
cd windmill/local-devcp .env.example .envcp wmill.yaml.example wmill.yaml
cp prepush.env.example prepush.envgit config core.hooksPath .githooks
- Add a VM: create
inventory/<name>.yaml(the file name becomes the VM name and hostname — DNS label rules apply) andtofu apply. Seeinventory-example.yamlfor every knob including snapshot pinning and ansible roles. - Remove a VM:
mv inventory/<name>.yaml inventory/destroy/andtofu apply— onlyinventory/*.yamlis scanned, so the spec stays on disk while the VM and its disk are destroyed. Moving it back provisions a fresh VM (all guest data is gone). To keep a VM but power it off, setstarted: falseinstead. (Plainmv: this template gitignoresinventory/*.yaml, sogit mvfails. A fork that tracks its own specs can usegit mv.) - Install software: list roles in the spec
(
ansible_roles: [docker, ...]) and runansible-playbook ansible/site.yaml [--limit <vm>]— no plan diff, no recreation, idempotent (second run reports changed=0). Removing a role from the list does not uninstall it; clean removal is a rebuild. - After touching anything under
ansible/: run./scripts/check-ansible.sh. - Prove a rebuild is identical:
./scripts/vm-fingerprint.sh ubuntu@<ip> fingerprints/<name>.txtcaptures the VM's environment (packages, apt config, enabled units, users, and the ansible-installed files — minus per-instance noise like host keys and machine-id). Capture, destroy + reprovision, re-run ansible, capture again to the same path: an empty diff is the proof.fingerprints/**is gitignored, so captures stay local unless you commit one deliberately.
Short version of the command sequence to destroy, recreate, and validate the
fingerprint, assuming an inventory entry for a VM called ubuntu-test at
10.0.0.50. Capture to one path throughout and let git diff do the comparing;
rebuilding the same configuration should produce no diff.
Important: Always start a session with source tofu.env
source tofu.env
./scripts/vm-fingerprint.sh ubuntu@10.0.0.50 fingerprints/ubuntu-test.txt
cp fingerprints/ubuntu-test.txt /tmp/baseline.txt
mv inventory/ubuntu-test.yaml inventory/destroy/
tofu apply
mv inventory/destroy/ubuntu-test.yaml inventory/
tofu apply
ansible-playbook ansible/site.yaml --limit ubuntu-test
./scripts/vm-fingerprint.sh ubuntu@10.0.0.50 fingerprints/ubuntu-test.txt
diff /tmp/baseline.txt fingerprints/ubuntu-test.txt # empty = identical rebuildfingerprints/** and inventory/*.yaml are both gitignored, so this uses
cp/diff and plain mv rather than git add -f/git diff and git mv.
A fork that tracks its specs and captures can use the git forms throughout.
NOTE: The inventory/destroy directory name is intentional, so it is clear what the next "tofu apply" is expected to do.
Cloud-init is decided at first boot and reachable only by recreating the VM —
that is the right place for identity and network, and the wrong place for
anything else. Everything softer lives in ansible/ and follows the same
declarative grammar as the rest of the repo:
- Specs declare, roles implement.
ansible_roles:in a VM's YAML rides through the module intotofu output -json vms;ansible/inventory/tofu.py(python3 stdlib only, no galaxy dependencies) reshapes that output into hosts, one group per role, and hostvars.site.yamlis a single play thatinclude_roles each host's declared list, so adding a role never touches it, and a typo'd role name fails attofu plantime via afileexists()validation in the spec contract. - The
baserole is implicit. No spec lists it and none can opt out:site.yamlapplies it to every host viaroles:, ahead of the include loop. It owns the apt snapshot pin, theAPT::Periodiczeros, the masked apt-daily timers, key-only SSH, the timezone, and the spec'spackages:— the baseline that used to be cloud-init's, now re-appliable to a running VM. - Collections are pinned in
ansible/requirements.yml, and both a workstation and CI install from it with./scripts/install-collections.sh.requirements.txtpinsansible-core, which ships none, so acommunity.*task without an entry there fails in both places — andcheck-ansible.shverifies the installed versions against the pins before it lints anything. - Role names use underscores (
nats_server, notnats-server): they double as Ansible group names, which must be valid identifiers. - Every download is verified, every version pinned in the role's
defaults/main.yaml. The bundled roles show four install patterns:nats_server(tarball + upstreamSHA256SUMSviaget_url checksum=),bun(zip +SHASUMS256.txt; needsunzipin the VM's packages — asserted in-role; also owns the~/.bun/binPATH block in.bashrc),claude(vendor installer, binary verified against the release manifest first),metafactory_arc(pinned git tag +bun install/bun link; needsgitin the VM's packages, andbunearlier in the role list — spec order is application order), anddocker— which pins the signing key by full GPG fingerprint, writes a deb822 source, installs version-pinned packages, and managesdaemon.jsonwith a restart handler. assay_envis the capstone, and installs nothing. Listed last, it runsscripts/vm-fingerprint.shfrom the control node (delegate_to: localhost), parses the capture'sDIGESTSblock, and writes/etc/assay/environment.json— the interchange assay reads to learn which environment a result was produced under. It anddockerare the only spec-declared roles usingbecome— the implicitbaserole uses it throughout. A missing or malformed digest fails the role before anything is written, and revokes any environment file an earlier run left behind: a file naming a digest that was never computed is worse than no file, and so is one naming a digest this run could not confirm. The capture lands under gitignoredfingerprints/and is never registered as content — it carries the guest'sauthorized_keys.- Host-key checking is off in
ansible.cfg(same stance asvm-fingerprint.sh): host keys are per-instance noise in a fleet where rebuilds are routine, andaccept-newwould poisonknown_hostson first contact then hard-fail after every rebuild. - Unreachable VMs (powered off, or the guest agent not up) are skipped by the inventory with a stderr notice instead of failing the whole run.
Two credentials, one job each:
| Credential | Authenticates to | Used for |
|---|---|---|
API token (in secrets.enc.json) |
ProxMox API | provisioning, and uploading the golden image |
ssh_public_keys (tfvars) |
<ci_user>@<vm> |
reaching the VMs |
There is deliberately no hypervisor SSH credential. One used to exist, because the ProxMox API has no snippets endpoint and the provider fell back to SSH as root to upload cloud-init user-data. Removing the snippet removed the reason: every call this project makes is now an API call carrying the token, and a compromised workstation cannot reach root on the node through anything here.
The API identity lives in the @pve realm, which is not a Linux account, so it
could never have been the SSH identity — that split was structural, and is now
simply absent.
Three plan-time layers in guards.tofu + modules/vm-pve/main.tofu
(preconditions, not check blocks — they fail the plan rather than warn):
- VMID floor — managed VMs live at or above
managed_vmid_min. - Named protected list — failure messages name the VM, not just the ID.
- Live foreign-VM check — everything on the node not tagged
opentofuis refused, covering VMs hand-built after adoption. Fails closed: an unreachable node fails the plan, and a postcondition rejects the listing if the protected VMs are not all visible (an ACL-narrowed token shortens the list instead of erroring).
Two things no code can do, so do them on the node: never tofu import a
pre-existing VMID (import puts it in state; from that moment destroy can
reach it), and set qm set <id> --protection 1 on the VMs that matter — the
one guard OpenTofu genuinely cannot bypass.
- Name images with their build serial (the
-20260720suffix in the examples). cloud-images.ubuntu.com publishes a.manifestper build listing every installed package+version, but only the serial ties your local file to it — the PVE web UI downloads a serial-less name that becomes untraceable oncecurrent/moves on. Verify with the build'sSHA256SUMSbefore renaming. Serial-suffixed names also let images from different builds co-exist for different test setups. Baseline = manifest, delta = inventorypackages:, delta versions =archive_snapshot— the whole environment is specified without booting anything. - Ubuntu cloud images ship apt sources but not package indexes; the base
role refreshes them (
update_cache: true) before installing, so the spec'spackages:resolve against the pin. - unattended-upgrades is disabled on every VM (the image ships it
enabled). The base role zeroes the
APT::Periodicjobs and masks the apt-daily timers — masked rather than merely disabled, because a package postinst re-runningsystemctl presetcan undo a disable, and a test environment must not change itself. Drop those tasks fromansible/roles/base/tasks/main.yamlif you want automatic updates. archive_snapshot:writesAPT::Snapshot "<ts>";to apt.conf.d from the base role, before it installs anything, so the pin is in force for the spec's own packages and for any later manualapt install. An apt.conf.d file survives cloud-init regeneratingubuntu.sources.- What reaches a running VM, and what does not.
packages:,archive_snapshot:,ci_timezoneand everyansible_rolesentry are applied by ansible, so editing them and re-running the playbook is enough.package_upgradeis the exception: it becomes PVE'sciupgrade, which cloud-init reads on first boot only, so changing it reaches new VMs only. - The disk's image reference is create-only (
ignore_changes): bumpingcloud_image_file_idaffects new VMs, never existing ones. That is also why an uploaded image must never be replaced in place — same name, different bytes, no plan diff.
encryption.tofu has a standing two-slot design — main (current) and
previous (pre-rotation, placeholder outside rotation windows) — with both
key providers sharing encrypted_metadata_alias = "state". That alias
matters: pbkdf2 otherwise keys its salt metadata to the provider's block
name, and renaming/moving passphrases between blocks makes existing state
undecryptable. With the alias, rotation never touches HCL:
# 1. demote the current passphrase, generate a new one
OLD="$(sops -d --extract '["state"]["passphrase"]' secrets.enc.json)"
sops set secrets.enc.json '["state"]["passphrase_previous"]' "\"$OLD\""
sops set secrets.enc.json '["state"]["passphrase"]' "\"$(openssl rand -base64 24)\""
# 2. re-encrypt state under the new key
source tofu.env
tofu apply -refresh-only -auto-approve
# 3. prove the new key decrypts alone
TF_VAR_state_passphrase_previous=rotation-placeholder-unused \
tofu state pull >/dev/null && echo ok
# 4. retire the old passphrase and re-verify
sops set secrets.enc.json '["state"]["passphrase_previous"]' '"rotation-placeholder-unused"'
source tofu.env && tofu plan # expect: No changesterraform.tfstate.backup keeps pre-rotation ciphertext for one write cycle
— delete it when rotating away from a compromised passphrase. The placeholder
must stay >= 16 characters (pbkdf2 minimum).
Extracted from a working single-node homelab setup; values in this template
(IPs, VMIDs, names) are placeholders — every spot needing a real value is
marked EDIT or replace-me.