v0.4.0 76a5504

actions_docker_cmd.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 11

↑↓ moveenter openesc close