runner.go

v0.4.0
Doc Versions Source
1
package legatus
2
3
import (
4
	"context"
5
	"io"
6
	"log/slog"
7
	"sync"
8
	"time"
9
10
	"go.bigb.es/auxilia/culpa"
11
	"go.bigb.es/auxilia/scribe"
12
)
13
14
// Runner executes flows across an inventory. It pools one Conn per host and
15
// carries a SharedStore so sequential Run calls can pass data between groups
16
// (e.g. exits → relay). The Runner reaches hosts only through the Dialer/Conn
17
// interfaces, never SSH directly (IV1, PC1).
18
type Runner struct {
19
	inv         *Inventory
20
	dialer      Dialer
21
	concurrency int
22
	policy      ErrorPolicy
23
	reporter    Reporter
24
	prompter    Prompter
25
	logger      *slog.Logger
26
	dryRun      bool
27
	out         io.Writer
28
29
	shared *SharedStore
30
31
	mu   sync.Mutex
32
	pool map[string]Conn
33
}
34
35
// NewRunner builds a Runner. Defaults: concurrency 1, ContinueOnError, no-op
36
// reporter, non-interactive prompter, slog.Default logger.
37
func NewRunner(inv *Inventory, opts ...Option) *Runner {
38
	r := &Runner{
39
		inv:         inv,
40
		concurrency: 1,
41
		policy:      ContinueOnError,
42
		reporter:    NopReporter{},
43
		prompter:    DenyPrompter{},
44
		logger:      slog.Default(),
45
		out:         io.Discard,
46
		shared:      NewSharedStore(),
47
		pool:        map[string]Conn{},
48
	}
49
	for _, o := range opts {
50
		o(r)
51
	}
52
	return r
53
}
54
55
// Shared returns the cross-host store, persistent across Run calls.
56
func (r *Runner) Shared() *SharedStore { return r.shared }
57
58
// Run executes flow against the hosts matching sel (all hosts if none), fanning
59
// out across an ordered worker pool capped at the configured concurrency. It
60
// always returns a result per host. Under FailFast, once a host fails the run
61
// context is cancelled and hosts not yet dispatched are recorded as cancelled
62
// without dialing — because jobs are fed in inventory order, with concurrency 1
63
// this cancellation is deterministic.
64
func (r *Runner) Run(ctx context.Context, flow Flow, sel ...Selector) RunResult {
65
	hosts := r.inv.Hosts(sel...)
66
	results := make([]FlowResult, len(hosts))
67
68
	conc := r.concurrency
69
	if conc < 1 {
70
		conc = 1
71
	}
72
	if conc > len(hosts) {
73
		conc = len(hosts)
74
	}
75
76
	runCtx, cancel := context.WithCancel(ctx)
77
	defer cancel()
78
79
	type job struct {
80
		idx int
81
		h   *Host
82
	}
83
	jobs := make(chan job)
84
	var wg sync.WaitGroup
85
	var failOnce sync.Once
86
87
	for w := 0; w < conc; w++ {
88
		wg.Add(1)
89
		go func() {
90
			defer wg.Done()
91
			for j := range jobs {
92
				fr := r.runHost(runCtx, flow, j.h)
93
				results[j.idx] = fr
94
				if r.policy == FailFast && fr.Status == StatusFailed {
95
					failOnce.Do(cancel)
96
				}
97
			}
98
		}()
99
	}
100
101
	for i, h := range hosts {
102
		if r.policy == FailFast {
103
			select {
104
			case <-runCtx.Done():
105
				results[i] = r.cancelledFlow(flow, h, runCtx.Err())
106
				continue
107
			default:
108
			}
109
		}
110
		jobs <- job{idx: i, h: h}
111
	}
112
	close(jobs)
113
	wg.Wait()
114
115
	rr := RunResult{Flows: results}
116
	r.reporter.RunDone(rr)
117
	return rr
118
}
119
120
func (r *Runner) cancelledFlow(flow Flow, h *Host, err error) FlowResult {
121
	sr := StepResult{Step: "cancelled", Status: StatusFailed, Err: err, Started: time.Now()}
122
	return FlowResult{Flow: flow.Name, Host: h.Name, Steps: []StepResult{sr}, Status: StatusFailed, Params: map[string]any{}}
123
}
124
125
func (r *Runner) runHost(ctx context.Context, flow Flow, h *Host) FlowResult {
126
	hlog := r.logger.With("host", h.Name, "flow", flow.Name)
127
	r.reporter.FlowStart(h.Name, flow.Name)
128
	fr := FlowResult{Flow: flow.Name, Host: h.Name}
129
130
	conn, err := r.connFor(ctx, h)
131
	if err != nil {
132
		err = culpa.Wrap(err, "dial host "+h.Name)
133
		sr := StepResult{Step: "dial", Status: StatusFailed, Err: err, Started: time.Now()}
134
		hlog.Error("dial failed", scribe.Err(err))
135
		r.reporter.StepDone(h.Name, sr)
136
		fr.Steps = append(fr.Steps, sr)
137
		fr.Status = StatusFailed
138
		fr.Params = map[string]any{}
139
		r.reporter.FlowDone(h.Name, fr)
140
		return fr
141
	}
142
143
	fc := NewFlowCtx(ctx, h, conn,
144
		WithFlowLogger(hlog),
145
		WithFlowDryRun(r.dryRun),
146
		WithFlowPrompter(r.prompter),
147
		WithFlowShared(r.shared),
148
		WithFlowOutput(r.out),
149
	)
150
151
	for _, step := range flow.Steps {
152
		sr := r.runStep(fc, hlog, h.Name, step)
153
		// Record change before the next step's Check so later steps can gate on it
154
		// (steps.When/OnChanged); WouldChange counts so gates fire in dry-run too.
155
		fc.setChanged(sr.Step, sr.Status == StatusChanged || sr.Status == StatusWouldChange)
156
		fr.Steps = append(fr.Steps, sr)
157
		if sr.Status == StatusFailed {
158
			break // fail-fast within a flow (IV7)
159
		}
160
	}
161
	fr.Status = aggregate(fr.Steps)
162
	fr.Params = fc.snapshot()
163
	r.reporter.FlowDone(h.Name, fr)
164
	return fr
165
}
166
167
// runStep runs one step's check/apply lifecycle, emitting structured logs and
168
// reporter events with no step-author involvement (PC5).
169
func (r *Runner) runStep(fc *FlowCtx, hlog *slog.Logger, host string, step Step) StepResult {
170
	name := step.Name()
171
	slogger := hlog.With("step", name)
172
	fc.log = slogger // per-step tag; params and other fields persist across steps
173
	r.reporter.StepStart(host, name)
174
175
	started := time.Now()
176
	slogger.Debug("step starting")
177
	sr := StepResult{Step: name, Started: started}
178
179
	need, err := step.Check(fc)
180
	if err != nil {
181
		sr.Status = StatusFailed
182
		sr.Err = culpa.Wrap(err, "check "+name)
183
		sr.Duration = time.Since(started)
184
		slogger.Error("step failed", "stage", "check", scribe.Err(sr.Err))
185
		r.reporter.StepDone(host, sr)
186
		return sr
187
	}
188
	slogger.Debug("check complete", "need", need)
189
190
	if !need {
191
		sr.Status = StatusSkipped
192
		sr.Duration = time.Since(started)
193
		slogger.Info("step skipped")
194
		r.reporter.StepDone(host, sr)
195
		return sr
196
	}
197
	if fc.dryRun {
198
		sr.Status = StatusWouldChange
199
		sr.Duration = time.Since(started)
200
		slogger.Info("step would change (dry-run)")
201
		r.reporter.StepDone(host, sr)
202
		return sr
203
	}
204
205
	out, err := step.Apply(fc)
206
	sr.Duration = time.Since(started)
207
	if err != nil {
208
		sr.Status = StatusFailed
209
		sr.Err = culpa.Wrap(err, "apply "+name)
210
		slogger.Error("step failed", "stage", "apply", scribe.Err(sr.Err))
211
		r.reporter.StepDone(host, sr)
212
		return sr
213
	}
214
	sr.Outcome = out
215
	if out.Changed {
216
		sr.Status = StatusChanged
217
	} else {
218
		sr.Status = StatusOK
219
	}
220
	slogger.Info("step applied", "status", sr.Status.String(), "changed", out.Changed, "dur", sr.Duration)
221
	r.reporter.StepDone(host, sr)
222
	return sr
223
}
224
225
func (r *Runner) connFor(ctx context.Context, h *Host) (Conn, error) {
226
	r.mu.Lock()
227
	c, ok := r.pool[h.Name]
228
	r.mu.Unlock()
229
	if ok {
230
		return c, nil
231
	}
232
	if r.dialer == nil {
233
		return nil, culpa.WithCode(culpa.New("legatus: no dialer configured"), "LEGATUS_NO_DIALER")
234
	}
235
	c, err := r.dialer.Dial(ctx, h)
236
	if err != nil {
237
		return nil, err
238
	}
239
	r.mu.Lock()
240
	if existing, ok := r.pool[h.Name]; ok { // lost a race; keep the first
241
		r.mu.Unlock()
242
		_ = c.Close()
243
		return existing, nil
244
	}
245
	r.pool[h.Name] = c
246
	r.mu.Unlock()
247
	return c, nil
248
}
249
250
// Close closes all pooled connections. It also satisfies steward's Stopper.
251
func (r *Runner) Close() error {
252
	r.mu.Lock()
253
	defer r.mu.Unlock()
254
	var firstErr error
255
	for name, c := range r.pool {
256
		if err := c.Close(); err != nil && firstErr == nil {
257
			firstErr = culpa.Wrap(err, "close conn "+name)
258
		}
259
		delete(r.pool, name)
260
	}
261
	return firstErr
262
}
263
264
// Stop closes pooled connections (steward Stopper).
265
func (r *Runner) Stop(context.Context) error { return r.Close() }
266

Source Files