Skip to content

feat(project): declare a repository's VMs in stoat.toml - #69

Merged
NovusEdge merged 60 commits into
mainfrom
feat/project-file
Sep 5, 2026
Merged

NovusEdge merged 60 commits into
mainfrom
feat/project-file

Conversation

@NovusEdge

@NovusEdge NovusEdge commented Sep 5, 2026 •

Copy link
Copy Markdown
Owner

What changed

stoat.toml declares a repository's VMs. git clone then stoat up builds them.

  • internal/project loads and validates the file in Reject mode, keeps declaration order, resolves shares inside the project after EvalSymlinks, and reads the project's secrets.
  • core.SpecFor, core.Diff and core.Reconcile turn a declaration into a VM and keep it matching. image and disk are immutable; a change errors and names stoat rm <key>. cpus, ram and shares report needs_restart. Reconcile unsets params and secrets a declaration drops, and is a no-op on disk when nothing changed.
  • vm.toml records the project and its shares; qemu, apkovl, cloud-init and sshx mount every share.
  • stoat init writes the annotated sample; stoat status reports state, health and drift.
  • up, down, apply, wait and rm act on every declared VM in declaration order when given no argument. A bare up reconciles every declaration before it starts any.
  • ls gains a project column and --project; new refuses inside a project unless --global.
  • The MCP server takes the same project scope from its cwd and gains the project_* tools.

Why

Spec docs/specs/2026-09-04-project-file-design.md. A contributor should get the maintainer's VMs from the repository.

Tests run

  • go build ./... && go test ./... && golangci-lint run ./... clean at the tip, rebased onto main after feat(mcp): serve MCP from the stoat binary #68.
  • Three chunk reviews plus two closeout rounds and a plan-level final review (opus), each with fix rounds. The reviews caught and fixed: Diff comparing the catalog id against the stored ISO path; an omitted disk never reconciling; a bare up skipping later declarations after the first failure; params and secrets of a dropped recipe failing the update.
  • Live check on Debian 13 and Ubuntu 24.04 cloud VMs: stoat init, a bare stoat up, status with drift, ls --project, the new refusal, project shares mounted over 9p in the guest, a bare down and rm. Evidence in the comment below.

Deviations from the plan, all reviewed

  • The plan's confirm and qemu.Args signatures were stale; main's won.
  • Args.Project collided with mcp-go's mcp install --project flag, renamed to Args.InstallProject.
  • The bare up reconciles every declaration before any start. The plan's inline code reconciled inside the fan-out, which left later declarations uncreated after a first boot failure.
  • tomlx Reject mode now treats keys nested under a map[string]any field as known, pinned by tests; six remote-recipe fixtures moved from an unknown [vm] table to [project].

Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Reconcile named a fully-dropped recipe in UnsetParams, but by then
Update had already removed it from Recipes, so manifestForVM refused
it with ErrRecipeNotApplicable. Update now clears a dropped recipe's
params and secrets itself, and removedParams skips a recipe no longer
in the declaration since Update already covers it.

Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
Signed-off-by: NovusEdge <novusedge0@gmail.com>
@NovusEdge NovusEdge added enhancement New feature needs-live-boot Cannot be verified by agents; needs a real Alpine boot labels Sep 5, 2026
@NovusEdge NovusEdge self-assigned this Sep 5, 2026
@coderabbitai

coderabbitai Bot commented Sep 5, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@NovusEdge

Copy link
Copy Markdown
Owner Author

Live check at e1fdf97 on a temp project, cloud mode, 2048 MiB, 2 CPUs.

Debian 13: stoat init, a bare stoat up, status, ls --project, the new refusal, drift after a cpus edit, a bare down and rm. /work is absent there by design: Debian's cloud kernel has no 9p module and its guest file sets skip_9p, which predates this PR.

