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
|
||||
}
|
||||
Reference in New Issue
Block a user