Skip to content

Commit e15601a

Browse files
authored
Merge pull request moby#5339 from jedevc/exec-exit-codes
exec: allow specifying non-zero exit codes for execs
2 parents 3953dc7 + 7e6c20a commit e15601a

File tree

10 files changed

+441
-214
lines changed

10 files changed

+441
-214
lines changed

client/client_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,7 @@ var allTests = []func(t *testing.T, sb integration.Sandbox){
220220
testOCIIndexMediatype,
221221
testLayerLimitOnMounts,
222222
testFrontendVerifyPlatforms,
223+
testRunValidExitCodes,
223224
}
224225

225226
func TestIntegration(t *testing.T) {
@@ -10588,3 +10589,47 @@ func testClientCustomGRPCOpts(t *testing.T, sb integration.Sandbox) {
1058810589

1058910590
require.Contains(t, interceptedMethods, "/moby.buildkit.v1.Control/Solve")
1059010591
}
10592+
10593+
func testRunValidExitCodes(t *testing.T, sb integration.Sandbox) {
10594+
requiresLinux(t)
10595+
c, err := New(sb.Context(), sb.Address())
10596+
require.NoError(t, err)
10597+
defer c.Close()
10598+
10599+
// no exit codes specified, equivalent to [0]
10600+
out := llb.Image("busybox:latest")
10601+
out = out.Run(llb.Shlex(`sh -c "exit 0"`)).Root()
10602+
out = out.Run(llb.Shlex(`sh -c "exit 1"`)).Root()
10603+
def, err := out.Marshal(sb.Context())
10604+
require.NoError(t, err)
10605+
_, err = c.Solve(sb.Context(), def, SolveOpt{}, nil)
10606+
require.Error(t, err)
10607+
require.ErrorContains(t, err, "exit code: 1")
10608+
10609+
// empty exit codes, equivalent to [0]
10610+
out = llb.Image("busybox:latest")
10611+
out = out.Run(llb.Shlex(`sh -c "exit 0"`), llb.ValidExitCodes()).Root()
10612+
def, err = out.Marshal(sb.Context())
10613+
require.NoError(t, err)
10614+
_, err = c.Solve(sb.Context(), def, SolveOpt{}, nil)
10615+
require.NoError(t, err)
10616+
10617+
// if we expect non-zero, those non-zero codes should succeed
10618+
out = llb.Image("busybox:latest")
10619+
out = out.Run(llb.Shlex(`sh -c "exit 1"`), llb.ValidExitCodes(1)).Root()
10620+
out = out.Run(llb.Shlex(`sh -c "exit 2"`), llb.ValidExitCodes(2, 3)).Root()
10621+
out = out.Run(llb.Shlex(`sh -c "exit 3"`), llb.ValidExitCodes(2, 3)).Root()
10622+
def, err = out.Marshal(sb.Context())
10623+
require.NoError(t, err)
10624+
_, err = c.Solve(sb.Context(), def, SolveOpt{}, nil)
10625+
require.NoError(t, err)
10626+
10627+
// if we expect non-zero, returning zero should fail
10628+
out = llb.Image("busybox:latest")
10629+
out = out.Run(llb.Shlex(`sh -c "exit 0"`), llb.ValidExitCodes(1)).Root()
10630+
def, err = out.Marshal(sb.Context())
10631+
require.NoError(t, err)
10632+
_, err = c.Solve(sb.Context(), def, SolveOpt{}, nil)
10633+
require.Error(t, err)
10634+
require.ErrorContains(t, err, "exit code: 0")
10635+
}

client/llb/exec.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,17 @@ func (e *ExecOp) Marshal(ctx context.Context, c *Constraints) (digest.Digest, []
193193
return "", nil, nil, nil, err
194194
}
195195

196+
var validExitCodes []int32
197+
if codes, err := getValidExitCodes(e.base)(ctx, c); err != nil {
198+
return "", nil, nil, nil, err
199+
} else if codes != nil {
200+
validExitCodes = make([]int32, len(codes))
201+
for i, code := range codes {
202+
validExitCodes[i] = int32(code)
203+
}
204+
addCap(&e.constraints, pb.CapExecValidExitCode)
205+
}
206+
196207
meta := &pb.Meta{
197208
Args: args,
198209
Env: env.ToArray(),
@@ -201,6 +212,7 @@ func (e *ExecOp) Marshal(ctx context.Context, c *Constraints) (digest.Digest, []
201212
Hostname: hostname,
202213
CgroupParent: cgrpParent,
203214
RemoveMountStubsRecursive: true,
215+
ValidExitCodes: validExitCodes,
204216
}
205217

206218
extraHosts, err := getExtraHosts(e.base)(ctx, c)
@@ -581,6 +593,7 @@ func Shlex(str string) RunOption {
581593
ei.State = shlexf(str, false)(ei.State)
582594
})
583595
}
596+
584597
func Shlexf(str string, v ...interface{}) RunOption {
585598
return runOptionFunc(func(ei *ExecInfo) {
586599
ei.State = shlexf(str, true, v...)(ei.State)
@@ -605,6 +618,12 @@ func AddUlimit(name UlimitName, soft int64, hard int64) RunOption {
605618
})
606619
}
607620

621+
func ValidExitCodes(codes ...int) RunOption {
622+
return runOptionFunc(func(ei *ExecInfo) {
623+
ei.State = validExitCodes(codes...)(ei.State)
624+
})
625+
}
626+
608627
func WithCgroupParent(cp string) RunOption {
609628
return runOptionFunc(func(ei *ExecInfo) {
610629
ei.State = ei.State.WithCgroupParent(cp)

client/llb/meta.go

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,15 @@ import (
1818
type contextKeyT string
1919

2020
var (
21-
keyArgs = contextKeyT("llb.exec.args")
22-
keyDir = contextKeyT("llb.exec.dir")
23-
keyEnv = contextKeyT("llb.exec.env")
24-
keyExtraHost = contextKeyT("llb.exec.extrahost")
25-
keyHostname = contextKeyT("llb.exec.hostname")
26-
keyUlimit = contextKeyT("llb.exec.ulimit")
27-
keyCgroupParent = contextKeyT("llb.exec.cgroup.parent")
28-
keyUser = contextKeyT("llb.exec.user")
21+
keyArgs = contextKeyT("llb.exec.args")
22+
keyDir = contextKeyT("llb.exec.dir")
23+
keyEnv = contextKeyT("llb.exec.env")
24+
keyExtraHost = contextKeyT("llb.exec.extrahost")
25+
keyHostname = contextKeyT("llb.exec.hostname")
26+
keyUlimit = contextKeyT("llb.exec.ulimit")
27+
keyCgroupParent = contextKeyT("llb.exec.cgroup.parent")
28+
keyUser = contextKeyT("llb.exec.user")
29+
keyValidExitCodes = contextKeyT("llb.exec.validexitcodes")
2930

3031
keyPlatform = contextKeyT("llb.platform")
3132
keyNetwork = contextKeyT("llb.network")
@@ -165,6 +166,25 @@ func getUser(s State) func(context.Context, *Constraints) (string, error) {
165166
}
166167
}
167168

169+
func validExitCodes(codes ...int) StateOption {
170+
return func(s State) State {
171+
return s.WithValue(keyValidExitCodes, codes)
172+
}
173+
}
174+
175+
func getValidExitCodes(s State) func(context.Context, *Constraints) ([]int, error) {
176+
return func(ctx context.Context, c *Constraints) ([]int, error) {
177+
v, err := s.getValue(keyValidExitCodes)(ctx, c)
178+
if err != nil {
179+
return nil, err
180+
}
181+
if v != nil {
182+
return v.([]int), nil
183+
}
184+
return nil, nil
185+
}
186+
}
187+
168188
// Hostname returns a [StateOption] which sets the hostname used for containers created by [State.Run].
169189
// This is the equivalent of [State.Hostname]
170190
// See [State.With] for where to use this.
@@ -312,6 +332,7 @@ func Network(v pb.NetMode) StateOption {
312332
return s.WithValue(keyNetwork, v)
313333
}
314334
}
335+
315336
func getNetwork(s State) func(context.Context, *Constraints) (pb.NetMode, error) {
316337
return func(ctx context.Context, c *Constraints) (pb.NetMode, error) {
317338
v, err := s.getValue(keyNetwork)(ctx, c)
@@ -334,6 +355,7 @@ func Security(v pb.SecurityMode) StateOption {
334355
return s.WithValue(keySecurity, v)
335356
}
336357
}
358+
337359
func getSecurity(s State) func(context.Context, *Constraints) (pb.SecurityMode, error) {
338360
return func(ctx context.Context, c *Constraints) (pb.SecurityMode, error) {
339361
v, err := s.getValue(keySecurity)(ctx, c)

executor/containerdexecutor/executor.go

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"io"
66
"os"
77
"path/filepath"
8+
"slices"
89
"sync"
910
"syscall"
1011
"time"
@@ -215,7 +216,7 @@ func (w *containerdExecutor) Run(ctx context.Context, id string, root executor.M
215216
}
216217

217218
trace.SpanFromContext(ctx).AddEvent("Container created")
218-
err = w.runProcess(ctx, task, process.Resize, process.Signal, func() {
219+
err = w.runProcess(ctx, task, process.Resize, process.Signal, process.Meta.ValidExitCodes, func() {
219220
startedOnce.Do(func() {
220221
trace.SpanFromContext(ctx).AddEvent("Container started")
221222
if started != nil {
@@ -306,7 +307,7 @@ func (w *containerdExecutor) Exec(ctx context.Context, id string, process execut
306307
return errors.WithStack(err)
307308
}
308309

309-
err = w.runProcess(ctx, taskProcess, process.Resize, process.Signal, nil)
310+
err = w.runProcess(ctx, taskProcess, process.Resize, process.Signal, process.Meta.ValidExitCodes, nil)
310311
return err
311312
}
312313

@@ -323,7 +324,7 @@ func fixProcessOutput(process *executor.ProcessInfo) {
323324
}
324325
}
325326

326-
func (w *containerdExecutor) runProcess(ctx context.Context, p containerd.Process, resize <-chan executor.WinSize, signal <-chan syscall.Signal, started func()) error {
327+
func (w *containerdExecutor) runProcess(ctx context.Context, p containerd.Process, resize <-chan executor.WinSize, signal <-chan syscall.Signal, validExitCodes []int, started func()) error {
327328
// Not using `ctx` here because the context passed only affects the statusCh which we
328329
// don't want cancelled when ctx.Done is sent. We want to process statusCh on cancel.
329330
statusCh, err := p.Wait(context.Background())
@@ -408,22 +409,30 @@ func (w *containerdExecutor) runProcess(ctx context.Context, p containerd.Proces
408409
attribute.Int("exit.code", int(status.ExitCode())),
409410
),
410411
)
411-
if status.ExitCode() != 0 {
412-
exitErr := &gatewayapi.ExitError{
413-
ExitCode: status.ExitCode(),
414-
Err: status.Error(),
415-
}
416-
if status.ExitCode() == gatewayapi.UnknownExitStatus && status.Error() != nil {
417-
exitErr.Err = errors.Wrap(status.Error(), "failure waiting for process")
418-
}
419-
select {
420-
case <-ctx.Done():
421-
exitErr.Err = errors.Wrap(context.Cause(ctx), exitErr.Error())
422-
default:
412+
413+
if validExitCodes == nil {
414+
// no exit codes specified, so only 0 is allowed
415+
if status.ExitCode() == 0 {
416+
return nil
423417
}
424-
return exitErr
418+
} else if slices.Contains(validExitCodes, int(status.ExitCode())) {
419+
// exit code in allowed list, so exit cleanly
420+
return nil
421+
}
422+
423+
exitErr := &gatewayapi.ExitError{
424+
ExitCode: status.ExitCode(),
425+
Err: status.Error(),
426+
}
427+
if status.ExitCode() == gatewayapi.UnknownExitStatus && status.Error() != nil {
428+
exitErr.Err = errors.Wrap(status.Error(), "failure waiting for process")
429+
}
430+
select {
431+
case <-ctx.Done():
432+
exitErr.Err = errors.Wrap(context.Cause(ctx), exitErr.Error())
433+
default:
425434
}
426-
return nil
435+
return exitErr
427436
case <-killCtxDone:
428437
if cancel != nil {
429438
cancel(errors.WithStack(context.Canceled))

executor/executor.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ type Meta struct {
2525
CgroupParent string
2626
NetMode pb.NetMode
2727
SecurityMode pb.SecurityMode
28+
ValidExitCodes []int
2829

2930
RemoveMountStubsRecursive bool
3031
}

executor/runcexecutor/executor.go

Lines changed: 32 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"os"
1111
"os/exec"
1212
"path/filepath"
13+
"slices"
1314
"strconv"
1415
"sync"
1516
"syscall"
@@ -335,7 +336,7 @@ func (w *runcExecutor) Run(ctx context.Context, id string, root executor.Mount,
335336
}
336337
doReleaseNetwork = false
337338

338-
err = exitError(ctx, cgroupPath, err)
339+
err = exitError(ctx, cgroupPath, err, process.Meta.ValidExitCodes)
339340
if err != nil {
340341
if rec != nil {
341342
rec.Close()
@@ -351,41 +352,44 @@ func (w *runcExecutor) Run(ctx context.Context, id string, root executor.Mount,
351352
return rec, rec.CloseAsync(releaseContainer)
352353
}
353354

354-
func exitError(ctx context.Context, cgroupPath string, err error) error {
355-
if err != nil {
356-
exitErr := &gatewayapi.ExitError{
357-
ExitCode: gatewayapi.UnknownExitStatus,
358-
Err: err,
359-
}
355+
func exitError(ctx context.Context, cgroupPath string, err error, validExitCodes []int) error {
356+
exitErr := &gatewayapi.ExitError{ExitCode: uint32(gatewayapi.UnknownExitStatus), Err: err}
357+
358+
if err == nil {
359+
exitErr.ExitCode = 0
360+
} else {
360361
var runcExitError *runc.ExitError
361-
if errors.As(err, &runcExitError) && runcExitError.Status >= 0 {
362-
exitErr = &gatewayapi.ExitError{
363-
ExitCode: uint32(runcExitError.Status),
364-
}
362+
if errors.As(err, &runcExitError) {
363+
exitErr = &gatewayapi.ExitError{ExitCode: uint32(runcExitError.Status)}
365364
}
366365

367366
detectOOM(ctx, cgroupPath, exitErr)
368-
369-
trace.SpanFromContext(ctx).AddEvent(
370-
"Container exited",
371-
trace.WithAttributes(
372-
attribute.Int("exit.code", int(exitErr.ExitCode)),
373-
),
374-
)
375-
select {
376-
case <-ctx.Done():
377-
exitErr.Err = errors.Wrap(context.Cause(ctx), exitErr.Error())
378-
return exitErr
379-
default:
380-
return stack.Enable(exitErr)
381-
}
382367
}
383368

384369
trace.SpanFromContext(ctx).AddEvent(
385370
"Container exited",
386-
trace.WithAttributes(attribute.Int("exit.code", 0)),
371+
trace.WithAttributes(attribute.Int("exit.code", int(exitErr.ExitCode))),
387372
)
388-
return nil
373+
374+
if validExitCodes == nil {
375+
// no exit codes specified, so only 0 is allowed
376+
if exitErr.ExitCode == 0 {
377+
return nil
378+
}
379+
} else {
380+
// exit code in allowed list, so exit cleanly
381+
if slices.Contains(validExitCodes, int(exitErr.ExitCode)) {
382+
return nil
383+
}
384+
}
385+
386+
select {
387+
case <-ctx.Done():
388+
exitErr.Err = errors.Wrap(context.Cause(ctx), exitErr.Error())
389+
return exitErr
390+
default:
391+
return stack.Enable(exitErr)
392+
}
389393
}
390394

391395
func (w *runcExecutor) Exec(ctx context.Context, id string, process executor.ProcessInfo) (err error) {
@@ -456,7 +460,7 @@ func (w *runcExecutor) Exec(ctx context.Context, id string, process executor.Pro
456460
}
457461

458462
err = w.exec(ctx, id, spec.Process, process, nil)
459-
return exitError(ctx, "", err)
463+
return exitError(ctx, "", err, process.Meta.ValidExitCodes)
460464
}
461465

462466
type forwardIO struct {

solver/llbsolver/ops/exec.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,13 @@ func (e *ExecOp) Exec(ctx context.Context, g session.Group, inputs []solver.Resu
472472
}
473473
meta.Env = append(meta.Env, secretEnv...)
474474

475+
if e.op.Meta.ValidExitCodes != nil {
476+
meta.ValidExitCodes = make([]int, len(e.op.Meta.ValidExitCodes))
477+
for i, code := range e.op.Meta.ValidExitCodes {
478+
meta.ValidExitCodes[i] = int(code)
479+
}
480+
}
481+
475482
stdout, stderr, flush := logs.NewLogStreams(ctx, os.Getenv("BUILDKIT_DEBUG_EXEC_OUTPUT") == "1")
476483
defer stdout.Close()
477484
defer stderr.Close()

solver/pb/caps.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ const (
6060
CapExecMountContentCache apicaps.CapID = "exec.mount.cache.content"
6161
CapExecCgroupsMounted apicaps.CapID = "exec.cgroup"
6262
CapExecSecretEnv apicaps.CapID = "exec.secretenv"
63+
CapExecValidExitCode apicaps.CapID = "exec.validexitcode"
6364

6465
CapFileBase apicaps.CapID = "file.base"
6566
CapFileRmWildcard apicaps.CapID = "file.rm.wildcard"
@@ -357,6 +358,12 @@ func init() {
357358
Status: apicaps.CapStatusExperimental,
358359
})
359360

361+
Caps.Init(apicaps.Cap{
362+
ID: CapExecValidExitCode,
363+
Enabled: true,
364+
Status: apicaps.CapStatusExperimental,
365+
})
366+
360367
Caps.Init(apicaps.Cap{
361368
ID: CapFileBase,
362369
Enabled: true,

0 commit comments

Comments
 (0)