diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 096ac59..bdae9dd 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -14,7 +14,10 @@ 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 + // 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. @@ -91,26 +94,43 @@ func Run(ctx context.Context, reg *module.Registry, opts RunOptions) (Summary, e 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, skipped) - if opts.HaltOnError { - break + var ( + plan module.Plan + err error + ) + if opts.Force { + plan, err = m.PlanForce(ctx) + 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) - if err != nil { - res.Status = StatusFailed - res.Err = fmt.Errorf("plan: %w", err) - markFailed(metas, id, skipped) - if opts.HaltOnError { - break + plan, err = m.Plan(ctx, state) + if err != nil { + res.Status = StatusFailed + res.Err = fmt.Errorf("plan: %w", err) + markFailed(metas, id, skipped) + if opts.HaltOnError { + break + } + continue } - continue } res.Plan = plan diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index c1453d9..709e6a5 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -10,40 +10,51 @@ import ( // 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 + id string + deps []string + detect func() (module.State, error) + plan func() (module.Plan, error) + planForce func() (module.Plan, 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) Detect(ctx context.Context) (module.State, 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) { + s.detectCalls++ if s.detect != nil { return s.detect() } 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 { return s.plan() } 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 { return s.plan() } 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 { return s.apply() } return nil } -func (s scriptedModule) Verify(ctx context.Context) error { +func (s *scriptedModule) Verify(ctx context.Context) error { if s.verify != nil { return s.verify() } @@ -63,8 +74,8 @@ func newReg(t *testing.T, mods ...module.Module) *module.Registry { func TestRunAllSucceed(t *testing.T) { reg := newReg(t, - scriptedModule{id: "a"}, - scriptedModule{id: "b", deps: []string{"a"}}, + &scriptedModule{id: "a"}, + &scriptedModule{id: "b", deps: []string{"a"}}, ) sum, err := Run(context.Background(), reg, RunOptions{Reporter: NewBufferedReporter()}) if err != nil { @@ -81,7 +92,7 @@ func TestRunAllSucceed(t *testing.T) { func TestRunDryRunDoesNotApply(t *testing.T) { applied := false - reg := newReg(t, scriptedModule{ + reg := newReg(t, &scriptedModule{ id: "a", apply: func() error { applied = true; return nil }, }) @@ -99,10 +110,10 @@ func TestRunDryRunDoesNotApply(t *testing.T) { 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"}}, + &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 { @@ -128,8 +139,8 @@ func TestRunFailureSkipsDescendantsContinuesSiblings(t *testing.T) { func TestRunHaltOnError(t *testing.T) { reg := newReg(t, - scriptedModule{id: "a", apply: func() error { return errors.New("nope") }}, - scriptedModule{id: "b"}, // independent + &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 { @@ -150,3 +161,36 @@ func TestRunUnknownModule(t *testing.T) { 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)) + } +}