Plan — the standalone, installable ztd CLI
Turn ztd from “a binary that must run inside a checkout of this repo” into a
self-contained CLI you install (Homebrew on macOS, a downloaded binary on
Linux) and run from any project directory: brew install ztd → cd my-project
→ ztd init → ztd up. The binary carries everything it needs; the only config a
user ever touches is a single ztd.toml.
Delivered as phases, each an Opus-orchestrated red/green/verify cycle (see the
red-green-verify skill) with an independent adversarial review before it closes —
the same discipline that built the vz backend.
Linux handoff — finishing the Terraform-backend work
Complete. The handoff described here was carried out: Phase C-verify, D, D1 and E all landed on a Linux host, and
kvm/proxmox/ec2are validated end-to-end from.ztd/ztd.tomlwith noterraform.tfvars. Kept as the record of how the work moved between machines.
Phases A–C were built and verified on macOS, where the vz backend + ztd init
- the config→env layer are green. What remains needs a Linux host, because
kvm/proxmox/ec2boot Terraform inside the toolbox and cannot be boot-tested on macOS (macOS only runsterraform validate). To move the work over:
Prerequisites on the Linux box
- Commit + push the branch (currently
0.4.x). The macOS work is committed per phase by the operator; the Linux box clones/pulls it. (At the time,.ztd/ztd.tomlwas tracked and travelled with the branch. It no longer is — this repo is public and the file held a real homelab topology and public IP, so it is gitignored and.ztd/ztd.toml.exampleis the committed template. Recreate it on the new host alongside the secrets in step 2.) - Recreate secrets —
.ztd/secrets/proxmox.env+aws.envare gitignored and do NOT travel:cp examples/proxmox.env.example .ztd/secrets/proxmox.env(+aws.env) and fill in the endpoint/token / AWS creds. - Host prep — Docker + libvirt/qemu/dnsmasq (kvm); then
./ztd check+./ztd setup(the Docker→libvirt forwarding shim). See Host prerequisites. - Sanity —
task go:test(deterministic on Linux;localresolves to libvirt there) andTARGET=proxmox ./ztd validateshould be green out of the box.
Ordered Linux work (each has a full checklist elsewhere):
- Phase C-verify (below) — boot
kvm+proxmox/ec2from.ztd/ztd.toml(noterraform.tfvars); relocate Terraform state →.ztd/state/. - Backend cohesion Phase 1 — make the
Terraform
tftpland the limaprovisionboth consume the onecloud-init/install-agent-stack.sh; boot-test all three TF backends. - Phase D (below) — publish the toolbox image; pin it in
compose.yaml. - Phase E (below) — release + Homebrew; the Linux tarball download is tested here.
Do NOT redo on Linux: the vz backend, ztd init, and the config→env layer are
macOS-proven and green — and vz cannot run on Linux (it needs macOS
Virtualization.framework). Run ./ztd test (kvm) there, not TARGET=vz ./ztd test.
The two problems this solves
- Assets aren’t bundled. The binary shells out to repo-relative files at
runtime —
scripts/*.sh, the Terraform root (*.tf+modules/),cloud-init/,lima/ztd.yaml.tmpl,compose.yaml,docker/Dockerfile,Taskfile.yml. There is nogo:embed; they’re read from the current directory, so abrew-installed binary would find none of them. - CWD is doing double duty — it’s both the program (source of the assets
above) and the project (
.ztd/config + keys + the working tree to share). Those must split: the binary finds its own assets (embedded); the user runs from their project.
The config north star (the user’s directive)
ztd.tomlis the ONLY config a user touches. Every dependency’s config — Terraform variables, the lima instance YAML, cloud-init, the compose env — is generated inside the binary fromztd.toml+ baked-in defaults, and is never written as a file the user references or edits.- Reasonable defaults for everything, so a
ztd.tomlthat says onlybackend = "vz"(or nothing at all, on a Mac) boots. - Concretely:
terraform.tfvarsis deleted (the binary feeds allTF_VAR_*from the resolved config);lima/cloud-init/composeare embedded + rendered to a scratch dir; the*.examplefiles and the host scripts’tfvar()helper (which readsterraform.tfvarsforusername/repo_target) go away.
Two settled decisions
ztd.tomllives under.ztd/(.ztd/ztd.toml, dropping the hidden.ztd.tomlname) so all project state is under one.ztd/dir.- Secrets stay OUT of
ztd.toml— the Proxmox token / AWS creds remain gitignored env files under.ztd/secrets/(ztd.tomlis meant to be committed and holds only non-secret config).
No repo split. The repo becomes the CLI (approach A below); the root-level source scaffolding is embedded or retired in place as it stops being needed.
Hard constraints / non-goals
Constraints
- Single binary, no runtime repo dependency. After install, nothing the binary
needs may live outside the binary except the user’s
.ztd/project dir. Assets are embedded (//go:embed) and extracted to a versioned cache dir. go:embedcan’t reach..— embed patterns are relative to the.gofile and cannot escape the module tree. This is why the module hoists to the repo root (Phase A): the assets (scripts/,lima/, …) currently sit above thecli/module and can’t be embedded from there.- One user config surface (
ztd.toml) + defaults. No user-facing dependency config files. The binary is the single source of generated Terraform/lima/cloud-init. - The other backends must not regress.
kvm/proxmox/ec2/vzkeep working at every phase; the Go suite stays green. - Cross-platform. linux + darwin, amd64 + arm64 (goreleaser already targets this).
localstays platform-native (vz on macOS, libvirt on Linux).
Non-goals
- Extracting the CLI to a separate repo (explicitly rejected — monorepo).
- A GUI / hosted control plane.
- New backends or backend features (this is packaging, not capability).
- Auto-installing host deps (lima/docker/libvirt) —
ztd checkguides; the Homebrew formula may declare deps, but ztd doesn’t install them.
Repository layout (target, after Phase A + B)
go.mod ⊕ hoisted to root — module gitlab.com/frob/ztd
main.go ⊕ package main (was cli/main.go)
cmd/ config/ engine/ ✎ moved up from cli/*; imports rewritten
readiness/ orchestration/
internal/assets/ ⊕ embedded runtime payload (embed.FS)
assets.go ⊕ //go:embed scripts lima cloud-init tf compose docker taskfile
scripts/… ← moved from repo-root scripts/
lima/ztd.yaml.tmpl ← moved from repo-root lima/
cloud-init/… ← moved from repo-root cloud-init/
tf/ (main.tf, variables.tf, outputs.tf, modules/) ← moved from repo root
compose.yaml, docker/, Taskfile.yml ← moved from repo root
ztd / ztd.sh ✎ dev shim: builds + execs ./bin/ztd (root module now)
.goreleaser.yaml ✎ drop dir:/gomod.dir (module at root); add brews:
docs/ Hugo docs (unchanged location)Runtime dirs (created by the binary, not shipped):
<cache>/ztd/<version>/… extracted assets (macOS ~/Library/Caches, else $XDG_CACHE_HOME)
<project>/.ztd/ztd.toml the ONE user config
<project>/.ztd/{keys,state,vz,runs,secrets}/ per-project state (as today)Execution model — phased red/green/verify
Each phase is one Opus-orchestrated red/green/verify cycle (red-green-verify
skill) closed by an independent adversarial review + a docs-update box. Two test
characters, as in the vz plan:
- Unit-testable (Go): asset resolution, config→
TF_VAR_*mapping, defaults, the platform/dir split — pure Go with table tests. - Structural / acceptance: the hoist and embedding are proven by the existing
suite staying green + a real
ztd upon a Mac (vz, the toolbox-free path) and, where possible, a Linuxkvm/remote run. A “red” for these is a new assertion (e.g. “assets resolve when CWD is an empty dir”) that fails pre-change.
Shared commands: task go:test / go:vet / go:fmt; task build:docs;
TARGET=vz ./ztd test (macOS acceptance, toolbox-free); ./ztd test (kvm, Linux).
Global rules (copy from the vz plan): red fails at runtime; green may not edit
tests/fixtures; verify re-runs the suite + a real run and git diffs the tests;
a phase isn’t done at green — the assume-bad review must find nothing; later phases
only add; close each phase by updating docs (rebuilt with task build:docs).
Milestones (not all phases are needed for the first install)
- MVP — Homebrew
vzon macOS: Phases A → B → C(partial) → E.vzneeds no Docker toolbox and no Terraform, so a Mac user getsbrew install ztd→ztd init→ztd upwith only lima. Phase D is deferred for this milestone. - Full standalone (all backends): add D (published toolbox image) + the
Terraform-side of C (state relocation, all
TF_VAR_*from config).
Phase map
| # | Delivers | Key risk |
|---|---|---|
| A | Hoist the Go module to the repo root | repo-wide import rewrite; goreleaser/shim/Taskfile paths |
| B | Embed assets; split assets dir vs project dir | scripts assume CWD==repo; toolbox mounts (assets vs project/state) |
| C | ztd.toml as the sole config; delete terraform.tfvars | schema expansion + defaults; tfvar() removal; TF state location |
| D | Publish the toolbox image; pin it in compose | registry + CI; version↔image tag coupling |
| E | goreleaser brews: + tap + release pipeline | formula deps; tag-driven release wiring |
Phase A — Hoist the Go module to the repo root
- Phase A complete
- Red/Green —
git mv’dgo.mod/go.sum/main.go+cli/{cmd,config,engine,readiness,orchestration,tools}to the repo root (history preserved as renames); module pathgitlab.com/frob/ztd/cli→gitlab.com/frob/ztd; rewrote every import; updated.goreleaser.yaml(droppedgomod.dir/builds.dir, fixed thecmd.Versionldflag),Taskfile.yml(droppeddir: cli, fixed thego:docspath), the./ztdshim (bin/ztd+ root.gostaleness find),.gitignore(/bin/), and the Dockerfile comment. Suite green at the new root;./ztd version/status,task go:docs(no diff), and gofmt all clean. - Verify + review —
task go:build/vet/test/fmtgreen; real./ztd version+./ztd status(→ vz on macOS) work; README/code/config carry no old module path; goreleasercheckvalidates;go:docsno diff. Independent review: all categories clean EXCEPT a half-staged index I’d created (git mvstaged the renames; the sed import-rewrites were unstaged → a commit would’ve been non-building). Fixed withgit add -A(staged .go files with the old path: 24 → 0; fresh build green) + the two cosmetic comment stragglers it flagged (ztd/Taskfile“cli/” mentions). - Done when: the module is rooted at the repo, the full suite is green, and
./ztdruns unchanged. ✅ - Update docs: CLAUDE.md Layout (root Go module) + the vz-gotcha path
(
cmd/vz.go); Dockerfile comment. (Module-path change is internal — no user-facing behavior change; the./ztdinterface is identical.)
- Red/Green —
Phase B — Embed assets; split assets dir vs project dir
- Phase B complete
- Red/Green —
assets.goat the repo ROOT (packagemain)//go:embedsscripts lima cloud-init+ the Terraform root +compose.yaml docker Taskfile.yml;mainregisters it viacmd.SetAssets.cmd/assets.goresolves the assets dir ($ZTD_ASSETS_DIR→ extract embedded to<UserCacheDir>/ztd/<version>/idempotently, “dev” re-extracts →""in tests soassetPathstays relative and the dispatch tests are unchanged);hostLanerunsbash assetPath(script). The project dir is split out:mainexportsZTD_PROJECT_DIR+ZTD_BIN,rootprefers$ZTD_PROJECT_DIR,vz.shsplitsASSETS(templates) vsPROJECT(.ztd/), and the agent scripts call"${ZTD_BIN:-./ztd}". Dev shim exportsZTD_ASSETS_DIR=$PWDfor live scripts. Unit tests:cmd/assets_test.go(resolveAssetsDir/extractAssets).- Deviation from the sketch: assets stay at the repo root (embedded from the
root
mainpackage + threaded viaSetAssets) rather than moved underinternal/assets/—go:embedmust be co-located with a package, and the root package ismain; this keeps the terraform/scripts dev workflow in place. - Scoped: the containerLane (kvm/proxmox/ec2) still runs
docker composeincwd=project(works from the source repo; from an arbitrary dir it needs the assets tf mounted separately from the project) — that landed in Phase D1, not “Phase C/D”: Phase C moved only the state. Phase B proves the toolbox-free vz path.
- Deviation from the sketch: assets stay at the repo root (embedded from the
root
- Verify + review — proven: the built binary run from an empty
/tmpdir (no source,env -u ZTD_ASSETS_DIR) extracted its assets and booted a real vz guest;.ztd/keys+ the rendered lima config landed in the PROJECT dir, scripts/templates came from the extracted cache;statusRunning; clean down. Full Go suite + gofmt/vet green; the dev-shim flow still works. Independent review: all categories clean except one HIGH —captureVZEndpoint(status --watch) hardcodedbash scripts/vz.shinstead ofassetPath, so--watchon vz would’ve failed from a non-source dir. Fixed (status.go→assetPath(vzScript)). Also hardened the two flaggedextractAssetsitems: keyed cache reuse on a build id (the binary’s mtime) so repeated + recursive invocations reuse instead of re-extracting (was “dev always re-extracts”, which could yank files from a running script), and a testinitclearsZTD_ASSETS_DIRso the dispatch tests can’t be perturbed by a shim-sourced shell. Left (noted): echoed./ztdnext-step hints and the container-lane “no compose.yaml” native error are cosmetic / Phase C/D. - Done when: a binary run from a project dir with no ztd source present
boots a
vzguest end-to-end. ✅ - Update docs: CLAUDE.md — the embed/assets-dir/project-dir/
ZTD_BINmechanism + the containerLane caveat. (User-facing “install & run anywhere” how-to lands with the release in Phase E.)
- Red/Green —
Phase C — ztd.toml as the sole config
- Phase C complete (macOS-provable scope; kvm/proxmox/ec2 boot is Linux-gated — see Phase C-verify)
- Red/Green —
config.Resolver(reusable env>toml>default) +ConfigPath(renamed.ztd/ztd.toml, legacy.ztd/.ztd.tomlfallback);orchestration.BackendEnvemitsZTD_*(vz sizing/user/image/repo_target) + the fullTF_VAR_*set fromztd.toml+defaults, merged intodispatchEnv;compose.yamlforwards the wholeTF_VAR_*namespace. Ported all host scripts OFF thetfvar()helper toZTD_*/TF_VAR_*env (helper removed).vz.shreadsZTD_MEMORY_MB/ZTD_DISK_GB+ honorsZTD_REPO_TARGET. Newztd initscaffolds.ztd/ztd.toml(the old terraform-initverb removed; Taskfile up/plan/validate auto-runterraform init). Deletedterraform.tfvars+.ztd/.ztd.toml.example. ConfigConfigstruct- its tests unchanged (topology read via the Resolver, not typed fields).
- Deferred to Phase C-verify (per the option-2 decision): the Terraform
state →
.ztd/state/relocation ships with the Linux boot verification (it needs a real apply to confirm), not this turn.
- Verify + review — proven on macOS:
ztd initscaffolds + is idempotent;ztd up(vz) from a project whose ONLY config is a custom.ztd/ztd.toml(vcpus=1 memory_mb=2048 username=agent) renderedcpus: 1,memory: "2048MiB", useragentand booted — config→env→vz end-to-end.TARGET=proxmox ./ztd validate→ “configuration is valid” with NOterraform.tfvars. Full Go suite + vet + gofmt green. kvm/proxmox/ec2 boot is unverifiable on macOS → Phase C-verify (Linux). Independent review — one HIGH fixed: the deletedterraform.tfvarsheld this repo’s REAL topology (proxmoxmcp/ztd-ssh/insecure, the AWS ingress IP) which BackendEnv’s defaults did NOT reproduce (→ broken proxmox boot / world-open AWS SG); migrated it into a committed.ztd/ztd.toml. MEDIUM fixed:[vm] archwas advertised but unread —BackendEnvnow resolvesvm.arch(tested). LOWs fixed: staleterraform.tfvarshints incheck-requirements.sh→ztd.toml; a config test for the primary.ztd/ztd.tomlname. Left (LOW, tracked): the deadscripts/load-config.sh+ itsztd test:configsmoke check still key off the old name — remove in a cleanup pass. - Done when: the whole system runs from
.ztd/ztd.toml+ defaults; no external dependency-config file exists or is read. (vz ✅; kvm/proxmox/ec2 ✅ — see Phase C-verify below.) - Update docs: a full
ztd.tomlreference (every key + default); retire the tfvars references in the proxmox/ec2 how-tos. (Landed with Phase C-verify’s doc pass below —docs/content/reference/configuration.mdrewritten as theztd.tomlreference;proxmox-backend.md/ec2-backend.md/README.md/variables.tfcomments retired theirterraform.tfvarsmentions.)
- Red/Green —
Phase C-verify — boot the Terraform backends from ztd.toml (Linux)
Confirm on a Linux host what macOS can’t: that the TF_VAR_*-from-config flow
actually provisions kvm/proxmox/ec2 with no terraform.tfvars.
This repo already carries a migrated .ztd/ztd.toml (proxmox mcp/ztd-ssh/
insecure, the AWS ingress IP — the values from the retired terraform.tfvars), so
the dogfood config is ready; the toolbox mounts the source repo at /work, so a run
from this checkout is the test (from-an-arbitrary-dir Terraform is Phase B’s deferred
containerLane rework, tracked separately — not needed here).
- Phase C-verify complete
-
./ztd up(kvm) boots green from the committed.ztd/ztd.toml(+.ztd/secrets/*.env), with noterraform.tfvars;./ztd downclean. Confirm the guest got the config’s sizing (virsh dominfo) — provesTF_VAR_vcpus/etc. flowed. Spot-check a non-default override (e.g.[vm] vcpus = 4) actually changes the VM. — Bootedztd-ztd-dev;virsh dominfoshowed CPU(s)=2, Max memory=4194304 KiB, exactly matching.ztd/ztd.toml’svcpus=2/memory_mb=4096.ZTD_VCPUS=4 ./ztd up(env-override path) rebuilt with CPU(s)=4, confirming env > toml precedence. Guest Docker (docker run hello-world) andclaude --versionboth worked over SSH../ztd downdestroyed cleanly both times. -
TARGET=proxmox ./ztd up+TARGET=ec2 ./ztd upboot green — the real test of the migrated topology (proxmox_node=mcpreaches the node; the AWS SG uses the[aws] ssh_ingress_cidr, not0.0.0.0/0). Confirm the SG ingress withaws ec2 describe-security-groups. — Proxmox: booted VM 132124044 on nodemcpat 192.168.1.133 (2 vcpu/3.8Gi RAM matching config); guest Docker +claude --versionworked over SSH;./ztd downdestroyed cleanly. Needed anssh-agentwith the operator key loaded, exported asSSH_AUTH_SOCK(notZTD_SSH_AUTH_SOCK— that’s compose’s mount target;orchestration/hostenv.goreadsSSH_AUTH_SOCK) — this Linux box had no agent running by default, unlike the prior macOS session. EC2: booted at 18.237.87.121; noawsCLI on this host, so the SG ingress was confirmed by readingaws_security_group.ztd’singress[0].cidr_blocksstraight out ofterraform.tfstate—["203.0.113.10/32"], matching.ztd/ztd.toml’s[aws] ssh_ingress_cidr, not0.0.0.0/0. Guest Docker + Claude Code confirmed over SSH;./ztd downdestroyed cleanly. - Relocate Terraform state →
<project>/.ztd/state/terraform.tfstate(currently lands at the repo root asterraform.tfstate). Mechanism: add abackend "local" { path = ".ztd/state/terraform.tfstate" }tomain.tf’sterraform {}block (path is relative to/work= the mounted project), or setTF_DATA_DIR+-statein the Taskfile. Confirm a full up/down cycle and that the repo root is clean. Add.ztd/state/to.gitignore(already listed). — Added thebackend "local"block tomain.tf. Verified with a real kvm up/down:.ztd/state/terraform.tfstatewas created (root-owned, written by the toolbox),validate/upneeded no interactive migration since the pre-existing root-level state was empty (0 resources). Deleted the now-stale rootterraform.tfstate/terraform.tfstate.backup; repo root is clean (.ztd/state/was already gitignored). - Review: every
TF_VAR_*BackendEnv/compose supplies matchesvariables.tf; no default silently changed the VM shape vs the oldterraform.tfvars. — Diffedorchestration/backendenv.go’sTF_VAR_*map against everyvariableinvariables.tf: all topology vars (name/arch/vcpus/memory_mb/disk_gb/username/repo_dirname/repo_target/repo_source/debian_image_url/proxmox_*/aws_*) are supplied by BackendEnv fromztd.toml+ matching defaults. The 3 vars BackendEnv does NOT set (target,ssh_public_key_path,libvirt_uri) are intentionally out of scope —targetflows viaTARGET/dispatch elsewhere, and the other two keep theirvariables.tfdefaults (ephemeral-key path,qemu:///system), which were never in the oldterraform.tfvarseither. No drift found. - Update docs: proxmox/ec2 how-tos — topology now in
ztd.toml, secrets in.ztd/secrets/; note state lives in.ztd/state/. —docs/content/reference/configuration.mdrewritten as the fullztd.tomlreference (every[vm]/[agent]/[proxmox]/[aws]key, env override, default);how-to/proxmox-backend.md+how-to/ec2-backend.md+README.mdretired theirterraform.tfvarsmentions in favor of.ztd/ztd.toml;variables.tfcomments updated too. Left (LOW, tracked): the deadscripts/load-config.sh+ztd test:configstill key off the legacy.ztd/.ztd.tomlname — a pre-existing cleanup item noted at the end of Phase C, unchanged by this pass.
-
Phase D — Publish the toolbox image (Terraform backends)
- Phase D complete
Red — an acceptance assertion that
kvm/proxmox/ec2provision using a pulledztd-toolbox:<version>image (no localbuild:), failing today (compose builds from./docker). — Two layers. Go:orchestration/toolbox_test.go—TestComposeConsumesToolboxEnvreads the realcompose.yamland fails on the hard-codedimage: ztd-toolbox:local/ absentpull_policy, plusTestToolboxImageIsVersionPinned/…PullPolicyFollowsVersion/…EnvOverridesfor the derivation. Shell:assert_toolbox_imageinscripts/smoke-lib.sh, called from the three container-lane suites’ step 0 — it resolves the image through realdocker compose config(so an interpolation typo can’t pass) and asserts the image/policy pairing matches the build kind. Confirmed red: the Go suite failed to build against the missingorchestration.Toolbox*seam, and everywantFullEnv-based dispatch test went red once the expectation includedToolboxEnv.Green — CI builds + pushes
ztd-toolboxto a registry on tag; the embeddedcompose.yamlreferences the pinned published image (version == binary version), withbuild:kept only as a dev fallback. —orchestration/toolbox.goderives the image fromcmd.Version(release →registry.gitlab.com/frob/ztd/ztd-toolbox:<version>; dev/snapshot →ztd-toolbox:local) with pull policymissingfor BOTH — compose resolves it per image (registry tag → pulled;ztd-toolbox:local, in no registry → falls through tobuild:).dispatchEnvinjectsZTD_TOOLBOX_IMAGE/ZTD_TOOLBOX_PULL_POLICYinto every verb’s env, andcompose.yamlconsumes them with the dev values as fallbacks —build:kept.ztd configgainedtoolbox_image/toolbox_pull_policyso the resolution is inspectable (and so the shell assertion has a non-circular source of truth). New.gitlab-ci.yml(the repo had NO CI):test:goon every push (gofmt/vet/ test inside a freshly built toolbox, so a broken Dockerfile can’t reach a tag) andtoolbox:publishon tags only — multi-arch buildx push of$CI_REGISTRY_IMAGE/ztd-toolbox:${CI_COMMIT_TAG#v}+:latest, then animagetools inspectso a missing tag fails loudly.Verify + review — a Terraform-backend run pulls the image and provisions; the image tag matches the binary version. Review: air-gapped/build fallback still works for contributors; registry auth for private images. — Go suite + vet green. Both paths exercised end-to-end on the host: the dev binary reports
ztd-toolbox:local/buildandassert_toolbox_imagepasses 2/2; a binary rebuilt with-X …cmd.Version=0.4.0reportsregistry.gitlab.com/frob/ztd/ztd-toolbox:0.4.0/missing,docker compose config --imagesresolves to exactly that ref, and the assertion correctly fails only its “image is present locally” leg — nothing is published at 0.4.0 yet, which is the assertion doing its job (it goes green on the first tagged CI publish). Air-gapped/contributor fallback:build:retained andZTD_TOOLBOX_PULL_POLICY=buildforces it on a release. Registry auth is deliberately NOT ztd’s job —docker loginfirst (documented). REGRESSION FOUND AND FIXED AFTER THIS BOX WAS FIRST TICKED. The policy was initiallybuildfor dev. That rebuilds the toolbox on every container-lane invocation, and Docker Compose writes build progress to stdout — soIP="$(ztd ip)", which every host-side script uses to locate the guest, captured BuildKit output instead of an address, silently breakingztd auth,mount,fetch,runandaudit. Caught by./ztd test(auth failed); the original Phase-D verification missed it because it only checked that compose resolved the right image name, never that a verb’s stdout stayed clean. Fixed tomissingfor both (semantics confirmed empirically with throwaway compose files: a registry-resolvable tag is PULLED undermissing, an unresolvable one falls through tobuild:),guest-endpoint.shhardened to take the last non-empty line + shape-check it, andassert_toolbox_imagenow pinspull_policy = missingso it cannot regress. Re-verified on real infrastructure afterwards:./ztd test(kvm) 32/32,TARGET=proxmox ./ztd test35/35,TARGET=ec2 ./ztd test35/35 — the two remote suites resolve the guest through./ztd ipfor mount/fetch/run/audit, so they are the broad confirmation the fix is correct. Since resolved: the CI file has now been run by real GitLab pipelines and the image is published —ztd-toolbox:0.4.2(and:latest) resolve as a two-platform manifest list, confirmed against the registry.Done when: an installed binary runs
kvm/remote backends with no local Docker build, from the pinned image. — Demonstrated end to end on0.4.2. CI publishedregistry.gitlab.com/frob/ztd/ztd-toolbox:0.4.2(manifest list = exactly linux/amd64 + linux/arm64, no attestation entries;:latestat the same digest). A binary stamped-X cmd.Version=0.4.2, run from outside the source tree, resolved that ref, pulled it after the local copy was deleted (Image …:0.4.2 Pulling), ranterraform validateinside it (Success!), provisioned a real guest (upexit 0, IP 192.168.122.168,hostname= ztd-ztd-dev over SSH), and tore it down clean.ztd-toolbox:localwas never touched — no local build happened at any point. — Registry visibility RESOLVED: the published image is anonymously pullable (docker manifest inspectsucceeds with nodocker login), so a released binary works for someone who has never authenticated to GitLab. — Caveat at the time, RESOLVED by Phase D1: the container lane then randocker composein the PROJECT dir and mounted./:/work, so the Terraform backends still needed the source repo in CWD. Phase D1 split that into/work(assets) +/project.Publish the first image — done on tag
0.4.2. Note0.4.1FAILED to publish: buildx attaches provenance attestations asunknown/unknownmanifest entries and the GitLab registry rejects them withblob unknown to registryafter the layers upload. Fixed with--provenance=false --sbom=false.Run a Terraform backend from a released binary — see “Done when”.
Update docs: how-to — image source + version pinning; contributor build. — New
docs/content/how-to/toolbox-image.md(the rule,ztd configoutput, both env overrides, private registries, building it yourself) + how-to index; CLAUDE.md gained a toolbox-pinning gotcha including the tag-must-match-CI invariant.
Phase D1 — Relocate the compose root (run from an arbitrary dir)
Phase D made the toolbox image installable. This makes the toolbox invocation
installable. Until it lands, brew install ztd && cd anywhere && ztd up is true
only for vz; the three Terraform backends still require a source checkout in the
working directory, which makes the Phase-E packaging hollow.
The problem. containerLane runs docker compose with cwd = the project dir
and compose.yaml mounts ./:/work, so the toolbox receives the directory you
invoked from and the Terraform root has to be inside it. Note this is NOT the
state relocation deferred in Phase B — Phase C already moved state to
<project>/.ztd/state/. What remains is the config root and the compose file
itself. (Phase B’s note and CLAUDE.md both call this “Phase C/D — state
relocation”; both are stale and are corrected by this phase.)
The shape of the fix. Split the single /work mount in two, mirroring the
assets-vs-project split the binary already makes everywhere else:
| Container path | Host source | Holds |
|---|---|---|
/work | the assets dir (extracted cache, or $ZTD_ASSETS_DIR) | main.tf, modules/, cloud-init/, Taskfile.yml |
/project | the project dir ($ZTD_PROJECT_DIR) | .ztd/ — state, keys, secrets |
Four consequences, each of which is a real edit rather than a detail:
docker compose -f <assets>/compose.yaml— the compose file itself comes from the assets tree, since an arbitrary project dir has none.An explicit
-p <project>is REQUIRED, not cosmetic. With-fpointing at the cache, compose derives the project name from that dir’s basename — which is the version (0.4.2). Dots are illegal in a compose project name, so without-pevery released binary fails immediately.TF_DATA_DIR=/project/.ztd/terraformsoterraform initwrites providers into the project, not the read-mostly asset cache.Taskfile paths that are project state (
.ztd/keys/id_ed25519) must move to/project/...; paths that are config stay relative to/work.Phase D1 complete
- Red — an acceptance assertion that a container-lane backend provisions
from a project dir containing only
.ztd/. —cmd/compose_root_test.go:TestContainerLaneUsesAssetsComposeFile,TestContainerLaneSetsProjectName,TestComposeSplitsAssetsFromProject,TestDispatchEnvCarriesBothDirs. All four confirmed red. The behavioural red was the real one: booting from an empty/tmpdir with an installed-style binary. - Green — the
/work(assets) +/projectsplit,-f/-pon the lane,TF_DATA_DIR=/project/.ztd/terraform, Taskfile key paths under/project,.terraform.lock.hclembedded, andZTD_ASSETS_DIR/ZTD_PROJECT_DIRset EXPLICITLY bydispatchEnvrather than relying on compose’s${VAR:-.}fallback (which resolves to the compose file’s own dir — correct today only by coincidence). Two latent bugs surfaced and were fixed:cmd/status.gohand-rolled its owndocker compose … task ipargv instead of usingcontainerLane, soztd statuswould have broken from any non-checkout dir. Now routed through the lane.var.ssh_public_key_pathdefaulted to.ztd/keys/id_ed25519.pub, relative to the terraform root — which is now the ASSET tree.main.tfguards the read withfileexists(), so it did not error: it injected an EMPTY authorized_key and booted a guest nobody could reach. It passed from a checkout purely because/workhappened to contain.ztd/. Default is now absolute under/project. This is exactly the class of bug the bare-dir acceptance existed to catch, and only the bare-dir run caught it.
- Verify + review — no regression from the source checkout:
./ztd test(kvm) 32/32,TARGET=proxmox ./ztd test35/35,TARGET=ec2 ./ztd test35/35 (the remote suites are the strong ones here — mount/fetch/run/audit all resolve the guest through the container lane). Bare-dir acceptance: a binary stamped0.4.2, run withZTD_ASSETS_DIR/ZTD_PROJECT_DIRunset from/tmp/ztd-d1-projcontaining only.ztd/, booted a guest (192.168.122.147), SSH’d in on the PROJECT’s own ephemeral key, and tore down clean. Review — nothing writes to the asset cache: state, providers (TF_DATA_DIR) and keys all landed under the project, and afindover~/.cache/ztd/0.4.2forterraform.tfstate*/id_ed25519*came back empty. Two projects on one host: the lane pins-p ztd, and every container is arun --rmone-shot with a compose-assigned random suffix; withnetwork_mode: hostand no named volumes there is no project-scoped resource left to collide over. - Done when:
kvm/proxmox/ec2provision from a project dir holding only.ztd/, matching whatvzalready does. — Demonstrated above. - Update docs: — CLAUDE.md’s assets-vs-project gotcha now states the
/workvs/projectcontainer-lane contract and drops the “needs the source repo in CWD” caveat; Phase B’s stale “lands in Phase C/D” note and Phase D’s Done-when caveat both corrected to point here.
- Red — an acceptance assertion that a container-lane backend provisions
from a project dir containing only
Phase E — Release pipeline + Homebrew (non-red/green)
Implement-and-review pass (packaging/CI, not unit-testable).
- Phase E complete (the clean-Mac
brew installis the one item that needs a Mac and a real tag — see “Left to verify” below)- Homebrew — a
homebrew_casks:block, NOTbrews:. Homebrew deprecated formulae that install pre-built binaries, goreleaser followed, andgoreleaser checkFAILS onbrews:as of v2.17 (confirmed empirically before writing the config). A cask is macOS-only, which matches the plan exactly: brew on macOS, tar.gz on Linux. Declares lima as a dependency and strips the Gatekeeper quarantine attribute in apostflight(unsigned binaries). Docker is left a documented prerequisite — Desktop/Colima/other is the user’s choice, not something a cask should decide. nfpm.deb/.rpmskipped (the plan marked it optional); the tarball + checksums cover Linux. - Tag → release pipeline —
release:cliin a newreleasestage, so it runs AFTERtoolbox:publish. That ordering is load-bearing, not tidiness: a released binary derives its toolbox image tag from its own version, so binaries published before the image exists hand anyone installing in the gap aztd upthat cannot pull.scripts/ci/release.shasserts the stamped version equals${CI_COMMIT_TAG#v}— the exact stringpublish.shpushed. The job image IS goreleaser (no dind), which sidesteps the bind-mount trapci/test.shdocuments rather than working around it; the shared script uses a native goreleaser when one is on PATH anddocker runlocally. - Verify —
task release:cigreen: four archives (linux/darwin × amd64/arm64),checksums.txt, and a generated cask. The rehearsal asserts the things a bare--snapshotwill not: the full arch matrix (goreleaser exits 0 on a subset just as happily), a runnable binary whose version ldflag is actually stamped (an unstampeddevbuild resolves its toolbox image toztd-toolbox:local, which exists in no registry),ztd initsucceeding from a bare dir withZTD_ASSETS_DIR/ZTD_PROJECT_DIRunset — the embedded assets survived the release build — and the cask’s dependency, quarantine hook and download URL.- One real bug caught by that URL assertion: goreleaser picks its forge
from which
*_TOKENis in the environment, NOT from the git remote or therelease:block. With none set it assumes GitHub and wrotegithub.com/frob/ztddownload URLs into the cask — a repo that does not exist — while the build, archives and checksums all stayed green. The rehearsal now exports a dummyGITLAB_TOKEN(--skip=publish, so nothing is published) to reproduce CI faithfully. - The real tag has since happened.
0.4.3published all four archives pluschecksums.txtto the GitLab release, and pushedCasks/ztd.rbtofrob/homebrew-ztd— both confirmed against the API. Two defects surfaced only once a tag ran for real, and are fixed: the release body was empty (goreleaser generates none whenchangelog.disableis set, and does not treat that as an error — the CHANGELOG section for the tag is now extracted and passed via--release-notes, with a missing section a hard failure), and tags0.4.0–0.4.2predated therelease:clijob so they published an image but no CLI. - Left to verify (needs a clean Mac):
brew tap+brew install --cask ztd, thenztd uponvzfrom a project whose only config is.ztd/ztd.toml. Everything up to and including the tap push is now proven.
- One real bug caught by that URL assertion: goreleaser picks its forge
from which
- Update docs: new
how-to/install.md(brew, the Linux download with checksum verification, from source, the first-run flow, and where ztd puts things —<project>/.ztd/vs the disposable version-scoped asset cache); the Quickstart and README rewritten to start from an installed binary rather than a cloned repo; CONTRIBUTING gained a “Cutting a release” section and therelease:cirehearsal.
- Homebrew — a
Specification
§1 — Assets & the cache dir
Embed scripts/ lima/ cloud-init/ tf/ compose.yaml docker/ Taskfile.yml via
//go:embed. Extract once per version to <cache>/ztd/<version>/ (macOS
~/Library/Caches/ztd, else $XDG_CACHE_HOME/ztd → ~/.cache/ztd). The extracted
tree is read-only program payload; never the project. Re-extract only when the
version dir is absent (idempotent, cheap).
§2 — Project dir vs assets dir
ZTD_PROJECT_DIR = the dir the user invoked from (holds .ztd/, the tree to
mount). The binary exports it; host scripts read .ztd/… and repo-source from it,
and templates from the assets dir. This retires the current CWD==repo assumption
(vz.sh cd $(dirname)/..; agent scripts’ $PWD/.ztd).
§3 — Config schema (.ztd/ztd.toml)
Superset of today’s [vm]/[agent]: backend, name, vcpus, memory, disk, arch,
username, repo-sync; [proxmox] node/datastore/bridge/…; [aws] region/type/…;
[agent] skills/commands/max_time/max_turns. Every key has a default; the binary
resolves env > ztd.toml > default (unchanged precedence) and emits the full
TF_VAR_* set. Secrets are NOT here — .ztd/secrets/*.env as today.
§4 — Generated dependency config
From the resolved config the binary produces, in the scratch/cache dir only:
TF_VAR_* env (no terraform.tfvars); the rendered lima YAML; the rendered
cloud-init; the compose env. Terraform state lives in <project>/.ztd/state/.
Nothing here is a user-editable file.
§5 — ztd init
Scaffold <project>/.ztd/ztd.toml from an embedded template with commented
defaults, and .ztd/secrets/*.env.example stubs for the remote backends. Idempotent
(won’t clobber an existing ztd.toml).
§6 — Release
goreleaser at the repo root (module hoisted): cross-platform binaries + tar.gz +
checksums + a Homebrew formula pushed to a tap, plus the tagged toolbox image.
Version flows via the existing cmd.Version ldflag and stamps the asset cache dir +
the image tag.
Taskfile
Two Taskfiles, unchanged in spirit: the host dev Taskfile (go:*, build:docs)
points at the root module after Phase A; the toolbox Taskfile (up/down/… run
inside the container) becomes an embedded asset (extracted into the toolbox’s
/work). No new user-facing tasks — the Go CLI is the interface. Everything stays
containerized (containerized-tooling) and verb:subject (taskfile-conventions).
Definition of done
-
brew install ztd(macOS) / a downloaded binary (Linux) yields a workingztdwith no ztd source checkout present. — The Linux half is proven end-to-end (task test:standalone, andtask release:cirunsztd initfrom a bare dir using the archived binary). The macOS half is proven through the tap push —0.4.3put the archives on the release andCasks/ztd.rbin the tap — leaving only thebrew installon a clean Mac. - From an arbitrary project dir:
ztd init→ edit/accept.ztd/ztd.toml→ztd upboots a guest (vz on macOS, libvirt on Linux) using only.ztd/ztd.toml- defaults. — Phase D1 verify (a
0.4.2-stamped binary booting from/tmp/ztd-d1-proj) andtask test:standalone.
- defaults. — Phase D1 verify (a
- No external dependency-config file exists or is read — no
terraform.tfvars, no user-facing lima/cloud-init/compose files; all generated inside the binary. — Phase C. - Secrets remain in
.ztd/secrets/;ztd.tomlis safe to commit. — Phase C;ztd check’s git-hygiene section now enforces the second half. - All backends (
vz/kvm/proxmox/ec2) work from the installed binary; Terraform backends pull the pinned toolbox image. —kvm/proxmox/ec2proven from an installed-style binary (Phase D1 +test:standalone, withassert_toolbox_imageguarding the pin).vzis proven from the dev wrapper only — the macOS backend has not been re-run against an installed binary; it shares the same asset/project resolution, but that is inference, not a run. - Go suite green; docs build green; every code phase (A–D) landed via red/green/verify + independent review, Phase E the implement-and-review pass.