feat(recipes): run a recipe under its declared runtime - #38
Conversation
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.
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".
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.
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.
WalkthroughThe change adds runtime selection to recipe manifests. It supports ChangesRecipe runtime execution
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches 💡 1🛠️ Fix failing CI checks 💡
📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with 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.
Inline comments:
In `@internal/recipes/manifest.go`:
- Around line 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.
In `@internal/recipes/recipes.go`:
- Around line 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.
In `@internal/sshx/sshx_test.go`:
- Around line 188-195: Update the test around the captured SSH command lines to
assert exactly two SSH calls are emitted, then verify the first call uses `sh
-s` for the bootstrap step. Keep the existing assertion that the final
recipe-body call uses `python3 -`, so the test validates both invocation stages.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: f99d82f5-f24c-4673-b131-46fcad1ae58e
📒 Files selected for processing (10)
docs/recipe-spec-v2.mddocs/writing-recipes.mdinternal/recipes/manifest.gointernal/recipes/manifest_test.gointernal/recipes/recipes.gointernal/recipes/recipes_test.gointernal/recipes/runtime.gointernal/recipes/runtime_test.gointernal/sshx/sshx.gointernal/sshx/sshx_test.go
| 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 |
There was a problem hiding this comment.
🎯 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 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
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.
| // 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 | ||
| } |
There was a problem hiding this comment.
🗄️ 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 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.
| // 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) | ||
| } |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
Assert the bootstrap SSH invocation.
The test checks only the final recipe command. It passes if Provision skips the required bootstrap call. Assert that two SSH calls occur and that the first call uses sh -s.
Proposed test update
lines := strings.Split(strings.TrimRight(string(argv), "\n"), "\n")
+if len(lines) != 2 {
+ t.Fatalf("ssh calls = %q, want bootstrap and recipe calls", lines)
+}
+if !strings.HasSuffix(lines[0], " sh -s") {
+ t.Errorf("bootstrap ssh call = %q, want it to end in sh -s", lines[0])
+}
last := lines[len(lines)-1]
if !strings.Contains(last, "python3 -") {📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // 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) | |
| } | |
| // 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") | |
| if len(lines) != 2 { | |
| t.Fatalf("ssh calls = %q, want bootstrap and recipe calls", lines) | |
| } | |
| if !strings.HasSuffix(lines[0], " sh -s") { | |
| t.Errorf("bootstrap ssh call = %q, want it to end in sh -s", lines[0]) | |
| } | |
| last := lines[len(lines)-1] | |
| if !strings.Contains(last, "python3 -") { | |
| t.Errorf("recipe body ssh call = %q, want it to end in python3 -", last) | |
| } |
🤖 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/sshx/sshx_test.go` around lines 188 - 195, Update the test around
the captured SSH command lines to assert exactly two SSH calls are emitted, then
verify the first call uses `sh -s` for the bootstrap step. Keep the existing
assertion that the final recipe-body call uses `python3 -`, so the test
validates both invocation stages.
Part 3 of the plan, and the last. A recipe declares a
runtime; stoat installs the interpreter in the guest if missing, then runs the recipe under it instead of alwayssh.Manifest:
runtimefield (internal/recipes/manifest.go), defaultsh, validated against{sh, python3}.Bootstrap (
internal/recipes/runtime.go):installCommandmaps a guest OS to its package-install prefix (apk --wait 60 add,apt-get install -y,pacman -S --noconfirm,dnf install -y);runtimePackagemaps a runtime to its per-OS package name (arch's Python 3 ispython).BootstrapScript(runtime, os)returns "" forshand, forpython3, an idempotentcommand -v python3 || <install> <pkg>snippet reusing the apk--waitdiscipline.InterpreterArgsmapssh→sh -s,python3→python3 -.Transport (
internal/sshx/sshx.go):Provisionresolves each recipe's runtime, runs the bootstrap snippet first when non-empty (loggingensuring python3 is installed...), then pipes the body under the runtime. A bootstrap failure fails the recipe with a clear log line.Docs:
writing-recipes.mdandrecipe-spec-v2.mdgain theruntimefield, a Runtime section, and a python example.Scope note: the TUI recipe label stays unchanged.
recipeLabeltakes a bare name with no guest OS in scope, so showing· pywould need a manifest load inside the render loop.Tests: manifest default/accept/reject;
RuntimeFor(v1 flat file → sh, manifest → its runtime);BootstrapScript(empty for sh,apk --wait 60 add python3on alpine,pythonon arch); a Provision-level test proving the ssh call ends inpython3 -via a fake ssh on PATH.just checkandjust testpass.Summary by CodeRabbit
shorpython3as their runtime.sh.