mirror of
https://github.com/moby/moby.git
synced 2026-01-11 10:41:43 +00:00
Use a more idiomatic name so that it can be used as `client.New()`. We should look if we want `New()` to have different / updated defaults i.e., enable `WithEnv` as default, and have an opt-out and have API- version negotiation enabled by default (with an opt-out option). Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
411 lines
14 KiB
Go
411 lines
14 KiB
Go
/*
|
|
Package client is a Go client for the Docker Engine API.
|
|
|
|
For more information about the Engine API, see the documentation:
|
|
https://docs.docker.com/reference/api/engine/
|
|
|
|
# Usage
|
|
|
|
You use the library by constructing a client object using [New]
|
|
and calling methods on it. The client can be configured from environment
|
|
variables by passing the [FromEnv] option, and the [WithAPIVersionNegotiation]
|
|
option to allow downgrading the API version used when connecting with an older
|
|
daemon version. Other options cen be configured manually by passing any of
|
|
the available [Opt] options.
|
|
|
|
For example, to list running containers (the equivalent of "docker ps"):
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
|
|
"github.com/moby/moby/client"
|
|
)
|
|
|
|
func main() {
|
|
// Create a new client that handles common environment variables
|
|
// for configuration (DOCKER_HOST, DOCKER_API_VERSION), and does
|
|
// API-version negotiation to allow downgrading the API version
|
|
// when connecting with an older daemon version.
|
|
apiClient, err := client.New(client.FromEnv, client.WithAPIVersionNegotiation())
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
// List all containers (both stopped and running).
|
|
result, err := apiClient.ContainerList(context.Background(), client.ContainerListOptions{
|
|
All: true,
|
|
})
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
// Print each container's ID, status and the image it was created from.
|
|
fmt.Printf("%s %-22s %s\n", "ID", "STATUS", "IMAGE")
|
|
for _, ctr := range result.Items {
|
|
fmt.Printf("%s %-22s %s\n", ctr.ID, ctr.Status, ctr.Image)
|
|
}
|
|
}
|
|
*/
|
|
package client
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"path"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
cerrdefs "github.com/containerd/errdefs"
|
|
"github.com/docker/go-connections/sockets"
|
|
"github.com/moby/moby/client/pkg/versions"
|
|
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
|
)
|
|
|
|
// DummyHost is a hostname used for local communication.
|
|
//
|
|
// It acts as a valid formatted hostname for local connections (such as "unix://"
|
|
// or "npipe://") which do not require a hostname. It should never be resolved,
|
|
// but uses the special-purpose ".localhost" TLD (as defined in [RFC 2606, Section 2]
|
|
// and [RFC 6761, Section 6.3]).
|
|
//
|
|
// [RFC 7230, Section 5.4] defines that an empty header must be used for such
|
|
// cases:
|
|
//
|
|
// If the authority component is missing or undefined for the target URI,
|
|
// then a client MUST send a Host header field with an empty field-value.
|
|
//
|
|
// However, [Go stdlib] enforces the semantics of HTTP(S) over TCP, does not
|
|
// allow an empty header to be used, and requires req.URL.Scheme to be either
|
|
// "http" or "https".
|
|
//
|
|
// For further details, refer to:
|
|
//
|
|
// - https://github.com/docker/engine-api/issues/189
|
|
// - https://github.com/golang/go/issues/13624
|
|
// - https://github.com/golang/go/issues/61076
|
|
// - https://github.com/moby/moby/issues/45935
|
|
//
|
|
// [RFC 2606, Section 2]: https://www.rfc-editor.org/rfc/rfc2606.html#section-2
|
|
// [RFC 6761, Section 6.3]: https://www.rfc-editor.org/rfc/rfc6761#section-6.3
|
|
// [RFC 7230, Section 5.4]: https://datatracker.ietf.org/doc/html/rfc7230#section-5.4
|
|
// [Go stdlib]: https://github.com/golang/go/blob/6244b1946bc2101b01955468f1be502dbadd6807/src/net/http/transport.go#L558-L569
|
|
const DummyHost = "api.moby.localhost"
|
|
|
|
// MaxAPIVersion is the highest REST API version supported by the client.
|
|
// If API-version negotiation is enabled (see [WithAPIVersionNegotiation],
|
|
// [Client.NegotiateAPIVersion]), the client may downgrade its API version.
|
|
// Similarly, the [WithVersion] and [WithVersionFromEnv] allow overriding
|
|
// the version.
|
|
//
|
|
// This version may be lower than the version of the api library module used.
|
|
const MaxAPIVersion = "1.52"
|
|
|
|
// fallbackAPIVersion is the version to fall back to if API-version negotiation
|
|
// fails. API versions below this version are not supported by the client,
|
|
// and not considered when negotiating.
|
|
const fallbackAPIVersion = "1.44"
|
|
|
|
// Ensure that Client always implements APIClient.
|
|
var _ APIClient = &Client{}
|
|
|
|
// Client is the API client that performs all operations
|
|
// against a docker server.
|
|
type Client struct {
|
|
clientConfig
|
|
|
|
// negotiated indicates that API version negotiation took place
|
|
negotiated atomic.Bool
|
|
|
|
// negotiateLock is used to single-flight the version negotiation process
|
|
negotiateLock sync.Mutex
|
|
|
|
// When the client transport is an *http.Transport (default) we need to do some extra things (like closing idle connections).
|
|
// Store the original transport as the http.Client transport will be wrapped with tracing libs.
|
|
baseTransport *http.Transport
|
|
}
|
|
|
|
// ErrRedirect is the error returned by checkRedirect when the request is non-GET.
|
|
var ErrRedirect = errors.New("unexpected redirect in response")
|
|
|
|
// CheckRedirect specifies the policy for dealing with redirect responses. It
|
|
// can be set on [http.Client.CheckRedirect] to prevent HTTP redirects for
|
|
// non-GET requests. It returns an [ErrRedirect] for non-GET request, otherwise
|
|
// returns a [http.ErrUseLastResponse], which is special-cased by http.Client
|
|
// to use the last response.
|
|
//
|
|
// Go 1.8 changed behavior for HTTP redirects (specifically 301, 307, and 308)
|
|
// in the client. The client (and by extension API client) can be made to send
|
|
// a request like "POST /containers//start" where what would normally be in the
|
|
// name section of the URL is empty. This triggers an HTTP 301 from the daemon.
|
|
//
|
|
// In go 1.8 this 301 is converted to a GET request, and ends up getting
|
|
// a 404 from the daemon. This behavior change manifests in the client in that
|
|
// before, the 301 was not followed and the client did not generate an error,
|
|
// but now results in a message like "Error response from daemon: page not found".
|
|
func CheckRedirect(_ *http.Request, via []*http.Request) error {
|
|
if via[0].Method == http.MethodGet {
|
|
return http.ErrUseLastResponse
|
|
}
|
|
return ErrRedirect
|
|
}
|
|
|
|
// NewClientWithOpts initializes a new API client.
|
|
//
|
|
// Deprecated: use New. This function will be removed in the next release.
|
|
func NewClientWithOpts(ops ...Opt) (*Client, error) {
|
|
return New(ops...)
|
|
}
|
|
|
|
// New initializes a new API client with a default HTTPClient, and
|
|
// default API host and version. It also initializes the custom HTTP headers to
|
|
// add to each request.
|
|
//
|
|
// It takes an optional list of [Opt] functional arguments, which are applied in
|
|
// the order they're provided, which allows modifying the defaults when creating
|
|
// the client. For example, the following initializes a client that configures
|
|
// itself with values from environment variables ([FromEnv]), and has automatic
|
|
// API version negotiation enabled ([WithAPIVersionNegotiation]).
|
|
//
|
|
// cli, err := client.New(
|
|
// client.FromEnv,
|
|
// client.WithAPIVersionNegotiation(),
|
|
// )
|
|
func New(ops ...Opt) (*Client, error) {
|
|
hostURL, err := ParseHostURL(DefaultDockerHost)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
client, err := defaultHTTPClient(hostURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
c := &Client{
|
|
clientConfig: clientConfig{
|
|
host: DefaultDockerHost,
|
|
version: MaxAPIVersion,
|
|
client: client,
|
|
proto: hostURL.Scheme,
|
|
addr: hostURL.Host,
|
|
traceOpts: []otelhttp.Option{
|
|
otelhttp.WithSpanNameFormatter(func(_ string, req *http.Request) string {
|
|
return req.Method + " " + req.URL.Path
|
|
}),
|
|
},
|
|
},
|
|
}
|
|
cfg := &c.clientConfig
|
|
|
|
for _, op := range ops {
|
|
if err := op(cfg); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if tr, ok := c.client.Transport.(*http.Transport); ok {
|
|
// Store the base transport before we wrap it in tracing libs below
|
|
// This is used, as an example, to close idle connections when the client is closed
|
|
c.baseTransport = tr
|
|
}
|
|
|
|
if c.scheme == "" {
|
|
// TODO(stevvooe): This isn't really the right way to write clients in Go.
|
|
// `NewClient` should probably only take an `*http.Client` and work from there.
|
|
// Unfortunately, the model of having a host-ish/url-thingy as the connection
|
|
// string has us confusing protocol and transport layers. We continue doing
|
|
// this to avoid breaking existing clients but this should be addressed.
|
|
if c.tlsConfig() != nil {
|
|
c.scheme = "https"
|
|
} else {
|
|
c.scheme = "http"
|
|
}
|
|
}
|
|
|
|
c.client.Transport = otelhttp.NewTransport(c.client.Transport, c.traceOpts...)
|
|
|
|
return c, nil
|
|
}
|
|
|
|
func (cli *Client) tlsConfig() *tls.Config {
|
|
if cli.baseTransport == nil {
|
|
return nil
|
|
}
|
|
return cli.baseTransport.TLSClientConfig
|
|
}
|
|
|
|
func defaultHTTPClient(hostURL *url.URL) (*http.Client, error) {
|
|
transport := &http.Transport{}
|
|
// Necessary to prevent long-lived processes using the
|
|
// client from leaking connections due to idle connections
|
|
// not being released.
|
|
// TODO: see if we can also address this from the server side,
|
|
// or in go-connections.
|
|
// see: https://github.com/moby/moby/issues/45539
|
|
transport.MaxIdleConns = 6
|
|
transport.IdleConnTimeout = 30 * time.Second
|
|
err := sockets.ConfigureTransport(transport, hostURL.Scheme, hostURL.Host)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &http.Client{
|
|
Transport: transport,
|
|
CheckRedirect: CheckRedirect,
|
|
}, nil
|
|
}
|
|
|
|
// Close the transport used by the client
|
|
func (cli *Client) Close() error {
|
|
if cli.baseTransport != nil {
|
|
cli.baseTransport.CloseIdleConnections()
|
|
return nil
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// checkVersion manually triggers API version negotiation (if configured).
|
|
// This allows for version-dependent code to use the same version as will
|
|
// be negotiated when making the actual requests, and for which cases
|
|
// we cannot do the negotiation lazily.
|
|
func (cli *Client) checkVersion(ctx context.Context) error {
|
|
if cli.manualOverride || !cli.negotiateVersion || cli.negotiated.Load() {
|
|
return nil
|
|
}
|
|
_, err := cli.Ping(ctx, PingOptions{
|
|
NegotiateAPIVersion: true,
|
|
})
|
|
return err
|
|
}
|
|
|
|
// getAPIPath returns the versioned request path to call the API.
|
|
// It appends the query parameters to the path if they are not empty.
|
|
func (cli *Client) getAPIPath(ctx context.Context, p string, query url.Values) string {
|
|
var apiPath string
|
|
_ = cli.checkVersion(ctx)
|
|
if cli.version != "" {
|
|
apiPath = path.Join(cli.basePath, "/v"+strings.TrimPrefix(cli.version, "v"), p)
|
|
} else {
|
|
apiPath = path.Join(cli.basePath, p)
|
|
}
|
|
return (&url.URL{Path: apiPath, RawQuery: query.Encode()}).String()
|
|
}
|
|
|
|
// ClientVersion returns the API version used by this client.
|
|
func (cli *Client) ClientVersion() string {
|
|
return cli.version
|
|
}
|
|
|
|
// negotiateAPIVersion updates the version to match the API version from
|
|
// the ping response. It falls back to the lowest version supported if the
|
|
// API version is empty, or returns an error if the API version is lower than
|
|
// the lowest supported API version, in which case the version is not modified.
|
|
func (cli *Client) negotiateAPIVersion(pingVersion string) error {
|
|
pingVersion = strings.TrimPrefix(pingVersion, "v")
|
|
if pingVersion == "" {
|
|
// TODO(thaJeztah): consider returning an error on empty value or not falling back; see https://github.com/moby/moby/pull/51119#discussion_r2413148487
|
|
pingVersion = fallbackAPIVersion
|
|
} else if versions.LessThan(pingVersion, fallbackAPIVersion) {
|
|
return cerrdefs.ErrInvalidArgument.WithMessage(fmt.Sprintf("API version %s is not supported by this client: the minimum supported API version is %s", pingVersion, fallbackAPIVersion))
|
|
}
|
|
|
|
// if the client is not initialized with a version, start with the latest supported version
|
|
if cli.version == "" {
|
|
cli.version = MaxAPIVersion
|
|
}
|
|
|
|
// if server version is lower than the client version, downgrade
|
|
if versions.LessThan(pingVersion, cli.version) {
|
|
cli.version = pingVersion
|
|
}
|
|
|
|
// Store the results, so that automatic API version negotiation (if enabled)
|
|
// won't be performed on the next request.
|
|
if cli.negotiateVersion {
|
|
cli.negotiated.Store(true)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DaemonHost returns the host address used by the client
|
|
func (cli *Client) DaemonHost() string {
|
|
return cli.host
|
|
}
|
|
|
|
// ParseHostURL parses a url string, validates the string is a host url, and
|
|
// returns the parsed URL
|
|
func ParseHostURL(host string) (*url.URL, error) {
|
|
proto, addr, ok := strings.Cut(host, "://")
|
|
if !ok || addr == "" {
|
|
return nil, fmt.Errorf("unable to parse docker host `%s`", host)
|
|
}
|
|
|
|
var basePath string
|
|
if proto == "tcp" {
|
|
parsed, err := url.Parse("tcp://" + addr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
addr = parsed.Host
|
|
basePath = parsed.Path
|
|
}
|
|
return &url.URL{
|
|
Scheme: proto,
|
|
Host: addr,
|
|
Path: basePath,
|
|
}, nil
|
|
}
|
|
|
|
func (cli *Client) dialerFromTransport() func(context.Context, string, string) (net.Conn, error) {
|
|
if cli.baseTransport == nil || cli.baseTransport.DialContext == nil {
|
|
return nil
|
|
}
|
|
|
|
if cli.baseTransport.TLSClientConfig != nil {
|
|
// When using a tls config we don't use the configured dialer but instead a fallback dialer...
|
|
// Note: It seems like this should use the normal dialer and wrap the returned net.Conn in a tls.Conn
|
|
// I honestly don't know why it doesn't do that, but it doesn't and such a change is entirely unrelated to the change in this commit.
|
|
return nil
|
|
}
|
|
return cli.baseTransport.DialContext
|
|
}
|
|
|
|
// Dialer returns a dialer for a raw stream connection, with an HTTP/1.1 header,
|
|
// that can be used for proxying the daemon connection. It is used by
|
|
// ["docker dial-stdio"].
|
|
//
|
|
// ["docker dial-stdio"]: https://github.com/docker/cli/pull/1014
|
|
func (cli *Client) Dialer() func(context.Context) (net.Conn, error) {
|
|
return cli.dialer()
|
|
}
|
|
|
|
func (cli *Client) dialer() func(context.Context) (net.Conn, error) {
|
|
return func(ctx context.Context) (net.Conn, error) {
|
|
if dialFn := cli.dialerFromTransport(); dialFn != nil {
|
|
return dialFn(ctx, cli.proto, cli.addr)
|
|
}
|
|
switch cli.proto {
|
|
case "unix":
|
|
return net.Dial(cli.proto, cli.addr)
|
|
case "npipe":
|
|
ctx, cancel := context.WithTimeout(ctx, 32*time.Second)
|
|
defer cancel()
|
|
return dialPipeContext(ctx, cli.addr)
|
|
default:
|
|
if tlsConfig := cli.tlsConfig(); tlsConfig != nil {
|
|
return tls.Dial(cli.proto, cli.addr, tlsConfig)
|
|
}
|
|
return net.Dial(cli.proto, cli.addr)
|
|
}
|
|
}
|
|
}
|