github.com/vmware/go-vcloud-director/v2@v2.24.0/govcd/cse_util.go (about) 1 package govcd 2 3 import ( 4 _ "embed" 5 "encoding/base64" 6 "encoding/json" 7 "fmt" 8 semver "github.com/hashicorp/go-version" 9 "github.com/vmware/go-vcloud-director/v2/types/v56" 10 "github.com/vmware/go-vcloud-director/v2/util" 11 "net" 12 "net/url" 13 "regexp" 14 "sort" 15 "strconv" 16 "strings" 17 "time" 18 ) 19 20 // getCseComponentsVersions gets the versions of the subcomponents that are part of Container Service Extension. 21 // NOTE: This function should be updated on every CSE release to update the supported versions. 22 func getCseComponentsVersions(cseVersion semver.Version) (*cseComponentsVersions, error) { 23 v43, _ := semver.NewVersion("4.3.0") 24 v42, _ := semver.NewVersion("4.2.0") 25 v41, _ := semver.NewVersion("4.1.0") 26 err := fmt.Errorf("the Container Service Extension version '%s' is not supported", cseVersion.String()) 27 28 if cseVersion.GreaterThanOrEqual(v43) { 29 return nil, err 30 } 31 if cseVersion.GreaterThanOrEqual(v42) { 32 return &cseComponentsVersions{ 33 VcdKeConfigRdeTypeVersion: "1.1.0", 34 CapvcdRdeTypeVersion: "1.3.0", 35 CseInterfaceVersion: "1.0.0", 36 }, nil 37 } 38 if cseVersion.GreaterThanOrEqual(v41) { 39 return &cseComponentsVersions{ 40 VcdKeConfigRdeTypeVersion: "1.1.0", 41 CapvcdRdeTypeVersion: "1.2.0", 42 CseInterfaceVersion: "1.0.0", 43 }, nil 44 } 45 return nil, err 46 } 47 48 // cseConvertToCseKubernetesClusterType takes a generic RDE that must represent an existing CSE Kubernetes cluster, 49 // and transforms it to an equivalent CseKubernetesCluster object that represents the same cluster, but 50 // it is easy to explore and consume. If the input RDE is not a CSE Kubernetes cluster, this method 51 // will obviously return an error. 52 // 53 // The transformation from a generic RDE to a CseKubernetesCluster is done by querying VCD for every needed item, 54 // such as Network IDs, Compute Policies IDs, vApp Template IDs, etc. It deeply explores the RDE contents 55 // (even the CAPI YAML) to retrieve information and getting the missing pieces from VCD. 56 // 57 // WARNING: Don't use this method inside loops or avoid calling it multiple times in a row, as it performs many queries 58 // to VCD. 59 func cseConvertToCseKubernetesClusterType(rde *DefinedEntity) (*CseKubernetesCluster, error) { 60 requiredType := fmt.Sprintf("%s:%s", cseKubernetesClusterVendor, cseKubernetesClusterNamespace) 61 62 if !strings.Contains(rde.DefinedEntity.ID, requiredType) || !strings.Contains(rde.DefinedEntity.EntityType, requiredType) { 63 return nil, fmt.Errorf("the receiver RDE is not a '%s' entity, it is '%s'", requiredType, rde.DefinedEntity.EntityType) 64 } 65 66 entityBytes, err := json.Marshal(rde.DefinedEntity.Entity) 67 if err != nil { 68 return nil, fmt.Errorf("could not marshal the RDE contents to create a capvcdType instance: %s", err) 69 } 70 71 capvcd := &types.Capvcd{} 72 err = json.Unmarshal(entityBytes, &capvcd) 73 if err != nil { 74 return nil, fmt.Errorf("could not unmarshal the RDE contents to create a Capvcd instance: %s", err) 75 } 76 77 result := &CseKubernetesCluster{ 78 CseClusterSettings: CseClusterSettings{ 79 Name: rde.DefinedEntity.Name, 80 ApiToken: "******", // We must not return this one, we return the "standard" 6-asterisk value 81 AutoRepairOnErrors: capvcd.Spec.VcdKe.AutoRepairOnErrors, 82 ControlPlane: CseControlPlaneSettings{}, 83 }, 84 ID: rde.DefinedEntity.ID, 85 Etag: rde.Etag, 86 ClusterResourceSetBindings: make([]string, len(capvcd.Status.Capvcd.ClusterResourceSetBindings)), 87 State: capvcd.Status.VcdKe.State, 88 Events: make([]CseClusterEvent, 0), 89 client: rde.client, 90 capvcdType: capvcd, 91 supportedUpgrades: make([]*types.VAppTemplate, 0), 92 } 93 94 // Add all events to the resulting cluster 95 for _, s := range capvcd.Status.VcdKe.EventSet { 96 result.Events = append(result.Events, CseClusterEvent{ 97 Name: s.Name, 98 Type: "event", 99 ResourceId: s.VcdResourceId, 100 ResourceName: s.VcdResourceName, 101 OccurredAt: s.OccurredAt, 102 Details: s.AdditionalDetails.DetailedEvent, 103 }) 104 } 105 for _, s := range capvcd.Status.VcdKe.ErrorSet { 106 result.Events = append(result.Events, CseClusterEvent{ 107 Name: s.Name, 108 Type: "error", 109 ResourceId: s.VcdResourceId, 110 ResourceName: s.VcdResourceName, 111 OccurredAt: s.OccurredAt, 112 Details: s.AdditionalDetails.DetailedError, 113 }) 114 } 115 for _, s := range capvcd.Status.Capvcd.EventSet { 116 result.Events = append(result.Events, CseClusterEvent{ 117 Name: s.Name, 118 Type: "event", 119 ResourceId: s.VcdResourceId, 120 ResourceName: s.VcdResourceName, 121 OccurredAt: s.OccurredAt, 122 Details: s.Name, 123 }) 124 } 125 for _, s := range capvcd.Status.Capvcd.ErrorSet { 126 result.Events = append(result.Events, CseClusterEvent{ 127 Name: s.Name, 128 Type: "error", 129 ResourceId: s.VcdResourceId, 130 ResourceName: s.VcdResourceName, 131 OccurredAt: s.OccurredAt, 132 Details: s.AdditionalDetails.DetailedError, 133 }) 134 } 135 for _, s := range capvcd.Status.Cpi.EventSet { 136 result.Events = append(result.Events, CseClusterEvent{ 137 Name: s.Name, 138 Type: "event", 139 ResourceId: s.VcdResourceId, 140 ResourceName: s.VcdResourceName, 141 OccurredAt: s.OccurredAt, 142 Details: s.Name, 143 }) 144 } 145 for _, s := range capvcd.Status.Cpi.ErrorSet { 146 result.Events = append(result.Events, CseClusterEvent{ 147 Name: s.Name, 148 Type: "error", 149 ResourceId: s.VcdResourceId, 150 ResourceName: s.VcdResourceName, 151 OccurredAt: s.OccurredAt, 152 Details: s.AdditionalDetails.DetailedError, 153 }) 154 } 155 for _, s := range capvcd.Status.Csi.EventSet { 156 result.Events = append(result.Events, CseClusterEvent{ 157 Name: s.Name, 158 Type: "event", 159 ResourceId: s.VcdResourceId, 160 ResourceName: s.VcdResourceName, 161 OccurredAt: s.OccurredAt, 162 Details: s.Name, 163 }) 164 } 165 for _, s := range capvcd.Status.Csi.ErrorSet { 166 result.Events = append(result.Events, CseClusterEvent{ 167 Name: s.Name, 168 Type: "error", 169 ResourceId: s.VcdResourceId, 170 ResourceName: s.VcdResourceName, 171 OccurredAt: s.OccurredAt, 172 Details: s.AdditionalDetails.DetailedError, 173 }) 174 } 175 for _, s := range capvcd.Status.Projector.EventSet { 176 result.Events = append(result.Events, CseClusterEvent{ 177 Name: s.Name, 178 Type: "event", 179 ResourceId: s.VcdResourceId, 180 ResourceName: s.VcdResourceName, 181 OccurredAt: s.OccurredAt, 182 Details: s.Name, 183 }) 184 } 185 for _, s := range capvcd.Status.Projector.ErrorSet { 186 result.Events = append(result.Events, CseClusterEvent{ 187 Name: s.Name, 188 Type: "error", 189 ResourceId: s.VcdResourceId, 190 ResourceName: s.VcdResourceName, 191 OccurredAt: s.OccurredAt, 192 Details: s.AdditionalDetails.DetailedError, 193 }) 194 } 195 sort.SliceStable(result.Events, func(i, j int) bool { 196 return result.Events[i].OccurredAt.After(result.Events[j].OccurredAt) 197 }) 198 199 if capvcd.Status.Capvcd.CapvcdVersion != "" { 200 version, err := semver.NewVersion(capvcd.Status.Capvcd.CapvcdVersion) 201 if err != nil { 202 return nil, fmt.Errorf("could not read Capvcd version: %s", err) 203 } 204 result.CapvcdVersion = *version 205 } 206 207 if capvcd.Status.Cpi.Version != "" { 208 version, err := semver.NewVersion(strings.TrimSpace(capvcd.Status.Cpi.Version)) // Note: We use trim as the version comes with spacing characters 209 if err != nil { 210 return nil, fmt.Errorf("could not read CPI version: %s", err) 211 } 212 result.CpiVersion = *version 213 } 214 215 if capvcd.Status.Csi.Version != "" { 216 version, err := semver.NewVersion(capvcd.Status.Csi.Version) 217 if err != nil { 218 return nil, fmt.Errorf("could not read CSI version: %s", err) 219 } 220 result.CsiVersion = *version 221 } 222 223 if capvcd.Status.VcdKe.VcdKeVersion != "" { 224 cseVersion, err := semver.NewVersion(capvcd.Status.VcdKe.VcdKeVersion) 225 if err != nil { 226 return nil, fmt.Errorf("could not read the CSE Version that the cluster uses: %s", err) 227 } 228 // Remove the possible version suffixes as we just want MAJOR.MINOR.PATCH 229 // TODO: This can be replaced with (*cseVersion).Core() in newer versions of the library 230 cseVersionSegs := (*cseVersion).Segments() 231 cseVersion, err = semver.NewVersion(fmt.Sprintf("%d.%d.%d", cseVersionSegs[0], cseVersionSegs[1], cseVersionSegs[2])) 232 if err != nil { 233 return nil, fmt.Errorf("could not read the CSE Version that the cluster uses: %s", err) 234 } 235 result.CseVersion = *cseVersion 236 } 237 238 // Retrieve the Owner 239 if rde.DefinedEntity.Owner != nil { 240 result.Owner = rde.DefinedEntity.Owner.Name 241 } 242 243 // Retrieve the Organization ID 244 for i, binding := range capvcd.Status.Capvcd.ClusterResourceSetBindings { 245 result.ClusterResourceSetBindings[i] = binding.Name 246 } 247 248 if len(capvcd.Status.Capvcd.ClusterApiStatus.ApiEndpoints) > 0 { 249 result.ControlPlane.Ip = capvcd.Status.Capvcd.ClusterApiStatus.ApiEndpoints[0].Host 250 } 251 252 if len(result.capvcdType.Status.Capvcd.VcdProperties.Organizations) > 0 { 253 result.OrganizationId = result.capvcdType.Status.Capvcd.VcdProperties.Organizations[0].Id 254 } 255 256 // If the Org/VDC information is not set, we can't continue retrieving information for the cluster. 257 // This scenario is when the cluster is not correctly provisioned (Error state) 258 if len(result.capvcdType.Status.Capvcd.VcdProperties.OrgVdcs) == 0 { 259 return result, nil 260 } 261 262 // NOTE: The code below, until the end of this function, requires the Org/VDC information 263 264 // Retrieve the VDC ID 265 result.VdcId = result.capvcdType.Status.Capvcd.VcdProperties.OrgVdcs[0].Id 266 // FIXME: This is a workaround, because for some reason the OrgVdcs[*].Id property contains the VDC name instead of the VDC ID. 267 // Once this is fixed, this conditional should not be needed anymore. 268 if result.VdcId == result.capvcdType.Status.Capvcd.VcdProperties.OrgVdcs[0].Name { 269 vdcs, err := queryOrgVdcList(rde.client, map[string]string{}) 270 if err != nil { 271 return nil, fmt.Errorf("could not get VDC IDs as no VDC was found: %s", err) 272 } 273 found := false 274 for _, vdc := range vdcs { 275 if vdc.Name == result.capvcdType.Status.Capvcd.VcdProperties.OrgVdcs[0].Name { 276 result.VdcId = fmt.Sprintf("urn:vcloud:vdc:%s", extractUuid(vdc.HREF)) 277 found = true 278 break 279 } 280 } 281 if !found { 282 return nil, fmt.Errorf("could not get VDC IDs as no VDC with name '%s' was found", result.capvcdType.Status.Capvcd.VcdProperties.OrgVdcs[0].Name) 283 } 284 } 285 286 // Retrieve the Network ID 287 params := url.Values{} 288 params.Add("filter", fmt.Sprintf("name==%s", result.capvcdType.Status.Capvcd.VcdProperties.OrgVdcs[0].OvdcNetworkName)) 289 params = queryParameterFilterAnd("ownerRef.id=="+result.VdcId, params) 290 networks, err := getAllOpenApiOrgVdcNetworks(rde.client, params) 291 if err != nil { 292 return nil, fmt.Errorf("could not read Org VDC Network from Capvcd type: %s", err) 293 } 294 if len(networks) != 1 { 295 return nil, fmt.Errorf("expected one Org VDC Network from Capvcd type, but got %d", len(networks)) 296 } 297 result.NetworkId = networks[0].OpenApiOrgVdcNetwork.ID 298 299 // Here we retrieve several items that we need from now onwards, like Storage Profiles and Compute Policies 300 storageProfiles := map[string]string{} 301 if rde.client.IsSysAdmin { 302 allSp, err := queryAdminOrgVdcStorageProfilesByVdcId(rde.client, result.VdcId) 303 if err != nil { 304 return nil, fmt.Errorf("could not get all the Storage Profiles: %s", err) 305 } 306 for _, recordType := range allSp { 307 storageProfiles[recordType.Name] = fmt.Sprintf("urn:vcloud:vdcstorageProfile:%s", extractUuid(recordType.HREF)) 308 } 309 } else { 310 allSp, err := queryOrgVdcStorageProfilesByVdcId(rde.client, result.VdcId) 311 if err != nil { 312 return nil, fmt.Errorf("could not get all the Storage Profiles: %s", err) 313 } 314 for _, recordType := range allSp { 315 storageProfiles[recordType.Name] = fmt.Sprintf("urn:vcloud:vdcstorageProfile:%s", extractUuid(recordType.HREF)) 316 } 317 } 318 319 computePolicies, err := getAllVdcComputePoliciesV2(rde.client, nil) 320 if err != nil { 321 return nil, fmt.Errorf("could not get all the Compute Policies: %s", err) 322 } 323 324 if result.capvcdType.Spec.VcdKe.DefaultStorageClassOptions.K8SStorageClassName != "" { // This would mean there is a Default Storage Class defined 325 result.DefaultStorageClass = &CseDefaultStorageClassSettings{ 326 Name: result.capvcdType.Spec.VcdKe.DefaultStorageClassOptions.K8SStorageClassName, 327 ReclaimPolicy: "retain", 328 Filesystem: result.capvcdType.Spec.VcdKe.DefaultStorageClassOptions.Filesystem, 329 } 330 for spName, spId := range storageProfiles { 331 if spName == result.capvcdType.Spec.VcdKe.DefaultStorageClassOptions.VcdStorageProfileName { 332 result.DefaultStorageClass.StorageProfileId = spId 333 } 334 } 335 if result.capvcdType.Spec.VcdKe.DefaultStorageClassOptions.UseDeleteReclaimPolicy { 336 result.DefaultStorageClass.ReclaimPolicy = "delete" 337 } 338 } 339 340 // NOTE: We get the remaining elements from the CAPI YAML, despite they are also inside capvcdType.Status. 341 // The reason is that any change on the cluster is immediately reflected in the CAPI YAML, but not in the capvcdType.Status 342 // elements, which may take more than 10 minutes to be refreshed. 343 yamlDocuments, err := unmarshalMultipleYamlDocuments(result.capvcdType.Spec.CapiYaml) 344 if err != nil { 345 return nil, err 346 } 347 348 // We need a map of worker pools and not a slice, because there are two types of YAML documents 349 // that contain data about a specific worker pool (VCDMachineTemplate and MachineDeployment), and we can get them in no 350 // particular order, so we store the worker pools with their name as key. This way we can easily (O(1)) fetch and update them. 351 workerPools := map[string]CseWorkerPoolSettings{} 352 for _, yamlDocument := range yamlDocuments { 353 switch yamlDocument["kind"] { 354 case "KubeadmControlPlane": 355 result.ControlPlane.MachineCount = int(traverseMapAndGet[float64](yamlDocument, "spec.replicas")) 356 users := traverseMapAndGet[[]interface{}](yamlDocument, "spec.kubeadmConfigSpec.users") 357 if len(users) == 0 { 358 return nil, fmt.Errorf("expected 'spec.kubeadmConfigSpec.users' slice to not to be empty") 359 } 360 keys := traverseMapAndGet[[]interface{}](users[0], "sshAuthorizedKeys") 361 if len(keys) > 0 { 362 result.SshPublicKey = keys[0].(string) // Optional field 363 } 364 365 version, err := semver.NewVersion(traverseMapAndGet[string](yamlDocument, "spec.version")) 366 if err != nil { 367 return nil, fmt.Errorf("could not read Kubernetes version: %s", err) 368 } 369 result.KubernetesVersion = *version 370 371 case "VCDMachineTemplate": 372 name := traverseMapAndGet[string](yamlDocument, "metadata.name") 373 sizingPolicyName := traverseMapAndGet[string](yamlDocument, "spec.template.spec.sizingPolicy") 374 placementPolicyName := traverseMapAndGet[string](yamlDocument, "spec.template.spec.placementPolicy") 375 storageProfileName := traverseMapAndGet[string](yamlDocument, "spec.template.spec.storageProfile") 376 diskSizeGi, err := strconv.Atoi(strings.ReplaceAll(traverseMapAndGet[string](yamlDocument, "spec.template.spec.diskSize"), "Gi", "")) 377 if err != nil { 378 return nil, err 379 } 380 381 if strings.Contains(name, "control-plane-node-pool") { 382 // This is the single Control Plane 383 for _, policy := range computePolicies { 384 if sizingPolicyName == policy.VdcComputePolicyV2.Name && policy.VdcComputePolicyV2.IsSizingOnly { 385 result.ControlPlane.SizingPolicyId = policy.VdcComputePolicyV2.ID 386 } else if placementPolicyName == policy.VdcComputePolicyV2.Name && !policy.VdcComputePolicyV2.IsSizingOnly { 387 result.ControlPlane.PlacementPolicyId = policy.VdcComputePolicyV2.ID 388 } 389 } 390 for spName, spId := range storageProfiles { 391 if storageProfileName == spName { 392 result.ControlPlane.StorageProfileId = spId 393 } 394 } 395 396 result.ControlPlane.DiskSizeGi = diskSizeGi 397 398 // We retrieve the Kubernetes Template OVA just once for the Control Plane because all YAML blocks share the same 399 vAppTemplateName := traverseMapAndGet[string](yamlDocument, "spec.template.spec.template") 400 catalogName := traverseMapAndGet[string](yamlDocument, "spec.template.spec.catalog") 401 vAppTemplates, err := queryVappTemplateListWithFilter(rde.client, map[string]string{ 402 "catalogName": catalogName, 403 "name": vAppTemplateName, 404 }) 405 if err != nil { 406 return nil, fmt.Errorf("could not find any vApp Template with name '%s' in Catalog '%s': %s", vAppTemplateName, catalogName, err) 407 } 408 if len(vAppTemplates) == 0 { 409 return nil, fmt.Errorf("could not find any vApp Template with name '%s' in Catalog '%s'", vAppTemplateName, catalogName) 410 } 411 // The records don't have ID set, so we calculate it 412 result.KubernetesTemplateOvaId = fmt.Sprintf("urn:vcloud:vapptemplate:%s", extractUuid(vAppTemplates[0].HREF)) 413 } else { 414 // This is one Worker Pool. We need to check the map of worker pools, just in case we already saved the 415 // machine count from MachineDeployment. 416 if _, ok := workerPools[name]; !ok { 417 workerPools[name] = CseWorkerPoolSettings{} 418 } 419 workerPool := workerPools[name] 420 workerPool.Name = name 421 for _, policy := range computePolicies { 422 if sizingPolicyName == policy.VdcComputePolicyV2.Name && policy.VdcComputePolicyV2.IsSizingOnly { 423 workerPool.SizingPolicyId = policy.VdcComputePolicyV2.ID 424 } else if placementPolicyName == policy.VdcComputePolicyV2.Name && !policy.VdcComputePolicyV2.IsSizingOnly && !policy.VdcComputePolicyV2.IsVgpuPolicy { 425 workerPool.PlacementPolicyId = policy.VdcComputePolicyV2.ID 426 } else if placementPolicyName == policy.VdcComputePolicyV2.Name && !policy.VdcComputePolicyV2.IsSizingOnly && policy.VdcComputePolicyV2.IsVgpuPolicy { 427 workerPool.VGpuPolicyId = policy.VdcComputePolicyV2.ID 428 } 429 } 430 for spName, spId := range storageProfiles { 431 if storageProfileName == spName { 432 workerPool.StorageProfileId = spId 433 } 434 } 435 workerPool.DiskSizeGi = diskSizeGi 436 workerPools[name] = workerPool // Override the worker pool with the updated data 437 } 438 case "MachineDeployment": 439 name := traverseMapAndGet[string](yamlDocument, "metadata.name") 440 // This is one Worker Pool. We need to check the map of worker pools, just in case we already saved the 441 // other information from VCDMachineTemplate. 442 if _, ok := workerPools[name]; !ok { 443 workerPools[name] = CseWorkerPoolSettings{} 444 } 445 workerPool := workerPools[name] 446 workerPool.MachineCount = int(traverseMapAndGet[float64](yamlDocument, "spec.replicas")) 447 workerPools[name] = workerPool // Override the worker pool with the updated data 448 case "VCDCluster": 449 result.VirtualIpSubnet = traverseMapAndGet[string](yamlDocument, "spec.loadBalancerConfigSpec.vipSubnet") 450 case "Cluster": 451 version, err := semver.NewVersion(traverseMapAndGet[string](yamlDocument, "metadata.annotations.TKGVERSION")) 452 if err != nil { 453 return nil, fmt.Errorf("could not read TKG version: %s", err) 454 } 455 result.TkgVersion = *version 456 457 cidrBlocks := traverseMapAndGet[[]interface{}](yamlDocument, "spec.clusterNetwork.pods.cidrBlocks") 458 if len(cidrBlocks) == 0 { 459 return nil, fmt.Errorf("expected at least one 'spec.clusterNetwork.pods.cidrBlocks' item") 460 } 461 result.PodCidr = cidrBlocks[0].(string) 462 463 cidrBlocks = traverseMapAndGet[[]interface{}](yamlDocument, "spec.clusterNetwork.services.cidrBlocks") 464 if len(cidrBlocks) == 0 { 465 return nil, fmt.Errorf("expected at least one 'spec.clusterNetwork.services.cidrBlocks' item") 466 } 467 result.ServiceCidr = cidrBlocks[0].(string) 468 case "MachineHealthCheck": 469 // This is quite simple, if we find this document, means that Machine Health Check is enabled 470 result.NodeHealthCheck = true 471 } 472 } 473 result.WorkerPools = make([]CseWorkerPoolSettings, len(workerPools)) 474 i := 0 475 for _, workerPool := range workerPools { 476 result.WorkerPools[i] = workerPool 477 i++ 478 } 479 480 return result, nil 481 } 482 483 // waitUntilClusterIsProvisioned waits for the Kubernetes cluster to be in "provisioned" state, either indefinitely (if timeout = 0) 484 // or until the timeout is reached. 485 // If one of the states of the cluster at a given point is "error", this function also checks whether the cluster has the "AutoRepairOnErrors" flag enabled, 486 // so it keeps waiting if it's true. 487 // If timeout is reached before the cluster is in "provisioned" state, it returns an error. 488 func waitUntilClusterIsProvisioned(client *Client, clusterId string, timeout time.Duration) error { 489 var elapsed time.Duration 490 sleepTime := 10 491 492 start := time.Now() 493 capvcd := &types.Capvcd{} 494 for elapsed <= timeout || timeout == 0 { // If the user specifies timeout=0, we wait forever 495 rde, err := getRdeById(client, clusterId) 496 if err != nil { 497 return err 498 } 499 500 // Here we don't use cseConvertToCseKubernetesClusterType to avoid calling VCD. We only need the state. 501 entityBytes, err := json.Marshal(rde.DefinedEntity.Entity) 502 if err != nil { 503 return fmt.Errorf("could not check the Kubernetes cluster state: %s", err) 504 } 505 err = json.Unmarshal(entityBytes, &capvcd) 506 if err != nil { 507 return fmt.Errorf("could not check the Kubernetes cluster state: %s", err) 508 } 509 510 switch capvcd.Status.VcdKe.State { 511 case "provisioned": 512 return nil 513 case "error": 514 // We just finish if auto-recovery is disabled, otherwise we just let CSE fixing things in background 515 if !capvcd.Spec.VcdKe.AutoRepairOnErrors { 516 // Give feedback about what went wrong 517 errors := "" 518 for _, event := range capvcd.Status.Capvcd.ErrorSet { 519 errors += fmt.Sprintf("%s,\n", event.AdditionalDetails.DetailedError) 520 } 521 return fmt.Errorf("got an error and 'AutoRepairOnErrors' is disabled, aborting. Error events:\n%s", errors) 522 } 523 } 524 525 util.Logger.Printf("[DEBUG] Cluster '%s' is in '%s' state, will check again in %d seconds", rde.DefinedEntity.ID, capvcd.Status.VcdKe.State, sleepTime) 526 elapsed = time.Since(start) 527 time.Sleep(time.Duration(sleepTime) * time.Second) 528 } 529 return fmt.Errorf("timeout of %s reached, latest cluster state obtained was '%s'", timeout, capvcd.Status.VcdKe.State) 530 } 531 532 // validate validates the receiver CseClusterSettings. Returns an error if any of the fields is empty or wrong. 533 func (input *CseClusterSettings) validate() error { 534 if input == nil { 535 return fmt.Errorf("the receiver CseClusterSettings cannot be nil") 536 } 537 // This regular expression is used to validate the constraints placed by Container Service Extension on the names 538 // of the components of the Kubernetes clusters: 539 // Names must contain only lowercase alphanumeric characters or '-', start with an alphabetic character, end with an alphanumeric, and contain at most 31 characters. 540 cseNamesRegex, err := regexp.Compile(`^[a-z](?:[a-z0-9-]{0,29}[a-z0-9])?$`) 541 if err != nil { 542 return fmt.Errorf("could not compile regular expression '%s'", err) 543 } 544 545 _, err = getCseComponentsVersions(input.CseVersion) 546 if err != nil { 547 return err 548 } 549 if !cseNamesRegex.MatchString(input.Name) { 550 return fmt.Errorf("the name '%s' must contain only lowercase alphanumeric characters or '-', start with an alphabetic character, end with an alphanumeric, and contain at most 31 characters", input.Name) 551 } 552 if input.OrganizationId == "" { 553 return fmt.Errorf("the Organization ID is required") 554 } 555 if input.VdcId == "" { 556 return fmt.Errorf("the VDC ID is required") 557 } 558 if input.NetworkId == "" { 559 return fmt.Errorf("the Network ID is required") 560 } 561 if input.KubernetesTemplateOvaId == "" { 562 return fmt.Errorf("the Kubernetes Template OVA ID is required") 563 } 564 if input.ControlPlane.MachineCount < 1 || input.ControlPlane.MachineCount%2 == 0 { 565 return fmt.Errorf("number of Control Plane nodes must be odd and higher than 0, but it was '%d'", input.ControlPlane.MachineCount) 566 } 567 if input.ControlPlane.DiskSizeGi < 20 { 568 return fmt.Errorf("disk size for the Control Plane in Gibibytes (Gi) must be at least 20, but it was '%d'", input.ControlPlane.DiskSizeGi) 569 } 570 if len(input.WorkerPools) == 0 { 571 return fmt.Errorf("there must be at least one Worker Pool") 572 } 573 existingWorkerPools := map[string]bool{} 574 for _, workerPool := range input.WorkerPools { 575 if _, alreadyExists := existingWorkerPools[workerPool.Name]; alreadyExists { 576 return fmt.Errorf("the names of the Worker Pools must be unique, but '%s' is repeated", workerPool.Name) 577 } 578 if workerPool.MachineCount < 1 { 579 return fmt.Errorf("number of Worker Pool '%s' nodes must higher than 0, but it was '%d'", workerPool.Name, workerPool.MachineCount) 580 } 581 if workerPool.DiskSizeGi < 20 { 582 return fmt.Errorf("disk size for the Worker Pool '%s' in Gibibytes (Gi) must be at least 20, but it was '%d'", workerPool.Name, workerPool.DiskSizeGi) 583 } 584 if !cseNamesRegex.MatchString(workerPool.Name) { 585 return fmt.Errorf("the Worker Pool name '%s' must contain only lowercase alphanumeric characters or '-', start with an alphabetic character, end with an alphanumeric, and contain at most 31 characters", workerPool.Name) 586 } 587 existingWorkerPools[workerPool.Name] = true 588 } 589 if input.DefaultStorageClass != nil { // This field is optional 590 if !cseNamesRegex.MatchString(input.DefaultStorageClass.Name) { 591 return fmt.Errorf("the Default Storage Class name '%s' must contain only lowercase alphanumeric characters or '-', start with an alphabetic character, end with an alphanumeric, and contain at most 31 characters", input.DefaultStorageClass.Name) 592 } 593 if input.DefaultStorageClass.StorageProfileId == "" { 594 return fmt.Errorf("the Storage Profile ID for the Default Storage Class is required") 595 } 596 if input.DefaultStorageClass.ReclaimPolicy != "delete" && input.DefaultStorageClass.ReclaimPolicy != "retain" { 597 return fmt.Errorf("the Reclaim Policy for the Default Storage Class must be either 'delete' or 'retain', but it was '%s'", input.DefaultStorageClass.ReclaimPolicy) 598 } 599 if input.DefaultStorageClass.Filesystem != "ext4" && input.DefaultStorageClass.ReclaimPolicy != "xfs" { 600 return fmt.Errorf("the filesystem for the Default Storage Class must be either 'ext4' or 'xfs', but it was '%s'", input.DefaultStorageClass.Filesystem) 601 } 602 } 603 if input.ApiToken == "" { 604 return fmt.Errorf("the API token is required") 605 } 606 if input.PodCidr == "" { 607 return fmt.Errorf("the Pod CIDR is required") 608 } 609 if _, _, err := net.ParseCIDR(input.PodCidr); err != nil { 610 return fmt.Errorf("the Pod CIDR is malformed: %s", err) 611 } 612 if input.ServiceCidr == "" { 613 return fmt.Errorf("the Service CIDR is required") 614 } 615 if _, _, err := net.ParseCIDR(input.ServiceCidr); err != nil { 616 return fmt.Errorf("the Service CIDR is malformed: %s", err) 617 } 618 if input.VirtualIpSubnet != "" { 619 if _, _, err := net.ParseCIDR(input.VirtualIpSubnet); err != nil { 620 return fmt.Errorf("the Virtual IP Subnet is malformed: %s", err) 621 } 622 } 623 if input.ControlPlane.Ip != "" { 624 if r := net.ParseIP(input.ControlPlane.Ip); r == nil { 625 return fmt.Errorf("the Control Plane IP is malformed: %s", input.ControlPlane.Ip) 626 } 627 } 628 return nil 629 } 630 631 // toCseClusterSettingsInternal transforms user input data (CseClusterSettings) into the final payload that 632 // will be used to define a Container Service Extension Kubernetes cluster (cseClusterSettingsInternal). 633 // 634 // For example, the most relevant transformation is the change of the item IDs that are present in CseClusterSettings 635 // (such as CseClusterSettings.KubernetesTemplateOvaId) to their corresponding Names (e.g. cseClusterSettingsInternal.KubernetesTemplateOvaName), 636 // which are the identifiers that Container Service Extension uses internally. 637 func (input *CseClusterSettings) toCseClusterSettingsInternal(org Org) (*cseClusterSettingsInternal, error) { 638 err := input.validate() 639 if err != nil { 640 return nil, err 641 } 642 643 output := &cseClusterSettingsInternal{} 644 if org.Org == nil { 645 return nil, fmt.Errorf("could not retrieve the Organization, it is nil") 646 } 647 output.OrganizationName = org.Org.Name 648 649 vdc, err := org.GetVDCById(input.VdcId, true) 650 if err != nil { 651 return nil, fmt.Errorf("could not retrieve the VDC with ID '%s': %s", input.VdcId, err) 652 } 653 output.VdcName = vdc.Vdc.Name 654 655 vAppTemplate, err := getVAppTemplateById(org.client, input.KubernetesTemplateOvaId) 656 if err != nil { 657 return nil, fmt.Errorf("could not retrieve the Kubernetes Template OVA with ID '%s': %s", input.KubernetesTemplateOvaId, err) 658 } 659 output.KubernetesTemplateOvaName = vAppTemplate.VAppTemplate.Name 660 661 tkgVersions, err := getTkgVersionBundleFromVAppTemplate(vAppTemplate.VAppTemplate) 662 if err != nil { 663 return nil, fmt.Errorf("could not retrieve the required information from the Kubernetes Template OVA: %s", err) 664 } 665 output.TkgVersionBundle = tkgVersions 666 667 catalogName, err := vAppTemplate.GetCatalogName() 668 if err != nil { 669 return nil, fmt.Errorf("could not retrieve the Catalog name where the the Kubernetes Template OVA '%s' (%s) is hosted: %s", input.KubernetesTemplateOvaId, vAppTemplate.VAppTemplate.Name, err) 670 } 671 output.CatalogName = catalogName 672 673 network, err := vdc.GetOrgVdcNetworkById(input.NetworkId, true) 674 if err != nil { 675 return nil, fmt.Errorf("could not retrieve the Org VDC Network with ID '%s': %s", input.NetworkId, err) 676 } 677 output.NetworkName = network.OrgVDCNetwork.Name 678 679 cseComponentsVersions, err := getCseComponentsVersions(input.CseVersion) 680 if err != nil { 681 return nil, err 682 } 683 rdeType, err := getRdeType(org.client, cseKubernetesClusterVendor, cseKubernetesClusterNamespace, cseComponentsVersions.CapvcdRdeTypeVersion) 684 if err != nil { 685 return nil, err 686 } 687 output.RdeType = rdeType.DefinedEntityType 688 689 // Gather all the IDs of the Compute Policies and Storage Profiles, so we can transform them to Names in bulk. 690 var computePolicyIds []string 691 var storageProfileIds []string 692 for _, w := range input.WorkerPools { 693 computePolicyIds = append(computePolicyIds, w.SizingPolicyId, w.PlacementPolicyId, w.VGpuPolicyId) 694 storageProfileIds = append(storageProfileIds, w.StorageProfileId) 695 } 696 computePolicyIds = append(computePolicyIds, input.ControlPlane.SizingPolicyId, input.ControlPlane.PlacementPolicyId) 697 storageProfileIds = append(storageProfileIds, input.ControlPlane.StorageProfileId) 698 if input.DefaultStorageClass != nil { 699 storageProfileIds = append(storageProfileIds, input.DefaultStorageClass.StorageProfileId) 700 } 701 702 idToNameCache, err := idToNames(org.client, computePolicyIds, storageProfileIds) 703 if err != nil { 704 return nil, err 705 } 706 707 // Now that everything is cached in memory, we can build the Node pools and Storage Class payloads in a trivial way. 708 output.WorkerPools = make([]cseWorkerPoolSettingsInternal, len(input.WorkerPools)) 709 for i, w := range input.WorkerPools { 710 output.WorkerPools[i] = cseWorkerPoolSettingsInternal{ 711 Name: w.Name, 712 MachineCount: w.MachineCount, 713 DiskSizeGi: w.DiskSizeGi, 714 } 715 output.WorkerPools[i].SizingPolicyName = idToNameCache[w.SizingPolicyId] 716 output.WorkerPools[i].PlacementPolicyName = idToNameCache[w.PlacementPolicyId] 717 output.WorkerPools[i].VGpuPolicyName = idToNameCache[w.VGpuPolicyId] 718 output.WorkerPools[i].StorageProfileName = idToNameCache[w.StorageProfileId] 719 } 720 output.ControlPlane = cseControlPlaneSettingsInternal{ 721 MachineCount: input.ControlPlane.MachineCount, 722 DiskSizeGi: input.ControlPlane.DiskSizeGi, 723 SizingPolicyName: idToNameCache[input.ControlPlane.SizingPolicyId], 724 PlacementPolicyName: idToNameCache[input.ControlPlane.PlacementPolicyId], 725 StorageProfileName: idToNameCache[input.ControlPlane.StorageProfileId], 726 Ip: input.ControlPlane.Ip, 727 } 728 729 if input.DefaultStorageClass != nil { 730 output.DefaultStorageClass = cseDefaultStorageClassInternal{ 731 StorageProfileName: idToNameCache[input.DefaultStorageClass.StorageProfileId], 732 Name: input.DefaultStorageClass.Name, 733 Filesystem: input.DefaultStorageClass.Filesystem, 734 } 735 output.DefaultStorageClass.UseDeleteReclaimPolicy = false 736 if input.DefaultStorageClass.ReclaimPolicy == "delete" { 737 output.DefaultStorageClass.UseDeleteReclaimPolicy = true 738 } 739 } 740 741 vcdKeConfig, err := getVcdKeConfig(org.client, cseComponentsVersions.VcdKeConfigRdeTypeVersion, input.NodeHealthCheck) 742 if err != nil { 743 return nil, err 744 } 745 output.VcdKeConfig = vcdKeConfig 746 747 output.Owner = input.Owner 748 if input.Owner == "" { 749 sessionInfo, err := org.client.GetSessionInfo() 750 if err != nil { 751 return nil, fmt.Errorf("error getting the Owner: %s", err) 752 } 753 output.Owner = sessionInfo.User.Name 754 } 755 756 output.VcdUrl = strings.Replace(org.client.VCDHREF.String(), "/api", "", 1) 757 758 // These don't change, don't need mapping 759 output.ApiToken = input.ApiToken 760 output.AutoRepairOnErrors = input.AutoRepairOnErrors 761 output.CseVersion = input.CseVersion 762 output.Name = input.Name 763 output.PodCidr = input.PodCidr 764 output.ServiceCidr = input.ServiceCidr 765 output.SshPublicKey = input.SshPublicKey 766 output.VirtualIpSubnet = input.VirtualIpSubnet 767 768 return output, nil 769 } 770 771 // getTkgVersionBundleFromVAppTemplate returns a tkgVersionBundle with the details of 772 // all the Kubernetes cluster components versions given a valid Kubernetes Template OVA. 773 // If it is not a valid Kubernetes Template OVA, returns an error. 774 func getTkgVersionBundleFromVAppTemplate(template *types.VAppTemplate) (tkgVersionBundle, error) { 775 result := tkgVersionBundle{} 776 if template == nil { 777 return result, fmt.Errorf("the Kubernetes Template OVA is nil") 778 } 779 if template.Children == nil || len(template.Children.VM) == 0 { 780 return result, fmt.Errorf("the Kubernetes Template OVA '%s' doesn't have any child VM", template.Name) 781 } 782 if template.Children.VM[0].ProductSection == nil { 783 return result, fmt.Errorf("the Product section of the Kubernetes Template OVA '%s' is empty, can't proceed", template.Name) 784 } 785 id := "" 786 for _, prop := range template.Children.VM[0].ProductSection.Property { 787 if prop != nil && prop.Key == "VERSION" { 788 id = prop.DefaultValue // Use DefaultValue and not Value as the value we want is in the "value" attr 789 } 790 } 791 if id == "" { 792 return result, fmt.Errorf("could not find any VERSION property inside the Kubernetes Template OVA '%s' Product section", template.Name) 793 } 794 795 tkgVersionsMap := "cse/tkg_versions.json" 796 cseTkgVersionsJson, err := cseFiles.ReadFile(tkgVersionsMap) 797 if err != nil { 798 return result, fmt.Errorf("failed reading %s: %s", tkgVersionsMap, err) 799 } 800 801 versionsMap := map[string]interface{}{} 802 err = json.Unmarshal(cseTkgVersionsJson, &versionsMap) 803 if err != nil { 804 return result, fmt.Errorf("failed unmarshalling %s: %s", tkgVersionsMap, err) 805 } 806 versionMap, ok := versionsMap[id] 807 if !ok { 808 return result, fmt.Errorf("the Kubernetes Template OVA '%s' is not supported", template.Name) 809 } 810 811 // We don't need to check the Split result because the map checking above guarantees that the ID is well-formed. 812 idParts := strings.Split(id, "-") 813 result.KubernetesVersion = idParts[0] 814 result.TkrVersion = versionMap.(map[string]interface{})["tkr"].(string) 815 result.TkgVersion = versionMap.(map[string]interface{})["tkg"].(string) 816 result.EtcdVersion = versionMap.(map[string]interface{})["etcd"].(string) 817 result.CoreDnsVersion = versionMap.(map[string]interface{})["coreDns"].(string) 818 return result, nil 819 } 820 821 // compareTkgVersion returns -1, 0 or 1 if the receiver TKG version is less than, equal or higher to the input TKG version. 822 // If they cannot be compared it returns -2. 823 func (tkgVersions tkgVersionBundle) compareTkgVersion(tkgVersion string) int { 824 receiverVersion, err := semver.NewVersion(tkgVersions.TkgVersion) 825 if err != nil { 826 return -2 827 } 828 inputVersion, err := semver.NewVersion(tkgVersion) 829 if err != nil { 830 return -2 831 } 832 return receiverVersion.Compare(inputVersion) 833 } 834 835 // kubernetesVersionIsUpgradeableFrom returns true either if the receiver Kubernetes version is exactly one minor version higher 836 // than the given input version (being the minor digit the 'Y' in 'X.Y.Z') or if the minor is the same, but the patch is higher 837 // (being the minor digit the 'Z' in 'X.Y.Z'). 838 // Any malformed version returns false. 839 // Examples: 840 // * "1.19.2".kubernetesVersionIsUpgradeableFrom("1.18.7") = true 841 // * "1.19.2".kubernetesVersionIsUpgradeableFrom("1.19.2") = false 842 // * "1.19.2".kubernetesVersionIsUpgradeableFrom("1.19.0") = true 843 // * "1.19.10".kubernetesVersionIsUpgradeableFrom("1.18.0") = true 844 // * "1.20.2".kubernetesVersionIsUpgradeableFrom("1.18.7") = false 845 // * "1.21.2".kubernetesVersionIsUpgradeableFrom("1.18.7") = false 846 // * "1.18.0".kubernetesVersionIsUpgradeableFrom("1.18.7") = false 847 func (tkgVersions tkgVersionBundle) kubernetesVersionIsUpgradeableFrom(kubernetesVersion string) bool { 848 upgradeToVersion, err := semver.NewVersion(tkgVersions.KubernetesVersion) 849 if err != nil { 850 return false 851 } 852 fromVersion, err := semver.NewVersion(kubernetesVersion) 853 if err != nil { 854 return false 855 } 856 857 if upgradeToVersion.Equal(fromVersion) { 858 return false 859 } 860 861 upgradeToVersionSegments := upgradeToVersion.Segments() 862 if len(upgradeToVersionSegments) < 2 { 863 return false 864 } 865 fromVersionSegments := fromVersion.Segments() 866 if len(fromVersionSegments) < 2 { 867 return false 868 } 869 870 majorIsEqual := upgradeToVersionSegments[0] == fromVersionSegments[0] 871 minorIsJustOneHigher := upgradeToVersionSegments[1]-1 == fromVersionSegments[1] 872 minorIsEqual := upgradeToVersionSegments[1] == fromVersionSegments[1] 873 patchIsHigher := upgradeToVersionSegments[2] > fromVersionSegments[2] 874 875 return majorIsEqual && (minorIsJustOneHigher || (minorIsEqual && patchIsHigher)) 876 } 877 878 // getVcdKeConfig gets the required information from the CSE Server configuration RDE (VCDKEConfig), such as the 879 // Machine Health Check settings and the Container Registry URL. 880 func getVcdKeConfig(client *Client, vcdKeConfigVersion string, retrieveMachineHealtchCheckInfo bool) (vcdKeConfig, error) { 881 result := vcdKeConfig{} 882 rdes, err := getRdesByName(client, "vmware", "VCDKEConfig", vcdKeConfigVersion, "vcdKeConfig") 883 if err != nil { 884 return result, err 885 } 886 if len(rdes) != 1 { 887 return result, fmt.Errorf("expected exactly one VCDKEConfig RDE with version '%s', but got %d", vcdKeConfigVersion, len(rdes)) 888 } 889 890 profiles, ok := rdes[0].DefinedEntity.Entity["profiles"].([]interface{}) 891 if !ok { 892 return result, fmt.Errorf("wrong format of VCDKEConfig RDE contents, expected a 'profiles' array") 893 } 894 if len(profiles) == 0 { 895 return result, fmt.Errorf("wrong format of VCDKEConfig RDE contents, expected a non-empty 'profiles' element") 896 } 897 898 // We append /tkg as required, even in air-gapped environments: 899 // https://docs.vmware.com/en/VMware-Cloud-Director-Container-Service-Extension/4.2/VMware-Cloud-Director-Container-Service-Extension-Install-provider-4.2/GUID-B5C19221-2ECA-4DCD-8EA1-8E391F6217C1.html 900 result.ContainerRegistryUrl = fmt.Sprintf("%s/tkg", profiles[0].(map[string]interface{})["containerRegistryUrl"]) 901 902 k8sConfig, ok := profiles[0].(map[string]interface{})["K8Config"].(map[string]interface{}) 903 if !ok { 904 return result, fmt.Errorf("wrong format of VCDKEConfig RDE contents, expected a 'K8Config' object") 905 } 906 certificates, ok := k8sConfig["certificateAuthorities"] 907 if ok { 908 result.Base64Certificates = make([]string, len(certificates.([]interface{}))) 909 for i, certificate := range certificates.([]interface{}) { 910 result.Base64Certificates[i] = base64.StdEncoding.EncodeToString([]byte(certificate.(string))) 911 } 912 } 913 914 if retrieveMachineHealtchCheckInfo { 915 mhc, ok := profiles[0].(map[string]interface{})["K8Config"].(map[string]interface{})["mhc"] 916 if !ok { 917 // If there is no "mhc" entry in the VCDKEConfig JSON, we skip setting this part of the Kubernetes cluster configuration 918 return result, nil 919 } 920 result.MaxUnhealthyNodesPercentage = mhc.(map[string]interface{})["maxUnhealthyNodes"].(float64) 921 result.NodeStartupTimeout = mhc.(map[string]interface{})["nodeStartupTimeout"].(string) 922 result.NodeNotReadyTimeout = mhc.(map[string]interface{})["nodeUnknownTimeout"].(string) 923 result.NodeUnknownTimeout = mhc.(map[string]interface{})["nodeNotReadyTimeout"].(string) 924 } 925 926 return result, nil 927 } 928 929 // idToNames returns a map that associates Compute Policies/Storage Profiles IDs with their respective names. 930 // This is useful as the input to create/update a cluster uses different entities IDs, but CSE cluster creation/update process uses Names. 931 // For that reason, we need to transform IDs to Names by querying VCD 932 func idToNames(client *Client, computePolicyIds, storageProfileIds []string) (map[string]string, error) { 933 result := map[string]string{ 934 "": "", // Default empty value to map optional values that were not set, to avoid extra checks. For example, an empty vGPU Policy. 935 } 936 // Retrieve the Compute Policies and Storage Profiles names and put them in the resulting map. This map also can 937 // be used to reduce the calls to VCD. The URN format used by VCD guarantees that IDs are unique, so there is no possibility of clashes here. 938 for _, id := range storageProfileIds { 939 if _, alreadyPresent := result[id]; !alreadyPresent { 940 storageProfile, err := getStorageProfileById(client, id) 941 if err != nil { 942 return nil, fmt.Errorf("could not retrieve Storage Profile with ID '%s': %s", id, err) 943 } 944 result[id] = storageProfile.Name 945 } 946 } 947 for _, id := range computePolicyIds { 948 if _, alreadyPresent := result[id]; !alreadyPresent { 949 computePolicy, err := getVdcComputePolicyV2ById(client, id) 950 if err != nil { 951 return nil, fmt.Errorf("could not retrieve Compute Policy with ID '%s': %s", id, err) 952 } 953 result[id] = computePolicy.VdcComputePolicyV2.Name 954 } 955 } 956 return result, nil 957 } 958 959 // getCseTemplate reads the Go template present in the embedded cseFiles filesystem. 960 func getCseTemplate(cseVersion semver.Version, templateName string) (string, error) { 961 minimumVersion, err := semver.NewVersion("4.1") 962 if err != nil { 963 return "", err 964 } 965 if cseVersion.LessThan(minimumVersion) { 966 return "", fmt.Errorf("the Container Service minimum version is '%s'", minimumVersion.String()) 967 } 968 versionSegments := cseVersion.Segments() 969 // We try with major.minor.patch 970 fullTemplatePath := fmt.Sprintf("cse/%d.%d.%d/%s.tmpl", versionSegments[0], versionSegments[1], versionSegments[2], templateName) 971 result, err := cseFiles.ReadFile(fullTemplatePath) 972 if err != nil { 973 // We try now just with major.minor 974 fullTemplatePath = fmt.Sprintf("cse/%d.%d/%s.tmpl", versionSegments[0], versionSegments[1], templateName) 975 result, err = cseFiles.ReadFile(fullTemplatePath) 976 if err != nil { 977 return "", fmt.Errorf("could not read Go template '%s.tmpl' for CSE version %s", templateName, cseVersion.String()) 978 } 979 } 980 return string(result), nil 981 }