-
Notifications
You must be signed in to change notification settings - Fork 12
Expand file tree
/
Copy pathexecutor.go
More file actions
409 lines (326 loc) · 9.64 KB
/
executor.go
File metadata and controls
409 lines (326 loc) · 9.64 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
package executor
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
"github.com/lets-cli/lets/internal/checksum"
"github.com/lets-cli/lets/internal/config/config"
"github.com/lets-cli/lets/internal/docopt"
"github.com/lets-cli/lets/internal/env"
"github.com/lets-cli/lets/internal/logging"
"golang.org/x/sync/errgroup"
)
type ExecuteError struct {
err error
}
func (e *ExecuteError) Error() string {
return e.err.Error()
}
func (e *ExecuteError) Unwrap() error {
return e.err
}
func (e *ExecuteError) Cause() error {
if err := errors.Unwrap(e.err); err != nil {
return err
}
return e.err
}
// 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
}
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
}
func NewExecutorCtx(ctx context.Context, command *config.Command) *Context {
return &Context{
ctx: ctx,
command: command,
logger: logging.NewExecLogger().Child(command.Name),
}
}
func ChildExecutorCtx(ctx *Context, command *config.Command) *Context {
return &Context{
command: command,
logger: ctx.logger.Child(command.Name),
}
}
// 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 prependToChain(command.Name, err)
}
if err := e.executeDepends(ctx); err != nil {
return prependToChain(command.Name, err)
}
for _, cmd := range command.Cmds.Commands {
if err := e.runCmd(ctx, cmd); err != nil {
return prependToChain(command.Name, err)
}
}
// persist checksum only if exit code 0
if err := e.persistChecksum(ctx); err != nil {
return prependToChain(command.Name, err)
}
return nil
}
// 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 := e.cfg.CommandBuiltinEnv(command, shell, osCmd.Dir)
checksumEnvMap := getChecksumEnvMap(command.ChecksumMap)
var changedChecksumEnvMap map[string]string
if command.PersistChecksum {
changedChecksumEnvMap = getChangedChecksumEnvMap(
command.ChecksumMap,
command.GetPersistedChecksums(),
)
}
cmdEnv, err := command.GetEnv(*e.cfg, defaultEnv)
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())
}
return e.Execute(ChildExecutorCtx(ctx, cmd))
})
}
// 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 prependToChain(command.Name, err)
}
if err := e.executeDepends(ctx); err != nil {
return prependToChain(command.Name, err)
}
group, _ := errgroup.WithContext(ctx.ctx)
for _, cmd := range command.Cmds.Commands {
group.Go(func() error {
return e.runCmd(ctx, cmd)
})
}
if err := group.Wait(); err != nil {
return prependToChain(command.Name, err)
}
// persist checksum only if exit code 0
if err := e.persistChecksum(ctx); err != nil {
err := fmt.Errorf("persist checksum error in command '%s': %w", command.Name, err)
return prependToChain(command.Name, err)
}
return nil
}