11package docker
22
33import (
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
2734func (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
49241func (s * dockerSandbox ) StartProcess (context.Context , string , sandbox.Command ) error {
0 commit comments