github.com/vmware/go-vcloud-director/v2@v2.24.0/govcd/cse_yaml.go (about)

     1  package govcd
     2  
     3  import (
     4  	"fmt"
     5  	semver "github.com/hashicorp/go-version"
     6  	"github.com/vmware/go-vcloud-director/v2/types/v56"
     7  	"sigs.k8s.io/yaml"
     8  	"strings"
     9  )
    10  
    11  // updateCapiYaml takes a YAML and modifies its Kubernetes Template OVA, its Control plane, its Worker pools
    12  // and its Node Health Check capabilities, by using the new values provided as input.
    13  // If some of the values of the input is not provided, it doesn't change them.
    14  // If none of the values is provided, it just returns the same untouched YAML.
    15  func (cluster *CseKubernetesCluster) updateCapiYaml(input CseClusterUpdateInput) (string, error) {
    16  	if cluster == nil || cluster.capvcdType == nil {
    17  		return "", fmt.Errorf("receiver cluster is nil")
    18  	}
    19  
    20  	if input.ControlPlane == nil && input.WorkerPools == nil && input.NodeHealthCheck == nil && input.KubernetesTemplateOvaId == nil && input.NewWorkerPools == nil {
    21  		return cluster.capvcdType.Spec.CapiYaml, nil
    22  	}
    23  
    24  	// The YAML contains multiple documents, so we cannot use a simple yaml.Unmarshal() as this one just gets the first
    25  	// document it finds.
    26  	yamlDocs, err := unmarshalMultipleYamlDocuments(cluster.capvcdType.Spec.CapiYaml)
    27  	if err != nil {
    28  		return cluster.capvcdType.Spec.CapiYaml, fmt.Errorf("error unmarshalling YAML: %s", err)
    29  	}
    30  
    31  	if input.ControlPlane != nil {
    32  		err := cseUpdateControlPlaneInYaml(yamlDocs, *input.ControlPlane)
    33  		if err != nil {
    34  			return cluster.capvcdType.Spec.CapiYaml, err
    35  		}
    36  	}
    37  
    38  	if input.WorkerPools != nil {
    39  		err := cseUpdateWorkerPoolsInYaml(yamlDocs, *input.WorkerPools)
    40  		if err != nil {
    41  			return cluster.capvcdType.Spec.CapiYaml, err
    42  		}
    43  	}
    44  
    45  	// Order matters. We need to add the new pools before updating the Kubernetes template.
    46  	if input.NewWorkerPools != nil {
    47  		// Worker pool names must be unique
    48  		for _, existingPool := range cluster.WorkerPools {
    49  			for _, newPool := range *input.NewWorkerPools {
    50  				if newPool.Name == existingPool.Name {
    51  					return cluster.capvcdType.Spec.CapiYaml, fmt.Errorf("there is an existing Worker Pool with name '%s'", existingPool.Name)
    52  				}
    53  			}
    54  		}
    55  
    56  		yamlDocs, err = cseAddWorkerPoolsInYaml(yamlDocs, *cluster, *input.NewWorkerPools)
    57  		if err != nil {
    58  			return cluster.capvcdType.Spec.CapiYaml, err
    59  		}
    60  	}
    61  
    62  	// As a side note, we can't optimize this one with "if <current value> equals <new value> do nothing" because
    63  	// in order to retrieve the current value we would need to explore the YAML anyway, which is what we also need to do to update it.
    64  	// Also, even if we did it, the current value obtained from YAML would be a Name, but the new value is an ID, so we would need to query VCD anyway
    65  	// as well.
    66  	// So in this special case this "optimization" would optimize nothing. The same happens with other YAML values.
    67  	if input.KubernetesTemplateOvaId != nil {
    68  		vAppTemplate, err := getVAppTemplateById(cluster.client, *input.KubernetesTemplateOvaId)
    69  		if err != nil {
    70  			return cluster.capvcdType.Spec.CapiYaml, fmt.Errorf("could not retrieve the Kubernetes Template OVA with ID '%s': %s", *input.KubernetesTemplateOvaId, err)
    71  		}
    72  		// Check the versions of the selected OVA before upgrading
    73  		versions, err := getTkgVersionBundleFromVAppTemplate(vAppTemplate.VAppTemplate)
    74  		if err != nil {
    75  			return cluster.capvcdType.Spec.CapiYaml, fmt.Errorf("could not retrieve the TKG versions of OVA '%s': %s", *input.KubernetesTemplateOvaId, err)
    76  		}
    77  		if versions.compareTkgVersion(cluster.capvcdType.Status.Capvcd.Upgrade.Current.TkgVersion) < 0 || !versions.kubernetesVersionIsUpgradeableFrom(cluster.capvcdType.Status.Capvcd.Upgrade.Current.KubernetesVersion) {
    78  			return cluster.capvcdType.Spec.CapiYaml, fmt.Errorf("cannot perform an OVA change as the new one '%s' has an older TKG/Kubernetes version (%s/%s)", vAppTemplate.VAppTemplate.Name, versions.TkgVersion, versions.KubernetesVersion)
    79  		}
    80  		err = cseUpdateKubernetesTemplateInYaml(yamlDocs, vAppTemplate.VAppTemplate)
    81  		if err != nil {
    82  			return cluster.capvcdType.Spec.CapiYaml, err
    83  		}
    84  	}
    85  
    86  	if input.NodeHealthCheck != nil {
    87  		cseComponentsVersions, err := getCseComponentsVersions(cluster.CseVersion)
    88  		if err != nil {
    89  			return "", err
    90  		}
    91  		vcdKeConfig, err := getVcdKeConfig(cluster.client, cseComponentsVersions.VcdKeConfigRdeTypeVersion, *input.NodeHealthCheck)
    92  		if err != nil {
    93  			return "", err
    94  		}
    95  		yamlDocs, err = cseUpdateNodeHealthCheckInYaml(yamlDocs, cluster.Name, cluster.CseVersion, vcdKeConfig)
    96  		if err != nil {
    97  			return "", err
    98  		}
    99  	}
   100  
   101  	return marshalMultipleYamlDocuments(yamlDocs)
   102  }
   103  
   104  // cseUpdateKubernetesTemplateInYaml modifies the given Kubernetes cluster YAML by modifying the Kubernetes Template OVA
   105  // used by all the cluster elements.
   106  // The caveat here is that not only VCDMachineTemplate needs to be changed with the new OVA name, but also
   107  // other fields that reference the related Kubernetes version, TKG version and other derived information.
   108  func cseUpdateKubernetesTemplateInYaml(yamlDocuments []map[string]interface{}, kubernetesTemplateOva *types.VAppTemplate) error {
   109  	tkgBundle, err := getTkgVersionBundleFromVAppTemplate(kubernetesTemplateOva)
   110  	if err != nil {
   111  		return err
   112  	}
   113  	for _, d := range yamlDocuments {
   114  		switch d["kind"] {
   115  		case "VCDMachineTemplate":
   116  			ok := traverseMapAndGet[string](d, "spec.template.spec.template") != ""
   117  			if !ok {
   118  				return fmt.Errorf("the VCDMachineTemplate 'spec.template.spec.template' field is missing")
   119  			}
   120  			d["spec"].(map[string]interface{})["template"].(map[string]interface{})["spec"].(map[string]interface{})["template"] = kubernetesTemplateOva.Name
   121  		case "MachineDeployment":
   122  			ok := traverseMapAndGet[string](d, "spec.template.spec.version") != ""
   123  			if !ok {
   124  				return fmt.Errorf("the MachineDeployment 'spec.template.spec.version' field is missing")
   125  			}
   126  			d["spec"].(map[string]interface{})["template"].(map[string]interface{})["spec"].(map[string]interface{})["version"] = tkgBundle.KubernetesVersion
   127  		case "Cluster":
   128  			ok := traverseMapAndGet[string](d, "metadata.annotations.TKGVERSION") != ""
   129  			if !ok {
   130  				return fmt.Errorf("the Cluster 'metadata.annotations.TKGVERSION' field is missing")
   131  			}
   132  			d["metadata"].(map[string]interface{})["annotations"].(map[string]interface{})["TKGVERSION"] = tkgBundle.TkgVersion
   133  			ok = traverseMapAndGet[string](d, "metadata.labels.tanzuKubernetesRelease") != ""
   134  			if !ok {
   135  				return fmt.Errorf("the Cluster 'metadata.labels.tanzuKubernetesRelease' field is missing")
   136  			}
   137  			d["metadata"].(map[string]interface{})["labels"].(map[string]interface{})["tanzuKubernetesRelease"] = tkgBundle.TkrVersion
   138  		case "KubeadmControlPlane":
   139  			ok := traverseMapAndGet[string](d, "spec.version") != ""
   140  			if !ok {
   141  				return fmt.Errorf("the KubeadmControlPlane 'spec.version' field is missing")
   142  			}
   143  			d["spec"].(map[string]interface{})["version"] = tkgBundle.KubernetesVersion
   144  			ok = traverseMapAndGet[string](d, "spec.kubeadmConfigSpec.clusterConfiguration.dns.imageTag") != ""
   145  			if !ok {
   146  				return fmt.Errorf("the KubeadmControlPlane 'spec.kubeadmConfigSpec.clusterConfiguration.dns.imageTag' field is missing")
   147  			}
   148  			d["spec"].(map[string]interface{})["kubeadmConfigSpec"].(map[string]interface{})["clusterConfiguration"].(map[string]interface{})["dns"].(map[string]interface{})["imageTag"] = tkgBundle.CoreDnsVersion
   149  			ok = traverseMapAndGet[string](d, "spec.kubeadmConfigSpec.clusterConfiguration.etcd.local.imageTag") != ""
   150  			if !ok {
   151  				return fmt.Errorf("the KubeadmControlPlane 'spec.kubeadmConfigSpec.clusterConfiguration.etcd.local.imageTag' field is missing")
   152  			}
   153  			d["spec"].(map[string]interface{})["kubeadmConfigSpec"].(map[string]interface{})["clusterConfiguration"].(map[string]interface{})["etcd"].(map[string]interface{})["local"].(map[string]interface{})["imageTag"] = tkgBundle.EtcdVersion
   154  		}
   155  	}
   156  	return nil
   157  }
   158  
   159  // cseUpdateControlPlaneInYaml modifies the given Kubernetes cluster YAML contents by changing the Control Plane with the input parameters.
   160  func cseUpdateControlPlaneInYaml(yamlDocuments []map[string]interface{}, input CseControlPlaneUpdateInput) error {
   161  	if input.MachineCount < 1 || input.MachineCount%2 == 0 {
   162  		return fmt.Errorf("incorrect machine count for Control Plane: %d. Should be at least 1 and an odd number", input.MachineCount)
   163  	}
   164  
   165  	updated := false
   166  	for _, d := range yamlDocuments {
   167  		if d["kind"] != "KubeadmControlPlane" {
   168  			continue
   169  		}
   170  		d["spec"].(map[string]interface{})["replicas"] = float64(input.MachineCount) // As it was originally unmarshalled as a float64
   171  		updated = true
   172  	}
   173  	if !updated {
   174  		return fmt.Errorf("could not find the KubeadmControlPlane object in the YAML")
   175  	}
   176  	return nil
   177  }
   178  
   179  // cseUpdateControlPlaneInYaml modifies the given Kubernetes cluster YAML contents by changing
   180  // the existing Worker Pools with the input parameters.
   181  func cseUpdateWorkerPoolsInYaml(yamlDocuments []map[string]interface{}, workerPools map[string]CseWorkerPoolUpdateInput) error {
   182  	updated := 0
   183  	for _, d := range yamlDocuments {
   184  		if d["kind"] != "MachineDeployment" {
   185  			continue
   186  		}
   187  
   188  		workerPoolName := traverseMapAndGet[string](d, "metadata.name")
   189  		if workerPoolName == "" {
   190  			return fmt.Errorf("the MachineDeployment 'metadata.name' field is empty")
   191  		}
   192  
   193  		workerPoolToUpdate := ""
   194  		for wpName := range workerPools {
   195  			if wpName == workerPoolName {
   196  				workerPoolToUpdate = wpName
   197  			}
   198  		}
   199  		// This worker pool must not be updated as it is not present in the input, continue searching for the ones we want
   200  		if workerPoolToUpdate == "" {
   201  			continue
   202  		}
   203  
   204  		if workerPools[workerPoolToUpdate].MachineCount < 0 {
   205  			return fmt.Errorf("incorrect machine count for worker pool %s: %d. Should be at least 0", workerPoolToUpdate, workerPools[workerPoolToUpdate].MachineCount)
   206  		}
   207  
   208  		d["spec"].(map[string]interface{})["replicas"] = float64(workerPools[workerPoolToUpdate].MachineCount) // As it was originally unmarshalled as a float64
   209  		updated++
   210  	}
   211  	if updated != len(workerPools) {
   212  		return fmt.Errorf("could not update all the Node pools. Updated %d, expected %d", updated, len(workerPools))
   213  	}
   214  	return nil
   215  }
   216  
   217  // cseAddWorkerPoolsInYaml modifies the given Kubernetes cluster YAML contents by adding new Worker Pools
   218  // described by the input parameters.
   219  // NOTE: This function doesn't modify the input, but returns a copy of the YAML with the added unmarshalled documents.
   220  func cseAddWorkerPoolsInYaml(docs []map[string]interface{}, cluster CseKubernetesCluster, newWorkerPools []CseWorkerPoolSettings) ([]map[string]interface{}, error) {
   221  	if len(newWorkerPools) == 0 {
   222  		return docs, nil
   223  	}
   224  
   225  	var computePolicyIds []string
   226  	var storageProfileIds []string
   227  	for _, w := range newWorkerPools {
   228  		computePolicyIds = append(computePolicyIds, w.SizingPolicyId, w.PlacementPolicyId, w.VGpuPolicyId)
   229  		storageProfileIds = append(storageProfileIds, w.StorageProfileId)
   230  	}
   231  
   232  	idToNameCache, err := idToNames(cluster.client, computePolicyIds, storageProfileIds)
   233  	if err != nil {
   234  		return nil, err
   235  	}
   236  
   237  	internalSettings := cseClusterSettingsInternal{WorkerPools: make([]cseWorkerPoolSettingsInternal, len(newWorkerPools))}
   238  	for i, workerPool := range newWorkerPools {
   239  		internalSettings.WorkerPools[i] = cseWorkerPoolSettingsInternal{
   240  			Name:                workerPool.Name,
   241  			MachineCount:        workerPool.MachineCount,
   242  			DiskSizeGi:          workerPool.DiskSizeGi,
   243  			StorageProfileName:  idToNameCache[workerPool.StorageProfileId],
   244  			SizingPolicyName:    idToNameCache[workerPool.SizingPolicyId],
   245  			VGpuPolicyName:      idToNameCache[workerPool.VGpuPolicyId],
   246  			PlacementPolicyName: idToNameCache[workerPool.PlacementPolicyId],
   247  		}
   248  	}
   249  
   250  	// Extra information needed to render the YAML. As all the worker pools share the same
   251  	// Kubernetes OVA name, version and Catalog, we pick this info from any of the available ones.
   252  	for _, doc := range docs {
   253  		if internalSettings.CatalogName == "" && doc["kind"] == "VCDMachineTemplate" {
   254  			internalSettings.CatalogName = traverseMapAndGet[string](doc, "spec.template.spec.catalog")
   255  		}
   256  		if internalSettings.KubernetesTemplateOvaName == "" && doc["kind"] == "VCDMachineTemplate" {
   257  			internalSettings.KubernetesTemplateOvaName = traverseMapAndGet[string](doc, "spec.template.spec.template")
   258  		}
   259  		if internalSettings.TkgVersionBundle.KubernetesVersion == "" && doc["kind"] == "MachineDeployment" {
   260  			internalSettings.TkgVersionBundle.KubernetesVersion = traverseMapAndGet[string](doc, "spec.template.spec.version")
   261  		}
   262  		if internalSettings.CatalogName != "" && internalSettings.KubernetesTemplateOvaName != "" && internalSettings.TkgVersionBundle.KubernetesVersion != "" {
   263  			break
   264  		}
   265  	}
   266  	internalSettings.Name = cluster.Name
   267  	internalSettings.CseVersion = cluster.CseVersion
   268  	nodePoolsYaml, err := internalSettings.generateWorkerPoolsYaml()
   269  	if err != nil {
   270  		return nil, err
   271  	}
   272  
   273  	newWorkerPoolsYamlDocs, err := unmarshalMultipleYamlDocuments(nodePoolsYaml)
   274  	if err != nil {
   275  		return nil, err
   276  	}
   277  
   278  	result := make([]map[string]interface{}, len(docs))
   279  	copy(result, docs)
   280  	return append(result, newWorkerPoolsYamlDocs...), nil
   281  }
   282  
   283  // cseUpdateNodeHealthCheckInYaml updates the Kubernetes cluster described in the given YAML documents by adding or removing
   284  // the MachineHealthCheck object.
   285  // NOTE: This function doesn't modify the input, but returns a copy of the YAML with the modifications.
   286  func cseUpdateNodeHealthCheckInYaml(yamlDocuments []map[string]interface{}, clusterName string, cseVersion semver.Version, vcdKeConfig vcdKeConfig) ([]map[string]interface{}, error) {
   287  	mhcPosition := -1
   288  	result := make([]map[string]interface{}, len(yamlDocuments))
   289  	for i, d := range yamlDocuments {
   290  		if d["kind"] == "MachineHealthCheck" {
   291  			mhcPosition = i
   292  		}
   293  		result[i] = d
   294  	}
   295  
   296  	machineHealthCheckEnabled := vcdKeConfig.NodeUnknownTimeout != "" && vcdKeConfig.NodeStartupTimeout != "" && vcdKeConfig.NodeNotReadyTimeout != "" &&
   297  		vcdKeConfig.MaxUnhealthyNodesPercentage != 0
   298  
   299  	if mhcPosition < 0 {
   300  		// There is no MachineHealthCheck block
   301  		if !machineHealthCheckEnabled {
   302  			// We don't want it neither, so nothing to do
   303  			return result, nil
   304  		}
   305  
   306  		// We need to add the block to the slice of YAML documents
   307  		settings := &cseClusterSettingsInternal{CseVersion: cseVersion, Name: clusterName, VcdKeConfig: vcdKeConfig}
   308  		mhcYaml, err := settings.generateMachineHealthCheckYaml()
   309  		if err != nil {
   310  			return nil, err
   311  		}
   312  		var mhc map[string]interface{}
   313  		err = yaml.Unmarshal([]byte(mhcYaml), &mhc)
   314  		if err != nil {
   315  			return nil, err
   316  		}
   317  		result = append(result, mhc)
   318  	} else {
   319  		// There is a MachineHealthCheck block
   320  		if machineHealthCheckEnabled {
   321  			// We want it, but it is already there, so nothing to do
   322  			return result, nil
   323  		}
   324  
   325  		// We don't want Machine Health Checks, we delete the YAML document
   326  		result[mhcPosition] = result[len(result)-1] // We override the MachineHealthCheck block with the last document
   327  		result = result[:len(result)-1]             // We remove the last document (now duplicated)
   328  	}
   329  	return result, nil
   330  }
   331  
   332  // marshalMultipleYamlDocuments takes a slice of maps representing multiple YAML documents (one per item in the slice) and
   333  // marshals all of them into a single string with the corresponding separators "---".
   334  func marshalMultipleYamlDocuments(yamlDocuments []map[string]interface{}) (string, error) {
   335  	result := ""
   336  	for i, yamlDoc := range yamlDocuments {
   337  		updatedSingleDoc, err := yaml.Marshal(yamlDoc)
   338  		if err != nil {
   339  			return "", fmt.Errorf("error marshaling the updated CAPVCD YAML '%v': %s", yamlDoc, err)
   340  		}
   341  		result += fmt.Sprintf("%s\n", updatedSingleDoc)
   342  		if i < len(yamlDocuments)-1 { // The last document doesn't need the YAML separator
   343  			result += "---\n"
   344  		}
   345  	}
   346  	return result, nil
   347  }
   348  
   349  // unmarshalMultipleYamlDocuments takes a multi-document YAML (multiple YAML documents are separated by "---") and
   350  // unmarshalls all of them into a slice of generic maps with the corresponding content.
   351  func unmarshalMultipleYamlDocuments(yamlDocuments string) ([]map[string]interface{}, error) {
   352  	if len(strings.TrimSpace(yamlDocuments)) == 0 {
   353  		return []map[string]interface{}{}, nil
   354  	}
   355  
   356  	splitYamlDocs := strings.Split(yamlDocuments, "---\n")
   357  	result := make([]map[string]interface{}, len(splitYamlDocs))
   358  	for i, yamlDoc := range splitYamlDocs {
   359  		err := yaml.Unmarshal([]byte(yamlDoc), &result[i])
   360  		if err != nil {
   361  			return nil, fmt.Errorf("could not unmarshal document %s: %s", yamlDoc, err)
   362  		}
   363  	}
   364  
   365  	return result, nil
   366  }
   367  
   368  // traverseMapAndGet traverses the input interface{}, which should be a map of maps, by following the path specified as
   369  // "keyA.keyB.keyC.keyD", doing something similar to, visually speaking, map["keyA"]["keyB"]["keyC"]["keyD"], or in other words,
   370  // it goes inside every inner map iteratively, until the given path is finished.
   371  // If the path doesn't lead to any value, or if the value is nil, or there is any other issue, returns the "zero" value of T.
   372  func traverseMapAndGet[T any](input interface{}, path string) T {
   373  	var nothing T
   374  	if input == nil {
   375  		return nothing
   376  	}
   377  	inputMap, ok := input.(map[string]interface{})
   378  	if !ok {
   379  		return nothing
   380  	}
   381  	if len(inputMap) == 0 {
   382  		return nothing
   383  	}
   384  	pathUnits := strings.Split(path, ".")
   385  	completed := false
   386  	i := 0
   387  	var result interface{}
   388  	for !completed {
   389  		subPath := pathUnits[i]
   390  		traversed, ok := inputMap[subPath]
   391  		if !ok {
   392  			return nothing
   393  		}
   394  		if i < len(pathUnits)-1 {
   395  			traversedMap, ok := traversed.(map[string]interface{})
   396  			if !ok {
   397  				return nothing
   398  			}
   399  			inputMap = traversedMap
   400  		} else {
   401  			completed = true
   402  			result = traversed
   403  		}
   404  		i++
   405  	}
   406  	resultTyped, ok := result.(T)
   407  	if !ok {
   408  		return nothing
   409  	}
   410  	return resultTyped
   411  }