=== stoat init
{"v":3,"type":"result","cmd":"init","ok":true,"data":{"path":"/home/novusedge/vms/stoat/.worktrees/.live-wave-b.ytR40g/project/proj-2212432/stoat.toml","project":"proj-2212432","gitignore_updated":tru
=== stoat.toml (edited: debian-13, 2048 MiB, 2 CPUs)
schema = 1
[project]
name = "proj-2212432"
[recipes]
[vms.dev]
image = "debian-13"
cpus = 2
ram = 2048
disk = "8G"
recipes = []
shares = ["."]
agent_access = "manage"
.stoat/
=== stoat up (no argument, reconciles then starts every declaration)
{"v":3,"type":"result","cmd":"up","ok":true,"data":{"project":"proj-2212432","vms":[{"key":"dev","name":"proj-2212432-dev","status":"ok"}]}}
=== stoat wait --until reachable
=== stoat wait --until reachable
{"v":3,"type":"result","cmd":"wait","ok":true,"data":{"reached":true,"until":"reachable","vm":"proj-2212432-dev","waited_ms":14}}
=== stoat status
KEY          NAME                 STATE     HEALTH    DRIFT
dev          proj-2212432-dev     running   -         -
{"v":3,"type":"result","cmd":"status","ok":true,"data":{"project":"proj-2212432","dir":"/home/novusedge/vms/stoat/.worktrees/.live-wave-b.ytR40g/project/proj-2212432","vms":[{"key":"dev","name":"proj-2212432-dev","state":"running","health":"unknown","drift":[]}]}}
=== stoat ls --project
NAME            MODE  STATE    CPUS  RAM    SSH    PROJECT
proj-2212432-dev cloud running  2     2048   2203   /home/novusedge/vms/stoat/.worktrees/.live-wave-b.ytR40g/project/proj-2212432
=== share in the guest
{"v":3,"type":"result","cmd":"exec","ok":true,"data":{"vm":"proj-2212432-dev","stderr":"ls: cannot access '/work': No such file or directory\n","exit_code":2}}
=== stoat new refused inside a project
{"v":3,"type":"result","cmd":"create","ok":false,"error":{"code":"invalid_spec","message":"invalid spec: a stoat.toml is present; declare the VM there and run stoat up, or pass --global"}}
=== drift: change cpus, status reports needs_restart
{"v":3,"type":"result","cmd":"status","ok":true,"data":{"project":"proj-2212432","dir":"/home/novusedge/vms/stoat/.worktrees/.live-wave-b.ytR40g/project/proj-2212432","vms":[{"key":"dev","name":"proj-2212432-dev","state":"running","health":"unknown","drift":[{"field":"cpus","from":"2","to":"1","need
=== stoat down and rm (no argument)
proj-2212432-dev stopped
{"v":3,"type":"result","cmd":"down","ok":true,"data":{"project":"proj-2212432","vms":[{"key":"dev","name":"proj-2212432-dev","status":"ok"}]}}
proj-2212432-dev deleted
{"v":3,"type":"result","cmd":"rm","ok":true,"data":{"project":"proj-2212432","vms":[{"key":"dev","name":"proj-2212432-dev","status":"ok"}]}}
PROJECT-LIVE-DONE

Ubuntu 24.04: the same project with shares = [".", "src"]. Both mount over 9p and the host marker file reads back.

image = "ubuntu-24.04"
shares = [".", "src"]
=== stoat up (bare)
{"v":3,"type":"result","cmd":"up","ok":true,"data":{"project":"proj-ubuntu-2214195","vms":[{"key":"dev","name":"proj-ubuntu-2214195-dev","status":"ok"}]}}
{"v":3,"type":"result","cmd":"wait","ok":true,"data":{"reached":true,"until":"reachable","vm":"proj-ubuntu-2214195-dev","waited_ms":24544}}
=== shares in the guest
{"v":3,"type":"result","cmd":"exec","ok":true,"data":{"vm":"proj-ubuntu-2214195-dev","stdout":"work on /mnt/work type 9p (rw,relatime,access=client,trans=virtio,_netdev)\np0 on /work type 9p (rw,relatime,access=client,trans=virtio,_netdev)\np1 on /work/src type 9p (rw,relatime,access=client,trans=virtio,_netdev)\nsrc\nstoat.toml\nmarker from the host\n/work/stoat.toml\n\n/work/src:\nhello.txt\n","
=== cleanup
proj-ubuntu-2214195-dev stopped
{"v":3,"type":"result","cmd":"down","ok":true,"data":{"project":"proj-ubuntu-2214195","vms":[{"key":"dev","name":"proj-u
proj-ubuntu-2214195-dev deleted
{"v":3,"type":"result","cmd":"rm","ok":true,"data":{"project":"proj-ubuntu-2214195","vms":[{"key":"dev","name":"proj-ubu
PROJECT-LIVE3-DONE

@NovusEdge
NovusEdge marked this pull request as ready for review September 5, 2026 21:03
@NovusEdge
NovusEdge merged commit 37176b5 into main Sep 5, 2026
4 checks passed
@NovusEdge
NovusEdge deleted the feat/project-file branch September 5, 2026 21:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature needs-live-boot Cannot be verified by agents; needs a real Alpine boot

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant