The Embedded Playbook
sand provisions each VM's software with an Ansible playbook — the same
site.yml, roles/, ansible.cfg, and group_vars/ this repository has
carried since before its conversion to a Go CLI. That playbook is not a
separate tool you run: it is baked into the sand binary and driven
entirely by sand itself. This page documents the mechanism for
contributors who need to touch roles/ or site.yml. It is not a guide
to running Ansible by hand — nothing here should be read as an
instruction to install Ansible or invoke ansible-playbook; sand does
both, inside the guest, on your behalf.
The fileset is embedded in the binary
playbook_embed.go at the repository root go:embeds the whole fileset —
site.yml, ansible.cfg, inventory, roles/, and group_vars/ — into
the sandbar module as PlaybookFS. This is what lets a Homebrew-installed
sand provision without a repository checkout anywhere on disk: the
playbook travels inside the compiled binary.
How the playbook directory is resolved
At run time, provision.LocatePlaybook() (internal/provision/playbook.go)
picks the directory to mount into the VM in two tiers:
- Working tree first. It runs
git rev-parse --show-topleveland, if that succeeds and the resulting directory containssite.yml, uses it directly. - Embedded fallback. Otherwise — no git checkout, e.g. a
Homebrew-installed binary run outside any repository — it extracts
PlaybookFSto a fresh private temp directory and uses that.
This is the single most useful fact on this page for a contributor: if
you run go run ./cmd/sand from inside this checkout, your uncommitted
edits to roles/ or site.yml take effect on the very next provision.
There is no rebuild-and-reinstall step to remember.
The mount, and what runs where
The resolved directory is mounted into the guest as its only mount, and
that mount is read-only. Ansible itself is installed inside the
guest, not on the host, and the playbook runs there with
--connection=local — the host never executes ansible-playbook at all.
Inside the guest, the in-guest provisioning script (inGuestScript in
internal/provision/provision.go) rsyncs the mounted playbook fileset into
a guest-local working copy before each run, filtered to exactly the members
playbook_embed.go declares (site.yml, ansible.cfg, inventory,
roles/***, group_vars/***) — never the whole mount, which in
working-tree mode would otherwise be an entire git checkout. Per-phase
extra-vars are streamed into the guest over stdin into /dev/shm (tmpfs)
and removed on exit; they are never placed on argv or written to the
persistent disk, so a clone token never appears in a process listing.
The three provisioning phases
site.yml's roles gate on a provision_phase variable
(internal/provision/vars.go sets it per run) that takes one of three
values:
| Phase | What runs | When |
|---|---|---|
base |
Heavy setup: base, (conditionally) samba, dev-tools, claude-code |
Building the shared base image once, before any clone exists |
finalize |
Light, per-VM identity: base, user, project |
Against each limactl clone of the base image |
full |
Everything, in one pass | The default when the phase isn't otherwise specified |
This split is what lets sand build one expensive base image and clone it
cheaply for every subsequent VM, running only the light identity-specific
work (hostname, git identity, optional project clone) against each clone.
The six roles
roles/ has six roles: base, user, samba, dev-tools, claude-code,
and project. site.yml runs them in that order, gated by
provision_phase as above. samba is worth calling out specifically:
internal/provision/vars.go sets samba_enabled: false on every sand
run (Lima VMs use a bind mount or limactl copy instead of a Samba share),
so the role exists in the tree and is exercised by CI's syntax check, but it
does not execute on the sand path.
CI coverage
The lint job in .github/workflows/test.yml runs
ansible-playbook -i localhost, --connection=local site.yml --syntax-check
on every push and pull request, so a syntax error anywhere in the playbook
fails CI fast — this is the one place in the pipeline where
ansible-playbook runs directly, and it exists purely to validate the
fileset that sand embeds.
inventory is vestigial
inventory still contains a single placeholder host
(ansible_host=CHANGE_ME), and it is part of the embedded fileset. It is a
holdover from before the Go conversion, when the playbook was run directly
against a real inventory. On the sand path it is unused: sand invokes
Ansible with -i localhost, inside the guest and never reads this file.
It stays in the tree because it is embedded and harmless, not because
anything on the sand path consults it.