feat(engine): add RunOptions.Force to skip Detect and call PlanForce

This commit is contained in:
MDietermann
2026-04-14 21:51:29 +02:00
parent d525646f3a
commit 613363e993
2 changed files with 103 additions and 39 deletions

View File

@@ -14,7 +14,10 @@ type RunOptions struct {
Selected []string // module IDs to run; empty = all Selected []string // module IDs to run; empty = all
DryRun bool // Detect + Plan only, no Apply/Verify DryRun bool // Detect + Plan only, no Apply/Verify
HaltOnError bool // stop at first failure instead of continuing siblings HaltOnError bool // stop at first failure instead of continuing siblings
Reporter module.Reporter // Force, when true, skips Detect and calls PlanForce on every module
// so each one produces a from-scratch plan regardless of current state.
Force bool
Reporter module.Reporter
} }
// ModuleStatus is the terminal state of a module within a run. // ModuleStatus is the terminal state of a module within a run.
@@ -91,26 +94,43 @@ func Run(ctx context.Context, reg *module.Registry, opts RunOptions) (Summary, e
res := &ModuleResult{ID: id} res := &ModuleResult{ID: id}
results[id] = res results[id] = res
state, err := m.Detect(ctx) var (
if err != nil { plan module.Plan
res.Status = StatusFailed err error
res.Err = fmt.Errorf("detect: %w", err) )
markFailed(metas, id, skipped) if opts.Force {
if opts.HaltOnError { plan, err = m.PlanForce(ctx)
break if err != nil {
res.Status = StatusFailed
res.Err = fmt.Errorf("plan-force: %w", err)
markFailed(metas, id, skipped)
if opts.HaltOnError {
break
}
continue
}
} else {
state, derr := m.Detect(ctx)
if derr != nil {
res.Status = StatusFailed
res.Err = fmt.Errorf("detect: %w", derr)
markFailed(metas, id, skipped)
if opts.HaltOnError {
break
}
continue
} }
continue
}
plan, err := m.Plan(ctx, state) plan, err = m.Plan(ctx, state)
if err != nil { if err != nil {
res.Status = StatusFailed res.Status = StatusFailed
res.Err = fmt.Errorf("plan: %w", err) res.Err = fmt.Errorf("plan: %w", err)
markFailed(metas, id, skipped) markFailed(metas, id, skipped)
if opts.HaltOnError { if opts.HaltOnError {
break break
}
continue
} }
continue
} }
res.Plan = plan res.Plan = plan

View File

@@ -10,40 +10,51 @@ import (
// scriptedModule lets tests control what each phase returns. // scriptedModule lets tests control what each phase returns.
type scriptedModule struct { type scriptedModule struct {
id string id string
deps []string deps []string
detect func() (module.State, error) detect func() (module.State, error)
plan func() (module.Plan, error) plan func() (module.Plan, error)
apply func() error planForce func() (module.Plan, error)
verify func() error apply func() error
verify func() error
detectCalls int
planCalls int
planForceCalls int
} }
func (s scriptedModule) Meta() module.Meta { return module.Meta{ID: s.id, Name: s.id, Deps: s.deps} } 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) { func (s *scriptedModule) Detect(ctx context.Context) (module.State, error) {
s.detectCalls++
if s.detect != nil { if s.detect != nil {
return s.detect() return s.detect()
} }
return nil, nil return nil, nil
} }
func (s scriptedModule) Plan(ctx context.Context, _ module.State) (module.Plan, error) { func (s *scriptedModule) Plan(ctx context.Context, _ module.State) (module.Plan, error) {
s.planCalls++
if s.plan != nil { if s.plan != nil {
return s.plan() return s.plan()
} }
return module.Plan{}, nil return module.Plan{}, nil
} }
func (s scriptedModule) PlanForce(ctx context.Context) (module.Plan, error) { func (s *scriptedModule) PlanForce(ctx context.Context) (module.Plan, error) {
s.planForceCalls++
if s.planForce != nil {
return s.planForce()
}
if s.plan != nil { if s.plan != nil {
return s.plan() return s.plan()
} }
return module.Plan{}, nil return module.Plan{}, nil
} }
func (s scriptedModule) Apply(ctx context.Context, _ module.Plan, _ module.Reporter) error { func (s *scriptedModule) Apply(ctx context.Context, _ module.Plan, _ module.Reporter) error {
if s.apply != nil { if s.apply != nil {
return s.apply() return s.apply()
} }
return nil return nil
} }
func (s scriptedModule) Verify(ctx context.Context) error { func (s *scriptedModule) Verify(ctx context.Context) error {
if s.verify != nil { if s.verify != nil {
return s.verify() return s.verify()
} }
@@ -63,8 +74,8 @@ func newReg(t *testing.T, mods ...module.Module) *module.Registry {
func TestRunAllSucceed(t *testing.T) { func TestRunAllSucceed(t *testing.T) {
reg := newReg(t, reg := newReg(t,
scriptedModule{id: "a"}, &scriptedModule{id: "a"},
scriptedModule{id: "b", deps: []string{"a"}}, &scriptedModule{id: "b", deps: []string{"a"}},
) )
sum, err := Run(context.Background(), reg, RunOptions{Reporter: NewBufferedReporter()}) sum, err := Run(context.Background(), reg, RunOptions{Reporter: NewBufferedReporter()})
if err != nil { if err != nil {
@@ -81,7 +92,7 @@ func TestRunAllSucceed(t *testing.T) {
func TestRunDryRunDoesNotApply(t *testing.T) { func TestRunDryRunDoesNotApply(t *testing.T) {
applied := false applied := false
reg := newReg(t, scriptedModule{ reg := newReg(t, &scriptedModule{
id: "a", id: "a",
apply: func() error { applied = true; return nil }, apply: func() error { applied = true; return nil },
}) })
@@ -99,10 +110,10 @@ func TestRunDryRunDoesNotApply(t *testing.T) {
func TestRunFailureSkipsDescendantsContinuesSiblings(t *testing.T) { func TestRunFailureSkipsDescendantsContinuesSiblings(t *testing.T) {
reg := newReg(t, reg := newReg(t,
scriptedModule{id: "root"}, &scriptedModule{id: "root"},
scriptedModule{id: "fails", deps: []string{"root"}, apply: func() error { return errors.New("boom") }}, &scriptedModule{id: "fails", deps: []string{"root"}, apply: func() error { return errors.New("boom") }},
scriptedModule{id: "child", deps: []string{"fails"}}, &scriptedModule{id: "child", deps: []string{"fails"}},
scriptedModule{id: "sibling", deps: []string{"root"}}, &scriptedModule{id: "sibling", deps: []string{"root"}},
) )
sum, err := Run(context.Background(), reg, RunOptions{Reporter: NewBufferedReporter()}) sum, err := Run(context.Background(), reg, RunOptions{Reporter: NewBufferedReporter()})
if err != nil { if err != nil {
@@ -128,8 +139,8 @@ func TestRunFailureSkipsDescendantsContinuesSiblings(t *testing.T) {
func TestRunHaltOnError(t *testing.T) { func TestRunHaltOnError(t *testing.T) {
reg := newReg(t, reg := newReg(t,
scriptedModule{id: "a", apply: func() error { return errors.New("nope") }}, &scriptedModule{id: "a", apply: func() error { return errors.New("nope") }},
scriptedModule{id: "b"}, // independent &scriptedModule{id: "b"}, // independent
) )
sum, err := Run(context.Background(), reg, RunOptions{HaltOnError: true, Reporter: NewBufferedReporter()}) sum, err := Run(context.Background(), reg, RunOptions{HaltOnError: true, Reporter: NewBufferedReporter()})
if err != nil { if err != nil {
@@ -150,3 +161,36 @@ func TestRunUnknownModule(t *testing.T) {
t.Fatal("expected error for unknown module") t.Fatal("expected error for unknown module")
} }
} }
func TestRunForceSkipsDetectAndCallsPlanForce(t *testing.T) {
mod := &scriptedModule{
id: "a",
detect: func() (module.State, error) {
panic("Detect must not be called when Force=true")
},
planForce: func() (module.Plan, error) {
return module.Plan{Actions: []module.Action{{Description: "forced"}}}, nil
},
}
reg := newReg(t, mod)
sum, err := Run(context.Background(), reg, RunOptions{Force: true, Reporter: NewBufferedReporter()})
if err != nil {
t.Fatal(err)
}
if mod.planForceCalls != 1 {
t.Errorf("planForceCalls = %d, want 1", mod.planForceCalls)
}
if mod.detectCalls != 0 {
t.Errorf("detectCalls = %d, want 0", mod.detectCalls)
}
if mod.planCalls != 0 {
t.Errorf("planCalls = %d, want 0", mod.planCalls)
}
ok, fail, skip := sum.Counts()
if ok != 1 || fail != 0 || skip != 0 {
t.Fatalf("counts = (%d,%d,%d)", ok, fail, skip)
}
if len(sum.Results[0].Plan.Actions) != 1 {
t.Fatalf("expected 1 action in plan, got %d", len(sum.Results[0].Plan.Actions))
}
}