detached 90f106a

feat(guest): run jobs with the executor as the runner user; the stub stays for runs without a job

commit
90f106aa598e826213cab277a7d6e6714ddf4a22
author
brice · · 2026-10-12 18:22 UTC
parent
aa5a961
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +492 -4
M internal/executor/filecmd.go +5 -0
@@ -93,6 +93,8 @@ type Sink interface {
9393 type FileCommands struct {
9494 dir string
9595 suffix string
96+ // Own, when set, gives a file or directory to the user of the steps.
97+ Own func(path string)
9698 }
9799
98100 // NewFileCommands uses dir (RUNNER_TEMP/_runner_file_commands), creating it.
@@ -125,6 +127,9 @@ func (f *FileCommands) Init() (map[string]string, error) {
125127 return nil, err
126128 }
127129 _ = fh.Close()
130+ if f.Own != nil {
131+ f.Own(p)
132+ }
128133 env[envVarName(c.ContextName)] = p
129134 }
130135 return env, nil
@@ -93,6 +93,8 @@ type Sink interface {
93 type FileCommands struct {93 type FileCommands struct {
94 dir string94 dir string
95 suffix string95 suffix string
96+ // Own, when set, gives a file or directory to the user of the steps.
97+ Own func(path string)
96 }98 }
97 99
98 // NewFileCommands uses dir (RUNNER_TEMP/_runner_file_commands), creating it.100 // NewFileCommands uses dir (RUNNER_TEMP/_runner_file_commands), creating it.
@@ -125,6 +127,9 @@ func (f *FileCommands) Init() (map[string]string, error) {
125 return nil, err127 return nil, err
126 }128 }
127 _ = fh.Close()129 _ = fh.Close()
130+ if f.Own != nil {
131+ f.Own(p)
132+ }
128 env[envVarName(c.ContextName)] = p133 env[envVarName(c.ContextName)] = p
129 }134 }
130 return env, nil135 return env, nil
M internal/executor/steps.go +32 -2
@@ -52,8 +52,15 @@ type Config struct {
5252
5353 Runner RunnerInfo
5454 RunnerVersion string
55- // Layout overrides the directory layout (tests); nil means the hosted layout.
55+ // Layout overrides the directory layout (tests); nil means the hosted layout
56+ // under Home.
5657 Layout *Layout
58+ // Home is the home directory of the user of the steps; empty means
59+ // /home/runner.
60+ Home string
61+ // ToolCache is RUNNER_TOOL_CACHE; empty means DefaultToolCache. The official
62+ // runner reads it from its own environment (HostContext.cs:411).
63+ ToolCache string
5764 // BaseEnv is the environment steps inherit; nil means os.Environ().
5865 BaseEnv []string
5966 // Debug turns ##[debug] lines on (ACTIONS_STEP_DEBUG).
@@ -324,9 +331,17 @@ func (j *job) setUp() ([]*workflow.Step, error) {
324331 if err != nil {
325332 return nil, err
326333 }
327- if cfg.Layout != nil {
334+ switch {
335+ case cfg.Layout != nil:
328336 state.WithLayout(*cfg.Layout)
329337 layout = *cfg.Layout
338+ case (cfg.Home != "" && cfg.Home != layout.Home) || cfg.ToolCache != "":
339+ home := cfg.Home
340+ if home == "" {
341+ home = layout.Home
342+ }
343+ layout = NewLayout(home, filepath.Base(layout.Workspace), cfg.ToolCache)
344+ state.WithLayout(layout)
330345 }
331346 j.state = state
332347
@@ -355,7 +370,11 @@ func (j *job) setUp() ([]*workflow.Step, error) {
355370 if err != nil {
356371 return nil, err
357372 }
373+ fc.Own = j.own
358374 j.fc = fc
375+ for _, d := range []string{layout.WorkRoot, layout.RunnerWorkspace, layout.Workspace, layout.Temp, fc.dir} {
376+ j.own(d)
377+ }
359378
360379 j.baseEnv = cfg.BaseEnv
361380 if j.baseEnv == nil {
@@ -411,6 +430,15 @@ func (j *job) setUp() ([]*workflow.Step, error) {
411430 return jb.Steps, nil
412431 }
413432
433+// own gives a file or directory created by the executor to the user the steps
434+// run as, so that scripts can write to RUNNER_TEMP, the workspace and the file
435+// command files. Best effort: without Credential nothing is done.
436+func (j *job) own(path string) {
437+ if c := j.cfg.Credential; c != nil {
438+ _ = os.Lchown(path, int(c.Uid), int(c.Gid))
439+ }
440+}
441+
414442 func clearDir(dir string) {
415443 ents, err := os.ReadDir(dir)
416444 if err != nil {
@@ -730,8 +758,10 @@ func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) (result stepResult
730758 if ev := j.state.EventJSON(); ev != "" {
731759 dir := filepath.Join(j.state.Layout().Temp, "_github_workflow")
732760 if err := os.MkdirAll(dir, 0o755); err == nil {
761+ j.own(dir)
733762 p := filepath.Join(dir, "event.json")
734763 if err := os.WriteFile(p, []byte(ev), 0o644); err == nil {
764+ j.own(p)
735765 j.state.SetGitHub("event_path", p)
736766 }
737767 }
@@ -52,8 +52,15 @@ type Config struct {
52 52
53 Runner RunnerInfo53 Runner RunnerInfo
54 RunnerVersion string54 RunnerVersion string
55- // Layout overrides the directory layout (tests); nil means the hosted layout.55+ // Layout overrides the directory layout (tests); nil means the hosted layout
56+ // under Home.
56 Layout *Layout57 Layout *Layout
58+ // Home is the home directory of the user of the steps; empty means
59+ // /home/runner.
60+ Home string
61+ // ToolCache is RUNNER_TOOL_CACHE; empty means DefaultToolCache. The official
62+ // runner reads it from its own environment (HostContext.cs:411).
63+ ToolCache string
57 // BaseEnv is the environment steps inherit; nil means os.Environ().64 // BaseEnv is the environment steps inherit; nil means os.Environ().
58 BaseEnv []string65 BaseEnv []string
59 // Debug turns ##[debug] lines on (ACTIONS_STEP_DEBUG).66 // Debug turns ##[debug] lines on (ACTIONS_STEP_DEBUG).
@@ -324,9 +331,17 @@ func (j *job) setUp() ([]*workflow.Step, error) {
324 if err != nil {331 if err != nil {
325 return nil, err332 return nil, err
326 }333 }
327- if cfg.Layout != nil {334+ switch {
335+ case cfg.Layout != nil:
328 state.WithLayout(*cfg.Layout)336 state.WithLayout(*cfg.Layout)
329 layout = *cfg.Layout337 layout = *cfg.Layout
338+ case (cfg.Home != "" && cfg.Home != layout.Home) || cfg.ToolCache != "":
339+ home := cfg.Home
340+ if home == "" {
341+ home = layout.Home
342+ }
343+ layout = NewLayout(home, filepath.Base(layout.Workspace), cfg.ToolCache)
344+ state.WithLayout(layout)
330 }345 }
331 j.state = state346 j.state = state
332 347
@@ -355,7 +370,11 @@ func (j *job) setUp() ([]*workflow.Step, error) {
355 if err != nil {370 if err != nil {
356 return nil, err371 return nil, err
357 }372 }
373+ fc.Own = j.own
358 j.fc = fc374 j.fc = fc
375+ for _, d := range []string{layout.WorkRoot, layout.RunnerWorkspace, layout.Workspace, layout.Temp, fc.dir} {
376+ j.own(d)
377+ }
359 378
360 j.baseEnv = cfg.BaseEnv379 j.baseEnv = cfg.BaseEnv
361 if j.baseEnv == nil {380 if j.baseEnv == nil {
@@ -411,6 +430,15 @@ func (j *job) setUp() ([]*workflow.Step, error) {
411 return jb.Steps, nil430 return jb.Steps, nil
412 }431 }
413 432
433+// own gives a file or directory created by the executor to the user the steps
434+// run as, so that scripts can write to RUNNER_TEMP, the workspace and the file
435+// command files. Best effort: without Credential nothing is done.
436+func (j *job) own(path string) {
437+ if c := j.cfg.Credential; c != nil {
438+ _ = os.Lchown(path, int(c.Uid), int(c.Gid))
439+ }
440+}
441+
414 func clearDir(dir string) {442 func clearDir(dir string) {
415 ents, err := os.ReadDir(dir)443 ents, err := os.ReadDir(dir)
416 if err != nil {444 if err != nil {
@@ -730,8 +758,10 @@ func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) (result stepResult
730 if ev := j.state.EventJSON(); ev != "" {758 if ev := j.state.EventJSON(); ev != "" {
731 dir := filepath.Join(j.state.Layout().Temp, "_github_workflow")759 dir := filepath.Join(j.state.Layout().Temp, "_github_workflow")
732 if err := os.MkdirAll(dir, 0o755); err == nil {760 if err := os.MkdirAll(dir, 0o755); err == nil {
761+ j.own(dir)
733 p := filepath.Join(dir, "event.json")762 p := filepath.Join(dir, "event.json")
734 if err := os.WriteFile(p, []byte(ev), 0o644); err == nil {763 if err := os.WriteFile(p, []byte(ev), 0o644); err == nil {
764+ j.own(p)
735 j.state.SetGitHub("event_path", p)765 j.state.SetGitHub("event_path", p)
736 }766 }
737 }767 }
M internal/guest/exec.go +15 -2
@@ -38,6 +38,9 @@ type ExecConfig struct {
3838 Version string
3939 // Shell runs the script as `Shell -c script`; default /bin/sh.
4040 Shell string
41+ // StepUser is the account the steps of a job run as when the executor is
42+ // root; default "runner".
43+ StepUser string
4144 // ConnectTimeout bounds the connection attempts (default 30 s).
4245 ConnectTimeout time.Duration
4346 // RunTimeout bounds the wait for the host's Run frame (default 60 s).
@@ -57,6 +60,9 @@ func (c *ExecConfig) defaults() {
5760 if c.Shell == "" {
5861 c.Shell = "/bin/sh"
5962 }
63+ if c.StepUser == "" {
64+ c.StepUser = "runner"
65+ }
6066 if c.ConnectTimeout == 0 {
6167 c.ConnectTimeout = 30 * time.Second
6268 }
@@ -77,8 +83,9 @@ func (c *ExecConfig) defaults() {
7783 }
7884 }
7985
80-// Exec is `ermite-runner exec`, the CI-1 stub executor: it connects back to the
81-// host, receives one Run, runs the script with the shell, streams stdout and
86+// Exec is `ermite-runner exec`: it connects back to the host and receives one
87+// Run. A Run with a job is executed by package executor (see runJob); a Run
88+// without one is the stub of sandbox-check, which runs the script with the shell, streams stdout and
8289 // stderr as Output frames, and ends with an Exit frame. It returns the exit code
8390 // it reported. A Cancel frame, or the end of ctx, stops the script's process
8491 // group with escalating signals (see ExecConfig).
@@ -202,6 +209,12 @@ func serve(ctx context.Context, rw io.ReadWriteCloser, cfg ExecConfig) (int, err
202209 return 1, ctx.Err()
203210 }
204211
212+ if run.GetJob() != nil {
213+ code := runJob(ctx, conn, cfg, run.GetJob())
214+ return sendExit(conn, code, nil)
215+ }
216+
217+ // Without a job this is the stub executor of sandbox-check.
205218 script := run.GetScript()
206219 if script == "" {
207220 script = DefaultScript
@@ -38,6 +38,9 @@ type ExecConfig struct {
38 Version string38 Version string
39 // Shell runs the script as `Shell -c script`; default /bin/sh.39 // Shell runs the script as `Shell -c script`; default /bin/sh.
40 Shell string40 Shell string
41+ // StepUser is the account the steps of a job run as when the executor is
42+ // root; default "runner".
43+ StepUser string
41 // ConnectTimeout bounds the connection attempts (default 30 s).44 // ConnectTimeout bounds the connection attempts (default 30 s).
42 ConnectTimeout time.Duration45 ConnectTimeout time.Duration
43 // RunTimeout bounds the wait for the host's Run frame (default 60 s).46 // RunTimeout bounds the wait for the host's Run frame (default 60 s).
@@ -57,6 +60,9 @@ func (c *ExecConfig) defaults() {
57 if c.Shell == "" {60 if c.Shell == "" {
58 c.Shell = "/bin/sh"61 c.Shell = "/bin/sh"
59 }62 }
63+ if c.StepUser == "" {
64+ c.StepUser = "runner"
65+ }
60 if c.ConnectTimeout == 0 {66 if c.ConnectTimeout == 0 {
61 c.ConnectTimeout = 30 * time.Second67 c.ConnectTimeout = 30 * time.Second
62 }68 }
@@ -77,8 +83,9 @@ func (c *ExecConfig) defaults() {
77 }83 }
78 }84 }
79 85
80-// Exec is `ermite-runner exec`, the CI-1 stub executor: it connects back to the86+// Exec is `ermite-runner exec`: it connects back to the host and receives one
81-// host, receives one Run, runs the script with the shell, streams stdout and87+// Run. A Run with a job is executed by package executor (see runJob); a Run
88+// without one is the stub of sandbox-check, which runs the script with the shell, streams stdout and
82 // stderr as Output frames, and ends with an Exit frame. It returns the exit code89 // stderr as Output frames, and ends with an Exit frame. It returns the exit code
83 // it reported. A Cancel frame, or the end of ctx, stops the script's process90 // it reported. A Cancel frame, or the end of ctx, stops the script's process
84 // group with escalating signals (see ExecConfig).91 // group with escalating signals (see ExecConfig).
@@ -202,6 +209,12 @@ func serve(ctx context.Context, rw io.ReadWriteCloser, cfg ExecConfig) (int, err
202 return 1, ctx.Err()209 return 1, ctx.Err()
203 }210 }
204 211
212+ if run.GetJob() != nil {
213+ code := runJob(ctx, conn, cfg, run.GetJob())
214+ return sendExit(conn, code, nil)
215+ }
216+
217+ // Without a job this is the stub executor of sandbox-check.
205 script := run.GetScript()218 script := run.GetScript()
206 if script == "" {219 if script == "" {
207 script = DefaultScript220 script = DefaultScript
A internal/guest/jobexec.go +224 -0
@@ -0,0 +1,224 @@
1+// SPDX-License-Identifier: MIT
2+
3+package guest
4+
5+import (
6+ "context"
7+ "os"
8+ "os/user"
9+ "strconv"
10+ "strings"
11+ "syscall"
12+ "time"
13+ "unicode/utf8"
14+
15+ "bnema.dev/ermite-runner/internal/execstream"
16+ "bnema.dev/ermite-runner/internal/execstream/execpb"
17+ "bnema.dev/ermite-runner/internal/executor"
18+)
19+
20+// MaxLogLine is the largest piece of a log line that fits an AppendLog line
21+// (64 KiB); longer lines are cut by SplitLine.
22+const MaxLogLine = 64 * 1024
23+
24+// SplitLine cuts text into pieces of at most max bytes at UTF-8 character
25+// boundaries. The result has one piece for a short line, and always at least
26+// one (an empty line is one empty piece). Invalid UTF-8 in text is replaced
27+// with U+FFFD first, because the protocol only carries valid text.
28+func SplitLine(text string, max int) []string {
29+ if !utf8.ValidString(text) {
30+ text = strings.ToValidUTF8(text, "\ufffd")
31+ }
32+ if len(text) <= max {
33+ return []string{text}
34+ }
35+ var out []string
36+ for len(text) > max {
37+ cut := max
38+ for cut > 0 && !utf8.RuneStart(text[cut]) {
39+ cut--
40+ }
41+ if cut == 0 { // cannot happen with max >= utf8.UTFMax
42+ cut = max
43+ }
44+ out = append(out, text[:cut])
45+ text = text[cut:]
46+ }
47+ return append(out, text)
48+}
49+
50+// frameReporter is the executor.Reporter that writes frames on the stream.
51+type frameReporter struct {
52+ conn *execstream.Conn
53+ // err keeps the first send error; the job goes on without a host.
54+ failed chan struct{}
55+}
56+
57+func ts(t time.Time) int64 { return t.UnixNano() }
58+
59+func conclusionProto(c executor.Conclusion) execpb.StepConclusion {
60+ switch c {
61+ case executor.ConclusionSuccess:
62+ return execpb.StepConclusion_STEP_CONCLUSION_SUCCESS
63+ case executor.ConclusionFailure:
64+ return execpb.StepConclusion_STEP_CONCLUSION_FAILURE
65+ case executor.ConclusionCancelled:
66+ return execpb.StepConclusion_STEP_CONCLUSION_CANCELLED
67+ case executor.ConclusionSkipped:
68+ return execpb.StepConclusion_STEP_CONCLUSION_SKIPPED
69+ case executor.ConclusionTimedOut:
70+ return execpb.StepConclusion_STEP_CONCLUSION_TIMED_OUT
71+ }
72+ return execpb.StepConclusion_STEP_CONCLUSION_UNSPECIFIED
73+}
74+
75+func (r *frameReporter) send(f *execpb.Frame) {
76+ if err := r.conn.Send(f); err != nil {
77+ select {
78+ case <-r.failed:
79+ default:
80+ close(r.failed)
81+ }
82+ }
83+}
84+
85+func (r *frameReporter) StepStarted(n int, name string, at time.Time) {
86+ r.send(&execpb.Frame{Body: &execpb.Frame_StepStarted{StepStarted: &execpb.StepStarted{Number: int32(n), Name: name, UnixNano: ts(at)}}})
87+}
88+
89+func (r *frameReporter) Log(n int, at time.Time, line string) {
90+ for i, piece := range SplitLine(line, MaxLogLine) {
91+ r.send(&execpb.Frame{Body: &execpb.Frame_Log{Log: &execpb.Log{Step: int32(n), UnixNano: ts(at), Text: piece, Continued: i > 0}}})
92+ }
93+}
94+
95+func (r *frameReporter) StepCompleted(n int, name string, c executor.Conclusion, at time.Time) {
96+ r.send(&execpb.Frame{Body: &execpb.Frame_StepCompleted{StepCompleted: &execpb.StepCompleted{Number: int32(n), Name: name, Conclusion: conclusionProto(c), UnixNano: ts(at)}}})
97+}
98+
99+func (r *frameReporter) JobCompleted(res executor.Result) {
100+ r.send(&execpb.Frame{Body: &execpb.Frame_JobResult{JobResult: &execpb.JobResult{
101+ Conclusion: conclusionProto(res.Conclusion), Outputs: res.Outputs, Error: res.Error, RunnerShutdown: res.RunnerShutdown,
102+ }}})
103+}
104+
105+// StepUser is the account the steps run as: uid, gid and supplementary groups,
106+// like the hosted runners' "runner" user. Nil credential means the steps run as
107+// the executor itself.
108+type StepUser struct {
109+ Credential *syscall.Credential
110+ Name, Home string
111+}
112+
113+// LookupStepUser finds the user the steps run as. When the executor is not
114+// root it cannot switch user: the steps run as the current user. When it is
115+// root and name does not exist, the steps run as root (an image without the
116+// user is a test image).
117+func LookupStepUser(name string) StepUser {
118+ if os.Geteuid() != 0 || name == "" {
119+ u := StepUser{Home: os.Getenv("HOME")}
120+ if cu, err := user.Current(); err == nil {
121+ u.Name = cu.Username
122+ if u.Home == "" {
123+ u.Home = cu.HomeDir
124+ }
125+ }
126+ return u
127+ }
128+ u, err := user.Lookup(name)
129+ if err != nil {
130+ return StepUser{Name: "root", Home: "/root"}
131+ }
132+ uid, _ := strconv.ParseUint(u.Uid, 10, 32)
133+ gid, _ := strconv.ParseUint(u.Gid, 10, 32)
134+ cred := &syscall.Credential{Uid: uint32(uid), Gid: uint32(gid)}
135+ if ids, err := u.GroupIds(); err == nil {
136+ for _, g := range ids {
137+ if n, err := strconv.ParseUint(g, 10, 32); err == nil {
138+ cred.Groups = append(cred.Groups, uint32(n))
139+ }
140+ }
141+ }
142+ return StepUser{Credential: cred, Name: u.Username, Home: u.HomeDir}
143+}
144+
145+// stepEnv is the environment the steps inherit: the executor's, without the
146+// variables of the stream, with HOME, USER and LOGNAME of the step user.
147+func stepEnv(environ []string, u StepUser) []string {
148+ var out []string
149+ for _, kv := range environ {
150+ k, _, _ := strings.Cut(kv, "=")
151+ switch {
152+ case strings.HasPrefix(k, "ERMITE_"), k == "RUNNER_TOOL_CACHE", k == "HOME", k == "USER", k == "LOGNAME":
153+ continue
154+ }
155+ out = append(out, kv)
156+ }
157+ if u.Home != "" {
158+ out = append(out, "HOME="+u.Home)
159+ }
160+ if u.Name != "" {
161+ out = append(out, "USER="+u.Name, "LOGNAME="+u.Name)
162+ }
163+ return out
164+}
165+
166+func cancelKind(k execpb.CancelKind) executor.CancelReason {
167+ switch k {
168+ case execpb.CancelKind_CANCEL_KIND_TIMEOUT:
169+ return executor.CancelTimeout
170+ case execpb.CancelKind_CANCEL_KIND_SHUTDOWN:
171+ return executor.CancelShutdown
172+ }
173+ return executor.CancelUser
174+}
175+
176+// runJob runs the job of a Run frame with the executor and reports on conn.
177+// It returns the exit code to report.
178+func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *execpb.JobSpec) int {
179+ su := LookupStepUser(cfg.StepUser)
180+ canceller := executor.NewCanceller()
181+ rep := &frameReporter{conn: conn, failed: make(chan struct{})}
182+
183+ // Frames from the host while the job runs: a Cancel stops the job; a lost
184+ // host does too, like a runner shutdown.
185+ go func() {
186+ for {
187+ f, err := conn.Recv()
188+ if err != nil {
189+ canceller.Cancel(executor.CancelShutdown)
190+ return
191+ }
192+ if c := f.GetCancel(); c != nil {
193+ canceller.Cancel(cancelKind(c.GetKind()))
194+ }
195+ }
196+ }()
197+ // The executor's own context (SIGTERM forwarded by init: the sandbox is
198+ // being stopped) is a shutdown.
199+ stop := context.AfterFunc(ctx, func() { canceller.Cancel(executor.CancelShutdown) })
200+ defer stop()
201+ go func() {
202+ select {
203+ case <-rep.failed:
204+ canceller.Cancel(executor.CancelShutdown)
205+ case <-canceller.Done():
206+ }
207+ }()
208+
209+ env := spec.GetRunnerEnvironment()
210+ res := executor.Run(canceller, executor.Config{
211+ WorkflowPath: spec.GetWorkflowPath(), WorkflowYAML: []byte(spec.GetWorkflowYaml()), JobKey: spec.GetJobKey(),
212+ ContextsJSON: spec.GetContexts(), MatrixJSON: spec.GetMatrix(), Repo: spec.GetRepo(),
213+ TimeoutMinutes: int(spec.GetTimeoutMinutes()),
214+ Runner: executor.RunnerInfo{Name: spec.GetRunnerName(), Environment: env},
215+ RunnerVersion: spec.GetRunnerVersion(),
216+ Home: su.Home, ToolCache: os.Getenv("RUNNER_TOOL_CACHE"), BaseEnv: stepEnv(os.Environ(), su), Credential: su.Credential,
217+ Debug: spec.GetDebug(), Reporter: rep,
218+ TermAfter: cfg.TermAfter, KillAfter: cfg.KillAfter, WaitDelay: cfg.WaitDelay, Signal: cfg.signal,
219+ })
220+ if res.Conclusion == executor.ConclusionSuccess {
221+ return 0
222+ }
223+ return 1
224+}
@@ -0,0 +1,224 @@
1+// SPDX-License-Identifier: MIT
2+
3+package guest
4+
5+import (
6+ "context"
7+ "os"
8+ "os/user"
9+ "strconv"
10+ "strings"
11+ "syscall"
12+ "time"
13+ "unicode/utf8"
14+
15+ "bnema.dev/ermite-runner/internal/execstream"
16+ "bnema.dev/ermite-runner/internal/execstream/execpb"
17+ "bnema.dev/ermite-runner/internal/executor"
18+)
19+
20+// MaxLogLine is the largest piece of a log line that fits an AppendLog line
21+// (64 KiB); longer lines are cut by SplitLine.
22+const MaxLogLine = 64 * 1024
23+
24+// SplitLine cuts text into pieces of at most max bytes at UTF-8 character
25+// boundaries. The result has one piece for a short line, and always at least
26+// one (an empty line is one empty piece). Invalid UTF-8 in text is replaced
27+// with U+FFFD first, because the protocol only carries valid text.
28+func SplitLine(text string, max int) []string {
29+ if !utf8.ValidString(text) {
30+ text = strings.ToValidUTF8(text, "\ufffd")
31+ }
32+ if len(text) <= max {
33+ return []string{text}
34+ }
35+ var out []string
36+ for len(text) > max {
37+ cut := max
38+ for cut > 0 && !utf8.RuneStart(text[cut]) {
39+ cut--
40+ }
41+ if cut == 0 { // cannot happen with max >= utf8.UTFMax
42+ cut = max
43+ }
44+ out = append(out, text[:cut])
45+ text = text[cut:]
46+ }
47+ return append(out, text)
48+}
49+
50+// frameReporter is the executor.Reporter that writes frames on the stream.
51+type frameReporter struct {
52+ conn *execstream.Conn
53+ // err keeps the first send error; the job goes on without a host.
54+ failed chan struct{}
55+}
56+
57+func ts(t time.Time) int64 { return t.UnixNano() }
58+
59+func conclusionProto(c executor.Conclusion) execpb.StepConclusion {
60+ switch c {
61+ case executor.ConclusionSuccess:
62+ return execpb.StepConclusion_STEP_CONCLUSION_SUCCESS
63+ case executor.ConclusionFailure:
64+ return execpb.StepConclusion_STEP_CONCLUSION_FAILURE
65+ case executor.ConclusionCancelled:
66+ return execpb.StepConclusion_STEP_CONCLUSION_CANCELLED
67+ case executor.ConclusionSkipped:
68+ return execpb.StepConclusion_STEP_CONCLUSION_SKIPPED
69+ case executor.ConclusionTimedOut:
70+ return execpb.StepConclusion_STEP_CONCLUSION_TIMED_OUT
71+ }
72+ return execpb.StepConclusion_STEP_CONCLUSION_UNSPECIFIED
73+}
74+
75+func (r *frameReporter) send(f *execpb.Frame) {
76+ if err := r.conn.Send(f); err != nil {
77+ select {
78+ case <-r.failed:
79+ default:
80+ close(r.failed)
81+ }
82+ }
83+}
84+
85+func (r *frameReporter) StepStarted(n int, name string, at time.Time) {
86+ r.send(&execpb.Frame{Body: &execpb.Frame_StepStarted{StepStarted: &execpb.StepStarted{Number: int32(n), Name: name, UnixNano: ts(at)}}})
87+}
88+
89+func (r *frameReporter) Log(n int, at time.Time, line string) {
90+ for i, piece := range SplitLine(line, MaxLogLine) {
91+ r.send(&execpb.Frame{Body: &execpb.Frame_Log{Log: &execpb.Log{Step: int32(n), UnixNano: ts(at), Text: piece, Continued: i > 0}}})
92+ }
93+}
94+
95+func (r *frameReporter) StepCompleted(n int, name string, c executor.Conclusion, at time.Time) {
96+ r.send(&execpb.Frame{Body: &execpb.Frame_StepCompleted{StepCompleted: &execpb.StepCompleted{Number: int32(n), Name: name, Conclusion: conclusionProto(c), UnixNano: ts(at)}}})
97+}
98+
99+func (r *frameReporter) JobCompleted(res executor.Result) {
100+ r.send(&execpb.Frame{Body: &execpb.Frame_JobResult{JobResult: &execpb.JobResult{
101+ Conclusion: conclusionProto(res.Conclusion), Outputs: res.Outputs, Error: res.Error, RunnerShutdown: res.RunnerShutdown,
102+ }}})
103+}
104+
105+// StepUser is the account the steps run as: uid, gid and supplementary groups,
106+// like the hosted runners' "runner" user. Nil credential means the steps run as
107+// the executor itself.
108+type StepUser struct {
109+ Credential *syscall.Credential
110+ Name, Home string
111+}
112+
113+// LookupStepUser finds the user the steps run as. When the executor is not
114+// root it cannot switch user: the steps run as the current user. When it is
115+// root and name does not exist, the steps run as root (an image without the
116+// user is a test image).
117+func LookupStepUser(name string) StepUser {
118+ if os.Geteuid() != 0 || name == "" {
119+ u := StepUser{Home: os.Getenv("HOME")}
120+ if cu, err := user.Current(); err == nil {
121+ u.Name = cu.Username
122+ if u.Home == "" {
123+ u.Home = cu.HomeDir
124+ }
125+ }
126+ return u
127+ }
128+ u, err := user.Lookup(name)
129+ if err != nil {
130+ return StepUser{Name: "root", Home: "/root"}
131+ }
132+ uid, _ := strconv.ParseUint(u.Uid, 10, 32)
133+ gid, _ := strconv.ParseUint(u.Gid, 10, 32)
134+ cred := &syscall.Credential{Uid: uint32(uid), Gid: uint32(gid)}
135+ if ids, err := u.GroupIds(); err == nil {
136+ for _, g := range ids {
137+ if n, err := strconv.ParseUint(g, 10, 32); err == nil {
138+ cred.Groups = append(cred.Groups, uint32(n))
139+ }
140+ }
141+ }
142+ return StepUser{Credential: cred, Name: u.Username, Home: u.HomeDir}
143+}
144+
145+// stepEnv is the environment the steps inherit: the executor's, without the
146+// variables of the stream, with HOME, USER and LOGNAME of the step user.
147+func stepEnv(environ []string, u StepUser) []string {
148+ var out []string
149+ for _, kv := range environ {
150+ k, _, _ := strings.Cut(kv, "=")
151+ switch {
152+ case strings.HasPrefix(k, "ERMITE_"), k == "RUNNER_TOOL_CACHE", k == "HOME", k == "USER", k == "LOGNAME":
153+ continue
154+ }
155+ out = append(out, kv)
156+ }
157+ if u.Home != "" {
158+ out = append(out, "HOME="+u.Home)
159+ }
160+ if u.Name != "" {
161+ out = append(out, "USER="+u.Name, "LOGNAME="+u.Name)
162+ }
163+ return out
164+}
165+
166+func cancelKind(k execpb.CancelKind) executor.CancelReason {
167+ switch k {
168+ case execpb.CancelKind_CANCEL_KIND_TIMEOUT:
169+ return executor.CancelTimeout
170+ case execpb.CancelKind_CANCEL_KIND_SHUTDOWN:
171+ return executor.CancelShutdown
172+ }
173+ return executor.CancelUser
174+}
175+
176+// runJob runs the job of a Run frame with the executor and reports on conn.
177+// It returns the exit code to report.
178+func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *execpb.JobSpec) int {
179+ su := LookupStepUser(cfg.StepUser)
180+ canceller := executor.NewCanceller()
181+ rep := &frameReporter{conn: conn, failed: make(chan struct{})}
182+
183+ // Frames from the host while the job runs: a Cancel stops the job; a lost
184+ // host does too, like a runner shutdown.
185+ go func() {
186+ for {
187+ f, err := conn.Recv()
188+ if err != nil {
189+ canceller.Cancel(executor.CancelShutdown)
190+ return
191+ }
192+ if c := f.GetCancel(); c != nil {
193+ canceller.Cancel(cancelKind(c.GetKind()))
194+ }
195+ }
196+ }()
197+ // The executor's own context (SIGTERM forwarded by init: the sandbox is
198+ // being stopped) is a shutdown.
199+ stop := context.AfterFunc(ctx, func() { canceller.Cancel(executor.CancelShutdown) })
200+ defer stop()
201+ go func() {
202+ select {
203+ case <-rep.failed:
204+ canceller.Cancel(executor.CancelShutdown)
205+ case <-canceller.Done():
206+ }
207+ }()
208+
209+ env := spec.GetRunnerEnvironment()
210+ res := executor.Run(canceller, executor.Config{
211+ WorkflowPath: spec.GetWorkflowPath(), WorkflowYAML: []byte(spec.GetWorkflowYaml()), JobKey: spec.GetJobKey(),
212+ ContextsJSON: spec.GetContexts(), MatrixJSON: spec.GetMatrix(), Repo: spec.GetRepo(),
213+ TimeoutMinutes: int(spec.GetTimeoutMinutes()),
214+ Runner: executor.RunnerInfo{Name: spec.GetRunnerName(), Environment: env},
215+ RunnerVersion: spec.GetRunnerVersion(),
216+ Home: su.Home, ToolCache: os.Getenv("RUNNER_TOOL_CACHE"), BaseEnv: stepEnv(os.Environ(), su), Credential: su.Credential,
217+ Debug: spec.GetDebug(), Reporter: rep,
218+ TermAfter: cfg.TermAfter, KillAfter: cfg.KillAfter, WaitDelay: cfg.WaitDelay, Signal: cfg.signal,
219+ })
220+ if res.Conclusion == executor.ConclusionSuccess {
221+ return 0
222+ }
223+ return 1
224+}
A internal/guest/jobexec_test.go +216 -0
@@ -0,0 +1,216 @@
1+// SPDX-License-Identifier: MIT
2+
3+package guest
4+
5+import (
6+ "context"
7+ "net"
8+ "os/exec"
9+ "path/filepath"
10+ "strings"
11+ "sync"
12+ "testing"
13+ "time"
14+ "unicode/utf8"
15+
16+ "bnema.dev/ermite-runner/internal/execstream"
17+ "bnema.dev/ermite-runner/internal/execstream/execpb"
18+)
19+
20+func TestSplitLine(t *testing.T) {
21+ if got := SplitLine("", 10); len(got) != 1 || got[0] != "" {
22+ t.Fatalf("empty: %q", got)
23+ }
24+ if got := SplitLine("short", 10); len(got) != 1 || got[0] != "short" {
25+ t.Fatalf("%q", got)
26+ }
27+ // Cuts at character boundaries and joins back exactly.
28+ in := strings.Repeat("é€😀a", 40) // 2, 3, 4, 1 bytes
29+ for _, max := range []int{4, 5, 7, 10, 33} {
30+ parts := SplitLine(in, max)
31+ var b strings.Builder
32+ for _, p := range parts {
33+ if len(p) > max || !utf8.ValidString(p) || p == "" {
34+ t.Fatalf("max %d: bad piece %q", max, p)
35+ }
36+ b.WriteString(p)
37+ }
38+ if b.String() != in {
39+ t.Fatalf("max %d: pieces do not rebuild the line", max)
40+ }
41+ }
42+ // Invalid UTF-8 is replaced before cutting.
43+ if got := SplitLine("a\xffb", 10); got[0] != "a\ufffdb" {
44+ t.Fatalf("%q", got)
45+ }
46+ big := SplitLine(strings.Repeat("x", 3*MaxLogLine+5), MaxLogLine)
47+ if len(big) != 4 || len(big[0]) != MaxLogLine || len(big[3]) != 5 {
48+ t.Fatalf("%d pieces", len(big))
49+ }
50+}
51+
52+// runJobSession starts Exec on a unix socket and drives it with a job.
53+func runJobSession(t *testing.T, yaml string, cancelAfter time.Duration, tweak func(*ExecConfig)) (steps []string, logs []*execpb.Log, res *execpb.JobResult, exit *execpb.Exit) {
54+ t.Helper()
55+ sock := filepath.Join(t.TempDir(), "s")
56+ ln, err := net.Listen("unix", sock)
57+ if err != nil {
58+ t.Skip(err)
59+ }
60+ defer func() { _ = ln.Close() }()
61+ home := t.TempDir()
62+ t.Setenv("HOME", home)
63+ t.Setenv("RUNNER_TOOL_CACHE", filepath.Join(home, "toolcache"))
64+ t.Setenv("ERMITE_CONNECT", "unix:"+sock)
65+ done := make(chan struct{})
66+ go func() {
67+ defer close(done)
68+ cfg := ExecConfig{Target: Target{"unix", sock}, Sandbox: "container", Version: "test", RunTimeout: 5 * time.Second,
69+ TermAfter: 200 * time.Millisecond, KillAfter: 200 * time.Millisecond, WaitDelay: 300 * time.Millisecond}
70+ if tweak != nil {
71+ tweak(&cfg)
72+ }
73+ _, _ = Exec(context.Background(), cfg)
74+ }()
75+ conn, err := ln.Accept()
76+ if err != nil {
77+ t.Fatal(err)
78+ }
79+ defer func() { _ = conn.Close() }()
80+ var cancel chan *execpb.Cancel
81+ if cancelAfter > 0 {
82+ cancel = make(chan *execpb.Cancel, 1)
83+ time.AfterFunc(cancelAfter, func() { cancel <- &execpb.Cancel{Reason: "test", Kind: execpb.CancelKind_CANCEL_KIND_USER} })
84+ }
85+ var mu sync.Mutex
86+ _, exit, err = execstream.DriveJob(conn, &execpb.Run{Job: &execpb.JobSpec{
87+ WorkflowPath: ".github/workflows/ci.yml", WorkflowYaml: yaml, JobKey: "j", Repo: "hello", RunnerName: "r", RunnerVersion: "v",
88+ Contexts: `{"github":{"repository":"octo/hello","event_name":"push","job":"j"}}`, Matrix: "{}",
89+ }}, cancel, execstream.JobHandler{
90+ StepStarted: func(s *execpb.StepStarted) {
91+ mu.Lock()
92+ steps = append(steps, s.Name)
93+ mu.Unlock()
94+ },
95+ Log: func(l *execpb.Log) { logs = append(logs, l) },
96+ JobResult: func(r *execpb.JobResult) { res = r },
97+ })
98+ if err != nil {
99+ t.Fatal(err)
100+ }
101+ <-done
102+ return
103+}
104+
105+func requireBash(t *testing.T) {
106+ t.Helper()
107+ if _, err := lookPath("bash"); err != nil {
108+ t.Skip("bash not installed")
109+ }
110+}
111+
112+func TestExecRunsAJob(t *testing.T) {
113+ requireBash(t)
114+ steps, logs, res, exit := runJobSession(t, "on: push\njobs:\n j:\n runs-on: x\n outputs:\n o: ${{ steps.s.outputs.v }}\n steps:\n - id: s\n run: |\n echo hello-from-job\n echo v=42 >> \"$GITHUB_OUTPUT\"\n", 0, nil)
115+ if res == nil || res.Conclusion != execpb.StepConclusion_STEP_CONCLUSION_SUCCESS || res.Outputs["o"] != "42" || exit.Code != 0 {
116+ t.Fatalf("res=%v exit=%v", res, exit)
117+ }
118+ if strings.Join(steps, "|") != "Set up job|Run echo hello-from-job|Complete job" {
119+ t.Fatalf("steps %v", steps)
120+ }
121+ found := false
122+ for _, l := range logs {
123+ if l.Text == "hello-from-job" && l.Step == 2 && l.UnixNano > 0 && !l.Continued {
124+ found = true
125+ }
126+ }
127+ if !found {
128+ t.Fatalf("log line missing: %d lines", len(logs))
129+ }
130+}
131+
132+func TestExecJobFailureExitsNonZero(t *testing.T) {
133+ requireBash(t)
134+ _, _, res, exit := runJobSession(t, "on: push\njobs:\n j:\n runs-on: x\n steps:\n - run: exit 4\n", 0, nil)
135+ if res.Conclusion != execpb.StepConclusion_STEP_CONCLUSION_FAILURE || exit.Code == 0 {
136+ t.Fatalf("res=%v exit=%v", res, exit)
137+ }
138+}
139+
140+func TestExecJobLongLinesAreContinued(t *testing.T) {
141+ requireBash(t)
142+ _, logs, res, _ := runJobSession(t, "on: push\njobs:\n j:\n runs-on: x\n steps:\n - run: head -c 150000 /dev/zero | tr '\\0' y; echo\n", 0, nil)
143+ if res.Conclusion != execpb.StepConclusion_STEP_CONCLUSION_SUCCESS {
144+ t.Fatalf("%v", res)
145+ }
146+ var total int
147+ var pieces int
148+ for _, l := range logs {
149+ if strings.HasPrefix(l.Text, "yyyy") {
150+ pieces++
151+ total += len(l.Text)
152+ if len(l.Text) > MaxLogLine || (pieces > 1) != l.Continued {
153+ t.Fatalf("piece %d: len %d continued %v", pieces, len(l.Text), l.Continued)
154+ }
155+ }
156+ }
157+ if pieces != 3 || total != 150000 {
158+ t.Fatalf("pieces=%d total=%d", pieces, total)
159+ }
160+}
161+
162+func TestExecJobCancel(t *testing.T) {
163+ requireBash(t)
164+ start := time.Now()
165+ _, _, res, _ := runJobSession(t, "on: push\njobs:\n j:\n runs-on: x\n steps:\n - run: |\n echo ready\n sleep 30\n", 700*time.Millisecond, nil)
166+ if res.Conclusion != execpb.StepConclusion_STEP_CONCLUSION_CANCELLED || res.RunnerShutdown {
167+ t.Fatalf("%v", res)
168+ }
169+ if time.Since(start) > 8*time.Second {
170+ t.Fatal("cancel too slow")
171+ }
172+}
173+
174+func TestExecJobHostGoneIsAShutdown(t *testing.T) {
175+ requireBash(t)
176+ sock := filepath.Join(t.TempDir(), "s")
177+ ln, err := net.Listen("unix", sock)
178+ if err != nil {
179+ t.Skip(err)
180+ }
181+ defer func() { _ = ln.Close() }()
182+ t.Setenv("HOME", t.TempDir())
183+ t.Setenv("RUNNER_TOOL_CACHE", filepath.Join(t.TempDir(), "toolcache"))
184+ done := make(chan int, 1)
185+ go func() {
186+ c, _ := Exec(context.Background(), ExecConfig{Target: Target{"unix", sock}, RunTimeout: 5 * time.Second,
187+ TermAfter: 100 * time.Millisecond, KillAfter: 100 * time.Millisecond, WaitDelay: 200 * time.Millisecond})
188+ done <- c
189+ }()
190+ conn, _ := ln.Accept()
191+ c := execstream.NewConn(conn)
192+ if _, err := c.Recv(); err != nil {
193+ t.Fatal(err)
194+ }
195+ _ = c.Send(&execpb.Frame{Body: &execpb.Frame_Run{Run: &execpb.Run{Job: &execpb.JobSpec{
196+ WorkflowYaml: "on: push\njobs:\n j:\n runs-on: x\n steps:\n - run: sleep 60\n", JobKey: "j", Repo: "hello",
197+ Contexts: `{"github":{"repository":"o/hello"}}`,
198+ }}}})
199+ time.Sleep(700 * time.Millisecond)
200+ _ = conn.Close()
201+ select {
202+ case <-done:
203+ case <-time.After(10 * time.Second):
204+ t.Fatal("the executor kept running without a host")
205+ }
206+}
207+
208+func TestStepEnv(t *testing.T) {
209+ got := stepEnv([]string{"PATH=/bin", "HOME=/root", "ERMITE_CONNECT=x", "USER=root", "RUNNER_TOOL_CACHE=/x", "FOO=1"}, StepUser{Name: "runner", Home: "/home/runner"})
210+ want := "PATH=/bin FOO=1 HOME=/home/runner USER=runner LOGNAME=runner"
211+ if strings.Join(got, " ") != want {
212+ t.Fatalf("%v", got)
213+ }
214+}
215+
216+func lookPath(name string) (string, error) { return exec.LookPath(name) }
@@ -0,0 +1,216 @@
1+// SPDX-License-Identifier: MIT
2+
3+package guest
4+
5+import (
6+ "context"
7+ "net"
8+ "os/exec"
9+ "path/filepath"
10+ "strings"
11+ "sync"
12+ "testing"
13+ "time"
14+ "unicode/utf8"
15+
16+ "bnema.dev/ermite-runner/internal/execstream"
17+ "bnema.dev/ermite-runner/internal/execstream/execpb"
18+)
19+
20+func TestSplitLine(t *testing.T) {
21+ if got := SplitLine("", 10); len(got) != 1 || got[0] != "" {
22+ t.Fatalf("empty: %q", got)
23+ }
24+ if got := SplitLine("short", 10); len(got) != 1 || got[0] != "short" {
25+ t.Fatalf("%q", got)
26+ }
27+ // Cuts at character boundaries and joins back exactly.
28+ in := strings.Repeat("é€😀a", 40) // 2, 3, 4, 1 bytes
29+ for _, max := range []int{4, 5, 7, 10, 33} {
30+ parts := SplitLine(in, max)
31+ var b strings.Builder
32+ for _, p := range parts {
33+ if len(p) > max || !utf8.ValidString(p) || p == "" {
34+ t.Fatalf("max %d: bad piece %q", max, p)
35+ }
36+ b.WriteString(p)
37+ }
38+ if b.String() != in {
39+ t.Fatalf("max %d: pieces do not rebuild the line", max)
40+ }
41+ }
42+ // Invalid UTF-8 is replaced before cutting.
43+ if got := SplitLine("a\xffb", 10); got[0] != "a\ufffdb" {
44+ t.Fatalf("%q", got)
45+ }
46+ big := SplitLine(strings.Repeat("x", 3*MaxLogLine+5), MaxLogLine)
47+ if len(big) != 4 || len(big[0]) != MaxLogLine || len(big[3]) != 5 {
48+ t.Fatalf("%d pieces", len(big))
49+ }
50+}
51+
52+// runJobSession starts Exec on a unix socket and drives it with a job.
53+func runJobSession(t *testing.T, yaml string, cancelAfter time.Duration, tweak func(*ExecConfig)) (steps []string, logs []*execpb.Log, res *execpb.JobResult, exit *execpb.Exit) {
54+ t.Helper()
55+ sock := filepath.Join(t.TempDir(), "s")
56+ ln, err := net.Listen("unix", sock)
57+ if err != nil {
58+ t.Skip(err)
59+ }
60+ defer func() { _ = ln.Close() }()
61+ home := t.TempDir()
62+ t.Setenv("HOME", home)
63+ t.Setenv("RUNNER_TOOL_CACHE", filepath.Join(home, "toolcache"))
64+ t.Setenv("ERMITE_CONNECT", "unix:"+sock)
65+ done := make(chan struct{})
66+ go func() {
67+ defer close(done)
68+ cfg := ExecConfig{Target: Target{"unix", sock}, Sandbox: "container", Version: "test", RunTimeout: 5 * time.Second,
69+ TermAfter: 200 * time.Millisecond, KillAfter: 200 * time.Millisecond, WaitDelay: 300 * time.Millisecond}
70+ if tweak != nil {
71+ tweak(&cfg)
72+ }
73+ _, _ = Exec(context.Background(), cfg)
74+ }()
75+ conn, err := ln.Accept()
76+ if err != nil {
77+ t.Fatal(err)
78+ }
79+ defer func() { _ = conn.Close() }()
80+ var cancel chan *execpb.Cancel
81+ if cancelAfter > 0 {
82+ cancel = make(chan *execpb.Cancel, 1)
83+ time.AfterFunc(cancelAfter, func() { cancel <- &execpb.Cancel{Reason: "test", Kind: execpb.CancelKind_CANCEL_KIND_USER} })
84+ }
85+ var mu sync.Mutex
86+ _, exit, err = execstream.DriveJob(conn, &execpb.Run{Job: &execpb.JobSpec{
87+ WorkflowPath: ".github/workflows/ci.yml", WorkflowYaml: yaml, JobKey: "j", Repo: "hello", RunnerName: "r", RunnerVersion: "v",
88+ Contexts: `{"github":{"repository":"octo/hello","event_name":"push","job":"j"}}`, Matrix: "{}",
89+ }}, cancel, execstream.JobHandler{
90+ StepStarted: func(s *execpb.StepStarted) {
91+ mu.Lock()
92+ steps = append(steps, s.Name)
93+ mu.Unlock()
94+ },
95+ Log: func(l *execpb.Log) { logs = append(logs, l) },
96+ JobResult: func(r *execpb.JobResult) { res = r },
97+ })
98+ if err != nil {
99+ t.Fatal(err)
100+ }
101+ <-done
102+ return
103+}
104+
105+func requireBash(t *testing.T) {
106+ t.Helper()
107+ if _, err := lookPath("bash"); err != nil {
108+ t.Skip("bash not installed")
109+ }
110+}
111+
112+func TestExecRunsAJob(t *testing.T) {
113+ requireBash(t)
114+ steps, logs, res, exit := runJobSession(t, "on: push\njobs:\n j:\n runs-on: x\n outputs:\n o: ${{ steps.s.outputs.v }}\n steps:\n - id: s\n run: |\n echo hello-from-job\n echo v=42 >> \"$GITHUB_OUTPUT\"\n", 0, nil)
115+ if res == nil || res.Conclusion != execpb.StepConclusion_STEP_CONCLUSION_SUCCESS || res.Outputs["o"] != "42" || exit.Code != 0 {
116+ t.Fatalf("res=%v exit=%v", res, exit)
117+ }
118+ if strings.Join(steps, "|") != "Set up job|Run echo hello-from-job|Complete job" {
119+ t.Fatalf("steps %v", steps)
120+ }
121+ found := false
122+ for _, l := range logs {
123+ if l.Text == "hello-from-job" && l.Step == 2 && l.UnixNano > 0 && !l.Continued {
124+ found = true
125+ }
126+ }
127+ if !found {
128+ t.Fatalf("log line missing: %d lines", len(logs))
129+ }
130+}
131+
132+func TestExecJobFailureExitsNonZero(t *testing.T) {
133+ requireBash(t)
134+ _, _, res, exit := runJobSession(t, "on: push\njobs:\n j:\n runs-on: x\n steps:\n - run: exit 4\n", 0, nil)
135+ if res.Conclusion != execpb.StepConclusion_STEP_CONCLUSION_FAILURE || exit.Code == 0 {
136+ t.Fatalf("res=%v exit=%v", res, exit)
137+ }
138+}
139+
140+func TestExecJobLongLinesAreContinued(t *testing.T) {
141+ requireBash(t)
142+ _, logs, res, _ := runJobSession(t, "on: push\njobs:\n j:\n runs-on: x\n steps:\n - run: head -c 150000 /dev/zero | tr '\\0' y; echo\n", 0, nil)
143+ if res.Conclusion != execpb.StepConclusion_STEP_CONCLUSION_SUCCESS {
144+ t.Fatalf("%v", res)
145+ }
146+ var total int
147+ var pieces int
148+ for _, l := range logs {
149+ if strings.HasPrefix(l.Text, "yyyy") {
150+ pieces++
151+ total += len(l.Text)
152+ if len(l.Text) > MaxLogLine || (pieces > 1) != l.Continued {
153+ t.Fatalf("piece %d: len %d continued %v", pieces, len(l.Text), l.Continued)
154+ }
155+ }
156+ }
157+ if pieces != 3 || total != 150000 {
158+ t.Fatalf("pieces=%d total=%d", pieces, total)
159+ }
160+}
161+
162+func TestExecJobCancel(t *testing.T) {
163+ requireBash(t)
164+ start := time.Now()
165+ _, _, res, _ := runJobSession(t, "on: push\njobs:\n j:\n runs-on: x\n steps:\n - run: |\n echo ready\n sleep 30\n", 700*time.Millisecond, nil)
166+ if res.Conclusion != execpb.StepConclusion_STEP_CONCLUSION_CANCELLED || res.RunnerShutdown {
167+ t.Fatalf("%v", res)
168+ }
169+ if time.Since(start) > 8*time.Second {
170+ t.Fatal("cancel too slow")
171+ }
172+}
173+
174+func TestExecJobHostGoneIsAShutdown(t *testing.T) {
175+ requireBash(t)
176+ sock := filepath.Join(t.TempDir(), "s")
177+ ln, err := net.Listen("unix", sock)
178+ if err != nil {
179+ t.Skip(err)
180+ }
181+ defer func() { _ = ln.Close() }()
182+ t.Setenv("HOME", t.TempDir())
183+ t.Setenv("RUNNER_TOOL_CACHE", filepath.Join(t.TempDir(), "toolcache"))
184+ done := make(chan int, 1)
185+ go func() {
186+ c, _ := Exec(context.Background(), ExecConfig{Target: Target{"unix", sock}, RunTimeout: 5 * time.Second,
187+ TermAfter: 100 * time.Millisecond, KillAfter: 100 * time.Millisecond, WaitDelay: 200 * time.Millisecond})
188+ done <- c
189+ }()
190+ conn, _ := ln.Accept()
191+ c := execstream.NewConn(conn)
192+ if _, err := c.Recv(); err != nil {
193+ t.Fatal(err)
194+ }
195+ _ = c.Send(&execpb.Frame{Body: &execpb.Frame_Run{Run: &execpb.Run{Job: &execpb.JobSpec{
196+ WorkflowYaml: "on: push\njobs:\n j:\n runs-on: x\n steps:\n - run: sleep 60\n", JobKey: "j", Repo: "hello",
197+ Contexts: `{"github":{"repository":"o/hello"}}`,
198+ }}}})
199+ time.Sleep(700 * time.Millisecond)
200+ _ = conn.Close()
201+ select {
202+ case <-done:
203+ case <-time.After(10 * time.Second):
204+ t.Fatal("the executor kept running without a host")
205+ }
206+}
207+
208+func TestStepEnv(t *testing.T) {
209+ got := stepEnv([]string{"PATH=/bin", "HOME=/root", "ERMITE_CONNECT=x", "USER=root", "RUNNER_TOOL_CACHE=/x", "FOO=1"}, StepUser{Name: "runner", Home: "/home/runner"})
210+ want := "PATH=/bin FOO=1 HOME=/home/runner USER=runner LOGNAME=runner"
211+ if strings.Join(got, " ") != want {
212+ t.Fatalf("%v", got)
213+ }
214+}
215+
216+func lookPath(name string) (string, error) { return exec.LookPath(name) }

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