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}