-
Notifications
You must be signed in to change notification settings - Fork 64
/
Copy pathrun_steps.go
428 lines (351 loc) · 12.7 KB
/
run_steps.go
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
package batches
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"os"
"os/exec"
"strings"
"time"
"github.com/hashicorp/go-multierror"
"github.com/pkg/errors"
"github.com/sourcegraph/src-cli/internal/batches/graphql"
yamlv3 "gopkg.in/yaml.v3"
)
type executionResult struct {
// Diff is the produced by executing all steps.
Diff string `json:"diff"`
// ChangedFiles are files that have been changed by all steps.
ChangedFiles *StepChanges `json:"changedFiles"`
// Outputs are the outputs produced by all steps.
Outputs map[string]interface{} `json:"outputs"`
// Path relative to the repository's root directory in which the steps
// have been executed.
// No leading slashes. Root directory is blank string.
Path string
}
type executionOpts struct {
archive RepoZip
wc WorkspaceCreator
path string
batchChangeAttributes *BatchChangeAttributes
repo *graphql.Repository
steps []Step
tempDir string
logger *TaskLogger
reportProgress func(string)
}
func runSteps(ctx context.Context, opts *executionOpts) (result executionResult, err error) {
opts.reportProgress("Downloading archive")
err = opts.archive.Fetch(ctx)
if err != nil {
return executionResult{}, errors.Wrap(err, "fetching repo")
}
defer opts.archive.Close()
opts.reportProgress("Initializing workspace")
workspace, err := opts.wc.Create(ctx, opts.repo, opts.steps, opts.archive)
if err != nil {
return executionResult{}, errors.Wrap(err, "creating workspace")
}
defer workspace.Close(ctx)
execResult := executionResult{
Outputs: make(map[string]interface{}),
Path: opts.path,
}
results := make([]StepResult, len(opts.steps))
for i, step := range opts.steps {
opts.reportProgress(fmt.Sprintf("Preparing step %d", i+1))
stepContext := StepContext{BatchChange: *opts.batchChangeAttributes, Repository: *opts.repo, Outputs: execResult.Outputs}
if i > 0 {
stepContext.PreviousStep = results[i-1]
}
// Find a location that we can use for a cidfile, which will contain the
// container ID that is used below. We can then use this to remove the
// container on a successful run, rather than leaving it dangling.
cidFile, err := ioutil.TempFile(opts.tempDir, opts.repo.Slug()+"-container-id")
if err != nil {
return execResult, errors.Wrap(err, "Creating a CID file failed")
}
// However, Docker will fail if the cidfile actually exists, so we need
// to remove it. Because Windows can't remove open files, we'll first
// close it, even though that's unnecessary elsewhere.
cidFile.Close()
if err = os.Remove(cidFile.Name()); err != nil {
return execResult, errors.Wrap(err, "removing cidfile")
}
// Since we went to all that effort, we can now defer a function that
// uses the cidfile to clean up after this function is done.
defer func() {
cid, err := ioutil.ReadFile(cidFile.Name())
_ = os.Remove(cidFile.Name())
if err == nil {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
_ = exec.CommandContext(ctx, "docker", "rm", "-f", "--", string(cid)).Run()
}
}()
// We need to grab the digest for the exact image we're using.
digest, err := step.image.Digest(ctx)
if err != nil {
return execResult, errors.Wrapf(err, "getting digest for %v", step.image)
}
// For now, we only support shell scripts provided via the Run field.
shell, containerTemp, err := probeImageForShell(ctx, digest)
if err != nil {
return execResult, errors.Wrapf(err, "probing image %q for shell", step.image)
}
// Set up a temporary file on the host filesystem to contain the
// script.
runScriptFile, err := ioutil.TempFile(opts.tempDir, "")
if err != nil {
return execResult, errors.Wrap(err, "creating temporary file")
}
defer os.Remove(runScriptFile.Name())
// Parse step.Run as a template and render it into a buffer and the
// temp file we just created.
var runScript bytes.Buffer
out := io.MultiWriter(&runScript, runScriptFile)
if err := renderStepTemplate("step-run", step.Run, out, &stepContext); err != nil {
return execResult, errors.Wrap(err, "parsing step run")
}
if err := runScriptFile.Close(); err != nil {
return execResult, errors.Wrap(err, "closing temporary file")
}
// This file needs to be readable within the container regardless of the
// user the container is running as, so we'll set the appropriate group
// and other bits to make it so.
//
// A fun note: although os.File exposes a Chmod() method, we can't
// unconditionally use it because Windows cannot change the attributes
// of an open file. Rather than going to the trouble of having
// conditionally compiled files here, instead we'll just wait until the
// file is closed to twiddle the permission bits. Which is now!
if err := os.Chmod(runScriptFile.Name(), 0644); err != nil {
return execResult, errors.Wrap(err, "setting permissions on the temporary file")
}
// Parse and render the step.Files.
files, err := renderStepMap(step.Files, &stepContext)
if err != nil {
return execResult, errors.Wrap(err, "parsing step files")
}
// Create temp files with the rendered content of step.Files so that we
// can mount them into the container.
filesToMount := make(map[string]*os.File, len(files))
for name, content := range files {
fp, err := ioutil.TempFile(opts.tempDir, "")
if err != nil {
return execResult, errors.Wrap(err, "creating temporary file")
}
defer os.Remove(fp.Name())
if _, err := fp.WriteString(content); err != nil {
return execResult, errors.Wrap(err, "writing to temporary file")
}
if err := fp.Close(); err != nil {
return execResult, errors.Wrap(err, "closing temporary file")
}
filesToMount[name] = fp
}
// Resolve step.Env given the current environment.
stepEnv, err := step.Env.Resolve(os.Environ())
if err != nil {
return execResult, errors.Wrap(err, "resolving step environment")
}
// Render the step.Env variables as templates.
env, err := renderStepMap(stepEnv, &stepContext)
if err != nil {
return execResult, errors.Wrap(err, "parsing step environment")
}
opts.reportProgress(runScript.String())
const workDir = "/work"
workspaceOpts, err := workspace.DockerRunOpts(ctx, workDir)
if err != nil {
return execResult, errors.Wrap(err, "getting Docker options for workspace")
}
// Where should we execute the steps.run script?
scriptWorkDir := workDir
if opts.path != "" {
scriptWorkDir = workDir + "/" + opts.path
}
args := append([]string{
"run",
"--rm",
"--init",
"--cidfile", cidFile.Name(),
"--workdir", scriptWorkDir,
"--mount", fmt.Sprintf("type=bind,source=%s,target=%s,ro", runScriptFile.Name(), containerTemp),
}, workspaceOpts...)
for target, source := range filesToMount {
args = append(args, "--mount", fmt.Sprintf("type=bind,source=%s,target=%s,ro", source.Name(), target))
}
for k, v := range env {
args = append(args, "-e", k+"="+v)
}
args = append(args, "--entrypoint", shell)
cmd := exec.CommandContext(ctx, "docker", args...)
cmd.Args = append(cmd.Args, "--", digest, containerTemp)
if dir := workspace.WorkDir(); dir != nil {
cmd.Dir = *dir
}
var stdoutBuffer, stderrBuffer bytes.Buffer
cmd.Stdout = io.MultiWriter(&stdoutBuffer, opts.logger.PrefixWriter("stdout"))
cmd.Stderr = io.MultiWriter(&stderrBuffer, opts.logger.PrefixWriter("stderr"))
opts.logger.Logf("[Step %d] run: %q, container: %q", i+1, step.Run, step.Container)
opts.logger.Logf("[Step %d] full command: %q", i+1, strings.Join(cmd.Args, " "))
t0 := time.Now()
err = cmd.Run()
elapsed := time.Since(t0).Round(time.Millisecond)
if err != nil {
opts.logger.Logf("[Step %d] took %s; error running Docker container: %+v", i+1, elapsed, err)
return execResult, stepFailedErr{
Err: err,
Args: cmd.Args,
Run: runScript.String(),
Container: step.Container,
TmpFilename: containerTemp,
Stdout: strings.TrimSpace(stdoutBuffer.String()),
Stderr: strings.TrimSpace(stderrBuffer.String()),
}
}
opts.logger.Logf("[Step %d] complete in %s", i+1, elapsed)
changes, err := workspace.Changes(ctx)
if err != nil {
return execResult, errors.Wrap(err, "getting changed files in step")
}
result := StepResult{files: changes, Stdout: &stdoutBuffer, Stderr: &stderrBuffer}
stepContext.Step = result
results[i] = result
if err := setOutputs(step.Outputs, execResult.Outputs, &stepContext); err != nil {
return execResult, errors.Wrap(err, "setting step outputs")
}
}
opts.reportProgress("Calculating diff")
diffOut, err := workspace.Diff(ctx)
if err != nil {
return execResult, errors.Wrap(err, "git diff failed")
}
execResult.Diff = string(diffOut)
if len(results) > 0 && results[len(results)-1].files != nil {
execResult.ChangedFiles = results[len(results)-1].files
}
return execResult, err
}
func setOutputs(stepOutputs Outputs, global map[string]interface{}, stepCtx *StepContext) error {
for name, output := range stepOutputs {
var value bytes.Buffer
if err := renderStepTemplate("outputs-"+name, output.Value, &value, stepCtx); err != nil {
return errors.Wrap(err, "parsing step run")
}
switch output.Format {
case "yaml":
var out interface{}
// We use yamlv3 here, because it unmarshals YAML into
// map[string]interface{} which we need to serialize it back to
// JSON when we cache the results.
// See https://github.com/go-yaml/yaml/issues/139 for context
if err := yamlv3.NewDecoder(&value).Decode(&out); err != nil {
return err
}
global[name] = out
case "json":
var out interface{}
if err := json.NewDecoder(&value).Decode(&out); err != nil {
return err
}
global[name] = out
default:
global[name] = value.String()
}
}
return nil
}
func probeImageForShell(ctx context.Context, image string) (shell, tempfile string, err error) {
// We need to know two things to be able to run a shell script:
//
// 1. Which shell is available. We're going to look for /bin/bash and then
// /bin/sh, in that order. (Sorry, tcsh users.)
// 2. Where to put the shell script in the container so that we don't
// clobber any actual user data.
//
// We can do these together: although it's not part of POSIX proper, every
// *nix made in the last decade or more has mktemp(1) available. We know
// that mktemp will give us a file name that doesn't exist in the image if
// we run it as part of the command. We can also probe for the shell at the
// same time by trying to run /bin/bash -c mktemp,
// followed by /bin/sh -c mktemp.
// We'll also set up our error.
err = new(multierror.Error)
// Now we can iterate through our shell options and try to run mktemp with
// them.
for _, shell = range []string{"/bin/bash", "/bin/sh"} {
stdout := new(bytes.Buffer)
stderr := new(bytes.Buffer)
args := []string{"run", "--rm", "--entrypoint", shell, image, "-c", "mktemp"}
cmd := exec.CommandContext(ctx, "docker", args...)
cmd.Stdout = stdout
cmd.Stderr = stderr
if runErr := cmd.Run(); runErr != nil {
err = multierror.Append(err, errors.Wrapf(runErr, "probing shell %q:\n%s", shell, stderr.String()))
} else {
// Even if there were previous errors, we can now ignore them.
err = nil
tempfile = strings.TrimSpace(stdout.String())
return
}
}
// If we got here, then all the attempts to probe the shell failed. Let's
// admit defeat and return. At least err is already in place.
return
}
type stepFailedErr struct {
Run string
Container string
TmpFilename string
Args []string
Stdout string
Stderr string
Err error
}
func (e stepFailedErr) Cause() error { return e.Err }
func (e stepFailedErr) Error() string {
var out strings.Builder
fmtRun := func(run string) string {
lines := strings.Split(run, "\n")
if len(lines) == 1 {
return lines[0]
}
return lines[0] + fmt.Sprintf("\n\t(... and %d more lines)", len(lines)-1)
}
out.WriteString(fmt.Sprintf("run: %s\ncontainer: %s\n", fmtRun(e.Run), e.Container))
printOutput := func(output string) {
for _, line := range strings.Split(output, "\n") {
if e.TmpFilename != "" {
line = strings.ReplaceAll(line, e.TmpFilename+": ", "")
}
out.WriteString("\t" + line + "\n")
}
}
if len(e.Stdout) > 0 {
out.WriteString("\nstandard out:\n")
printOutput(e.Stdout)
}
if len(e.Stderr) > 0 {
out.WriteString("\nstandard error:\n")
printOutput(e.Stderr)
}
if exitErr, ok := e.Err.(*exec.ExitError); ok {
out.WriteString(fmt.Sprintf("\nCommand failed with exit code %d.", exitErr.ExitCode()))
} else {
out.WriteString(fmt.Sprintf("\nCommand failed: %s", e.Err))
}
return out.String()
}
func (e stepFailedErr) SingleLineError() string {
out := e.Err.Error()
if len(e.Stderr) > 0 {
out = e.Stderr
}
return strings.Split(out, "\n")[0]
}