github.com/kaisenlinux/docker.io@v0.0.0-20230510090727-ea55db55fac7/engine/daemon/config/config.go (about) 1 package config // import "github.com/docker/docker/daemon/config" 2 3 import ( 4 "bytes" 5 "encoding/json" 6 "fmt" 7 "io" 8 "net" 9 "os" 10 "reflect" 11 "strings" 12 "sync" 13 14 daemondiscovery "github.com/docker/docker/daemon/discovery" 15 "github.com/docker/docker/opts" 16 "github.com/docker/docker/pkg/authorization" 17 "github.com/docker/docker/pkg/discovery" 18 "github.com/docker/docker/registry" 19 "github.com/imdario/mergo" 20 "github.com/pkg/errors" 21 "github.com/sirupsen/logrus" 22 "github.com/spf13/pflag" 23 ) 24 25 const ( 26 // DefaultMaxConcurrentDownloads is the default value for 27 // maximum number of downloads that 28 // may take place at a time for each pull. 29 DefaultMaxConcurrentDownloads = 3 30 // DefaultMaxConcurrentUploads is the default value for 31 // maximum number of uploads that 32 // may take place at a time for each push. 33 DefaultMaxConcurrentUploads = 5 34 // DefaultDownloadAttempts is the default value for 35 // maximum number of attempts that 36 // may take place at a time for each pull when the connection is lost. 37 DefaultDownloadAttempts = 5 38 // DefaultShmSize is the default value for container's shm size 39 DefaultShmSize = int64(67108864) 40 // DefaultNetworkMtu is the default value for network MTU 41 DefaultNetworkMtu = 1500 42 // DisableNetworkBridge is the default value of the option to disable network bridge 43 DisableNetworkBridge = "none" 44 // DefaultInitBinary is the name of the default init binary 45 DefaultInitBinary = "docker-init" 46 47 // StockRuntimeName is the reserved name/alias used to represent the 48 // OCI runtime being shipped with the docker daemon package. 49 StockRuntimeName = "runc" 50 // LinuxV1RuntimeName is the runtime used to specify the containerd v1 shim with the runc binary 51 // Note this is different than io.containerd.runc.v1 which would be the v1 shim using the v2 shim API. 52 // This is specifically for the v1 shim using the v1 shim API. 53 LinuxV1RuntimeName = "io.containerd.runtime.v1.linux" 54 // LinuxV2RuntimeName is the runtime used to specify the containerd v2 runc shim 55 LinuxV2RuntimeName = "io.containerd.runc.v2" 56 ) 57 58 var builtinRuntimes = map[string]bool{ 59 StockRuntimeName: true, 60 LinuxV1RuntimeName: true, 61 LinuxV2RuntimeName: true, 62 } 63 64 // flatOptions contains configuration keys 65 // that MUST NOT be parsed as deep structures. 66 // Use this to differentiate these options 67 // with others like the ones in CommonTLSOptions. 68 var flatOptions = map[string]bool{ 69 "cluster-store-opts": true, 70 "log-opts": true, 71 "runtimes": true, 72 "default-ulimits": true, 73 "features": true, 74 "builder": true, 75 } 76 77 // skipValidateOptions contains configuration keys 78 // that will be skipped from findConfigurationConflicts 79 // for unknown flag validation. 80 var skipValidateOptions = map[string]bool{ 81 "features": true, 82 "builder": true, 83 // Corresponding flag has been removed because it was already unusable 84 "deprecated-key-path": true, 85 } 86 87 // skipDuplicates contains configuration keys that 88 // will be skipped when checking duplicated 89 // configuration field defined in both daemon 90 // config file and from dockerd cli flags. 91 // This allows some configurations to be merged 92 // during the parsing. 93 var skipDuplicates = map[string]bool{ 94 "runtimes": true, 95 } 96 97 // LogConfig represents the default log configuration. 98 // It includes json tags to deserialize configuration from a file 99 // using the same names that the flags in the command line use. 100 type LogConfig struct { 101 Type string `json:"log-driver,omitempty"` 102 Config map[string]string `json:"log-opts,omitempty"` 103 } 104 105 // commonBridgeConfig stores all the platform-common bridge driver specific 106 // configuration. 107 type commonBridgeConfig struct { 108 Iface string `json:"bridge,omitempty"` 109 FixedCIDR string `json:"fixed-cidr,omitempty"` 110 } 111 112 // NetworkConfig stores the daemon-wide networking configurations 113 type NetworkConfig struct { 114 // Default address pools for docker networks 115 DefaultAddressPools opts.PoolsOpt `json:"default-address-pools,omitempty"` 116 // NetworkControlPlaneMTU allows to specify the control plane MTU, this will allow to optimize the network use in some components 117 NetworkControlPlaneMTU int `json:"network-control-plane-mtu,omitempty"` 118 } 119 120 // CommonTLSOptions defines TLS configuration for the daemon server. 121 // It includes json tags to deserialize configuration from a file 122 // using the same names that the flags in the command line use. 123 type CommonTLSOptions struct { 124 CAFile string `json:"tlscacert,omitempty"` 125 CertFile string `json:"tlscert,omitempty"` 126 KeyFile string `json:"tlskey,omitempty"` 127 } 128 129 // DNSConfig defines the DNS configurations. 130 type DNSConfig struct { 131 DNS []string `json:"dns,omitempty"` 132 DNSOptions []string `json:"dns-opts,omitempty"` 133 DNSSearch []string `json:"dns-search,omitempty"` 134 HostGatewayIP net.IP `json:"host-gateway-ip,omitempty"` 135 } 136 137 // CommonConfig defines the configuration of a docker daemon which is 138 // common across platforms. 139 // It includes json tags to deserialize configuration from a file 140 // using the same names that the flags in the command line use. 141 type CommonConfig struct { 142 AuthzMiddleware *authorization.Middleware `json:"-"` 143 AuthorizationPlugins []string `json:"authorization-plugins,omitempty"` // AuthorizationPlugins holds list of authorization plugins 144 AutoRestart bool `json:"-"` 145 Context map[string][]string `json:"-"` 146 DisableBridge bool `json:"-"` 147 ExecOptions []string `json:"exec-opts,omitempty"` 148 GraphDriver string `json:"storage-driver,omitempty"` 149 GraphOptions []string `json:"storage-opts,omitempty"` 150 Labels []string `json:"labels,omitempty"` 151 Mtu int `json:"mtu,omitempty"` 152 NetworkDiagnosticPort int `json:"network-diagnostic-port,omitempty"` 153 Pidfile string `json:"pidfile,omitempty"` 154 RawLogs bool `json:"raw-logs,omitempty"` 155 RootDeprecated string `json:"graph,omitempty"` 156 Root string `json:"data-root,omitempty"` 157 ExecRoot string `json:"exec-root,omitempty"` 158 SocketGroup string `json:"group,omitempty"` 159 CorsHeaders string `json:"api-cors-header,omitempty"` 160 161 // TrustKeyPath is used to generate the daemon ID and for signing schema 1 manifests 162 // when pushing to a registry which does not support schema 2. This field is marked as 163 // deprecated because schema 1 manifests are deprecated in favor of schema 2 and the 164 // daemon ID will use a dedicated identifier not shared with exported signatures. 165 TrustKeyPath string `json:"deprecated-key-path,omitempty"` 166 167 // LiveRestoreEnabled determines whether we should keep containers 168 // alive upon daemon shutdown/start 169 LiveRestoreEnabled bool `json:"live-restore,omitempty"` 170 171 // ClusterStore is the storage backend used for the cluster information. It is used by both 172 // multihost networking (to store networks and endpoints information) and by the node discovery 173 // mechanism. 174 // Deprecated: host-discovery and overlay networks with external k/v stores are deprecated 175 ClusterStore string `json:"cluster-store,omitempty"` 176 177 // ClusterOpts is used to pass options to the discovery package for tuning libkv settings, such 178 // as TLS configuration settings. 179 // Deprecated: host-discovery and overlay networks with external k/v stores are deprecated 180 ClusterOpts map[string]string `json:"cluster-store-opts,omitempty"` 181 182 // ClusterAdvertise is the network endpoint that the Engine advertises for the purpose of node 183 // discovery. This should be a 'host:port' combination on which that daemon instance is 184 // reachable by other hosts. 185 // Deprecated: host-discovery and overlay networks with external k/v stores are deprecated 186 ClusterAdvertise string `json:"cluster-advertise,omitempty"` 187 188 // MaxConcurrentDownloads is the maximum number of downloads that 189 // may take place at a time for each pull. 190 MaxConcurrentDownloads *int `json:"max-concurrent-downloads,omitempty"` 191 192 // MaxConcurrentUploads is the maximum number of uploads that 193 // may take place at a time for each push. 194 MaxConcurrentUploads *int `json:"max-concurrent-uploads,omitempty"` 195 196 // MaxDownloadAttempts is the maximum number of attempts that 197 // may take place at a time for each push. 198 MaxDownloadAttempts *int `json:"max-download-attempts,omitempty"` 199 200 // ShutdownTimeout is the timeout value (in seconds) the daemon will wait for the container 201 // to stop when daemon is being shutdown 202 ShutdownTimeout int `json:"shutdown-timeout,omitempty"` 203 204 Debug bool `json:"debug,omitempty"` 205 Hosts []string `json:"hosts,omitempty"` 206 LogLevel string `json:"log-level,omitempty"` 207 TLS *bool `json:"tls,omitempty"` 208 TLSVerify *bool `json:"tlsverify,omitempty"` 209 210 // Embedded structs that allow config 211 // deserialization without the full struct. 212 CommonTLSOptions 213 214 // SwarmDefaultAdvertiseAddr is the default host/IP or network interface 215 // to use if a wildcard address is specified in the ListenAddr value 216 // given to the /swarm/init endpoint and no advertise address is 217 // specified. 218 SwarmDefaultAdvertiseAddr string `json:"swarm-default-advertise-addr"` 219 220 // SwarmRaftHeartbeatTick is the number of ticks in time for swarm mode raft quorum heartbeat 221 // Typical value is 1 222 SwarmRaftHeartbeatTick uint32 `json:"swarm-raft-heartbeat-tick"` 223 224 // SwarmRaftElectionTick is the number of ticks to elapse before followers in the quorum can propose 225 // a new round of leader election. Default, recommended value is at least 10X that of Heartbeat tick. 226 // Higher values can make the quorum less sensitive to transient faults in the environment, but this also 227 // means it takes longer for the managers to detect a down leader. 228 SwarmRaftElectionTick uint32 `json:"swarm-raft-election-tick"` 229 230 MetricsAddress string `json:"metrics-addr"` 231 232 DNSConfig 233 LogConfig 234 BridgeConfig // bridgeConfig holds bridge network specific configuration. 235 NetworkConfig 236 registry.ServiceOptions 237 238 sync.Mutex 239 // FIXME(vdemeester) This part is not that clear and is mainly dependent on cli flags 240 // It should probably be handled outside this package. 241 ValuesSet map[string]interface{} `json:"-"` 242 243 Experimental bool `json:"experimental"` // Experimental indicates whether experimental features should be exposed or not 244 245 // Exposed node Generic Resources 246 // e.g: ["orange=red", "orange=green", "orange=blue", "apple=3"] 247 NodeGenericResources []string `json:"node-generic-resources,omitempty"` 248 249 // ContainerAddr is the address used to connect to containerd if we're 250 // not starting it ourselves 251 ContainerdAddr string `json:"containerd,omitempty"` 252 253 // CriContainerd determines whether a supervised containerd instance 254 // should be configured with the CRI plugin enabled. This allows using 255 // Docker's containerd instance directly with a Kubernetes kubelet. 256 CriContainerd bool `json:"cri-containerd,omitempty"` 257 258 // Features contains a list of feature key value pairs indicating what features are enabled or disabled. 259 // If a certain feature doesn't appear in this list then it's unset (i.e. neither true nor false). 260 Features map[string]bool `json:"features,omitempty"` 261 262 Builder BuilderConfig `json:"builder,omitempty"` 263 264 ContainerdNamespace string `json:"containerd-namespace,omitempty"` 265 ContainerdPluginNamespace string `json:"containerd-plugin-namespace,omitempty"` 266 } 267 268 // IsValueSet returns true if a configuration value 269 // was explicitly set in the configuration file. 270 func (conf *Config) IsValueSet(name string) bool { 271 if conf.ValuesSet == nil { 272 return false 273 } 274 _, ok := conf.ValuesSet[name] 275 return ok 276 } 277 278 // New returns a new fully initialized Config struct 279 func New() *Config { 280 config := Config{} 281 config.LogConfig.Config = make(map[string]string) 282 config.ClusterOpts = make(map[string]string) 283 return &config 284 } 285 286 // ParseClusterAdvertiseSettings parses the specified advertise settings 287 func ParseClusterAdvertiseSettings(clusterStore, clusterAdvertise string) (string, error) { 288 if clusterAdvertise == "" { 289 return "", daemondiscovery.ErrDiscoveryDisabled 290 } 291 if clusterStore == "" { 292 return "", errors.New("invalid cluster configuration. --cluster-advertise must be accompanied by --cluster-store configuration") 293 } 294 295 advertise, err := discovery.ParseAdvertise(clusterAdvertise) 296 if err != nil { 297 return "", errors.Wrap(err, "discovery advertise parsing failed") 298 } 299 return advertise, nil 300 } 301 302 // GetConflictFreeLabels validates Labels for conflict 303 // In swarm the duplicates for labels are removed 304 // so we only take same values here, no conflict values 305 // If the key-value is the same we will only take the last label 306 func GetConflictFreeLabels(labels []string) ([]string, error) { 307 labelMap := map[string]string{} 308 for _, label := range labels { 309 stringSlice := strings.SplitN(label, "=", 2) 310 if len(stringSlice) > 1 { 311 // If there is a conflict we will return an error 312 if v, ok := labelMap[stringSlice[0]]; ok && v != stringSlice[1] { 313 return nil, fmt.Errorf("conflict labels for %s=%s and %s=%s", stringSlice[0], stringSlice[1], stringSlice[0], v) 314 } 315 labelMap[stringSlice[0]] = stringSlice[1] 316 } 317 } 318 319 newLabels := []string{} 320 for k, v := range labelMap { 321 newLabels = append(newLabels, fmt.Sprintf("%s=%s", k, v)) 322 } 323 return newLabels, nil 324 } 325 326 // Reload reads the configuration in the host and reloads the daemon and server. 327 func Reload(configFile string, flags *pflag.FlagSet, reload func(*Config)) error { 328 logrus.Infof("Got signal to reload configuration, reloading from: %s", configFile) 329 newConfig, err := getConflictFreeConfiguration(configFile, flags) 330 if err != nil { 331 if flags.Changed("config-file") || !os.IsNotExist(err) { 332 return errors.Wrapf(err, "unable to configure the Docker daemon with file %s", configFile) 333 } 334 newConfig = New() 335 } 336 337 if err := Validate(newConfig); err != nil { 338 return errors.Wrap(err, "file configuration validation failed") 339 } 340 341 // Check if duplicate label-keys with different values are found 342 newLabels, err := GetConflictFreeLabels(newConfig.Labels) 343 if err != nil { 344 return err 345 } 346 newConfig.Labels = newLabels 347 348 reload(newConfig) 349 return nil 350 } 351 352 // boolValue is an interface that boolean value flags implement 353 // to tell the command line how to make -name equivalent to -name=true. 354 type boolValue interface { 355 IsBoolFlag() bool 356 } 357 358 // MergeDaemonConfigurations reads a configuration file, 359 // loads the file configuration in an isolated structure, 360 // and merges the configuration provided from flags on top 361 // if there are no conflicts. 362 func MergeDaemonConfigurations(flagsConfig *Config, flags *pflag.FlagSet, configFile string) (*Config, error) { 363 fileConfig, err := getConflictFreeConfiguration(configFile, flags) 364 if err != nil { 365 return nil, err 366 } 367 368 if err := Validate(fileConfig); err != nil { 369 return nil, errors.Wrap(err, "configuration validation from file failed") 370 } 371 372 // merge flags configuration on top of the file configuration 373 if err := mergo.Merge(fileConfig, flagsConfig); err != nil { 374 return nil, err 375 } 376 377 // We need to validate again once both fileConfig and flagsConfig 378 // have been merged 379 if err := Validate(fileConfig); err != nil { 380 return nil, errors.Wrap(err, "merged configuration validation from file and command line flags failed") 381 } 382 383 return fileConfig, nil 384 } 385 386 // getConflictFreeConfiguration loads the configuration from a JSON file. 387 // It compares that configuration with the one provided by the flags, 388 // and returns an error if there are conflicts. 389 func getConflictFreeConfiguration(configFile string, flags *pflag.FlagSet) (*Config, error) { 390 b, err := os.ReadFile(configFile) 391 if err != nil { 392 return nil, err 393 } 394 395 var config Config 396 var reader io.Reader 397 if flags != nil { 398 var jsonConfig map[string]interface{} 399 reader = bytes.NewReader(b) 400 if err := json.NewDecoder(reader).Decode(&jsonConfig); err != nil { 401 return nil, err 402 } 403 404 configSet := configValuesSet(jsonConfig) 405 406 if err := findConfigurationConflicts(configSet, flags); err != nil { 407 return nil, err 408 } 409 410 // Override flag values to make sure the values set in the config file with nullable values, like `false`, 411 // are not overridden by default truthy values from the flags that were not explicitly set. 412 // See https://github.com/docker/docker/issues/20289 for an example. 413 // 414 // TODO: Rewrite configuration logic to avoid same issue with other nullable values, like numbers. 415 namedOptions := make(map[string]interface{}) 416 for key, value := range configSet { 417 f := flags.Lookup(key) 418 if f == nil { // ignore named flags that don't match 419 namedOptions[key] = value 420 continue 421 } 422 423 if _, ok := f.Value.(boolValue); ok { 424 f.Value.Set(fmt.Sprintf("%v", value)) 425 } 426 } 427 if len(namedOptions) > 0 { 428 // set also default for mergeVal flags that are boolValue at the same time. 429 flags.VisitAll(func(f *pflag.Flag) { 430 if opt, named := f.Value.(opts.NamedOption); named { 431 v, set := namedOptions[opt.Name()] 432 _, boolean := f.Value.(boolValue) 433 if set && boolean { 434 f.Value.Set(fmt.Sprintf("%v", v)) 435 } 436 } 437 }) 438 } 439 440 config.ValuesSet = configSet 441 } 442 443 reader = bytes.NewReader(b) 444 if err := json.NewDecoder(reader).Decode(&config); err != nil { 445 return nil, err 446 } 447 448 if config.RootDeprecated != "" { 449 logrus.Warn(`The "graph" config file option is deprecated. Please use "data-root" instead.`) 450 451 if config.Root != "" { 452 return nil, errors.New(`cannot specify both "graph" and "data-root" config file options`) 453 } 454 455 config.Root = config.RootDeprecated 456 } 457 458 return &config, nil 459 } 460 461 // configValuesSet returns the configuration values explicitly set in the file. 462 func configValuesSet(config map[string]interface{}) map[string]interface{} { 463 flatten := make(map[string]interface{}) 464 for k, v := range config { 465 if m, isMap := v.(map[string]interface{}); isMap && !flatOptions[k] { 466 for km, vm := range m { 467 flatten[km] = vm 468 } 469 continue 470 } 471 472 flatten[k] = v 473 } 474 return flatten 475 } 476 477 // findConfigurationConflicts iterates over the provided flags searching for 478 // duplicated configurations and unknown keys. It returns an error with all the conflicts if 479 // it finds any. 480 func findConfigurationConflicts(config map[string]interface{}, flags *pflag.FlagSet) error { 481 // 1. Search keys from the file that we don't recognize as flags. 482 unknownKeys := make(map[string]interface{}) 483 for key, value := range config { 484 if flag := flags.Lookup(key); flag == nil && !skipValidateOptions[key] { 485 unknownKeys[key] = value 486 } 487 } 488 489 // 2. Discard values that implement NamedOption. 490 // Their configuration name differs from their flag name, like `labels` and `label`. 491 if len(unknownKeys) > 0 { 492 unknownNamedConflicts := func(f *pflag.Flag) { 493 if namedOption, ok := f.Value.(opts.NamedOption); ok { 494 delete(unknownKeys, namedOption.Name()) 495 } 496 } 497 flags.VisitAll(unknownNamedConflicts) 498 } 499 500 if len(unknownKeys) > 0 { 501 var unknown []string 502 for key := range unknownKeys { 503 unknown = append(unknown, key) 504 } 505 return fmt.Errorf("the following directives don't match any configuration option: %s", strings.Join(unknown, ", ")) 506 } 507 508 var conflicts []string 509 printConflict := func(name string, flagValue, fileValue interface{}) string { 510 return fmt.Sprintf("%s: (from flag: %v, from file: %v)", name, flagValue, fileValue) 511 } 512 513 // 3. Search keys that are present as a flag and as a file option. 514 duplicatedConflicts := func(f *pflag.Flag) { 515 // search option name in the json configuration payload if the value is a named option 516 if namedOption, ok := f.Value.(opts.NamedOption); ok { 517 if optsValue, ok := config[namedOption.Name()]; ok && !skipDuplicates[namedOption.Name()] { 518 conflicts = append(conflicts, printConflict(namedOption.Name(), f.Value.String(), optsValue)) 519 } 520 } else { 521 // search flag name in the json configuration payload 522 for _, name := range []string{f.Name, f.Shorthand} { 523 if value, ok := config[name]; ok && !skipDuplicates[name] { 524 conflicts = append(conflicts, printConflict(name, f.Value.String(), value)) 525 break 526 } 527 } 528 } 529 } 530 531 flags.Visit(duplicatedConflicts) 532 533 if len(conflicts) > 0 { 534 return fmt.Errorf("the following directives are specified both as a flag and in the configuration file: %s", strings.Join(conflicts, ", ")) 535 } 536 return nil 537 } 538 539 // Validate validates some specific configs. 540 // such as config.DNS, config.Labels, config.DNSSearch, 541 // as well as config.MaxConcurrentDownloads, config.MaxConcurrentUploads and config.MaxDownloadAttempts. 542 func Validate(config *Config) error { 543 // validate DNS 544 for _, dns := range config.DNS { 545 if _, err := opts.ValidateIPAddress(dns); err != nil { 546 return err 547 } 548 } 549 550 // validate DNSSearch 551 for _, dnsSearch := range config.DNSSearch { 552 if _, err := opts.ValidateDNSSearch(dnsSearch); err != nil { 553 return err 554 } 555 } 556 557 // validate Labels 558 for _, label := range config.Labels { 559 if _, err := opts.ValidateLabel(label); err != nil { 560 return err 561 } 562 } 563 // validate MaxConcurrentDownloads 564 if config.MaxConcurrentDownloads != nil && *config.MaxConcurrentDownloads < 0 { 565 return fmt.Errorf("invalid max concurrent downloads: %d", *config.MaxConcurrentDownloads) 566 } 567 // validate MaxConcurrentUploads 568 if config.MaxConcurrentUploads != nil && *config.MaxConcurrentUploads < 0 { 569 return fmt.Errorf("invalid max concurrent uploads: %d", *config.MaxConcurrentUploads) 570 } 571 if err := ValidateMaxDownloadAttempts(config); err != nil { 572 return err 573 } 574 575 // validate that "default" runtime is not reset 576 if runtimes := config.GetAllRuntimes(); len(runtimes) > 0 { 577 if _, ok := runtimes[StockRuntimeName]; ok { 578 return fmt.Errorf("runtime name '%s' is reserved", StockRuntimeName) 579 } 580 } 581 582 if _, err := ParseGenericResources(config.NodeGenericResources); err != nil { 583 return err 584 } 585 586 if defaultRuntime := config.GetDefaultRuntimeName(); defaultRuntime != "" { 587 if !builtinRuntimes[defaultRuntime] { 588 runtimes := config.GetAllRuntimes() 589 if _, ok := runtimes[defaultRuntime]; !ok { 590 return fmt.Errorf("specified default runtime '%s' does not exist", defaultRuntime) 591 } 592 } 593 } 594 595 // validate platform-specific settings 596 return config.ValidatePlatformConfig() 597 } 598 599 // ValidateMaxDownloadAttempts validates if the max-download-attempts is within the valid range 600 func ValidateMaxDownloadAttempts(config *Config) error { 601 if config.MaxDownloadAttempts != nil && *config.MaxDownloadAttempts <= 0 { 602 return fmt.Errorf("invalid max download attempts: %d", *config.MaxDownloadAttempts) 603 } 604 return nil 605 } 606 607 // ModifiedDiscoverySettings returns whether the discovery configuration has been modified or not. 608 func ModifiedDiscoverySettings(config *Config, backendType, advertise string, clusterOpts map[string]string) bool { 609 if config.ClusterStore != backendType || config.ClusterAdvertise != advertise { 610 return true 611 } 612 613 if (config.ClusterOpts == nil && clusterOpts == nil) || 614 (config.ClusterOpts == nil && len(clusterOpts) == 0) || 615 (len(config.ClusterOpts) == 0 && clusterOpts == nil) { 616 return false 617 } 618 619 return !reflect.DeepEqual(config.ClusterOpts, clusterOpts) 620 }