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 }