Skip to content

feat(core): re-apply a recipe when its script changed - #30

Merged
NovusEdge merged 1 commit into
mainfrom
core-recipe-hash
Aug 9, 2026
Merged

feat(core): re-apply a recipe when its script changed#30
NovusEdge merged 1 commit into
mainfrom
core-recipe-hash

Conversation

@NovusEdge

@NovusEdge NovusEdge commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Part A of the auto-provision design (spec: 2026-08-10-auto-provision).

A "once" recipe was skipped when Applied[name].Version matched the manifest version. A recipe author who fixes a script without bumping the version could not reach a VM that already ran the old one — which is exactly why the udev fix never reached already-provisioned VMs.

AppliedRecipe gains a Hash field: the hex SHA-256 of the script the VM's OS runs (recipes.ScriptHash over ScriptBody). filterByRunMode skips a "once" recipe only when the stored hash matches the current script's hash; a changed script re-runs at the same version. always and manual are untouched.

An Applied entry written before Hash existed decodes as empty, which never matches a real hash, so an existing VM re-runs its "once" recipes exactly once and then carries a real hash. That is the self-heal: v4 and v5 pick up the udev recipe fix on their next provision with no manual step.

Tests: filterByRunMode across matching/changed/empty hash plus always/manual; Apply records the hash and a second apply with no change runs nothing; ScriptHash tracks ScriptBody. just check and just test pass.

Summary by CodeRabbit

  • New Features
    • Recipe scripts are now tracked by their content hash.
    • One-time recipes automatically rerun when their script changes.
    • Existing recipes without a recorded hash are safely rerun once to establish tracking.
  • Bug Fixes
    • Prevented unchanged one-time recipes from rerunning unnecessarily.
    • Preserved recipe hash information when applied state is reloaded.

A "once" recipe skipped only on a version match. A fixed script at
the same version never reran on a VM that already applied the old
one. filterByRunMode now compares the script's sha256 hash instead
of the version alone; recipes.ScriptHash resolves and hashes the
same body ScriptBody returns. Apply records the hash alongside the
version after each successful run.

An Applied entry saved before Hash existed decodes with an empty
string, which never matches a real hash, so an existing VM reruns
its "once" recipes once and then tracks hashes from then on.
@NovusEdge NovusEdge added the enhancement New feature label Aug 9, 2026
@NovusEdge NovusEdge self-assigned this Aug 9, 2026
@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Recipe application state now stores a SHA-256 script hash. once recipes rerun when the script changes or when legacy state has no hash. Tests cover hashing, run modes, and persistence.

Changes

Recipe hash tracking

Layer / File(s) Summary
Script hash contract
internal/config/config.go, internal/recipes/recipes.go, internal/recipes/recipes_test.go
AppliedRecipe stores a TOML hash field. ScriptHash returns the SHA-256 digest of the resolved script. Tests cover stable and changed script content.
Applied state persistence
internal/core/vm.go, internal/core/apply.go, internal/core/apply_test.go
Apply records the script hash, and core state preserves it across configuration conversion and reload.
Hash-based run-mode filtering
internal/core/apply.go, internal/core/apply_test.go
once recipes compare stored and current hashes. Tests cover matching, changed, empty, always, and manual modes.

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

Possibly related PRs

Sequence Diagram(s)

sequenceDiagram
  participant Apply
  participant ScriptHash
  participant AppliedRecipe
  participant VM
  Apply->>ScriptHash: compute executed recipe script hash
  ScriptHash-->>Apply: return SHA-256 hash
  Apply->>AppliedRecipe: record version, timestamp, and hash
  Apply->>VM: persist applied recipe state
  VM->>AppliedRecipe: reload stored hash
  Apply->>ScriptHash: compute current script hash
  ScriptHash-->>Apply: return current hash
  Apply-->>AppliedRecipe: skip once recipe when hashes match
Loading

Poem

A rabbit checks the script by moonlight,
Hashes sparkle, neat and bright.
Changed recipes hop once more,
Old blank hashes open the door.
Stable code rests in its burrow deep.

🚥 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: re-applying recipes when their script changes.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch core-recipe-hash

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.

@NovusEdge
NovusEdge merged commit feabfde into main Aug 9, 2026
7 of 8 checks passed
@NovusEdge
NovusEdge deleted the core-recipe-hash branch August 9, 2026 21:12

@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: 2

