From 078c22f01fae46d9c1e2682764c2b0de84f78790 Mon Sep 17 00:00:00 2001 From: Integralist Date: Fri, 27 Oct 2023 20:27:06 +0100 Subject: [PATCH] feat(resources/servicevcl): handle state drift --- docs/resources/fastly_service_vcl.md | 2 + internal/helpers/errors.go | 2 + internal/provider/models/service_vcl.go | 4 + .../resources/servicevcl/process_delete.go | 13 +- .../resources/servicevcl/process_read.go | 167 ++++++++++++------ .../resources/servicevcl/process_update.go | 2 +- .../provider/resources/servicevcl/resource.go | 12 ++ internal/provider/schemas/service.go | 16 +- .../tests/resources/service_vcl_test.go | 111 +++++++++++- 9 files changed, 268 insertions(+), 61 deletions(-) diff --git a/docs/resources/fastly_service_vcl.md b/docs/resources/fastly_service_vcl.md index ceb27c1e..d1bc1119 100644 --- a/docs/resources/fastly_service_vcl.md +++ b/docs/resources/fastly_service_vcl.md @@ -36,7 +36,9 @@ The Service resource requires a domain name configured to direct traffic to the ### Read-Only +- `force_refresh` (Boolean) Used internally by the provider to temporarily indicate if all resources should call their associated API to update the local state. This is for scenarios where the service version has been reverted outside of Terraform (e.g. via the Fastly UI) and the provider needs to resync the state for a different active version (this is only if `activate` is `true`) - `id` (String) Alphanumeric string identifying the service +- `imported` (Boolean) Used internally by the provider to temporarily indicate if the service is being imported, and is reset to false once the import is finished - `last_active` (Number) The last 'active' service version (typically in-sync with `version` but not if `activate` is `false`) - `version` (Number) The latest version that the provider will clone from (typically in-sync with `last_active` but not if `activate` is `false`) diff --git a/internal/helpers/errors.go b/internal/helpers/errors.go index f2f64b0b..16c3db75 100644 --- a/internal/helpers/errors.go +++ b/internal/helpers/errors.go @@ -7,6 +7,8 @@ const ( ErrorAPIClient = "API Client Error" // ErrorProvider indicates a Provider error. ErrorProvider = "Provider Error" + // ErrorUnknown indicates an error incompatible with any other known scenario. + ErrorUnknown = "Unknown Error" // ErrorUser indicates a User error. ErrorUser = "User Error" ) diff --git a/internal/provider/models/service_vcl.go b/internal/provider/models/service_vcl.go index 014f238e..336b466d 100644 --- a/internal/provider/models/service_vcl.go +++ b/internal/provider/models/service_vcl.go @@ -18,8 +18,12 @@ type ServiceVCL struct { Domains map[string]Domain `tfsdk:"domains"` // ForceDestroy ensures a service will be fully deleted upon `terraform destroy`. ForceDestroy types.Bool `tfsdk:"force_destroy"` + // ForceRefresh ensures all nested resources will have their state refreshed. + ForceRefresh types.Bool `tfsdk:"force_refresh"` // ID is a unique ID for the service. ID types.String `tfsdk:"id"` + // Imported indicates the resource is being imported. + Imported types.Bool `tfsdk:"imported"` // LastActive is the last known active service version. LastActive types.Int64 `tfsdk:"last_active"` // Name is the service name. diff --git a/internal/provider/resources/servicevcl/process_delete.go b/internal/provider/resources/servicevcl/process_delete.go index 6189b6b0..7ab198c6 100644 --- a/internal/provider/resources/servicevcl/process_delete.go +++ b/internal/provider/resources/servicevcl/process_delete.go @@ -25,7 +25,7 @@ func (r *Resource) Delete(ctx context.Context, req resource.DeleteRequest, resp return } - if (state.ForceDestroy.ValueBool() || state.Reuse.ValueBool()) && state.Activate.ValueBool() { + if state.ForceDestroy.ValueBool() || state.Reuse.ValueBool() { clientReq := r.client.ServiceAPI.GetServiceDetail(r.clientCtx, state.ID.ValueString()) clientResp, httpResp, err := clientReq.Execute() if err != nil { @@ -40,14 +40,17 @@ func (r *Resource) Delete(ctx context.Context, req resource.DeleteRequest, resp return } - version := *clientResp.GetActiveVersion().Number + var activeVersion int32 + if clientResp.GetActiveVersion().Number != nil { + activeVersion = *clientResp.GetActiveVersion().Number + } - if version != 0 { - clientReq := r.client.VersionAPI.DeactivateServiceVersion(r.clientCtx, state.ID.ValueString(), version) + if activeVersion != 0 { + clientReq := r.client.VersionAPI.DeactivateServiceVersion(r.clientCtx, state.ID.ValueString(), activeVersion) _, httpResp, err := clientReq.Execute() if err != nil { tflog.Trace(ctx, "Fastly VersionAPI.DeactivateServiceVersion error", map[string]any{"http_resp": httpResp}) - resp.Diagnostics.AddError(helpers.ErrorAPIClient, fmt.Sprintf("Unable to deactivate service version %d, got error: %s", version, err)) + resp.Diagnostics.AddError(helpers.ErrorAPIClient, fmt.Sprintf("Unable to deactivate service version %d, got error: %s", activeVersion, err)) return } defer httpResp.Body.Close() diff --git a/internal/provider/resources/servicevcl/process_read.go b/internal/provider/resources/servicevcl/process_read.go index a4f52e22..6b1aeda7 100644 --- a/internal/provider/resources/servicevcl/process_read.go +++ b/internal/provider/resources/servicevcl/process_read.go @@ -28,8 +28,8 @@ func (r *Resource) Read(ctx context.Context, req resource.ReadRequest, resp *res return } - clientReq := r.client.ServiceAPI.GetServiceDetail(r.clientCtx, state.ID.ValueString()) - clientResp, httpResp, err := clientReq.Execute() + serviceDetailsReq := r.client.ServiceAPI.GetServiceDetail(r.clientCtx, state.ID.ValueString()) + serviceDetailsResp, httpResp, err := serviceDetailsReq.Execute() if err != nil { tflog.Trace(ctx, "Fastly ServiceAPI.GetServiceDetail error", map[string]any{"http_resp": httpResp}) resp.Diagnostics.AddError(helpers.ErrorAPIClient, fmt.Sprintf("Unable to retrieve service details, got error: %s", err)) @@ -39,14 +39,14 @@ func (r *Resource) Read(ctx context.Context, req resource.ReadRequest, resp *res // Check if the service has been deleted outside of Terraform. // And if so we'll just return. - if t, ok := clientResp.GetDeletedAtOk(); ok && t != nil { + if t, ok := serviceDetailsResp.GetDeletedAtOk(); ok && t != nil { tflog.Trace(ctx, "Fastly ServiceAPI.GetDeletedAtOk", map[string]any{"deleted_at": t, "state": state}) resp.State.RemoveResource(ctx) return } // Avoid issue with service type mismatch (only relevant when importing). - serviceType := clientResp.GetType() + serviceType := serviceDetailsResp.GetType() vclServiceType := helpers.ServiceTypeVCL.String() if serviceType != vclServiceType { tflog.Trace(ctx, "Fastly service type error", map[string]any{"http_resp": httpResp, "type": serviceType}) @@ -54,7 +54,26 @@ func (r *Resource) Read(ctx context.Context, req resource.ReadRequest, resp *res return } - serviceVersion := readServiceVersion(state, clientResp) + remoteServiceVersion, err := readServiceVersion(state, serviceDetailsResp) + if err != nil { + tflog.Trace(ctx, "Fastly service version identification error", map[string]any{"state": state, "service_details": serviceDetailsResp, "error": err}) + resp.Diagnostics.AddError(helpers.ErrorUnknown, err.Error()) + return + } + + // If the user has indicated they want their service to be 'active', then we + // presume when refreshing the state that we should be dealing with a service + // version that is active. If the prior state has a `version` field that + // doesn't match the current latest active version, then this suggests that + // the service versions have drifted outside of Terraform. + // + // e.g. a user has reverted the service version to another version via the UI. + // + // In this scenario, we'll set `force_refresh=true` so that the nested + // resources will call the Fastly API to get updated state information. + if state.Activate.ValueBool() && state.Version != types.Int64Value(remoteServiceVersion) { + state.ForceRefresh = types.BoolValue(true) + } api := helpers.API{ Client: r.client, @@ -71,8 +90,8 @@ func (r *Resource) Read(ctx context.Context, req resource.ReadRequest, resp *res // See `readSettings()` for an example of directly modifying `state`. for _, nestedResource := range r.nestedResources { serviceData := helpers.Service{ - ID: clientResp.GetID(), - Version: int32(serviceVersion), + ID: serviceDetailsResp.GetID(), + Version: int32(remoteServiceVersion), } if err := nestedResource.Read(ctx, &req, resp, api, &serviceData); err != nil { return @@ -86,17 +105,37 @@ func (r *Resource) Read(ctx context.Context, req resource.ReadRequest, resp *res return } - state.Comment = types.StringValue(clientResp.GetComment()) - state.ID = types.StringValue(clientResp.GetID()) - state.Name = types.StringValue(clientResp.GetName()) - state.Version = types.Int64Value(serviceVersion) - state.LastActive = types.Int64Value(serviceVersion) + state.Comment = types.StringValue(serviceDetailsResp.GetComment()) + state.ID = types.StringValue(serviceDetailsResp.GetID()) + state.Name = types.StringValue(serviceDetailsResp.GetName()) + state.Version = types.Int64Value(remoteServiceVersion) - err = readSettings(ctx, state, resp, api) + // We set `last_active` to align with `version` only if `activate = true` or + // if we're importing (i.e. `activate` will be null). This is because we only + // expect `version` to drift from `last_active` if `activate = false`. + if state.Activate.ValueBool() || state.Activate.IsNull() { + state.LastActive = types.Int64Value(remoteServiceVersion) + } + + err = readServiceSettings(ctx, remoteServiceVersion, state, resp, api) if err != nil { return } + // To ensure nested resources don't continue to call the Fastly API to + // refresh the internal Terraform state, we set `imported`/`force_refresh` + // back to false. + // + // `force_refresh` is set to true earlier in this method. + // `imported` is set to true when `ImportState()` is called in ./resource.go + // + // We do this because it's slow and expensive to refresh the state for every + // nested resource if they've not even been defined in the user's TF config. + // But during an import we DO want to refresh all the state because we can't + // know up front what nested resources should exist. + state.ForceRefresh = types.BoolValue(false) + state.Imported = types.BoolValue(false) + // Save the final `state` data back into Terraform state. resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) @@ -105,62 +144,90 @@ func (r *Resource) Read(ctx context.Context, req resource.ReadRequest, resp *res // readServiceVersion returns the service version. // -// The returned value depends on if we're in an import scenario. +// The returned values depends on if we're in an import scenario. // // When importing a service there might be no prior 'serviceVersion' in state. // If the user imports using the `ID@VERSION` syntax, then there will be. // This is because `ImportState()` in ./resource.go makes sure it's set. // -// So we check if the attribute is null or not. -// -// If it's null, then we'll presume the user wants the last active version. -// Which we retrieve from the GetServiceDetail call. -// We fallback to the latest version if there is no prior active version. +// So we check if `imported` is set and if the `version` attribute is not null. +// If these conditions are true we'll check the specified version exists. +// (see `versionFromImport()` for details). // -// Otherwise we'll use whatever version they specified in their import. -func readServiceVersion(state *models.ServiceVCL, clientResp *fastly.ServiceDetail) int64 { - var serviceVersion int64 +// If the conditions aren't met, then we'll call the Fastly API to get all +// available service versions, and then we'll figure out which version we want +// to return (see `versionFromRemote()` for details). +func readServiceVersion(state *models.ServiceVCL, serviceDetailsResp *fastly.ServiceDetail) (serviceVersion int64, err error) { + if state.Imported.ValueBool() && !state.Version.IsNull() { + serviceVersion, err = versionFromImport(state, serviceDetailsResp) + } else { + serviceVersion, err = versionFromAttr(state, serviceDetailsResp) + } + return serviceVersion, err +} - // This is the 'import without version' scenario. - // - // The Read() flow only gets executed after resources exist in the state - // (and we know a service resource will always have a version) or if the user - // is importing the resource (where upon there is no prior state and so the - // version field will be null). - // - // The only caveat to that is if the user specifies a version when importing. - // In that case, they'll end up in the `else` block below. - if state.Version.IsNull() { - var foundActive bool - versions := clientResp.GetVersions() +// versionFromImport returns import specified service version. +// It will validate the version specified actually exists remotely. +func versionFromImport(state *models.ServiceVCL, serviceDetailsResp *fastly.ServiceDetail) (serviceVersion int64, err error) { + serviceVersion = state.Version.ValueInt64() // whatever version the user specified in their import + versions := serviceDetailsResp.GetVersions() + var foundVersion bool + for _, version := range versions { + if int64(version.GetNumber()) == serviceVersion { + foundVersion = true + break + } + } + if !foundVersion { + err = fmt.Errorf("failed to find version '%d' remotely", serviceVersion) + } + return serviceVersion, err +} + +// versionFromAttr returns the service version based on `activate` attribute. +// If `activate = true`, then we return the latest 'active' service version. +// If `activate = false` we return the latest version. This allows state drift. +func versionFromAttr(state *models.ServiceVCL, serviceDetailsResp *fastly.ServiceDetail) (serviceVersion int64, err error) { + versions := serviceDetailsResp.GetVersions() + size := len(versions) + switch { + case size == 0: + err = errors.New("failed to find any service versions remotely") + case state.Activate.IsNull(): + fallthrough // when importing `activate` doesn't have its default value set so we default to importing the latest 'active' version. + case state.Activate.ValueBool(): + var foundVersion bool for _, version := range versions { if version.GetActive() { serviceVersion = int64(version.GetNumber()) - foundActive = true + foundVersion = true break } } - if !foundActive { - // Use latest version if the user imports a service with no active versions. - serviceVersion = int64(versions[0].GetNumber()) + if !foundVersion { + // If we're importing a service, then we don't have `activate` value. + // So if there's no active version to use, fallback the latest version. + if state.Imported.ValueBool() { + serviceVersion = getLatestServiceVersion(size-1, versions) + } else { + err = errors.New("failed to find active version remotely") + } } - } else { - // This is the standard flow which is reached either when the user has a - // plan and there is already this resource in the state (and we know a - // service resource will always have a version) or if the user is importing - // the resource WITH a service version specified. - serviceVersion = state.Version.ValueInt64() + default: + // If `activate = false` then we expect state drift and will pull in the + // latest version available (regardless of if it's active or not). + serviceVersion = getLatestServiceVersion(size-1, versions) } + return serviceVersion, err +} - return serviceVersion +func getLatestServiceVersion(i int, versions []fastly.SchemasVersionResponse) int64 { + return int64(versions[i].GetNumber()) } -func readSettings(ctx context.Context, state *models.ServiceVCL, resp *resource.ReadResponse, api helpers.API) error { +func readServiceSettings(ctx context.Context, serviceVersion int64, state *models.ServiceVCL, resp *resource.ReadResponse, api helpers.API) error { serviceID := state.ID.ValueString() - serviceVersion := int32(state.Version.ValueInt64()) - - clientReq := api.Client.SettingsAPI.GetServiceSettings(api.ClientCtx, serviceID, serviceVersion) - + clientReq := api.Client.SettingsAPI.GetServiceSettings(api.ClientCtx, serviceID, int32(serviceVersion)) readErr := errors.New("failed to read service settings") clientResp, httpResp, err := clientReq.Execute() diff --git a/internal/provider/resources/servicevcl/process_update.go b/internal/provider/resources/servicevcl/process_update.go index 8b138f89..57eab12e 100644 --- a/internal/provider/resources/servicevcl/process_update.go +++ b/internal/provider/resources/servicevcl/process_update.go @@ -78,7 +78,7 @@ func (r *Resource) Update(ctx context.Context, req resource.UpdateRequest, resp return } - if nestedResourcesChanged { + if nestedResourcesChanged && plan.Activate.ValueBool() { clientReq := r.client.VersionAPI.ActivateServiceVersion(r.clientCtx, plan.ID.ValueString(), serviceVersion) _, httpResp, err := clientReq.Execute() if err != nil { diff --git a/internal/provider/resources/servicevcl/resource.go b/internal/provider/resources/servicevcl/resource.go index ae0a461f..96b43fb7 100644 --- a/internal/provider/resources/servicevcl/resource.go +++ b/internal/provider/resources/servicevcl/resource.go @@ -142,6 +142,18 @@ func (r *Resource) Configure(_ context.Context, req resource.ConfigureRequest, r // The service resource then iterates over all nested resources populating the // state for each nested resource. func (r *Resource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + // FIXME: Make sure we validate this in a test. + // + // To ensure nested resources don't continue to call the Fastly API to + // refresh the internal Terraform state, we set `imported` to true. + // It's set back to false in ./process_read.go + // + // We do this because it's slow and expensive to refresh the state for every + // nested resource if they've not even been defined in the user's TF config. + // But during an import we DO want to refresh all the state because we can't + // know up front what nested resources should exist. + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("imported"), true)...) + id, version, found := strings.Cut(req.ID, "@") if found { v, err := strconv.ParseInt(version, 10, 64) diff --git a/internal/provider/schemas/service.go b/internal/provider/schemas/service.go index f590804d..1d67ca2c 100644 --- a/internal/provider/schemas/service.go +++ b/internal/provider/schemas/service.go @@ -10,8 +10,12 @@ import ( // Service returns the common schema attributes between VCL/Compute services. // -// NOTE: Some optional attributes are also 'computed' so we can set a default. +// NOTE: Some 'optional' attributes are also 'computed' so we can set a default. // This is a requirement enforced on us by Terraform. +// +// NOTE: Some 'computed' attributes require a default to avoid test errors. +// If we don't set a default, the Create/Update methods have to explicitly set a +// value for the computed attributes. It's cleaner/easier to just set defaults. func Service() map[string]schema.Attribute { return map[string]schema.Attribute{ "activate": schema.BoolAttribute{ @@ -46,6 +50,11 @@ func Service() map[string]schema.Attribute { MarkdownDescription: "Services that are active cannot be destroyed. In order to destroy the service, set `force_destroy` to `true`. Default `false`", Optional: true, }, + "force_refresh": schema.BoolAttribute{ + Computed: true, + Default: booldefault.StaticBool(false), + MarkdownDescription: "Used internally by the provider to temporarily indicate if all resources should call their associated API to update the local state. This is for scenarios where the service version has been reverted outside of Terraform (e.g. via the Fastly UI) and the provider needs to resync the state for a different active version (this is only if `activate` is `true`)", + }, "id": schema.StringAttribute{ Computed: true, MarkdownDescription: "Alphanumeric string identifying the service", @@ -55,6 +64,11 @@ func Service() map[string]schema.Attribute { stringplanmodifier.UseStateForUnknown(), }, }, + "imported": schema.BoolAttribute{ + Computed: true, + Default: booldefault.StaticBool(false), + MarkdownDescription: "Used internally by the provider to temporarily indicate if the service is being imported, and is reset to false once the import is finished", + }, "last_active": schema.Int64Attribute{ Computed: true, MarkdownDescription: "The last 'active' service version (typically in-sync with `version` but not if `activate` is `false`)", diff --git a/internal/provider/tests/resources/service_vcl_test.go b/internal/provider/tests/resources/service_vcl_test.go index 7784060c..6433b637 100644 --- a/internal/provider/tests/resources/service_vcl_test.go +++ b/internal/provider/tests/resources/service_vcl_test.go @@ -239,9 +239,22 @@ func TestAccResourceServiceVCLImportServiceTypeCheck(t *testing.T) { domain2Name: domain2Name, }) + apiClient := fastly.NewAPIClient(fastly.NewConfiguration()) + ctx := fastly.NewAPIKeyContextFromEnv(helpers.APIKeyEnv) + + var computeServiceID string + resource.ParallelTest(t, resource.TestCase{ PreCheck: func() { provider.TestAccPreCheck(t) }, ProtoV6ProviderFactories: provider.TestAccProtoV6ProviderFactories, + CheckDestroy: func(*terraform.State) error { + deleteReq := apiClient.ServiceAPI.DeleteService(ctx, computeServiceID) + _, _, err := deleteReq.Execute() + if err != nil { + return fmt.Errorf("failed to delete service '%s' outside of Terraform: %w", computeServiceID, err) + } + return nil + }, Steps: []resource.TestStep{ // We need a resource to be created by Terraform so we can import into it. { @@ -252,18 +265,19 @@ func TestAccResourceServiceVCLImportServiceTypeCheck(t *testing.T) { ResourceName: "fastly_service_vcl.test", ImportState: true, ImportStateIdFunc: func(_ *terraform.State) (string, error) { - apiClient := fastly.NewAPIClient(fastly.NewConfiguration()) - ctx := fastly.NewAPIKeyContextFromEnv(helpers.APIKeyEnv) req := apiClient.ServiceAPI.CreateService(ctx) resp, _, err := req.Name(fmt.Sprintf("tf-test-compute-service-%s", acctest.RandString(10))).ResourceType("wasm").Execute() if err != nil { return "", fmt.Errorf("failed to create Compute service: %w", err) } - return *resp.ID, nil + computeServiceID = *resp.ID + return computeServiceID, nil }, ExpectError: regexp.MustCompile(`Expected service type vcl, got: wasm`), }, - // Delete testing automatically occurs in TestCase + // Delete testing automatically occurs in TestCase. + // But when creating a resource outside of TF we have to manually delete. + // See CheckDestroy() function above. }, }) } @@ -325,6 +339,95 @@ func TestAccResourceServiceVCLImportServiceVersion(t *testing.T) { }) } +// The following test validates that when we have more than one service version, +// where the latter is not 'active' (because the user has set `activate=false`), +// that we return and track that version instead of any prior active version. +// i.e. we're allowing for `version` attribute to drift from `last_active`. +func TestAccResourceServiceVCLLatestNonActiveVersion(t *testing.T) { + serviceName := fmt.Sprintf("tf-test-%s", acctest.RandString(10)) + domain1Name := fmt.Sprintf("%s-tpff-1.integralist.co.uk", serviceName) + domain1CommentAdded := "a random updated comment" + domain2Name := fmt.Sprintf("%s-tpff-2.integralist.co.uk", serviceName) + + configCreate := configServiceVCLCreate(configServiceVCLCreateOpts{ + activate: true, + forceDestroy: false, + serviceName: serviceName, + domain1Name: domain1Name, + domain2Name: domain2Name, + }) + + // Update the first domain's comment + set `activate = false`. + // + // IMPORTANT: Must set `force_destroy` to `true` so we can delete the service. + configUpdate := fmt.Sprintf(` + resource "fastly_service_vcl" "test" { + activate = false + name = "%s" + force_destroy = true + + domains = { + "example-1" = { + name = "%s" + comment = "%s" + }, + "example-2" = { + name = "%s" + }, + } + } + `, serviceName, domain1Name, domain1CommentAdded, domain2Name) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { provider.TestAccPreCheck(t) }, + ProtoV6ProviderFactories: provider.TestAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Create and Read testing + { + Config: configCreate, + }, + // Update and Read testing + { + Config: configUpdate, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("fastly_service_vcl.test", "last_active", "1"), // we expect `version` to drift from `last_active` + resource.TestCheckResourceAttr("fastly_service_vcl.test", "version", "2"), + ), + }, + // ImportState testing + { + ResourceName: "fastly_service_vcl.test", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"activate", "domain", "force_destroy", "version"}, + ImportStateCheck: func(is []*terraform.InstanceState) error { + for _, s := range is { + // In this test case, when we're importing a service we expect + // `activate = true` and so we expect to pull in the last active + // service version. + // + // FIXME: this ^^ isn't accurate! We might well want to import a + // service that doesn't have an active version. So we need to handle + // this and make sure if when importing, we can't find an active + // version that we fallback to whatever the latest version is and + // also have a test to validate that behaviour. + if version, ok := s.Attributes["version"]; ok && version != "1" { + return fmt.Errorf("import failed: expected service version 1 (the last active): got %s", version) + } + if numDomains, ok := s.Attributes["domains.%"]; ok { + if numDomains != "2" { + return fmt.Errorf("import failed: unexpected number of domains found: got %s, want 2", numDomains) + } + } + } + return nil + }, + }, + // Delete testing automatically occurs in TestCase + }, + }) +} + type configServiceVCLCreateOpts struct { activate, forceDestroy bool serviceName, domain1Name, domain2Name string