containers_spec.go RawHistoryFolder

1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6	"context"
7	"fmt"
8	"path"
9	"regexp"
10	"strings"
11
12	"bnema.dev/ermite-actions/expr"
13	"bnema.dev/ermite-actions/template"
14	"github.com/google/uuid"
15)
16
17// This file is the model of the containers of a job (the job container and
18// the service containers): ContainerInfo.cs and the evaluation of
19// jobs.<id>.container and jobs.<id>.services
20// (PipelineTemplateConverter.ConvertToJobContainer, :248-352).
21
22// mount is MountVolume (ContainerInfo.cs:246-318).
23type mount struct {
24	source, target string
25	readOnly       bool
26	// user is the text the workflow gave (UserProvidedValue), empty for the
27	// mounts of the runner.
28	user string
29}
30
31// parseVolume is MountVolume.ParseVolumeString (ContainerInfo.cs:283-318):
32// "source:target[:ro]", "target:ro" or "target". The runner does not look at
33// the text further; docker rejects what is not valid.
34func parseVolume(v string) mount {
35	parts := strings.Split(v, ":")
36	switch len(parts) {
37	case 3:
38		return mount{source: parts[0], target: parts[1], readOnly: strings.EqualFold(parts[2], "ro")}
39	case 2:
40		if strings.EqualFold(parts[1], "ro") {
41			return mount{target: parts[0], readOnly: true}
42		}
43		return mount{source: parts[0], target: parts[1]}
44	}
45	return mount{target: v}
46}
47
48// portMapping is PortMapping: a published port as `docker port` shows it.
49type portMapping struct{ host, container, proto string }
50
51// container is ContainerInfo: what the workflow asked for and what is known
52// of the container once it runs.
53type container struct {
54	isJob bool
55	// alias is the network alias of a service (its key in services:).
56	alias string
57	// name is the container name (ContainerDisplayName).
58	name                                  string
59	image, options, entrypoint, entryArgs string
60	env                                   *ordered
61	ports                                 []string // UserPortMappings, in order, without duplicates
62	mounts                                []mount
63	credUser, credPass                    string
64	registryServer                        string
65	workDir                               string
66	id, network, runtimePath              string
67	failedInit                            bool
68	createTried, removed                  bool
69	maps                                  []pathMap // path translation, first match wins
70	portMappings                          []portMapping
71}
72
73// userMounts are the mounts the workflow listed.
74func (c *container) userMounts() []mount {
75	var out []mount
76	for _, m := range c.mounts {
77		if m.user != "" {
78			out = append(out, m)
79		}
80	}
81	return out
82}
83
84// translateToContainer is TranslateToContainerPath (ContainerInfo.cs:190-228,
85// Linux): a path equal to a mapped host directory, or below it.
86func (c *container) translateToContainer(p string) string {
87	if p == "" {
88		return p
89	}
90	for _, m := range c.maps {
91		if p == m.host {
92			return m.container
93		}
94		if strings.HasPrefix(p, m.host+"/") {
95			return m.container + p[len(m.host):]
96		}
97	}
98	return p
99}
100
101// translateToHost is TranslateToHostPath (ContainerInfo.cs:230-268) for a path
102// the container printed. An absolute path is normalised first, so that
103// "/__w/../outside/x" is "/outside/x" and is not found below /__w: the runner
104// joins the text as it is, which would read <work>/../outside/x on the host.
105// What comes out of a mapping is the host directory and a cleaned relative
106// part, so it is always inside the host directory of that mapping. Paths that
107// match no mapping are returned as given (relative ones included).
108func (c *container) translateToHost(p string) string {
109	if p == "" {
110		return p
111	}
112	if strings.HasPrefix(p, "/") {
113		p = path.Clean(p)
114	}
115	for _, m := range c.maps {
116		if p == m.container {
117			return m.host
118		}
119		if strings.HasPrefix(p, m.container+"/") {
120			return m.host + p[len(m.container):]
121		}
122	}
123	return p
124}
125
126// insideMappings reports whether a host path (cleaned) is a mapped host
127// directory or below one: the only host files a path printed by the container
128// can stand for. A path of the container that no mount covers (/etc/passwd of
129// the image) is not a file of the host.
130func (c *container) insideMappings(host string) bool {
131	host = path.Clean(host)
132	for _, m := range c.maps {
133		if host == m.host || strings.HasPrefix(host, m.host+"/") {
134			return true
135		}
136	}
137	return false
138}
139
140// resolvePathForStepHost is ContainerStepHost.ResolvePathForStepHost
141// (StepHost.cs:127-152): the path is translated when it starts with the host
142// path of one of the mounts (a plain string prefix, as in the runner).
143func (c *container) resolvePath(p string) string {
144	p = strings.Trim(p, `"`)
145	for _, m := range c.mounts {
146		if m.source != "" && strings.HasPrefix(p, m.source) {
147			return c.translateToContainer(p)
148		}
149	}
150	return p
151}
152
153// parseRegistryHost is DockerUtil.ParseRegistryHostnameFromImageName
154// (DockerUtil.cs:55-68).
155func parseRegistryHost(image string) string {
156	parts := strings.Split(image, "/")
157	if len(parts) == 2 && strings.ContainsAny(parts[0], ":.") {
158		return parts[0]
159	}
160	if len(parts) > 2 {
161		return parts[0]
162	}
163	return ""
164}
165
166// containerName is the display name `<alias>_<sanitized image>_<6 hex>`
167// (ContainerInfo.cs:44) with a fresh 32-hex alias.
168func containerName(image string) string {
169	return strings.ReplaceAll(uuid.NewString(), "-", "") + "_" + sanitizeName(image) + "_" +
170		strings.ReplaceAll(uuid.NewString(), "-", "")[:6]
171}
172
173// newContainer is the constructor of ContainerInfo (ContainerInfo.cs:35-96).
174func (j *job) newContainer(spec *containerSpec, isJob bool, alias string) *container {
175	c := &container{
176		isJob: isJob, alias: alias, image: spec.image, options: spec.options,
177		entrypoint: spec.entrypoint, entryArgs: spec.command, env: newOrdered(),
178		credUser: spec.credUser, credPass: spec.credPass, registryServer: parseRegistryHost(spec.image),
179	}
180	c.name = containerName(spec.image)
181	for _, p := range spec.env {
182		c.env.Set(p.Key, p.Value)
183	}
184	lay := j.state.Layout()
185	// The order of the mappings is the runner's (ContainerInfo.cs:55-58).
186	c.maps = []pathMap{{lay.WorkRoot, "/__w"}, {lay.ToolCache, "/__t"}, {j.externalsDir(), "/__e"}}
187	if isJob {
188		c.mounts = append(c.mounts, mount{source: "/var/run/docker.sock", target: "/var/run/docker.sock"})
189	}
190	for _, p := range spec.ports {
191		if !containsString(c.ports, p) { // UserPortMappings[port] = port
192			c.ports = append(c.ports, p)
193		}
194	}
195	for _, v := range spec.volumes {
196		m := parseVolume(v)
197		m.user = v
198		c.mounts = append(c.mounts, m)
199	}
200	c.proxyEnv(j.baseEnv)
201	return c
202}
203
204func containsString(l []string, s string) bool {
205	for _, x := range l {
206		if x == s {
207			return true
208		}
209	}
210	return false
211}
212
213// proxyEnv is UpdateWebProxyEnv (ContainerInfo.cs:353-374): the proxy
214// settings of the runner, unless the container sets them itself.
215func (c *container) proxyEnv(base []string) {
216	pick := func(names ...string) string {
217		for _, n := range names {
218			if v := strings.TrimSpace(envValue(base, n)); v != "" {
219				return v
220			}
221		}
222		return ""
223	}
224	add := func(v string, keys ...string) {
225		if v == "" {
226			return
227		}
228		for _, k := range keys {
229			if _, ok := c.env.Get(k); !ok {
230				c.env.Set(k, v)
231			}
232		}
233	}
234	add(pick("http_proxy", "HTTP_PROXY"), "HTTP_PROXY", "http_proxy")
235	add(pick("https_proxy", "HTTPS_PROXY"), "HTTPS_PROXY", "https_proxy")
236	add(pick("no_proxy", "NO_PROXY"), "NO_PROXY", "no_proxy")
237}
238
239// containerSpec is JobContainer: the evaluated container: or service.
240type containerSpec struct {
241	image, options, entrypoint, command string
242	env                                 []Pair
243	ports, volumes                      []string
244	credUser, credPass                  string
245}
246
247// asContainerString is AssertString of the converter: scalars are strings.
248func asContainerString(v expr.Value, what string) (string, error) {
249	switch v.Kind() {
250	case expr.KindArray, expr.KindDictionary:
251		return "", ghErrorf("Unexpected type '%s' encountered while reading '%s'. The type 'StringToken' was expected.", v.Kind(), what)
252	}
253	return expr.CoerceString(v), nil
254}
255
256func containerStrings(v expr.Value, what string) ([]string, error) {
257	a, ok := v.(*expr.Array)
258	if !ok {
259		return nil, ghErrorf("Unexpected type '%s' encountered while reading '%s'. The type 'SequenceToken' was expected.", v.Kind(), what)
260	}
261	var out []string
262	for _, it := range a.Items() {
263		s, err := asContainerString(it, what+" "+expr.CoerceString(it))
264		if err != nil {
265			return nil, err
266		}
267		out = append(out, s)
268	}
269	return out, nil
270}
271
272// imageRefRe is the grammar of a docker image reference
273// (github.com/distribution/reference): [domain[:port]/]path[:tag][@digest].
274// Path components may be upper case here: docker words that error itself, as
275// it does for the image of the recorded ctr-errors scenario. What matters is
276// that the text is one argument that cannot be taken for an option (the first
277// character is a letter or a digit) and holds no space or control character.
278var imageRefRe = regexp.MustCompile(
279	`^(?:(?:` + domainName + `|` + ipv6Address + `)(?::[0-9]+)?/)?` +
280		`[A-Za-z0-9]+(?:(?:[._]|__|-+)[A-Za-z0-9]+)*(?:/[A-Za-z0-9]+(?:(?:[._]|__|-+)[A-Za-z0-9]+)*)*` +
281		`(?::[A-Za-z0-9_][A-Za-z0-9_.-]{0,127})?` +
282		`(?:@[A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*:[0-9A-Fa-f]{32,})?$`)
283
284// domainName and ipv6Address are the domain grammar of distribution/reference
285// (reference/regexp.go: domainNameComponent, domainName, ipv6address),
286// copied here instead of importing the module.
287const (
288	domainNameComponent = `(?:[A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])`
289	domainName          = domainNameComponent + `(?:\.` + domainNameComponent + `)*`
290	ipv6Address         = `\[(?:[A-Fa-f0-9:]+)\]`
291)
292
293// validImageRef reports whether image can be passed to docker pull and create.
294func validImageRef(image string) bool { return len(image) <= 1024 && imageRefRe.MatchString(image) }
295
296// aliasRe is the name of a service: it becomes a network alias and is never
297// taken for an option.
298var aliasRe = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_.-]{0,62}$`)
299
300// containerIDRe is the id docker create prints.
301var containerIDRe = regexp.MustCompile(`^[0-9a-f]{64}$`)
302
303// serviceCommandAllowed is the feature flag of entrypoint: and command: of a
304// service (ExecutionContext.cs:1545: actions_service_container_command or
305// ACTIONS_SERVICE_CONTAINER_COMMAND; ermite has no feature flags).
306func (j *job) serviceCommandAllowed() bool {
307	return convertToBool(envValue(j.baseEnv, "ACTIONS_SERVICE_CONTAINER_COMMAND"))
308}
309
310// convertContainer is ConvertToJobContainer (PipelineTemplateConverter.cs:
311// 248-352) on an evaluated value. nil means no container (an empty image).
312func (j *job) convertContainer(v expr.Value, service bool) (*containerSpec, error) {
313	spec := &containerSpec{}
314	if d, ok := v.(*expr.Dictionary); ok {
315		for _, p := range d.Pairs() {
316			var err error
317			switch p.Key {
318			case "image":
319				spec.image, err = asContainerString(p.Value, "container image")
320			case "env":
321				spec.env, err = containerEnv(p.Value)
322			case "options":
323				spec.options, err = asContainerString(p.Value, "container options")
324			case "entrypoint", "command":
325				if !service || !j.serviceCommandAllowed() {
326					return nil, ghErrorf("The key '%s' is not allowed", p.Key)
327				}
328				s, e := asContainerString(p.Value, "container "+p.Key)
329				err = e
330				if p.Key == "entrypoint" {
331					spec.entrypoint = s
332				} else {
333					spec.command = s
334				}
335			case "ports":
336				spec.ports, err = containerStrings(p.Value, "container ports")
337			case "volumes":
338				spec.volumes, err = containerStrings(p.Value, "container volumes")
339			case "credentials":
340				err = containerCredentials(p.Value, spec)
341			default:
342				err = ghErrorf("Error while reading 'container key'. Unexpected value '%s'", p.Key)
343			}
344			if err != nil {
345				return nil, err
346			}
347		}
348	} else {
349		s, err := asContainerString(v, "container")
350		if err != nil {
351			return nil, err
352		}
353		spec.image = s
354	}
355	spec.image = strings.TrimPrefix(spec.image, "docker://")
356	if spec.image == "" {
357		return nil, nil
358	}
359	if !validImageRef(spec.image) {
360		return nil, ghErrorf("Invalid container image '%s'", spec.image)
361	}
362	return spec, nil
363}
364
365func containerEnv(v expr.Value) ([]Pair, error) {
366	d, ok := v.(*expr.Dictionary)
367	if !ok {
368		return nil, ghErrorf("Unexpected type '%s' encountered while reading 'container env'. The type 'MappingToken' was expected.", v.Kind())
369	}
370	var out []Pair
371	for _, p := range d.Pairs() {
372		if err := checkEnvNames([]string{p.Key}); err != nil {
373			return nil, err
374		}
375		s, err := asContainerString(p.Value, "container env "+p.Key)
376		if err != nil {
377			return nil, err
378		}
379		out = append(out, Pair{p.Key, s})
380	}
381	return out, nil
382}
383
384func containerCredentials(v expr.Value, spec *containerSpec) error {
385	d, ok := v.(*expr.Dictionary)
386	if !ok {
387		return ghErrorf("Unexpected type '%s' encountered while reading 'credentials'. The type 'MappingToken' was expected.", v.Kind())
388	}
389	for _, p := range d.Pairs() {
390		s, err := asContainerString(p.Value, p.Key)
391		if err != nil {
392			return err
393		}
394		switch p.Key {
395		case "username":
396			spec.credUser = s
397		case "password":
398			spec.credPass = s
399		default:
400			return ghErrorf("Error while reading 'credentials key %s'. Unexpected value '%s'", p.Key, p.Key)
401		}
402	}
403	return nil
404}
405
406// evalContainers evaluates container: and services: of the job
407// (JobExtension.cs:250-275) with the contexts the job has by then.
408func (j *job) evalContainers() (job *container, services []*container, err error) {
409	ctx := context.Background()
410	j.mu.Lock()
411	env := j.state.JobEnv()
412	j.mu.Unlock()
413	te := j.evalEnv(env)
414	if t := j.jb.Container; t != nil {
415		v, e := template.Evaluate(ctx, t, te)
416		if e != nil {
417			return nil, nil, e
418		}
419		if _, null := v.(expr.Null); !null {
420			spec, e := j.convertContainer(v, false)
421			if e != nil {
422				return nil, nil, e
423			}
424			if spec != nil {
425				job = j.newContainer(spec, true, "")
426			}
427		}
428	}
429	if t := j.jb.Services; t != nil {
430		v, e := template.Evaluate(ctx, t, te)
431		if e != nil {
432			return nil, nil, e
433		}
434		d, ok := v.(*expr.Dictionary)
435		if !ok {
436			if _, null := v.(expr.Null); null {
437				return job, nil, nil
438			}
439			return nil, nil, ghErrorf("Unexpected type '%s' encountered while reading 'services'. The type 'MappingToken' was expected.", v.Kind())
440		}
441		for _, p := range d.Pairs() {
442			if !aliasRe.MatchString(p.Key) {
443				return nil, nil, ghErrorf("Invalid service name '%s': use letters, digits, '_', '.' and '-', starting with a letter, a digit or '_'", p.Key)
444			}
445			spec, e := j.convertContainer(p.Value, true)
446			if e != nil {
447				return nil, nil, e
448			}
449			if spec == nil {
450				j.out(1, fmt.Sprintf("The service '%s' will not be started because the container definition has an empty image.", p.Key))
451				continue
452			}
453			services = append(services, j.newContainer(spec, false, p.Key))
454		}
455	}
456	return job, services, nil
457}

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