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