-
Notifications
You must be signed in to change notification settings - Fork 0
feat(recipes): run a recipe under its declared runtime #38
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
fe74d4c
cad1d7e
7ec21fb
f604ae3
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 |
|---|---|---|
|
|
@@ -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
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 | 🟠 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.goRepository: NovusEdge/stoat Length of output: 22325 Include
🤖 Prompt for AI Agents |
||
|
|
||
| // 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 | ||
|
|
||
| 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) | ||
| } |
| 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) | ||
| } | ||
| } |
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.
🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift
Support
python3for 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 throughpython3on 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 documentedpython3behavior 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