detached f72c62d

feat(executor): model and evaluate job container and services (CI-2 phase H)

commit
f72c62dd7dbe7a6f94a5ae3a5ca960381e535f92
author
brice · · 2026-10-14 05:36 UTC
parent
3e5ad18
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +555 -0
A internal/executor/containers_spec.go +395 -0
@@ -0,0 +1,395 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "context"
7+ "fmt"
8+ "strings"
9+
10+ "bnema.dev/ermite-actions/expr"
11+ "bnema.dev/ermite-actions/template"
12+ "github.com/google/uuid"
13+)
14+
15+// This file is the model of the containers of a job (the job container and
16+// the service containers): ContainerInfo.cs and the evaluation of
17+// jobs.<id>.container and jobs.<id>.services
18+// (PipelineTemplateConverter.ConvertToJobContainer, :248-352).
19+
20+// mount is MountVolume (ContainerInfo.cs:246-318).
21+type mount struct {
22+ source, target string
23+ readOnly bool
24+ // user is the text the workflow gave (UserProvidedValue), empty for the
25+ // mounts of the runner.
26+ user string
27+}
28+
29+// parseVolume is MountVolume.ParseVolumeString (ContainerInfo.cs:283-318):
30+// "source:target[:ro]", "target:ro" or "target". The runner does not look at
31+// the text further; docker rejects what is not valid.
32+func parseVolume(v string) mount {
33+ parts := strings.Split(v, ":")
34+ switch len(parts) {
35+ case 3:
36+ return mount{source: parts[0], target: parts[1], readOnly: strings.EqualFold(parts[2], "ro")}
37+ case 2:
38+ if strings.EqualFold(parts[1], "ro") {
39+ return mount{target: parts[0], readOnly: true}
40+ }
41+ return mount{source: parts[0], target: parts[1]}
42+ }
43+ return mount{target: v}
44+}
45+
46+// portMapping is PortMapping: a published port as `docker port` shows it.
47+type portMapping struct{ host, container, proto string }
48+
49+// container is ContainerInfo: what the workflow asked for and what is known
50+// of the container once it runs.
51+type container struct {
52+ isJob bool
53+ // alias is the network alias of a service (its key in services:).
54+ alias string
55+ // name is the container name (ContainerDisplayName).
56+ name string
57+ image, options, entrypoint, entryArgs string
58+ env *ordered
59+ ports []string // UserPortMappings, in order, without duplicates
60+ mounts []mount
61+ credUser, credPass string
62+ registryServer string
63+ workDir string
64+ id, network, runtimePath string
65+ failedInit bool
66+ maps []pathMap // path translation, first match wins
67+ portMappings []portMapping
68+}
69+
70+// userMounts are the mounts the workflow listed.
71+func (c *container) userMounts() []mount {
72+ var out []mount
73+ for _, m := range c.mounts {
74+ if m.user != "" {
75+ out = append(out, m)
76+ }
77+ }
78+ return out
79+}
80+
81+// translateToContainer is TranslateToContainerPath (ContainerInfo.cs:190-228,
82+// Linux): a path equal to a mapped host directory, or below it.
83+func (c *container) translateToContainer(p string) string {
84+ if p == "" {
85+ return p
86+ }
87+ for _, m := range c.maps {
88+ if p == m.host {
89+ return m.container
90+ }
91+ if strings.HasPrefix(p, m.host+"/") {
92+ return m.container + p[len(m.host):]
93+ }
94+ }
95+ return p
96+}
97+
98+// translateToHost is TranslateToHostPath (ContainerInfo.cs:230-268).
99+func (c *container) translateToHost(p string) string {
100+ if p == "" {
101+ return p
102+ }
103+ for _, m := range c.maps {
104+ if p == m.container {
105+ return m.host
106+ }
107+ if strings.HasPrefix(p, m.container+"/") {
108+ return m.host + p[len(m.container):]
109+ }
110+ }
111+ return p
112+}
113+
114+// resolvePathForStepHost is ContainerStepHost.ResolvePathForStepHost
115+// (StepHost.cs:127-152): the path is translated when it starts with the host
116+// path of one of the mounts (a plain string prefix, as in the runner).
117+func (c *container) resolvePath(p string) string {
118+ p = strings.Trim(p, `"`)
119+ for _, m := range c.mounts {
120+ if m.source != "" && strings.HasPrefix(p, m.source) {
121+ return c.translateToContainer(p)
122+ }
123+ }
124+ return p
125+}
126+
127+// parseRegistryHost is DockerUtil.ParseRegistryHostnameFromImageName
128+// (DockerUtil.cs:55-68).
129+func parseRegistryHost(image string) string {
130+ parts := strings.Split(image, "/")
131+ if len(parts) == 2 && strings.ContainsAny(parts[0], ":.") {
132+ return parts[0]
133+ }
134+ if len(parts) > 2 {
135+ return parts[0]
136+ }
137+ return ""
138+}
139+
140+// containerName is the display name `<alias>_<sanitized image>_<6 hex>`
141+// (ContainerInfo.cs:44) with a fresh 32-hex alias.
142+func containerName(image string) string {
143+ return strings.ReplaceAll(uuid.NewString(), "-", "") + "_" + sanitizeName(image) + "_" +
144+ strings.ReplaceAll(uuid.NewString(), "-", "")[:6]
145+}
146+
147+// newContainer is the constructor of ContainerInfo (ContainerInfo.cs:35-96).
148+func (j *job) newContainer(spec *containerSpec, isJob bool, alias string) *container {
149+ c := &container{
150+ isJob: isJob, alias: alias, image: spec.image, options: spec.options,
151+ entrypoint: spec.entrypoint, entryArgs: spec.command, env: newOrdered(),
152+ credUser: spec.credUser, credPass: spec.credPass, registryServer: parseRegistryHost(spec.image),
153+ }
154+ c.name = containerName(spec.image)
155+ for _, p := range spec.env {
156+ c.env.Set(p.Key, p.Value)
157+ }
158+ lay := j.state.Layout()
159+ // The order of the mappings is the runner's (ContainerInfo.cs:55-58).
160+ c.maps = []pathMap{{lay.WorkRoot, "/__w"}, {lay.ToolCache, "/__t"}, {j.externalsDir(), "/__e"}}
161+ if isJob {
162+ c.mounts = append(c.mounts, mount{source: "/var/run/docker.sock", target: "/var/run/docker.sock"})
163+ }
164+ for _, p := range spec.ports {
165+ if !containsString(c.ports, p) { // UserPortMappings[port] = port
166+ c.ports = append(c.ports, p)
167+ }
168+ }
169+ for _, v := range spec.volumes {
170+ m := parseVolume(v)
171+ m.user = v
172+ c.mounts = append(c.mounts, m)
173+ }
174+ c.proxyEnv(j.baseEnv)
175+ return c
176+}
177+
178+func containsString(l []string, s string) bool {
179+ for _, x := range l {
180+ if x == s {
181+ return true
182+ }
183+ }
184+ return false
185+}
186+
187+// proxyEnv is UpdateWebProxyEnv (ContainerInfo.cs:353-374): the proxy
188+// settings of the runner, unless the container sets them itself.
189+func (c *container) proxyEnv(base []string) {
190+ pick := func(names ...string) string {
191+ for _, n := range names {
192+ if v := strings.TrimSpace(envValue(base, n)); v != "" {
193+ return v
194+ }
195+ }
196+ return ""
197+ }
198+ add := func(v string, keys ...string) {
199+ if v == "" {
200+ return
201+ }
202+ for _, k := range keys {
203+ if _, ok := c.env.Get(k); !ok {
204+ c.env.Set(k, v)
205+ }
206+ }
207+ }
208+ add(pick("http_proxy", "HTTP_PROXY"), "HTTP_PROXY", "http_proxy")
209+ add(pick("https_proxy", "HTTPS_PROXY"), "HTTPS_PROXY", "https_proxy")
210+ add(pick("no_proxy", "NO_PROXY"), "NO_PROXY", "no_proxy")
211+}
212+
213+// containerSpec is JobContainer: the evaluated container: or service.
214+type containerSpec struct {
215+ image, options, entrypoint, command string
216+ env []Pair
217+ ports, volumes []string
218+ credUser, credPass string
219+}
220+
221+// asContainerString is AssertString of the converter: scalars are strings.
222+func asContainerString(v expr.Value, what string) (string, error) {
223+ switch v.Kind() {
224+ case expr.KindArray, expr.KindDictionary:
225+ return "", ghErrorf("Unexpected type '%s' encountered while reading '%s'. The type 'StringToken' was expected.", v.Kind(), what)
226+ }
227+ return expr.CoerceString(v), nil
228+}
229+
230+func containerStrings(v expr.Value, what string) ([]string, error) {
231+ a, ok := v.(*expr.Array)
232+ if !ok {
233+ return nil, ghErrorf("Unexpected type '%s' encountered while reading '%s'. The type 'SequenceToken' was expected.", v.Kind(), what)
234+ }
235+ var out []string
236+ for _, it := range a.Items() {
237+ s, err := asContainerString(it, what+" "+expr.CoerceString(it))
238+ if err != nil {
239+ return nil, err
240+ }
241+ out = append(out, s)
242+ }
243+ return out, nil
244+}
245+
246+// serviceCommandAllowed is the feature flag of entrypoint: and command: of a
247+// service (ExecutionContext.cs:1545: actions_service_container_command or
248+// ACTIONS_SERVICE_CONTAINER_COMMAND; ermite has no feature flags).
249+func (j *job) serviceCommandAllowed() bool {
250+ return convertToBool(envValue(j.baseEnv, "ACTIONS_SERVICE_CONTAINER_COMMAND"))
251+}
252+
253+// convertContainer is ConvertToJobContainer (PipelineTemplateConverter.cs:
254+// 248-352) on an evaluated value. nil means no container (an empty image).
255+func (j *job) convertContainer(v expr.Value, service bool) (*containerSpec, error) {
256+ spec := &containerSpec{}
257+ if d, ok := v.(*expr.Dictionary); ok {
258+ for _, p := range d.Pairs() {
259+ var err error
260+ switch p.Key {
261+ case "image":
262+ spec.image, err = asContainerString(p.Value, "container image")
263+ case "env":
264+ spec.env, err = containerEnv(p.Value)
265+ case "options":
266+ spec.options, err = asContainerString(p.Value, "container options")
267+ case "entrypoint", "command":
268+ if !service || !j.serviceCommandAllowed() {
269+ return nil, ghErrorf("The key '%s' is not allowed", p.Key)
270+ }
271+ s, e := asContainerString(p.Value, "container "+p.Key)
272+ err = e
273+ if p.Key == "entrypoint" {
274+ spec.entrypoint = s
275+ } else {
276+ spec.command = s
277+ }
278+ case "ports":
279+ spec.ports, err = containerStrings(p.Value, "container ports")
280+ case "volumes":
281+ spec.volumes, err = containerStrings(p.Value, "container volumes")
282+ case "credentials":
283+ err = containerCredentials(p.Value, spec)
284+ default:
285+ err = ghErrorf("Error while reading 'container key'. Unexpected value '%s'", p.Key)
286+ }
287+ if err != nil {
288+ return nil, err
289+ }
290+ }
291+ } else {
292+ s, err := asContainerString(v, "container")
293+ if err != nil {
294+ return nil, err
295+ }
296+ spec.image = s
297+ }
298+ spec.image = strings.TrimPrefix(spec.image, "docker://")
299+ if spec.image == "" {
300+ return nil, nil
301+ }
302+ // The image is one argument of docker; a leading "-" would make it an option.
303+ if strings.HasPrefix(spec.image, "-") {
304+ return nil, ghErrorf("Invalid container image '%s'", spec.image)
305+ }
306+ return spec, nil
307+}
308+
309+func containerEnv(v expr.Value) ([]Pair, error) {
310+ d, ok := v.(*expr.Dictionary)
311+ if !ok {
312+ return nil, ghErrorf("Unexpected type '%s' encountered while reading 'container env'. The type 'MappingToken' was expected.", v.Kind())
313+ }
314+ var out []Pair
315+ for _, p := range d.Pairs() {
316+ s, err := asContainerString(p.Value, "container env "+p.Key)
317+ if err != nil {
318+ return nil, err
319+ }
320+ out = append(out, Pair{p.Key, s})
321+ }
322+ return out, nil
323+}
324+
325+func containerCredentials(v expr.Value, spec *containerSpec) error {
326+ d, ok := v.(*expr.Dictionary)
327+ if !ok {
328+ return ghErrorf("Unexpected type '%s' encountered while reading 'credentials'. The type 'MappingToken' was expected.", v.Kind())
329+ }
330+ for _, p := range d.Pairs() {
331+ s, err := asContainerString(p.Value, p.Key)
332+ if err != nil {
333+ return err
334+ }
335+ switch p.Key {
336+ case "username":
337+ spec.credUser = s
338+ case "password":
339+ spec.credPass = s
340+ default:
341+ return ghErrorf("Error while reading 'credentials key %s'. Unexpected value '%s'", p.Key, p.Key)
342+ }
343+ }
344+ return nil
345+}
346+
347+// evalContainers evaluates container: and services: of the job
348+// (JobExtension.cs:250-275) with the contexts the job has by then.
349+func (j *job) evalContainers() (job *container, services []*container, err error) {
350+ ctx := context.Background()
351+ j.mu.Lock()
352+ env := j.state.JobEnv()
353+ j.mu.Unlock()
354+ te := j.evalEnv(env)
355+ if t := j.jb.Container; t != nil {
356+ v, e := template.Evaluate(ctx, t, te)
357+ if e != nil {
358+ return nil, nil, e
359+ }
360+ if _, null := v.(expr.Null); !null {
361+ spec, e := j.convertContainer(v, false)
362+ if e != nil {
363+ return nil, nil, e
364+ }
365+ if spec != nil {
366+ job = j.newContainer(spec, true, "")
367+ }
368+ }
369+ }
370+ if t := j.jb.Services; t != nil {
371+ v, e := template.Evaluate(ctx, t, te)
372+ if e != nil {
373+ return nil, nil, e
374+ }
375+ d, ok := v.(*expr.Dictionary)
376+ if !ok {
377+ if _, null := v.(expr.Null); null {
378+ return job, nil, nil
379+ }
380+ return nil, nil, ghErrorf("Unexpected type '%s' encountered while reading 'services'. The type 'MappingToken' was expected.", v.Kind())
381+ }
382+ for _, p := range d.Pairs() {
383+ spec, e := j.convertContainer(p.Value, true)
384+ if e != nil {
385+ return nil, nil, e
386+ }
387+ if spec == nil {
388+ j.out(1, fmt.Sprintf("The service '%s' will not be started because the container definition has an empty image.", p.Key))
389+ continue
390+ }
391+ services = append(services, j.newContainer(spec, false, p.Key))
392+ }
393+ }
394+ return job, services, nil
395+}
@@ -0,0 +1,395 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "context"
7+ "fmt"
8+ "strings"
9+
10+ "bnema.dev/ermite-actions/expr"
11+ "bnema.dev/ermite-actions/template"
12+ "github.com/google/uuid"
13+)
14+
15+// This file is the model of the containers of a job (the job container and
16+// the service containers): ContainerInfo.cs and the evaluation of
17+// jobs.<id>.container and jobs.<id>.services
18+// (PipelineTemplateConverter.ConvertToJobContainer, :248-352).
19+
20+// mount is MountVolume (ContainerInfo.cs:246-318).
21+type mount struct {
22+ source, target string
23+ readOnly bool
24+ // user is the text the workflow gave (UserProvidedValue), empty for the
25+ // mounts of the runner.
26+ user string
27+}
28+
29+// parseVolume is MountVolume.ParseVolumeString (ContainerInfo.cs:283-318):
30+// "source:target[:ro]", "target:ro" or "target". The runner does not look at
31+// the text further; docker rejects what is not valid.
32+func parseVolume(v string) mount {
33+ parts := strings.Split(v, ":")
34+ switch len(parts) {
35+ case 3:
36+ return mount{source: parts[0], target: parts[1], readOnly: strings.EqualFold(parts[2], "ro")}
37+ case 2:
38+ if strings.EqualFold(parts[1], "ro") {
39+ return mount{target: parts[0], readOnly: true}
40+ }
41+ return mount{source: parts[0], target: parts[1]}
42+ }
43+ return mount{target: v}
44+}
45+
46+// portMapping is PortMapping: a published port as `docker port` shows it.
47+type portMapping struct{ host, container, proto string }
48+
49+// container is ContainerInfo: what the workflow asked for and what is known
50+// of the container once it runs.
51+type container struct {
52+ isJob bool
53+ // alias is the network alias of a service (its key in services:).
54+ alias string
55+ // name is the container name (ContainerDisplayName).
56+ name string
57+ image, options, entrypoint, entryArgs string
58+ env *ordered
59+ ports []string // UserPortMappings, in order, without duplicates
60+ mounts []mount
61+ credUser, credPass string
62+ registryServer string
63+ workDir string
64+ id, network, runtimePath string
65+ failedInit bool
66+ maps []pathMap // path translation, first match wins
67+ portMappings []portMapping
68+}
69+
70+// userMounts are the mounts the workflow listed.
71+func (c *container) userMounts() []mount {
72+ var out []mount
73+ for _, m := range c.mounts {
74+ if m.user != "" {
75+ out = append(out, m)
76+ }
77+ }
78+ return out
79+}
80+
81+// translateToContainer is TranslateToContainerPath (ContainerInfo.cs:190-228,
82+// Linux): a path equal to a mapped host directory, or below it.
83+func (c *container) translateToContainer(p string) string {
84+ if p == "" {
85+ return p
86+ }
87+ for _, m := range c.maps {
88+ if p == m.host {
89+ return m.container
90+ }
91+ if strings.HasPrefix(p, m.host+"/") {
92+ return m.container + p[len(m.host):]
93+ }
94+ }
95+ return p
96+}
97+
98+// translateToHost is TranslateToHostPath (ContainerInfo.cs:230-268).
99+func (c *container) translateToHost(p string) string {
100+ if p == "" {
101+ return p
102+ }
103+ for _, m := range c.maps {
104+ if p == m.container {
105+ return m.host
106+ }
107+ if strings.HasPrefix(p, m.container+"/") {
108+ return m.host + p[len(m.container):]
109+ }
110+ }
111+ return p
112+}
113+
114+// resolvePathForStepHost is ContainerStepHost.ResolvePathForStepHost
115+// (StepHost.cs:127-152): the path is translated when it starts with the host
116+// path of one of the mounts (a plain string prefix, as in the runner).
117+func (c *container) resolvePath(p string) string {
118+ p = strings.Trim(p, `"`)
119+ for _, m := range c.mounts {
120+ if m.source != "" && strings.HasPrefix(p, m.source) {
121+ return c.translateToContainer(p)
122+ }
123+ }
124+ return p
125+}
126+
127+// parseRegistryHost is DockerUtil.ParseRegistryHostnameFromImageName
128+// (DockerUtil.cs:55-68).
129+func parseRegistryHost(image string) string {
130+ parts := strings.Split(image, "/")
131+ if len(parts) == 2 && strings.ContainsAny(parts[0], ":.") {
132+ return parts[0]
133+ }
134+ if len(parts) > 2 {
135+ return parts[0]
136+ }
137+ return ""
138+}
139+
140+// containerName is the display name `<alias>_<sanitized image>_<6 hex>`
141+// (ContainerInfo.cs:44) with a fresh 32-hex alias.
142+func containerName(image string) string {
143+ return strings.ReplaceAll(uuid.NewString(), "-", "") + "_" + sanitizeName(image) + "_" +
144+ strings.ReplaceAll(uuid.NewString(), "-", "")[:6]
145+}
146+
147+// newContainer is the constructor of ContainerInfo (ContainerInfo.cs:35-96).
148+func (j *job) newContainer(spec *containerSpec, isJob bool, alias string) *container {
149+ c := &container{
150+ isJob: isJob, alias: alias, image: spec.image, options: spec.options,
151+ entrypoint: spec.entrypoint, entryArgs: spec.command, env: newOrdered(),
152+ credUser: spec.credUser, credPass: spec.credPass, registryServer: parseRegistryHost(spec.image),
153+ }
154+ c.name = containerName(spec.image)
155+ for _, p := range spec.env {
156+ c.env.Set(p.Key, p.Value)
157+ }
158+ lay := j.state.Layout()
159+ // The order of the mappings is the runner's (ContainerInfo.cs:55-58).
160+ c.maps = []pathMap{{lay.WorkRoot, "/__w"}, {lay.ToolCache, "/__t"}, {j.externalsDir(), "/__e"}}
161+ if isJob {
162+ c.mounts = append(c.mounts, mount{source: "/var/run/docker.sock", target: "/var/run/docker.sock"})
163+ }
164+ for _, p := range spec.ports {
165+ if !containsString(c.ports, p) { // UserPortMappings[port] = port
166+ c.ports = append(c.ports, p)
167+ }
168+ }
169+ for _, v := range spec.volumes {
170+ m := parseVolume(v)
171+ m.user = v
172+ c.mounts = append(c.mounts, m)
173+ }
174+ c.proxyEnv(j.baseEnv)
175+ return c
176+}
177+
178+func containsString(l []string, s string) bool {
179+ for _, x := range l {
180+ if x == s {
181+ return true
182+ }
183+ }
184+ return false
185+}
186+
187+// proxyEnv is UpdateWebProxyEnv (ContainerInfo.cs:353-374): the proxy
188+// settings of the runner, unless the container sets them itself.
189+func (c *container) proxyEnv(base []string) {
190+ pick := func(names ...string) string {
191+ for _, n := range names {
192+ if v := strings.TrimSpace(envValue(base, n)); v != "" {
193+ return v
194+ }
195+ }
196+ return ""
197+ }
198+ add := func(v string, keys ...string) {
199+ if v == "" {
200+ return
201+ }
202+ for _, k := range keys {
203+ if _, ok := c.env.Get(k); !ok {
204+ c.env.Set(k, v)
205+ }
206+ }
207+ }
208+ add(pick("http_proxy", "HTTP_PROXY"), "HTTP_PROXY", "http_proxy")
209+ add(pick("https_proxy", "HTTPS_PROXY"), "HTTPS_PROXY", "https_proxy")
210+ add(pick("no_proxy", "NO_PROXY"), "NO_PROXY", "no_proxy")
211+}
212+
213+// containerSpec is JobContainer: the evaluated container: or service.
214+type containerSpec struct {
215+ image, options, entrypoint, command string
216+ env []Pair
217+ ports, volumes []string
218+ credUser, credPass string
219+}
220+
221+// asContainerString is AssertString of the converter: scalars are strings.
222+func asContainerString(v expr.Value, what string) (string, error) {
223+ switch v.Kind() {
224+ case expr.KindArray, expr.KindDictionary:
225+ return "", ghErrorf("Unexpected type '%s' encountered while reading '%s'. The type 'StringToken' was expected.", v.Kind(), what)
226+ }
227+ return expr.CoerceString(v), nil
228+}
229+
230+func containerStrings(v expr.Value, what string) ([]string, error) {
231+ a, ok := v.(*expr.Array)
232+ if !ok {
233+ return nil, ghErrorf("Unexpected type '%s' encountered while reading '%s'. The type 'SequenceToken' was expected.", v.Kind(), what)
234+ }
235+ var out []string
236+ for _, it := range a.Items() {
237+ s, err := asContainerString(it, what+" "+expr.CoerceString(it))
238+ if err != nil {
239+ return nil, err
240+ }
241+ out = append(out, s)
242+ }
243+ return out, nil
244+}
245+
246+// serviceCommandAllowed is the feature flag of entrypoint: and command: of a
247+// service (ExecutionContext.cs:1545: actions_service_container_command or
248+// ACTIONS_SERVICE_CONTAINER_COMMAND; ermite has no feature flags).
249+func (j *job) serviceCommandAllowed() bool {
250+ return convertToBool(envValue(j.baseEnv, "ACTIONS_SERVICE_CONTAINER_COMMAND"))
251+}
252+
253+// convertContainer is ConvertToJobContainer (PipelineTemplateConverter.cs:
254+// 248-352) on an evaluated value. nil means no container (an empty image).
255+func (j *job) convertContainer(v expr.Value, service bool) (*containerSpec, error) {
256+ spec := &containerSpec{}
257+ if d, ok := v.(*expr.Dictionary); ok {
258+ for _, p := range d.Pairs() {
259+ var err error
260+ switch p.Key {
261+ case "image":
262+ spec.image, err = asContainerString(p.Value, "container image")
263+ case "env":
264+ spec.env, err = containerEnv(p.Value)
265+ case "options":
266+ spec.options, err = asContainerString(p.Value, "container options")
267+ case "entrypoint", "command":
268+ if !service || !j.serviceCommandAllowed() {
269+ return nil, ghErrorf("The key '%s' is not allowed", p.Key)
270+ }
271+ s, e := asContainerString(p.Value, "container "+p.Key)
272+ err = e
273+ if p.Key == "entrypoint" {
274+ spec.entrypoint = s
275+ } else {
276+ spec.command = s
277+ }
278+ case "ports":
279+ spec.ports, err = containerStrings(p.Value, "container ports")
280+ case "volumes":
281+ spec.volumes, err = containerStrings(p.Value, "container volumes")
282+ case "credentials":
283+ err = containerCredentials(p.Value, spec)
284+ default:
285+ err = ghErrorf("Error while reading 'container key'. Unexpected value '%s'", p.Key)
286+ }
287+ if err != nil {
288+ return nil, err
289+ }
290+ }
291+ } else {
292+ s, err := asContainerString(v, "container")
293+ if err != nil {
294+ return nil, err
295+ }
296+ spec.image = s
297+ }
298+ spec.image = strings.TrimPrefix(spec.image, "docker://")
299+ if spec.image == "" {
300+ return nil, nil
301+ }
302+ // The image is one argument of docker; a leading "-" would make it an option.
303+ if strings.HasPrefix(spec.image, "-") {
304+ return nil, ghErrorf("Invalid container image '%s'", spec.image)
305+ }
306+ return spec, nil
307+}
308+
309+func containerEnv(v expr.Value) ([]Pair, error) {
310+ d, ok := v.(*expr.Dictionary)
311+ if !ok {
312+ return nil, ghErrorf("Unexpected type '%s' encountered while reading 'container env'. The type 'MappingToken' was expected.", v.Kind())
313+ }
314+ var out []Pair
315+ for _, p := range d.Pairs() {
316+ s, err := asContainerString(p.Value, "container env "+p.Key)
317+ if err != nil {
318+ return nil, err
319+ }
320+ out = append(out, Pair{p.Key, s})
321+ }
322+ return out, nil
323+}
324+
325+func containerCredentials(v expr.Value, spec *containerSpec) error {
326+ d, ok := v.(*expr.Dictionary)
327+ if !ok {
328+ return ghErrorf("Unexpected type '%s' encountered while reading 'credentials'. The type 'MappingToken' was expected.", v.Kind())
329+ }
330+ for _, p := range d.Pairs() {
331+ s, err := asContainerString(p.Value, p.Key)
332+ if err != nil {
333+ return err
334+ }
335+ switch p.Key {
336+ case "username":
337+ spec.credUser = s
338+ case "password":
339+ spec.credPass = s
340+ default:
341+ return ghErrorf("Error while reading 'credentials key %s'. Unexpected value '%s'", p.Key, p.Key)
342+ }
343+ }
344+ return nil
345+}
346+
347+// evalContainers evaluates container: and services: of the job
348+// (JobExtension.cs:250-275) with the contexts the job has by then.
349+func (j *job) evalContainers() (job *container, services []*container, err error) {
350+ ctx := context.Background()
351+ j.mu.Lock()
352+ env := j.state.JobEnv()
353+ j.mu.Unlock()
354+ te := j.evalEnv(env)
355+ if t := j.jb.Container; t != nil {
356+ v, e := template.Evaluate(ctx, t, te)
357+ if e != nil {
358+ return nil, nil, e
359+ }
360+ if _, null := v.(expr.Null); !null {
361+ spec, e := j.convertContainer(v, false)
362+ if e != nil {
363+ return nil, nil, e
364+ }
365+ if spec != nil {
366+ job = j.newContainer(spec, true, "")
367+ }
368+ }
369+ }
370+ if t := j.jb.Services; t != nil {
371+ v, e := template.Evaluate(ctx, t, te)
372+ if e != nil {
373+ return nil, nil, e
374+ }
375+ d, ok := v.(*expr.Dictionary)
376+ if !ok {
377+ if _, null := v.(expr.Null); null {
378+ return job, nil, nil
379+ }
380+ return nil, nil, ghErrorf("Unexpected type '%s' encountered while reading 'services'. The type 'MappingToken' was expected.", v.Kind())
381+ }
382+ for _, p := range d.Pairs() {
383+ spec, e := j.convertContainer(p.Value, true)
384+ if e != nil {
385+ return nil, nil, e
386+ }
387+ if spec == nil {
388+ j.out(1, fmt.Sprintf("The service '%s' will not be started because the container definition has an empty image.", p.Key))
389+ continue
390+ }
391+ services = append(services, j.newContainer(spec, false, p.Key))
392+ }
393+ }
394+ return job, services, nil
395+}
A internal/executor/containers_spec_test.go +97 -0
@@ -0,0 +1,97 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "strings"
7+ "testing"
8+
9+ "bnema.dev/ermite-actions/expr"
10+)
11+
12+func TestParseVolume(t *testing.T) {
13+ for in, want := range map[string]mount{
14+ "/data": {target: "/data"},
15+ "vol:/data": {source: "vol", target: "/data"},
16+ "/data:ro": {target: "/data", readOnly: true},
17+ "/h:/c:ro": {source: "/h", target: "/c", readOnly: true},
18+ "/h:/c:rw": {source: "/h", target: "/c"},
19+ "/h:/c:ro:extra": {target: "/h:/c:ro:extra"},
20+ "/data:RO": {target: "/data", readOnly: true},
21+ } {
22+ if got := parseVolume(in); got != want {
23+ t.Errorf("%q: %+v, want %+v", in, got, want)
24+ }
25+ }
26+}
27+
28+func TestContainerPathTranslation(t *testing.T) {
29+ c := &container{maps: []pathMap{{"/h/work", "/__w"}, {"/opt/tc", "/__t"}}}
30+ c.mounts = []mount{{source: "/h/work", target: "/__w"}}
31+ for in, want := range map[string]string{
32+ "/h/work": "/__w", "/h/work/_temp/x": "/__w/_temp/x", "/h/workx": "/h/workx", "": "", "/etc": "/etc",
33+ } {
34+ if got := c.translateToContainer(in); got != want {
35+ t.Errorf("%q -> %q, want %q", in, got, want)
36+ }
37+ }
38+ if got := c.translateToHost("/__w/a/b"); got != "/h/work/a/b" {
39+ t.Error(got)
40+ }
41+ // ResolvePathForStepHost translates quoted paths under a mount only.
42+ if got := c.resolvePath(`"/h/work/a"`); got != "/__w/a" {
43+ t.Error(got)
44+ }
45+ if got := c.resolvePath("/opt/tc/x"); got != "/opt/tc/x" {
46+ t.Errorf("no mount source covers it: %s", got)
47+ }
48+}
49+
50+func TestRegistryHost(t *testing.T) {
51+ for in, want := range map[string]string{
52+ "ubuntu:24.04": "", "owner/img": "", "ghcr.io/owner/img:1": "ghcr.io",
53+ "localhost:5000/img": "localhost:5000", "a/b/c": "a",
54+ } {
55+ if got := parseRegistryHost(in); got != want {
56+ t.Errorf("%q: %q want %q", in, got, want)
57+ }
58+ }
59+}
60+
61+func mustValue(t *testing.T, js string) expr.Value {
62+ t.Helper()
63+ v, err := expr.FromJSON([]byte(js))
64+ if err != nil {
65+ t.Fatal(err)
66+ }
67+ return v
68+}
69+
70+func TestConvertContainerForms(t *testing.T) {
71+ j := &job{}
72+ v := mustValue(t, `{"image":"docker://ubuntu:24.04","env":{"A":"1","B":2},"ports":[8080,"9000:9001"],"volumes":["v:/d"],"options":"--cpus 1","credentials":{"username":"u","password":"p"}}`)
73+ spec, err := j.convertContainer(v, false)
74+ if err != nil || spec.image != "ubuntu:24.04" || len(spec.env) != 2 || spec.env[1].Value != "2" ||
75+ strings.Join(spec.ports, ",") != "8080,9000:9001" || spec.credPass != "p" || spec.options != "--cpus 1" {
76+ t.Fatalf("%+v %v", spec, err)
77+ }
78+ if s, err := j.convertContainer(mustValue(t, `""`), false); s != nil || err != nil {
79+ t.Errorf("empty string: %v %v", s, err)
80+ }
81+ if s, err := j.convertContainer(mustValue(t, `{"image":""}`), true); s != nil || err != nil {
82+ t.Errorf("empty image: %v %v", s, err)
83+ }
84+ for _, bad := range []string{`{"entrypoint":"x","image":"i"}`, `{"nope":1,"image":"i"}`, `{"image":["a"]}`, `"-v"`} {
85+ if _, err := j.convertContainer(mustValue(t, bad), false); err == nil {
86+ t.Errorf("%s accepted", bad)
87+ }
88+ }
89+ // A service may only set entrypoint and command when the server allows it.
90+ if _, err := j.convertContainer(mustValue(t, `{"image":"i","command":"x"}`), true); err == nil {
91+ t.Error("command without the feature")
92+ }
93+ j.baseEnv = []string{"ACTIONS_SERVICE_CONTAINER_COMMAND=true"}
94+ if s, err := j.convertContainer(mustValue(t, `{"image":"i","command":"x","entrypoint":"e"}`), true); err != nil || s.command != "x" || s.entrypoint != "e" {
95+ t.Errorf("%+v %v", s, err)
96+ }
97+}
@@ -0,0 +1,97 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "strings"
7+ "testing"
8+
9+ "bnema.dev/ermite-actions/expr"
10+)
11+
12+func TestParseVolume(t *testing.T) {
13+ for in, want := range map[string]mount{
14+ "/data": {target: "/data"},
15+ "vol:/data": {source: "vol", target: "/data"},
16+ "/data:ro": {target: "/data", readOnly: true},
17+ "/h:/c:ro": {source: "/h", target: "/c", readOnly: true},
18+ "/h:/c:rw": {source: "/h", target: "/c"},
19+ "/h:/c:ro:extra": {target: "/h:/c:ro:extra"},
20+ "/data:RO": {target: "/data", readOnly: true},
21+ } {
22+ if got := parseVolume(in); got != want {
23+ t.Errorf("%q: %+v, want %+v", in, got, want)
24+ }
25+ }
26+}
27+
28+func TestContainerPathTranslation(t *testing.T) {
29+ c := &container{maps: []pathMap{{"/h/work", "/__w"}, {"/opt/tc", "/__t"}}}
30+ c.mounts = []mount{{source: "/h/work", target: "/__w"}}
31+ for in, want := range map[string]string{
32+ "/h/work": "/__w", "/h/work/_temp/x": "/__w/_temp/x", "/h/workx": "/h/workx", "": "", "/etc": "/etc",
33+ } {
34+ if got := c.translateToContainer(in); got != want {
35+ t.Errorf("%q -> %q, want %q", in, got, want)
36+ }
37+ }
38+ if got := c.translateToHost("/__w/a/b"); got != "/h/work/a/b" {
39+ t.Error(got)
40+ }
41+ // ResolvePathForStepHost translates quoted paths under a mount only.
42+ if got := c.resolvePath(`"/h/work/a"`); got != "/__w/a" {
43+ t.Error(got)
44+ }
45+ if got := c.resolvePath("/opt/tc/x"); got != "/opt/tc/x" {
46+ t.Errorf("no mount source covers it: %s", got)
47+ }
48+}
49+
50+func TestRegistryHost(t *testing.T) {
51+ for in, want := range map[string]string{
52+ "ubuntu:24.04": "", "owner/img": "", "ghcr.io/owner/img:1": "ghcr.io",
53+ "localhost:5000/img": "localhost:5000", "a/b/c": "a",
54+ } {
55+ if got := parseRegistryHost(in); got != want {
56+ t.Errorf("%q: %q want %q", in, got, want)
57+ }
58+ }
59+}
60+
61+func mustValue(t *testing.T, js string) expr.Value {
62+ t.Helper()
63+ v, err := expr.FromJSON([]byte(js))
64+ if err != nil {
65+ t.Fatal(err)
66+ }
67+ return v
68+}
69+
70+func TestConvertContainerForms(t *testing.T) {
71+ j := &job{}
72+ v := mustValue(t, `{"image":"docker://ubuntu:24.04","env":{"A":"1","B":2},"ports":[8080,"9000:9001"],"volumes":["v:/d"],"options":"--cpus 1","credentials":{"username":"u","password":"p"}}`)
73+ spec, err := j.convertContainer(v, false)
74+ if err != nil || spec.image != "ubuntu:24.04" || len(spec.env) != 2 || spec.env[1].Value != "2" ||
75+ strings.Join(spec.ports, ",") != "8080,9000:9001" || spec.credPass != "p" || spec.options != "--cpus 1" {
76+ t.Fatalf("%+v %v", spec, err)
77+ }
78+ if s, err := j.convertContainer(mustValue(t, `""`), false); s != nil || err != nil {
79+ t.Errorf("empty string: %v %v", s, err)
80+ }
81+ if s, err := j.convertContainer(mustValue(t, `{"image":""}`), true); s != nil || err != nil {
82+ t.Errorf("empty image: %v %v", s, err)
83+ }
84+ for _, bad := range []string{`{"entrypoint":"x","image":"i"}`, `{"nope":1,"image":"i"}`, `{"image":["a"]}`, `"-v"`} {
85+ if _, err := j.convertContainer(mustValue(t, bad), false); err == nil {
86+ t.Errorf("%s accepted", bad)
87+ }
88+ }
89+ // A service may only set entrypoint and command when the server allows it.
90+ if _, err := j.convertContainer(mustValue(t, `{"image":"i","command":"x"}`), true); err == nil {
91+ t.Error("command without the feature")
92+ }
93+ j.baseEnv = []string{"ACTIONS_SERVICE_CONTAINER_COMMAND=true"}
94+ if s, err := j.convertContainer(mustValue(t, `{"image":"i","command":"x","entrypoint":"e"}`), true); err != nil || s.command != "x" || s.entrypoint != "e" {
95+ t.Errorf("%+v %v", s, err)
96+ }
97+}
A internal/executor/contexts_containers.go +63 -0
@@ -0,0 +1,63 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import "bnema.dev/ermite-actions/expr"
6+
7+// The job.container and job.services contexts (JobContext.cs:20-47,
8+// ContainerOperationProvider.cs:96, :215-232, :237-240). The runner's
9+// JobContext.Container and .Services create their dictionary when they are
10+// read, which is why a recorded job that ran a docker action has
11+// "container": {} (act-docker-image).
12+
13+// jobSub returns the dictionary at job.<key>, creating it when missing.
14+func (s *State) jobSub(key string) *expr.Dictionary {
15+ if v, ok := s.job.Get(key); ok {
16+ if d, ok := v.(*expr.Dictionary); ok {
17+ return d
18+ }
19+ }
20+ d := expr.NewDictionary()
21+ s.job = setKey(s.job, key, d)
22+ return d
23+}
24+
25+// TouchJobContainer is the read of JobContext.Container: job.container exists
26+// from then on.
27+func (s *State) TouchJobContainer() { s.jobSub("container") }
28+
29+// SetJobContainer sets job.container.<key> ("network", "id").
30+func (s *State) SetJobContainer(key, value string) {
31+ d := s.jobSub("container")
32+ if _, ok := d.Get(key); ok {
33+ s.job = setKey(s.job, "container", replaceKey(d, key, expr.String(value)))
34+ return
35+ }
36+ d.Add(key, expr.String(value))
37+}
38+
39+// JobContainerNetwork is job.container.network, "" when it is not set.
40+func (s *State) JobContainerNetwork() string {
41+ if v, ok := s.jobSub("container").Get("network"); ok {
42+ return expr.CoerceString(v)
43+ }
44+ return ""
45+}
46+
47+// SetJobService sets job.services.<alias> ({id, ports, network}).
48+func (s *State) SetJobService(alias, id, network string, ports []portMapping) {
49+ svc := expr.NewDictionary()
50+ svc.Add("id", expr.String(id))
51+ pd := expr.NewDictionary()
52+ for _, p := range ports {
53+ pd.Add(p.container, expr.String(p.host))
54+ }
55+ svc.Add("ports", pd)
56+ svc.Add("network", expr.String(network))
57+ d := s.jobSub("services")
58+ s.job = setKey(s.job, "services", replaceKey(d, alias, svc))
59+}
60+
61+func replaceKey(d *expr.Dictionary, key string, v expr.Value) *expr.Dictionary {
62+ return setKey(d, key, v)
63+}
@@ -0,0 +1,63 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import "bnema.dev/ermite-actions/expr"
6+
7+// The job.container and job.services contexts (JobContext.cs:20-47,
8+// ContainerOperationProvider.cs:96, :215-232, :237-240). The runner's
9+// JobContext.Container and .Services create their dictionary when they are
10+// read, which is why a recorded job that ran a docker action has
11+// "container": {} (act-docker-image).
12+
13+// jobSub returns the dictionary at job.<key>, creating it when missing.
14+func (s *State) jobSub(key string) *expr.Dictionary {
15+ if v, ok := s.job.Get(key); ok {
16+ if d, ok := v.(*expr.Dictionary); ok {
17+ return d
18+ }
19+ }
20+ d := expr.NewDictionary()
21+ s.job = setKey(s.job, key, d)
22+ return d
23+}
24+
25+// TouchJobContainer is the read of JobContext.Container: job.container exists
26+// from then on.
27+func (s *State) TouchJobContainer() { s.jobSub("container") }
28+
29+// SetJobContainer sets job.container.<key> ("network", "id").
30+func (s *State) SetJobContainer(key, value string) {
31+ d := s.jobSub("container")
32+ if _, ok := d.Get(key); ok {
33+ s.job = setKey(s.job, "container", replaceKey(d, key, expr.String(value)))
34+ return
35+ }
36+ d.Add(key, expr.String(value))
37+}
38+
39+// JobContainerNetwork is job.container.network, "" when it is not set.
40+func (s *State) JobContainerNetwork() string {
41+ if v, ok := s.jobSub("container").Get("network"); ok {
42+ return expr.CoerceString(v)
43+ }
44+ return ""
45+}
46+
47+// SetJobService sets job.services.<alias> ({id, ports, network}).
48+func (s *State) SetJobService(alias, id, network string, ports []portMapping) {
49+ svc := expr.NewDictionary()
50+ svc.Add("id", expr.String(id))
51+ pd := expr.NewDictionary()
52+ for _, p := range ports {
53+ pd.Add(p.container, expr.String(p.host))
54+ }
55+ svc.Add("ports", pd)
56+ svc.Add("network", expr.String(network))
57+ d := s.jobSub("services")
58+ s.job = setKey(s.job, "services", replaceKey(d, alias, svc))
59+}
60+
61+func replaceKey(d *expr.Dictionary, key string, v expr.Value) *expr.Dictionary {
62+ return setKey(d, key, v)
63+}

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