v0.2.0 43088fd

jobexec.go RawHistoryFolder

1// SPDX-License-Identifier: MIT
2
3package guest
4
5import (
6	"context"
7	"fmt"
8	"os"
9	"os/user"
10	"strconv"
11	"strings"
12	"sync"
13	"syscall"
14	"time"
15
16	"github.com/bnema/zerowrap"
17
18	"bnema.dev/ermite-runner/internal/execstream"
19	"bnema.dev/ermite-runner/internal/execstream/execpb"
20	"bnema.dev/ermite-runner/internal/executor"
21)
22
23// frameReporter is the executor.Reporter that writes frames on the stream.
24type frameReporter struct {
25	conn *execstream.Conn
26	// outcomes keeps steps.<id>.outcome from StepResults until StepCompleted.
27	omu      sync.Mutex
28	outcomes map[int]executor.Conclusion
29	// err keeps the first send error; the job goes on without a host.
30	failed chan struct{}
31}
32
33func ts(t time.Time) int64 { return t.UnixNano() }
34
35func conclusionProto(c executor.Conclusion) execpb.StepConclusion {
36	switch c {
37	case executor.ConclusionSuccess:
38		return execpb.StepConclusion_STEP_CONCLUSION_SUCCESS
39	case executor.ConclusionFailure:
40		return execpb.StepConclusion_STEP_CONCLUSION_FAILURE
41	case executor.ConclusionCancelled:
42		return execpb.StepConclusion_STEP_CONCLUSION_CANCELLED
43	case executor.ConclusionSkipped:
44		return execpb.StepConclusion_STEP_CONCLUSION_SKIPPED
45	case executor.ConclusionTimedOut:
46		return execpb.StepConclusion_STEP_CONCLUSION_TIMED_OUT
47	}
48	return execpb.StepConclusion_STEP_CONCLUSION_UNSPECIFIED
49}
50
51func (r *frameReporter) send(f *execpb.Frame) {
52	if err := r.conn.Send(f); err != nil {
53		select {
54		case <-r.failed:
55		default:
56			close(r.failed)
57		}
58	}
59}
60
61func (r *frameReporter) StepStarted(n int, name string, at time.Time) {
62	r.send(&execpb.Frame{Body: &execpb.Frame_StepStarted{StepStarted: &execpb.StepStarted{Number: int32(n), Name: name, UnixNano: ts(at)}}})
63}
64
65func (r *frameReporter) Log(n int, at time.Time, line string) {
66	for i, piece := range execstream.SplitLine(line, execstream.MaxLogLine) {
67		r.send(&execpb.Frame{Body: &execpb.Frame_Log{Log: &execpb.Log{Step: int32(n), UnixNano: ts(at), Text: piece, Continued: i > 0}}})
68	}
69}
70
71func (r *frameReporter) StepCompleted(n int, name string, c executor.Conclusion, at time.Time) {
72	r.omu.Lock()
73	outcome := r.outcomes[n]
74	delete(r.outcomes, n)
75	r.omu.Unlock()
76	r.send(&execpb.Frame{Body: &execpb.Frame_StepCompleted{StepCompleted: &execpb.StepCompleted{
77		Number: int32(n), Name: name, Conclusion: conclusionProto(c), Outcome: conclusionProto(outcome), UnixNano: ts(at),
78	}}})
79}
80
81func levelProto(l executor.AnnotationLevel) execpb.AnnotationLevel {
82	switch l {
83	case executor.LevelNotice:
84		return execpb.AnnotationLevel_ANNOTATION_LEVEL_NOTICE
85	case executor.LevelWarning:
86		return execpb.AnnotationLevel_ANNOTATION_LEVEL_WARNING
87	case executor.LevelFailure:
88		return execpb.AnnotationLevel_ANNOTATION_LEVEL_FAILURE
89	}
90	return execpb.AnnotationLevel_ANNOTATION_LEVEL_UNSPECIFIED
91}
92
93// StepResults sends the annotations and the summary of a step, and keeps its
94// outcome for the StepCompleted frame that follows.
95func (r *frameReporter) StepResults(n int, res executor.StepResults) {
96	r.omu.Lock()
97	if r.outcomes == nil {
98		r.outcomes = map[int]executor.Conclusion{}
99	}
100	r.outcomes[n] = res.Outcome
101	r.omu.Unlock()
102	if len(res.Annotations) > 0 {
103		sa := &execpb.StepAnnotations{Step: int32(n)}
104		for _, a := range res.Annotations {
105			sa.Annotations = append(sa.Annotations, &execpb.Annotation{
106				Level: levelProto(a.Level), Title: a.Title, Message: a.Message, File: a.File,
107				StartLine: a.StartLine, EndLine: a.EndLine, StartColumn: a.StartColumn, EndColumn: a.EndColumn,
108			})
109		}
110		r.send(&execpb.Frame{Body: &execpb.Frame_StepAnnotations{StepAnnotations: sa}})
111	}
112	if res.HasSummary {
113		r.send(&execpb.Frame{Body: &execpb.Frame_StepSummary{StepSummary: &execpb.StepSummary{Step: int32(n), Markdown: res.Summary}}})
114	}
115}
116
117func (r *frameReporter) JobCompleted(res executor.Result) {
118	r.send(&execpb.Frame{Body: &execpb.Frame_JobResult{JobResult: &execpb.JobResult{
119		Conclusion: conclusionProto(res.Conclusion), Outputs: res.Outputs, Error: res.Error, RunnerShutdown: res.RunnerShutdown,
120	}}})
121}
122
123// StepUser is the account the steps run as: uid, gid and supplementary groups,
124// like the hosted runners' "runner" user. Nil credential means the steps run as
125// the executor itself.
126type StepUser struct {
127	Credential *syscall.Credential
128	Name, Home string
129}
130
131// LookupStepUser finds the user the steps run as. When the executor is not
132// root it cannot switch user: the steps run as the current user (a development
133// or test setup, never the sandbox of a runner, whose init is root). When it is
134// root and the user does not exist, the lookup fails: running a job's code as
135// root of the sandbox, with the docker daemon and the whole image at hand,
136// because an image lacks the account would hide a broken image. A test image
137// opts in with allowRoot.
138func LookupStepUser(name string, allowRoot bool) (StepUser, error) {
139	return lookupStepUser(os.Geteuid(), user.Lookup, name, allowRoot)
140}
141
142func lookupStepUser(euid int, lookup func(string) (*user.User, error), name string, allowRoot bool) (StepUser, error) {
143	if euid != 0 || name == "" {
144		u := StepUser{Home: os.Getenv("HOME")}
145		if cu, err := user.Current(); err == nil {
146			u.Name = cu.Username
147			if u.Home == "" {
148				u.Home = cu.HomeDir
149			}
150		}
151		return u, nil
152	}
153	u, err := lookup(name)
154	if err != nil {
155		if allowRoot {
156			return StepUser{Name: "root", Home: "/root"}, nil
157		}
158		return StepUser{}, fmt.Errorf("the image has no user %q to run the steps as (%v): refusing to run them as root", name, err)
159	}
160	uid, _ := strconv.ParseUint(u.Uid, 10, 32)
161	gid, _ := strconv.ParseUint(u.Gid, 10, 32)
162	cred := &syscall.Credential{Uid: uint32(uid), Gid: uint32(gid)}
163	if ids, err := u.GroupIds(); err == nil {
164		for _, g := range ids {
165			if n, err := strconv.ParseUint(g, 10, 32); err == nil {
166				cred.Groups = append(cred.Groups, uint32(n))
167			}
168		}
169	}
170	return StepUser{Credential: cred, Name: u.Username, Home: u.HomeDir}, nil
171}
172
173// stepEnv is the environment the steps inherit: the executor's, without the
174// variables of the stream, with HOME, USER and LOGNAME of the step user.
175func stepEnv(environ []string, u StepUser) []string {
176	var out []string
177	for _, kv := range environ {
178		k, _, _ := strings.Cut(kv, "=")
179		switch {
180		case strings.HasPrefix(k, "ERMITE_"), k == "RUNNER_TOOL_CACHE", k == "HOME", k == "USER", k == "LOGNAME":
181			continue
182		}
183		out = append(out, kv)
184	}
185	if u.Home != "" {
186		out = append(out, "HOME="+u.Home)
187	}
188	if u.Name != "" {
189		out = append(out, "USER="+u.Name, "LOGNAME="+u.Name)
190	}
191	return out
192}
193
194func cancelKind(k execpb.CancelKind) executor.CancelReason {
195	switch k {
196	case execpb.CancelKind_CANCEL_KIND_TIMEOUT:
197		return executor.CancelTimeout
198	case execpb.CancelKind_CANCEL_KIND_SHUTDOWN:
199		return executor.CancelShutdown
200	}
201	return executor.CancelUser
202}
203
204// runJob runs the job of a Run frame with the executor and reports on conn.
205// It returns the exit code to report.
206func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *execpb.JobSpec) int {
207	su, err := LookupStepUser(cfg.StepUser, cfg.AllowRootSteps)
208	if err != nil {
209		// Reported like any failure of "Set up job": the job fails, with the reason
210		// in its log, and nothing of the workflow runs.
211		return failSetup(conn, err.Error())
212	}
213	canceller := executor.NewCanceller()
214	rep := &frameReporter{conn: conn, failed: make(chan struct{})}
215	res := newResolver(conn)
216
217	// Frames from the host while the job runs: a Cancel stops the job; a lost
218	// host does too, like a runner shutdown. The reader ends with the stream: the
219	// caller closes it after runJob, and the goroutine that reports a failed
220	// send ends with the job (done).
221	done := make(chan struct{})
222	defer close(done)
223	go func() {
224		for {
225			f, err := conn.Recv()
226			if err != nil {
227				canceller.Cancel(executor.CancelShutdown)
228				return
229			}
230			if c := f.GetCancel(); c != nil {
231				canceller.Cancel(cancelKind(c.GetKind()))
232			}
233			if r := f.GetResolveResponse(); r != nil {
234				res.deliver(r)
235			}
236		}
237	}()
238	// The executor's own context (SIGTERM forwarded by init: the sandbox is
239	// being stopped) is a shutdown.
240	stop := context.AfterFunc(ctx, func() { canceller.Cancel(executor.CancelShutdown) })
241	defer stop()
242	go func() {
243		select {
244		case <-rep.failed:
245			canceller.Cancel(executor.CancelShutdown)
246		case <-canceller.Done():
247		case <-done:
248		}
249	}()
250
251	env := spec.GetRunnerEnvironment()
252	result := executor.Run(canceller, executor.Config{
253		WorkflowPath: spec.GetWorkflowPath(), WorkflowYAML: []byte(spec.GetWorkflowYaml()), JobKey: spec.GetJobKey(),
254		ContextsJSON: spec.GetContexts(), MatrixJSON: spec.GetMatrix(), Repo: spec.GetRepo(),
255		TimeoutMinutes: int(spec.GetTimeoutMinutes()), Deadline: time.Duration(spec.GetDeadlineMs()) * time.Millisecond,
256		Runner:        executor.RunnerInfo{Name: spec.GetRunnerName(), Environment: env},
257		RunnerVersion: spec.GetRunnerVersion(),
258		Home:          su.Home, ToolCache: os.Getenv("RUNNER_TOOL_CACHE"), BaseEnv: stepEnv(os.Environ(), su), Credential: su.Credential,
259		Debug: spec.GetDebug(), Reporter: rep, Actions: executor.DefaultActionsConfig(res.resolve), Log: zerowrap.FromCtx(ctx),
260		SendAnnotations: spec.GetSendAnnotations(), SendSummaries: spec.GetSendSummaries(),
261		TermAfter: cfg.TermAfter, KillAfter: cfg.KillAfter, WaitDelay: cfg.WaitDelay,
262	})
263	if result.Conclusion == executor.ConclusionSuccess {
264		return 0
265	}
266	return 1
267}
268
269// failSetup ends a job that cannot start, like a failed "Set up job": step 1
270// starts, logs the reason as an error, and fails; the job result is a failure.
271func failSetup(conn *execstream.Conn, msg string) int {
272	rep := &frameReporter{conn: conn, failed: make(chan struct{})}
273	now := time.Now()
274	rep.StepStarted(1, executor.SetUpJobName, now)
275	rep.Log(1, now, "##[error]"+msg)
276	rep.StepCompleted(1, executor.SetUpJobName, executor.ConclusionFailure, time.Now())
277	rep.JobCompleted(executor.Result{Conclusion: executor.ConclusionFailure, Error: msg})
278	return 1
279}

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