diff --git a/docs/README.md b/docs/README.md index fc0ea6b..6ca4cdb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,11 +5,10 @@ Alpine-first, and it manages each VM through a small `vm.toml` file instead of a system daemon or a virtualization stack like libvirt. stoat is for developers who want a disposable Linux VM (or a handful of -persistent ones) on their own machine, driven entirely from the terminal. What -sets it apart is what it *doesn't* need: no libvirt, no background daemon, no -database, just a single Go binary that shells out to `qemu-system-x86_64`, -`qemu-img`, and `ssh`, and a data root of plain files under `~/.stoat` (or -`$STOAT_HOME`). +persistent ones) on their own machine, driven entirely from the terminal. +stoat needs no libvirt, no background daemon, and no database, just a single +Go binary that shells out to `qemu-system-x86_64`, `qemu-img`, and `ssh`, and +a data root of plain files under `~/.stoat` (or `$STOAT_HOME`). New here? Start with [Installation](getting-started/installation.md), then [Your first VM](getting-started/first-vm.md). diff --git a/docs/concepts/access-and-auth.md b/docs/concepts/access-and-auth.md index 914f601..68e273b 100644 --- a/docs/concepts/access-and-auth.md +++ b/docs/concepts/access-and-auth.md @@ -85,20 +85,19 @@ builds a very similar argument list: same host-key options, same `-i id_stoat`, same `root@127.0.0.1` target for live VMs, but deliberately **omits `BatchMode=yes`**. `Args` (used for unattended provisioning) needs SSH to fail immediately rather than block waiting for input that will never come. -The interactive path hands the terminal to a real, attended `ssh` process, so -if key auth doesn't work, for instance a disk-mode VM that was installed +The interactive path hands the terminal to a real, attended `ssh` process. +If key auth doesn't work, for instance a disk-mode VM that was installed manually and never had the key provisioned, SSH is free to fall back to prompting for a password at the terminal, the same as running `ssh` by hand. ## Why host key checking is off Both paths set `StrictHostKeyChecking=no` and `UserKnownHostsFile=/dev/null`. -This is deliberate, not a shortcut: live Alpine VMs are rebuilt by stoat +This is deliberate. live Alpine VMs are rebuilt by stoat constantly, and even with the stable guest host key described above, disk and cloud VMs generate their own host keys independently. Strict checking would mean a stale or mismatched `known_hosts` entry breaking a connection to a VM -stoat just built, the kind of false alarm host-key checking exists to avoid, -not the kind it's meant to catch. +stoat just built: a false alarm, not an actual key mismatch. ## Passwords: console only, never over SSH @@ -136,7 +135,7 @@ the detail screen: and in the log line written when the VM starts, which you can read with `stoat logs`. -### Why a fixed default is not a compromise +### Why a fixed default is safe You end up at that console precisely when SSH *isn't* working, which is the worst possible moment to go and look a credential up. And it is no weaker diff --git a/docs/concepts/data-root.md b/docs/concepts/data-root.md index 318b126..63df1fe 100644 --- a/docs/concepts/data-root.md +++ b/docs/concepts/data-root.md @@ -40,7 +40,7 @@ than an error. └── meta-data ``` -A couple of things worth calling out about that tree: +Two facts about that tree: - `isos/` holds both plain ISOs (Alpine) and downloaded cloud images (Ubuntu/Debian/Fedora/Arch `.qcow2`/`.img` files). A `cloud` VM's @@ -81,8 +81,7 @@ Each VM directory holds one `vm.toml`. Every field: `vm.toml` is a plain TOML file and nothing stops you from editing it directly while the VM is stopped: stoat re-reads it fresh every time, there's no -cache to invalidate. That said, some fields are much safer to touch than -others: +cache to invalidate. Some fields are safer to edit than others: - **Safe-ish**: `ram`, `cpus`, `share`, `recipes` (as long as the filenames still exist under `recipes/`), `sshuser`. diff --git a/docs/concepts/modes-and-backends.md b/docs/concepts/modes-and-backends.md index 0d56ce9..f827fed 100644 --- a/docs/concepts/modes-and-backends.md +++ b/docs/concepts/modes-and-backends.md @@ -10,13 +10,10 @@ related but not the same thing: what its start logic checks before launching a VM. - **Backend** (`vm.toml`'s `backend` field) records how the create form picked recipes and provisioning at creation time. Once the VM exists, - nothing at runtime dispatches on this field: it's informational, not - load-bearing. + nothing at runtime dispatches on this field: it's informational. -If you only remember one thing from this page: **`disk` mode gives you no -SSH access until you install an OS yourself and tell stoat so.** That single -fact trips up more people than everything else in this tool combined, so it -gets its own section below. +**`disk` mode gives you no SSH access until you install an OS yourself and +tell stoat so.** See the section below. ## The three modes @@ -27,7 +24,7 @@ into RAM: the root filesystem is an Alpine initramfs overlay, and nothing about it is written anywhere persistent. Stop or reboot the VM and you get a clean slate next time. -What makes `live` mode useful rather than just a toy: every time you start +What makes `live` mode useful: every time you start one, stoat builds a small Alpine overlay tarball (an "apkovl") and hands it to the guest as a fake FAT disk over `-virtfs`/vvfat. That overlay bakes in: diff --git a/docs/concepts/networking-and-sharing.md b/docs/concepts/networking-and-sharing.md index c8bb037..6b36fe9 100644 --- a/docs/concepts/networking-and-sharing.md +++ b/docs/concepts/networking-and-sharing.md @@ -63,9 +63,6 @@ Whether the share actually gets *mounted* inside the guest depends on mode: ## Sharing a binary built on the host: the musl/glibc trap -This is the single most confusing failure mode around the share feature, so -it gets called out explicitly. - If you build a binary on a typical glibc-based Linux host (Arch, Fedora, Ubuntu, Debian) and drop it into your shared directory expecting to run it straight from `/mnt/host` inside an **Alpine** guest (the default for `live` diff --git a/docs/design/core-api.md b/docs/design/core-api.md index 2d9e853..34d98da 100644 --- a/docs/design/core-api.md +++ b/docs/design/core-api.md @@ -157,7 +157,7 @@ The TUI keeps its prompt and passes the answer. The MCP server requires the agen ## 7. Convenience operations -These are the "quick VM-based testing" features that make stoat pleasant rather than merely functional. All are cheap because of what QEMU and qcow2 already give us. +These are the "quick VM-based testing" features that make stoat pleasant to use. All are cheap because of what QEMU and qcow2 already give us. | Operation | Signature | Why it earns a slot | |---|---|---| @@ -282,7 +282,7 @@ The mechanism that does work is `security_model=mapped-xattr`, which stores *"ui ### 10.3 MCP tool taxonomy -Three classes, annotated honestly and, crucially, enforced independently of whether the client honours the annotation. +Three classes, annotated honestly and enforced independently of whether the client honours the annotation. | Class | Tools | Annotations | Server-side rule | |---|---|---|---| diff --git a/docs/design/guest-subsystem.md b/docs/design/guest-subsystem.md index 295f61b..3cd1b1f 100644 --- a/docs/design/guest-subsystem.md +++ b/docs/design/guest-subsystem.md @@ -358,11 +358,11 @@ These are requirements the TUI never forced, and each one is a real change: **Declarative creation.** One `Create(Spec)` call, no multi-step form state. Everything the form asks for either has a default or is in the `Spec`. -**Recipe metadata becomes load-bearing.** §5's `# stoat:os` / `# stoat:requires`, enforced at selection, is *optional politeness* for a human who can read a black screen and *mandatory* for an agent that cannot. `Create` returns `ErrRecipeNotApplicable` rather than producing a VM that boots broken. This is the strongest argument for the departure from prior art in §2. +**Recipe metadata now gates VM creation.** §5's `# stoat:os` / `# stoat:requires`, enforced at selection, is *optional politeness* for a human who can read a black screen and *mandatory* for an agent that cannot. `Create` returns `ErrRecipeNotApplicable` rather than producing a VM that boots broken. This is the strongest argument for the departure from prior art in §2. **Cancellation.** Every operation takes a `context.Context`. Downloads and boot-waits are long; an agent that abandons a task must be able to stop them. Today `esc` leaves a download goroutine running (a known open item). -### 9.5 Concurrency: new, and load-bearing +### 9.5 Concurrency: new, and required once a second caller exists A TUI has one user doing one thing. An MCP server plus a TUI plus a CLI can act **at the same time**, and two current mechanisms are not safe under that: @@ -407,6 +407,6 @@ If any of these needs logic that is not already in `core`, the layering is wrong ## 10. Risks - **This is a real refactor**, touching `qemu`, `cloudinit`, `apkovl`, `recipes`, `iso` and `tui`. The mitigation is that behaviour must not change except where a bug is named here: existing tests are the contract, and any test that needs updating is a signal to stop and check rather than to edit. -- **`Backend.Prepare`'s call frequency differs by backend** (apkovl every boot; cloudinit once, ever). That asymmetry is load-bearing: a cloud VM whose seed is rebuilt would have its instance identity change under it. The interface hides it, which is right, but the implementations must be explicit about it and tested for it. +- **`Backend.Prepare`'s call frequency differs by backend** (apkovl every boot; cloudinit once, ever). A cloud VM whose seed is rebuilt would have its instance identity change under it. The interface hides it, which is right, but the implementations must be explicit about it and tested for it. - **`cloud-init schema` may be absent** on the host (Arch does not install cloud-init by default). Validation must degrade to "not checked", never to "assumed valid". - **The merge-semantics change is silent when wrong.** Packages that quietly stop being installed look like a recipe bug, not a merge bug. Needs a direct test. diff --git a/docs/design/tui-migration-draft.md b/docs/design/tui-migration-draft.md index 045622d..39ba200 100644 --- a/docs/design/tui-migration-draft.md +++ b/docs/design/tui-migration-draft.md @@ -440,7 +440,7 @@ original and is fine), `vmlist.go`'s delegate and every width constant, `app.go`'s `View`/`newView`/`renderModal` compositing, `list.go brokenReason`, `access.go`'s `shortenPath`/`joinAccess`. -**Every width constant is load-bearing and has a story:** `listWidth = 60` +**Every width constant has a story:** `listWidth = 60` (sized to a running row), `formContentWidth = 72` (sized to fit 80 columns), `byoFileWidth = 24`, `imageMetaWidth = 11` (sized to `"13 (trixie)"`), `modalSizeWidth = 9` (sized to `"~66.0 MiB"`), `accessWidth = 40`, diff --git a/docs/recipe-authoring-spec.md b/docs/recipe-authoring-spec.md index d71d038..b267192 100644 --- a/docs/recipe-authoring-spec.md +++ b/docs/recipe-authoring-spec.md @@ -20,9 +20,8 @@ and survives upgrades. named `mything.alpine.sh` and it appears in the picker for Alpine VMs. No code required. Any "creator" competes with `$EDITOR` on a path that already works. -So the question is not "can we add a creator" but "what does a creator do that -`vim ~/.stoat/recipes/x.alpine.sh` doesn't". Three honest candidates below, -smallest first. +The question is what a creator does that `vim ~/.stoat/recipes/x.alpine.sh` +doesn't. Candidates below, smallest first. --- diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 6089408..b721826 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -399,7 +399,7 @@ broken vm: /home/user/.stoat/vms/oldvm partial download: /home/user/.stoat/isos/ubuntu-24.04.iso.part ``` -`--broken` additionally considers VMs whose `vm.toml` won't parse for removal; `--images` additionally considers downloaded images no VM refers to. Without either, `prune` only reports partial downloads by default (broken VMs and orphaned images need to be asked for explicitly). Printing an identical list for the dry run and the real run is deliberate: the two are meant to be readable as the same thing, one with the deletions actually applied. +`--broken` also considers VMs whose `vm.toml` won't parse for removal; `--images` also considers downloaded images no VM refers to. Without either, `prune` only reports partial downloads by default (broken VMs and orphaned images need to be asked for explicitly). Printing an identical list for the dry run and the real run is deliberate: the two are meant to be readable as the same thing, one with the deletions actually applied. **Exit codes:** 0 on success, including "nothing to prune"; 1 if pruning fails. @@ -567,7 +567,7 @@ A usage error (2) always prints both the specific complaint and the full usage t ## Scripting - **`-q`, `--quiet`, `--no-interactive`** are three names for the same flag, present on every subcommand. Where it has an effect, it suppresses the "in-progress" chatter (`starting work...`, `provisioning work...`, ...); final results and all errors print regardless of this flag. -- **`rm`** additionally treats `--no-interactive`/`-q`/`--json` as "there is no one to answer a confirmation prompt": without `-y` it refuses rather than blocking on stdin. +- **`rm`** also treats `--no-interactive`/`-q`/`--json` as "there is no one to answer a confirmation prompt": without `-y` it refuses rather than blocking on stdin. - **`--json`** is the machine-readable mode: one JSON object per line on stdout, errors included, implying `--quiet` and never prompting. See [json.md](json.md) for the wire format. - **`NO_COLOR`** (any non-empty value) disables ANSI color in `ls`'s output. - Color is also **disabled automatically whenever stdout is not a terminal** (checked via `os.ModeCharDevice`), so piping `stoat ls` into `awk`, `grep`, or a file never carries escape codes even without setting `NO_COLOR`. Only `ls`'s `STATE` column is ever colored. diff --git a/docs/reference/json.md b/docs/reference/json.md index 4b863f4..55f4cda 100644 --- a/docs/reference/json.md +++ b/docs/reference/json.md @@ -48,8 +48,7 @@ Four rules make it work: Rule 3 is not a preference. A consumer that must merge two pipes to reconstruct one result will eventually interleave them wrong, and a naive `subprocess` read of two pipes in sequence deadlocks when either buffer fills. -The error envelope is the answer to the question that was asked, not a -diagnostic. +The error envelope answers the question that was asked. ## The envelope diff --git a/docs/specs/2026-08-10-recipe-system-fixes-design.md b/docs/specs/2026-08-10-recipe-system-fixes-design.md index e091a73..75b2041 100644 --- a/docs/specs/2026-08-10-recipe-system-fixes-design.md +++ b/docs/specs/2026-08-10-recipe-system-fixes-design.md @@ -1,4 +1,4 @@ -# Recipe System Fixes +# Recipe system fixes Status: **draft** @@ -6,7 +6,7 @@ Status: **draft** This spec addresses six architectural issues in the recipe system. Items 1 and the cloudinit v2 conversion are coupled; the rest can land independently. -## Priority Order +## Priority order 1. Delete v1 metadata parser + wire cloudinit to v2 scripts (coupled) 2. Add dependency ordering @@ -17,7 +17,7 @@ This spec addresses six architectural issues in the recipe system. Items 1 and t --- -## 1. Delete v1 Metadata Parser + Cloudinit v2 Conversion +## 1. Delete v1 metadata parser + cloudinit v2 conversion **Can implement independently:** No — cloudinit's `Prepare()` and `clone.go` call `recipes.Read()` on v2 directory names, which fails. These must be fixed together. @@ -31,12 +31,12 @@ Additionally, the cloudinit backend is already broken for v2 recipes. `cloudinit Delete the v1 path, wire cloudinit to v2 scripts, and make `Manifest` the single source of truth. -### Files to Delete +### Files to delete - `internal/recipes/metadata.go` - `internal/recipes/metadata_test.go` -### Files to Modify +### Files to modify **`internal/core/apply.go`:** - Remove `ReadMetadata` calls @@ -69,7 +69,7 @@ Warning: Moved legacy recipe to .v1-removed/. Convert to v2 format. See d --- -## 2. Dependency Ordering +## 2. Dependency ordering **Can implement independently:** Yes @@ -77,7 +77,7 @@ Warning: Moved legacy recipe to .v1-removed/. Convert to v2 format. See d `v.Recipes` is a flat list run in array order. Recipes cannot declare "run after X". If devtools assumes Docker is present, the user must manually order them correctly. -### Manifest Change +### Manifest change ```toml name = "devtools" @@ -98,7 +98,7 @@ depends = ["docker"] - Error with the cycle path if found: `"cycle detected: devtools -> docker -> devtools"` - Auto-added dependencies must pass `MatchesVM` for the target VM's OS -### Topo-Sort +### Topo-sort **In `filterByRunMode`:** - Before filtering by run mode, topo-sort `targets` so dependencies come first @@ -106,7 +106,7 @@ depends = ["docker"] - Kahn's algorithm or DFS-based sort; either works for small N - **Cycle detection here too**: user can edit manifests on disk after add-time. Error if a cycle appears; do not silently fall back to array order. -### Dependency Satisfaction Rules +### Dependency satisfaction rules A dependency is satisfied when: - The dependency recipe ran earlier in this apply run, OR @@ -116,13 +116,13 @@ A dependency is **not** satisfied when: - The dependency is `run = "manual"` and was never applied — error: `"devtools depends on docker, which has never been applied (run = manual)"` - The dependency is filtered out by `ApplyOpts.Only` and was never applied — error: `"devtools depends on docker; add it to --recipe or apply it first"` -### TUI Behavior +### TUI behavior User adds "devtools" which depends on "docker": - If docker is already in `v.Recipes`: proceed - If docker is missing: run `CheckRecipes` on docker for this VM's OS. If it passes, auto-add and show: `"Added docker (required by devtools)"`. If it fails, error with the reason. -### CLI Behavior +### CLI behavior `stoat vm create --recipes devtools` errors if docker isn't also specified: ``` @@ -133,7 +133,7 @@ The CLI does not auto-add. Scripts must be explicit. --- -## 3. Cloudinit Post-Boot Path +## 3. Cloudinit post-boot path **Can implement independently:** Yes (after item 1) @@ -150,7 +150,7 @@ After item 1, cloudinit generates seeds with v2 scripts. `Apply()` still returns Remove `ErrAppliedAtBoot`. Allow `Apply()` to work on cloudinit VMs via SSH after first boot. Track what cloud-init ran via marker files. -### Marker Files +### Marker files **At VM creation (in cloudinit backend's seed generation):** @@ -162,7 +162,7 @@ runcmd: Each recipe writes a marker file on success. -### Apply Behavior +### Apply behavior **In `applyLocked` (after removing the `ErrAppliedAtBoot` check):** @@ -175,7 +175,7 @@ Each recipe writes a marker file on success. **Fallback:** If `/var/lib/stoat/.applied/` doesn't exist (old VM created before this feature, or cloud-init failed entirely), treat all recipes as pending. The first `Apply()` re-runs everything once; after that, state is tracked correctly. -### Files to Modify +### Files to modify **`internal/core/apply.go`:** - Remove the `backend.For(v).Name() == "cloudinit"` check that returns `ErrAppliedAtBoot` @@ -201,7 +201,7 @@ Cloudinit VMs behave like other VMs after first boot: --- -## 4. Dry-Run +## 4. Dry-run **Can implement independently:** Yes @@ -213,7 +213,7 @@ Cloudinit VMs behave like other VMs after first boot: Add `stoat apply --dry-run` and expose the filtering result. -### CLI Output +### CLI output **Human-readable (default):** ``` @@ -257,11 +257,11 @@ func PlanApply(v *config.VM, opts ApplyOpts) ([]ApplyPlan, error) --- -## 5. Single Reboot Behavior +## 5. Single reboot behavior **Can implement independently:** Yes (documentation only) -### Current Behavior +### Current behavior `reboot = true` in the manifest triggers one reboot after all recipes finish. The first recipe that declares `reboot = true` names the reboot in the log. Subsequent `reboot = true` recipes don't trigger additional reboots. @@ -271,7 +271,7 @@ Reboot only fires for disk-mode VMs (`apply.go:185` checks `v.Mode == "disk"`). Keep this behavior. Document it clearly. -### Documentation Update +### Documentation update In `docs/recipe-spec-v2.md`, add a section: @@ -291,13 +291,13 @@ and a reboot wipes everything. Live recipes that need a session restart should restart in place instead (e.g., `kill -HUP 1`). ``` -### Future Extension +### Future extension Per-recipe reboots (reboot after recipe A, then run recipe B) could be added later. Out of scope for this spec. --- -## 6. Stage Field Validation +## 6. Stage field validation **Can implement independently:** Yes @@ -305,7 +305,7 @@ Per-recipe reboots (reboot after recipe A, then run recipe B) could be added lat `stage = "install"` parses and validates, but install-stage recipe bodies are never executed. The field exists in the schema with no effect. -### Use Case +### Use case BYO ISO support will need install-stage hooks for custom partitioning, bootloader config, and installer automation. That feature does not exist yet. @@ -322,7 +322,7 @@ if m.Stage == "install" { } ``` -### Why Not Implement Now +### Why not implement now The cloudinit design has a bug: cloud-init's `bootcmd` runs *before* the `write_files` module, so the script file doesn't exist yet. `bootcmd` also runs before networking, so package installs fail. "Install stage" on a cloud image is conceptually unclear anyway — the system is already installed. @@ -334,9 +334,9 @@ When BYO ISO support is added, revisit this section. The implementation will lik --- -## Testing Strategy +## Testing strategy -### Unit Tests +### Unit tests - `TestParseManifestDependsField` — validates depends parsing - `TestCycleDetection` — catches A→B→A cycles at add-time @@ -347,14 +347,14 @@ When BYO ISO support is added, revisit this section. The implementation will lik - `TestPlanApplyStoppedVM` — works when VM is not running - `TestCloudInitMarkerDiscovery` — reads marker files, populates Applied -### Integration Tests +### Integration tests - Create a VM with depends, verify execution order - `--dry-run` outputs correct plan - cloudinit VM: apply after first boot, verify state tracking - TUI: add recipe with missing dep that fails OS check, verify error -### Manual Tests +### Manual tests - TUI: add recipe with missing dep, verify auto-add message - CLI: `--recipes devtools` without docker, verify error @@ -362,9 +362,9 @@ When BYO ISO support is added, revisit this section. The implementation will lik --- -## Implementation Notes +## Implementation notes -### Order Matters +### Order matters 1. **Item 1** first — deletes v1, fixes broken cloudinit backend 2. **Item 2** — dependency ordering, no blockers @@ -373,7 +373,7 @@ When BYO ISO support is added, revisit this section. The implementation will lik 5. **Item 5** — docs only 6. **Item 6** — validation only, can land anytime -### Backwards Compatibility +### Backwards compatibility - v1 flat-file recipes are swept to `.v1-removed/` with a warning; users must migrate - Existing v2 recipes without `depends` keep working (empty deps = no ordering constraint) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 4fa6296..fa2006c 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -34,9 +34,9 @@ window, which stays reachable even if the guest locks up or loses its network. stoat: up: qemu failed to start: qemu-system-x86_64: OpenGL is not supported by display backend 'gtk' ``` -This one is not about OpenGL, and mesa and your GPU drivers are not the place -to look. stoat starts a VM with `-display gtk,gl=on` by default, and `gl=on` -is simply the first option QEMU rejects when it cannot open a window at all. +`gl=on` is the first option QEMU rejects when it can't open a window at all; +mesa and GPU drivers aren't involved. stoat starts a VM with +`-display gtk,gl=on` by default. With the same window and no `gl=on`, the same host says `gtk initialization failed` instead. @@ -185,7 +185,7 @@ membership actually takes effect. Confirm with `ls -l /dev/kvm` and `groups`. ## A live VM lost everything after a reboot -This isn't a bug, it's how live mode works. A live VM's root filesystem is a +Live mode works this way by design. A live VM's root filesystem is a `tmpfs`/`overlay` mount that only exists in RAM for that boot (`internal/apkovl/apkovl.go`'s doc comment, and the same detection every bundled recipe uses: `awk '$2 == "/" { print $3 }' /proc/mounts` reporting diff --git a/internal/cli/cli.go b/internal/cli/cli.go index e66b8fc..cf9f342 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -18,6 +18,7 @@ import ( "bytes" "fmt" "io" + "strings" "time" "github.com/alecthomas/kong" @@ -165,18 +166,7 @@ func commandPath(ctx *kong.Context) string { parts = append(parts, t.Command.Name) } } - return joinSpace(parts) -} - -func joinSpace(parts []string) string { - out := "" - for i, p := range parts { - if i > 0 { - out += " " - } - out += p - } - return out + return strings.Join(parts, " ") } // Parse turns argv (excluding the "stoat" program name) into an Args, or a diff --git a/internal/core/apply.go b/internal/core/apply.go index 55179d3..8f91a29 100644 --- a/internal/core/apply.go +++ b/internal/core/apply.go @@ -96,18 +96,9 @@ func PlanApply(name string, opts ApplyOpts) ([]ApplyPlan, error) { return nil, err } - targets := v.Recipes - if len(opts.Only) > 0 { - have := make(map[string]bool, len(v.Recipes)) - for _, r := range v.Recipes { - have[r] = true - } - for _, o := range opts.Only { - if !have[o] { - return nil, fmt.Errorf("%w: recipe %q is not one of %s's recipes", ErrRecipeNotApplicable, o, v.Name) - } - } - targets = opts.Only + targets, err := resolveTargets(v, opts.Only) + if err != nil { + return nil, err } explicit := make(map[string]bool, len(opts.Only)) for _, o := range opts.Only { @@ -129,24 +120,35 @@ func PlanApply(name string, opts ApplyOpts) ([]ApplyPlan, error) { return plan, nil } +// resolveTargets validates only against v's own recipes and returns the +// target list: only itself when non-empty, v.Recipes otherwise. Shared by +// PlanApply and applyLocked so a dry-run plan always targets the same +// recipes a real Apply would. +func resolveTargets(v *config.VM, only []string) ([]string, error) { + if len(only) == 0 { + return v.Recipes, nil + } + have := make(map[string]bool, len(v.Recipes)) + for _, r := range v.Recipes { + have[r] = true + } + for _, o := range only { + if !have[o] { + return nil, fmt.Errorf("%w: recipe %q is not one of %s's recipes", ErrRecipeNotApplicable, o, v.Name) + } + } + return only, nil +} + // applyLocked is Apply's body, run while Apply holds name's provision lock. func applyLocked(ctx context.Context, v *config.VM, opts ApplyOpts) error { if !qemu.Running(v) { return fmt.Errorf("%w: %s", ErrNotRunning, v.Name) } - targets := v.Recipes - if len(opts.Only) > 0 { - have := make(map[string]bool, len(v.Recipes)) - for _, r := range v.Recipes { - have[r] = true - } - for _, o := range opts.Only { - if !have[o] { - return fmt.Errorf("%w: recipe %q is not one of %s's recipes", ErrRecipeNotApplicable, o, v.Name) - } - } - targets = opts.Only + targets, err := resolveTargets(v, opts.Only) + if err != nil { + return err } if len(targets) == 0 { // Nothing to run is not an error. An explicit Apply on a VM with no diff --git a/internal/core/image.go b/internal/core/image.go index 5536e35..6b51d42 100644 --- a/internal/core/image.go +++ b/internal/core/image.go @@ -21,10 +21,13 @@ import ( // file would show up as selectable and let a VM build on a truncated // image. Downloads run minutes with no cancel key, so aborted ones, and // their .part files, are common. -func LocalImages() []string { +func LocalImages() ([]string, error) { entries, err := os.ReadDir(filepath.Join(config.Root(), "isos")) if err != nil { - return nil + if os.IsNotExist(err) { + return nil, nil + } + return nil, err } var out []string for _, e := range entries { @@ -32,7 +35,7 @@ func LocalImages() []string { out = append(out, e.Name()) } } - return out + return out, nil } // MatchLocal reports which local file, if any, satisfies catalog entry e. @@ -86,7 +89,7 @@ func MatchLocal(e iso.Entry, files []string) string { // image is everything Create needs to know about the file a Spec named. type image struct { - abs string // absolute path to the file on disk + abs string rel string // bare name under isos/; "" when abs lives elsewhere entry *iso.Entry // nil for BYO osName string @@ -126,7 +129,10 @@ func resolveImage(spec string) (image, error) { return image{abs: spec, backend: backend, osName: osName}, nil } - files := LocalImages() + files, err := LocalImages() + if err != nil { + return image{}, err + } for _, e := range iso.Catalog() { if e.ID != spec { continue diff --git a/internal/core/images.go b/internal/core/images.go index d7c2b2c..25a0d0f 100644 --- a/internal/core/images.go +++ b/internal/core/images.go @@ -60,7 +60,10 @@ func fileSize(file string) (int64, bool) { // resolveImage, so Images can never disagree with what a Spec naming a catalog // ID resolves to. func Images() ([]CatalogImage, error) { - files := LocalImages() + files, err := LocalImages() + if err != nil { + return nil, err + } matched := map[string]bool{} var out []CatalogImage diff --git a/internal/core/prune.go b/internal/core/prune.go index addc127..91bb41b 100644 --- a/internal/core/prune.go +++ b/internal/core/prune.go @@ -302,8 +302,13 @@ func pruneImages(dryRun bool) ([]PruneItem, error) { return nil, err } + files, err := LocalImages() + if err != nil { + return nil, err + } + var out []PruneItem - for _, f := range LocalImages() { + for _, f := range files { abs, err := filepath.Abs(filepath.Join(config.Root(), "isos", f)) if err != nil { continue diff --git a/internal/installer/build.go b/internal/installer/build.go index a2d94bb..69c9c1f 100644 --- a/internal/installer/build.go +++ b/internal/installer/build.go @@ -62,12 +62,6 @@ func (e *BuildError) Error() string { func (e *BuildError) Unwrap() error { return e.Err } -// Install copies srcPath into destDir as an executable, creating destDir if -// needed. -// -// It writes to a temp file and renames, rather than opening the destination -// for write. Replacing a running binary that way is atomic and avoids -// ETXTBSY, the error a reinstall over a running stoat would otherwise hit. // InstallData creates ~/.stoat subdirectories and copies bundled recipes from // the repo. Safe to call on reinstall: existing user files are not overwritten. func InstallData(repoDir, home string) error { diff --git a/internal/qemu/qmp.go b/internal/qemu/qmp.go index c0395df..f8dcb18 100644 --- a/internal/qemu/qmp.go +++ b/internal/qemu/qmp.go @@ -145,35 +145,31 @@ func (q *qmp) hmpChecked(cmd string) error { return nil } -// SnapshotSave takes a live snapshot of a RUNNING VM, capturing RAM as well as -// disk, so restoring resumes execution rather than rebooting. -func SnapshotSave(v *config.VM, tag string) error { +// snapshotCmd dials QMP, runs an HMP command that prints nothing on success, +// and closes the session. savevm, loadvm and delvm all share this shape. +func snapshotCmd(v *config.VM, hmp string) error { q, err := dialQMP(v) if err != nil { return err } defer q.Close() - return q.hmpChecked("savevm " + tag) + return q.hmpChecked(hmp) +} + +// SnapshotSave takes a live snapshot of a RUNNING VM, capturing RAM as well as +// disk, so restoring resumes execution rather than rebooting. +func SnapshotSave(v *config.VM, tag string) error { + return snapshotCmd(v, "savevm "+tag) } // SnapshotLoad restores a RUNNING VM to a snapshot. func SnapshotLoad(v *config.VM, tag string) error { - q, err := dialQMP(v) - if err != nil { - return err - } - defer q.Close() - return q.hmpChecked("loadvm " + tag) + return snapshotCmd(v, "loadvm "+tag) } // SnapshotDelete removes a snapshot from a RUNNING VM's disk. func SnapshotDelete(v *config.VM, tag string) error { - q, err := dialQMP(v) - if err != nil { - return err - } - defer q.Close() - return q.hmpChecked("delvm " + tag) + return snapshotCmd(v, "delvm "+tag) } // SnapshotInfo returns the raw "info snapshots" text from a RUNNING VM. diff --git a/internal/recipes/bundled/docker/install-arch.sh b/internal/recipes/bundled/docker/install-arch.sh index 6c8fd2a..109b49a 100755 --- a/internal/recipes/bundled/docker/install-arch.sh +++ b/internal/recipes/bundled/docker/install-arch.sh @@ -8,7 +8,6 @@ pacman -Sy --noconfirm docker docker-compose systemctl enable docker systemctl start docker -# Wait for daemon i=0 while [ $i -lt 30 ]; do docker info >/dev/null 2>&1 && break diff --git a/internal/recipes/bundled/docker/install-debian.sh b/internal/recipes/bundled/docker/install-debian.sh index 053e639..3f1719a 100755 --- a/internal/recipes/bundled/docker/install-debian.sh +++ b/internal/recipes/bundled/docker/install-debian.sh @@ -5,17 +5,14 @@ set -e export DEBIAN_FRONTEND=noninteractive -# Install prerequisites apt-get update apt-get install -y ca-certificates curl gnupg -# Add Docker's official GPG key install -m 0755 -d /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg | \ gpg --dearmor -o /etc/apt/keyrings/docker.gpg chmod a+r /etc/apt/keyrings/docker.gpg -# Add the repository echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \ https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") \ $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ @@ -27,7 +24,6 @@ apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin systemctl enable docker systemctl start docker -# Wait for daemon i=0 while [ $i -lt 30 ]; do docker info >/dev/null 2>&1 && break diff --git a/internal/recipes/bundled/docker/install-fedora.sh b/internal/recipes/bundled/docker/install-fedora.sh index 7c281fd..13adfa2 100755 --- a/internal/recipes/bundled/docker/install-fedora.sh +++ b/internal/recipes/bundled/docker/install-fedora.sh @@ -13,7 +13,6 @@ dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin systemctl enable docker systemctl start docker -# Wait for daemon i=0 while [ $i -lt 30 ]; do docker info >/dev/null 2>&1 && break diff --git a/internal/recipes/bundled/tailscale/install-debian.sh b/internal/recipes/bundled/tailscale/install-debian.sh index a78461d..e1793f6 100755 --- a/internal/recipes/bundled/tailscale/install-debian.sh +++ b/internal/recipes/bundled/tailscale/install-debian.sh @@ -9,7 +9,6 @@ set -e export DEBIAN_FRONTEND=noninteractive -# Use Tailscale's official install script curl -fsSL https://tailscale.com/install.sh | sh systemctl enable tailscaled diff --git a/internal/recipes/bundled/tailscale/install-fedora.sh b/internal/recipes/bundled/tailscale/install-fedora.sh index 5d3fdd1..e3e1f25 100755 --- a/internal/recipes/bundled/tailscale/install-fedora.sh +++ b/internal/recipes/bundled/tailscale/install-fedora.sh @@ -7,7 +7,6 @@ # you the one command to run yourself. set -e -# Use Tailscale's official install script curl -fsSL https://tailscale.com/install.sh | sh systemctl enable tailscaled diff --git a/internal/recipes/scaffold.go b/internal/recipes/scaffold.go index e2ec0fd..74b05e2 100644 --- a/internal/recipes/scaffold.go +++ b/internal/recipes/scaffold.go @@ -90,13 +90,11 @@ func New(name, osName, _ string) (string, error) { return "", err } - // Write recipe.toml manifest := fmt.Sprintf(manifestTemplate, name, osName) if err := os.WriteFile(filepath.Join(recipeDir, "recipe.toml"), []byte(manifest), 0o644); err != nil { return "", err } - // Write install.sh setup, install := osSetup(osName) script := fmt.Sprintf(shellTemplate, name, osName, setup, install) if err := os.WriteFile(filepath.Join(recipeDir, "install.sh"), []byte(script), 0o755); err != nil { diff --git a/internal/tui/form.go b/internal/tui/form.go index 26163e6..895d95a 100644 --- a/internal/tui/form.go +++ b/internal/tui/form.go @@ -163,8 +163,8 @@ var matchLocalImage = core.MatchLocal // core.CatalogImage carries an ID but not the richer iso.Entry fields // (Variant, SSHUser) the picker's rows and resolvedSSHUser need, so each row // is paired with its iso.Entry by ID here. The error core.Images returns is -// not surfaced: LocalImages, underneath it, already treats an unreadable -// isos/ dir as "no local files" rather than an error. +// discarded: the picker has no error channel, so a failed isos/ read shows an +// empty list, not a message. func buildImages() []imageOption { imgs, _ := core.Images() catalog := map[string]iso.Entry{} diff --git a/internal/tui/list.go b/internal/tui/list.go index a682beb..b8ef47e 100644 --- a/internal/tui/list.go +++ b/internal/tui/list.go @@ -274,6 +274,7 @@ func (m model) viewList() string { ci = m.cloudInit[cur.Name] } box = joinAccess(box, accessBox(cur, ci, m.ciProg, m.width), m.width) + sshAvailable := cur != nil && cur.State == core.StateRunning // column holds every piece to appContentWidth and stacks them left- // aligned. Without it, the box, the status line, and the footer would @@ -291,8 +292,6 @@ func (m model) viewList() string { parts = append(parts, l) } parts = append(parts, warnStyle.Render(m.status)) - v := m.current() - sshAvailable := v != nil && v.State == core.StateRunning parts = append(parts, renderFooter(listHelp{sshAvailable: sshAvailable}, m.width, m.showHelp)) return column(appContentWidth, parts...) } diff --git a/internal/tui/progressbar.go b/internal/tui/progressbar.go index 4dce081..95c56f0 100644 --- a/internal/tui/progressbar.go +++ b/internal/tui/progressbar.go @@ -17,16 +17,20 @@ const ( provLabelMax = 18 ) -// progressLabel renders p as the bar plus a number: "N/M" when the source -// counted packages, "NN%" when it only gave a fraction. bar() already draws -// the track with the theme's colors; this adds the number and, when there -// is room, the package name. -func progressLabel(p Progress, width int) string { - num := fmt.Sprintf("%3.0f%%", p.Frac*100) +// progressNum renders p's count as "N/M" when the source counted packages, +// "NN%" when it only gave a fraction. +func progressNum(p Progress) string { if p.Total > 0 { - num = fmt.Sprintf("%d/%d", p.Done, p.Total) + return fmt.Sprintf("%d/%d", p.Done, p.Total) } - out := bar(p.Frac, width) + " " + dimStyle.Render(num) + return fmt.Sprintf("%3.0f%%", p.Frac*100) +} + +// progressLabel renders p as the bar plus a number. bar() already draws the +// track with the theme's colors; this adds the number and, when there is +// room, the package name. +func progressLabel(p Progress, width int) string { + out := bar(p.Frac, width) + " " + dimStyle.Render(progressNum(p)) if l := p.Label; l != "" { out += dimStyle.Render(" · " + ansi.Truncate(l, provLabelMax, "…")) } @@ -66,11 +70,7 @@ func renderProgressPanel(phase string, p Progress, hasProg bool, elapsed time.Du var f fields f.row("", "phase", dimStyle.Render(phase)) if hasProg { - num := fmt.Sprintf("%3.0f%%", p.Frac*100) - if p.Total > 0 { - num = fmt.Sprintf("%d/%d", p.Done, p.Total) - } - f.row("", "", bar(p.Frac, detailBarWidth)+" "+dimStyle.Render(num)) + f.row("", "", bar(p.Frac, detailBarWidth)+" "+dimStyle.Render(progressNum(p))) if p.Label != "" { f.row("", "package", p.Label) } diff --git a/mcp/tests/test_guards.py b/mcp/tests/test_guards.py index 10e1bd1..a7b97f8 100644 --- a/mcp/tests/test_guards.py +++ b/mcp/tests/test_guards.py @@ -299,8 +299,8 @@ def test_rate_limiter_allows_up_to_capacity(): # A tiny nonzero refill rate, not 0.0: RateLimiter.check divides by # refill_per_second when it needs to report a wait time, so an actual # 0.0 raises ZeroDivisionError instead of GuardRejection once the bucket - # is empty. See the report for this finding; a still-positive rate this - # small is effectively "never refills" within any of these tests. + # is empty. A still-positive rate this small is effectively "never + # refills" within any of these tests. rl = RateLimiter(capacity=3, refill_per_second=1e-9) rl.check("create", now=0.0) rl.check("create", now=0.0)