101 lines
3.3 KiB
Go
101 lines
3.3 KiB
Go
package libstack
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"maps"
|
||
"sync"
|
||
|
||
"github.com/portainer/portainer/api/logs"
|
||
|
||
"github.com/docker/cli/cli/command"
|
||
configtypes "github.com/docker/cli/cli/config/types"
|
||
"github.com/docker/cli/cli/flags"
|
||
"github.com/docker/docker/registry"
|
||
"github.com/rs/zerolog/log"
|
||
)
|
||
|
||
// DockerCliOptions holds the settings required to initialise a DockerCli.
|
||
type DockerCliOptions struct {
|
||
Host string
|
||
Registries []configtypes.AuthConfig
|
||
// Headers are injected as custom HTTP headers on every request the client makes.
|
||
Headers map[string]string
|
||
}
|
||
|
||
// mu serialises calls to cli.Initialize across all deployer types (compose and
|
||
// swarm) to prevent concurrent initialisation of the Docker client config.
|
||
var mu sync.Mutex
|
||
|
||
// WithCli creates and initialises a DockerCli, injects registry credentials,
|
||
// and calls cliFn with the ready client. The client is closed after cliFn returns.
|
||
func WithCli(
|
||
ctx context.Context, //nolint:staticcheck
|
||
options DockerCliOptions,
|
||
cliFn func(context.Context, *command.DockerCli) error,
|
||
) error {
|
||
ctx = context.Background() //nolint:staticcheck
|
||
|
||
cli, err := command.NewDockerCli(command.WithCombinedStreams(log.Logger))
|
||
if err != nil {
|
||
return fmt.Errorf("unable to create a Docker client: %w", err)
|
||
}
|
||
|
||
opts := flags.NewClientOptions()
|
||
if options.Host != "" {
|
||
opts.Hosts = []string{options.Host}
|
||
}
|
||
|
||
mu.Lock()
|
||
if err := cli.Initialize(opts); err != nil {
|
||
mu.Unlock()
|
||
return fmt.Errorf("unable to initialize the Docker client: %w", err)
|
||
}
|
||
mu.Unlock()
|
||
|
||
// Inject custom headers before the first Client() call, which is where the
|
||
// Docker client is lazily created and reads ConfigFile().HTTPHeaders.
|
||
if len(options.Headers) > 0 {
|
||
cfg := cli.ConfigFile()
|
||
if cfg.HTTPHeaders == nil {
|
||
cfg.HTTPHeaders = make(map[string]string)
|
||
}
|
||
maps.Copy(cfg.HTTPHeaders, options.Headers)
|
||
}
|
||
|
||
defer logs.CloseAndLogErr(cli.Client())
|
||
|
||
for _, r := range options.Registries {
|
||
if r.ServerAddress == "" || r.ServerAddress == registry.DefaultNamespace {
|
||
r.ServerAddress = registry.IndexServer
|
||
}
|
||
|
||
cli.ConfigFile().AuthConfigs[r.ServerAddress] = r
|
||
}
|
||
|
||
// Docker resolves credentials in the following priority:
|
||
// 1. credHelpers – per-registry credential helpers
|
||
// 2. credsStore – global credential store used for all registries
|
||
// 3. auths – inline credentials defined in config.json
|
||
//
|
||
// Many Docker Desktop users (Windows/macOS) have a global credsStore configured
|
||
// by default (e.g. "desktop.exe" on Windows or "osxkeychain" on macOS). These
|
||
// global stores often do not include credentials for the custom registries
|
||
// defined in Portainer stacks, leading to authentication failures.
|
||
//
|
||
// To avoid this, when inline credentials are provided for one or more registries,
|
||
// we intentionally clear the global credsStore. This ensures Docker uses the
|
||
// credentials configured in Portainer instead of falling back to an empty global
|
||
// store.
|
||
//
|
||
// If no inline credentials are configured in Portainer, we keep the credsStore
|
||
// so Docker can still use it as a fallback.
|
||
// credHelpers are not affected as they are external services managed by the user.
|
||
// @ref: https://linear.app/portainer/issue/BE-12237
|
||
if len(options.Registries) > 0 {
|
||
cli.ConfigFile().CredentialsStore = ""
|
||
}
|
||
|
||
return cliFn(ctx, cli)
|
||
}
|