├── .github └── workflows │ └── test.yml ├── LICENSE ├── Makefile ├── README.md ├── buildkitopt ├── build.go ├── build_test.go ├── go.mod └── go.sum ├── client.go ├── container.go ├── container ├── attach.go ├── attach_test.go ├── commit.go ├── commit_test.go ├── container.go ├── containerapi │ ├── blkio.go │ ├── config.go │ ├── container.go │ ├── mount │ │ ├── bind.go │ │ ├── mount.go │ │ ├── propagation.go │ │ ├── tmpfs.go │ │ └── volume.go │ ├── networking.go │ └── resources.go ├── create.go ├── create_opts.go ├── create_test.go ├── exec.go ├── exec_test.go ├── inspect.go ├── inspect_test.go ├── kill.go ├── kill_test.go ├── list.go ├── list_test.go ├── logs.go ├── logs_test.go ├── remove.go ├── remove_test.go ├── service.go ├── service_test.go ├── start.go ├── start_test.go ├── stop.go ├── stop_test.go ├── streamutil │ ├── stdcopy.go │ ├── stdcopy_test.go │ ├── stdreader.go │ └── stdreader_test.go ├── wait.go └── wait_test.go ├── doc.go ├── errdefs ├── conflict.go ├── errors.go ├── errors_test.go ├── forbidden.go ├── invalid.go ├── notfound.go ├── notimplemented.go ├── notmodified.go ├── unauthorized.go └── unavailable.go ├── go.mod ├── go.sum ├── httputil ├── context.go ├── errors.go └── request.go ├── image.go ├── image ├── export.go ├── export_test.go ├── imageapi │ ├── image.go │ └── prune.go ├── list.go ├── list_test.go ├── load.go ├── load_test.go ├── prune.go ├── prune_test.go ├── pull.go ├── pull_test.go ├── ref.go ├── ref_test.go ├── remove.go ├── remove_test.go ├── service.go └── service_test.go ├── registry ├── login.go ├── login_test.go ├── service.go └── service_test.go ├── system.go ├── system ├── apiversion.go ├── events.go ├── events_test.go ├── ping.go ├── ping_test.go └── service.go ├── testutils ├── deadline.go ├── default_unix.go ├── random.go ├── transport.go └── transport_test.go ├── transport ├── dialer_unix.go ├── dialer_windows.go ├── hijack.go ├── npipe.go ├── npipe_test.go ├── stdio.go ├── stdio_test.go ├── tcp.go ├── tcp_test.go ├── transport.go ├── transport_unix.go ├── transport_windows.go ├── unix.go └── unix_test.go └── version ├── compare.go └── context.go /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | build: 11 | name: Build 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Set up Go 1.x 15 | uses: actions/setup-go@v3 16 | with: 17 | go-version: ^1.13 18 | id: go 19 | 20 | - name: Check out code into the Go module directory 21 | uses: actions/checkout@v3 22 | 23 | - name: Test 24 | run: make test 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Brian Goff 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | docker pull busybox:latest 3 | go test ./... 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # An experimental Docker client 2 | 3 | This is an experimental client for Docker. Most of the low-level details are finalized and just requires implementing 4 | the API calls on top of that. 5 | 6 | The goals of this project are: 7 | 8 | 1. Abstract away connection semantics - the official client is kind of broken in this regard, where abstractions for 9 | connection and transport protocol are co-mingled. This makes it difficult to, for instance, take advantage of HTTP/2 10 | in the official client, or implement things like SSH forwarding (this exists in the official client, but with great 11 | effort). 12 | 2. Semantic versioning - The official client is built such that it is very difficult to not make breaking changes when 13 | adding new things, therefore it is difficult to version semantically (without being at some insanely high and meaningless 14 | version). 15 | 3. Easy to consume in go.mod - Traditionally the official client is... painful... to import, particularly with go modules. 16 | This is caused by a number of project level issues, such as (lack of) version tagging. We are trying to fix this upstream 17 | but we have to make certain not to break too much or introduce things that are not maintainable. 18 | 4. Do not import from the upstream types -- the upstream repo has a lot of history, things move around, have lots of 19 | transitive dependencies, and in general is just slow to import due to that history. Instead we define the required types 20 | directly in this repo, even if it's only as a copy of the existing ones. 21 | 5. Easy to reason about errors - You should be able to know exactly what kind of error was returned without having to 22 | muck around with the http response 23 | 6. Integrate with your choice tracing/metrics frameworks 24 | 25 | ## Usage 26 | 27 | All client operations are dependent on a transport. Transports are defined in the transport package. You can implement 28 | your own, here is the interface: 29 | 30 | ```go 31 | // RequestOpt is as functional arguments to configure an HTTP request for a Doer. 32 | type RequestOpt func(*http.Request) error 33 | 34 | // Doer performs an http request for Client 35 | // It is the Doer's responsibility to deal with setting the host details on 36 | // the request 37 | // It is expected that one Doer connects to one Docker instance. 38 | type Doer interface { 39 | // Do typically performs a normal http request/response 40 | Do(ctx context.Context, method string, uri string, opts ...RequestOpt) (*http.Response, error) 41 | // DoRaw performs the request but passes along the response as a bi-directional stream 42 | DoRaw(ctx context.Context, method string, uri string, opts ...RequestOpt) (net.Conn, error) 43 | } 44 | ``` 45 | 46 | `Do` is the main function to implement, it takes an HTTP method, a uri (e.g. `/containers/json`), and a lits of options 47 | for configuring an `*http.Request` (e.g. to add request headers, query params, etc.) 48 | 49 | `DoRaw` is used only for endpoints that need to "hijack" the http connection (ie. drop all HTTP semantics and drop to a 50 | raw, bi-directional stream). This is used for container attach. 51 | 52 | The package contains a default transport that you can use directly, or wrap, as well as helpers for creating it from 53 | `DOCKER_HOST` style connection strings. 54 | 55 | Once you have a transport you can create a client: 56 | 57 | ```go 58 | // create a transport that connects over /var/run/docker.sock 59 | tr, err := transport.DefaultUnixTransport() 60 | if err != nil { 61 | panic(err) 62 | } 63 | client := NewClient(WithTransport(tr)) 64 | ``` 65 | 66 | Or if you don't provide a transport, the default for the platform will be used. 67 | 68 | Perform actions on a container: 69 | 70 | ```go 71 | s := client.ContainerService() 72 | c, err := s.Create(ctx, container.WithCreateImage("busybox:latest"), container.WithCreateCmd("/bin/echo", "hello")) 73 | if err != nil { 74 | // handle error 75 | } 76 | 77 | cStdout, err := c.StdoutPipe(ctx) 78 | if err != nil { 79 | // handle error 80 | } 81 | defer cStdout.Close() 82 | 83 | if err := c.Start(ctx); err != nil { 84 | // handle error 85 | } 86 | 87 | io.Copy(os.Stdout, cStdout) 88 | 89 | if err := s.Remove(ctx, c.ID(), container.WithRemoveForce); err != nil { 90 | // handle error 91 | } 92 | ``` 93 | -------------------------------------------------------------------------------- /buildkitopt/build.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package buildkit provides the neccessary functionality to create a buildkit 3 | client that can be used to communicate with the buildkit service provided by 4 | dockerd. 5 | 6 | This is provided in a module separate from the main go-docker module so that 7 | only those that need it will pull in the buildkit dependencies. 8 | */ 9 | 10 | package buildkitopt 11 | 12 | import ( 13 | "context" 14 | "net" 15 | "net/http" 16 | 17 | "github.com/cpuguy83/go-docker/transport" 18 | "github.com/cpuguy83/go-docker/version" 19 | "github.com/moby/buildkit/client" 20 | ) 21 | 22 | // NewClient is a convenience wrapper which creates a buildkit client for the 23 | // buildkit service provided by dockerd. This just wraps buildkit's client.New 24 | // to include WithSessionDialer and WithGRPCDialer automatically in addition to 25 | // the opts provided. 26 | func NewClient(ctx context.Context, tr transport.Doer, opts ...client.ClientOpt) (*client.Client, error) { 27 | return client.New(ctx, "", append(opts, FromDocker(tr)...)...) 28 | } 29 | 30 | // FromDocker is a convenience function that returns a slice of ClientOpts that can be used to create a client for the buildkit GRPC and session APIs provided by dockerd. 31 | func FromDocker(tr transport.Doer) []client.ClientOpt { 32 | return []client.ClientOpt{ 33 | WithGRPCDialer(tr), 34 | WithSessionDialer(tr), 35 | } 36 | } 37 | 38 | // WithSessionDialer creates a ClientOpt that can be used to create a client for the buildkit session API provided by dockerd. 39 | func WithSessionDialer(tr transport.Doer) client.ClientOpt { 40 | return client.WithSessionDialer(func(ctx context.Context, proto string, meta map[string][]string) (net.Conn, error) { 41 | return tr.DoRaw(ctx, http.MethodPost, version.Join(ctx, "/session"), transport.WithUpgrade(proto), transport.WithAddHeaders(meta)) 42 | }) 43 | } 44 | 45 | // WithGRPCDialer creates a ClientOpt that can be used to create a client for the buildkit GRPC API provided by dockerd. 46 | func WithGRPCDialer(tr transport.Doer) client.ClientOpt { 47 | return client.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { 48 | return tr.DoRaw(ctx, http.MethodPost, version.Join(ctx, "/grpc"), transport.WithUpgrade("h2c")) 49 | }) 50 | } 51 | -------------------------------------------------------------------------------- /buildkitopt/build_test.go: -------------------------------------------------------------------------------- 1 | package buildkitopt 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | "encoding/hex" 7 | "io" 8 | "testing" 9 | 10 | "github.com/cpuguy83/go-docker/transport" 11 | "github.com/moby/buildkit/client" 12 | "github.com/moby/buildkit/client/llb" 13 | ) 14 | 15 | func TestDial(t *testing.T) { 16 | t.Parallel() 17 | tr, err := transport.DefaultTransport() 18 | if err != nil { 19 | t.Fatal(err) 20 | } 21 | 22 | ctx := context.Background() 23 | 24 | c, err := NewClient(ctx, tr) 25 | if err != nil { 26 | t.Fatal(err) 27 | } 28 | defer c.Close() 29 | 30 | def, err := llb.Image("alpine:latest").Run(llb.Args([]string{"/bin/sh", "-c", "cat /etc/os-release"})).Marshal(ctx) 31 | if err != nil { 32 | t.Fatal(err) 33 | } 34 | 35 | key := make([]byte, 16) 36 | n, err := io.ReadFull(rand.Reader, key) 37 | if err != nil { 38 | t.Fatal(err) 39 | } 40 | ch := make(chan *client.SolveStatus, 1) 41 | go func() { 42 | for status := range ch { 43 | for _, st := range status.Statuses { 44 | t.Log(st.Name, st.ID, st.Total, st.Completed) 45 | } 46 | for _, v := range status.Logs { 47 | t.Log(v.Timestamp, v.Vertex, string(v.Data)) 48 | } 49 | } 50 | }() 51 | _, err = c.Solve(ctx, def, client.SolveOpt{SharedKey: hex.EncodeToString(key[:n])}, ch) 52 | if err != nil { 53 | t.Fatal(err) 54 | } 55 | 56 | <-ch 57 | } 58 | -------------------------------------------------------------------------------- /buildkitopt/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/cpuguy83/go-docker/buildkitopt 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/cpuguy83/go-docker v0.3.0 7 | github.com/moby/buildkit v0.12.2 8 | ) 9 | 10 | require ( 11 | github.com/Microsoft/go-winio v0.6.1 // indirect 12 | github.com/Microsoft/hcsshim v0.11.4 // indirect 13 | github.com/containerd/containerd v1.7.8 // indirect 14 | github.com/containerd/continuity v0.4.3 // indirect 15 | github.com/containerd/log v0.1.0 // indirect 16 | github.com/containerd/typeurl/v2 v2.1.1 // indirect 17 | github.com/distribution/reference v0.5.0 // indirect 18 | github.com/docker/distribution v2.8.3+incompatible // indirect 19 | github.com/go-logr/logr v1.3.0 // indirect 20 | github.com/go-logr/stdr v1.2.2 // indirect 21 | github.com/gofrs/flock v0.8.1 // indirect 22 | github.com/gogo/googleapis v1.4.1 // indirect 23 | github.com/gogo/protobuf v1.3.2 // indirect 24 | github.com/golang/protobuf v1.5.3 // indirect 25 | github.com/google/go-cmp v0.6.0 // indirect 26 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect 27 | github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect 28 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.1 // indirect 29 | github.com/kr/text v0.2.0 // indirect 30 | github.com/moby/patternmatcher v0.6.0 // indirect 31 | github.com/moby/sys/signal v0.7.0 // indirect 32 | github.com/opencontainers/go-digest v1.0.0 // indirect 33 | github.com/opencontainers/image-spec v1.1.0-rc5 // indirect 34 | github.com/pkg/errors v0.9.1 // indirect 35 | github.com/sirupsen/logrus v1.9.3 // indirect 36 | github.com/tonistiigi/fsutil v0.0.0-20230629203738-36ef4d8c0dbb // indirect 37 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.46.0 // indirect 38 | go.opentelemetry.io/otel v1.20.0 // indirect 39 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.20.0 // indirect 40 | go.opentelemetry.io/otel/metric v1.20.0 // indirect 41 | go.opentelemetry.io/otel/sdk v1.20.0 // indirect 42 | go.opentelemetry.io/otel/trace v1.20.0 // indirect 43 | go.opentelemetry.io/proto/otlp v1.0.0 // indirect 44 | golang.org/x/crypto v0.15.0 // indirect 45 | golang.org/x/mod v0.14.0 // indirect 46 | golang.org/x/net v0.18.0 // indirect 47 | golang.org/x/sync v0.5.0 // indirect 48 | golang.org/x/sys v0.14.0 // indirect 49 | golang.org/x/text v0.14.0 // indirect 50 | golang.org/x/tools v0.15.0 // indirect 51 | google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 // indirect 52 | google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17 // indirect 53 | google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17 // indirect 54 | google.golang.org/grpc v1.59.0 // indirect 55 | google.golang.org/protobuf v1.31.0 // indirect 56 | ) 57 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "github.com/cpuguy83/go-docker/transport" 5 | ) 6 | 7 | // Client is the main docker client 8 | // Create one with `NewClient` 9 | type Client struct { 10 | tr transport.Doer 11 | } 12 | 13 | // NewClientConfig is the list of options for configuring a new docker client 14 | type NewClientConfig struct { 15 | // Transport is the communication method for reaching a docker engine instance. 16 | // You can implement your own transport, or use the ones provided in the transport package. 17 | // If this is unset, the default transport will be used (unix socket connected to /var/run/docker.sock). 18 | Transport transport.Doer 19 | } 20 | 21 | type NewClientOption func(*NewClientConfig) 22 | 23 | // NewClient creates a new docker client 24 | // You can pass in options using functional arguments. 25 | // 26 | // If no transport is provided as an option, the default transport will be used. 27 | // 28 | // You probably want to set an API version for the client to use here. 29 | // See `NewClientConfig` for available options 30 | func NewClient(opts ...NewClientOption) *Client { 31 | var cfg NewClientConfig 32 | for _, o := range opts { 33 | o(&cfg) 34 | } 35 | tr := cfg.Transport 36 | if tr == nil { 37 | tr, _ = transport.DefaultTransport() 38 | } 39 | return &Client{tr: tr} 40 | } 41 | 42 | // WithTransport is a NewClientOption that sets the transport to be used for the client. 43 | func WithTransport(tr transport.Doer) NewClientOption { 44 | return func(cfg *NewClientConfig) { 45 | cfg.Transport = tr 46 | } 47 | } 48 | 49 | // Transport returns the transport used by the client. 50 | func (c *Client) Transport() transport.Doer { 51 | return c.tr 52 | } 53 | -------------------------------------------------------------------------------- /container.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "github.com/cpuguy83/go-docker/container" 5 | ) 6 | 7 | // ContainerService provides access to container functionaliaty, such as create, delete, start, stop, etc. 8 | func (c *Client) ContainerService() *container.Service { 9 | return container.NewService(c.tr) 10 | } 11 | -------------------------------------------------------------------------------- /container/attach.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "io/ioutil" 7 | "net/http" 8 | "strconv" 9 | 10 | "github.com/cpuguy83/go-docker/container/streamutil" 11 | "github.com/cpuguy83/go-docker/errdefs" 12 | "github.com/cpuguy83/go-docker/transport" 13 | "github.com/cpuguy83/go-docker/version" 14 | ) 15 | 16 | // AttachOption is used as functional arguments to container attach 17 | type AttachOption func(*AttachConfig) 18 | 19 | // AttachConfig holds the options for attaching to a container 20 | type AttachConfig struct { 21 | Stream bool 22 | Stdin bool 23 | Stdout bool 24 | Stderr bool 25 | DetachKeys string 26 | Logs bool 27 | } 28 | 29 | // AttachIO is used to for providing access to stdio streams of a container 30 | type AttachIO interface { 31 | Stdin() io.WriteCloser 32 | Stdout() io.ReadCloser 33 | Stderr() io.ReadCloser 34 | Close() error 35 | } 36 | 37 | // WithAttachStdin enables stdin on an attach request 38 | func WithAttachStdin(o *AttachConfig) { 39 | o.Stdin = true 40 | } 41 | 42 | // WithAttachStdOut enables stdout on an attach request 43 | func WithAttachStdout(o *AttachConfig) { 44 | o.Stdout = true 45 | } 46 | 47 | // WithAttachStdErr enables stderr on an attach request 48 | func WithAttachStderr(o *AttachConfig) { 49 | o.Stderr = true 50 | } 51 | 52 | // WithAttachStream sets the stream option on an attach request 53 | // When attaching, unless you only want historical data (e.g. setting Logs=true), you probably want this. 54 | func WithAttachStream(o *AttachConfig) { 55 | o.Stream = true 56 | } 57 | 58 | // WithAttachDetachKeys sets the key sequence for detaching from an attach request 59 | func WithAttachDetachKeys(keys string) func(*AttachConfig) { 60 | return func(o *AttachConfig) { 61 | o.DetachKeys = keys 62 | } 63 | } 64 | 65 | // Attach attaches to a container's stdio streams. 66 | // You must specify which streams you want to attach to. 67 | // Depending on the container config the streams may not be available for attach. 68 | // 69 | // It is recommend to call `Attach` separately for each stdio stream. This function does support attaching to any/all streams 70 | // in a single request, however the semantics of consuming/blocking the streams is quite a bit more complicated since all i/o 71 | // is multiplexed on a single HTTP stream which can cause one stream to block another if it is not consumed. 72 | // 73 | // Note that unconsumed attach streams can block the stdio of the container process. 74 | // 75 | // It is recommend to instantiate a container object and use the Stdio pipe functions instead of using this. 76 | // This is for advanced use cases only just to expose all the functionality that the Docker API does. 77 | func (s *Service) Attach(ctx context.Context, name string, opts ...AttachOption) (AttachIO, error) { 78 | var cfg AttachConfig 79 | cfg.Stream = true 80 | for _, o := range opts { 81 | o(&cfg) 82 | } 83 | 84 | return handleAttach(ctx, s.tr, name, cfg) 85 | } 86 | 87 | // TODO: this needs more tests to handle errors cases 88 | func handleAttach(ctx context.Context, tr transport.Doer, name string, cfg AttachConfig) (retAttach *attachIO, retErr error) { 89 | defer func() { 90 | if retErr != nil { 91 | if retAttach != nil { 92 | retAttach.Close() 93 | } 94 | } 95 | }() 96 | 97 | withAttachRequest := func(req *http.Request) error { 98 | q := req.URL.Query() 99 | q.Add("stdin", strconv.FormatBool(cfg.Stdin)) 100 | q.Add("stdout", strconv.FormatBool(cfg.Stdout)) 101 | q.Add("stderr", strconv.FormatBool(cfg.Stderr)) 102 | q.Add("logs", strconv.FormatBool(cfg.Logs)) 103 | q.Add("stream", strconv.FormatBool(cfg.Stream)) 104 | req.URL.RawQuery = q.Encode() 105 | return nil 106 | } 107 | 108 | rwc, err := tr.DoRaw(ctx, http.MethodPost, version.Join(ctx, "/containers/"+name+"/attach"), withAttachRequest, transport.WithUpgrade("tcp")) 109 | if err != nil { 110 | return nil, err 111 | } 112 | 113 | var ( 114 | stdin io.WriteCloser 115 | stdout, stderr io.ReadCloser 116 | ) 117 | 118 | var isTTY bool 119 | if cfg.Stdout { 120 | info, err := handleInspect(ctx, tr, name) 121 | if err != nil { 122 | return nil, errdefs.Wrap(err, "error getting container details") 123 | } 124 | isTTY = info.Config.Tty 125 | } 126 | 127 | if isTTY { 128 | if cfg.Stdout { 129 | var stdoutW *io.PipeWriter 130 | stdout, stdoutW = io.Pipe() 131 | go func() { 132 | _, err := io.Copy(stdoutW, rwc) 133 | stdoutW.CloseWithError(err) 134 | rwc.Close() 135 | }() 136 | } 137 | } else { 138 | // TODO: This implementation can be a little funky because stderr and stodout 139 | // are multiplexed on one stream. If the caller doesn't read a full entry 140 | // from one stream it can block the other stream. 141 | // 142 | // TODO: consider using websocket? I'm not sure this actually works correctly 143 | // in docker. 144 | var stdoutW, stderrW io.Writer 145 | closeStdout := func(error) {} 146 | closeStderr := func(error) {} 147 | if cfg.Stdout { 148 | r, w := io.Pipe() 149 | stdout = r 150 | stdoutW = w 151 | closeStdout = func(err error) { 152 | w.CloseWithError(err) 153 | closeStderr(err) 154 | } 155 | } 156 | if cfg.Stderr { 157 | r, w := io.Pipe() 158 | stderr = r 159 | stderrW = w 160 | closeStderr = func(err error) { 161 | w.CloseWithError(err) 162 | closeStdout(err) 163 | } 164 | } 165 | if stdoutW != nil && stderrW == nil { 166 | stderrW = ioutil.Discard 167 | } 168 | if stderrW != nil && stdoutW == nil { 169 | stdoutW = ioutil.Discard 170 | } 171 | go func() { 172 | _, err := streamutil.StdCopy(stdoutW, stderrW, rwc) 173 | closeStdout(err) 174 | closeStderr(err) 175 | rwc.Close() 176 | }() 177 | } 178 | if cfg.Stdin { 179 | stdin = rwc 180 | } 181 | 182 | return &attachIO{stdin: stdin, stdout: stdout, stderr: stderr}, nil 183 | } 184 | 185 | type attachIO struct { 186 | stdin io.WriteCloser 187 | stdout io.ReadCloser 188 | stderr io.ReadCloser 189 | } 190 | 191 | func (a *attachIO) Stdin() io.WriteCloser { 192 | return a.stdin 193 | } 194 | 195 | func (a *attachIO) Stdout() io.ReadCloser { 196 | return a.stdout 197 | } 198 | 199 | func (a *attachIO) Stderr() io.ReadCloser { 200 | return a.stderr 201 | } 202 | 203 | func (a *attachIO) Close() error { 204 | if a.stdin != nil { 205 | a.stdin.Close() 206 | } 207 | if a.stdout != nil { 208 | a.stdout.Close() 209 | } 210 | if a.stderr != nil { 211 | a.stderr.Close() 212 | } 213 | return nil 214 | } 215 | 216 | // TODO: Do these pipe calls need to be able to set options like DetachKeys? 217 | 218 | // StdinPipe opens a pipe to the container's stdin stream. 219 | // If the container is not configured with `OpenStdin`, this will not work. 220 | func (c *Container) StdinPipe(ctx context.Context) (io.WriteCloser, error) { 221 | attach, err := handleAttach(ctx, c.tr, c.id, AttachConfig{Stdin: true, Stream: true}) 222 | if err != nil { 223 | return nil, err 224 | } 225 | return attach.Stdin(), nil 226 | } 227 | 228 | // StdoutPipe opens a pipe to the container's stdout stream. 229 | func (c *Container) StdoutPipe(ctx context.Context) (io.ReadCloser, error) { 230 | attach, err := handleAttach(ctx, c.tr, c.id, AttachConfig{Stdout: true, Stream: true}) 231 | if err != nil { 232 | return nil, err 233 | } 234 | return attach.Stdout(), nil 235 | } 236 | 237 | // StderrPipe opens a pipe to the container's stderr stream. 238 | func (c *Container) StderrPipe(ctx context.Context) (io.ReadCloser, error) { 239 | attach, err := handleAttach(ctx, c.tr, c.id, AttachConfig{Stderr: true, Stream: true}) 240 | if err != nil { 241 | return nil, err 242 | } 243 | return attach.Stderr(), nil 244 | } 245 | -------------------------------------------------------------------------------- /container/attach_test.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io" 7 | "testing" 8 | 9 | "gotest.tools/v3/assert" 10 | "gotest.tools/v3/assert/cmp" 11 | ) 12 | 13 | func TestContainerAttachTTY(t *testing.T) { 14 | t.Parallel() 15 | 16 | s, ctx := newTestService(t, context.Background()) 17 | 18 | c, err := s.Create(ctx, 19 | "busybox:latest", 20 | WithCreateTTY, 21 | WithCreateAttachStdin, 22 | WithCreateAttachStdout, 23 | ) 24 | assert.NilError(t, err) 25 | defer func() { 26 | assert.Check(t, s.Remove(ctx, c.ID(), WithRemoveForce)) 27 | }() 28 | 29 | stdout, err := c.StdoutPipe(ctx) 30 | assert.NilError(t, err) 31 | defer stdout.Close() 32 | 33 | assert.Assert(t, c.Start(ctx), "failed to start container") 34 | 35 | expected := "/ # echo hello\r\nhello\r\n/ # " 36 | 37 | stdin, err := c.StdinPipe(ctx) 38 | assert.NilError(t, err) 39 | defer stdin.Close() 40 | 41 | chErr := make(chan error, 1) 42 | go func() { 43 | _, err := stdin.Write([]byte("echo hello\n")) 44 | chErr <- err 45 | }() 46 | 47 | buf := bytes.NewBuffer(nil) 48 | _, err = io.CopyN(buf, stdout, int64(len(expected))) 49 | assert.Check(t, <-chErr) 50 | assert.Check(t, err) 51 | assert.Check(t, cmp.Equal(buf.String(), expected)) 52 | } 53 | 54 | func TestContainerAttachNoTTY(t *testing.T) { 55 | t.Parallel() 56 | 57 | s, ctx := newTestService(t, context.Background()) 58 | 59 | c, err := s.Create(ctx, 60 | "busybox:latest", 61 | WithCreateAttachStdout, 62 | WithCreateAttachStderr, 63 | WithCreateCmd("/bin/sh", "-c", "echo hello; >&2 echo world"), 64 | ) 65 | assert.NilError(t, err) 66 | defer func() { 67 | assert.Check(t, s.Remove(ctx, c.ID(), WithRemoveForce)) 68 | }() 69 | 70 | stdout, err := c.StdoutPipe(ctx) 71 | assert.NilError(t, err) 72 | defer stdout.Close() 73 | 74 | stderr, err := c.StderrPipe(ctx) 75 | assert.NilError(t, err) 76 | defer stderr.Close() 77 | 78 | assert.Assert(t, c.Start(ctx)) 79 | 80 | outBuff := bytes.NewBuffer(nil) 81 | chErr := make(chan error, 2) 82 | go func() { 83 | _, err := io.CopyN(outBuff, stdout, 6) 84 | chErr <- err 85 | }() 86 | errBuff := bytes.NewBuffer(nil) 87 | go func() { 88 | _, err := io.CopyN(errBuff, stderr, 6) 89 | chErr <- err 90 | }() 91 | 92 | assert.Check(t, <-chErr) 93 | assert.Check(t, <-chErr) 94 | assert.Check(t, cmp.Equal(outBuff.String(), "hello\n")) 95 | assert.Check(t, cmp.Equal(errBuff.String(), "world\n")) 96 | } 97 | -------------------------------------------------------------------------------- /container/commit.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "io/ioutil" 7 | "net/http" 8 | "strconv" 9 | 10 | "github.com/cpuguy83/go-docker/container/containerapi" 11 | "github.com/cpuguy83/go-docker/errdefs" 12 | "github.com/cpuguy83/go-docker/httputil" 13 | "github.com/cpuguy83/go-docker/version" 14 | ) 15 | 16 | // CommitOption is used as a funtional option when commiting a container to an 17 | // an image. 18 | type CommitOption func(*CommitConfig) 19 | 20 | // CommitConfig is used by CommitOption to set options used for committing a 21 | // container to an image. 22 | type CommitConfig struct { 23 | Comment string 24 | Author string 25 | Changes []string 26 | Message string 27 | Pause *bool 28 | Config *containerapi.Config 29 | Reference *CommitImageReference 30 | } 31 | 32 | // CommitImageReference sets the image reference to use when committing an image. 33 | type CommitImageReference struct { 34 | Repo string 35 | Tag string 36 | } 37 | 38 | type containerCommitResponse struct { 39 | ID string `json:"Id"` 40 | } 41 | 42 | // Commit takes a snapshot of the container's filessystem and creates an image 43 | // from it. 44 | // TODO: Return an image type that can be inspected, etc. 45 | func (c *Container) Commit(ctx context.Context, opts ...CommitOption) (string, error) { 46 | var cfg CommitConfig 47 | for _, o := range opts { 48 | o(&cfg) 49 | } 50 | 51 | var repo, tag string 52 | 53 | if cfg.Reference != nil { 54 | repo = cfg.Reference.Repo 55 | tag = cfg.Reference.Tag 56 | } 57 | 58 | withOptions := func(req *http.Request) error { 59 | q := req.URL.Query() 60 | q.Set("container", c.id) 61 | q.Set("repo", repo) 62 | q.Set("tag", tag) 63 | q.Set("author", cfg.Author) 64 | q.Set("comment", cfg.Comment) 65 | if cfg.Pause != nil { 66 | q.Set("pause", strconv.FormatBool(*cfg.Pause)) 67 | } 68 | for _, c := range cfg.Changes { 69 | q.Add("changes", c) 70 | } 71 | 72 | req.URL.RawQuery = q.Encode() 73 | return nil 74 | } 75 | 76 | resp, err := httputil.DoRequest(ctx, func(ctx context.Context) (*http.Response, error) { 77 | return c.tr.Do(ctx, http.MethodPost, version.Join(ctx, "/commit"), withOptions) 78 | }) 79 | if err != nil { 80 | return "", errdefs.Wrap(err, "error commiting container") 81 | } 82 | defer resp.Body.Close() 83 | 84 | b, err := ioutil.ReadAll(resp.Body) 85 | if err != nil { 86 | return "", errdefs.Wrap(err, "error reading response body") 87 | } 88 | 89 | var r containerCommitResponse 90 | if err := json.Unmarshal(b, &r); err != nil { 91 | return "", errdefs.Wrap(err, "error unmarshalling response") 92 | } 93 | return r.ID, nil 94 | } 95 | -------------------------------------------------------------------------------- /container/commit_test.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import ( 4 | "context" 5 | "slices" 6 | "testing" 7 | 8 | "github.com/cpuguy83/go-docker/image" 9 | "github.com/cpuguy83/go-docker/testutils" 10 | "gotest.tools/v3/assert" 11 | ) 12 | 13 | func TestCommit(t *testing.T) { 14 | t.Parallel() 15 | 16 | s, ctx := newTestService(t, context.Background()) 17 | 18 | c, err := s.Create(ctx, "busybox:latest") 19 | assert.NilError(t, err) 20 | defer s.Remove(ctx, c.ID(), WithRemoveForce) 21 | 22 | repo := "test" 23 | tag := "commit" + testutils.GenerateRandomString() 24 | 25 | ref, err := c.Commit(ctx, func(cfg *CommitConfig) { 26 | cfg.Reference = &CommitImageReference{ 27 | Repo: repo, 28 | Tag: tag, 29 | } 30 | }) 31 | assert.NilError(t, err) 32 | 33 | resp, err := image.NewService(s.tr).Remove(ctx, ref, image.WithRemoveForce) 34 | if err != nil { 35 | t.Fatal(err) 36 | } 37 | 38 | if !slices.Contains(resp.Deleted, ref) { 39 | t.Errorf("expected ref returned by commit (%s) to be in deleted list: %v", ref, resp.Deleted) 40 | } 41 | 42 | if !slices.Contains(resp.Untagged, repo+":"+tag) { 43 | t.Errorf("expected tagged image (%s) to be in untagged list: %v", "test:commit", resp.Untagged) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /container/container.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/cpuguy83/go-docker/transport" 7 | ) 8 | 9 | // Container provides bindings for interacting with a container in Docker 10 | type Container struct { 11 | id string 12 | tr transport.Doer 13 | } 14 | 15 | // ID returns the container ID 16 | func (c *Container) ID() string { 17 | return c.id 18 | } 19 | 20 | // NewConfig holds the options available for `New` 21 | type NewConfig struct { 22 | } 23 | 24 | // NewOption is used as functional parameters to `New` 25 | type NewOption func(*NewConfig) 26 | 27 | // New creates a new container object in memory. This function does not interact with the Docker API at all. 28 | // If the container does not exist in Docker, all calls on the Container will fail. 29 | // 30 | // To actually create a container you must call `Create` first (which will return a container object to you). 31 | // 32 | // TODO: This may well change to actually inspect the container and fetch the actual container ID. 33 | func (s *Service) NewContainer(_ context.Context, id string, opts ...NewOption) *Container { 34 | var cfg NewConfig 35 | for _, o := range opts { 36 | o(&cfg) 37 | } 38 | return &Container{id: id, tr: s.tr} 39 | } 40 | -------------------------------------------------------------------------------- /container/containerapi/blkio.go: -------------------------------------------------------------------------------- 1 | package containerapi 2 | 3 | import "fmt" 4 | 5 | // WeightDevice is a structure that holds device:weight pair 6 | type WeightDevice struct { 7 | Path string 8 | Weight uint16 9 | } 10 | 11 | func (w *WeightDevice) String() string { 12 | return fmt.Sprintf("%s:%d", w.Path, w.Weight) 13 | } 14 | 15 | // ThrottleDevice is a structure that holds device:rate_per_second pair 16 | type ThrottleDevice struct { 17 | Path string 18 | Rate uint64 19 | } 20 | 21 | func (t *ThrottleDevice) String() string { 22 | return fmt.Sprintf("%s:%d", t.Path, t.Rate) 23 | } 24 | -------------------------------------------------------------------------------- /container/containerapi/config.go: -------------------------------------------------------------------------------- 1 | package containerapi 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/cpuguy83/go-docker/container/containerapi/mount" 7 | ) 8 | 9 | // Config contains the configuration data about a container. 10 | // It should hold only portable information about the container. 11 | // Here, "portable" means "independent from the host we are running on". 12 | // Non-portable information *should* appear in HostConfig. 13 | // All fields added to this struct must be marked `omitempty` to keep getting 14 | // predictable hashes from the old `v1Compatibility` configuration. 15 | type Config struct { 16 | Hostname string // Hostname 17 | Domainname string // Domainname 18 | User string // User that will run the command(s) inside the container, also support user:group 19 | AttachStdin bool // Attach the standard input, makes possible user interaction 20 | AttachStdout bool // Attach the standard output 21 | AttachStderr bool // Attach the standard error 22 | ExposedPorts map[string]struct{} `json:",omitempty"` // List of exposed ports 23 | Tty bool // Attach standard streams to a tty, including stdin if it is not closed. 24 | OpenStdin bool // Open stdin 25 | StdinOnce bool // If true, close stdin after the 1 attached client disconnects. 26 | Env []string // List of environment variable to set in the container 27 | Cmd []string // Command to run when starting the container 28 | Healthcheck *HealthConfig `json:",omitempty"` // Healthcheck describes how to check the container is healthy 29 | ArgsEscaped bool `json:",omitempty"` // True if command is already escaped (meaning treat as a command line) (Windows specific). 30 | Image string // Name of the image as it was passed by the operator (e.g. could be symbolic) 31 | Volumes map[string]struct{} // List of volumes (mounts) used for the container 32 | WorkingDir string // Current directory (PWD) in the command will be launched 33 | Entrypoint []string // Entrypoint to run when starting the container 34 | NetworkDisabled bool `json:",omitempty"` // Is network disabled 35 | MacAddress string `json:",omitempty"` // Mac Address of the container 36 | OnBuild []string // ONBUILD metadata that were defined on the image Dockerfile 37 | Labels map[string]string // List of labels set to this container 38 | StopSignal string `json:",omitempty"` // Signal to stop a container 39 | StopTimeout *int `json:",omitempty"` // Timeout (in seconds) to stop a container 40 | Shell []string `json:",omitempty"` // Shell for shell-form of RUN, CMD, ENTRYPOINT 41 | } 42 | 43 | // HealthConfig holds configuration settings for the HEALTHCHECK feature. 44 | type HealthConfig struct { 45 | // Test is the test to perform to check that the container is healthy. 46 | // An empty slice means to inherit the default. 47 | // The options are: 48 | // {} : inherit healthcheck 49 | // {"NONE"} : disable healthcheck 50 | // {"CMD", args...} : exec arguments directly 51 | // {"CMD-SHELL", command} : run command with system's default shell 52 | Test []string `json:",omitempty"` 53 | 54 | // Zero means to inherit. Durations are expressed as integer nanoseconds. 55 | Interval time.Duration `json:",omitempty"` // Interval is the time to wait between checks. 56 | Timeout time.Duration `json:",omitempty"` // Timeout is the time to wait before considering the check to have hung. 57 | StartPeriod time.Duration `json:",omitempty"` // The start period for the container to initialize before the retries starts to count down. 58 | 59 | // Retries is the number of consecutive failures needed to consider a container as unhealthy. 60 | // Zero means inherit. 61 | Retries int `json:",omitempty"` 62 | } 63 | 64 | // HostConfig the non-portable Config structure of a container. 65 | // Here, "non-portable" means "dependent of the host we are running on". 66 | // Portable information *should* appear in Config. 67 | type HostConfig struct { 68 | // Applicable to all platforms 69 | Binds []string // List of volume bindings for this container 70 | ContainerIDFile string // File (path) where the containerId is written 71 | LogConfig LogConfig // Configuration of the logs for this container 72 | NetworkMode string // Network mode to use for the container 73 | PortBindings PortMap // Port mapping between the exposed port (container) and the host 74 | RestartPolicy RestartPolicy // Restart policy to be used for the container 75 | AutoRemove bool // Automatically remove container when it exits 76 | VolumeDriver string // Name of the volume driver used to mount volumes 77 | VolumesFrom []string // List of volumes to take from other container 78 | 79 | // Applicable to UNIX platforms 80 | CapAdd []string // List of kernel capabilities to add to the container 81 | CapDrop []string // List of kernel capabilities to remove from the container 82 | Capabilities []string `json:"Capabilities"` // List of kernel capabilities to be available for container (this overrides the default set) 83 | DNS []string `json:"Dns"` // List of DNS server to lookup 84 | DNSOptions []string `json:"DnsOptions"` // List of DNSOption to look for 85 | DNSSearch []string `json:"DnsSearch"` // List of DNSSearch to look for 86 | ExtraHosts []string // List of extra hosts 87 | GroupAdd []string // List of additional groups that the container process will run as 88 | IpcMode string // IPC namespace to use for the container 89 | Cgroup string // Cgroup to use for the container 90 | Links []string // List of links (in the name:alias form) 91 | OomScoreAdj int // Container preference for OOM-killing 92 | PidMode string // PID namespace to use for the container 93 | Privileged bool // Is the container in privileged mode 94 | PublishAllPorts bool // Should docker publish all exposed port for the container 95 | ReadonlyRootfs bool // Is the container root filesystem in read-only 96 | SecurityOpt []string // List of string values to customize labels for MLS systems, such as SELinux. 97 | StorageOpt map[string]string `json:",omitempty"` // Storage driver options per container. 98 | Tmpfs map[string]string `json:",omitempty"` // List of tmpfs (mounts) used for the container 99 | UTSMode string // UTS namespace to use for the container 100 | UsernsMode string // The user namespace to use for the container 101 | ShmSize int64 // Total shm memory usage 102 | Sysctls map[string]string `json:",omitempty"` // List of Namespaced sysctls used for the container 103 | Runtime string `json:",omitempty"` // Runtime to use with this container 104 | 105 | // Applicable to Windows 106 | ConsoleSize [2]uint // Initial console size (height,width) 107 | Isolation string // Isolation technology of the container (e.g. default, hyperv) 108 | 109 | // Contains container's resources (cgroups, ulimits) 110 | Resources 111 | 112 | // Mounts specs used by the container 113 | Mounts []mount.Mount `json:",omitempty"` 114 | 115 | // MaskedPaths is the list of paths to be masked inside the container (this overrides the default set of paths) 116 | MaskedPaths []string 117 | 118 | // ReadonlyPaths is the list of paths to be set as read-only inside the container (this overrides the default set of paths) 119 | ReadonlyPaths []string 120 | 121 | // Run a custom init inside the container, if null, use the daemon's configured settings 122 | Init *bool `json:",omitempty"` 123 | } 124 | -------------------------------------------------------------------------------- /container/containerapi/container.go: -------------------------------------------------------------------------------- 1 | package containerapi 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/cpuguy83/go-docker/container/containerapi/mount" 7 | ) 8 | 9 | // Container represents a container 10 | type Container struct { 11 | ID string `json:"Id"` 12 | Names []string 13 | Created int 14 | Path string 15 | Args []string 16 | State string 17 | Image string 18 | ImageID string 19 | Command string 20 | Ports []NetworkPort 21 | Labels map[string]string 22 | HostConfig *HostConfig 23 | NetworkSettings *NetworkSettings 24 | Mounts []MountPoint 25 | SizeRootFs int 26 | SizeRw int 27 | } 28 | 29 | // ContainerInspect is newly used struct along with MountPoint 30 | type ContainerInspect struct { 31 | ID string `json:"Id"` 32 | Created string 33 | Path string 34 | Args []string 35 | State *ContainerState 36 | Image string 37 | ResolvConfPath string 38 | HostnamePath string 39 | HostsPath string 40 | LogPath string 41 | Name string 42 | RestartCount int 43 | Driver string 44 | Platform string 45 | MountLabel string 46 | ProcessLabel string 47 | AppArmorProfile string 48 | ExecIDs []string 49 | HostConfig *HostConfig 50 | GraphDriver GraphDriverData 51 | SizeRw *int64 `json:",omitempty"` 52 | SizeRootFs *int64 `json:",omitempty"` 53 | Mounts []MountPoint 54 | Config *Config 55 | NetworkSettings *NetworkSettings 56 | } 57 | 58 | // NetworkAddress represents an IP address 59 | type NetworkAddress struct { 60 | Addr string 61 | PrefixLen int 62 | } 63 | 64 | // NetworkPort represents a network port 65 | type NetworkPort struct { 66 | IP string 67 | PrivatePort int 68 | PublicPort int 69 | Type string 70 | } 71 | 72 | // NetworkSettings exposes the network settings in the api 73 | type NetworkSettings struct { 74 | Bridge string // Bridge is the Bridge name the network uses(e.g. `docker0`) 75 | SandboxID string // SandboxID uniquely represents a container's network stack 76 | HairpinMode bool // HairpinMode specifies if hairpin NAT should be enabled on the virtual interface 77 | LinkLocalIPv6Address string // LinkLocalIPv6Address is an IPv6 unicast address using the link-local prefix 78 | LinkLocalIPv6PrefixLen int // LinkLocalIPv6PrefixLen is the prefix length of an IPv6 unicast address 79 | Ports PortMap // Ports is a collection of PortBinding indexed by Port 80 | SandboxKey string // SandboxKey identifies the sandbox 81 | SecondaryIPAddresses []NetworkAddress 82 | SecondaryIPv6Addresses []NetworkAddress 83 | Networks map[string]*EndpointSettings 84 | } 85 | 86 | // ContainerState stores container's running state 87 | // it's part of ContainerJSONBase and will return by "inspect" command 88 | type ContainerState struct { 89 | Status string // String representation of the container state. Can be one of "created", "running", "paused", "restarting", "removing", "exited", or "dead" 90 | Running bool 91 | Paused bool 92 | Restarting bool 93 | OOMKilled bool 94 | Dead bool 95 | Pid int 96 | ExitCode int 97 | Error string 98 | StartedAt string 99 | FinishedAt string 100 | Health *Health `json:",omitempty"` 101 | } 102 | 103 | // MountPoint represents a mount point configuration inside the container. 104 | // This is used for reporting the mountpoints in use by a container. 105 | type MountPoint struct { 106 | Type mount.Type `json:",omitempty"` 107 | Name string `json:",omitempty"` 108 | Source string 109 | Destination string 110 | Driver string `json:",omitempty"` 111 | Mode string 112 | RW bool 113 | Propagation mount.Propagation 114 | } 115 | 116 | // GraphDriverData Information about a container's graph driver. 117 | // swagger:model GraphDriverData 118 | type GraphDriverData struct { 119 | 120 | // data 121 | // Required: true 122 | Data map[string]string `json:"Data"` 123 | 124 | // name 125 | // Required: true 126 | Name string `json:"Name"` 127 | } 128 | 129 | // Health stores information about the container's healthcheck results 130 | type Health struct { 131 | Status string // Status is one of Starting, Healthy or Unhealthy 132 | FailingStreak int // FailingStreak is the number of consecutive failures 133 | Log []*HealthcheckResult // Log contains the last few results (oldest first) 134 | } 135 | 136 | // HealthcheckResult stores information about a single run of a healthcheck probe 137 | type HealthcheckResult struct { 138 | Start time.Time // Start is the time this check started 139 | End time.Time // End is the time this check ended 140 | ExitCode int // ExitCode meanings: 0=healthy, 1=unhealthy, 2=reserved (considered unhealthy), else=error running probe 141 | Output string // Output from last check 142 | } 143 | 144 | // DeviceRequest represents a request for devices from a device driver. 145 | // Used by GPU device drivers. 146 | type DeviceRequest struct { 147 | Driver string // Name of device driver 148 | Count int // Number of devices to request (-1 = All) 149 | DeviceIDs []string // List of device IDs as recognizable by the device driver 150 | Capabilities [][]string // An OR list of AND lists of device capabilities (e.g. "gpu") 151 | Options map[string]string // Options to pass onto the device driver 152 | } 153 | 154 | // DeviceMapping represents the device mapping between the host and the container. 155 | type DeviceMapping struct { 156 | PathOnHost string 157 | PathInContainer string 158 | CgroupPermissions string 159 | } 160 | -------------------------------------------------------------------------------- /container/containerapi/mount/bind.go: -------------------------------------------------------------------------------- 1 | package mount 2 | 3 | // Consistency represents the consistency requirements of a mount. 4 | type Consistency string 5 | 6 | const ( 7 | // ConsistencyFull guarantees bind mount-like consistency 8 | ConsistencyFull Consistency = "consistent" 9 | // ConsistencyCached mounts can cache read data and FS structure 10 | ConsistencyCached Consistency = "cached" 11 | // ConsistencyDelegated mounts can cache read and written data and structure 12 | ConsistencyDelegated Consistency = "delegated" 13 | // ConsistencyDefault provides "consistent" behavior unless overridden 14 | ConsistencyDefault Consistency = "default" 15 | ) 16 | 17 | // BindOptions defines options specific to mounts of type "bind". 18 | type BindOptions struct { 19 | Propagation Propagation `json:",omitempty"` 20 | NonRecursive bool `json:",omitempty"` 21 | } 22 | -------------------------------------------------------------------------------- /container/containerapi/mount/mount.go: -------------------------------------------------------------------------------- 1 | package mount 2 | 3 | // Type represents the type of a mount. 4 | type Type string 5 | 6 | // Type constants 7 | const ( 8 | // TypeBind is the type for mounting host dir 9 | TypeBind Type = "bind" 10 | // TypeVolume is the type for remote storage volumes 11 | TypeVolume Type = "volume" 12 | // TypeTmpfs is the type for mounting tmpfs 13 | TypeTmpfs Type = "tmpfs" 14 | // TypeNamedPipe is the type for mounting Windows named pipes 15 | TypeNamedPipe Type = "npipe" 16 | ) 17 | 18 | // Mount represents a mount (volume). 19 | type Mount struct { 20 | Type Type `json:",omitempty"` 21 | // Source specifies the name of the mount. Depending on mount type, this 22 | // may be a volume name or a host path, or even ignored. 23 | // Source is not supported for tmpfs (must be an empty value) 24 | Source string `json:",omitempty"` 25 | Target string `json:",omitempty"` 26 | ReadOnly bool `json:",omitempty"` 27 | Consistency Consistency `json:",omitempty"` 28 | 29 | BindOptions *BindOptions `json:",omitempty"` 30 | VolumeOptions *VolumeOptions `json:",omitempty"` 31 | TmpfsOptions *TmpfsOptions `json:",omitempty"` 32 | } 33 | -------------------------------------------------------------------------------- /container/containerapi/mount/propagation.go: -------------------------------------------------------------------------------- 1 | package mount 2 | 3 | // Propagation represents the propagation of a mount. 4 | type Propagation string 5 | 6 | const ( 7 | // PropagationRPrivate RPRIVATE 8 | PropagationRPrivate Propagation = "rprivate" 9 | // PropagationPrivate PRIVATE 10 | PropagationPrivate Propagation = "private" 11 | // PropagationRShared RSHARED 12 | PropagationRShared Propagation = "rshared" 13 | // PropagationShared SHARED 14 | PropagationShared Propagation = "shared" 15 | // PropagationRSlave RSLAVE 16 | PropagationRSlave Propagation = "rslave" 17 | // PropagationSlave SLAVE 18 | PropagationSlave Propagation = "slave" 19 | ) 20 | -------------------------------------------------------------------------------- /container/containerapi/mount/tmpfs.go: -------------------------------------------------------------------------------- 1 | package mount 2 | 3 | import "os" 4 | 5 | // TmpfsOptions defines options specific to mounts of type "tmpfs". 6 | type TmpfsOptions struct { 7 | // Size sets the size of the tmpfs, in bytes. 8 | // 9 | // This will be converted to an operating system specific value 10 | // depending on the host. For example, on linux, it will be converted to 11 | // use a 'k', 'm' or 'g' syntax. BSD, though not widely supported with 12 | // docker, uses a straight byte value. 13 | // 14 | // Percentages are not supported. 15 | SizeBytes int64 `json:",omitempty"` 16 | // Mode of the tmpfs upon creation 17 | Mode os.FileMode `json:",omitempty"` 18 | } 19 | -------------------------------------------------------------------------------- /container/containerapi/mount/volume.go: -------------------------------------------------------------------------------- 1 | package mount 2 | 3 | // VolumeOptions represents the options for a mount of type volume. 4 | type VolumeOptions struct { 5 | NoCopy bool `json:",omitempty"` 6 | Labels map[string]string `json:",omitempty"` 7 | DriverConfig *Driver `json:",omitempty"` 8 | } 9 | 10 | // Driver represents a volume driver. 11 | type Driver struct { 12 | Name string `json:",omitempty"` 13 | Options map[string]string `json:",omitempty"` 14 | } 15 | -------------------------------------------------------------------------------- /container/containerapi/networking.go: -------------------------------------------------------------------------------- 1 | package containerapi 2 | 3 | // PortMap is a collection of PortBinding indexed by Port 4 | type PortMap map[string][]PortBinding 5 | 6 | // PortBinding represents a binding between a Host IP address and a Host Port 7 | type PortBinding struct { 8 | // HostIP is the host IP Address 9 | HostIP string `json:"HostIp"` 10 | // HostPort is the host port number 11 | HostPort string 12 | } 13 | 14 | type LogConfig struct { 15 | Type string 16 | Config map[string]string 17 | } 18 | 19 | type RestartPolicy struct { 20 | Name string 21 | MaximumRetryCount int 22 | } 23 | 24 | // NetworkingConfig represents the container's networking configuration for each of its interfaces 25 | // Carries the networking configs specified in the `docker run` and `docker network connect` commands 26 | type NetworkingConfig struct { 27 | EndpointsConfig map[string]*EndpointSettings // Endpoint configs for each connecting network 28 | } 29 | 30 | // EndpointSettings stores the network endpoint details 31 | type EndpointSettings struct { 32 | // Configurations 33 | IPAMConfig *EndpointIPAMConfig 34 | Links []string 35 | Aliases []string 36 | // Operational data 37 | NetworkID string 38 | EndpointID string 39 | Gateway string 40 | IPAddress string 41 | IPPrefixLen int 42 | IPv6Gateway string 43 | GlobalIPv6Address string 44 | GlobalIPv6PrefixLen int 45 | MacAddress string 46 | DriverOpts map[string]string 47 | } 48 | 49 | // EndpointIPAMConfig represents IPAM configurations for the endpoint 50 | type EndpointIPAMConfig struct { 51 | IPv4Address string `json:",omitempty"` 52 | IPv6Address string `json:",omitempty"` 53 | LinkLocalIPs []string `json:",omitempty"` 54 | } 55 | -------------------------------------------------------------------------------- /container/containerapi/resources.go: -------------------------------------------------------------------------------- 1 | package containerapi 2 | 3 | // Resources contains container's resources (cgroups config, ulimits...) 4 | type Resources struct { 5 | // Applicable to all platforms 6 | CPUShares int64 `json:"CpuShares"` // CPU shares (relative weight vs. other containers) 7 | Memory int64 // Memory limit (in bytes) 8 | NanoCPUs int64 `json:"NanoCpus"` // CPU quota in units of 10-9 CPUs. 9 | 10 | // Applicable to UNIX platforms 11 | CgroupParent string // Parent cgroup. 12 | BlkioWeight uint16 // Block IO weight (relative weight vs. other containers) 13 | BlkioWeightDevice []*WeightDevice 14 | BlkioDeviceReadBps []*ThrottleDevice 15 | BlkioDeviceWriteBps []*ThrottleDevice 16 | BlkioDeviceReadIOps []*ThrottleDevice 17 | BlkioDeviceWriteIOps []*ThrottleDevice 18 | CPUPeriod int64 `json:"CpuPeriod"` // CPU CFS (Completely Fair Scheduler) period 19 | CPUQuota int64 `json:"CpuQuota"` // CPU CFS (Completely Fair Scheduler) quota 20 | CPURealtimePeriod int64 `json:"CpuRealtimePeriod"` // CPU real-time period 21 | CPURealtimeRuntime int64 `json:"CpuRealtimeRuntime"` // CPU real-time runtime 22 | CpusetCpus string // CpusetCpus 0-2, 0,1 23 | CpusetMems string // CpusetMems 0-2, 0,1 24 | Devices []DeviceMapping // List of devices to map inside the container 25 | DeviceCgroupRules []string // List of rule to be added to the device cgroup 26 | DeviceRequests []DeviceRequest // List of device requests for device drivers 27 | KernelMemory int64 // Kernel memory limit (in bytes) 28 | KernelMemoryTCP int64 // Hard limit for kernel TCP buffer memory (in bytes) 29 | MemoryReservation int64 // Memory soft limit (in bytes) 30 | MemorySwap int64 // Total memory usage (memory + swap); set `-1` to enable unlimited swap 31 | MemorySwappiness *int64 // Tuning container memory swappiness behaviour 32 | OomKillDisable *bool // Whether to disable OOM Killer or not 33 | PidsLimit *int64 // Setting PIDs limit for a container; Set `0` or `-1` for unlimited, or `null` to not change. 34 | Ulimits []*Ulimit // List of ulimits to be set in the container 35 | 36 | // Applicable to Windows 37 | CPUCount int64 `json:"CpuCount"` // CPU count 38 | CPUPercent int64 `json:"CpuPercent"` // CPU percent 39 | IOMaximumIOps uint64 // Maximum IOps for the container system drive 40 | IOMaximumBandwidth uint64 // Maximum IO in bytes per second for the container system drive 41 | } 42 | 43 | // Ulimit represents the string name of an linux rlimit along with the hard/soft limits. 44 | type Ulimit struct { 45 | Name string 46 | Hard int64 47 | Soft int64 48 | } 49 | -------------------------------------------------------------------------------- /container/create.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "net/http" 9 | "net/url" 10 | 11 | "github.com/cpuguy83/go-docker/container/containerapi" 12 | "github.com/cpuguy83/go-docker/errdefs" 13 | "github.com/cpuguy83/go-docker/httputil" 14 | "github.com/cpuguy83/go-docker/version" 15 | ) 16 | 17 | // CreateConfig holds the options for creating a container 18 | type CreateConfig struct { 19 | Spec Spec 20 | Name string 21 | Platform string 22 | } 23 | 24 | // Spec holds all the configuration for the container create API request 25 | type Spec struct { 26 | containerapi.Config 27 | HostConfig containerapi.HostConfig 28 | NetworkConfig containerapi.NetworkingConfig 29 | } 30 | 31 | func WithCreatePlatform(platform string) CreateOption { 32 | return func(cfg *CreateConfig) { 33 | cfg.Platform = platform 34 | } 35 | } 36 | 37 | // Create creates a container using the provided image. 38 | func (s *Service) Create(ctx context.Context, img string, opts ...CreateOption) (*Container, error) { 39 | c := CreateConfig{ 40 | Spec: Spec{ 41 | Config: containerapi.Config{Image: img}, 42 | }, 43 | } 44 | for _, o := range opts { 45 | o(&c) 46 | } 47 | 48 | withName := func(req *http.Request) error { return nil } 49 | if c.Name != "" { 50 | withName = func(req *http.Request) error { 51 | q := req.URL.Query() 52 | if q == nil { 53 | q = url.Values{} 54 | } 55 | 56 | q.Set("name", c.Name) 57 | req.URL.RawQuery = q.Encode() 58 | return nil 59 | } 60 | } 61 | 62 | withPlatform := func(req *http.Request) error { 63 | q := req.URL.Query() 64 | q.Set("platform", c.Platform) 65 | req.URL.RawQuery = q.Encode() 66 | return nil 67 | } 68 | 69 | resp, err := httputil.DoRequest(ctx, func(ctx context.Context) (*http.Response, error) { 70 | return s.tr.Do(ctx, http.MethodPost, version.Join(ctx, "/containers/create"), httputil.WithJSONBody(c.Spec), withName, withPlatform) 71 | }) 72 | if err != nil { 73 | return nil, err 74 | } 75 | defer resp.Body.Close() 76 | 77 | data, err := ioutil.ReadAll(resp.Body) 78 | if err != nil { 79 | return nil, errdefs.Wrap(err, "error reading response body") 80 | } 81 | 82 | var cc containerCreateResponse 83 | if err := json.Unmarshal(data, &cc); err != nil { 84 | return nil, errdefs.Wrap(err, "error decoding container create response") 85 | } 86 | 87 | if cc.ID == "" { 88 | return nil, fmt.Errorf("empty ID in response: %v", string(data)) 89 | } 90 | return &Container{id: cc.ID, tr: s.tr}, nil 91 | } 92 | 93 | type containerCreateResponse struct { 94 | ID string `json:"Id"` 95 | } 96 | -------------------------------------------------------------------------------- /container/create_opts.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import ( 4 | "github.com/cpuguy83/go-docker/container/containerapi" 5 | ) 6 | 7 | // CreateOption is used as functional arguments for creating a container 8 | // CreateOptions configure a CreateConfig 9 | type CreateOption func(*CreateConfig) 10 | 11 | // WithCreateHostConfig is a CreateOption which sets the HostConfig for the container create spec 12 | func WithCreateHostConfig(hc containerapi.HostConfig) CreateOption { 13 | return func(c *CreateConfig) { 14 | c.Spec.HostConfig = hc 15 | } 16 | } 17 | 18 | // WithCreateConfig is a CreateOption which sets the Config for the container create spec 19 | func WithCreateConfig(cfg containerapi.Config) CreateOption { 20 | return func(c *CreateConfig) { 21 | c.Spec.Config = cfg 22 | } 23 | } 24 | 25 | // WithCreateNetworkingConfig is a CreateOption which sets the NetworkConfig for the container create spec 26 | func WithCreateNetworkConfig(cfg containerapi.NetworkingConfig) CreateOption { 27 | return func(c *CreateConfig) { 28 | c.Spec.NetworkConfig = cfg 29 | } 30 | } 31 | 32 | // WithCreateName is a CreateOption which sets the container's name 33 | func WithCreateName(name string) CreateOption { 34 | return func(c *CreateConfig) { 35 | c.Name = name 36 | } 37 | } 38 | 39 | // WithCreateImage is a CreateOption which sets the container image 40 | func WithCreateImage(image string) CreateOption { 41 | return func(c *CreateConfig) { 42 | c.Spec.Image = image 43 | } 44 | } 45 | 46 | // WithCreateCmd is a CreateOption which sets the command to run in the container 47 | func WithCreateCmd(cmd ...string) CreateOption { 48 | return func(c *CreateConfig) { 49 | c.Spec.Config.Cmd = cmd 50 | } 51 | } 52 | 53 | // WithCreateTTY is a CreateOption which configures the container with a TTY 54 | func WithCreateTTY(cfg *CreateConfig) { 55 | cfg.Spec.Config.Tty = true 56 | } 57 | 58 | // WithCreateAttachStdin is a CreateOption which enables attaching to the container's stdin 59 | func WithCreateAttachStdin(cfg *CreateConfig) { 60 | cfg.Spec.AttachStdin = true 61 | cfg.Spec.OpenStdin = true 62 | } 63 | 64 | // WithCreateAttachStdinOnce is a CreateOption which enables attaching to the container's one time 65 | func WithCreateStdinOnce(cfg *CreateConfig) { 66 | cfg.Spec.StdinOnce = true 67 | } 68 | 69 | // WithCreateAttachStdout is a CreateOption which enables attaching to the container's stdout 70 | func WithCreateAttachStdout(cfg *CreateConfig) { 71 | cfg.Spec.AttachStdout = true 72 | } 73 | 74 | // WithCreateAttachStderr is a CreateOption which enables attaching to the container's stderr 75 | func WithCreateAttachStderr(cfg *CreateConfig) { 76 | cfg.Spec.AttachStderr = true 77 | } 78 | -------------------------------------------------------------------------------- /container/create_test.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/cpuguy83/go-docker/errdefs" 9 | "github.com/cpuguy83/go-docker/testutils" 10 | "gotest.tools/v3/assert" 11 | ) 12 | 13 | func TestCreate(t *testing.T) { 14 | t.Parallel() 15 | 16 | s, ctx := newTestService(t, context.Background()) 17 | 18 | c, err := s.Create(ctx, "") 19 | assert.Check(t, errdefs.IsInvalid(err), err) 20 | assert.Check(t, c == nil) 21 | if c != nil { 22 | if err := s.Remove(ctx, c.ID(), WithRemoveForce); err != nil && !errdefs.IsNotFound(err) { 23 | t.Error(err) 24 | } 25 | } 26 | 27 | name := strings.ToLower(t.Name()) + testutils.GenerateRandomString() 28 | c, err = s.Create(ctx, "busybox:latest", WithCreateName(name)) 29 | assert.NilError(t, err) 30 | defer func() { 31 | assert.Check(t, s.Remove(ctx, c.ID(), WithRemoveForce)) 32 | }() 33 | 34 | assert.Assert(t, c.ID() != "") 35 | 36 | inspect, err := c.Inspect(ctx) 37 | assert.NilError(t, err) 38 | assert.Equal(t, name, strings.TrimPrefix(inspect.Name, "/")) 39 | } 40 | -------------------------------------------------------------------------------- /container/exec_test.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "io" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/cpuguy83/go-docker/errdefs" 11 | "gotest.tools/v3/assert" 12 | "gotest.tools/v3/assert/cmp" 13 | ) 14 | 15 | func TestExec(t *testing.T) { 16 | t.Parallel() 17 | 18 | s, ctx := newTestService(t, context.Background()) 19 | 20 | c := s.NewContainer(ctx, "notexist") 21 | _, err := c.Exec(ctx, WithExecCmd("true")) 22 | assert.Check(t, errdefs.IsNotFound(err), err) 23 | 24 | c, err = s.Create(ctx, "busybox:latest", 25 | WithCreateCmd("/bin/sh", "-c", "trap 'exit 0' SIGTERM; while true; do sleep 0.1; done"), 26 | ) 27 | assert.NilError(t, err) 28 | defer func() { 29 | assert.Check(t, s.Remove(ctx, c.ID(), WithRemoveForce)) 30 | }() 31 | 32 | _, err = c.Exec(ctx, WithExecCmd("/bin/echo", "hello")) 33 | assert.Check(t, errdefs.IsConflict(err)) 34 | 35 | assert.NilError(t, c.Start(ctx)) 36 | 37 | ep, err := c.Exec(ctx, WithExecCmd("/bin/sh", "-c", "trap 'exit 0' SIGTERM; while true; do sleep 0.1; done")) 38 | assert.NilError(t, err) 39 | 40 | inspect, err := ep.Inspect(ctx) 41 | assert.NilError(t, err) 42 | assert.Check(t, cmp.Equal(inspect.ID, ep.ID())) 43 | assert.Check(t, cmp.Equal(inspect.ContainerID, c.ID())) 44 | assert.Check(t, !inspect.Running) 45 | assert.Check(t, cmp.Equal(inspect.Pid, 0)) 46 | var nilCode *int 47 | assert.Check(t, cmp.Equal(inspect.ExitCode, nilCode)) 48 | 49 | assert.NilError(t, ep.Start(ctx)) 50 | 51 | inspect, err = ep.Inspect(ctx) 52 | assert.NilError(t, err) 53 | assert.Check(t, cmp.Equal(inspect.ID, ep.ID())) 54 | assert.Check(t, cmp.Equal(inspect.ContainerID, c.ID())) 55 | assert.Check(t, inspect.Running) 56 | assert.Check(t, inspect.Pid != 0) 57 | assert.Check(t, cmp.Equal(inspect.ExitCode, nilCode)) 58 | 59 | assert.NilError(t, c.Stop(ctx)) 60 | inspect, err = ep.Inspect(ctx) 61 | assert.NilError(t, err) 62 | assert.Check(t, !inspect.Running) 63 | assert.Assert(t, inspect.ExitCode != nil) 64 | assert.Check(t, *inspect.ExitCode != 0) 65 | 66 | assert.NilError(t, c.Start(ctx)) 67 | 68 | r, w := io.Pipe() 69 | defer r.Close() 70 | 71 | ep, err = c.Exec(ctx, WithExecCmd("cat"), func(cfg *ExecConfig) { 72 | cfg.Stdin = io.NopCloser(strings.NewReader("hello\n")) 73 | cfg.Stdout = w 74 | cfg.Stderr = w 75 | }) 76 | assert.NilError(t, err) 77 | 78 | err = ep.Start(ctx) 79 | assert.NilError(t, err) 80 | 81 | line, _ := bufio.NewReader(r).ReadString('\n') 82 | assert.Equal(t, line, "hello\n") 83 | } 84 | -------------------------------------------------------------------------------- /container/inspect.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "io/ioutil" 7 | "net/http" 8 | 9 | "github.com/cpuguy83/go-docker/container/containerapi" 10 | "github.com/cpuguy83/go-docker/errdefs" 11 | "github.com/cpuguy83/go-docker/httputil" 12 | "github.com/cpuguy83/go-docker/transport" 13 | "github.com/cpuguy83/go-docker/version" 14 | ) 15 | 16 | // DefaultInspectDecodeLimitBytes is the default value used for limit how much data is read from the inspect response. 17 | const DefaultInspectDecodeLimitBytes = 64 * 1024 18 | 19 | // InspectConfig holds the options for inspecting a container 20 | type InspectConfig struct { 21 | // Allows callers of `Inspect` to unmarshal to any object rather than only the built-in types. 22 | // This is useful for anyone wrapping the API and providing more metadata (e.g. classic swarm) 23 | // To must be a pointer or it may cause a panic. 24 | // If `To` is provided, `Inspect`'s returned container object may be empty. 25 | To interface{} 26 | } 27 | 28 | // InspectOption is used as functional arguments to inspect a container 29 | // InspectOptions configure an InspectConfig. 30 | type InspectOption func(config *InspectConfig) 31 | 32 | // Inspect fetches detailed information about a container. 33 | func (s *Service) Inspect(ctx context.Context, name string, opts ...InspectOption) (containerapi.ContainerInspect, error) { 34 | return handleInspect(ctx, s.tr, name, opts...) 35 | } 36 | 37 | func handleInspect(ctx context.Context, tr transport.Doer, name string, opts ...InspectOption) (containerapi.ContainerInspect, error) { 38 | cfg := InspectConfig{} 39 | for _, o := range opts { 40 | o(&cfg) 41 | } 42 | 43 | var c containerapi.ContainerInspect 44 | 45 | resp, err := httputil.DoRequest(ctx, func(ctx context.Context) (*http.Response, error) { 46 | return tr.Do(ctx, http.MethodGet, version.Join(ctx, "/containers/"+name+"/json")) 47 | }) 48 | if err != nil { 49 | return c, err 50 | } 51 | 52 | defer resp.Body.Close() 53 | 54 | data, err := ioutil.ReadAll(resp.Body) 55 | if err != nil { 56 | return c, nil 57 | } 58 | 59 | if cfg.To != nil { 60 | if err := json.Unmarshal(data, cfg.To); err != nil { 61 | return c, errdefs.Wrap(err, "error unmarshalling to requested type") 62 | } 63 | return c, nil 64 | } 65 | 66 | if err := json.Unmarshal(data, &c); err != nil { 67 | return c, errdefs.Wrap(err, "error unmarshalling container json") 68 | } 69 | 70 | return c, nil 71 | } 72 | 73 | // Inspect fetches detailed information about the container. 74 | func (c *Container) Inspect(ctx context.Context, opts ...InspectOption) (containerapi.ContainerInspect, error) { 75 | return handleInspect(ctx, c.tr, c.id, opts...) 76 | } 77 | -------------------------------------------------------------------------------- /container/inspect_test.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | "testing" 7 | 8 | "gotest.tools/v3/assert/cmp" 9 | 10 | "github.com/cpuguy83/go-docker/errdefs" 11 | "github.com/cpuguy83/go-docker/testutils" 12 | "gotest.tools/v3/assert" 13 | ) 14 | 15 | func TestInspect(t *testing.T) { 16 | t.Parallel() 17 | 18 | s, ctx := newTestService(t, context.Background()) 19 | 20 | _, err := s.Inspect(ctx, "notExist"+testutils.GenerateRandomString()) 21 | assert.Check(t, errdefs.IsNotFound(err), err) 22 | 23 | name := strings.ToLower(t.Name()) 24 | c, err := s.Create(ctx, "busybox:latest", WithCreateName(name)) 25 | assert.NilError(t, err) 26 | defer func() { 27 | assert.Check(t, s.Remove(ctx, c.ID(), WithRemoveForce)) 28 | }() 29 | 30 | inspect, err := c.Inspect(ctx) 31 | assert.NilError(t, err) 32 | assert.Check(t, cmp.Equal(inspect.ID, c.ID())) 33 | assert.Check(t, cmp.Equal(strings.TrimPrefix(inspect.Name, "/"), name)) 34 | 35 | type inspectTo struct { 36 | ID string 37 | } 38 | to := &inspectTo{} 39 | inspect, err = c.Inspect(ctx, func(o *InspectConfig) { 40 | o.To = &to 41 | }) 42 | assert.NilError(t, err) 43 | assert.Equal(t, to.ID, c.ID()) 44 | } 45 | -------------------------------------------------------------------------------- /container/kill.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/cpuguy83/go-docker/errdefs" 8 | "github.com/cpuguy83/go-docker/httputil" 9 | "github.com/cpuguy83/go-docker/transport" 10 | "github.com/cpuguy83/go-docker/version" 11 | ) 12 | 13 | // KillOption is a functional argument passed to `Kill`, it is used to configure a KillConfig 14 | type KillOption func(*KillConfig) 15 | 16 | // KillConfig holds options available for the kill API 17 | type KillConfig struct { 18 | Signal string 19 | } 20 | 21 | // WithKillSignal returns a KillOption that sets the signal to send to the container 22 | func WithKillSignal(signal string) KillOption { 23 | return func(cfg *KillConfig) { 24 | cfg.Signal = signal 25 | } 26 | } 27 | 28 | // Kill sends a signal to the container. 29 | // If no signal is provided docker will send the default signal (SIGKILL on Linux) to the container. 30 | func (s *Service) Kill(ctx context.Context, name string, opts ...KillOption) error { 31 | return handleKill(ctx, s.tr, name, opts...) 32 | } 33 | 34 | func handleKill(ctx context.Context, tr transport.Doer, name string, opts ...KillOption) error { 35 | var cfg KillConfig 36 | for _, o := range opts { 37 | o(&cfg) 38 | } 39 | resp, err := httputil.DoRequest(ctx, func(ctx context.Context) (*http.Response, error) { 40 | return tr.Do(ctx, http.MethodPost, version.Join(ctx, "/containers/"+name+"/kill"), func(req *http.Request) error { 41 | q := req.URL.Query() 42 | q.Add("signal", cfg.Signal) 43 | req.URL.RawQuery = q.Encode() 44 | return nil 45 | }) 46 | }) 47 | if err != nil { 48 | return errdefs.Wrap(err, "error sending signal") 49 | } 50 | resp.Body.Close() 51 | return nil 52 | } 53 | 54 | // Kill sends a signal to the container 55 | // If no signal is provided docker will send the default signal (SIGKILL on Linux). 56 | func (c *Container) Kill(ctx context.Context, opts ...KillOption) error { 57 | return handleKill(ctx, c.tr, c.id, opts...) 58 | } 59 | -------------------------------------------------------------------------------- /container/kill_test.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/cpuguy83/go-docker/errdefs" 9 | "github.com/cpuguy83/go-docker/testutils" 10 | "gotest.tools/v3/assert" 11 | "gotest.tools/v3/assert/cmp" 12 | ) 13 | 14 | func TestKill(t *testing.T) { 15 | t.Parallel() 16 | 17 | s, ctx := newTestService(t, context.Background()) 18 | 19 | err := s.Kill(ctx, "notexist"+testutils.GenerateRandomString()) 20 | assert.Check(t, errdefs.IsNotFound(err), err) 21 | 22 | c, err := s.Create(ctx, "busybox:latest", WithCreateName(strings.ToLower(t.Name())), WithCreateTTY, WithCreateCmd("/bin/sh", "-c", "trap 'exit 0' SIGTERM; while true; do usleep 100000; done")) 23 | assert.NilError(t, err) 24 | defer func() { 25 | assert.Check(t, s.Remove(ctx, c.ID(), WithRemoveForce)) 26 | }() 27 | assert.NilError(t, c.Start(ctx)) 28 | 29 | err = c.Kill(ctx, WithKillSignal("FAKESIG")) 30 | assert.Check(t, errdefs.IsInvalid(err), err) 31 | 32 | err = c.Kill(ctx, WithKillSignal("SIGUSR1")) 33 | assert.NilError(t, err) 34 | 35 | inspect, err := c.Inspect(ctx) 36 | assert.NilError(t, err) 37 | assert.Check(t, cmp.Equal(inspect.State.Running, true)) 38 | 39 | err = c.Kill(ctx, WithKillSignal("SIGKILL")) 40 | assert.NilError(t, err) 41 | inspect, err = c.Inspect(ctx) 42 | assert.NilError(t, err) 43 | assert.Check(t, cmp.Equal(inspect.State.Running, false)) 44 | 45 | assert.NilError(t, c.Start(ctx)) 46 | inspect, err = c.Inspect(ctx) 47 | assert.NilError(t, err) 48 | assert.Check(t, cmp.Equal(inspect.State.Running, true)) 49 | 50 | err = c.Kill(ctx) 51 | assert.NilError(t, err) 52 | inspect, err = c.Inspect(ctx) 53 | assert.NilError(t, err) 54 | assert.Check(t, cmp.Equal(inspect.State.Running, false)) 55 | } 56 | -------------------------------------------------------------------------------- /container/list.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "io/ioutil" 7 | "net/http" 8 | "strconv" 9 | 10 | "github.com/cpuguy83/go-docker/container/containerapi" 11 | "github.com/cpuguy83/go-docker/errdefs" 12 | "github.com/cpuguy83/go-docker/httputil" 13 | 14 | "github.com/cpuguy83/go-docker/version" 15 | ) 16 | 17 | // ListFilter represents filters to process on the container list. 18 | type ListFilter struct { 19 | Ancestor []string `json:"ancestor,omitempty"` 20 | Before []string `json:"before,omitempty"` 21 | Expose []string `json:"expose,omitempty"` 22 | Exited []string `json:"exited,omitempty"` 23 | Health []string `json:"health,omitempty"` 24 | ID []string `json:"id,omitempty"` 25 | Isolation []string `json:"isolation,omitempty"` 26 | IsTask []string `json:"is-task,omitempty"` 27 | Label []string `json:"label,omitempty"` 28 | Name []string `json:"name,omitempty"` 29 | Network []string `json:"network,omitempty"` 30 | Publish []string `json:"publish,omitempty"` 31 | Since []string `json:"since,omitempty"` 32 | Status []string `json:"status,omitempty"` 33 | Volume []string `json:"volume,omitempty"` 34 | } 35 | 36 | // ListConfig holds the options for listing containers 37 | type ListConfig struct { 38 | All bool 39 | Limit int 40 | Size bool 41 | Filter ListFilter 42 | } 43 | 44 | // ListOption is used as functional arguments to list containers 45 | // ListOption configure a ListConfig. 46 | type ListOption func(config *ListConfig) 47 | 48 | // List fetches a list of containers. 49 | func (s *Service) List(ctx context.Context, opts ...ListOption) ([]containerapi.Container, error) { 50 | cfg := ListConfig{ 51 | Limit: -1, 52 | } 53 | for _, o := range opts { 54 | o(&cfg) 55 | } 56 | 57 | withListConfig := func(req *http.Request) error { 58 | q := req.URL.Query() 59 | q.Add("all", strconv.FormatBool(cfg.All)) 60 | q.Add("limit", strconv.Itoa(cfg.Limit)) 61 | q.Add("size", strconv.FormatBool(cfg.Size)) 62 | filterJSON, err := json.Marshal(cfg.Filter) 63 | 64 | if err != nil { 65 | return err 66 | } 67 | q.Add("filters", string(filterJSON)) 68 | 69 | req.URL.RawQuery = q.Encode() 70 | return nil 71 | } 72 | 73 | var containers []containerapi.Container 74 | 75 | resp, err := httputil.DoRequest(ctx, func(ctx context.Context) (*http.Response, error) { 76 | return s.tr.Do(ctx, http.MethodGet, version.Join(ctx, "/containers/json"), withListConfig) 77 | }) 78 | if err != nil { 79 | return containers, err 80 | } 81 | 82 | defer resp.Body.Close() 83 | 84 | data, err := ioutil.ReadAll(resp.Body) 85 | if err != nil { 86 | return containers, nil 87 | } 88 | 89 | if err := json.Unmarshal(data, &containers); err != nil { 90 | return containers, errdefs.Wrap(err, "error unmarshalling container json") 91 | } 92 | 93 | return containers, nil 94 | } 95 | -------------------------------------------------------------------------------- /container/list_test.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | 8 | "gotest.tools/v3/assert" 9 | ) 10 | 11 | func TestList(t *testing.T) { 12 | t.Parallel() 13 | 14 | s, ctx := newTestService(t, context.Background()) 15 | 16 | c, err := s.Create(ctx, "busybox:latest", 17 | WithCreateCmd("/bin/sh", "-c", "trap 'exit 0' SIGTERM; while true; do sleep 0.1; done"), 18 | ) 19 | assert.NilError(t, err) 20 | 21 | defer func() { 22 | assert.Check(t, s.Remove(ctx, c.ID(), WithRemoveForce)) 23 | }() 24 | 25 | err = c.Start(ctx) 26 | assert.NilError(t, err) 27 | 28 | containers, err := s.List(ctx) 29 | assert.NilError(t, err) 30 | 31 | found := false 32 | for _, container := range containers { 33 | fmt.Printf("\n\nid: %s\n", c.ID()) 34 | fmt.Printf("\n\nlooking for: %s\n", container.ID) 35 | if container.ID == c.ID() { 36 | found = true 37 | break 38 | } 39 | } 40 | 41 | assert.Assert(t, found, "expected container to be found but it wasn't") 42 | } 43 | 44 | func TestListLimit(t *testing.T) { 45 | t.Parallel() 46 | 47 | s, ctx := newTestService(t, context.Background()) 48 | n := 4 49 | 50 | for i := 0; i < n; i++ { 51 | c, err := s.Create(ctx, "busybox:latest", 52 | WithCreateCmd("/bin/sh", "-c", "trap 'exit 0' SIGTERM; while true; do sleep 0.1; done"), 53 | ) 54 | assert.NilError(t, err) 55 | 56 | defer func() { 57 | assert.Check(t, s.Remove(ctx, c.ID(), WithRemoveForce)) 58 | }() 59 | 60 | err = c.Start(ctx) 61 | assert.NilError(t, err) 62 | } 63 | 64 | containers, err := s.List(ctx, func(config *ListConfig) { 65 | config.Limit = 2 66 | }) 67 | assert.NilError(t, err) 68 | assert.Assert(t, len(containers) == 2, "expected container to be found but it wasn't") 69 | assert.Equal(t, containers[0].SizeRootFs, 0, "expected container's SizeRootFs to be a zero value") 70 | assert.Assert(t, containers[0].SizeRw == 0, "expected container's SizeRw to be a positive integer") 71 | 72 | } 73 | 74 | func TestListSize(t *testing.T) { 75 | s, ctx := newTestService(t, context.Background()) 76 | n := 1 77 | 78 | for i := 0; i < n; i++ { 79 | c, err := s.Create(ctx, "busybox:latest", 80 | WithCreateCmd("/bin/sh", "-c", "trap 'exit 0' SIGTERM; while true; do sleep 0.1; done"), 81 | ) 82 | assert.NilError(t, err) 83 | 84 | defer func() { 85 | assert.Check(t, s.Remove(ctx, c.ID(), WithRemoveForce)) 86 | }() 87 | 88 | err = c.Start(ctx) 89 | assert.NilError(t, err) 90 | } 91 | 92 | containers, err := s.List(ctx, func(config *ListConfig) { 93 | config.Limit = 1 94 | config.Size = true 95 | }) 96 | assert.NilError(t, err) 97 | assert.Assert(t, len(containers) == 1, "expected container to be found but it wasn't") 98 | assert.Assert(t, containers[0].SizeRw == 0, "expected container's SizeRw to exist") 99 | assert.Assert(t, containers[0].SizeRootFs > 0, "expected container's SizeRootFs to be a positive integer") 100 | } 101 | 102 | func TestListFilter(t *testing.T) { 103 | s, ctx := newTestService(t, context.Background()) 104 | n := 2 105 | 106 | for i := 0; i < n; i++ { 107 | c, err := s.Create(ctx, 108 | "busybox:latest", 109 | WithCreateCmd("/bin/sh", "-c", "trap 'exit 0' SIGTERM; while true; do sleep 0.1; done"), 110 | WithCreateName(fmt.Sprintf("foobar-%d", i)), 111 | ) 112 | assert.NilError(t, err) 113 | 114 | defer func() { 115 | assert.Check(t, s.Remove(ctx, c.ID(), WithRemoveForce)) 116 | }() 117 | 118 | err = c.Start(ctx) 119 | assert.NilError(t, err) 120 | } 121 | 122 | containers, err := s.List(ctx, func(config *ListConfig) { 123 | config.Filter = ListFilter{Name: []string{"foobar-0"}} 124 | }) 125 | assert.NilError(t, err) 126 | assert.Assert(t, len(containers) == 1, "expected container to be %d but received %d", 1, len(containers)) 127 | assert.Assert(t, containers[0].Names[0] == "/foobar-0") 128 | } 129 | -------------------------------------------------------------------------------- /container/logs.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/http" 7 | "strconv" 8 | 9 | "github.com/cpuguy83/go-docker/container/streamutil" 10 | "github.com/cpuguy83/go-docker/httputil" 11 | "github.com/cpuguy83/go-docker/version" 12 | ) 13 | 14 | type LogsReadOption func(*LogReadConfig) 15 | 16 | type LogReadConfig struct { 17 | Since string `json:"since"` 18 | Until string `json:"until"` 19 | Timestamps bool `json:"timestamps"` 20 | Follow bool `json:"follow"` 21 | Tail string `json:"tail"` 22 | Details bool `json:"details,omitempty"` 23 | Stdout io.WriteCloser `json:"-"` 24 | Stderr io.WriteCloser `json:"-"` 25 | } 26 | 27 | type logReadConfigAPI struct { 28 | ShowStdout bool `json:"stdout"` 29 | ShowStderr bool `json:"stderr"` 30 | LogReadConfig 31 | } 32 | 33 | const ( 34 | mediaTypeMultiplexed = "application/vnd.docker.multiplexed-stream" 35 | ) 36 | 37 | // Logs returns the logs for a container. 38 | // The logs may be a multiplexed stream with both stdout and stderr, in which case you'll need to split the stream using github.com/cpuguy83/go-docker/container/streamutil.StdCopy 39 | // The bool value returned indicates whether the logs are multiplexed or not. 40 | func (c *Container) Logs(ctx context.Context, opts ...LogsReadOption) error { 41 | var cfg LogReadConfig 42 | for _, o := range opts { 43 | o(&cfg) 44 | } 45 | 46 | cfgAPI := logReadConfigAPI{ 47 | ShowStdout: cfg.Stdout != nil, 48 | ShowStderr: cfg.Stderr != nil, 49 | LogReadConfig: cfg, 50 | } 51 | 52 | withLogConfig := func(req *http.Request) error { 53 | q := req.URL.Query() 54 | q.Add("follow", strconv.FormatBool(cfgAPI.Follow)) 55 | q.Add("stdout", strconv.FormatBool(cfgAPI.ShowStdout)) 56 | q.Add("stderr", strconv.FormatBool(cfgAPI.ShowStderr)) 57 | q.Add("since", cfgAPI.Since) 58 | q.Add("until", cfgAPI.Until) 59 | q.Add("timestamps", strconv.FormatBool(cfgAPI.Timestamps)) 60 | q.Add("tail", cfgAPI.Tail) 61 | 62 | req.URL.RawQuery = q.Encode() 63 | return nil 64 | } 65 | 66 | // Here we do not want to limit the response size since we are returning a log stream, so we perform this manually 67 | // instead of with httputil.DoRequest 68 | resp, err := c.tr.Do(ctx, http.MethodGet, version.Join(ctx, "/containers/"+c.id+"/logs"), withLogConfig) 69 | if err != nil { 70 | return err 71 | } 72 | 73 | // Starting with api version 1.42, docker should returnn a header with the content-type indicating if the stream is multiplexed. 74 | // If the api version is lower then we'll need to inspect the container to determine if the stream is multiplexed. 75 | mux := resp.Header.Get("Content-Type") == mediaTypeMultiplexed 76 | if !mux { 77 | if version.APIVersion(ctx) == "" || version.LessThan(version.APIVersion(ctx), "1.42") { 78 | inspect, err := c.Inspect(ctx) 79 | if err == nil { 80 | mux = !inspect.Config.Tty 81 | } 82 | } 83 | } 84 | 85 | body := resp.Body 86 | httputil.LimitResponse(ctx, resp) 87 | if err := httputil.CheckResponseError(resp); err != nil { 88 | return err 89 | } 90 | 91 | if mux { 92 | if cfg.Stdout != nil || cfg.Stderr != nil { 93 | go func() { 94 | streamutil.StdCopy(cfg.Stdout, cfg.Stderr, body) 95 | closeWrite(cfg.Stdout) 96 | closeWrite(cfg.Stderr) 97 | body.Close() 98 | }() 99 | } 100 | return nil 101 | } 102 | 103 | if cfg.Stdout != nil { 104 | go func() { 105 | io.Copy(cfg.Stdout, body) 106 | closeWrite(cfg.Stdout) 107 | body.Close() 108 | }() 109 | } 110 | 111 | if cfg.Stderr != nil { 112 | go func() { 113 | io.Copy(cfg.Stderr, body) 114 | closeWrite(cfg.Stderr) 115 | body.Close() 116 | }() 117 | } 118 | 119 | return nil 120 | } 121 | -------------------------------------------------------------------------------- /container/logs_test.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "io" 7 | "strconv" 8 | "strings" 9 | "testing" 10 | "time" 11 | 12 | "gotest.tools/v3/assert" 13 | "gotest.tools/v3/assert/cmp" 14 | ) 15 | 16 | func waitForContainerExit(ctx context.Context, t *testing.T, c *Container) { 17 | t.Helper() 18 | 19 | wait, err := c.Wait(ctx, WithWaitCondition(WaitConditionNotRunning)) 20 | assert.NilError(t, err) 21 | _, err = wait.ExitCode() 22 | assert.NilError(t, err) 23 | } 24 | 25 | func TestStdoutLogs(t *testing.T) { 26 | t.Parallel() 27 | 28 | s, ctx := newTestService(t, context.Background()) 29 | 30 | c, err := s.Create(ctx, "busybox:latest", 31 | WithCreateCmd("/bin/sh", "-c", "echo 'hello there'"), 32 | ) 33 | assert.NilError(t, err) 34 | 35 | defer func() { 36 | assert.Check(t, s.Remove(ctx, c.ID(), WithRemoveForce)) 37 | }() 38 | 39 | err = c.Start(ctx) 40 | assert.NilError(t, err) 41 | 42 | waitForContainerExit(ctx, t, c) 43 | 44 | r, w := io.Pipe() 45 | err = c.Logs(ctx, func(config *LogReadConfig) { 46 | config.Stdout = w 47 | }) 48 | assert.NilError(t, err) 49 | defer r.Close() 50 | 51 | data, err := io.ReadAll(r) 52 | assert.NilError(t, err) 53 | 54 | assert.Assert(t, cmp.Contains(string(data), "hello there"), "expected container logs to contain 'hello there'") 55 | } 56 | 57 | func TestStderrLogs(t *testing.T) { 58 | t.Parallel() 59 | 60 | s, ctx := newTestService(t, context.Background()) 61 | 62 | c, err := s.Create(ctx, "busybox:latest", 63 | WithCreateCmd("/bin/sh", "-c", ">&2 echo 'bad things'"), 64 | ) 65 | assert.NilError(t, err) 66 | 67 | defer func() { 68 | assert.Check(t, s.Remove(ctx, c.ID(), WithRemoveForce)) 69 | }() 70 | 71 | err = c.Start(ctx) 72 | assert.NilError(t, err) 73 | 74 | waitForContainerExit(ctx, t, c) 75 | 76 | r, w := io.Pipe() 77 | err = c.Logs(ctx, func(config *LogReadConfig) { 78 | config.Stderr = w 79 | }) 80 | defer r.Close() 81 | assert.NilError(t, err) 82 | 83 | data, err := io.ReadAll(r) 84 | assert.NilError(t, err) 85 | 86 | assert.Assert(t, cmp.Contains(string(data), "bad things"), "expected container logs to contain 'bad things'") 87 | } 88 | 89 | func TestStdoutStderrLogs(t *testing.T) { 90 | t.Parallel() 91 | 92 | s, ctx := newTestService(t, context.Background()) 93 | 94 | c, err := s.Create(ctx, "busybox:latest", 95 | WithCreateCmd("/bin/sh", "-c", ">&2 echo 'bad things'"), 96 | ) 97 | assert.NilError(t, err) 98 | 99 | defer func() { 100 | assert.Check(t, s.Remove(ctx, c.ID(), WithRemoveForce)) 101 | }() 102 | 103 | err = c.Start(ctx) 104 | assert.NilError(t, err) 105 | 106 | r, w := io.Pipe() 107 | err = c.Logs(ctx, func(config *LogReadConfig) { 108 | config.Stdout = w 109 | }) 110 | defer r.Close() 111 | assert.NilError(t, err) 112 | 113 | data, err := io.ReadAll(r) 114 | assert.NilError(t, err) 115 | 116 | assert.Equal(t, string(data), "") 117 | } 118 | 119 | func TestLogsSince(t *testing.T) { 120 | t.Parallel() 121 | 122 | s, ctx := newTestService(t, context.Background()) 123 | 124 | c, err := s.Create(ctx, "busybox:latest", 125 | WithCreateCmd("/bin/sh", "-c", "echo 'hello there'; sleep 2; echo 'why hello'"), 126 | ) 127 | assert.NilError(t, err) 128 | 129 | wait, err := c.Wait(ctx, WithWaitCondition(WaitConditionNextExit)) 130 | assert.NilError(t, err) 131 | 132 | defer func() { 133 | assert.Check(t, s.Remove(ctx, c.ID(), WithRemoveForce)) 134 | }() 135 | 136 | err = c.Start(ctx) 137 | assert.NilError(t, err) 138 | 139 | inspect, err := c.Inspect(ctx) 140 | assert.NilError(t, err) 141 | started, err := time.Parse(time.RFC3339Nano, inspect.State.StartedAt) 142 | assert.NilError(t, err) 143 | ts := started.Add(2 * time.Second).Unix() 144 | 145 | _, err = wait.ExitCode() 146 | assert.NilError(t, err) 147 | 148 | r, w := io.Pipe() 149 | defer r.Close() 150 | err = c.Logs(ctx, func(config *LogReadConfig) { 151 | config.Stdout = w 152 | config.Since = strconv.FormatInt(ts, 10) 153 | }) 154 | assert.NilError(t, err) 155 | 156 | data, err := io.ReadAll(r) 157 | assert.NilError(t, err) 158 | 159 | assert.Assert(t, !strings.Contains(string(data), "hello there"), "expected container logs to not contain 'hello there'") 160 | assert.Assert(t, strings.Contains(string(data), "why hello"), "expected container logs to contain 'why hello'") 161 | } 162 | 163 | func TestLogsUntil(t *testing.T) { 164 | t.Parallel() 165 | 166 | s, ctx := newTestService(t, context.Background()) 167 | 168 | c, err := s.Create(ctx, "busybox:latest", 169 | WithCreateCmd("/bin/sh", "-c", "echo 'hello there'; sleep 2; echo 'why hello'"), 170 | ) 171 | assert.NilError(t, err) 172 | 173 | wait, err := c.Wait(ctx, WithWaitCondition(WaitConditionNextExit)) 174 | assert.NilError(t, err) 175 | 176 | defer func() { 177 | assert.Check(t, s.Remove(ctx, c.ID(), WithRemoveForce)) 178 | }() 179 | 180 | err = c.Start(ctx) 181 | assert.NilError(t, err) 182 | 183 | inspect, err := c.Inspect(ctx) 184 | assert.NilError(t, err) 185 | started, err := time.Parse(time.RFC3339Nano, inspect.State.StartedAt) 186 | assert.NilError(t, err) 187 | 188 | _, err = wait.ExitCode() 189 | assert.NilError(t, err) 190 | 191 | r, w := io.Pipe() 192 | defer r.Close() 193 | err = c.Logs(ctx, func(config *LogReadConfig) { 194 | config.Stdout = w 195 | config.Until = strconv.FormatInt(started.Add(time.Second).Unix(), 10) 196 | }) 197 | assert.NilError(t, err) 198 | 199 | data, err := io.ReadAll(r) 200 | assert.NilError(t, err) 201 | 202 | assert.Assert(t, strings.Contains(string(data), "hello there"), "expected container logs to contain 'hello there'") 203 | assert.Assert(t, !strings.Contains(string(data), "why hello"), "expected container logs to not contain 'why hello'") 204 | } 205 | 206 | func TestLogsTimestamps(t *testing.T) { 207 | t.Parallel() 208 | 209 | s, ctx := newTestService(t, context.Background()) 210 | 211 | c, err := s.Create(ctx, "busybox:latest", 212 | WithCreateCmd("/bin/sh", "-c", "echo 'hello there'"), 213 | ) 214 | assert.NilError(t, err) 215 | 216 | defer func() { 217 | assert.Check(t, s.Remove(ctx, c.ID(), WithRemoveForce)) 218 | }() 219 | 220 | err = c.Start(ctx) 221 | assert.NilError(t, err) 222 | 223 | waitForContainerExit(ctx, t, c) 224 | 225 | pr, pw := io.Pipe() 226 | defer pr.Close() 227 | err = c.Logs(ctx, func(config *LogReadConfig) { 228 | config.Stdout = pw 229 | config.Timestamps = true 230 | }) 231 | assert.NilError(t, err) 232 | 233 | r := bufio.NewReader(pr) 234 | 235 | ts, err := r.ReadString(' ') 236 | assert.NilError(t, err) 237 | 238 | parsedTime, err := time.Parse("2006-01-02T15:04:05.999999999Z", ts[:len(ts)-1]) 239 | assert.NilError(t, err) 240 | 241 | now := time.Now().UTC() 242 | assert.Assert(t, parsedTime.Year() == now.Year(), "expected parsed year to be %d but received %d", now.Year(), parsedTime.Year()) 243 | assert.Assert(t, parsedTime.Month() == now.Month(), "expected parsed month to be %s but received %s", now.Month(), parsedTime.Month()) 244 | assert.Assert(t, parsedTime.Day() == now.Day(), "expected parsed day to be %d but received %d", now.Day(), parsedTime.Day()) 245 | } 246 | -------------------------------------------------------------------------------- /container/remove.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "strconv" 7 | 8 | "github.com/cpuguy83/go-docker/httputil" 9 | "github.com/cpuguy83/go-docker/version" 10 | ) 11 | 12 | // RemoveOption is used as functional arguments for container remove 13 | // RemoveOptioni configure a RemoveConfig 14 | type RemoveOption func(*RemoveConfig) 15 | 16 | // RemoveConfig holds options for container remove. 17 | type RemoveConfig struct { 18 | RemoveVolumes bool 19 | RemoveLinks bool 20 | Force bool 21 | } 22 | 23 | // WithRemoveForce is a RemoveOption that enables the force remove option. 24 | // This enables a container to be removed even if it is running. 25 | func WithRemoveForce(o *RemoveConfig) { 26 | o.Force = true 27 | } 28 | 29 | // Remove removes a container. 30 | func (s *Service) Remove(ctx context.Context, name string, opts ...RemoveOption) error { 31 | var cfg RemoveConfig 32 | for _, o := range opts { 33 | o(&cfg) 34 | } 35 | withRemoveConfig := func(req *http.Request) error { 36 | q := req.URL.Query() 37 | q.Add("force", strconv.FormatBool(cfg.Force)) 38 | q.Add("link", strconv.FormatBool(cfg.RemoveLinks)) 39 | q.Add("v", strconv.FormatBool(cfg.RemoveVolumes)) 40 | req.URL.RawQuery = q.Encode() 41 | return nil 42 | } 43 | 44 | resp, err := httputil.DoRequest(ctx, func(ctx context.Context) (*http.Response, error) { 45 | return s.tr.Do(ctx, http.MethodDelete, version.Join(ctx, "/containers/"+name), withRemoveConfig) 46 | }) 47 | if err != nil { 48 | return err 49 | } 50 | resp.Body.Close() 51 | return nil 52 | } 53 | -------------------------------------------------------------------------------- /container/remove_test.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/cpuguy83/go-docker/errdefs" 8 | "github.com/cpuguy83/go-docker/testutils" 9 | "gotest.tools/v3/assert" 10 | ) 11 | 12 | func TestRemove(t *testing.T) { 13 | t.Parallel() 14 | 15 | s, ctx := newTestService(t, context.Background()) 16 | 17 | err := s.Remove(ctx, "notexist"+testutils.GenerateRandomString()) 18 | assert.Check(t, errdefs.IsNotFound(err)) 19 | 20 | c, err := s.Create(ctx, "busybox:latest") 21 | assert.NilError(t, err) 22 | assert.Check(t, s.Remove(ctx, c.ID()), "leaked container: %s", c.ID()) 23 | 24 | c, err = s.Create(ctx, "busybox:latest", WithCreateCmd("top")) 25 | assert.NilError(t, err) 26 | assert.Assert(t, c.Start(ctx)) 27 | err = s.Remove(ctx, c.ID()) 28 | assert.Assert(t, errdefs.IsConflict(err), err) 29 | err = s.Remove(ctx, c.ID(), WithRemoveForce) 30 | assert.NilError(t, err) 31 | } 32 | -------------------------------------------------------------------------------- /container/service.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import "github.com/cpuguy83/go-docker/transport" 4 | 5 | // Service facilitates all communication with Docker's container endpoints. 6 | // Create one with `NewService` 7 | type Service struct { 8 | tr transport.Doer 9 | } 10 | 11 | // NewService creates a new Service. 12 | func NewService(tr transport.Doer) *Service { 13 | return &Service{tr: tr} 14 | } 15 | -------------------------------------------------------------------------------- /container/service_test.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | "testing" 7 | 8 | "github.com/cpuguy83/go-docker/system" 9 | "github.com/cpuguy83/go-docker/testutils" 10 | "github.com/cpuguy83/go-docker/transport" 11 | "github.com/cpuguy83/go-docker/version" 12 | ) 13 | 14 | var ( 15 | versionOnce sync.Once 16 | negotiatedAPIVersion = "" 17 | ) 18 | 19 | func negoiateTestAPIVersion(t testing.TB, tr transport.Doer) { 20 | versionOnce.Do(func() { 21 | ctx := context.Background() 22 | ctx, err := system.NewService(tr).NegotiateAPIVersion(ctx) 23 | if err != nil { 24 | t.Fatalf("error negotiating api version: %v", err) 25 | } 26 | negotiatedAPIVersion = version.APIVersion(ctx) 27 | }) 28 | } 29 | 30 | func newTestServiceNoTap(t *testing.T, ctx context.Context, noTap bool) (*Service, context.Context) { 31 | tr, _ := testutils.NewDefaultTestTransport(t, noTap) 32 | if version.APIVersion(ctx) == "" { 33 | negoiateTestAPIVersion(t, tr) 34 | ctx = version.WithAPIVersion(ctx, negotiatedAPIVersion) 35 | } 36 | return NewService(tr), ctx 37 | } 38 | 39 | func newTestService(t *testing.T, ctx context.Context) (*Service, context.Context) { 40 | return newTestServiceNoTap(t, ctx, true) 41 | } 42 | -------------------------------------------------------------------------------- /container/start.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/cpuguy83/go-docker/httputil" 8 | "github.com/cpuguy83/go-docker/version" 9 | ) 10 | 11 | // StartOption is used as functional arguments for container Start 12 | // A StartOption configures a StartConfig 13 | type StartOption func(*StartConfig) 14 | 15 | // StartConfig holds configuration options for container start 16 | type StartConfig struct { 17 | CheckpointID string 18 | CheckpointDir string 19 | } 20 | 21 | // Start starts a container 22 | func (c *Container) Start(ctx context.Context, opts ...StartOption) error { 23 | var cfg StartConfig 24 | for _, o := range opts { 25 | o(&cfg) 26 | } 27 | 28 | withStartConfig := func(req *http.Request) error { 29 | q := req.URL.Query() 30 | if cfg.CheckpointID != "" { 31 | q.Add("checkpoint", cfg.CheckpointID) 32 | } 33 | if cfg.CheckpointDir != "" { 34 | q.Add("checkpoint-dir", cfg.CheckpointDir) 35 | } 36 | req.URL.RawQuery = q.Encode() 37 | return nil 38 | } 39 | 40 | resp, err := httputil.DoRequest(ctx, func(ctx context.Context) (*http.Response, error) { 41 | return c.tr.Do(ctx, http.MethodPost, version.Join(ctx, "/containers/"+c.id+"/start"), withStartConfig) 42 | }) 43 | if err != nil { 44 | return err 45 | } 46 | 47 | resp.Body.Close() 48 | return nil 49 | } 50 | -------------------------------------------------------------------------------- /container/start_test.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/cpuguy83/go-docker/errdefs" 8 | "github.com/cpuguy83/go-docker/testutils" 9 | "gotest.tools/v3/assert" 10 | ) 11 | 12 | func TestStart(t *testing.T) { 13 | t.Parallel() 14 | 15 | s, ctx := newTestService(t, context.Background()) 16 | 17 | c := s.NewContainer(ctx, "notexist"+testutils.GenerateRandomString()) 18 | err := c.Start(ctx) 19 | assert.Assert(t, errdefs.IsNotFound(err), err) 20 | 21 | c, err = s.Create(ctx, "busybox:latest", WithCreateCmd("top")) 22 | defer func() { 23 | if c != nil { 24 | assert.Check(t, s.Remove(ctx, c.ID(), WithRemoveForce)) 25 | } 26 | }() 27 | assert.NilError(t, err) 28 | assert.NilError(t, c.Start(ctx)) 29 | assert.NilError(t, c.Start(ctx)) 30 | 31 | inspect, err := c.Inspect(ctx) 32 | assert.NilError(t, err) 33 | assert.Assert(t, inspect.State.Running) 34 | } 35 | -------------------------------------------------------------------------------- /container/stop.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "strconv" 7 | "time" 8 | 9 | "github.com/cpuguy83/go-docker/httputil" 10 | "github.com/cpuguy83/go-docker/version" 11 | ) 12 | 13 | // StopOption is used as functional arguments to container stop 14 | // StopOptions configure a StopConfig 15 | type StopOption func(*StopConfig) 16 | 17 | // StopConfig holds the options for stopping a container 18 | type StopConfig struct { 19 | Timeout *time.Duration 20 | } 21 | 22 | // WithStopTimeout sets the timeout for a stop request. 23 | // Docker waits up to the timeout duration for the container to respond to the stop signal configured on the container. 24 | // Once the timeout is reached and the container still has not stopped, Docker will forcefully terminate the process. 25 | func WithStopTimeout(dur time.Duration) StopOption { 26 | return func(cfg *StopConfig) { 27 | cfg.Timeout = &dur 28 | } 29 | } 30 | 31 | // Stop stops a container 32 | func (c *Container) Stop(ctx context.Context, opts ...StopOption) error { 33 | var cfg StopConfig 34 | for _, o := range opts { 35 | o(&cfg) 36 | } 37 | // TODO: Set timeout based on context? 38 | 39 | withQuery := func(req *http.Request) error { 40 | if cfg.Timeout != nil { 41 | q := req.URL.Query() 42 | q.Set("timeout", strconv.FormatFloat(cfg.Timeout.Seconds(), 'f', 0, 64)) 43 | req.URL.RawQuery = q.Encode() 44 | } 45 | return nil 46 | } 47 | 48 | resp, err := httputil.DoRequest(ctx, func(ctx context.Context) (*http.Response, error) { 49 | return c.tr.Do(ctx, http.MethodPost, version.Join(ctx, "/containers/"+c.id+"/stop"), withQuery) 50 | }) 51 | if err != nil { 52 | return err 53 | } 54 | 55 | resp.Body.Close() 56 | return nil 57 | } 58 | -------------------------------------------------------------------------------- /container/stop_test.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/cpuguy83/go-docker/errdefs" 8 | "github.com/cpuguy83/go-docker/testutils" 9 | "gotest.tools/v3/assert" 10 | ) 11 | 12 | func TestStop(t *testing.T) { 13 | t.Parallel() 14 | 15 | s, ctx := newTestService(t, context.Background()) 16 | 17 | c := s.NewContainer(ctx, "notexist"+testutils.GenerateRandomString()) 18 | err := c.Stop(ctx) 19 | assert.Assert(t, errdefs.IsNotFound(err), err) 20 | 21 | c, err = s.Create(ctx, "busybox:latest", 22 | WithCreateCmd("/bin/sh", "-c", "trap 'exit 1' EXIT; while true; do sleep 0.1; done"), 23 | ) 24 | defer func() { 25 | if c != nil { 26 | assert.Check(t, s.Remove(ctx, c.ID(), WithRemoveForce)) 27 | } 28 | }() 29 | assert.NilError(t, err) 30 | assert.NilError(t, c.Start(ctx)) 31 | assert.NilError(t, c.Stop(ctx)) 32 | 33 | inspect, err := c.Inspect(ctx) 34 | assert.NilError(t, err) 35 | assert.Assert(t, !inspect.State.Running) 36 | } 37 | -------------------------------------------------------------------------------- /container/streamutil/stdcopy.go: -------------------------------------------------------------------------------- 1 | package streamutil 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "github.com/cpuguy83/go-docker/errdefs" 8 | ) 9 | 10 | // StdCopy will de-multiplex `src`, assuming that it contains two streams, 11 | // previously multiplexed together using a StdWriter instance. 12 | // As it reads from `src`, StdCopy will write to `dstout` and `dsterr`. 13 | // 14 | // StdCopy will read until it hits EOF on `src`. It will then return a nil error. 15 | // In other words: if `err` is non nil, it indicates a real underlying error. 16 | // 17 | // `written` will hold the total number of bytes written to `dstout` and `dsterr`. 18 | func StdCopy(dstout, dsterr io.Writer, src io.Reader) (written int64, retErr error) { 19 | rdr := NewStdReader(src) 20 | buf := make([]byte, 32*2014) 21 | 22 | if dstout == nil { 23 | dstout = io.Discard 24 | } 25 | if dsterr == nil { 26 | dsterr = io.Discard 27 | } 28 | 29 | for { 30 | hdr, err := rdr.Next() 31 | if err != nil { 32 | if err == io.EOF { 33 | err = nil 34 | } 35 | return written, err 36 | } 37 | 38 | var out io.Writer 39 | 40 | switch hdr.Descriptor { 41 | case Stdout: 42 | out = dstout 43 | case Stderr: 44 | out = dsterr 45 | case Systemerr: 46 | // Limit the size of this message to the size of our buffer to prevent memory exhaustion 47 | n, err := rdr.Read(buf) 48 | if err != nil { 49 | return written, errdefs.Wrapf(err, "error while copying system error from stdio stream, truncated message=%q", buf[:n]) 50 | } 51 | return written, fmt.Errorf("%s", buf[:n]) 52 | default: 53 | return written, fmt.Errorf("got data for unknown stream id: %d", hdr.Descriptor) 54 | } 55 | 56 | n, err := io.CopyBuffer(out, rdr, buf) 57 | written += n 58 | if err != nil { 59 | return written, errdefs.Wrap(err, "got error while copying to stream") 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /container/streamutil/stdcopy_test.go: -------------------------------------------------------------------------------- 1 | package streamutil 2 | 3 | import ( 4 | "bytes" 5 | "encoding/binary" 6 | "io" 7 | "testing" 8 | "time" 9 | 10 | "github.com/cpuguy83/go-docker/testutils" 11 | 12 | "gotest.tools/v3/assert" 13 | "gotest.tools/v3/assert/cmp" 14 | ) 15 | 16 | func TestStdCopyNormal(t *testing.T) { 17 | stdout := bytes.NewBuffer(nil) 18 | stderr := bytes.NewBuffer(nil) 19 | r, w := io.Pipe() 20 | defer r.Close() 21 | 22 | var ( 23 | nw int64 24 | copied int64 25 | ) 26 | 27 | data1 := []byte("hello stdout!") 28 | data2 := []byte("what's up stderr!") 29 | data3 := []byte("here's some more for you stdout") 30 | 31 | testCh := make(chan func(t *testing.T), 1) 32 | go func() { 33 | t.Helper() 34 | var err error 35 | copied, err = StdCopy(stdout, stderr, r) 36 | testCh <- func(t *testing.T) { 37 | assert.NilError(t, err) 38 | assert.Check(t, cmp.Equal(copied, nw)) 39 | assert.Check(t, cmp.Equal(stdout.String(), string(data1)+string(data3))) 40 | assert.Check(t, cmp.Equal(stderr.String(), string(data2))) 41 | } 42 | }() 43 | 44 | stdoutHeader := [stdHeaderPrefixLen]byte{stdHeaderFdIndex: Stdout} 45 | stderrHeader := [stdHeaderPrefixLen]byte{stdHeaderFdIndex: Stderr} 46 | 47 | binary.BigEndian.PutUint32(stdoutHeader[stdHeaderSizeIndex:], uint32(len(data1))) 48 | _, err := w.Write(append(stdoutHeader[:], data1...)) 49 | assert.NilError(t, err) 50 | nw += int64(len(data1)) 51 | 52 | select { 53 | case f := <-testCh: 54 | f(t) 55 | default: 56 | } 57 | 58 | binary.BigEndian.PutUint32(stderrHeader[stdHeaderSizeIndex:], uint32(len(data2))) 59 | _, err = w.Write(append(stderrHeader[:], data2...)) 60 | assert.NilError(t, err) 61 | nw += int64(len(data2)) 62 | 63 | select { 64 | case f := <-testCh: 65 | f(t) 66 | default: 67 | } 68 | 69 | binary.BigEndian.PutUint32(stdoutHeader[stdHeaderSizeIndex:], uint32(len(data3))) 70 | _, err = w.Write(append(stdoutHeader[:], data3...)) 71 | assert.NilError(t, err) 72 | nw += int64(len(data3)) 73 | 74 | w.Close() 75 | 76 | testutils.Deadline(t, 10*time.Second, testCh) 77 | } 78 | 79 | func TestStdCopyWithSystemErr(t *testing.T) { 80 | out := bytes.NewBuffer(nil) 81 | buf := bytes.NewBuffer(nil) 82 | stdoutHeader := [stdHeaderPrefixLen]byte{stdHeaderFdIndex: Stdout} 83 | systemErrHeader := [stdHeaderPrefixLen]byte{stdHeaderFdIndex: Systemerr} 84 | 85 | data1 := []byte("hello world!") 86 | binary.BigEndian.PutUint32(stdoutHeader[stdHeaderSizeIndex:], uint32(len(data1))) 87 | _, err := buf.Write(append(stdoutHeader[:], data1...)) 88 | assert.NilError(t, err) 89 | 90 | badError := []byte("something really really really bad has happened") 91 | binary.BigEndian.PutUint32(systemErrHeader[stdHeaderSizeIndex:], uint32(len(badError))) 92 | _, err = buf.Write(append(systemErrHeader[:], badError...)) 93 | assert.NilError(t, err) 94 | 95 | testCh := make(chan func(t *testing.T), 1) 96 | go func() { 97 | copied, err := StdCopy(out, out, buf) 98 | testCh <- func(t *testing.T) { 99 | assert.Check(t, cmp.Error(err, string(badError))) 100 | assert.Check(t, cmp.Equal(out.String(), string(data1))) 101 | assert.Check(t, cmp.Equal(copied, int64(len(data1)))) 102 | } 103 | }() 104 | testutils.Deadline(t, 10*time.Second, testCh) 105 | } 106 | -------------------------------------------------------------------------------- /container/streamutil/stdreader.go: -------------------------------------------------------------------------------- 1 | package streamutil 2 | 3 | import ( 4 | "encoding/binary" 5 | "fmt" 6 | "io" 7 | 8 | "github.com/cpuguy83/go-docker/errdefs" 9 | ) 10 | 11 | const ( 12 | // Stdin represents standard input stream type. 13 | Stdin = iota 14 | // Stdout represents standard output stream type. 15 | Stdout 16 | // Stderr represents standard error steam type. 17 | Stderr 18 | // Systemerr represents errors originating from the system that make it 19 | // into the multiplexed stream. 20 | Systemerr 21 | 22 | stdHeaderPrefixLen = 8 23 | stdHeaderFdIndex = 0 24 | stdHeaderSizeIndex = 4 25 | ) 26 | 27 | type StdReader struct { 28 | // The whole stream 29 | rdr io.Reader 30 | // The current frame only 31 | curr io.Reader 32 | currNR int 33 | 34 | buf []byte 35 | hdr *StdHeader 36 | err error 37 | } 38 | 39 | // StdHeader is a descriptor for a stdio stream frame 40 | // It gets used in conjunction with StdReader 41 | type StdHeader struct { 42 | Descriptor int 43 | Size int 44 | } 45 | 46 | // NewStdReader creates a reader for consuming a stdio stream. 47 | // Specifically this can be used for processing a streaming following Docker's stdio stream format where the stream 48 | // has an 8 byte header describing the message including what stdio stream (stdout, stderr) it belongs to and the size 49 | // of the message followed by the message itself. 50 | func NewStdReader(rdr io.Reader) *StdReader { 51 | return &StdReader{rdr: rdr, buf: make([]byte, stdHeaderPrefixLen)} 52 | } 53 | 54 | // Next returns the next stream header 55 | // 56 | // If Next is called before consuming the previous descriptor, an errdefs.Conflict error will be returned (check with errdefs.IsConflict(err)). 57 | // If there are any other errors processing the stream the error will be returned immediately, and again on all subsequent calls. 58 | func (s *StdReader) Next() (*StdHeader, error) { 59 | if s.err != nil { 60 | return nil, s.err 61 | } 62 | if s.curr != nil { 63 | // Cannot proceed until all data is drained for s.curr 64 | return nil, errdefs.Conflict("unconsumed data in stream; read to EOF before calling again") 65 | } 66 | 67 | _, err := io.ReadFull(s.rdr, s.buf) 68 | if err != nil { 69 | if err == io.EOF { 70 | s.err = err 71 | } else { 72 | s.err = errdefs.Wrap(err, "error reading log message header") 73 | } 74 | return nil, s.err 75 | } 76 | 77 | if s.hdr == nil { 78 | s.hdr = &StdHeader{} 79 | } 80 | 81 | fd := int(s.buf[stdHeaderFdIndex]) 82 | switch fd { 83 | case Stdin: 84 | fallthrough 85 | case Stdout, Stderr, Systemerr: 86 | s.hdr.Descriptor = fd 87 | default: 88 | s.err = fmt.Errorf("malformed stream, got unexpected stream descriptor in header %d", fd) 89 | return nil, s.err 90 | } 91 | 92 | s.hdr.Size = int(binary.BigEndian.Uint32(s.buf[stdHeaderSizeIndex : stdHeaderSizeIndex+4])) 93 | s.curr = io.LimitReader(s.rdr, int64(s.hdr.Size)) 94 | 95 | return s.hdr, nil 96 | } 97 | 98 | // Read reads up to p bytes from the stream. 99 | // You must read until EOF before calling Next again. 100 | func (s *StdReader) Read(p []byte) (int, error) { 101 | if s.err != nil { 102 | return 0, s.err 103 | } 104 | if s.curr == nil { 105 | return 0, io.EOF 106 | } 107 | 108 | n, err := s.curr.Read(p) 109 | if err == io.EOF { 110 | s.curr = nil 111 | } 112 | s.currNR += n 113 | if s.currNR == s.hdr.Size { 114 | s.currNR = 0 115 | s.curr = nil 116 | } 117 | 118 | return n, err 119 | } 120 | -------------------------------------------------------------------------------- /container/streamutil/stdreader_test.go: -------------------------------------------------------------------------------- 1 | package streamutil 2 | 3 | import ( 4 | "bytes" 5 | "encoding/binary" 6 | "io" 7 | "testing" 8 | 9 | "github.com/cpuguy83/go-docker/errdefs" 10 | 11 | "gotest.tools/v3/assert" 12 | ) 13 | 14 | func TestStreamReader(t *testing.T) { 15 | stream := bytes.NewBuffer(nil) 16 | 17 | line1 := []byte("this is line 1") 18 | line2 := []byte("this is line 2") 19 | 20 | header := [stdHeaderPrefixLen]byte{stdHeaderFdIndex: Stdout} 21 | binary.BigEndian.PutUint32(header[stdHeaderSizeIndex:], uint32(len(line1))) 22 | stream.Write(header[:]) 23 | stream.Write(line1) 24 | 25 | header = [stdHeaderPrefixLen]byte{stdHeaderFdIndex: Stderr} 26 | binary.BigEndian.PutUint32(header[stdHeaderSizeIndex:], uint32(len(line2))) 27 | stream.Write(header[:]) 28 | stream.Write(line2) 29 | 30 | rdr := NewStdReader(stream) 31 | 32 | buf := make([]byte, len(line1)) 33 | 34 | n, err := rdr.Read(buf) 35 | assert.ErrorType(t, err, io.EOF) 36 | assert.Equal(t, n, 0) 37 | 38 | h, err := rdr.Next() 39 | assert.NilError(t, err) 40 | assert.Equal(t, h.Descriptor, Stdout) 41 | assert.Equal(t, h.Size, len(line1)) 42 | 43 | _, err = rdr.Next() 44 | assert.Assert(t, errdefs.IsConflict(err), err) 45 | 46 | n, err = rdr.Read(buf) 47 | assert.NilError(t, err) 48 | assert.Equal(t, n, len(line1)) 49 | 50 | n, err = rdr.Read(buf) 51 | assert.ErrorType(t, err, io.EOF) 52 | assert.Equal(t, n, 0) 53 | 54 | h, err = rdr.Next() 55 | assert.NilError(t, err) 56 | assert.Equal(t, h.Descriptor, Stderr) 57 | assert.Equal(t, h.Size, len(line2)) 58 | assert.Equal(t, string(buf), string(line1)) 59 | 60 | buf = make([]byte, len(line2)) 61 | n, err = rdr.Read(buf) 62 | assert.NilError(t, err) 63 | assert.Equal(t, n, len(line2)) 64 | assert.Equal(t, string(buf), string(line2)) 65 | 66 | _, err = rdr.Next() 67 | assert.ErrorType(t, err, io.EOF) 68 | } 69 | -------------------------------------------------------------------------------- /container/wait.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "net/http" 9 | "sync" 10 | 11 | "github.com/cpuguy83/go-docker/errdefs" 12 | "github.com/cpuguy83/go-docker/httputil" 13 | "github.com/cpuguy83/go-docker/version" 14 | ) 15 | 16 | // WaitCondition is a type used to specify a container state for which 17 | // to wait. 18 | type WaitCondition string 19 | 20 | // Possible WaitCondition Values. 21 | // 22 | // WaitConditionNotRunning (default) is used to wait for any of the non-running 23 | // states: "created", "exited", "dead", "removing", or "removed". 24 | // 25 | // WaitConditionNextExit is used to wait for the next time the state changes 26 | // to a non-running state. If the state is currently "created" or "exited", 27 | // this would cause Wait() to block until either the container runs and exits 28 | // or is removed. 29 | // 30 | // WaitConditionRemoved is used to wait for the container to be removed. 31 | const ( 32 | WaitConditionNotRunning WaitCondition = "not-running" 33 | WaitConditionNextExit WaitCondition = "next-exit" 34 | WaitConditionRemoved WaitCondition = "removed" 35 | 36 | // DefaultWaitDecodeLimitBytes is the default max size that will be read from a container wait response. 37 | // This value is used if no value is set on CreateConfig. 38 | DefaultWaitDecodeLimitBytes = 64 * 1024 39 | ) 40 | 41 | // WaitConfig holds the options for waiting on a container 42 | type WaitConfig struct { 43 | Condition WaitCondition 44 | } 45 | 46 | // WaitOption is used as functional arguments to container wait 47 | // WaitOptions configure a WaitConfig 48 | type WaitOption func(*WaitConfig) 49 | 50 | // ExitStatus is used to report information about a container exit 51 | // It is used by container.Wait. 52 | type ExitStatus interface { 53 | ExitCode() (int, error) 54 | } 55 | 56 | type waitStatus struct { 57 | mu sync.Mutex 58 | ready bool 59 | StatusCode int 60 | err error 61 | Err *struct { 62 | Message string 63 | } `json:"Error"` 64 | } 65 | 66 | func (s *waitStatus) ExitCode() (int, error) { 67 | s.mu.Lock() 68 | defer s.mu.Unlock() 69 | return s.StatusCode, s.err 70 | } 71 | 72 | func WithWaitCondition(cond WaitCondition) WaitOption { 73 | return func(cfg *WaitConfig) { 74 | cfg.Condition = cond 75 | } 76 | } 77 | 78 | // Wait waits on the container to meet the provided wait condition. 79 | func (c *Container) Wait(ctx context.Context, opts ...WaitOption) (ExitStatus, error) { 80 | var cfg WaitConfig 81 | for _, o := range opts { 82 | o(&cfg) 83 | } 84 | 85 | if version.LessThan(version.APIVersion(ctx), "1.30") { 86 | // Before 1.30: 87 | // - wait condition is not supported 88 | // - The API blocks until wait is completed 89 | // 90 | // On 2nd point above, this would require running the request in a goroutine. 91 | // Not difficult but for now just return an error. 92 | return nil, errdefs.NotImplemented("container wait requires API version 1.30 or higher") 93 | } 94 | resp, err := httputil.DoRequest(ctx, func(ctx context.Context) (*http.Response, error) { 95 | return c.tr.Do(ctx, http.MethodPost, version.Join(ctx, "/containers/"+c.id+"/wait"), func(req *http.Request) error { 96 | q := req.URL.Query() 97 | q.Add("condition", string(cfg.Condition)) 98 | req.URL.RawQuery = q.Encode() 99 | return nil 100 | }) 101 | }) 102 | if err != nil { 103 | return nil, err 104 | } 105 | 106 | ws := &waitStatus{} 107 | ws.mu.Lock() 108 | 109 | go func() { 110 | defer ws.mu.Unlock() 111 | defer resp.Body.Close() 112 | 113 | if err := json.NewDecoder(resp.Body).Decode(&ws); err != nil { 114 | ws.err = fmt.Errorf("could not decode response: %w", err) 115 | ws.StatusCode = -1 116 | return 117 | } 118 | 119 | if ws.Err != nil && ws.Err.Message != "" { 120 | ws.err = errors.New(ws.Err.Message) 121 | } 122 | }() 123 | 124 | return ws, nil 125 | } 126 | -------------------------------------------------------------------------------- /container/wait_test.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/cpuguy83/go-docker/testutils" 9 | 10 | "github.com/cpuguy83/go-docker/errdefs" 11 | "gotest.tools/v3/assert" 12 | ) 13 | 14 | func TestWait(t *testing.T) { 15 | t.Parallel() 16 | 17 | s, ctx := newTestService(t, context.Background()) 18 | 19 | c := s.NewContainer(ctx, "notexist"+testutils.GenerateRandomString()) 20 | _, err := c.Wait(ctx) 21 | assert.Assert(t, errdefs.IsNotFound(err), err) 22 | 23 | c, err = s.Create(ctx, "busybox:latest", 24 | WithCreateCmd("/bin/sh", "-c", "trap 'exit 1' EXIT; while true; do sleep 0.1; done"), 25 | ) 26 | assert.NilError(t, err) 27 | 28 | defer func() { 29 | ch := make(chan func(t *testing.T), 1) 30 | go func() { 31 | _, err := c.Wait(ctx, WithWaitCondition(WaitConditionNextExit)) 32 | ch <- func(t *testing.T) { 33 | assert.NilError(t, err) 34 | 35 | _, err = c.Inspect(ctx) 36 | assert.Check(t, errdefs.IsNotFound(err)) 37 | } 38 | }() 39 | assert.Check(t, s.Remove(ctx, c.id, WithRemoveForce)) 40 | testutils.Deadline(t, 30*time.Second, ch) 41 | }() 42 | 43 | es, err := c.Wait(ctx, WithWaitCondition(WaitConditionNotRunning)) 44 | assert.NilError(t, err) 45 | code, err := es.ExitCode() 46 | assert.NilError(t, err) 47 | assert.Equal(t, code, 0) 48 | 49 | ch := make(chan func(t *testing.T), 1) 50 | 51 | es, err = c.Wait(ctx, WithWaitCondition(WaitConditionNextExit)) 52 | assert.NilError(t, err) 53 | 54 | go func() { 55 | code, err := es.ExitCode() 56 | ch <- func(t *testing.T) { 57 | assert.NilError(t, err) 58 | inspect, err := c.Inspect(ctx) 59 | assert.NilError(t, err) 60 | assert.Equal(t, code, inspect.State.ExitCode) 61 | } 62 | }() 63 | 64 | assert.NilError(t, c.Start(ctx)) 65 | assert.NilError(t, c.Kill(ctx)) 66 | 67 | testutils.Deadline(t, 10*time.Second, ch) 68 | } 69 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | package docker provides a client for accessing the Docker API. 3 | 4 | *Usage* 5 | 6 | All client operations are dependent on a transport. Transports are defined in the transport package. You can implement your own, here is the interface: 7 | 8 | // RequestOpt is as functional arguments to configure an HTTP request for a Doer. 9 | type RequestOpt func(*http.Request) error 10 | 11 | // Doer performs an http request for Client 12 | // It is the Doer's responsibility to deal with setting the host details on 13 | // the request 14 | // It is expected that one Doer connects to one Docker instance. 15 | type Doer interface { 16 | // Do typically performs a normal http request/response 17 | Do(ctx context.Context, method string, uri string, opts ...RequestOpt) (*http.Response, error) 18 | // DoRaw performs the request but passes along the response as a bi-directional stream 19 | DoRaw(ctx context.Context, method string, uri string, opts ...RequestOpt) (io.ReadWriteCloser, error) 20 | } 21 | 22 | Do is the main function to implement, it takes an HTTP method, a uri (e.g. /containers/json), and a lits of options for configuring an *http.Request (e.g. to add request headers, query params, etc.) 23 | 24 | DoRaw is used only for endpoints that need to “hijack” the http connection (ie. drop all HTTP semantics and drop to a raw, bi-directional stream). This is used for container attach. 25 | 26 | The package contains a default transport that you can use directly, or wrap, as well as helpers for creating it from DOCKER_HOST style connection strings. 27 | 28 | Once you have a transport you can create a client: 29 | 30 | // create a transport that connects over /var/run/docker.sock 31 | tr, err := transport.DefaultUnixTransport() 32 | if err != nil { 33 | panic(err) 34 | } 35 | client := NewClient(WithTransport(tr)) 36 | 37 | Or if you don’t provide a transport, the default for the platform will be used. 38 | 39 | Perform actions on a container: 40 | 41 | s := client.ContainerService() 42 | c, err := s.Create(ctx, container.WithCreateImage("busybox:latest"), container.WithCreateCmd("/bin/echo", "hello")) 43 | if err != nil { 44 | // handle error 45 | } 46 | 47 | cStdout, err := c.StdoutPipe(ctx) 48 | if err != nil { 49 | // handle error 50 | } 51 | defer cStdout.Close() 52 | 53 | if err := c.Start(ctx); err != nil { 54 | // handle error 55 | } 56 | 57 | io.Copy(os.Stdout, cStdout) 58 | 59 | if err := s.Remove(ctx, c.ID(), container.WithRemoveForce); err != nil { 60 | // handle error 61 | } 62 | 63 | */ 64 | package docker 65 | -------------------------------------------------------------------------------- /errdefs/conflict.go: -------------------------------------------------------------------------------- 1 | package errdefs 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | ) 7 | 8 | var ErrConflict = errors.New("conflict") 9 | 10 | // Conflict makes an ErrConflict from the provided error message 11 | func Conflict(msg string) error { 12 | return fmt.Errorf("%w: %s", ErrConflict, msg) 13 | } 14 | 15 | // Conflictf makes an ErrConflict from the provided error format and args 16 | func Conflictf(format string, args ...interface{}) error { 17 | return fmt.Errorf("%w: %s", ErrConflict, fmt.Sprintf(format, args...)) 18 | } 19 | 20 | // IsConflict determines if the passed in error is of type ErrConflict 21 | func IsConflict(err error) bool { 22 | return errors.Is(err, ErrConflict) 23 | } 24 | 25 | // AsConflict returns a wrapped error which will return true for IsConflict 26 | func AsConflict(err error) error { 27 | return as(err, ErrConflict) 28 | } 29 | -------------------------------------------------------------------------------- /errdefs/errors.go: -------------------------------------------------------------------------------- 1 | package errdefs 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | ) 7 | 8 | func as(e, target error) error { 9 | return &wrapped{e: e, cause: target} 10 | } 11 | 12 | type wrapped struct { 13 | cause error 14 | e error 15 | } 16 | 17 | func (w *wrapped) Error() string { 18 | return fmt.Sprintf("%s: %s", w.cause.Error(), w.e.Error()) 19 | } 20 | 21 | func (w *wrapped) Unwrap() error { 22 | return w.cause 23 | } 24 | 25 | func (w *wrapped) Is(target error) bool { 26 | if errors.Is(w.e, target) { 27 | return true 28 | } 29 | if errors.Is(w.cause, target) { 30 | return true 31 | } 32 | return false 33 | } 34 | 35 | // Wrap is a convenience function to wrap an error with extra text. 36 | func Wrap(err error, msg string) error { 37 | return fmt.Errorf("%w: %s", err, msg) 38 | } 39 | 40 | // Wrapf is a convenience function to wrap an error with extra text. 41 | func Wrapf(err error, format string, args ...interface{}) error { 42 | return fmt.Errorf("%w: %s", err, fmt.Sprintf(format, args...)) 43 | } 44 | -------------------------------------------------------------------------------- /errdefs/errors_test.go: -------------------------------------------------------------------------------- 1 | package errdefs 2 | 3 | import ( 4 | "errors" 5 | "reflect" 6 | "runtime" 7 | "strings" 8 | "testing" 9 | ) 10 | 11 | type testCase struct { 12 | Err error 13 | New func(string) error 14 | Newf func(string, ...interface{}) error 15 | Is func(error) bool 16 | As func(error) error 17 | } 18 | 19 | func TestErrors(t *testing.T) { 20 | cases := map[string]testCase{ 21 | "conflict": {ErrConflict, Conflict, Conflictf, IsConflict, AsConflict}, 22 | "forbidden": {ErrForbidden, Forbidden, Forbiddenf, IsForbidden, AsForbidden}, 23 | "invalid": {ErrInvalid, Invalid, Invalidf, IsInvalid, AsInvalid}, 24 | "not found": {ErrNotFound, NotFound, NotFoundf, IsNotFound, AsNotFound}, 25 | "not implemented": {ErrNotImplemented, NotImplemented, NotImplementedf, IsNotImplemented, AsNotImplemented}, 26 | "not modified": {ErrNotModified, NotModified, NotModifiedf, IsNotModified, AsNotModified}, 27 | "unauthorized": {ErrUnauthorized, Unauthorized, Unauthorizedf, IsUnauthorized, AsUnauthorized}, 28 | "unavailable": {ErrUnavailable, Unavailable, Unavailablef, IsUnavailable, AsUnavailable}, 29 | } 30 | 31 | for name, tc := range cases { 32 | tc := tc 33 | t.Run(name, testError(tc)) 34 | } 35 | } 36 | 37 | func getFunctionName(f interface{}) string { 38 | n := runtime.FuncForPC(reflect.ValueOf(f).Pointer()).Name() 39 | return n[strings.LastIndexAny(n, ".")+1:] 40 | } 41 | 42 | func testError(tc testCase) func(t *testing.T) { 43 | return func(t *testing.T) { 44 | e := tc.New("name") 45 | if !errors.Is(e, tc.Err) { 46 | t.Fatalf("%s: expected %v to be %v", getFunctionName(tc.New), e, tc.Err) 47 | } 48 | if !tc.Is(e) { 49 | t.Fatalf("%s did not return true after creating error with %s", getFunctionName(tc.Is), getFunctionName(tc.New)) 50 | } 51 | 52 | e = tc.Newf("name %s", "value") 53 | if !errors.Is(e, tc.Err) { 54 | t.Fatalf("%s: expected %v to be %v", getFunctionName(tc.Newf), e, tc.Err) 55 | } 56 | if !tc.Is(e) { 57 | t.Fatalf("%s did not return true after creating error with %s", getFunctionName(tc.Is), getFunctionName(tc.Newf)) 58 | } 59 | 60 | e = errors.New(t.Name()) 61 | e = tc.As(e) 62 | if !errors.Is(e, tc.Err) { 63 | t.Fatalf("expected error to be wrapped by %v", tc.Err) 64 | } 65 | if !tc.Is(e) { 66 | t.Fatalf("%s did not return true after wrapping error with %s", getFunctionName(tc.Is), getFunctionName(tc.As)) 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /errdefs/forbidden.go: -------------------------------------------------------------------------------- 1 | package errdefs 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | ) 7 | 8 | var ErrForbidden = errors.New("forbidden") 9 | 10 | // Forbidden makes an ErrForbidden from the provided error message 11 | func Forbidden(msg string) error { 12 | return fmt.Errorf("%w: %s", ErrForbidden, msg) 13 | } 14 | 15 | // Forbiddenf makes an ErrForbidden from the provided error format and args 16 | func Forbiddenf(format string, args ...interface{}) error { 17 | return fmt.Errorf("%w: %s", ErrForbidden, fmt.Sprintf(format, args...)) 18 | } 19 | 20 | // IsForbidden determines if the passed in error is of type ErrForbidden 21 | func IsForbidden(err error) bool { 22 | return errors.Is(err, ErrForbidden) 23 | } 24 | 25 | // AsForbidden returns a wrapped error which will return true for IsForbidden 26 | func AsForbidden(err error) error { 27 | return as(err, ErrForbidden) 28 | } 29 | -------------------------------------------------------------------------------- /errdefs/invalid.go: -------------------------------------------------------------------------------- 1 | package errdefs 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | ) 7 | 8 | var ErrInvalid = errors.New("invalid") 9 | 10 | // InvalidInput makes an ErrInvalidInput from the provided error message 11 | func Invalid(msg string) error { 12 | return fmt.Errorf("%w: %s", ErrInvalid, msg) 13 | } 14 | 15 | // InvalidInputf makes an ErrInvalidInput from the provided error format and args 16 | func Invalidf(format string, args ...interface{}) error { 17 | return fmt.Errorf("%w: %s", ErrInvalid, fmt.Sprintf(format, args...)) 18 | } 19 | 20 | // IsInvalid determines if the passed in error is of type ErrIsInvalid 21 | func IsInvalid(err error) bool { 22 | return errors.Is(err, ErrInvalid) 23 | } 24 | 25 | // AsInvalid returns a wrapped error which will return true for IsInvalid 26 | func AsInvalid(err error) error { 27 | return as(err, ErrInvalid) 28 | } 29 | -------------------------------------------------------------------------------- /errdefs/notfound.go: -------------------------------------------------------------------------------- 1 | package errdefs 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | ) 7 | 8 | var ErrNotFound = errors.New("not found") 9 | 10 | // NotFound makes an ErrNotFound from the provided error message 11 | func NotFound(msg string) error { 12 | return fmt.Errorf("%w: %s", ErrNotFound, msg) 13 | } 14 | 15 | // NotFoundf makes an ErrNotFound from the provided error format and args 16 | func NotFoundf(format string, args ...interface{}) error { 17 | return fmt.Errorf("%w: %s", ErrNotFound, fmt.Sprintf(format, args...)) 18 | } 19 | 20 | // IsNotFound determines if the passed in error is of type ErrNotFound 21 | func IsNotFound(err error) bool { 22 | return errors.Is(err, ErrNotFound) 23 | } 24 | 25 | // AsNotFound returns a wrapped error which will return true for IsNotFound 26 | func AsNotFound(err error) error { 27 | return as(err, ErrNotFound) 28 | } 29 | -------------------------------------------------------------------------------- /errdefs/notimplemented.go: -------------------------------------------------------------------------------- 1 | package errdefs 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | ) 7 | 8 | var ErrNotImplemented = errors.New("not implemented") 9 | 10 | // NotImplemented makes an ErrNotImplemented from the provided error message 11 | func NotImplemented(msg string) error { 12 | return fmt.Errorf("%w: %s", ErrNotImplemented, msg) 13 | } 14 | 15 | // NotImplementedf makes an ErrNotImplemented from the provided error format and args 16 | func NotImplementedf(format string, args ...interface{}) error { 17 | return fmt.Errorf("%w: %s", ErrNotImplemented, fmt.Sprintf(format, args...)) 18 | } 19 | 20 | // IsNotImplemented determines if the passed in error is of type ErrNotImplemented 21 | func IsNotImplemented(err error) bool { 22 | return errors.Is(err, ErrNotImplemented) 23 | } 24 | 25 | // AsNotImplemented returns a wrapped error which will return true for IsNotImplemented 26 | func AsNotImplemented(err error) error { 27 | return as(err, ErrNotImplemented) 28 | } 29 | -------------------------------------------------------------------------------- /errdefs/notmodified.go: -------------------------------------------------------------------------------- 1 | package errdefs 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | ) 7 | 8 | var ErrNotModified = errors.New("not modified") 9 | 10 | // NotModified makes an ErrNotModified from the provided error message 11 | func NotModified(msg string) error { 12 | return fmt.Errorf("%w: %s", ErrNotModified, msg) 13 | } 14 | 15 | // NotModifiedf makes an ErrNotModified from the provided error format and args 16 | func NotModifiedf(format string, args ...interface{}) error { 17 | return fmt.Errorf("%w: %s", ErrNotModified, fmt.Sprintf(format, args...)) 18 | } 19 | 20 | // IsNotModified determines if the passed in error is of type ErrNotModified 21 | func IsNotModified(err error) bool { 22 | return errors.Is(err, ErrNotModified) 23 | } 24 | 25 | // AsNotModified returns a wrapped error which will return true for IsNotModified 26 | func AsNotModified(err error) error { 27 | return as(err, ErrNotModified) 28 | } 29 | -------------------------------------------------------------------------------- /errdefs/unauthorized.go: -------------------------------------------------------------------------------- 1 | package errdefs 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | ) 7 | 8 | var ErrUnauthorized = errors.New("unauthorized") 9 | 10 | // Unauthorized makes an ErrUnauthorized from the provided error message 11 | func Unauthorized(msg string) error { 12 | return fmt.Errorf("%w: %s", ErrUnauthorized, msg) 13 | } 14 | 15 | // Unauthorizedf makes an ErrUnauthorized from the provided error format and args 16 | func Unauthorizedf(format string, args ...interface{}) error { 17 | return fmt.Errorf("%w: %s", ErrUnauthorized, fmt.Sprintf(format, args...)) 18 | } 19 | 20 | // IsUnauthorized determines if the passed in error is of type ErrUnauthorized 21 | func IsUnauthorized(err error) bool { 22 | return errors.Is(err, ErrUnauthorized) 23 | } 24 | 25 | // IsUnauthorized determines if the passed in error is of type ErrUnauthorized 26 | func AsUnauthorized(err error) error { 27 | return as(err, ErrUnauthorized) 28 | } 29 | -------------------------------------------------------------------------------- /errdefs/unavailable.go: -------------------------------------------------------------------------------- 1 | package errdefs 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | ) 7 | 8 | var ErrUnavailable = errors.New("unavailable") 9 | 10 | // Unavailable makes an ErrUnavailable from the provided error message 11 | func Unavailable(msg string) error { 12 | return fmt.Errorf("%w: %s", ErrUnavailable, msg) 13 | } 14 | 15 | // Unavailablef makes an ErrUnavailable from the provided error format and args 16 | func Unavailablef(format string, args ...interface{}) error { 17 | return fmt.Errorf("%w: %s", ErrUnavailable, fmt.Sprintf(format, args...)) 18 | } 19 | 20 | // IsUnavailable determines if the passed in error is of type ErrUnavailable 21 | func IsUnavailable(err error) bool { 22 | return errors.Is(err, ErrUnavailable) 23 | } 24 | 25 | // AsUnavailable returns a wrapped error which will return true for IsUnavailable 26 | func AsUnavailable(err error) error { 27 | return as(err, ErrUnavailable) 28 | } 29 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/cpuguy83/go-docker 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/Microsoft/go-winio v0.6.1 7 | github.com/opencontainers/go-digest v1.0.0 8 | gotest.tools/v3 v3.3.0 9 | ) 10 | 11 | require ( 12 | github.com/google/go-cmp v0.5.5 // indirect 13 | golang.org/x/mod v0.14.0 // indirect 14 | golang.org/x/sys v0.14.0 // indirect 15 | golang.org/x/tools v0.15.0 // indirect 16 | ) 17 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= 2 | github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= 3 | github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= 4 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 5 | github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= 6 | github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= 7 | github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 8 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 9 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 10 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 11 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 12 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 13 | golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= 14 | golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 15 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 16 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 17 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 18 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 19 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 20 | golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= 21 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 22 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 23 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 24 | golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 25 | golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= 26 | golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 27 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 28 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 29 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 30 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 31 | golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= 32 | golang.org/x/tools v0.15.0 h1:zdAyfUGbYmuVokhzVmghFl2ZJh5QhcfebBgmVPFYA+8= 33 | golang.org/x/tools v0.15.0/go.mod h1:hpksKq4dtpQWS1uQ61JkdqWM3LscIS6Slf+VVkm+wQk= 34 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 35 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 36 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 37 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= 38 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 39 | gotest.tools/v3 v3.3.0 h1:MfDY1b1/0xN1CyMlQDac0ziEy9zJQd9CXBRRDHw2jJo= 40 | gotest.tools/v3 v3.3.0/go.mod h1:Mcr9QNxkg0uMvy/YElmo4SpXgJKWgQvYrT7Kw5RzJ1A= 41 | -------------------------------------------------------------------------------- /httputil/context.go: -------------------------------------------------------------------------------- 1 | package httputil 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/http" 7 | ) 8 | 9 | var ( 10 | // The default limit used by the client when reading response bodies. 11 | DefaultResponseLimit int64 = 16 * 1024 12 | ) 13 | 14 | const ( 15 | // UnlimitedResponseLimit is a value that can be used to indicate that a response should not be limited. 16 | UnlimitedResponseLimit int64 = -1 17 | ) 18 | 19 | type responseLimit struct{} 20 | 21 | // WithResponseLimit sets a limit for the max size to read from an http response. 22 | // This value will be used by the client to limit how much data will be consumed from http responses. 23 | func WithResponseLimit(ctx context.Context, limit int64) context.Context { 24 | return context.WithValue(ctx, responseLimit{}, limit) 25 | } 26 | 27 | // WithResponseLimitIfEmpty is like WithResponseLimit, but only sets a limit if none is set. 28 | func WithResponseLimitIfEmpty(ctx context.Context, limit int64) context.Context { 29 | v := ctx.Value(responseLimit{}) 30 | if v != nil { 31 | return ctx 32 | } 33 | return WithResponseLimit(ctx, limit) 34 | } 35 | 36 | // LimitResponse limits the size of the response body. 37 | // This is used throughout the client to prevent a bad response from consuming too much memory. 38 | // If a response limit is not set in the context, DefaultResponseLimit will be used. 39 | // 40 | // The value used is taken from the passed in context. 41 | // Set this value by using: 42 | // 43 | // ctx = WithResponseLimit(ctx, limit) 44 | func LimitResponse(ctx context.Context, resp *http.Response) { 45 | limit := DefaultResponseLimit 46 | v := ctx.Value(responseLimit{}) 47 | if v != nil { 48 | limit = v.(int64) 49 | } 50 | if limit == UnlimitedResponseLimit { 51 | return 52 | } 53 | limited := io.LimitReader(resp.Body, limit) 54 | resp.Body = &wrapBody{limited, resp.Body} 55 | } 56 | 57 | type wrapBody struct { 58 | io.Reader 59 | io.Closer 60 | } 61 | -------------------------------------------------------------------------------- /httputil/errors.go: -------------------------------------------------------------------------------- 1 | package httputil 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/cpuguy83/go-docker/errdefs" 9 | ) 10 | 11 | type errorResponse struct { 12 | Message string `json:"message"` 13 | } 14 | 15 | func (e errorResponse) Error() string { 16 | return e.Message 17 | } 18 | 19 | // CheckResponseError checks the http response for standard error codes. 20 | // 21 | // For the most part this should return error implemented from the `errdefs` package 22 | func CheckResponseError(resp *http.Response) error { 23 | if resp.StatusCode >= 200 && resp.StatusCode < 400 { 24 | return nil 25 | } 26 | 27 | var e errorResponse 28 | if err := json.NewDecoder(resp.Body).Decode(&e); err != nil { 29 | resp.Body.Close() 30 | return errdefs.Wrap(fromStatusCode(err, resp.StatusCode), "error unmarshaling server error response") 31 | } 32 | 33 | return fromStatusCode(&e, resp.StatusCode) 34 | } 35 | 36 | func fromStatusCode(err error, statusCode int) error { 37 | if err == nil { 38 | return err 39 | } 40 | err = fmt.Errorf("%w: error in response, status code: %d", err, statusCode) 41 | switch statusCode { 42 | case http.StatusNotFound: 43 | err = errdefs.AsNotFound(err) 44 | case http.StatusBadRequest: 45 | err = errdefs.AsInvalid(err) 46 | case http.StatusConflict: 47 | err = errdefs.AsConflict(err) 48 | case http.StatusUnauthorized: 49 | err = errdefs.AsUnauthorized(err) 50 | case http.StatusServiceUnavailable: 51 | err = errdefs.AsUnavailable(err) 52 | case http.StatusForbidden: 53 | err = errdefs.AsForbidden(err) 54 | case http.StatusNotModified: 55 | err = errdefs.AsNotModified(err) 56 | case http.StatusNotImplemented: 57 | err = errdefs.AsNotImplemented(err) 58 | default: 59 | if statusCode >= 400 && statusCode < 500 { 60 | err = errdefs.AsInvalid(err) 61 | } 62 | } 63 | return err 64 | } 65 | -------------------------------------------------------------------------------- /httputil/request.go: -------------------------------------------------------------------------------- 1 | package httputil 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "io/ioutil" 8 | "net/http" 9 | 10 | "github.com/cpuguy83/go-docker/errdefs" 11 | ) 12 | 13 | // DoRequest performs the passed in function, passing it the provided context. 14 | // The response body reader is then limited and checked for error status codes. 15 | // The returned response body will always be limited by the limit value set in the passed in context. 16 | // 17 | // In the case that an error is found, the response body will be closed. 18 | // This may return a non-nil response even on error. 19 | // This allows callers to inspect response headers and other things. 20 | func DoRequest(ctx context.Context, do func(context.Context) (*http.Response, error)) (*http.Response, error) { 21 | resp, err := do(ctx) 22 | if err != nil { 23 | return nil, errdefs.Wrap(err, "error doing request") 24 | } 25 | 26 | LimitResponse(ctx, resp) 27 | return resp, CheckResponseError(resp) 28 | } 29 | 30 | // WithJSONBody is a request option that sets the request body to the JSON encoded version of the passed in value. 31 | func WithJSONBody(v interface{}) func(req *http.Request) error { 32 | return func(req *http.Request) error { 33 | data, err := json.Marshal(v) 34 | if err != nil { 35 | return errdefs.Wrap(err, "error marshaling json body") 36 | } 37 | req.Body = ioutil.NopCloser(bytes.NewReader(data)) 38 | if req.Header == nil { 39 | req.Header = http.Header{} 40 | } 41 | req.Header.Set("Content-Type", "application/json") 42 | return nil 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /image.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import "github.com/cpuguy83/go-docker/image" 4 | 5 | // ImageService provides access to image functionality, such as create, list. 6 | func (c *Client) ImageService() *image.Service { 7 | return image.NewService(c.tr) 8 | } 9 | -------------------------------------------------------------------------------- /image/export.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | 9 | "github.com/cpuguy83/go-docker/errdefs" 10 | "github.com/cpuguy83/go-docker/httputil" 11 | "github.com/cpuguy83/go-docker/version" 12 | ) 13 | 14 | // ExportConfig is the configuration for exporting an image. 15 | type ExportConfig struct { 16 | Refs []string 17 | } 18 | 19 | // ExportOption is a functional option for configuring an image export. 20 | type ExportOption func(*ExportConfig) error 21 | 22 | // WithExportRefs adds the given image refs to the list of refs to export. 23 | func WithExportRefs(refs ...string) ExportOption { 24 | return func(cfg *ExportConfig) error { 25 | cfg.Refs = append(cfg.Refs, refs...) 26 | return nil 27 | } 28 | } 29 | 30 | // Export exports an image(s) from the daemon. 31 | // The returned reader is a tar archive of the exported image(s). 32 | // 33 | // Note: The way the docker API works, this will always return a reader. 34 | // If there is an error it will be in that reader. 35 | // TODO: Figure out how to deal with this case. For sure upstream moby should be fixed to return an error status code if there is an error. 36 | // TODO: Right now the moby daemon writes the response header immediately before even validating any of the image refs. 37 | func (s *Service) Export(ctx context.Context, opts ...ExportOption) (io.ReadCloser, error) { 38 | var cfg ExportConfig 39 | for _, o := range opts { 40 | if err := o(&cfg); err != nil { 41 | return nil, err 42 | } 43 | } 44 | 45 | if len(cfg.Refs) == 0 { 46 | return nil, errdefs.Invalid("no refs provided") 47 | } 48 | 49 | withNames := func(req *http.Request) error { 50 | q := req.URL.Query() 51 | for _, ref := range cfg.Refs { 52 | q.Add("names", ref) 53 | } 54 | req.URL.RawQuery = q.Encode() 55 | return nil 56 | } 57 | 58 | ctx = httputil.WithResponseLimitIfEmpty(ctx, httputil.UnlimitedResponseLimit) 59 | resp, err := httputil.DoRequest(ctx, func(ctx context.Context) (*http.Response, error) { 60 | return s.tr.Do(ctx, http.MethodGet, version.Join(ctx, "/images/get"), withNames) 61 | }) 62 | if err != nil { 63 | return nil, err 64 | } 65 | 66 | if resp.Header.Get("Content-Type") != "application/x-tar" { 67 | resp.Body.Close() 68 | return nil, fmt.Errorf("unexpected content type: %s", resp.Header.Get("Content-Type")) 69 | } 70 | return resp.Body, nil 71 | } 72 | -------------------------------------------------------------------------------- /image/export_test.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "archive/tar" 5 | "context" 6 | "io" 7 | "testing" 8 | 9 | "gotest.tools/v3/assert" 10 | ) 11 | 12 | func TestExport(t *testing.T) { 13 | t.Parallel() 14 | 15 | s := newTestService(t) 16 | 17 | ctx := context.Background() 18 | digest := "faa03e786c97f07ef34423fccceeec2398ec8a5759259f94d99078f264e9d7af" 19 | err := s.Pull(ctx, Remote{Locator: "hello-world", Host: "docker.io", Tag: "sha256:" + digest}) 20 | assert.NilError(t, err) 21 | 22 | defer s.Remove(ctx, "hello-world@sha256:"+digest) 23 | 24 | rdr, err := s.Export(ctx, WithExportRefs("hello-world@sha256:faa03e786c97f07ef34423fccceeec2398ec8a5759259f94d99078f264e9d7af")) 25 | assert.NilError(t, err) 26 | defer rdr.Close() 27 | 28 | tar := tar.NewReader(rdr) 29 | 30 | var found bool 31 | for { 32 | hdr, err := tar.Next() 33 | if err == io.EOF { 34 | break 35 | } 36 | assert.NilError(t, err) 37 | 38 | t.Log(hdr.Name) 39 | 40 | if hdr.Name == "c28b9c2faac407005d4d657e49f372fb3579a47dd4e4d87d13e29edd1c912d5c/VERSION" { 41 | found = true 42 | break 43 | } 44 | } 45 | 46 | assert.Assert(t, found) 47 | } 48 | -------------------------------------------------------------------------------- /image/imageapi/image.go: -------------------------------------------------------------------------------- 1 | package imageapi 2 | 3 | // Image represents an image from the docker HTTP API. 4 | type Image struct { 5 | ID string `json:"Id,omitempty"` 6 | ParentID string `json:"ParentId,omitempty"` 7 | RepoTags []string 8 | RepoDigests []string 9 | Created int64 10 | Size int64 11 | SharedSize int64 12 | VirtualSize int64 13 | Labels map[string]string 14 | Containers int64 15 | } 16 | -------------------------------------------------------------------------------- /image/imageapi/prune.go: -------------------------------------------------------------------------------- 1 | package imageapi 2 | 3 | type Prune struct { 4 | ImagesDeleted []DeletedImage 5 | SpaceReclaimed int64 6 | } 7 | 8 | type DeletedImage struct { 9 | Untagged string 10 | Deleted string 11 | } 12 | -------------------------------------------------------------------------------- /image/list.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "net/http" 9 | "strconv" 10 | 11 | "github.com/cpuguy83/go-docker/httputil" 12 | "github.com/cpuguy83/go-docker/image/imageapi" 13 | "github.com/cpuguy83/go-docker/version" 14 | ) 15 | 16 | // ListFilter represents filters to process on the image list. See the official 17 | // docker docs for the meaning of each field 18 | // https://docs.docker.com/engine/api/v1.41/#operation/ImageList 19 | type ListFilter struct { 20 | Before []string `json:"before,omitempty"` 21 | Dangling []string `json:"dangling,omitempty"` 22 | Label []string `json:"label,omitempty"` 23 | Reference []string `json:"reference,omitempty"` 24 | Since []string `json:"since,omitempty"` 25 | } 26 | 27 | // ListConfig holds the options for listing images. 28 | type ListConfig struct { 29 | All bool 30 | Digests bool 31 | Filter ListFilter 32 | } 33 | 34 | // ListOption is used as functional arguments to list images. 35 | // ListOption configure a ListConfig. 36 | type ListOption func(config *ListConfig) 37 | 38 | // List lists images. 39 | func (s *Service) List(ctx context.Context, opts ...ListOption) ([]imageapi.Image, error) { 40 | cfg := ListConfig{} 41 | for _, o := range opts { 42 | o(&cfg) 43 | } 44 | 45 | withListConfig := func(req *http.Request) error { 46 | q := req.URL.Query() 47 | q.Add("all", strconv.FormatBool(cfg.All)) 48 | q.Add("digests", strconv.FormatBool(cfg.Digests)) 49 | filterJSON, err := json.Marshal(cfg.Filter) 50 | 51 | if err != nil { 52 | return err 53 | } 54 | q.Add("filters", string(filterJSON)) 55 | 56 | req.URL.RawQuery = q.Encode() 57 | return nil 58 | } 59 | 60 | var images []imageapi.Image 61 | 62 | resp, err := httputil.DoRequest(ctx, func(ctx context.Context) (*http.Response, error) { 63 | return s.tr.Do(ctx, http.MethodGet, version.Join(ctx, "/images/json"), withListConfig) 64 | }) 65 | if err != nil { 66 | return nil, err 67 | } 68 | 69 | defer resp.Body.Close() 70 | 71 | data, err := ioutil.ReadAll(resp.Body) 72 | if err != nil { 73 | return nil, err 74 | } 75 | 76 | if err := json.Unmarshal(data, &images); err != nil { 77 | return nil, fmt.Errorf("unmarshaling container json: %s", err) 78 | } 79 | 80 | return images, nil 81 | } 82 | -------------------------------------------------------------------------------- /image/list_test.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "gotest.tools/v3/assert" 8 | ) 9 | 10 | func TestList(t *testing.T) { 11 | ctx := context.Background() 12 | s := newTestService(t) 13 | 14 | // First create a few images that we can list later. 15 | err := s.Pull(ctx, Remote{ 16 | Locator: "busybox", 17 | Host: "docker.io", 18 | Tag: "latest", 19 | }) 20 | // This is used by some other tests right now, so don't remove it 21 | // defer s.Remove(ctx, "busybox:latest") 22 | 23 | assert.NilError(t, err, "expected pulling busybox to succeed") 24 | err = s.Pull(ctx, Remote{ 25 | Locator: "hello-world", 26 | Host: "docker.io", 27 | Tag: "latest", 28 | }) 29 | assert.NilError(t, err, "expected pulling hello-world to succeed") 30 | defer s.Remove(ctx, "hello-world:latest") 31 | 32 | images, err := s.List(ctx, func(config *ListConfig) { 33 | config.Filter.Reference = append(config.Filter.Reference, "busybox:latest", "hello-world:latest") 34 | }) 35 | assert.NilError(t, err, "expected listing images with no options to succeed") 36 | assert.Assert(t, len(images) == 2, "expected created images to be listed") 37 | } 38 | -------------------------------------------------------------------------------- /image/load.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "strconv" 9 | 10 | "github.com/cpuguy83/go-docker/httputil" 11 | "github.com/cpuguy83/go-docker/version" 12 | ) 13 | 14 | // LoadConfig holds the options for loading images. 15 | type LoadConfig struct { 16 | // ConsumeProgress is called after a pull response is received to consume the progress messages from the response body. 17 | // ConSumeProgress should not return until EOF is reached on the passed in stream or it may cause the pull to be cancelled. 18 | // If this is not set, progress messages are discarded. 19 | ConsumeProgress StreamConsumer 20 | } 21 | 22 | // LoadOption is used as functional arguments to list images. LoadOption 23 | // configure a LoadConfig. 24 | type LoadOption func(config *LoadConfig) error 25 | 26 | // Load loads container images. 27 | func (s *Service) Load(ctx context.Context, tar io.ReadCloser, opts ...LoadOption) error { 28 | cfg := LoadConfig{} 29 | for _, o := range opts { 30 | if err := o(&cfg); err != nil { 31 | return err 32 | } 33 | } 34 | 35 | withListConfig := func(req *http.Request) error { 36 | quiet := cfg.ConsumeProgress == nil 37 | 38 | q := req.URL.Query() 39 | q.Add("quiet", strconv.FormatBool(quiet)) 40 | req.URL.RawQuery = q.Encode() 41 | req.Body = tar 42 | return nil 43 | } 44 | 45 | resp, err := httputil.DoRequest(ctx, func(ctx context.Context) (*http.Response, error) { 46 | return s.tr.Do(ctx, http.MethodPost, version.Join(ctx, "/images/load"), withListConfig) 47 | }) 48 | if err != nil { 49 | return fmt.Errorf("loading images: %w", err) 50 | } 51 | defer resp.Body.Close() 52 | 53 | if cfg.ConsumeProgress != nil { 54 | if err := cfg.ConsumeProgress(ctx, resp.Body); err != nil { 55 | return fmt.Errorf("consuming progress: %w", err) 56 | } 57 | } else { 58 | _, err := io.Copy(io.Discard, resp.Body) 59 | if err != nil { 60 | return fmt.Errorf("discarding progress: %w", err) 61 | } 62 | } 63 | 64 | return nil 65 | } 66 | -------------------------------------------------------------------------------- /image/load_test.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io" 7 | "strings" 8 | "testing" 9 | 10 | "gotest.tools/v3/assert" 11 | ) 12 | 13 | func TestLoad(t *testing.T) { 14 | ctx := context.Background() 15 | s := newTestService(t) 16 | 17 | // Pull a dummy image that we can export. 18 | err := s.Pull(ctx, Remote{ 19 | Locator: "hello-world", 20 | Host: "docker.io", 21 | Tag: "latest", 22 | }) 23 | assert.NilError(t, err, "expected pulling hello-world to succeed") 24 | 25 | rdr, err := s.Export(ctx, WithExportRefs("hello-world:latest")) 26 | assert.NilError(t, err) 27 | 28 | buf := bytes.NewBuffer(nil) 29 | consume := func(ctx context.Context, rdr io.Reader) error { 30 | _, err := io.Copy(buf, rdr) 31 | return err 32 | } 33 | 34 | err = s.Load(ctx, rdr, func(cfg *LoadConfig) error { 35 | cfg.ConsumeProgress = consume 36 | return nil 37 | }) 38 | defer s.Remove(ctx, "hello-world:latest") 39 | assert.NilError(t, err, "expecting load to succeed") 40 | assert.Equal(t, `{"stream":"Loaded image: hello-world:latest\n"}`, strings.TrimSpace(buf.String())) 41 | } 42 | -------------------------------------------------------------------------------- /image/prune.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "net/http" 9 | 10 | "github.com/cpuguy83/go-docker/httputil" 11 | "github.com/cpuguy83/go-docker/image/imageapi" 12 | "github.com/cpuguy83/go-docker/version" 13 | ) 14 | 15 | // PruneFilter represents filters to process on the prune list. See the official 16 | // docker docs for the meaning of each field 17 | // https://docs.docker.com/engine/api/v1.41/#operation/ImagePrune 18 | type PruneFilter struct { 19 | Dangling []string `json:"dangling,omitempty"` 20 | Label []string `json:"label,omitempty"` 21 | NotLabel []string `json:"label!,omitempty"` 22 | Until []string `json:"until,omitempty"` 23 | } 24 | 25 | // PruneConfig holds the options for pruning images. 26 | type PruneConfig struct { 27 | Filters PruneFilter 28 | } 29 | 30 | // PruneOption is used as functional arguments to prune images. PruneOption 31 | // configure a PruneConfig. 32 | type PruneOption func(config *PruneConfig) 33 | 34 | // prune prunes container images. 35 | func (s *Service) Prune(ctx context.Context, opts ...PruneOption) (imageapi.Prune, error) { 36 | cfg := PruneConfig{} 37 | for _, o := range opts { 38 | o(&cfg) 39 | } 40 | 41 | withPruneConfig := func(req *http.Request) error { 42 | q := req.URL.Query() 43 | filterJSON, err := json.Marshal(cfg.Filters) 44 | if err != nil { 45 | return err 46 | } 47 | _ = filterJSON 48 | q.Add("filters", string(filterJSON)) 49 | req.URL.RawQuery = q.Encode() 50 | return nil 51 | } 52 | 53 | resp, err := httputil.DoRequest(ctx, func(ctx context.Context) (*http.Response, error) { 54 | return s.tr.Do(ctx, http.MethodPost, version.Join(ctx, "/images/prune"), withPruneConfig) 55 | }) 56 | if err != nil { 57 | return imageapi.Prune{}, fmt.Errorf("pruning images: %w", err) 58 | } 59 | defer resp.Body.Close() 60 | 61 | data, err := ioutil.ReadAll(resp.Body) 62 | if err != nil { 63 | return imageapi.Prune{}, err 64 | } 65 | var prune imageapi.Prune 66 | if err := json.Unmarshal(data, &prune); err != nil { 67 | return imageapi.Prune{}, fmt.Errorf("reading response body: %w", err) 68 | } 69 | return prune, nil 70 | } 71 | -------------------------------------------------------------------------------- /image/prune_test.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | "sort" 9 | "testing" 10 | 11 | "github.com/cpuguy83/go-docker/image/imageapi" 12 | "gotest.tools/v3/assert" 13 | ) 14 | 15 | func TestPrune(t *testing.T) { 16 | ctx := context.Background() 17 | s := newTestService(t) 18 | 19 | buildContainers := func(t *testing.T) { 20 | buildContainer(t, "test-image:positive", "test-image", "positive=true") 21 | buildContainer(t, "test-image:negative", "test-image", "positive=false") 22 | // Add a dummy image that should never be discovered in the tests below. 23 | // In particualar, this tests that the interaction between Label and 24 | // NotLabel filter works as intended. 25 | buildContainer(t, "other-image:all", "other-image") 26 | } 27 | cleanup := func(t *testing.T) { 28 | _, err := s.Prune(ctx, func(config *PruneConfig) { 29 | config.Filters.Dangling = []string{"false"} 30 | config.Filters.Label = []string{"test-image"} 31 | }) 32 | assert.NilError(t, err) 33 | _, err = s.Prune(ctx, func(config *PruneConfig) { 34 | config.Filters.Dangling = []string{"false"} 35 | config.Filters.Label = []string{"other-image"} 36 | }) 37 | assert.NilError(t, err) 38 | } 39 | 40 | extract := func(prune imageapi.Prune) []string { 41 | var l []string 42 | for _, deleted := range prune.ImagesDeleted { 43 | if deleted.Untagged != "" { 44 | l = append(l, deleted.Untagged) 45 | } 46 | } 47 | sort.Strings(l) 48 | return l 49 | } 50 | 51 | t.Run("all", func(t *testing.T) { 52 | buildContainers(t) 53 | defer cleanup(t) 54 | 55 | rep, err := s.Prune(ctx, func(config *PruneConfig) { 56 | config.Filters.Dangling = []string{"false"} 57 | config.Filters.Label = []string{"test-image"} 58 | }) 59 | assert.NilError(t, err) 60 | assert.DeepEqual(t, extract(rep), []string{"test-image:negative", "test-image:positive"}) 61 | }) 62 | 63 | t.Run("positive", func(t *testing.T) { 64 | buildContainers(t) 65 | defer cleanup(t) 66 | 67 | rep, err := s.Prune(ctx, func(config *PruneConfig) { 68 | config.Filters.Dangling = []string{"false"} 69 | config.Filters.Label = []string{"positive=true"} 70 | }) 71 | assert.NilError(t, err) 72 | assert.DeepEqual(t, extract(rep), []string{"test-image:positive"}) 73 | }) 74 | 75 | t.Run("non-positive", func(t *testing.T) { 76 | buildContainers(t) 77 | defer cleanup(t) 78 | 79 | rep, err := s.Prune(ctx, func(config *PruneConfig) { 80 | config.Filters.Dangling = []string{"false"} 81 | config.Filters.Label = []string{"test-image"} 82 | config.Filters.NotLabel = []string{"positive=true"} 83 | }) 84 | assert.NilError(t, err) 85 | assert.DeepEqual(t, extract(rep), []string{"test-image:negative"}) 86 | }) 87 | } 88 | 89 | // buildContainer builds the container by executing the docker CLI until we have 90 | // extended the image service to provide the build endpoint. 91 | func buildContainer(t *testing.T, tag string, labels ...string) { 92 | dockerfile := `FROM scratch 93 | CMD ["hello"] 94 | ` 95 | dir := t.TempDir() 96 | assert.NilError(t, os.WriteFile(filepath.Join(dir, "Dockerfile"), []byte(dockerfile), 0666)) 97 | 98 | args := []string{"build", dir, "--tag=" + tag} 99 | for _, label := range labels { 100 | args = append(args, "--label="+label) 101 | } 102 | 103 | rep, err := exec.Command("docker", args...).CombinedOutput() 104 | assert.NilError(t, err, string(rep)) 105 | } 106 | -------------------------------------------------------------------------------- /image/pull.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "encoding/json" 7 | "io" 8 | "io/ioutil" 9 | "net/http" 10 | "strings" 11 | 12 | "github.com/cpuguy83/go-docker/httputil" 13 | "github.com/cpuguy83/go-docker/version" 14 | ) 15 | 16 | // PullConfig is the configuration for pulling an image. 17 | type PullConfig struct { 18 | // When supplied, will be used to retrieve credentials for the given domain. 19 | // If you want this to work with docker CLI auth you can use https://github.com/cpuguy83/dockercfg/blob/3ee4ae1349920b54391faf7d2cff00385a3c6a39/auth.go#L23-L32 20 | CredsFunction func(string) (string, string, error) 21 | // Platform is the platform spec to use when pulling the image. 22 | // Example: "linux/amd64", "linux/arm64", "linux/arm/v7", etc. 23 | // This is used to filter a manifest list to find the right image. 24 | // 25 | // If not set, the default platform for the daemon will be used. 26 | Platform string 27 | // ConsumeProgress is called after a pull response is received to consume the progress messages from the response body. 28 | // ConSumeProgress should not return until EOF is reached on the passed in stream or it may cause the pull to be cancelled. 29 | // If this is not set, progress messages are discarded. 30 | ConsumeProgress StreamConsumer 31 | } 32 | 33 | func WithPullPlatform(platform string) PullOption { 34 | return func(cfg *PullConfig) error { 35 | cfg.Platform = platform 36 | return nil 37 | } 38 | } 39 | 40 | // StreamConsumer is a function that consumes a stream of data, typically a stream of messages. 41 | type StreamConsumer func(context.Context, io.Reader) error 42 | 43 | // PullProgressDecoderV1 is a decoder for the v1 progress message. 44 | // PullProgressMessageV1 represents a message received from the Docker daemon during a pull operation. 45 | type PullProgressMessage struct { 46 | Status string `json:"status,omitempty"` 47 | Progress string `json:"progress,omitempty"` 48 | ID string `json:"id"` 49 | Detail struct { 50 | Current int64 `json:"current"` 51 | Total int64 `json:"total"` 52 | } `json:"progressDetail,omitempty"` 53 | } 54 | 55 | // PullProgressMessageHandler is used with `WithPullProgressMessage` to handle progress messages. 56 | type PullProgressMessageHandler func(context.Context, PullProgressMessage) error 57 | 58 | // PullProgressDigest wraps the passed in handler with a progress callback suitable for `WithPullProgressMessage` 59 | // The passed in callback is called when the digest of a pulled image is received. 60 | func PullProgressDigest(h func(ctx context.Context, digest string) error) PullProgressMessageHandler { 61 | return func(ctx context.Context, msg PullProgressMessage) error { 62 | _, right, ok := strings.Cut(msg.Status, "Digest:") 63 | if !ok { 64 | return nil 65 | } 66 | return h(ctx, strings.TrimSpace(right)) 67 | } 68 | } 69 | 70 | // PullProgressHandlers makes a PullProgressMessageHandler from a list of PullProgressMessageHandlers. 71 | // Handlers are executed in the order they are passed in. 72 | // An error in a handler will stop execution of the remaining handlers. 73 | func PullProgressHandlers(handlers ...PullProgressMessageHandler) PullProgressMessageHandler { 74 | return func(ctx context.Context, msg PullProgressMessage) error { 75 | for _, h := range handlers { 76 | if err := h(ctx, msg); err != nil { 77 | return err 78 | } 79 | } 80 | return nil 81 | } 82 | } 83 | 84 | // WithPullProgressMessage returns a PullOption that sets a pull progress consumer. 85 | // The passed in callback will be called for each progress message. 86 | func WithPullProgressMessage(h PullProgressMessageHandler) PullOption { 87 | return func(cfg *PullConfig) error { 88 | cfg.ConsumeProgress = func(ctx context.Context, r io.Reader) error { 89 | dec := json.NewDecoder(r) 90 | msg := &PullProgressMessage{} 91 | for { 92 | select { 93 | case <-ctx.Done(): 94 | return ctx.Err() 95 | default: 96 | } 97 | 98 | if err := dec.Decode(msg); err != nil { 99 | if err == io.EOF { 100 | return nil 101 | } 102 | return err 103 | } 104 | 105 | if err := h(ctx, *msg); err != nil { 106 | return err 107 | } 108 | *msg = PullProgressMessage{} 109 | } 110 | } 111 | return nil 112 | } 113 | } 114 | 115 | // PullOption is a function that can be used to modify the pull config. 116 | // It is used during `Pull` as functional arguments. 117 | type PullOption func(config *PullConfig) error 118 | 119 | // Pull pulls an image from a remote registry. 120 | // It is up to the caller to set a response size limit as the normal default limit is not used in this case. 121 | func (s *Service) Pull(ctx context.Context, remote Remote, opts ...PullOption) error { 122 | cfg := PullConfig{} 123 | 124 | for _, opt := range opts { 125 | if err := opt(&cfg); err != nil { 126 | return err 127 | } 128 | } 129 | 130 | if cfg.ConsumeProgress == nil { 131 | cfg.ConsumeProgress = func(ctx context.Context, r io.Reader) error { 132 | _, err := io.Copy(ioutil.Discard, r) 133 | return err 134 | } 135 | } 136 | 137 | withConfig := func(req *http.Request) error { 138 | q := req.URL.Query() 139 | if cfg.Platform != "" { 140 | q.Set("platform", cfg.Platform) 141 | } 142 | q.Set("tag", remote.Tag) 143 | from := remote.Locator 144 | if remote.Host != "" && remote.Host != dockerDomain { 145 | from = remote.Host + "/" + remote.Locator 146 | } 147 | q.Set("fromImage", from) 148 | req.URL.RawQuery = q.Encode() 149 | 150 | if cfg.CredsFunction != nil { 151 | username, password, err := cfg.CredsFunction(resolveRegistryHost(remote.Host)) 152 | if err != nil { 153 | return err 154 | } 155 | var ac authConfig 156 | if username == "" || username == "" { 157 | ac.IdentityToken = password 158 | } else { 159 | ac.Username = username 160 | ac.Password = password 161 | } 162 | 163 | auth, err := json.Marshal(&ac) 164 | if err != nil { 165 | return err 166 | } 167 | req.Header = map[string][]string{} 168 | req.Header.Set("X-Registry-Auth", base64.URLEncoding.EncodeToString(auth)) 169 | } 170 | 171 | return nil 172 | } 173 | 174 | // Set unlimited response size since this is going to be consumed by a progress reader. 175 | // It's also pretty important to read the full body. 176 | ctx = httputil.WithResponseLimitIfEmpty(ctx, httputil.UnlimitedResponseLimit) 177 | resp, err := httputil.DoRequest(ctx, func(ctx context.Context) (*http.Response, error) { 178 | return s.tr.Do(ctx, http.MethodPost, version.Join(ctx, "/images/create"), withConfig) 179 | }) 180 | if err != nil { 181 | return err 182 | } 183 | defer resp.Body.Close() 184 | 185 | return cfg.ConsumeProgress(ctx, resp.Body) 186 | } 187 | 188 | type authConfig struct { 189 | Username string `json:"username,omitempty"` 190 | Password string `json:"password,omitempty"` 191 | IdentityToken string `json:"identitytoken,omitempty"` 192 | } 193 | -------------------------------------------------------------------------------- /image/pull_test.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | 8 | "github.com/opencontainers/go-digest" 9 | "gotest.tools/v3/assert" 10 | ) 11 | 12 | func TestPull(t *testing.T) { 13 | t.Parallel() 14 | 15 | svc := newTestService(t) 16 | 17 | ctx := context.Background() 18 | var dgst string 19 | digestFn := func(ctx context.Context, s string) error { 20 | if _, err := digest.Parse(s); err != nil { 21 | return fmt.Errorf("%w: %s", err, s) 22 | } 23 | dgst = s 24 | return nil 25 | } 26 | err := svc.Pull(ctx, Remote{Locator: "busybox", Tag: "latest"}, WithPullProgressMessage(PullProgressDigest(digestFn))) 27 | assert.NilError(t, err) 28 | assert.Assert(t, dgst != "") 29 | 30 | // This is used by some other tests right now, so don't remove it 31 | /// _, err = svc.Remove(ctx, "busybox:latest") 32 | assert.NilError(t, err) 33 | } 34 | -------------------------------------------------------------------------------- /image/ref.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "net/url" 5 | "path" 6 | "strings" 7 | 8 | "github.com/cpuguy83/go-docker/errdefs" 9 | ) 10 | 11 | const ( 12 | dockerDomain = "docker.io" 13 | legacyDomain = "index.docker.io" 14 | ) 15 | 16 | // Remote represents are remote repository reference. 17 | // Tag may include a tag or digest. 18 | type Remote struct { 19 | // Host is the registry hostname. 20 | // Host may be empty, which will cause the daemon to use the default registry (e.g. docker.io). 21 | Host string 22 | // Locator is the repository name, without the host. 23 | Locator string 24 | // Tag is the tag or digest of the image. 25 | Tag string 26 | } 27 | 28 | func (r Remote) String() string { 29 | s := path.Join(r.Host, r.Locator) 30 | if r.Tag != "" { 31 | if strings.Contains(r.Tag, ":") { 32 | // Should be a digest 33 | s = s + "@" + r.Tag 34 | } else if r.Tag != "" { 35 | s += ":" + r.Tag 36 | } 37 | } 38 | 39 | return s 40 | } 41 | 42 | // ParseRef takes an image ref and parses it into a `Remote` struct. 43 | // This can handle the non-canonicalized docker reference format. 44 | func ParseRef(ref string) (Remote, error) { 45 | var r Remote 46 | if ref == "" { 47 | return r, errdefs.Invalid("invalid reference: " + ref) 48 | } 49 | u, err := url.Parse("dummy://" + ref) 50 | if err != nil { 51 | tagIdx := strings.LastIndex(ref, ":") 52 | if tagIdx > strings.LastIndex(ref, "/") { 53 | r.Tag = ref[tagIdx+1:] 54 | ref = ref[:tagIdx] 55 | } 56 | var err2 error 57 | u, err2 = url.Parse("dummy://" + ref) 58 | if err2 != nil { 59 | return Remote{}, errdefs.AsInvalid(err) 60 | } 61 | } 62 | 63 | if u.Scheme != "dummy" { 64 | // Something is very wrong if this happened 65 | return r, errdefs.Invalid("invalid reference: " + ref) 66 | } 67 | 68 | switch { 69 | case u.Path == "": 70 | r.Host, r.Locator = splitDockerDomain(u.Host) 71 | case u.Host == "": 72 | r.Host, r.Locator = splitDockerDomain(u.Path) 73 | default: 74 | r.Host, r.Locator = splitDockerDomain(ref) 75 | } 76 | 77 | l, t, ok := strings.Cut(r.Locator, ":") 78 | if ok { 79 | r.Locator = l 80 | r.Tag = t 81 | } 82 | if r.Tag == "" { 83 | r.Tag = "latest" 84 | } 85 | return r, nil 86 | } 87 | 88 | // splitDockerDomain splits a repository name to domain and remotename string. 89 | // If no valid domain is found, the default domain is used. 90 | func splitDockerDomain(name string) (host, locator string) { 91 | i := strings.IndexRune(name, '/') 92 | var domain string 93 | if i == -1 || (!strings.ContainsAny(name[:i], ".:") && name[:i] != "localhost") { 94 | return dockerDomain, name 95 | } else { 96 | domain = name[:i] 97 | locator = name[i+1:] 98 | } 99 | if domain == legacyDomain { 100 | domain = dockerDomain 101 | } 102 | return domain, locator 103 | } 104 | 105 | func resolveRegistryHost(host string) string { 106 | switch host { 107 | case "index.docker.io", "docker.io", "https://index.docker.io/v1/", "registry-1.docker.io": 108 | return "https://index.docker.io/v1/" 109 | } 110 | return host 111 | } 112 | -------------------------------------------------------------------------------- /image/ref_test.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/cpuguy83/go-docker/errdefs" 7 | "gotest.tools/v3/assert" 8 | "gotest.tools/v3/assert/cmp" 9 | ) 10 | 11 | func TestParseRef(t *testing.T) { 12 | type testCase struct { 13 | ref string 14 | host string 15 | locator string 16 | tag string 17 | errCheck func(error) bool 18 | } 19 | 20 | testCases := []testCase{ 21 | {ref: "", errCheck: errdefs.IsInvalid}, 22 | {ref: "foo", host: "docker.io", locator: "foo", tag: "latest"}, 23 | {ref: "foo:latest", host: "docker.io", locator: "foo", tag: "latest"}, 24 | {ref: "foo:other", host: "docker.io", locator: "foo", tag: "other"}, 25 | {ref: "foo/bar", host: "docker.io", locator: "foo/bar", tag: "latest"}, 26 | {ref: "foo/bar:latest", host: "docker.io", locator: "foo/bar", tag: "latest"}, 27 | {ref: "foo/bar:other", host: "docker.io", locator: "foo/bar", tag: "other"}, 28 | {ref: "foo/bar/baz:latest", host: "docker.io", locator: "foo/bar/baz", tag: "latest"}, 29 | {ref: "foo/bar/baz:other", host: "docker.io", locator: "foo/bar/baz", tag: "other"}, 30 | {ref: "docker.io/foo/bar", host: "docker.io", locator: "foo/bar", tag: "latest"}, 31 | {ref: "docker.io/foo/bar:latest", host: "docker.io", locator: "foo/bar", tag: "latest"}, 32 | {ref: "foo:5000/bar", host: "foo:5000", locator: "bar", tag: "latest"}, 33 | {ref: "foo:5000/bar:latest", host: "foo:5000", locator: "bar", tag: "latest"}, 34 | {ref: "foo:5000/bar/baz", host: "foo:5000", locator: "bar/baz", tag: "latest"}, 35 | {ref: "foo:5000/bar/baz:latest", host: "foo:5000", locator: "bar/baz", tag: "latest"}, 36 | {ref: "foo:invalid/bar/baz", errCheck: errdefs.IsInvalid}, 37 | {ref: "foo:invalid/bar/baz:latest", errCheck: errdefs.IsInvalid}, 38 | } 39 | 40 | format := func(host, locator, tag string) string { 41 | return "host=" + host + " locator=" + locator + " tag=" + tag 42 | } 43 | 44 | for _, tc := range testCases { 45 | tc := tc 46 | t.Run(tc.ref, func(t *testing.T) { 47 | t.Parallel() 48 | 49 | r, err := ParseRef(tc.ref) 50 | if tc.errCheck == nil { 51 | tc.errCheck = func(err error) bool { 52 | return err == nil 53 | } 54 | } 55 | if !tc.errCheck(err) { 56 | t.Error("unexpected error:", err) 57 | } 58 | assert.Check(t, cmp.Equal(tc.host, r.Host), format(r.Host, r.Locator, r.Tag)) 59 | assert.Check(t, cmp.Equal(tc.locator, r.Locator), format(r.Host, r.Locator, r.Tag)) 60 | assert.Check(t, cmp.Equal(tc.tag, r.Tag), format(r.Host, r.Locator, r.Tag)) 61 | }) 62 | } 63 | } 64 | 65 | func TestRefString(t *testing.T) { 66 | type testCase struct { 67 | ref Remote 68 | expected string 69 | } 70 | 71 | testCases := []testCase{ 72 | {ref: Remote{Host: "docker.io", Locator: "foo", Tag: "latest"}, expected: "docker.io/foo:latest"}, 73 | {ref: Remote{Host: "docker.io", Locator: "foo", Tag: "sha256:aaaaa"}, expected: "docker.io/foo@sha256:aaaaa"}, 74 | {ref: Remote{Host: "docker.io", Locator: "foo/bar", Tag: "latest"}, expected: "docker.io/foo/bar:latest"}, 75 | {ref: Remote{Host: "docker.io", Locator: "foo/bar", Tag: "sha256:aaaaa"}, expected: "docker.io/foo/bar@sha256:aaaaa"}, 76 | {ref: Remote{Locator: "foo", Tag: "latest"}, expected: "foo:latest"}, 77 | {ref: Remote{Locator: "foo"}, expected: "foo"}, 78 | {ref: Remote{Locator: "foo", Tag: "sha256:aaaaaa"}, expected: "foo@sha256:aaaaaa"}, 79 | } 80 | 81 | for _, tc := range testCases { 82 | t.Run(tc.expected, func(t *testing.T) { 83 | assert.Check(t, cmp.Equal(tc.expected, tc.ref.String())) 84 | }) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /image/remove.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "strconv" 9 | 10 | "github.com/cpuguy83/go-docker/httputil" 11 | "github.com/cpuguy83/go-docker/version" 12 | ) 13 | 14 | // ImageRemoveConfig is the configuration for removing an image. 15 | // Use ImageRemoveOption to configure this. 16 | type ImageRemoveConfig struct { 17 | Force bool 18 | } 19 | 20 | func WithRemoveForce(cfg *ImageRemoveConfig) error { 21 | cfg.Force = true 22 | return nil 23 | } 24 | 25 | // ImageRemoveOption is a functional option for configuring an image remove. 26 | type ImageRemoveOption func(config *ImageRemoveConfig) error 27 | 28 | // ImageRemoved represents the response from removing an image. 29 | type ImageRemoved struct { 30 | Deleted []string 31 | Untagged []string 32 | } 33 | 34 | type removeStreamResponse struct { 35 | Deleted string `json:"Deleted"` 36 | Untagged string `json:"Untagged"` 37 | } 38 | 39 | // Remove removes an image. 40 | func (s *Service) Remove(ctx context.Context, ref string, opts ...ImageRemoveOption) (ImageRemoved, error) { 41 | var cfg ImageRemoveConfig 42 | for _, o := range opts { 43 | if err := o(&cfg); err != nil { 44 | return ImageRemoved{}, err 45 | } 46 | } 47 | 48 | withRemoveConfig := func(req *http.Request) error { 49 | q := req.URL.Query() 50 | q.Add("force", strconv.FormatBool(cfg.Force)) 51 | req.URL.RawQuery = q.Encode() 52 | return nil 53 | } 54 | 55 | resp, err := httputil.DoRequest(ctx, func(ctx context.Context) (*http.Response, error) { 56 | return s.tr.Do(ctx, http.MethodDelete, version.Join(ctx, "/images/"+ref), withRemoveConfig) 57 | }) 58 | if err != nil { 59 | return ImageRemoved{}, err 60 | } 61 | defer resp.Body.Close() 62 | 63 | var rmS []removeStreamResponse 64 | if err := json.NewDecoder(resp.Body).Decode(&rmS); err != nil { 65 | return ImageRemoved{}, fmt.Errorf("decoding response: %w", err) 66 | } 67 | 68 | var rm ImageRemoved 69 | for _, r := range rmS { 70 | rm.Deleted = append(rm.Deleted, r.Deleted) 71 | rm.Untagged = append(rm.Untagged, r.Untagged) 72 | } 73 | 74 | return rm, nil 75 | } 76 | -------------------------------------------------------------------------------- /image/remove_test.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "gotest.tools/v3/assert" 8 | ) 9 | 10 | func TestRemove(t *testing.T) { 11 | s := newTestService(t) 12 | 13 | ctx := context.Background() 14 | err := s.Pull(ctx, Remote{Locator: "hello-world", Host: "docker.io", Tag: "latest"}) 15 | assert.NilError(t, err) 16 | 17 | rm, err := s.Remove(ctx, "hello-world:latest") 18 | assert.NilError(t, err) 19 | assert.Assert(t, len(rm.Deleted) > 0 || len(rm.Untagged) > 0) 20 | } 21 | -------------------------------------------------------------------------------- /image/service.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import "github.com/cpuguy83/go-docker/transport" 4 | 5 | // Service facilitates all communication with Docker's container endpoints. 6 | // Create one with `NewService` 7 | type Service struct { 8 | tr transport.Doer 9 | } 10 | 11 | // NewService creates a new Service. 12 | func NewService(tr transport.Doer) *Service { 13 | return &Service{tr: tr} 14 | } 15 | -------------------------------------------------------------------------------- /image/service_test.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/cpuguy83/go-docker/testutils" 7 | "gotest.tools/v3/assert" 8 | ) 9 | 10 | func newTestService(t *testing.T) *Service { 11 | tr, err := testutils.NewDefaultTestTransport(t, true) 12 | assert.NilError(t, err) 13 | return NewService(tr) 14 | } 15 | -------------------------------------------------------------------------------- /registry/login.go: -------------------------------------------------------------------------------- 1 | package registry 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | 10 | "github.com/cpuguy83/go-docker/httputil" 11 | "github.com/cpuguy83/go-docker/version" 12 | ) 13 | 14 | // LoginConfig is the configuration for logging into a registry. 15 | type LoginConfig struct { 16 | Username string `json:"username,omitempty"` 17 | Password string `json:"password,omitempty"` 18 | Auth string `json:"auth,omitempty"` 19 | 20 | ServerAddress string `json:"serveraddress,omitempty"` 21 | 22 | // IdentityToken is used to authenticate the user and get 23 | // an access token for the registry. 24 | IdentityToken string `json:"identitytoken,omitempty"` 25 | 26 | // RegistryToken is a bearer token to be sent to a registry 27 | RegistryToken string `json:"registrytoken,omitempty"` 28 | } 29 | 30 | type loginResponse struct { 31 | // An opaque token used to authenticate a user after a successful login 32 | // Required: true 33 | IdentityToken string `json:"IdentityToken"` 34 | } 35 | 36 | // LoginOption is a function that can be used to modify the login config. 37 | type LoginOption func(config *LoginConfig) error 38 | 39 | // Login logs in to a registry with the given credentials. 40 | // Login may return an access token for the registry. 41 | func (s *Service) Login(ctx context.Context, opts ...LoginOption) (string, error) { 42 | cfg := LoginConfig{} 43 | for _, o := range opts { 44 | if err := o(&cfg); err != nil { 45 | return "", err 46 | } 47 | } 48 | 49 | resp, err := httputil.DoRequest(ctx, func(ctx context.Context) (*http.Response, error) { 50 | return s.tr.Do(ctx, http.MethodPost, version.Join(ctx, "/auth"), httputil.WithJSONBody(&cfg)) 51 | }) 52 | if err != nil { 53 | return "", err 54 | } 55 | defer resp.Body.Close() 56 | 57 | data, err := io.ReadAll(resp.Body) 58 | if err != nil { 59 | return "", fmt.Errorf("error reading response: %w", err) 60 | } 61 | 62 | var lr loginResponse 63 | if err := json.Unmarshal(data, &lr); err != nil { 64 | return "", fmt.Errorf("error unmarshaling response: %w", err) 65 | } 66 | 67 | return lr.IdentityToken, nil 68 | } 69 | -------------------------------------------------------------------------------- /registry/login_test.go: -------------------------------------------------------------------------------- 1 | package registry 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/cpuguy83/go-docker/errdefs" 8 | "gotest.tools/v3/assert" 9 | ) 10 | 11 | func TestLogin(t *testing.T) { 12 | getCreds := func(cfg *LoginConfig) error { 13 | cfg.IdentityToken = "asdf" 14 | return nil 15 | } 16 | 17 | s := newTestService(t) 18 | 19 | token, err := s.Login(context.Background(), getCreds) 20 | assert.ErrorIs(t, err, errdefs.ErrUnauthorized) 21 | assert.Assert(t, token == "") 22 | 23 | // TODO: Add a test for a successful login 24 | } 25 | -------------------------------------------------------------------------------- /registry/service.go: -------------------------------------------------------------------------------- 1 | package registry 2 | 3 | import "github.com/cpuguy83/go-docker/transport" 4 | 5 | // Service facilitates all communication with Docker's container endpoints. 6 | // Create one with `NewService` 7 | type Service struct { 8 | tr transport.Doer 9 | } 10 | 11 | // NewService creates a new Service. 12 | func NewService(tr transport.Doer) *Service { 13 | return &Service{tr: tr} 14 | } 15 | -------------------------------------------------------------------------------- /registry/service_test.go: -------------------------------------------------------------------------------- 1 | package registry 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/cpuguy83/go-docker/testutils" 7 | "gotest.tools/v3/assert" 8 | ) 9 | 10 | func newTestService(t *testing.T) *Service { 11 | tr, err := testutils.NewDefaultTestTransport(t, true) 12 | assert.NilError(t, err) 13 | return NewService(tr) 14 | } 15 | -------------------------------------------------------------------------------- /system.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import "github.com/cpuguy83/go-docker/system" 4 | 5 | // SystemService creates a new system service from the client. 6 | func (c *Client) SystemService() *system.Service { 7 | return system.NewService(c.tr) 8 | } 9 | -------------------------------------------------------------------------------- /system/apiversion.go: -------------------------------------------------------------------------------- 1 | package system 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/cpuguy83/go-docker/version" 7 | ) 8 | 9 | // NegoitateAPIVersion negotiates the API version to use with the server. 10 | // The returned context stores the version. 11 | // Pass that ctx into calls that you want to use this negoiated version with. 12 | func (s *Service) NegotiateAPIVersion(ctx context.Context) (context.Context, error) { 13 | p, err := s.Ping(ctx) 14 | if err != nil { 15 | if p.APIVersion == "" { 16 | return ctx, err 17 | } 18 | } 19 | return version.Negotiate(ctx, p.APIVersion), nil 20 | } 21 | -------------------------------------------------------------------------------- /system/events.go: -------------------------------------------------------------------------------- 1 | package system 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net/http" 7 | "net/url" 8 | "strconv" 9 | "time" 10 | 11 | "github.com/cpuguy83/go-docker/version" 12 | ) 13 | 14 | type eventAPI struct { 15 | Type string `json:"type"` 16 | Action string `json:"action"` 17 | Scope string `json:"scope,omitempty"` 18 | TimeNano int64 `json:"timeNano"` 19 | Actor EventActor `json:"actor"` 20 | } 21 | 22 | // Event represents a docker event. 23 | type Event struct { 24 | Type string 25 | Action string 26 | Scope string 27 | Time time.Time 28 | Actor EventActor 29 | } 30 | 31 | // EventActor represents the actor of a docker event with attributes specific to that actor. 32 | type EventActor struct { 33 | ID string `json:"id,omitempty"` 34 | Attributes map[string]string `json:"attributes,omitempty"` 35 | } 36 | 37 | // EventConfig is used to configure the event stream. 38 | type EventConfig struct { 39 | Since *time.Time 40 | Until *time.Time 41 | FieldFilters FieldFilter 42 | } 43 | 44 | type FieldFilter struct { 45 | fields map[string]map[string]bool 46 | } 47 | 48 | // WithEventsBetween is an EventOption that sets the time range for the event stream. 49 | func WithEventsBetween(since, until time.Time) EventOption { 50 | return func(cfg *EventConfig) { 51 | cfg.Since = &since 52 | cfg.Until = &until 53 | } 54 | } 55 | 56 | func (f *FieldFilter) Add(key, value string) { 57 | if f.fields == nil { 58 | f.fields = make(map[string]map[string]bool) 59 | } 60 | if _, ok := f.fields[key]; !ok { 61 | f.fields[key] = make(map[string]bool) 62 | } 63 | f.fields[key][value] = true 64 | } 65 | 66 | // EventOption is a function that can be passed to Events to configure the event stream. 67 | type EventOption func(*EventConfig) 68 | 69 | // WithFilters is an EventOption that adds filters to the event stream. 70 | // These filters are passed to the docker daemon and are used to filter the events returned. 71 | func WithEventFilters(f FieldFilter) EventOption { 72 | return func(cfg *EventConfig) { 73 | cfg.FieldFilters = f 74 | } 75 | } 76 | 77 | // WithAddEventFilter is an EventOption that adds a filter to the event stream. 78 | // If the key already exists in the filters list, the new value is appended to the list of values for that key. 79 | func WithAddEventFilter(key, value string) EventOption { 80 | return func(cfg *EventConfig) { 81 | cfg.FieldFilters.Add(key, value) 82 | } 83 | } 84 | 85 | // Events returns a function that can be called to get the next event. 86 | // The function will block until an event is available. 87 | // The returned event is only valid until the next call to the function. 88 | // 89 | // Canceling the context will stop the event stream. 90 | // Once cancelled the next call to the returned function will return the context error. 91 | func (s *Service) Events(ctx context.Context, opts ...EventOption) (func() (*Event, error), error) { 92 | var cfg EventConfig 93 | for _, o := range opts { 94 | o(&cfg) 95 | } 96 | 97 | resp, err := s.tr.Do(ctx, http.MethodGet, version.Join(ctx, "/events"), func(req *http.Request) error { 98 | q := url.Values{} 99 | if len(cfg.FieldFilters.fields) > 0 { 100 | dt, err := json.Marshal(cfg.FieldFilters.fields) 101 | if err != nil { 102 | return err 103 | } 104 | q.Set("filters", string(dt)) 105 | } 106 | if cfg.Since != nil { 107 | q.Set("since", strconv.FormatInt(cfg.Since.Unix(), 10)) 108 | } 109 | 110 | if cfg.Until != nil { 111 | q.Set("until", strconv.FormatInt(cfg.Until.Unix(), 10)) 112 | } 113 | if len(q) > 0 { 114 | req.URL.RawQuery = q.Encode() 115 | } 116 | return nil 117 | }) 118 | if err != nil { 119 | return nil, err 120 | } 121 | 122 | dec := json.NewDecoder(resp.Body) 123 | evAPI := &eventAPI{} 124 | ev := &Event{} 125 | 126 | return func() (*Event, error) { 127 | *evAPI = eventAPI{} 128 | if err := dec.Decode(evAPI); err != nil { 129 | return nil, err 130 | } 131 | 132 | ev.Type = evAPI.Type 133 | ev.Action = evAPI.Action 134 | ev.Scope = evAPI.Scope 135 | ev.Time = time.Unix(0, evAPI.TimeNano) 136 | ev.Actor = evAPI.Actor 137 | return ev, nil 138 | }, nil 139 | } 140 | -------------------------------------------------------------------------------- /system/events_test.go: -------------------------------------------------------------------------------- 1 | package system 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "io" 7 | "testing" 8 | "time" 9 | 10 | "github.com/cpuguy83/go-docker/image" 11 | "github.com/cpuguy83/go-docker/testutils" 12 | ) 13 | 14 | func TestEvents(t *testing.T) { 15 | t.Parallel() 16 | 17 | tr, _ := testutils.NewDefaultTestTransport(t, true) 18 | svc := NewService(tr) 19 | 20 | ctx, cancel := context.WithCancel(context.Background()) 21 | defer cancel() 22 | 23 | f, err := svc.Events(ctx) 24 | if err != nil { 25 | t.Fatal(err) 26 | } 27 | 28 | cancel() 29 | if _, err := f(); !errors.Is(err, context.Canceled) { 30 | t.Fatal(err) 31 | } 32 | 33 | ctx, cancel = context.WithCancel(context.Background()) 34 | defer cancel() 35 | 36 | const imgRef = "hello-world:latest" 37 | 38 | // TODO: It would be nice to not have to trigger a pull to get an event. 39 | remote, err := image.ParseRef(imgRef) 40 | if err != nil { 41 | t.Fatal(err) 42 | } 43 | 44 | f, err = svc.Events(ctx) 45 | if err != nil { 46 | t.Fatal(err) 47 | } 48 | 49 | beforePull := time.Now() 50 | 51 | if err := image.NewService(tr).Pull(ctx, remote); err != nil { 52 | cancel() 53 | t.Fatal(err) 54 | } 55 | afterPull := time.Now() 56 | 57 | // Even if the test was not run in parallel, we can't guarantee that the event will be the first one 58 | // returned, so we loop until we get the event we want. 59 | for { 60 | ev, err := f() 61 | if err != nil { 62 | t.Error(err) 63 | break 64 | } 65 | 66 | t.Log(ev) 67 | 68 | if ev.Type != "image" { 69 | continue 70 | } 71 | if ev.Action != "pull" { 72 | continue 73 | } 74 | 75 | if ev.Actor.ID != imgRef { 76 | continue 77 | } 78 | 79 | if !ev.Time.After(beforePull) { 80 | continue 81 | } 82 | 83 | if !ev.Time.Before(afterPull) { 84 | continue 85 | } 86 | break 87 | } 88 | 89 | cancel() 90 | 91 | ctxT, cancel := context.WithTimeout(context.Background(), 120*time.Second) 92 | defer cancel() 93 | for { 94 | if ctxT.Err() != nil { 95 | t.Fatal("timeout waiting for event") 96 | } 97 | if _, err := f(); !errors.Is(err, context.Canceled) { 98 | continue 99 | } 100 | break 101 | } 102 | 103 | ctx, cancel = context.WithCancel(context.Background()) 104 | defer cancel() 105 | 106 | f, err = svc.Events(ctx, WithEventsBetween(time.Now(), time.Now())) 107 | if err != nil { 108 | t.Fatal(err) 109 | } 110 | 111 | _, err = f() 112 | if !errors.Is(err, io.EOF) { 113 | t.Fatalf("expected EOF, got: %v", err) 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /system/ping.go: -------------------------------------------------------------------------------- 1 | package system 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/cpuguy83/go-docker/httputil" 8 | ) 9 | 10 | type Ping struct { 11 | APIVersion string 12 | OSType string 13 | Experimental bool 14 | BuilderVersion string 15 | } 16 | 17 | func (s *Service) Ping(ctx context.Context) (Ping, error) { 18 | resp, err := httputil.DoRequest(ctx, func(ctx context.Context) (*http.Response, error) { 19 | return s.tr.Do(ctx, "GET", "/_ping") 20 | }) 21 | var p Ping 22 | if resp != nil { 23 | defer resp.Body.Close() 24 | 25 | p.APIVersion = resp.Header.Get("API-Version") 26 | p.OSType = resp.Header.Get("OSType") 27 | p.Experimental = resp.Header.Get("Docker-Experimental") == "true" 28 | p.BuilderVersion = resp.Header.Get("Builder-Version") 29 | } 30 | 31 | // We are intentionally returning a populated ping response even if there is an error 32 | // since this data may have been returned by the API. 33 | return p, err 34 | } 35 | -------------------------------------------------------------------------------- /system/ping_test.go: -------------------------------------------------------------------------------- 1 | package system 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "io/ioutil" 8 | "net" 9 | "net/http" 10 | "path" 11 | "strconv" 12 | "testing" 13 | 14 | "github.com/cpuguy83/go-docker/transport" 15 | "gotest.tools/v3/assert" 16 | "gotest.tools/v3/assert/cmp" 17 | ) 18 | 19 | type mockDoer struct { 20 | doHandlers map[string]func(*http.Request) *http.Response 21 | } 22 | 23 | func (m *mockDoer) registerHandler(method, uri string, h func(*http.Request) *http.Response) { 24 | if m.doHandlers == nil { 25 | m.doHandlers = make(map[string]func(*http.Request) *http.Response) 26 | } 27 | m.doHandlers[path.Join(method, uri)] = h 28 | } 29 | 30 | func (m *mockDoer) Do(ctx context.Context, method string, uri string, opts ...transport.RequestOpt) (*http.Response, error) { 31 | var req http.Request 32 | for _, o := range opts { 33 | if err := o(&req); err != nil { 34 | return nil, err 35 | } 36 | } 37 | 38 | h, ok := m.doHandlers[path.Join(method, uri)] 39 | if !ok { 40 | return &http.Response{StatusCode: http.StatusNotFound, Status: "not found"}, nil 41 | } 42 | return h(&req), nil 43 | } 44 | 45 | func (m *mockDoer) DoRaw(ctx context.Context, method string, uri string, opts ...transport.RequestOpt) (net.Conn, error) { 46 | return nil, errors.New("not supported") 47 | } 48 | 49 | func TestPing(t *testing.T) { 50 | statuses := []int{http.StatusOK, http.StatusConflict} 51 | 52 | for _, s := range statuses { 53 | t.Run("Status"+strconv.Itoa(s), func(t *testing.T) { 54 | pingHandler := func(req *http.Request) *http.Response { 55 | resp := &http.Response{ 56 | StatusCode: s, 57 | Header: http.Header{}, 58 | Body: ioutil.NopCloser(bytes.NewBuffer(nil)), 59 | } 60 | 61 | resp.Header.Add("OSType", "the best (one for the job)!") 62 | resp.Header.Add("API-Version", "banana") 63 | resp.Header.Add("Builder-Version", "apple") 64 | resp.Header.Add("Docker-Experimental", "true") 65 | return resp 66 | } 67 | 68 | tr := &mockDoer{} 69 | tr.registerHandler(http.MethodGet, "/_ping", pingHandler) 70 | system := &Service{ 71 | tr: tr, 72 | } 73 | p, _ := system.Ping(context.Background()) 74 | 75 | assert.Check(t, cmp.Equal(p.OSType, "the best (one for the job)!")) 76 | assert.Check(t, cmp.Equal(p.APIVersion, "banana")) 77 | assert.Check(t, cmp.Equal(p.BuilderVersion, "apple")) 78 | assert.Check(t, cmp.Equal(p.Experimental, true)) 79 | }) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /system/service.go: -------------------------------------------------------------------------------- 1 | package system 2 | 3 | import "github.com/cpuguy83/go-docker/transport" 4 | 5 | // Service facilitates all communication with Docker's container endpoints. 6 | // Create one with `NewService` 7 | type Service struct { 8 | tr transport.Doer 9 | } 10 | 11 | // NewService creates a Service. 12 | // This is the entrypoint to this package. 13 | func NewService(tr transport.Doer) *Service { 14 | return &Service{tr} 15 | } 16 | -------------------------------------------------------------------------------- /testutils/deadline.go: -------------------------------------------------------------------------------- 1 | package testutils 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func Deadline(t *testing.T, dur time.Duration, fChan <-chan func(t *testing.T)) { 9 | t.Helper() 10 | 11 | timer := time.NewTimer(dur) 12 | defer timer.Stop() 13 | 14 | select { 15 | case <-timer.C: 16 | t.Fatal("timeout waiting") 17 | case f := <-fChan: 18 | f(t) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /testutils/default_unix.go: -------------------------------------------------------------------------------- 1 | package testutils 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/cpuguy83/go-docker/transport" 8 | "gotest.tools/v3/assert" 9 | ) 10 | 11 | // NewDefaultTestTransport creates a default test transport 12 | func NewDefaultTestTransport(t *testing.T, noTap bool) (*Transport, error) { 13 | if os.Getenv("DOCKER_HOST") != "" || os.Getenv("DOCKER_CONTEXT") != "" { 14 | t.Log("Using docker cli transport") 15 | tr := transport.FromDockerCLI(func(cfg *transport.DockerCLIConnectionConfig) error { 16 | cfg.StderrPipe = &testWriter{t} 17 | return nil 18 | }) 19 | return NewTransport(t, tr, noTap), nil 20 | } 21 | 22 | t.Log("Using system default transport") 23 | tr, err := transport.DefaultTransport() 24 | assert.NilError(t, err) 25 | 26 | return NewTransport(t, tr, noTap), nil 27 | } 28 | 29 | type testWriter struct { 30 | t *testing.T 31 | } 32 | 33 | func (t *testWriter) Write(p []byte) (int, error) { 34 | t.t.Helper() 35 | t.t.Log(string(p)) 36 | return len(p), nil 37 | } 38 | -------------------------------------------------------------------------------- /testutils/random.go: -------------------------------------------------------------------------------- 1 | package testutils 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/hex" 6 | ) 7 | 8 | // GenerateRandomString generates a random string 9 | func GenerateRandomString() string { 10 | buf := make([]byte, 16) 11 | 12 | n, err := rand.Read(buf) 13 | if err != nil { 14 | panic(err) 15 | } 16 | 17 | return hex.EncodeToString(buf[:n]) 18 | } 19 | -------------------------------------------------------------------------------- /testutils/transport.go: -------------------------------------------------------------------------------- 1 | package testutils 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io" 7 | "io/ioutil" 8 | "net" 9 | "net/http" 10 | "regexp" 11 | 12 | "github.com/cpuguy83/go-docker/transport" 13 | ) 14 | 15 | var ( 16 | // regex to match any non-empty identity token 17 | jsonIdentityTokenRegex = regexp.MustCompile(`"((?i)identitytoken|password|auth)":\ ?".*"`) 18 | ) 19 | 20 | func NewTransport(t LogT, client transport.Doer, noTap bool) *Transport { 21 | return &Transport{client, t, noTap} 22 | } 23 | 24 | type LogT interface { 25 | Log(...interface{}) 26 | Logf(string, ...interface{}) 27 | Helper() 28 | } 29 | 30 | type Transport struct { 31 | d transport.Doer 32 | t LogT 33 | noTap bool 34 | } 35 | 36 | type readCloserWrapper struct { 37 | io.Reader 38 | close func() error 39 | } 40 | 41 | func (w *readCloserWrapper) Close() error { 42 | if w.close != nil { 43 | return w.close() 44 | } 45 | return nil 46 | } 47 | 48 | func wrapReader(r io.Reader, f func() error) io.ReadCloser { 49 | return &readCloserWrapper{r, f} 50 | } 51 | 52 | func (t *Transport) Do(ctx context.Context, method, uri string, opts ...transport.RequestOpt) (*http.Response, error) { 53 | t.t.Helper() 54 | opts = append(opts, t.logRequest) 55 | return t.logResponse(t.d.Do(ctx, method, uri, opts...)) 56 | } 57 | 58 | func (t *Transport) DoRaw(ctx context.Context, method, uri string, opts ...transport.RequestOpt) (net.Conn, error) { 59 | t.t.Helper() 60 | opts = append(opts, t.logRequest) 61 | conn, err := t.d.DoRaw(ctx, method, uri, opts...) 62 | if err != nil { 63 | return conn, err 64 | } 65 | 66 | if t.noTap { 67 | return conn, nil 68 | } 69 | 70 | p1, p2 := net.Pipe() 71 | 72 | go func() { 73 | io.Copy(p2, io.TeeReader(conn, &testingWriter{t.t})) 74 | p2.Close() 75 | }() 76 | 77 | go func() { 78 | io.Copy(conn, io.TeeReader(p2, &testingWriter{t.t})) 79 | conn.Close() 80 | }() 81 | 82 | return p1, nil 83 | } 84 | 85 | type testingWriter struct { 86 | t LogT 87 | } 88 | 89 | func (t *testingWriter) Write(p []byte) (int, error) { 90 | t.t.Helper() 91 | t.t.Log(string(p)) 92 | return len(p), nil 93 | } 94 | 95 | func (t *Transport) logRequest(req *http.Request) error { 96 | t.t.Helper() 97 | t.t.Log(req.Method, req.URL.String()) 98 | t.t.Log(req.Header) 99 | 100 | if req.Header.Get("Content-Type") != "application/json" { 101 | return nil 102 | } 103 | 104 | buf := bytes.NewBuffer(nil) 105 | if _, err := ioutil.ReadAll(io.TeeReader(req.Body, buf)); err != nil { 106 | return err 107 | } 108 | 109 | req.Body = wrapReader(buf, req.Body.Close) 110 | 111 | t.t.Log(filterBuf(buf).String()) 112 | return nil 113 | } 114 | 115 | func (t *Transport) logResponse(resp *http.Response, err error) (*http.Response, error) { 116 | t.t.Helper() 117 | 118 | if resp == nil { 119 | return resp, err 120 | } 121 | 122 | if resp != nil { 123 | t.t.Log(resp.Status, err) 124 | t.t.Log(resp.Header) 125 | } 126 | 127 | if resp.Header.Get("Content-Type") != "application/json" { 128 | return resp, nil 129 | } 130 | 131 | buf := bytes.NewBuffer(nil) 132 | b := resp.Body 133 | rdr := io.TeeReader(b, buf) 134 | resp.Body = wrapReader(rdr, func() error { 135 | t.t.Log(filterBuf(buf).String()) 136 | return b.Close() 137 | }) 138 | 139 | return resp, err 140 | } 141 | 142 | func filterBuf(buf *bytes.Buffer) *bytes.Buffer { 143 | return bytes.NewBuffer(jsonIdentityTokenRegex.ReplaceAll(buf.Bytes(), []byte(`"${1}": ""`))) 144 | } 145 | -------------------------------------------------------------------------------- /testutils/transport_test.go: -------------------------------------------------------------------------------- 1 | package testutils 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "gotest.tools/v3/assert" 8 | ) 9 | 10 | func TestFilter(t *testing.T) { 11 | cases := []string{ 12 | "identitytoken", 13 | "identityToken", 14 | "IdentityToken", 15 | "password", 16 | "Password", 17 | "auth", 18 | "Auth", 19 | } 20 | 21 | for _, c := range cases { 22 | buf := bytes.NewBuffer([]byte(`{"` + c + `": "foo"}`)) 23 | assert.Check(t, jsonIdentityTokenRegex.Match(buf.Bytes())) 24 | 25 | filtered := filterBuf(buf) 26 | assert.Check(t, bytes.Contains(filtered.Bytes(), []byte(`""`))) 27 | assert.Check(t, !bytes.Contains(filtered.Bytes(), []byte(`"foo"`))) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /transport/dialer_unix.go: -------------------------------------------------------------------------------- 1 | // +build !windows 2 | 3 | package transport 4 | 5 | import ( 6 | "errors" 7 | "net" 8 | "time" 9 | ) 10 | 11 | func winDailer(path string, timeout *time.Duration) (net.Conn, error) { 12 | return nil, errors.New("windows dailers are not supported on unix platforms") 13 | } 14 | 15 | -------------------------------------------------------------------------------- /transport/dialer_windows.go: -------------------------------------------------------------------------------- 1 | // +build windows 2 | 3 | package transport 4 | 5 | import ( 6 | "net" 7 | "time" 8 | 9 | "github.com/Microsoft/go-winio" 10 | ) 11 | 12 | func winDailer(path string, timeout *time.Duration) (net.Conn, error) { 13 | return winio.DialPipe(path, timeout) 14 | } 15 | -------------------------------------------------------------------------------- /transport/hijack.go: -------------------------------------------------------------------------------- 1 | package transport 2 | 3 | import ( 4 | "bufio" 5 | "net" 6 | ) 7 | 8 | func newHijackedConn(conn net.Conn, buf *bufio.Reader) net.Conn { 9 | if buf.Buffered() == 0 { 10 | buf.Reset(nil) 11 | return conn 12 | } 13 | 14 | hc := &hijackConn{Conn: conn, buf: buf} 15 | 16 | if _, ok := conn.(closeWriter); ok { 17 | return &hijackConnCloseWrite{hc} 18 | } 19 | 20 | return hc 21 | } 22 | 23 | type hijackConn struct { 24 | net.Conn 25 | buf *bufio.Reader 26 | } 27 | 28 | func (c *hijackConn) Read(p []byte) (int, error) { 29 | return c.buf.Read(p) 30 | } 31 | 32 | type hijackConnCloseWrite struct { 33 | *hijackConn 34 | } 35 | 36 | func (c *hijackConnCloseWrite) CloseWrite() error { 37 | return c.Conn.(closeWriter).CloseWrite() 38 | } 39 | -------------------------------------------------------------------------------- /transport/npipe.go: -------------------------------------------------------------------------------- 1 | package transport 2 | 3 | import ( 4 | "context" 5 | "net" 6 | "net/http" 7 | "net/url" 8 | ) 9 | 10 | func DefaultWindowsTransport() (*Transport, error) { 11 | return NpipeTransport("//./pipe/docker_engine") 12 | } 13 | 14 | func NpipeTransport(path string, opts ...ConnectionOption) (*Transport, error) { 15 | var cfg ConnectionConfig 16 | 17 | for _, o := range opts { 18 | if err := o(&cfg); err != nil { 19 | return nil, err 20 | } 21 | } 22 | 23 | t := &http.Transport{ 24 | DisableCompression: true, 25 | DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { 26 | return winDailer(path, nil) 27 | }, 28 | } 29 | 30 | dail := func(ctx context.Context) (net.Conn, error) { 31 | return t.DialContext(ctx, "", "") 32 | } 33 | 34 | return &Transport{ 35 | host: url.PathEscape(path), 36 | scheme: "http", 37 | c: &http.Client{ 38 | Transport: t, 39 | }, 40 | dial: dail, 41 | }, nil 42 | } 43 | -------------------------------------------------------------------------------- /transport/npipe_test.go: -------------------------------------------------------------------------------- 1 | // +build windows 2 | 3 | package transport 4 | 5 | import ( 6 | "context" 7 | "io" 8 | "net/http" 9 | "testing" 10 | 11 | "github.com/Microsoft/go-winio" 12 | "gotest.tools/v3/assert" 13 | ) 14 | 15 | var testPipeName = `\\.\pipe\winiotestpipe` 16 | 17 | func TestWindowsTransport(t *testing.T) { 18 | l, err := winio.ListenPipe(testPipeName, nil) 19 | assert.NilError(t, err) 20 | defer l.Close() 21 | 22 | go http.Serve(l, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 23 | w.Write([]byte("hello " + req.URL.Path)) 24 | })) 25 | 26 | ctx := context.Background() 27 | 28 | tr, err := NpipeTransport(testPipeName) 29 | assert.NilError(t, err) 30 | 31 | resp, err := tr.Do(ctx, "GET", "/foo") 32 | assert.NilError(t, err) 33 | defer resp.Body.Close() 34 | 35 | data := "hello /foo" 36 | buf := make([]byte, len(data)) 37 | _, err = io.ReadFull(resp.Body, buf) 38 | assert.NilError(t, err) 39 | assert.Equal(t, string(buf), data) 40 | } 41 | -------------------------------------------------------------------------------- /transport/stdio.go: -------------------------------------------------------------------------------- 1 | package transport 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net" 8 | "net/http" 9 | "os" 10 | "os/exec" 11 | ) 12 | 13 | // DockerCLIConnectionConfig is the configuration for the DockerCLIConnectionOption 14 | type DockerCLIConnectionConfig struct { 15 | // Env is the environment to use for the docker CLI 16 | // Useful for setting DOCKER_HOST or DOCKER_CONTEXT to specify the docker daemon that the docker cli should connect to 17 | // Defaults to os.Environ() 18 | Env []string 19 | // StderrPipe is the writer to use for the stderr of the docker CLI 20 | // This is the only means of getting error messages from the CLI. 21 | // 22 | // Deprecated: This is only here until validation is done on the cli connection. 23 | // Right now this is the only way to get failure details from the CLI. 24 | StderrPipe io.Writer 25 | } 26 | 27 | // DockerCLIConnectionOption is an option for the FromDockerCLI function 28 | type DockerCLIConnectionOption func(*DockerCLIConnectionConfig) error 29 | 30 | // FromDockerCLI creates a Transport from the docker CLI 31 | // In this case, the docker CLI acts as a proxy to the docker daemon. 32 | // Any protocol your CLI supports, this transport will support. 33 | func FromDockerCLI(opts ...DockerCLIConnectionOption) *Transport { 34 | dial := func(ctx context.Context, _, _ string) (net.Conn, error) { 35 | cfg := DockerCLIConnectionConfig{ 36 | Env: os.Environ(), 37 | } 38 | for _, o := range opts { 39 | if err := o(&cfg); err != nil { 40 | return nil, err 41 | } 42 | } 43 | 44 | cmd := exec.CommandContext(ctx, "docker", "system", "dial-stdio") 45 | cmd.Env = cfg.Env 46 | 47 | c1, c2 := net.Pipe() 48 | cmd.Stdin = c1 49 | cmd.Stdout = c1 50 | cmd.Stderr = cfg.StderrPipe 51 | 52 | if err := cmd.Start(); err != nil { 53 | c1.Close() 54 | c2.Close() 55 | return nil, fmt.Errorf("failed to start docker dial-stdio: %w", err) 56 | } 57 | go func() { 58 | cmd.Wait() 59 | c1.Close() 60 | c2.Close() 61 | }() 62 | 63 | // TODO: Validate that we can actually handshake with the server 64 | 65 | return c2, nil 66 | } 67 | 68 | tr := &http.Transport{ 69 | DisableCompression: true, 70 | DialContext: dial, 71 | } 72 | 73 | return &Transport{ 74 | scheme: "http", 75 | c: &http.Client{ 76 | Transport: tr, 77 | }, 78 | host: ".", 79 | dial: func(ctx context.Context) (net.Conn, error) { 80 | return tr.DialContext(ctx, "", "") 81 | }, 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /transport/stdio_test.go: -------------------------------------------------------------------------------- 1 | package transport 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io" 7 | "net/http" 8 | "net/http/httptest" 9 | "net/url" 10 | "testing" 11 | "time" 12 | 13 | "gotest.tools/v3/assert" 14 | ) 15 | 16 | func TestFromDockerCLI(t *testing.T) { 17 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 18 | w.Write([]byte("hello " + req.URL.Path)) 19 | })) 20 | defer srv.Close() 21 | 22 | u, err := url.Parse(srv.URL) 23 | assert.NilError(t, err) 24 | 25 | errBuf := bytes.NewBuffer(nil) 26 | tr := FromDockerCLI(func(dcc *DockerCLIConnectionConfig) error { 27 | dcc.Env = []string{"DOCKER_HOST=" + "tcp://" + u.Host} 28 | dcc.StderrPipe = errBuf 29 | return nil 30 | }) 31 | t.Cleanup(func() { 32 | if t.Failed() && errBuf.Len() > 0 { 33 | t.Log(errBuf.String()) 34 | } 35 | }) 36 | 37 | ctx := context.Background() 38 | 39 | ctxT, cancel := context.WithTimeout(ctx, 10*time.Second) 40 | resp, err := tr.Do(ctxT, "GET", "/foo") 41 | cancel() 42 | assert.NilError(t, err) 43 | defer resp.Body.Close() 44 | 45 | data := "hello /foo" 46 | buf := make([]byte, len(data)) 47 | _, err = io.ReadFull(resp.Body, buf) 48 | assert.NilError(t, err) 49 | assert.Equal(t, string(buf), data, &readerStringer{resp.Body}) 50 | } 51 | 52 | type readerStringer struct { 53 | io.Reader 54 | } 55 | 56 | func (r *readerStringer) String() string { 57 | out, _ := io.ReadAll(r.Reader) 58 | return string(out) 59 | } 60 | -------------------------------------------------------------------------------- /transport/tcp.go: -------------------------------------------------------------------------------- 1 | package transport 2 | 3 | import ( 4 | "net" 5 | "net/http" 6 | ) 7 | 8 | func TCPTransport(host string, opts ...ConnectionOption) (*Transport, error) { 9 | var cfg ConnectionConfig 10 | 11 | for _, o := range opts { 12 | if err := o(&cfg); err != nil { 13 | return nil, err 14 | } 15 | } 16 | 17 | t := &Transport{ 18 | scheme: "http", 19 | host: host, 20 | c: &http.Client{ 21 | Transport: &http.Transport{ 22 | DialContext: new(net.Dialer).DialContext, 23 | TLSClientConfig: cfg.TLSConfig, 24 | }, 25 | }, 26 | } 27 | 28 | if cfg.TLSConfig != nil { 29 | t.scheme = "https" 30 | } 31 | 32 | return t, nil 33 | } 34 | -------------------------------------------------------------------------------- /transport/tcp_test.go: -------------------------------------------------------------------------------- 1 | package transport 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/http" 7 | "net/http/httptest" 8 | "net/url" 9 | "testing" 10 | 11 | "gotest.tools/v3/assert" 12 | ) 13 | 14 | func TestTCPTransport(t *testing.T) { 15 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 16 | w.Write([]byte("hello " + req.URL.Path)) 17 | })) 18 | defer srv.Close() 19 | 20 | ctx := context.Background() 21 | 22 | u, err := url.Parse(srv.URL) 23 | assert.NilError(t, err) 24 | 25 | tr, err := TCPTransport(u.Host) 26 | assert.NilError(t, err) 27 | 28 | resp, err := tr.Do(ctx, "GET", "/foo") 29 | assert.NilError(t, err) 30 | defer resp.Body.Close() 31 | 32 | data := "hello /foo" 33 | buf := make([]byte, len(data)) 34 | _, err = io.ReadFull(resp.Body, buf) 35 | assert.NilError(t, err) 36 | assert.Equal(t, string(buf), data) 37 | } 38 | -------------------------------------------------------------------------------- /transport/transport.go: -------------------------------------------------------------------------------- 1 | package transport 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "fmt" 7 | "net" 8 | "net/http" 9 | "net/http/httputil" 10 | "net/url" 11 | "path" 12 | "strings" 13 | "time" 14 | ) 15 | 16 | // Doer performs an http request for Client 17 | // It is the Doer's responsibility to deal with setting the host details on 18 | // the request 19 | // It is expected that one Doer connects to one Docker instance. 20 | type Doer interface { 21 | // Do typically performs a normal http request/response 22 | Do(ctx context.Context, method string, uri string, opts ...RequestOpt) (*http.Response, error) 23 | // DoRaw performs the request but passes along the response as a bi-directional stream 24 | DoRaw(ctx context.Context, method string, uri string, opts ...RequestOpt) (net.Conn, error) 25 | } 26 | 27 | // RequestOpt is as functional arguments to configure an HTTP request for a Doer. 28 | type RequestOpt func(*http.Request) error 29 | 30 | // Transport implements the Doer interface for all the normal docker protocols). 31 | // This would normally be things that would go over a net.Conn, such as unix or tcp sockets. 32 | // 33 | // Create a transport from one of the available helper functions. 34 | type Transport struct { 35 | c *http.Client 36 | dial func(context.Context) (net.Conn, error) 37 | host string 38 | scheme string 39 | transform func(*http.Request) 40 | } 41 | 42 | // Do implements the Doer.Do interface 43 | func (t *Transport) Do(ctx context.Context, method, uri string, opts ...RequestOpt) (*http.Response, error) { 44 | req := &http.Request{} 45 | req.Method = method 46 | req.URL = &url.URL{Path: uri, Host: t.host, Scheme: t.scheme} 47 | 48 | req = req.WithContext(ctx) 49 | 50 | for _, o := range opts { 51 | if err := o(req); err != nil { 52 | return nil, err 53 | } 54 | } 55 | 56 | if t.transform != nil { 57 | t.transform(req) 58 | } 59 | resp, err := t.c.Do(req) 60 | if err != nil { 61 | return resp, err 62 | } 63 | return resp, nil 64 | } 65 | 66 | // Do implements the Doer.DoRaw interface 67 | func (t *Transport) DoRaw(ctx context.Context, method, uri string, opts ...RequestOpt) (conn net.Conn, retErr error) { 68 | req := &http.Request{Header: http.Header{}} 69 | req.Method = method 70 | req.URL = &url.URL{Path: uri, Host: t.host, Scheme: t.scheme} 71 | 72 | req = req.WithContext(ctx) 73 | 74 | for _, o := range opts { 75 | if err := o(req); err != nil { 76 | return nil, err 77 | } 78 | } 79 | 80 | if t.transform != nil { 81 | t.transform(req) 82 | } 83 | 84 | conn, err := t.dial(ctx) 85 | if err != nil { 86 | return nil, err 87 | } 88 | 89 | // There can be long periods of inactivity when hijacking a connection. 90 | // Set keep-alive to ensure that the connection is not broken due to idle time. 91 | if tc, ok := conn.(*net.TCPConn); ok { 92 | tc.SetKeepAlive(true) 93 | tc.SetKeepAlivePeriod(30 * time.Second) 94 | } 95 | 96 | cc := httputil.NewClientConn(conn, nil) 97 | defer func() { 98 | if retErr != nil { 99 | cc.Close() 100 | } 101 | }() 102 | 103 | resp, err := cc.Do(req) 104 | if err != httputil.ErrPersistEOF { 105 | if err != nil { 106 | return nil, err 107 | } 108 | if resp.StatusCode != http.StatusSwitchingProtocols { 109 | resp.Body.Close() 110 | return nil, fmt.Errorf("unable to upgrade to %s, received %d", req.Header.Get("Upgrade"), resp.StatusCode) 111 | } 112 | } 113 | 114 | conn, buf := cc.Hijack() 115 | return newHijackedConn(conn, buf), nil 116 | } 117 | 118 | type closeWriter interface { 119 | CloseWrite() error 120 | } 121 | 122 | // FromConnectionString creates a transport from the provided connection string 123 | // This connection string is the one defined in the official docker client for DOCKER_HOST 124 | func FromConnectionString(s string, opts ...ConnectionOption) (*Transport, error) { 125 | u, err := url.Parse(s) 126 | if err != nil { 127 | return nil, err 128 | } 129 | return FromConnectionURL(u, opts...) 130 | } 131 | 132 | // ConnectionOption is use as functional arguments for creating a Transport 133 | // It configures a ConnectionConfig 134 | type ConnectionOption func(*ConnectionConfig) error 135 | 136 | // ConnectionConfig holds the options available for configuring a new transport. 137 | type ConnectionConfig struct { 138 | TLSConfig *tls.Config 139 | } 140 | 141 | // FromConnectionURL creates a Transport from a provided URL 142 | // 143 | // The URL's scheme must specify the protocol ("unix", "tcp", etc.) 144 | // 145 | // TODO: implement ssh schemes. 146 | func FromConnectionURL(u *url.URL, opts ...ConnectionOption) (*Transport, error) { 147 | switch u.Scheme { 148 | case "unix": 149 | return UnixSocketTransport(path.Join(u.Host, u.Path), opts...) 150 | case "tcp": 151 | return TCPTransport(u.Host, opts...) 152 | case "npipe": 153 | return NpipeTransport(u.Path, opts...) 154 | default: 155 | // TODO: ssh 156 | return nil, fmt.Errorf("protocol not supported: %s", u.Scheme) 157 | } 158 | } 159 | 160 | const ( 161 | headerConnection = "Connection" 162 | headerUpgrade = "Upgrade" 163 | ) 164 | 165 | // WithUpgrade is a RequestOpt that sets the request to upgrade to the specified protocol. 166 | func WithUpgrade(proto string) RequestOpt { 167 | return func(req *http.Request) error { 168 | req.Header.Set(headerConnection, headerUpgrade) 169 | req.Header.Set(headerUpgrade, proto) 170 | return nil 171 | } 172 | } 173 | 174 | // WithAddHeaders is a RequestOpt that adds the specified headers to the request. 175 | // If the header already exists, it will be appended to. 176 | func WithAddHeaders(headers map[string][]string) RequestOpt { 177 | return func(req *http.Request) error { 178 | for k, v := range headers { 179 | for _, vv := range v { 180 | req.Header.Add(k, vv) 181 | } 182 | } 183 | return nil 184 | } 185 | } 186 | 187 | // go1.20.6 introduced a breaking change which makes paths an invalid value for a host header 188 | // This is problematic for us because we use the path as the URI for the request. 189 | // If req.Host is not set OR is the same as the socket path (basically unmodified by something else) then we can rewrite it. 190 | // If its anything else then this was changed by something else and we should not touch it. 191 | func go120Dot6HostTransform(sock string) func(req *http.Request) { 192 | return func(req *http.Request) { 193 | if req.Host == "" || req.Host == sock { 194 | req.Host = strings.Replace(sock, "/", "_", -1) 195 | } 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /transport/transport_unix.go: -------------------------------------------------------------------------------- 1 | // +build !windows 2 | 3 | package transport 4 | 5 | func DefaultTransport() (*Transport, error) { 6 | return UnixSocketTransport("/var/run/docker.sock") 7 | } 8 | 9 | -------------------------------------------------------------------------------- /transport/transport_windows.go: -------------------------------------------------------------------------------- 1 | // +build windows 2 | 3 | package transport 4 | 5 | func DefaultTransport() (*Transport, error) { 6 | return NpipeTransport("//./pipe/docker_engine") 7 | } 8 | -------------------------------------------------------------------------------- /transport/unix.go: -------------------------------------------------------------------------------- 1 | package transport 2 | 3 | import ( 4 | "context" 5 | "net" 6 | "net/http" 7 | ) 8 | 9 | func DefaultUnixTransport() (*Transport, error) { 10 | return UnixSocketTransport("/var/run/docker.sock") 11 | } 12 | 13 | // UnixSocketTransport creates a Transport that works for unix sockets. 14 | // 15 | // Note: This will attempt to use the TLSConfig if it is set on the connection options 16 | // If you do not want to use TLS, do not set it on the connection options. 17 | func UnixSocketTransport(sock string, opts ...ConnectionOption) (*Transport, error) { 18 | var cfg ConnectionConfig 19 | for _, o := range opts { 20 | if err := o(&cfg); err != nil { 21 | return nil, err 22 | } 23 | } 24 | 25 | t := &http.Transport{ 26 | DisableCompression: true, 27 | DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { 28 | return new(net.Dialer).DialContext(ctx, "unix", sock) 29 | }, 30 | TLSClientConfig: cfg.TLSConfig, 31 | } 32 | 33 | scheme := "http" 34 | if cfg.TLSConfig != nil { 35 | scheme = "https" 36 | } 37 | 38 | dial := func(ctx context.Context) (net.Conn, error) { 39 | return t.DialContext(ctx, "", "") 40 | } 41 | 42 | return &Transport{ 43 | host: sock, 44 | scheme: scheme, 45 | c: &http.Client{ 46 | Transport: t, 47 | }, 48 | dial: dial, 49 | transform: go120Dot6HostTransform(sock), 50 | }, nil 51 | } 52 | -------------------------------------------------------------------------------- /transport/unix_test.go: -------------------------------------------------------------------------------- 1 | // +build unix 2 | 3 | package transport 4 | 5 | import ( 6 | "context" 7 | "io" 8 | "io/ioutil" 9 | "net" 10 | "net/http" 11 | "os" 12 | "path/filepath" 13 | "testing" 14 | 15 | "gotest.tools/v3/assert" 16 | ) 17 | 18 | func TestUnixTransport(t *testing.T) { 19 | dir, err := ioutil.TempDir("", t.Name()) 20 | assert.NilError(t, err) 21 | defer os.RemoveAll(dir) 22 | 23 | sockPath := filepath.Join(dir, "test.sock") 24 | l, err := net.Listen("unix", sockPath) 25 | assert.NilError(t, err) 26 | defer l.Close() 27 | 28 | go http.Serve(l, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 29 | w.Write([]byte("hello " + req.URL.Path)) 30 | })) 31 | 32 | ctx := context.Background() 33 | 34 | tr, err := UnixSocketTransport(sockPath) 35 | assert.NilError(t, err) 36 | 37 | resp, err := tr.Do(ctx, "GET", "/foo") 38 | assert.NilError(t, err) 39 | defer resp.Body.Close() 40 | 41 | data := "hello /foo" 42 | buf := make([]byte, len(data)) 43 | _, err = io.ReadFull(resp.Body, buf) 44 | assert.NilError(t, err) 45 | assert.Equal(t, string(buf), data) 46 | } 47 | -------------------------------------------------------------------------------- /version/compare.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | ) 7 | 8 | // compare compares two version strings 9 | // returns -1 if v1 < v2, 1 if v1 > v2, 0 otherwise. 10 | func compare(v1, v2 string) int { 11 | if v1 == v2 { 12 | return 0 13 | } 14 | v1 = strings.TrimPrefix(v1, "v") 15 | v2 = strings.TrimPrefix(v2, "v") 16 | var ( 17 | currTab = strings.Split(v1, ".") 18 | otherTab = strings.Split(v2, ".") 19 | ) 20 | 21 | max := len(currTab) 22 | if len(otherTab) > max { 23 | max = len(otherTab) 24 | } 25 | for i := 0; i < max; i++ { 26 | var currInt, otherInt int 27 | 28 | if len(currTab) > i { 29 | currInt, _ = strconv.Atoi(currTab[i]) 30 | } 31 | if len(otherTab) > i { 32 | otherInt, _ = strconv.Atoi(otherTab[i]) 33 | } 34 | if currInt > otherInt { 35 | return 1 36 | } 37 | if otherInt > currInt { 38 | return -1 39 | } 40 | } 41 | return 0 42 | } 43 | 44 | // LessThan checks if a version is less than another 45 | func LessThan(v, other string) bool { 46 | if v == "" { 47 | return false 48 | } 49 | return compare(v, other) == -1 50 | } 51 | -------------------------------------------------------------------------------- /version/context.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "path" 7 | ) 8 | 9 | type apiVersion struct{} 10 | 11 | // WithAPIVersion stores the API version to make a request with in the provided context 12 | func WithAPIVersion(ctx context.Context, version string) context.Context { 13 | return context.WithValue(ctx, apiVersion{}, version) 14 | } 15 | 16 | // APIVersion gets the API version from the passed in context 17 | // If no version is set then an empty string will be returned. 18 | func APIVersion(ctx context.Context) string { 19 | v := ctx.Value(apiVersion{}) 20 | if v == nil { 21 | return "" 22 | } 23 | return v.(string) 24 | } 25 | 26 | // Join adds the API version stored in the context to the provided uri 27 | func Join(ctx context.Context, uri string) string { 28 | v := APIVersion(ctx) 29 | if len(v) > 0 && v[0] != 'v' { 30 | v = "v" + v 31 | } 32 | return path.Join("/", v, uri) 33 | } 34 | 35 | // FromEnv sets the API version to use from the DOCKER_API_VERSION environment variable 36 | // This is like how the Docker CLI sets a specific API version. 37 | func FromEnv(ctx context.Context) context.Context { 38 | return WithAPIVersion(ctx, os.Getenv("DOCKER_API_VERSION")) 39 | } 40 | 41 | const ( 42 | maxAPIVersion = "v1.41" 43 | minAPIVersion = "v1.12" 44 | ) 45 | 46 | // Negotiate looks at the version currently in ctx and the verion of the server. 47 | // It returns a context with the best api version to use. 48 | func Negotiate(ctx context.Context, srv string) context.Context { 49 | if srv == "" { 50 | srv = minAPIVersion 51 | } 52 | 53 | v := APIVersion(ctx) 54 | if v == "" { 55 | v = maxAPIVersion 56 | } 57 | 58 | if LessThan(srv, v) { 59 | return WithAPIVersion(ctx, srv) 60 | } 61 | return WithAPIVersion(ctx, v) 62 | } 63 | --------------------------------------------------------------------------------