diff --git a/mmv1/products/gkebackup/BackupPlan.yaml b/mmv1/products/gkebackup/BackupPlan.yaml index 50acbc1d26d5..0d74bf3c84b4 100644 --- a/mmv1/products/gkebackup/BackupPlan.yaml +++ b/mmv1/products/gkebackup/BackupPlan.yaml @@ -549,3 +549,10 @@ properties: description: | Detailed description of why BackupPlan is in its current state. output: true + - name: 'tags' + type: KeyValuePairs + description: | + A map of resource manager tags. + Resource manager tag keys and values have the same definition as resource manager tags. + Keys must be in the format tagKeys/{tag_key_id}, and values are in the format tagValues/{tag_key_value}. + immutable: true diff --git a/mmv1/products/gkebackup/RestorePlan.yaml b/mmv1/products/gkebackup/RestorePlan.yaml index ca41261db03d..49e1b32df94c 100644 --- a/mmv1/products/gkebackup/RestorePlan.yaml +++ b/mmv1/products/gkebackup/RestorePlan.yaml @@ -657,3 +657,10 @@ properties: description: | Detailed description of why RestorePlan is in its current state. output: true + - name: 'tags' + type: KeyValuePairs + description: | + A map of resource manager tags. + Resource manager tag keys and values have the same definition as resource manager tags. + Keys must be in the format tagKeys/{tag_key_id}, and values are in the format tagValues/{tag_key_value}. + immutable: true diff --git a/mmv1/third_party/terraform/services/gkebackup/resource_gke_backup_backup_plan_test.go b/mmv1/third_party/terraform/services/gkebackup/resource_gke_backup_backup_plan_test.go index 536137fbcda7..1ddc636fc5cf 100644 --- a/mmv1/third_party/terraform/services/gkebackup/resource_gke_backup_backup_plan_test.go +++ b/mmv1/third_party/terraform/services/gkebackup/resource_gke_backup_backup_plan_test.go @@ -1,15 +1,20 @@ package gkebackup_test import ( + "fmt" + "net/url" + "strings" "testing" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" "github.com/hashicorp/terraform-provider-google/google/acctest" "github.com/hashicorp/terraform-provider-google/google/envvar" tpgcompute "github.com/hashicorp/terraform-provider-google/google/services/compute" _ "github.com/hashicorp/terraform-provider-google/google/services/container" _ "github.com/hashicorp/terraform-provider-google/google/services/gkebackup" - - "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-provider-google/google/services/tags" + transport_tpg "github.com/hashicorp/terraform-provider-google/google/transport" ) func TestAccGKEBackupBackupPlan_update(t *testing.T) { @@ -367,3 +372,215 @@ resource "google_gke_backup_backup_plan" "backupplan" { } `, context) } + +func TestAccGKEBackupBackupPlan_tags(t *testing.T) { + t.Parallel() + + tagKey := tags.BootstrapSharedTestOrganizationTagKey(t, "gkebackup-backupplan-tagkey", map[string]interface{}{}) + + context := map[string]interface{}{ + "project": envvar.GetTestProjectFromEnv(), + "org": envvar.GetTestOrgFromEnv(t), + "random_suffix": acctest.RandString(t, 10), + "network_name": tpgcompute.BootstrapSharedTestNetwork(t, "gke-cluster"), + "tagKey": tagKey, + "tagValue": tags.BootstrapSharedTestOrganizationTagValue(t, "gkebackup-backupplan-tagvalue", tagKey), + "region": "us-east1", + } + + acctest.VcrTest(t, resource.TestCase{ + PreCheck: func() { acctest.AccTestPreCheck(t) }, + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories(t), + CheckDestroy: testAccCheckGKEBackupBackupPlanDestroyProducer(t), + Steps: []resource.TestStep{ + { + Config: testAccGKEBackupBackupPlan_tags(context), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("google_gke_backup_backup_plan.backupplan", "tags.%"), + testAccCheckGKEBackupBackupPlanHasTagBindings(t), + ), + }, + { + ResourceName: "google_gke_backup_backup_plan.backupplan", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"labels", "terraform_labels"}, + }, + }, + }) +} + +func testAccGKEBackupBackupPlan_tags(context map[string]interface{}) string { + return acctest.Nprintf(` +resource "google_compute_subnetwork" "primary" { + name = "tf-test-subnet%{random_suffix}" + ip_cidr_range = "10.2.0.0/20" + region = "%{region}" + network = "%{network_name}" +} + +resource "google_container_cluster" "primary" { + name = "tf-test-testcluster%{random_suffix}" + location = "%{region}" + initial_node_count = 1 + workload_identity_config { + workload_pool = "%{project}.svc.id.goog" + } + addons_config { + gke_backup_agent_config { + enabled = true + } + } + deletion_protection = false + network = "%{network_name}" + subnetwork = google_compute_subnetwork.primary.name +} + +resource "google_gke_backup_backup_plan" "backupplan" { + name = "tf-test-testplan%{random_suffix}" + cluster = google_container_cluster.primary.id + location = "%{region}" + backup_config { + include_volume_data = false + include_secrets = false + all_namespaces = true + } + labels = { + "some-key-1": "some-value-1" + } + tags = { + "%{org}/%{tagKey}" = "%{tagValue}" + } +} +`, context) +} + +func testAccCheckGKEBackupBackupPlanHasTagBindings(t *testing.T) func(s *terraform.State) error { + return func(s *terraform.State) error { + for name, rs := range s.RootModule().Resources { + if rs.Type != "google_gke_backup_backup_plan" { + continue + } + if strings.HasPrefix(name, "data.") { + continue + } + + config := acctest.GoogleProviderConfig(t) + + // 1. Get the configured tag key and value from the state. + var configuredTagValueNamespacedName string + var tagKeyNamespacedName, tagValueShortName string + for key, val := range rs.Primary.Attributes { + if strings.HasPrefix(key, "tags.") && key != "tags.%" { + tagKeyNamespacedName = strings.TrimPrefix(key, "tags.") + tagValueShortName = val + if tagValueShortName != "" { + configuredTagValueNamespacedName = fmt.Sprintf("%s/%s", tagKeyNamespacedName, tagValueShortName) + break + } + } + } + + if configuredTagValueNamespacedName == "" { + return fmt.Errorf("could not find a configured tag value in the state for resource %s", rs.Primary.ID) + } + + // Check if placeholders are still present. + if strings.Contains(configuredTagValueNamespacedName, "%{") { + return fmt.Errorf("tag namespaced name contains unsubstituted variables: %q. Ensure the context map in the test step is populated", configuredTagValueNamespacedName) + } + + // 2. Describe the tag value using the namespaced name to get its full resource name. + safeNamespacedName := url.QueryEscape(configuredTagValueNamespacedName) + describeTagValueURL := fmt.Sprintf("https://cloudresourcemanager.googleapis.com/v3/tagValues/namespaced?name=%s", safeNamespacedName) + + respDescribe, err := transport_tpg.SendRequest(transport_tpg.SendRequestOptions{ + Config: config, + Method: "GET", + RawURL: describeTagValueURL, + UserAgent: config.UserAgent, + }) + + if err != nil { + return fmt.Errorf("error describing tag value using namespaced name %q: %v", configuredTagValueNamespacedName, err) + } + + fullTagValueName, ok := respDescribe["name"].(string) + if !ok || fullTagValueName == "" { + return fmt.Errorf("tag value details (name) not found in response for namespaced name: %q, response: %v", configuredTagValueNamespacedName, respDescribe) + } + + // 3. Check if tags are public (returned in resource GET response) + parts := strings.Split(rs.Primary.ID, "/") + if len(parts) != 6 { + return fmt.Errorf("invalid resource ID format: %s", rs.Primary.ID) + } + project := parts[1] + location := parts[3] + planName := parts[5] + + resourceURL := fmt.Sprintf("https://gkebackup.googleapis.com/v1/projects/%s/locations/%s/backupPlans/%s", project, location, planName) + respResource, err := transport_tpg.SendRequest(transport_tpg.SendRequestOptions{ + Config: config, + Method: "GET", + RawURL: resourceURL, + UserAgent: config.UserAgent, + }) + if err != nil { + return fmt.Errorf("error calling BackupPlan GET API: %v", err) + } + + if tagsVal, exists := respResource["tags"]; exists { + t.Logf("Tags are public and returned in BackupPlan GET response: %v", tagsVal) + } else { + t.Logf("Tags are NOT returned in BackupPlan GET response") + } + + // 4. Get the tag bindings from TagBindings API. + parentURL := fmt.Sprintf("//gkebackup.googleapis.com/projects/%s/locations/%s/backupPlans/%s", project, location, planName) + listBindingsURL := fmt.Sprintf("https://%s-cloudresourcemanager.googleapis.com/v3/tagBindings?parent=%s", location, url.QueryEscape(parentURL)) + + resp, err := transport_tpg.SendRequest(transport_tpg.SendRequestOptions{ + Config: config, + Method: "GET", + RawURL: listBindingsURL, + UserAgent: config.UserAgent, + }) + + if err != nil { + return fmt.Errorf("error calling TagBindings API: %v", err) + } + + tagBindingsVal, exists := resp["tagBindings"] + if !exists { + tagBindingsVal = []interface{}{} + } + + tagBindings, ok := tagBindingsVal.([]interface{}) + if !ok { + return fmt.Errorf("'tagBindings' is not a slice in response for resource %s. Response: %v", rs.Primary.ID, resp) + } + + // 5. Perform the comparison. + foundMatch := false + for _, binding := range tagBindings { + bindingMap, ok := binding.(map[string]interface{}) + if !ok { + continue + } + if bindingMap["tagValue"] == fullTagValueName { + foundMatch = true + break + } + } + + if !foundMatch { + return fmt.Errorf("expected tag value %s (from namespaced %q) not found in tag bindings for resource %s. Bindings: %v", fullTagValueName, configuredTagValueNamespacedName, rs.Primary.ID, tagBindings) + } + + t.Logf("Successfully found matching tag binding for %s with tagValue %s", rs.Primary.ID, fullTagValueName) + } + + return nil + } +} diff --git a/mmv1/third_party/terraform/services/gkebackup/resource_gke_backup_restore_plan_test.go b/mmv1/third_party/terraform/services/gkebackup/resource_gke_backup_restore_plan_test.go index 2962a8bcfcaf..109815a36017 100644 --- a/mmv1/third_party/terraform/services/gkebackup/resource_gke_backup_restore_plan_test.go +++ b/mmv1/third_party/terraform/services/gkebackup/resource_gke_backup_restore_plan_test.go @@ -1,14 +1,20 @@ package gkebackup_test import ( + "fmt" + "net/url" + "strings" "testing" "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" "github.com/hashicorp/terraform-provider-google/google/acctest" "github.com/hashicorp/terraform-provider-google/google/envvar" tpgcompute "github.com/hashicorp/terraform-provider-google/google/services/compute" _ "github.com/hashicorp/terraform-provider-google/google/services/container" _ "github.com/hashicorp/terraform-provider-google/google/services/gkebackup" + "github.com/hashicorp/terraform-provider-google/google/services/tags" + transport_tpg "github.com/hashicorp/terraform-provider-google/google/transport" ) func TestAccGKEBackupRestorePlan_update(t *testing.T) { @@ -205,3 +211,227 @@ resource "google_gke_backup_restore_plan" "restore_plan" { } `, context) } + +func TestAccGKEBackupRestorePlan_tags(t *testing.T) { + t.Parallel() + + tagKey := tags.BootstrapSharedTestOrganizationTagKey(t, "gkebackup-restoreplan-tagkey", map[string]interface{}{}) + + context := map[string]interface{}{ + "project": envvar.GetTestProjectFromEnv(), + "org": envvar.GetTestOrgFromEnv(t), + "random_suffix": acctest.RandString(t, 10), + "network_name": tpgcompute.BootstrapSharedTestNetwork(t, "gke-cluster"), + "tagKey": tagKey, + "tagValue": tags.BootstrapSharedTestOrganizationTagValue(t, "gkebackup-restoreplan-tagvalue", tagKey), + "region": "us-east1", + } + + acctest.VcrTest(t, resource.TestCase{ + PreCheck: func() { acctest.AccTestPreCheck(t) }, + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories(t), + Steps: []resource.TestStep{ + { + Config: testAccGKEBackupRestorePlan_tags(context), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("google_gke_backup_restore_plan.restore_plan", "tags.%"), + testAccCheckGKEBackupRestorePlanHasTagBindings(t), + ), + }, + { + ResourceName: "google_gke_backup_restore_plan.restore_plan", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"labels", "location", "terraform_labels"}, + }, + }, + }) +} + +func testAccGKEBackupRestorePlan_tags(context map[string]interface{}) string { + return acctest.Nprintf(` +resource "google_compute_subnetwork" "primary" { + name = "tf-test-subnet%{random_suffix}" + ip_cidr_range = "10.2.0.0/20" + region = "%{region}" + network = "%{network_name}" +} + +resource "google_container_cluster" "primary" { + name = "tf-test-testcluster%{random_suffix}" + location = "%{region}" + initial_node_count = 1 + workload_identity_config { + workload_pool = "%{project}.svc.id.goog" + } + addons_config { + gke_backup_agent_config { + enabled = true + } + } + deletion_protection = false + network = "%{network_name}" + subnetwork = google_compute_subnetwork.primary.name +} + +resource "google_gke_backup_backup_plan" "basic" { + name = "tf-test-restore-plan%{random_suffix}" + cluster = google_container_cluster.primary.id + location = "%{region}" + backup_config { + include_volume_data = true + include_secrets = true + all_namespaces = true + } +} + +resource "google_gke_backup_restore_plan" "restore_plan" { + name = "tf-test-restore-plan%{random_suffix}" + location = "%{region}" + backup_plan = google_gke_backup_backup_plan.basic.id + cluster = google_container_cluster.primary.id + restore_config { + all_namespaces = true + namespaced_resource_restore_mode = "MERGE_SKIP_ON_CONFLICT" + volume_data_restore_policy = "RESTORE_VOLUME_DATA_FROM_BACKUP" + cluster_resource_restore_scope { + all_group_kinds = true + } + cluster_resource_conflict_policy = "USE_EXISTING_VERSION" + } + tags = { + "%{org}/%{tagKey}" = "%{tagValue}" + } +} +`, context) +} + +func testAccCheckGKEBackupRestorePlanHasTagBindings(t *testing.T) func(s *terraform.State) error { + return func(s *terraform.State) error { + for name, rs := range s.RootModule().Resources { + if rs.Type != "google_gke_backup_restore_plan" { + continue + } + if strings.HasPrefix(name, "data.") { + continue + } + + config := acctest.GoogleProviderConfig(t) + + // 1. Get the configured tag key and value from the state. + var configuredTagValueNamespacedName string + var tagKeyNamespacedName, tagValueShortName string + for key, val := range rs.Primary.Attributes { + if strings.HasPrefix(key, "tags.") && key != "tags.%" { + tagKeyNamespacedName = strings.TrimPrefix(key, "tags.") + tagValueShortName = val + if tagValueShortName != "" { + configuredTagValueNamespacedName = fmt.Sprintf("%s/%s", tagKeyNamespacedName, tagValueShortName) + break + } + } + } + + if configuredTagValueNamespacedName == "" { + return fmt.Errorf("could not find a configured tag value in the state for resource %s", rs.Primary.ID) + } + + // Check if placeholders are still present. + if strings.Contains(configuredTagValueNamespacedName, "%{") { + return fmt.Errorf("tag namespaced name contains unsubstituted variables: %q. Ensure the context map in the test step is populated", configuredTagValueNamespacedName) + } + + // 2. Describe the tag value using the namespaced name to get its full resource name. + safeNamespacedName := url.QueryEscape(configuredTagValueNamespacedName) + describeTagValueURL := fmt.Sprintf("https://cloudresourcemanager.googleapis.com/v3/tagValues/namespaced?name=%s", safeNamespacedName) + + respDescribe, err := transport_tpg.SendRequest(transport_tpg.SendRequestOptions{ + Config: config, + Method: "GET", + RawURL: describeTagValueURL, + UserAgent: config.UserAgent, + }) + + if err != nil { + return fmt.Errorf("error describing tag value using namespaced name %q: %v", configuredTagValueNamespacedName, err) + } + + fullTagValueName, ok := respDescribe["name"].(string) + if !ok || fullTagValueName == "" { + return fmt.Errorf("tag value details (name) not found in response for namespaced name: %q, response: %v", configuredTagValueNamespacedName, respDescribe) + } + + // 3. Check if tags are public (returned in resource GET response) + parts := strings.Split(rs.Primary.ID, "/") + if len(parts) != 6 { + return fmt.Errorf("invalid resource ID format: %s", rs.Primary.ID) + } + project := parts[1] + location := parts[3] + planName := parts[5] + + resourceURL := fmt.Sprintf("https://gkebackup.googleapis.com/v1/projects/%s/locations/%s/restorePlans/%s", project, location, planName) + respResource, err := transport_tpg.SendRequest(transport_tpg.SendRequestOptions{ + Config: config, + Method: "GET", + RawURL: resourceURL, + UserAgent: config.UserAgent, + }) + if err != nil { + return fmt.Errorf("error calling RestorePlan GET API: %v", err) + } + + if tagsVal, exists := respResource["tags"]; exists { + t.Logf("Tags are public and returned in RestorePlan GET response: %v", tagsVal) + } else { + t.Logf("Tags are NOT returned in RestorePlan GET response") + } + + // 4. Get the tag bindings from TagBindings API. + parentURL := fmt.Sprintf("//gkebackup.googleapis.com/projects/%s/locations/%s/restorePlans/%s", project, location, planName) + listBindingsURL := fmt.Sprintf("https://%s-cloudresourcemanager.googleapis.com/v3/tagBindings?parent=%s", location, url.QueryEscape(parentURL)) + + resp, err := transport_tpg.SendRequest(transport_tpg.SendRequestOptions{ + Config: config, + Method: "GET", + RawURL: listBindingsURL, + UserAgent: config.UserAgent, + }) + + if err != nil { + return fmt.Errorf("error calling TagBindings API: %v", err) + } + + tagBindingsVal, exists := resp["tagBindings"] + if !exists { + tagBindingsVal = []interface{}{} + } + + tagBindings, ok := tagBindingsVal.([]interface{}) + if !ok { + return fmt.Errorf("'tagBindings' is not a slice in response for resource %s. Response: %v", rs.Primary.ID, resp) + } + + // 5. Perform the comparison. + foundMatch := false + for _, binding := range tagBindings { + bindingMap, ok := binding.(map[string]interface{}) + if !ok { + continue + } + if bindingMap["tagValue"] == fullTagValueName { + foundMatch = true + break + } + } + + if !foundMatch { + return fmt.Errorf("expected tag value %s (from namespaced %q) not found in tag bindings for resource %s. Bindings: %v", fullTagValueName, configuredTagValueNamespacedName, rs.Primary.ID, tagBindings) + } + + t.Logf("Successfully found matching tag binding for %s with tagValue %s", rs.Primary.ID, fullTagValueName) + } + + return nil + } +}