github.com/thajeztah/cli@v0.0.0-20240223162942-dc6bfac81a8b/cli/command/cli.go (about)

     1  // FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
     2  //go:build go1.19
     3  
     4  package command
     5  
     6  import (
     7  	"context"
     8  	"fmt"
     9  	"io"
    10  	"os"
    11  	"path/filepath"
    12  	"runtime"
    13  	"strconv"
    14  	"sync"
    15  	"time"
    16  
    17  	"github.com/docker/cli/cli/config"
    18  	"github.com/docker/cli/cli/config/configfile"
    19  	dcontext "github.com/docker/cli/cli/context"
    20  	"github.com/docker/cli/cli/context/docker"
    21  	"github.com/docker/cli/cli/context/store"
    22  	"github.com/docker/cli/cli/debug"
    23  	cliflags "github.com/docker/cli/cli/flags"
    24  	manifeststore "github.com/docker/cli/cli/manifest/store"
    25  	registryclient "github.com/docker/cli/cli/registry/client"
    26  	"github.com/docker/cli/cli/streams"
    27  	"github.com/docker/cli/cli/trust"
    28  	"github.com/docker/cli/cli/version"
    29  	dopts "github.com/docker/cli/opts"
    30  	"github.com/docker/docker/api"
    31  	"github.com/docker/docker/api/types"
    32  	"github.com/docker/docker/api/types/registry"
    33  	"github.com/docker/docker/api/types/swarm"
    34  	"github.com/docker/docker/client"
    35  	"github.com/docker/go-connections/tlsconfig"
    36  	"github.com/pkg/errors"
    37  	"github.com/spf13/cobra"
    38  	notaryclient "github.com/theupdateframework/notary/client"
    39  )
    40  
    41  const defaultInitTimeout = 2 * time.Second
    42  
    43  // Streams is an interface which exposes the standard input and output streams
    44  type Streams interface {
    45  	In() *streams.In
    46  	Out() *streams.Out
    47  	Err() io.Writer
    48  }
    49  
    50  // Cli represents the docker command line client.
    51  type Cli interface {
    52  	Client() client.APIClient
    53  	Streams
    54  	SetIn(in *streams.In)
    55  	Apply(ops ...CLIOption) error
    56  	ConfigFile() *configfile.ConfigFile
    57  	ServerInfo() ServerInfo
    58  	NotaryClient(imgRefAndAuth trust.ImageRefAndAuth, actions []string) (notaryclient.Repository, error)
    59  	DefaultVersion() string
    60  	CurrentVersion() string
    61  	ManifestStore() manifeststore.Store
    62  	RegistryClient(bool) registryclient.RegistryClient
    63  	ContentTrustEnabled() bool
    64  	BuildKitEnabled() (bool, error)
    65  	ContextStore() store.Store
    66  	CurrentContext() string
    67  	DockerEndpoint() docker.Endpoint
    68  }
    69  
    70  // DockerCli is an instance the docker command line client.
    71  // Instances of the client can be returned from NewDockerCli.
    72  type DockerCli struct {
    73  	configFile         *configfile.ConfigFile
    74  	options            *cliflags.ClientOptions
    75  	in                 *streams.In
    76  	out                *streams.Out
    77  	err                io.Writer
    78  	client             client.APIClient
    79  	serverInfo         ServerInfo
    80  	contentTrust       bool
    81  	contextStore       store.Store
    82  	currentContext     string
    83  	init               sync.Once
    84  	initErr            error
    85  	dockerEndpoint     docker.Endpoint
    86  	contextStoreConfig store.Config
    87  	initTimeout        time.Duration
    88  
    89  	// baseCtx is the base context used for internal operations. In the future
    90  	// this may be replaced by explicitly passing a context to functions that
    91  	// need it.
    92  	baseCtx context.Context
    93  }
    94  
    95  // DefaultVersion returns api.defaultVersion.
    96  func (cli *DockerCli) DefaultVersion() string {
    97  	return api.DefaultVersion
    98  }
    99  
   100  // CurrentVersion returns the API version currently negotiated, or the default
   101  // version otherwise.
   102  func (cli *DockerCli) CurrentVersion() string {
   103  	_ = cli.initialize()
   104  	if cli.client == nil {
   105  		return api.DefaultVersion
   106  	}
   107  	return cli.client.ClientVersion()
   108  }
   109  
   110  // Client returns the APIClient
   111  func (cli *DockerCli) Client() client.APIClient {
   112  	if err := cli.initialize(); err != nil {
   113  		_, _ = fmt.Fprintf(cli.Err(), "Failed to initialize: %s\n", err)
   114  		os.Exit(1)
   115  	}
   116  	return cli.client
   117  }
   118  
   119  // Out returns the writer used for stdout
   120  func (cli *DockerCli) Out() *streams.Out {
   121  	return cli.out
   122  }
   123  
   124  // Err returns the writer used for stderr
   125  func (cli *DockerCli) Err() io.Writer {
   126  	return cli.err
   127  }
   128  
   129  // SetIn sets the reader used for stdin
   130  func (cli *DockerCli) SetIn(in *streams.In) {
   131  	cli.in = in
   132  }
   133  
   134  // In returns the reader used for stdin
   135  func (cli *DockerCli) In() *streams.In {
   136  	return cli.in
   137  }
   138  
   139  // ShowHelp shows the command help.
   140  func ShowHelp(err io.Writer) func(*cobra.Command, []string) error {
   141  	return func(cmd *cobra.Command, args []string) error {
   142  		cmd.SetOut(err)
   143  		cmd.HelpFunc()(cmd, args)
   144  		return nil
   145  	}
   146  }
   147  
   148  // ConfigFile returns the ConfigFile
   149  func (cli *DockerCli) ConfigFile() *configfile.ConfigFile {
   150  	// TODO(thaJeztah): when would this happen? Is this only in tests (where cli.Initialize() is not called first?)
   151  	if cli.configFile == nil {
   152  		cli.configFile = config.LoadDefaultConfigFile(cli.err)
   153  	}
   154  	return cli.configFile
   155  }
   156  
   157  // ServerInfo returns the server version details for the host this client is
   158  // connected to
   159  func (cli *DockerCli) ServerInfo() ServerInfo {
   160  	_ = cli.initialize()
   161  	return cli.serverInfo
   162  }
   163  
   164  // ContentTrustEnabled returns whether content trust has been enabled by an
   165  // environment variable.
   166  func (cli *DockerCli) ContentTrustEnabled() bool {
   167  	return cli.contentTrust
   168  }
   169  
   170  // BuildKitEnabled returns buildkit is enabled or not.
   171  func (cli *DockerCli) BuildKitEnabled() (bool, error) {
   172  	// use DOCKER_BUILDKIT env var value if set and not empty
   173  	if v := os.Getenv("DOCKER_BUILDKIT"); v != "" {
   174  		enabled, err := strconv.ParseBool(v)
   175  		if err != nil {
   176  			return false, errors.Wrap(err, "DOCKER_BUILDKIT environment variable expects boolean value")
   177  		}
   178  		return enabled, nil
   179  	}
   180  	// if a builder alias is defined, we are using BuildKit
   181  	aliasMap := cli.ConfigFile().Aliases
   182  	if _, ok := aliasMap["builder"]; ok {
   183  		return true, nil
   184  	}
   185  	// otherwise, assume BuildKit is enabled but
   186  	// not if wcow reported from server side
   187  	return cli.ServerInfo().OSType != "windows", nil
   188  }
   189  
   190  // ManifestStore returns a store for local manifests
   191  func (cli *DockerCli) ManifestStore() manifeststore.Store {
   192  	// TODO: support override default location from config file
   193  	return manifeststore.NewStore(filepath.Join(config.Dir(), "manifests"))
   194  }
   195  
   196  // RegistryClient returns a client for communicating with a Docker distribution
   197  // registry
   198  func (cli *DockerCli) RegistryClient(allowInsecure bool) registryclient.RegistryClient {
   199  	resolver := func(ctx context.Context, index *registry.IndexInfo) registry.AuthConfig {
   200  		return ResolveAuthConfig(cli.ConfigFile(), index)
   201  	}
   202  	return registryclient.NewRegistryClient(resolver, UserAgent(), allowInsecure)
   203  }
   204  
   205  // WithInitializeClient is passed to DockerCli.Initialize by callers who wish to set a particular API Client for use by the CLI.
   206  func WithInitializeClient(makeClient func(dockerCli *DockerCli) (client.APIClient, error)) CLIOption {
   207  	return func(dockerCli *DockerCli) error {
   208  		var err error
   209  		dockerCli.client, err = makeClient(dockerCli)
   210  		return err
   211  	}
   212  }
   213  
   214  // Initialize the dockerCli runs initialization that must happen after command
   215  // line flags are parsed.
   216  func (cli *DockerCli) Initialize(opts *cliflags.ClientOptions, ops ...CLIOption) error {
   217  	for _, o := range ops {
   218  		if err := o(cli); err != nil {
   219  			return err
   220  		}
   221  	}
   222  	cliflags.SetLogLevel(opts.LogLevel)
   223  
   224  	if opts.ConfigDir != "" {
   225  		config.SetDir(opts.ConfigDir)
   226  	}
   227  
   228  	if opts.Debug {
   229  		debug.Enable()
   230  	}
   231  	if opts.Context != "" && len(opts.Hosts) > 0 {
   232  		return errors.New("conflicting options: either specify --host or --context, not both")
   233  	}
   234  
   235  	cli.options = opts
   236  	cli.configFile = config.LoadDefaultConfigFile(cli.err)
   237  	cli.currentContext = resolveContextName(cli.options, cli.configFile)
   238  	cli.contextStore = &ContextStoreWithDefault{
   239  		Store: store.New(config.ContextStoreDir(), cli.contextStoreConfig),
   240  		Resolver: func() (*DefaultContext, error) {
   241  			return ResolveDefaultContext(cli.options, cli.contextStoreConfig)
   242  		},
   243  	}
   244  	return nil
   245  }
   246  
   247  // NewAPIClientFromFlags creates a new APIClient from command line flags
   248  func NewAPIClientFromFlags(opts *cliflags.ClientOptions, configFile *configfile.ConfigFile) (client.APIClient, error) {
   249  	if opts.Context != "" && len(opts.Hosts) > 0 {
   250  		return nil, errors.New("conflicting options: either specify --host or --context, not both")
   251  	}
   252  
   253  	storeConfig := DefaultContextStoreConfig()
   254  	contextStore := &ContextStoreWithDefault{
   255  		Store: store.New(config.ContextStoreDir(), storeConfig),
   256  		Resolver: func() (*DefaultContext, error) {
   257  			return ResolveDefaultContext(opts, storeConfig)
   258  		},
   259  	}
   260  	endpoint, err := resolveDockerEndpoint(contextStore, resolveContextName(opts, configFile))
   261  	if err != nil {
   262  		return nil, errors.Wrap(err, "unable to resolve docker endpoint")
   263  	}
   264  	return newAPIClientFromEndpoint(endpoint, configFile)
   265  }
   266  
   267  func newAPIClientFromEndpoint(ep docker.Endpoint, configFile *configfile.ConfigFile) (client.APIClient, error) {
   268  	opts, err := ep.ClientOpts()
   269  	if err != nil {
   270  		return nil, err
   271  	}
   272  	if len(configFile.HTTPHeaders) > 0 {
   273  		opts = append(opts, client.WithHTTPHeaders(configFile.HTTPHeaders))
   274  	}
   275  	opts = append(opts, client.WithUserAgent(UserAgent()))
   276  	return client.NewClientWithOpts(opts...)
   277  }
   278  
   279  func resolveDockerEndpoint(s store.Reader, contextName string) (docker.Endpoint, error) {
   280  	if s == nil {
   281  		return docker.Endpoint{}, fmt.Errorf("no context store initialized")
   282  	}
   283  	ctxMeta, err := s.GetMetadata(contextName)
   284  	if err != nil {
   285  		return docker.Endpoint{}, err
   286  	}
   287  	epMeta, err := docker.EndpointFromContext(ctxMeta)
   288  	if err != nil {
   289  		return docker.Endpoint{}, err
   290  	}
   291  	return docker.WithTLSData(s, contextName, epMeta)
   292  }
   293  
   294  // Resolve the Docker endpoint for the default context (based on config, env vars and CLI flags)
   295  func resolveDefaultDockerEndpoint(opts *cliflags.ClientOptions) (docker.Endpoint, error) {
   296  	host, err := getServerHost(opts.Hosts, opts.TLSOptions)
   297  	if err != nil {
   298  		return docker.Endpoint{}, err
   299  	}
   300  
   301  	var (
   302  		skipTLSVerify bool
   303  		tlsData       *dcontext.TLSData
   304  	)
   305  
   306  	if opts.TLSOptions != nil {
   307  		skipTLSVerify = opts.TLSOptions.InsecureSkipVerify
   308  		tlsData, err = dcontext.TLSDataFromFiles(opts.TLSOptions.CAFile, opts.TLSOptions.CertFile, opts.TLSOptions.KeyFile)
   309  		if err != nil {
   310  			return docker.Endpoint{}, err
   311  		}
   312  	}
   313  
   314  	return docker.Endpoint{
   315  		EndpointMeta: docker.EndpointMeta{
   316  			Host:          host,
   317  			SkipTLSVerify: skipTLSVerify,
   318  		},
   319  		TLSData: tlsData,
   320  	}, nil
   321  }
   322  
   323  func (cli *DockerCli) getInitTimeout() time.Duration {
   324  	if cli.initTimeout != 0 {
   325  		return cli.initTimeout
   326  	}
   327  	return defaultInitTimeout
   328  }
   329  
   330  func (cli *DockerCli) initializeFromClient() {
   331  	ctx, cancel := context.WithTimeout(cli.baseCtx, cli.getInitTimeout())
   332  	defer cancel()
   333  
   334  	ping, err := cli.client.Ping(ctx)
   335  	if err != nil {
   336  		// Default to true if we fail to connect to daemon
   337  		cli.serverInfo = ServerInfo{HasExperimental: true}
   338  
   339  		if ping.APIVersion != "" {
   340  			cli.client.NegotiateAPIVersionPing(ping)
   341  		}
   342  		return
   343  	}
   344  
   345  	cli.serverInfo = ServerInfo{
   346  		HasExperimental: ping.Experimental,
   347  		OSType:          ping.OSType,
   348  		BuildkitVersion: ping.BuilderVersion,
   349  		SwarmStatus:     ping.SwarmStatus,
   350  	}
   351  	cli.client.NegotiateAPIVersionPing(ping)
   352  }
   353  
   354  // NotaryClient provides a Notary Repository to interact with signed metadata for an image
   355  func (cli *DockerCli) NotaryClient(imgRefAndAuth trust.ImageRefAndAuth, actions []string) (notaryclient.Repository, error) {
   356  	return trust.GetNotaryRepository(cli.In(), cli.Out(), UserAgent(), imgRefAndAuth.RepoInfo(), imgRefAndAuth.AuthConfig(), actions...)
   357  }
   358  
   359  // ContextStore returns the ContextStore
   360  func (cli *DockerCli) ContextStore() store.Store {
   361  	return cli.contextStore
   362  }
   363  
   364  // CurrentContext returns the current context name, based on flags,
   365  // environment variables and the cli configuration file, in the following
   366  // order of preference:
   367  //
   368  //  1. The "--context" command-line option.
   369  //  2. The "DOCKER_CONTEXT" environment variable ([EnvOverrideContext]).
   370  //  3. The current context as configured through the in "currentContext"
   371  //     field in the CLI configuration file ("~/.docker/config.json").
   372  //  4. If no context is configured, use the "default" context.
   373  //
   374  // # Fallbacks for backward-compatibility
   375  //
   376  // To preserve backward-compatibility with the "pre-contexts" behavior,
   377  // the "default" context is used if:
   378  //
   379  //   - The "--host" option is set
   380  //   - The "DOCKER_HOST" ([client.EnvOverrideHost]) environment variable is set
   381  //     to a non-empty value.
   382  //
   383  // In these cases, the default context is used, which uses the host as
   384  // specified in "DOCKER_HOST", and TLS config from flags/env vars.
   385  //
   386  // Setting both the "--context" and "--host" flags is ambiguous and results
   387  // in an error when the cli is started.
   388  //
   389  // CurrentContext does not validate if the given context exists or if it's
   390  // valid; errors may occur when trying to use it.
   391  func (cli *DockerCli) CurrentContext() string {
   392  	return cli.currentContext
   393  }
   394  
   395  // CurrentContext returns the current context name, based on flags,
   396  // environment variables and the cli configuration file. It does not
   397  // validate if the given context exists or if it's valid; errors may
   398  // occur when trying to use it.
   399  //
   400  // Refer to [DockerCli.CurrentContext] above for further details.
   401  func resolveContextName(opts *cliflags.ClientOptions, cfg *configfile.ConfigFile) string {
   402  	if opts != nil && opts.Context != "" {
   403  		return opts.Context
   404  	}
   405  	if opts != nil && len(opts.Hosts) > 0 {
   406  		return DefaultContextName
   407  	}
   408  	if os.Getenv(client.EnvOverrideHost) != "" {
   409  		return DefaultContextName
   410  	}
   411  	if ctxName := os.Getenv(EnvOverrideContext); ctxName != "" {
   412  		return ctxName
   413  	}
   414  	if cfg != nil && cfg.CurrentContext != "" {
   415  		// We don't validate if this context exists: errors may occur when trying to use it.
   416  		return cfg.CurrentContext
   417  	}
   418  	return DefaultContextName
   419  }
   420  
   421  // DockerEndpoint returns the current docker endpoint
   422  func (cli *DockerCli) DockerEndpoint() docker.Endpoint {
   423  	if err := cli.initialize(); err != nil {
   424  		// Note that we're not terminating here, as this function may be used
   425  		// in cases where we're able to continue.
   426  		_, _ = fmt.Fprintf(cli.Err(), "%v\n", cli.initErr)
   427  	}
   428  	return cli.dockerEndpoint
   429  }
   430  
   431  func (cli *DockerCli) getDockerEndPoint() (ep docker.Endpoint, err error) {
   432  	cn := cli.CurrentContext()
   433  	if cn == DefaultContextName {
   434  		return resolveDefaultDockerEndpoint(cli.options)
   435  	}
   436  	return resolveDockerEndpoint(cli.contextStore, cn)
   437  }
   438  
   439  func (cli *DockerCli) initialize() error {
   440  	cli.init.Do(func() {
   441  		cli.dockerEndpoint, cli.initErr = cli.getDockerEndPoint()
   442  		if cli.initErr != nil {
   443  			cli.initErr = errors.Wrap(cli.initErr, "unable to resolve docker endpoint")
   444  			return
   445  		}
   446  		if cli.client == nil {
   447  			if cli.client, cli.initErr = newAPIClientFromEndpoint(cli.dockerEndpoint, cli.configFile); cli.initErr != nil {
   448  				return
   449  			}
   450  		}
   451  		if cli.baseCtx == nil {
   452  			cli.baseCtx = context.Background()
   453  		}
   454  		cli.initializeFromClient()
   455  	})
   456  	return cli.initErr
   457  }
   458  
   459  // Apply all the operation on the cli
   460  func (cli *DockerCli) Apply(ops ...CLIOption) error {
   461  	for _, op := range ops {
   462  		if err := op(cli); err != nil {
   463  			return err
   464  		}
   465  	}
   466  	return nil
   467  }
   468  
   469  // ServerInfo stores details about the supported features and platform of the
   470  // server
   471  type ServerInfo struct {
   472  	HasExperimental bool
   473  	OSType          string
   474  	BuildkitVersion types.BuilderVersion
   475  
   476  	// SwarmStatus provides information about the current swarm status of the
   477  	// engine, obtained from the "Swarm" header in the API response.
   478  	//
   479  	// It can be a nil struct if the API version does not provide this header
   480  	// in the ping response, or if an error occurred, in which case the client
   481  	// should use other ways to get the current swarm status, such as the /swarm
   482  	// endpoint.
   483  	SwarmStatus *swarm.Status
   484  }
   485  
   486  // NewDockerCli returns a DockerCli instance with all operators applied on it.
   487  // It applies by default the standard streams, and the content trust from
   488  // environment.
   489  func NewDockerCli(ops ...CLIOption) (*DockerCli, error) {
   490  	defaultOps := []CLIOption{
   491  		WithContentTrustFromEnv(),
   492  		WithDefaultContextStoreConfig(),
   493  		WithStandardStreams(),
   494  	}
   495  	ops = append(defaultOps, ops...)
   496  
   497  	cli := &DockerCli{baseCtx: context.Background()}
   498  	if err := cli.Apply(ops...); err != nil {
   499  		return nil, err
   500  	}
   501  	return cli, nil
   502  }
   503  
   504  func getServerHost(hosts []string, tlsOptions *tlsconfig.Options) (string, error) {
   505  	var host string
   506  	switch len(hosts) {
   507  	case 0:
   508  		host = os.Getenv(client.EnvOverrideHost)
   509  	case 1:
   510  		host = hosts[0]
   511  	default:
   512  		return "", errors.New("Please specify only one -H")
   513  	}
   514  
   515  	return dopts.ParseHost(tlsOptions != nil, host)
   516  }
   517  
   518  // UserAgent returns the user agent string used for making API requests
   519  func UserAgent() string {
   520  	return "Docker-Client/" + version.Version + " (" + runtime.GOOS + ")"
   521  }
   522  
   523  var defaultStoreEndpoints = []store.NamedTypeGetter{
   524  	store.EndpointTypeGetter(docker.DockerEndpoint, func() any { return &docker.EndpointMeta{} }),
   525  }
   526  
   527  // RegisterDefaultStoreEndpoints registers a new named endpoint
   528  // metadata type with the default context store config, so that
   529  // endpoint will be supported by stores using the config returned by
   530  // DefaultContextStoreConfig.
   531  func RegisterDefaultStoreEndpoints(ep ...store.NamedTypeGetter) {
   532  	defaultStoreEndpoints = append(defaultStoreEndpoints, ep...)
   533  }
   534  
   535  // DefaultContextStoreConfig returns a new store.Config with the default set of endpoints configured.
   536  func DefaultContextStoreConfig() store.Config {
   537  	return store.NewConfig(
   538  		func() any { return &DockerContext{} },
   539  		defaultStoreEndpoints...,
   540  	)
   541  }