From fe74d4c0d1a2276e06ecc0a1b51d3eefac0e0164 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Mon, 10 Aug 2026 18:02:17 +0300 Subject: [PATCH 1/4] feat(recipes): add a runtime field to the manifest A recipe declares its interpreter as sh or python3 in recipe.toml. ParseManifest defaults an empty runtime to sh and rejects any other value, the same way it validates stage and run. --- internal/recipes/manifest.go | 13 +++++++++++-- internal/recipes/manifest_test.go | 32 +++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/internal/recipes/manifest.go b/internal/recipes/manifest.go index 9823ef9..65916da 100644 --- a/internal/recipes/manifest.go +++ b/internal/recipes/manifest.go @@ -21,8 +21,9 @@ type Manifest struct { Script string `toml:"script"` Scripts map[string]string `toml:"scripts"` // OS-specific overrides Auto bool `toml:"auto"` - Run string `toml:"run"` // "once" | "always" | "manual" - Reboot bool `toml:"reboot"` // guest needs a reboot after this recipe to take effect + Run string `toml:"run"` // "once" | "always" | "manual" + Reboot bool `toml:"reboot"` // guest needs a reboot after this recipe to take effect + Runtime string `toml:"runtime"` // "sh" | "python3", the interpreter the script runs under dir string // recipe directory, set by ParseManifest; scripts resolve against it } @@ -31,6 +32,8 @@ var validStages = map[string]bool{"install": true, "provision": true} var validRuns = map[string]bool{"once": true, "always": true, "manual": true} +var validRuntimes = map[string]bool{"sh": true, "python3": true} + // ParseManifest reads and validates a recipe.toml at path. Defaults are // applied before validation: Stage defaults to "provision" (the common // case, docs/recipe-spec-v2.md's Stages section), Run defaults to "once". @@ -47,6 +50,9 @@ func ParseManifest(path string) (Manifest, error) { if m.Run == "" { m.Run = "once" } + if m.Runtime == "" { + m.Runtime = "sh" + } if m.Name == "" { return Manifest{}, fmt.Errorf("%s: missing required field %q", path, "name") @@ -60,6 +66,9 @@ func ParseManifest(path string) (Manifest, error) { if !validRuns[m.Run] { return Manifest{}, fmt.Errorf("%s: invalid run %q, want %q, %q, or %q", path, m.Run, "once", "always", "manual") } + if !validRuntimes[m.Runtime] { + return Manifest{}, fmt.Errorf("%s: invalid runtime %q, want %q or %q", path, m.Runtime, "sh", "python3") + } return m, nil } diff --git a/internal/recipes/manifest_test.go b/internal/recipes/manifest_test.go index d2b7630..a2acaa0 100644 --- a/internal/recipes/manifest_test.go +++ b/internal/recipes/manifest_test.go @@ -96,6 +96,38 @@ script = "install.sh" if m.Reboot { t.Error("Reboot = true, want default false") } + if m.Runtime != "sh" { + t.Errorf("Runtime = %q, want default sh", m.Runtime) + } +} + +func TestParseManifestRuntimePython3(t *testing.T) { + dir := t.TempDir() + path := writeManifestFile(t, dir, ` +name = "pyrecipe" +script = "install.py" +runtime = "python3" +`) + m, err := ParseManifest(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if m.Runtime != "python3" { + t.Errorf("Runtime = %q, want python3", m.Runtime) + } +} + +func TestParseManifestBadRuntime(t *testing.T) { + dir := t.TempDir() + path := writeManifestFile(t, dir, ` +name = "docker" +script = "install.sh" +runtime = "perl" +`) + _, err := ParseManifest(path) + if err == nil || !strings.Contains(err.Error(), `invalid runtime "perl"`) { + t.Errorf("err = %v, want an invalid-runtime error", err) + } } func TestParseManifestMissingName(t *testing.T) { From cad1d7e60db9b5f9b18141e8a281d5d45a022f58 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Mon, 10 Aug 2026 18:02:24 +0300 Subject: [PATCH 2/4] feat(recipes): resolve a recipe's runtime and its bootstrap script RuntimeFor reads a recipe's manifest runtime, or sh for a v1 flat file with no manifest. BootstrapScript returns the guest shell snippet that installs a non-sh runtime if missing, keyed off the same apk/apt/pacman/dnf capability table the docs already use. Arch's python3 binary ships in the "python" package, not "python3". --- internal/recipes/recipes.go | 14 +++++++ internal/recipes/recipes_test.go | 36 ++++++++++++++++ internal/recipes/runtime.go | 71 ++++++++++++++++++++++++++++++++ internal/recipes/runtime_test.go | 42 +++++++++++++++++++ 4 files changed, 163 insertions(+) create mode 100644 internal/recipes/runtime.go create mode 100644 internal/recipes/runtime_test.go diff --git a/internal/recipes/recipes.go b/internal/recipes/recipes.go index c74662a..7d8fafd 100644 --- a/internal/recipes/recipes.go +++ b/internal/recipes/recipes.go @@ -339,6 +339,20 @@ func ScriptBody(name, osName string) (string, error) { return m.ScriptContent(osName) } +// RuntimeFor returns the interpreter that runs name's script: the manifest's +// Runtime field, or "sh" for a v1 flat file, which has no manifest to +// declare one. +func RuntimeFor(name, osName string) (string, error) { + m, ok, err := ManifestFor(name) + if err != nil { + return "", err + } + if !ok { + return "sh", nil + } + return m.Runtime, nil +} + // ScriptHash returns the hex sha256 of ScriptBody(name, osName). A caller // compares it against a stored AppliedRecipe.Hash to tell whether a "once" // recipe's script changed since it last ran, even at the same manifest diff --git a/internal/recipes/recipes_test.go b/internal/recipes/recipes_test.go index 9ad2479..1a50a85 100644 --- a/internal/recipes/recipes_test.go +++ b/internal/recipes/recipes_test.go @@ -373,6 +373,42 @@ func TestScriptBodyReadsV1FlatFile(t *testing.T) { } } +func TestRuntimeForV1FlatFile(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + if err := os.MkdirAll(dir(), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir(), "legacy.sh"), []byte("echo hi\n"), 0o644); err != nil { + t.Fatal(err) + } + got, err := RuntimeFor("legacy.sh", "alpine") + if err != nil { + t.Fatalf("RuntimeFor: %v", err) + } + if got != "sh" { + t.Errorf("RuntimeFor(legacy.sh) = %q, want sh", got) + } +} + +func TestRuntimeForManifestPython3(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + rd := filepath.Join(dir(), "pyrecipe") + if err := os.MkdirAll(rd, 0o755); err != nil { + t.Fatal(err) + } + toml := "name = \"pyrecipe\"\nscript = \"install.py\"\nruntime = \"python3\"\n" + if err := os.WriteFile(filepath.Join(rd, "recipe.toml"), []byte(toml), 0o644); err != nil { + t.Fatal(err) + } + got, err := RuntimeFor("pyrecipe", "alpine") + if err != nil { + t.Fatalf("RuntimeFor: %v", err) + } + if got != "python3" { + t.Errorf("RuntimeFor(pyrecipe) = %q, want python3", got) + } +} + // TestScriptHashMatchesScriptBody pins ScriptHash to the sha256 of the exact // bytes ScriptBody resolves, so a caller can compare it against a stored // AppliedRecipe.Hash without re-deriving the sum itself. diff --git a/internal/recipes/runtime.go b/internal/recipes/runtime.go new file mode 100644 index 0000000..56990cf --- /dev/null +++ b/internal/recipes/runtime.go @@ -0,0 +1,71 @@ +package recipes + +import "fmt" + +// installCommand maps a guest OS to the shell command that installs a +// package by name, one word per argument the way apk/apt/pacman/dnf all +// accept. BootstrapScript appends the package name itself. +var installCommand = map[string][]string{ + "alpine": {"apk", "--wait", "60", "add"}, + "ubuntu": {"apt-get", "install", "-y"}, + "debian": {"apt-get", "install", "-y"}, + "arch": {"pacman", "-S", "--noconfirm"}, + "fedora": {"dnf", "install", "-y"}, +} + +// runtimePackage maps a non-sh runtime to the package name that provides it +// per guest OS. Arch names its Python 2 successor package "python", not +// "python3"; every other OS here uses "python3". +var runtimePackage = map[string]map[string]string{ + "python3": { + "alpine": "python3", + "ubuntu": "python3", + "debian": "python3", + "fedora": "python3", + "arch": "python", + }, +} + +// interpreterCommand maps a runtime to the guest command that reads a script +// from stdin. "sh -s" and "python3 -" both read a script body piped to +// them, matching how sshx.Provision pipes ScriptBody as the recipe's stdin. +var interpreterCommand = map[string][]string{ + "sh": {"sh", "-s"}, + "python3": {"python3", "-"}, +} + +// InterpreterArgs returns the guest command that runs a recipe body under +// runtime, for use as the ssh command's trailing argv. +func InterpreterArgs(runtime string) []string { + if args, ok := interpreterCommand[runtime]; ok { + return args + } + return []string{runtime} +} + +// BootstrapScript returns a sh snippet that installs runtime on osName if +// missing, or "" when runtime needs no install step ("sh" is always present +// in a POSIX guest). sshx.Provision pipes this to `sh -s` over ssh before +// running a recipe under a non-sh runtime. +// +// The check-then-install shape, and apk's --wait 60, mirror the bundled +// recipes' own idempotent-install pattern (internal/recipes/bundled), so a +// recipe re-applied on a VM that already has the runtime does nothing. +func BootstrapScript(runtime, osName string) string { + if runtime == "sh" { + return "" + } + pkg := runtimePackage[runtime][osName] + if pkg == "" { + return "" + } + install := installCommand[osName] + if install == nil { + return "" + } + cmd := "" + for _, w := range install { + cmd += w + " " + } + return fmt.Sprintf("set -e\nif ! command -v %s >/dev/null 2>&1; then\n%s%s\nfi\n", runtime, cmd, pkg) +} diff --git a/internal/recipes/runtime_test.go b/internal/recipes/runtime_test.go new file mode 100644 index 0000000..c9f92fb --- /dev/null +++ b/internal/recipes/runtime_test.go @@ -0,0 +1,42 @@ +package recipes + +import ( + "strings" + "testing" +) + +func TestBootstrapScriptShIsEmpty(t *testing.T) { + if got := BootstrapScript("sh", "alpine"); got != "" { + t.Errorf("BootstrapScript(sh, alpine) = %q, want empty", got) + } +} + +func TestBootstrapScriptPython3Alpine(t *testing.T) { + got := BootstrapScript("python3", "alpine") + for _, want := range []string{"command -v python3", "apk --wait 60 add python3"} { + if !strings.Contains(got, want) { + t.Errorf("BootstrapScript(python3, alpine) missing %q, got:\n%s", want, got) + } + } +} + +func TestBootstrapScriptPython3Arch(t *testing.T) { + got := BootstrapScript("python3", "arch") + for _, want := range []string{"command -v python3", "pacman -S --noconfirm python"} { + if !strings.Contains(got, want) { + t.Errorf("BootstrapScript(python3, arch) missing %q, got:\n%s", want, got) + } + } + if strings.Contains(got, "add python3") { + t.Errorf("BootstrapScript(python3, arch) installed python3, want the arch package name python") + } +} + +func TestInterpreterArgs(t *testing.T) { + if got := InterpreterArgs("sh"); len(got) != 2 || got[0] != "sh" || got[1] != "-s" { + t.Errorf("InterpreterArgs(sh) = %v, want [sh -s]", got) + } + if got := InterpreterArgs("python3"); len(got) != 2 || got[0] != "python3" || got[1] != "-" { + t.Errorf("InterpreterArgs(python3) = %v, want [python3 -]", got) + } +} From 7ec21fbafd01d0debebf2f77e21b8b9165957472 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Mon, 10 Aug 2026 18:02:30 +0300 Subject: [PATCH 3/4] feat(recipes): run a recipe under its declared runtime Provision resolves each recipe's runtime, installs it over ssh first if the guest lacks it, then pipes the recipe body into that interpreter's stdin invocation instead of always sh -s. --- internal/sshx/sshx.go | 25 +++++++++++++++- internal/sshx/sshx_test.go | 60 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/internal/sshx/sshx.go b/internal/sshx/sshx.go index 7e27203..7094575 100644 --- a/internal/sshx/sshx.go +++ b/internal/sshx/sshx.go @@ -247,9 +247,32 @@ func Provision(ctx context.Context, v *config.VM) (err error) { fmt.Fprintf(log, "FAILED: recipe %s: %v\n", name, err) return err } + runtime, err := recipes.RuntimeFor(name, v.OS) + if err != nil { + fmt.Fprintf(log, "FAILED: recipe %s: %v\n", name, err) + return err + } fmt.Fprintf(log, "\n%s\n", RecipeMarker(name)) - cmd := exec.CommandContext(ctx, "ssh", Args(v, "sh", "-s")...) + if bootstrap := recipes.BootstrapScript(runtime, v.OS); bootstrap != "" { + fmt.Fprintf(log, "ensuring %s is installed...\n", runtime) + bs := exec.CommandContext(ctx, "ssh", Args(v, "sh", "-s")...) + bs.Cancel = func() error { return bs.Process.Signal(syscall.SIGTERM) } + bs.WaitDelay = recipeShutdownGrace + bs.Stdin = strings.NewReader(bootstrap) + bs.Stdout = log + bs.Stderr = log + if err := bs.Run(); err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + fmt.Fprintf(log, "CANCELLED: recipe %s: %v\n", name, ctxErr) + return ctxErr + } + fmt.Fprintf(log, "FAILED: recipe %s: installing %s: %v\n", name, runtime, err) + return fmt.Errorf("recipe %s: installing %s: %w", name, runtime, err) + } + } + + cmd := exec.CommandContext(ctx, "ssh", Args(v, recipes.InterpreterArgs(runtime)...)...) cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGTERM) } cmd.WaitDelay = recipeShutdownGrace cmd.Stdin = strings.NewReader(body) diff --git a/internal/sshx/sshx_test.go b/internal/sshx/sshx_test.go index cb27bbf..3501e87 100644 --- a/internal/sshx/sshx_test.go +++ b/internal/sshx/sshx_test.go @@ -135,6 +135,66 @@ func TestArgsExtraGoesAfterTarget(t *testing.T) { } } +// installArgvRecordingSSH puts a stand-in "ssh" on PATH ahead of the real +// one. It appends its own argv, one invocation per line, to argvFile and +// exits 0 without reading stdin. Multiple recipe/bootstrap ssh calls append +// to the same file, so a test can assert the order and shape of each call +// Provision made. +func installArgvRecordingSSH(t *testing.T, argvFile string) { + t.Helper() + bin := t.TempDir() + script := "#!/bin/sh\necho \"$*\" >> " + shellQuoteForTest(argvFile) + "\ncat >/dev/null\n" + if err := os.WriteFile(filepath.Join(bin, "ssh"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+":"+os.Getenv("PATH")) +} + +// TestProvisionRunsPythonRecipeUnderPython3 pins the transport change: a +// recipe whose manifest declares runtime = "python3" must run under +// `python3 -` over ssh, not `sh -s`, and stoat must bootstrap python3 first. +// Falsified by a Provision that keeps hardcoding "sh", "-s" regardless of +// the recipe's declared runtime. +func TestProvisionRunsPythonRecipeUnderPython3(t *testing.T) { + root := t.TempDir() + t.Setenv("STOAT_HOME", root) + rd := filepath.Join(root, "recipes", "pyrecipe") + if err := os.MkdirAll(rd, 0o755); err != nil { + t.Fatal(err) + } + toml := "name = \"pyrecipe\"\nscript = \"install.py\"\nruntime = \"python3\"\n" + if err := os.WriteFile(filepath.Join(rd, "recipe.toml"), []byte(toml), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(rd, "install.py"), []byte("print('hi')\n"), 0o644); err != nil { + t.Fatal(err) + } + + vmDir := t.TempDir() + argvFile := filepath.Join(vmDir, "ssh.argv") + installArgvRecordingSSH(t, argvFile) + + port := acceptOnly(t, "SSH-2.0-OpenSSH_9.6\r\n") + v := &config.VM{Name: "x", SSHPort: port, Dir: vmDir, OS: "alpine", Recipes: []string{"pyrecipe"}} + + if err := Provision(context.Background(), v); err != nil { + t.Fatalf("Provision: %v", err) + } + + argv, err := os.ReadFile(argvFile) + if err != nil { + t.Fatalf("reading recorded argv: %v", err) + } + // The bootstrap step legitimately runs under sh -s (it installs + // python3), so only the last ssh call, the recipe body itself, is + // checked for the runtime switch. + lines := strings.Split(strings.TrimRight(string(argv), "\n"), "\n") + last := lines[len(lines)-1] + if !strings.Contains(last, "python3 -") { + t.Errorf("recipe body ssh call = %q, want it to end in python3 -", last) + } +} + func containsPair(argv []string, flag, val string) bool { for i := 0; i+1 < len(argv); i++ { if argv[i] == flag && argv[i+1] == val { From f604ae3852aef7924e9d492706ffee537c121244 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Mon, 10 Aug 2026 18:02:35 +0300 Subject: [PATCH 4/4] docs(recipes): document the runtime field and its bootstrap step Adds runtime to both Fields tables, a Runtime section with a python3 example recipe in writing-recipes.md, and an Execution Model note in recipe-spec-v2.md about the changed pipe target and the install step that precedes it. --- docs/recipe-spec-v2.md | 5 ++++- docs/writing-recipes.md | 34 ++++++++++++++++++++++++++++++++-- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/docs/recipe-spec-v2.md b/docs/recipe-spec-v2.md index 23d5078..9922a25 100644 --- a/docs/recipe-spec-v2.md +++ b/docs/recipe-spec-v2.md @@ -39,6 +39,7 @@ requires = ["systemd"] # capabilities: systemd, apt, apk, # Execution stage = "provision" # "install" | "provision" (see Stages below) script = "install.sh" # relative path to the script +runtime = "sh" # "sh" | "python3", interpreter the script runs under. Default: "sh" # Optional: OS-specific script overrides [scripts] @@ -58,6 +59,7 @@ fedora = "install-fedora.sh" | `stage` | string | no | When to run: `install` or `provision`. Default: `provision` | | `script` | string | yes | Default script path, relative to recipe dir | | `scripts` | table | no | Per-OS script overrides | +| `runtime` | string | no | Interpreter to run the script under: `sh` or `python3`. Default: `sh` | ### Capabilities @@ -80,7 +82,8 @@ A recipe declaring `requires = ["systemd"]` is not offered to Alpine. Stoat reso Runs after the VM is booted and reachable over SSH. This is the common case: install packages, configure services, etc. -- **apkovl/ssh backend**: Pushed over SSH via `sh -s`, same as today. +- **apkovl/ssh backend**: Pushed over SSH via the recipe's `runtime` (`sh -s` by default, `python3 -` for `runtime = "python3"`). + A non-`sh` runtime is bootstrapped first: stoat checks the guest for it and installs it with the guest's package manager if missing, over a separate SSH call, before piping the recipe body. - **cloudinit backend**: Wrapped into a cloud-config `runcmd` block at VM creation, runs at first boot. ### `install` diff --git a/docs/writing-recipes.md b/docs/writing-recipes.md index e65b846..77f350d 100644 --- a/docs/writing-recipes.md +++ b/docs/writing-recipes.md @@ -65,6 +65,7 @@ arch = "install-arch.sh" | `scripts` | table | no | | Per-OS script overrides; unlisted OSes fall back to `script` | | `run` | string | no | `"once"` | `"once"`, `"always"`, or `"manual"` | | `auto` | bool | no | `false` | Run automatically the first time the VM becomes reachable | +| `runtime` | string | no | `"sh"` | Interpreter the script runs under: `"sh"` or `"python3"` | `name` and `script` are the only required fields; a manifest missing either fails to parse (`internal/recipes/manifest.go`). @@ -100,11 +101,40 @@ this against `guest.OS` at list/check time, before ssh is even involved. Runs after the VM has booted and is reachable over ssh. This is the common case: install packages, enable services, write config. -- **apkovl/ssh backends**: the script is piped into `sh -s` over ssh, same as - the current bundled recipes. +- **apkovl/ssh backends**: the script is piped into the declared runtime over + ssh, `sh -s` by default. - **cloudinit backend**: the script is wrapped into the cloud-config `runcmd` block at VM creation and runs at first boot. +## Runtime + +`runtime` picks the interpreter a `provision`-stage script runs under. It +defaults to `"sh"`, always present in a POSIX guest. Setting it to +`"python3"` lets a recipe write a real Python script instead of shell. + +Before running a `python3` recipe, stoat checks the guest for `python3` and +installs it with the guest's package manager if missing (`apk`, `apt-get`, +`pacman`, or `dnf`, matching the `requires` capability table above). The +check and install happen once, over ssh, right before the recipe body itself +runs, piped into `python3 -`. + +A minimal Python recipe, `recipe.toml`: + +```toml +name = "hello-py" +description = "prints the guest hostname with a Python script" +os = ["alpine", "ubuntu"] +runtime = "python3" +script = "install.py" +``` + +and `install.py`: + +```python +import socket +print("hello from", socket.gethostname()) +``` + ### `install` Reserved for initial disk setup, before the first real boot. Alpine disk-mode