1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6 "crypto/sha256"
7 "encoding/hex"
8 "os"
9 "os/exec"
10 "regexp"
11 "slices"
12 "strings"
13
14 "github.com/google/uuid"
15)
16
17// dockerPath is the docker CLI (DockerCommandManager.Initialize: which docker).
18func (j *job) dockerPath() string {
19 if p := j.cfg.Actions.DockerPath; p != "" {
20 return p
21 }
22 if p, err := exec.LookPath("docker"); err == nil {
23 return p
24 }
25 return "/usr/bin/docker"
26}
27
28// dockerLabel is DockerInstanceLabel: six hex characters that identify the
29// runner (the first characters of the SHA-256 of its .runner file). The
30// sandbox has one runner per job; the job's tracking id stands in.
31func (j *job) dockerLabel() string {
32 h := sha256.Sum256([]byte(j.trackID))
33 return hex.EncodeToString(h[:])[:6]
34}
35
36// escapeDockerArg is DockerUtil.EscapeString: the string wrapped in quotes with
37// the backslashes before a quote doubled (and at the end), as the runner writes
38// the arguments of its docker commands (it echoes them, so the log has them).
39var (
40 quoteEscape = regexp.MustCompile(`(\\*)"`)
41 endOfStringEscape = regexp.MustCompile(`(\\+)$`)
42)
43
44func escapeDockerArg(v string) string {
45 if v == "" {
46 return ""
47 }
48 v = quoteEscape.ReplaceAllString(v, `$1$1\"`)
49 v = endOfStringEscape.ReplaceAllString(v, `$1$1`)
50 return `"` + v + `"`
51}
52
53// dockerCmd is a docker command: the line the runner echoes and the arguments
54// that are run.
55type dockerCmd struct {
56 line string
57 argv []string
58 // env is added to the environment of the docker process, after its own:
59 // "-e NAME" options read their values there, so that they are neither on
60 // the command line nor in the echoed text.
61 env []string
62}
63
64// newDockerCmd builds "docker <command> <options>" the way the runner does
65// (ExecuteDockerCommandAsync: command and options joined by a space and
66// trimmed) and keeps the argv apart, so that nothing is re-split.
67func (j *job) newDockerCmd(command string, parts ...dockerPart) dockerCmd {
68 lines := []string{}
69 argv := []string{j.dockerPath(), command}
70 var env []string
71 for _, p := range parts {
72 lines = append(lines, p.line)
73 argv = append(argv, p.args...)
74 env = append(env, p.env...)
75 }
76 line := strings.TrimSpace(command + " " + strings.Join(lines, " "))
77 return dockerCmd{line: j.dockerPath() + " " + line, argv: argv, env: env}
78}
79
80// dockerPart is one piece of a docker command: its text in the echoed line and
81// its arguments.
82type dockerPart struct {
83 line string
84 args []string
85 env []string // NAME=value for the process of docker, see dockerCmd.env
86}
87
88func raw(args ...string) dockerPart { return dockerPart{line: strings.Join(args, " "), args: args} }
89
90// envOpt is CreateEscapedOption("-e", key): the value is read from the
91// environment of the docker process.
92func envOpt(key string) dockerPart {
93 return dockerPart{line: "-e " + escapeDockerArg(key), args: []string{"-e", key}}
94}
95
96func entrypointOpt(ep string) dockerPart {
97 return dockerPart{line: `--entrypoint "` + ep + `"`, args: []string{"--entrypoint", ep}}
98}
99
100func volumeOpt(src, dst string) dockerPart {
101 return dockerPart{
102 line: `-v "` + strings.ReplaceAll(src, `"`, `\"`) + `":"` + strings.ReplaceAll(dst, `"`, `\"`) + `"`,
103 args: []string{"-v", src + ":" + dst},
104 }
105}
106
107// sanitizeName is NameValidation.Sanitize: letters, digits (not first) and "_".
108func sanitizeName(s string) string {
109 var b strings.Builder
110 for _, r := range s {
111 switch {
112 case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r == '_':
113 b.WriteRune(r)
114 case r >= '0' && r <= '9' && b.Len() > 0:
115 b.WriteRune(r)
116 }
117 }
118 return b.String()
119}
120
121func newImageTag(label string) string {
122 return label + ":" + strings.ReplaceAll(uuid.NewString(), "-", "")
123}
124
125// splitArgs splits a command line like .NET does for the arguments of a
126// process: spaces separate, double quotes group, a backslash before a quote
127// escapes it (ArgUtil.SplitArguments is not needed: ProcessInvoker passes the
128// string to the platform, which splits it with these rules).
129func splitArgs(s string) []string {
130 var out []string
131 var cur strings.Builder
132 in, started := false, false
133 for i := 0; i < len(s); i++ {
134 c := s[i]
135 switch {
136 case c == '\\':
137 n := 0
138 for i < len(s) && s[i] == '\\' {
139 n++
140 i++
141 }
142 if i < len(s) && s[i] == '"' {
143 cur.WriteString(strings.Repeat(`\`, n/2))
144 if n%2 == 1 {
145 cur.WriteByte('"')
146 } else {
147 in = !in
148 }
149 } else {
150 cur.WriteString(strings.Repeat(`\`, n))
151 i--
152 }
153 started = true
154 case c == '"':
155 in = !in
156 started = true
157 case (c == ' ' || c == '\t') && !in:
158 if started {
159 out = append(out, cur.String())
160 cur.Reset()
161 started = false
162 }
163 default:
164 cur.WriteByte(c)
165 started = true
166 }
167 }
168 if started {
169 out = append(out, cur.String())
170 }
171 return out
172}
173
174// runDockerCmd echoes the command (##[command], ExecutionContext.Command) and
175// runs it, sending every line of its output to the log as is: the output of
176// docker is not interpreted as workflow commands (ExecuteDockerCommandAsync
177// writes it with context.Output). It returns the exit code.
178func (j *job) runDockerCmd(s *stepRun, c dockerCmd, dir string, env []string, cancel <-chan struct{}) (int, error) {
179 j.out(s.num, "##[command]"+c.line)
180 if env == nil {
181 env = j.processEnv(newOrdered())
182 }
183 env = append(slices.Clone(env), c.env...)
184 res, err := runProcess(procSpec{
185 Argv: c.argv, Dir: dir, Env: env, Credential: j.cfg.Credential,
186 OnLine: func(line string) { j.write(s.num, line) },
187 Cancel: cancel, TermAfter: j.cfg.TermAfter, KillAfter: j.cfg.KillAfter, WaitDelay: j.cfg.WaitDelay, Signal: j.cfg.Signal,
188 })
189 if err != nil {
190 return 0, err
191 }
192 if res.Cancelled {
193 return res.ExitCode, errCancelled
194 }
195 return res.ExitCode, nil
196}
197
198// errCancelled is a docker command stopped by the cancellation of the step.
199var errCancelled = ghErrorf("The operation was canceled.")
200
201func dirOrCwd(d string) string {
202 if st, err := os.Stat(d); err == nil && st.IsDir() {
203 return d
204 }
205 return ""
206}