🤖 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/core/apply_test.go`:
- Around line 399-474: Update TestApplyRecordsHashAndSkipsOnRerun to exercise
Apply directly instead of manually assigning v.Applied and calling
filterByRunMode. Add a provisioning seam or test double that allows Apply to
complete successfully, then assert the first Apply persists the executed script
hash and the second Apply performs no work while preserving the existing recipe
setup.

In `@internal/core/apply.go`:
- Around line 149-153: Update the apply flow around sshx.Provision and
recipes.ScriptHash so each recipe script is resolved once into an immutable
snapshot before provisioning; pass that snapshot to provisioning, then persist
its hash in v.Applied[name] after success. Do not reread the mutable recipe
source afterward, ensuring the recorded hash matches the script that executed.
🪄 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: 76fe8849-ffba-4688-b06d-15e8d682ee35

📥 Commits

Reviewing files that changed from the base of the PR and between 69a4558 and ae63ace.

📒 Files selected for processing (6)
  • internal/config/config.go
  • internal/core/apply.go
  • internal/core/apply_test.go
  • internal/core/vm.go
  • internal/recipes/recipes.go
  • internal/recipes/recipes_test.go

Comment on lines +399 to +474
// TestApplyRecordsHashAndSkipsOnRerun exercises the whole path through
// Apply: a successful run records the script hash, and a second Apply with
// no script change selects nothing to run.
func TestApplyRecordsHashAndSkipsOnRerun(t *testing.T) {
dir := root(t)
writeV2Recipe(t, dir, "tool", "once", "1.0", "#!/bin/sh\necho one\n")

ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
c.Write([]byte("SSH-2.0-fake\r\n"))
c.Close()
}
}()
port := ln.Addr().(*net.TCPAddr).Port

v := &config.VM{
Name: "work", Mode: "live", OS: "alpine", Backend: "apkovl",
RAM: 512, CPUs: 1, SSHPort: port, Recipes: []string{"tool"},
}
if err := v.Save(); err != nil {
t.Fatal(err)
}
v.Dir = dir + "/work"
stop := fakeRunning(t, v)
defer stop()

// Provision needs a real ssh binary and sshd it will never reach; a
// cancelled ctx makes it fail fast right after the ssh banner check,
// before this test needs it to succeed. This test is about the
// pre/post-run bookkeeping in filterByRunMode and Apply's record step,
// exercised directly rather than through a full provisioning run.
hash, err := recipes.ScriptHash("tool", "alpine")
if err != nil {
t.Fatal(err)
}
explicit := map[string]bool{}
kept, manifests, err := filterByRunMode(v, v.Recipes, explicit)
if err != nil {
t.Fatal(err)
}
if len(kept) != 1 {
t.Fatalf("kept = %v, want [tool] on the first pass", kept)
}
m := manifests["tool"]
v.Applied = map[string]config.AppliedRecipe{
"tool": {Version: m.Version, Hash: hash, At: time.Now()},
}
if err := v.Save(); err != nil {
t.Fatal(err)
}

reloaded, err := config.Load("work")
if err != nil {
t.Fatal(err)
}
if reloaded.Applied["tool"].Hash != hash {
t.Fatalf("saved Hash = %q, want %q", reloaded.Applied["tool"].Hash, hash)
}

kept, _, err = filterByRunMode(reloaded, reloaded.Recipes, explicit)
if err != nil {
t.Fatal(err)
}
if len(kept) != 0 {
t.Errorf("kept = %v, want none: second pass, no script change", kept)
}
}

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 | 🏗️ Heavy lift

Test the Apply persistence path.

Lines 439-454 calculate ScriptHash and assign v.Applied directly. Lines 467-473 only call filterByRunMode. This test never calls Apply, so it cannot detect a regression in Apply lines 149-159.

Add a provisioning seam that lets this test complete Apply successfully. Then assert that Apply saves the executed hash and that a second Apply is a no-op.

🤖 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/core/apply_test.go` around lines 399 - 474, Update
TestApplyRecordsHashAndSkipsOnRerun to exercise Apply directly instead of
manually assigning v.Applied and calling filterByRunMode. Add a provisioning
seam or test double that allows Apply to complete successfully, then assert the
first Apply persists the executed script hash and the second Apply performs no
work while preserving the existing recipe setup.

Comment thread internal/core/apply.go
Comment on lines +149 to +153
hash, err := recipes.ScriptHash(name, v.OS)
if err != nil {
return err
}
v.Applied[name] = config.AppliedRecipe{Version: m.Version, Hash: hash, At: time.Now()}

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

Persist the hash of the executed script snapshot.

Line 149 reads the script after sshx.Provision completes. If the recipe changes during provisioning, provisioning can execute script A while this code saves script B's hash. The next once apply then skips script B although it did not run.

Resolve each script into an immutable snapshot before provisioning. Make provisioning execute that snapshot. Persist the same snapshot hash after success.

🤖 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/core/apply.go` around lines 149 - 153, Update the apply flow around
sshx.Provision and recipes.ScriptHash so each recipe script is resolved once
into an immutable snapshot before provisioning; pass that snapshot to
provisioning, then persist its hash in v.Applied[name] after success. Do not
reread the mutable recipe source afterward, ensuring the recorded hash matches
the script that executed.

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