diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0a66fee8..a80eedc5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,7 +7,8 @@ just setup # builds, installs to ~/.local/bin, reports missing host deps just dev # runs the TUI against a scratch STOAT_HOME ``` -`just setup` also installs the git hooks. +`just setup` also installs the git hooks. A repository with a `stoat.toml` +declares its own VMs; run `stoat up` in it to build them. Go 1.26 (pinned in `go.mod`), `just`, and for anything that boots a VM: KVM, `qemu-system-x86_64`, `qemu-img`, `ssh`. `stoat doctor` lists what is diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 5f849512..1f60aaf4 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -20,9 +20,11 @@ * [CLI](reference/cli.md) * [JSON output](reference/json.md) * [Guest definitions](reference/guest.md) +* [The project file](reference/project-file.md) * [Recipe sample](reference/samples/recipe.toml) * [VM sample](reference/samples/vm.toml) * [Guest sample](reference/samples/guest.toml) +* [Project sample](reference/samples/stoat.toml) ## Recipes diff --git a/docs/reference/cli.md b/docs/reference/cli.md index aaf7a49a..2ae1c245 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -15,10 +15,20 @@ usage: stoat [flags] - **`-h`, `--help`** prints the command's usage and flags and exits 0. `stoat help` (the subcommand) prints the same top-level text `stoat --help` does. - **`-v`, `--version`** prints `stoat ` and exits 0. It is matched as the **first argument only**, before any parsing (`cmd/stoat/main.go`), which has two consequences worth knowing: `stoat -v --json` prints plain text and ignores `--json`, and `stoat --json --version` is a usage error because `-v` is no longer first. **Scripts and machine consumers should use the `version` subcommand**, which behaves normally under `--json`. +## Project scope + +A `stoat.toml` in the current directory activates project scope. `up`, +`down`, `apply`, `wait` and `rm` act on every declared VM, in declaration +order, when given no VM argument. A bare VM argument resolves against +`stoat.toml` first, then against a global VM name. See +[project-file.md](project-file.md). + ## Subcommands | Command | Synopsis | Exit codes | |---|---|---| +| [`init`](#stoat-init---name-n) | Write a `stoat.toml` for this directory | 0, 1 | +| [`status`](#stoat-status) | One line per declared VM: state, health, drift | 0, 1 | | [`ls`](#stoat-ls) | List VMs, one line per VM | 0, 1 | | [`get`](#stoat-get-name) | Show one VM's details | 0, 1 | | [`create`](#stoat-create-name---imageimage) | Create a VM without starting it | 0, 1, 2 | @@ -67,7 +77,39 @@ oldvm - broken - - - unexpected token near line 4 The `STATE` column is colored (green `running`, red `broken`) when [color is enabled](#scripting). `-q`/`--quiet` is accepted but has no effect on `ls`'s output. -**Exit codes:** 0 on success; 1 if the data root can't be read. +`--project` filters the list to VMs the `stoat.toml` in the current directory declares. It refuses outside a project. + +**Exit codes:** 0 on success; 1 if the data root can't be read, or `--project` is given outside a project. + +## `stoat init [--name n]` + +Writes `stoat.toml` for this directory: one `[vms.dev]` declaration, annotated with every field's type and default. Refuses if `stoat.toml` already exists; there is no safe automatic merge into a file you already wrote. + +``` +$ stoat init +wrote stoat.toml +added .stoat/ to .gitignore +edit it, then run: stoat up +``` + +`--name` sets `project.name`, the prefix for a VM's global name; it defaults to the current directory's name, lowercased. In a git checkout, `init` also appends `.stoat/` to `.gitignore` if it is not already there. + +**Exit codes:** 0 on success; 1 if `stoat.toml` already exists or the file can't be written. + +## `stoat status` + +Prints one line per VM `stoat.toml` declares: declaration key, global name, state, health, and every field where the declaration and the VM disagree. + +``` +$ stoat status +KEY NAME STATE HEALTH DRIFT +dev myrepo-dev running ok cpus 2 → 4 (restart) +ci myrepo-ci missing - - +``` + +A VM `stoat.toml` declares but that does not exist yet shows state `missing`. An immutable-field mismatch (`image` or `disk`) prints in place of the drift column, naming `stoat rm ` as the fix. + +**Exit codes:** 0 on success; 1 outside a project, or if a VM's status can't be read. ## `stoat get ` @@ -107,7 +149,9 @@ start it with: stoat up work Flags: `--image` (required; catalog id or a path to your own image), `--os`, `--backend` (override what a bring-your-own image's filename would otherwise infer), `--mode` (`live` or `disk`; only meaningful for the alpine iso, every other image has one mode), `--ram` (MB), `--cpus`, `--disk` (absolute size, e.g. `8G`), `--share` (host directory to expose), `--console-password` (`random` generates one), `--recipes` (comma-separated or repeated), `--set recipe.param=value` (set a non-secret recipe parameter), `--secret recipe.param` (read a secret from the environment or prompt), `--allow-exec` (default true; `--allow-exec=false` opts this VM out of `exec`/`copy_to`/`copy_from`, enforced by the MCP server rather than stoat itself). -**Exit codes:** 0 on success; 1 if creation fails (e.g. the image isn't downloaded yet: run `stoat pull` or download it from the TUI's image picker first); 2 if `--image` is missing. +`create` (alias `new`) refuses at project scope: `a stoat.toml is present; declare the VM there and run stoat up, or pass --global`. `--global` creates the VM outside the project. + +**Exit codes:** 0 on success; 1 if creation fails (e.g. the image isn't downloaded yet: run `stoat pull` or download it from the TUI's image picker first) or a `stoat.toml` refuses it without `--global`; 2 if `--image` is missing. ## `stoat update ` @@ -153,6 +197,8 @@ display: no qemu window; the screen is on /home/user/.stoat/work/vnc.sock `-q`/`--quiet`/`--no-interactive` suppresses the `starting ...` line; the final result line always prints. +At project scope, `` is optional. A named VM is reconciled against its `stoat.toml` declaration before it starts, the same change `stoat update` would make. With no name, every declared VM is reconciled, then started, in declaration order; a VM that fails to reconcile or start stops the run, and every later VM is reported skipped. + ### Where the screen is A VM gets a real QEMU window by default, on a host with a graphical session. Set `display = "vnc"` in `vm.toml` (or cycle it with the `d` key in the TUI) to keep a VM headless instead. QEMU then starts with `-display none` and a VNC server bound to a unix socket in the VM's directory; `-display none` cannot be undone on a running QEMU, so binding VNC at launch keeps a misbehaving guest recoverable. @@ -201,6 +247,8 @@ work stopped `-q` suppresses the `stopping ...` line only. +At project scope, `` is optional: with no name, every declared VM is stopped in declaration order, and a failure stops the run and reports every later VM as skipped. + **Exit codes:** 0 on success; 1 if the VM can't be loaded, is broken, isn't running, or fails to stop. ## `stoat wait ` @@ -216,6 +264,8 @@ work reached reachable (1240ms) A request that cannot ever be satisfied fails immediately rather than waiting out the timeout: `--until applied` on a VM with no recipes configured, or `--until reachable` on a VM that isn't running. +At project scope, `` is optional: with no name, `wait` blocks on every declared VM in turn, in declaration order, and a VM that does not reach the state stops the run. + **Exit codes:** 0 if the state was reached; 1 if the timeout expires or the state can't be reached at all; 2 if `--timeout` is zero or negative. ## `stoat rm [-y]` @@ -230,6 +280,8 @@ scratch deleted Without `-y`, confirmation is required: interactively it prompts on stdout and reads a line from stdin (anything other than a `y`/`Y` aborts); in `-q`/`--quiet`/`--no-interactive` mode there is no prompt to answer, so it refuses outright instead of guessing. Under `--json` the same rule applies for the same reason: nothing reads stdin, so `-y` is required or the command fails with `confirmation_required`. `-y` skips the prompt in every mode. +At project scope, `` is optional: with no name, every declared VM is asked for (or refused without `-y`) and deleted in declaration order. + **Exit codes:** 0 if deleted; 1 if the VM can't be loaded, is running, the confirmation is declined or aborted, `-y` was needed but not given, or the delete itself fails. Note that declining the confirmation prompt is exit 1, not 0: a script checking `$?` sees "delete didn't happen" as a failure either way, whether the VM was running or the user just said no. ## `stoat clone ` @@ -421,6 +473,8 @@ work: recipes applied `--only` restricts the run to a subset of the VM's own recipe list (comma-separated or repeated), instead of applying all of them. +At project scope, `` is optional: with no name, every declared VM's recipes run in turn, in declaration order, and a failure stops the run. + **Exit codes:** 0 on success; 1 if the VM can't be loaded, the run fails, or (for a cloud-mode VM) recipes were already applied at boot rather than by this command. `provision` is a hidden alias of `apply`: `stoat provision work` behaves exactly like `stoat apply work`, and reports `"cmd":"apply"` under `--json`. diff --git a/docs/reference/json.md b/docs/reference/json.md index 95db4de3..07676214 100644 --- a/docs/reference/json.md +++ b/docs/reference/json.md @@ -196,7 +196,8 @@ VM {"name":"work","os":"alpine","mode":"cloud","backend":"cloudinit", "ssh_port":2200,"ssh_user":"stoat","installed":false, "forwards":[{"host_port":8080,"guest_port":80}], "allow_exec":true,"agent_access":"manage","display":"vnc", - "error":"only on a broken VM"} + "error":"only on a broken VM", + "project":"/home/u/myrepo","key":"dev","project_missing":false} VMStatus {"name":"work",...VM fields...,"health":"ok","recipes_detail":[ {"name":"xfce","applied":true,"version":"1.2","at":"...", @@ -261,11 +262,41 @@ Guest {"name":"fedora","init":"systemd","shell":"/bin/bash", MCPClient {"client":"cursor","path":"/home/u/.cursor/mcp.json", "installed":true,"command":"/home/u/.local/bin/stoat", "current":true} + +InitResult {"path":"/home/u/myrepo/stoat.toml","project":"myrepo", + "gitignore_updated":true} + +Drift {"field":"cpus","from":"2","to":"4","needs_restart":true} + +ProjectStatusVM {"key":"dev","name":"myrepo-dev","state":"running", + "health":"ok","drift":[Drift,...], + "error":"only on an immutable-field mismatch"} +ProjectStatus {"project":"myrepo","dir":"/home/u/myrepo", + "vms":[ProjectStatusVM,...]} + +ProjectRunVM {"key":"dev","name":"myrepo-dev","status":"ok", + "error":"only when status is error"} +ProjectRun {"project":"myrepo","vms":[ProjectRunVM,...]} ``` `MCPClient.current` is false when the client's entry names a different binary than the running one, which is the stale entry `mcp doctor` reports. +`VM.project` is the absolute directory of the `stoat.toml` that declared this +VM, and `VM.key` is the declaration key, both empty for a VM `stoat create` +made outside a project. `VM.project_missing` is true when that directory no +longer exists; the VM still lists and runs. + +`ProjectStatusVM.state` is `missing` for a declared VM that does not exist +yet, otherwise a `VM.state` value. `drift` is empty when `error` is set: an +immutable-field mismatch (`image` or `disk`) stops the comparison before the +rest of the fields are checked. + +`ProjectRunVM.status` is `ok`, `error` or `skipped`. `skipped` means a VM +earlier in declaration order failed and this one was never attempted; +`ProjectRun.vms` always lists every declared VM, in declaration order, so a +caller can see what did not run as plainly as what did. + `RecipeEntry` has `name`, `description`, `scope`, `source`, `ref`, and `commit`. `scope` is one of `bundled`, `local`, `global`, or `project`; only `global` and `project` entries carry `source`, `ref`, and the seven-character @@ -369,12 +400,15 @@ so a leak fails the build rather than shipping. | `cmd` | `data` | |---|---| +| `init` | `InitResult` | +| `status` | `ProjectStatus` | | `ls` | `{"vms":[VM,...]}` | | `get` | `{"vm":VMStatus}` | | `create` | `{"vm":VM}` | | `update` | `{"vm":VM,"changed":["ram"],"applies_at":"now"}` | -| `up` | `{"vm":VM}` (re-read after start, so `state` is authoritative) | -| `down` | `{"vm":VM}` | +| `up` (one VM) | `{"vm":VM}` (re-read after start, so `state` is authoritative) | +| `up`, `down`, `apply`, `wait`, `rm` (no VM, project scope) | `ProjectRun` | +| `down` (one VM) | `{"vm":VM}` | | `wait` | `{"vm":"work","until":"reachable","reached":true,"waited_ms":4210}` | | `rm` | `{"name":"scratch","deleted":true}` | | `clone` | `{"vm":VM,"source":"work","forwards_copied":false}` | @@ -433,6 +467,11 @@ scope (`bundled`, `local`, `global`, or `project`); only remote `global` and `project` rows carry source, ref, and the seven-character commit prefix. The `roots` list gives the search order and the scope label for each root. +`up`, `down`, `apply`, `wait` and `rm` report `ProjectRun` only when they run +at project scope with no VM argument; given a VM, each keeps its one-VM shape +from the row above. `stoat.toml`'s `[project]` fan-out is the only thing that +changes `data`'s shape; the command's own `cmd` name does not. + Fields worth knowing about: - **`update.changed`** names the fields that actually changed, in wire naming @@ -552,3 +591,10 @@ The same version also adds `agent_access` to `VM`, additive alongside `stoat mcp` in this binary. Neither change removes or repurposes a field, so neither bumped the version on its own; they are noted here only because they landed in the same branch as the `recipe list` change. + +The project-file plan adds `init` and `status` commands, `--project` on `ls`, +and a no-argument fan-out on `up`, `down`, `apply`, `wait` and `rm` at project +scope, plus `project`, `key` and `project_missing` on `VM`, and five new MCP +tools (`project_status`, `project_up`, `project_down`, `project_apply`, +`project_wait`) alongside `start`, `stop`, `apply_recipes` and `wait`, which +keep their existing inputs and outputs. All additions; the contract stays 3. diff --git a/docs/reference/project-file.md b/docs/reference/project-file.md new file mode 100644 index 00000000..f8676c18 --- /dev/null +++ b/docs/reference/project-file.md @@ -0,0 +1,107 @@ +# The project file + +`stoat.toml` in a repository declares that repository's VMs. `git clone` +the repository, then run `stoat up`, to build them. + +## Scope + +`stoat.toml` in the current directory activates project scope. There is +no walk-up: a parent directory's `stoat.toml` has no effect. + +## The file + +```toml +# stoat.toml declares this repository's VMs. Commit it, and stoat.lock with it. +# Every field carries its type, its default, and who writes it. +schema = 1 # int, required. The file format version. + +[project] +name = "myrepo" # string, default: the directory name. + # The prefix for a VM's global name. + +# Remote recipes. A value is a ref string, or a table naming a source. +# stoat recipe lock pins each one to a commit in stoat.lock. +[recipes] +tailscale = "v1.2" + +[vms.dev] # the key "dev" is the name you type +image = "ubuntu-24.04" # string, required. A catalog id, or a path + # to your own image, relative to this file. +name = "shared-dev" # string, default "-". The VM's + # global name under ~/.stoat. +cpus = 4 # int, default 4 +ram = 4096 # int, MB, default 4096 +disk = "20G" # string, default 8G. Disk-mode images only. +recipes = ["docker", "tailscale"] # applied in dependency order +shares = [".", "src"] # directories from this project, mounted under + # /work. "." mounts at /work, "src" at + # /work/src. Every entry stays inside the + # project. +agent_access = "manage" # none | observe | manage | exec, default manage + +[vms.dev.params.docker] # non-secret recipe params +user = "dev" # secrets go in .stoat/secrets.toml, 0600 + +[vms.docs] +image = "alpine-virt" +shares = ["docs"] +``` + +See the [sample file](samples/stoat.toml) on its own. + +## Names + +A VM's global name is its declaration's `name` field, if set, otherwise +`-`. `project.name` defaults to the repository directory +name. + +A bare command argument resolves to the declaration key first, then to a +global name. `stoat ssh dev` reaches `shared-dev`. + +Two declarations that resolve to one global name are an error. + +## Shares + +Each `shares` entry mounts read-write under `/work` in the guest. `.` +mounts at `/work`. Every other entry mounts at `/work/`. + +Every entry must resolve inside the project directory. A relative path +that escapes it, directly or through a symlink, is refused. + +## Reconcile + +`stoat up` reconciles a declared VM before it starts it: + +- A missing VM is created from its declaration. +- An existing VM takes `cpus`, `ram`, `recipes`, `params`, `shares` and + `agent_access` from the declaration, through the same path as `stoat + update`. `cpus`, `ram` and `shares` take effect at the VM's next `down` + and `up`. +- `image` and `disk` are immutable. A declaration that changes either is + an error naming `stoat rm ` as the fix. + +## Secrets + +Secrets live in `.stoat/secrets.toml`, mode 0600, keyed +`..`. `stoat init` adds `.stoat/` to `.gitignore` in +a git checkout. Every reader renders a secret as `` or ``, +never as its value. + +## Commands + +| Command | Effect | +|---|---| +| `stoat init [--name n]` | writes `stoat.toml` from the annotated sample, with one VM | +| `stoat status` | one line per declared VM: global name, state, health, drift | +| `stoat ls --project` | filters the VM list to the current project | +| `stoat up`, `down`, `apply`, `wait`, `rm` with no VM argument | act on every declared VM, in declaration order | + +## Errors + +| Condition | Message | +|---|---| +| duplicate global name | `stoat.toml: vms.dev and vms.ci both resolve to "myrepo-dev"` | +| share outside project | `stoat.toml: vms.dev.shares: "../secrets" is outside the project` | +| immutable change | `dev: image changed (ubuntu-24 → debian-12); run stoat rm dev and stoat up` | +| new at project scope | `a stoat.toml is present; declare the VM there and run stoat up, or pass --global` | +| unknown key in a bare argument | `no VM "db" in stoat.toml or ~/.stoat/vms` | diff --git a/docs/reference/samples/stoat.toml b/docs/reference/samples/stoat.toml new file mode 100644 index 00000000..abac203c --- /dev/null +++ b/docs/reference/samples/stoat.toml @@ -0,0 +1,34 @@ +# stoat.toml declares this repository's VMs. Commit it, and stoat.lock with it. +# Every field carries its type, its default, and who writes it. +schema = 1 # int, required. The file format version. + +[project] +name = "myrepo" # string, default: the directory name. + # The prefix for a VM's global name. + +# Remote recipes. A value is a ref string, or a table naming a source. +# stoat recipe lock pins each one to a commit in stoat.lock. +[recipes] +tailscale = "v1.2" + +[vms.dev] # the key "dev" is the name you type +image = "ubuntu-24.04" # string, required. A catalog id, or a path + # to your own image, relative to this file. +name = "shared-dev" # string, default "-". The VM's + # global name under ~/.stoat. +cpus = 4 # int, default 4 +ram = 4096 # int, MB, default 4096 +disk = "20G" # string, default 8G. Disk-mode images only. +recipes = ["docker", "tailscale"] # applied in dependency order +shares = [".", "src"] # directories from this project, mounted under + # /work. "." mounts at /work, "src" at + # /work/src. Every entry stays inside the + # project. +agent_access = "manage" # none | observe | manage | exec, default manage + +[vms.dev.params.docker] # non-secret recipe params +user = "dev" # secrets go in .stoat/secrets.toml, 0600 + +[vms.docs] +image = "alpine-virt" +shares = ["docs"] diff --git a/internal/apkovl/apkovl.go b/internal/apkovl/apkovl.go index b526a195..684535de 100644 --- a/internal/apkovl/apkovl.go +++ b/internal/apkovl/apkovl.go @@ -11,6 +11,7 @@ import ( "compress/gzip" "os" "path/filepath" + "strings" "github.com/novusedge/stoat/internal/config" "github.com/novusedge/stoat/internal/keys" @@ -215,6 +216,11 @@ func Build(v *config.VM) error { fstab += HostMount9p.FstabLine() b.dir("mnt/host", 0o755) } + for _, s := range v.Shares { + m := Mount9pFor(s) + fstab += m.FstabLine() + b.dir(strings.TrimPrefix(m.Dir, "/"), 0o755) + } // The initramfs's default-boot-services set doesn't include localmount, // so without this symlink the shares would only mount as a side effect // of a recipe running `mount -a`. diff --git a/internal/apkovl/mount9p.go b/internal/apkovl/mount9p.go index be63abf1..98824691 100644 --- a/internal/apkovl/mount9p.go +++ b/internal/apkovl/mount9p.go @@ -1,6 +1,10 @@ package apkovl -import "fmt" +import ( + "fmt" + + "github.com/novusedge/stoat/internal/config" +) // Mount9p describes one 9p share qemu attaches to a VM: its mount tag (qemu's // mount_tag / virtfs id), the guest mountpoint, and the fstab options. @@ -44,3 +48,9 @@ var WorkMount9p = Mount9p{ func (m Mount9p) FstabLine() string { return fmt.Sprintf("%s %s 9p %s 0 0\n", m.Tag, m.Dir, m.Options) } + +// Mount9pFor turns a project share into a fstab entry. Project shares are rw: +// a share exists so the guest can build the checkout it is mounted from. +func Mount9pFor(s config.Share) Mount9p { + return Mount9p{Tag: s.Tag, Dir: s.Guest, Options: workMountOptions} +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 7add2e3a..08bd5fc2 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -31,6 +31,7 @@ import ( "github.com/novusedge/stoat/internal/keys" "github.com/novusedge/stoat/internal/logx" "github.com/novusedge/stoat/internal/mcpsrv" + "github.com/novusedge/stoat/internal/project" "github.com/novusedge/stoat/internal/recipes" ) @@ -77,7 +78,11 @@ type Args struct { // Sub is the second word for subcommands that have one ("recipe list"). // OS and Backend belong to "recipe new"; VM carries the recipe name // there, since it is the same "one positional argument" slot. - Sub string + Sub string + + // Global belongs to the "recipe" subcommands and to "create". On a recipe + // command it forces the home scope; on create it opts out of the + // project-scope refusal. No two of those share a toArgs case. Global bool Force bool Refresh bool @@ -103,9 +108,12 @@ type Args struct { Remote string ToRemote bool - // Forwards and Clear belong to "forward". Clear is separate from an empty - // Forwards because they mean different things: no pairs means "show me", - // --clear means "remove them all". + // Forwards belongs to "forward". Clear belongs to "forward" (--clear + // removes every port forward) and to "ls" (--project, filtering to the + // current project); no command uses both, so the field is shared. Clear + // is separate from an empty Forwards because they mean different things + // for "forward": no pairs means "show me", --clear means "remove them + // all". Forwards []core.PortForward Clear bool @@ -138,14 +146,18 @@ type Args struct { Changed []string Params []ParamEdit - // HTTP, Limits, Client, Project and Print belong to "mcp". HTTP is the - // loopback address for "mcp serve --http"; empty means stdio. Client, - // Project and Print belong to "mcp install". - HTTP string - Limits mcpsrv.Limits - Client string - Project bool - Print bool + // HTTP, Limits, Client, InstallProject and Print belong to "mcp". HTTP is + // the loopback address for "mcp serve --http"; empty means stdio. Client, + // InstallProject and Print belong to "mcp install". + HTTP string + Limits mcpsrv.Limits + Client string + InstallProject bool + Print bool + + // Project is the stoat.toml in the current directory, nil outside a + // project. Main fills it after Parse; Parse itself never reads disk. + Project *project.Project } // ParamEdit is one recipe parameter edit parsed from create or update flags. @@ -331,6 +343,23 @@ func (a *Args) ok(stdout io.Writer, data any) int { return ExitOK } +// coreGet is core.Get behind a variable so resolveScope's "does this VM +// exist" probe stays one call the tests can see failing. +var coreGet = core.Get + +// failUsage reports a usage error the CLI detected itself and returns +// ExitUsage. failMsg returns ExitFail, which is the wrong code for "you did +// not name a VM": a script that branches on 2 for a usage mistake would read +// it as a runtime failure. +func (a *Args) failUsage(stdout, stderr io.Writer, msg string) int { + if a.JSON { + _ = wire.NewEmitter(stdout).ResultErr(a.Cmd, wire.UsageError(msg)) + return ExitUsage + } + fmt.Fprintf(stderr, "stoat: %s: %s\n", a.Cmd, msg) + return ExitUsage +} + // fail reports err and returns ExitFail: one JSON result line on STDOUT under // --json (§4: everything structured goes to stdout, errors included, because a // consumer that has to merge two pipes to read one result will eventually @@ -439,6 +468,16 @@ func Main(args []string, version string, stdin io.Reader, stdout, stderr io.Writ defer func() { _ = logx.Close() }() logx.L().Debug("cli", "cmd", a.Cmd, "vm", a.VM) + if err := resolveScope(a); err != nil { + return a.fail(stdout, stderr, err) + } + // fanOutCommands, not vmCommands: logs is in vmCommands too, so a bare + // "dev" still resolves for it, but "stoat logs" with no VM has always + // meant "tail stoat's own log" and stays legal outside a project. + if a.VM == "" && fanOutCommands[a.Cmd] && a.Project == nil { + return a.failUsage(stdout, stderr, "missing vm name") + } + if err := guest.Load(filepath.Join(config.Root(), "guests")); err != nil { if a.JSON { _ = wire.NewEmitter(stdout).ResultErr(a.Cmd, wire.UsageError(err.Error())) @@ -457,6 +496,8 @@ func Main(args []string, version string, stdin io.Reader, stdout, stderr io.Writ return runImages(a, stdout, stderr) case "pull": return runPull(a, stdout, stderr) + case "init": + return runInit(a, stdout, stderr) case "clone": return runClone(a, stdout, stderr) case "prune": @@ -503,6 +544,8 @@ func Main(args []string, version string, stdin io.Reader, stdout, stderr io.Writ return runDoctor(a, stdout, stderr) case "mcp": return runMCP(a, version, stdout, stderr) + case "status": + return runStatus(a, stdout, stderr) default: // Unreachable: Parse already rejected anything not handled above. fmt.Fprintln(stderr, "stoat: unknown subcommand", a.Cmd) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index d3a40205..41f2c1e2 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -70,21 +70,23 @@ func TestParse(t *testing.T) { // compares with DeepEqual and cannot sensibly hardcode. {"up", []string{"up", "alpine"}, &Args{Cmd: "up", VM: "alpine"}, false}, - {"up missing name", []string{"up"}, nil, true}, + // A missing name is a project-scope fan-out, not a parse error; see + // resolveScope and TestMissingVMArgumentOutsideAProjectIsUsage. + {"up missing name", []string{"up"}, &Args{Cmd: "up"}, false}, {"up too many args", []string{"up", "a", "b"}, nil, true}, {"up quiet", []string{"up", "-q", "alpine"}, &Args{Cmd: "up", VM: "alpine", Quiet: true}, false}, {"up --no-apply", []string{"up", "--no-apply", "alpine"}, &Args{Cmd: "up", VM: "alpine", NoApply: true}, false}, {"up --no-provision (hidden alias)", []string{"up", "--no-provision", "alpine"}, &Args{Cmd: "up", VM: "alpine", NoApply: true}, false}, {"down", []string{"down", "alpine"}, &Args{Cmd: "down", VM: "alpine"}, false}, - {"down missing name", []string{"down"}, nil, true}, + {"down missing name", []string{"down"}, &Args{Cmd: "down"}, false}, {"ssh", []string{"ssh", "alpine"}, &Args{Cmd: "ssh", VM: "alpine"}, false}, {"ssh missing name", []string{"ssh"}, nil, true}, // provision is a kong alias of apply, so it parses to Cmd "apply". {"provision", []string{"provision", "alpine"}, &Args{Cmd: "apply", VM: "alpine"}, false}, - {"provision missing name", []string{"provision"}, nil, true}, + {"provision missing name", []string{"provision"}, &Args{Cmd: "apply"}, false}, {"provision quiet alias", []string{"provision", "--no-interactive", "alpine"}, &Args{Cmd: "apply", VM: "alpine", Quiet: true}, false}, {"rm", []string{"rm", "alpine"}, &Args{Cmd: "rm", VM: "alpine"}, false}, @@ -94,7 +96,7 @@ func TestParse(t *testing.T) { // TestParseRMAcceptsFlagsEitherSide. {"rm name then -y", []string{"rm", "alpine", "-y"}, &Args{Cmd: "rm", VM: "alpine", Yes: true}, false}, {"rm two names", []string{"rm", "alpine", "extra"}, nil, true}, - {"rm missing name", []string{"rm"}, nil, true}, + {"rm missing name", []string{"rm"}, &Args{Cmd: "rm"}, false}, {"rm quiet and yes", []string{"rm", "-q", "-y", "alpine"}, &Args{Cmd: "rm", VM: "alpine", Quiet: true, Yes: true}, false}, {"logs default", []string{"logs"}, &Args{Cmd: "logs", N: 50, Which: core.WhichConsole}, false}, @@ -351,11 +353,11 @@ func TestRunLSOutput(t *testing.T) { if len(lines) != 3 { t.Fatalf("got %d lines, want 3 (header + good + broken): %q", len(lines), out.String()) } - wantHeader := fmt.Sprintf("%-15s %-5s %-8s %-5s %-6s %s", "NAME", "MODE", "STATE", "CPUS", "RAM", "SSH") + wantHeader := fmt.Sprintf("%-15s %-5s %-8s %-5s %-6s %-6s %s", "NAME", "MODE", "STATE", "CPUS", "RAM", "SSH", "PROJECT") if lines[0] != wantHeader { t.Errorf("header = %q, want %q", lines[0], wantHeader) } - wantGood := fmt.Sprintf("%-15s %-5s %s %-5d %-6d %d", "good", "live", "stopped ", 2, 1024, 2200) + wantGood := fmt.Sprintf("%-15s %-5s %s %-5d %-6d %-6d %s", "good", "live", "stopped ", 2, 1024, 2200, "-") if lines[1] != wantGood { t.Errorf("good row = %q, want %q", lines[1], wantGood) } diff --git a/internal/cli/grammar.go b/internal/cli/grammar.go index e96d59f7..35e13c14 100644 --- a/internal/cli/grammar.go +++ b/internal/cli/grammar.go @@ -32,9 +32,12 @@ type grammar struct { // contract, and so exec's guest command keeps its own --json. Quiet bool `short:"q" name:"quiet" aliases:"no-interactive" help:"suppress progress chatter"` + Init initCmd `cmd:"" help:"write a stoat.toml for this directory"` + Status statusCmd `cmd:"" help:"one line per declared VM: state, health and drift"` + LS lsCmd `cmd:"" name:"ls" help:"list VMs, one line per VM"` Get getCmd `cmd:"" help:"show one VM"` - Create createCmd `cmd:"" help:"create a VM without starting it"` + Create createCmd `cmd:"" aliases:"new" help:"create a VM without starting it"` Update updateCmd `cmd:"" help:"change a stopped VM; only the flags you pass change"` Up upCmd `cmd:"" help:"start a VM"` Down downCmd `cmd:"" help:"stop a VM (graceful)"` @@ -92,7 +95,15 @@ type mcpDoctorCmd struct{} type helpCmd struct{} -type lsCmd struct{} +type lsCmd struct { + Project bool `help:"only VMs declared by the stoat.toml in this directory"` +} + +type statusCmd struct{} + +type initCmd struct { + Name string `help:"project name; defaults to the directory name"` +} type imagesCmd struct{} type doctorCmd struct{} type versionCmd struct{} @@ -102,12 +113,12 @@ type getCmd struct { } type upCmd struct { - VM string `arg:"" help:"vm name"` + VM string `arg:"" optional:"" help:"vm name; omit at project scope for every declared VM"` NoApply bool `name:"no-apply" aliases:"no-provision" help:"start only; skip the automatic post-boot apply"` } type downCmd struct { - VM string `arg:"" help:"vm name"` + VM string `arg:"" optional:"" help:"vm name; omit at project scope for every declared VM"` } type sshCmd struct { @@ -119,7 +130,7 @@ type sshCmdCmd struct { } type rmCmd struct { - VM string `arg:"" help:"vm name"` + VM string `arg:"" optional:"" help:"vm name; omit at project scope for every declared VM"` Yes bool `short:"y" help:"skip the delete confirmation"` } @@ -146,6 +157,11 @@ type createCmd struct { // --agent-access always wins over this alias. AllowExec *bool `name:"allow-exec" hidden:"" help:"alias of --agent-access exec (true) or manage (false)"` AgentAccess *string `name:"agent-access" enum:"none,observe,manage,exec" help:"what an MCP agent may do in this VM"` + + // Global creates a VM outside the project even when a stoat.toml is + // present. Without it, a create at project scope is refused: a VM that + // exists only on one machine is exactly what stoat.toml removes. + Global bool `help:"create outside the project even inside one"` } // updateCmd's pointers are the point: see the type comment on grammar. @@ -227,14 +243,14 @@ type pruneCmd struct { } type waitCmd struct { - VM string `arg:"" help:"vm name"` + VM string `arg:"" optional:"" help:"vm name; omit at project scope for every declared VM"` Until string `enum:"reachable,applied,stopped" default:"reachable" help:"state to wait for"` Healthy bool `help:"wait for every applied recipe's health check to pass"` Timeout time.Duration `default:"2m" help:"give up after this long"` } type applyCmd struct { - VM string `arg:"" help:"vm name"` + VM string `arg:"" optional:"" help:"vm name; omit at project scope for every declared VM"` Only []string `help:"subset of the VM's own recipes"` DryRun bool `name:"dry-run" help:"print what would run without running it"` } @@ -334,7 +350,10 @@ type screenshotCmd struct { func (g *grammar) toArgs(path string) (*Args, error) { a := &Args{Cmd: path, Quiet: g.Quiet} switch path { - case "ls", "images", "doctor", "version": + case "ls": + a.Clear = g.LS.Project + + case "images", "doctor", "version", "status": case "help": // The `help` COMMAND has to render the text itself; only the -h/--help @@ -354,6 +373,7 @@ func (g *grammar) toArgs(path string) (*Args, error) { case "create": c := g.Create a.VM = c.Name + a.Global = c.Global // Absent --allow-exec keeps Spec.AllowExec's own default of true // (see Spec.AllowExec's doc comment); given, it also sets the // access level, per --allow-exec's alias contract. @@ -489,6 +509,9 @@ func (g *grammar) toArgs(path string) (*Args, error) { case "pull": a.VM = g.Pull.ID + case "init": + a.Tag = g.Init.Name + case "snapshot": // The mutual exclusion of --restore and --delete is kong's, via // xor:"action"; only the "needs a tag" rule is left here. @@ -593,7 +616,7 @@ func (g *grammar) toArgs(path string) (*Args, error) { case "mcp install": i := g.MCP.Install a.Cmd, a.Sub = "mcp", "install" - a.Client, a.Project, a.Print = i.Client, i.Project, i.Print + a.Client, a.InstallProject, a.Print = i.Client, i.Project, i.Print case "mcp doctor": a.Cmd, a.Sub = "mcp", "doctor" diff --git a/internal/cli/json_test.go b/internal/cli/json_test.go index 54773a10..c2b27cb4 100644 --- a/internal/cli/json_test.go +++ b/internal/cli/json_test.go @@ -233,6 +233,8 @@ func TestJSONNeverLeaksHostPathsOrConsolePassword(t *testing.T) { if err != nil { t.Fatal(err) } + // v.Dir is the VM's data directory, not project.Project.Dir: wire.VM.Project + // is a project's host path and is meant to ship, so only v.Dir is checked. for _, leak := range []string{"deadbeefcafe", "console_password", "paths", v.Dir} { if bytes.Contains(raw, []byte(leak)) { t.Errorf("ls output leaks %q: %s", leak, raw) diff --git a/internal/cli/project.go b/internal/cli/project.go new file mode 100644 index 00000000..b852430a --- /dev/null +++ b/internal/cli/project.go @@ -0,0 +1,56 @@ +package cli + +import ( + "fmt" + + "github.com/novusedge/stoat/internal/project" +) + +// vmCommands are the commands whose single positional is a VM name. Only +// these get their argument resolved against stoat.toml. pull takes an image +// id and `recipe new` takes a recipe name in the same Args.VM slot, so a +// blanket rewrite would send both to the wrong place. +// "provision" is absent on purpose: the guest-definitions plan made it a kong +// alias of apply, so Args.Cmd never holds it. +var vmCommands = map[string]bool{ + "get": true, "up": true, "down": true, "ssh": true, "ssh-command": true, + "rm": true, "clone": true, "exec": true, "cp": true, "forward": true, + "snapshot": true, "wait": true, "apply": true, + "update": true, "logs": true, +} + +// fanOutCommands are the commands that act on every declared VM when given no +// argument at project scope. +var fanOutCommands = map[string]bool{ + "up": true, "down": true, "apply": true, "wait": true, "rm": true, +} + +// resolveScope loads the project in the current directory, if any, and turns +// a bare VM argument into a global name. +// +// It runs after Parse, not inside it: Parse never touches disk, which is what +// makes it testable as a pure function over argv. +func resolveScope(a *Args) error { + p, ok, err := project.Find() + if err != nil { + return err + } + if !ok { + return nil + } + a.Project = p + + if a.VM == "" || !vmCommands[a.Cmd] { + return nil + } + if global, ok := p.Resolve(a.VM); ok { + a.VM = global + return nil + } + // The name may still be a global VM that this project never declared; + // only refuse when the data root does not have it either. + if _, err := coreGet(a.VM); err != nil { + return fmt.Errorf("no VM %q in %s or ~/.stoat/vms", a.VM, project.FileName) + } + return nil +} diff --git a/internal/cli/project_test.go b/internal/cli/project_test.go new file mode 100644 index 00000000..3cf7b514 --- /dev/null +++ b/internal/cli/project_test.go @@ -0,0 +1,108 @@ +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/novusedge/stoat/internal/config" + "github.com/novusedge/stoat/internal/project" +) + +// projectRoot sets up a data root, writes stoat.toml into a separate project +// directory and chdirs into it. Every project CLI test starts here. +func projectRoot(t *testing.T, body string) string { + t.Helper() + cliRoot(t) + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, project.FileName), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + t.Chdir(dir) + return dir +} + +const twoVMs = ` +schema = 1 + +[project] +name = "myrepo" + +[vms.dev] +image = "alpine-virt" + +[vms.ci] +image = "alpine-virt" +` + +// A bare key reaches the global name, so stoat get dev answers about +// myrepo-dev. +func TestBareKeyResolvesToTheGlobalName(t *testing.T) { + projectRoot(t, twoVMs) + if err := (&config.VM{Name: "myrepo-dev", Mode: "live", RAM: 1024, CPUs: 1, SSHPort: 2200}).Save(); err != nil { + t.Fatal(err) + } + _, objs := runJSON(t, "get", "dev") + data, _ := result(t, objs)["data"].(map[string]any) + vm, _ := data["vm"].(map[string]any) + if vm["name"] != "myrepo-dev" { + t.Errorf("name = %v, want myrepo-dev", vm["name"]) + } +} + +// An argument that is neither a key nor an existing VM names both places it +// was looked for. +func TestUnknownBareArgumentNamesBothScopes(t *testing.T) { + projectRoot(t, twoVMs) + code, objs := runJSON(t, "get", "db") + if code != ExitFail { + t.Errorf("exit = %d, want %d", code, ExitFail) + } + errObj, _ := result(t, objs)["error"].(map[string]any) + msg, _ := errObj["message"].(string) + if !strings.Contains(msg, `no VM "db" in stoat.toml or ~/.stoat/vms`) { + t.Errorf("message = %q", msg) + } +} + +// A global name still works inside a project: the project shadows nothing it +// did not declare. +func TestGlobalNameStillWorksAtProjectScope(t *testing.T) { + projectRoot(t, twoVMs) + if err := (&config.VM{Name: "other", Mode: "live", RAM: 1024, CPUs: 1, SSHPort: 2201}).Save(); err != nil { + t.Fatal(err) + } + code, _ := runJSON(t, "get", "other") + if code != ExitOK { + t.Errorf("exit = %d, want %d for a global VM at project scope", code, ExitOK) + } +} + +// Outside a project, a missing VM argument is still a usage error with the +// same exit code kong produced before the positional became optional. +func TestMissingVMArgumentOutsideAProjectIsUsage(t *testing.T) { + cliRoot(t) + t.Chdir(t.TempDir()) + code, objs := runJSON(t, "up") + if code != ExitUsage { + t.Errorf("exit = %d, want %d", code, ExitUsage) + } + errObj, _ := result(t, objs)["error"].(map[string]any) + if errObj["code"] != "usage" { + t.Errorf("code = %v, want usage", errObj["code"]) + } +} + +// pull's positional is an image id, not a VM name, so the project must not +// rewrite it. +func TestPullArgumentIsNotResolved(t *testing.T) { + projectRoot(t, twoVMs) + a := &Args{Cmd: "pull", VM: "dev"} + if err := resolveScope(a); err != nil { + t.Fatal(err) + } + if a.VM != "dev" { + t.Errorf("pull id = %q, want it untouched", a.VM) + } +} diff --git a/internal/cli/run_apply.go b/internal/cli/run_apply.go index a12d9287..ecc372fd 100644 --- a/internal/cli/run_apply.go +++ b/internal/cli/run_apply.go @@ -19,6 +19,16 @@ import ( // missing VM came back as code "internal" instead of "not_found". core.Apply // loads and validates independently, so this read cannot desync from it. func runApply(a *Args, stdout, stderr io.Writer) int { + if a.VM == "" { + return fanOut(a, stdout, stderr, func(name string) error { + sub := *a + sub.VM, sub.JSON = name, false + if code := runApply(&sub, stdout, stderr); code != ExitOK { + return fmt.Errorf("%s: apply failed", name) + } + return nil + }) + } v, err := core.Get(a.VM) if err != nil { return a.fail(stdout, stderr, err) diff --git a/internal/cli/run_init.go b/internal/cli/run_init.go new file mode 100644 index 00000000..2730f276 --- /dev/null +++ b/internal/cli/run_init.go @@ -0,0 +1,116 @@ +package cli + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/novusedge/stoat/internal/cli/wire" + "github.com/novusedge/stoat/internal/project" +) + +// initTemplate is the annotated sample stoat init writes. Every key in it +// exists on project's own structs, so the file decodes in Reject mode; +// TestInitOutputLoads is what holds that true. +func initTemplate(name string) string { + return fmt.Sprintf(`# stoat.toml: this repository's VMs. Commit it. +# Run stoat up to build and start every VM declared here. +schema = 1 + +[project] +# The prefix for a VM's global name. "dev" below becomes "%s-dev". +name = %q + +# Remote recipes, by index name or by source. stoat recipe lock pins each one +# to a commit in stoat.lock, which you also commit. +[recipes] + +[vms.dev] +# image is the only required field: a catalog id from stoat images, or a path +# to your own image, relative to this file. +image = "ubuntu-24.04" +# Every field below takes stoat new's default when you delete it. +cpus = 4 +ram = 4096 +disk = "20G" +# Recipe names, applied in dependency order on every stoat up. +recipes = [] +# Directories from this project, mounted under /work in the guest. "." is the +# project root and mounts at /work. Every entry must stay inside the project. +shares = ["."] +# What an MCP agent may do: none, observe, manage, exec. +agent_access = "manage" + +# Non-secret recipe params. Secrets go in .stoat/secrets.toml, which stoat +# writes 0600 and never commits. +# [vms.dev.params.docker] +# user = "dev" +`, name, name) +} + +// runInit writes stoat.toml and gitignores the cache directory. +// +// It refuses an existing file rather than merging into it: the file is the +// user's own declaration, and there is no safe automatic edit of one. +func runInit(a *Args, stdout, stderr io.Writer) int { + dir, err := os.Getwd() + if err != nil { + return a.fail(stdout, stderr, err) + } + path := filepath.Join(dir, project.FileName) + if _, err := os.Stat(path); err == nil { + return a.failMsg(stdout, stderr, os.ErrExist, project.FileName+" already exists") + } + + name := a.Tag + if name == "" { + name = strings.ToLower(filepath.Base(dir)) + } + if err := os.WriteFile(path, []byte(initTemplate(name)), 0o644); err != nil { + return a.fail(stdout, stderr, err) + } + + ignored, err := ignoreCacheDir(dir) + if err != nil { + return a.fail(stdout, stderr, err) + } + + if a.JSON { + return a.ok(stdout, wire.InitResult{Path: path, Project: name, GitignoreUpdated: ignored}) + } + if !a.Quiet { + fmt.Fprintf(stdout, "wrote %s\n", project.FileName) + if ignored { + fmt.Fprintf(stdout, "added %s/ to .gitignore\n", project.CacheDir) + } + fmt.Fprintln(stdout, "edit it, then run: stoat up") + } + return ExitOK +} + +// ignoreCacheDir appends ".stoat/" to .gitignore, in a git checkout only and +// only when the line is absent. Outside a checkout there is nothing to +// ignore, so it does nothing and says so. +func ignoreCacheDir(dir string) (bool, error) { + if _, err := os.Stat(filepath.Join(dir, ".git")); err != nil { + return false, nil + } + path := filepath.Join(dir, ".gitignore") + body, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + return false, err + } + line := project.CacheDir + "/" + for _, l := range strings.Split(string(body), "\n") { + if strings.TrimSpace(l) == line { + return false, nil + } + } + if len(body) > 0 && !strings.HasSuffix(string(body), "\n") { + body = append(body, '\n') + } + body = append(body, []byte(line+"\n")...) + return true, os.WriteFile(path, body, 0o644) +} diff --git a/internal/cli/run_init_test.go b/internal/cli/run_init_test.go new file mode 100644 index 00000000..e5f24912 --- /dev/null +++ b/internal/cli/run_init_test.go @@ -0,0 +1,101 @@ +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/novusedge/stoat/internal/project" +) + +// The template stoat init writes must load in Reject mode. Otherwise the +// first command a new user runs after init fails on the file init made. +func TestInitOutputLoads(t *testing.T) { + cliRoot(t) + dir := t.TempDir() + t.Chdir(dir) + + code, _ := runJSON(t, "init", "--name", "myrepo") + if code != ExitOK { + t.Fatalf("exit = %d", code) + } + p, err := project.Load(dir) + if err != nil { + t.Fatalf("stoat init wrote a file it cannot read back: %v", err) + } + if p.Name != "myrepo" { + t.Errorf("name = %q, want myrepo", p.Name) + } + if len(p.VMs) != 1 || p.VMs[0].Key != "dev" { + t.Errorf("vms = %+v, want one, keyed dev", p.VMs) + } +} + +func TestInitDefaultsTheNameToTheDirectory(t *testing.T) { + cliRoot(t) + dir := filepath.Join(t.TempDir(), "myrepo") + if err := os.Mkdir(dir, 0o755); err != nil { + t.Fatal(err) + } + t.Chdir(dir) + if code, _ := runJSON(t, "init"); code != ExitOK { + t.Fatalf("exit = %d", code) + } + body, err := os.ReadFile(filepath.Join(dir, project.FileName)) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(body), `name = "myrepo"`) { + t.Errorf("stoat.toml does not name the directory:\n%s", body) + } +} + +// .stoat holds the recipe cache and the secrets file. Committing either is a +// mistake init prevents once, in a git checkout only. +func TestInitAppendsTheCacheDirToGitignore(t *testing.T) { + cliRoot(t) + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, ".git"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("bin/\n"), 0o644); err != nil { + t.Fatal(err) + } + t.Chdir(dir) + if code, _ := runJSON(t, "init"); code != ExitOK { + t.Fatalf("exit = %d", code) + } + body, err := os.ReadFile(filepath.Join(dir, ".gitignore")) + if err != nil { + t.Fatal(err) + } + if got := string(body); got != "bin/\n.stoat/\n" { + t.Errorf(".gitignore = %q, want the cache dir appended once", got) + } + + // A second init must not append it twice. + runJSON(t, "init") + body, _ = os.ReadFile(filepath.Join(dir, ".gitignore")) + if strings.Count(string(body), ".stoat/") != 1 { + t.Errorf(".gitignore = %q, want one .stoat/ line", body) + } +} + +func TestInitRefusesToOverwrite(t *testing.T) { + cliRoot(t) + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, project.FileName), []byte("schema = 1\n"), 0o644); err != nil { + t.Fatal(err) + } + t.Chdir(dir) + code, objs := runJSON(t, "init") + if code != ExitFail { + t.Errorf("exit = %d, want %d", code, ExitFail) + } + errObj, _ := result(t, objs)["error"].(map[string]any) + msg, _ := errObj["message"].(string) + if !strings.Contains(msg, "already exists") { + t.Errorf("message = %q", msg) + } +} diff --git a/internal/cli/run_mcp.go b/internal/cli/run_mcp.go index e11482ef..862d4ab7 100644 --- a/internal/cli/run_mcp.go +++ b/internal/cli/run_mcp.go @@ -27,7 +27,7 @@ func runMCP(a *Args, version string, stdout, stderr io.Writer) int { } return ExitOK case "install": - report, err := mcpsrv.Install(a.Client, mcpsrv.InstallOpts{Project: a.Project, Print: a.Print}) + report, err := mcpsrv.Install(a.Client, mcpsrv.InstallOpts{Project: a.InstallProject, Print: a.Print}) if err != nil { return a.fail(stdout, stderr, err) } diff --git a/internal/cli/run_project.go b/internal/cli/run_project.go new file mode 100644 index 00000000..8e6a80b5 --- /dev/null +++ b/internal/cli/run_project.go @@ -0,0 +1,51 @@ +package cli + +import ( + "fmt" + "io" + + "github.com/novusedge/stoat/internal/cli/wire" +) + +// fanOut runs one over every declared VM in declaration order. +// +// It stops at the first failure. A project is a set of VMs a contributor +// expects to come up together, and carrying on after one fails produces a +// half-built project the user has to reconstruct from the log. Every VM after +// the failure is reported as skipped, so what did not run is as visible as +// what did. +func fanOut(a *Args, stdout, stderr io.Writer, one func(name string) error) int { + var runs []wire.ProjectRunVM + failed := false + for _, d := range a.Project.VMs { + name := a.Project.GlobalName(d.Key) + entry := wire.ProjectRunVM{Key: d.Key, Name: name, Status: "ok"} + switch { + case failed: + entry.Status = "skipped" + default: + if err := one(name); err != nil { + entry.Status, entry.Error, failed = "error", err.Error(), true + } + } + runs = append(runs, entry) + if !a.Quiet { + fmt.Fprintf(stdout, "%-12s %s\n", d.Key, entry.Status) + if entry.Error != "" { + fmt.Fprintf(stderr, "stoat: %s: %s: %s\n", a.Cmd, d.Key, entry.Error) + } + } + } + if a.JSON { + code := ExitOK + if failed { + code = ExitFail + } + _ = wire.NewEmitter(stdout).ResultOKCode(a.Cmd, wire.FromProjectRun(a.Project.Name, runs), !failed) + return code + } + if failed { + return ExitFail + } + return ExitOK +} diff --git a/internal/cli/run_project_linux_test.go b/internal/cli/run_project_linux_test.go new file mode 100644 index 00000000..96ac551b --- /dev/null +++ b/internal/cli/run_project_linux_test.go @@ -0,0 +1,44 @@ +//go:build linux + +package cli + +import ( + "bytes" + "testing" + + "github.com/novusedge/stoat/internal/config" +) + +// rm at project scope asks once per VM. Answering n for one VM leaves it and +// stops the run, the same as any other failure in the fan-out. +// +// This needs a real pty, not a strings.Reader: confirm refuses any stdin +// that terminal() does not report as a TTY, and openTestPTY (defined in +// confirm_linux_test.go, same package) is the fixture that already exists +// for that. It also relies on a pty's canonical mode delivering one queued +// line per read(2): confirm wraps stdin in a fresh bufio.Reader on every +// call, and a reader that could return both answers in one Read would starve +// the second call. +func TestRMAtProjectScopeAsksPerVM(t *testing.T) { + projectRoot(t, twoVMs) + for _, n := range []string{"myrepo-dev", "myrepo-ci"} { + if err := (&config.VM{Name: n, Mode: "live", RAM: 1024, CPUs: 1, SSHPort: 2200}).Save(); err != nil { + t.Fatal(err) + } + } + master, tty := openTestPTY(t) + if _, err := master.Write([]byte("y\nn\n")); err != nil { + t.Fatal(err) + } + var errOut bytes.Buffer + code := Main([]string{"rm"}, "test", tty, tty, &errOut) + if code != ExitFail { + t.Errorf("exit = %d, want %d after a declined VM", code, ExitFail) + } + if _, err := config.Load("myrepo-dev"); err == nil { + t.Error("myrepo-dev survived a y answer") + } + if _, err := config.Load("myrepo-ci"); err != nil { + t.Error("myrepo-ci was deleted after an n answer") + } +} diff --git a/internal/cli/run_project_test.go b/internal/cli/run_project_test.go new file mode 100644 index 00000000..209ce77e --- /dev/null +++ b/internal/cli/run_project_test.go @@ -0,0 +1,224 @@ +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/novusedge/stoat/internal/config" +) + +// haveImage drops a placeholder ISO into /isos so core.Create resolves +// the alpine-virt catalog entry without reaching the network. Mirrors +// internal/core's own haveImage, unexported in a different package. +func haveImage(t *testing.T, home, name string) { + t.Helper() + dir := filepath.Join(home, "isos") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } +} + +// The fan-out runs in declaration order, stops at the first failure and +// reports every VM after it as skipped, so a half-built project is readable. +// down refuses every fixture VM here, since none are running; "one" is first +// in declaration order, so it is the first failure. +func TestFanOutStopsAtTheFirstError(t *testing.T) { + projectRoot(t, ` +schema = 1 + +[project] +name = "myrepo" + +[vms.one] +image = "alpine-virt" + +[vms.two] +image = "alpine-virt" + +[vms.three] +image = "alpine-virt" +`) + for _, n := range []string{"myrepo-one", "myrepo-two", "myrepo-three"} { + if err := (&config.VM{Name: n, Mode: "live", RAM: 1024, CPUs: 1, SSHPort: 2200}).Save(); err != nil { + t.Fatal(err) + } + } + code, objs := runJSON(t, "down") + if code != ExitFail { + t.Errorf("exit = %d, want %d", code, ExitFail) + } + data, _ := result(t, objs)["data"].(map[string]any) + vms, _ := data["vms"].([]any) + if len(vms) != 3 { + t.Fatalf("vms = %v, want three entries", vms) + } + want := []struct{ key, status string }{ + {"one", "error"}, {"two", "skipped"}, {"three", "skipped"}, + } + for i, w := range want { + e, _ := vms[i].(map[string]any) + if e["key"] != w.key || e["status"] != w.status { + t.Errorf("vms[%d] = %v, want %s/%s", i, e, w.key, w.status) + } + } +} + +// stoat up with no argument creates every declared VM that is missing. +func TestUpWithNoArgumentReconcilesEveryDeclaration(t *testing.T) { + projectRoot(t, twoVMs) + haveImage(t, os.Getenv("STOAT_HOME"), "alpine-virt-3.24.1-x86_64.iso") + // --no-apply keeps the run host-side: nothing boots in a unit test. + runJSON(t, "up", "--no-apply") + for _, n := range []string{"myrepo-dev", "myrepo-ci"} { + if _, err := config.Load(n); err != nil { + t.Errorf("%s was not created: %v", n, err) + } + } +} + +// --json never prompts, so rm with no -y is an error, not a question, at +// project scope too. +func TestRMAtProjectScopeRefusesWithoutYesUnderJSON(t *testing.T) { + projectRoot(t, twoVMs) + for _, n := range []string{"myrepo-dev", "myrepo-ci"} { + if err := (&config.VM{Name: n, Mode: "live", RAM: 1024, CPUs: 1, SSHPort: 2200}).Save(); err != nil { + t.Fatal(err) + } + } + code, _ := runJSON(t, "rm") + if code != ExitFail { + t.Errorf("exit = %d, want %d", code, ExitFail) + } + if _, err := config.Load("myrepo-dev"); err != nil { + t.Error("myrepo-dev was deleted without -y under --json") + } +} + +// apply with no argument runs every declaration in order. The fixture VMs are +// stopped, so every entry fails; the point is that all three are attempted in +// order and the report names them. +func TestApplyWithNoArgumentCoversEveryDeclaration(t *testing.T) { + projectRoot(t, twoVMs) + for _, n := range []string{"myrepo-dev", "myrepo-ci"} { + if err := (&config.VM{Name: n, Mode: "live", RAM: 1024, CPUs: 1, SSHPort: 2200}).Save(); err != nil { + t.Fatal(err) + } + } + _, objs := runJSON(t, "apply", "--dry-run") + data, _ := result(t, objs)["data"].(map[string]any) + vms, _ := data["vms"].([]any) + if len(vms) != 2 { + t.Fatalf("vms = %v, want two entries", vms) + } + first, _ := vms[0].(map[string]any) + if first["key"] != "dev" { + t.Errorf("first entry = %v, want dev, the first declaration", first) + } +} + +func TestLSShowsProjectAndKey(t *testing.T) { + dir := projectRoot(t, twoVMs) + if err := (&config.VM{Name: "myrepo-dev", Mode: "live", RAM: 1024, CPUs: 1, SSHPort: 2200, Project: dir}).Save(); err != nil { + t.Fatal(err) + } + if err := (&config.VM{Name: "other", Mode: "live", RAM: 1024, CPUs: 1, SSHPort: 2201}).Save(); err != nil { + t.Fatal(err) + } + _, objs := runJSON(t, "ls") + data, _ := result(t, objs)["data"].(map[string]any) + vms, _ := data["vms"].([]any) + byName := map[string]map[string]any{} + for _, v := range vms { + m, _ := v.(map[string]any) + byName[m["name"].(string)] = m + } + if byName["myrepo-dev"]["key"] != "dev" { + t.Errorf("myrepo-dev key = %v, want dev", byName["myrepo-dev"]["key"]) + } + if byName["myrepo-dev"]["project"] != dir { + t.Errorf("myrepo-dev project = %v, want %q", byName["myrepo-dev"]["project"], dir) + } + if byName["other"]["project"] != "" || byName["other"]["key"] != "" { + t.Errorf("global VM carries project fields: %v", byName["other"]) + } +} + +func TestLSProjectFilter(t *testing.T) { + dir := projectRoot(t, twoVMs) + if err := (&config.VM{Name: "myrepo-dev", Mode: "live", RAM: 1024, CPUs: 1, SSHPort: 2200, Project: dir}).Save(); err != nil { + t.Fatal(err) + } + if err := (&config.VM{Name: "other", Mode: "live", RAM: 1024, CPUs: 1, SSHPort: 2201}).Save(); err != nil { + t.Fatal(err) + } + _, objs := runJSON(t, "ls", "--project") + data, _ := result(t, objs)["data"].(map[string]any) + vms, _ := data["vms"].([]any) + if len(vms) != 1 { + t.Fatalf("vms = %v, want only this project's", vms) + } +} + +// A VM whose project directory is gone still lists; ls marks the path. +func TestLSMarksAMissingProjectDirectory(t *testing.T) { + cliRoot(t) + t.Chdir(t.TempDir()) + if err := (&config.VM{Name: "orphan", Mode: "live", RAM: 1024, CPUs: 1, SSHPort: 2200, Project: "/gone/myrepo"}).Save(); err != nil { + t.Fatal(err) + } + _, objs := runJSON(t, "ls") + data, _ := result(t, objs)["data"].(map[string]any) + vms, _ := data["vms"].([]any) + m, _ := vms[0].(map[string]any) + if m["project_missing"] != true { + t.Errorf("project_missing = %v, want true", m["project_missing"]) + } +} + +func TestNewRefusesAtProjectScope(t *testing.T) { + projectRoot(t, twoVMs) + code, objs := runJSON(t, "new", "scratch", "--image", "alpine-virt") + if code != ExitFail { + t.Errorf("exit = %d, want %d", code, ExitFail) + } + errObj, _ := result(t, objs)["error"].(map[string]any) + msg, _ := errObj["message"].(string) + want := "a stoat.toml is present; declare the VM there and run stoat up, or pass --global" + if !strings.Contains(msg, want) { + t.Errorf("message = %q, want it to contain %q", msg, want) + } + if _, err := config.Load("scratch"); err == nil { + t.Error("scratch was created at project scope") + } +} + +func TestNewGlobalBypassesTheRefusal(t *testing.T) { + projectRoot(t, twoVMs) + haveImage(t, os.Getenv("STOAT_HOME"), "alpine-virt-3.24.1-x86_64.iso") + code, _ := runJSON(t, "new", "scratch", "--image", "alpine-virt", "--global") + if code != ExitOK { + t.Fatalf("exit = %d, want %d", code, ExitOK) + } + v, err := config.Load("scratch") + if err != nil { + t.Fatal(err) + } + if v.Project != "" { + t.Errorf("project = %q, want empty for a --global VM", v.Project) + } +} + +// create is the old spelling and keeps working, with the same refusal. +func TestCreateIsTheSameCommandAsNew(t *testing.T) { + projectRoot(t, twoVMs) + haveImage(t, os.Getenv("STOAT_HOME"), "alpine-virt-3.24.1-x86_64.iso") + code, _ := runJSON(t, "create", "scratch", "--image", "alpine-virt", "--global") + if code != ExitOK { + t.Errorf("exit = %d, want %d", code, ExitOK) + } +} diff --git a/internal/cli/run_status.go b/internal/cli/run_status.go new file mode 100644 index 00000000..9aad0430 --- /dev/null +++ b/internal/cli/run_status.go @@ -0,0 +1,91 @@ +package cli + +import ( + "errors" + "fmt" + "io" + "strings" + + "github.com/novusedge/stoat/internal/cli/wire" + "github.com/novusedge/stoat/internal/core" + "github.com/novusedge/stoat/internal/project" +) + +// runStatus prints one line per declared VM: global name, state, health and +// drift. It changes nothing, so it is safe to run against a project someone +// else is building. +func runStatus(a *Args, stdout, stderr io.Writer) int { + if a.Project == nil { + return a.failMsg(stdout, stderr, core.ErrNotFound, "no "+project.FileName+" in this directory") + } + + var rows []wire.ProjectStatusVM + for _, d := range a.Project.VMs { + name := a.Project.GlobalName(d.Key) + row := wire.ProjectStatusVM{Key: d.Key, Name: name, State: "missing", Health: "", Drift: []wire.Drift{}} + + v, err := core.Get(name) + switch { + case errors.Is(err, core.ErrNotFound): + case err != nil: + return a.fail(stdout, stderr, err) + default: + row.State, row.Health = string(v.State), string(v.Health) + drift, err := core.Diff(a.Project, d.Key) + if err != nil { + // An immutable change is a real answer about this VM, not a + // reason to refuse the whole table. + row.Error = err.Error() + fmt.Fprintf(stderr, "stoat: status: %v\n", err) + } else { + row.Drift = wire.FromDrifts(drift) + } + } + rows = append(rows, row) + } + + if a.JSON { + return a.ok(stdout, wire.FromProjectStatus(a.Project.Name, a.Project.Dir, rows)) + } + fmt.Fprintf(stdout, "%-12s %-20s %-9s %-9s %s\n", "KEY", "NAME", "STATE", "HEALTH", "DRIFT") + for _, r := range rows { + cell := r.Error + if cell == "" { + cell = renderDrift(r.Drift) + } + fmt.Fprintf(stdout, "%-12s %-20s %-9s %-9s %s\n", + r.Key, r.Name, r.State, healthCell(r.Health), dash(cell)) + } + return ExitOK +} + +// renderDrift is the DRIFT column: "cpus 2 → 4 (restart), recipes a → a,b". +func renderDrift(ds []wire.Drift) string { + parts := make([]string, len(ds)) + for i, d := range ds { + parts[i] = fmt.Sprintf("%s %s → %s", d.Field, dash(d.From), dash(d.To)) + if d.NeedsRestart { + parts[i] += " (restart)" + } + } + return strings.Join(parts, ", ") +} + +// dash renders an empty value as "-", so a column never reads as a missing +// field. +func dash(s string) string { + if s == "" { + return "-" + } + return s +} + +// healthCell is the HEALTH column: core.HealthUnknown reads the same as no +// verdict at all, since a VM that has never applied a recipe is not a health +// failure. +func healthCell(h string) string { + if h == string(core.HealthUnknown) { + return "-" + } + return dash(h) +} diff --git a/internal/cli/run_status_test.go b/internal/cli/run_status_test.go new file mode 100644 index 00000000..5fe3bff2 --- /dev/null +++ b/internal/cli/run_status_test.go @@ -0,0 +1,104 @@ +package cli + +import ( + "bytes" + "os" + "strings" + "testing" + + "github.com/novusedge/stoat/internal/config" +) + +func TestStatusLineFormat(t *testing.T) { + projectRoot(t, ` +schema = 1 + +[project] +name = "myrepo" + +[vms.dev] +image = "alpine-virt" +cpus = 4 + +[vms.ci] +image = "alpine-virt" +`) + haveImage(t, os.Getenv("STOAT_HOME"), "alpine-virt-3.24.1-x86_64.iso") + // dev exists at 2 cpus, so the declaration drifts by two. + if err := (&config.VM{ + Name: "myrepo-dev", Mode: "live", ISO: "isos/alpine-virt-3.24.1-x86_64.iso", + RAM: 4096, CPUs: 2, SSHPort: 2200, + }).Save(); err != nil { + t.Fatal(err) + } + var out, errOut bytes.Buffer + if code := Main([]string{"status"}, "test", strings.NewReader(""), &out, &errOut); code != ExitOK { + t.Fatalf("exit = %d: %s", code, errOut.String()) + } + lines := strings.Split(strings.TrimRight(out.String(), "\n"), "\n") + if len(lines) != 3 { + t.Fatalf("got %d lines, want a header and two VMs:\n%s", len(lines), out.String()) + } + if !strings.HasPrefix(lines[0], "KEY") { + t.Errorf("header = %q", lines[0]) + } + if !strings.Contains(lines[1], "myrepo-dev") || !strings.Contains(lines[1], "cpus 2 → 4 (restart)") { + t.Errorf("dev line = %q", lines[1]) + } + if !strings.Contains(lines[2], "missing") { + t.Errorf("ci line = %q, want state missing", lines[2]) + } +} + +func TestStatusJSON(t *testing.T) { + projectRoot(t, twoVMs) + haveImage(t, os.Getenv("STOAT_HOME"), "alpine-virt-3.24.1-x86_64.iso") + // dev has applied docker, which twoVMs no longer declares, so it drifts + // by one field. + if err := (&config.VM{ + Name: "myrepo-dev", Mode: "live", ISO: "isos/alpine-virt-3.24.1-x86_64.iso", + RAM: 4096, CPUs: 4, SSHPort: 2200, Recipes: []string{"docker"}, + }).Save(); err != nil { + t.Fatal(err) + } + code, objs := runJSON(t, "status") + if code != ExitOK { + t.Fatalf("exit = %d", code) + } + data, _ := result(t, objs)["data"].(map[string]any) + if data["project"] != "myrepo" { + t.Errorf("project = %v, want myrepo", data["project"]) + } + vms, _ := data["vms"].([]any) + if len(vms) != 2 { + t.Fatalf("vms = %v, want two", vms) + } + first, _ := vms[0].(map[string]any) + if first["key"] != "dev" || first["name"] != "myrepo-dev" { + t.Errorf("first = %v", first) + } + drift, ok := first["drift"].([]any) + if !ok || len(drift) != 1 { + t.Fatalf("drift = %#v, want one entry", first["drift"]) + } + d, _ := drift[0].(map[string]any) + if d["field"] != "recipes" || d["from"] != "docker" || d["to"] != "" { + t.Errorf("drift[0] = %v", d) + } +} + +// Outside a project, status has nothing to report and says so rather than +// printing an empty table. +func TestStatusOutsideAProject(t *testing.T) { + cliRoot(t) + t.Chdir(t.TempDir()) + code, objs := runJSON(t, "status") + if code != ExitFail { + t.Errorf("exit = %d, want %d", code, ExitFail) + } + errObj, _ := result(t, objs)["error"].(map[string]any) + msg, _ := errObj["message"].(string) + if !strings.Contains(msg, "no stoat.toml") { + t.Errorf("message = %q", msg) + } +} diff --git a/internal/cli/run_vm.go b/internal/cli/run_vm.go index 56619d35..aeb6efa7 100644 --- a/internal/cli/run_vm.go +++ b/internal/cli/run_vm.go @@ -10,6 +10,7 @@ import ( "github.com/novusedge/stoat/internal/cli/wire" "github.com/novusedge/stoat/internal/config" "github.com/novusedge/stoat/internal/core" + "github.com/novusedge/stoat/internal/project" "github.com/novusedge/stoat/internal/sshx" ) @@ -18,11 +19,24 @@ func runLS(a *Args, stdout, stderr io.Writer) int { if err != nil { return a.fail(stdout, stderr, err) } + core.AttachKeys(vms, a.Project) + if a.Clear { + if a.Project == nil { + return a.failMsg(stdout, stderr, core.ErrNotFound, "--project needs a "+project.FileName+" in this directory") + } + var kept []core.VM + for _, v := range vms { + if v.Project == a.Project.Dir { + kept = append(kept, v) + } + } + vms = kept + } if a.JSON { return a.ok(stdout, map[string]any{"vms": wire.FromVMs(vms, core.GraphicalSession())}) } - fmt.Fprintf(stdout, "%-15s %-5s %-8s %-5s %-6s %s\n", "NAME", "MODE", "STATE", "CPUS", "RAM", "SSH") + fmt.Fprintf(stdout, "%-15s %-5s %-8s %-5s %-6s %-6s %s\n", "NAME", "MODE", "STATE", "CPUS", "RAM", "SSH", "PROJECT") // core.List() sorts every VM, broken ones included, together by name, // so a broken VM can interleave alphabetically with good ones. The // original two calls (config.List then config.ListBroken) printed every @@ -38,8 +52,8 @@ func runLS(a *Args, stdout, stderr io.Writer) int { if v.State == core.StateRunning { state = "running" } - fmt.Fprintf(stdout, "%-15s %-5s %s %-5d %-6d %d\n", - v.Name, v.Mode, colorState(state, 8), v.CPUs, v.RAM, v.SSHPort) + fmt.Fprintf(stdout, "%-15s %-5s %s %-5d %-6d %-6d %s\n", + v.Name, v.Mode, colorState(state, 8), v.CPUs, v.RAM, v.SSHPort, projectCell(v)) } // Broken VMs are real entries: hiding them is the bug that was already // reported once. They get dashes for the fields a broken vm.toml can't @@ -48,13 +62,48 @@ func runLS(a *Args, stdout, stderr io.Writer) int { if v.State != core.StateBroken { continue } - fmt.Fprintf(stdout, "%-15s %-5s %s %-5s %-6s %-4s %s\n", - v.Name, "-", colorState("broken", 8), "-", "-", "-", oneLine(v.Error)) + fmt.Fprintf(stdout, "%-15s %-5s %s %-5s %-6s %-4s %-6s %s\n", + v.Name, "-", colorState("broken", 8), "-", "-", "-", "-", oneLine(v.Error)) } return ExitOK } +// projectCell renders the PROJECT column: the declaring directory, marked +// when it is gone, and "-" for a VM stoat new created. +func projectCell(v core.VM) string { + switch { + case v.Project == "": + return "-" + case v.ProjectMissing: + return v.Project + " (missing)" + default: + return v.Project + } +} + func runUp(a *Args, stdout, stderr io.Writer) int { + if a.VM == "" { + // Reconcile every declaration before starting any. A start failure + // stops the fan-out below (the spec's contract for a half-built + // project), but by then every declared VM already exists: a later + // declaration's presence must not depend on an earlier one's start + // succeeding. + for _, d := range a.Project.VMs { + if err := reconcileOne(a, d.Key, stdout); err != nil { + return a.fail(stdout, stderr, err) + } + } + return fanOut(a, stdout, stderr, func(name string) error { + return upOne(a, name, stdout, stderr) + }) + } + if a.Project != nil { + if key, ok := a.Project.KeyFor(a.VM); ok { + if err := reconcileOne(a, key, stdout); err != nil { + return a.fail(stdout, stderr, err) + } + } + } // core.Get first: a caller must learn "no such VM" or "broken" before // any progress line prints, not after. v, err := core.Get(a.VM) @@ -169,6 +218,43 @@ func afterStart(a *Args, v core.VM, stdout, stderr io.Writer) int { return ExitOK } +// reconcileOne makes one declared VM match stoat.toml before it starts. A +// change that needs a restart is already saved; the guest sees it at the next +// down and up, and that is what the line says. +func reconcileOne(a *Args, key string, stdout io.Writer) error { + r, err := core.Reconcile(a.Project, key) + if err != nil { + return err + } + if a.Quiet { + return nil + } + if r.Created { + fmt.Fprintf(stdout, "created %s from %s\n", r.Name, project.FileName) + return nil + } + for _, d := range r.Drift { + suffix := "" + if d.NeedsRestart { + suffix = " (applies at the next down and up)" + } + fmt.Fprintf(stdout, "%s: %s %s -> %s%s\n", d.Key, d.Field, d.From, d.To, suffix) + } + return nil +} + +// upOne is runUp's body for one VM, used by the no-argument fan-out. It +// returns an error rather than an exit code, which is what fanOut collects. +func upOne(a *Args, name string, stdout, stderr io.Writer) error { + sub := *a + sub.VM = name + sub.JSON = false // the fan-out emits the single terminal result line + if code := runUp(&sub, stdout, stderr); code != ExitOK { + return fmt.Errorf("%s: up failed", name) + } + return nil +} + // printDisplay says where a VM's screen is and how to reach it. // // This prints on every start, not only the surprising one, because the @@ -203,6 +289,16 @@ func printDisplay(w io.Writer, d core.Display) { } func runDown(a *Args, stdout, stderr io.Writer) int { + if a.VM == "" { + return fanOut(a, stdout, stderr, func(name string) error { + sub := *a + sub.VM, sub.JSON = name, false + if code := runDown(&sub, stdout, stderr); code != ExitOK { + return fmt.Errorf("%s is not running", name) + } + return nil + }) + } v, err := core.Get(a.VM) if err != nil { return a.fail(stdout, stderr, err) @@ -238,6 +334,16 @@ func runDown(a *Args, stdout, stderr io.Writer) int { } func runRM(a *Args, stdin io.Reader, stdout, stderr io.Writer) int { + if a.VM == "" { + return fanOut(a, stdout, stderr, func(name string) error { + sub := *a + sub.VM, sub.JSON = name, false + if code := runRM(&sub, stdin, stdout, stderr); code != ExitOK { + return fmt.Errorf("%s was not deleted", name) + } + return nil + }) + } v, err := core.Get(a.VM) if err != nil { return a.fail(stdout, stderr, err) @@ -268,6 +374,10 @@ func runRM(a *Args, stdin io.Reader, stdout, stderr io.Writer) int { // what came back. There was no create subcommand before core existed, because // everything it needed lived inside the TUI's form. func runCreate(a *Args, stdout, stderr io.Writer) int { + if a.Project != nil && !a.Global { + return a.failMsg(stdout, stderr, core.ErrInvalidSpec, + "a "+project.FileName+" is present; declare the VM there and run stoat up, or pass --global") + } set, _, secrets := paramMaps(a.Params) a.Spec.Params = set a.Spec.Secrets = secrets diff --git a/internal/cli/run_wait.go b/internal/cli/run_wait.go index 58234492..4d1764b3 100644 --- a/internal/cli/run_wait.go +++ b/internal/cli/run_wait.go @@ -14,6 +14,13 @@ import ( // ctx, so a stopped VM asked to become reachable fails fast rather than // waiting out the whole timeout. func runWait(a *Args, stdout, stderr io.Writer) int { + if a.VM == "" { + return fanOut(a, stdout, stderr, func(name string) error { + ctx, cancel := context.WithTimeout(context.Background(), a.Timeout) + defer cancel() + return core.Wait(ctx, name, a.Until) + }) + } ctx, cancel := context.WithTimeout(context.Background(), a.Timeout) defer cancel() diff --git a/internal/cli/samples_test.go b/internal/cli/samples_test.go new file mode 100644 index 00000000..a8053f83 --- /dev/null +++ b/internal/cli/samples_test.go @@ -0,0 +1,50 @@ +package cli + +import ( + "os" + "path/filepath" + "testing" + + "github.com/novusedge/stoat/internal/core" + "github.com/novusedge/stoat/internal/iso" + "github.com/novusedge/stoat/internal/project" +) + +// The documented sample must decode in Reject mode, so a field renamed in +// project's structs cannot leave the docs describing a file stoat refuses. +func TestSampleProjectFileDecodes(t *testing.T) { + body, err := os.ReadFile(filepath.Join("..", "..", "docs", "reference", "samples", "stoat.toml")) + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, project.FileName), body, 0o644); err != nil { + t.Fatal(err) + } + for _, d := range []string{"src", "docs"} { + if err := os.Mkdir(filepath.Join(dir, d), 0o755); err != nil { + t.Fatal(err) + } + } + p, err := project.Load(dir) + if err != nil { + t.Fatalf("the documented sample does not load: %v", err) + } + if len(p.VMs) == 0 { + t.Fatal("the sample declares no VM") + } + if _, err := p.Shares(p.VMs[0].Key); err != nil { + t.Errorf("the sample's shares do not resolve: %v", err) + } + + spec, err := core.SpecFor(p, p.VMs[0].Key) + if err != nil { + t.Fatalf("core.SpecFor cannot build a spec from the sample: %v", err) + } + for _, e := range iso.Catalog() { + if e.ID == spec.Image { + return + } + } + t.Errorf("image %q in the sample is not a catalog id", spec.Image) +} diff --git a/internal/cli/subcommands_test.go b/internal/cli/subcommands_test.go index 997799cb..4e799a75 100644 --- a/internal/cli/subcommands_test.go +++ b/internal/cli/subcommands_test.go @@ -331,9 +331,6 @@ func TestParseApplyOnly(t *testing.T) { if len(a.Only) != 2 || a.Only[0] != "a.sh" || a.Only[1] != "b.sh" { t.Errorf("Only = %v, want [a.sh b.sh]", a.Only) } - if _, err := Parse([]string{"apply"}); err == nil { - t.Error("apply with no vm name was accepted") - } } func TestApplyMissingVMIsNotFound(t *testing.T) { diff --git a/internal/cli/wire/dto.go b/internal/cli/wire/dto.go index 0ae255e5..f11da69c 100644 --- a/internal/cli/wire/dto.go +++ b/internal/cli/wire/dto.go @@ -108,6 +108,13 @@ type VM struct { // already wrong. Display string `json:"display"` Error string `json:"error,omitempty"` + + // Project is the directory of the stoat.toml that declared this VM, and + // Key the declaration key. Both are empty for a VM created by stoat new. + // ProjectMissing says the directory is gone; the VM still runs. + Project string `json:"project"` + Key string `json:"key"` + ProjectMissing bool `json:"project_missing"` } // RecipeState is one recipe's redacted per-VM state. @@ -202,6 +209,10 @@ func FromVM(v core.VM, graphical bool) VM { // PATH, which DisplayFor does once per VM it is handed. Display: core.DisplayKind(v, graphical), Error: v.Error, + + Project: v.Project, + Key: v.Key, + ProjectMissing: v.ProjectMissing, } } @@ -890,3 +901,78 @@ type MCPDoctor struct { type JobList struct { Jobs []Job `json:"jobs"` } + +// InitResult is stoat init's answer: where the file landed and whether the +// gitignore needed a line. +type InitResult struct { + Path string `json:"path"` + Project string `json:"project"` + GitignoreUpdated bool `json:"gitignore_updated"` +} + +// ProjectRunVM is one VM's outcome in a no-argument project command. +// Status is "ok", "error" or "skipped"; skipped means a VM earlier in +// declaration order failed and this one was never attempted. +type ProjectRunVM struct { + Key string `json:"key"` + Name string `json:"name"` + Status string `json:"status"` + Error string `json:"error,omitempty"` +} + +// ProjectRun is a no-argument project command's answer: one entry per +// declared VM, in declaration order. +type ProjectRun struct { + Project string `json:"project"` + VMs []ProjectRunVM `json:"vms"` +} + +func FromProjectRun(name string, vms []ProjectRunVM) ProjectRun { + return ProjectRun{Project: name, VMs: nonNil(vms)} +} + +// Drift is core.Drift for the wire: one field where stoat.toml and vm.toml +// disagree. +type Drift struct { + Field string `json:"field"` + From string `json:"from"` + To string `json:"to"` + NeedsRestart bool `json:"needs_restart"` +} + +func FromDrift(d core.Drift) Drift { + return Drift{Field: d.Field, From: d.From, To: d.To, NeedsRestart: d.NeedsRestart} +} + +func FromDrifts(ds []core.Drift) []Drift { + out := make([]Drift, len(ds)) + for i, d := range ds { + out[i] = FromDrift(d) + } + return nonNil(out) +} + +// ProjectStatusVM is one declaration's line in stoat status. State is +// "missing" for a declared VM that has not been created; every other value is +// a core.State. Error carries core.Diff's message for an immutable-field +// mismatch; Drift is empty whenever Error is set, since Diff refuses to +// compare the rest of the fields once image or disk disagrees. +type ProjectStatusVM struct { + Key string `json:"key"` + Name string `json:"name"` + State string `json:"state"` + Health string `json:"health"` + Drift []Drift `json:"drift"` + Error string `json:"error,omitempty"` +} + +// ProjectStatus is stoat status, and the MCP project_status tool. +type ProjectStatus struct { + Project string `json:"project"` + Dir string `json:"dir"` + VMs []ProjectStatusVM `json:"vms"` +} + +func FromProjectStatus(name, dir string, vms []ProjectStatusVM) ProjectStatus { + return ProjectStatus{Project: name, Dir: dir, VMs: nonNil(vms)} +} diff --git a/internal/cli/wire/dto_test.go b/internal/cli/wire/dto_test.go index 5c65b80d..a0018bb5 100644 --- a/internal/cli/wire/dto_test.go +++ b/internal/cli/wire/dto_test.go @@ -13,7 +13,7 @@ import ( func TestVMGolden(t *testing.T) { got := marshal(t, FromVM(sampleVM(), true)) - want := `{"name":"work","os":"alpine","mode":"live","backend":"apkovl","state":"running","cpus":4,"ram_mb":4096,"disk":"8G","share":"/home/u/src","recipes":["xfce"],"ssh_port":2222,"ssh_user":"root","installed":false,"forwards":[{"host_port":8080,"guest_port":80}],"allow_exec":true,"display":"window"}` + want := `{"name":"work","os":"alpine","mode":"live","backend":"apkovl","state":"running","cpus":4,"ram_mb":4096,"disk":"8G","share":"/home/u/src","recipes":["xfce"],"ssh_port":2222,"ssh_user":"root","installed":false,"forwards":[{"host_port":8080,"guest_port":80}],"allow_exec":true,"display":"window","project":"","key":"","project_missing":false}` if got != want { t.Errorf("got %s\nwant %s", got, want) } diff --git a/internal/cli/wire/envelope.go b/internal/cli/wire/envelope.go index b086f83d..e6883338 100644 --- a/internal/cli/wire/envelope.go +++ b/internal/cli/wire/envelope.go @@ -123,6 +123,13 @@ func (e *Emitter) ResultErr(cmd string, errInfo *ErrorInfo) error { return e.Result(cmd, false, nil, errInfo) } +// ResultOKCode emits a terminal result whose ok flag the caller decides. A +// project fan-out carries a full per-VM report even when one VM failed, so it +// cannot use ResultErr, which carries an error object and no data. +func (e *Emitter) ResultOKCode(cmd string, data any, ok bool) error { + return e.Result(cmd, ok, data, nil) +} + func (e *Emitter) write(env envelope) error { if err := e.enc.Encode(env); err != nil { return err diff --git a/internal/cloudinit/cloudinit.go b/internal/cloudinit/cloudinit.go index 45a33c2b..9f87bda2 100644 --- a/internal/cloudinit/cloudinit.go +++ b/internal/cloudinit/cloudinit.go @@ -150,6 +150,9 @@ func mountsDoc(v *config.VM) string { if v.Share != "" { fmt.Fprintf(&b, " - [ host, /mnt/host, 9p, %q, \"0\", \"0\" ]\n", fmt.Sprintf(opts, "ro")) } + for _, s := range v.Shares { + b.WriteString(fmt.Sprintf(" - [ %s, %s, 9p, %q, \"0\", \"0\" ]\n", s.Tag, s.Guest, fmt.Sprintf(opts, "rw"))) + } return b.String() } diff --git a/internal/config/config.go b/internal/config/config.go index 71fa3012..764b38f0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -43,6 +43,17 @@ type AppliedRecipe struct { Health string `toml:"health"` } +// Share is one 9p export a project VM gets, beyond the legacy single Share +// and the per-VM work scratch. Tag is qemu's mount_tag and must be unique +// within a VM; "host" and "work" are reserved by the two exports qemu.Args +// already attaches. stoat writes these from stoat.toml at create and at +// reconcile; a hand edit is overwritten by the next stoat up. +type Share struct { + Tag string `toml:"tag"` + Host string `toml:"host"` + Guest string `toml:"guest"` +} + // VM is one virtual machine. vm.toml is authoritative; there is no cache. type VM struct { Name string `toml:"name"` @@ -115,6 +126,16 @@ type VM struct { // Applied tracks which recipes have been run on this VM, keyed by recipe name. Applied map[string]AppliedRecipe `toml:"applied,omitempty" comment:"written by stoat; do not edit"` + // Project is the absolute directory of the stoat.toml that declared this + // VM, empty for a VM created by stoat new. A VM whose project directory + // is gone still lists and still runs; ls marks the path "(missing)". + Project string `toml:"project"` + + // Shares are the project's 9p exports, mounted under /work in the guest. + // The legacy single Share field stays as it is: it is a different export + // (read-only, at /mnt/host) with its own users. + Shares []Share `toml:"shares,omitempty"` + Dir string `toml:"-"` // absolute path to the VM directory } diff --git a/internal/config/shares_test.go b/internal/config/shares_test.go new file mode 100644 index 00000000..2902c3bb --- /dev/null +++ b/internal/config/shares_test.go @@ -0,0 +1,36 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +// A project VM round-trips its project path and its shares through vm.toml. +func TestProjectAndSharesRoundTrip(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + v := &VM{ + Name: "myrepo-dev", Mode: "live", RAM: 1024, CPUs: 1, SSHPort: 2200, + Project: "/home/u/myrepo", + Shares: []Share{ + {Tag: "p0", Host: "/home/u/myrepo", Guest: "/work"}, + {Tag: "p1", Host: "/home/u/myrepo/src", Guest: "/work/src"}, + }, + } + if err := v.Save(); err != nil { + t.Fatal(err) + } + got, err := Load("myrepo-dev") + if err != nil { + t.Fatal(err) + } + if got.Project != "/home/u/myrepo" { + t.Errorf("project = %q, want /home/u/myrepo", got.Project) + } + if len(got.Shares) != 2 || got.Shares[1].Guest != "/work/src" { + t.Errorf("shares = %+v", got.Shares) + } + if _, err := os.Stat(filepath.Join(Root(), "myrepo-dev", "vm.toml")); err != nil { + t.Fatal(err) + } +} diff --git a/internal/core/core.go b/internal/core/core.go index 1d0b489d..e4058c50 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -71,6 +71,15 @@ type Spec struct { Params map[string]map[string]string Secrets config.Secrets + // Project is the absolute directory of the stoat.toml that declared this + // VM. Empty for stoat new. plan writes it to vm.toml unchanged. + Project string + + // Shares are the project's 9p exports, already resolved and checked by + // project.Shares. plan does not validate them again: containment is a + // question about the project directory, which core cannot see. + Shares []config.Share + // Display is the screen preference to record in vm.toml: "" or "auto" // (default), "window", or "vnc". validateDisplay is the single check // for the value; plan applies it. @@ -269,6 +278,8 @@ func plan(s Spec) (*config.VM, error) { AllowExec: allowExec, AgentAccess: agentAccess, Display: s.Display, + Project: s.Project, + Shares: s.Shares, } if img.backend == "cloudinit" { diff --git a/internal/core/image.go b/internal/core/image.go index 6b51d429..85ff6a1e 100644 --- a/internal/core/image.go +++ b/internal/core/image.go @@ -108,6 +108,17 @@ func (i image) isoField() string { return i.abs } +// id names the image for a drift comparison: the catalog id when the image +// came from the catalog, the absolute path otherwise. A BYO image has no +// catalog id, so the path is the only stable identifier to compare against a +// re-declared vms..image. +func (i image) id() string { + if i.entry != nil { + return i.entry.ID + } + return i.abs +} + // resolveImage turns Spec.Image into a concrete file. It accepts a catalog // entry ID, a bare filename under isos/, or an absolute path to anything. // diff --git a/internal/core/project.go b/internal/core/project.go new file mode 100644 index 00000000..69e30638 --- /dev/null +++ b/internal/core/project.go @@ -0,0 +1,386 @@ +package core + +import ( + "errors" + "fmt" + "reflect" + "sort" + "strconv" + "strings" + + "github.com/novusedge/stoat/internal/config" + "github.com/novusedge/stoat/internal/iso" + "github.com/novusedge/stoat/internal/project" +) + +// SpecFor turns one declaration into the Spec that Create takes. +// +// It sets only what the declaration states. Every other field stays zero so +// plan() applies the same default stoat new would, which is what makes a +// minimal [vms.x] produce the VM a user gets by pressing enter through the +// form. +func SpecFor(p *project.Project, key string) (Spec, error) { + d, ok := p.VM(key) + if !ok { + return Spec{}, fmt.Errorf("%w: no vms.%s in %s", ErrNotFound, key, project.FileName) + } + shares, err := p.Shares(key) + if err != nil { + return Spec{}, err + } + secrets, err := p.Secrets(key) + if err != nil { + return Spec{}, err + } + params, err := stringParams(key, d.Params) + if err != nil { + return Spec{}, err + } + return Spec{ + Name: p.GlobalName(key), + Image: d.Image, + RAM: d.RAM, + CPUs: d.CPUs, + Disk: d.Disk, + Recipes: d.Recipes, + AgentAccess: d.AgentAccess, + Project: p.Dir, + Shares: configShares(shares), + Params: params, + Secrets: config.Secrets(secrets), + }, nil +} + +// stringParams renders a declaration's TOML param values to the strings the +// guest reads. TOML gives "port = 2375" as an int64 and "tls = true" as a +// bool; vm.toml, the recipe hash and STOAT_PARAM_* all carry one spelling of +// each, the same one recipes.ParseManifest renders a manifest default to. +func stringParams(key string, in map[string]map[string]any) (map[string]map[string]string, error) { + if len(in) == 0 { + return nil, nil + } + out := make(map[string]map[string]string, len(in)) + for recipe, params := range in { + vals := make(map[string]string, len(params)) + for name, v := range params { + switch t := v.(type) { + case string: + vals[name] = t + case int64: + vals[name] = strconv.FormatInt(t, 10) + case bool: + vals[name] = strconv.FormatBool(t) + default: + return nil, fmt.Errorf("%w: %s: vms.%s.params.%s.%s: %v is not a string, an integer or a boolean", + ErrInvalidSpec, project.FileName, key, recipe, name, v) + } + } + out[recipe] = vals + } + return out, nil +} + +// configShares converts project.Share to the on-disk shape. config cannot +// import project: project is a caller-side concept and config is the file +// format, so the conversion lives here, in the package that knows both. +func configShares(in []project.Share) []config.Share { + if len(in) == 0 { + return nil + } + out := make([]config.Share, len(in)) + for i, s := range in { + out[i] = config.Share{Tag: s.Tag, Host: s.Host, Guest: s.Guest} + } + return out +} + +// ErrImmutableDeclaration is returned by Diff when a declaration changes +// image or disk. Neither can be applied to an existing VM: image decides the +// disk contents and disk is the size that disk was created at, so the only +// honest answer is to delete the VM and let stoat up build it again. +var ErrImmutableDeclaration = errors.New("immutable declaration field") + +// Drift is one field where the declaration and vm.toml disagree. +// +// Key is the declaration key, not the global name: the user wrote "dev" and +// every message about it says "dev". +type Drift struct { + Key string + Field string + From string + To string + + // NeedsRestart is true for a field qemu reads at start and cannot change + // on a live guest: cpus, ram and shares. stoat up saves such a change and + // reports it; it takes effect at the next down and up. + NeedsRestart bool +} + +// Diff compares one declaration to the VM it names. A VM that does not exist +// yet is not drift; Reconcile creates it. +func Diff(p *project.Project, key string) ([]Drift, error) { + spec, err := SpecFor(p, key) + if err != nil { + return nil, err + } + v, err := load(spec.Name) + if err != nil { + return nil, err + } + + // vm.toml stores the resolved ISO path or base image, never a catalog + // id. The declared image is compared against its resolved spelling. + img, err := resolveImage(spec.Image) + if err != nil { + return nil, err + } + now := img.isoField() + if img.backend == "cloudinit" { + now = img.abs + } + if was := declaredImage(v); was != now { + return nil, fmt.Errorf("%w: %s: image changed (%s -> %s); run stoat rm %s and stoat up", + ErrImmutableDeclaration, key, imageName(was), img.id(), key) + } + declaredDisk := spec.Disk + if declaredDisk == "" { + mode, err := modeFor(img.backend, "") + if err != nil { + return nil, err + } + if mode != "live" { + declaredDisk = DefaultDisk + } + } + if declaredDisk != v.Disk { + return nil, fmt.Errorf("%w: %s: disk changed (%s -> %s); run stoat rm %s and stoat up", + ErrImmutableDeclaration, key, v.Disk, declaredDisk, key) + } + + var out []Drift + add := func(field, from, to string, restart bool) { + if from != to { + out = append(out, Drift{Key: key, Field: field, From: from, To: to, NeedsRestart: restart}) + } + } + if spec.CPUs != 0 { + add("cpus", strconv.Itoa(v.CPUs), strconv.Itoa(spec.CPUs), true) + } + if spec.RAM != 0 { + add("ram", strconv.Itoa(v.RAM), strconv.Itoa(spec.RAM), true) + } + add("recipes", strings.Join(v.Recipes, ","), strings.Join(spec.Recipes, ","), false) + add("shares", renderShares(v.Shares), renderShares(spec.Shares), true) + add("params", renderParams(v.Params), renderParams(spec.Params), false) + if spec.AgentAccess != "" { + add("agent_access", v.AgentAccess, spec.AgentAccess, false) + } + return out, nil +} + +// declaredImage names the image a VM was created from, whichever field holds +// it. A cloud VM records Base, every other mode records ISO. +func declaredImage(v *config.VM) string { + if v.Base != "" { + return v.Base + } + return v.ISO +} + +// imageName renders a stored image field back to a catalog id, so an +// image-changed message names the image the way a user typed it in +// stoat.toml rather than the file it resolved to. A field with no catalog +// match (a BYO image, or an absolute Base path) is returned as recorded. +func imageName(field string) string { + rel := strings.TrimPrefix(field, "isos/") + if rel == field { + return field + } + for _, e := range iso.Catalog() { + if MatchLocal(e, []string{rel}) == rel { + return e.ID + } + } + return rel +} + +// renderShares is the comparable form of a share list: guest mountpoint and +// host path, in order. The mount tag is derived from position, so comparing +// it would report drift for a reordering that changes nothing in the guest. +func renderShares(ss []config.Share) string { + parts := make([]string, len(ss)) + for i, s := range ss { + parts[i] = s.Guest + "=" + s.Host + } + return strings.Join(parts, ",") +} + +// renderParams is the comparable form of a param table: recipe.param=value, +// sorted, so a map walk cannot report drift where there is none. It takes +// the stored shape, map[string]map[string]string, which is what both +// config.VM and core.Spec carry after SpecFor renders the declaration's TOML +// values. +func renderParams(m map[string]map[string]string) string { + var parts []string + for recipe, params := range m { + for name, val := range params { + parts = append(parts, fmt.Sprintf("%s.%s=%s", recipe, name, val)) + } + } + sort.Strings(parts) + return strings.Join(parts, ",") +} + +// removedParams finds recipe.param entries that vm.toml has and the +// declaration no longer states, for a recipe the declaration still applies, +// so Reconcile unsets them instead of leaving a stale value that Diff +// reports as drift on every later stoat up. A recipe missing from keep is +// excluded: Update clears a dropped recipe's params (and secrets) itself +// when Reconcile removes it from vm.toml's recipe list, and naming it here +// too would ask Update to resolve a recipe already gone from that list. +func removedParams(have, want map[string]map[string]string, keep []string) map[string][]string { + kept := make(map[string]bool, len(keep)) + for _, r := range keep { + kept[r] = true + } + var out map[string][]string + for recipe, params := range have { + if !kept[recipe] { + continue + } + for name := range params { + if _, ok := want[recipe][name]; ok { + continue + } + if out == nil { + out = make(map[string][]string) + } + out[recipe] = append(out[recipe], name) + } + } + return out +} + +// filterSecrets keeps only the recipes recipes still names. project.Secrets +// reads every recipe table under a VM's key in secrets.toml regardless of +// which recipes the declaration currently applies, so a recipe dropped from +// [vms.x].recipes still shows up in spec.Secrets. Handing that unfiltered to +// Update fails manifestForVM's lookup with ErrRecipeNotApplicable, because +// the Recipes patch has already removed the recipe from the VM's own list by +// the time stageParamEdits validates against it. +func filterSecrets(secrets config.Secrets, recipes []string) config.Secrets { + kept := make(map[string]bool, len(recipes)) + for _, r := range recipes { + kept[r] = true + } + out := config.Secrets{} + for recipe, values := range secrets { + if kept[recipe] { + out[recipe] = values + } + } + return out +} + +// AttachKeys fills VM.Key from p for every VM p declares. A VM no declaration +// names keeps an empty Key, which is what tells ls it is a global VM. +func AttachKeys(vms []VM, p *project.Project) { + if p == nil { + return + } + for i := range vms { + if key, ok := p.KeyFor(vms[i].Name); ok { + vms[i].Key = key + } + } +} + +// Reconciled is what one Reconcile call did. +type Reconciled struct { + Key string + Name string + + // Created is true when the VM did not exist and Reconcile made it. + Created bool + + // Drift is what the declaration changed on an existing VM. It is empty on + // a create: a VM built from the declaration cannot differ from it. + Drift []Drift + + // RestartPending is true when at least one applied drift needs a restart. + // The change is already in vm.toml; the guest sees it at the next down + // and up. + RestartPending bool +} + +// Reconcile makes the VM named by one declaration match it. A missing VM is +// created; an existing one takes every mutable difference through the same +// Update path stoat update uses, so there is one place that validates an +// edit. It does not start anything: the caller decides that. +func Reconcile(p *project.Project, key string) (Reconciled, error) { + spec, err := SpecFor(p, key) + if err != nil { + return Reconciled{}, err + } + r := Reconciled{Key: key, Name: spec.Name} + + v, err := load(spec.Name) + if errors.Is(err, ErrNotFound) { + if _, err := Create(spec); err != nil { + return Reconciled{}, err + } + r.Created = true + return r, nil + } else if err != nil { + return Reconciled{}, err + } + + drift, err := Diff(p, key) + if err != nil { + return Reconciled{}, err + } + r.Drift = drift + + secrets := filterSecrets(spec.Secrets, spec.Recipes) + if len(drift) == 0 { + stored, err := config.LoadSecrets(v.Dir) + if err != nil { + return Reconciled{}, err + } + if reflect.DeepEqual(secrets, stored) { + return r, nil + } + } + + patch := Patch{} + for _, d := range drift { + if d.NeedsRestart { + r.RestartPending = true + } + switch d.Field { + case "cpus": + cpus := spec.CPUs + patch.CPUs = &cpus + case "ram": + ram := spec.RAM + patch.RAM = &ram + case "recipes": + recipes := spec.Recipes + patch.Recipes = &recipes + case "shares": + shares := spec.Shares + patch.Shares = &shares + case "params": + patch.SetParams = spec.Params + patch.UnsetParams = removedParams(v.Params, spec.Params, spec.Recipes) + case "agent_access": + access := spec.AgentAccess + patch.AgentAccess = &access + } + } + patch.Secrets = secrets + if _, err := Update(spec.Name, patch); err != nil { + return Reconciled{}, err + } + return r, nil +} diff --git a/internal/core/project_test.go b/internal/core/project_test.go new file mode 100644 index 00000000..bdc78913 --- /dev/null +++ b/internal/core/project_test.go @@ -0,0 +1,656 @@ +package core + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/novusedge/stoat/internal/config" + "github.com/novusedge/stoat/internal/project" + "github.com/novusedge/stoat/internal/recipes" +) + +// projectDir writes a stoat.toml into a fresh directory, chdirs there and +// returns the loaded project. Every project test runs from inside the project +// directory, the same place a user runs stoat from. It also points +// STOAT_HOME at a fresh data root with alpine-virt marked downloaded and the +// bundled recipes installed, since every declaration in this file resolves +// that image and most declare the docker recipe. +func projectDir(t *testing.T, body string) *project.Project { + t.Helper() + home := root(t) + haveImage(t, home, "alpine-virt-3.24.1-x86_64.iso") + if err := recipes.Install(); err != nil { + t.Fatal(err) + } + + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, project.FileName), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + t.Chdir(dir) + p, err := project.Load(dir) + if err != nil { + t.Fatal(err) + } + return p +} + +const fullDecl = ` +schema = 1 + +[project] +name = "myrepo" + +[vms.dev] +image = "alpine-virt" +cpus = 2 +ram = 2048 +recipes = ["docker"] +shares = ["."] +agent_access = "observe" + +[vms.dev.params.docker] +user = "dev" +` + +func TestSpecForFullDeclaration(t *testing.T) { + p := projectDir(t, fullDecl) + s, err := SpecFor(p, "dev") + if err != nil { + t.Fatal(err) + } + if s.Name != "myrepo-dev" { + t.Errorf("name = %q, want myrepo-dev", s.Name) + } + if s.Image != "alpine-virt" || s.CPUs != 2 || s.RAM != 2048 { + t.Errorf("spec = %+v", s) + } + if s.Project != p.Dir { + t.Errorf("project = %q, want %q", s.Project, p.Dir) + } + if len(s.Shares) != 1 || s.Shares[0].Guest != "/work" { + t.Errorf("shares = %+v", s.Shares) + } + if s.Params["docker"]["user"] != "dev" { + t.Errorf("params = %v", s.Params) + } + if s.AgentAccess != "observe" { + t.Errorf("agent_access = %q, want observe", s.AgentAccess) + } +} + +// A minimal declaration takes stoat new's defaults for everything but image. +func TestSpecForMinimalDeclaration(t *testing.T) { + p := projectDir(t, "schema = 1\n\n[project]\nname = \"myrepo\"\n\n[vms.ci]\nimage = \"alpine-virt\"\n") + s, err := SpecFor(p, "ci") + if err != nil { + t.Fatal(err) + } + if s.Name != "myrepo-ci" || s.Image != "alpine-virt" { + t.Errorf("spec = %+v", s) + } + if s.RAM != 0 || s.CPUs != 0 || s.Disk != "" { + t.Errorf("spec sets a default it should leave to plan(): %+v", s) + } + if len(s.Shares) != 0 { + t.Errorf("shares = %+v, want none", s.Shares) + } +} + +// The vm.toml a declaration produces is the golden this whole feature rests +// on: a contributor's stoat up must build the same VM the author's did. +func TestSpecForWritesTheExpectedVMToml(t *testing.T) { + p := projectDir(t, fullDecl) + s, err := SpecFor(p, "dev") + if err != nil { + t.Fatal(err) + } + v, err := Plan(s) + if err != nil { + t.Fatal(err) + } + if v.Name != "myrepo-dev" || v.RAM != 2048 || v.CPUs != 2 { + t.Errorf("vm = %+v", v) + } + if v.Project != p.Dir { + t.Errorf("project = %q, want %q", v.Project, p.Dir) + } + if len(v.Shares) != 1 || v.Shares[0].Tag != "p0" || v.Shares[0].Guest != "/work" { + t.Errorf("shares = %+v", v.Shares) + } +} + +func TestDiffReportsEveryMutableField(t *testing.T) { + p := projectDir(t, fullDecl) + s, err := SpecFor(p, "dev") + if err != nil { + t.Fatal(err) + } + if _, err := Create(s); err != nil { + t.Fatal(err) + } + + // Re-declare with every mutable field changed. + changed := ` +schema = 1 + +[project] +name = "myrepo" + +[vms.dev] +image = "alpine-virt" +cpus = 4 +ram = 4096 +recipes = ["docker", "devtools"] +agent_access = "exec" + +[vms.dev.params.docker] +user = "build" +` + if err := os.WriteFile(filepath.Join(p.Dir, project.FileName), []byte(changed), 0o644); err != nil { + t.Fatal(err) + } + p2, err := project.Load(p.Dir) + if err != nil { + t.Fatal(err) + } + drift, err := Diff(p2, "dev") + if err != nil { + t.Fatal(err) + } + + want := map[string]bool{ // field -> needs restart + "cpus": true, "ram": true, "shares": true, + "recipes": false, "params": false, "agent_access": false, + } + got := map[string]bool{} + for _, d := range drift { + if d.Key != "dev" { + t.Errorf("drift key = %q, want dev", d.Key) + } + got[d.Field] = d.NeedsRestart + } + for f, restart := range want { + r, ok := got[f] + if !ok { + t.Errorf("no drift reported for %s: %+v", f, drift) + continue + } + if r != restart { + t.Errorf("%s needs_restart = %v, want %v", f, r, restart) + } + } + for _, d := range drift { + if d.Field == "cpus" && (d.From != "2" || d.To != "4") { + t.Errorf("cpus drift = %+v, want 2 -> 4", d) + } + } +} + +func TestDiffOnAnUnchangedDeclarationIsEmpty(t *testing.T) { + p := projectDir(t, fullDecl) + s, err := SpecFor(p, "dev") + if err != nil { + t.Fatal(err) + } + if _, err := Create(s); err != nil { + t.Fatal(err) + } + drift, err := Diff(p, "dev") + if err != nil { + t.Fatal(err) + } + if len(drift) != 0 { + t.Errorf("drift = %+v, want none", drift) + } +} + +func TestDiffRefusesAnImageChange(t *testing.T) { + p := projectDir(t, "schema = 1\n\n[project]\nname = \"myrepo\"\n\n[vms.dev]\nimage = \"alpine-virt\"\n") + haveImage(t, os.Getenv("STOAT_HOME"), "alpine-standard-3.24.1-x86_64.iso") + s, err := SpecFor(p, "dev") + if err != nil { + t.Fatal(err) + } + if _, err := Create(s); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(p.Dir, project.FileName), + []byte("schema = 1\n\n[project]\nname = \"myrepo\"\n\n[vms.dev]\nimage = \"alpine-standard\"\n"), 0o644); err != nil { + t.Fatal(err) + } + p2, err := project.Load(p.Dir) + if err != nil { + t.Fatal(err) + } + _, err = Diff(p2, "dev") + if !errors.Is(err, ErrImmutableDeclaration) { + t.Fatalf("err = %v, want ErrImmutableDeclaration", err) + } + if !strings.Contains(err.Error(), "dev: image changed (alpine-virt -> alpine-standard)") || + !strings.Contains(err.Error(), "stoat rm dev") { + t.Errorf("err = %q, want the image-changed message with the stoat rm hint", err.Error()) + } +} + +func TestDiffRefusesADiskChange(t *testing.T) { + p := projectDir(t, "schema = 1\n\n[project]\nname = \"myrepo\"\n\n[vms.dev]\nimage = \"debian-13\"\ndisk = \"10G\"\n") + haveImage(t, os.Getenv("STOAT_HOME"), "debian-13-genericcloud-amd64.qcow2") + s, err := SpecFor(p, "dev") + if err != nil { + t.Fatal(err) + } + if _, err := Create(s); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(p.Dir, project.FileName), + []byte("schema = 1\n\n[project]\nname = \"myrepo\"\n\n[vms.dev]\nimage = \"debian-13\"\ndisk = \"20G\"\n"), 0o644); err != nil { + t.Fatal(err) + } + p2, err := project.Load(p.Dir) + if err != nil { + t.Fatal(err) + } + _, err = Diff(p2, "dev") + if !errors.Is(err, ErrImmutableDeclaration) { + t.Fatalf("err = %v, want ErrImmutableDeclaration", err) + } + if !strings.Contains(err.Error(), "dev: disk changed (10G -> 20G)") || + !strings.Contains(err.Error(), "stoat rm dev") { + t.Errorf("err = %q, want the disk-changed message with the stoat rm hint", err.Error()) + } +} + +// A declaration that omits disk takes stoat new's default, the same as a +// minimal declaration does for every other field. vm.toml then records that +// default rather than an empty string, and re-declaring the same omission +// must not read as the user having changed disk. +func TestDiffOmittedDiskMatchesTheDefault(t *testing.T) { + p := projectDir(t, "schema = 1\n\n[project]\nname = \"myrepo\"\n\n[vms.dev]\nimage = \"debian-13\"\n") + haveImage(t, os.Getenv("STOAT_HOME"), "debian-13-genericcloud-amd64.qcow2") + s, err := SpecFor(p, "dev") + if err != nil { + t.Fatal(err) + } + if _, err := Create(s); err != nil { + t.Fatal(err) + } + drift, err := Diff(p, "dev") + if err != nil { + t.Fatal(err) + } + for _, d := range drift { + if d.Field == "disk" { + t.Errorf("disk reported as drift for an omitted field: %+v", d) + } + } +} + +func TestReconcileCreatesAMissingVM(t *testing.T) { + p := projectDir(t, fullDecl) + r, err := Reconcile(p, "dev") + if err != nil { + t.Fatal(err) + } + if !r.Created || r.Name != "myrepo-dev" || r.Key != "dev" { + t.Fatalf("reconciled = %+v", r) + } + if _, err := Get("myrepo-dev"); err != nil { + t.Fatalf("vm was not created: %v", err) + } +} + +func TestReconcileAppliesDriftAndReportsRestart(t *testing.T) { + p := projectDir(t, fullDecl) + if _, err := Reconcile(p, "dev"); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(p.Dir, project.FileName), []byte(` +schema = 1 + +[project] +name = "myrepo" + +[vms.dev] +image = "alpine-virt" +cpus = 8 +ram = 2048 +recipes = ["docker"] +shares = ["."] +agent_access = "observe" + +[vms.dev.params.docker] +user = "dev" +`), 0o644); err != nil { + t.Fatal(err) + } + p2, err := project.Load(p.Dir) + if err != nil { + t.Fatal(err) + } + r, err := Reconcile(p2, "dev") + if err != nil { + t.Fatal(err) + } + if r.Created { + t.Error("Created = true on an existing VM") + } + if !r.RestartPending { + t.Errorf("RestartPending = false after a cpus change: %+v", r.Drift) + } + v, err := Get("myrepo-dev") + if err != nil { + t.Fatal(err) + } + if v.CPUs != 8 { + t.Errorf("cpus = %d, want 8; the drift was reported but not applied", v.CPUs) + } +} + +// writeProjectSecrets writes a project's .stoat/secrets.toml, 0600 as +// project.Secrets requires. +func writeProjectSecrets(t *testing.T, dir, body string) { + t.Helper() + cache := filepath.Join(dir, project.CacheDir) + if err := os.MkdirAll(cache, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cache, "secrets.toml"), []byte(body), 0o600); err != nil { + t.Fatal(err) + } +} + +func TestReconcileDropsOneParamButKeepsTheRecipe(t *testing.T) { + p := projectDir(t, fullDecl) + if _, err := Reconcile(p, "dev"); err != nil { + t.Fatal(err) + } + + // Same recipe, no [vms.dev.params.docker] table: the declaration no + // longer states a user, so vm.toml must stop holding one. + dropped := ` +schema = 1 + +[project] +name = "myrepo" + +[vms.dev] +image = "alpine-virt" +cpus = 2 +ram = 2048 +recipes = ["docker"] +shares = ["."] +agent_access = "observe" +` + if err := os.WriteFile(filepath.Join(p.Dir, project.FileName), []byte(dropped), 0o644); err != nil { + t.Fatal(err) + } + p2, err := project.Load(p.Dir) + if err != nil { + t.Fatal(err) + } + if _, err := Reconcile(p2, "dev"); err != nil { + t.Fatal(err) + } + + v, err := load("myrepo-dev") + if err != nil { + t.Fatal(err) + } + if _, ok := v.Params["docker"]["user"]; ok { + t.Errorf("params = %+v, want docker.user unset", v.Params) + } + + drift, err := Diff(p2, "dev") + if err != nil { + t.Fatal(err) + } + if len(drift) != 0 { + t.Errorf("second diff = %+v, want none", drift) + } +} + +func TestReconcileDropsAWholeRecipeWithParams(t *testing.T) { + p := projectDir(t, fullDecl) + if _, err := Reconcile(p, "dev"); err != nil { + t.Fatal(err) + } + + // docker is gone from recipes and its params table is gone with it. + dropped := ` +schema = 1 + +[project] +name = "myrepo" + +[vms.dev] +image = "alpine-virt" +cpus = 2 +ram = 2048 +recipes = [] +shares = ["."] +agent_access = "observe" +` + if err := os.WriteFile(filepath.Join(p.Dir, project.FileName), []byte(dropped), 0o644); err != nil { + t.Fatal(err) + } + p2, err := project.Load(p.Dir) + if err != nil { + t.Fatal(err) + } + if _, err := Reconcile(p2, "dev"); err != nil { + t.Fatal(err) + } + + v, err := load("myrepo-dev") + if err != nil { + t.Fatal(err) + } + for _, r := range v.Recipes { + if r == "docker" { + t.Errorf("recipes = %v, want docker dropped", v.Recipes) + } + } + if _, ok := v.Params["docker"]; ok { + t.Errorf("params = %+v, want the docker table dropped", v.Params) + } + + drift, err := Diff(p2, "dev") + if err != nil { + t.Fatal(err) + } + if len(drift) != 0 { + t.Errorf("second diff = %+v, want none", drift) + } +} + +// A recipe with a secret, dropped from the declaration, must take that +// secret with it the same way TestReconcileDropsAWholeRecipeWithParams +// shows a non-secret param going. +func TestReconcileDropsARecipeAndItsSecret(t *testing.T) { + withTailscale := ` +schema = 1 + +[project] +name = "myrepo" + +[vms.dev] +image = "alpine-virt" +cpus = 2 +ram = 2048 +recipes = ["docker", "tailscale"] +shares = ["."] +agent_access = "observe" + +[vms.dev.params.docker] +user = "dev" +` + p := projectDir(t, withTailscale) + writeProjectSecrets(t, p.Dir, "[dev.tailscale]\nauthkey = \"first-key\"\n") + p, err := project.Load(p.Dir) + if err != nil { + t.Fatal(err) + } + if _, err := Reconcile(p, "dev"); err != nil { + t.Fatal(err) + } + + // tailscale is gone from recipes; its secret goes with it. + dropped := ` +schema = 1 + +[project] +name = "myrepo" + +[vms.dev] +image = "alpine-virt" +cpus = 2 +ram = 2048 +recipes = ["docker"] +shares = ["."] +agent_access = "observe" + +[vms.dev.params.docker] +user = "dev" +` + if err := os.WriteFile(filepath.Join(p.Dir, project.FileName), []byte(dropped), 0o644); err != nil { + t.Fatal(err) + } + p2, err := project.Load(p.Dir) + if err != nil { + t.Fatal(err) + } + if _, err := Reconcile(p2, "dev"); err != nil { + t.Fatal(err) + } + + v, err := load("myrepo-dev") + if err != nil { + t.Fatal(err) + } + secrets, err := config.LoadSecrets(v.Dir) + if err != nil { + t.Fatal(err) + } + if _, ok := secrets["tailscale"]; ok { + t.Errorf("secrets = %+v, want tailscale dropped", secrets) + } + + drift, err := Diff(p2, "dev") + if err != nil { + t.Fatal(err) + } + if len(drift) != 0 { + t.Errorf("second diff = %+v, want none", drift) + } +} + +// A repeat Reconcile of an unchanged declaration must not rewrite the VM's +// secrets.toml: commitUpdate replaces the file by rename, so an unnecessary +// write still changes its mtime and inode even when the content is the same. +func TestReconcileWithNoDriftDoesNotRewriteSecrets(t *testing.T) { + withTailscale := ` +schema = 1 + +[project] +name = "myrepo" + +[vms.dev] +image = "alpine-virt" +cpus = 2 +ram = 2048 +recipes = ["docker", "tailscale"] +shares = ["."] +agent_access = "observe" + +[vms.dev.params.docker] +user = "dev" +` + p := projectDir(t, withTailscale) + writeProjectSecrets(t, p.Dir, "[dev.tailscale]\nauthkey = \"first-key\"\n") + p, err := project.Load(p.Dir) + if err != nil { + t.Fatal(err) + } + if _, err := Reconcile(p, "dev"); err != nil { + t.Fatal(err) + } + + v, err := load("myrepo-dev") + if err != nil { + t.Fatal(err) + } + secretsPath := filepath.Join(v.Dir, config.SecretsName) + before, err := os.Stat(secretsPath) + if err != nil { + t.Fatal(err) + } + + if _, err := Reconcile(p, "dev"); err != nil { + t.Fatal(err) + } + + after, err := os.Stat(secretsPath) + if err != nil { + t.Fatal(err) + } + if !after.ModTime().Equal(before.ModTime()) || !os.SameFile(before, after) { + t.Errorf("secrets.toml was rewritten by a no-op reconcile: mtime %v -> %v", before.ModTime(), after.ModTime()) + } +} + +func TestReconcileAppliesASecretsOnlyChange(t *testing.T) { + withTailscale := ` +schema = 1 + +[project] +name = "myrepo" + +[vms.dev] +image = "alpine-virt" +cpus = 2 +ram = 2048 +recipes = ["docker", "tailscale"] +shares = ["."] +agent_access = "observe" + +[vms.dev.params.docker] +user = "dev" +` + p := projectDir(t, withTailscale) + writeProjectSecrets(t, p.Dir, "[dev.tailscale]\nauthkey = \"first-key\"\n") + p, err := project.Load(p.Dir) + if err != nil { + t.Fatal(err) + } + if _, err := Reconcile(p, "dev"); err != nil { + t.Fatal(err) + } + + writeProjectSecrets(t, p.Dir, "[dev.tailscale]\nauthkey = \"second-key\"\n") + drift, err := Diff(p, "dev") + if err != nil { + t.Fatal(err) + } + if len(drift) != 0 { + t.Errorf("drift = %+v, want none; this is a secrets-only change", drift) + } + + if _, err := Reconcile(p, "dev"); err != nil { + t.Fatal(err) + } + v, err := load("myrepo-dev") + if err != nil { + t.Fatal(err) + } + secrets, err := config.LoadSecrets(v.Dir) + if err != nil { + t.Fatal(err) + } + if secrets["tailscale"]["authkey"] != "second-key" { + t.Errorf("stored authkey = %q, want second-key", secrets["tailscale"]["authkey"]) + } +} diff --git a/internal/core/update.go b/internal/core/update.go index a84def1a..6e38ebc3 100644 --- a/internal/core/update.go +++ b/internal/core/update.go @@ -50,6 +50,11 @@ type Patch struct { SSHPort *int Disk *string + // Shares replaces the project's 9p export list wholesale when non-nil. + // Like Recipes, a non-nil empty slice clears it and nil leaves it alone. + // Applies at next start: qemu cannot hot-add a virtfs device. + Shares *[]config.Share + // Recipes replaces the recipe list wholesale when non-nil, including // with an empty (but non-nil) slice to clear it. A nil Recipes leaves // the list untouched, matching every other field's "nil means don't @@ -142,11 +147,24 @@ func Update(name string, p Patch) (VM, error) { if p.Share != nil { work.Share = strings.TrimSpace(*p.Share) } + var droppedRecipes []string if p.Recipes != nil { + kept := make(map[string]bool, len(*p.Recipes)) + for _, r := range *p.Recipes { + kept[r] = true + } + for _, r := range work.Recipes { + if !kept[r] { + droppedRecipes = append(droppedRecipes, r) + } + } work.Recipes = append([]string(nil), (*p.Recipes)...) } + if p.Shares != nil { + work.Shares = *p.Shares + } var stored config.Secrets - if hasParamEdits(p) { + if hasParamEdits(p) || len(droppedRecipes) > 0 { stored, err = config.LoadSecrets(work.Dir) if err != nil { return VM{}, err @@ -158,6 +176,17 @@ func Update(name string, p Patch) (VM, error) { if err != nil { return VM{}, err } + // A recipe dropped from Recipes takes its params and secrets with it. + // manifestForVM only resolves a recipe still in v.Recipes, so an UnsetParams + // entry for a dropped recipe would fail here with ErrRecipeNotApplicable; + // callers (Reconcile) rely on this clearing instead of naming the recipe. + for _, recipe := range droppedRecipes { + delete(work.Params, recipe) + if _, ok := stagedSecrets[recipe]; ok { + delete(stagedSecrets, recipe) + secretTouched = true + } + } if p.Installed != nil { work.Installed = *p.Installed } diff --git a/internal/core/vm.go b/internal/core/vm.go index 59149eee..c1ebf8b2 100644 --- a/internal/core/vm.go +++ b/internal/core/vm.go @@ -193,6 +193,18 @@ type VM struct { // config.Load's parse error so a caller can show the user why, not just // that it's broken. Error string + + // Project is the absolute directory of the stoat.toml that declared this + // VM, empty for a VM created by stoat new. ProjectMissing is true when + // that directory is gone: the VM still lists and still runs, and ls says + // the declaration is no longer reachable. + Project string + ProjectMissing bool + + // Key is the declaration key this VM answers to, filled by AttachKeys + // from the active project. It is not in vm.toml: only a loaded stoat.toml + // knows it, and two projects could name the same VM differently. + Key string } // A VM's IDENTITY is its DIRECTORY under the data root. It is never the @@ -277,6 +289,8 @@ func fromConfigUnchecked(v *config.VM) VM { Installed: v.Installed, AllowExec: v.AllowExec, AgentAccess: v.AgentAccess, + Project: v.Project, + ProjectMissing: v.Project != "" && !dirExists(v.Project), Paths: Paths{ Dir: v.Dir, Disk: v.DiskPath(), @@ -288,6 +302,14 @@ func fromConfigUnchecked(v *config.VM) VM { } } +// dirExists answers whether a VM's project directory is still there. A stat +// error of any kind counts as gone: an unreadable path is no more usable than +// an absent one. +func dirExists(p string) bool { + fi, err := os.Stat(p) + return err == nil && fi.IsDir() +} + // fromConfigChecked adds the stored recipe state that requires reading the // protected secret store. Public readers use this form so a security error // cannot be mistaken for an empty VM state. diff --git a/internal/mcpsrv/server.go b/internal/mcpsrv/server.go index f6fecd0e..d590830d 100644 --- a/internal/mcpsrv/server.go +++ b/internal/mcpsrv/server.go @@ -10,6 +10,7 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/novusedge/stoat/internal/cli/wire" + "github.com/novusedge/stoat/internal/project" ) // class is a tool's annotation class. The four classes are the taxonomy in @@ -52,6 +53,16 @@ const ( type srv struct { opts Options lim *limiter + + // proj is the stoat.toml in the server's cwd, nil outside a project. + // Read once in New: a client that has listed VMs must not find the scope + // changed under it by a later chdir. + proj *project.Project + + // projErr holds a stoat.toml that will not parse. New returns no error, + // so the failure surfaces from the tools that need the project rather + // than taking the whole server down over a file most tools never read. + projErr error } // New builds the server with every tool registered. The caller runs it over @@ -61,6 +72,13 @@ func New(opts Options) *mcp.Server { opts.Limits = DefaultLimits() } s := &srv{opts: opts, lim: newLimiter(opts.Limits)} + p, ok, err := project.Find() + switch { + case err != nil: + s.projErr = err + case ok: + s.proj = p + } server := mcp.NewServer(&mcp.Implementation{Name: "stoat", Version: opts.Version}, nil) s.registerRead(server) s.registerVM(server) @@ -68,6 +86,7 @@ func New(opts Options) *mcp.Server { s.registerGuestRead(server) s.registerGuestWrite(server) s.registerExec(server) + s.registerProject(server) // redact must be receiving middleware, not sending: a Server's sending // method handler only covers requests the server itself initiates // (sampling, elicitation), never the CallToolResult built for an diff --git a/internal/mcpsrv/table_test.go b/internal/mcpsrv/table_test.go index 8572ae4c..ed85fbf9 100644 --- a/internal/mcpsrv/table_test.go +++ b/internal/mcpsrv/table_test.go @@ -62,6 +62,13 @@ var toolTable = []toolSpec{ {"job_output", classRead, LevelExec}, {"job_kill", classExec, LevelExec}, {"list_jobs", classRead, LevelExec}, + + // Project scope (project-file plan). + {"project_status", classRead, LevelNone}, + {"project_up", classMutate, LevelNone}, + {"project_down", classMutate, LevelNone}, + {"project_apply", classMutate, LevelNone}, + {"project_wait", classMutate, LevelNone}, } // forbiddenSurfaces are absent rather than gated. A parameter that exists diff --git a/internal/mcpsrv/tools_project.go b/internal/mcpsrv/tools_project.go new file mode 100644 index 00000000..0548b7f3 --- /dev/null +++ b/internal/mcpsrv/tools_project.go @@ -0,0 +1,119 @@ +package mcpsrv + +import ( + "context" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/novusedge/stoat/internal/cli/wire" + "github.com/novusedge/stoat/internal/core" + "github.com/novusedge/stoat/internal/project" +) + +// projectIn is the input of every project tool. It is empty on purpose: the +// scope is the server's own working directory, which stoat mcp install writes +// into the client's entry, and a caller cannot point the server elsewhere. +type projectIn struct{} + +// requireProject answers with the loaded stoat.toml, or with the error a +// caller can act on. A file that will not parse reports the parse error, not +// "no project": those are different problems with different fixes. +func (s *srv) requireProject() (*project.Project, error) { + if s.projErr != nil { + return nil, s.projErr + } + if s.proj == nil { + return nil, fmt.Errorf("this server's working directory has no %s; run stoat init there, or use start, stop, apply_recipes and wait with a vm", project.FileName) + } + return s.proj, nil +} + +// fanOut runs one over every declared VM in declaration order, stops at the +// first failure and reports the rest as skipped. It mirrors the CLI's own +// fan-out (internal/cli/run_project.go) so an agent and a shell see one +// behaviour. +func (s *srv) fanOut(one func(name, key string) error) (wire.ProjectRun, error) { + p, err := s.requireProject() + if err != nil { + return wire.ProjectRun{}, err + } + var runs []wire.ProjectRunVM + failed := false + for _, d := range p.VMs { + name := p.GlobalName(d.Key) + entry := wire.ProjectRunVM{Key: d.Key, Name: name, Status: "ok"} + switch { + case failed: + entry.Status = "skipped" + default: + if err := one(name, d.Key); err != nil { + entry.Status, entry.Error, failed = "error", err.Error(), true + } + } + runs = append(runs, entry) + } + return wire.FromProjectRun(p.Name, runs), nil +} + +func (s *srv) registerProject(server *mcp.Server) { + register(server, "project_status", classRead, + "Report every VM the stoat.toml in this server's working directory declares: its declaration key, its global name, whether it exists yet, its health, and every field where the declaration and the VM disagree. Call it before project_up to see what that would change. It runs nothing and touches no VM. Read-only.", + func(ctx context.Context, _ projectIn) (wire.ProjectStatus, error) { + p, err := s.requireProject() + if err != nil { + return wire.ProjectStatus{}, err + } + var rows []wire.ProjectStatusVM + for _, d := range p.VMs { + name := p.GlobalName(d.Key) + row := wire.ProjectStatusVM{Key: d.Key, Name: name, State: "missing", Drift: []wire.Drift{}} + if v, err := core.Get(name); err == nil { + row.State, row.Health = string(v.State), string(v.Health) + if drift, err := core.Diff(p, d.Key); err == nil { + row.Drift = wire.FromDrifts(drift) + } else { + row.Error = err.Error() + } + } + rows = append(rows, row) + } + return wire.FromProjectStatus(p.Name, p.Dir, rows), nil + }) + + register(server, "project_up", classMutate, + "Create and start every VM the stoat.toml in this server's working directory declares, in declaration order. A VM that does not exist yet is created from its declaration; an existing one takes every mutable change the declaration makes before it starts. It stops at the first failure and reports every later VM as skipped. Use start when you mean one named VM. Mutating: it creates VM directories and disks and it runs qemu.", + func(ctx context.Context, _ projectIn) (wire.ProjectRun, error) { + return s.fanOut(func(name, key string) error { + if _, err := core.Reconcile(s.proj, key); err != nil { + return err + } + return core.Start(name) + }) + }) + + register(server, "project_down", classMutate, + "Stop every VM the stoat.toml in this server's working directory declares, in declaration order. It stops at the first failure and reports every later VM as skipped. Use stop when you mean one named VM. Reversible with project_up. Mutating: it shuts qemu down.", + func(ctx context.Context, _ projectIn) (wire.ProjectRun, error) { + return s.fanOut(func(name, _ string) error { return core.Stop(name) }) + }) + + register(server, "project_apply", classMutate, + "Run the configured recipes of every VM the stoat.toml in this server's working directory declares, in declaration order. A recipe body is arbitrary guest code, so each VM needs agent_access manage or higher and a VM below that fails its own entry. It stops at the first failure and reports every later VM as skipped. Mutating: it runs recipe scripts inside each guest, and it reaches outside this process.", + func(ctx context.Context, _ projectIn) (wire.ProjectRun, error) { + return s.fanOut(func(name, _ string) error { + // Mirrors apply_recipes' row in table_test.go: LevelManage. + if err := requireAccess(name, LevelManage); err != nil { + return err + } + return core.Apply(ctx, name, core.ApplyOpts{}) + }) + }) + + register(server, "project_wait", classMutate, + "Block until every VM the stoat.toml in this server's working directory declares answers on ssh, in declaration order. It stops at the first VM that does not, and reports every later VM as skipped. Use wait when you mean one named VM or a state other than reachable. Mutating only in that it blocks the caller.", + func(ctx context.Context, _ projectIn) (wire.ProjectRun, error) { + return s.fanOut(func(name, _ string) error { + return core.Wait(ctx, name, core.UntilReachable) + }) + }) +} diff --git a/internal/mcpsrv/tools_project_test.go b/internal/mcpsrv/tools_project_test.go new file mode 100644 index 00000000..c982302b --- /dev/null +++ b/internal/mcpsrv/tools_project_test.go @@ -0,0 +1,189 @@ +package mcpsrv + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/novusedge/stoat/internal/cli/wire" + "github.com/novusedge/stoat/internal/core" + "github.com/novusedge/stoat/internal/project" +) + +// inProject writes a stoat.toml into a fresh directory and chdirs there, so +// the server's cwd is a project when callTool builds it. +func inProject(t *testing.T, body string) string { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, project.FileName), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + t.Chdir(dir) + return dir +} + +// haveImage drops a file into isos/ so alpine-virt counts as downloaded, +// without ever exec'ing qemu: core.Create for a live-mode image writes +// vm.toml only, and this package must never boot a real VM to fill it. +func haveImage(t *testing.T, home string) { + t.Helper() + dir := filepath.Join(home, "isos") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "alpine-virt-3.24.1-x86_64.iso"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } +} + +const twoDecls = ` +schema = 1 + +[project] +name = "myrepo" + +[vms.dev] +image = "alpine-virt" + +[vms.ci] +image = "alpine-virt" +` + +// list_vms carries the declaration key, so an agent can say "the dev VM" and +// mean what stoat.toml means. Only "dev" has a VM on disk; "ci" is declared +// but never created, so it must not appear. +func TestListVMsCarriesProjectAndKey(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + inProject(t, twoDecls) + writeVM(t, "myrepo-dev", "manage") + + res := callTool(t, "list_vms", map[string]any{}) + raw, _ := json.Marshal(res.StructuredContent) + var out wire.VMList + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatal(err) + } + if len(out.VMs) != 1 || out.VMs[0].Key != "dev" { + t.Errorf("vms = %+v, want one keyed dev", out.VMs) + } +} + +// project_status is read-only and reports a field where a declaration and +// vm.toml disagree, the same comparison core.Diff makes for stoat status. +func TestProjectStatusReportsDrift(t *testing.T) { + home := t.TempDir() + t.Setenv("STOAT_HOME", home) + haveImage(t, home) + dir := inProject(t, "schema = 1\n\n[project]\nname = \"myrepo\"\n\n[vms.dev]\nimage = \"alpine-virt\"\ncpus = 2\n") + + p, err := project.Load(dir) + if err != nil { + t.Fatal(err) + } + if _, err := core.Reconcile(p, "dev"); err != nil { + t.Fatal(err) + } + redeclared := "schema = 1\n\n[project]\nname = \"myrepo\"\n\n[vms.dev]\nimage = \"alpine-virt\"\ncpus = 8\n" + if err := os.WriteFile(filepath.Join(dir, project.FileName), []byte(redeclared), 0o644); err != nil { + t.Fatal(err) + } + + for _, tool := range listTools(t) { + if tool.Name == "project_status" && !tool.Annotations.ReadOnlyHint { + t.Error("project_status is not annotated read-only") + } + } + res := callTool(t, "project_status", map[string]any{}) + raw, _ := json.Marshal(res.StructuredContent) + var out wire.ProjectStatus + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatal(err) + } + if len(out.VMs) != 1 { + t.Fatalf("vms = %+v, want one", out.VMs) + } + row := out.VMs[0] + var drift *wire.Drift + for i := range row.Drift { + if row.Drift[i].Field == "cpus" { + drift = &row.Drift[i] + } + } + if drift == nil || drift.From != "2" || drift.To != "8" || !drift.NeedsRestart { + t.Errorf("cpus drift = %+v, want 2 -> 8, needs restart", row.Drift) + } +} + +// project_up reconciles every declaration in order and stops at the first +// failure, reporting the rest as skipped. Neither declaration's image is +// downloaded, so Reconcile refuses before core.Start would ever run: this +// package must not boot a real VM to observe the fan-out. +func TestProjectUpStopsAtFirstFailureInDeclarationOrder(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + inProject(t, twoDecls) + + res := callTool(t, "project_up", map[string]any{}) + if res.IsError { + t.Fatalf("project_up errored at the tool level: %+v", res.Content) + } + raw, _ := json.Marshal(res.StructuredContent) + var out wire.ProjectRun + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatal(err) + } + if len(out.VMs) != 2 || out.VMs[0].Key != "dev" || out.VMs[1].Key != "ci" { + t.Fatalf("vms = %+v, want dev then ci", out.VMs) + } + if out.VMs[0].Status != "error" || out.VMs[0].Error == "" { + t.Errorf("dev = %+v, want a reconcile error", out.VMs[0]) + } + if out.VMs[1].Status != "skipped" { + t.Errorf("ci = %+v, want skipped after dev failed", out.VMs[1]) + } +} + +// project_apply gates each declared VM at its own agent_access, the same +// level apply_recipes needs. A VM below manage fails its own entry and the +// rest of the run is skipped, rather than the whole call being refused. +func TestProjectApplyGatesAccessPerVM(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + inProject(t, twoDecls) + writeVM(t, "myrepo-dev", "observe") + writeVM(t, "myrepo-ci", "manage") + + res := callTool(t, "project_apply", map[string]any{}) + raw, _ := json.Marshal(res.StructuredContent) + var out wire.ProjectRun + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatal(err) + } + if len(out.VMs) != 2 { + t.Fatalf("vms = %+v, want two", out.VMs) + } + if out.VMs[0].Status != "error" || !strings.Contains(out.VMs[0].Error, "agent_access =") { + t.Errorf("dev = %+v, want an access refusal", out.VMs[0]) + } + if out.VMs[1].Status != "skipped" { + t.Errorf("ci = %+v, want skipped after dev's access refusal", out.VMs[1]) + } +} + +// Every project tool is refused outside a project, and the message names the +// file the agent has to create. +func TestProjectToolsRefusedOutsideAProject(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + t.Chdir(t.TempDir()) + for _, name := range []string{"project_status", "project_up", "project_down", "project_apply", "project_wait"} { + res := callTool(t, name, map[string]any{}) + if !res.IsError { + t.Errorf("%s succeeded outside a project", name) + continue + } + raw, _ := json.Marshal(res.Content) + if !strings.Contains(string(raw), "stoat.toml") { + t.Errorf("%s refusal does not name stoat.toml: %s", name, raw) + } + } +} diff --git a/internal/mcpsrv/tools_read.go b/internal/mcpsrv/tools_read.go index 249f8543..e0c49817 100644 --- a/internal/mcpsrv/tools_read.go +++ b/internal/mcpsrv/tools_read.go @@ -54,6 +54,7 @@ func (s *srv) registerRead(server *mcp.Server) { if err != nil { return wire.VMList{}, err } + core.AttachKeys(vms, s.proj) return wire.VMList{VMs: wire.FromVMs(vms, core.GraphicalSession())}, nil }) diff --git a/internal/project/project.go b/internal/project/project.go new file mode 100644 index 00000000..f3384551 --- /dev/null +++ b/internal/project/project.go @@ -0,0 +1,255 @@ +// Package project owns stoat.toml: the VMs, recipes and params a repository +// declares. It reads the file and answers name questions about it. It runs +// nothing and touches no VM; internal/core does that. +package project + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/novusedge/stoat/internal/tomlx" +) + +// FileName is the declaration file. Its presence in os.Getwd() is the whole +// project-scope test; see Find. +const FileName = "stoat.toml" + +// CacheDir holds the recipe cache and the secrets file. It is gitignored by +// stoat init and by recipe add. +const CacheDir = ".stoat" + +// Schema is the schema this stoat reads. A file declaring a higher one is +// refused, so a newer project does not decode as a silently smaller one. +const Schema = 1 + +// nameRE is the VM name grammar, shared by a declaration key, a name +// override and the generated global name. internal/mcpsrv's checkVMName uses +// the same expression. +var nameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) + +// tableRE matches a [vms.] header and nothing deeper. The closing +// bracket is what excludes [vms.dev.params.docker]. +var tableRE = regexp.MustCompile(`(?m)^\s*\[vms\.([^.\]\s]+)\]\s*$`) + +// VM is one [vms.] declaration. Every field except Image is optional and +// falls back to the same default stoat new applies. +type VM struct { + Key string `toml:"-"` + + Name string `toml:"name"` + Image string `toml:"image"` + CPUs int `toml:"cpus"` + RAM int `toml:"ram"` + Disk string `toml:"disk"` + Recipes []string `toml:"recipes"` + Shares []string `toml:"shares"` + AgentAccess string `toml:"agent_access"` + Params map[string]map[string]any `toml:"params"` +} + +// meta is the [project] table. +type meta struct { + Name string `toml:"name"` +} + +// file is the decoded shape of stoat.toml. Recipes decodes as map[string]any +// on purpose: a value is either a ref string or an inline source table, and +// internal/recipes owns that union. Decoding it into a concrete type here +// would give this package a second, drifting definition of it. +type file struct { + Schema int `toml:"schema"` + Project meta `toml:"project"` + Recipes map[string]any `toml:"recipes"` + VMs map[string]VM `toml:"vms"` +} + +// Project is one loaded stoat.toml. +type Project struct { + // Dir is the absolute directory the file was loaded from. It is the root + // every shares entry must resolve inside. + Dir string + Name string + + // VMs are the declarations in the order they appear in the file. Every + // no-argument command iterates this slice, so the order is part of the + // contract, not an implementation detail. + VMs []VM + + // Recipes is the [recipes] table, uninterpreted. internal/recipes reads it. + Recipes map[string]any + + byKey map[string]VM +} + +// Load reads dir/stoat.toml. +func Load(dir string) (*Project, error) { + abs, err := filepath.Abs(dir) + if err != nil { + return nil, err + } + path := filepath.Join(abs, FileName) + + var f file + if err := tomlx.Decode(path, &f, tomlx.Reject); err != nil { + return nil, fmt.Errorf("%s: %w", FileName, err) + } + if f.Schema > Schema { + return nil, fmt.Errorf("%s: schema %d is newer than this stoat (%d)", FileName, f.Schema, Schema) + } + + p := &Project{Dir: abs, Recipes: f.Recipes, byKey: make(map[string]VM, len(f.VMs))} + p.Name = f.Project.Name + if p.Name == "" { + p.Name = slug(filepath.Base(abs)) + } + if !nameRE.MatchString(p.Name) { + return nil, fmt.Errorf("%s: project.name %q must match %s", FileName, p.Name, nameRE) + } + + for _, key := range order(path, f.VMs) { + if !nameRE.MatchString(key) { + return nil, fmt.Errorf("%s: vms.%s: a vm key must match %s", FileName, key, nameRE) + } + v := f.VMs[key] + v.Key = key + if v.Image == "" { + return nil, fmt.Errorf("%s: vms.%s: image is required", FileName, key) + } + if v.Name != "" && !nameRE.MatchString(v.Name) { + return nil, fmt.Errorf("%s: vms.%s.name %q must match %s", FileName, key, v.Name, nameRE) + } + p.VMs = append(p.VMs, v) + p.byKey[key] = v + } + + // The global name is the VM's directory under the data root, so two + // declarations that resolve to one name would fight over one directory. + seen := map[string]string{} + for _, v := range p.VMs { + g := p.GlobalName(v.Key) + if first, dup := seen[g]; dup { + a, b := sortPair(first, v.Key) + return nil, fmt.Errorf("%s: vms.%s and vms.%s both resolve to %q", FileName, a, b, g) + } + seen[g] = v.Key + } + return p, nil +} + +// Find loads the project in the current directory, if there is one. There is +// no walk-up: a command's scope must be readable from the directory the user +// typed it in, not from a parent three levels up. +func Find() (*Project, bool, error) { + dir, err := os.Getwd() + if err != nil { + return nil, false, err + } + if _, err := os.Stat(filepath.Join(dir, FileName)); err != nil { + return nil, false, nil + } + p, err := Load(dir) + if err != nil { + return nil, false, err + } + return p, true, nil +} + +// GlobalName is the VM's directory name under the data root: the declaration's +// own name if it set one, otherwise "-". +func (p *Project) GlobalName(key string) string { + v, ok := p.byKey[key] + if !ok { + return "" + } + if v.Name != "" { + return v.Name + } + return p.Name + "-" + key +} + +// Resolve turns a bare command argument into a global VM name. It tries the +// declaration key first, then a global name, so `stoat ssh dev` reaches +// myrepo-dev and `stoat ssh myrepo-dev` still works. +func (p *Project) Resolve(arg string) (string, bool) { + if _, ok := p.byKey[arg]; ok { + return p.GlobalName(arg), true + } + for _, v := range p.VMs { + if g := p.GlobalName(v.Key); g == arg { + return g, true + } + } + return "", false +} + +// VM returns one declaration by key. +func (p *Project) VM(key string) (VM, bool) { + v, ok := p.byKey[key] + return v, ok +} + +// KeyFor is Resolve backwards: it names the declaration a global name belongs +// to, for a message that must say "dev" where the user wrote "dev". +func (p *Project) KeyFor(global string) (string, bool) { + for _, v := range p.VMs { + if p.GlobalName(v.Key) == global { + return v.Key, true + } + } + return "", false +} + +// order returns the declaration keys in file order. tomlx decodes into a Go +// map, which has none, so the header positions in the raw file are the only +// record of it. A key the scan misses, because the file declares only a +// sub-table for it, is appended in sorted order. +func order(path string, vms map[string]VM) []string { + var out []string + seen := map[string]bool{} + if data, err := os.ReadFile(path); err == nil { + for _, m := range tableRE.FindAllStringSubmatch(string(data), -1) { + key := strings.Trim(m[1], `"'`) + if _, ok := vms[key]; ok && !seen[key] { + out = append(out, key) + seen[key] = true + } + } + } + var rest []string + for key := range vms { + if !seen[key] { + rest = append(rest, key) + } + } + sort.Strings(rest) + return append(out, rest...) +} + +// slug lower-cases a directory name and replaces every character outside the +// name grammar with a dash, so a checkout called "MyRepo" or "my repo" still +// produces a usable default project name. +func slug(s string) string { + var b strings.Builder + for _, r := range strings.ToLower(s) { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-': + b.WriteRune(r) + default: + b.WriteByte('-') + } + } + return strings.Trim(b.String(), "-") +} + +// sortPair orders two keys so the duplicate-name message reads the same on +// every run; a map walk would otherwise swap them between runs. +func sortPair(a, b string) (string, string) { + if a < b { + return a, b + } + return b, a +} diff --git a/internal/project/project_test.go b/internal/project/project_test.go new file mode 100644 index 00000000..d7bae4cf --- /dev/null +++ b/internal/project/project_test.go @@ -0,0 +1,153 @@ +package project + +import ( + "os" + "path/filepath" + "testing" +) + +// write drops a stoat.toml into a fresh directory and returns the directory. +func write(t *testing.T, body string) string { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, FileName), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return dir +} + +const full = ` +schema = 1 + +[project] +name = "myrepo" + +[recipes] +tailscale = "v1.2" + +[vms.dev] +name = "shared-dev" +image = "ubuntu-24" +cpus = 4 +ram = 4096 +disk = "20G" +recipes = ["docker", "tailscale"] +shares = ["."] +agent_access = "manage" + +[vms.dev.params.docker] +user = "dev" + +[vms.ci] +image = "alpine-virt" +` + +func TestLoadFullDeclaration(t *testing.T) { + p, err := Load(write(t, full)) + if err != nil { + t.Fatal(err) + } + if p.Name != "myrepo" { + t.Errorf("name = %q, want myrepo", p.Name) + } + if len(p.VMs) != 2 || p.VMs[0].Key != "dev" || p.VMs[1].Key != "ci" { + t.Fatalf("vms = %+v, want dev then ci in declaration order", p.VMs) + } + dev := p.VMs[0] + if dev.Image != "ubuntu-24" || dev.CPUs != 4 || dev.RAM != 4096 || dev.Disk != "20G" { + t.Errorf("dev = %+v", dev) + } + if got := dev.Params["docker"]["user"]; got != "dev" { + t.Errorf("params.docker.user = %v, want dev", got) + } +} + +// The project name defaults to the directory it was loaded from, so a +// clone of a repository with no [project] table still names its VMs. +func TestProjectNameDefaultsToDirectory(t *testing.T) { + dir := t.TempDir() + sub := filepath.Join(dir, "MyRepo") + if err := os.Mkdir(sub, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sub, FileName), []byte("schema = 1\n\n[vms.dev]\nimage = \"alpine-virt\"\n"), 0o644); err != nil { + t.Fatal(err) + } + p, err := Load(sub) + if err != nil { + t.Fatal(err) + } + if p.Name != "myrepo" { + t.Errorf("name = %q, want myrepo", p.Name) + } +} + +func TestGlobalNameAndResolve(t *testing.T) { + p, err := Load(write(t, full)) + if err != nil { + t.Fatal(err) + } + if got := p.GlobalName("dev"); got != "shared-dev" { + t.Errorf("GlobalName(dev) = %q, want shared-dev", got) + } + if got := p.GlobalName("ci"); got != "myrepo-ci" { + t.Errorf("GlobalName(ci) = %q, want myrepo-ci", got) + } + for _, tc := range []struct{ arg, want string }{ + {"dev", "shared-dev"}, // by key + {"shared-dev", "shared-dev"}, // by global name + {"myrepo-ci", "myrepo-ci"}, + } { + got, ok := p.Resolve(tc.arg) + if !ok || got != tc.want { + t.Errorf("Resolve(%q) = %q,%v, want %q,true", tc.arg, got, ok, tc.want) + } + } + if _, ok := p.Resolve("db"); ok { + t.Error("Resolve(db) succeeded, want not found") + } +} + +func TestDuplicateGlobalNameIsAnError(t *testing.T) { + dir := write(t, "schema = 1\n\n[project]\nname = \"myrepo\"\n\n[vms.dev]\nimage = \"a\"\n\n[vms.ci]\nname = \"myrepo-dev\"\nimage = \"b\"\n") + _, err := Load(dir) + if err == nil { + t.Fatal("Load succeeded, want a duplicate-name error") + } + want := `stoat.toml: vms.ci and vms.dev both resolve to "myrepo-dev"` + if err.Error() != want { + t.Errorf("err = %q, want %q", err.Error(), want) + } +} + +func TestUnknownFieldIsRejected(t *testing.T) { + dir := write(t, "schema = 1\n\n[vms.dev]\nimage = \"a\"\ncpu = 4\n") + if _, err := Load(dir); err == nil { + t.Fatal("Load accepted an unknown field; tomlx.Reject is not in force") + } +} + +func TestBadKeyIsRejected(t *testing.T) { + dir := write(t, "schema = 1\n\n[vms.Dev_1]\nimage = \"a\"\n") + if _, err := Load(dir); err == nil { + t.Fatal("Load accepted an out-of-grammar vm key") + } +} + +func TestFindUsesTheCurrentDirectoryOnly(t *testing.T) { + dir := write(t, full) + t.Chdir(dir) + p, ok, err := Find() + if err != nil || !ok || p.Name != "myrepo" { + t.Fatalf("Find() = %v,%v,%v in the project dir", p, ok, err) + } + + sub := filepath.Join(dir, "cmd") + if err := os.Mkdir(sub, 0o755); err != nil { + t.Fatal(err) + } + t.Chdir(sub) + if _, ok, err := Find(); ok || err != nil { + t.Errorf("Find() = %v,%v in a subdirectory; there is no walk-up", ok, err) + } +} diff --git a/internal/project/secrets.go b/internal/project/secrets.go new file mode 100644 index 00000000..15f0cbc8 --- /dev/null +++ b/internal/project/secrets.go @@ -0,0 +1,45 @@ +package project + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/novusedge/stoat/internal/tomlx" +) + +// SecretsPath is the project's secret store. It is gitignored with the rest +// of CacheDir, and stoat writes it 0600. +func SecretsPath(dir string) string { + return filepath.Join(dir, CacheDir, "secrets.toml") +} + +// Secrets returns one declaration's secrets as recipe to param to value. +// +// The file is keyed .., so one file covers every VM in +// the project. An absent file is not an error: a project whose recipes need +// no secret never has one. +func (p *Project) Secrets(key string) (map[string]map[string]string, error) { + path := SecretsPath(p.Dir) + info, err := os.Stat(path) + if os.IsNotExist(err) { + return map[string]map[string]string{}, nil + } + if err != nil { + return nil, err + } + // A secret readable by every account on the host is not a secret. The + // refusal names the mode so the fix is one chmod. + if mode := info.Mode().Perm(); mode&0o077 != 0 { + return nil, fmt.Errorf("secrets.toml: mode %#o, want 0600", mode) + } + + var all map[string]map[string]map[string]string + if err := tomlx.Decode(path, &all, tomlx.Reject); err != nil { + return nil, fmt.Errorf("secrets.toml: %w", err) + } + if all[key] == nil { + return map[string]map[string]string{}, nil + } + return all[key], nil +} diff --git a/internal/project/secrets_test.go b/internal/project/secrets_test.go new file mode 100644 index 00000000..c24bdb2c --- /dev/null +++ b/internal/project/secrets_test.go @@ -0,0 +1,63 @@ +package project + +import ( + "os" + "path/filepath" + "testing" +) + +func writeSecrets(t *testing.T, dir, body string, mode os.FileMode) { + t.Helper() + cache := filepath.Join(dir, CacheDir) + if err := os.MkdirAll(cache, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cache, "secrets.toml"), []byte(body), mode); err != nil { + t.Fatal(err) + } +} + +func TestSecretsReadPerKey(t *testing.T) { + dir := write(t, "schema = 1\n\n[vms.dev]\nimage = \"a\"\n") + writeSecrets(t, dir, "[dev.tailscale]\nauthkey = \"tskey-abc\"\n", 0o600) + p, err := Load(dir) + if err != nil { + t.Fatal(err) + } + got, err := p.Secrets("dev") + if err != nil { + t.Fatal(err) + } + if got["tailscale"]["authkey"] != "tskey-abc" { + t.Errorf("secrets = %v, want tailscale.authkey", got) + } +} + +func TestSecretsAbsentIsNotAnError(t *testing.T) { + dir := write(t, "schema = 1\n\n[vms.dev]\nimage = \"a\"\n") + p, err := Load(dir) + if err != nil { + t.Fatal(err) + } + got, err := p.Secrets("dev") + if err != nil || len(got) != 0 { + t.Errorf("Secrets = %v,%v, want an empty map and no error", got, err) + } +} + +func TestSecretsWideModeIsRefused(t *testing.T) { + dir := write(t, "schema = 1\n\n[vms.dev]\nimage = \"a\"\n") + writeSecrets(t, dir, "[dev.tailscale]\nauthkey = \"x\"\n", 0o644) + p, err := Load(dir) + if err != nil { + t.Fatal(err) + } + _, err = p.Secrets("dev") + if err == nil { + t.Fatal("Secrets read a world-readable file") + } + want := "secrets.toml: mode 0644, want 0600" + if err.Error() != want { + t.Errorf("err = %q, want %q", err.Error(), want) + } +} diff --git a/internal/project/shares.go b/internal/project/shares.go new file mode 100644 index 00000000..536b73c3 --- /dev/null +++ b/internal/project/shares.go @@ -0,0 +1,70 @@ +package project + +import ( + "fmt" + "path/filepath" + "strings" +) + +// Share is one project directory exposed to a VM over 9p. +// +// Tag is qemu's mount_tag. qemu derives the fsdev id from it, so two shares +// on one VM must not share a tag. "host" and "work" are reserved: qemu.Args +// already attaches the legacy single share and the per-VM scratch export +// under those two names. +type Share struct { + Tag string + Host string + Guest string +} + +// Shares resolves one declaration's shares entries to absolute host paths and +// guest mountpoints. +// +// Every entry is resolved through filepath.EvalSymlinks before the +// containment check. A plain string comparison passes "link" that points at +// /etc, which would hand the guest a writable mount of the host's config. +func (p *Project) Shares(key string) ([]Share, error) { + v, ok := p.byKey[key] + if !ok { + return nil, fmt.Errorf("%s: no vms.%s", FileName, key) + } + root, err := filepath.EvalSymlinks(p.Dir) + if err != nil { + return nil, err + } + + var out []Share + guests := map[string]string{} + for i, entry := range v.Shares { + // filepath.Join already cleans ".." components, so an escape shows up + // as a lexical prefix mismatch before the entry needs to exist at + // all. That check runs first: a share need not exist for this error, + // only for the EvalSymlinks call below, which resolves the escape a + // symlink hides. + joined := filepath.Join(p.Dir, entry) + if joined != p.Dir && !strings.HasPrefix(joined, p.Dir+string(filepath.Separator)) { + return nil, fmt.Errorf("%s: vms.%s.shares: %q is outside the project", FileName, key, entry) + } + abs, err := filepath.EvalSymlinks(joined) + if err != nil { + return nil, fmt.Errorf("%s: vms.%s.shares: %q: %v", FileName, key, entry, err) + } + if abs != root && !strings.HasPrefix(abs, root+string(filepath.Separator)) { + return nil, fmt.Errorf("%s: vms.%s.shares: %q is outside the project", FileName, key, entry) + } + + guest := "/work" + if abs != root { + guest = "/work/" + filepath.Base(abs) + } + if first, dup := guests[guest]; dup { + return nil, fmt.Errorf("%s: vms.%s.shares: %q and %q both mount at %s", + FileName, key, first, entry, guest) + } + guests[guest] = entry + + out = append(out, Share{Tag: fmt.Sprintf("p%d", i), Host: abs, Guest: guest}) + } + return out, nil +} diff --git a/internal/project/shares_test.go b/internal/project/shares_test.go new file mode 100644 index 00000000..f4055c6b --- /dev/null +++ b/internal/project/shares_test.go @@ -0,0 +1,87 @@ +package project + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSharesResolve(t *testing.T) { + dir := write(t, "schema = 1\n\n[project]\nname = \"myrepo\"\n\n[vms.dev]\nimage = \"a\"\nshares = [\".\", \"src\"]\n") + if err := os.Mkdir(filepath.Join(dir, "src"), 0o755); err != nil { + t.Fatal(err) + } + p, err := Load(dir) + if err != nil { + t.Fatal(err) + } + got, err := p.Shares("dev") + if err != nil { + t.Fatal(err) + } + real, err := filepath.EvalSymlinks(dir) + if err != nil { + t.Fatal(err) + } + want := []Share{ + {Tag: "p0", Host: real, Guest: "/work"}, + {Tag: "p1", Host: filepath.Join(real, "src"), Guest: "/work/src"}, + } + if len(got) != len(want) { + t.Fatalf("shares = %+v, want %+v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("share %d = %+v, want %+v", i, got[i], want[i]) + } + } +} + +func TestShareOutsideTheProjectIsRefused(t *testing.T) { + dir := write(t, "schema = 1\n\n[vms.dev]\nimage = \"a\"\nshares = [\"../secrets\"]\n") + p, err := Load(dir) + if err != nil { + t.Fatal(err) + } + _, err = p.Shares("dev") + if err == nil { + t.Fatal("Shares accepted a path outside the project") + } + want := `stoat.toml: vms.dev.shares: "../secrets" is outside the project` + if err.Error() != want { + t.Errorf("err = %q, want %q", err.Error(), want) + } +} + +// A symlink is the escape the string check alone misses: "link" is a plain +// relative name and only EvalSymlinks shows where it lands. +func TestShareSymlinkEscapeIsRefused(t *testing.T) { + dir := write(t, "schema = 1\n\n[vms.dev]\nimage = \"a\"\nshares = [\"link\"]\n") + outside := t.TempDir() + if err := os.Symlink(outside, filepath.Join(dir, "link")); err != nil { + t.Fatal(err) + } + p, err := Load(dir) + if err != nil { + t.Fatal(err) + } + if _, err := p.Shares("dev"); err == nil { + t.Fatal("Shares followed a symlink out of the project") + } +} + +func TestTwoSharesWithOneBasenameAreRefused(t *testing.T) { + dir := write(t, "schema = 1\n\n[vms.dev]\nimage = \"a\"\nshares = [\"a/lib\", \"b/lib\"]\n") + for _, d := range []string{"a/lib", "b/lib"} { + if err := os.MkdirAll(filepath.Join(dir, d), 0o755); err != nil { + t.Fatal(err) + } + } + p, err := Load(dir) + if err != nil { + t.Fatal(err) + } + if _, err := p.Shares("dev"); err == nil { + t.Fatal("Shares accepted two entries mounting at one guest path") + } +} diff --git a/internal/qemu/args.go b/internal/qemu/args.go index 550b48dc..9b8ea837 100644 --- a/internal/qemu/args.go +++ b/internal/qemu/args.go @@ -138,6 +138,10 @@ func Args(v *config.VM, graphical bool) []string { } a = append(a, "-virtfs", fmt.Sprintf("local,path=%s,mount_tag=work,security_model=mapped-xattr", v.WorkDir())) + for _, s := range v.Shares { + a = append(a, "-virtfs", + fmt.Sprintf("local,path=%s,mount_tag=%s,security_model=mapped-xattr", s.Host, s.Tag)) + } // Mode owns the boot media: the qcow2, the installer ISO, the boot // order. Provisioning is the backend's job, below. diff --git a/internal/qemu/args_test.go b/internal/qemu/args_test.go index cd298cda..0377f56d 100644 --- a/internal/qemu/args_test.go +++ b/internal/qemu/args_test.go @@ -305,3 +305,27 @@ func TestArgsShareExportsAreAsymmetric(t *testing.T) { t.Error("the work export must be writable") } } + +// Every project share gets its own -virtfs with its own mount tag. qemu +// derives the fsdev id from the tag, so a duplicate tag makes qemu refuse to +// start with an error that names neither share. +func TestArgsRenderEveryProjectShare(t *testing.T) { + t.Setenv("STOAT_HOME", "/data") + v := &config.VM{ + Name: "dev", Mode: "live", ISO: "alpine.iso", RAM: 1024, CPUs: 1, SSHPort: 2200, + Dir: filepath.Join("/data", "dev"), + Shares: []config.Share{ + {Tag: "p0", Host: "/proj", Guest: "/work"}, + {Tag: "p1", Host: "/proj/src", Guest: "/work/src"}, + }, + } + got := joined(Args(v, true)) + for _, want := range []string{ + "local,path=/proj,mount_tag=p0,security_model=mapped-xattr", + "local,path=/proj/src,mount_tag=p1,security_model=mapped-xattr", + } { + if !strings.Contains(got, want) { + t.Errorf("args do not carry %q:\n%s", want, got) + } + } +} diff --git a/internal/recipes/remote_test.go b/internal/recipes/remote_test.go index f7f5ff24..a68809aa 100644 --- a/internal/recipes/remote_test.go +++ b/internal/recipes/remote_test.go @@ -220,7 +220,7 @@ func TestAddValidationFailurePreservesProjectState(t *testing.T) { remoteRoot(t) project := t.TempDir() t.Chdir(project) - writeFile(t, filepath.Join(project, "stoat.toml"), "[vm]\nname = \"kept\"\n\n[recipes]\n") + writeFile(t, filepath.Join(project, "stoat.toml"), "[project]\nname = \"kept\"\n\n[recipes]\n") if err := os.Chmod(filepath.Join(project, "stoat.toml"), 0o600); err != nil { t.Fatal(err) } @@ -295,7 +295,7 @@ func TestProjectAddPreservesGitignoreAndLeavesNoRootCoordinationFile(t *testing. remoteRoot(t) project := t.TempDir() t.Chdir(project) - writeFile(t, filepath.Join(project, "stoat.toml"), "[vm]\nname = \"keep\"\n\n[recipes]\n") + writeFile(t, filepath.Join(project, "stoat.toml"), "[project]\nname = \"keep\"\n\n[recipes]\n") if err := os.Mkdir(filepath.Join(project, ".git"), 0o755); err != nil { t.Fatal(err) } @@ -341,7 +341,7 @@ func setupTransactionalAdd(t *testing.T) (Scope, string, transactionSnapshot) { project := t.TempDir() t.Chdir(project) projectPath := filepath.Join(project, "stoat.toml") - writeFile(t, projectPath, "[vm]\nname = \"keep\"\n\n[recipes]\n") + writeFile(t, projectPath, "[project]\nname = \"keep\"\n\n[recipes]\n") if err := os.Chmod(projectPath, 0o600); err != nil { t.Fatal(err) } diff --git a/internal/recipes/scope.go b/internal/recipes/scope.go index 214d320e..9ec846f1 100644 --- a/internal/recipes/scope.go +++ b/internal/recipes/scope.go @@ -8,11 +8,14 @@ import ( "strings" "github.com/novusedge/stoat/internal/config" + "github.com/novusedge/stoat/internal/project" "github.com/novusedge/stoat/internal/tomlx" ) -// ProjectFile is the name that puts stoat in project scope. -const ProjectFile = "stoat.toml" +// ProjectFile is the name that puts stoat in project scope. internal/project +// owns it: the recipe scope and the VM scope must never disagree about which +// directory is a project. +const ProjectFile = project.FileName // Scope is where one lock and its cache live. // @@ -38,21 +41,18 @@ type Decl struct { // scope even inside a project. func ScopeFor(global bool) (Scope, error) { if !global { - wd, err := os.Getwd() + p, ok, err := project.Find() if err != nil { return Scope{}, err } - cfg := filepath.Join(wd, ProjectFile) - if _, err := os.Stat(cfg); err == nil { + if ok { return Scope{ Name: "project", - Dir: wd, - LockPath: filepath.Join(wd, "stoat.lock"), - CachePath: filepath.Join(wd, ".stoat", "recipes"), - ConfigPath: cfg, + Dir: p.Dir, + LockPath: filepath.Join(p.Dir, "stoat.lock"), + CachePath: filepath.Join(p.Dir, ".stoat", "recipes"), + ConfigPath: filepath.Join(p.Dir, ProjectFile), }, nil - } else if !os.IsNotExist(err) { - return Scope{}, err } } root := config.Root() diff --git a/internal/recipes/scope_test.go b/internal/recipes/scope_test.go index 898c09d2..8f4b747b 100644 --- a/internal/recipes/scope_test.go +++ b/internal/recipes/scope_test.go @@ -86,14 +86,14 @@ xfce = { source = "https://github.com/x/stoat-xfce", ref = "main" } func TestSetDeclAndRemoveDecl(t *testing.T) { type projectDocument struct { - VM struct { + Project struct { Name string `toml:"name"` - } `toml:"vm"` + } `toml:"project"` Recipes map[string]string `toml:"recipes"` } wd := t.TempDir() - writeFile(t, filepath.Join(wd, "stoat.toml"), `[vm] + writeFile(t, filepath.Join(wd, "stoat.toml"), `[project] name = "alpha" [recipes] @@ -113,7 +113,7 @@ name = "alpha" if err := tomlx.Decode(filepath.Join(wd, "stoat.toml"), &project, tomlx.Reject); err != nil { t.Fatal(err) } - if project.VM.Name != "alpha" { + if project.Project.Name != "alpha" { t.Fatalf("unrelated project data was lost: %+v", project) } } diff --git a/internal/sshx/sharemount.go b/internal/sshx/sharemount.go index fe5bb438..786ea6f4 100644 --- a/internal/sshx/sharemount.go +++ b/internal/sshx/sharemount.go @@ -21,16 +21,19 @@ const guestFstabPath = "/etc/fstab" // // A live VM already mounts through the apkovl overlay's own fstab (see // apkovl.Build). A cloud VM seeds its mounts through cloud-init instead. So -// this only fires for Mode "disk", and only when Share is configured: with -// no Share there is no host mount to add, by this feature's design. -// -// qemu.Args attaches the work virtfs to every VM unconditionally, so a disk -// VM gets both tags once Share makes this step run at all. +// this only fires for Mode "disk". func shareMountTags(v *config.VM) []apkovl.Mount9p { - if v.Mode != "disk" || v.Share == "" { + if v.Mode != "disk" { return nil } - return []apkovl.Mount9p{apkovl.HostMount9p, apkovl.WorkMount9p} + var out []apkovl.Mount9p + if v.Share != "" { + out = append(out, apkovl.HostMount9p, apkovl.WorkMount9p) + } + for _, s := range v.Shares { + out = append(out, apkovl.Mount9pFor(s)) + } + return out } // fstabEnsureLine returns the sh fragment that appends m's fstab line to diff --git a/internal/tomlx/tomlx.go b/internal/tomlx/tomlx.go index af386b4e..ea9b8576 100644 --- a/internal/tomlx/tomlx.go +++ b/internal/tomlx/tomlx.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "os" + "reflect" "strings" "github.com/BurntSushi/toml" @@ -81,6 +82,9 @@ func decode(path string, v any, o options) (toml.MetaData, error) { } } for _, k := range md.Undecoded() { + if insideDynamicField(reflect.TypeOf(v), k) { + continue + } key := strings.Join(k, ".") if o.warn == nil { return md, fmt.Errorf("%s: unknown key %q", path, key) @@ -90,6 +94,46 @@ func decode(path string, v any, o options) (toml.MetaData, error) { return md, nil } +// insideDynamicField reports whether key names a value nested under a field +// whose Go type carries an interface{} somewhere on its path, such as +// map[string]any. BurntSushi's TOML decoder places a table value fully into +// such a field but still lists its nested keys as undecoded, because it +// tracks only which struct fields it used, not which map entries it filled. +// Any key under an interface{} is definitionally known: an untyped value +// accepts every shape by construction. A map keyed into a concrete struct +// (map[string]VM) is not dynamic; its element's own fields are still checked, +// only the map key itself (arbitrary, one segment) is skipped. +func insideDynamicField(t reflect.Type, key []string) bool { + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + if t.Kind() == reflect.Interface { + return true + } + if len(key) == 0 { + return false + } + switch t.Kind() { + case reflect.Map: + if t.Elem().Kind() == reflect.Interface { + return true + } + return insideDynamicField(t.Elem(), key[1:]) + case reflect.Struct: + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + name := strings.Split(f.Tag.Get("toml"), ",")[0] + if name == "" { + name = f.Name + } + if name == key[0] { + return insideDynamicField(f.Type, key[1:]) + } + } + } + return false +} + // Encode is the single TOML writer for files owned by stoat. func Encode(path string, v any) error { var buf bytes.Buffer diff --git a/internal/tomlx/tomlx_test.go b/internal/tomlx/tomlx_test.go index 8c0a5335..4cbf34d7 100644 --- a/internal/tomlx/tomlx_test.go +++ b/internal/tomlx/tomlx_test.go @@ -83,6 +83,41 @@ func TestDecodeSchemaAbsentIsFine(t *testing.T) { } } +// A key nested under a map[string]any field is inside a dynamic field: an +// untyped value accepts any shape, so an inline table's own keys are known +// even in Reject mode. +func TestDecodeRejectAcceptsKeysUnderADynamicMap(t *testing.T) { + type dynDoc struct { + Recipes map[string]any `toml:"recipes"` + } + p := write(t, "[recipes]\ndemo = { source = \"x\", ref = \"y\" }\n") + var d dynDoc + if err := Decode(p, &d, Reject); err != nil { + t.Fatalf("Decode = %v, want a dynamic map's inline-table keys accepted", err) + } +} + +// A map keyed into a concrete struct type is not dynamic: only the map key +// itself is arbitrary, and an unknown field on the element type is still +// rejected. +func TestDecodeRejectStillRejectsUnknownFieldOnATypedMapElement(t *testing.T) { + type item struct { + Image string `toml:"image"` + } + type typedDoc struct { + Items map[string]item `toml:"items"` + } + p := write(t, "[items.dev]\nimage = \"a\"\nbogus = 1\n") + var d typedDoc + err := Decode(p, &d, Reject) + if err == nil { + t.Fatal("Decode accepted an unknown field on a typed map element") + } + if !strings.Contains(err.Error(), `unknown key "items.dev.bogus"`) { + t.Errorf("err = %v, want it to name items.dev.bogus", err) + } +} + func TestEncodeThenDecodeRoundTrips(t *testing.T) { p := filepath.Join(t.TempDir(), "x.toml") if err := Encode(p, doc{Schema: 1, Name: "x"}); err != nil {