diff --git a/.changes/v1.0.0/11-features.md b/.changes/v1.0.0/11-features.md new file mode 100644 index 0000000..b1eb617 --- /dev/null +++ b/.changes/v1.0.0/11-features.md @@ -0,0 +1,3 @@ +* **New Data Source:** `vcfa_edge_cluster` to read and sync Edge Clusters [GH-11] +* **New Resource:** `vcfa_edge_cluster_qos` to manage QoS settings for Edge Clusters [GH-11] +* **New Data Source:** `vcfa_edge_cluster_qos` to read QoS settings for Edge Clusters [GH-11] diff --git a/.changes/v1.0.0/5-features.md b/.changes/v1.0.0/5-features.md index 89c5660..88aa893 100644 --- a/.changes/v1.0.0/5-features.md +++ b/.changes/v1.0.0/5-features.md @@ -1,4 +1,4 @@ -- **New Resource:** `vcfa_region` to manage Regions [GH-5] +- **New Resource:** `vcfa_region` to manage Regions [GH-5, GH-11] - **New Data Source:** `vcfa_region` to read Regions [GH-5] - **New Data Source:** `vcfa_supervisor` to read Supervisors [GH-5] - **New Data Source:** `vcfa_supervisor_zone` to read Supervisor Zones [GH-5] diff --git a/vcfa/datasource_not_found_test.go b/vcfa/datasource_not_found_test.go index 1d2d704..d08339a 100644 --- a/vcfa/datasource_not_found_test.go +++ b/vcfa/datasource_not_found_test.go @@ -148,6 +148,8 @@ func addMandatoryParams(dataSourceName string, mandatoryFields []string, t *test templateFields = templateFields + `region_id = "urn:vcloud:region:12345678-1234-1234-1234-123456789012"` + "\n" case "org_id": templateFields = templateFields + `org_id = "urn:vcloud:org:12345678-1234-1234-1234-123456789012"` + "\n" + case "edge_cluster_id": + templateFields = templateFields + `edge_cluster_id = "urn:vcloud:edgeCluster:12345678-1234-1234-1234-123456789012"` + "\n" } } diff --git a/vcfa/datasource_vcfa_edge_cluster.go b/vcfa/datasource_vcfa_edge_cluster.go new file mode 100644 index 0000000..64f48a0 --- /dev/null +++ b/vcfa/datasource_vcfa_edge_cluster.go @@ -0,0 +1,129 @@ +package vcfa + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/vmware/go-vcloud-director/v3/govcd" + "github.com/vmware/go-vcloud-director/v3/types/v56" +) + +const labelVcfaEdgeCluster = "Edge Cluster" +const labelVcfaEdgeClusterSync = "Edge Cluster Sync" + +func datasourceVcfaEdgeCluster() *schema.Resource { + return &schema.Resource{ + ReadContext: datasourceVcfaEdgeClusterRead, + + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Required: true, + Description: fmt.Sprintf("Name %s", labelVcfaEdgeCluster), + }, + "region_id": { + Type: schema.TypeString, + Required: true, + Description: fmt.Sprintf("Region ID of %s", labelVcfaEdgeCluster), + }, + "sync_before_read": { + Type: schema.TypeBool, + Optional: true, + Default: false, + Description: fmt.Sprintf("Will trigger SYNC operation before looking for a given %s", labelVcfaEdgeCluster), + }, + "node_count": { + Type: schema.TypeInt, + Computed: true, + Description: fmt.Sprintf("Node count in %s", labelVcfaEdgeCluster), + }, + "org_count": { + Type: schema.TypeInt, + Computed: true, + Description: fmt.Sprintf("Org count %s", labelVcfaEdgeCluster), + }, + "vpc_count": { + Type: schema.TypeInt, + Computed: true, + Description: fmt.Sprintf("VPC count %s", labelVcfaEdgeCluster), + }, + "average_cpu_usage_percentage": { + Type: schema.TypeFloat, + Computed: true, + Description: fmt.Sprintf("Average CPU Usage percentage of %s ", labelVcfaEdgeCluster), + }, + "average_memory_usage_percentage": { + Type: schema.TypeFloat, + Computed: true, + Description: fmt.Sprintf("Average Memory Usage percentage of %s ", labelVcfaEdgeCluster), + }, + "health_status": { + Type: schema.TypeString, + Computed: true, + Description: fmt.Sprintf("Health status of %s", labelVcfaEdgeCluster), + }, + "status": { + Type: schema.TypeString, + Computed: true, + Description: fmt.Sprintf("Status of %s", labelVcfaEdgeCluster), + }, + "deployment_type": { + Type: schema.TypeString, + Computed: true, + Description: fmt.Sprintf("Deployment type of %s", labelVcfaEdgeCluster), + }, + }, + } +} + +func datasourceVcfaEdgeClusterRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + vcdClient := meta.(*VCDClient) + + regionId := d.Get("region_id").(string) + getByName := func(name string) (*govcd.TmEdgeCluster, error) { + return vcdClient.GetTmEdgeClusterByNameAndRegionId(name, regionId) + } + + c := dsReadConfig[*govcd.TmEdgeCluster, types.TmEdgeCluster]{ + entityLabel: labelVcfaEdgeCluster, + getEntityFunc: getByName, + stateStoreFunc: setTmEdgeClusterData, + preReadHooks: []schemaHook{syncTmEdgeClustersBeforeReadHook}, + } + return readDatasource(ctx, d, meta, c) +} + +func setTmEdgeClusterData(_ *VCDClient, d *schema.ResourceData, t *govcd.TmEdgeCluster) error { + if t == nil || t.TmEdgeCluster == nil { + return fmt.Errorf("empty %s received", labelVcfaEdgeCluster) + } + + d.SetId(t.TmEdgeCluster.ID) + dSet(d, "status", t.TmEdgeCluster.Status) + dSet(d, "health_status", t.TmEdgeCluster.HealthStatus) + + dSet(d, "region_id", "") + if t.TmEdgeCluster.RegionRef != nil { + dSet(d, "region_id", t.TmEdgeCluster.RegionRef.ID) + } + dSet(d, "deployment_type", t.TmEdgeCluster.DeploymentType) + dSet(d, "node_count", t.TmEdgeCluster.NodeCount) + dSet(d, "org_count", t.TmEdgeCluster.OrgCount) + dSet(d, "vpc_count", t.TmEdgeCluster.VpcCount) + dSet(d, "average_cpu_usage_percentage", t.TmEdgeCluster.AvgCPUUsagePercentage) + dSet(d, "average_memory_usage_percentage", t.TmEdgeCluster.AvgMemoryUsagePercentage) + + return nil +} + +func syncTmEdgeClustersBeforeReadHook(vcdClient *VCDClient, d *schema.ResourceData) error { + if d.Get("sync_before_read").(bool) { + err := vcdClient.TmSyncEdgeClusters() + if err != nil { + return fmt.Errorf("error syncing %s before lookup: %s", labelVcfaEdgeClusterSync, err) + } + } + return nil +} diff --git a/vcfa/datasource_vcfa_edge_cluster_qos.go b/vcfa/datasource_vcfa_edge_cluster_qos.go new file mode 100644 index 0000000..e69e5f1 --- /dev/null +++ b/vcfa/datasource_vcfa_edge_cluster_qos.go @@ -0,0 +1,62 @@ +package vcfa + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/vmware/go-vcloud-director/v3/govcd" + "github.com/vmware/go-vcloud-director/v3/types/v56" +) + +func datasourceVcfaEdgeClusterQos() *schema.Resource { + return &schema.Resource{ + ReadContext: datasourceVcfaEdgeClusterQosRead, + + Schema: map[string]*schema.Schema{ + "edge_cluster_id": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: fmt.Sprintf("ID of %s", labelVcfaEdgeCluster), + }, + "region_id": { + Type: schema.TypeString, + Computed: true, + Description: fmt.Sprintf("Region ID of %s", labelVcfaEdgeCluster), + }, + "ingress_committed_bandwidth_mbps": { + Type: schema.TypeString, + Computed: true, + Description: fmt.Sprintf("Ingress committed bandwidth in Mbps for %s", labelVcfaEdgeCluster), + }, + "ingress_burst_size_bytes": { + Type: schema.TypeString, + Computed: true, + Description: fmt.Sprintf("Ingress burst size bytes for %s", labelVcfaEdgeCluster), + }, + "egress_committed_bandwidth_mbps": { + Type: schema.TypeString, + Computed: true, + Description: fmt.Sprintf("Egress committed bandwidth in Mbps for %s", labelVcfaEdgeCluster), + }, + "egress_burst_size_bytes": { + Type: schema.TypeString, + Computed: true, + Description: fmt.Sprintf("Egress burst size bytes for %s", labelVcfaEdgeCluster), + }, + }, + } +} + +func datasourceVcfaEdgeClusterQosRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + vcdClient := meta.(*VCDClient) + c := dsReadConfig[*govcd.TmEdgeCluster, types.TmEdgeCluster]{ + entityLabel: labelVcfaEdgeClusterQos, + stateStoreFunc: setTmEdgeClusterQosData, + overrideDefaultNameField: "edge_cluster_id", // pass the value of this field to getEntityFunc + getEntityFunc: vcdClient.GetTmEdgeClusterById, + } + return readDatasource(ctx, d, meta, c) +} diff --git a/vcfa/provider.go b/vcfa/provider.go index fe0af45..d10eff0 100644 --- a/vcfa/provider.go +++ b/vcfa/provider.go @@ -47,6 +47,8 @@ var globalDataSourceMap = map[string]*schema.Resource{ "vcfa_content_library": datasourceVcfaContentLibrary(), // 1.0 "vcfa_tier0_gateway": datasourceVcfaTier0Gateway(), // 1.0 "vcfa_provider_gateway": datasourceVcfaProviderGateway(), // 1.0 + "vcfa_edge_cluster": datasourceVcfaEdgeCluster(), // 1.0 + "vcfa_edge_cluster_qos": datasourceVcfaEdgeClusterQos(), // 1.0 } var globalResourceMap = map[string]*schema.Resource{ @@ -58,6 +60,7 @@ var globalResourceMap = map[string]*schema.Resource{ "vcfa_org_vdc": resourceVcfaOrgVdc(), // 1.0 "vcfa_content_library": resourceVcfaContentLibrary(), // 1.0 "vcfa_provider_gateway": resourceVcfaProviderGateway(), // 1.0 + "vcfa_edge_cluster_qos": resourceVcfaEdgeClusterQos(), // 1.0 } // Provider returns a terraform.ResourceProvider. diff --git a/vcfa/remove_leftovers_test.go b/vcfa/remove_leftovers_test.go index ef9be10..6fcc172 100644 --- a/vcfa/remove_leftovers_test.go +++ b/vcfa/remove_leftovers_test.go @@ -50,6 +50,26 @@ func removeLeftovers(govcdClient *govcd.VCDClient, verbose bool) error { fmt.Printf("Start leftovers removal\n") } + // -------------------------------------------------------------- + // Edge Cluster QoS (Edge Clusters themselves are read-only) + // -------------------------------------------------------------- + if govcdClient.Client.IsSysAdmin { + allEcs, err := govcdClient.GetAllTmEdgeClusters(nil) + if err != nil { + return fmt.Errorf("error retrieving Edge Clusters: %s", err) + } + for _, ec := range allEcs { + toBeDeleted := shouldDeleteEntity(alsoDelete, doNotDelete, ec.TmEdgeCluster.Name, "vcfa_edge_cluster_qos", 2, verbose) + if toBeDeleted { + fmt.Printf("\t REMOVING Edge Cluster QoS Settings %s\n", ec.TmEdgeCluster.Name) + err := ec.Delete() + if err != nil { + return fmt.Errorf("error deleting %s '%s': %s", labelVcfaEdgeClusterQos, ec.TmEdgeCluster.Name, err) + } + } + } + } + // -------------------------------------------------------------- // Content Libraries // -------------------------------------------------------------- @@ -198,6 +218,10 @@ func removeLeftovers(govcdClient *govcd.VCDClient, verbose bool) error { } } + if verbose { + fmt.Printf("End leftovers removal\n") + } + return nil } diff --git a/vcfa/resource_vcfa_edge_cluster_qos.go b/vcfa/resource_vcfa_edge_cluster_qos.go new file mode 100644 index 0000000..224ab71 --- /dev/null +++ b/vcfa/resource_vcfa_edge_cluster_qos.go @@ -0,0 +1,239 @@ +package vcfa + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + "github.com/vmware/go-vcloud-director/v3/govcd" + "github.com/vmware/go-vcloud-director/v3/types/v56" +) + +const labelVcfaEdgeClusterQos = "Edge Cluster QoS" + +func resourceVcfaEdgeClusterQos() *schema.Resource { + return &schema.Resource{ + CreateContext: resourceVcfaEdgeClusterQosCreate, + ReadContext: resourceVcfaEdgeClusterQosRead, + UpdateContext: resourceVcfaEdgeClusterQosUpdate, + DeleteContext: resourceVcfaEdgeClusterQosDelete, + Importer: &schema.ResourceImporter{ + StateContext: resourceVcfaEdgeClusterQosImport, + }, + + Schema: map[string]*schema.Schema{ + "edge_cluster_id": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: fmt.Sprintf("ID of %s", labelVcfaEdgeCluster), + }, + "region_id": { + Type: schema.TypeString, + Computed: true, + Description: fmt.Sprintf("Region ID of %s", labelVcfaEdgeCluster), + }, + "ingress_committed_bandwidth_mbps": { + Type: schema.TypeString, // string + validation due to usual problem of differentiation between 0 and empty value for TypeInt + Optional: true, + Description: fmt.Sprintf("Ingress committed bandwidth in Mbps for %s", labelVcfaEdgeCluster), + ValidateDiagFunc: validation.AnyDiag( + validation.ToDiagFunc(validation.StringIsEmpty), + IsIntAndAtLeast(-1), // -1 is unlimited + ), + Default: -1, // -1 is the default value, which means unlimited + RequiredWith: []string{"ingress_burst_size_bytes"}, + }, + "ingress_burst_size_bytes": { + Type: schema.TypeString, // string + validation due to usual problem of differentiation between 0 and empty value for TypeInt + Optional: true, + Description: fmt.Sprintf("Ingress burst size bytes for %s", labelVcfaEdgeCluster), + ValidateDiagFunc: validation.AnyDiag( + validation.ToDiagFunc(validation.StringIsEmpty), + IsIntAndAtLeast(-1), // -1 is unlimited + ), + Default: -1, // -1 is the default value, which means unlimited + RequiredWith: []string{"ingress_committed_bandwidth_mbps"}, + }, + "egress_committed_bandwidth_mbps": { + Type: schema.TypeString, // string + validation due to usual problem of differentiation between 0 and empty value for TypeInt + Optional: true, + Description: fmt.Sprintf("Egress committed bandwidth in Mbps for %s", labelVcfaEdgeCluster), + ValidateDiagFunc: validation.AnyDiag( + validation.ToDiagFunc(validation.StringIsEmpty), + IsIntAndAtLeast(-1), // -1 is unlimited + ), + Default: -1, // -1 is the default value, which means unlimited + RequiredWith: []string{"egress_burst_size_bytes"}, + }, + "egress_burst_size_bytes": { + Type: schema.TypeString, // string + validation due to usual problem of differentiation between 0 and empty value for TypeInt + Optional: true, + Description: fmt.Sprintf("Ingress burst size bytes for %s", labelVcfaEdgeCluster), + ValidateDiagFunc: validation.AnyDiag( + validation.ToDiagFunc(validation.StringIsEmpty), + IsIntAndAtLeast(-1), // -1 is unlimited + ), + Default: -1, // -1 is the default value, which means unlimited + RequiredWith: []string{"egress_committed_bandwidth_mbps"}, + }, + }, + } +} + +func resourceVcfaEdgeClusterQosCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + vcdClient := meta.(*VCDClient) + + // The Edge Cluster is already existing that is handled by 'vcfa_edge_cluster' data source. + // This is not a "real" entity creation, rather a lookup and update of existing one + createQosConfigInEdgeCluster := func(config *types.TmEdgeCluster) (*govcd.TmEdgeCluster, error) { + ec, err := vcdClient.GetTmEdgeClusterById(d.Get("edge_cluster_id").(string)) + if err != nil { + return nil, fmt.Errorf("error looking up %s by ID: %s", labelVcfaEdgeCluster, err) + } + return ec.Update(config) + } + + c := crudConfig[*govcd.TmEdgeCluster, types.TmEdgeCluster]{ + entityLabel: labelVcfaEdgeClusterQos, + getTypeFunc: getTmEdgeClusterQosType, + stateStoreFunc: setTmEdgeClusterQosData, + createFunc: createQosConfigInEdgeCluster, + resourceReadFunc: resourceVcfaEdgeClusterQosRead, + } + return createResource(ctx, d, meta, c) +} + +func resourceVcfaEdgeClusterQosUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + vcdClient := meta.(*VCDClient) + c := crudConfig[*govcd.TmEdgeCluster, types.TmEdgeCluster]{ + entityLabel: labelVcfaEdgeClusterQos, + getTypeFunc: getTmEdgeClusterQosType, + getEntityFunc: vcdClient.GetTmEdgeClusterById, + resourceReadFunc: resourceVcfaEdgeClusterQosRead, + } + + return updateResource(ctx, d, meta, c) +} + +func resourceVcfaEdgeClusterQosRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + vcdClient := meta.(*VCDClient) + c := crudConfig[*govcd.TmEdgeCluster, types.TmEdgeCluster]{ + entityLabel: labelVcfaEdgeClusterQos, + getEntityFunc: vcdClient.GetTmEdgeClusterById, + stateStoreFunc: setTmEdgeClusterQosData, + } + return readResource(ctx, d, meta, c) +} + +func resourceVcfaEdgeClusterQosDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + vcdClient := meta.(*VCDClient) + c := crudConfig[*govcd.TmEdgeCluster, types.TmEdgeCluster]{ + entityLabel: labelVcfaEdgeClusterQos, + getEntityFunc: vcdClient.GetTmEdgeClusterById, + } + + return deleteResource(ctx, d, meta, c) +} + +func resourceVcfaEdgeClusterQosImport(ctx context.Context, d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) { + vcdClient := meta.(*VCDClient) + resourceURI := strings.Split(d.Id(), ImportSeparator) + if len(resourceURI) != 2 { + return nil, fmt.Errorf("resource name must be specified as region-name.edge-cluster-name") + } + regionName, edgeClusterName := resourceURI[0], resourceURI[1] + + region, err := vcdClient.GetRegionByName(regionName) + if err != nil { + return nil, fmt.Errorf("error retrieving %s by name '%s': %s", labelVcfaRegion, regionName, err) + } + + ec, err := vcdClient.GetTmEdgeClusterByNameAndRegionId(edgeClusterName, region.Region.ID) + if err != nil { + return nil, fmt.Errorf("error retrieving %s by Name '%s' in %s '%s': %s", + labelVcfaEdgeClusterQos, edgeClusterName, labelVcfaRegion, regionName, err) + } + + d.SetId(ec.TmEdgeCluster.ID) + return []*schema.ResourceData{d}, nil +} + +func getTmEdgeClusterQosType(vcdClient *VCDClient, d *schema.ResourceData) (*types.TmEdgeCluster, error) { + // Only the QoS configuration is updatable, everything else is read-only + t := &types.TmEdgeCluster{DefaultQosConfig: types.TmEdgeClusterDefaultQosConfig{}} + + // Ingress setup + // Only initialize IngressProfile type if at least one of the fields is set + ingressCommittedBandwidthMbps := d.Get("ingress_committed_bandwidth_mbps").(string) + ingressBurstSizeBytes := d.Get("ingress_burst_size_bytes").(string) + if ingressCommittedBandwidthMbps != "" || ingressBurstSizeBytes != "" { + t.DefaultQosConfig.IngressProfile = &types.TmEdgeClusterQosProfile{Type: "DEFAULT"} + + if ingressCommittedBandwidthMbps != "" { + t.DefaultQosConfig.IngressProfile.CommittedBandwidthMbps = mustStrToInt(ingressCommittedBandwidthMbps) + } + + if ingressBurstSizeBytes != "" { + t.DefaultQosConfig.IngressProfile.BurstSizeBytes = mustStrToInt(ingressBurstSizeBytes) + } + } + + // Egress setup + // Only initialize EgressProfile type if at least one of the fields is set + egressCommittedBandwidthMbps := d.Get("egress_committed_bandwidth_mbps").(string) + egressBurstSizeBytes := d.Get("egress_burst_size_bytes").(string) + if egressCommittedBandwidthMbps != "" || egressBurstSizeBytes != "" { + t.DefaultQosConfig.EgressProfile = &types.TmEdgeClusterQosProfile{Type: "DEFAULT"} + + if egressCommittedBandwidthMbps != "" { + t.DefaultQosConfig.EgressProfile.CommittedBandwidthMbps = mustStrToInt(egressCommittedBandwidthMbps) + } + + if egressBurstSizeBytes != "" { + t.DefaultQosConfig.EgressProfile.BurstSizeBytes = mustStrToInt(egressBurstSizeBytes) + } + } + + return t, nil +} + +func setTmEdgeClusterQosData(_ *VCDClient, d *schema.ResourceData, t *govcd.TmEdgeCluster) error { + if t == nil || t.TmEdgeCluster == nil { + return fmt.Errorf("empty %s received", labelVcfaEdgeCluster) + } + + d.SetId(t.TmEdgeCluster.ID) + dSet(d, "edge_cluster_id", t.TmEdgeCluster.ID) + + dSet(d, "region_id", "") + if t.TmEdgeCluster.RegionRef != nil { + dSet(d, "region_id", t.TmEdgeCluster.RegionRef.ID) + } + + dSet(d, "ingress_committed_bandwidth_mbps", nil) + dSet(d, "ingress_burst_size_bytes", nil) + if t.TmEdgeCluster.DefaultQosConfig.IngressProfile != nil { + strValue := strconv.Itoa(t.TmEdgeCluster.DefaultQosConfig.IngressProfile.BurstSizeBytes) + dSet(d, "ingress_burst_size_bytes", strValue) + + strValueCommitted := strconv.Itoa(t.TmEdgeCluster.DefaultQosConfig.IngressProfile.CommittedBandwidthMbps) + dSet(d, "ingress_committed_bandwidth_mbps", strValueCommitted) + } + + dSet(d, "egress_committed_bandwidth_mbps", nil) + dSet(d, "egress_burst_size_bytes", nil) + if t.TmEdgeCluster.DefaultQosConfig.EgressProfile != nil { + strValueCommitted := strconv.Itoa(t.TmEdgeCluster.DefaultQosConfig.EgressProfile.CommittedBandwidthMbps) + dSet(d, "egress_committed_bandwidth_mbps", strValueCommitted) + + strValueBurstSize := strconv.Itoa(t.TmEdgeCluster.DefaultQosConfig.EgressProfile.BurstSizeBytes) + dSet(d, "egress_burst_size_bytes", strValueBurstSize) + } + + return nil +} diff --git a/vcfa/resource_vcfa_edge_cluster_qos_test.go b/vcfa/resource_vcfa_edge_cluster_qos_test.go new file mode 100644 index 0000000..f29d085 --- /dev/null +++ b/vcfa/resource_vcfa_edge_cluster_qos_test.go @@ -0,0 +1,216 @@ +//go:build tm || ALL || functional + +package vcfa + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +func TestAccVcfaEdgeCluster(t *testing.T) { + preTestChecks(t) + skipIfNotSysAdmin(t) + + vCenterHcl, vCenterHclRef := getVCenterHcl(t) + nsxManagerHcl, nsxManagerHclRef := getNsxManagerHcl(t) + regionHcl, regionHclRef := getRegionHcl(t, vCenterHclRef, nsxManagerHclRef) + var params = StringMap{ + "Testname": t.Name(), + "VcenterRef": vCenterHclRef, + "RegionId": fmt.Sprintf("%s.id", regionHclRef), + "RegionName": t.Name(), + "EdgeClusterName": testConfig.Tm.NsxEdgeCluster, + "SyncBeforeRead": "true", + + "Tags": "tm", + } + testParamsNotEmpty(t, params) + + // TODO: TM: There shouldn't be a need to create `preRequisites` separately, but region + // creation fails if it is spawned instantly after adding vCenter, therefore this extra step + // give time (with additional 'refresh' and 'refresh storage policies' operations on vCenter) + skipBinaryTest := "# skip-binary-test: prerequisite buildup for acceptance tests" + configText0 := templateFill(vCenterHcl+nsxManagerHcl+skipBinaryTest, params) + params["FuncName"] = t.Name() + "-step0" + + preRequisites := vCenterHcl + nsxManagerHcl + regionHcl + configText1 := templateFill(preRequisites+testAccVcfaEdgeClusterQosStep1, params) + params["FuncName"] = t.Name() + "-step2" + params["SyncBeforeRead"] = "false" // TODO TM - Sync'ing resets QoS policy to unlimited (-1) + configText2 := templateFill(preRequisites+testAccVcfaEdgeClusterQosStep2, params) + params["FuncName"] = t.Name() + "-step3" + configText3 := templateFill(preRequisites+testAccVcfaEdgeClusterQosStep3, params) + params["FuncName"] = t.Name() + "-step4" + configText4 := templateFill(preRequisites+testAccVcfaEdgeClusterQosStep4, params) + params["FuncName"] = t.Name() + "-step5" + configText5 := templateFill(preRequisites+testAccVcfaEdgeClusterQosStep5, params) + + debugPrintf("#[DEBUG] CONFIGURATION step1: %s\n", configText1) + debugPrintf("#[DEBUG] CONFIGURATION step2: %s\n", configText2) + debugPrintf("#[DEBUG] CONFIGURATION step3: %s\n", configText3) + debugPrintf("#[DEBUG] CONFIGURATION step4: %s\n", configText4) + debugPrintf("#[DEBUG] CONFIGURATION step5: %s\n", configText5) + if vcfaShortTest { + t.Skip(acceptanceTestsSkipped) + return + } + + resource.Test(t, resource.TestCase{ + ProviderFactories: testAccProviders, + Steps: []resource.TestStep{ + { + Config: configText0, + }, + { + Config: configText1, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("data.vcfa_edge_cluster.demo", "id"), + resource.TestCheckResourceAttrSet("data.vcfa_edge_cluster_qos.demo", "id"), + resource.TestCheckResourceAttrPair("data.vcfa_edge_cluster_qos.demo", "id", "data.vcfa_edge_cluster.demo", "id"), + resource.TestCheckResourceAttr("data.vcfa_edge_cluster.demo", "name", testConfig.Tm.NsxEdgeCluster), + resource.TestCheckResourceAttrSet("data.vcfa_edge_cluster.demo", "region_id"), + resource.TestCheckResourceAttrSet("data.vcfa_edge_cluster.demo", "node_count"), + resource.TestCheckResourceAttrSet("data.vcfa_edge_cluster.demo", "org_count"), + resource.TestCheckResourceAttrSet("data.vcfa_edge_cluster.demo", "vpc_count"), + resource.TestCheckResourceAttrSet("data.vcfa_edge_cluster.demo", "average_cpu_usage_percentage"), + resource.TestCheckResourceAttrSet("data.vcfa_edge_cluster.demo", "average_memory_usage_percentage"), + resource.TestCheckResourceAttrSet("data.vcfa_edge_cluster.demo", "health_status"), + resource.TestCheckResourceAttrSet("data.vcfa_edge_cluster.demo", "status"), + resource.TestCheckResourceAttrSet("data.vcfa_edge_cluster.demo", "deployment_type"), + ), + }, + { + Config: configText2, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("data.vcfa_edge_cluster.demo", "id"), + resource.TestCheckResourceAttrSet("data.vcfa_edge_cluster_qos.demo", "id"), + resource.TestCheckResourceAttrPair("data.vcfa_edge_cluster_qos.demo", "id", "data.vcfa_edge_cluster.demo", "id"), + resource.TestCheckResourceAttr("data.vcfa_edge_cluster.demo", "name", testConfig.Tm.NsxEdgeCluster), + + resource.TestCheckResourceAttr("vcfa_edge_cluster_qos.demo", "egress_committed_bandwidth_mbps", "1"), + resource.TestCheckResourceAttr("vcfa_edge_cluster_qos.demo", "egress_burst_size_bytes", "2"), + resource.TestCheckResourceAttr("vcfa_edge_cluster_qos.demo", "ingress_committed_bandwidth_mbps", "3"), + resource.TestCheckResourceAttr("vcfa_edge_cluster_qos.demo", "ingress_burst_size_bytes", "4"), + ), + }, + { + RefreshState: true, // ensuring that data source is reloaded with latest data that is configured in the resource + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("data.vcfa_edge_cluster.demo", "id"), + resource.TestCheckResourceAttrSet("data.vcfa_edge_cluster_qos.demo", "id"), + resource.TestCheckResourceAttrPair("data.vcfa_edge_cluster_qos.demo", "id", "data.vcfa_edge_cluster.demo", "id"), + resource.TestCheckResourceAttr("data.vcfa_edge_cluster.demo", "name", testConfig.Tm.NsxEdgeCluster), + + resource.TestCheckResourceAttr("vcfa_edge_cluster_qos.demo", "egress_committed_bandwidth_mbps", "1"), + resource.TestCheckResourceAttr("vcfa_edge_cluster_qos.demo", "egress_burst_size_bytes", "2"), + resource.TestCheckResourceAttr("vcfa_edge_cluster_qos.demo", "ingress_committed_bandwidth_mbps", "3"), + resource.TestCheckResourceAttr("vcfa_edge_cluster_qos.demo", "ingress_burst_size_bytes", "4"), + resourceFieldsEqual("data.vcfa_edge_cluster_qos.demo", "vcfa_edge_cluster_qos.demo", nil), + ), + }, + { + ResourceName: "vcfa_edge_cluster_qos.demo", + ImportState: true, + ImportStateVerify: true, + ImportStateId: testConfig.Tm.Region + ImportSeparator + params["EdgeClusterName"].(string), + }, + { + // Ensuring that the resource is removed (therefore QoS settings must be unset) + Config: configText3, + Check: resource.ComposeTestCheckFunc(), + }, + { + // Checking that the data source reflects empty QoS values (delete of resource removes Qos Settings) + // The refresh is required + RefreshState: true, // ensuring that data source is reloaded with latest data that is configured in the resource + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("data.vcfa_edge_cluster_qos.demo2", "egress_committed_bandwidth_mbps", "-1"), + resource.TestCheckResourceAttr("data.vcfa_edge_cluster_qos.demo2", "egress_burst_size_bytes", "-1"), + resource.TestCheckResourceAttr("data.vcfa_edge_cluster_qos.demo2", "ingress_committed_bandwidth_mbps", "-1"), + resource.TestCheckResourceAttr("data.vcfa_edge_cluster_qos.demo2", "ingress_burst_size_bytes", "-1"), + ), + }, + { + // Ensuring that the resource is removed (therefore QoS settings must be unset) + Config: configText4, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("vcfa_edge_cluster_qos.demo", "egress_committed_bandwidth_mbps", "7"), + resource.TestCheckResourceAttr("vcfa_edge_cluster_qos.demo", "egress_burst_size_bytes", "8"), + resource.TestCheckResourceAttr("vcfa_edge_cluster_qos.demo", "ingress_committed_bandwidth_mbps", "-1"), + resource.TestCheckResourceAttr("vcfa_edge_cluster_qos.demo", "ingress_burst_size_bytes", "-1"), + ), + }, + { + // Ensuring that the resource is removed (therefore QoS settings must be unset) + Config: configText5, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("vcfa_edge_cluster_qos.demo", "egress_committed_bandwidth_mbps", "-1"), + resource.TestCheckResourceAttr("vcfa_edge_cluster_qos.demo", "egress_burst_size_bytes", "-1"), + resource.TestCheckResourceAttr("vcfa_edge_cluster_qos.demo", "ingress_committed_bandwidth_mbps", "5"), + resource.TestCheckResourceAttr("vcfa_edge_cluster_qos.demo", "ingress_burst_size_bytes", "6"), + ), + }, + }, + }) + + postTestChecks(t) +} + +const testAccVcfaEdgeClusterQosStep1 = ` +data "vcfa_edge_cluster" "demo" { + name = "{{.EdgeClusterName}}" + region_id = {{.RegionId}} + sync_before_read = {{.SyncBeforeRead}} +} + +data "vcfa_edge_cluster_qos" "demo" { + edge_cluster_id = data.vcfa_edge_cluster.demo.id +} +` + +const testAccVcfaEdgeClusterQosStep2 = testAccVcfaEdgeClusterQosStep1 + ` +resource "vcfa_edge_cluster_qos" "demo" { + edge_cluster_id = data.vcfa_edge_cluster.demo.id + + egress_committed_bandwidth_mbps = 1 + egress_burst_size_bytes = 2 + ingress_committed_bandwidth_mbps = 3 + ingress_burst_size_bytes = 4 +} +` + +const testAccVcfaEdgeClusterQosStep3 = ` +data "vcfa_edge_cluster" "demo" { + name = "{{.EdgeClusterName}}" + region_id = {{.RegionId}} + sync_before_read = {{.SyncBeforeRead}} +} + +data "vcfa_edge_cluster_qos" "demo2" { + edge_cluster_id = data.vcfa_edge_cluster.demo.id +} +` + +// egress only +const testAccVcfaEdgeClusterQosStep4 = testAccVcfaEdgeClusterQosStep1 + ` +resource "vcfa_edge_cluster_qos" "demo" { + edge_cluster_id = data.vcfa_edge_cluster.demo.id + + egress_committed_bandwidth_mbps = 7 + egress_burst_size_bytes = 8 + +} +` + +// ingress only +const testAccVcfaEdgeClusterQosStep5 = testAccVcfaEdgeClusterQosStep1 + ` +resource "vcfa_edge_cluster_qos" "demo" { + edge_cluster_id = data.vcfa_edge_cluster.demo.id + + ingress_committed_bandwidth_mbps = 5 + ingress_burst_size_bytes = 6 + +} +` diff --git a/vcfa/resource_vcfa_region.go b/vcfa/resource_vcfa_region.go index 0af19f9..fb42756 100644 --- a/vcfa/resource_vcfa_region.go +++ b/vcfa/resource_vcfa_region.go @@ -33,6 +33,7 @@ func resourceVcfaRegion() *schema.Resource { "name": { Type: schema.TypeString, Required: true, + ForceNew: true, // Region names cannot be changed Description: fmt.Sprintf("%s name", labelVcfaRegion), ValidateDiagFunc: validation.ToDiagFunc( validation.StringMatch(rfc1123LabelNameRegex, "Name must match RFC 1123 Label name (lower case alphabet, 0-9 and hyphen -)"), diff --git a/vcfa/resource_vcfa_vcenter.go b/vcfa/resource_vcfa_vcenter.go index 7e594c1..2659afb 100644 --- a/vcfa/resource_vcfa_vcenter.go +++ b/vcfa/resource_vcfa_vcenter.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/url" + "regexp" "time" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" @@ -14,8 +15,12 @@ import ( ) const labelVcfaVirtualCenter = "vCenter Server" +const extraSleepAfterListenerConnected = 3 * time.Second -const extraSleepAfterOperations = 3 * time.Second +// vCenter task is sometimes unreliable and trying to refresh it immediately after it becomes +// connected causes a "BUSY_ENTITY" error (which has a few different messages) +var maximumVcenterRetryTime = 120 * time.Second // The maximum time a single operation will be retried before giving up +var vCenterEntityBusyRegexp = regexp.MustCompile(`(is currently busy|400|BUSY_ENTITY)`) // Regexp to match entity busy error func resourceVcfaVcenter() *schema.Resource { return &schema.Resource{ @@ -304,13 +309,11 @@ func disableVcenter(v *govcd.VCenter) error { func refreshVcenter(execute bool) outerEntityHook[*govcd.VCenter] { return func(v *govcd.VCenter) error { if execute { - err := v.RefreshVcenter() + err := runWithRetry(v.RefreshVcenter, vCenterEntityBusyRegexp, maximumVcenterRetryTime) if err != nil { return fmt.Errorf("error refreshing vCenter: %s", err) } } - // TODO: TM: put an extra sleep to be sure the entity is released - time.Sleep(extraSleepAfterOperations) return nil } } @@ -320,13 +323,11 @@ func refreshVcenter(execute bool) outerEntityHook[*govcd.VCenter] { func refreshVcenterPolicy(execute bool) outerEntityHook[*govcd.VCenter] { return func(v *govcd.VCenter) error { if execute { - err := v.RefreshStorageProfiles() + err := runWithRetry(v.RefreshStorageProfiles, vCenterEntityBusyRegexp, maximumVcenterRetryTime) if err != nil { - return fmt.Errorf("error refreshing Storage Policies: %s", err) + return fmt.Errorf("error refreshing vCenter Storage Policies: %s", err) } } - // TODO: TM: put an extra sleep to be sure the entity is released - time.Sleep(extraSleepAfterOperations) return nil } } @@ -345,7 +346,7 @@ func shouldWaitForListenerStatusConnected(shouldWait bool) func(v *govcd.VCenter if v.VSphereVCenter.ListenerState == "CONNECTED" { // TODO: TM: put an extra sleep to be sure the entity is released - time.Sleep(extraSleepAfterOperations) + time.Sleep(extraSleepAfterListenerConnected) return nil } @@ -382,3 +383,36 @@ func autoTrustHostCertificate(urlSchemaFieldName, trustSchemaFieldName string) s return nil } } + +func runWithRetry(runOperation func() error, errRegexp *regexp.Regexp, duration time.Duration) error { + startTime := time.Now() + endTime := startTime.Add(duration) + util.Logger.Printf("[DEBUG] runWithRetry - running with retry for %f seconds if error contains '%s' ", duration.Seconds(), errRegexp) + count := 1 + for { + err := runOperation() + util.Logger.Printf("[DEBUG] runWithRetry - ran attempt %d, got error: %s ", count, err) + // Operation had no error - it succeeded + if err == nil { + util.Logger.Printf("[DEBUG] runWithRetry - no error occurred after attempt %d, got error: %s ", count, err) + return nil + } + // If there is an error, but it doesn't contain the retryIfErrContains value - exit it + if !errRegexp.MatchString(err.Error()) { + util.Logger.Printf("[DEBUG] runWithRetry - returning error after attempt %d, got error: %s ", count, err) + return err + } + + // If time limit is exceeded - return error containing statistics and original error + if time.Now().After(endTime) { + util.Logger.Printf("[DEBUG] runWithRetry - exceeded time after attempt %d, got error: %s ", count, err) + return fmt.Errorf("error attempting to wait until error does not contain '%s' after %f seconds: %s", errRegexp, duration.Seconds(), err) + } + + // Sleep and continue + util.Logger.Printf("[DEBUG] runWithRetry - sleeping after attempt %d, will retry", count) + // Sleep 2 seconds and attempt once more if the timeout is not excdeeded + time.Sleep(2 * time.Second) + count++ + } +} diff --git a/vcfa/structure.go b/vcfa/structure.go index be8bdee..d145e40 100644 --- a/vcfa/structure.go +++ b/vcfa/structure.go @@ -3,6 +3,7 @@ package vcfa import ( "fmt" "os" + "strconv" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/vmware/go-vcloud-director/v3/types/v56" @@ -81,3 +82,14 @@ func fileExists(filename string) bool { fileMode := f.Mode() return fileMode.IsRegular() } + +// mustStrToInt will convert string to int and panic if an error while convert occurs +// Note. It is convenient to use for inline type conversions, but the string _must be_ validated before +// e.g. field validation using `ValidateFunc: IsIntAndAtLeast(1), ` +func mustStrToInt(s string) int { + v, err := strconv.Atoi(s) + if err != nil { + panic(fmt.Sprintf("failed converting '%s' to int: %s", s, err)) + } + return v +} diff --git a/website/docs/d/edge_cluster.html.markdown b/website/docs/d/edge_cluster.html.markdown new file mode 100644 index 0000000..09553a5 --- /dev/null +++ b/website/docs/d/edge_cluster.html.markdown @@ -0,0 +1,59 @@ +--- +layout: "vcfa" +page_title: "VMware Cloud Foundation Automation: vcfa_edge_cluster" +sidebar_current: "docs-vcfa-data-source-edge-cluster" +description: |- + Provides a VMware Cloud Foundation Automation Edge Cluster data source. +--- + +# vcfa\_edge\_cluster + +Provides a VMware Cloud Foundation Automation Edge Cluster data source. + +## Example Usage + +```hcl +data "vcfa_region" "demo" { + name = "region-one" +} + +data "vcfa_edge_cluster" "demo" { + name = "my-edge-cluster" + region_id = data.vcfa_region.demo.id + sync_before_read = true +} +``` + +## Argument Reference + +The following arguments are supported: + +* `name` - (Required) The name of Edge Cluster +* `region_id` - (Required) The ID of parent region. Can be looked up using + [`vcfa_region`](/providers/vmware/vcfa/latest/docs/data-sources/region) data source +* `sync_before_read` - (Optional) Set to true to trigger Sync before attempting to search for Edge + Cluster . Default `false`. + +## Attribute Reference + +* `node_count` - Number of transport nodes in the Edge Cluster. If this information is not + available, it will be set to `-1` +* `org_count` - Number of organizations using this Edge Cluster +* `vpc_count` - Number of VPCs using this Edge Cluster +* `average_cpu_usage_percentage` - Average CPU utilization percentage across all member nodes +* `average_memory_usage_percentage` - Average RAM utilization percentage across all member nodes +* `health_status` - Current health status of Edge Cluster. One of: + * `UP` - The Edge Cluster is healthy + * `DOWN` - The Edge Cluster is down + * `DEGRADED` - The Edge Cluster is not operating at capacity. One or more member nodes are down or inactive + * `UNKNOWN` - The Edge Cluster state is unknown. If UNKNOWN, `average_cpu_usage_percentage` and `average_memory_usage_percentage` will be not be set +* `status` - Represents current status of the networking entity. One of: + * `PENDING` - Desired entity configuration has been received by system and is pending realization + * `CONFIGURING` - The system is in process of realizing the entity + * `REALIZED` - The entity is successfully realized in the system + * `REALIZATION_FAILED` - There are some issues and the system is not able to realize the entity + * `UNKNOWN` - Current state of entity is unknown +* `deployment_type` - Deployment type for transport nodes in the Edge Cluster. Possible values are: + * `VIRTUAL_MACHINE` - If all members are of type _VIRTUAL_MACHINE_ + * `PHYSICAL_MACHINE` - If all members are of type _PHYSICAL_MACHINE_ + * `UNKNOWN` - If there are no members or their type is not known diff --git a/website/docs/d/edge_cluster_qos.html.markdown b/website/docs/d/edge_cluster_qos.html.markdown new file mode 100644 index 0000000..b690b90 --- /dev/null +++ b/website/docs/d/edge_cluster_qos.html.markdown @@ -0,0 +1,41 @@ +--- +layout: "vcfa" +page_title: "VMware Cloud Foundation Automation: vcfa_edge_cluster_qos" +sidebar_current: "docs-vcfa-data-source-edge-cluster-qos" +description: |- + Provides a VMware Cloud Foundation Automation Edge Cluster QoS data source. +--- + +# vcfa\_edge\_cluster\_qos + +Provides a VMware Cloud Foundation Automation Edge Cluster QoS data source. + +## Example Usage + +```hcl +data "vcfa_region" "demo" { + name = "region-one" +} + +data "vcfa_edge_cluster" "demo" { + name = "my-edge-cluster" + region_id = data.vcfa_region.demo.id + sync_before_read = true +} + +data "vcfa_edge_cluster_qos" "demo" { + edge_cluster_id = data.vcfa_edge_cluster.demo.id +} +``` + +## Argument Reference + +The following arguments are supported: + +* `edge_cluster_id` - (Required) An ID of Edge Cluster. Can be looked up using + [vcfa_edge_cluster](/providers/vmware/vcfa/latest/docs/data-sources/edge_cluster) data source + +## Attribute Reference + +All the arguments and attributes defined in +[`vcfa_edge_cluster_qos`](/providers/vmware/vcfa/latest/docs/resources/edge_cluster_qos) resource are available. \ No newline at end of file diff --git a/website/docs/r/edge_cluster_qos.html.markdown b/website/docs/r/edge_cluster_qos.html.markdown new file mode 100644 index 0000000..e37b568 --- /dev/null +++ b/website/docs/r/edge_cluster_qos.html.markdown @@ -0,0 +1,75 @@ +--- +layout: "vcfa" +page_title: "VMware Cloud Foundation Automation: vcfa_edge_cluster_qos" +sidebar_current: "docs-vcfa-resource-edge-cluster-qos" +description: |- + Provides a VMware Cloud Foundation Automation Edge Cluster QoS resource. +--- + +# vcfa\_edge\_cluster\_qos + +Provides a VMware Cloud Foundation Automation Edge Cluster QoS resource. + +-> This resource does not create an Edge Cluster QoS entity, but configures QoS for a given +`edge_cluster_id`. Similarly, `terraform destroy` operation does not remove Edge Cluster, but resets +QoS settings to default (unlimited). + +## Example Usage + +```hcl +data "vcfa_region" "demo" { + name = "region-one" +} + +data "vcfa_edge_cluster" "demo" { + name = "my-edge-cluster" + region_id = data.vcfa_region.demo.id + sync_before_read = true +} + +resource "vcfa_edge_cluster_qos" "demo" { + edge_cluster_id = data.vcfa_edge_cluster.demo.id + + egress_committed_bandwidth_mbps = 1 + egress_burst_size_bytes = 2 + ingress_committed_bandwidth_mbps = 3 + ingress_burst_size_bytes = 4 +} +``` + +## Argument Reference + +The following arguments are supported: + +* `edge_cluster_id` - (Required) An ID of Edge Cluster. Can be looked up using + [vcfa_edge_cluster](/providers/vmware/vcfa/latest/docs/data-sources/edge_cluster) data source +* `egress_committed_bandwidth_mbps` - (Optional) Committed egress bandwidth specified in Mbps. + Bandwidth is limited to line rate. Traffic exceeding bandwidth will be dropped. Required with + `egress_burst_size_bytes`. Default is `-1` - unlimited +* `egress_burst_size_bytes` - (Optional) Egress burst size in bytes. Required with + `egress_committed_bandwidth_mbps`. Default is `-1` - unlimited +* `ingress_committed_bandwidth_mbps` - (Optional) Committed ingress bandwidth specified in Mbps. + Bandwidth is limited to line rate. Traffic exceeding bandwidth will be dropped. Required with + `ingress_burst_size_bytes`. Default is `-1` - unlimited +* `ingress_burst_size_bytes` - (Optional) Ingress burst size in bytes. Required with + `ingress_committed_bandwidth_mbps`. Default is `-1` - unlimited + + -> Deleting this resource will reset all values to unlimited + +## Importing + +~> **Note:** The current implementation of Terraform import can only import resources into the +state. It does not generate configuration. However, an experimental feature in Terraform 1.5+ allows +also code generation. See [Importing resources][importing-resources] for more information. + +An existing IP Space configuration can be [imported][docs-import] into this resource via supplying +path for it. An example is below: + +[docs-import]: https://www.terraform.io/docs/import/ + +``` +terraform import vcfa_edge_cluster_qos.imported my-region-name.my-edge-cluster-name +``` + +The above would import the `my-edge-cluster-name` Edge Cluster QoS settings that is in +`my-region-name` Region. diff --git a/website/vcfa.erb b/website/vcfa.erb index 8b65b81..6192609 100644 --- a/website/vcfa.erb +++ b/website/vcfa.erb @@ -66,6 +66,12 @@