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,6 +14,9 @@ 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
// 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 Reporter module.Reporter
} }
@@ -91,10 +94,26 @@ 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 (
plan module.Plan
err error
)
if opts.Force {
plan, err = m.PlanForce(ctx)
if err != nil { if err != nil {
res.Status = StatusFailed res.Status = StatusFailed
res.Err = fmt.Errorf("detect: %w", err) 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) markFailed(metas, id, skipped)
if opts.HaltOnError { if opts.HaltOnError {
break break
@@ -102,7 +121,7 @@ func Run(ctx context.Context, reg *module.Registry, opts RunOptions) (Summary, e
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)
@@ -112,6 +131,7 @@ func Run(ctx context.Context, reg *module.Registry, opts RunOptions) (Summary, e
} }
continue continue
} }
}
res.Plan = plan res.Plan = plan
if opts.DryRun { if opts.DryRun {

View File

@@ -14,36 +14,47 @@ type scriptedModule struct {
deps []string deps []string
detect func() (module.State, error) detect func() (module.State, error)
plan func() (module.Plan, error) plan func() (module.Plan, error)
planForce func() (module.Plan, error)
apply func() error apply func() error
verify 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))
}
}