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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | + | } | |||