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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 41 | 41 | // StepUser is the account the steps of a job run as when the executor is | |
| 42 | 42 | // root; default "runner". | |
| 43 | 43 | 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 | 48 | // ConnectTimeout bounds the connection attempts (default 30 s). | |
| 45 | 49 | ConnectTimeout time.Duration | |
| 46 | 50 | // 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 is | 41 | // 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 string | 43 | 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.Duration | 49 | 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 | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | + | "fmt" | |
| 7 | 8 | "os" | |
| 8 | 9 | "os/user" | |
| 9 | 10 | "strconv" | |
| @@ -80,11 +81,18 @@ type StepUser struct { | |||
| 80 | 81 | } | |
| 81 | 82 | ||
| 82 | 83 | // 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 == "" { | |
| 88 | 96 | u := StepUser{Home: os.Getenv("HOME")} | |
| 89 | 97 | if cu, err := user.Current(); err == nil { | |
| 90 | 98 | u.Name = cu.Username | |
| @@ -92,11 +100,14 @@ func LookupStepUser(name string) StepUser { | |||
| 92 | 100 | u.Home = cu.HomeDir | |
| 93 | 101 | } | |
| 94 | 102 | } | |
| 95 | - | return u | |
| 103 | + | return u, nil | |
| 96 | 104 | } | |
| 97 | - | u, err := user.Lookup(name) | |
| 105 | + | u, err := lookup(name) | |
| 98 | 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 | 112 | uid, _ := strconv.ParseUint(u.Uid, 10, 32) | |
| 102 | 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 | 125 | // stepEnv is the environment the steps inherit: the executor's, without the | |
| @@ -145,7 +156,12 @@ func cancelKind(k execpb.CancelKind) executor.CancelReason { | |||
| 145 | 156 | // runJob runs the job of a Run frame with the executor and reports on conn. | |
| 146 | 157 | // It returns the exit code to report. | |
| 147 | 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 | 165 | canceller := executor.NewCanceller() | |
| 150 | 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 | 213 | 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 | + | } | |
| @@ -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 not | 83 | // 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 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 the | 85 | + | // 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.Username | 98 | u.Name = cu.Username | ||
| @@ -92,11 +100,14 @@ func LookupStepUser(name string) StepUser { | |||||
| 92 | u.Home = cu.HomeDir | 100 | u.Home = cu.HomeDir | ||
| 93 | } | 101 | } | ||
| 94 | } | 102 | } | ||
| 95 | - | return u | 103 | + | 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 the | 125 | // 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 1 | 213 | 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 ( | |||
| 6 | 6 | "context" | |
| 7 | 7 | "net" | |
| 8 | 8 | "os/exec" | |
| 9 | + | "os/user" | |
| 9 | 10 | "path/filepath" | |
| 10 | 11 | "strings" | |
| 11 | 12 | "sync" | |
| @@ -181,3 +182,62 @@ func TestStepEnv(t *testing.T) { | |||
| 181 | 182 | } | |
| 182 | 183 | ||
| 183 | 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 | + | } | |
| @@ -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 { | |||
| 81 | 81 | Target: target, | |
| 82 | 82 | Sandbox: envOr("ERMITE_SANDBOX", "container"), | |
| 83 | 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 | 88 | if err != nil { | |
| 86 | 89 | 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") | ||