-
Notifications
You must be signed in to change notification settings - Fork 12
Expand file tree
/
Copy pathexecutor.go
More file actions
487 lines (398 loc) · 11.9 KB
/
executor.go
File metadata and controls
487 lines (398 loc) · 11.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
package executor
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/lets-cli/lets/checksum"
"github.com/lets-cli/lets/config/config"
"github.com/lets-cli/lets/docopt"
"github.com/lets-cli/lets/env"
"github.com/lets-cli/lets/logging"
"golang.org/x/sync/errgroup"
)
type ExecuteError struct {
err error
}
func (e *ExecuteError) Error() string {
return e.err.Error()
}
// ExitCode will return exit code from underlying ExitError or returns default error code.
func (e *ExecuteError) ExitCode() int {
var exitErr *exec.ExitError
if ok := errors.As(e.err, &exitErr); ok {
return exitErr.ExitCode()
}
return 1 // default error code
}
// DependencyError represents an error that occurred in a dependency chain
type DependencyError struct {
rootCommand string
failedCommand string
dependencyPath []string
underlyingErr error
exitCode int
}
func (e *DependencyError) Error() string {
if len(e.dependencyPath) <= 1 {
// No dependency chain, use original error format
return e.underlyingErr.Error()
}
// Build dependency tree visualization
var builder strings.Builder
// Show the failed command
builder.WriteString(fmt.Sprintf("'%s' failed: %s\n\n", e.failedCommand, e.getUnderlyingErrorMessage()))
// Show the dependency chain
builder.WriteString(fmt.Sprintf("'%s' ->", e.rootCommand))
for i := 1; i < len(e.dependencyPath); i++ {
builder.WriteString(fmt.Sprintf("\n '%s'", e.dependencyPath[i]))
if e.dependencyPath[i] == e.failedCommand {
builder.WriteString(" ⚠️")
}
}
return builder.String()
}
func (e *DependencyError) getUnderlyingErrorMessage() string {
if e.underlyingErr == nil {
return fmt.Sprintf("exit status %d", e.exitCode)
}
// Extract just the exit status from the underlying error
errStr := e.underlyingErr.Error()
if strings.Contains(errStr, "exit status") {
parts := strings.Split(errStr, ": ")
if len(parts) > 0 {
return parts[len(parts)-1]
}
}
return errStr
}
func (e *DependencyError) ExitCode() int {
return e.exitCode
}
func (e *DependencyError) Unwrap() error {
return e.underlyingErr
}
type Executor struct {
cfg *config.Config
out io.Writer
initCalled bool
}
func NewExecutor(cfg *config.Config, out io.Writer) *Executor {
return &Executor{
cfg: cfg,
out: out,
}
}
type Context struct {
ctx context.Context
command *config.Command
logger *logging.ExecLogger
dependencyPath []string
}
func NewExecutorCtx(ctx context.Context, command *config.Command) *Context {
return &Context{
ctx: ctx,
command: command,
logger: logging.NewExecLogger().Child(command.Name),
dependencyPath: []string{command.Name},
}
}
func ChildExecutorCtx(ctx *Context, command *config.Command) *Context {
dependencyPath := make([]string, len(ctx.dependencyPath)+1)
copy(dependencyPath, ctx.dependencyPath)
dependencyPath[len(ctx.dependencyPath)] = command.Name
return &Context{
ctx: ctx.ctx,
command: command,
logger: ctx.logger.Child(command.Name),
dependencyPath: dependencyPath,
}
}
// Execute executes command and it depends recursively
// Command can be executed in parallel.
func (e *Executor) Execute(ctx *Context) error {
if e.cfg.Init != "" && !e.initCalled {
e.initCalled = true
if err := e.runCmd(ctx, &config.Cmd{Script: e.cfg.Init}); err != nil {
return err
}
}
if ctx.command.Cmds.Parallel {
return e.executeParallel(ctx)
}
return e.execute(ctx)
}
// Execute main command and wait for result.
// Must be used only when Command.Cmd is string or []string.
func (e *Executor) execute(ctx *Context) error {
command := ctx.command
if env.DebugLevel() > 1 {
ctx.logger.Debug("command %s", command.Dump())
}
defer func() {
if command.After != "" {
e.executeAfterScript(ctx)
}
}()
if err := e.initCmd(ctx); err != nil {
return err
}
if err := e.executeDepends(ctx); err != nil {
return err
}
for _, cmd := range command.Cmds.Commands {
if err := e.runCmd(ctx, cmd); err != nil {
return err
}
}
// persist checksum only if exit code 0
return e.persistChecksum(ctx)
}
// Executes 'after' script after main 'cmd' script
// It allowed to fail and will print error
// Do not return error directly to root because we consider only 'cmd' exit code.
// Even if 'after' script failed we return exit code from 'cmd'.
// This behavior may change in the future if needed.
func (e *Executor) executeAfterScript(ctx *Context) {
command := ctx.command
osCmd, err := e.newOsCommand(command, command.After)
if err != nil {
ctx.logger.Info("failed to run `after` script: %s", err)
return
}
ctx.logger.Debug("executing 'after':\n cmd: %s\n env: %s", command.After, fmtEnv(osCmd.Env))
if ExecuteError := osCmd.Run(); ExecuteError != nil {
ctx.logger.Info("failed to run `after` script: %s", ExecuteError)
}
}
// format docopts error and adds usage string to output.
func formatOptsUsageError(err error, opts docopt.Opts, cmdName string, rawOptions string) error {
if opts == nil && err.Error() == "" {
// TODO how to get wrong option name
err = errors.New("no such option")
}
errTpl := fmt.Sprintf("failed to parse docopt options for cmd %s: %s", cmdName, err)
return fmt.Errorf("%s\n\n%s", errTpl, rawOptions)
}
// Init Command before execution:
// - parse docopt
// - calculate checksum.
func (e *Executor) initCmd(ctx *Context) error {
cmd := ctx.command
if !cmd.SkipDocopts {
ctx.logger.Debug("parse docopt: %s, args: %s", cmd.Docopts, cmd.Args)
opts, err := docopt.Parse(cmd.Name, cmd.Args, cmd.Docopts)
if err != nil {
// TODO if accept_args, just continue with what we got
// but this may require changes in go-docopt
return formatOptsUsageError(err, opts, cmd.Name, cmd.Docopts)
}
ctx.logger.Debug("docopt parsed: %v", opts)
cmd.Options = docopt.OptsToLetsOpt(opts)
cmd.CliOptions = docopt.OptsToLetsCli(opts)
}
// calculate checksum if needed
if err := cmd.ChecksumCalculator(e.cfg.WorkDir); err != nil {
return fmt.Errorf("failed to calculate checksum for command '%s': %w", cmd.Name, err)
}
// if command declared as persist_checksum we must read current persisted checksums into memory
if cmd.PersistChecksum {
if checksum.IsChecksumForCmdPersisted(e.cfg.ChecksumsDir, cmd.Name) {
err := cmd.ReadChecksumsFromDisk(e.cfg.ChecksumsDir, cmd.Name, cmd.ChecksumMap)
if err != nil {
return fmt.Errorf("failed to read persisted checksum for command '%s': %w", cmd.Name, err)
}
}
}
return nil
}
func joinBeforeAndScript(before string, script string) string {
if before == "" {
return script
}
before = strings.TrimSpace(before)
return strings.Join([]string{before, script}, "\n")
}
// Setup env for cmd.
func (e *Executor) setupEnv(osCmd *exec.Cmd, command *config.Command, shell string) error {
defaultEnv := map[string]string{
"LETS_COMMAND_NAME": command.Name,
"LETS_COMMAND_ARGS": strings.Join(command.Args, " "),
"LETS_COMMAND_WORK_DIR": osCmd.Dir,
"LETS_CONFIG": filepath.Base(e.cfg.FilePath),
"LETS_CONFIG_DIR": filepath.Dir(e.cfg.FilePath),
"LETS_SHELL": shell,
}
checksumEnvMap := getChecksumEnvMap(command.ChecksumMap)
var changedChecksumEnvMap map[string]string
if command.PersistChecksum {
changedChecksumEnvMap = getChangedChecksumEnvMap(
command.ChecksumMap,
command.GetPersistedChecksums(),
)
}
cmdEnv, err := command.GetEnv(*e.cfg)
if err != nil {
return err
}
envMaps := []map[string]string{
defaultEnv,
e.cfg.GetEnv(),
cmdEnv,
command.Options,
command.CliOptions,
checksumEnvMap,
changedChecksumEnvMap,
}
envList := os.Environ()
for _, envMap := range envMaps {
envList = append(envList, convertEnvMapToList(envMap)...)
}
osCmd.Env = envList
return nil
}
// Prepare cmd to be executed:
// - set in/out
// - set dir
// - prepare environment
//
// NOTE: We intentionally do not passing ctx to exec.Command because we want to wait for process end.
// Passing ctx will change behavior of program drastically - it will kill process if context will be canceled.
func (e *Executor) newOsCommand(command *config.Command, cmdScript string) (*exec.Cmd, error) {
script := joinBeforeAndScript(e.cfg.Before, cmdScript)
shell := e.cfg.Shell
if command.Shell != "" {
shell = command.Shell
}
args := []string{"-c", script}
if len(command.Args) > 0 {
// for "--" see https://linux.die.net/man/1/bash
args = append(args, "--", strings.Join(command.Args, " "))
}
osCmd := exec.Command(
shell,
args...,
)
// setup std out and err
osCmd.Stdout = e.out
osCmd.Stderr = e.out
osCmd.Stdin = os.Stdin
// set working directory for command
osCmd.Dir = e.cfg.WorkDir
if command.WorkDir != "" {
osCmd.Dir = command.WorkDir
}
if err := e.setupEnv(osCmd, command, shell); err != nil {
return nil, err
}
return osCmd, nil
}
// Run all commands from Depends in sequential order.
func (e *Executor) executeDepends(ctx *Context) error {
return ctx.command.Depends.Range(func(depName string, dep config.Dep) error {
ctx.logger.Debug("running dependency '%s'", depName)
cmd := e.cfg.Commands[depName]
cmd = cmd.Clone()
if dep.HasArgs() {
cmd.Args = dep.Args
ctx.logger.Debug("dependency args overridden: '%s'", cmd.Args)
}
if !dep.Env.Empty() {
cmd.Env.Merge(dep.Env)
ctx.logger.Debug("dependency env overridden: '%s'", cmd.Env.Dump())
}
if err := e.Execute(ChildExecutorCtx(ctx, cmd)); err != nil {
// Wrap error with dependency context if it's not already a DependencyError
var depErr *DependencyError
if !errors.As(err, &depErr) {
// Extract exit code from ExecuteError or use default
exitCode := 1
var execErr *ExecuteError
if errors.As(err, &execErr) {
exitCode = execErr.ExitCode()
}
return &DependencyError{
rootCommand: ctx.dependencyPath[0],
failedCommand: cmd.Name,
dependencyPath: append(ctx.dependencyPath, cmd.Name),
underlyingErr: err,
exitCode: exitCode,
}
}
// If it's already a DependencyError, just return it
return err
}
return nil
})
}
// Persist new calculated checksum to disk.
// This function mus be called only after command finished(exited) with status 0.
func (e *Executor) persistChecksum(ctx *Context) error {
cmd := ctx.command
if cmd.PersistChecksum {
ctx.logger.Debug("persisting checksum")
if err := e.cfg.CreateChecksumsDir(); err != nil {
return err
}
err := checksum.PersistCommandsChecksumToDisk(
e.cfg.ChecksumsDir,
cmd.ChecksumMap,
cmd.Name,
)
if err != nil {
return fmt.Errorf("can not persist checksum to disk: %w", err)
}
}
return nil
}
func (e *Executor) runCmd(ctx *Context, cmd *config.Cmd) error {
command := ctx.command
osCmd, err := e.newOsCommand(command, cmd.Script)
if err != nil {
return err
}
if env.DebugLevel() == 1 {
ctx.logger.Debug("executing script:\n%s", cmd.Script)
} else if env.DebugLevel() > 1 {
ctx.logger.Debug("executing:\nscript: %s\nenv: %s\n", cmd.Script, fmtEnv(osCmd.Env))
}
if err := osCmd.Run(); err != nil {
return &ExecuteError{err: fmt.Errorf("failed to run command '%s': %w", command.Name, err)}
}
return nil
}
// Execute all commands from Cmds in parallel and wait for results.
func (e *Executor) executeParallel(ctx *Context) error {
command := ctx.command
defer func() {
if command.After != "" {
e.executeAfterScript(ctx)
}
}()
if err := e.initCmd(ctx); err != nil {
return err
}
if err := e.executeDepends(ctx); err != nil {
return err
}
group, _ := errgroup.WithContext(ctx.ctx)
for _, cmd := range command.Cmds.Commands {
cmd := cmd
// wait for cmd to end in a goroutine with error propagation
group.Go(func() error {
return e.runCmd(ctx, cmd)
})
}
if err := group.Wait(); err != nil {
return err //nolint:wrapcheck
}
// persist checksum only if exit code 0
if err := e.persistChecksum(ctx); err != nil {
return fmt.Errorf("persist checksum error in command '%s': %w", command.Name, err)
}
return nil
}