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}