feat(engine): run modules through Detect/Plan/Apply/Verify with DAG + skip-on-failure
This commit is contained in:
189
internal/engine/engine.go
Normal file
189
internal/engine/engine.go
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RunOptions controls a single engine run.
|
||||||
|
type RunOptions struct {
|
||||||
|
Selected []string // module IDs to run; empty = all
|
||||||
|
DryRun bool // Detect + Plan only, no Apply/Verify
|
||||||
|
HaltOnError bool // stop at first failure instead of continuing siblings
|
||||||
|
Reporter module.Reporter
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModuleStatus is the terminal state of a module within a run.
|
||||||
|
type ModuleStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusSucceeded ModuleStatus = "succeeded"
|
||||||
|
StatusFailed ModuleStatus = "failed"
|
||||||
|
StatusSkipped ModuleStatus = "skipped"
|
||||||
|
StatusPlanned ModuleStatus = "planned" // dry-run only
|
||||||
|
)
|
||||||
|
|
||||||
|
// ModuleResult captures what happened for one module.
|
||||||
|
type ModuleResult struct {
|
||||||
|
ID string
|
||||||
|
Status ModuleStatus
|
||||||
|
Plan module.Plan
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary aggregates results.
|
||||||
|
type Summary struct {
|
||||||
|
Results []ModuleResult
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counts returns (succeeded, failed, skipped).
|
||||||
|
func (s Summary) Counts() (int, int, int) {
|
||||||
|
var ok, fail, skip int
|
||||||
|
for _, r := range s.Results {
|
||||||
|
switch r.Status {
|
||||||
|
case StatusSucceeded, StatusPlanned:
|
||||||
|
ok++
|
||||||
|
case StatusFailed:
|
||||||
|
fail++
|
||||||
|
case StatusSkipped:
|
||||||
|
skip++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ok, fail, skip
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run drives the selected modules through the four phases in DAG order.
|
||||||
|
// Parallelism is intentionally omitted in this foundation; a later plan
|
||||||
|
// will introduce it. Sequential execution is a correct baseline.
|
||||||
|
func Run(ctx context.Context, reg *module.Registry, opts RunOptions) (Summary, error) {
|
||||||
|
mods, err := resolveSelection(reg, opts.Selected)
|
||||||
|
if err != nil {
|
||||||
|
return Summary{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
metas := make([]module.Meta, 0, len(mods))
|
||||||
|
for _, m := range mods {
|
||||||
|
metas = append(metas, m.Meta())
|
||||||
|
}
|
||||||
|
order, err := TopoSort(metas)
|
||||||
|
if err != nil {
|
||||||
|
return Summary{}, fmt.Errorf("module graph: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
byID := make(map[string]module.Module, len(mods))
|
||||||
|
for _, m := range mods {
|
||||||
|
byID[m.Meta().ID] = m
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make(map[string]*ModuleResult, len(mods))
|
||||||
|
failed := make(map[string]bool)
|
||||||
|
skipped := make(map[string]bool)
|
||||||
|
|
||||||
|
for _, id := range order {
|
||||||
|
if skipped[id] {
|
||||||
|
results[id] = &ModuleResult{ID: id, Status: StatusSkipped, Err: errors.New("skipped: upstream failed")}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m := byID[id]
|
||||||
|
res := &ModuleResult{ID: id}
|
||||||
|
results[id] = res
|
||||||
|
|
||||||
|
state, err := m.Detect(ctx)
|
||||||
|
if err != nil {
|
||||||
|
res.Status = StatusFailed
|
||||||
|
res.Err = fmt.Errorf("detect: %w", err)
|
||||||
|
markFailed(metas, id, failed, skipped)
|
||||||
|
if opts.HaltOnError {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
plan, err := m.Plan(ctx, state)
|
||||||
|
if err != nil {
|
||||||
|
res.Status = StatusFailed
|
||||||
|
res.Err = fmt.Errorf("plan: %w", err)
|
||||||
|
markFailed(metas, id, failed, skipped)
|
||||||
|
if opts.HaltOnError {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
res.Plan = plan
|
||||||
|
|
||||||
|
if opts.DryRun {
|
||||||
|
res.Status = StatusPlanned
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.Apply(ctx, plan, opts.Reporter); err != nil {
|
||||||
|
res.Status = StatusFailed
|
||||||
|
res.Err = fmt.Errorf("apply: %w", err)
|
||||||
|
markFailed(metas, id, failed, skipped)
|
||||||
|
if opts.HaltOnError {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := m.Verify(ctx); err != nil {
|
||||||
|
res.Status = StatusFailed
|
||||||
|
res.Err = fmt.Errorf("verify: %w", err)
|
||||||
|
markFailed(metas, id, failed, skipped)
|
||||||
|
if opts.HaltOnError {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
res.Status = StatusSucceeded
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := Summary{}
|
||||||
|
// Emit results in topo order for stable output.
|
||||||
|
ids := make([]string, 0, len(results))
|
||||||
|
for id := range results {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
sort.SliceStable(ids, func(i, j int) bool {
|
||||||
|
return indexOf(order, ids[i]) < indexOf(order, ids[j])
|
||||||
|
})
|
||||||
|
for _, id := range ids {
|
||||||
|
summary.Results = append(summary.Results, *results[id])
|
||||||
|
}
|
||||||
|
return summary, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveSelection(reg *module.Registry, selected []string) ([]module.Module, error) {
|
||||||
|
all := reg.All()
|
||||||
|
if len(selected) == 0 {
|
||||||
|
return all, nil
|
||||||
|
}
|
||||||
|
out := make([]module.Module, 0, len(selected))
|
||||||
|
for _, id := range selected {
|
||||||
|
m, ok := reg.Get(id)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unknown module %q", id)
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func markFailed(metas []module.Meta, id string, failed, skipped map[string]bool) {
|
||||||
|
failed[id] = true
|
||||||
|
for d := range Descendants(metas, id) {
|
||||||
|
skipped[d] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexOf(s []string, v string) int {
|
||||||
|
for i, x := range s {
|
||||||
|
if x == v {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
146
internal/engine/engine_test.go
Normal file
146
internal/engine/engine_test.go
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
)
|
||||||
|
|
||||||
|
// scriptedModule lets tests control what each phase returns.
|
||||||
|
type scriptedModule struct {
|
||||||
|
id string
|
||||||
|
deps []string
|
||||||
|
detect func() (module.State, error)
|
||||||
|
plan func() (module.Plan, error)
|
||||||
|
apply func() error
|
||||||
|
verify func() error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s scriptedModule) Meta() module.Meta { return module.Meta{ID: s.id, Name: s.id, Deps: s.deps} }
|
||||||
|
func (s scriptedModule) Detect(ctx context.Context) (module.State, error) {
|
||||||
|
if s.detect != nil {
|
||||||
|
return s.detect()
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (s scriptedModule) Plan(ctx context.Context, _ module.State) (module.Plan, error) {
|
||||||
|
if s.plan != nil {
|
||||||
|
return s.plan()
|
||||||
|
}
|
||||||
|
return module.Plan{}, nil
|
||||||
|
}
|
||||||
|
func (s scriptedModule) Apply(ctx context.Context, _ module.Plan, _ module.Reporter) error {
|
||||||
|
if s.apply != nil {
|
||||||
|
return s.apply()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (s scriptedModule) Verify(ctx context.Context) error {
|
||||||
|
if s.verify != nil {
|
||||||
|
return s.verify()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newReg(t *testing.T, mods ...module.Module) *module.Registry {
|
||||||
|
t.Helper()
|
||||||
|
r := module.NewRegistry()
|
||||||
|
for _, m := range mods {
|
||||||
|
if err := r.Register(m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunAllSucceed(t *testing.T) {
|
||||||
|
reg := newReg(t,
|
||||||
|
scriptedModule{id: "a"},
|
||||||
|
scriptedModule{id: "b", deps: []string{"a"}},
|
||||||
|
)
|
||||||
|
sum, err := Run(context.Background(), reg, RunOptions{Reporter: NewBufferedReporter()})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ok, fail, skip := sum.Counts()
|
||||||
|
if ok != 2 || fail != 0 || skip != 0 {
|
||||||
|
t.Fatalf("counts = (%d,%d,%d)", ok, fail, skip)
|
||||||
|
}
|
||||||
|
if sum.Results[0].ID != "a" || sum.Results[1].ID != "b" {
|
||||||
|
t.Fatalf("order = %v", sum.Results)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunDryRunDoesNotApply(t *testing.T) {
|
||||||
|
applied := false
|
||||||
|
reg := newReg(t, scriptedModule{
|
||||||
|
id: "a",
|
||||||
|
apply: func() error { applied = true; return nil },
|
||||||
|
})
|
||||||
|
sum, err := Run(context.Background(), reg, RunOptions{DryRun: true, Reporter: NewBufferedReporter()})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if applied {
|
||||||
|
t.Fatal("Apply was called during dry-run")
|
||||||
|
}
|
||||||
|
if sum.Results[0].Status != StatusPlanned {
|
||||||
|
t.Fatalf("status = %v", sum.Results[0].Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunFailureSkipsDescendantsContinuesSiblings(t *testing.T) {
|
||||||
|
reg := newReg(t,
|
||||||
|
scriptedModule{id: "root"},
|
||||||
|
scriptedModule{id: "fails", deps: []string{"root"}, apply: func() error { return errors.New("boom") }},
|
||||||
|
scriptedModule{id: "child", deps: []string{"fails"}},
|
||||||
|
scriptedModule{id: "sibling", deps: []string{"root"}},
|
||||||
|
)
|
||||||
|
sum, err := Run(context.Background(), reg, RunOptions{Reporter: NewBufferedReporter()})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
status := map[string]ModuleStatus{}
|
||||||
|
for _, r := range sum.Results {
|
||||||
|
status[r.ID] = r.Status
|
||||||
|
}
|
||||||
|
if status["root"] != StatusSucceeded {
|
||||||
|
t.Errorf("root = %v", status["root"])
|
||||||
|
}
|
||||||
|
if status["fails"] != StatusFailed {
|
||||||
|
t.Errorf("fails = %v", status["fails"])
|
||||||
|
}
|
||||||
|
if status["child"] != StatusSkipped {
|
||||||
|
t.Errorf("child = %v", status["child"])
|
||||||
|
}
|
||||||
|
if status["sibling"] != StatusSucceeded {
|
||||||
|
t.Errorf("sibling = %v (should have run)", status["sibling"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunHaltOnError(t *testing.T) {
|
||||||
|
reg := newReg(t,
|
||||||
|
scriptedModule{id: "a", apply: func() error { return errors.New("nope") }},
|
||||||
|
scriptedModule{id: "b"}, // independent
|
||||||
|
)
|
||||||
|
sum, err := Run(context.Background(), reg, RunOptions{HaltOnError: true, Reporter: NewBufferedReporter()})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// "b" should not appear in results because we halted before reaching it.
|
||||||
|
for _, r := range sum.Results {
|
||||||
|
if r.ID == "b" {
|
||||||
|
t.Fatalf("b was run despite HaltOnError")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunUnknownModule(t *testing.T) {
|
||||||
|
reg := module.NewRegistry()
|
||||||
|
_, err := Run(context.Background(), reg, RunOptions{Selected: []string{"ghost"}})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for unknown module")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user