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 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) { 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) + } +} 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 {