Skip to content

feat(recipes): run a recipe under its declared runtime - #38

Merged
NovusEdge merged 4 commits into
mainfrom
recipe-runtimes
Aug 10, 2026
Merged

feat(recipes): run a recipe under its declared runtime#38
NovusEdge merged 4 commits into
mainfrom
recipe-runtimes

Conversation

@NovusEdge

@NovusEdge NovusEdge commented Aug 10, 2026

Copy link
Copy Markdown
Owner

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 always sh.

Manifest: runtime field (internal/recipes/manifest.go), default sh, validated against {sh, python3}.

Bootstrap (internal/recipes/runtime.go): installCommand maps a guest OS to its package-install prefix (apk --wait 60 add, apt-get install -y, pacman -S --noconfirm, dnf install -y); runtimePackage maps a runtime to its per-OS package name (arch's Python 3 is python). BootstrapScript(runtime, os) returns "" for sh and, for python3, an idempotent command -v python3 || <install> <pkg> snippet reusing the apk --wait discipline. InterpreterArgs maps shsh -s, python3python3 -.

Transport (internal/sshx/sshx.go): Provision resolves each recipe's runtime, runs the bootstrap snippet first when non-empty (logging ensuring python3 is installed...), then pipes the body under the runtime. A bootstrap failure fails the recipe with a clear log line.

Docs: writing-recipes.md and recipe-spec-v2.md gain the runtime field, a Runtime section, and a python example.

Scope note: the TUI recipe label stays unchanged. recipeLabel takes a bare name with no guest OS in scope, so showing · py would 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 python3 on alpine, python on arch); a Provision-level test proving the ssh call ends in python3 - via a fake ssh on PATH. just check and just test pass.

Summary by CodeRabbit

  • New Features
    • Recipes can now specify sh or python3 as their runtime.
    • Python recipes are automatically prepared on supported systems before execution.
    • Existing recipes continue to default to sh.
  • Bug Fixes
    • Unsupported runtime values are rejected with a clear error.
    • Runtime setup failures are reported separately during provisioning.
  • Documentation
    • Added runtime configuration guidance and a minimal Python recipe example.

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.
@NovusEdge NovusEdge added the enhancement New feature label Aug 10, 2026
@NovusEdge NovusEdge self-assigned this Aug 10, 2026
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The change adds runtime selection to recipe manifests. It supports sh and python3, bootstraps Python when needed, and executes recipes through the selected interpreter during SSH provisioning.

Changes

Recipe runtime execution

Layer / File(s) Summary
Manifest runtime contract
internal/recipes/manifest.go, internal/recipes/manifest_test.go, internal/recipes/recipes.go, internal/recipes/recipes_test.go
Manifests accept sh and python3, default omitted runtimes to sh, reject unsupported values, and resolve runtimes for v1 and v2 recipes.
Interpreter preparation
internal/recipes/runtime.go, internal/recipes/runtime_test.go
Runtime helpers provide interpreter arguments and generate OS-specific Python bootstrap scripts.
SSH execution and recipe guidance
internal/sshx/sshx.go, internal/sshx/sshx_test.go, docs/recipe-spec-v2.md, docs/writing-recipes.md
SSH provisioning bootstraps the selected runtime and executes recipes with runtime-specific arguments. Documentation and integration tests cover Python recipes.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

  • NovusEdge/stoat#22: Both changes modify recipe resolution and SSH provisioning for v2 recipes.

Poem

A rabbit hops through scripts so bright,
python3 - now runs just right.
Shell stays first when none is named,
New runtimes get checked and framed.
Provisioning follows the recipe’s plan—
Hop, hop, execute! 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: executing recipes under their declared runtime.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch recipe-runtimes

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between 7b084b2 and f604ae3.

📒 Files selected for processing (10)
  • docs/recipe-spec-v2.md
  • docs/writing-recipes.md
  • internal/recipes/manifest.go
  • internal/recipes/manifest_test.go
  • internal/recipes/recipes.go
  • internal/recipes/recipes_test.go
  • internal/recipes/runtime.go
  • internal/recipes/runtime_test.go
  • internal/sshx/sshx.go
  • internal/sshx/sshx_test.go

Comment on lines +24 to +26
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

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.

Comment on lines +342 to +354
// 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
}

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.

Comment on lines +188 to +195
// 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)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 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.

Suggested change
// 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.

@NovusEdge
NovusEdge merged commit 893b742 into main Aug 10, 2026
7 of 8 checks passed
@NovusEdge
NovusEdge deleted the recipe-runtimes branch August 10, 2026 17:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant