diff --git a/acceptance_tests/backup_acceptance_test.go b/acceptance_tests/backup_acceptance_test.go new file mode 100644 index 000000000..04f67426e --- /dev/null +++ b/acceptance_tests/backup_acceptance_test.go @@ -0,0 +1,249 @@ +package acceptance_tests + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "regexp" + "strconv" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + + "github.com/couchbasecloud/terraform-provider-couchbase-capella/internal/api" + backupapi "github.com/couchbasecloud/terraform-provider-couchbase-capella/internal/api/backup" + "github.com/couchbasecloud/terraform-provider-couchbase-capella/internal/errors" + providerschema "github.com/couchbasecloud/terraform-provider-couchbase-capella/internal/schema" +) + +// TestAccBackupResource creates a backup, verifies its attributes, then +// layers the couchbase-capella_backups data source on top to confirm the +// backups list returns the just-created backup. The data source step +// intentionally reuses the same backup resource (no new backup is created) +// because Capella's legacy bucket backup endpoint serialises manual +// backups per bucket and a back-to-back second backup gets stuck in a +// non-terminal state until the per-bucket spacing window elapses. +func TestAccBackupResource(t *testing.T) { + resourceName := randomStringWithPrefix("tf_acc_backup_") + resourceReference := "couchbase-capella_backup." + resourceName + dsName := randomStringWithPrefix("tf_acc_backups_ds_") + dsReference := "data.couchbase-capella_backups." + dsName + + resource.ParallelTest(t, resource.TestCase{ + ProtoV6ProviderFactories: globalProtoV6ProviderFactory, + Steps: []resource.TestStep{ + { + Config: testAccBackupResourceConfig(resourceName), + Check: resource.ComposeAggregateTestCheckFunc( + testAccExistsBackupResource(t, resourceReference), + resource.TestCheckResourceAttr(resourceReference, "organization_id", globalOrgId), + resource.TestCheckResourceAttr(resourceReference, "project_id", globalProjectId), + resource.TestCheckResourceAttr(resourceReference, "cluster_id", globalClusterId), + resource.TestCheckResourceAttr(resourceReference, "bucket_id", globalBucketId), + resource.TestCheckResourceAttrSet(resourceReference, "id"), + resource.TestCheckResourceAttrSet(resourceReference, "cycle_id"), + resource.TestCheckResourceAttrSet(resourceReference, "date"), + resource.TestCheckResourceAttrSet(resourceReference, "status"), + resource.TestCheckResourceAttrSet(resourceReference, "method"), + resource.TestCheckResourceAttrSet(resourceReference, "bucket_name"), + resource.TestCheckResourceAttrSet(resourceReference, "source"), + resource.TestCheckResourceAttrSet(resourceReference, "cloud_provider"), + ), + }, + { + Config: testAccBackupWithDatasourceConfig(resourceName, dsName), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(dsReference, "organization_id", globalOrgId), + resource.TestCheckResourceAttr(dsReference, "project_id", globalProjectId), + resource.TestCheckResourceAttr(dsReference, "cluster_id", globalClusterId), + resource.TestCheckResourceAttr(dsReference, "bucket_id", globalBucketId), + testAccCheckDataSourceContainsBackup(dsReference, resourceReference), + ), + }, + { + ResourceName: resourceReference, + ImportStateIdFunc: generateBackupImportIdForResource(resourceReference), + ImportState: true, + }, + }, + }) +} + +func TestAccBackupResourceInvalidBucket(t *testing.T) { + resourceName := randomStringWithPrefix("tf_acc_backup_invalid_") + + resource.ParallelTest(t, resource.TestCase{ + ProtoV6ProviderFactories: globalProtoV6ProviderFactory, + Steps: []resource.TestStep{ + { + Config: testAccBackupResourceConfigWithBucketID(resourceName, "00000000-0000-0000-0000-000000000000"), + ExpectError: regexp.MustCompile("Error getting latest bucket backup|There is an error during backup creation"), + }, + }, + }) +} + +func TestAccBackupResourceInvalidProject(t *testing.T) { + resourceName := randomStringWithPrefix("tf_acc_backup_invalid_proj_") + + resource.ParallelTest(t, resource.TestCase{ + ProtoV6ProviderFactories: globalProtoV6ProviderFactory, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` +%[1]s + +resource "couchbase-capella_backup" "%[2]s" { + organization_id = "%[3]s" + project_id = "00000000-0000-0000-0000-000000000000" + cluster_id = "%[4]s" + bucket_id = "%[5]s" +} +`, globalProviderBlock, resourceName, globalOrgId, globalClusterId, globalBucketId), + ExpectError: regexp.MustCompile("Error getting latest bucket backup|There is an error during backup creation"), + }, + }, + }) +} + +func TestAccBackupResourceRestoreTimesOnCreate(t *testing.T) { + resourceName := randomStringWithPrefix("tf_acc_backup_invalid_rt_") + + resource.ParallelTest(t, resource.TestCase{ + ProtoV6ProviderFactories: globalProtoV6ProviderFactory, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` +%[1]s + +resource "couchbase-capella_backup" "%[2]s" { + organization_id = "%[3]s" + project_id = "%[4]s" + cluster_id = "%[5]s" + bucket_id = "%[6]s" + restore_times = 1 +} +`, globalProviderBlock, resourceName, globalOrgId, globalProjectId, globalClusterId, globalBucketId), + ExpectError: regexp.MustCompile("restore times must not be set while create backup"), + }, + }, + }) +} + +func testAccBackupResourceConfig(resourceName string) string { + return testAccBackupResourceConfigWithBucketID(resourceName, globalBucketId) +} + +func testAccBackupResourceConfigWithBucketID(resourceName, bucketID string) string { + return fmt.Sprintf(` +%[1]s + +resource "couchbase-capella_backup" "%[2]s" { + organization_id = "%[3]s" + project_id = "%[4]s" + cluster_id = "%[5]s" + bucket_id = "%[6]s" +} +`, globalProviderBlock, resourceName, globalOrgId, globalProjectId, globalClusterId, bucketID) +} + +func testAccBackupWithDatasourceConfig(resourceName, dsName string) string { + return fmt.Sprintf(` +%[1]s + +resource "couchbase-capella_backup" "%[2]s" { + organization_id = "%[3]s" + project_id = "%[4]s" + cluster_id = "%[5]s" + bucket_id = "%[6]s" +} + +data "couchbase-capella_backups" "%[7]s" { + organization_id = "%[3]s" + project_id = "%[4]s" + cluster_id = "%[5]s" + bucket_id = "%[6]s" + depends_on = [couchbase-capella_backup.%[2]s] +} +`, globalProviderBlock, resourceName, globalOrgId, globalProjectId, globalClusterId, globalBucketId, dsName) +} + +func generateBackupImportIdForResource(resourceReference string) resource.ImportStateIdFunc { + return func(state *terraform.State) (string, error) { + var rawState map[string]string + for _, m := range state.Modules { + if len(m.Resources) > 0 { + if v, ok := m.Resources[resourceReference]; ok { + rawState = v.Primary.Attributes + } + } + } + return fmt.Sprintf("id=%s,cluster_id=%s,project_id=%s,organization_id=%s", + rawState["id"], rawState["cluster_id"], rawState["project_id"], rawState["organization_id"]), nil + } +} + +func retrieveBackupFromServer(data *providerschema.Data, organizationId, projectId, clusterId, backupId string) error { + url := fmt.Sprintf("%s/v4/organizations/%s/projects/%s/clusters/%s/backups/%s", + data.HostURL, organizationId, projectId, clusterId, backupId) + cfg := api.EndpointCfg{Url: url, Method: http.MethodGet, SuccessStatus: http.StatusOK} + response, err := data.ClientV1.ExecuteWithRetry( + context.Background(), + cfg, + nil, + data.Token, + nil, + ) + if err != nil { + return err + } + backupResp := backupapi.GetBackupResponse{} + if err := json.Unmarshal(response.Body, &backupResp); err != nil { + return err + } + if backupResp.Id == "" { + return errors.ErrNotFound + } + return nil +} + +func testAccExistsBackupResource(t *testing.T, resourceReference string) resource.TestCheckFunc { + return func(s *terraform.State) error { + var rawState map[string]string + for _, m := range s.Modules { + if len(m.Resources) > 0 { + if v, ok := m.Resources[resourceReference]; ok { + rawState = v.Primary.Attributes + } + } + } + data := newTestClient(t) + return retrieveBackupFromServer(data, rawState["organization_id"], rawState["project_id"], rawState["cluster_id"], rawState["id"]) + } +} + +// testAccCheckDataSourceContainsBackup verifies that the backups data source +// contains the specific backup created by resourceReference, regardless of +// its position in the list (older backups may appear at lower indices). +func testAccCheckDataSourceContainsBackup(dsReference, resourceReference string) resource.TestCheckFunc { + return func(s *terraform.State) error { + ds := s.RootModule().Resources[dsReference] + if ds == nil { + return fmt.Errorf("datasource %s not found in state", dsReference) + } + res := s.RootModule().Resources[resourceReference] + if res == nil { + return fmt.Errorf("resource %s not found in state", resourceReference) + } + expectedID := res.Primary.Attributes["id"] + count, _ := strconv.Atoi(ds.Primary.Attributes["data.#"]) + for i := 0; i < count; i++ { + if ds.Primary.Attributes[fmt.Sprintf("data.%d.id", i)] == expectedID { + return nil + } + } + return fmt.Errorf("datasource %s does not contain backup id=%s (checked %d items)", dsReference, expectedID, count) + } +} diff --git a/acceptance_tests/backup_schedule_acceptance_test.go b/acceptance_tests/backup_schedule_acceptance_test.go new file mode 100644 index 000000000..83a13a104 --- /dev/null +++ b/acceptance_tests/backup_schedule_acceptance_test.go @@ -0,0 +1,232 @@ +package acceptance_tests + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + + "github.com/couchbasecloud/terraform-provider-couchbase-capella/internal/api" + scheduleapi "github.com/couchbasecloud/terraform-provider-couchbase-capella/internal/api/backup_schedule" + "github.com/couchbasecloud/terraform-provider-couchbase-capella/internal/errors" + providerschema "github.com/couchbasecloud/terraform-provider-couchbase-capella/internal/schema" +) + +// TODO: legacy bucket backup schedule endpoint returns 404 "Unable to find the +// specified bucket" for a bucket that the bucket endpoint resolves successfully. +// Track via the bug filed for couchbase-capella_backup_schedule. +func TestAccBackupScheduleResource(t *testing.T) { + resourceName := randomStringWithPrefix("tf_acc_backup_schedule_") + resourceReference := "couchbase-capella_backup_schedule." + resourceName + + resource.ParallelTest(t, resource.TestCase{ + ProtoV6ProviderFactories: globalProtoV6ProviderFactory, + Steps: []resource.TestStep{ + { + Config: testAccBackupScheduleResourceConfig(resourceName, "weekly", "sunday", 10, 4, "30days", false), + Check: resource.ComposeAggregateTestCheckFunc( + testAccExistsBackupScheduleResource(t, resourceReference), + resource.TestCheckResourceAttr(resourceReference, "organization_id", globalOrgId), + resource.TestCheckResourceAttr(resourceReference, "project_id", globalProjectId), + resource.TestCheckResourceAttr(resourceReference, "cluster_id", globalClusterId), + resource.TestCheckResourceAttr(resourceReference, "bucket_id", globalBucketId), + resource.TestCheckResourceAttr(resourceReference, "type", "weekly"), + resource.TestCheckResourceAttr(resourceReference, "weekly_schedule.day_of_week", "sunday"), + resource.TestCheckResourceAttr(resourceReference, "weekly_schedule.start_at", "10"), + resource.TestCheckResourceAttr(resourceReference, "weekly_schedule.incremental_every", "4"), + resource.TestCheckResourceAttr(resourceReference, "weekly_schedule.retention_time", "30days"), + resource.TestCheckResourceAttr(resourceReference, "weekly_schedule.cost_optimized_retention", "false"), + ), + }, + { + ResourceName: resourceReference, + ImportStateIdFunc: generateBackupScheduleImportIdForResource(resourceReference), + ImportState: true, + }, + { + Config: testAccBackupScheduleResourceConfig(resourceName, "weekly", "monday", 14, 6, "60days", true), + Check: resource.ComposeAggregateTestCheckFunc( + testAccExistsBackupScheduleResource(t, resourceReference), + resource.TestCheckResourceAttr(resourceReference, "organization_id", globalOrgId), + resource.TestCheckResourceAttr(resourceReference, "project_id", globalProjectId), + resource.TestCheckResourceAttr(resourceReference, "cluster_id", globalClusterId), + resource.TestCheckResourceAttr(resourceReference, "bucket_id", globalBucketId), + resource.TestCheckResourceAttr(resourceReference, "type", "weekly"), + resource.TestCheckResourceAttr(resourceReference, "weekly_schedule.day_of_week", "monday"), + resource.TestCheckResourceAttr(resourceReference, "weekly_schedule.start_at", "14"), + resource.TestCheckResourceAttr(resourceReference, "weekly_schedule.incremental_every", "6"), + resource.TestCheckResourceAttr(resourceReference, "weekly_schedule.retention_time", "60days"), + resource.TestCheckResourceAttr(resourceReference, "weekly_schedule.cost_optimized_retention", "true"), + ), + }, + }, + }) +} + +func TestAccBackupScheduleResourceInvalidDayOfWeek(t *testing.T) { + resourceName := randomStringWithPrefix("tf_acc_backup_schedule_") + + resource.ParallelTest(t, resource.TestCase{ + ProtoV6ProviderFactories: globalProtoV6ProviderFactory, + Steps: []resource.TestStep{ + { + Config: testAccBackupScheduleResourceConfig(resourceName, "weekly", "funday", 10, 4, "30days", false), + ExpectError: regexp.MustCompile("There is an error during backup schedule creation"), + }, + }, + }) +} + +func TestAccBackupScheduleResourceInvalidRetention(t *testing.T) { + resourceName := randomStringWithPrefix("tf_acc_backup_schedule_") + + resource.ParallelTest(t, resource.TestCase{ + ProtoV6ProviderFactories: globalProtoV6ProviderFactory, + Steps: []resource.TestStep{ + { + Config: testAccBackupScheduleResourceConfig(resourceName, "weekly", "sunday", 10, 4, "9999days", false), + ExpectError: regexp.MustCompile("There is an error during backup schedule creation"), + }, + }, + }) +} + +func TestAccBackupScheduleResourceInvalidType(t *testing.T) { + resourceName := randomStringWithPrefix("tf_acc_backup_schedule_") + + resource.ParallelTest(t, resource.TestCase{ + ProtoV6ProviderFactories: globalProtoV6ProviderFactory, + Steps: []resource.TestStep{ + { + Config: testAccBackupScheduleResourceConfig(resourceName, "yearly", "sunday", 10, 4, "30days", false), + ExpectError: regexp.MustCompile("There is an error during backup schedule creation"), + }, + }, + }) +} + +func TestAccBackupScheduleResourceInvalidStartAt(t *testing.T) { + resourceName := randomStringWithPrefix("tf_acc_backup_schedule_") + + resource.ParallelTest(t, resource.TestCase{ + ProtoV6ProviderFactories: globalProtoV6ProviderFactory, + Steps: []resource.TestStep{ + { + Config: testAccBackupScheduleResourceConfig(resourceName, "weekly", "sunday", 99, 4, "30days", false), + ExpectError: regexp.MustCompile("There is an error during backup schedule creation"), + }, + }, + }) +} + +func TestAccBackupScheduleResourceInvalidBucket(t *testing.T) { + resourceName := randomStringWithPrefix("tf_acc_backup_schedule_invalid_bkt_") + + resource.ParallelTest(t, resource.TestCase{ + ProtoV6ProviderFactories: globalProtoV6ProviderFactory, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` +%[1]s + +resource "couchbase-capella_backup_schedule" "%[2]s" { + organization_id = "%[3]s" + project_id = "%[4]s" + cluster_id = "%[5]s" + bucket_id = "00000000-0000-0000-0000-000000000000" + type = "weekly" + weekly_schedule = { + day_of_week = "sunday" + start_at = 10 + incremental_every = 4 + retention_time = "30days" + cost_optimized_retention = false + } +} +`, globalProviderBlock, resourceName, globalOrgId, globalProjectId, globalClusterId), + ExpectError: regexp.MustCompile("There is an error during backup schedule creation"), + }, + }, + }) +} + +func testAccBackupScheduleResourceConfig(resourceName, scheduleType, dayOfWeek string, startAt, incrementalEvery int, retentionTime string, costOptimized bool) string { + return fmt.Sprintf(` +%[1]s + +resource "couchbase-capella_backup_schedule" "%[2]s" { + organization_id = "%[3]s" + project_id = "%[4]s" + cluster_id = "%[5]s" + bucket_id = "%[6]s" + type = "%[7]s" + weekly_schedule = { + day_of_week = "%[8]s" + start_at = %[9]d + incremental_every = %[10]d + retention_time = "%[11]s" + cost_optimized_retention = %[12]t + } +} +`, globalProviderBlock, resourceName, globalOrgId, globalProjectId, globalClusterId, globalBucketId, + scheduleType, dayOfWeek, startAt, incrementalEvery, retentionTime, costOptimized) +} + +func generateBackupScheduleImportIdForResource(resourceReference string) resource.ImportStateIdFunc { + return func(state *terraform.State) (string, error) { + var rawState map[string]string + for _, m := range state.Modules { + if len(m.Resources) > 0 { + if v, ok := m.Resources[resourceReference]; ok { + rawState = v.Primary.Attributes + } + } + } + return fmt.Sprintf("organization_id=%s,project_id=%s,cluster_id=%s,bucket_id=%s", + rawState["organization_id"], rawState["project_id"], rawState["cluster_id"], rawState["bucket_id"]), nil + } +} + +func retrieveBackupScheduleFromServer(data *providerschema.Data, organizationId, projectId, clusterId, bucketId string) error { + url := fmt.Sprintf("%s/v4/organizations/%s/projects/%s/clusters/%s/buckets/%s/backup/schedules", + data.HostURL, organizationId, projectId, clusterId, bucketId) + cfg := api.EndpointCfg{Url: url, Method: http.MethodGet, SuccessStatus: http.StatusOK} + response, err := data.ClientV1.ExecuteWithRetry( + context.Background(), + cfg, + nil, + data.Token, + nil, + ) + if err != nil { + return err + } + scheduleResp := scheduleapi.GetBackupScheduleResponse{} + if err := json.Unmarshal(response.Body, &scheduleResp); err != nil { + return err + } + if scheduleResp.WeeklySchedule == nil { + return errors.ErrNotFound + } + return nil +} + +func testAccExistsBackupScheduleResource(t *testing.T, resourceReference string) resource.TestCheckFunc { + return func(s *terraform.State) error { + var rawState map[string]string + for _, m := range s.Modules { + if len(m.Resources) > 0 { + if v, ok := m.Resources[resourceReference]; ok { + rawState = v.Primary.Attributes + } + } + } + data := newTestClient(t) + return retrieveBackupScheduleFromServer(data, rawState["organization_id"], rawState["project_id"], rawState["cluster_id"], rawState["bucket_id"]) + } +} diff --git a/acceptance_tests/bucket.go b/acceptance_tests/bucket.go index b604d6090..a624b0d48 100644 --- a/acceptance_tests/bucket.go +++ b/acceptance_tests/bucket.go @@ -13,25 +13,45 @@ import ( bucketapi "github.com/couchbasecloud/terraform-provider-couchbase-capella/internal/api/bucket" ) -func createBucket(ctx context.Context, client *api.Client) error { - // First, check if bucket already exists +// fetchBucket returns the bucket name for the given ID, or ("", notFound) if +// the bucket does not exist. +func fetchBucket(ctx context.Context, client *api.Client, bucketId string) (name string, found bool, err error) { + url := fmt.Sprintf("%s/v4/organizations/%s/projects/%s/clusters/%s/buckets/%s", globalHost, globalOrgId, globalProjectId, globalClusterId, bucketId) + cfg := api.EndpointCfg{Url: url, Method: http.MethodGet, SuccessStatus: http.StatusOK} + response, apiErr := client.ExecuteWithRetry(ctx, cfg, nil, globalToken, nil) + if apiErr != nil { + var apiErrTyped *api.Error + if errors.As(apiErr, &apiErrTyped) && apiErrTyped.HttpStatusCode == http.StatusNotFound { + return "", false, nil + } + return "", false, apiErr + } + var bucket bucketapi.GetBucketResponse + if err = json.Unmarshal(response.Body, &bucket); err != nil { + return "", false, err + } + return bucket.Name, true, nil +} + +func discoverFirstBucket(ctx context.Context, client *api.Client) (string, string, error) { listUrl := fmt.Sprintf("%s/v4/organizations/%s/projects/%s/clusters/%s/buckets", globalHost, globalOrgId, globalProjectId, globalClusterId) listCfg := api.EndpointCfg{Url: listUrl, Method: http.MethodGet, SuccessStatus: http.StatusOK} - - // Use the paginated API to get all buckets buckets, err := api.GetPaginated[[]bucketapi.GetBucketResponse](ctx, client, globalToken, listCfg, api.SortById) - if err == nil { - // Check if bucket with globalBucketName already exists - for _, bucket := range buckets { - if bucket.Name == globalBucketName { - globalBucketId = bucket.Id - log.Printf("Bucket '%s' already exists with ID: %s", globalBucketName, globalBucketId) - return nil - } + if err != nil { + return "", "", err + } + for _, bucket := range buckets { + if bucket.Name == globalBucketName { + return bucket.Id, bucket.Name, nil } } + if len(buckets) > 0 { + return buckets[0].Id, buckets[0].Name, nil + } + return "", "", nil +} - // Bucket doesn't exist, create it +func createBucket(ctx context.Context, client *api.Client) error { bucketRequest := bucketapi.CreateBucketRequest{ Name: globalBucketName, } @@ -58,6 +78,62 @@ func createBucket(ctx context.Context, client *api.Client) error { return nil } +func resolveBucket(ctx context.Context, client *api.Client) error { + if globalBucketId != "" { + name, found, err := fetchBucket(ctx, client, globalBucketId) + if err != nil { + return err + } + if found { + globalBucketName = name + log.Printf("Using existing bucket: %s (%s)", name, globalBucketId) + return nil + } + log.Printf("TF_VAR_bucket_id=%s does not exist on cluster; discovering or creating one", globalBucketId) + globalBucketId = "" + } + + discoveredId, discoveredName, err := discoverFirstBucket(ctx, client) + if err != nil { + return err + } + if discoveredId != "" { + globalBucketId = discoveredId + globalBucketName = discoveredName + log.Printf("Discovered existing bucket: %s (%s)", discoveredName, discoveredId) + return nil + } + + if err := createBucket(ctx, client); err != nil { + return err + } + // Mark the bucket as created BEFORE waiting for it to become healthy. The + // POST has already succeeded so the bucket exists remotely; if bucketWait + // times out, cleanup must still delete it to avoid leaking the bucket + // across flaky runs. + globalBucketCreated = true + if err := bucketWait(ctx, client); err != nil { + return err + } + log.Printf("Created bucket: %s (%s)", globalBucketName, globalBucketId) + return nil +} + +func destroyBucket(ctx context.Context, client *api.Client) error { + url := fmt.Sprintf("%s/v4/organizations/%s/projects/%s/clusters/%s/buckets/%s", globalHost, globalOrgId, globalProjectId, globalClusterId, globalBucketId) + cfg := api.EndpointCfg{Url: url, Method: http.MethodDelete, SuccessStatus: http.StatusNoContent} + _, err := client.ExecuteWithRetry(ctx, cfg, nil, globalToken, nil) + if err != nil { + var apiErr *api.Error + if errors.As(err, &apiErr) && apiErr.HttpStatusCode == http.StatusNotFound { + return nil + } + return err + } + log.Printf("bucket destroyed: %s", globalBucketId) + return nil +} + func resolveBucketNameById(ctx context.Context, client *api.Client, bucketID string) (string, error) { listUrl := fmt.Sprintf("%s/v4/organizations/%s/projects/%s/clusters/%s/buckets", globalHost, globalOrgId, globalProjectId, globalClusterId) listCfg := api.EndpointCfg{Url: listUrl, Method: http.MethodGet, SuccessStatus: http.StatusOK} diff --git a/acceptance_tests/cloud_snapshot_backup_schedule_datasource_test.go b/acceptance_tests/cloud_snapshot_backup_schedule_datasource_test.go new file mode 100644 index 000000000..98319c07d --- /dev/null +++ b/acceptance_tests/cloud_snapshot_backup_schedule_datasource_test.go @@ -0,0 +1,82 @@ +package acceptance_tests + +import ( + "fmt" + "regexp" + "testing" + "time" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestAccDatasourceCloudSnapshotBackupSchedule(t *testing.T) { + scheduleResourceName := randomStringWithPrefix("tf_acc_cloud_snapshot_backup_schedule_") + dsName := randomStringWithPrefix("tf_acc_cloud_snapshot_backup_schedule_ds_") + dsReference := "data.couchbase-capella_cloud_snapshot_backup_schedule." + dsName + + startTime := time.Now().Add(24 * time.Hour).Truncate(time.Hour).Format(time.RFC3339) + + resource.ParallelTest(t, resource.TestCase{ + ProtoV6ProviderFactories: globalProtoV6ProviderFactory, + Steps: []resource.TestStep{ + { + Config: testAccCloudSnapshotBackupScheduleDatasourceConfig(scheduleResourceName, dsName, 12, 240, startTime), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(dsReference, "organization_id", globalOrgId), + resource.TestCheckResourceAttr(dsReference, "project_id", globalProjectId), + resource.TestCheckResourceAttr(dsReference, "cluster_id", globalClusterId), + resource.TestCheckResourceAttr(dsReference, "interval", "12"), + resource.TestCheckResourceAttr(dsReference, "retention", "240"), + resource.TestCheckResourceAttr(dsReference, "start_time", startTime), + resource.TestCheckResourceAttr(dsReference, "copy_to_regions.#", "0"), + ), + }, + }, + }) +} + +func TestAccDatasourceCloudSnapshotBackupScheduleInvalidCluster(t *testing.T) { + dsName := randomStringWithPrefix("tf_acc_cloud_snapshot_backup_schedule_ds_invalid_") + + resource.ParallelTest(t, resource.TestCase{ + ProtoV6ProviderFactories: globalProtoV6ProviderFactory, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` +%[1]s + +data "couchbase-capella_cloud_snapshot_backup_schedule" "%[2]s" { + organization_id = "%[3]s" + project_id = "%[4]s" + cluster_id = "00000000-0000-0000-0000-000000000000" +} +`, globalProviderBlock, dsName, globalOrgId, globalProjectId), + ExpectError: regexp.MustCompile("Error Reading Capella Snapshot Backup Schedule"), + }, + }, + }) +} + +func testAccCloudSnapshotBackupScheduleDatasourceConfig(scheduleResourceName, dsName string, interval, retention int, startTime string) string { + return fmt.Sprintf(` +%[1]s + +resource "couchbase-capella_cloud_snapshot_backup_schedule" "%[2]s" { + organization_id = "%[3]s" + project_id = "%[4]s" + cluster_id = "%[5]s" + interval = %[6]d + retention = %[7]d + start_time = "%[8]s" + copy_to_regions = null +} + +data "couchbase-capella_cloud_snapshot_backup_schedule" "%[9]s" { + organization_id = "%[3]s" + project_id = "%[4]s" + cluster_id = "%[5]s" + depends_on = [couchbase-capella_cloud_snapshot_backup_schedule.%[2]s] +} +`, globalProviderBlock, scheduleResourceName, globalOrgId, globalProjectId, globalClusterId, + interval, retention, startTime, dsName) +} diff --git a/acceptance_tests/cluster.go b/acceptance_tests/cluster.go index 3a4ff97dc..aa125e404 100644 --- a/acceptance_tests/cluster.go +++ b/acceptance_tests/cluster.go @@ -80,6 +80,23 @@ func createCluster(ctx context.Context, client *api.Client) error { return nil } +// findClusterByName lists clusters in the current project and returns the ID of +// the first cluster matching name, or "" if none is found. +func findClusterByName(ctx context.Context, client *api.Client, name string) (string, error) { + url := fmt.Sprintf("%s/v4/organizations/%s/projects/%s/clusters", globalHost, globalOrgId, globalProjectId) + cfg := api.EndpointCfg{Url: url, Method: http.MethodGet, SuccessStatus: http.StatusOK} + clusters, err := api.GetPaginated[[]clusterapi.GetClusterResponse](ctx, client, globalToken, cfg, api.SortById) + if err != nil { + return "", err + } + for _, c := range clusters { + if c.Name == name { + return c.Id.String(), nil + } + } + return "", nil +} + func destroyCluster(ctx context.Context, client *api.Client) error { url := fmt.Sprintf("%s/v4/organizations/%s/projects/%s/clusters/%s", globalHost, globalOrgId, globalProjectId, globalClusterId) cfg := api.EndpointCfg{Url: url, Method: http.MethodDelete, SuccessStatus: http.StatusAccepted} diff --git a/acceptance_tests/globals.go b/acceptance_tests/globals.go index 611f691da..cbfe33786 100644 --- a/acceptance_tests/globals.go +++ b/acceptance_tests/globals.go @@ -27,6 +27,7 @@ var ( globalScopeName = "_default" globalCollectionName = "_default" globalBucketId string + globalBucketCreated bool globalAppServiceId string globalAppServiceName = "tf_acc_test_app_service_common" globalAppEndpointName = "tf_acc_test_app_endpoint_common" diff --git a/acceptance_tests/project.go b/acceptance_tests/project.go index 7c917e2b7..e16a9ed76 100644 --- a/acceptance_tests/project.go +++ b/acceptance_tests/project.go @@ -3,6 +3,7 @@ package acceptance_tests import ( "context" "encoding/json" + "errors" "fmt" "log" "net/http" @@ -10,51 +11,69 @@ import ( "github.com/couchbasecloud/terraform-provider-couchbase-capella/internal/api" ) -func createProject(ctx context.Context, client *api.Client) error { +const projectName = "tf_acc_test_project_common" + +// createProject creates a new test project. On quota error (403) it falls back +// to findProjectByName and reuses an existing project so that concurrent CI +// runs sharing one Capella account do not all fail when the project limit is +// reached. Returns (created=true) only when a new project was actually created. +func createProject(ctx context.Context, client *api.Client) (bool, error) { projectRequest := api.CreateProjectRequest{ - Name: "tf_acc_test_project_common", + Name: projectName, } url := fmt.Sprintf("%s/v4/organizations/%s/projects", globalHost, globalOrgId) cfg := api.EndpointCfg{Url: url, Method: http.MethodPost, SuccessStatus: http.StatusCreated} - response, err := client.ExecuteWithRetry( - ctx, - cfg, - projectRequest, - globalToken, - nil, - ) + response, err := client.ExecuteWithRetry(ctx, cfg, projectRequest, globalToken, nil) if err != nil { - return err + // On quota exhaustion, try to reuse an existing project rather than + // failing the whole test suite immediately. + var apiErr *api.Error + if errors.As(err, &apiErr) && apiErr.HttpStatusCode == http.StatusForbidden { + id, findErr := findProjectByName(ctx, client, projectName) + if findErr == nil && id != "" { + log.Printf("project quota hit; reusing existing project %s", id) + globalProjectId = id + return false, nil + } + } + return false, err } projectResponse := api.GetProjectResponse{} if err = json.Unmarshal(response.Body, &projectResponse); err != nil { - return err + return false, err } log.Print("project created") - globalProjectId = projectResponse.Id.String() + return true, nil +} - return nil +// findProjectByName returns the ID of the first project in the org whose name +// matches, or "" if none is found. +func findProjectByName(ctx context.Context, client *api.Client, name string) (string, error) { + url := fmt.Sprintf("%s/v4/organizations/%s/projects", globalHost, globalOrgId) + cfg := api.EndpointCfg{Url: url, Method: http.MethodGet, SuccessStatus: http.StatusOK} + projects, err := api.GetPaginated[[]api.GetProjectResponse](ctx, client, globalToken, cfg, api.SortById) + if err != nil { + return "", err + } + for _, p := range projects { + if p.Name == name { + return p.Id.String(), nil + } + } + return "", nil } func destroyProject(ctx context.Context, client *api.Client) error { url := fmt.Sprintf("%s/v4/organizations/%s/projects/%s", globalHost, globalOrgId, globalProjectId) cfg := api.EndpointCfg{Url: url, Method: http.MethodDelete, SuccessStatus: http.StatusNoContent} - _, err := client.ExecuteWithRetry( - ctx, - cfg, - nil, - globalToken, - nil, - ) + _, err := client.ExecuteWithRetry(ctx, cfg, nil, globalToken, nil) if err != nil { return err } - log.Print("project destroyed") - return nil } diff --git a/acceptance_tests/setup_test.go b/acceptance_tests/setup_test.go index 0706659c9..81d3c224b 100644 --- a/acceptance_tests/setup_test.go +++ b/acceptance_tests/setup_test.go @@ -2,6 +2,7 @@ package acceptance_tests import ( "context" + "errors" "log" "os" "testing" @@ -58,10 +59,11 @@ provider "couchbase-capella" { func setup(ctx context.Context, client *api.Client) error { // Create project only if not provided via env var if globalProjectId == "" { - if err := createProject(ctx, client); err != nil { + created, err := createProject(ctx, client) + if err != nil { return err } - globalProjectCreated = true + globalProjectCreated = created } else { log.Printf("Using existing project: %s", globalProjectId) } @@ -69,9 +71,21 @@ func setup(ctx context.Context, client *api.Client) error { // Create cluster only if not provided via env var if globalClusterId == "" { if err := createCluster(ctx, client); err != nil { - return err + // Only fall back to findClusterByName on 5xx (AV-129960): the backend + // sometimes returns 500 while still creating the cluster. + var apiErr *api.Error + if !errors.As(err, &apiErr) || apiErr.HttpStatusCode < 500 { + return err + } + id, findErr := findClusterByName(ctx, client, globalClusterName) + if findErr != nil || id == "" { + return err + } + log.Printf("createCluster returned 5xx but cluster was found; adopting %s", id) + globalClusterId = id + } else { + globalClusterCreated = true } - globalClusterCreated = true if err := clusterWait(ctx, client, false); err != nil { return err } @@ -79,21 +93,8 @@ func setup(ctx context.Context, client *api.Client) error { log.Printf("Using existing cluster: %s", globalClusterId) } - // Create bucket only if not provided via env var - if globalBucketId == "" { - if err := createBucket(ctx, client); err != nil { - return err - } - if err := bucketWait(ctx, client); err != nil { - return err - } - } else { - bucketName, err := resolveBucketNameById(ctx, client, globalBucketId) - if err != nil { - return err - } - globalBucketName = bucketName - log.Printf("Using existing bucket: %s (%s)", globalBucketName, globalBucketId) + if err := resolveBucket(ctx, client); err != nil { + return err } // Create app service only if not provided via env var @@ -142,6 +143,12 @@ func cleanup(ctx context.Context, client *api.Client) error { } } + if globalBucketCreated { + if err := destroyBucket(ctx, client); err != nil { + return err + } + } + if globalClusterCreated { if err := destroyCluster(ctx, client); err != nil { return err diff --git a/internal/resources/backup.go b/internal/resources/backup.go index 413da32a9..428443f8e 100644 --- a/internal/resources/backup.go +++ b/internal/resources/backup.go @@ -412,16 +412,21 @@ func (b *Backup) checkLatestBackupStatus(ctx context.Context, organizationId, pr err error ) - // Assuming 60 minutes is the max time backup completion takes, can change after discussion - const timeout = time.Minute * 60 + // Capella legacy bucket backup completion can take well over an hour under + // load. 90 minutes gives realistic headroom; previous 60-minute limit was + // being exhausted on busy clusters. + const timeout = 90 * time.Minute var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, timeout) defer cancel() - const sleep = time.Second * 1 + // Poll every 30 seconds instead of every 1 second. The previous 1-second + // cadence issued ~3600 GETs per backup wait and could itself contribute to + // backend pressure; 30 s still detects completion promptly. + const sleep = 30 * time.Second - timer := time.NewTimer(1 * time.Second) + timer := time.NewTimer(sleep) for { select {