detached 3530513

fix(guest): a missing step user fails the job instead of running the steps as root (m4)

commit
3530513325fd66c1f96fa90d642a270563cf7747
author
brice · · 2026-10-12 19:38 UTC
parent
320711b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +105 -10
M internal/guest/exec.go +4 -0
@@ -41,6 +41,10 @@ type ExecConfig struct {
4141 // StepUser is the account the steps of a job run as when the executor is
4242 // root; default "runner".
4343 StepUser string
44+ // AllowRootSteps lets the steps run as root when StepUser does not exist.
45+ // For test images only; set by ERMITE_RUNNER_ALLOW_ROOT_STEPS=1 in the
46+ // executor's environment.
47+ AllowRootSteps bool
4448 // ConnectTimeout bounds the connection attempts (default 30 s).
4549 ConnectTimeout time.Duration
4650 // RunTimeout bounds the wait for the host's Run frame (default 60 s).
@@ -41,6 +41,10 @@ type ExecConfig struct {
41 // StepUser is the account the steps of a job run as when the executor is41 // StepUser is the account the steps of a job run as when the executor is
42 // root; default "runner".42 // root; default "runner".
43 StepUser string43 StepUser string
44+ // AllowRootSteps lets the steps run as root when StepUser does not exist.
45+ // For test images only; set by ERMITE_RUNNER_ALLOW_ROOT_STEPS=1 in the
46+ // executor's environment.
47+ AllowRootSteps bool
44 // ConnectTimeout bounds the connection attempts (default 30 s).48 // ConnectTimeout bounds the connection attempts (default 30 s).
45 ConnectTimeout time.Duration49 ConnectTimeout time.Duration
46 // RunTimeout bounds the wait for the host's Run frame (default 60 s).50 // RunTimeout bounds the wait for the host's Run frame (default 60 s).
M internal/guest/jobexec.go +38 -10
@@ -4,6 +4,7 @@ package guest
44
55 import (
66 "context"
7+ "fmt"
78 "os"
89 "os/user"
910 "strconv"
@@ -80,11 +81,18 @@ type StepUser struct {
8081 }
8182
8283 // LookupStepUser finds the user the steps run as. When the executor is not
83-// root it cannot switch user: the steps run as the current user. When it is
84-// root and name does not exist, the steps run as root (an image without the
85-// user is a test image).
86-func LookupStepUser(name string) StepUser {
87- if os.Geteuid() != 0 || name == "" {
84+// root it cannot switch user: the steps run as the current user (a development
85+// or test setup, never the sandbox of a runner, whose init is root). When it is
86+// root and the user does not exist, the lookup fails: running a job's code as
87+// root of the sandbox, with the docker daemon and the whole image at hand,
88+// because an image lacks the account would hide a broken image. A test image
89+// opts in with allowRoot.
90+func LookupStepUser(name string, allowRoot bool) (StepUser, error) {
91+ return lookupStepUser(os.Geteuid(), user.Lookup, name, allowRoot)
92+}
93+
94+func lookupStepUser(euid int, lookup func(string) (*user.User, error), name string, allowRoot bool) (StepUser, error) {
95+ if euid != 0 || name == "" {
8896 u := StepUser{Home: os.Getenv("HOME")}
8997 if cu, err := user.Current(); err == nil {
9098 u.Name = cu.Username
@@ -92,11 +100,14 @@ func LookupStepUser(name string) StepUser {
92100 u.Home = cu.HomeDir
93101 }
94102 }
95- return u
103+ return u, nil
96104 }
97- u, err := user.Lookup(name)
105+ u, err := lookup(name)
98106 if err != nil {
99- return StepUser{Name: "root", Home: "/root"}
107+ if allowRoot {
108+ return StepUser{Name: "root", Home: "/root"}, nil
109+ }
110+ return StepUser{}, fmt.Errorf("the image has no user %q to run the steps as (%v): refusing to run them as root", name, err)
100111 }
101112 uid, _ := strconv.ParseUint(u.Uid, 10, 32)
102113 gid, _ := strconv.ParseUint(u.Gid, 10, 32)
@@ -108,7 +119,7 @@ func LookupStepUser(name string) StepUser {
108119 }
109120 }
110121 }
111- return StepUser{Credential: cred, Name: u.Username, Home: u.HomeDir}
122+ return StepUser{Credential: cred, Name: u.Username, Home: u.HomeDir}, nil
112123 }
113124
114125 // stepEnv is the environment the steps inherit: the executor's, without the
@@ -145,7 +156,12 @@ func cancelKind(k execpb.CancelKind) executor.CancelReason {
145156 // runJob runs the job of a Run frame with the executor and reports on conn.
146157 // It returns the exit code to report.
147158 func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *execpb.JobSpec) int {
148- su := LookupStepUser(cfg.StepUser)
159+ su, err := LookupStepUser(cfg.StepUser, cfg.AllowRootSteps)
160+ if err != nil {
161+ // Reported like any failure of "Set up job": the job fails, with the reason
162+ // in its log, and nothing of the workflow runs.
163+ return failSetup(conn, err.Error())
164+ }
149165 canceller := executor.NewCanceller()
150166 rep := &frameReporter{conn: conn, failed: make(chan struct{})}
151167
@@ -196,3 +212,15 @@ func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *ex
196212 }
197213 return 1
198214 }
215+
216+// failSetup ends a job that cannot start, like a failed "Set up job": step 1
217+// starts, logs the reason as an error, and fails; the job result is a failure.
218+func failSetup(conn *execstream.Conn, msg string) int {
219+ rep := &frameReporter{conn: conn, failed: make(chan struct{})}
220+ now := time.Now()
221+ rep.StepStarted(1, executor.SetUpJobName, now)
222+ rep.Log(1, now, "##[error]"+msg)
223+ rep.StepCompleted(1, executor.SetUpJobName, executor.ConclusionFailure, time.Now())
224+ rep.JobCompleted(executor.Result{Conclusion: executor.ConclusionFailure, Error: msg})
225+ return 1
226+}
@@ -4,6 +4,7 @@ package guest
4 4
5 import (5 import (
6 "context"6 "context"
7+ "fmt"
7 "os"8 "os"
8 "os/user"9 "os/user"
9 "strconv"10 "strconv"
@@ -80,11 +81,18 @@ type StepUser struct {
80 }81 }
81 82
82 // LookupStepUser finds the user the steps run as. When the executor is not83 // LookupStepUser finds the user the steps run as. When the executor is not
83-// root it cannot switch user: the steps run as the current user. When it is84+// root it cannot switch user: the steps run as the current user (a development
84-// root and name does not exist, the steps run as root (an image without the85+// or test setup, never the sandbox of a runner, whose init is root). When it is
85-// user is a test image).86+// root and the user does not exist, the lookup fails: running a job's code as
86-func LookupStepUser(name string) StepUser {87+// root of the sandbox, with the docker daemon and the whole image at hand,
87- if os.Geteuid() != 0 || name == "" {88+// because an image lacks the account would hide a broken image. A test image
89+// opts in with allowRoot.
90+func LookupStepUser(name string, allowRoot bool) (StepUser, error) {
91+ return lookupStepUser(os.Geteuid(), user.Lookup, name, allowRoot)
92+}
93+
94+func lookupStepUser(euid int, lookup func(string) (*user.User, error), name string, allowRoot bool) (StepUser, error) {
95+ if euid != 0 || name == "" {
88 u := StepUser{Home: os.Getenv("HOME")}96 u := StepUser{Home: os.Getenv("HOME")}
89 if cu, err := user.Current(); err == nil {97 if cu, err := user.Current(); err == nil {
90 u.Name = cu.Username98 u.Name = cu.Username
@@ -92,11 +100,14 @@ func LookupStepUser(name string) StepUser {
92 u.Home = cu.HomeDir100 u.Home = cu.HomeDir
93 }101 }
94 }102 }
95- return u103+ return u, nil
96 }104 }
97- u, err := user.Lookup(name)105+ u, err := lookup(name)
98 if err != nil {106 if err != nil {
99- return StepUser{Name: "root", Home: "/root"}107+ if allowRoot {
108+ return StepUser{Name: "root", Home: "/root"}, nil
109+ }
110+ return StepUser{}, fmt.Errorf("the image has no user %q to run the steps as (%v): refusing to run them as root", name, err)
100 }111 }
101 uid, _ := strconv.ParseUint(u.Uid, 10, 32)112 uid, _ := strconv.ParseUint(u.Uid, 10, 32)
102 gid, _ := strconv.ParseUint(u.Gid, 10, 32)113 gid, _ := strconv.ParseUint(u.Gid, 10, 32)
@@ -108,7 +119,7 @@ func LookupStepUser(name string) StepUser {
108 }119 }
109 }120 }
110 }121 }
111- return StepUser{Credential: cred, Name: u.Username, Home: u.HomeDir}122+ return StepUser{Credential: cred, Name: u.Username, Home: u.HomeDir}, nil
112 }123 }
113 124
114 // stepEnv is the environment the steps inherit: the executor's, without the125 // stepEnv is the environment the steps inherit: the executor's, without the
@@ -145,7 +156,12 @@ func cancelKind(k execpb.CancelKind) executor.CancelReason {
145 // runJob runs the job of a Run frame with the executor and reports on conn.156 // runJob runs the job of a Run frame with the executor and reports on conn.
146 // It returns the exit code to report.157 // It returns the exit code to report.
147 func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *execpb.JobSpec) int {158 func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *execpb.JobSpec) int {
148- su := LookupStepUser(cfg.StepUser)159+ su, err := LookupStepUser(cfg.StepUser, cfg.AllowRootSteps)
160+ if err != nil {
161+ // Reported like any failure of "Set up job": the job fails, with the reason
162+ // in its log, and nothing of the workflow runs.
163+ return failSetup(conn, err.Error())
164+ }
149 canceller := executor.NewCanceller()165 canceller := executor.NewCanceller()
150 rep := &frameReporter{conn: conn, failed: make(chan struct{})}166 rep := &frameReporter{conn: conn, failed: make(chan struct{})}
151 167
@@ -196,3 +212,15 @@ func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *ex
196 }212 }
197 return 1213 return 1
198 }214 }
215+
216+// failSetup ends a job that cannot start, like a failed "Set up job": step 1
217+// starts, logs the reason as an error, and fails; the job result is a failure.
218+func failSetup(conn *execstream.Conn, msg string) int {
219+ rep := &frameReporter{conn: conn, failed: make(chan struct{})}
220+ now := time.Now()
221+ rep.StepStarted(1, executor.SetUpJobName, now)
222+ rep.Log(1, now, "##[error]"+msg)
223+ rep.StepCompleted(1, executor.SetUpJobName, executor.ConclusionFailure, time.Now())
224+ rep.JobCompleted(executor.Result{Conclusion: executor.ConclusionFailure, Error: msg})
225+ return 1
226+}
M internal/guest/jobexec_test.go +60 -0
@@ -6,6 +6,7 @@ import (
66 "context"
77 "net"
88 "os/exec"
9+ "os/user"
910 "path/filepath"
1011 "strings"
1112 "sync"
@@ -181,3 +182,62 @@ func TestStepEnv(t *testing.T) {
181182 }
182183
183184 func lookPath(name string) (string, error) { return exec.LookPath(name) }
185+
186+func TestMissingStepUserFailsTheJobUnlessTestOptionSet(t *testing.T) {
187+ none := func(string) (*user.User, error) { return nil, user.UnknownUserError("runner") }
188+ // As root with no such user: refuse.
189+ if _, err := lookupStepUser(0, none, "runner", false); err == nil || !strings.Contains(err.Error(), `no user "runner"`) || !strings.Contains(err.Error(), "refusing to run them as root") {
190+ t.Fatalf("%v", err)
191+ }
192+ // The test option allows root.
193+ if su, err := lookupStepUser(0, none, "runner", true); err != nil || su.Name != "root" || su.Credential != nil {
194+ t.Fatalf("%+v %v", su, err)
195+ }
196+ // Not root: the steps run as the current user, whatever the name.
197+ if su, err := lookupStepUser(1000, none, "runner", false); err != nil || su.Credential != nil {
198+ t.Fatalf("%+v %v", su, err)
199+ }
200+ // As root with the user: switch to it.
201+ found := func(string) (*user.User, error) {
202+ return &user.User{Uid: "1001", Gid: "1001", Username: "runner", HomeDir: "/home/runner"}, nil
203+ }
204+ su, err := lookupStepUser(0, found, "runner", false)
205+ if err != nil || su.Credential == nil || su.Credential.Uid != 1001 || su.Credential.Gid != 1001 || su.Home != "/home/runner" {
206+ t.Fatalf("%+v %v", su, err)
207+ }
208+}
209+
210+// The failure is reported like a failed "Set up job".
211+func TestFailSetupReportsAFailedFirstStep(t *testing.T) {
212+ host, guest := net.Pipe()
213+ got := make(chan []string, 1)
214+ go func() {
215+ var frames []string
216+ c := execstream.NewConn(host)
217+ for {
218+ f, err := c.Recv()
219+ if err != nil {
220+ got <- frames
221+ return
222+ }
223+ switch b := f.Body.(type) {
224+ case *execpb.Frame_StepStarted:
225+ frames = append(frames, "start:"+b.StepStarted.Name)
226+ case *execpb.Frame_Log:
227+ frames = append(frames, "log:"+b.Log.Text)
228+ case *execpb.Frame_StepCompleted:
229+ frames = append(frames, "done:"+b.StepCompleted.Conclusion.String())
230+ case *execpb.Frame_JobResult:
231+ frames = append(frames, "result:"+b.JobResult.Conclusion.String())
232+ }
233+ }
234+ }()
235+ if code := failSetup(execstream.NewConn(guest), "no user"); code != 1 {
236+ t.Fatal(code)
237+ }
238+ _ = guest.Close()
239+ want := "start:Set up job|log:##[error]no user|done:STEP_CONCLUSION_FAILURE|result:STEP_CONCLUSION_FAILURE"
240+ if g := strings.Join(<-got, "|"); g != want {
241+ t.Fatalf("%s", g)
242+ }
243+}
@@ -6,6 +6,7 @@ import (
6 "context"6 "context"
7 "net"7 "net"
8 "os/exec"8 "os/exec"
9+ "os/user"
9 "path/filepath"10 "path/filepath"
10 "strings"11 "strings"
11 "sync"12 "sync"
@@ -181,3 +182,62 @@ func TestStepEnv(t *testing.T) {
181 }182 }
182 183
183 func lookPath(name string) (string, error) { return exec.LookPath(name) }184 func lookPath(name string) (string, error) { return exec.LookPath(name) }
185+
186+func TestMissingStepUserFailsTheJobUnlessTestOptionSet(t *testing.T) {
187+ none := func(string) (*user.User, error) { return nil, user.UnknownUserError("runner") }
188+ // As root with no such user: refuse.
189+ if _, err := lookupStepUser(0, none, "runner", false); err == nil || !strings.Contains(err.Error(), `no user "runner"`) || !strings.Contains(err.Error(), "refusing to run them as root") {
190+ t.Fatalf("%v", err)
191+ }
192+ // The test option allows root.
193+ if su, err := lookupStepUser(0, none, "runner", true); err != nil || su.Name != "root" || su.Credential != nil {
194+ t.Fatalf("%+v %v", su, err)
195+ }
196+ // Not root: the steps run as the current user, whatever the name.
197+ if su, err := lookupStepUser(1000, none, "runner", false); err != nil || su.Credential != nil {
198+ t.Fatalf("%+v %v", su, err)
199+ }
200+ // As root with the user: switch to it.
201+ found := func(string) (*user.User, error) {
202+ return &user.User{Uid: "1001", Gid: "1001", Username: "runner", HomeDir: "/home/runner"}, nil
203+ }
204+ su, err := lookupStepUser(0, found, "runner", false)
205+ if err != nil || su.Credential == nil || su.Credential.Uid != 1001 || su.Credential.Gid != 1001 || su.Home != "/home/runner" {
206+ t.Fatalf("%+v %v", su, err)
207+ }
208+}
209+
210+// The failure is reported like a failed "Set up job".
211+func TestFailSetupReportsAFailedFirstStep(t *testing.T) {
212+ host, guest := net.Pipe()
213+ got := make(chan []string, 1)
214+ go func() {
215+ var frames []string
216+ c := execstream.NewConn(host)
217+ for {
218+ f, err := c.Recv()
219+ if err != nil {
220+ got <- frames
221+ return
222+ }
223+ switch b := f.Body.(type) {
224+ case *execpb.Frame_StepStarted:
225+ frames = append(frames, "start:"+b.StepStarted.Name)
226+ case *execpb.Frame_Log:
227+ frames = append(frames, "log:"+b.Log.Text)
228+ case *execpb.Frame_StepCompleted:
229+ frames = append(frames, "done:"+b.StepCompleted.Conclusion.String())
230+ case *execpb.Frame_JobResult:
231+ frames = append(frames, "result:"+b.JobResult.Conclusion.String())
232+ }
233+ }
234+ }()
235+ if code := failSetup(execstream.NewConn(guest), "no user"); code != 1 {
236+ t.Fatal(code)
237+ }
238+ _ = guest.Close()
239+ want := "start:Set up job|log:##[error]no user|done:STEP_CONCLUSION_FAILURE|result:STEP_CONCLUSION_FAILURE"
240+ if g := strings.Join(<-got, "|"); g != want {
241+ t.Fatalf("%s", g)
242+ }
243+}
M main.go +3 -0
@@ -81,6 +81,9 @@ func execCmd(ctx context.Context) int {
8181 Target: target,
8282 Sandbox: envOr("ERMITE_SANDBOX", "container"),
8383 Version: envOr("ERMITE_VERSION", version),
84+ // A test image without the user "runner" (ERMITE_RUNNER_ALLOW_ROOT_STEPS=1);
85+ // a runner never sets it: the job refuses to start instead.
86+ AllowRootSteps: os.Getenv("ERMITE_RUNNER_ALLOW_ROOT_STEPS") == "1",
8487 })
8588 if err != nil {
8689 log.Error().Err(err).Msg("exec")
@@ -81,6 +81,9 @@ func execCmd(ctx context.Context) int {
81 Target: target,81 Target: target,
82 Sandbox: envOr("ERMITE_SANDBOX", "container"),82 Sandbox: envOr("ERMITE_SANDBOX", "container"),
83 Version: envOr("ERMITE_VERSION", version),83 Version: envOr("ERMITE_VERSION", version),
84+ // A test image without the user "runner" (ERMITE_RUNNER_ALLOW_ROOT_STEPS=1);
85+ // a runner never sets it: the job refuses to start instead.
86+ AllowRootSteps: os.Getenv("ERMITE_RUNNER_ALLOW_ROOT_STEPS") == "1",
84 })87 })
85 if err != nil {88 if err != nil {
86 log.Error().Err(err).Msg("exec")89 log.Error().Err(err).Msg("exec")

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