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 }