├── .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 |
--------------------------------------------------------------------------------