-
Notifications
You must be signed in to change notification settings - Fork 0
feat(recipes): multi-distro install for docker, devtools, tailscale #44
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
8fc96c2
23cbd2d
2db7a85
edf3ed1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| # TUI click-through: manual smoke test | ||
|
|
||
| The checks an agent cannot run, because they need a real Alpine boot and a human | ||
| reading the screen. Run these after any change to the list or detail screens. | ||
|
|
||
| Data root is `~/.stoat` (or `$STOAT_HOME`). Each VM is a directory | ||
| `~/.stoat/<dir>/` that holds a `vm.toml`. Build and launch the TUI with `just run` | ||
| or `go run ./cmd/stoat`. | ||
|
|
||
| Keys, for reference. List screen: `enter` start/stop, `l`/`→` details, `s` ssh, | ||
| `p` provision, `d` delete, `n` new, `r` edit recipes, `/` search. Detail screen: | ||
| `e` edit form, `E` raw vm.toml in `$EDITOR`, `i` installed toggle, `s` ssh, `p` | ||
| provision, `L` console log, `S` snapshots, `esc`/`h`/`q` back. | ||
|
|
||
| ## 1. VM start: row flip and uptime tick | ||
|
|
||
| Verifies the running dot and the `time.Since(StartedAt)` uptime that ticks with | ||
| no list refresh. | ||
|
|
||
| 1. On the list, select a stopped VM. The dot is the stopped glyph, the row shows | ||
| `-`. | ||
| 2. Press `enter`. The VM starts. | ||
| 3. Watch the row. The dot flips to the running glyph. The row reads | ||
| `up 1s :PORT`, then `up 2s`, then `up 3s`. The count climbs every second on | ||
| its own. | ||
| 4. Confirm the seconds keep climbing without any keypress. A frozen number means | ||
| the uptime regressed to a stored duration. | ||
| 5. Press `enter` again to stop it. The dot returns to stopped and the uptime | ||
| drops to `-`. | ||
|
|
||
| ## 2. Corrupt vm.toml: shows broken, deletes with d+y | ||
|
|
||
| Verifies a broken VM still lists and deletes, keyed by its directory. | ||
|
|
||
| 1. Stop the TUI. Pick a directory name that sorts mid-list, e.g. `mmm-broken`. | ||
| Create `~/.stoat/mmm-broken/vm.toml` with garbage: `not = valid = toml`. | ||
| 2. Launch the TUI. `mmm-broken` appears in alphabetical position, marked broken. | ||
| 3. Select it and press `l`. The detail screen still opens; `L` serves any | ||
| console log the directory holds. | ||
| 4. Back on the list, press `d`. A `y/N` confirmation appears. | ||
| 5. Press `y`. The row is removed and `~/.stoat/mmm-broken/` is gone. | ||
| 6. Repeat step 4. Press any key other than `y`. The TUI cancels the delete and | ||
| the row stays. | ||
|
|
||
| ## 3. vm.toml name differs from directory | ||
|
|
||
| Verifies every operation keys off the directory, never the `name` field. This | ||
| identity bug recurred before. It must stay fixed. | ||
|
|
||
| 1. Stop the TUI. In a working VM at `~/.stoat/realdir/vm.toml`, change the `name` | ||
| field to `wrongname` (leave the directory `realdir`). | ||
| 2. Launch the TUI. The row shows `realdir`, the directory, not `wrongname`. | ||
| 3. Press `l` for details. Run each key below. Each one must act on this VM, and | ||
| none may report "not found": | ||
| - `E` opens `realdir/vm.toml` in `$EDITOR`. | ||
| - `L` shows this VM's console log. | ||
| - `s` opens an ssh session to it (VM must be running). | ||
| - `p` starts a provision run against it. | ||
| 4. Restore the `name` field afterward if you care about it. | ||
|
|
||
| ## 4. Snapshots modal (S), four states | ||
|
|
||
| Verifies the `S` modal and both confirmation-gated destructive paths. Snapshots | ||
| need a disk or cloud VM, not live. | ||
|
|
||
| 1. On the detail screen of a VM with no snapshots, press `S`. The modal | ||
| opens and shows an empty-state line, not a blank box. | ||
| 2. Press `d`, then `r`. Neither does anything on an empty list. `esc` closes the | ||
| modal. | ||
| 3. Take a snapshot first (`stoat snapshot <vm> <tag>` from a shell), then reopen | ||
| the detail screen and press `S`. The snapshot lists by its tag and date. | ||
| 4. Press `d`. A `delete clean? y/N` line appears. Press a non-`y` key. The TUI | ||
| deletes nothing. Press `d` then `y`. The TUI removes the snapshot and | ||
| refreshes the list. | ||
| 5. Take another snapshot. Press `r`. A `restore clean? y/N` line appears. Confirm | ||
| with `y` and the VM's disk rolls back to that snapshot. | ||
| 6. Run steps 1-5 once with the VM stopped and once running. Both must behave the | ||
| same. A running VM's `info snapshots` prints `--` for the ID. The modal keys | ||
| everything off the tag for that reason. | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -327,7 +327,7 @@ func TestBuildIncludesInstallStageForDiskMode(t *testing.T) { | |||||||||||||
| t.Errorf("stoat-install.start does not redirect to the captured serial port:\n%s", script) | ||||||||||||||
| } | ||||||||||||||
| // The VGA console shows a splash instead of a bare login prompt. | ||||||||||||||
| if !strings.Contains(content["etc/issue"], "installing") { | ||||||||||||||
| if !strings.Contains(content["etc/issue"], "Installing") { | ||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Assert the full banner contract.
Proposed test update- if !strings.Contains(content["etc/issue"], "Installing") {
- t.Errorf("etc/issue missing the install banner: %q", content["etc/issue"])
+ for _, want := range []string{"┌", "Installing Alpine.", "(◕ ᴥ ◕)", "└"} {
+ if !strings.Contains(content["etc/issue"], want) {
+ t.Errorf("etc/issue missing %q from the install banner: %q", want, content["etc/issue"])
+ }
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||
| t.Errorf("etc/issue missing the install banner: %q", content["etc/issue"]) | ||||||||||||||
| } | ||||||||||||||
| if m := hdrs["etc/local.d/stoat-install.start"].Mode; m&0o111 == 0 { | ||||||||||||||
|
|
||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -171,24 +171,23 @@ func TestRecipesListsAndFilters(t *testing.T) { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| t.Fatal("recipes returned nothing with no filter") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // debian only satisfies xfce's OS list (the other bundled recipes all | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // require alpine-only capabilities like apk/openrc), so filtering to it | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // is strictly narrower than the full catalog. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // debian satisfies xfce, devtools, docker, and tailscale OS lists. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| _, only := runJSON(t, "recipes", "--os", "debian", "--backend", "cloudinit") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| debian, _ := dataOf(t, only)["recipes"].([]any) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if len(debian) == 0 { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| t.Fatal("recipes --os debian --backend cloudinit returned nothing") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if len(debian) >= len(every) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| t.Errorf("filtered %d is not narrower than unfiltered %d", len(debian), len(every)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| want := map[string]bool{"xfce": true, "devtools": true, "docker": true, "tailscale": true} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for _, r := range debian { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| m, _ := r.(map[string]any) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| name, _ := m["name"].(string) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if name != "xfce" { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| t.Errorf("debian/cloudinit offered %q, want only xfce", name) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if !want[name] { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| t.Errorf("debian/cloudinit offered unexpected recipe %q", name) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if len(debian) != len(want) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| t.Errorf("debian/cloudinit offered %d recipes, want %d", len(debian), len(want)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+174
to
+190
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Assert set membership in both directions. The current loop uses Proposed assertion fix want := map[string]bool{"xfce": true, "devtools": true, "docker": true, "tailscale": true}
+ seen := make(map[string]bool, len(debian))
for _, r := range debian {
m, _ := r.(map[string]any)
name, _ := m["name"].(string)
if !want[name] {
t.Errorf("debian/cloudinit offered unexpected recipe %q", name)
}
+ seen[name] = true
}
if len(debian) != len(want) {
t.Errorf("debian/cloudinit offered %d recipes, want %d", len(debian), len(want))
}
+ for name := range want {
+ if !seen[name] {
+ t.Errorf("debian/cloudinit is missing recipe %q", name)
+ }
+ }📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| func TestRecipesUnknownOSIsAnEmptyArray(t *testing.T) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -223,8 +222,8 @@ func TestCheckRecipesApplicableAndNot(t *testing.T) { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| t.Errorf("issues = %v, want empty", issues) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // docker requires alpine's apk/openrc; debian cannot run it. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| code, objs = runJSON(t, "check-recipes", "docker", "--os", "debian", "--backend", "cloudinit") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // A non-existent recipe is inapplicable to any OS. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| code, objs = runJSON(t, "check-recipes", "nosuchrecipe", "--os", "debian", "--backend", "cloudinit") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if code != ExitOK { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| t.Fatalf("inapplicable: exit = %d, want 0: checking SUCCEEDED", code) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,18 +9,18 @@ import "github.com/novusedge/stoat/internal/config" | |
| // disk VM with a share set, since sshx.Provision's share-mount step is | ||
| // idempotent but leaves no Applied entry to check. | ||
| // | ||
| // A cloud VM always returns false. cloud-init applies its recipes from the | ||
| // seed at first boot, so an ssh provision run has nothing to do. | ||
| // For cloud VMs, this may return true on first boot (before discoverCloudInitApplied | ||
| // has populated v.Applied). Apply handles that: it reads the markers cloud-init | ||
| // left, populates Applied, and skips recipes already run. A minor no-op pass | ||
| // on first boot is acceptable; it's the price of supporting recipes added | ||
| // after creation. | ||
| func NeedsProvision(v *config.VM) (bool, error) { | ||
| if v.Mode == "cloud" { | ||
| return false, nil | ||
| } | ||
| runTargets, _, err := filterByRunMode(v, v.Recipes, nil) | ||
| if err != nil { | ||
| return false, err | ||
| } | ||
| if len(runTargets) > 0 { | ||
| return true, nil | ||
| } | ||
| return v.Mode == "disk" && v.Share != "", nil | ||
| return (v.Mode == "disk" || v.Mode == "cloud") && v.Share != "", nil | ||
|
Comment on lines
+12
to
+25
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
# Expected: discoverCloudInitApplied runs before recipe filtering or execution.
rg -n -C 12 'func Apply|discoverCloudInitApplied|filterByRunMode|runTargets' internal/coreRepository: NovusEdge/stoat Length of output: 38135 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- applyLocked and discovery call order ---'
sed -n '120,190p' internal/core/apply.go
printf '%s\n' '--- discovery implementation ---'
sed -n '296,340p' internal/core/apply.go
printf '%s\n' '--- marker-related tests and definitions ---'
rg -n -C 8 'discoverCloudInitApplied|MarkerDir|cloudinit|marker' internal/core internal/cloudinit internal 2>/dev/null | head -n 240Repository: NovusEdge/stoat Length of output: 19966 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- cloud-init marker definition and seed generation ---'
rg -n -C 10 'MarkerDir|marker' internal/cloudinit internal/backend internal/core | head -n 240
printf '%s\n' '--- existing cloud-init Apply tests and test helpers ---'
sed -n '1,125p' internal/core/apply_test.go
rg -n -C 12 'fakeSSH|fakeSSHD|installFakeSSH|exec.Command|Backend: "cloudinit"|MarkerDir' internal/core/*_test.go internal/*/*_test.go 2>/dev/null | head -n 300
printf '%s\n' '--- static call-order and coverage verifier ---'
python3 - <<'PY'
from pathlib import Path
apply = Path("internal/core/apply.go").read_text()
start = apply.index("func applyLocked")
end = apply.index("\n}\n\n// appendProvisionLog", start)
body = apply[start:end]
discover = body.index("discoverCloudInitApplied")
filtering = body.index("filterByRunMode")
provision = body.index("sshx.Provision")
print({
"discovery_before_filter": discover < filtering,
"filter_before_provision": filtering < provision,
"discovery_before_provision": discover < provision,
"cloudinit_marker_test_present": "MarkerDir" in Path("internal/core/apply_test.go").read_text(),
})
if not (discover < filtering < provision):
raise SystemExit("Apply ordering invariant failed")
PYRepository: NovusEdge/stoat Length of output: 40720 🏁 Script executed: #!/bin/bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
lines = Path("internal/core/apply.go").read_text().splitlines()
start = next(i for i, line in enumerate(lines) if line.startswith("func applyLocked"))
end = next(i for i in range(start + 1, len(lines)) if lines[i].startswith("// discoverCloudInitApplied"))
body = lines[start:end]
positions = {}
for name in ("discoverCloudInitApplied", "filterByRunMode", "sshx.Provision"):
positions[name] = next(i for i, line in enumerate(body) if name in line)
tests = Path("internal/core/apply_test.go").read_text()
result = {
"discovery_before_filter": positions["discoverCloudInitApplied"] < positions["filterByRunMode"],
"filter_before_provision": positions["filterByRunMode"] < positions["sshx.Provision"],
"discovery_before_provision": positions["discoverCloudInitApplied"] < positions["sshx.Provision"],
"apply_test_references_marker_dir": "MarkerDir" in tests,
"apply_test_references_discovery": "discoverCloudInitApplied" in tests,
}
print(result)
if not all(result[key] for key in (
"discovery_before_filter",
"filter_before_provision",
"discovery_before_provision",
)):
raise SystemExit("Apply ordering invariant failed")
PYRepository: NovusEdge/stoat Length of output: 374 🏁 Script executed: #!/bin/bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
lines = Path("internal/core/apply.go").read_text().splitlines()
start = next(i for i, line in enumerate(lines) if line.startswith("func applyLocked"))
end = next(i for i in range(start + 1, len(lines)) if lines[i].startswith("// discoverCloudInitApplied"))
body = lines[start:end]
def line_index(fragment):
return next(i for i, line in enumerate(body) if fragment in line)
positions = {
"discovery_call": line_index("if err := discoverCloudInitApplied(ctx, v); err != nil"),
"filter_call": line_index("runTargets, manifests, err := filterByRunMode(v, targets, explicit)"),
"provision_call": line_index("if err := sshx.Provision(ctx, &run); err != nil"),
}
tests = Path("internal/core/apply_test.go").read_text()
result = {
"discovery_before_filter": positions["discovery_call"] < positions["filter_call"],
"filter_before_provision": positions["filter_call"] < positions["provision_call"],
"discovery_before_provision": positions["discovery_call"] < positions["provision_call"],
"apply_test_references_marker_dir": "MarkerDir" in tests,
"apply_test_references_discovery": "discoverCloudInitApplied" in tests,
}
print(result)
if not all(result[key] for key in (
"discovery_before_filter",
"filter_before_provision",
"discovery_before_provision",
)):
raise SystemExit("Apply ordering invariant failed")
PYRepository: NovusEdge/stoat Length of output: 341 Add a cloud-init marker integration test. 🤖 Prompt for AI Agents |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -89,17 +89,42 @@ func TestNeedsProvisionDiskWithShareEvenWhenApplied(t *testing.T) { | |
| } | ||
| } | ||
|
|
||
| // TestNeedsProvisionCloud: cloud-init applies a cloud VM's recipes at first | ||
| // boot, so there is never anything left for an ssh-based provision run. | ||
| func TestNeedsProvisionCloud(t *testing.T) { | ||
| root(t) | ||
| v := &config.VM{Mode: "cloud", OS: "debian", Recipes: []string{"xfce"}} | ||
| // TestNeedsProvisionCloudUnapplied: a cloud VM with recipes not yet in Applied | ||
| // returns true. On first boot this triggers a no-op pass (discoverCloudInitApplied | ||
| // finds the markers); for recipes added after creation it runs them. | ||
| func TestNeedsProvisionCloudUnapplied(t *testing.T) { | ||
| dir := root(t) | ||
| writeV2Recipe(t, dir, "tool", "once", "1.0", "#!/bin/sh\necho one\n") | ||
| v := &config.VM{Mode: "cloud", OS: "alpine", Recipes: []string{"tool"}} | ||
|
|
||
| got, err := NeedsProvision(v) | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| if !got { | ||
| t.Error("got false, want true: the recipe has never run (or markers not yet discovered)") | ||
| } | ||
| } | ||
|
|
||
| // TestNeedsProvisionCloudAllApplied: a cloud VM with all recipes already in | ||
| // Applied (markers discovered, or added and run) returns false. | ||
| func TestNeedsProvisionCloudAllApplied(t *testing.T) { | ||
| dir := root(t) | ||
| writeV2Recipe(t, dir, "tool", "once", "1.0", "#!/bin/sh\necho one\n") | ||
| hash, err := recipes.ScriptHash("tool", "alpine") | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| v := &config.VM{ | ||
| Mode: "cloud", OS: "alpine", Recipes: []string{"tool"}, | ||
| Applied: map[string]config.AppliedRecipe{"tool": {Version: "1.0", Hash: hash}}, | ||
| } | ||
|
|
||
| got, err := NeedsProvision(v) | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| if got { | ||
| t.Error("got true, want false: a cloud VM provisions through cloud-init") | ||
| t.Error("got true, want false: all recipes already applied") | ||
|
Comment on lines
+109
to
+128
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win Add coverage for a changed cloud recipe. This test proves only that an exact version and script hash suppress provisioning. Add a case with a stale 🤖 Prompt for AI Agents |
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| #!/bin/sh | ||
| # The baseline you end up installing on every throwaway VM: version control, a | ||
| # compiler, an editor, and the tools to fetch things. Runs as root over ssh on | ||
| # a booted Alpine VM. | ||
| set -e | ||
|
|
||
| # -c enables community (where most of these live outside the base set); -1 | ||
| # picks a mirror and refreshes indexes, so no separate `apk update`. | ||
| # setup-apkrepos runs apk update with no lock-wait, so another apk that holds | ||
| # the database lock fails it with exit 99. Retry until the lock frees, up to | ||
| # ~60s. | ||
| n=0 | ||
| until setup-apkrepos -c -1; do | ||
| n=$((n + 1)) | ||
| [ "$n" -ge 30 ] && { echo "apk database stayed locked; giving up" >&2; exit 1; } | ||
| sleep 2 | ||
| done | ||
|
|
||
| # build-base is Alpine's meta-package for gcc/make/libc-dev: the equivalent of | ||
| # Debian's build-essential. Alpine has no package called "build-essential". | ||
| # --wait 60 makes apk wait up to 60s for the lock instead of failing with | ||
| # exit 99 when another apk run holds it. | ||
| apk --wait 60 add git curl ca-certificates build-base vim tmux less | ||
|
|
||
| # Alpine's default shell is ash and there is no bash unless asked for; scripts | ||
| # copied in from elsewhere routinely assume it, so it is part of a baseline. | ||
| apk --wait 60 add bash | ||
|
|
||
| git --version | ||
| echo "devtools installed: git, curl, build-base (gcc/make), vim, tmux, less, bash" | ||
|
|
||
| # Live VMs are diskless: the root filesystem is a tmpfs/overlay in RAM, so | ||
| # every package installed above is gone on reboot. A disk install mounts a | ||
| # real block device as root, which persists. Detecting it from inside the | ||
| # guest (rather than assuming) is the same mechanism xfce.alpine.sh uses. | ||
| root_fstype=$(awk '$2 == "/" { print $3 }' /proc/mounts) | ||
|
|
||
| case "$root_fstype" in | ||
| tmpfs | overlay) | ||
| echo "NOTE: this is a live VM (root is $root_fstype, in RAM). Everything installed above is gone after a reboot. Rebooting will NOT bring it back. Use a disk VM to keep it." | ||
| ;; | ||
| *) | ||
| echo "installed on a disk VM (root is $root_fstype), so this survives a reboot." | ||
| ;; | ||
| esac |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| #!/bin/sh | ||
| # git, a compiler, an editor and basic fetch tools. Runs as root over ssh on a | ||
| # booted Arch VM. | ||
| set -e | ||
|
|
||
| pacman -Sy --noconfirm git curl ca-certificates base-devel vim tmux less bash | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift 🧩 Analysis chain🌐 Web query:
💡 Result: According to Arch Linux documentation, running commands that refresh the package database without also performing a full system upgrade can lead to unsupported partial upgrades [1][2]. Specifically, the Arch Wiki warns against running Citations: 🏁 Script executed: #!/bin/bash
set -eu
for f in \
internal/recipes/bundled/devtools/install-arch.sh \
internal/recipes/bundled/tailscale/install-arch.sh
do
echo "=== $f ==="
cat -n "$f"
done
echo "=== related Arch package commands ==="
rg -n --glob '*.sh' 'pacman\s+-[^[:space:]]*y|pacman\s+.*--sync|pacman\s+-S' internal/recipes/bundledRepository: NovusEdge/stoat Length of output: 1928 🏁 Script executed: #!/bin/bash
set -eu
python3 - <<'PY'
import pathlib
import re
for path in sorted(pathlib.Path("internal/recipes/bundled").glob("*/install-arch.sh")):
text = path.read_text()
commands = [
line.strip()
for line in text.splitlines()
if re.search(r"\bpacman\b", line) and not line.lstrip().startswith("#")
]
for command in commands:
flags = re.search(r"\bpacman\s+(-\S+)", command)
print(f"{path}: {command}")
print(f" has_sync={bool(flags and 'S' in flags.group(1))} "
f"has_sysupgrade={bool(flags and 'u' in flags.group(1))} "
f"has_database_refresh={bool(flags and 'y' in flags.group(1))}")
PYRepository: NovusEdge/stoat Length of output: 820 Use
📍 Affects 2 files
🤖 Prompt for AI Agents |
||
|
|
||
| git --version | ||
| echo "devtools installed: git, curl, base-devel (gcc/make), vim, tmux, less, bash" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| #!/bin/sh | ||
| # git, a compiler, an editor and basic fetch tools. Runs as root over ssh on a | ||
| # booted Ubuntu or Debian VM. | ||
| set -e | ||
|
|
||
| export DEBIAN_FRONTEND=noninteractive | ||
| apt-get update | ||
| apt-get install -y git curl ca-certificates build-essential vim tmux less bash | ||
|
|
||
| git --version | ||
| echo "devtools installed: git, curl, build-essential (gcc/make), vim, tmux, less, bash" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| #!/bin/sh | ||
| # git, a compiler, an editor and basic fetch tools. Runs as root over ssh on a | ||
| # booted Fedora VM. | ||
| set -e | ||
|
|
||
| dnf install -y git curl ca-certificates gcc make vim tmux less bash | ||
|
|
||
| git --version | ||
| echo "devtools installed: git, curl, gcc, make, vim, tmux, less, bash" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Create the broken VM directory before writing vm.toml.
The procedure writes
~/.stoat/mmm-broken/vm.tomlbut never createsmmm-broken. Add amkdir -pstep so the manual test works from a clean data root.Proposed documentation fix
📝 Committable suggestion
🤖 Prompt for AI Agents