Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/recipe-spec-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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

Expand All @@ -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`
Expand Down
34 changes: 32 additions & 2 deletions docs/writing-recipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down Expand Up @@ -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
Expand Down
13 changes: 11 additions & 2 deletions internal/recipes/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +24 to +26

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Support python3 for cloudinit recipes or restrict the runtime contract.

runtime = "python3" is valid for every v2 manifest, but cloudinit recipes are documented as direct script-path executions. A Python recipe without a shebang, including the documented example, will not be bootstrapped or executed through python3 on cloudinit guests.

  • internal/recipes/manifest.go#L24-L26: make the runtime contract available to every execution backend, or reject unsupported runtime/backend combinations before apply.
  • docs/writing-recipes.md#L111-L119: limit the documented python3 behavior to apkovl/SSH until cloudinit renders runtime-aware commands and installs the interpreter.
📍 Affects 2 files
  • internal/recipes/manifest.go#L24-L26 (this comment)
  • docs/writing-recipes.md#L111-L119
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/recipes/manifest.go` around lines 24 - 26, Restrict the Runtime
contract so cloudinit recipes cannot use "python3": validate and reject that
runtime/backend combination before apply, while retaining "sh" support. Update
docs/writing-recipes.md lines 111-119 to state that python3 is supported only
for apkovl/SSH until cloudinit gains runtime-aware commands and interpreter
installation.


dir string // recipe directory, set by ParseManifest; scripts resolve against it
}
Expand All @@ -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".
Expand All @@ -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")
Expand All @@ -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
}
Expand Down
32 changes: 32 additions & 0 deletions internal/recipes/manifest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
14 changes: 14 additions & 0 deletions internal/recipes/recipes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Comment on lines +342 to +354

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline internal/recipes/recipes.go --items all

rg -n -C 6 '\bScriptHash\s*\(' internal -g '*.go'
rg -n -C 8 '\bAppliedRecipe\b|\.Applied\b|Applied\s*:' internal -g '*.go'

Repository: NovusEdge/stoat

Length of output: 48056


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== recipes.go relevant sections =="
sed -n '84,198p' internal/recipes/recipes.go
sed -n '320,370p' internal/recipes/recipes.go

echo "== manifests and runtime usage =="
rg -n -C 5 '\bRuntime\b|runtime|ScriptHash|RuntimeFor|applyRecipe|apply' internal -g '*.go'

echo "== tests mentioning runtime =="
rg -n -C 4 'runtime|RuntimeFor|runtime' internal/recipes internal/core internal/config internal/tui -g '*.go'

Repository: NovusEdge/stoat

Length of output: 50372


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== core apply apply.go relevant section =="
sed -n '148,300p' internal/core/apply.go

echo "== sshx provision sections around recipe execution =="
rg -n -C 8 'BootstrapScript|InterpreterArgs|RuntimeFor|Provision' internal/sshx/sshx.go internal/recipes/runtime.go

echo "== all RuntimeFor usages =="
rg -n -C 3 '\bRuntimeFor\b' internal -g '*.go'

echo "== Manifest struct fields =="
sed -n '1,85p' internal/recipes/manifest.go

Repository: NovusEdge/stoat

Length of output: 22325


Include runtime in applied-state identity.

filterByRunMode skips once recipes when Applied.Hash matches recipes.ScriptHash, and recipes.ScriptHash only includes ScriptBody. runtime changes the interpreter and bootstrapping in sshx.Provision, but the record still only has Version and Hash; a changing runtime in recipe.toml can leave a once recipe skipped. Add runtime to the applied entry/hash contract, or enforce that runtime changes always bump the recipe version.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/recipes/recipes.go` around lines 342 - 354, Update the applied-state
identity used by filterByRunMode and recipes.ScriptHash to include the resolved
runtime from RuntimeFor alongside ScriptBody, and persist/compare that runtime
in the applied record used by sshx.Provision. Ensure a runtime change
invalidates an existing once-recipe match without requiring a version bump.


// 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
Expand Down
36 changes: 36 additions & 0 deletions internal/recipes/recipes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
71 changes: 71 additions & 0 deletions internal/recipes/runtime.go
Original file line number Diff line number Diff line change
@@ -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)
}
42 changes: 42 additions & 0 deletions internal/recipes/runtime_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
25 changes: 24 additions & 1 deletion internal/sshx/sshx.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading