Skip to content

Commit 979a9e3

Browse files
Martin Yankovsclaude
andcommitted
feat(docker): exec with enforced timeouts, and file transfer over exec
Two findings from running this against real gVisor, both of which changed the implementation rather than just fixing it. Docker's archive API cannot see tmpfs mounts. CopyToContainer and CopyFromContainer resolve paths against the container's image layers, not its live mount namespace, so a directory created by exec and confirmed by ls is reported as "no such file" by docker cp — and a copy onto a writable tmpfs under a read-only rootfs fails as "rootfs is marked read-only". Since openblox's scratch space must be tmpfs (container-layer quotas need overlay2 on xfs with pquota and hard-fail on ext4), the archive API is unusable for us in both directions. WriteFile and ReadFile now stream through exec, which sees the real mount namespace. The destination path is passed as an argument rather than interpolated into the shell script, so it lands in $0 and cannot escape the redirect. Building `sh -c "cat > " + dest` would have been a command injection on any caller that accepts a path from its user. Cancelling a context does not interrupt a blocked read on a hijacked connection. Exec previously returned ErrTimeout only after the command finished on its own — correct error, useless deadline. StdCopy now runs in a goroutine and the connection is closed on ctx.Done to unblock it. The integration suite dropped from 126s to 8.8s, which is the two timeout tests no longer running their sleep 60 to completion. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 15c8a69 commit 979a9e3

3 files changed

Lines changed: 422 additions & 7 deletions

File tree

pkg/docker/backend.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ const (
2929
labelCreatedAt = "sh.openblox.created-at"
3030
labelIdle = "sh.openblox.idle-timeout"
3131
labelMaxAge = "sh.openblox.max-age"
32+
labelDefTmo = "sh.openblox.default-timeout"
33+
labelMaxTmo = "sh.openblox.max-timeout"
3234
labelUserPfx = "sh.openblox.user."
3335
)
3436

@@ -113,7 +115,14 @@ func (b *Backend) Open(ctx context.Context, name string) (sandbox.Sandbox, error
113115
if inspect.Config == nil || inspect.Config.Labels[labelManaged] != "true" {
114116
return nil, fmt.Errorf("%w: %q exists but is not managed by openblox", sandbox.ErrInvalid, name)
115117
}
116-
return &dockerSandbox{cli: b.cli, id: inspect.ID, info: infoFrom(inspect.ID, inspect.Config.Labels, inspect.Config.Image, inspect.State)}, nil
118+
labels := inspect.Config.Labels
119+
return &dockerSandbox{
120+
cli: b.cli,
121+
id: inspect.ID,
122+
info: infoFrom(inspect.ID, labels, inspect.Config.Image, inspect.State),
123+
defaultTimeout: parseDurationLabel(labels[labelDefTmo], sandbox.DefaultCommandTimeout),
124+
maxTimeout: parseDurationLabel(labels[labelMaxTmo], sandbox.MaxCommandTimeout),
125+
}, nil
117126
}
118127

119128
// List returns every sandbox this backend manages.
@@ -175,6 +184,8 @@ func buildConfig(name string, spec sandbox.Spec) (*container.Config, *container.
175184
labelCreatedAt: time.Now().UTC().Format(time.RFC3339Nano),
176185
labelIdle: spec.Lifetime.IdleTimeout.String(),
177186
labelMaxAge: spec.Lifetime.MaxAge.String(),
187+
labelDefTmo: spec.DefaultTimeout.String(),
188+
labelMaxTmo: spec.MaxTimeout.String(),
178189
}
179190
for k, v := range spec.Labels {
180191
labels[labelUserPfx+k] = v
@@ -256,6 +267,14 @@ func stateFromStatus(status string) sandbox.State {
256267
}
257268
}
258269

270+
func parseDurationLabel(v string, fallback time.Duration) time.Duration {
271+
d, err := time.ParseDuration(v)
272+
if err != nil || d <= 0 {
273+
return fallback
274+
}
275+
return d
276+
}
277+
259278
func parseTimeLabel(v string) time.Time {
260279
t, err := time.Parse(time.RFC3339Nano, v)
261280
if err != nil {

pkg/docker/sandbox.go

Lines changed: 198 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
package docker
22

33
import (
4+
"bytes"
45
"context"
56
"errors"
67
"fmt"
78
"io"
89
"io/fs"
10+
"path"
911
"time"
1012

13+
"github.com/docker/docker/api/types"
1114
"github.com/docker/docker/api/types/container"
1215
"github.com/docker/docker/client"
16+
"github.com/docker/docker/pkg/stdcopy"
1317

1418
"github.com/blox-eng/openblox/pkg/sandbox"
1519
)
@@ -22,6 +26,9 @@ type dockerSandbox struct {
2226
cli *client.Client
2327
id string
2428
info sandbox.Info
29+
30+
defaultTimeout time.Duration
31+
maxTimeout time.Duration
2532
}
2633

2734
func (s *dockerSandbox) Info() sandbox.Info { return s.info }
@@ -34,16 +41,201 @@ func (s *dockerSandbox) Stop(ctx context.Context) error {
3441
return nil
3542
}
3643

37-
func (s *dockerSandbox) Exec(context.Context, sandbox.Command) (sandbox.Result, error) {
38-
return sandbox.Result{}, fmt.Errorf("Exec: %w", errNotImplemented)
44+
// resolveTimeout applies this sandbox's default and ceiling to a request.
45+
func (s *dockerSandbox) resolveTimeout(requested time.Duration) time.Duration {
46+
if requested <= 0 {
47+
requested = s.defaultTimeout
48+
}
49+
if s.maxTimeout > 0 && requested > s.maxTimeout {
50+
return s.maxTimeout
51+
}
52+
return requested
53+
}
54+
55+
// Exec runs a command to completion inside the sandbox.
56+
func (s *dockerSandbox) Exec(ctx context.Context, cmd sandbox.Command) (sandbox.Result, error) {
57+
if err := cmd.Validate(); err != nil {
58+
return sandbox.Result{}, err
59+
}
60+
61+
timeout := s.resolveTimeout(cmd.Timeout)
62+
ctx, cancel := context.WithTimeout(ctx, timeout)
63+
defer cancel()
64+
65+
execID, attached, err := s.attach(ctx, cmd)
66+
if err != nil {
67+
return sandbox.Result{}, err
68+
}
69+
defer attached.Close()
70+
71+
var stdout, stderr bytes.Buffer
72+
copyDone := make(chan error, 1)
73+
go func() {
74+
// The attach stream is multiplexed unless a TTY was allocated; StdCopy
75+
// splits it back into the two streams.
76+
_, err := stdcopy.StdCopy(&stdout, &stderr, attached.Reader)
77+
copyDone <- err
78+
}()
79+
80+
select {
81+
case err := <-copyDone:
82+
if err != nil {
83+
return sandbox.Result{}, fmt.Errorf("read exec output in %q: %w", s.info.Name, err)
84+
}
85+
case <-ctx.Done():
86+
// The attach stream is a hijacked connection: cancelling the context does
87+
// not interrupt a blocked read on it. Closing the connection is what
88+
// unblocks StdCopy, so without this a timed-out command would still take
89+
// as long as the command itself.
90+
attached.Close()
91+
<-copyDone
92+
return sandbox.Result{}, fmt.Errorf("%w: command in %q exceeded %s", sandbox.ErrTimeout, s.info.Name, timeout)
93+
}
94+
95+
// Inspect with a fresh context: the exec finished, and reusing an expired
96+
// one would turn a completed command into a spurious failure.
97+
inspectCtx, inspectCancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
98+
defer inspectCancel()
99+
100+
inspect, err := s.cli.ContainerExecInspect(inspectCtx, execID)
101+
if err != nil {
102+
return sandbox.Result{}, fmt.Errorf("inspect exec in %q: %w", s.info.Name, err)
103+
}
104+
105+
// A non-zero exit is the command's result, not our error.
106+
return sandbox.Result{
107+
Stdout: stdout.Bytes(),
108+
Stderr: stderr.Bytes(),
109+
ExitCode: inspect.ExitCode,
110+
}, nil
111+
}
112+
113+
func (s *dockerSandbox) attach(ctx context.Context, cmd sandbox.Command) (string, types.HijackedResponse, error) {
114+
created, err := s.cli.ContainerExecCreate(ctx, s.id, container.ExecOptions{
115+
Cmd: cmd.Argv,
116+
Env: cmd.Env,
117+
WorkingDir: cmd.Dir,
118+
AttachStdin: cmd.Stdin != nil,
119+
AttachStdout: true,
120+
AttachStderr: true,
121+
})
122+
if err != nil {
123+
return "", types.HijackedResponse{}, fmt.Errorf("exec create in %q: %w", s.info.Name, err)
124+
}
125+
126+
attached, err := s.cli.ContainerExecAttach(ctx, created.ID, container.ExecAttachOptions{})
127+
if err != nil {
128+
return "", types.HijackedResponse{}, fmt.Errorf("exec attach in %q: %w", s.info.Name, err)
129+
}
130+
131+
if cmd.Stdin != nil {
132+
// Copy in the background: a guest that never reads stdin would otherwise
133+
// block us before the timeout could fire.
134+
go func() {
135+
defer func() { _ = attached.CloseWrite() }()
136+
_, _ = io.Copy(attached.Conn, cmd.Stdin)
137+
}()
138+
}
139+
return created.ID, attached, nil
140+
}
141+
142+
// WriteFile writes src to a path inside the sandbox, creating parent directories.
143+
//
144+
// This streams through exec rather than Docker's archive API. The archive API
145+
// resolves paths against the container's image layers and cannot see tmpfs
146+
// mounts — and openblox's writable scratch space is tmpfs, because
147+
// container-layer disk quotas are unavailable on most hosts. So CopyToContainer
148+
// reports "no such file" for a directory that demonstrably exists inside the
149+
// sandbox. Exec sees the real mount namespace.
150+
func (s *dockerSandbox) WriteFile(ctx context.Context, dest string, mode fs.FileMode, src io.Reader) error {
151+
if !path.IsAbs(dest) {
152+
return fmt.Errorf("%w: path %q is not absolute", sandbox.ErrInvalid, dest)
153+
}
154+
155+
if err := s.run(ctx, "create directory", []string{"mkdir", "-p", path.Dir(dest)}); err != nil {
156+
return err
157+
}
158+
159+
// The destination is passed as an argument, not interpolated into the shell
160+
// script, so it lands in $0 and cannot break out of the redirect. Building
161+
// `sh -c "cat > " + dest` instead would be a command injection on any caller
162+
// that accepts a path from its user.
163+
res, err := s.Exec(ctx, sandbox.Command{
164+
Argv: []string{"sh", "-c", `cat > "$0"`, dest},
165+
Stdin: src,
166+
})
167+
if err != nil {
168+
return fmt.Errorf("write %q in %q: %w", dest, s.info.Name, err)
169+
}
170+
if res.ExitCode != 0 {
171+
return fmt.Errorf("write %q in %q: exit %d: %s",
172+
dest, s.info.Name, res.ExitCode, bytes.TrimSpace(res.Stderr))
173+
}
174+
175+
return s.run(ctx, "set mode", []string{"chmod", fmt.Sprintf("%04o", mode.Perm()), dest})
176+
}
177+
178+
// ReadFile opens a path inside the sandbox. The caller must close the reader.
179+
//
180+
// Like WriteFile, this goes through exec rather than the archive API, which
181+
// cannot see the tmpfs scratch mounts.
182+
func (s *dockerSandbox) ReadFile(ctx context.Context, src string) (io.ReadCloser, error) {
183+
if !path.IsAbs(src) {
184+
return nil, fmt.Errorf("%w: path %q is not absolute", sandbox.ErrInvalid, src)
185+
}
186+
187+
// Probe first. The body is streamed, so a missing file would otherwise
188+
// surface as an empty read rather than an error the caller can act on.
189+
probe, err := s.Exec(ctx, sandbox.Command{Argv: []string{"test", "-f", src}})
190+
if err != nil {
191+
return nil, fmt.Errorf("stat %q in %q: %w", src, s.info.Name, err)
192+
}
193+
if probe.ExitCode != 0 {
194+
return nil, fmt.Errorf("%w: %q in sandbox %q", sandbox.ErrNotFound, src, s.info.Name)
195+
}
196+
197+
streamCtx, cancel := context.WithCancel(context.WithoutCancel(ctx))
198+
_, attached, err := s.attach(streamCtx, sandbox.Command{Argv: []string{"cat", "--", src}})
199+
if err != nil {
200+
cancel()
201+
return nil, err
202+
}
203+
204+
pr, pw := io.Pipe()
205+
go func() {
206+
// stderr is discarded: the probe above already established the file is
207+
// readable, and a partial read surfaces as a short body.
208+
_, err := stdcopy.StdCopy(pw, io.Discard, attached.Reader)
209+
_ = pw.CloseWithError(err)
210+
}()
211+
212+
return &execStream{Reader: pr, attached: attached, cancel: cancel}, nil
213+
}
214+
215+
type execStream struct {
216+
io.Reader
217+
attached types.HijackedResponse
218+
cancel context.CancelFunc
39219
}
40220

41-
func (s *dockerSandbox) WriteFile(context.Context, string, fs.FileMode, io.Reader) error {
42-
return fmt.Errorf("WriteFile: %w", errNotImplemented)
221+
func (e *execStream) Close() error {
222+
e.attached.Close()
223+
e.cancel()
224+
return nil
43225
}
44226

45-
func (s *dockerSandbox) ReadFile(context.Context, string) (io.ReadCloser, error) {
46-
return nil, fmt.Errorf("ReadFile: %w", errNotImplemented)
227+
// run executes a command and turns a non-zero exit into an error. For internal
228+
// helpers a non-zero exit is a failure, unlike a caller's own command.
229+
func (s *dockerSandbox) run(ctx context.Context, what string, argv []string) error {
230+
res, err := s.Exec(ctx, sandbox.Command{Argv: argv})
231+
if err != nil {
232+
return fmt.Errorf("%s in %q: %w", what, s.info.Name, err)
233+
}
234+
if res.ExitCode != 0 {
235+
return fmt.Errorf("%s in %q: exit %d: %s",
236+
what, s.info.Name, res.ExitCode, bytes.TrimSpace(res.Stderr))
237+
}
238+
return nil
47239
}
48240

49241
func (s *dockerSandbox) StartProcess(context.Context, string, sandbox.Command) error {

0 commit comments

Comments
 (0)