diff --git a/docker/auth.go b/docker/auth.go index 1d336794..bc949dc3 100644 --- a/docker/auth.go +++ b/docker/auth.go @@ -9,16 +9,17 @@ import ( "encoding/base64" "encoding/json" "errors" - "fmt" "io" "io/ioutil" + "net/http" "os" + "os/exec" "path" "strings" ) // ErrCannotParseDockercfg is the error returned by NewAuthConfigurations when the dockercfg cannot be parsed. -var ErrCannotParseDockercfg = errors.New("Failed to read authentication from dockercfg") +var ErrCannotParseDockercfg = errors.New("failed to read authentication from dockercfg") // AuthConfiguration represents authentication options to use in the PushImage // method. It represents the authentication in the Docker index server. @@ -27,7 +28,22 @@ type AuthConfiguration struct { Password string `json:"password,omitempty"` Email string `json:"email,omitempty"` ServerAddress string `json:"serveraddress,omitempty"` + + // IdentityToken can be supplied with the identitytoken response of the AuthCheck call + // see https://pkg.go.dev/github.com/docker/docker/api/types?tab=doc#AuthConfig + // It can be used in place of password not in conjunction with it IdentityToken string `json:"identitytoken,omitempty"` + + // RegistryToken can be supplied with the registrytoken + RegistryToken string `json:"registrytoken,omitempty"` +} + +func (c AuthConfiguration) isEmpty() bool { + return c == AuthConfiguration{} +} + +func (c AuthConfiguration) headerKey() string { + return "X-Registry-Auth" } // AuthConfigurations represents authentication options to use for the @@ -36,16 +52,46 @@ type AuthConfigurations struct { Configs map[string]AuthConfiguration `json:"configs"` } +func (c AuthConfigurations) isEmpty() bool { + return len(c.Configs) == 0 +} + +func (AuthConfigurations) headerKey() string { + return "X-Registry-Config" +} + +// merge updates the configuration. If a key is defined in both maps, the one +// in c.Configs takes precedence. +func (c *AuthConfigurations) merge(other AuthConfigurations) { + for k, v := range other.Configs { + if c.Configs == nil { + c.Configs = make(map[string]AuthConfiguration) + } + if _, ok := c.Configs[k]; !ok { + c.Configs[k] = v + } + } +} + // AuthConfigurations119 is used to serialize a set of AuthConfigurations // for Docker API >= 1.19. type AuthConfigurations119 map[string]AuthConfiguration +func (c AuthConfigurations119) isEmpty() bool { + return len(c) == 0 +} + +func (c AuthConfigurations119) headerKey() string { + return "X-Registry-Config" +} + // dockerConfig represents a registry authentation configuration from the // .dockercfg file. type dockerConfig struct { Auth string `json:"auth"` Email string `json:"email"` IdentityToken string `json:"identitytoken"` + RegistryToken string `json:"registrytoken"` } // NewAuthConfigurationsFromFile returns AuthConfigurations from a path containing JSON @@ -59,34 +105,65 @@ func NewAuthConfigurationsFromFile(path string) (*AuthConfigurations, error) { } func cfgPaths(dockerConfigEnv string, homeEnv string) []string { - var paths []string if dockerConfigEnv != "" { - paths = append(paths, path.Join(dockerConfigEnv, "config.json")) + return []string{ + path.Join(dockerConfigEnv, "plaintext-passwords.json"), + path.Join(dockerConfigEnv, "config.json"), + } } if homeEnv != "" { - paths = append(paths, path.Join(homeEnv, ".docker", "config.json")) - paths = append(paths, path.Join(homeEnv, ".dockercfg")) + return []string{ + path.Join(homeEnv, ".docker", "plaintext-passwords.json"), + path.Join(homeEnv, ".docker", "config.json"), + path.Join(homeEnv, ".dockercfg"), + } } - return paths + return nil } -// NewAuthConfigurationsFromDockerCfg returns AuthConfigurations from -// system config files. The following files are checked in the order listed: -// - $DOCKER_CONFIG/config.json if DOCKER_CONFIG set in the environment, +// NewAuthConfigurationsFromDockerCfg returns AuthConfigurations from system +// config files. The following files are checked in the order listed: +// +// If the environment variable DOCKER_CONFIG is set to a non-empty string: +// +// - $DOCKER_CONFIG/plaintext-passwords.json +// - $DOCKER_CONFIG/config.json +// +// Otherwise, it looks for files in the $HOME directory and the legacy +// location: +// +// - $HOME/.docker/plaintext-passwords.json // - $HOME/.docker/config.json // - $HOME/.dockercfg func NewAuthConfigurationsFromDockerCfg() (*AuthConfigurations, error) { - err := fmt.Errorf("No docker configuration found") - var auths *AuthConfigurations - pathsToTry := cfgPaths(os.Getenv("DOCKER_CONFIG"), os.Getenv("HOME")) + if len(pathsToTry) < 1 { + return nil, errors.New("no docker configuration found") + } + return newAuthConfigurationsFromDockerCfg(pathsToTry) +} + +func newAuthConfigurationsFromDockerCfg(pathsToTry []string) (*AuthConfigurations, error) { + var result *AuthConfigurations + var auths *AuthConfigurations + var err error for _, path := range pathsToTry { auths, err = NewAuthConfigurationsFromFile(path) - if err == nil { - return auths, nil + if err != nil { + continue } + + if result == nil { + result = auths + } else { + result.merge(*auths) + } + } + + if result != nil { + return result, nil } - return auths, err + return result, err } // NewAuthConfigurations returns AuthConfigurations from a JSON encoded string in the @@ -130,29 +207,47 @@ func authConfigs(confs map[string]dockerConfig) (*AuthConfigurations, error) { c := &AuthConfigurations{ Configs: make(map[string]AuthConfiguration), } + for reg, conf := range confs { - switch { - case conf.IdentityToken != "": - c.Configs[reg] = AuthConfiguration{ - IdentityToken: conf.IdentityToken, - } - case conf.Auth != "": - data, err := base64.StdEncoding.DecodeString(conf.Auth) - if err != nil { - return nil, err - } - userpass := strings.SplitN(string(data), ":", 2) - if len(userpass) != 2 { - return nil, ErrCannotParseDockercfg - } - c.Configs[reg] = AuthConfiguration{ - Email: conf.Email, - Username: userpass[0], - Password: userpass[1], - ServerAddress: reg, - } + if conf.Auth == "" { + continue + } + + // support both padded and unpadded encoding + data, err := base64.StdEncoding.DecodeString(conf.Auth) + if err != nil { + data, err = base64.StdEncoding.WithPadding(base64.NoPadding).DecodeString(conf.Auth) + } + if err != nil { + return nil, errors.New("error decoding plaintext credentials") + } + + userpass := strings.SplitN(string(data), ":", 2) + if len(userpass) != 2 { + return nil, ErrCannotParseDockercfg + } + + authConfig := AuthConfiguration{ + Email: conf.Email, + Username: userpass[0], + Password: userpass[1], + ServerAddress: reg, } + + // if identitytoken provided then zero the password and set it + if conf.IdentityToken != "" { + authConfig.Password = "" + authConfig.IdentityToken = conf.IdentityToken + } + + // if registrytoken provided then zero the password and set it + if conf.RegistryToken != "" { + authConfig.Password = "" + authConfig.RegistryToken = conf.RegistryToken + } + c.Configs[reg] = authConfig } + return c, nil } @@ -172,7 +267,7 @@ func (c *Client) AuthCheck(conf *AuthConfiguration) (AuthStatus, error) { if conf == nil { return authStatus, errors.New("conf is nil") } - resp, err := c.do("POST", "/auth", doOptions{data: conf}) + resp, err := c.do(http.MethodPost, "/auth", doOptions{data: conf}) if err != nil { return authStatus, err } @@ -189,3 +284,102 @@ func (c *Client) AuthCheck(conf *AuthConfiguration) (AuthStatus, error) { } return authStatus, nil } + +// helperCredentials represents credentials commit from an helper +type helperCredentials struct { + Username string `json:"Username,omitempty"` + Secret string `json:"Secret,omitempty"` +} + +// NewAuthConfigurationsFromCredsHelpers returns AuthConfigurations from +// installed credentials helpers +func NewAuthConfigurationsFromCredsHelpers(registry string) (*AuthConfiguration, error) { + // Load docker configuration file in order to find a possible helper provider + pathsToTry := cfgPaths(os.Getenv("DOCKER_CONFIG"), os.Getenv("HOME")) + if len(pathsToTry) < 1 { + return nil, errors.New("no docker configuration found") + } + + provider, err := getHelperProviderFromDockerCfg(pathsToTry, registry) + if err != nil { + return nil, err + } + + c, err := getCredentialsFromHelper(provider, registry) + if err != nil { + return nil, err + } + + creds := new(AuthConfiguration) + creds.Username = c.Username + creds.Password = c.Secret + return creds, nil +} + +func getHelperProviderFromDockerCfg(pathsToTry []string, registry string) (string, error) { + for _, path := range pathsToTry { + content, err := ioutil.ReadFile(path) + if err != nil { + // if we can't read the file keep going + continue + } + + provider, err := parseCredsDockerConfig(content, registry) + if err != nil { + continue + } + if provider != "" { + return provider, nil + } + } + return "", errors.New("no docker credentials provider found") +} + +func parseCredsDockerConfig(config []byte, registry string) (string, error) { + creds := struct { + CredsStore string `json:"credsStore,omitempty"` + CredHelpers map[string]string `json:"credHelpers,omitempty"` + }{} + err := json.Unmarshal(config, &creds) + if err != nil { + return "", err + } + + provider, ok := creds.CredHelpers[registry] + if ok { + return provider, nil + } + return creds.CredsStore, nil +} + +// Run and parse the found credential helper +func getCredentialsFromHelper(provider string, registry string) (*helperCredentials, error) { + helpercreds, err := runDockerCredentialsHelper(provider, registry) + if err != nil { + return nil, err + } + + c := new(helperCredentials) + err = json.Unmarshal(helpercreds, c) + if err != nil { + return nil, err + } + + return c, nil +} + +func runDockerCredentialsHelper(provider string, registry string) ([]byte, error) { + cmd := exec.Command("docker-credential-"+provider, "get") + + var stdout bytes.Buffer + + cmd.Stdin = bytes.NewBuffer([]byte(registry)) + cmd.Stdout = &stdout + + err := cmd.Run() + if err != nil { + return nil, err + } + + return stdout.Bytes(), nil +} diff --git a/docker/client.go b/docker/client.go index 488bce6f..410cef36 100644 --- a/docker/client.go +++ b/docker/client.go @@ -28,7 +28,6 @@ import ( "runtime" "strconv" "strings" - "sync" "sync/atomic" "time" @@ -56,9 +55,12 @@ var ( ErrInactivityTimeout = errors.New("inactivity time exceeded timeout") apiVersion112, _ = NewAPIVersion("1.12") + apiVersion118, _ = NewAPIVersion("1.18") apiVersion119, _ = NewAPIVersion("1.19") + apiVersion121, _ = NewAPIVersion("1.21") apiVersion124, _ = NewAPIVersion("1.24") apiVersion125, _ = NewAPIVersion("1.25") + apiVersion135, _ = NewAPIVersion("1.35") ) // APIVersion is an internal representation of a version of the Remote API. @@ -70,7 +72,7 @@ type APIVersion []int // and are integer numbers. func NewAPIVersion(input string) (APIVersion, error) { if !strings.Contains(input, ".") { - return nil, fmt.Errorf("Unable to parse version %q", input) + return nil, fmt.Errorf("unable to parse version %q", input) } raw := strings.Split(input, "-") arr := strings.Split(raw[0], ".") @@ -79,39 +81,36 @@ func NewAPIVersion(input string) (APIVersion, error) { for i, val := range arr { ret[i], err = strconv.Atoi(val) if err != nil { - return nil, fmt.Errorf("Unable to parse version %q: %q is not an integer", input, val) + return nil, fmt.Errorf("unable to parse version %q: %q is not an integer", input, val) } } return ret, nil } func (version APIVersion) String() string { - var str string + parts := make([]string, len(version)) for i, val := range version { - str += strconv.Itoa(val) - if i < len(version)-1 { - str += "." - } + parts[i] = strconv.Itoa(val) } - return str + return strings.Join(parts, ".") } -// LessThan is a function for comparing APIVersion structs +// LessThan is a function for comparing APIVersion structs. func (version APIVersion) LessThan(other APIVersion) bool { return version.compare(other) < 0 } -// LessThanOrEqualTo is a function for comparing APIVersion structs +// LessThanOrEqualTo is a function for comparing APIVersion structs. func (version APIVersion) LessThanOrEqualTo(other APIVersion) bool { return version.compare(other) <= 0 } -// GreaterThan is a function for comparing APIVersion structs +// GreaterThan is a function for comparing APIVersion structs. func (version APIVersion) GreaterThan(other APIVersion) bool { return version.compare(other) > 0 } -// GreaterThanOrEqualTo is a function for comparing APIVersion structs +// GreaterThanOrEqualTo is a function for comparing APIVersion structs. func (version APIVersion) GreaterThanOrEqualTo(other APIVersion) bool { return version.compare(other) >= 0 } @@ -145,11 +144,9 @@ type Client struct { TLSConfig *tls.Config Dialer Dialer - endpoint string - endpointURL *url.URL - eventMonitor *eventMonitoringState - - apiVersionMutex sync.RWMutex + endpoint string + endpointURL *url.URL + eventMonitor *eventMonitoringState requestedAPIVersion APIVersion serverAPIVersion APIVersion expectedAPIVersion APIVersion @@ -265,16 +262,19 @@ func NewVersionedTLSClient(endpoint string, cert, key, ca, apiVersionString stri } // NewClientFromEnv returns a Client instance ready for communication created from -// Docker's default logic for the environment variables DOCKER_HOST, DOCKER_TLS_VERIFY, and DOCKER_CERT_PATH. +// Docker's default logic for the environment variables DOCKER_HOST, DOCKER_TLS_VERIFY, DOCKER_CERT_PATH, +// and DOCKER_API_VERSION. // // See https://github.com/docker/docker/blob/1f963af697e8df3a78217f6fdbf67b8123a7db94/docker/docker.go#L68. // See https://github.com/docker/compose/blob/81707ef1ad94403789166d2fe042c8a718a4c748/compose/cli/docker_client.py#L7. +// See https://github.com/moby/moby/blob/28d7dba41d0c0d9c7f0dafcc79d3c59f2b3f5dc3/client/options.go#L51 func NewClientFromEnv() (*Client, error) { - client, err := NewVersionedClientFromEnv("") + apiVersionString := os.Getenv("DOCKER_API_VERSION") + client, err := NewVersionedClientFromEnv(apiVersionString) if err != nil { return nil, err } - client.SkipServerVersionCheck = true + client.SkipServerVersionCheck = apiVersionString == "" return client, nil } @@ -318,7 +318,7 @@ func NewVersionedTLSClientFromBytes(endpoint string, certPEMBlock, keyPEMBlock, return nil, err } } - tlsConfig := &tls.Config{} + tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12} if certPEMBlock != nil && keyPEMBlock != nil { tlsCert, err := tls.X509KeyPair(certPEMBlock, keyPEMBlock) if err != nil { @@ -331,7 +331,7 @@ func NewVersionedTLSClientFromBytes(endpoint string, certPEMBlock, keyPEMBlock, } else { caPool := x509.NewCertPool() if !caPool.AppendCertsFromPEM(caPEMCert) { - return nil, errors.New("Could not add RootCA pem") + return nil, errors.New("could not add RootCA pem") } tlsConfig.RootCAs = caPool } @@ -362,26 +362,19 @@ func (c *Client) SetTimeout(t time.Duration) { } func (c *Client) checkAPIVersion() error { - c.apiVersionMutex.Lock() - defer c.apiVersionMutex.Unlock() - - if c.serverAPIVersion == nil { - serverAPIVersionString, err := c.getServerAPIVersionString() - if err != nil { - return err - } - c.serverAPIVersion, err = NewAPIVersion(serverAPIVersionString) - if err != nil { - return err - } + serverAPIVersionString, err := c.getServerAPIVersionString() + if err != nil { + return err + } + c.serverAPIVersion, err = NewAPIVersion(serverAPIVersionString) + if err != nil { + return err } - if c.requestedAPIVersion == nil { c.expectedAPIVersion = c.serverAPIVersion } else { c.expectedAPIVersion = c.requestedAPIVersion } - return nil } @@ -396,7 +389,7 @@ func (c *Client) Endpoint() string { // // See https://goo.gl/wYfgY1 for more details. func (c *Client) Ping() error { - return c.PingWithContext(nil) + return c.PingWithContext(context.TODO()) } // PingWithContext pings the docker server @@ -405,7 +398,7 @@ func (c *Client) Ping() error { // See https://goo.gl/wYfgY1 for more details. func (c *Client) PingWithContext(ctx context.Context) error { path := "/_ping" - resp, err := c.do("GET", path, doOptions{context: ctx}) + resp, err := c.do(http.MethodGet, path, doOptions{context: ctx}) if err != nil { return err } @@ -417,13 +410,13 @@ func (c *Client) PingWithContext(ctx context.Context) error { } func (c *Client) getServerAPIVersionString() (version string, err error) { - resp, err := c.do("GET", "/version", doOptions{}) + resp, err := c.do(http.MethodGet, "/version", doOptions{}) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("Received unexpected status %d while trying to retrieve the server version", resp.StatusCode) + return "", fmt.Errorf("received unexpected status %d while trying to retrieve the server version", resp.StatusCode) } var versionResponse map[string]interface{} if err := json.NewDecoder(resp.Body).Decode(&versionResponse); err != nil { @@ -473,7 +466,7 @@ func (c *Client) do(method, path string, doOptions doOptions) (*http.Response, e req.Header.Set("User-Agent", userAgent) if doOptions.data != nil { req.Header.Set("Content-Type", "application/json") - } else if method == "POST" { + } else if method == http.MethodPost { req.Header.Set("Content-Type", "plain/text") } @@ -517,7 +510,6 @@ type streamOptions struct { context context.Context } -// if error in context, return that instead of generic http error func chooseError(ctx context.Context, err error) error { select { case <-ctx.Done(): @@ -528,7 +520,7 @@ func chooseError(ctx context.Context, err error) error { } func (c *Client) stream(method, path string, streamOptions streamOptions) error { - if (method == "POST" || method == "PUT") && streamOptions.in == nil { + if (method == http.MethodPost || method == http.MethodPut) && streamOptions.in == nil { streamOptions.in = bytes.NewReader(nil) } if path != "/version" && !c.SkipServerVersionCheck && c.expectedAPIVersion == nil { @@ -537,12 +529,34 @@ func (c *Client) stream(method, path string, streamOptions streamOptions) error return err } } - req, err := http.NewRequest(method, c.getURL(path), streamOptions.in) + return c.streamURL(method, c.getURL(path), streamOptions) +} + +func (c *Client) streamURL(method, url string, streamOptions streamOptions) error { + if (method == http.MethodPost || method == http.MethodPut) && streamOptions.in == nil { + streamOptions.in = bytes.NewReader(nil) + } + if !c.SkipServerVersionCheck && c.expectedAPIVersion == nil { + err := c.checkAPIVersion() + if err != nil { + return err + } + } + + // make a sub-context so that our active cancellation does not affect parent + ctx := streamOptions.context + if ctx == nil { + ctx = context.Background() + } + subCtx, cancelRequest := context.WithCancel(ctx) + defer cancelRequest() + + req, err := http.NewRequestWithContext(ctx, method, url, streamOptions.in) if err != nil { return err } req.Header.Set("User-Agent", userAgent) - if method == "POST" { + if method == http.MethodPost { req.Header.Set("Content-Type", "plain/text") } for key, val := range streamOptions.headers { @@ -558,14 +572,6 @@ func (c *Client) stream(method, path string, streamOptions streamOptions) error streamOptions.stderr = ioutil.Discard } - // make a sub-context so that our active cancellation does not affect parent - ctx := streamOptions.context - if ctx == nil { - ctx = context.Background() - } - subCtx, cancelRequest := context.WithCancel(ctx) - defer cancelRequest() - if protocol == unixProtocol || protocol == namedPipeProtocol { var dial net.Conn dial, err = c.Dialer.Dial(protocol, address) @@ -601,6 +607,7 @@ func (c *Client) stream(method, path string, streamOptions streamOptions) error return chooseError(subCtx, err) } + defer resp.Body.Close() } else { if resp, err = c.HTTPClient.Do(req.WithContext(subCtx)); err != nil { if strings.Contains(err.Error(), "connection refused") { @@ -608,11 +615,11 @@ func (c *Client) stream(method, path string, streamOptions streamOptions) error } return chooseError(subCtx, err) } + defer resp.Body.Close() if streamOptions.reqSent != nil { close(streamOptions.reqSent) } } - defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 400 { return newError(resp) } @@ -648,11 +655,7 @@ func handleStreamResponse(resp *http.Response, streamOptions *streamOptions) err _, err = io.Copy(streamOptions.stdout, resp.Body) return err } - if st, ok := streamOptions.stdout.(interface { - io.Writer - FD() uintptr - IsTerminal() bool - }); ok { + if st, ok := streamOptions.stdout.(stream); ok { err = jsonmessage.DisplayJSONMessagesToStream(resp.Body, st, nil) } else { err = jsonmessage.DisplayJSONMessagesStream(resp.Body, streamOptions.stdout, 0, false, nil) @@ -660,6 +663,12 @@ func handleStreamResponse(resp *http.Response, streamOptions *streamOptions) err return err } +type stream interface { + io.Writer + FD() uintptr + IsTerminal() bool +} + type proxyReader struct { io.ReadCloser calls uint64 @@ -769,6 +778,7 @@ func (c *Client) hijack(method, path string, hijackOptions hijackOptions) (Close errs := make(chan error, 1) quit := make(chan struct{}) go func() { + //lint:ignore SA1019 the alternative doesn't quite work, so keep using the deprecated thing. clientconn := httputil.NewClientConn(dial, nil) defer clientconn.Close() clientconn.Do(req) @@ -865,6 +875,29 @@ func (c *Client) getURL(path string) string { return fmt.Sprintf("%s%s", urlStr, path) } +func (c *Client) getPath(basepath string, opts interface{}) (string, error) { + queryStr, requiredAPIVersion := queryStringVersion(opts) + return c.pathVersionCheck(basepath, queryStr, requiredAPIVersion) +} + +func (c *Client) pathVersionCheck(basepath, queryStr string, requiredAPIVersion APIVersion) (string, error) { + urlStr := strings.TrimRight(c.endpointURL.String(), "/") + if c.endpointURL.Scheme == unixProtocol || c.endpointURL.Scheme == namedPipeProtocol { + urlStr = "" + } + if c.requestedAPIVersion != nil { + if c.requestedAPIVersion.GreaterThanOrEqualTo(requiredAPIVersion) { + return fmt.Sprintf("%s/v%s%s?%s", urlStr, c.requestedAPIVersion, basepath, queryStr), nil + } + return "", fmt.Errorf("API %s requires version %s, requested version %s is insufficient", + basepath, requiredAPIVersion, c.requestedAPIVersion) + } + if requiredAPIVersion != nil { + return fmt.Sprintf("%s/v%s%s?%s", urlStr, requiredAPIVersion, basepath, queryStr), nil + } + return fmt.Sprintf("%s%s?%s", urlStr, basepath, queryStr), nil +} + // getFakeNativeURL returns the URL needed to make an HTTP request over a UNIX // domain socket to the given path. func (c *Client) getFakeNativeURL(path string) string { @@ -881,24 +914,18 @@ func (c *Client) getFakeNativeURL(path string) string { return fmt.Sprintf("%s%s", urlStr, path) } -type jsonMessage struct { - Status string `json:"status,omitempty"` - Progress string `json:"progress,omitempty"` - Error string `json:"error,omitempty"` - Stream string `json:"stream,omitempty"` -} - -func queryString(opts interface{}) string { +func queryStringVersion(opts interface{}) (string, APIVersion) { if opts == nil { - return "" + return "", nil } value := reflect.ValueOf(opts) if value.Kind() == reflect.Ptr { value = value.Elem() } if value.Kind() != reflect.Struct { - return "" + return "", nil } + var apiVersion APIVersion items := url.Values(map[string][]string{}) for i := 0; i < value.NumField(); i++ { field := value.Type().Field(i) @@ -911,53 +938,80 @@ func queryString(opts interface{}) string { } else if key == "-" { continue } - addQueryStringValue(items, key, value.Field(i)) + if addQueryStringValue(items, key, value.Field(i)) { + verstr := field.Tag.Get("ver") + if verstr != "" { + ver, _ := NewAPIVersion(verstr) + if apiVersion == nil { + apiVersion = ver + } else if ver.GreaterThan(apiVersion) { + apiVersion = ver + } + } + } } - return items.Encode() + return items.Encode(), apiVersion } -func addQueryStringValue(items url.Values, key string, v reflect.Value) { +func queryString(opts interface{}) string { + s, _ := queryStringVersion(opts) + return s +} + +func addQueryStringValue(items url.Values, key string, v reflect.Value) bool { switch v.Kind() { case reflect.Bool: if v.Bool() { items.Add(key, "1") + return true } case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: if v.Int() > 0 { items.Add(key, strconv.FormatInt(v.Int(), 10)) + return true } case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: if v.Uint() > 0 { items.Add(key, strconv.FormatUint(v.Uint(), 10)) + return true } case reflect.Float32, reflect.Float64: if v.Float() > 0 { items.Add(key, strconv.FormatFloat(v.Float(), 'f', -1, 64)) + return true } case reflect.String: if v.String() != "" { items.Add(key, v.String()) + return true } case reflect.Ptr: if !v.IsNil() { if b, err := json.Marshal(v.Interface()); err == nil { items.Add(key, string(b)) + return true } } case reflect.Map: if len(v.MapKeys()) > 0 { if b, err := json.Marshal(v.Interface()); err == nil { items.Add(key, string(b)) + return true } } case reflect.Array, reflect.Slice: vLen := v.Len() + var valuesAdded int if vLen > 0 { for i := 0; i < vLen; i++ { - addQueryStringValue(items, key, v.Index(i)) + if addQueryStringValue(items, key, v.Index(i)) { + valuesAdded++ + } } } + return valuesAdded > 0 } + return false } // Error represents failures in the API. It represents a failure from the API. @@ -1004,7 +1058,8 @@ func parseEndpoint(endpoint string, tls bool) (*url.URL, error) { case "http", "https", "tcp": _, port, err := net.SplitHostPort(u.Host) if err != nil { - if e, ok := err.(*net.AddrError); ok { + var e *net.AddrError + if errors.As(err, &e) { if e.Err == "missing port in address" { return u, nil } diff --git a/docker/client_unix.go b/docker/client_unix.go index 57d7904e..6ac3ad2d 100644 --- a/docker/client_unix.go +++ b/docker/client_unix.go @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. +//go:build !windows // +build !windows package docker diff --git a/docker/client_windows.go b/docker/client_windows.go index 8e7b457d..27c14ea5 100644 --- a/docker/client_windows.go +++ b/docker/client_windows.go @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. +//go:build windows // +build windows package docker @@ -15,7 +16,9 @@ import ( "github.com/Microsoft/go-winio" ) -const namedPipeConnectTimeout = 2 * time.Second +const ( + namedPipeConnectTimeout = 2 * time.Second +) type pipeDialer struct { dialFunc func(network, addr string) (net.Conn, error) diff --git a/docker/image.go b/docker/image.go index e3a845a1..11e33b2e 100644 --- a/docker/image.go +++ b/docker/image.go @@ -5,7 +5,6 @@ package docker import ( - "bytes" "context" "encoding/base64" "encoding/json" @@ -89,7 +88,7 @@ var ( // InputStream are provided in BuildImageOptions ErrMultipleContexts = errors.New("image build may not be provided BOTH context dir and input stream") - // ErrMustSpecifyNames is the error rreturned when the Names field on + // ErrMustSpecifyNames is the error returned when the Names field on // ExportImagesOptions is nil or empty ErrMustSpecifyNames = errors.New("must specify at least one name to export") ) @@ -110,7 +109,7 @@ type ListImagesOptions struct { // See https://goo.gl/BVzauZ for more details. func (c *Client) ListImages(opts ListImagesOptions) ([]APIImages, error) { path := "/images/json?" + queryString(opts) - resp, err := c.do("GET", path, doOptions{context: opts.Context}) + resp, err := c.do(http.MethodGet, path, doOptions{context: opts.Context}) if err != nil { return nil, err } @@ -130,15 +129,17 @@ type ImageHistory struct { Created int64 `json:"Created,omitempty" yaml:"Created,omitempty" toml:"Tags,omitempty"` CreatedBy string `json:"CreatedBy,omitempty" yaml:"CreatedBy,omitempty" toml:"CreatedBy,omitempty"` Size int64 `json:"Size,omitempty" yaml:"Size,omitempty" toml:"Size,omitempty"` + Comment string `json:"Comment,omitempty" yaml:"Comment,omitempty" toml:"Comment,omitempty"` } // ImageHistory returns the history of the image by its name or ID. // // See https://goo.gl/fYtxQa for more details. func (c *Client) ImageHistory(name string) ([]ImageHistory, error) { - resp, err := c.do("GET", "/images/"+name+"/history", doOptions{}) + resp, err := c.do(http.MethodGet, "/images/"+name+"/history", doOptions{}) if err != nil { - if e, ok := err.(*Error); ok && e.Status == http.StatusNotFound { + var e *Error + if errors.As(err, &e) && e.Status == http.StatusNotFound { return nil, ErrNoSuchImage } return nil, err @@ -155,9 +156,10 @@ func (c *Client) ImageHistory(name string) ([]ImageHistory, error) { // // See https://goo.gl/Vd2Pck for more details. func (c *Client) RemoveImage(name string) error { - resp, err := c.do("DELETE", "/images/"+name, doOptions{}) + resp, err := c.do(http.MethodDelete, "/images/"+name, doOptions{}) if err != nil { - if e, ok := err.(*Error); ok && e.Status == http.StatusNotFound { + var e *Error + if errors.As(err, &e) && e.Status == http.StatusNotFound { return ErrNoSuchImage } return err @@ -182,9 +184,10 @@ type RemoveImageOptions struct { // See https://goo.gl/Vd2Pck for more details. func (c *Client) RemoveImageExtended(name string, opts RemoveImageOptions) error { uri := fmt.Sprintf("/images/%s?%s", name, queryString(&opts)) - resp, err := c.do("DELETE", uri, doOptions{context: opts.Context}) + resp, err := c.do(http.MethodDelete, uri, doOptions{context: opts.Context}) if err != nil { - if e, ok := err.(*Error); ok && e.Status == http.StatusNotFound { + var e *Error + if errors.As(err, &e) && e.Status == http.StatusNotFound { return ErrNoSuchImage } return err @@ -197,9 +200,10 @@ func (c *Client) RemoveImageExtended(name string, opts RemoveImageOptions) error // // See https://goo.gl/ncLTG8 for more details. func (c *Client) InspectImage(name string) (*Image, error) { - resp, err := c.do("GET", "/images/"+name+"/json", doOptions{}) + resp, err := c.do(http.MethodGet, "/images/"+name+"/json", doOptions{}) if err != nil { - if e, ok := err.(*Error); ok && e.Status == http.StatusNotFound { + var e *Error + if errors.As(err, &e) && e.Status == http.StatusNotFound { return nil, ErrNoSuchImage } return nil, err @@ -272,7 +276,7 @@ func (c *Client) PushImage(opts PushImageOptions, auth AuthConfiguration) error name := opts.Name opts.Name = "" path := "/images/" + name + "/push?" + queryString(&opts) - return c.stream("POST", path, streamOptions{ + return c.stream(http.MethodPost, path, streamOptions{ setRawTerminal: true, rawJSONStream: opts.RawJSONStream, headers: headers, @@ -287,6 +291,7 @@ func (c *Client) PushImage(opts PushImageOptions, auth AuthConfiguration) error // // See https://goo.gl/qkoSsn for more details. type PullImageOptions struct { + All bool Repository string `qs:"fromImage"` Tag string Platform string @@ -320,12 +325,15 @@ func (c *Client) PullImage(opts PullImageOptions, auth AuthConfiguration) error opts.Repository = parts[0] opts.Tag = parts[1] } - return c.createImage(queryString(&opts), headers, nil, opts.OutputStream, opts.RawJSONStream, opts.InactivityTimeout, opts.Context) + return c.createImage(&opts, headers, nil, opts.OutputStream, opts.RawJSONStream, opts.InactivityTimeout, opts.Context) } -func (c *Client) createImage(qs string, headers map[string]string, in io.Reader, w io.Writer, rawJSONStream bool, timeout time.Duration, context context.Context) error { - path := "/images/create?" + qs - return c.stream("POST", path, streamOptions{ +func (c *Client) createImage(opts interface{}, headers map[string]string, in io.Reader, w io.Writer, rawJSONStream bool, timeout time.Duration, context context.Context) error { + url, err := c.getPath("/images/create", opts) + if err != nil { + return err + } + return c.streamURL(http.MethodPost, url, streamOptions{ setRawTerminal: true, headers: headers, in: in, @@ -349,7 +357,7 @@ type LoadImageOptions struct { // // See https://goo.gl/rEsBV3 for more details. func (c *Client) LoadImage(opts LoadImageOptions) error { - return c.stream("POST", "/images/load", streamOptions{ + return c.stream(http.MethodPost, "/images/load", streamOptions{ setRawTerminal: true, in: opts.InputStream, stdout: opts.OutputStream, @@ -371,7 +379,7 @@ type ExportImageOptions struct { // // See https://goo.gl/AuySaA for more details. func (c *Client) ExportImage(opts ExportImageOptions) error { - return c.stream("GET", fmt.Sprintf("/images/%s/get", opts.Name), streamOptions{ + return c.stream(http.MethodGet, fmt.Sprintf("/images/%s/get", opts.Name), streamOptions{ setRawTerminal: true, stdout: opts.OutputStream, inactivityTimeout: opts.InactivityTimeout, @@ -396,7 +404,28 @@ func (c *Client) ExportImages(opts ExportImagesOptions) error { if opts.Names == nil || len(opts.Names) == 0 { return ErrMustSpecifyNames } - return c.stream("GET", "/images/get?"+queryString(&opts), streamOptions{ + // API < 1.25 allows multiple name values + // 1.25 says name must be a comma separated list + var err error + var exporturl string + if c.requestedAPIVersion.GreaterThanOrEqualTo(apiVersion125) { + str := opts.Names[0] + for _, val := range opts.Names[1:] { + str += "," + val + } + exporturl, err = c.getPath("/images/get", ExportImagesOptions{ + Names: []string{str}, + OutputStream: opts.OutputStream, + InactivityTimeout: opts.InactivityTimeout, + Context: opts.Context, + }) + } else { + exporturl, err = c.getPath("/images/get", &opts) + } + if err != nil { + return err + } + return c.streamURL(http.MethodGet, exporturl, streamOptions{ setRawTerminal: true, stdout: opts.OutputStream, inactivityTimeout: opts.InactivityTimeout, @@ -437,7 +466,7 @@ func (c *Client) ImportImage(opts ImportImageOptions) error { opts.InputStream = f opts.Source = "-" } - return c.createImage(queryString(&opts), nil, opts.InputStream, opts.OutputStream, opts.RawJSONStream, opts.InactivityTimeout, opts.Context) + return c.createImage(&opts, nil, opts.InputStream, opts.OutputStream, opts.RawJSONStream, opts.InactivityTimeout, opts.Context) } // BuildImageOptions present the set of informations available for building an @@ -446,38 +475,40 @@ func (c *Client) ImportImage(opts ImportImageOptions) error { // For more details about the Docker building process, see // https://goo.gl/4nYHwV. type BuildImageOptions struct { - Name string `qs:"t"` - Dockerfile string `qs:"dockerfile"` - NoCache bool `qs:"nocache"` - CacheFrom []string `qs:"-"` - SuppressOutput bool `qs:"q"` - Pull bool `qs:"pull"` - RmTmpContainer bool `qs:"rm"` - ForceRmTmpContainer bool `qs:"forcerm"` - RawJSONStream bool `qs:"-"` - Memory int64 `qs:"memory"` - Memswap int64 `qs:"memswap"` - CPUShares int64 `qs:"cpushares"` - CPUQuota int64 `qs:"cpuquota"` - CPUPeriod int64 `qs:"cpuperiod"` - CPUSetCPUs string `qs:"cpusetcpus"` - Labels map[string]string `qs:"labels"` - InputStream io.Reader `qs:"-"` - OutputStream io.Writer `qs:"-"` - ErrorStream io.Writer `qs:"-"` - Remote string `qs:"remote"` + Context context.Context + Name string `qs:"t"` + Dockerfile string `ver:"1.25"` + ExtraHosts string `ver:"1.28"` + CacheFrom []string `qs:"-" ver:"1.25"` + Memory int64 + Memswap int64 + ShmSize int64 + CPUShares int64 + CPUQuota int64 `ver:"1.21"` + CPUPeriod int64 `ver:"1.21"` + CPUSetCPUs string + Labels map[string]string + InputStream io.Reader `qs:"-"` + OutputStream io.Writer `qs:"-"` + Remote string Auth AuthConfiguration `qs:"-"` // for older docker X-Registry-Auth header AuthConfigs AuthConfigurations `qs:"-"` // for newer docker X-Registry-Config header ContextDir string `qs:"-"` - Ulimits []ULimit `qs:"-"` - BuildArgs []BuildArg `qs:"-"` - NetworkMode string `qs:"networkmode"` + Ulimits []ULimit `qs:"-" ver:"1.18"` + BuildArgs []BuildArg `qs:"-" ver:"1.21"` + NetworkMode string `ver:"1.25"` + Platform string `ver:"1.32"` InactivityTimeout time.Duration `qs:"-"` - CgroupParent string `qs:"cgroupparent"` - SecurityOpt []string `qs:"securityopt"` - Target string `gs:"target"` - Platform string `qs:"platform"` - Context context.Context + CgroupParent string + SecurityOpt []string + Target string + Outputs string `ver:"1.40"` + NoCache bool + SuppressOutput bool `qs:"q"` + Pull bool `ver:"1.16"` + RmTmpContainer bool `qs:"rm"` + ForceRmTmpContainer bool `qs:"forcerm" ver:"1.12"` + RawJSONStream bool `qs:"-"` } // BuildArg represents arguments that can be passed to the image when building @@ -520,13 +551,16 @@ func (c *Client) BuildImage(opts BuildImageOptions) error { return err } } - qs := queryString(&opts) + qs, ver := queryStringVersion(&opts) - if c.serverAPIVersion.GreaterThanOrEqualTo(apiVersion125) && len(opts.CacheFrom) > 0 { + if len(opts.CacheFrom) > 0 { if b, err := json.Marshal(opts.CacheFrom); err == nil { item := url.Values(map[string][]string{}) item.Add("cachefrom", string(b)) qs = fmt.Sprintf("%s&%s", qs, item.Encode()) + if ver == nil || apiVersion125.GreaterThan(ver) { + ver = apiVersion125 + } } } @@ -535,6 +569,9 @@ func (c *Client) BuildImage(opts BuildImageOptions) error { item := url.Values(map[string][]string{}) item.Add("ulimits", string(b)) qs = fmt.Sprintf("%s&%s", qs, item.Encode()) + if ver == nil || apiVersion118.GreaterThan(ver) { + ver = apiVersion118 + } } } @@ -547,22 +584,29 @@ func (c *Client) BuildImage(opts BuildImageOptions) error { item := url.Values(map[string][]string{}) item.Add("buildargs", string(b)) qs = fmt.Sprintf("%s&%s", qs, item.Encode()) + if ver == nil || apiVersion121.GreaterThan(ver) { + ver = apiVersion121 + } } } - return c.stream("POST", fmt.Sprintf("/build?%s", qs), streamOptions{ + buildURL, err := c.pathVersionCheck("/build", qs, ver) + if err != nil { + return err + } + + return c.streamURL(http.MethodPost, buildURL, streamOptions{ setRawTerminal: true, rawJSONStream: opts.RawJSONStream, headers: headers, in: opts.InputStream, stdout: opts.OutputStream, - stderr: opts.ErrorStream, inactivityTimeout: opts.InactivityTimeout, context: opts.Context, }) } -func (c *Client) versionedAuthConfigs(authConfigs AuthConfigurations) interface{} { +func (c *Client) versionedAuthConfigs(authConfigs AuthConfigurations) registryAuth { if c.serverAPIVersion == nil { c.checkAPIVersion() } @@ -589,10 +633,9 @@ func (c *Client) TagImage(name string, opts TagImageOptions) error { if name == "" { return ErrNoSuchImage } - resp, err := c.do("POST", "/images/"+name+"/tag?"+queryString(&opts), doOptions{ + resp, err := c.do(http.MethodPost, "/images/"+name+"/tag?"+queryString(&opts), doOptions{ context: opts.Context, }) - if err != nil { return err } @@ -614,24 +657,18 @@ func isURL(u string) bool { return p.Scheme == "http" || p.Scheme == "https" } -func headersWithAuth(auths ...interface{}) (map[string]string, error) { - var headers = make(map[string]string) +func headersWithAuth(auths ...registryAuth) (map[string]string, error) { + headers := make(map[string]string) for _, auth := range auths { - switch auth.(type) { - case AuthConfiguration: - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(auth); err != nil { - return nil, err - } - headers["X-Registry-Auth"] = base64.URLEncoding.EncodeToString(buf.Bytes()) - case AuthConfigurations, AuthConfigurations119: - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(auth); err != nil { - return nil, err - } - headers["X-Registry-Config"] = base64.URLEncoding.EncodeToString(buf.Bytes()) + if auth.isEmpty() { + continue + } + data, err := json.Marshal(auth) + if err != nil { + return nil, err } + headers[auth.headerKey()] = base64.URLEncoding.EncodeToString(data) } return headers, nil @@ -652,7 +689,7 @@ type APIImageSearch struct { // // See https://goo.gl/KLO9IZ for more details. func (c *Client) SearchImages(term string) ([]APIImageSearch, error) { - resp, err := c.do("GET", "/images/search?term="+term, doOptions{}) + resp, err := c.do(http.MethodGet, "/images/search?term="+term, doOptions{}) if err != nil { return nil, err } @@ -673,7 +710,7 @@ func (c *Client) SearchImagesEx(term string, auth AuthConfiguration) ([]APIImage return nil, err } - resp, err := c.do("GET", "/images/search?term="+term, doOptions{ + resp, err := c.do(http.MethodGet, "/images/search?term="+term, doOptions{ headers: headers, }) if err != nil { @@ -711,7 +748,7 @@ type PruneImagesResults struct { // See https://goo.gl/qfZlbZ for more details. func (c *Client) PruneImages(opts PruneImagesOptions) (*PruneImagesResults, error) { path := "/images/prune?" + queryString(opts) - resp, err := c.do("POST", path, doOptions{context: opts.Context}) + resp, err := c.do(http.MethodPost, path, doOptions{context: opts.Context}) if err != nil { return nil, err } diff --git a/docker/registry_auth.go b/docker/registry_auth.go new file mode 100644 index 00000000..1f60d1e8 --- /dev/null +++ b/docker/registry_auth.go @@ -0,0 +1,10 @@ +// Copyright 2013 go-dockerclient authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package docker + +type registryAuth interface { + isEmpty() bool + headerKey() string +} diff --git a/dockertest.go b/dockertest.go index b9809143..1b53473e 100644 --- a/dockertest.go +++ b/dockertest.go @@ -405,11 +405,23 @@ func (d *Pool) RunWithOptions(opts *RunOptions, hcOpts ...func(*dc.HostConfig)) _, err := d.Client.InspectImage(fmt.Sprintf("%s:%s", repository, tag)) if err != nil { + var ( + auth = opts.Auth + parts = strings.SplitN(repository, "/", 3) + empty = opts.Auth == dc.AuthConfiguration{} + ) + if empty && len(parts) == 3 { + res, err := dc.NewAuthConfigurationsFromCredsHelpers(parts[0]) + if err == nil { + auth = *res + } + } + if err := d.Client.PullImage(dc.PullImageOptions{ Repository: repository, Tag: tag, Platform: opts.Platform, - }, opts.Auth); err != nil { + }, auth); err != nil { return nil, errors.Wrap(err, "") } } @@ -515,7 +527,7 @@ func (d *Pool) RemoveContainerByName(containerName string) error { containers, err := d.Client.ListContainers(dc.ListContainersOptions{ All: true, Filters: map[string][]string{ - "name": []string{containerName}, + "name": {containerName}, }, }) if err != nil {