feat(executor): run steps through docker exec in the job container
- commit
- 85978ae8f73915fad1c44a0d672d2ac49ddd2436
- author
- brice · · 2026-10-14 05:50 UTC
- parent
- 596a6c3
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +232 -3
A internal/executor/containers_exec.go +97 -0
| @@ -0,0 +1,97 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "strings" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // ContainerStepHost (Runner.Worker/Handlers/StepHost.cs:102-296): the steps of | |
| 10 | + | // a job that has a container run through `docker exec` in it. | |
| 11 | + | ||
| 12 | + | // stepContainer is the job container when steps run in it, else nil. | |
| 13 | + | func (j *job) stepContainer() *container { | |
| 14 | + | if j.cs == nil { | |
| 15 | + | return nil | |
| 16 | + | } | |
| 17 | + | return j.cs.job | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // containerReady is the check of ActionRunner.cs:157-164: the container must | |
| 21 | + | // exist. After a failed "Initialize containers" an always() step gets the | |
| 22 | + | // runner's error. | |
| 23 | + | func (j *job) containerReady() error { | |
| 24 | + | if c := j.stepContainer(); c != nil && c.id == "" { | |
| 25 | + | return ghErrorf("Value cannot be null. (Parameter 'ContainerId')") | |
| 26 | + | } | |
| 27 | + | return nil | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // hostEnv is the environment a handler gives its process: the step's | |
| 31 | + | // variables then the runtime variables of the contexts (GITHUB_*, RUNNER_*) | |
| 32 | + | // (ScriptHandler.cs:289-299). It is what `docker exec -e` passes on. | |
| 33 | + | func (j *job) handlerEnv(stepEnv *ordered) *ordered { | |
| 34 | + | env := stepEnv.Clone() | |
| 35 | + | j.mu.Lock() | |
| 36 | + | rt := j.state.RuntimeEnv() | |
| 37 | + | j.mu.Unlock() | |
| 38 | + | for _, k := range rt.Keys() { | |
| 39 | + | v, _ := rt.Get(k) | |
| 40 | + | env.Set(k, v) | |
| 41 | + | } | |
| 42 | + | return env | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // execArgv is ContainerStepHost.ExecuteAsync (StepHost.cs:196-246): | |
| 46 | + | // `docker exec -i --workdir <dir> -e NAME... [-e PATH="<prepend>:<PATH>"] <id> | |
| 47 | + | // <command> <arguments>`. The values of the variables are not on the command | |
| 48 | + | // line: -e NAME takes them from the environment of docker, which gets them | |
| 49 | + | // with the host paths translated to the container's (:248-254). args are the | |
| 50 | + | // arguments of the command, already split. | |
| 51 | + | func (j *job) execArgv(c *container, workDir string, env *ordered, command string, args []string) (argv []string, procEnv []string) { | |
| 52 | + | argv = []string{j.dockerPath(), "exec", "-i", "--workdir", workDir} | |
| 53 | + | cenv := newOrdered() | |
| 54 | + | for _, k := range env.Keys() { | |
| 55 | + | v, _ := env.Get(k) | |
| 56 | + | argv = append(argv, "-e", k) | |
| 57 | + | cenv.Set(k, c.translateToContainer(v)) | |
| 58 | + | } | |
| 59 | + | j.mu.Lock() | |
| 60 | + | prepend := j.state.Prepend() | |
| 61 | + | j.mu.Unlock() | |
| 62 | + | if len(prepend) > 0 { | |
| 63 | + | rev := make([]string, len(prepend)) | |
| 64 | + | for i, p := range prepend { | |
| 65 | + | rev[len(prepend)-1-i] = p | |
| 66 | + | } | |
| 67 | + | full := strings.Join(rev, ":") | |
| 68 | + | if c.runtimePath != "" { | |
| 69 | + | full += ":" + c.runtimePath | |
| 70 | + | } | |
| 71 | + | argv = append(argv, "-e", "PATH="+full) | |
| 72 | + | } | |
| 73 | + | argv = append(argv, c.id, command) | |
| 74 | + | argv = append(argv, args...) | |
| 75 | + | return argv, j.dockerProcessEnv(cenv) | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // containerScript prepares a run step for the container: the script is | |
| 79 | + | // written on the host in RUNNER_TEMP, which the container has at /__w/_temp, | |
| 80 | + | // and the shell gets that path. | |
| 81 | + | func (j *job) containerScript(c *container, plan ShellPlan, in scriptIn, s *stepRun) (argv []string, err error) { | |
| 82 | + | sc, err := WriteScriptFor(j.tempRoot, plan, in.script, c.translateToContainer) | |
| 83 | + | if err != nil { | |
| 84 | + | return nil, err | |
| 85 | + | } | |
| 86 | + | j.mu.Lock() | |
| 87 | + | workspace := j.state.GitHub("workspace") | |
| 88 | + | j.mu.Unlock() | |
| 89 | + | wd := c.resolvePath(ResolveWorkingDirectory(workspace, in.workingDirectory)) | |
| 90 | + | var rest []string | |
| 91 | + | if len(sc.Argv) > 1 { | |
| 92 | + | rest = sc.Argv[1:] | |
| 93 | + | } | |
| 94 | + | a, penv := j.execArgv(c, wd, j.handlerEnv(s.env), plan.CommandPath, rest) | |
| 95 | + | s.rawEnvList = penv | |
| 96 | + | return a, nil | |
| 97 | + | } | |
| @@ -0,0 +1,97 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package executor | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "strings" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // ContainerStepHost (Runner.Worker/Handlers/StepHost.cs:102-296): the steps of | |||
| 10 | + | // a job that has a container run through `docker exec` in it. | |||
| 11 | + | ||||
| 12 | + | // stepContainer is the job container when steps run in it, else nil. | |||
| 13 | + | func (j *job) stepContainer() *container { | |||
| 14 | + | if j.cs == nil { | |||
| 15 | + | return nil | |||
| 16 | + | } | |||
| 17 | + | return j.cs.job | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // containerReady is the check of ActionRunner.cs:157-164: the container must | |||
| 21 | + | // exist. After a failed "Initialize containers" an always() step gets the | |||
| 22 | + | // runner's error. | |||
| 23 | + | func (j *job) containerReady() error { | |||
| 24 | + | if c := j.stepContainer(); c != nil && c.id == "" { | |||
| 25 | + | return ghErrorf("Value cannot be null. (Parameter 'ContainerId')") | |||
| 26 | + | } | |||
| 27 | + | return nil | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | // hostEnv is the environment a handler gives its process: the step's | |||
| 31 | + | // variables then the runtime variables of the contexts (GITHUB_*, RUNNER_*) | |||
| 32 | + | // (ScriptHandler.cs:289-299). It is what `docker exec -e` passes on. | |||
| 33 | + | func (j *job) handlerEnv(stepEnv *ordered) *ordered { | |||
| 34 | + | env := stepEnv.Clone() | |||
| 35 | + | j.mu.Lock() | |||
| 36 | + | rt := j.state.RuntimeEnv() | |||
| 37 | + | j.mu.Unlock() | |||
| 38 | + | for _, k := range rt.Keys() { | |||
| 39 | + | v, _ := rt.Get(k) | |||
| 40 | + | env.Set(k, v) | |||
| 41 | + | } | |||
| 42 | + | return env | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // execArgv is ContainerStepHost.ExecuteAsync (StepHost.cs:196-246): | |||
| 46 | + | // `docker exec -i --workdir <dir> -e NAME... [-e PATH="<prepend>:<PATH>"] <id> | |||
| 47 | + | // <command> <arguments>`. The values of the variables are not on the command | |||
| 48 | + | // line: -e NAME takes them from the environment of docker, which gets them | |||
| 49 | + | // with the host paths translated to the container's (:248-254). args are the | |||
| 50 | + | // arguments of the command, already split. | |||
| 51 | + | func (j *job) execArgv(c *container, workDir string, env *ordered, command string, args []string) (argv []string, procEnv []string) { | |||
| 52 | + | argv = []string{j.dockerPath(), "exec", "-i", "--workdir", workDir} | |||
| 53 | + | cenv := newOrdered() | |||
| 54 | + | for _, k := range env.Keys() { | |||
| 55 | + | v, _ := env.Get(k) | |||
| 56 | + | argv = append(argv, "-e", k) | |||
| 57 | + | cenv.Set(k, c.translateToContainer(v)) | |||
| 58 | + | } | |||
| 59 | + | j.mu.Lock() | |||
| 60 | + | prepend := j.state.Prepend() | |||
| 61 | + | j.mu.Unlock() | |||
| 62 | + | if len(prepend) > 0 { | |||
| 63 | + | rev := make([]string, len(prepend)) | |||
| 64 | + | for i, p := range prepend { | |||
| 65 | + | rev[len(prepend)-1-i] = p | |||
| 66 | + | } | |||
| 67 | + | full := strings.Join(rev, ":") | |||
| 68 | + | if c.runtimePath != "" { | |||
| 69 | + | full += ":" + c.runtimePath | |||
| 70 | + | } | |||
| 71 | + | argv = append(argv, "-e", "PATH="+full) | |||
| 72 | + | } | |||
| 73 | + | argv = append(argv, c.id, command) | |||
| 74 | + | argv = append(argv, args...) | |||
| 75 | + | return argv, j.dockerProcessEnv(cenv) | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | // containerScript prepares a run step for the container: the script is | |||
| 79 | + | // written on the host in RUNNER_TEMP, which the container has at /__w/_temp, | |||
| 80 | + | // and the shell gets that path. | |||
| 81 | + | func (j *job) containerScript(c *container, plan ShellPlan, in scriptIn, s *stepRun) (argv []string, err error) { | |||
| 82 | + | sc, err := WriteScriptFor(j.tempRoot, plan, in.script, c.translateToContainer) | |||
| 83 | + | if err != nil { | |||
| 84 | + | return nil, err | |||
| 85 | + | } | |||
| 86 | + | j.mu.Lock() | |||
| 87 | + | workspace := j.state.GitHub("workspace") | |||
| 88 | + | j.mu.Unlock() | |||
| 89 | + | wd := c.resolvePath(ResolveWorkingDirectory(workspace, in.workingDirectory)) | |||
| 90 | + | var rest []string | |||
| 91 | + | if len(sc.Argv) > 1 { | |||
| 92 | + | rest = sc.Argv[1:] | |||
| 93 | + | } | |||
| 94 | + | a, penv := j.execArgv(c, wd, j.handlerEnv(s.env), plan.CommandPath, rest) | |||
| 95 | + | s.rawEnvList = penv | |||
| 96 | + | return a, nil | |||
| 97 | + | } | |||
A internal/executor/containers_exec_test.go +87 -0
| @@ -0,0 +1,87 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // execCalls are the `docker exec` calls recorded by the fake. | |
| 11 | + | func (f *fakeCtr) execCalls(t *testing.T) [][]string { | |
| 12 | + | var out [][]string | |
| 13 | + | for _, c := range f.calls(t) { | |
| 14 | + | if c[0] == "exec" { | |
| 15 | + | out = append(out, c) | |
| 16 | + | } | |
| 17 | + | } | |
| 18 | + | return out | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func TestRunStepThroughDockerExec(t *testing.T) { | |
| 22 | + | f := newFakeCtr(t) | |
| 23 | + | f.set(t, "exec_out", "hello from the container\n") | |
| 24 | + | var work string | |
| 25 | + | rec, res := ctrRun(t, f, " container:\n image: ubuntu:24.04\n env:\n CVAR: c\n", | |
| 26 | + | " - name: s\n working-directory: sub\n env:\n FOO: bar\n run: echo hi\n", func(c *Config) { work = c.Layout.WorkRoot }) | |
| 27 | + | if res.Conclusion != ConclusionSuccess { | |
| 28 | + | t.Fatalf("%+v\n%s", res, logOf(rec.step(t, "Initialize containers"))) | |
| 29 | + | } | |
| 30 | + | if got := strings.Join(rec.names(), "|"); got != "Set up job|Initialize containers|s|Stop containers|Complete job" { | |
| 31 | + | t.Fatalf("steps %s", got) | |
| 32 | + | } | |
| 33 | + | ex := f.execCalls(t) | |
| 34 | + | if len(ex) != 1 { | |
| 35 | + | t.Fatalf("exec calls %v", ex) | |
| 36 | + | } | |
| 37 | + | c := strings.Join(ex[0], "\n") | |
| 38 | + | id := strings.Repeat("0", 63) + "1" | |
| 39 | + | for _, w := range []string{ | |
| 40 | + | "exec\n-i\n--workdir\n/__w/hello/hello/sub\n-e\nFOO\n", "\n-e\nGITHUB_WORKSPACE\n", "\n-e\nGITHUB_OUTPUT\n", | |
| 41 | + | "\n" + id + "\nsh\n-e\n/__w/_temp/", | |
| 42 | + | } { | |
| 43 | + | if !strings.Contains(c, w) { | |
| 44 | + | t.Errorf("exec lacks %q:\n%s", w, c) | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | if strings.Contains(c, "bar") || strings.Contains(c, work) { | |
| 48 | + | t.Errorf("values and host paths stay off the command line:\n%s", c) | |
| 49 | + | } | |
| 50 | + | log := logOf(rec.step(t, "s")) | |
| 51 | + | if !strings.Contains(log, "shell: sh -e {0}\n") || !strings.Contains(log, "hello from the container") { | |
| 52 | + | t.Errorf("log:\n%s", log) | |
| 53 | + | } | |
| 54 | + | // The create command of the job container. | |
| 55 | + | create := logOf(rec.step(t, "Initialize containers")) | |
| 56 | + | for _, w := range []string{ | |
| 57 | + | "--workdir /__w/hello/hello --network github_network_", " -e \"CVAR=c\" -e \"HOME=/github/home\" -e GITHUB_ACTIONS=true -e CI=true -v \"/var/run/docker.sock\":\"/var/run/docker.sock\" -v \"" + work + "\":\"/__w\"", | |
| 58 | + | "\":\"/__e\":ro -v \"" + work + "/_temp\":\"/__w/_temp\"", "\":\"/__t\" -v \"" + work + "/_temp/_github_home\":\"/github/home\" -v \"" + work + "/_temp/_github_workflow\":\"/github/workflow\" --entrypoint \"tail\" ubuntu:24.04 \"-f\" \"/dev/null\"", | |
| 59 | + | } { | |
| 60 | + | if !strings.Contains(create, w) { | |
| 61 | + | t.Errorf("create lacks %q:\n%s", w, create) | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func TestRunEnvIsTranslatedForTheContainer(t *testing.T) { | |
| 67 | + | f := newFakeCtr(t) | |
| 68 | + | f.set(t, "exec_env", "1") | |
| 69 | + | rec, res := ctrRun(t, f, " container: ubuntu:24.04\n", | |
| 70 | + | " - name: s\n run: echo hi\n", nil) | |
| 71 | + | if res.Conclusion != ConclusionSuccess { | |
| 72 | + | t.Fatalf("%+v %v", res, rec.names()) | |
| 73 | + | } | |
| 74 | + | // HOME is the container's own (/github/home): it is not passed with -e. | |
| 75 | + | if c := strings.Join(f.execCalls(t)[0], "\n"); strings.Contains(c, "\nHOME\n") { | |
| 76 | + | t.Errorf("HOME must not be passed:\n%s", c) | |
| 77 | + | } | |
| 78 | + | log := logOf(rec.step(t, "s")) | |
| 79 | + | for _, w := range []string{ | |
| 80 | + | "ENV GITHUB_WORKSPACE=/__w/hello/hello", "RUNNER_TEMP=/__w/_temp", | |
| 81 | + | "GITHUB_OUTPUT=/__w/_temp/_runner_file_commands/set_output_", "GITHUB_EVENT_PATH=/github/workflow/event.json", | |
| 82 | + | } { | |
| 83 | + | if !strings.Contains(log, w) { | |
| 84 | + | t.Errorf("log lacks %q:\n%s", w, log) | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | } | |
| @@ -0,0 +1,87 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package executor | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // execCalls are the `docker exec` calls recorded by the fake. | |||
| 11 | + | func (f *fakeCtr) execCalls(t *testing.T) [][]string { | |||
| 12 | + | var out [][]string | |||
| 13 | + | for _, c := range f.calls(t) { | |||
| 14 | + | if c[0] == "exec" { | |||
| 15 | + | out = append(out, c) | |||
| 16 | + | } | |||
| 17 | + | } | |||
| 18 | + | return out | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func TestRunStepThroughDockerExec(t *testing.T) { | |||
| 22 | + | f := newFakeCtr(t) | |||
| 23 | + | f.set(t, "exec_out", "hello from the container\n") | |||
| 24 | + | var work string | |||
| 25 | + | rec, res := ctrRun(t, f, " container:\n image: ubuntu:24.04\n env:\n CVAR: c\n", | |||
| 26 | + | " - name: s\n working-directory: sub\n env:\n FOO: bar\n run: echo hi\n", func(c *Config) { work = c.Layout.WorkRoot }) | |||
| 27 | + | if res.Conclusion != ConclusionSuccess { | |||
| 28 | + | t.Fatalf("%+v\n%s", res, logOf(rec.step(t, "Initialize containers"))) | |||
| 29 | + | } | |||
| 30 | + | if got := strings.Join(rec.names(), "|"); got != "Set up job|Initialize containers|s|Stop containers|Complete job" { | |||
| 31 | + | t.Fatalf("steps %s", got) | |||
| 32 | + | } | |||
| 33 | + | ex := f.execCalls(t) | |||
| 34 | + | if len(ex) != 1 { | |||
| 35 | + | t.Fatalf("exec calls %v", ex) | |||
| 36 | + | } | |||
| 37 | + | c := strings.Join(ex[0], "\n") | |||
| 38 | + | id := strings.Repeat("0", 63) + "1" | |||
| 39 | + | for _, w := range []string{ | |||
| 40 | + | "exec\n-i\n--workdir\n/__w/hello/hello/sub\n-e\nFOO\n", "\n-e\nGITHUB_WORKSPACE\n", "\n-e\nGITHUB_OUTPUT\n", | |||
| 41 | + | "\n" + id + "\nsh\n-e\n/__w/_temp/", | |||
| 42 | + | } { | |||
| 43 | + | if !strings.Contains(c, w) { | |||
| 44 | + | t.Errorf("exec lacks %q:\n%s", w, c) | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | if strings.Contains(c, "bar") || strings.Contains(c, work) { | |||
| 48 | + | t.Errorf("values and host paths stay off the command line:\n%s", c) | |||
| 49 | + | } | |||
| 50 | + | log := logOf(rec.step(t, "s")) | |||
| 51 | + | if !strings.Contains(log, "shell: sh -e {0}\n") || !strings.Contains(log, "hello from the container") { | |||
| 52 | + | t.Errorf("log:\n%s", log) | |||
| 53 | + | } | |||
| 54 | + | // The create command of the job container. | |||
| 55 | + | create := logOf(rec.step(t, "Initialize containers")) | |||
| 56 | + | for _, w := range []string{ | |||
| 57 | + | "--workdir /__w/hello/hello --network github_network_", " -e \"CVAR=c\" -e \"HOME=/github/home\" -e GITHUB_ACTIONS=true -e CI=true -v \"/var/run/docker.sock\":\"/var/run/docker.sock\" -v \"" + work + "\":\"/__w\"", | |||
| 58 | + | "\":\"/__e\":ro -v \"" + work + "/_temp\":\"/__w/_temp\"", "\":\"/__t\" -v \"" + work + "/_temp/_github_home\":\"/github/home\" -v \"" + work + "/_temp/_github_workflow\":\"/github/workflow\" --entrypoint \"tail\" ubuntu:24.04 \"-f\" \"/dev/null\"", | |||
| 59 | + | } { | |||
| 60 | + | if !strings.Contains(create, w) { | |||
| 61 | + | t.Errorf("create lacks %q:\n%s", w, create) | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func TestRunEnvIsTranslatedForTheContainer(t *testing.T) { | |||
| 67 | + | f := newFakeCtr(t) | |||
| 68 | + | f.set(t, "exec_env", "1") | |||
| 69 | + | rec, res := ctrRun(t, f, " container: ubuntu:24.04\n", | |||
| 70 | + | " - name: s\n run: echo hi\n", nil) | |||
| 71 | + | if res.Conclusion != ConclusionSuccess { | |||
| 72 | + | t.Fatalf("%+v %v", res, rec.names()) | |||
| 73 | + | } | |||
| 74 | + | // HOME is the container's own (/github/home): it is not passed with -e. | |||
| 75 | + | if c := strings.Join(f.execCalls(t)[0], "\n"); strings.Contains(c, "\nHOME\n") { | |||
| 76 | + | t.Errorf("HOME must not be passed:\n%s", c) | |||
| 77 | + | } | |||
| 78 | + | log := logOf(rec.step(t, "s")) | |||
| 79 | + | for _, w := range []string{ | |||
| 80 | + | "ENV GITHUB_WORKSPACE=/__w/hello/hello", "RUNNER_TEMP=/__w/_temp", | |||
| 81 | + | "GITHUB_OUTPUT=/__w/_temp/_runner_file_commands/set_output_", "GITHUB_EVENT_PATH=/github/workflow/event.json", | |||
| 82 | + | } { | |||
| 83 | + | if !strings.Contains(log, w) { | |||
| 84 | + | t.Errorf("log lacks %q:\n%s", w, log) | |||
| 85 | + | } | |||
| 86 | + | } | |||
| 87 | + | } | |||
M internal/executor/containers_fake_test.go +2 -1
| @@ -47,7 +47,8 @@ inspect) case "$*" in | |||
| 47 | 47 | port) [ -f $D/ports ] && cat $D/ports ;; | |
| 48 | 48 | logs) echo "service log of $3" ;; | |
| 49 | 49 | rm) echo "$3" ;; | |
| 50 | - | exec) [ -f $D/hang_exec ] && { trap 'exit 130' INT TERM; while :; do sleep 0.05; done; } | |
| 50 | + | exec) [ -f $D/exec_env ] && echo "ENV GITHUB_WORKSPACE=$GITHUB_WORKSPACE RUNNER_TEMP=$RUNNER_TEMP HOME=${HOME:-<unset>} GITHUB_OUTPUT=$GITHUB_OUTPUT GITHUB_EVENT_PATH=$GITHUB_EVENT_PATH" | |
| 51 | + | [ -f $D/hang_exec ] && { trap 'exit 130' INT TERM; while :; do sleep 0.05; done; } | |
| 51 | 52 | [ -f $D/exec_out ] && cat $D/exec_out ;; | |
| 52 | 53 | esac | |
| 53 | 54 | exit 0 | |
| @@ -47,7 +47,8 @@ inspect) case "$*" in | |||||
| 47 | port) [ -f $D/ports ] && cat $D/ports ;; | 47 | port) [ -f $D/ports ] && cat $D/ports ;; | ||
| 48 | logs) echo "service log of $3" ;; | 48 | logs) echo "service log of $3" ;; | ||
| 49 | rm) echo "$3" ;; | 49 | rm) echo "$3" ;; | ||
| 50 | - | exec) [ -f $D/hang_exec ] && { trap 'exit 130' INT TERM; while :; do sleep 0.05; done; } | 50 | + | exec) [ -f $D/exec_env ] && echo "ENV GITHUB_WORKSPACE=$GITHUB_WORKSPACE RUNNER_TEMP=$RUNNER_TEMP HOME=${HOME:-<unset>} GITHUB_OUTPUT=$GITHUB_OUTPUT GITHUB_EVENT_PATH=$GITHUB_EVENT_PATH" |
| 51 | + | [ -f $D/hang_exec ] && { trap 'exit 130' INT TERM; while :; do sleep 0.05; done; } | |||
| 51 | [ -f $D/exec_out ] && cat $D/exec_out ;; | 52 | [ -f $D/exec_out ] && cat $D/exec_out ;; | ||
| 52 | esac | 53 | esac | ||
| 53 | exit 0 | 54 | exit 0 | ||
M internal/executor/exec_step.go +17 -1
| @@ -15,6 +15,9 @@ func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) stepResult { | |||
| 15 | 15 | return j.runAction(s, stepCancel) | |
| 16 | 16 | } | |
| 17 | 17 | fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed } | |
| 18 | + | if err := j.containerReady(); err != nil { | |
| 19 | + | return fail(err) | |
| 20 | + | } | |
| 18 | 21 | failTemplate := func(err error) stepResult { | |
| 19 | 22 | j.errorf(num, "%s", templateNotValid(err, true)) | |
| 20 | 23 | return resFailed | |
| @@ -34,6 +37,13 @@ func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) stepResult { | |||
| 34 | 37 | if err != nil { | |
| 35 | 38 | return fail(err) // the group stays open, like the original | |
| 36 | 39 | } | |
| 40 | + | if c := j.stepContainer(); c != nil { | |
| 41 | + | argv, err := j.containerScript(c, plan, in, s) | |
| 42 | + | if err != nil { | |
| 43 | + | return fail(err) | |
| 44 | + | } | |
| 45 | + | return j.runProc(s, argv[0], j.state.Layout().WorkRoot, argv, "", stepCancel) | |
| 46 | + | } | |
| 37 | 47 | dir, argv, err := j.scriptCommand(plan, in) | |
| 38 | 48 | if err != nil { | |
| 39 | 49 | return fail(err) | |
| @@ -120,7 +130,13 @@ func (j *job) printHeader(s *stepRun, in scriptIn) (ShellPlan, error) { | |||
| 120 | 130 | j.mu.Lock() | |
| 121 | 131 | lookup := Lookup{Path: j.lookupPath, Prepend: j.state.Prepend()} | |
| 122 | 132 | j.mu.Unlock() | |
| 123 | - | plan, err := PlanShell(in.shell, lookup) | |
| 133 | + | var plan ShellPlan | |
| 134 | + | var err error | |
| 135 | + | if j.stepContainer() != nil { | |
| 136 | + | plan, err = PlanShellInContainer(in.shell) | |
| 137 | + | } else { | |
| 138 | + | plan, err = PlanShell(in.shell, lookup) | |
| 139 | + | } | |
| 124 | 140 | if err != nil { | |
| 125 | 141 | return plan, err | |
| 126 | 142 | } | |
| @@ -15,6 +15,9 @@ func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) stepResult { | |||||
| 15 | return j.runAction(s, stepCancel) | 15 | return j.runAction(s, stepCancel) | ||
| 16 | } | 16 | } | ||
| 17 | fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed } | 17 | fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed } | ||
| 18 | + | if err := j.containerReady(); err != nil { | |||
| 19 | + | return fail(err) | |||
| 20 | + | } | |||
| 18 | failTemplate := func(err error) stepResult { | 21 | failTemplate := func(err error) stepResult { | ||
| 19 | j.errorf(num, "%s", templateNotValid(err, true)) | 22 | j.errorf(num, "%s", templateNotValid(err, true)) | ||
| 20 | return resFailed | 23 | return resFailed | ||
| @@ -34,6 +37,13 @@ func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) stepResult { | |||||
| 34 | if err != nil { | 37 | if err != nil { | ||
| 35 | return fail(err) // the group stays open, like the original | 38 | return fail(err) // the group stays open, like the original | ||
| 36 | } | 39 | } | ||
| 40 | + | if c := j.stepContainer(); c != nil { | |||
| 41 | + | argv, err := j.containerScript(c, plan, in, s) | |||
| 42 | + | if err != nil { | |||
| 43 | + | return fail(err) | |||
| 44 | + | } | |||
| 45 | + | return j.runProc(s, argv[0], j.state.Layout().WorkRoot, argv, "", stepCancel) | |||
| 46 | + | } | |||
| 37 | dir, argv, err := j.scriptCommand(plan, in) | 47 | dir, argv, err := j.scriptCommand(plan, in) | ||
| 38 | if err != nil { | 48 | if err != nil { | ||
| 39 | return fail(err) | 49 | return fail(err) | ||
| @@ -120,7 +130,13 @@ func (j *job) printHeader(s *stepRun, in scriptIn) (ShellPlan, error) { | |||||
| 120 | j.mu.Lock() | 130 | j.mu.Lock() | ||
| 121 | lookup := Lookup{Path: j.lookupPath, Prepend: j.state.Prepend()} | 131 | lookup := Lookup{Path: j.lookupPath, Prepend: j.state.Prepend()} | ||
| 122 | j.mu.Unlock() | 132 | j.mu.Unlock() | ||
| 123 | - | plan, err := PlanShell(in.shell, lookup) | 133 | + | var plan ShellPlan |
| 134 | + | var err error | |||
| 135 | + | if j.stepContainer() != nil { | |||
| 136 | + | plan, err = PlanShellInContainer(in.shell) | |||
| 137 | + | } else { | |||
| 138 | + | plan, err = PlanShell(in.shell, lookup) | |||
| 139 | + | } | |||
| 124 | if err != nil { | 140 | if err != nil { | ||
| 125 | return plan, err | 141 | return plan, err | ||
| 126 | } | 142 | } | ||
M internal/executor/shell.go +29 -1
| @@ -291,9 +291,21 @@ type Script struct { | |||
| 291 | 291 | // so the file is created through root, exclusively and without following links | |
| 292 | 292 | // (see createExcl). | |
| 293 | 293 | func WriteScript(root *os.Root, p ShellPlan, contents string) (Script, error) { | |
| 294 | + | return WriteScriptFor(root, p, contents, nil) | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | // WriteScriptFor is WriteScript for a shell that sees the file under another | |
| 298 | + | // path: tr translates the host path for the arguments (a job container sees | |
| 299 | + | // RUNNER_TEMP as /__w/_temp, ScriptHandler.cs:252). Script.Path stays the | |
| 300 | + | // host's. | |
| 301 | + | func WriteScriptFor(root *os.Root, p ShellPlan, contents string, tr func(string) string) (Script, error) { | |
| 294 | 302 | name := uuid.NewString() + p.Extension() | |
| 295 | 303 | path := filepath.Join(root.Name(), name) | |
| 296 | - | args, err := FormatArguments(p.ArgFormat, path) | |
| 304 | + | shown := path | |
| 305 | + | if tr != nil { | |
| 306 | + | shown = tr(path) | |
| 307 | + | } | |
| 308 | + | args, err := FormatArguments(p.ArgFormat, shown) | |
| 297 | 309 | if err != nil { | |
| 298 | 310 | return Script{}, err | |
| 299 | 311 | } | |
| @@ -315,3 +327,19 @@ func ResolveWorkingDirectory(workspace, dir string) string { | |||
| 315 | 327 | } | |
| 316 | 328 | return strings.TrimRight(workspace, "/") + "/" + dir | |
| 317 | 329 | } | |
| 330 | + | ||
| 331 | + | // PlanShellInContainer is the shell of a step that runs in the job container | |
| 332 | + | // (ScriptHandler.cs:73-117, :182-232 with isContainerStepHost): nothing is | |
| 333 | + | // looked up, the shell is found by the container itself, so the default is | |
| 334 | + | // always "sh -e {0}" even when the image has bash. | |
| 335 | + | func PlanShellInContainer(shell string) (ShellPlan, error) { | |
| 336 | + | if shell == "" { | |
| 337 | + | return checkFormat(ShellPlan{Command: "sh", CommandPath: "sh", ArgFormat: ScriptArgumentsFormat("sh")}) | |
| 338 | + | } | |
| 339 | + | cmd, args := ParseShellOption(shell) | |
| 340 | + | format := strings.TrimLeft(args, " \t\n\v\f\r") | |
| 341 | + | if format == "" { | |
| 342 | + | format = ScriptArgumentsFormat(cmd) | |
| 343 | + | } | |
| 344 | + | return checkFormat(ShellPlan{Command: cmd, CommandPath: cmd, ArgFormat: format}) | |
| 345 | + | } | |
| @@ -291,9 +291,21 @@ type Script struct { | |||||
| 291 | // so the file is created through root, exclusively and without following links | 291 | // so the file is created through root, exclusively and without following links | ||
| 292 | // (see createExcl). | 292 | // (see createExcl). | ||
| 293 | func WriteScript(root *os.Root, p ShellPlan, contents string) (Script, error) { | 293 | func WriteScript(root *os.Root, p ShellPlan, contents string) (Script, error) { | ||
| 294 | + | return WriteScriptFor(root, p, contents, nil) | |||
| 295 | + | } | |||
| 296 | + | ||||
| 297 | + | // WriteScriptFor is WriteScript for a shell that sees the file under another | |||
| 298 | + | // path: tr translates the host path for the arguments (a job container sees | |||
| 299 | + | // RUNNER_TEMP as /__w/_temp, ScriptHandler.cs:252). Script.Path stays the | |||
| 300 | + | // host's. | |||
| 301 | + | func WriteScriptFor(root *os.Root, p ShellPlan, contents string, tr func(string) string) (Script, error) { | |||
| 294 | name := uuid.NewString() + p.Extension() | 302 | name := uuid.NewString() + p.Extension() | ||
| 295 | path := filepath.Join(root.Name(), name) | 303 | path := filepath.Join(root.Name(), name) | ||
| 296 | - | args, err := FormatArguments(p.ArgFormat, path) | 304 | + | shown := path |
| 305 | + | if tr != nil { | |||
| 306 | + | shown = tr(path) | |||
| 307 | + | } | |||
| 308 | + | args, err := FormatArguments(p.ArgFormat, shown) | |||
| 297 | if err != nil { | 309 | if err != nil { | ||
| 298 | return Script{}, err | 310 | return Script{}, err | ||
| 299 | } | 311 | } | ||
| @@ -315,3 +327,19 @@ func ResolveWorkingDirectory(workspace, dir string) string { | |||||
| 315 | } | 327 | } | ||
| 316 | return strings.TrimRight(workspace, "/") + "/" + dir | 328 | return strings.TrimRight(workspace, "/") + "/" + dir | ||
| 317 | } | 329 | } | ||
| 330 | + | ||||
| 331 | + | // PlanShellInContainer is the shell of a step that runs in the job container | |||
| 332 | + | // (ScriptHandler.cs:73-117, :182-232 with isContainerStepHost): nothing is | |||
| 333 | + | // looked up, the shell is found by the container itself, so the default is | |||
| 334 | + | // always "sh -e {0}" even when the image has bash. | |||
| 335 | + | func PlanShellInContainer(shell string) (ShellPlan, error) { | |||
| 336 | + | if shell == "" { | |||
| 337 | + | return checkFormat(ShellPlan{Command: "sh", CommandPath: "sh", ArgFormat: ScriptArgumentsFormat("sh")}) | |||
| 338 | + | } | |||
| 339 | + | cmd, args := ParseShellOption(shell) | |||
| 340 | + | format := strings.TrimLeft(args, " \t\n\v\f\r") | |||
| 341 | + | if format == "" { | |||
| 342 | + | format = ScriptArgumentsFormat(cmd) | |||
| 343 | + | } | |||
| 344 | + | return checkFormat(ShellPlan{Command: cmd, CommandPath: cmd, ArgFormat: format}) | |||
| 345 | + | } | |||