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