From 69c1eb8eeb4603e16eb612ba579e5eb294247a10 Mon Sep 17 00:00:00 2001 From: Mauricio Alvarez Leon <65101411+BBBmau@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:18:11 -0700 Subject: [PATCH 01/30] `container`: set default timeout for `google_container_node_pool` to 2 hours (#18250) --- .../services/container/resource_container_node_pool.go.tmpl | 6 +++--- .../website/docs/r/container_node_pool.html.markdown | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/mmv1/third_party/terraform/services/container/resource_container_node_pool.go.tmpl b/mmv1/third_party/terraform/services/container/resource_container_node_pool.go.tmpl index b432f70529f3..e808ce486cb7 100644 --- a/mmv1/third_party/terraform/services/container/resource_container_node_pool.go.tmpl +++ b/mmv1/third_party/terraform/services/container/resource_container_node_pool.go.tmpl @@ -188,9 +188,9 @@ func ResourceContainerNodePool() *schema.Resource { Exists: resourceContainerNodePoolExists, Timeouts: &schema.ResourceTimeout{ - Create: schema.DefaultTimeout(60 * time.Minute), - Update: schema.DefaultTimeout(60 * time.Minute), - Delete: schema.DefaultTimeout(60 * time.Minute), + Create: schema.DefaultTimeout(2 * time.Hour), + Update: schema.DefaultTimeout(2 * time.Hour), + Delete: schema.DefaultTimeout(2 * time.Hour), }, SchemaVersion: 1, diff --git a/mmv1/third_party/terraform/website/docs/r/container_node_pool.html.markdown b/mmv1/third_party/terraform/website/docs/r/container_node_pool.html.markdown index d612e187ce81..9299e5145254 100644 --- a/mmv1/third_party/terraform/website/docs/r/container_node_pool.html.markdown +++ b/mmv1/third_party/terraform/website/docs/r/container_node_pool.html.markdown @@ -374,9 +374,9 @@ In addition to the arguments listed above, the following computed attributes are `google_container_node_pool` provides the following [Timeouts](https://developer.hashicorp.com/terraform/plugin/sdkv2/resources/retries-and-customizable-timeouts) configuration options: configuration options: -- `create` - (Default `60 minutes`) Used for adding node pools -- `update` - (Default `60 minutes`) Used for updates to node pools -- `delete` - (Default `60 minutes`) Used for removing node pools. +- `create` - (Default `2 hours`) Used for adding node pools +- `update` - (Default `2 hours`) Used for updates to node pools +- `delete` - (Default `2 hours`) Used for removing node pools. The `kubelet_config` block supports: From 75bf6fd4a40e48742de9acf1a2ebdd97fd673bc2 Mon Sep 17 00:00:00 2001 From: adityacherla-cpu Date: Fri, 17 Jul 2026 23:26:43 +0530 Subject: [PATCH 02/30] Promoting QuotaAdjusterSettings resource to GA (#18259) --- .../cloudquotas/QuotaAdjusterSettings.yaml | 3 +-- ...udquotas_quota_adjuster_settings_basic.tf.tmpl | 1 - ..._cloud_quotas_quota_adjuster_settings_test.go} | 15 +++------------ 3 files changed, 4 insertions(+), 15 deletions(-) rename mmv1/third_party/terraform/services/cloudquotas/{resource_cloud_quotas_quota_adjuster_settings_test.go.tmpl => resource_cloud_quotas_quota_adjuster_settings_test.go} (90%) diff --git a/mmv1/products/cloudquotas/QuotaAdjusterSettings.yaml b/mmv1/products/cloudquotas/QuotaAdjusterSettings.yaml index e5b3172da2f7..e146e8e5ff17 100644 --- a/mmv1/products/cloudquotas/QuotaAdjusterSettings.yaml +++ b/mmv1/products/cloudquotas/QuotaAdjusterSettings.yaml @@ -15,11 +15,10 @@ name: 'QuotaAdjusterSettings' description: | QuotaAdjusterSettings resource represents your quota adjuster settings for a particular project. When enabled, the quota adjuster monitors your usage for the specified resources and issues quota adjustment requests when resource usage approaches its quota value. -min_version: beta references: guides: 'Cloud Quotas Overview': 'https://cloud.google.com/docs/quotas/overview' - api: 'https://cloud.google.com/docs/quotas/reference/rest/v1beta/projects.locations.quotaAdjusterSettings' + api: 'https://cloud.google.com/docs/quotas/reference/rest/v1/projects.locations.quotaAdjusterSettings' docs: id_format: '{{parent}}/locations/global/quotaAdjusterSettings' base_url: '{{parent}}/locations/global/quotaAdjusterSettings' diff --git a/mmv1/templates/terraform/samples/services/cloudquotas/cloudquotas_quota_adjuster_settings_basic.tf.tmpl b/mmv1/templates/terraform/samples/services/cloudquotas/cloudquotas_quota_adjuster_settings_basic.tf.tmpl index 859d924bbe7a..97be2360856d 100644 --- a/mmv1/templates/terraform/samples/services/cloudquotas/cloudquotas_quota_adjuster_settings_basic.tf.tmpl +++ b/mmv1/templates/terraform/samples/services/cloudquotas/cloudquotas_quota_adjuster_settings_basic.tf.tmpl @@ -1,5 +1,4 @@ resource "google_cloud_quotas_quota_adjuster_settings" "{{$.PrimaryResourceId}}" { - provider = google-beta parent = "{{index $.ResourceIdVars "parent"}}" enablement = "{{index $.ResourceIdVars "enablement"}}" } \ No newline at end of file diff --git a/mmv1/third_party/terraform/services/cloudquotas/resource_cloud_quotas_quota_adjuster_settings_test.go.tmpl b/mmv1/third_party/terraform/services/cloudquotas/resource_cloud_quotas_quota_adjuster_settings_test.go similarity index 90% rename from mmv1/third_party/terraform/services/cloudquotas/resource_cloud_quotas_quota_adjuster_settings_test.go.tmpl rename to mmv1/third_party/terraform/services/cloudquotas/resource_cloud_quotas_quota_adjuster_settings_test.go index b844ac8512a7..dd0c52a190f6 100644 --- a/mmv1/third_party/terraform/services/cloudquotas/resource_cloud_quotas_quota_adjuster_settings_test.go.tmpl +++ b/mmv1/third_party/terraform/services/cloudquotas/resource_cloud_quotas_quota_adjuster_settings_test.go @@ -1,19 +1,16 @@ package cloudquotas_test -{{- if ne $.TargetVersionName "ga" }} - import ( "testing" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-provider-google/google/acctest" - _ "github.com/hashicorp/terraform-provider-google/google/services/resourcemanager" - _ "github.com/hashicorp/terraform-provider-google/google/services/cloudquotas" "github.com/hashicorp/terraform-provider-google/google/envvar" + _ "github.com/hashicorp/terraform-provider-google/google/services/cloudquotas" + _ "github.com/hashicorp/terraform-provider-google/google/services/resourcemanager" ) - func TestAccCloudQuotasQuotaAdjusterSettings_cloudQuotasQuotaAdjusterSettingsUpdate(t *testing.T) { t.Parallel() @@ -27,7 +24,7 @@ func TestAccCloudQuotasQuotaAdjusterSettings_cloudQuotasQuotaAdjusterSettingsUpd acctest.VcrTest(t, resource.TestCase{ PreCheck: func() { acctest.AccTestPreCheck(t) }, - ProtoV5ProviderFactories: acctest.ProtoV5ProviderBetaFactories(t), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories(t), Steps: []resource.TestStep{ { Config: testAccCloudQuotasQuotaAdjusterSettings_basic(context), @@ -54,11 +51,9 @@ func TestAccCloudQuotasQuotaAdjusterSettings_cloudQuotasQuotaAdjusterSettingsUpd func testAccCloudQuotasQuotaAdjusterSettings_basic(context map[string]interface{}) string { return acctest.Nprintf(` data "google_project" "project" { - provider = google-beta } resource "google_cloud_quotas_quota_adjuster_settings" "adjuster_settings" { - provider = google-beta parent = "projects/${data.google_project.project.number}" enablement = "%{enablement}" } @@ -68,15 +63,11 @@ func testAccCloudQuotasQuotaAdjusterSettings_basic(context map[string]interface{ func testAccCloudQuotasQuotaAdjusterSettings_updateEnabelement(context map[string]interface{}) string { return acctest.Nprintf(` data "google_project" "project" { - provider = google-beta } resource "google_cloud_quotas_quota_adjuster_settings" "adjuster_settings" { - provider = google-beta parent = "projects/${data.google_project.project.number}" enablement = "%{updated_enablement}" } `, context) } - -{{- end }} \ No newline at end of file From def01c748c1f936ff143fd8a59aa2ec035b56443 Mon Sep 17 00:00:00 2001 From: Benjamin Kaplan <58792807+bskaplan@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:24:08 -0700 Subject: [PATCH 03/30] cloudrunv2: promote start_execution_token and run_execution_token to GA (#18296) --- mmv1/products/cloudrunv2/Job.yaml | 2 - ...tmpl => resource_cloud_run_v2_job_test.go} | 93 +++++++++---------- 2 files changed, 43 insertions(+), 52 deletions(-) rename mmv1/third_party/terraform/services/cloudrunv2/{resource_cloud_run_v2_job_test.go.tmpl => resource_cloud_run_v2_job_test.go} (94%) diff --git a/mmv1/products/cloudrunv2/Job.yaml b/mmv1/products/cloudrunv2/Job.yaml index 04872ae03ca3..8e5a5f93532d 100644 --- a/mmv1/products/cloudrunv2/Job.yaml +++ b/mmv1/products/cloudrunv2/Job.yaml @@ -298,7 +298,6 @@ properties: description: |- A unique string used as a suffix creating a new execution upon job create or update. The Job will become ready when the execution is successfully started. The sum of job name and token length must be fewer than 63 characters. - min_version: beta conflicts: - run_execution_token - name: runExecutionToken @@ -306,7 +305,6 @@ properties: description: |- A unique string used as a suffix creating a new execution upon job create or update. The Job will become ready when the execution is successfully completed. The sum of job name and token length must be fewer than 63 characters. - min_version: beta conflicts: - start_execution_token - name: template diff --git a/mmv1/third_party/terraform/services/cloudrunv2/resource_cloud_run_v2_job_test.go.tmpl b/mmv1/third_party/terraform/services/cloudrunv2/resource_cloud_run_v2_job_test.go similarity index 94% rename from mmv1/third_party/terraform/services/cloudrunv2/resource_cloud_run_v2_job_test.go.tmpl rename to mmv1/third_party/terraform/services/cloudrunv2/resource_cloud_run_v2_job_test.go index 7ada3790d892..4110c0ff31da 100644 --- a/mmv1/third_party/terraform/services/cloudrunv2/resource_cloud_run_v2_job_test.go.tmpl +++ b/mmv1/third_party/terraform/services/cloudrunv2/resource_cloud_run_v2_job_test.go @@ -6,12 +6,12 @@ import ( "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-provider-google/google/acctest" - _ "github.com/hashicorp/terraform-provider-google/google/services/resourcemanager" - _ "github.com/hashicorp/terraform-provider-google/google/services/vpcaccess" - _ "github.com/hashicorp/terraform-provider-google/google/services/compute" + "github.com/hashicorp/terraform-provider-google/google/envvar" _ "github.com/hashicorp/terraform-provider-google/google/services/cloudrunv2" - "github.com/hashicorp/terraform-provider-google/google/envvar" + _ "github.com/hashicorp/terraform-provider-google/google/services/compute" + _ "github.com/hashicorp/terraform-provider-google/google/services/resourcemanager" "github.com/hashicorp/terraform-provider-google/google/services/tags" + _ "github.com/hashicorp/terraform-provider-google/google/services/vpcaccess" ) func TestAccCloudRunV2Job_cloudrunv2JobFullUpdate(t *testing.T) { @@ -219,7 +219,7 @@ func TestAccCloudRunV2Job_cloudrunv2JobWithDirectVPCUpdate(t *testing.T) { jobName := fmt.Sprintf("tf-test-cloudrun-job%s", acctest.RandString(t, 10)) context := map[string]interface{}{ "job_name": jobName, - "project": envvar.GetTestProjectFromEnv(), + "project": envvar.GetTestProjectFromEnv(), } acctest.VcrTest(t, resource.TestCase{ @@ -477,40 +477,39 @@ func testAccCloudRunV2Job_cloudrunv2JobWithNfsVolume(context map[string]interfac `, context) } - func TestAccCloudRunV2Job_cloudrunv2JobWithTags(t *testing.T) { - t.Parallel() - - org := envvar.GetTestOrgFromEnv(t) - tagKeyResult := tags.BootstrapSharedTestTagKeyDetails(t, "cloud-run-tagkey", "organizations/"+org, make(map[string]interface{})) - sharedTagkey := tagKeyResult["shared_tag_key"] - tagValueResult := tags.BootstrapSharedTestTagValueDetails(t, "cloud-run-tagvalue", sharedTagkey, org) - - context := map[string]interface{}{} - context["random_suffix"] = acctest.RandString(t, 10) - context["tag_key_id"] = tagKeyResult["name"] - context["tag_value_id"] = tagValueResult["name"] - - acctest.VcrTest(t, resource.TestCase{ - PreCheck: func() { acctest.AccTestPreCheck(t) }, - ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories(t), - CheckDestroy: testAccCheckCloudRunV2JobDestroyProducer(t), - Steps: []resource.TestStep{ - { - Config: testAccCloudRunV2Job_cloudrunv2JobWithTags(context), - }, - { - ResourceName: "google_cloud_run_v2_job.default", - ImportState: true, - ImportStateVerify: true, - ImportStateVerifyIgnore: []string{"location", "launch_stage", "deletion_protection", "tags"}, - }, - }, - }) + t.Parallel() + + org := envvar.GetTestOrgFromEnv(t) + tagKeyResult := tags.BootstrapSharedTestTagKeyDetails(t, "cloud-run-tagkey", "organizations/"+org, make(map[string]interface{})) + sharedTagkey := tagKeyResult["shared_tag_key"] + tagValueResult := tags.BootstrapSharedTestTagValueDetails(t, "cloud-run-tagvalue", sharedTagkey, org) + + context := map[string]interface{}{} + context["random_suffix"] = acctest.RandString(t, 10) + context["tag_key_id"] = tagKeyResult["name"] + context["tag_value_id"] = tagValueResult["name"] + + acctest.VcrTest(t, resource.TestCase{ + PreCheck: func() { acctest.AccTestPreCheck(t) }, + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories(t), + CheckDestroy: testAccCheckCloudRunV2JobDestroyProducer(t), + Steps: []resource.TestStep{ + { + Config: testAccCloudRunV2Job_cloudrunv2JobWithTags(context), + }, + { + ResourceName: "google_cloud_run_v2_job.default", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"location", "launch_stage", "deletion_protection", "tags"}, + }, + }, + }) } func testAccCloudRunV2Job_cloudrunv2JobWithTags(context map[string]interface{}) string { - return acctest.Nprintf(` + return acctest.Nprintf(` resource "google_cloud_run_v2_job" "default" { name = "tf-test-cloudrun-tags-job%{random_suffix}" location = "us-central1" @@ -613,7 +612,7 @@ func TestAccCloudRunV2Job_cloudrunv2JobGRPCProbesUpdate(t *testing.T) { CheckDestroy: testAccCheckCloudRunV2JobDestroyProducer(t), Steps: []resource.TestStep{ { - Config: testAccCloudRunV2Job_cloudRunJobUpdateWithEmptyGRPCStartupProbe(context), + Config: testAccCloudRunV2Job_cloudRunJobUpdateWithEmptyGRPCStartupProbe(context), }, { ResourceName: "google_cloud_run_v2_job.default", @@ -622,7 +621,7 @@ func TestAccCloudRunV2Job_cloudrunv2JobGRPCProbesUpdate(t *testing.T) { ImportStateVerifyIgnore: []string{"name", "location", "annotations", "deletion_protection"}, }, { - Config: testAccCloudRunV2Job_cloudRunJobUpdateWithGRPCStartupProbe(context), + Config: testAccCloudRunV2Job_cloudRunJobUpdateWithGRPCStartupProbe(context), }, { ResourceName: "google_cloud_run_v2_job.default", @@ -864,8 +863,6 @@ resource "google_cloud_run_v2_job" "default" { `, context) } - - func testAccCloudRunV2Job_cloudRunJobWithDependsOn(context map[string]interface{}) string { return acctest.Nprintf(` resource "google_cloud_run_v2_job" "default" { @@ -936,19 +933,17 @@ resource "google_cloud_run_v2_job" "default" { `, context) } - -{{ if ne $.TargetVersionName `ga` -}} func TestAccCloudRunV2Job_cloudrunv2JobWithStartExecutionTokenUpdate(t *testing.T) { t.Parallel() jobName := fmt.Sprintf("tf-test-cloudrun-job%s", acctest.RandString(t, 10)) context1 := map[string]interface{}{ "job_name": jobName, - "token": "token1", + "token": "token1", } - context2 := map[string]interface{}{ + context2 := map[string]interface{}{ "job_name": jobName, - "token": "token2", + "token": "token2", } acctest.VcrTest(t, resource.TestCase{ @@ -1002,11 +997,11 @@ func TestAccCloudRunV2Job_cloudrunv2JobWithRunExecutionTokenUpdate(t *testing.T) jobName := fmt.Sprintf("tf-test-cloudrun-job%s", acctest.RandString(t, 10)) context1 := map[string]interface{}{ "job_name": jobName, - "token": "token1", + "token": "token1", } - context2 := map[string]interface{}{ + context2 := map[string]interface{}{ "job_name": jobName, - "token": "token2", + "token": "token2", } acctest.VcrTest(t, resource.TestCase{ @@ -1014,7 +1009,7 @@ func TestAccCloudRunV2Job_cloudrunv2JobWithRunExecutionTokenUpdate(t *testing.T) ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories(t), CheckDestroy: testAccCheckCloudRunV2JobDestroyProducer(t), Steps: []resource.TestStep{ - { + { Config: testAccCloudRunV2Job_cloudrunv2JobWithRunExecutionToken(context1), }, { @@ -1053,8 +1048,6 @@ func testAccCloudRunV2Job_cloudrunv2JobWithRunExecutionToken(context map[string] } `, context) } -{{- end }} - func TestAccCloudRunV2Job_cloudrunv2JobWithGpuUpdate(t *testing.T) { acctest.SkipIfVcr(t) From b75efb15abbbc63f8f4128a4441a573cfb38d0d8 Mon Sep 17 00:00:00 2001 From: malhotrasagar2212 Date: Mon, 20 Jul 2026 11:49:12 -0400 Subject: [PATCH 04/30] Fix failing Project And folder List tests in teamcity (#18294) --- .../resource_google_folder_iam_member_list_test.go | 2 ++ .../resource_google_project_iam_member_list_test.go | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/mmv1/third_party/terraform/services/resourcemanager/resource_google_folder_iam_member_list_test.go b/mmv1/third_party/terraform/services/resourcemanager/resource_google_folder_iam_member_list_test.go index 62eef6494e99..8444c3d7d0ac 100644 --- a/mmv1/third_party/terraform/services/resourcemanager/resource_google_folder_iam_member_list_test.go +++ b/mmv1/third_party/terraform/services/resourcemanager/resource_google_folder_iam_member_list_test.go @@ -106,6 +106,7 @@ func testAccFolderIamMemberListQuery(folderName string) string { list "google_folder_iam_member" "test" { provider = google include_resource = true + limit = 1000 config { folder = "%s" } @@ -119,6 +120,7 @@ func testAccFolderIamMemberListQueryWithFilters(folderName, role, member string) list "google_folder_iam_member" "test" { provider = google include_resource = true + limit = 1000 config { folder = "%s" role = "%s" diff --git a/mmv1/third_party/terraform/services/resourcemanager/resource_google_project_iam_member_list_test.go b/mmv1/third_party/terraform/services/resourcemanager/resource_google_project_iam_member_list_test.go index 1fd01c64fee3..2ec46b07a1bf 100644 --- a/mmv1/third_party/terraform/services/resourcemanager/resource_google_project_iam_member_list_test.go +++ b/mmv1/third_party/terraform/services/resourcemanager/resource_google_project_iam_member_list_test.go @@ -104,7 +104,7 @@ list "google_project_iam_member" "test" { provider = google include_resource = true - + limit = 1000 config { project = %q } @@ -117,7 +117,7 @@ func testAccProjectIamMemberListQueryWithFilters(project, role, member string) s list "google_project_iam_member" "test" { provider = google include_resource = true - + limit = 1000 config { project = %q role = %q From 949c7c95fbe0b0ee11af38e5e5a0556dabfd6cdd Mon Sep 17 00:00:00 2001 From: Mateusz Pielat Date: Mon, 20 Jul 2026 18:52:16 +0200 Subject: [PATCH 05/30] feat: add traffic_routing_config to composer beta resource (#18016) Co-authored-by: Mateusz Pielat --- .../resource_composer_environment.go.tmpl | 87 ++++++++++++++++- ...source_composer_environment_meta.yaml.tmpl | 1 + .../resource_composer_environment_test.go | 94 +++++++++++++++++++ .../docs/r/composer_environment.html.markdown | 14 +++ 4 files changed, 194 insertions(+), 2 deletions(-) diff --git a/mmv1/third_party/terraform/services/composer/resource_composer_environment.go.tmpl b/mmv1/third_party/terraform/services/composer/resource_composer_environment.go.tmpl index f5064b4b6815..140a2a0cf6b6 100644 --- a/mmv1/third_party/terraform/services/composer/resource_composer_environment.go.tmpl +++ b/mmv1/third_party/terraform/services/composer/resource_composer_environment.go.tmpl @@ -374,6 +374,26 @@ func ResourceComposerEnvironment() *schema.Resource { ValidateFunc: validateComposerInternalIpv4CidrBlock, Description: `IPv4 cidr range that will be used by Composer internal components.`, }, +{{- if ne $.TargetVersionName "ga" }} + "traffic_routing_config": { + Type: schema.TypeList, + Optional: true, + Computed: true, + MaxItems: 1, + Description: `Traffic routing configuration for Cloud Composer environment.`, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "cloud_run_functions_routing": { + Type: schema.TypeString, + Optional: true, + Computed: true, + ValidateFunc: validation.StringInSlice([]string{"DIRECT", "VIA_NETWORK_ATTACHMENT"}, false), + Description: `Traffic routing mode for Cloud Run functions. Possible values: ["DIRECT", "VIA_NETWORK_ATTACHMENT"]`, + }, + }, + }, + }, +{{- end }} }, }, }, @@ -1295,6 +1315,23 @@ func resourceComposerEnvironmentUpdate(d *schema.ResourceData, meta interface{}) } } +{{ if ne $.TargetVersionName `ga` -}} + if d.HasChange("config.0.node_config.0.traffic_routing_config") { + patchObj := &composer.Environment{ + Config: &composer.EnvironmentConfig{ + NodeConfig: &composer.NodeConfig{}, + }, + } + if config != nil && config.NodeConfig != nil { + patchObj.Config.NodeConfig.TrafficRoutingConfig = config.NodeConfig.TrafficRoutingConfig + } + err = resourceComposerEnvironmentPatchField("config.nodeConfig.trafficRoutingConfig", userAgent, patchObj, d, tfConfig) + if err != nil { + return err + } + } +{{- end }} + if d.HasChange("config.0.software_config.0.image_version") { patchObj := &composer.Environment{ Config: &composer.EnvironmentConfig{ @@ -1714,9 +1751,9 @@ func flattenComposerEnvironmentConfig(envCfg *composer.EnvironmentConfig) interf transformed["dag_gcs_prefix"] = envCfg.DagGcsPrefix transformed["node_count"] = envCfg.NodeCount transformed["airflow_uri"] = envCfg.AirflowUri - transformed["node_config"] = flattenComposerEnvironmentConfigNodeConfig(envCfg.NodeConfig) transformed["software_config"] = flattenComposerEnvironmentConfigSoftwareConfig(envCfg.SoftwareConfig) imageVersion := envCfg.SoftwareConfig.ImageVersion + transformed["node_config"] = flattenComposerEnvironmentConfigNodeConfig(envCfg.NodeConfig, imageVersion) if !isComposer3(imageVersion){ transformed["private_environment_config"] = flattenComposerEnvironmentConfigPrivateEnvironmentConfig(envCfg.PrivateEnvironmentConfig) } @@ -1970,7 +2007,7 @@ func flattenComposerEnvironmentConfigPrivateEnvironmentConfig(envCfg *composer.P return []interface{}{transformed} } -func flattenComposerEnvironmentConfigNodeConfig(nodeCfg *composer.NodeConfig) interface{} { +func flattenComposerEnvironmentConfigNodeConfig(nodeCfg *composer.NodeConfig, imageVersion string) interface{} { if nodeCfg == nil { return nil } @@ -1990,6 +2027,11 @@ func flattenComposerEnvironmentConfigNodeConfig(nodeCfg *composer.NodeConfig) in transformed["tags"] = flattenComposerEnvironmentConfigNodeConfigTags(nodeCfg.Tags) transformed["ip_allocation_policy"] = flattenComposerEnvironmentConfigNodeConfigIPAllocationPolicy(nodeCfg.IpAllocationPolicy) transformed["composer_internal_ipv4_cidr_block"] = nodeCfg.ComposerInternalIpv4CidrBlock +{{- if ne $.TargetVersionName "ga" }} + if isComposer3(imageVersion) { + transformed["traffic_routing_config"] = flattenComposerEnvironmentConfigNodeConfigTrafficRoutingConfig(nodeCfg.TrafficRoutingConfig) + } +{{- end }} return []interface{}{transformed} } @@ -2007,6 +2049,17 @@ func flattenComposerEnvironmentConfigNodeConfigIPAllocationPolicy(ipPolicy *comp return []interface{}{transformed} } +{{ if ne $.TargetVersionName `ga` -}} +func flattenComposerEnvironmentConfigNodeConfigTrafficRoutingConfig(cfg *composer.TrafficRoutingConfig) interface{} { + if cfg == nil { + return nil + } + transformed := make(map[string]interface{}) + transformed["cloud_run_functions_routing"] = cfg.CloudRunFunctionsRouting + return []interface{}{transformed} +} +{{- end }} + func flattenComposerEnvironmentConfigNodeConfigOauthScopes(v interface{}) interface{} { if v == nil { return v @@ -2645,8 +2698,37 @@ func expandComposerEnvironmentConfigNodeConfig(v interface{}, d *schema.Resource transformed.ComposerInternalIpv4CidrBlock = transformedComposerInternalIpv4CidrBlock.(string) } +{{ if ne $.TargetVersionName `ga` -}} + transformedTrafficRoutingConfig, err := expandComposerEnvironmentTrafficRoutingConfig(original["traffic_routing_config"], d, config) + if err != nil { + return nil, err + } + transformed.TrafficRoutingConfig = transformedTrafficRoutingConfig +{{- end }} + + return transformed, nil +} + +{{ if ne $.TargetVersionName `ga` -}} +func expandComposerEnvironmentTrafficRoutingConfig(v interface{}, d *schema.ResourceData, config *transport_tpg.Config) (*composer.TrafficRoutingConfig, error) { + if v == nil { + return nil, nil + } + l, ok := v.([]interface{}) + if !ok || len(l) == 0 || l[0] == nil { + return nil, nil + } + raw := l[0] + original := raw.(map[string]interface{}) + transformed := &composer.TrafficRoutingConfig{} + + if v, ok := original["cloud_run_functions_routing"]; ok && v != nil { + transformed.CloudRunFunctionsRouting = v.(string) + } + return transformed, nil } +{{- end }} func expandComposerEnvironmentIPAllocationPolicy(v interface{}, d *schema.ResourceData, config *transport_tpg.Config) (*composer.IPAllocationPolicy, error) { l := v.([]interface{}) @@ -3138,6 +3220,7 @@ func versionValidationCustomizeDiffFunc(ctx context.Context, d *schema.ResourceD "config.0.master_authorized_networks_config": false, "config.0.node_config.0.composer_network_attachment": true, // allowed only in composer 3 "config.0.node_config.0.composer_internal_ipv4_cidr_block": true, + "config.0.node_config.0.traffic_routing_config": true, "config.0.software_config.0.web_server_plugins_mode": true, "config.0.enable_private_environment": true, "config.0.enable_private_builds_only": true, diff --git a/mmv1/third_party/terraform/services/composer/resource_composer_environment_meta.yaml.tmpl b/mmv1/third_party/terraform/services/composer/resource_composer_environment_meta.yaml.tmpl index 152011dac829..c3b893d4bc32 100644 --- a/mmv1/third_party/terraform/services/composer/resource_composer_environment_meta.yaml.tmpl +++ b/mmv1/third_party/terraform/services/composer/resource_composer_environment_meta.yaml.tmpl @@ -38,6 +38,7 @@ fields: - api_field: 'config.nodeConfig.machineType' {{- if ne $.TargetVersionName "ga" }} - api_field: 'config.nodeConfig.maxPodsPerNode' + - api_field: 'config.nodeConfig.trafficRoutingConfig.cloudRunFunctionsRouting' {{- end }} - api_field: 'config.nodeConfig.network' - api_field: 'config.nodeConfig.oauthScopes' diff --git a/mmv1/third_party/terraform/services/composer/resource_composer_environment_test.go b/mmv1/third_party/terraform/services/composer/resource_composer_environment_test.go index 401786a122bd..da0bc141bf0e 100644 --- a/mmv1/third_party/terraform/services/composer/resource_composer_environment_test.go +++ b/mmv1/third_party/terraform/services/composer/resource_composer_environment_test.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "log" + "reflect" "regexp" "strings" "testing" @@ -15,6 +16,7 @@ import ( "github.com/hashicorp/terraform-provider-google/google/services/kms" "github.com/hashicorp/terraform-provider-google/google/services/resourcemanager" _ "github.com/hashicorp/terraform-provider-google/google/services/storage" + transport_tpg "github.com/hashicorp/terraform-provider-google/google/transport" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/terraform" @@ -1127,6 +1129,48 @@ func TestAccComposerEnvironmentComposer3_usesUnsupportedField_expectError(t *tes }) } +func TestAccComposerEnvironmentComposer3_trafficRoutingConfig(t *testing.T) { + t.Parallel() + + if !strings.Contains(reflect.TypeOf(transport_tpg.Config{}).PkgPath(), "google-beta") { + t.Skip("skipping beta-only test in GA provider") + } + + envName := fmt.Sprintf("%s-%d", testComposerEnvironmentPrefix, acctest.RandInt(t)) + network := fmt.Sprintf("%s-%d", testComposerNetworkPrefix, acctest.RandInt(t)) + subnetwork := network + "-1" + serviceAccount := fmt.Sprintf("tf-test-%d", acctest.RandInt(t)) + + acctest.VcrTest(t, resource.TestCase{ + PreCheck: func() { acctest.AccTestPreCheck(t) }, + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories(t), + CheckDestroy: testAccComposerEnvironmentDestroyProducer(t), + Steps: []resource.TestStep{ + { + Config: testAccComposerEnvironmentComposer3_trafficRouting(envName, network, subnetwork, serviceAccount, "DIRECT"), + }, + { + Config: testAccComposerEnvironmentComposer3_trafficRouting(envName, network, subnetwork, serviceAccount, "VIA_NETWORK_ATTACHMENT"), + }, + { + ResourceName: "google_composer_environment.test", + ImportState: true, + ImportStateVerify: true, + }, + // This is a terrible clean-up step in order to get destroy to succeed, + // due to dangling firewall rules left by the Composer Environment blocking network deletion. + // TODO: Remove this check if firewall rules bug gets fixed by Composer. + + { + PlanOnly: true, + ExpectNonEmptyPlan: false, + Config: testAccComposerEnvironmentComposer3_trafficRouting(envName, network, subnetwork, serviceAccount, "VIA_NETWORK_ATTACHMENT"), + Check: testAccCheckClearComposerEnvironmentFirewalls(t, network), + }, + }, + }) +} + func testAccComposerEnvironment_customBucket(bucketName, envName, network, subnetwork, serviceAccount string) string { return fmt.Sprintf(` data "google_project" "project" {} @@ -2947,6 +2991,56 @@ resource "google_compute_subnetwork" "test" { `, serviceAccount, name, network, subnetwork) } +func testAccComposerEnvironmentComposer3_trafficRouting(name, network, subnetwork, serviceAccount, routingMode string) string { + return fmt.Sprintf(` +data "google_project" "project" {} + +resource "google_service_account" "test" { + account_id = "%s" + display_name = "Test Service Account for Composer Environment" +} +resource "google_project_iam_member" "composer-worker" { + project = data.google_project.project.project_id + role = "roles/composer.worker" + member = "serviceAccount:${google_service_account.test.email}" +} + +resource "google_composer_environment" "test" { + name = "%s" + region = "us-central1" + config { + node_config { + network = google_compute_network.test.id + subnetwork = google_compute_subnetwork.test.id + service_account = google_service_account.test.name + traffic_routing_config { + cloud_run_functions_routing = "%s" + } + } + software_config { + image_version = "composer-3-airflow-2" + } + enable_private_environment = true + } + depends_on = [google_project_iam_member.composer-worker] +} + +// use a separate network to avoid conflicts with other tests running in parallel +// that use the default network/subnet +resource "google_compute_network" "test" { + name = "%s" + auto_create_subnetworks = false +} + +resource "google_compute_subnetwork" "test" { + name = "%s" + ip_cidr_range = "10.2.0.0/16" + region = "us-central1" + network = google_compute_network.test.self_link +} +`, serviceAccount, name, routingMode, network, subnetwork) +} + // WARNING: This is not actually a check and is a terrible clean-up step because Composer Environments // have a bug that hasn't been fixed. Composer will add firewalls to non-default networks for environments // but will not remove them when the Environment is deleted. diff --git a/mmv1/third_party/terraform/website/docs/r/composer_environment.html.markdown b/mmv1/third_party/terraform/website/docs/r/composer_environment.html.markdown index 290529e334db..b96fabc339c5 100644 --- a/mmv1/third_party/terraform/website/docs/r/composer_environment.html.markdown +++ b/mmv1/third_party/terraform/website/docs/r/composer_environment.html.markdown @@ -598,6 +598,20 @@ block supports: /20 IPv4 cidr range that will be used by the environment's internal components. Cannot be updated. +* `traffic_routing_config` - + (Optional, [Beta](../guides/provider_versions.html.markdown), + Managed Airflow (Gen 3) only) + Traffic routing configuration for Cloud Composer environment. + Structure is [documented below](#nested_traffic_routing_config). + + +The `traffic_routing_config` block supports: + +* `cloud_run_functions_routing` - + (Optional, [Beta](../guides/provider_versions.html.markdown)) + Traffic routing mode for Cloud Run functions. Possible values: `DIRECT`, `VIA_NETWORK_ATTACHMENT`. + If unspecified, defaults to `DIRECT`. + The `software_config` block supports: * `airflow_config_overrides` - From 484bbc0ab570846db6add023de0bbce83ffd5eb1 Mon Sep 17 00:00:00 2001 From: syangcode Date: Mon, 20 Jul 2026 17:04:18 +0000 Subject: [PATCH 06/30] =?UTF-8?q?fix(vertexai):=20in-place=20replica-count?= =?UTF-8?q?=20updates=20for=20EndpointWithModelGa=E2=80=A6=20(#18116)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mmv1/api/resource.go | 2 +- .../EndpointWithModelGardenDeployment.yaml | 13 +- ...point_with_model_garden_deployment.go.tmpl | 102 ++++++++++++ ...point_with_model_garden_deployment_test.go | 146 ++++++++++++++++++ 4 files changed, 259 insertions(+), 4 deletions(-) create mode 100644 mmv1/templates/terraform/custom_update/vertex_ai_endpoint_with_model_garden_deployment.go.tmpl diff --git a/mmv1/api/resource.go b/mmv1/api/resource.go index 2bf5800e48b7..1ca1a95ac1a0 100644 --- a/mmv1/api/resource.go +++ b/mmv1/api/resource.go @@ -1387,7 +1387,7 @@ func (r Resource) Updatable() bool { if !r.Immutable { return true } - for _, p := range r.AllPropertiesInVersion() { + for _, p := range r.AllNestedProperties(r.RootProperties()) { if p.UpdateUrl != "" { return true } diff --git a/mmv1/products/vertexai/EndpointWithModelGardenDeployment.yaml b/mmv1/products/vertexai/EndpointWithModelGardenDeployment.yaml index f5088d438c14..e1d141465a18 100644 --- a/mmv1/products/vertexai/EndpointWithModelGardenDeployment.yaml +++ b/mmv1/products/vertexai/EndpointWithModelGardenDeployment.yaml @@ -30,6 +30,7 @@ create_url: projects/{{project}}/locations/{{location}}:deploy id_format: projects/{{project}}/locations/{{location}}/endpoints/{{endpoint}} timeouts: insert_minutes: 180 + update_minutes: 20 delete_minutes: 20 exclude_import: true # the resource does not support import async: @@ -44,6 +45,7 @@ async: exclude_read: true autogen_status: RW5kcG9pbnRXaXRoTW9kZWxHYXJkZW5EZXBsb3ltZW50 custom_code: + custom_update: templates/terraform/custom_update/vertex_ai_endpoint_with_model_garden_deployment.go.tmpl post_create: templates/terraform/post_create/resource_vertexai_endpoint_with_model_garden_deployment.go.tmpl custom_delete: templates/terraform/custom_delete/resource_vertexai_endpoint_with_model_garden_deployment.go.tmpl samples: @@ -1025,7 +1027,8 @@ properties: If traffic increases, it may dynamically be deployed onto more replicas, and as traffic decreases, some of these extra replicas may be freed. - immutable: true + update_url: 'projects/{{project}}/locations/{{location}}/endpoints/{{endpoint}}:mutateDeployedModel' + update_verb: 'POST' required: true - name: maxReplicaCount type: Integer @@ -1042,7 +1045,8 @@ properties: quotas. Specifically, you will be charged for (max_replica_count * number of cores in the selected machine type) and (max_replica_count * number of GPUs per replica in the selected machine type). - immutable: true + update_url: 'projects/{{project}}/locations/{{location}}/endpoints/{{endpoint}}:mutateDeployedModel' + update_verb: 'POST' - name: requiredReplicaCount type: Integer description: |- @@ -1052,6 +1056,8 @@ properties: available_replica_count reaches required_replica_count, and the rest of the replicas will be retried. If not set, the default required_replica_count will be min_replica_count. + update_url: 'projects/{{project}}/locations/{{location}}/endpoints/{{endpoint}}:mutateDeployedModel' + update_verb: 'POST' - name: autoscalingMetricSpecs type: Array description: |- @@ -1075,7 +1081,8 @@ properties: autoscaling_metric_specs.metric_name to `aiplatform.googleapis.com/prediction/online/cpu/utilization` and autoscaling_metric_specs.target to `80`. - immutable: true + update_url: 'projects/{{project}}/locations/{{location}}/endpoints/{{endpoint}}:mutateDeployedModel' + update_verb: 'POST' item_type: type: NestedObject properties: diff --git a/mmv1/templates/terraform/custom_update/vertex_ai_endpoint_with_model_garden_deployment.go.tmpl b/mmv1/templates/terraform/custom_update/vertex_ai_endpoint_with_model_garden_deployment.go.tmpl new file mode 100644 index 000000000000..7757e15055bb --- /dev/null +++ b/mmv1/templates/terraform/custom_update/vertex_ai_endpoint_with_model_garden_deployment.go.tmpl @@ -0,0 +1,102 @@ +{{/* + The license inside this block applies to this file. + Copyright 2026 Google Inc. + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ -}} +// `config` is declared in the enclosing generated Update body; do not redeclare. +userAgent, err := tpgresource.GenerateUserAgentString(d, config.UserAgent) +if err != nil { + return err +} + +billingProject := "" +project, err := tpgresource.GetProject(d, config) +if err != nil { + return fmt.Errorf("Error fetching project for EndpointWithModelGardenDeployment: %s", err) +} +billingProject = project +if bp, err := tpgresource.GetBillingProject(d, config); err == nil { + billingProject = bp +} + +deployedModelId, _ := d.Get("deployed_model_id").(string) +if deployedModelId == "" { + return fmt.Errorf("cannot update EndpointWithModelGardenDeployment %q: deployed_model_id is empty (resource may be in an inconsistent state)", d.Id()) +} + +dedicatedResources := map[string]interface{}{} +updateMask := []string{} + +if d.HasChange("deploy_config.0.dedicated_resources.0.min_replica_count") { + dedicatedResources["minReplicaCount"] = d.Get("deploy_config.0.dedicated_resources.0.min_replica_count") + updateMask = append(updateMask, "dedicatedResources.minReplicaCount") +} +if d.HasChange("deploy_config.0.dedicated_resources.0.max_replica_count") { + dedicatedResources["maxReplicaCount"] = d.Get("deploy_config.0.dedicated_resources.0.max_replica_count") + updateMask = append(updateMask, "dedicatedResources.maxReplicaCount") +} +if d.HasChange("deploy_config.0.dedicated_resources.0.required_replica_count") { + dedicatedResources["requiredReplicaCount"] = d.Get("deploy_config.0.dedicated_resources.0.required_replica_count") + updateMask = append(updateMask, "dedicatedResources.requiredReplicaCount") +} +if d.HasChange("deploy_config.0.dedicated_resources.0.autoscaling_metric_specs") { + expanded, err := expandVertexAIEndpointWithModelGardenDeploymentDeployConfigDedicatedResourcesAutoscalingMetricSpecs( + d.Get("deploy_config.0.dedicated_resources.0.autoscaling_metric_specs"), d, config) + if err != nil { + return err + } + dedicatedResources["autoscalingMetricSpecs"] = expanded + updateMask = append(updateMask, "dedicatedResources.autoscalingMetricSpecs") +} + +if len(updateMask) == 0 { + return resourceVertexAIEndpointWithModelGardenDeploymentRead(d, meta) +} + +body := map[string]interface{}{ + "deployedModel": map[string]interface{}{ + "id": deployedModelId, + "dedicatedResources": dedicatedResources, + }, + "updateMask": strings.Join(updateMask, ","), +} + +url, err := tpgresource.ReplaceVars(d, config, "{{"{{"}}VertexAIBasePath{{"}}"}}projects/{{"{{"}}project{{"}}"}}/locations/{{"{{"}}location{{"}}"}}/endpoints/{{"{{"}}endpoint{{"}}"}}:mutateDeployedModel") +if err != nil { + return err +} + +log.Printf("[DEBUG] Updating EndpointWithModelGardenDeployment %q via mutateDeployedModel with mask %q", d.Id(), strings.Join(updateMask, ",")) + +headers := make(http.Header) +res, err := transport_tpg.SendRequest(transport_tpg.SendRequestOptions{ + Config: config, + Method: "POST", + Project: billingProject, + RawURL: url, + UserAgent: userAgent, + Body: body, + Timeout: d.Timeout(schema.TimeoutUpdate), + Headers: headers, +}) +if err != nil { + return fmt.Errorf("Error updating EndpointWithModelGardenDeployment %q: %s", d.Id(), err) +} + +err = VertexAIOperationWaitTime( + config, res, project, "Updating EndpointWithModelGardenDeployment", userAgent, + d.Timeout(schema.TimeoutUpdate)) +if err != nil { + return fmt.Errorf("Error waiting to update EndpointWithModelGardenDeployment %q: %s", d.Id(), err) +} + +log.Printf("[DEBUG] Finished updating EndpointWithModelGardenDeployment %q", d.Id()) + +return resourceVertexAIEndpointWithModelGardenDeploymentRead(d, meta) diff --git a/mmv1/third_party/terraform/services/vertexai/resource_vertex_ai_endpoint_with_model_garden_deployment_test.go b/mmv1/third_party/terraform/services/vertexai/resource_vertex_ai_endpoint_with_model_garden_deployment_test.go index b3689a23afb7..cf9fed01ce45 100644 --- a/mmv1/third_party/terraform/services/vertexai/resource_vertex_ai_endpoint_with_model_garden_deployment_test.go +++ b/mmv1/third_party/terraform/services/vertexai/resource_vertex_ai_endpoint_with_model_garden_deployment_test.go @@ -321,3 +321,149 @@ func testAccCheckVertexAIEndpointWithModelGardenDeploymentDestroyProducer(t *tes return nil } } + +func TestAccVertexAIEndpointWithModelGardenDeployment_updateReplicaCountInPlace(t *testing.T) { + t.Parallel() + t.Skip("b/514579514 for manual test logs") + + context := map[string]interface{}{ + "random_suffix": acctest.RandString(t, 10), + } + + var endpointBefore, endpointAfter string + + acctest.VcrTest(t, resource.TestCase{ + PreCheck: func() { acctest.AccTestPreCheck(t) }, + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories(t), + CheckDestroy: testAccCheckVertexAIEndpointWithModelGardenDeploymentDestroyProducer(t), + Steps: []resource.TestStep{ + { + Config: testAccVertexAIEndpointWithModelGardenDeployment_updateReplicaCountInPlace(context, 2), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "google_vertex_ai_endpoint_with_model_garden_deployment.test_inplace", + "deploy_config.0.dedicated_resources.0.max_replica_count", "2"), + captureEndpointAttribute( + "google_vertex_ai_endpoint_with_model_garden_deployment.test_inplace", + "endpoint", &endpointBefore), + ), + }, + { + Config: testAccVertexAIEndpointWithModelGardenDeployment_updateReplicaCountInPlace(context, 3), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "google_vertex_ai_endpoint_with_model_garden_deployment.test_inplace", + "deploy_config.0.dedicated_resources.0.max_replica_count", "3"), + captureEndpointAttribute( + "google_vertex_ai_endpoint_with_model_garden_deployment.test_inplace", + "endpoint", &endpointAfter), + func(s *terraform.State) error { + if endpointBefore == "" || endpointAfter == "" { + return fmt.Errorf("endpoint id not captured (before=%q after=%q)", + endpointBefore, endpointAfter) + } + if endpointBefore != endpointAfter { + return fmt.Errorf( + "endpoint id changed across in-place update (before=%q after=%q); "+ + "resource was recreated instead of mutated in place", + endpointBefore, endpointAfter) + } + return nil + }, + ), + }, + }, + }) +} + +func testAccVertexAIEndpointWithModelGardenDeployment_updateReplicaCountInPlace(context map[string]interface{}, maxReplicas int) string { + context["max_replicas"] = maxReplicas + return acctest.Nprintf(` +resource "google_vertex_ai_endpoint_with_model_garden_deployment" "test_inplace" { + publisher_model_name = "publishers/google/models/paligemma@paligemma-224-float32" + location = "us-central1" + + model_config { + accept_eula = true + } + + deploy_config { + dedicated_resources { + machine_spec { + machine_type = "g2-standard-12" + accelerator_type = "NVIDIA_L4" + accelerator_count = 1 + } + min_replica_count = 1 + max_replica_count = %{max_replicas} + } + } +} +`, context) +} + +func TestAccVertexAIEndpointWithModelGardenDeployment_machineTypeStillForcesReplacement(t *testing.T) { + t.Parallel() + t.Skip("b/514579514 for manual test logs") + + context := map[string]interface{}{ + "random_suffix": acctest.RandString(t, 10), + } + + acctest.VcrTest(t, resource.TestCase{ + PreCheck: func() { acctest.AccTestPreCheck(t) }, + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories(t), + CheckDestroy: testAccCheckVertexAIEndpointWithModelGardenDeploymentDestroyProducer(t), + Steps: []resource.TestStep{ + { + Config: testAccVertexAIEndpointWithModelGardenDeployment_machineType(context, "g2-standard-12"), + }, + { + Config: testAccVertexAIEndpointWithModelGardenDeployment_machineType(context, "g2-standard-16"), + PlanOnly: true, + ExpectNonEmptyPlan: true, + }, + }, + }) +} + +func testAccVertexAIEndpointWithModelGardenDeployment_machineType(context map[string]interface{}, machineType string) string { + context["machine_type"] = machineType + return acctest.Nprintf(` +resource "google_vertex_ai_endpoint_with_model_garden_deployment" "test_machine_type" { + publisher_model_name = "publishers/google/models/paligemma@paligemma-224-float32" + location = "us-central1" + + model_config { + accept_eula = true + } + + deploy_config { + dedicated_resources { + machine_spec { + machine_type = "%{machine_type}" + accelerator_type = "NVIDIA_L4" + accelerator_count = 1 + } + min_replica_count = 1 + max_replica_count = 1 + } + } +} +`, context) +} + +func captureEndpointAttribute(resourceName, attr string, dest *string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("resource not found in state: %s", resourceName) + } + v, ok := rs.Primary.Attributes[attr] + if !ok || v == "" { + return fmt.Errorf("attribute %q is empty on %s", attr, resourceName) + } + *dest = v + return nil + } +} From d442dcad6079f42bcf696ae71422ba9c2df1d92e Mon Sep 17 00:00:00 2001 From: wadhah mahrouk <33103894+wadhah101@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:22:55 +0200 Subject: [PATCH 07/30] feat(modelarmor):add filter version selector support (#18153) --- mmv1/products/modelarmor/Template.yaml | 41 +++++++++++++++++++ ...elarmor_template_template_metadata.go.tmpl | 16 ++++++++ ...r_template_filter_version_selector.tf.tmpl | 18 ++++++++ ...elarmor_template_template_metadata.tf.tmpl | 3 ++ 4 files changed, 78 insertions(+) create mode 100644 mmv1/templates/terraform/samples/services/modelarmor/modelarmor_template_filter_version_selector.tf.tmpl diff --git a/mmv1/products/modelarmor/Template.yaml b/mmv1/products/modelarmor/Template.yaml index 44820704ba9f..6faf4d0f2830 100644 --- a/mmv1/products/modelarmor/Template.yaml +++ b/mmv1/products/modelarmor/Template.yaml @@ -82,6 +82,7 @@ samples: template_metadata_custom_prompt_safety_error_message: This is a custom error message for prompt template_metadata_custom_llm_response_safety_error_code: 401 template_metadata_enforcement_type: INSPECT_ONLY + template_metadata_filter_version_selector_alias: FILTER_VERSION_ALIAS_LATEST test_vars_overrides: templateId: '"modelarmor3"' location: '"us-central1"' @@ -96,6 +97,23 @@ samples: template_metadata_custom_prompt_safety_error_message: '"This is a custom error message for prompt"' template_metadata_custom_llm_response_safety_error_code: 401 template_metadata_enforcement_type: '"INSPECT_ONLY"' + template_metadata_filter_version_selector_alias: '"FILTER_VERSION_ALIAS_LATEST"' + - name: modelarmor_template_filter_version_selector + primary_resource_id: template-filter-version-selector + steps: + - name: modelarmor_template_filter_version_selector + resource_id_vars: + templateId: modelarmor5 + location: us-central1 + filter_config_rai_settings_rai_filters_0_filter_type: HATE_SPEECH + filter_config_rai_settings_rai_filters_0_confidence_level: HIGH + template_metadata_filter_version_selector_version: v1 + test_vars_overrides: + templateId: '"modelarmor5"' + location: '"us-central1"' + filter_config_rai_settings_rai_filters_0_filter_type: '"HATE_SPEECH"' + filter_config_rai_settings_rai_filters_0_confidence_level: '"HIGH"' + template_metadata_filter_version_selector_version: '"v1"' - name: modelarmor_template_label primary_resource_id: template-label-advanced-config steps: @@ -314,3 +332,26 @@ properties: Possible values: INSPECT_ONLY INSPECT_AND_BLOCK + - name: filterVersionSelector + type: NestedObject + description: |- + Selects the filter version to use for this template. Set exactly one of + 'alias' or 'version'. + properties: + - name: alias + type: String + conflicts: + - template_metadata.0.filter_version_selector.0.version + description: |- + A predefined filter version alias. The template automatically follows the + version this alias points to. + Possible values: + FILTER_VERSION_ALIAS_STABLE + FILTER_VERSION_ALIAS_LATEST + - name: version + type: String + conflicts: + - template_metadata.0.filter_version_selector.0.alias + description: |- + Pins the template to a specific, immutable filter version. Expected + format is a case-sensitive string such as 'v1' or 'v2'. diff --git a/mmv1/templates/terraform/custom_flatten/modelarmor_template_template_metadata.go.tmpl b/mmv1/templates/terraform/custom_flatten/modelarmor_template_template_metadata.go.tmpl index cb5489082142..97d62795b723 100644 --- a/mmv1/templates/terraform/custom_flatten/modelarmor_template_template_metadata.go.tmpl +++ b/mmv1/templates/terraform/custom_flatten/modelarmor_template_template_metadata.go.tmpl @@ -14,6 +14,22 @@ func flatten{{$.GetPrefix}}{{$.TitlelizeProperty}}(v interface{}, d *schema.Reso transformed["custom_llm_response_safety_error_code"] = original["customLlmResponseSafetyErrorCode"] transformed["custom_llm_response_safety_error_message"] = original["customLlmResponseSafetyErrorMessage"] transformed["enforcement_type"] = original["enforcementType"] + transformed["filter_version_selector"] = + flatten{{$.GetPrefix}}{{$.TitlelizeProperty}}FilterVersionSelector(original["filterVersionSelector"], d, config) + return []interface{}{transformed} +} + +func flatten{{$.GetPrefix}}{{$.TitlelizeProperty}}FilterVersionSelector(v interface{}, d *schema.ResourceData, config *transport_tpg.Config) interface{} { + if v == nil { + return nil + } + original, ok := v.(map[string]interface{}) + if !ok { + return nil + } + transformed := make(map[string]interface{}) + transformed["alias"] = original["alias"] + transformed["version"] = original["version"] return []interface{}{transformed} } diff --git a/mmv1/templates/terraform/samples/services/modelarmor/modelarmor_template_filter_version_selector.tf.tmpl b/mmv1/templates/terraform/samples/services/modelarmor/modelarmor_template_filter_version_selector.tf.tmpl new file mode 100644 index 000000000000..233e4d335ddb --- /dev/null +++ b/mmv1/templates/terraform/samples/services/modelarmor/modelarmor_template_filter_version_selector.tf.tmpl @@ -0,0 +1,18 @@ +resource "google_model_armor_template" "template-filter-version-selector" { + location = "{{index $.ResourceIdVars "location"}}" + template_id = "{{index $.ResourceIdVars "templateId"}}" + + filter_config { + rai_settings { + rai_filters { + filter_type = "{{index $.ResourceIdVars "filter_config_rai_settings_rai_filters_0_filter_type"}}" + confidence_level = "{{index $.ResourceIdVars "filter_config_rai_settings_rai_filters_0_confidence_level"}}" + } + } + } + template_metadata { + filter_version_selector { + version = "{{index $.ResourceIdVars "template_metadata_filter_version_selector_version"}}" + } + } +} diff --git a/mmv1/templates/terraform/samples/services/modelarmor/modelarmor_template_template_metadata.tf.tmpl b/mmv1/templates/terraform/samples/services/modelarmor/modelarmor_template_template_metadata.tf.tmpl index c8765fd4e715..e7699985a101 100644 --- a/mmv1/templates/terraform/samples/services/modelarmor/modelarmor_template_template_metadata.tf.tmpl +++ b/mmv1/templates/terraform/samples/services/modelarmor/modelarmor_template_template_metadata.tf.tmpl @@ -22,5 +22,8 @@ resource "google_model_armor_template" "template-template-metadata" { custom_prompt_safety_error_message = "{{index $.ResourceIdVars "template_metadata_custom_prompt_safety_error_message"}}" custom_llm_response_safety_error_code = {{index $.ResourceIdVars "template_metadata_custom_llm_response_safety_error_code"}} enforcement_type = "{{index $.ResourceIdVars "template_metadata_enforcement_type"}}" + filter_version_selector { + alias = "{{index $.ResourceIdVars "template_metadata_filter_version_selector_alias"}}" + } } } \ No newline at end of file From 53780a06b26e30dfb6d45e3074ac5078e6f7412c Mon Sep 17 00:00:00 2001 From: avbanerjee Date: Mon, 20 Jul 2026 23:22:16 +0530 Subject: [PATCH 08/30] feat: Add Chronicle BigQueryExport resource and tests (#18154) Co-authored-by: Cameron Thornton --- mmv1/products/chronicle/BigQueryExport.yaml | 236 ++++++++++++++++++ .../chronicle_bigquery_export_basic.tf.tmpl | 9 + .../chronicle_bigquery_export_full.tf.tmpl | 31 +++ 3 files changed, 276 insertions(+) create mode 100644 mmv1/products/chronicle/BigQueryExport.yaml create mode 100644 mmv1/templates/terraform/samples/services/chronicle/chronicle_bigquery_export_basic.tf.tmpl create mode 100644 mmv1/templates/terraform/samples/services/chronicle/chronicle_bigquery_export_full.tf.tmpl diff --git a/mmv1/products/chronicle/BigQueryExport.yaml b/mmv1/products/chronicle/BigQueryExport.yaml new file mode 100644 index 000000000000..47b849d34ad1 --- /dev/null +++ b/mmv1/products/chronicle/BigQueryExport.yaml @@ -0,0 +1,236 @@ +# Copyright 2026 Google Inc. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +--- +name: BigQueryExport +description: | + BigQueryExport resource represents the BigQuery export configuration for a Chronicle instance. +references: + guides: + 'BigQuery Export Overview': 'https://cloud.google.com/chronicle/docs/secops/bigquery-export-overview' + api: 'https://cloud.google.com/chronicle/docs/reference/rest/v1/projects.locations.instances.bigQueryExport' +base_url: projects/{{project}}/locations/{{location}}/instances/{{instance}}/bigQueryExport +self_link: projects/{{project}}/locations/{{location}}/instances/{{instance}}/bigQueryExport +create_url: projects/{{project}}/locations/{{location}}/instances/{{instance}}/bigQueryExport +update_mask: true +create_verb: PATCH +update_verb: PATCH +import_format: + - projects/{{project}}/locations/{{location}}/instances/{{instance}}/bigQueryExport +exclude_delete: true +samples: + - name: chronicle_bigquery_export_basic + primary_resource_id: example + steps: + - name: chronicle_bigquery_export_basic + vars: + location: 'us' + test_env_vars: + chronicle_id: 'CHRONICLE_ID' + project_name: 'PROJECT_NAME' + - name: chronicle_bigquery_export_full + primary_resource_id: example + steps: + - name: chronicle_bigquery_export_full + vars: + location: 'us' + test_env_vars: + chronicle_id: 'CHRONICLE_ID' + project_name: 'PROJECT_NAME' +parameters: + - name: location + type: String + required: true + description: Resource ID segment making up resource `name`. It identifies the resource within its parent collection as described in https://google.aip.dev/122. + immutable: true + url_param_only: true + - name: instance + type: String + required: true + description: Resource ID segment making up resource `name`. It identifies the resource within its parent collection as described in https://google.aip.dev/122. + immutable: true + url_param_only: true +properties: + - name: bigQueryExportPackage + type: Enum + default_from_api: true + enum_values: + - 'BIG_QUERY_EXPORT_PACKAGE_BYOBQ' + - 'BIG_QUERY_EXPORT_PACKAGE_ADVANCED' + description: The BigQueryExportPackage entitled for the Chronicle instance. + - name: entityGraphSettings + type: NestedObject + default_from_api: true + description: The export settings for a data source. + properties: + - name: dataFreshnessTime + type: String + description: |- + The data freshness of the given export which represents the time bucket at + which the latest event was exported. + output: true + - name: dataVolume + type: String + description: The stored data volume of all the exports. + output: true + - name: enabled + type: Boolean + required: true + description: Whether the data source is enabled for export. + - name: latestExportJobState + type: Enum + output: true + enum_values: + - 'LATEST_EXPORT_JOB_STATE_UNSPECIFIED' + - 'LATEST_EXPORT_JOB_STATE_SUCCESS' + - 'LATEST_EXPORT_JOB_STATE_FAILED' + description: The latest export job state. + - name: retentionDays + type: Integer + required: true + description: The retention period for the data source in days. + - name: iocMatchesSettings + type: NestedObject + default_from_api: true + description: The export settings for a data source. + properties: + - name: dataFreshnessTime + type: String + description: |- + The data freshness of the given export which represents the time bucket at + which the latest event was exported. + output: true + - name: dataVolume + type: String + description: The stored data volume of all the exports. + output: true + - name: enabled + type: Boolean + required: true + description: Whether the data source is enabled for export. + - name: latestExportJobState + type: Enum + output: true + enum_values: + - 'LATEST_EXPORT_JOB_STATE_UNSPECIFIED' + - 'LATEST_EXPORT_JOB_STATE_SUCCESS' + - 'LATEST_EXPORT_JOB_STATE_FAILED' + description: The latest export job state. + - name: retentionDays + type: Integer + required: true + description: The retention period for the data source in days. + - name: name + type: String + description: |- + Identifier. The resource name of the BigQueryExport. + Format: + projects/{project}/locations/{location}/instances/{instance}/bigQueryExport + output: true + - name: provisioned + type: Boolean + description: Whether the BigQueryExport has been provisioned for the Chronicle instance. + output: true + - name: ruleDetectionsSettings + type: NestedObject + default_from_api: true + description: The export settings for a data source. + properties: + - name: dataFreshnessTime + type: String + description: |- + The data freshness of the given export which represents the time bucket at + which the latest event was exported. + output: true + - name: dataVolume + type: String + description: The stored data volume of all the exports. + output: true + - name: enabled + type: Boolean + required: true + description: Whether the data source is enabled for export. + - name: latestExportJobState + type: Enum + output: true + enum_values: + - 'LATEST_EXPORT_JOB_STATE_UNSPECIFIED' + - 'LATEST_EXPORT_JOB_STATE_SUCCESS' + - 'LATEST_EXPORT_JOB_STATE_FAILED' + description: The latest export job state. + - name: retentionDays + type: Integer + required: true + description: The retention period for the data source in days. + - name: udmEventsAggregatesSettings + type: NestedObject + default_from_api: true + description: The export settings for a data source. + properties: + - name: dataFreshnessTime + type: String + description: |- + The data freshness of the given export which represents the time bucket at + which the latest event was exported. + output: true + - name: dataVolume + type: String + description: The stored data volume of all the exports. + output: true + - name: enabled + type: Boolean + required: true + description: Whether the data source is enabled for export. + - name: latestExportJobState + type: Enum + output: true + enum_values: + - 'LATEST_EXPORT_JOB_STATE_UNSPECIFIED' + - 'LATEST_EXPORT_JOB_STATE_SUCCESS' + - 'LATEST_EXPORT_JOB_STATE_FAILED' + description: The latest export job state. + - name: retentionDays + type: Integer + required: true + description: The retention period for the data source in days. + - name: udmEventsSettings + type: NestedObject + default_from_api: true + description: The export settings for a data source. + properties: + - name: dataFreshnessTime + type: String + description: |- + The data freshness of the given export which represents the time bucket at + which the latest event was exported. + output: true + - name: dataVolume + type: String + description: The stored data volume of all the exports. + output: true + - name: enabled + type: Boolean + required: true + description: Whether the data source is enabled for export. + - name: latestExportJobState + type: Enum + output: true + enum_values: + - 'LATEST_EXPORT_JOB_STATE_UNSPECIFIED' + - 'LATEST_EXPORT_JOB_STATE_SUCCESS' + - 'LATEST_EXPORT_JOB_STATE_FAILED' + description: The latest export job state. + - name: retentionDays + type: Integer + required: true + description: The retention period for the data source in days. diff --git a/mmv1/templates/terraform/samples/services/chronicle/chronicle_bigquery_export_basic.tf.tmpl b/mmv1/templates/terraform/samples/services/chronicle/chronicle_bigquery_export_basic.tf.tmpl new file mode 100644 index 000000000000..e2625c117fba --- /dev/null +++ b/mmv1/templates/terraform/samples/services/chronicle/chronicle_bigquery_export_basic.tf.tmpl @@ -0,0 +1,9 @@ +resource "google_chronicle_big_query_export" "{{$.PrimaryResourceId}}" { + location = "{{index $.Vars "location"}}" + instance = "{{index $.TestEnvVars "chronicle_id"}}" + + udm_events_settings { + enabled = true + retention_days = 7 + } +} diff --git a/mmv1/templates/terraform/samples/services/chronicle/chronicle_bigquery_export_full.tf.tmpl b/mmv1/templates/terraform/samples/services/chronicle/chronicle_bigquery_export_full.tf.tmpl new file mode 100644 index 000000000000..3b04f0549471 --- /dev/null +++ b/mmv1/templates/terraform/samples/services/chronicle/chronicle_bigquery_export_full.tf.tmpl @@ -0,0 +1,31 @@ +resource "google_chronicle_big_query_export" "{{$.PrimaryResourceId}}" { + location = "{{index $.Vars "location"}}" + instance = "{{index $.TestEnvVars "chronicle_id"}}" + + big_query_export_package = "BIG_QUERY_EXPORT_PACKAGE_BYOBQ" + + udm_events_settings { + enabled = true + retention_days = 7 + } + + entity_graph_settings { + enabled = true + retention_days = 7 + } + + ioc_matches_settings { + enabled = true + retention_days = 7 + } + + rule_detections_settings { + enabled = true + retention_days = 7 + } + + udm_events_aggregates_settings { + enabled = true + retention_days = 7 + } +} From 8e26a3938e9f13090e2508614d83bde232124736 Mon Sep 17 00:00:00 2001 From: Cameron Thornton Date: Mon, 20 Jul 2026 14:23:55 -0500 Subject: [PATCH 09/30] Seed the agent knowledge base (#18253) --- .agents/WORKFLOWS.md | 2 +- .agents/knowledge/README.md | 44 +++++++++++++++ .agents/knowledge/__BACKLOG.md | 32 +++++++++++ .agents/knowledge/field/enums-vs-strings.md | 37 ++++++++++++ .agents/knowledge/index.md | 56 +++++++++++++++++++ .agents/rules/general.md | 10 ++++ .agents/skills/utils/schema-review/SKILL.md | 14 ++--- .../workflows/add_list_resource/SKILL.md | 2 + .agents/skills/workflows/default/SKILL.md | 1 + .../skills/workflows/new_resource/SKILL.md | 1 + AGENTS.md | 8 +-- 11 files changed, 192 insertions(+), 15 deletions(-) create mode 100644 .agents/knowledge/README.md create mode 100644 .agents/knowledge/__BACKLOG.md create mode 100644 .agents/knowledge/field/enums-vs-strings.md create mode 100644 .agents/knowledge/index.md create mode 100644 .agents/rules/general.md diff --git a/.agents/WORKFLOWS.md b/.agents/WORKFLOWS.md index 213bc5580bb6..1948252ffb77 100644 --- a/.agents/WORKFLOWS.md +++ b/.agents/WORKFLOWS.md @@ -9,7 +9,7 @@ specific one. 1. **Source of Truth:** `magic-modules` contains YAML schema definitions in `mmv1/` and templates. Code is generated into downstream providers. 2. **Use Skills:** Discover and leverage `.agents/skills/` to perform complex tasks. 3. **Verify before a PR:** generate, build, and run the tests relevant to the change. A task is not done because it compiles. -4. **Never weaken the baseline:** no disabling or skipping tests, and no test-dodging behavior flags (`ignore_read`, `default_from_api`, `ImportStateVerifyIgnore`) without an adjacent comment justifying the API behavior that requires them. +4. **Never weaken baseline test coverage:** no disabling or skipping tests, and no test-dodging behavior flags (`ignore_read`, `ImportStateVerifyIgnore`) without an adjacent comment justifying the API behavior that requires them. 5. **PR descriptions are brief:** what changed and why, in a few sentences. ## Available Workflows: diff --git a/.agents/knowledge/README.md b/.agents/knowledge/README.md new file mode 100644 index 000000000000..fd87059ef795 --- /dev/null +++ b/.agents/knowledge/README.md @@ -0,0 +1,44 @@ +# Agent knowledge base + +The **funnel to the knowledge an agent needs**: [`index.md`](index.md) routes to the right source for a +given decision. Agents read the index at decision points and open only what the task needs — never the +whole base (loading everything degrades reasoning and wastes context). + +Most knowledge already lives in the **contributor documentation** (`docs/content/`), and that stays the +single source of truth for everything it covers — the index points there. **Agent-only entries** exist in +this directory only for knowledge that has no home in the docs: judgment rules that were never written +down, pitfall catalogs, and (later) lessons proposed by agents from completed tasks. If something is +covered by the contributor docs but covered badly, the fix is improving the docs — not writing a copy here. + +Planned entries: [`BACKLOG.md`](BACKLOG.md). + +## Entry format + +One Markdown file, one topic, ≤120 lines, YAML frontmatter: + +```yaml +--- +name: enums-vs-strings # kebab-case, unique, matches filename +description: Model an API enum as Enum (strict) or String (forward-compatible). # the index line; <=140 chars +topics: [field] # index section(s) that list it +task_types: [field-add, new-resource] +source: docs/content/best-practices/validation.md # provenance: doc, PR, or "authored" +status: certified # certified (human-reviewed) | draft (proposed, unreviewed) +last_verified: 2026-07-09 +--- +``` + +Body rules: **rule + the why**, never bare imperatives; a real example per rule; a "do NOT use for" line +where misuse is likely; reference the contributor docs rather than duplicating them. + +## Curation rules + +- **Nothing enters unreviewed.** Entries land via PR like code, **one entry at a time** — each one codifies + team judgment and deserves a real review conversation. Agent-proposed entries ship as `status: draft` in + the task PR that motivated them; a human review flips them to `certified`. +- **Never bulk-rewrite.** Edit entry by entry. Do not have a model re-summarize or reorganize the base + wholesale — that is the documented failure mode (drift and self-contradiction). +- **Contradictions are quarantined.** A proposed entry that contradicts an existing one is not merged; a + human adjudicates: update or retire the old entry, or discard the proposal. +- **Keep the index true.** Index lines come from entry frontmatter (name + description). Adding or changing + an entry updates the index in the same commit. diff --git a/.agents/knowledge/__BACKLOG.md b/.agents/knowledge/__BACKLOG.md new file mode 100644 index 000000000000..700bead0fae0 --- /dev/null +++ b/.agents/knowledge/__BACKLOG.md @@ -0,0 +1,32 @@ +# Knowledge backlog + +Tracks known work for growing the agent knowledge base. This document is not intended for agents themselves. + +## Not in the docs + +- **casing-and-pluralization** — how API names map to Terraform names; when `api_name` is required. +- **raw-config-access** — when to use `d.GetRawConfig()` / `GetRawPlan()` / `GetRawState()` instead of + `Get`/`GetOk`, which conflate "unset in config" with "set to the zero value" (false/0/""); the raw cty + values distinguish null from zero for detecting whether a user actually set a field. + +## Overlaps the docs (entry would add the judgment layer) + +- **permadiff-decision-path** — choosing between `output`, `default_from_api`, diff suppression, or a real + fix; mechanics are in `docs/content/develop/diffs.md`. +- **data-source-idioms** — pitfalls beyond the procedure in `docs/content/develop/add-handwritten-datasource.md`. +- **test-adequacy-traps** — cases `docs/content/test/test.md` doesn't cover (identical-config update steps, + missing import-and-recheck). + +## Mostly covered by the docs (revisit only if agents misread them) + +- **immutability-nuances** — `docs/content/reference/field.md`, `docs/content/best-practices/immutable-fields.md`. +- **sensitive-and-write-only** — `docs/content/reference/field.md`. + +## Lives elsewhere + +- **failure-troubleshooting-catalog** — `.agents/skills/operations/troubleshooting_reference.md`; migrates + here if it outgrows the skill. + +## Other future work + +- **PR mining** — mine historical PRs to discover new entries. These could be recurring review catches and per-service quirks that were only ever captured in the PR reviews themselves. diff --git a/.agents/knowledge/field/enums-vs-strings.md b/.agents/knowledge/field/enums-vs-strings.md new file mode 100644 index 000000000000..f01ff36e378c --- /dev/null +++ b/.agents/knowledge/field/enums-vs-strings.md @@ -0,0 +1,37 @@ +--- +name: enums-vs-strings +description: Model an API enum as Enum (strict, plan-time) or String (forward-compatible) - the deliberate trade-off. +topics: [field] +task_types: [field-add, new-resource] +source: docs/content/best-practices/validation.md + the schema-review skill's original trade-off note +status: draft +last_verified: 2026-07-09 +--- + +# Enum vs. String + +**The trade-off (decide deliberately, per field):** +- **`Enum`** when the value set is exhaustive for a clearly-defined domain and new values are extremely + unlikely. Buys strict plan-time validation — users fail fast instead of mid-apply. +- **`String`** (with allowed values named in the description and a link to API docs) when the API is + likely to add values. **Why:** an Enum's value list lives in the provider; when the API adds a value, + every user needs a provider upgrade before they can use it — and imports of resources using the new + value break. A String stays forward-compatible. + +```yaml +- name: 'severity' + type: Enum + enum_values: + - 'LOW' + - 'MEDIUM' + - 'HIGH' +``` + +**Rules for Enum:** +- Omit the `FIELD_NAME_UNSPECIFIED` value from `enum_values` — it is the proto convention's first enum + value, representing 0/unset, not a real choice. +- Enums validate against the list automatically; a custom `validation` **overrides** that default — if you + add one, it must re-verify the enum values itself. + +**Rule for String-instead-of-enum:** list the allowed values in the field description with a link to the +API docs — otherwise users only discover an invalid value when the API rejects it mid-apply. diff --git a/.agents/knowledge/index.md b/.agents/knowledge/index.md new file mode 100644 index 000000000000..f50151f54a7c --- /dev/null +++ b/.agents/knowledge/index.md @@ -0,0 +1,56 @@ +# Knowledge index + +Read this index at decision points; open only the source the task needs. Format and curation rules: +[README.md](README.md). + +## Contributor documentation (`docs/content/`) + +**Reference (what the schema properties mean):** + +- Field properties (`output`, `immutable`, defaults, `sensitive`, validation, enums, arrays, `api_name`, conflicts) — `docs/content/reference/field.md` +- Resource-level properties (URLs, timeouts, async, custom code hooks) — `docs/content/reference/resource.md` +- Resource metadata — `docs/content/reference/metadata.md` +- Samples format — `docs/content/reference/sample.md` +- Update test expectations — `docs/content/reference/update-test-changes.md` +- Make commands — `docs/content/reference/make-commands.md` + +**Procedures (how to do a task):** + +- Add or change a field — `docs/content/develop/add-fields.md` +- Add a resource — `docs/content/develop/add-resource.md` +- Add a handwritten data source — `docs/content/develop/add-handwritten-datasource.md` +- Add IAM support — `docs/content/develop/add-iam-support.md` +- Custom code (expanders, flatteners, hooks) — `docs/content/develop/custom-code.md` +- Fix diffs and permadiffs — `docs/content/develop/diffs.md` +- Client-side fields — `docs/content/develop/client-side-fields.md` +- Promote beta → GA — `docs/content/develop/promote-to-ga.md` +- Generate the providers / set up the environment — `docs/content/develop/generate-providers.md`, `docs/content/develop/set-up-dev-environment.md` + +**Testing:** + +- Write acceptance tests (create, update, import) — `docs/content/test/test.md` +- Run tests — `docs/content/test/run-tests.md` + +**Contributing (PRs, review, release notes):** + +- Contribution process end to end — `docs/content/contribution-process.md` +- Create a PR — `docs/content/code-review/create-pr.md` +- Write release notes — `docs/content/code-review/release-notes.md` +- Review a PR — `docs/content/code-review/review-pr.md` + +**Documentation:** + +- Add resource documentation — `docs/content/document/add-documentation.md` +- Handwritten docs style guide — `docs/content/document/handwritten-docs-style-guide.md` + +**Best practices (judgment calls with team positions):** + +- Immutable fields / ForceNew — `docs/content/best-practices/immutable-fields.md` +- Deletion policy — `docs/content/best-practices/deletion-policy.md` +- Labels and annotations — `docs/content/best-practices/labels-and-annotations.md` +- Client-side validation — `docs/content/best-practices/validation.md` +- Common resource patterns (singletons) — `docs/content/best-practices/common-resource-patterns.md` + +## Agent-only entries + +- **enums-vs-strings** — Model an API enum as Enum (strict, plan-time) or String (forward-compatible): the deliberate trade-off. — [field/enums-vs-strings.md](field/enums-vs-strings.md) diff --git a/.agents/rules/general.md b/.agents/rules/general.md new file mode 100644 index 000000000000..e3b5d0b7f56c --- /dev/null +++ b/.agents/rules/general.md @@ -0,0 +1,10 @@ +--- +trigger: always_on +description: Mandatory starting action to read the knowledge index at the beginning of each session. +--- + +# General Rules + +## 1. Mandatory Starting Action +- **Rule:** At the start of any session or conversation, you MUST first open and read the Knowledge Index file ([.agents/knowledge/index.md](.agents/knowledge/index.md)) before taking any other action or starting any research/implementation. +- **Purpose:** To check for relevant patterns, guides, or workspace-specific instructions. \ No newline at end of file diff --git a/.agents/skills/utils/schema-review/SKILL.md b/.agents/skills/utils/schema-review/SKILL.md index b04676cfa6ea..e69735b16883 100644 --- a/.agents/skills/utils/schema-review/SKILL.md +++ b/.agents/skills/utils/schema-review/SKILL.md @@ -1,6 +1,6 @@ --- name: schema-review -description: "Stub skill for reviewing Magic Modules schemas. Currently covers Enum vs. String trade-offs." +description: "Skill for reviewing Magic Modules schemas. Currently covers the Enum vs. String decision via the knowledge base." --- # `schema-review` @@ -14,17 +14,11 @@ description: "Stub skill for reviewing Magic Modules schemas. Currently covers E ## Execution Steps ### 1. Enum vs. String Trade-off -When adding a field that is defined as an Enum in the API, you must decide between `Enum` and `String` in Magic Modules. - -#### The Trade-off: -* **Prefer `Enum`** when you want strict, plan-time validation of values to fail fast. -* **Prefer `String`** (with allowed values documented) when the API is dynamic or likely to add values. This prevents Terraform from crashing with a validation error for users when the API updates *before* the provider catches up. +When adding a field that is defined as an Enum in the API, you must decide between `Enum` and `String` in Magic Modules. Read the knowledge entry [.agents/knowledge/field/enums-vs-strings.md](../../../knowledge/field/enums-vs-strings.md) and apply its trade-off. > [!NOTE] -> This skill is a **stub** and will be expanded over time with more schema review checklist items (such as `api_name` overrides, array flattening, output-only fields, etc.). +> This skill will be expanded over time with more schema review checklist items. The judgment content lives in [.agents/knowledge/](../../../knowledge/index.md) — this skill references it and does not hold its own copy. ### 2. Verification & Handoff -Instructions on how the agent should verify the command succeeded, and what workflow or rule it should return to next. -* Proceed with your field addition or PR review based on this stylistic choice. -* Reference the `modernization_roadmap.md` for upcoming expansions to this checklist. +* Proceed with your field addition or PR review based on the decision. diff --git a/.agents/skills/workflows/add_list_resource/SKILL.md b/.agents/skills/workflows/add_list_resource/SKILL.md index 28602c8045ea..4336384df9d3 100644 --- a/.agents/skills/workflows/add_list_resource/SKILL.md +++ b/.agents/skills/workflows/add_list_resource/SKILL.md @@ -9,6 +9,8 @@ description: "Opt resource into MMv1 list-resource generation by setting `genera This workflow produces a single PR scoped to **one product** that flips `generate_list_resource: true` on every eligible MMv1 resource in that product, generates the downstream code, runs the generated list-query tests, and opens the PR. Do **one product per PR**, with as many eligible resources as pass. +Consult `.agents/knowledge/index.md` for the topics this task touches and open the relevant sources. + ## Prerequisites * You are in the `magic-modules` root directory. diff --git a/.agents/skills/workflows/default/SKILL.md b/.agents/skills/workflows/default/SKILL.md index 3aeff1256696..906348cecff1 100644 --- a/.agents/skills/workflows/default/SKILL.md +++ b/.agents/skills/workflows/default/SKILL.md @@ -14,6 +14,7 @@ This document outlines the structured 5-step lifecycle for formal implementation ### 2. Triage * Gather context on the change or bug. Plan the change (New feature or bug fix) within schema or logic. +* Consult `.agents/knowledge/index.md` for the topics the change touches and open the relevant sources. * Execute the `triage` skill (located in `.agents/skills/operations/triage/`) to perform this work. * **Transfers to Step 3:** Approved implementation plan and file paths file. diff --git a/.agents/skills/workflows/new_resource/SKILL.md b/.agents/skills/workflows/new_resource/SKILL.md index a9d631bc89c2..f5f0d8da2a7d 100644 --- a/.agents/skills/workflows/new_resource/SKILL.md +++ b/.agents/skills/workflows/new_resource/SKILL.md @@ -31,5 +31,6 @@ There are two ways to generate the initial YAML definition for the resource: - If the subagent reports test failures, enter the **Default Workflow** ([default/SKILL.md](../default/SKILL.md)) at **Step 5 (Fix)** to plan remediation. ### 2B. Manual Path +* Consult `.agents/knowledge/index.md` for the topics the resource touches and open the relevant sources. * Follow the standard process to draft the YAML definition in `mmv1/products/...` based on API documentation and repository patterns. * **Handoff:** Once the YAML is drafted, enter the **Default Workflow** ([default/SKILL.md](../default/SKILL.md)) at **Step 3 (Generate)** to compile the provider and continue with testing. diff --git a/AGENTS.md b/AGENTS.md index 57bdac73fe33..83dd2a0f7a8b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,13 +12,13 @@ here, upstream — never in the downstream repos. provider task. - **`.agents/skills/`** — reusable skills the workflows compose (generation, testing, log parsing, troubleshooting). Each skill's `SKILL.md` frontmatter states when to use it. -- **`.agents/knowledge/`** — the curated knowledge base (initial seeding in progress). When present, - consult its `index.md` at decision points and open only the entries the task needs. +- **[.agents/knowledge/index.md](.agents/knowledge/index.md)** — the knowledge index: a short map of + where provider knowledge lives. Refer to this to check for relevant patterns or instructions. - **`.agents/archive/`** — parked tracks (currently TGC). Not maintained; do not use as reference. ## Ground rules -- **Never weaken a test or check to make it pass.** No disabling or skipping tests, and no test-dodging - behavior flags (`ignore_read`, `default_from_api`, `ImportStateVerifyIgnore`) without an adjacent +- **Never weaken baseline test coverage to make a test pass.** No disabling or skipping tests, and no test-dodging + behavior flags (`ignore_read`, `ImportStateVerifyIgnore`) without an adjacent comment justifying the API behavior that requires them. - **Verify before opening a PR**: generate, build, and run the tests relevant to what you changed. From c5d81661781ba0abb92311c13eec902b29f886c6 Mon Sep 17 00:00:00 2001 From: Mattie Fu Date: Mon, 20 Jul 2026 16:28:24 -0400 Subject: [PATCH 10/30] bigtable: Fix bigtable_custom_endpoint ignoring config and dropping gRPC endpoints (#18187) --- .../terraform/services/bigtable/client.go | 82 ++++++++++++------- .../services/bigtable/client_test.go | 71 ++++++++++++++++ 2 files changed, 125 insertions(+), 28 deletions(-) create mode 100644 mmv1/third_party/terraform/services/bigtable/client_test.go diff --git a/mmv1/third_party/terraform/services/bigtable/client.go b/mmv1/third_party/terraform/services/bigtable/client.go index 27bba8d5b139..838b3ea19359 100644 --- a/mmv1/third_party/terraform/services/bigtable/client.go +++ b/mmv1/third_party/terraform/services/bigtable/client.go @@ -3,7 +3,8 @@ package bigtable import ( "context" "log" - "os" + "net" + "strings" "cloud.google.com/go/bigtable" "golang.org/x/oauth2" @@ -14,68 +15,93 @@ import ( ) type ClientFactory struct { + BasePath string + AdminBasePath string + UniverseDomain string GRPCLoggingOptions []option.ClientOption UserAgent string TokenSource oauth2.TokenSource BillingProject string UserProjectOverride bool + RequestReason string } -func (s ClientFactory) NewInstanceAdminClient(project string) (*bigtable.InstanceAdminClient, error) { +// toGRPCEndpoint converts an HTTP-style base URL (e.g. "https://foo.googleapis.com/") +// into the "host:port" form that gRPC's option.WithEndpoint requires. If the host +// has no explicit port, the default TLS port (443) is appended — gRPC's dialer +// rejects a port-less address with "missing port in address". +func toGRPCEndpoint(url string) string { + endpoint := strings.TrimPrefix(url, "https://") + endpoint = strings.TrimPrefix(endpoint, "http://") + endpoint = strings.TrimSuffix(endpoint, "/") + if _, _, err := net.SplitHostPort(endpoint); err != nil { + endpoint = net.JoinHostPort(endpoint, "443") + } + return endpoint +} + +func (s ClientFactory) getClientOptions(isDataClient bool) []option.ClientOption { var opts []option.ClientOption - if requestReason := os.Getenv("CLOUDSDK_CORE_REQUEST_REASON"); requestReason != "" { - opts = append(opts, option.WithRequestReason(requestReason)) + if s.RequestReason != "" { + opts = append(opts, option.WithRequestReason(s.RequestReason)) } if s.UserProjectOverride && s.BillingProject != "" { opts = append(opts, option.WithQuotaProject(s.BillingProject)) } - opts = append(opts, option.WithTokenSource(s.TokenSource), option.WithUserAgent(s.UserAgent)) - opts = append(opts, s.GRPCLoggingOptions...) - - return bigtable.NewInstanceAdminClient(context.Background(), project, opts...) -} - -func (s ClientFactory) NewAdminClient(project, instance string) (*bigtable.AdminClient, error) { - var opts []option.ClientOption - if requestReason := os.Getenv("CLOUDSDK_CORE_REQUEST_REASON"); requestReason != "" { - opts = append(opts, option.WithRequestReason(requestReason)) + if s.UniverseDomain != "" { + opts = append(opts, option.WithUniverseDomain(s.UniverseDomain)) } - if s.UserProjectOverride && s.BillingProject != "" { - opts = append(opts, option.WithQuotaProject(s.BillingProject)) + if isDataClient && s.BasePath != "" { + basePath := s.BasePath + // The bigtable_custom_endpoint provider field targets the Admin API, so a + // custom endpoint often looks like "https://bigtableadmin.example/". Rewrite + // the "bigtableadmin." host prefix to "bigtable." for the Data API client so + // operators only need to configure a single endpoint. Endpoints that already + // use a distinct Data-API host (or a non-standard prefix) are left alone. + if strings.HasPrefix(strings.TrimPrefix(strings.TrimPrefix(basePath, "https://"), "http://"), "bigtableadmin.") { + basePath = strings.Replace(basePath, "bigtableadmin.", "bigtable.", 1) + } + opts = append(opts, option.WithEndpoint(toGRPCEndpoint(basePath))) + } else if !isDataClient && s.AdminBasePath != "" { + opts = append(opts, option.WithEndpoint(toGRPCEndpoint(s.AdminBasePath))) } opts = append(opts, option.WithTokenSource(s.TokenSource), option.WithUserAgent(s.UserAgent)) opts = append(opts, s.GRPCLoggingOptions...) - return bigtable.NewAdminClient(context.Background(), project, instance, opts...) + return opts } -func (s ClientFactory) NewClient(project, instance string) (*bigtable.Client, error) { - var opts []option.ClientOption - if requestReason := os.Getenv("CLOUDSDK_CORE_REQUEST_REASON"); requestReason != "" { - opts = append(opts, option.WithRequestReason(requestReason)) - } - - if s.UserProjectOverride && s.BillingProject != "" { - opts = append(opts, option.WithQuotaProject(s.BillingProject)) - } +func (s ClientFactory) NewInstanceAdminClient(project string) (*bigtable.InstanceAdminClient, error) { + opts := s.getClientOptions(false) + return bigtable.NewInstanceAdminClient(context.Background(), project, opts...) +} - opts = append(opts, option.WithTokenSource(s.TokenSource), option.WithUserAgent(s.UserAgent)) - opts = append(opts, s.GRPCLoggingOptions...) +func (s ClientFactory) NewAdminClient(project, instance string) (*bigtable.AdminClient, error) { + opts := s.getClientOptions(false) + return bigtable.NewAdminClient(context.Background(), project, instance, opts...) +} +func (s ClientFactory) NewClient(project, instance string) (*bigtable.Client, error) { + opts := s.getClientOptions(true) return bigtable.NewClient(context.Background(), project, instance, opts...) } func NewClientFactory(c *transport_tpg.Config, userAgent string) *ClientFactory { + baseUrl := transport_tpg.BaseUrl(Product, c) bigtableClientFactory := &ClientFactory{ + BasePath: transport_tpg.RemoveBasePathVersion(baseUrl), + AdminBasePath: transport_tpg.RemoveBasePathVersion(baseUrl), + UniverseDomain: c.UniverseDomain, UserAgent: userAgent, TokenSource: c.TokenSource, GRPCLoggingOptions: c.GRPCLoggingOptions, BillingProject: c.BillingProject, UserProjectOverride: c.UserProjectOverride, + RequestReason: c.RequestReason, } return bigtableClientFactory diff --git a/mmv1/third_party/terraform/services/bigtable/client_test.go b/mmv1/third_party/terraform/services/bigtable/client_test.go new file mode 100644 index 000000000000..7732a24be709 --- /dev/null +++ b/mmv1/third_party/terraform/services/bigtable/client_test.go @@ -0,0 +1,71 @@ +package bigtable_test + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-provider-google/google/provider" + "github.com/hashicorp/terraform-provider-google/google/services/bigtable" + transport_tpg "github.com/hashicorp/terraform-provider-google/google/transport" +) + +func TestClientFactoryPropagatesEndpoints(t *testing.T) { + cfg := &transport_tpg.Config{ + UniverseDomain: "apis-tpczero.goog", + CustomEndpoints: map[string]string{ + "bigtable_custom_endpoint": "https://bigtableadmin.apis-tpczero.goog/v2/", + }, + } + + factory := bigtable.NewClientFactory(cfg, "test-agent") + + if factory.UniverseDomain != "apis-tpczero.goog" { + t.Errorf("Expected UniverseDomain 'apis-tpczero.goog', got '%s'", factory.UniverseDomain) + } + + expectedPath := "https://bigtableadmin.apis-tpczero.goog/" + if factory.AdminBasePath != expectedPath { + t.Errorf("Expected AdminBasePath '%s', got '%s'", expectedPath, factory.AdminBasePath) + } + if factory.BasePath != expectedPath { + t.Errorf("Expected BasePath '%s', got '%s'", expectedPath, factory.BasePath) + } +} + +func TestClientFactoryPropagatesRequestReason(t *testing.T) { + expectedReason := "test-request-reason" + cfg := &transport_tpg.Config{ + RequestReason: expectedReason, + } + + factory := bigtable.NewClientFactory(cfg, "test-agent") + + if factory.RequestReason != expectedReason { + t.Errorf("Expected RequestReason '%s', got '%s'", expectedReason, factory.RequestReason) + } +} + +func TestClientFactoryPropagatesRequestReasonFromEnv(t *testing.T) { + expectedReason := "env-request-reason" + t.Setenv("CLOUDSDK_CORE_REQUEST_REASON", expectedReason) + + // Create empty schema.ResourceData using the SDK Provider schema + emptyConfigMap := map[string]interface{}{} + d := schema.TestResourceDataRaw(t, provider.Provider().Schema, emptyConfigMap) + + err := transport_tpg.HandleSDKDefaults(d) + if err != nil { + t.Fatalf("error: %v", err) + } + + cfg := &transport_tpg.Config{} + if v, ok := d.GetOk("request_reason"); ok { + cfg.RequestReason = v.(string) + } + + factory := bigtable.NewClientFactory(cfg, "test-agent") + + if factory.RequestReason != expectedReason { + t.Errorf("Expected RequestReason '%s' from env, got '%s'", expectedReason, factory.RequestReason) + } +} From 6208040aa100f8d06b27fc859d5260e6e19b3e14 Mon Sep 17 00:00:00 2001 From: "Stephen Lewis (Burrows)" Date: Mon, 20 Jul 2026 14:55:22 -0700 Subject: [PATCH 11/30] Added missing fields to google_colab_schedule and marked google_vertex_ai_schedule as deprecated (#18303) --- mmv1/products/colab/Schedule.yaml | 393 +++++++++++++++++- mmv1/products/vertexai/Schedule.yaml | 1 + .../terraform/encoders/colab_schedule.go.tmpl | 24 +- .../colab_schedule_notebook_full.tf.tmpl | 111 +++++ .../colab/colab_schedule_pipeline.tf.tmpl | 75 ++++ 5 files changed, 591 insertions(+), 13 deletions(-) create mode 100644 mmv1/templates/terraform/samples/services/colab/colab_schedule_notebook_full.tf.tmpl create mode 100644 mmv1/templates/terraform/samples/services/colab/colab_schedule_pipeline.tf.tmpl diff --git a/mmv1/products/colab/Schedule.yaml b/mmv1/products/colab/Schedule.yaml index eab8ef3817d7..c180cc62f814 100644 --- a/mmv1/products/colab/Schedule.yaml +++ b/mmv1/products/colab/Schedule.yaml @@ -48,6 +48,10 @@ samples: project_id: 'PROJECT_NAME' location: 'REGION' service_account: 'SERVICE_ACCT' + ignore_read_extra: + - last_scheduled_run_response + - next_run_time + - started_run_count - name: 'colab_schedule_paused' primary_resource_id: 'schedule' steps: @@ -62,15 +66,17 @@ samples: service_account: 'SERVICE_ACCT' ignore_read_extra: - desired_state + - last_scheduled_run_response + - next_run_time + - started_run_count - name: 'colab_schedule_full' - min_version: "beta" primary_resource_id: 'schedule' + min_version: beta bootstrap_iam: - member: "serviceAccount:service-{project_number}@gcp-sa-dataform.iam.gserviceaccount.com" role: "roles/cloudkms.cryptoKeyEncrypterDecrypter" steps: - name: 'colab_schedule_full' - min_version: "beta" resource_id_vars: display_name: 'full-schedule' bucket: 'my_bucket' @@ -92,6 +98,79 @@ samples: end_time: 'time.Date(time.Now().Year(), 12, 31, 0, 0, 0, 0, time.Now().Location()).AddDate(0, 0, 10).Format(time.RFC3339)' # for backward compatible secret: '"secret" + randomSuffix' + ignore_read_extra: + - last_scheduled_run_response + - next_run_time + - started_run_count + - name: colab_schedule_notebook_full + primary_resource_id: schedule + steps: + - name: colab_schedule_notebook_full + resource_id_vars: + bucket: 'my_bucket' + network_name: 'colab-test-default' + key_name: 'my-key' + vars: + display_name: 'full-notebook-schedule' + max_concurrent_run_count: 2 + cron: '*/5 * * * *' + test_env_vars: + project_id: 'PROJECT_NAME' + location: 'REGION' + service_account: 'SERVICE_ACCT' + test_vars_overrides: + key_name: 'kms.BootstrapKMSKeyInLocation(t, "us-central1").CryptoKey.Name' + ignore_read_extra: + - last_scheduled_run_response + - next_run_time + - started_run_count + - name: colab_schedule_pipeline + primary_resource_id: schedule + steps: + - name: colab_schedule_pipeline + resource_id_vars: + storage_bucket: pipeline-job + network_name: 'colab-test-default' + key_name: 'my-key' + vars: + display_name: test-schedule + max_concurrent_run_count: 2 + cron: '*/5 * * * *' + allow_queueing: true + max_concurrent_active_run_count: 2 + max_run_count: "10" + end_time: "2030-01-02T00:00:00Z" + preflight_validations: true + pipeline_job_label_value: value-one + failure_policy: PIPELINE_FAILURE_POLICY_FAIL_FAST + test_vars_overrides: + key_name: 'kms.BootstrapKMSKeyInLocation(t, "us-central1").CryptoKey.Name' + ignore_read_extra: + - last_scheduled_run_response + - next_run_time + - started_run_count + - name: colab_schedule_pipeline + resource_id_vars: + storage_bucket: pipeline-job + network_name: 'colab-test-default' + key_name: 'my-key' + vars: + display_name: test-schedule-updated + max_concurrent_run_count: 3 + cron: '*/10 * * * *' + allow_queueing: false + max_concurrent_active_run_count: 3 + max_run_count: "12" + end_time: "2030-01-03T00:00:00Z" + preflight_validations: false + pipeline_job_label_value: value-updated + failure_policy: PIPELINE_FAILURE_POLICY_FAIL_SLOW + test_vars_overrides: + key_name: 'kms.BootstrapKMSKeyInLocation(t, "us-central1").CryptoKey.Name' + ignore_read_extra: + - last_scheduled_run_response + - next_run_time + - started_run_count virtual_fields: - name: 'desired_state' description: | @@ -146,7 +225,9 @@ properties: immutable: true type: NestedObject description: Request for google_colab_notebook_execution. - required: true + exactly_one_of: + - create_notebook_execution_job_request + - create_pipeline_job_request properties: - name: 'notebookExecutionJob' type: NestedObject @@ -167,7 +248,7 @@ properties: - name: 'dataformRepositoryResourceName' type: String required: true - description: 'The resource name of the Dataform Repository.' + description: 'The resource name of the Dataform Repository. Format: `projects/{project_id}/locations/{location}/repositories/{repository_id}`' diff_suppress_func: 'tpgresource.CompareSelfLinkRelativePaths' - name: 'commitSha' type: String @@ -186,13 +267,15 @@ properties: - name: 'generation' type: String description: 'The version of the Cloud Storage object to read. If unset, the current version of the object is read. See https://cloud.google.com/storage/docs/metadata#generation-number.' - - name: executionTimeout + - name: 'executionTimeout' type: String description: 'Max running time of the execution job in seconds (default 86400s / 24 hrs). A duration in seconds with up to nine fractional digits, ending with "s". Example: "3.5s".' - name: 'notebookRuntimeTemplateResourceName' type: String description: 'The NotebookRuntimeTemplate to source compute configuration from.' - required: true + exactly_one_of: + - create_notebook_execution_job_request.0.notebook_execution_job.0.notebook_runtime_template_resource_name + - create_notebook_execution_job_request.0.notebook_execution_job.0.custom_environment_spec - name: 'gcsOutputUri' required: true type: String @@ -209,3 +292,301 @@ properties: - create_notebook_execution_job_request.0.notebook_execution_job.0.execution_user - create_notebook_execution_job_request.0.notebook_execution_job.0.service_account description: 'The service account to run the execution as.' + - name: 'createTime' + type: String + description: Timestamp when this NotebookExecutionJob was created. + output: true + - name: 'customEnvironmentSpec' + type: NestedObject + description: Compute configuration to use for an execution job. + exactly_one_of: + - create_notebook_execution_job_request.0.notebook_execution_job.0.notebook_runtime_template_resource_name + - create_notebook_execution_job_request.0.notebook_execution_job.0.custom_environment_spec + properties: + - name: 'machineSpec' + type: NestedObject + description: Specification of a single machine. + properties: + - name: 'acceleratorCount' + type: Integer + description: The number of accelerators to attach to the machine. For accelerator optimized machine types (https://cloud.google.com/compute/docs/accelerator-optimized-machines), One may set the accelerator_count from 1 to N for machine with N GPUs. If accelerator_count is less than or equal to N / 2, Vertex will co-schedule the replicas of the model into the same VM to save cost. For example, if the machine type is a3-highgpu-8g, which has 8 H100 GPUs, one can set accelerator_count to 1 to 8. If accelerator_count is 1, 2, 3, or 4, Vertex will co-schedule 8, 4, 2, or 2 replicas of the model into the same VM to save cost. When co-scheduling, CPU, memory and storage on the VM will be distributed to replicas on the VM. For example, one can expect a co-scheduled replica requesting 2 GPUs out of a 8-GPU VM will receive 25% of the CPU, memory and storage of the VM. Note that the feature is not compatible with multihost_gpu_node_count. When multihost_gpu_node_count is set, the co-scheduling will not be enabled. + - name: 'acceleratorType' + type: String + description: 'Possible values: NVIDIA_TESLA_K80 NVIDIA_TESLA_P100 NVIDIA_TESLA_V100 NVIDIA_TESLA_P4 NVIDIA_TESLA_T4 NVIDIA_TESLA_A100 NVIDIA_A100_80GB NVIDIA_L4 NVIDIA_H100_80GB NVIDIA_H100_MEGA_80GB NVIDIA_H200_141GB NVIDIA_B200 NVIDIA_GB200 NVIDIA_RTX_PRO_6000 TPU_V2 TPU_V3 TPU_V4_POD TPU_V5_LITEPOD' + - name: 'gpuPartitionSize' + type: String + description: The Nvidia GPU partition size. When specified, the requested accelerators will be partitioned into smaller GPU partitions. For example, if the request is for 8 units of NVIDIA A100 GPUs, and gpu_partition_size="1g.10gb", the service will create 8 * 7 = 56 partitioned MIG instances. The partition size must be a value supported by the requested accelerator. Refer to [Nvidia GPU Partitioning](https://cloud.google.com/kubernetes-engine/docs/how-to/gpus-multi#multi-instance_gpu_partitions) for the available partition sizes. If set, the accelerator_count should be set to 1. + immutable: true + - name: 'machineType' + type: String + description: The type of the machine. See the [list of machine types supported for prediction](https://cloud.google.com/vertex-ai/docs/predictions/configure-compute#machine-types) See the [list of machine types supported for custom training](https://cloud.google.com/vertex-ai/docs/training/configure-compute#machine-types). For DeployedModel this field is optional, and the default value is `n1-standard-2`. For BatchPredictionJob or as part of WorkerPoolSpec this field is required. + immutable: true + - name: 'reservationAffinity' + type: NestedObject + description: A ReservationAffinity can be used to configure a Vertex AI resource (e.g., a DeployedModel) to draw its Compute Engine resources from a Shared Reservation, or exclusively from on-demand capacity. + properties: + - name: 'key' + type: String + description: Corresponds to the label key of a reservation resource. To target a SPECIFIC_RESERVATION by name, use `compute.googleapis.com/reservation-name` as the key and specify the name of your reservation as its value. + - name: 'reservationAffinityType' + type: String + required: true + description: 'Specifies the reservation affinity type. Possible values: NO_RESERVATION ANY_RESERVATION SPECIFIC_RESERVATION SPECIFIC_THEN_ANY_RESERVATION SPECIFIC_THEN_NO_RESERVATION' + - name: 'useReservationPool' + type: Boolean + description: When set to true, resources will be drawn from go/cloud-ai-gcp-pool. + - name: 'values' + type: Array + description: Corresponds to the label values of a reservation resource. This must be the full resource name of the reservation or reservation block. + item_type: + type: String + - name: 'tpuTopology' + type: String + description: 'The topology of the TPUs. Corresponds to the TPU topologies available from GKE. (Example: tpu_topology: "2x2x1").' + immutable: true + - name: 'networkSpec' + type: NestedObject + description: Network spec. + properties: + - name: 'enableInternetAccess' + type: Boolean + description: Whether to enable public internet access. Default false. + - name: 'network' + type: String + description: The full name of the Google Compute Engine [network](https://cloud.google.com//compute/docs/networks-and-firewalls#networks) + - name: 'subnetwork' + type: String + description: 'The name of the subnet that this instance is in. Format: `projects/{project_id_or_number}/regions/{region}/subnetworks/{subnetwork_id}`' + - name: 'persistentDiskSpec' + type: NestedObject + description: Represents the spec of persistent disk options. + properties: + - name: 'diskSizeGb' + type: String + description: Size in GB of the disk (default is 100GB). + - name: 'diskType' + type: String + description: 'Type of the disk (default is "pd-standard"). Valid values: "pd-ssd" (Persistent Disk Solid State Drive) "pd-standard" (Persistent Disk Hard Disk Drive) "pd-balanced" (Balanced Persistent Disk) "pd-extreme" (Extreme Persistent Disk)' + - name: 'encryptionSpec' + type: NestedObject + description: Represents a customer-managed encryption key specification that can be applied to a Vertex AI resource. + properties: + - name: 'kmsKeyName' + type: String + required: true + description: Resource name of the Cloud KMS key used to protect the resource. The Cloud KMS key must be in the same region as the resource. It must have the format `projects/{project}/locations/{location}/keyRings/{key_ring}/cryptoKeys/{crypto_key}`. + - name: 'jobState' + type: String + output: true + description: 'Possible values: JOB_STATE_QUEUED JOB_STATE_PENDING JOB_STATE_RUNNING JOB_STATE_SUCCEEDED JOB_STATE_FAILED JOB_STATE_CANCELLING JOB_STATE_CANCELLED JOB_STATE_PAUSED JOB_STATE_EXPIRED JOB_STATE_UPDATING JOB_STATE_PARTIALLY_SUCCEEDED' + - name: 'kernelName' + type: String + description: 'The name of the kernel to use during notebook execution. If unset, the default kernel is used.' + - name: 'labels' + type: KeyValuePairs + description: 'The labels with user-defined metadata to organize NotebookExecutionJobs.' + - name: 'name' + type: String + output: true + description: 'The resource name of this NotebookExecutionJob. Format: `projects/{project_id}/locations/{location}/notebookExecutionJobs/{job_id}`' + - name: 'scheduleResourceName' + type: String + output: true + description: 'The Schedule resource name if this job is triggered by one. Format: `projects/{project_id}/locations/{location}/schedules/{schedule_id}`' + - name: 'updateTime' + type: String + output: true + description: 'Timestamp when this NotebookExecutionJob was most recently updated.' + - name: 'workbenchRuntime' + type: NestedObject + description: 'Configuration for a Workbench Instances-based environment.' + properties: [] + - name: 'notebookExecutionJobId' + type: String + description: User specified ID for the NotebookExecutionJob. + output: true + - name: 'parent' + type: String + description: 'The resource name of the Location to create the NotebookExecutionJob. Format: `projects/{project}/locations/{location}`' + default_from_api: true + - name: 'catchUp' + type: Boolean + description: Whether to backfill missed runs when the schedule is resumed from PAUSED state. If set to true, all missed runs will be scheduled. New runs will be scheduled after the backfill is complete. Default to false. + output: true + - name: 'createPipelineJobRequest' + type: NestedObject + description: Request message for PipelineService.CreatePipelineJob. + exactly_one_of: + - create_notebook_execution_job_request + - create_pipeline_job_request + properties: + - name: 'parent' + type: String + description: 'The resource name of the Location to create the PipelineJob in. Format: `projects/{project}/locations/{location}`' + default_from_api: true + - name: 'pipelineJob' + type: NestedObject + required: true + description: An instance of a machine learning PipelineJob. + properties: + - name: 'createTime' + type: String + description: Pipeline creation time. + output: true + - name: 'displayName' + type: String + description: The display name of the Pipeline. The name can be up to 128 characters long and can consist of any UTF-8 characters. + - name: 'encryptionSpec' + type: NestedObject + description: Represents a customer-managed encryption key specification that can be applied to a Vertex AI resource. + properties: + - name: 'kmsKeyName' + type: String + required: true + description: Resource name of the Cloud KMS key used to protect the resource. The Cloud KMS key must be in the same region as the resource. It must have the format `projects/{project}/locations/{location}/keyRings/{key_ring}/cryptoKeys/{crypto_key}`. + - name: 'endTime' + type: String + description: Pipeline end time. + output: true + - name: 'labels' + type: KeyValuePairs + default_from_api: true + description: The labels with user-defined metadata to organize PipelineJob. Label keys and values can be no longer than 64 characters (Unicode codepoints), can only contain lowercase letters, numeric characters, underscores and dashes. International characters are allowed. See https://goo.gl/xmQnxf for more information and examples of labels. Note there is some reserved label key for Vertex AI Pipelines. - `vertex-ai-pipelines-run-billing-id`, user set value will get overrided. + - name: 'name' + type: String + description: The resource name of the PipelineJob. + output: true + - name: 'network' + type: String + description: The full name of the Compute Engine [network](/compute/docs/networks-and-firewalls#networks) to which the Pipeline Job's workload should be peered. For example, `projects/12345/global/networks/myVPC`. [Format](/compute/docs/reference/rest/v1/networks/insert) is of the form `projects/{project}/global/networks/{network}`. Where {project} is a project number, as in `12345`, and {network} is a network name. Private services access must already be configured for the network. Pipeline job will apply the network configuration to the Google Cloud resources being launched, if applied, such as Vertex AI Training or Dataflow job. If left unspecified, the workload is not peered with any network. + - name: 'pipelineSpec' + type: String + custom_expand: templates/terraform/custom_expand/json_value.tmpl + custom_flatten: templates/terraform/custom_flatten/json_schema.tmpl + description: A compiled definition of a pipeline, represented as a `JSON` object. Defines the structure of the pipeline, including its components, tasks, and parameters. This specification is generated by compiling a pipeline function defined in `Python` using the `Kubeflow Pipelines SDK`. + - name: 'preflightValidations' + type: Boolean + description: Whether to do component level validations before job creation. + - name: 'pscInterfaceConfig' + type: NestedObject + description: Configuration for PSC-I. + properties: + - name: 'dnsPeeringConfigs' + type: Array + description: DNS peering configurations. When specified, Vertex AI will attempt to configure DNS peering zones in the tenant project VPC to resolve the specified domains using the target network's Cloud DNS. The user must grant the dns.peer role to the Vertex AI Service Agent on the target project. + item_type: + name: dnsPeeringConfigsItem + type: NestedObject + description: DNS peering configuration. These configurations are used to create DNS peering zones in the Vertex tenant project VPC, enabling resolution of records within the specified domain hosted in the target network's Cloud DNS. + properties: + - name: 'domain' + type: String + required: true + description: The DNS name suffix of the zone being peered to, e.g., "my-internal-domain.corp.". Must end with a dot. + - name: 'targetNetwork' + type: String + required: true + description: The VPC network name in the target_project where the DNS zone specified by 'domain' is visible. + - name: 'targetProject' + type: String + required: true + description: The project ID hosting the Cloud DNS managed zone that contains the 'domain'. The Vertex AI Service Agent requires the dns.peer role on this project. + - name: 'networkAttachment' + type: String + description: The name of the Compute Engine [network attachment](https://cloud.google.com/vpc/docs/about-network-attachments) to attach to the resource within the region and user project. To specify this field, you must have already [created a network attachment] (https://cloud.google.com/vpc/docs/create-manage-network-attachments#create-network-attachments). This field is only used for resources using PSC-I. + - name: 'reservedIpRanges' + type: Array + default_from_api: true + description: 'A list of names for the reserved ip ranges under the VPC network that can be used for this Pipeline Job''s workload. If set, we will deploy the Pipeline Job''s workload within the provided ip ranges. Otherwise, the job will be deployed to any ip ranges under the provided VPC network. Example: [''vertex-ai-ip-range''].' + item_type: + type: String + - name: 'runtimeConfig' + type: NestedObject + description: The runtime config of a PipelineJob. + properties: + - name: 'failurePolicy' + type: String + description: 'Possible values: PIPELINE_FAILURE_POLICY_FAIL_SLOW PIPELINE_FAILURE_POLICY_FAIL_FAST' + - name: 'gcsOutputDirectory' + type: String + required: true + description: A path in a Cloud Storage bucket, which will be treated as the root output directory of the pipeline. It is used by the system to generate the paths of output artifacts. The artifact paths are generated with a sub-path pattern `{job_id}/{task_id}/{output_key}` under the specified output directory. The service account specified in this pipeline must have the `storage.objects.get` and `storage.objects.create` permissions for this bucket. + - name: 'parameterValues' + type: KeyValuePairs + default_from_api: true + description: The runtime parameters of the PipelineJob. The parameters will be passed into PipelineJob.pipeline_spec to replace the placeholders at runtime. This field is used by pipelines built using `PipelineJob.pipeline_spec.schema_version` 2.1.0, such as pipelines built using Kubeflow Pipelines SDK 1.9 or higher and the v2 DSL. + - name: 'scheduleName' + type: String + description: The schedule resource name. Only returned if the Pipeline is created by Schedule API. + output: true + - name: 'serviceAccount' + type: String + description: The service account that the pipeline workload runs as. If not specified, the Compute Engine default service account in the project will be used. See https://cloud.google.com/compute/docs/access/service-accounts#default_service_account Users starting the pipeline must have the `iam.serviceAccounts.actAs` permission on this service account. + - name: 'startTime' + type: String + description: Pipeline start time. + output: true + - name: 'state' + type: String + output: true + description: 'Possible values: PIPELINE_STATE_QUEUED PIPELINE_STATE_PENDING PIPELINE_STATE_RUNNING PIPELINE_STATE_SUCCEEDED PIPELINE_STATE_FAILED PIPELINE_STATE_CANCELLING PIPELINE_STATE_CANCELLED PIPELINE_STATE_PAUSED' + - name: 'templateMetadata' + type: NestedObject + output: true + description: Pipeline template metadata if PipelineJob.template_uri is from supported template registry. Currently, the only supported registry is Artifact Registry. + properties: + - name: 'version' + type: String + output: true + description: The version_name in artifact registry. Will always be presented in output if the PipelineJob.template_uri is from supported template registry. Format is "sha256:abcdef123456...". + - name: 'templateUri' + type: String + description: A template uri from where the PipelineJob.pipeline_spec, if empty, will be downloaded. Currently, only uri from Vertex Template Registry & Gallery is supported. Reference to https://cloud.google.com/vertex-ai/docs/pipelines/create-pipeline-template. + - name: 'updateTime' + type: String + description: Timestamp when this PipelineJob was most recently updated. + output: true + - name: 'pipelineJobId' + type: String + description: The ID to use for the PipelineJob, which will become the final component of the PipelineJob name. If not provided, an ID will be automatically generated. This value should be less than 128 characters, and valid characters are `/a-z-/`. + output: true + - name: 'createTime' + type: String + description: Timestamp when this Schedule was created. + output: true + - name: 'lastPauseTime' + type: String + description: Timestamp when this Schedule was last paused. Unset if never paused. + output: true + - name: 'lastResumeTime' + type: String + description: Timestamp when this Schedule was last resumed. Unset if never resumed from pause. + output: true + - name: 'lastScheduledRunResponse' + type: NestedObject + output: true + description: Status of a scheduled run. + properties: + - name: 'runResponse' + type: String + output: true + description: The response of the scheduled run. + - name: 'scheduledRunTime' + type: String + output: true + description: The scheduled run time based on the user-specified schedule. + - name: 'maxConcurrentActiveRunCount' + type: String + description: Specifies the maximum number of active runs that can be executed concurrently for this Schedule. This limits the number of runs that can be in a non-terminal state at the same time. Currently, this field is only supported for requests of type CreatePipelineJobRequest. + - name: 'nextRunTime' + type: String + description: Timestamp when this Schedule should schedule the next run. Having a next_run_time in the past means the runs are being started behind schedule. + output: true + - name: 'startedRunCount' + type: String + description: The number of runs started by this schedule. + output: true + - name: 'updateTime' + type: String + description: Timestamp when this Schedule was updated. + output: true diff --git a/mmv1/products/vertexai/Schedule.yaml b/mmv1/products/vertexai/Schedule.yaml index adf542905084..99c2d2b32cbc 100644 --- a/mmv1/products/vertexai/Schedule.yaml +++ b/mmv1/products/vertexai/Schedule.yaml @@ -14,6 +14,7 @@ --- name: Schedule description: An online schedule that triggers running pipeline jobs or notebook execution jobs. +deprecation_message: '`google_vertex_ai_schedule` is deprecated and will be removed in a future major release. Use `google_colab_schedule` instead.' base_url: projects/{{project}}/locations/{{location}}/schedules self_link: projects/{{project}}/locations/{{location}}/schedules/{{name}} update_mask: true diff --git a/mmv1/templates/terraform/encoders/colab_schedule.go.tmpl b/mmv1/templates/terraform/encoders/colab_schedule.go.tmpl index e1e20d751442..c9fe9a66ebb5 100644 --- a/mmv1/templates/terraform/encoders/colab_schedule.go.tmpl +++ b/mmv1/templates/terraform/encoders/colab_schedule.go.tmpl @@ -9,15 +9,25 @@ if err != nil { return nil, err } -// createNotebookExecutionJobRequest does not exist in update requests -if obj["createNotebookExecutionJobRequest"] == nil { - return obj, nil +// Handle parent injection for creation requests if parent is not explicitly provided +if obj["createNotebookExecutionJobRequest"] != nil { + jobRequest, ok := obj["createNotebookExecutionJobRequest"].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("createNotebookExecutionJobRequest is not of type map[string]interface{} or is nil") + } + if jobRequest["parent"] == nil || jobRequest["parent"] == "" { + jobRequest["parent"] = fmt.Sprintf("projects/%s/locations/%s", project, location) + } } -jobRequest, ok := obj["createNotebookExecutionJobRequest"].(map[string]interface{}) -if !ok { - return nil, fmt.Errorf("createNotebookExecutionJobRequest is not of type map[string]interface{} or is nil") +if obj["createPipelineJobRequest"] != nil { + pipelineRequest, ok := obj["createPipelineJobRequest"].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("createPipelineJobRequest is not of type map[string]interface{} or is nil") + } + if pipelineRequest["parent"] == nil || pipelineRequest["parent"] == "" { + pipelineRequest["parent"] = fmt.Sprintf("projects/%s/locations/%s", project, location) + } } -jobRequest["parent"] = fmt.Sprintf("projects/%s/locations/%s", project, location) return obj, nil diff --git a/mmv1/templates/terraform/samples/services/colab/colab_schedule_notebook_full.tf.tmpl b/mmv1/templates/terraform/samples/services/colab/colab_schedule_notebook_full.tf.tmpl new file mode 100644 index 000000000000..a6d0d2c38308 --- /dev/null +++ b/mmv1/templates/terraform/samples/services/colab/colab_schedule_notebook_full.tf.tmpl @@ -0,0 +1,111 @@ +data "google_project" "project" {} + +resource "google_storage_bucket" "bucket" { + name = "{{index $.ResourceIdVars "bucket"}}" + location = "us-central1" + uniform_bucket_level_access = true + force_destroy = true +} + +resource "google_storage_bucket_object" "notebook" { + name = "hello_world.ipynb" + bucket = google_storage_bucket.bucket.name + content = < Date: Mon, 20 Jul 2026 23:57:32 +0200 Subject: [PATCH 12/30] Add conflicts validation to PolicyOrchestrator selectors (#18129) --- .../osconfigv2/PolicyOrchestrator.yaml | 4 + .../PolicyOrchestratorForFolder.yaml | 2 + .../PolicyOrchestratorForOrganization.yaml | 2 + .../osconfigv2_policy_orchestrator.go.tmpl | 38 ++++++++++ ...e_os_config_v2_policy_orchestrator_test.go | 73 +++++++++++++++++++ 5 files changed, 119 insertions(+) create mode 100644 mmv1/templates/terraform/constants/osconfigv2_policy_orchestrator.go.tmpl diff --git a/mmv1/products/osconfigv2/PolicyOrchestrator.yaml b/mmv1/products/osconfigv2/PolicyOrchestrator.yaml index 1b7a0b80cacb..941067e5f9e6 100644 --- a/mmv1/products/osconfigv2/PolicyOrchestrator.yaml +++ b/mmv1/products/osconfigv2/PolicyOrchestrator.yaml @@ -44,6 +44,10 @@ async: include_project: false autogen_status: UG9saWN5T3JjaGVzdHJhdG9y autogen_async: true +custom_diff: + - policyOrchestratorCustomizeDiff +custom_code: + constants: templates/terraform/constants/osconfigv2_policy_orchestrator.go.tmpl samples: - name: osconfigv2_policy_orchestrator_basic primary_resource_id: policy_orchestrator diff --git a/mmv1/products/osconfigv2/PolicyOrchestratorForFolder.yaml b/mmv1/products/osconfigv2/PolicyOrchestratorForFolder.yaml index 1e5253e4fc89..6760e264b87e 100644 --- a/mmv1/products/osconfigv2/PolicyOrchestratorForFolder.yaml +++ b/mmv1/products/osconfigv2/PolicyOrchestratorForFolder.yaml @@ -45,6 +45,8 @@ async: include_project: true autogen_status: UG9saWN5T3JjaGVzdHJhdG9yRm9yRm9sZGVy autogen_async: true +custom_diff: + - policyOrchestratorCustomizeDiff samples: - name: osconfigv2_policy_orchestrator_for_folder_basic primary_resource_id: policy_orchestrator_for_folder diff --git a/mmv1/products/osconfigv2/PolicyOrchestratorForOrganization.yaml b/mmv1/products/osconfigv2/PolicyOrchestratorForOrganization.yaml index bc67d5c0a667..4d6536355dca 100644 --- a/mmv1/products/osconfigv2/PolicyOrchestratorForOrganization.yaml +++ b/mmv1/products/osconfigv2/PolicyOrchestratorForOrganization.yaml @@ -45,6 +45,8 @@ async: include_project: true autogen_status: UG9saWN5T3JjaGVzdHJhdG9yRm9yT3JnYW5pemF0aW9u autogen_async: true +custom_diff: + - policyOrchestratorCustomizeDiff samples: - name: osconfigv2_policy_orchestrator_for_organization_basic primary_resource_id: policy_orchestrator_for_organization diff --git a/mmv1/templates/terraform/constants/osconfigv2_policy_orchestrator.go.tmpl b/mmv1/templates/terraform/constants/osconfigv2_policy_orchestrator.go.tmpl new file mode 100644 index 000000000000..1d4f0799e1ff --- /dev/null +++ b/mmv1/templates/terraform/constants/osconfigv2_policy_orchestrator.go.tmpl @@ -0,0 +1,38 @@ +func policyOrchestratorCustomizeDiff(_ context.Context, diff *schema.ResourceDiff, _ any) error { + v, ok := diff.GetOk("orchestration_scope.0.selectors") + if !ok { + return nil + } + selectors, ok := v.([]any) + if !ok { + return nil + } + for _, rawSelector := range selectors { + if rawSelector == nil { + continue + } + selector, ok := rawSelector.(map[string]any) + if !ok { + continue + } + + hasLocation := false + if val, ok := selector["location_selector"]; ok && val != nil { + if list, ok := val.([]any); ok && len(list) > 0 { + hasLocation = true + } + } + + hasHierarchy := false + if val, ok := selector["resource_hierarchy_selector"]; ok && val != nil { + if list, ok := val.([]any); ok && len(list) > 0 { + hasHierarchy = true + } + } + + if hasLocation && hasHierarchy { + return fmt.Errorf("only one of orchestration_scope.selectors.location_selector or orchestration_scope.selectors.resource_hierarchy_selector can be set") + } + } + return nil +} diff --git a/mmv1/third_party/terraform/services/osconfigv2/resource_os_config_v2_policy_orchestrator_test.go b/mmv1/third_party/terraform/services/osconfigv2/resource_os_config_v2_policy_orchestrator_test.go index 03e24d94ce7e..926184900a20 100644 --- a/mmv1/third_party/terraform/services/osconfigv2/resource_os_config_v2_policy_orchestrator_test.go +++ b/mmv1/third_party/terraform/services/osconfigv2/resource_os_config_v2_policy_orchestrator_test.go @@ -1,6 +1,7 @@ package osconfigv2_test import ( + "regexp" "testing" "github.com/hashicorp/terraform-plugin-testing/helper/resource" @@ -150,3 +151,75 @@ resource "google_os_config_v2_policy_orchestrator" "policy_orchestrator" { } `, context) } + +func TestAccOSConfigV2PolicyOrchestrator_conflictingSelectors(t *testing.T) { + t.Parallel() + + context := map[string]interface{}{ + "random_suffix": acctest.RandString(t, 10), + } + + acctest.VcrTest(t, resource.TestCase{ + PreCheck: func() { acctest.AccTestPreCheck(t) }, + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories(t), + Steps: []resource.TestStep{ + { + Config: testAccOSConfigV2PolicyOrchestrator_conflictingSelectors(context), + ExpectError: regexp.MustCompile("only one of orchestration_scope.selectors.location_selector or orchestration_scope.selectors.resource_hierarchy_selector can be set"), + }, + }, + }) +} + +func testAccOSConfigV2PolicyOrchestrator_conflictingSelectors(context map[string]interface{}) string { + return acctest.Nprintf(` +resource "google_os_config_v2_policy_orchestrator" "policy_orchestrator" { + policy_orchestrator_id = "tf-test-test-po%{random_suffix}" + + state = "ACTIVE" + action = "UPSERT" + + orchestration_scope { + selectors { + location_selector { + included_locations = ["us-central1-a"] + } + resource_hierarchy_selector { + included_folders = ["folders/12345"] + } + } + } + + orchestrated_resource { + id = "tf-test-test-orchestrated-resource%{random_suffix}" + os_policy_assignment_v1_payload { + os_policies { + id = "tf-test-os-policy%{random_suffix}" + mode = "VALIDATION" + resource_groups { + resources { + id = "resource-tf" + file { + content = "file-content-tf" + path = "file-path-tf-1" + state = "PRESENT" + } + } + } + } + instance_filter { + inventories { + os_short_name = "windows-10" + } + } + rollout { + disruption_budget { + percent = 100 + } + min_wait_duration = "60s" + } + } + } +} +`, context) +} From 193e8da094a20ca4c90695c84771b4d8b0012350 Mon Sep 17 00:00:00 2001 From: guvenenb Date: Tue, 21 Jul 2026 00:46:56 +0200 Subject: [PATCH 13/30] vertexai: fixed TestAccVertexAIReasoningEngine_apiParity ga failures (#18334) --- ...> resource_vertex_ai_reasoning_engine_test.go.tmpl} | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) rename mmv1/third_party/terraform/services/vertexai/{resource_vertex_ai_reasoning_engine_test.go => resource_vertex_ai_reasoning_engine_test.go.tmpl} (99%) diff --git a/mmv1/third_party/terraform/services/vertexai/resource_vertex_ai_reasoning_engine_test.go b/mmv1/third_party/terraform/services/vertexai/resource_vertex_ai_reasoning_engine_test.go.tmpl similarity index 99% rename from mmv1/third_party/terraform/services/vertexai/resource_vertex_ai_reasoning_engine_test.go rename to mmv1/third_party/terraform/services/vertexai/resource_vertex_ai_reasoning_engine_test.go.tmpl index 14971e540fdb..f21b4c3e331a 100644 --- a/mmv1/third_party/terraform/services/vertexai/resource_vertex_ai_reasoning_engine_test.go +++ b/mmv1/third_party/terraform/services/vertexai/resource_vertex_ai_reasoning_engine_test.go.tmpl @@ -714,6 +714,7 @@ resource "google_vertex_ai_reasoning_engine" "reasoning_engine" { `, context) } +{{- if ne $.TargetVersionName "ga" }} func TestAccVertexAIReasoningEngine_apiParity(t *testing.T) { t.Parallel() @@ -723,7 +724,7 @@ func TestAccVertexAIReasoningEngine_apiParity(t *testing.T) { acctest.VcrTest(t, resource.TestCase{ PreCheck: func() { acctest.AccTestPreCheck(t) }, - ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories(t), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderBetaFactories(t), CheckDestroy: testAccCheckVertexAIEndpointDestroyProducer(t), Steps: []resource.TestStep{ { @@ -735,9 +736,12 @@ func TestAccVertexAIReasoningEngine_apiParity(t *testing.T) { func testAccVertexAIReasoningEngine_apiParity(context map[string]interface{}) string { return acctest.Nprintf(` -data "google_project" "project" {} +data "google_project" "project" { + provider = google-beta +} resource "google_cloudbuild_worker_pool" "pool" { + provider = google-beta name = "pool-%{random_suffix}" location = "us-central1" worker_config { @@ -748,6 +752,7 @@ resource "google_cloudbuild_worker_pool" "pool" { } resource "google_vertex_ai_reasoning_engine" "primary" { + provider = google-beta display_name = "tf-test-reasoning-engine-%{random_suffix}" description = "Reasoning engine testing API parity fields" region = "us-central1" @@ -790,6 +795,7 @@ resource "google_vertex_ai_reasoning_engine" "primary" { } `, context) } +{{- end }} func TestAccVertexAIReasoningEngine_apiParityExternal(t *testing.T) { t.Skip("Skipping agent_gateway and runtime_revision_name due to external infrastructure dependencies") From 45d742e25aff6bb1af5548a8f3a94fc1cd45f3db Mon Sep 17 00:00:00 2001 From: "Stephen Lewis (Burrows)" Date: Mon, 20 Jul 2026 15:47:21 -0700 Subject: [PATCH 14/30] Removed note about ignore_read not working on nested fields (#18340) --- docs/content/develop/diffs.md | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/docs/content/develop/diffs.md b/docs/content/develop/diffs.md index b8ae5725b3b0..4cd1bd2c5a0d 100644 --- a/docs/content/develop/diffs.md +++ b/docs/content/develop/diffs.md @@ -181,27 +181,11 @@ In the flattener for the field, return the value of the field in the user's conf {{% tabs "ignore_read" %}} {{< tab "MMv1" >}} -On top-level fields, this can be done with: +Set: ```yaml ignore_read: true ``` - -For nested fields, `ignore_read` is [not currently supported](https://github.com/hashicorp/terraform-provider-google/issues/12410), so this must be implemented with a [custom flattener]({{< ref "/develop/custom-code#custom_flatten" >}}). You will also need to add the field to `ignore_read_extra` on any examples that are used to generate tests; this will cause tests to ignore the field when checking that the values in the API match the user's configuration. - -```go -func flatten{{$.GetPrefix}}{{$.TitlelizeProperty}}(v interface{}, d *schema.ResourceData, config *transport_tpg.Config) interface{} { - // We want to ignore read on this field, but cannot because it is nested - return d.Get("path.0.to.0.nested.0.field") -} -``` - -```yaml -examples: - # example configuration - ignore_read_extra: - - "path.0.to.0.nested.0.field" -``` {{< /tab >}} {{< tab "Handwritten" >}} Use `d.Get` to set the flattened value to be the same as the user-configured value (instead of a value from the API). From 4c123521c4fac00d40aea1bb2e8090ce3a7a8056 Mon Sep 17 00:00:00 2001 From: "Stephen Lewis (Burrows)" Date: Tue, 21 Jul 2026 08:34:09 -0700 Subject: [PATCH 15/30] Update enrolled_teams.yml (#18342) --- tools/issue-labeler/labeler/enrolled_teams.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tools/issue-labeler/labeler/enrolled_teams.yml b/tools/issue-labeler/labeler/enrolled_teams.yml index ad97f6873900..9bc9cc174805 100755 --- a/tools/issue-labeler/labeler/enrolled_teams.yml +++ b/tools/issue-labeler/labeler/enrolled_teams.yml @@ -13,6 +13,9 @@ service/agentidentity: service/agentregistry: resources: - google_agent_registry_.* +service/aiplatform-agent-anomaly-detection: + resources: + - google_vertex_ai_agent_anomaly_detection_.* service/aiplatform-agent-engine: resources: - google_vertex_ai_reasoning_engine.* @@ -196,6 +199,7 @@ service/compute-commitment: resources: - google_compute_region_commitment.* service/compute-health: + team: compute-health resources: - google_compute_health_check - google_compute_http_health_check @@ -330,6 +334,9 @@ service/compute-vpn: - google_compute_ha_vpn_gateway - google_compute_external_vpn_gateway - google_compute_vpn_.* +service/config: + resources: + - google_config_deployment service/contactcenterinsights: resources: - google_contact_center_insights_.* @@ -448,6 +455,7 @@ service/firebase: - google_firebase_storage.* - google_firebase_web.* - google_firebaserules_.* + - google_firebase_remote_config_.* service/firebaseailogic: team: firebase-terraform resources: From d98deb26119a055605b4b4f808eaa92ae7b7ec80 Mon Sep 17 00:00:00 2001 From: Nandini Agrawal Date: Tue, 21 Jul 2026 21:50:06 +0530 Subject: [PATCH 16/30] Add effective-location field to IC Resource. (#17951) --- mmv1/products/compute/Interconnect.yaml | 10 +++++-- .../compute_interconnect_basic.tf.tmpl | 2 +- .../compute_interconnect_basic_test.tf.tmpl | 2 +- .../terraform/schema_property.go.tmpl | 2 +- ...source_compute_interconnect_macsec_test.go | 29 +++++++++++++++++-- 5 files changed, 38 insertions(+), 7 deletions(-) diff --git a/mmv1/products/compute/Interconnect.yaml b/mmv1/products/compute/Interconnect.yaml index e0eb25b5f39b..a1d9115d1229 100644 --- a/mmv1/products/compute/Interconnect.yaml +++ b/mmv1/products/compute/Interconnect.yaml @@ -76,11 +76,17 @@ properties: type: ResourceRef required: true description: | - URL of the InterconnectLocation object that represents where this connection is to be provisioned. + URL of the InterconnectLocation object that represents where this connection is requested to be provisioned. Specifies the location inside Google's Networks. - immutable: true resource: InterconnectLocations imports: selfLink + - name: effectiveLocation + type: ResourceRef + resource: InterconnectLocations + description: | + URL of the InterconnectLocation object that represents where this connection is to be provisioned. + Specifies the location inside Google's Networks. + output: true - name: linkType type: Enum required: true diff --git a/mmv1/templates/terraform/samples/services/compute/compute_interconnect_basic.tf.tmpl b/mmv1/templates/terraform/samples/services/compute/compute_interconnect_basic.tf.tmpl index f71517bada30..42c0ff91266d 100644 --- a/mmv1/templates/terraform/samples/services/compute/compute_interconnect_basic.tf.tmpl +++ b/mmv1/templates/terraform/samples/services/compute/compute_interconnect_basic.tf.tmpl @@ -5,6 +5,6 @@ resource "google_compute_interconnect" "{{$.PrimaryResourceId}}" { customer_name = "{{index $.ResourceIdVars "customer_name"}}" interconnect_type = "DEDICATED" link_type = "LINK_TYPE_ETHERNET_10G_LR" - location = "https://www.googleapis.com/compute/v1/${data.google_project.project.id}/global/interconnectLocations/iad-zone1-1" + location = "https://www.googleapis.com/compute/v1/projects/${data.google_project.project.project_id}/global/interconnectLocations/iad-zone1-1" requested_link_count = 1 } diff --git a/mmv1/templates/terraform/samples/services/compute/compute_interconnect_basic_test.tf.tmpl b/mmv1/templates/terraform/samples/services/compute/compute_interconnect_basic_test.tf.tmpl index a01989dd017d..37595d726ea0 100644 --- a/mmv1/templates/terraform/samples/services/compute/compute_interconnect_basic_test.tf.tmpl +++ b/mmv1/templates/terraform/samples/services/compute/compute_interconnect_basic_test.tf.tmpl @@ -5,7 +5,7 @@ resource "google_compute_interconnect" "{{$.PrimaryResourceId}}" { customer_name = "internal_customer" # Special customer only available for Google testing. interconnect_type = "DEDICATED" link_type = "LINK_TYPE_ETHERNET_10G_LR" - location = "https://www.googleapis.com/compute/v1/${data.google_project.project.id}/global/interconnectLocations/z2z-us-east4-zone1-lciadl-a" # Special location only available for Google testing. + location = "https://www.googleapis.com/compute/v1/projects/${data.google_project.project.project_id}/global/interconnectLocations/z2z-us-east4-zone1-lciadl-a" # Special location only available for Google testing. requested_link_count = 1 admin_enabled = true description = "example description" diff --git a/mmv1/templates/terraform/schema_property.go.tmpl b/mmv1/templates/terraform/schema_property.go.tmpl index ccb99efc1b52..1cd0e0329436 100644 --- a/mmv1/templates/terraform/schema_property.go.tmpl +++ b/mmv1/templates/terraform/schema_property.go.tmpl @@ -52,7 +52,7 @@ {{ end -}} {{ if .DiffSuppressFunc -}} DiffSuppressFunc: {{ .DiffSuppressFunc }}, -{{ else if eq .Type "ResourceRef" -}} +{{ else if and (eq .Type "ResourceRef") (not .Output) -}} DiffSuppressFunc: tpgresource.CompareSelfLinkOrResourceName, {{ end -}} {{ if .StateFunc -}} diff --git a/mmv1/third_party/terraform/services/compute/resource_compute_interconnect_macsec_test.go b/mmv1/third_party/terraform/services/compute/resource_compute_interconnect_macsec_test.go index 76455b9f703e..c019d50ddddf 100644 --- a/mmv1/third_party/terraform/services/compute/resource_compute_interconnect_macsec_test.go +++ b/mmv1/third_party/terraform/services/compute/resource_compute_interconnect_macsec_test.go @@ -1,6 +1,7 @@ package compute_test import ( + "regexp" "testing" "github.com/hashicorp/terraform-plugin-testing/helper/resource" @@ -42,6 +43,10 @@ func TestAccComputeInterconnect_computeInterconnectMacsecTest(t *testing.T) { ImportStateVerify: true, ImportStateVerifyIgnore: []string{"labels", "location", "terraform_labels"}, }, + { + Config: testAccComputeInterconnect_computeInterconnectChangeLocation(context), + ExpectError: regexp.MustCompile("The location field must match the effectiveLocation field on patch"), + }, }, }) } @@ -55,7 +60,7 @@ resource "google_compute_interconnect" "example-interconnect" { customer_name = "internal_customer" # Special customer only available for Google testing. interconnect_type = "DEDICATED" link_type = "LINK_TYPE_ETHERNET_100G_LR" - location = "https://www.googleapis.com/compute/v1/projects/${data.google_project.project.name}/global/interconnectLocations/z2z-us-east4-zone1-lciadl-z" # Special location only available for Google testing. + location = "https://www.googleapis.com/compute/v1/projects/${data.google_project.project.project_id}/global/interconnectLocations/z2z-us-east4-zone1-lciadl-z" # Special location only available for Google testing. requested_link_count = 1 admin_enabled = true description = "example description" @@ -78,7 +83,7 @@ resource "google_compute_interconnect" "example-interconnect" { customer_name = "internal_customer" # Special customer only available for Google testing. interconnect_type = "DEDICATED" link_type = "LINK_TYPE_ETHERNET_100G_LR" - location = "https://www.googleapis.com/compute/v1/projects/${data.google_project.project.name}/global/interconnectLocations/z2z-us-east4-zone1-lciadl-z" # Special location only available for Google testing. + location = "https://www.googleapis.com/compute/v1/projects/${data.google_project.project.project_id}/global/interconnectLocations/z2z-us-east4-zone1-lciadl-z" # Special location only available for Google testing. requested_link_count = 1 admin_enabled = true description = "example description" @@ -158,3 +163,23 @@ resource "google_compute_interconnect" "example-interconnect" { } `, context) } + +func testAccComputeInterconnect_computeInterconnectChangeLocation(context map[string]interface{}) string { + return acctest.Nprintf(` +data "google_project" "project" {} + +resource "google_compute_interconnect" "example-interconnect" { + name = "tf-test-example-interconnect%{random_suffix}" + customer_name = "internal_customer" # Special customer only available for Google testing. + interconnect_type = "DEDICATED" + link_type = "LINK_TYPE_ETHERNET_100G_LR" + location = "https://www.googleapis.com/compute/v1/projects/${data.google_project.project.project_id}/global/interconnectLocations/z2z-us-east4-zone1-lciadl-a" # Changed to lciadl-a + requested_link_count = 1 + admin_enabled = true + description = "example description" + macsec_enabled = true + noc_contact_email = "user@example.com" + requested_features = ["IF_MACSEC"] +} +`, context) +} From 0a102452bb2dc4d6ed97ef815abf8587e309d035 Mon Sep 17 00:00:00 2001 From: "ajaysundar.k" Date: Tue, 21 Jul 2026 10:56:30 -0700 Subject: [PATCH 17/30] add support for node_readiness_config addon to google_container_cluster (#18285) --- .../resource_container_cluster.go.tmpl | 33 +++++++++ .../resource_container_cluster_meta.yaml.tmpl | 1 + ...source_container_cluster_migratev1.go.tmpl | 16 +++++ .../resource_container_cluster_test.go.tmpl | 71 +++++++++++++++++++ .../docs/r/container_cluster.html.markdown | 7 ++ .../container/resource_container_cluster.go | 17 +++++ .../resource_container_cluster_cai2hcl.go | 13 ++++ .../resource_container_cluster_tfplan2cai.go | 8 +++ 8 files changed, 166 insertions(+) diff --git a/mmv1/third_party/terraform/services/container/resource_container_cluster.go.tmpl b/mmv1/third_party/terraform/services/container/resource_container_cluster.go.tmpl index 913265796e8d..10a165354d6c 100644 --- a/mmv1/third_party/terraform/services/container/resource_container_cluster.go.tmpl +++ b/mmv1/third_party/terraform/services/container/resource_container_cluster.go.tmpl @@ -112,6 +112,7 @@ var ( "addons_config.0.slice_controller_config", "addons_config.0.pod_snapshot_config", "addons_config.0.slurm_operator_config", + "addons_config.0.node_readiness_config", "addons_config.0.agent_sandbox_config", {{- if ne $.TargetVersionName "ga" }} "addons_config.0.istio_config", @@ -711,6 +712,22 @@ func ResourceContainerCluster() *schema.Resource { }, }, }, + "node_readiness_config": { + Type: schema.TypeList, + Optional: true, + Computed: true, + AtLeastOneOf: addonsConfigKeys, + MaxItems: 1, + Description: `The status of the Node Readiness Controller addon.`, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "enabled": { + Type: schema.TypeBool, + Required: true, + }, + }, + }, + }, "agent_sandbox_config": { Type: schema.TypeList, Optional: true, @@ -6127,6 +6144,14 @@ func expandClusterAddonsConfig(configured interface{}) *container.AddonsConfig { } } + if v, ok := config["node_readiness_config"]; ok && len(v.([]interface{})) > 0 { + addon := v.([]interface{})[0].(map[string]interface{}) + ac.NodeReadinessConfig = &container.NodeReadinessConfig{ + Enabled: addon["enabled"].(bool), + ForceSendFields: []string{"Enabled"}, + } + } + if v, ok := config["agent_sandbox_config"]; ok && len(v.([]interface{})) > 0 { addon := v.([]interface{})[0].(map[string]interface{}) ac.AgentSandboxConfig = &container.AgentSandboxConfig{ @@ -7918,6 +7943,14 @@ func flattenClusterAddonsConfig(c *container.AddonsConfig) []map[string]interfac } } + if c.NodeReadinessConfig != nil { + result["node_readiness_config"] = []map[string]interface{}{ + { + "enabled": c.NodeReadinessConfig.Enabled, + }, + } + } + if c.AgentSandboxConfig != nil { result["agent_sandbox_config"] = []map[string]interface{}{ { diff --git a/mmv1/third_party/terraform/services/container/resource_container_cluster_meta.yaml.tmpl b/mmv1/third_party/terraform/services/container/resource_container_cluster_meta.yaml.tmpl index 131567ed1704..176695184635 100644 --- a/mmv1/third_party/terraform/services/container/resource_container_cluster_meta.yaml.tmpl +++ b/mmv1/third_party/terraform/services/container/resource_container_cluster_meta.yaml.tmpl @@ -45,6 +45,7 @@ fields: - api_field: 'addonsConfig.rayOperatorConfig.rayClusterLoggingConfig.enabled' - api_field: 'addonsConfig.rayOperatorConfig.rayClusterMonitoringConfig.enabled' - api_field: 'addonsConfig.slurmOperatorConfig.enabled' + - api_field: 'addonsConfig.nodeReadinessConfig.enabled' - api_field: 'addonsConfig.statefulHaConfig.enabled' - api_field: 'addonsConfig.sliceControllerConfig.enabled' - api_field: 'anonymousAuthenticationConfig.mode' diff --git a/mmv1/third_party/terraform/services/container/resource_container_cluster_migratev1.go.tmpl b/mmv1/third_party/terraform/services/container/resource_container_cluster_migratev1.go.tmpl index 6de1e03f7b51..88d3e4724650 100644 --- a/mmv1/third_party/terraform/services/container/resource_container_cluster_migratev1.go.tmpl +++ b/mmv1/third_party/terraform/services/container/resource_container_cluster_migratev1.go.tmpl @@ -248,6 +248,22 @@ func resourceContainerClusterResourceV1() *schema.Resource { }, }, }, + "node_readiness_config": { + Type: schema.TypeList, + Optional: true, + Computed: true, + AtLeastOneOf: addonsConfigKeys, + MaxItems: 1, + Description: `The status of the Node Readiness Controller addon.`, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "enabled": { + Type: schema.TypeBool, + Required: true, + }, + }, + }, + }, {{- if ne $.TargetVersionName "ga" }} "istio_config": { Type: schema.TypeList, diff --git a/mmv1/third_party/terraform/services/container/resource_container_cluster_test.go.tmpl b/mmv1/third_party/terraform/services/container/resource_container_cluster_test.go.tmpl index 0543a3eba0da..4d68be526204 100644 --- a/mmv1/third_party/terraform/services/container/resource_container_cluster_test.go.tmpl +++ b/mmv1/third_party/terraform/services/container/resource_container_cluster_test.go.tmpl @@ -19374,3 +19374,74 @@ resource "google_container_node_pool" "extra" { } `, suffix, suffix, clusterName, skipRefresh, poolName) } + +func TestAccContainerCluster_withNodeReadinessConfig(t *testing.T) { + t.Parallel() + + clusterName := fmt.Sprintf("tf-test-nrc-%s", acctest.RandString(t, 10)) + networkName := tpgcompute.BootstrapSharedTestNetwork(t, "gke-cluster") + subnetworkName := tpgcompute.BootstrapSubnet(t, "gke-cluster", networkName) + + acctest.VcrTest(t, resource.TestCase{ + PreCheck: func() { acctest.AccTestPreCheck(t) }, + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories(t), + CheckDestroy: testAccCheckContainerClusterDestroyProducer(t), + Steps: []resource.TestStep{ + { + Config: testAccContainerCluster_switchNodeReadinessConfig(clusterName, networkName, subnetworkName, true), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("google_container_cluster.with_nrc_config", "addons_config.0.node_readiness_config.0.enabled", "true"), + ), + }, + { + ResourceName: "google_container_cluster.with_nrc_config", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"deletion_protection"}, + }, + { + Config: testAccContainerCluster_switchNodeReadinessConfig(clusterName, networkName, subnetworkName, false), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("google_container_cluster.with_nrc_config", "addons_config.0.node_readiness_config.0.enabled", "false"), + ), + }, + { + ResourceName: "google_container_cluster.with_nrc_config", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"deletion_protection"}, + }, + { + Config: testAccContainerCluster_switchNodeReadinessConfig(clusterName, networkName, subnetworkName, true), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("google_container_cluster.with_nrc_config", "addons_config.0.node_readiness_config.0.enabled", "true"), + ), + }, + { + ResourceName: "google_container_cluster.with_nrc_config", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"deletion_protection"}, + }, + }, + }) +} + +func testAccContainerCluster_switchNodeReadinessConfig(clusterName, networkName, subnetworkName string, enabled bool) string { + return fmt.Sprintf(` +resource "google_container_cluster" "with_nrc_config" { + name = "%s" + location = "us-central1-a" + initial_node_count = 1 + network = "%s" + subnetwork = "%s" + deletion_protection = false + + addons_config { + node_readiness_config { + enabled = %t + } + } +} +`, clusterName, networkName, subnetworkName, enabled) +} diff --git a/mmv1/third_party/terraform/website/docs/r/container_cluster.html.markdown b/mmv1/third_party/terraform/website/docs/r/container_cluster.html.markdown index e2c3ddf79ef3..ab281424e3e3 100644 --- a/mmv1/third_party/terraform/website/docs/r/container_cluster.html.markdown +++ b/mmv1/third_party/terraform/website/docs/r/container_cluster.html.markdown @@ -574,6 +574,9 @@ Fleet configuration for the cluster. Structure is [documented below](#nested_fle Defaults to disabled for Standard clusters; set `enabled = true` to enable. It can not be enabled for Autopilot clusters. +* `node_readiness_config` - (Optional) The status of the Node Readiness Controller addon. It is disabled by default. Set `enabled = true` to enable. + Structure is [documented below](#nested_node_readiness_config). + This example `addons_config` disables two addons: ```hcl @@ -587,6 +590,10 @@ addons_config { } } ``` +The `node_readiness_config` block supports: + +* `enabled` - (Required) Enable the Node Readiness Controller addon for your cluster. + The `binary_authorization` block supports: * `enabled` - (DEPRECATED) Enable Binary Authorization for this cluster. Deprecated in favor of `evaluation_mode`. diff --git a/mmv1/third_party/tgc_next/pkg/services/container/resource_container_cluster.go b/mmv1/third_party/tgc_next/pkg/services/container/resource_container_cluster.go index 3ebec9ec00d1..b9ca13dd2dc2 100644 --- a/mmv1/third_party/tgc_next/pkg/services/container/resource_container_cluster.go +++ b/mmv1/third_party/tgc_next/pkg/services/container/resource_container_cluster.go @@ -118,6 +118,7 @@ var ( "addons_config.0.slice_controller_config", "addons_config.0.pod_snapshot_config", "addons_config.0.slurm_operator_config", + "addons_config.0.node_readiness_config", } privateClusterConfigKeys = []string{ @@ -658,6 +659,22 @@ func ResourceContainerCluster() *schema.Resource { }, }, }, + "node_readiness_config": { + Type: schema.TypeList, + Optional: true, + Computed: true, + AtLeastOneOf: addonsConfigKeys, + MaxItems: 1, + Description: `The status of the Node Readiness Controller addon.`, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "enabled": { + Type: schema.TypeBool, + Required: true, + }, + }, + }, + }, }, }, }, diff --git a/mmv1/third_party/tgc_next/pkg/services/container/resource_container_cluster_cai2hcl.go b/mmv1/third_party/tgc_next/pkg/services/container/resource_container_cluster_cai2hcl.go index aac0edc0ab34..94e534561057 100644 --- a/mmv1/third_party/tgc_next/pkg/services/container/resource_container_cluster_cai2hcl.go +++ b/mmv1/third_party/tgc_next/pkg/services/container/resource_container_cluster_cai2hcl.go @@ -642,6 +642,19 @@ func flattenClusterAddonsConfig(v interface{}, enableAutopilot bool) []map[strin } } + if val, ok := c["nodeReadinessConfig"].(map[string]interface{}); ok { + enabled := false + if v, ok := val["enabled"]; ok && v != nil { + enabled = v.(bool) + } + + result["node_readiness_config"] = []map[string]interface{}{ + { + "enabled": enabled, + }, + } + } + return []map[string]interface{}{result} } diff --git a/mmv1/third_party/tgc_next/pkg/services/container/resource_container_cluster_tfplan2cai.go b/mmv1/third_party/tgc_next/pkg/services/container/resource_container_cluster_tfplan2cai.go index c26c956aa2ca..d28c4b0b3079 100644 --- a/mmv1/third_party/tgc_next/pkg/services/container/resource_container_cluster_tfplan2cai.go +++ b/mmv1/third_party/tgc_next/pkg/services/container/resource_container_cluster_tfplan2cai.go @@ -506,6 +506,14 @@ func expandClusterAddonsConfig(configured interface{}) *container.AddonsConfig { } } + if v, ok := config["node_readiness_config"]; ok && len(v.([]interface{})) > 0 { + addon := v.([]interface{})[0].(map[string]interface{}) + ac.NodeReadinessConfig = &container.NodeReadinessConfig{ + Enabled: addon["enabled"].(bool), + ForceSendFields: []string{"Enabled"}, + } + } + return ac } From 3dc409222b873a58e7cabbe79760bd70afb4708d Mon Sep 17 00:00:00 2001 From: malhotrasagar2212 Date: Tue, 21 Jul 2026 15:14:46 -0400 Subject: [PATCH 18/30] secretmanager: fixed secret_data/secret_data_wo constraint allowing neither to be set (#18320) --- .../products/secretmanager/SecretVersion.yaml | 10 ++--- ...urce_secret_manager_secret_version_test.go | 38 +++++++++++++++++++ 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/mmv1/products/secretmanager/SecretVersion.yaml b/mmv1/products/secretmanager/SecretVersion.yaml index 01b7b9fdf815..1d30b3c83acc 100644 --- a/mmv1/products/secretmanager/SecretVersion.yaml +++ b/mmv1/products/secretmanager/SecretVersion.yaml @@ -202,8 +202,9 @@ properties: type: String description: The secret data. Must be no larger than 64KiB. api_name: data - conflicts: - - 'secretDataWo' + exactly_one_of: + - 'payload.0.secretData' + - 'payload.0.secretDataWo' immutable: true sensitive: true is_missing_in_cai: true @@ -211,10 +212,9 @@ properties: type: String description: The secret data. Must be no larger than 64KiB. For more info see [updating write-only arguments](/docs/providers/google/guides/using_write_only_arguments.html#updating-write-only-arguments) api_name: data - required_with: - - 'SecretDataWoVersion' - conflicts: + exactly_one_of: - 'payload.0.secretData' + - 'payload.0.secretDataWo' write_only_legacy: true - name: 'SecretDataWoVersion' type: Integer diff --git a/mmv1/third_party/terraform/services/secretmanager/resource_secret_manager_secret_version_test.go b/mmv1/third_party/terraform/services/secretmanager/resource_secret_manager_secret_version_test.go index 5453e47e9893..47923a661b06 100644 --- a/mmv1/third_party/terraform/services/secretmanager/resource_secret_manager_secret_version_test.go +++ b/mmv1/third_party/terraform/services/secretmanager/resource_secret_manager_secret_version_test.go @@ -1,6 +1,7 @@ package secretmanager_test import ( + "regexp" "testing" "github.com/hashicorp/terraform-plugin-testing/helper/resource" @@ -78,6 +79,43 @@ func TestAccSecretManagerSecretVersion_byName(t *testing.T) { }) } +func TestAccSecretManagerSecretVersion_neitherSecretDataSet(t *testing.T) { + acctest.SkipIfVcr(t) + t.Parallel() + + context := map[string]interface{}{ + "random_suffix": acctest.RandString(t, 10), + } + + acctest.VcrTest(t, resource.TestCase{ + PreCheck: func() { acctest.AccTestPreCheck(t) }, + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories(t), + Steps: []resource.TestStep{ + { + Config: testAccSecretManagerSecretVersion_noSecretData(context), + ExpectError: regexp.MustCompile(`Invalid combination of arguments`), + }, + }, + }) +} + +func testAccSecretManagerSecretVersion_noSecretData(context map[string]interface{}) string { + return acctest.Nprintf(` +resource "google_secret_manager_secret" "secret-basic" { + secret_id = "tf-test-secret-version-%{random_suffix}" + + replication { + auto {} + } +} + +resource "google_secret_manager_secret_version" "secret-version-basic" { + secret = google_secret_manager_secret.secret-basic.name + secret_data_wo_version = 3 +} +`, context) +} + func testAccSecretManagerSecretVersion_basic(context map[string]interface{}) string { return acctest.Nprintf(` resource "google_secret_manager_secret" "secret-basic" { From b39f631be4094913d5c853d86a524116e93e30c8 Mon Sep 17 00:00:00 2001 From: Sam Levenick Date: Tue, 21 Jul 2026 14:20:11 -0700 Subject: [PATCH 19/30] Fix hash function for security policy (#18336) --- .../resource_compute_security_policy.go.tmpl | 87 ++- ...ource_compute_security_policy_test.go.tmpl | 595 +++++++++++++++++- .../data/example_compute_security_policy.json | 16 +- 3 files changed, 670 insertions(+), 28 deletions(-) diff --git a/mmv1/third_party/terraform/services/compute/resource_compute_security_policy.go.tmpl b/mmv1/third_party/terraform/services/compute/resource_compute_security_policy.go.tmpl index da2bada78a4c..a68bb3a55d53 100644 --- a/mmv1/third_party/terraform/services/compute/resource_compute_security_policy.go.tmpl +++ b/mmv1/third_party/terraform/services/compute/resource_compute_security_policy.go.tmpl @@ -1,11 +1,12 @@ package compute import ( - "bytes" "context" + "encoding/json" "fmt" "log" "reflect" + "sort" "strings" "time" @@ -755,19 +756,79 @@ func resourceComputeSecurityPolicyRulePreconfiguredWafConfigExclusionFieldParams } } -// resourceComputeSecurityPolicyRuleHash hashes security policy rules by priority and action. -// Using only priority causes hash collisions when two rules share the same priority (which -// the API rejects, but which users may write transiently), causing schema.Set to silently -// drop one rule and bypass the duplicate-priority validation in rulesCustomizeDiff. -// Including action ensures distinct rules always produce distinct hashes while still -// excluding volatile optional fields (description, preview, match sub-fields) that the -// API may return as empty strings instead of nil, preventing unnecessary rule recreation. +// resourceComputeSecurityPolicyRuleHash computes a deterministic hash of all configured rule attributes. +// It normalizes empty/default values and sorts string collections to prevent permadiffs and unnecessary +// rule recreation from API return defaults while ensuring any configuration change produces a distinct hash. func resourceComputeSecurityPolicyRuleHash(v interface{}) int { - raw := v.(map[string]interface{}) - var buf bytes.Buffer - buf.WriteString(fmt.Sprintf("%d-", raw["priority"].(int))) - buf.WriteString(fmt.Sprintf("%s-", raw["action"].(string))) - return tpgresource.Hashcode(buf.String()) + norm := normalizeRuleForHash(v) + b, err := json.Marshal(norm) + if err != nil { + return 0 + } + return tpgresource.Hashcode(string(b)) +} + +func normalizeRuleForHash(v interface{}) interface{} { + if v == nil { + return nil + } + switch val := v.(type) { + case *schema.Set: + return normalizeRuleForHash(val.List()) + case []interface{}: + var list []interface{} + for _, item := range val { + norm := normalizeRuleForHash(item) + if norm != nil { + list = append(list, norm) + } + } + if len(list) == 0 { + return nil + } + if isStringSlice(list) { + strs := make([]string, len(list)) + for i, s := range list { + strs[i] = s.(string) + } + sort.Strings(strs) + return strs + } + return list + case map[string]interface{}: + m := make(map[string]interface{}) + for k, item := range val { + norm := normalizeRuleForHash(item) + if norm != nil { + m[k] = norm + } + } + if len(m) == 0 { + return nil + } + return m + case string: + if val == "" { + return nil + } + return val + case bool: + if !val { + return nil + } + return val + default: + return val + } +} + +func isStringSlice(list []interface{}) bool { + for _, item := range list { + if _, ok := item.(string); !ok { + return false + } + } + return true } func rulesCustomizeDiff(_ context.Context, diff *schema.ResourceDiff, _ interface{}) error { diff --git a/mmv1/third_party/terraform/services/compute/resource_compute_security_policy_test.go.tmpl b/mmv1/third_party/terraform/services/compute/resource_compute_security_policy_test.go.tmpl index 6db9b10ee4df..f8e6662f482c 100644 --- a/mmv1/third_party/terraform/services/compute/resource_compute_security_policy_test.go.tmpl +++ b/mmv1/third_party/terraform/services/compute/resource_compute_security_policy_test.go.tmpl @@ -5,13 +5,14 @@ import ( "regexp" "testing" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/plancheck" "github.com/hashicorp/terraform-plugin-testing/terraform" "github.com/hashicorp/terraform-provider-google/google/acctest" - _ "github.com/hashicorp/terraform-provider-google/google/services/compute" - _ "github.com/hashicorp/terraform-provider-google/google/services/recaptchaenterprise" "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/recaptchaenterprise" "github.com/hashicorp/terraform-provider-google/google/tpgresource" transport_tpg "github.com/hashicorp/terraform-provider-google/google/transport" ) @@ -189,7 +190,7 @@ func TestAccComputeSecurityPolicy_update(t *testing.T) { }, { - Config: testAccComputeSecurityPolicy_updateSamePriority(spName), + Config: testAccComputeSecurityPolicy_updateSamePriority(spName), ExpectError: regexp.MustCompile("Two rules have the same priority, please update one of the priorities to be different."), }, @@ -315,7 +316,6 @@ func TestAccComputeSecurityPolicy_withAdvancedOptionsConfig(t *testing.T) { ImportState: true, ImportStateVerify: true, }, - }, }) } @@ -441,6 +441,7 @@ func TestAccComputeSecurityPolicy_withAdaptiveProtectionAutoDeployConfig(t *test }, }) } + {{- end }} func TestAccComputeSecurityPolicy_withRateLimitOptions(t *testing.T) { @@ -547,7 +548,6 @@ func TestAccComputeSecurityPolicy_withRateLimitOption_withMultipleEnforceOnKeyCo }) } - func TestAccComputeSecurityPolicy_EnforceOnKeyUpdates(t *testing.T) { t.Parallel() @@ -687,7 +687,6 @@ func TestAccComputeSecurityPolicy_ruleActionUpdate(t *testing.T) { }) } - func TestAccComputeSecurityPolicy_withHeadAction(t *testing.T) { t.Parallel() @@ -771,7 +770,7 @@ func TestAccComputeSecurityPolicy_withExprOptions(t *testing.T) { func TestAccComputeSecurityPolicy_modifyExprOptions(t *testing.T) { t.Parallel() - spName := fmt.Sprintf("tf-test-%s", acctest.RandString(t, 10)) + spName := fmt.Sprintf("tf-test-%s", acctest.RandString(t, 10)) acctest.VcrTest(t, resource.TestCase{ PreCheck: func() { acctest.AccTestPreCheck(t) }, @@ -1780,6 +1779,7 @@ resource "google_compute_security_policy" "policy" { } `, spName) } + {{- end }} func testAccComputeSecurityPolicy_withRateLimitOptions(spName string) string { @@ -2456,3 +2456,584 @@ resource "google_compute_security_policy" "policy" { } `, spName) } + +func TestAccComputeSecurityPolicy_inlineRuleUpdate(t *testing.T) { + t.Parallel() + + spName := fmt.Sprintf("tf-test-%s", acctest.RandString(t, 10)) + + acctest.VcrTest(t, resource.TestCase{ + PreCheck: func() { acctest.AccTestPreCheck(t) }, + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories(t), + CheckDestroy: testAccCheckComputeSecurityPolicyDestroyProducer(t), + Steps: []resource.TestStep{ + { + Config: testAccComputeSecurityPolicy_inlineRuleUpdate_step1(spName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckTypeSetElemNestedAttrs("google_compute_security_policy.policy", "rule.*", map[string]string{ + "priority": "1000", + "action": "allow", + "description": "initial description", + "match.0.config.0.src_ip_ranges.#": "1", + }), + ), + }, + { + ResourceName: "google_compute_security_policy.policy", + ImportState: true, + ImportStateVerify: true, + }, + { + Config: testAccComputeSecurityPolicy_inlineRuleUpdate_step2(spName), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("google_compute_security_policy.policy", plancheck.ResourceActionUpdate), + }, + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckTypeSetElemNestedAttrs("google_compute_security_policy.policy", "rule.*", map[string]string{ + "priority": "1000", + "action": "allow", + "description": "updated description", + "match.0.config.0.src_ip_ranges.#": "2", + }), + ), + }, + }, + }) +} + +func testAccComputeSecurityPolicy_inlineRuleUpdate_step1(spName string) string { + return fmt.Sprintf(` +resource "google_compute_security_policy" "policy" { + name = "%s" + description = "test policy" + + rule { + action = "allow" + priority = "2147483647" + match { + versioned_expr = "SRC_IPS_V1" + config { + src_ip_ranges = ["*"] + } + } + description = "default rule" + } + + rule { + action = "allow" + priority = "1000" + description = "initial description" + match { + versioned_expr = "SRC_IPS_V1" + config { + src_ip_ranges = ["9.9.9.0/24"] + } + } + } +} +`, spName) +} + +func testAccComputeSecurityPolicy_inlineRuleUpdate_step2(spName string) string { + return fmt.Sprintf(` +resource "google_compute_security_policy" "policy" { + name = "%s" + description = "test policy" + + rule { + action = "allow" + priority = "2147483647" + match { + versioned_expr = "SRC_IPS_V1" + config { + src_ip_ranges = ["*"] + } + } + description = "default rule" + } + + rule { + action = "allow" + priority = "1000" + description = "updated description" + match { + versioned_expr = "SRC_IPS_V1" + config { + src_ip_ranges = ["9.9.9.0/24", "10.0.0.0/8"] + } + } + } +} +`, spName) +} + +func TestComputeSecurityPolicyRuleHash_nilAndEmptyCases(t *testing.T) { + ruleSchema := tpgcompute.ResourceComputeSecurityPolicy().Schema["rule"] + ruleSetFunc := ruleSchema.Set + + nilHash := ruleSetFunc(nil) + + emptyCases := map[string]interface{}{ + "nil": nil, + "typed nil map": (map[string]interface{})(nil), + "empty map": map[string]interface{}{}, + "map with nil values": map[string]interface{}{"description": nil, "priority": nil, "match": nil}, + "map with empty strings": map[string]interface{}{"description": "", "action": ""}, + "map with false bools": map[string]interface{}{"preview": false}, + "map with empty slice": map[string]interface{}{"match": []interface{}{}}, + "map with nested empty map in slice": map[string]interface{}{"match": []interface{}{map[string]interface{}{}}}, + "map with nested empty match configs": map[string]interface{}{"match": []interface{}{map[string]interface{}{"versioned_expr": "", "config": []interface{}{map[string]interface{}{"src_ip_ranges": []interface{}{}}}}}}, + "map with empty string slice element": map[string]interface{}{"match": []interface{}{map[string]interface{}{"config": []interface{}{map[string]interface{}{"src_ip_ranges": []interface{}{""}}}}}}, + "empty schema.Set": schema.NewSet(schema.HashResource(ruleSchema.Elem.(*schema.Resource)), []interface{}{}), + "schema.Set containing empty map": schema.NewSet(schema.HashResource(ruleSchema.Elem.(*schema.Resource)), []interface{}{map[string]interface{}{}}), + } + + for name, input := range emptyCases { + t.Run(name, func(t *testing.T) { + h := ruleSetFunc(input) + if h != nilHash { + t.Fatalf("expected hash for %s to equal nilHash %d, got %d", name, nilHash, h) + } + }) + } +} + +func TestComputeSecurityPolicyRuleHash_defaultAndZeroValueEquivalence(t *testing.T) { + ruleSchema := tpgcompute.ResourceComputeSecurityPolicy().Schema["rule"] + ruleSetFunc := ruleSchema.Set + + baseRule := map[string]interface{}{ + "priority": 1000, + "action": "allow", + } + baseHash := ruleSetFunc(baseRule) + + equivalentCases := map[string]map[string]interface{}{ + "explicit empty description": { + "priority": 1000, + "action": "allow", + "description": "", + }, + "nil description": { + "priority": 1000, + "action": "allow", + "description": nil, + }, + "explicit preview false": { + "priority": 1000, + "action": "allow", + "preview": false, + }, + "explicit empty match list": { + "priority": 1000, + "action": "allow", + "match": []interface{}{}, + }, + "nested empty match config": { + "priority": 1000, + "action": "allow", + "match": []interface{}{ + map[string]interface{}{ + "versioned_expr": "", + "config": []interface{}{}, + }, + }, + }, + "all zero/empty fields simultaneously present": { + "priority": 1000, + "action": "allow", + "description": "", + "preview": false, + "match": []interface{}{}, + "rate_limit_options": []interface{}{}, + "redirect_options": []interface{}{}, + "header_action": []interface{}{}, + "preconfigured_waf_config": []interface{}{}, + }, + } + + for name, rule := range equivalentCases { + t.Run(name, func(t *testing.T) { + h := ruleSetFunc(rule) + if h != baseHash { + t.Fatalf("expected hash for %s (%d) to match baseHash (%d)", name, h, baseHash) + } + }) + } +} + +func TestComputeSecurityPolicyRuleHash_inlineRuleModification(t *testing.T) { + ruleSchema := tpgcompute.ResourceComputeSecurityPolicy().Schema["rule"] + ruleSetFunc := ruleSchema.Set + + baseRule := map[string]interface{}{ + "priority": 1000, + "action": "allow", + "description": "initial description", + "preview": false, + "match": []interface{}{ + map[string]interface{}{ + "versioned_expr": "SRC_IPS_V1", + "config": []interface{}{ + map[string]interface{}{ + "src_ip_ranges": []interface{}{"9.9.9.0/24"}, + }, + }, + }, + }, + } + baseHash := ruleSetFunc(baseRule) + + testCases := map[string]struct { + modifiedRule map[string]interface{} + expectEqual bool + }{ + "description modification": { + modifiedRule: map[string]interface{}{ + "priority": 1000, + "action": "allow", + "description": "updated description", + "preview": false, + "match": []interface{}{ + map[string]interface{}{ + "versioned_expr": "SRC_IPS_V1", + "config": []interface{}{ + map[string]interface{}{ + "src_ip_ranges": []interface{}{"9.9.9.0/24"}, + }, + }, + }, + }, + }, + expectEqual: false, + }, + "action modification": { + modifiedRule: map[string]interface{}{ + "priority": 1000, + "action": "deny", + "description": "initial description", + "preview": false, + "match": []interface{}{ + map[string]interface{}{ + "versioned_expr": "SRC_IPS_V1", + "config": []interface{}{ + map[string]interface{}{ + "src_ip_ranges": []interface{}{"9.9.9.0/24"}, + }, + }, + }, + }, + }, + expectEqual: false, + }, + "priority modification": { + modifiedRule: map[string]interface{}{ + "priority": 2000, + "action": "allow", + "description": "initial description", + "preview": false, + "match": []interface{}{ + map[string]interface{}{ + "versioned_expr": "SRC_IPS_V1", + "config": []interface{}{ + map[string]interface{}{ + "src_ip_ranges": []interface{}{"9.9.9.0/24"}, + }, + }, + }, + }, + }, + expectEqual: false, + }, + "priority set to 0": { + modifiedRule: map[string]interface{}{ + "priority": 0, + "action": "allow", + "description": "initial description", + "preview": false, + "match": []interface{}{ + map[string]interface{}{ + "versioned_expr": "SRC_IPS_V1", + "config": []interface{}{ + map[string]interface{}{ + "src_ip_ranges": []interface{}{"9.9.9.0/24"}, + }, + }, + }, + }, + }, + expectEqual: false, + }, + "preview boolean toggle": { + modifiedRule: map[string]interface{}{ + "priority": 1000, + "action": "allow", + "description": "initial description", + "preview": true, + "match": []interface{}{ + map[string]interface{}{ + "versioned_expr": "SRC_IPS_V1", + "config": []interface{}{ + map[string]interface{}{ + "src_ip_ranges": []interface{}{"9.9.9.0/24"}, + }, + }, + }, + }, + }, + expectEqual: false, + }, + "versioned_expr modification": { + modifiedRule: map[string]interface{}{ + "priority": 1000, + "action": "allow", + "description": "initial description", + "preview": false, + "match": []interface{}{ + map[string]interface{}{ + "versioned_expr": "FIREWALL", + "config": []interface{}{ + map[string]interface{}{ + "src_ip_ranges": []interface{}{"9.9.9.0/24"}, + }, + }, + }, + }, + }, + expectEqual: false, + }, + "match expr expression added": { + modifiedRule: map[string]interface{}{ + "priority": 1000, + "action": "allow", + "description": "initial description", + "preview": false, + "match": []interface{}{ + map[string]interface{}{ + "expr": []interface{}{ + map[string]interface{}{ + "expression": "request.headers['host'].contains('test')", + }, + }, + }, + }, + }, + expectEqual: false, + }, + "number of src_ip_ranges increased": { + modifiedRule: map[string]interface{}{ + "priority": 1000, + "action": "allow", + "description": "initial description", + "preview": false, + "match": []interface{}{ + map[string]interface{}{ + "versioned_expr": "SRC_IPS_V1", + "config": []interface{}{ + map[string]interface{}{ + "src_ip_ranges": []interface{}{"9.9.9.0/24", "10.0.0.0/8"}, + }, + }, + }, + }, + }, + expectEqual: false, + }, + "src_ip_range value changed": { + modifiedRule: map[string]interface{}{ + "priority": 1000, + "action": "allow", + "description": "initial description", + "preview": false, + "match": []interface{}{ + map[string]interface{}{ + "versioned_expr": "SRC_IPS_V1", + "config": []interface{}{ + map[string]interface{}{ + "src_ip_ranges": []interface{}{"192.168.1.0/24"}, + }, + }, + }, + }, + }, + expectEqual: false, + }, + "rate_limit_options added": { + modifiedRule: map[string]interface{}{ + "priority": 1000, + "action": "allow", + "description": "initial description", + "preview": false, + "match": []interface{}{ + map[string]interface{}{ + "versioned_expr": "SRC_IPS_V1", + "config": []interface{}{ + map[string]interface{}{ + "src_ip_ranges": []interface{}{"9.9.9.0/24"}, + }, + }, + }, + }, + "rate_limit_options": []interface{}{ + map[string]interface{}{ + "conform_action": "allow", + "exceed_action": "deny(502)", + "rate_limit_threshold": []interface{}{ + map[string]interface{}{ + "count": 10, + "interval_sec": 60, + }, + }, + }, + }, + }, + expectEqual: false, + }, + "redirect_options added": { + modifiedRule: map[string]interface{}{ + "priority": 1000, + "action": "allow", + "description": "initial description", + "preview": false, + "match": []interface{}{ + map[string]interface{}{ + "versioned_expr": "SRC_IPS_V1", + "config": []interface{}{ + map[string]interface{}{ + "src_ip_ranges": []interface{}{"9.9.9.0/24"}, + }, + }, + }, + }, + "redirect_options": []interface{}{ + map[string]interface{}{ + "type": "EXTERNAL_302", + "target": "https://example.com", + }, + }, + }, + expectEqual: false, + }, + "header_action added": { + modifiedRule: map[string]interface{}{ + "priority": 1000, + "action": "allow", + "description": "initial description", + "preview": false, + "match": []interface{}{ + map[string]interface{}{ + "versioned_expr": "SRC_IPS_V1", + "config": []interface{}{ + map[string]interface{}{ + "src_ip_ranges": []interface{}{"9.9.9.0/24"}, + }, + }, + }, + }, + "header_action": []interface{}{ + map[string]interface{}{ + "request_headers_to_adds": []interface{}{ + map[string]interface{}{ + "header_name": "X-Custom-Header", + "header_value": "value", + }, + }, + }, + }, + }, + expectEqual: false, + }, + "preconfigured_waf_config added": { + modifiedRule: map[string]interface{}{ + "priority": 1000, + "action": "allow", + "description": "initial description", + "preview": false, + "match": []interface{}{ + map[string]interface{}{ + "versioned_expr": "SRC_IPS_V1", + "config": []interface{}{ + map[string]interface{}{ + "src_ip_ranges": []interface{}{"9.9.9.0/24"}, + }, + }, + }, + }, + "preconfigured_waf_config": []interface{}{ + map[string]interface{}{ + "exclusions": []interface{}{ + map[string]interface{}{ + "target_rule_set": "sqli-v33-stable", + }, + }, + }, + }, + }, + expectEqual: false, + }, + "src_ip_ranges with empty string filtered out should produce identical hash": { + modifiedRule: map[string]interface{}{ + "priority": 1000, + "action": "allow", + "description": "initial description", + "preview": false, + "match": []interface{}{ + map[string]interface{}{ + "versioned_expr": "SRC_IPS_V1", + "config": []interface{}{ + map[string]interface{}{ + "src_ip_ranges": []interface{}{"9.9.9.0/24", ""}, + }, + }, + }, + }, + }, + expectEqual: true, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + modHash := ruleSetFunc(tc.modifiedRule) + if tc.expectEqual && modHash != baseHash { + t.Fatalf("expected equal hash %d, got %d for case %q", baseHash, modHash, name) + } + if !tc.expectEqual && modHash == baseHash { + t.Fatalf("expected different hashes when %s, but both produced %d", name, baseHash) + } + }) + } + + // Specifically test that order of elements in a multi-element src_ip_ranges does not change hash + multiRange1 := map[string]interface{}{ + "priority": 1000, + "action": "allow", + "match": []interface{}{ + map[string]interface{}{ + "config": []interface{}{ + map[string]interface{}{ + "src_ip_ranges": []interface{}{"9.9.9.0/24", "10.0.0.0/8"}, + }, + }, + }, + }, + } + multiRange2 := map[string]interface{}{ + "priority": 1000, + "action": "allow", + "match": []interface{}{ + map[string]interface{}{ + "config": []interface{}{ + map[string]interface{}{ + "src_ip_ranges": []interface{}{"10.0.0.0/8", "9.9.9.0/24"}, + }, + }, + }, + }, + } + if h1, h2 := ruleSetFunc(multiRange1), ruleSetFunc(multiRange2); h1 != h2 { + t.Fatalf("expected identical hash for reordered IP ranges, got %d and %d", h1, h2) + } +} diff --git a/mmv1/third_party/tgc/tests/data/example_compute_security_policy.json b/mmv1/third_party/tgc/tests/data/example_compute_security_policy.json index b510426525ad..cb2b3327e8eb 100644 --- a/mmv1/third_party/tgc/tests/data/example_compute_security_policy.json +++ b/mmv1/third_party/tgc/tests/data/example_compute_security_policy.json @@ -12,30 +12,30 @@ "name": "my-policy", "rule": [ { - "action": "deny(403)", - "description": "Deny access to IPs in 9.9.9.0/24", + "action": "allow", + "description": "default rule", "match": { "config": { "srcIpRanges": [ - "9.9.9.0/24" + "*" ] }, "versionedExpr": "SRC_IPS_V1" }, - "priority": 1000 + "priority": 2147483647 }, { - "action": "allow", - "description": "default rule", + "action": "deny(403)", + "description": "Deny access to IPs in 9.9.9.0/24", "match": { "config": { "srcIpRanges": [ - "*" + "9.9.9.0/24" ] }, "versionedExpr": "SRC_IPS_V1" }, - "priority": 2147483647 + "priority": 1000 } ] } From 54fe8076326bbe4efd31438f995fb55f36e541e6 Mon Sep 17 00:00:00 2001 From: Ming Zhou Date: Tue, 21 Jul 2026 20:34:39 -0400 Subject: [PATCH 20/30] compute: add request_headers and response_headers to log_config on backend service (#18293) Signed-off-by: Ming Zhou --- mmv1/products/compute/BackendService.yaml | 26 ++++++++ .../compute/RegionBackendService.yaml | 26 ++++++++ ...ource_compute_backend_service_test.go.tmpl | 63 ++++++++++++++++++ ...ompute_region_backend_service_test.go.tmpl | 66 +++++++++++++++++++ 4 files changed, 181 insertions(+) diff --git a/mmv1/products/compute/BackendService.yaml b/mmv1/products/compute/BackendService.yaml index 1b0e8d45bba9..efd287e474c8 100644 --- a/mmv1/products/compute/BackendService.yaml +++ b/mmv1/products/compute/BackendService.yaml @@ -1652,6 +1652,32 @@ properties: For example: orca_load_report, tls.protocol item_type: type: String + - name: 'requestHeaders' + api_name: 'loggingHttpRequestHeaders' + type: Array + description: | + This field can only be specified if logging is enabled for this backend service and if the BackendService protocol is one of HTTP, HTTPS, HTTP2 and GRPC. Contains a list of request headers to be logged. + item_type: + type: NestedObject + properties: + - name: 'headerName' + type: String + description: | + The header name to match on for logging. + required: true + - name: 'responseHeaders' + api_name: 'loggingHttpResponseHeaders' + type: Array + description: | + This field can only be specified if logging is enabled for this backend service and if the BackendService protocol is one of HTTP, HTTPS, HTTP2 and GRPC. Contains a list of response headers to be logged. + item_type: + type: NestedObject + properties: + - name: 'headerName' + type: String + description: | + The header name to match on for logging. + required: true - name: 'serviceLbPolicy' type: String description: | diff --git a/mmv1/products/compute/RegionBackendService.yaml b/mmv1/products/compute/RegionBackendService.yaml index f9fe3c480567..a4ed71773193 100644 --- a/mmv1/products/compute/RegionBackendService.yaml +++ b/mmv1/products/compute/RegionBackendService.yaml @@ -1564,6 +1564,32 @@ properties: item_type: type: String default_from_api: true + - name: 'requestHeaders' + api_name: 'loggingHttpRequestHeaders' + type: Array + description: | + This field can only be specified if logging is enabled for this backend service and if the BackendService protocol is one of HTTP, HTTPS, HTTP2 and GRPC. Contains a list of request headers to be logged. + item_type: + type: NestedObject + properties: + - name: 'headerName' + type: String + description: | + The header name to match on for logging. + required: true + - name: 'responseHeaders' + api_name: 'loggingHttpResponseHeaders' + type: Array + description: | + This field can only be specified if logging is enabled for this backend service and if the BackendService protocol is one of HTTP, HTTPS, HTTP2 and GRPC. Contains a list of response headers to be logged. + item_type: + type: NestedObject + properties: + - name: 'headerName' + type: String + description: | + The header name to match on for logging. + required: true - name: 'network' type: ResourceRef description: | diff --git a/mmv1/third_party/terraform/services/compute/resource_compute_backend_service_test.go.tmpl b/mmv1/third_party/terraform/services/compute/resource_compute_backend_service_test.go.tmpl index 5139a3d91b0d..65cf62e1b554 100644 --- a/mmv1/third_party/terraform/services/compute/resource_compute_backend_service_test.go.tmpl +++ b/mmv1/third_party/terraform/services/compute/resource_compute_backend_service_test.go.tmpl @@ -861,6 +861,37 @@ func TestAccComputeBackendService_withLogConfigMode(t *testing.T) { }) } +func TestAccComputeBackendService_withLogConfigRequestResponseHeaders(t *testing.T) { + t.Parallel() + + serviceName := fmt.Sprintf("tf-test-lc-%s", acctest.RandString(t, 10)) + checkName := fmt.Sprintf("tf-test-lc-%s", acctest.RandString(t, 10)) + + acctest.VcrTest(t, resource.TestCase{ + PreCheck: func() { acctest.AccTestPreCheck(t) }, + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories(t), + CheckDestroy: testAccCheckComputeBackendServiceDestroyProducer(t), + Steps: []resource.TestStep{ + { + Config: testAccComputeBackendService_withLogConfigRequestResponseHeaders(serviceName, checkName, "X-Request-Id", "X-Response-Id"), + }, + { + ResourceName: "google_compute_backend_service.foobar", + ImportState: true, + ImportStateVerify: true, + }, + { + Config: testAccComputeBackendService_withLogConfigRequestResponseHeaders(serviceName, checkName, "X-Request-Id-Updated", "X-Response-Id-Updated"), + }, + { + ResourceName: "google_compute_backend_service.foobar", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + func TestAccComputeBackendService_trafficDirectorUpdateBasic(t *testing.T) { t.Parallel() @@ -2524,6 +2555,38 @@ resource "google_compute_health_check" "zero" { `, serviceName, enabled, mode, checkName) } +func testAccComputeBackendService_withLogConfigRequestResponseHeaders(serviceName, checkName, reqHeader, respHeader string) string { + return fmt.Sprintf(` +resource "google_compute_backend_service" "foobar" { + name = "%s" + protocol = "HTTP" + load_balancing_scheme = "INTERNAL_MANAGED" + health_checks = [google_compute_health_check.zero.self_link] + + log_config { + enable = true + sample_rate = 0.5 + request_headers { + header_name = "%s" + } + response_headers { + header_name = "%s" + } + } +} + +resource "google_compute_health_check" "zero" { + name = "%s" + check_interval_sec = 1 + timeout_sec = 1 + + http_health_check { + port = 80 + } +} +`, serviceName, reqHeader, respHeader, checkName) +} + func testAccComputeBackendService_withCompressionMode(serviceName, checkName, compressionMode string) string { return fmt.Sprintf(` resource "google_compute_backend_service" "foobar" { diff --git a/mmv1/third_party/terraform/services/compute/resource_compute_region_backend_service_test.go.tmpl b/mmv1/third_party/terraform/services/compute/resource_compute_region_backend_service_test.go.tmpl index b002db0d3792..17373f093b03 100644 --- a/mmv1/third_party/terraform/services/compute/resource_compute_region_backend_service_test.go.tmpl +++ b/mmv1/third_party/terraform/services/compute/resource_compute_region_backend_service_test.go.tmpl @@ -578,6 +578,37 @@ func TestAccComputeRegionBackendService_withLogConfig(t *testing.T) { }) } +func TestAccComputeRegionBackendService_withLogConfigRequestResponseHeaders(t *testing.T) { + t.Parallel() + + serviceName := fmt.Sprintf("tf-test-%s", acctest.RandString(t, 10)) + checkName := fmt.Sprintf("tf-test-%s", acctest.RandString(t, 10)) + + acctest.VcrTest(t, resource.TestCase{ + PreCheck: func() { acctest.AccTestPreCheck(t) }, + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories(t), + CheckDestroy: testAccCheckComputeRegionBackendServiceDestroyProducer(t), + Steps: []resource.TestStep{ + { + Config: testAccComputeRegionBackendService_withLogConfigRequestResponseHeaders(serviceName, checkName, "X-Request-Id", "X-Response-Id"), + }, + { + ResourceName: "google_compute_region_backend_service.foobar", + ImportState: true, + ImportStateVerify: true, + }, + { + Config: testAccComputeRegionBackendService_withLogConfigRequestResponseHeaders(serviceName, checkName, "X-Request-Id-Updated", "X-Response-Id-Updated"), + }, + { + ResourceName: "google_compute_region_backend_service.foobar", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + func TestAccComputeRegionBackendService_zonalILB(t *testing.T) { t.Parallel() @@ -2230,6 +2261,41 @@ resource "google_compute_region_health_check" "health_check" { `, serviceName, checkName) } +func testAccComputeRegionBackendService_withLogConfigRequestResponseHeaders(serviceName, checkName, reqHeader, respHeader string) string { + return fmt.Sprintf(` +resource "google_compute_region_backend_service" "foobar" { + name = "%s" + region = "us-central1" + health_checks = [google_compute_region_health_check.health_check.self_link] + protocol = "HTTP" + load_balancing_scheme = "INTERNAL_MANAGED" + locality_lb_policy = "ROUND_ROBIN" + + log_config { + enable = true + sample_rate = 1.0 + request_headers { + header_name = "%s" + } + response_headers { + header_name = "%s" + } + } +} + +resource "google_compute_region_health_check" "health_check" { + name = "%s" + region = "us-central1" + check_interval_sec = 1 + timeout_sec = 1 + + http_health_check { + port = 80 + } +} +`, serviceName, reqHeader, respHeader, checkName) +} + func testAccComputeRegionBackendService_withTags(serviceName, checkName string, tagKey string, tagValue string) string { return fmt.Sprintf(` resource "google_compute_region_backend_service" "foobar" { From 9992afb857e2c8e9cb461eee1cf11f15526e857c Mon Sep 17 00:00:00 2001 From: dhrutik-test Date: Wed, 22 Jul 2026 16:49:01 +0000 Subject: [PATCH 21/30] Fix Developer Connect connection update test by using active GLE endpoint (#18339) --- .../resource_developer_connect_connection_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mmv1/third_party/terraform/services/developerconnect/resource_developer_connect_connection_test.go b/mmv1/third_party/terraform/services/developerconnect/resource_developer_connect_connection_test.go index 186b95e30739..4e919e930782 100644 --- a/mmv1/third_party/terraform/services/developerconnect/resource_developer_connect_connection_test.go +++ b/mmv1/third_party/terraform/services/developerconnect/resource_developer_connect_connection_test.go @@ -412,16 +412,16 @@ resource "google_developer_connect_connection" "my-connection" { } gitlab_enterprise_config { - host_uri = "https://gle-old.gcb-test.com" + host_uri = "https://gle-asia.gcb-test.com" webhook_secret_secret_version = "projects/devconnect-terraform-creds/secrets/gitlab-enterprise-webhook/versions/latest" read_authorizer_credential { - user_token_secret_version = "projects/devconnect-terraform-creds/secrets/gitlab-enterprise-read-cred-update/versions/latest" + user_token_secret_version = "projects/devconnect-terraform-creds/secrets/tf-gle-asia-read/versions/latest" } authorizer_credential { - user_token_secret_version = "projects/devconnect-terraform-creds/secrets/gitlab-enterprise-auth-cred-update/versions/latest" + user_token_secret_version = "projects/devconnect-terraform-creds/secrets/tf-gle-asia-api/versions/latest" } } } From ce2464096fe68f5072300f732031564e02a15831 Mon Sep 17 00:00:00 2001 From: Vaibhav Rau Date: Wed, 22 Jul 2026 16:57:20 +0000 Subject: [PATCH 22/30] sql: Add Identity block to `google_sql_user` for list resource support (#18298) --- .../services/sql/resource_sql_user.go | 103 +++++++++++++++++- .../services/sql/resource_sql_user_test.go | 46 ++++++++ 2 files changed, 148 insertions(+), 1 deletion(-) diff --git a/mmv1/third_party/terraform/services/sql/resource_sql_user.go b/mmv1/third_party/terraform/services/sql/resource_sql_user.go index 6626fee12212..b9475c07761d 100644 --- a/mmv1/third_party/terraform/services/sql/resource_sql_user.go +++ b/mmv1/third_party/terraform/services/sql/resource_sql_user.go @@ -82,6 +82,30 @@ func ResourceSqlUser() *schema.Resource { SchemaVersion: 1, MigrateState: resourceSqlUserMigrateState, + Identity: &schema.ResourceIdentity{ + Version: 1, + SchemaFunc: func() map[string]*schema.Schema { + return map[string]*schema.Schema{ + "project": { + Type: schema.TypeString, + OptionalForImport: true, + }, + "instance": { + Type: schema.TypeString, + RequiredForImport: true, + }, + "host": { + Type: schema.TypeString, + OptionalForImport: true, + }, + "name": { + Type: schema.TypeString, + RequiredForImport: true, + }, + } + }, + }, + Schema: map[string]*schema.Schema{ "host": { Type: schema.TypeString, @@ -363,6 +387,14 @@ func resourceSqlUserCreate(d *schema.ResourceData, meta interface{}) error { "into %s: %s", name, instance, err) } + if err := tpgresource.SetResourceIdentityAttributes(d, map[string]interface{}{ + "project": project, + "instance": instance, + "host": user.Host, + "name": name, + }); err != nil { + return err + } return resourceSqlUserRead(d, meta) } @@ -475,6 +507,15 @@ func resourceSqlUserRead(d *schema.ResourceData, meta interface{}) error { return err } + if err := tpgresource.SetResourceIdentityAttributes(d, map[string]interface{}{ + "project": project, + "instance": user.Instance, + "host": user.Host, + "name": user.Name, + }); err != nil { + return err + } + if err := tpgresource.DeletionPolicyReadDefault(d, config, "DELETE"); err != nil { return err } @@ -601,6 +642,14 @@ func resourceSqlUserUpdate(d *schema.ResourceData, meta interface{}) error { "in %s: %s", name, instance, err) } + if err := tpgresource.SetResourceIdentityAttributes(d, map[string]interface{}{ + "project": project, + "instance": instance, + "host": host, + "name": name, + }); err != nil { + return err + } return resourceSqlUserRead(d, meta) } @@ -660,8 +709,59 @@ func resourceSqlUserDelete(d *schema.ResourceData, meta interface{}) error { } func resourceSqlUserImporter(d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) { - parts := strings.Split(d.Id(), "/") + if d.Id() == "" { + // Import via identity block - identity attributes are already set + identity, err := d.Identity() + if err != nil { + return nil, fmt.Errorf("error getting identity: %s", err) + } + + var projectStr string + if v, ok := identity.GetOk("project"); ok { + projectStr = v.(string) + } + if projectStr == "" { + projectStr = meta.(*transport_tpg.Config).Project + } + if err := d.Set("project", projectStr); err != nil { + return nil, fmt.Errorf("Error setting project: %s", err) + } + + var instanceStr string + if v, ok := identity.GetOk("instance"); ok { + instanceStr = v.(string) + } + if err := d.Set("instance", instanceStr); err != nil { + return nil, fmt.Errorf("Error setting instance: %s", err) + } + + var hostStr string + if v, ok := identity.GetOk("host"); ok { + hostStr = v.(string) + } + if hostStr != "" { + if err := d.Set("host", hostStr); err != nil { + return nil, fmt.Errorf("Error setting host: %s", err) + } + } + var nameStr string + if v, ok := identity.GetOk("name"); ok { + nameStr = v.(string) + } + if err := d.Set("name", nameStr); err != nil { + return nil, fmt.Errorf("Error setting name: %s", err) + } + + if hostStr != "" { + d.SetId(fmt.Sprintf("%s/%s/%s/%s", projectStr, instanceStr, hostStr, nameStr)) + } else { + d.SetId(fmt.Sprintf("%s/%s/%s", projectStr, instanceStr, nameStr)) + } + return []*schema.ResourceData{d}, nil + } + + parts := strings.Split(d.Id(), "/") if len(parts) == 3 { if err := d.Set("project", parts[0]); err != nil { return nil, fmt.Errorf("Error setting project: %s", err) @@ -698,6 +798,7 @@ func resourceSqlUserImporter(d *schema.ResourceData, meta interface{}) ([]*schem if err := d.Set("name", parts[4]); err != nil { return nil, fmt.Errorf("Error setting name: %s", err) } + } else { return nil, fmt.Errorf("Invalid specifier. Expecting {project}/{instance}/{name} for postgres instance and {project}/{instance}/{host}/{name} for MySQL instance") } diff --git a/mmv1/third_party/terraform/services/sql/resource_sql_user_test.go b/mmv1/third_party/terraform/services/sql/resource_sql_user_test.go index 917a4075020a..1a9a1c4c71d2 100644 --- a/mmv1/third_party/terraform/services/sql/resource_sql_user_test.go +++ b/mmv1/third_party/terraform/services/sql/resource_sql_user_test.go @@ -8,6 +8,7 @@ import ( "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/plancheck" "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/hashicorp/terraform-plugin-testing/tfversion" "github.com/hashicorp/terraform-provider-google/google/acctest" "github.com/hashicorp/terraform-provider-google/google/envvar" _ "github.com/hashicorp/terraform-provider-google/google/services/resourcemanager" @@ -1219,3 +1220,48 @@ resource "google_sql_user" "user" { } `, instance, username) } + +func TestAccSqlUser_importBlockWithResourceIdentity(t *testing.T) { + t.Parallel() + instance := fmt.Sprintf("tf-test-%d", acctest.RandInt(t)) + acctest.VcrTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_12_0), + }, + PreCheck: func() { acctest.AccTestPreCheck(t) }, + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories(t), + CheckDestroy: testAccSqlUserDestroyProducer(t), + Steps: []resource.TestStep{ + { + Config: testGoogleSqlUser_postgres(instance, "password"), + Check: resource.ComposeTestCheckFunc( + testAccCheckGoogleSqlUserExists(t, "google_sql_user.user"), + ), + }, + { + Config: testGoogleSqlUser_postgresNoPassword(instance), + ResourceName: "google_sql_user.user", + ImportState: true, + ImportStateKind: resource.ImportBlockWithResourceIdentity, + }, + }, + }) +} + +func testGoogleSqlUser_postgresNoPassword(instance string) string { + return fmt.Sprintf(` +resource "google_sql_database_instance" "instance" { + name = "%s" + region = "us-central1" + database_version = "POSTGRES_9_6" + deletion_protection = false + settings { + tier = "db-f1-micro" + } +} +resource "google_sql_user" "user" { + name = "admin" + instance = google_sql_database_instance.instance.name +} +`, instance) +} From 38c9d232aee471407c418dfd3e3c71c985ce1cc4 Mon Sep 17 00:00:00 2001 From: Mikolaj Luczko Date: Wed, 22 Jul 2026 17:22:40 +0000 Subject: [PATCH 23/30] Add support for user-triggered rollouts (#17756) --- mmv1/products/gkehub2/RolloutSequence.yaml | 97 ++++++++- .../gke_hub_rollout_sequence.go.tmpl | 202 +++++++++++++++++- .../gke_hub_rollout_sequence.go.tmpl | 59 +++++ .../gke_hub_rollout_sequence.go.tmpl | 53 +++++ .../gke_hub_rollout_sequence_create.tf.tmpl | 29 ++- .../gke_hub_rollout_sequence_update.tf.tmpl | 31 ++- ...out_sequence_user_triggered_create.tf.tmpl | 93 ++++++++ ...out_sequence_user_triggered_update.tf.tmpl | 93 ++++++++ .../resource_gke_hub_rollout_sequence_test.go | 124 +++++++++++ 9 files changed, 773 insertions(+), 8 deletions(-) create mode 100644 mmv1/templates/terraform/post_create/gke_hub_rollout_sequence.go.tmpl create mode 100644 mmv1/templates/terraform/post_update/gke_hub_rollout_sequence.go.tmpl create mode 100644 mmv1/templates/terraform/samples/services/gkehub2/gke_hub_rollout_sequence_user_triggered_create.tf.tmpl create mode 100644 mmv1/templates/terraform/samples/services/gkehub2/gke_hub_rollout_sequence_user_triggered_update.tf.tmpl diff --git a/mmv1/products/gkehub2/RolloutSequence.yaml b/mmv1/products/gkehub2/RolloutSequence.yaml index e9045eaf4dc7..b1392f3da6b7 100644 --- a/mmv1/products/gkehub2/RolloutSequence.yaml +++ b/mmv1/products/gkehub2/RolloutSequence.yaml @@ -27,8 +27,6 @@ update_verb: 'PATCH' update_mask: true import_format: - 'projects/{{project}}/locations/global/rolloutSequences/{{rollout_sequence_id}}' -custom_code: - constants: 'templates/terraform/constants/gke_hub_rollout_sequence.go.tmpl' timeouts: insert_minutes: 20 update_minutes: 20 @@ -41,6 +39,11 @@ async: base_url: '{{op_id}}' result: resource_inside_response: false +custom_code: + constants: 'templates/terraform/constants/gke_hub_rollout_sequence.go.tmpl' + post_create: 'templates/terraform/post_create/gke_hub_rollout_sequence.go.tmpl' + post_create_failure: 'templates/terraform/post_create_failure/delete_on_failure.go.tmpl' + post_update: 'templates/terraform/post_update/gke_hub_rollout_sequence.go.tmpl' parameters: - name: 'rollout_sequence_id' type: String @@ -152,6 +155,8 @@ properties: Exceptions are rollouts enforcing our security policies (e.g. such as end-of-support and outdated control plane patch enforcements). These policy enforcements cannot be disabled. + allow_empty_object: true + send_empty_value: true properties: - name: 'upgradeTypes' type: Array @@ -161,15 +166,65 @@ properties: Current valid values are `CONTROL_PLANE_MINOR`, `CONTROL_PLANE_PATCH`, `NODE_MINOR`, and `NODE_PATCH`. item_type: type: String + send_empty_value: true + + - name: 'targetControlPlaneVersion' + type: String + description: | + The current target control plane version. + output: true + - name: 'targetNodeVersion' + type: String + description: | + The current target node version. + output: true + - name: 'operationalState' + type: NestedObject + description: | + The operational state of the rollout sequence. + output: true + properties: + - name: 'state' + type: String + description: | + The state of the rollout sequence. + output: true + +virtual_fields: + - name: 'min_control_plane_version' + type: String + description: | + Minimum control plane version that the clusters in the sequence should be upgraded to. + Setting this field will cause the creation of a rollout to the specified version. + Any rollout of the same type already running on the first stage of the sequence will be cancelled to allow for the creation of the new rollout. + Should be a valid [semantic version](https://semver.org/). + Version aliases are supported, as described in the [cluster version docs](https://docs.cloud.google.com/kubernetes-engine/versioning#specifying_cluster_version). + Note that the `latest` and `-` aliases are not supported for this field. + Supported formats: `1.X`, `1.X.Y`, `1.X.Y-gke.N`. + - name: 'min_node_version' + type: String + description: | + Minimum node version that the clusters in the sequence should be upgraded to. + Setting this field will cause the creation of a rollout to the specified version. + Any rollout of the same type already running on the first stage of the sequence will be cancelled to allow for the creation of the new rollout. + Should be a valid [semantic version](https://semver.org/). + Version aliases are supported, as described in the [cluster version docs](https://docs.cloud.google.com/kubernetes-engine/versioning#specifying_cluster_version). + Note that the `latest` and `-` aliases are not supported for this field. + Supported formats: `1.X`, `1.X.Y`, `1.X.Y-gke.N`. + samples: - name: 'gke_hub_rollout_sequence_basic' primary_resource_id: 'rollout_sequence' + external_providers: + - 'time' steps: - name: 'gke_hub_rollout_sequence_create' resource_id_vars: rollout_sequence_id: 'rs-basic' + project_id: 'rs-project' test_env_vars: - project_id: 'PROJECT_NAME' + org_id: 'ORG_ID' + billing_acct: 'BILLING_ACCT' ignore_read_extra: - 'labels' - 'rollout_sequence_id' @@ -177,6 +232,40 @@ samples: - name: 'gke_hub_rollout_sequence_update' resource_id_vars: rollout_sequence_id: 'rs-basic' + project_id: 'rs-project' test_env_vars: - project_id: 'PROJECT_NAME' + org_id: 'ORG_ID' + billing_acct: 'BILLING_ACCT' include_step_doc: true + - name: 'gke_hub_rollout_sequence_user_triggered' + primary_resource_id: 'rollout_sequence' + external_providers: + - 'time' + steps: + - name: 'gke_hub_rollout_sequence_user_triggered_create' + resource_id_vars: + rollout_sequence_id: 'rs-user-triggered' + project_id: 'rs-project' + network_name: 'rs-network' + subnetwork_name: 'rs-subnet' + cluster_name: 'rs-cluster' + membership_id: 'rs-membership' + test_env_vars: + org_id: 'ORG_ID' + billing_acct: 'BILLING_ACCT' + ignore_read_extra: + - 'min_control_plane_version' + - name: 'gke_hub_rollout_sequence_user_triggered_update' + resource_id_vars: + rollout_sequence_id: 'rs-user-triggered' + project_id: 'rs-project' + network_name: 'rs-network' + subnetwork_name: 'rs-subnet' + cluster_name: 'rs-cluster' + membership_id: 'rs-membership' + test_env_vars: + org_id: 'ORG_ID' + billing_acct: 'BILLING_ACCT' + ignore_read_extra: + - 'min_control_plane_version' + - 'min_node_version' diff --git a/mmv1/templates/terraform/constants/gke_hub_rollout_sequence.go.tmpl b/mmv1/templates/terraform/constants/gke_hub_rollout_sequence.go.tmpl index 39724f0311e4..fd28afbbf58f 100644 --- a/mmv1/templates/terraform/constants/gke_hub_rollout_sequence.go.tmpl +++ b/mmv1/templates/terraform/constants/gke_hub_rollout_sequence.go.tmpl @@ -1,3 +1,7 @@ +import ( + "github.com/hashicorp/go-version" +) + func parseDurationAsSeconds(v string) (int, bool) { if len(v) == 0 { return 0, false @@ -31,4 +35,200 @@ func rolloutSequenceDurationDiffSuppress(_, old, new string, _ *schema.ResourceD return false } return oldSeconds == newSeconds -} \ No newline at end of file +} + +// Compares two GKE versions using the standard go-version package. +// Returns true if minVer is strictly greater than targetVer. +// Returns true if targetVer is empty (representing a new rollout sequence). +// Returns false if minVer is empty (representing an unset min_*_version field). +func shouldUpgradeRolloutSequence(minVer, targetVer string) (bool, error) { + if minVer == "" { + return false, nil + } + minV, err := version.NewVersion(minVer) + if err != nil { + return false, fmt.Errorf("invalid min version format %q: %w", minVer, err) + } + + if targetVer == "" { + return true, nil + } + targetV, err := version.NewVersion(targetVer) + if err != nil { + return false, fmt.Errorf("invalid target version format %q: %w", targetVer, err) + } + + return minV.GreaterThan(targetV), nil +} + +// Polls the state of a RolloutSequence resource, until it enters a state other +// than INITIALIZING or until the 1h timeout passes. +func pollSequenceInitialization(d *schema.ResourceData, meta interface{}) error { + config := meta.(*transport_tpg.Config) + url, err := tpgresource.ReplaceVars(d, config, "{{"{{"}}GKEHub2BasePath{{"}}"}}projects/{{"{{"}}project{{"}}"}}/locations/global/rolloutSequences/{{"{{"}}rollout_sequence_id{{"}}"}}") + if err != nil { + return err + } + + project, err := tpgresource.GetProject(d, config) + if err != nil { + return fmt.Errorf("failed to fetch project for RolloutSequence: %w", err) + } + billingProject := project + if bp, err := tpgresource.GetBillingProject(d, config); err == nil { + billingProject = bp + } + userAgent, err := tpgresource.GenerateUserAgentString(d, config.UserAgent) + if err != nil { + return err + } + + pollRead := func() (map[string]interface{}, error) { + return transport_tpg.SendRequest(transport_tpg.SendRequestOptions{ + Config: config, + Method: "GET", + Project: billingProject, + RawURL: url, + UserAgent: userAgent, + }) + } + + checkResponse := func(resp map[string]interface{}, respErr error) transport_tpg.PollResult { + if respErr != nil { + return transport_tpg.ErrorPollResult(respErr) + } + if resp == nil { + return transport_tpg.PendingStatusPollResult("nil response") + } + + opStateRaw, ok := resp["operationalState"] + if !ok || opStateRaw == nil { + return transport_tpg.PendingStatusPollResult("operationalState missing") + } + + opState, ok := opStateRaw.(map[string]interface{}) + if !ok { + return transport_tpg.ErrorPollResult(fmt.Errorf("operationalState is not a map")) + } + + stateRaw, ok := opState["state"] + if !ok || stateRaw == nil { + return transport_tpg.PendingStatusPollResult("state missing") + } + state, ok := stateRaw.(string) + if !ok { + return transport_tpg.ErrorPollResult(fmt.Errorf("state is not a string")) + } + + if state == "INITIALIZING" { + return transport_tpg.PendingStatusPollResult("INITIALIZING") + } + + return transport_tpg.SuccessPollResult() + } + + return transport_tpg.PollingWaitTime(pollRead, checkResponse, "Polling RolloutSequence initialization", 1*time.Hour, 1) +} + +// Fetches the current targetControlPlaneVersion and targetNodeVersion values of the RolloutSequence resource. +func fetchCurrentTargetVersions(d *schema.ResourceData, meta interface{}) (string, string, error) { + config := meta.(*transport_tpg.Config) + userAgent, err := tpgresource.GenerateUserAgentString(d, config.UserAgent) + if err != nil { + return "", "", err + } + project, err := tpgresource.GetProject(d, config) + if err != nil { + return "", "", fmt.Errorf("failed to fetch project for RolloutSequence: %w", err) + } + billingProject := project + if bp, err := tpgresource.GetBillingProject(d, config); err == nil { + billingProject = bp + } + url, err := tpgresource.ReplaceVars(d, config, "{{"{{"}}GKEHub2BasePath{{"}}"}}projects/{{"{{"}}project{{"}}"}}/locations/global/rolloutSequences/{{"{{"}}rollout_sequence_id{{"}}"}}") + if err != nil { + return "", "", err + } + + res, err := transport_tpg.SendRequest(transport_tpg.SendRequestOptions{ + Config: config, + Method: "GET", + Project: billingProject, + RawURL: url, + UserAgent: userAgent, + }) + if err != nil { + return "", "", fmt.Errorf("failed to fetch latest RolloutSequence state: %w", err) + } + + targetCPVer := "" + if v, ok := res["targetControlPlaneVersion"]; ok && v != nil { + if s, ok := v.(string); ok { + targetCPVer = s + } else { + return "", "", fmt.Errorf("expected targetControlPlaneVersion to be a string, got %T", v) + } + } + targetNodeVer := "" + if v, ok := res["targetNodeVersion"]; ok && v != nil { + if s, ok := v.(string); ok { + targetNodeVer = s + } else { + return "", "", fmt.Errorf("expected targetNodeVersion to be a string, got %T", v) + } + } + + return targetCPVer, targetNodeVer, nil +} + +{{- if ne $.ProductMetadata.Compiler "terraformgoogleconversion-codegen" }} +// Makes an UpgradeRolloutSequence call for the provided upgradeType and version. +func triggerUpgradeRolloutSequence(d *schema.ResourceData, meta interface{}, upgradeType string, version string) error { + config := meta.(*transport_tpg.Config) + url, err := tpgresource.ReplaceVars(d, config, "{{"{{"}}GKEHub2BasePath{{"}}"}}projects/{{"{{"}}project{{"}}"}}/locations/global/rolloutSequences/{{"{{"}}rollout_sequence_id{{"}}:upgrade"}}") + if err != nil { + return err + } + + project, err := tpgresource.GetProject(d, config) + if err != nil { + return fmt.Errorf("failed to fetch project for RolloutSequence: %w", err) + } + billingProject := project + if bp, err := tpgresource.GetBillingProject(d, config); err == nil { + billingProject = bp + } + userAgent, err := tpgresource.GenerateUserAgentString(d, config.UserAgent) + if err != nil { + return err + } + + body := map[string]interface{}{ + "upgradeType": upgradeType, + "version": version, + "force": true, + } + + res, err := transport_tpg.SendRequest(transport_tpg.SendRequestOptions{ + Config: config, + Method: "POST", + Project: billingProject, + RawURL: url, + UserAgent: userAgent, + Body: body, + Timeout: d.Timeout(schema.TimeoutUpdate), + }) + if err != nil { + return fmt.Errorf("failed to trigger RolloutSequence upgrade (%s): %w", upgradeType, err) + } + + err = GKEHub2OperationWaitTime( + config, res, project, "Triggering RolloutSequence Upgrade", userAgent, + d.Timeout(schema.TimeoutUpdate)) + if err != nil { + return fmt.Errorf("failed to wait for RolloutSequence upgrade (%s): %w", upgradeType, err) + } + + return nil +} +{{- end }} \ No newline at end of file diff --git a/mmv1/templates/terraform/post_create/gke_hub_rollout_sequence.go.tmpl b/mmv1/templates/terraform/post_create/gke_hub_rollout_sequence.go.tmpl new file mode 100644 index 000000000000..63426be5ab4b --- /dev/null +++ b/mmv1/templates/terraform/post_create/gke_hub_rollout_sequence.go.tmpl @@ -0,0 +1,59 @@ +if err := func() error { + var minCPVer string + if rawCP, ok := d.GetOk("min_control_plane_version"); ok { + minCPVer = rawCP.(string) + } + + var minNodeVer string + if rawNode, ok := d.GetOk("min_node_version"); ok { + minNodeVer = rawNode.(string) + } + + if minCPVer == "" && minNodeVer == "" { + return nil + } + + log.Printf("[DEBUG] Polling RolloutSequence initialization.") + if err := pollSequenceInitialization(d, meta); err != nil { + return err + } + + // There is a delay between the state of a RolloutSequence switching to ACTIVE, and the UpdateRolloutSequence endpoint + // releasing the lock on the resource. We sleep here to avoid conflict errors when making the UpgradeRolloutSequence call. + time.Sleep(1 * time.Minute) + + log.Printf("[DEBUG] Fetching current RolloutSequence target versions.") + targetCPVer, targetNodeVer, err := fetchCurrentTargetVersions(d, meta) + if err != nil { + return err + } + + if minCPVer != "" { + shouldUpgrade, err := shouldUpgradeRolloutSequence(minCPVer, targetCPVer) + if err != nil { + return err + } + if shouldUpgrade { + log.Printf("[DEBUG] min_control_plane_version (%s) specified on RolloutSequence creation. Upgrading RolloutSequence.", minCPVer) + if err := triggerUpgradeRolloutSequence(d, meta, "CONTROL_PLANE", minCPVer); err != nil { + return err + } + } + } + if minNodeVer != "" { + shouldUpgrade, err := shouldUpgradeRolloutSequence(minNodeVer, targetNodeVer) + if err != nil { + return err + } + if shouldUpgrade { + log.Printf("[DEBUG] min_node_version (%s) specified on RolloutSequence creation. Upgrading RolloutSequence.", minNodeVer) + if err := triggerUpgradeRolloutSequence(d, meta, "NODE", minNodeVer); err != nil { + return err + } + } + } + return nil +}(); err != nil { + resourceGKEHub2RolloutSequencePostCreateFailure(d, meta) + return err +} diff --git a/mmv1/templates/terraform/post_update/gke_hub_rollout_sequence.go.tmpl b/mmv1/templates/terraform/post_update/gke_hub_rollout_sequence.go.tmpl new file mode 100644 index 000000000000..975c446f714d --- /dev/null +++ b/mmv1/templates/terraform/post_update/gke_hub_rollout_sequence.go.tmpl @@ -0,0 +1,53 @@ +minCPChanged := d.HasChange("min_control_plane_version") +minNodeChanged := d.HasChange("min_node_version") + +if !minCPChanged && !minNodeChanged { + return nil +} + +log.Printf("[DEBUG] Polling RolloutSequence initialization.") +if err := pollSequenceInitialization(d, meta); err != nil { + return fmt.Errorf("failed to wait for RolloutSequence to initialize: %w", err) +} + +// There is a delay between the state of a RolloutSequence switching to ACTIVE, and the UpdateRolloutSequence endpoint +// releasing the lock on the resource. We sleep here to avoid conflict errors when making the UpgradeRolloutSequence call. +time.Sleep(1 * time.Minute) + +log.Printf("[DEBUG] Fetching current RolloutSequence target versions.") +targetCPVer, targetNodeVer, err := fetchCurrentTargetVersions(d, meta) +if err != nil { + return err +} + +if minCPChanged { + minCPVer := d.Get("min_control_plane_version").(string) + shouldUpgrade, err := shouldUpgradeRolloutSequence(minCPVer, targetCPVer) + if err != nil { + return err + } + if shouldUpgrade { + log.Printf("[DEBUG] min_control_plane_version (%s) > target_control_plane_version (%s). Upgrading RolloutSequence.", minCPVer, targetCPVer) + if err := triggerUpgradeRolloutSequence(d, meta, "CONTROL_PLANE", minCPVer); err != nil { + return err + } + } else { + log.Printf("[DEBUG] min_control_plane_version (%s) <= target_control_plane_version (%s). Skipping RolloutSequence upgrade.", minCPVer, targetCPVer) + } +} + +if minNodeChanged { + minNodeVer := d.Get("min_node_version").(string) + shouldUpgrade, err := shouldUpgradeRolloutSequence(minNodeVer, targetNodeVer) + if err != nil { + return err + } + if shouldUpgrade { + log.Printf("[DEBUG] min_node_version (%s) > target_node_version (%s). Upgrading RolloutSequence.", minNodeVer, targetNodeVer) + if err := triggerUpgradeRolloutSequence(d, meta, "NODE", minNodeVer); err != nil { + return err + } + } else { + log.Printf("[DEBUG] min_node_version (%s) <= target_node_version (%s). Skipping RolloutSequence upgrade.", minNodeVer, targetNodeVer) + } +} diff --git a/mmv1/templates/terraform/samples/services/gkehub2/gke_hub_rollout_sequence_create.tf.tmpl b/mmv1/templates/terraform/samples/services/gkehub2/gke_hub_rollout_sequence_create.tf.tmpl index 691e5250764f..abd650a8ef58 100644 --- a/mmv1/templates/terraform/samples/services/gkehub2/gke_hub_rollout_sequence_create.tf.tmpl +++ b/mmv1/templates/terraform/samples/services/gkehub2/gke_hub_rollout_sequence_create.tf.tmpl @@ -1,11 +1,37 @@ +resource "google_project" "project" { + project_id = "{{index $.ResourceIdVars "project_id"}}" + name = "{{index $.ResourceIdVars "project_id"}}" + org_id = "{{index $.TestEnvVars "org_id"}}" + billing_account = "{{index $.TestEnvVars "billing_acct"}}" + deletion_policy = "DELETE" +} + +resource "google_project_service" "gkehub" { + project = google_project.project.project_id + service = "gkehub.googleapis.com" +} + +// wait for API enablement +resource "time_sleep" "wait_120_seconds" { + create_duration = "120s" + depends_on = [google_project_service.gkehub] +} + +resource "google_gke_hub_fleet" "default" { + display_name = "rs-fleet" + project = google_project.project.project_id + depends_on = [time_sleep.wait_120_seconds] +} + resource "google_gke_hub_rollout_sequence" "{{$.PrimaryResourceId}}" { + project = google_project.project.project_id rollout_sequence_id = "{{index $.ResourceIdVars "rollout_sequence_id"}}" display_name = "Basic Rollout Sequence" ignored_clusters_selector { label_selector = "resource.labels.ignored == 'true'" } stages { - fleet_projects = ["projects/{{index $.TestEnvVars "project_id"}}"] + fleet_projects = ["projects/${google_project.project.project_id}"] soak_duration = "1h" } auto_upgrade_config { @@ -18,4 +44,5 @@ resource "google_gke_hub_rollout_sequence" "{{$.PrimaryResourceId}}" { ] } } + depends_on = [google_gke_hub_fleet.default] } diff --git a/mmv1/templates/terraform/samples/services/gkehub2/gke_hub_rollout_sequence_update.tf.tmpl b/mmv1/templates/terraform/samples/services/gkehub2/gke_hub_rollout_sequence_update.tf.tmpl index dd061e115ebe..ead31809ac34 100644 --- a/mmv1/templates/terraform/samples/services/gkehub2/gke_hub_rollout_sequence_update.tf.tmpl +++ b/mmv1/templates/terraform/samples/services/gkehub2/gke_hub_rollout_sequence_update.tf.tmpl @@ -1,18 +1,44 @@ +resource "google_project" "project" { + project_id = "{{index $.ResourceIdVars "project_id"}}" + name = "{{index $.ResourceIdVars "project_id"}}" + org_id = "{{index $.TestEnvVars "org_id"}}" + billing_account = "{{index $.TestEnvVars "billing_acct"}}" + deletion_policy = "DELETE" +} + +resource "google_project_service" "gkehub" { + project = google_project.project.project_id + service = "gkehub.googleapis.com" +} + +// wait for API enablement +resource "time_sleep" "wait_120_seconds" { + create_duration = "120s" + depends_on = [google_project_service.gkehub] +} + +resource "google_gke_hub_fleet" "default" { + display_name = "rs-fleet" + project = google_project.project.project_id + depends_on = [time_sleep.wait_120_seconds] +} + resource "google_gke_hub_rollout_sequence" "{{$.PrimaryResourceId}}" { + project = google_project.project.project_id rollout_sequence_id = "{{index $.ResourceIdVars "rollout_sequence_id"}}" display_name = "Modified Rollout Sequence" ignored_clusters_selector { label_selector = "resource.labels.ignored == 'super_true'" } stages { - fleet_projects = ["projects/{{index $.TestEnvVars "project_id"}}"] + fleet_projects = ["projects/${google_project.project.project_id}"] cluster_selector { label_selector = "resource.labels.canary=='true'" } soak_duration = "2h" } stages { - fleet_projects = ["projects/{{index $.TestEnvVars "project_id"}}"] + fleet_projects = ["projects/${google_project.project.project_id}"] soak_duration = "1d" } auto_upgrade_config { @@ -26,4 +52,5 @@ resource "google_gke_hub_rollout_sequence" "{{$.PrimaryResourceId}}" { labels = { some_key = "some_value" } + depends_on = [google_gke_hub_fleet.default] } diff --git a/mmv1/templates/terraform/samples/services/gkehub2/gke_hub_rollout_sequence_user_triggered_create.tf.tmpl b/mmv1/templates/terraform/samples/services/gkehub2/gke_hub_rollout_sequence_user_triggered_create.tf.tmpl new file mode 100644 index 000000000000..8aea831d1953 --- /dev/null +++ b/mmv1/templates/terraform/samples/services/gkehub2/gke_hub_rollout_sequence_user_triggered_create.tf.tmpl @@ -0,0 +1,93 @@ +resource "google_project" "project" { + project_id = "{{index $.ResourceIdVars "project_id"}}" + name = "{{index $.ResourceIdVars "project_id"}}" + org_id = "{{index $.TestEnvVars "org_id"}}" + billing_account = "{{index $.TestEnvVars "billing_acct"}}" + deletion_policy = "DELETE" +} + +// Enable APIs in a deterministic order to avoid inconsistent VCR recordings +resource "google_project_service" "gkehub" { + project = google_project.project.project_id + service = "gkehub.googleapis.com" +} + +resource "google_project_service" "container" { + project = google_project.project.project_id + service = "container.googleapis.com" + depends_on = [google_project_service.gkehub] +} + +resource "google_project_service" "compute" { + project = google_project.project.project_id + service = "compute.googleapis.com" + depends_on = [google_project_service.container] +} + +// wait for API enablement +resource "time_sleep" "wait_120_seconds" { + create_duration = "120s" + depends_on = [google_project_service.compute] +} + +resource "google_gke_hub_fleet" "default" { + display_name = "rs-fleet" + project = google_project.project.project_id + depends_on = [time_sleep.wait_120_seconds] +} + +data "google_container_engine_versions" "versions" { + location = "us-central1-a" + project = google_project.project.project_id + depends_on = [time_sleep.wait_120_seconds] +} + +resource "google_container_cluster" "primary" { + project = google_project.project.project_id + name = "{{index $.ResourceIdVars "cluster_name"}}" + location = "us-central1-a" + initial_node_count = 1 + min_master_version = data.google_container_engine_versions.versions.release_channel_default_version["REGULAR"] + node_version = data.google_container_engine_versions.versions.release_channel_default_version["REGULAR"] + deletion_protection = false + + release_channel { + channel = "REGULAR" + } + + resource_labels = { + rs_test_cluster = "tf-test-%{random_suffix}" + } + + fleet { + project = google_project.project.number + } + + depends_on = [google_gke_hub_fleet.default] +} + +resource "google_gke_hub_rollout_sequence" "rollout_sequence" { + project = google_project.project.project_id + rollout_sequence_id = "{{index $.ResourceIdVars "rollout_sequence_id"}}" + display_name = "User Triggered Rollout Sequence" + + min_control_plane_version = data.google_container_engine_versions.versions.release_channel_latest_version["REGULAR"] + + # Ensures that any rollouts created from this sequence will ignore all clusters in the project, other than the cluster we create for this test. + ignored_clusters_selector { + label_selector = "!(has(resource.labels.rs_test_cluster) && resource.labels.rs_test_cluster == 'tf-test-%{random_suffix}')" + } + + stages { + fleet_projects = ["projects/${google_project.project.project_id}"] + soak_duration = "30s" + } + + auto_upgrade_config { + rollout_creation_scope { + upgrade_types = [] + } + } + + depends_on = [google_container_cluster.primary] +} diff --git a/mmv1/templates/terraform/samples/services/gkehub2/gke_hub_rollout_sequence_user_triggered_update.tf.tmpl b/mmv1/templates/terraform/samples/services/gkehub2/gke_hub_rollout_sequence_user_triggered_update.tf.tmpl new file mode 100644 index 000000000000..97c79495446d --- /dev/null +++ b/mmv1/templates/terraform/samples/services/gkehub2/gke_hub_rollout_sequence_user_triggered_update.tf.tmpl @@ -0,0 +1,93 @@ +resource "google_project" "project" { + project_id = "{{index $.ResourceIdVars "project_id"}}" + name = "{{index $.ResourceIdVars "project_id"}}" + org_id = "{{index $.TestEnvVars "org_id"}}" + billing_account = "{{index $.TestEnvVars "billing_acct"}}" + deletion_policy = "DELETE" +} + +// Enable APIs in a deterministic order to avoid inconsistent VCR recordings +resource "google_project_service" "gkehub" { + project = google_project.project.project_id + service = "gkehub.googleapis.com" +} + +resource "google_project_service" "container" { + project = google_project.project.project_id + service = "container.googleapis.com" + depends_on = [google_project_service.gkehub] +} + +resource "google_project_service" "compute" { + project = google_project.project.project_id + service = "compute.googleapis.com" + depends_on = [google_project_service.container] +} + +// wait for API enablement +resource "time_sleep" "wait_120_seconds" { + create_duration = "120s" + depends_on = [google_project_service.compute] +} + +resource "google_gke_hub_fleet" "default" { + display_name = "rs-fleet" + project = google_project.project.project_id +} + +data "google_container_engine_versions" "versions" { + location = "us-central1-a" + project = google_project.project.project_id + depends_on = [time_sleep.wait_120_seconds] +} + +resource "google_container_cluster" "primary" { + project = google_project.project.project_id + name = "{{index $.ResourceIdVars "cluster_name"}}" + location = "us-central1-a" + initial_node_count = 1 + min_master_version = data.google_container_engine_versions.versions.release_channel_default_version["REGULAR"] + node_version = data.google_container_engine_versions.versions.release_channel_default_version["REGULAR"] + deletion_protection = false + + release_channel { + channel = "REGULAR" + } + + resource_labels = { + rs_test_cluster = "tf-test-%{random_suffix}" + } + + fleet { + project = google_project.project.number + } + + depends_on = [google_gke_hub_fleet.default] +} + +resource "google_gke_hub_rollout_sequence" "rollout_sequence" { + project = google_project.project.project_id + rollout_sequence_id = "{{index $.ResourceIdVars "rollout_sequence_id"}}" + display_name = "User Triggered Rollout Sequence" + + min_control_plane_version = data.google_container_engine_versions.versions.release_channel_latest_version["REGULAR"] + min_node_version = data.google_container_engine_versions.versions.release_channel_latest_version["REGULAR"] + + # Ensures that any rollouts created from this sequence will ignore all clusters in the project, other than the cluster we create for this test. + ignored_clusters_selector { + label_selector = "!(has(resource.labels.rs_test_cluster) && resource.labels.rs_test_cluster == 'tf-test-%{random_suffix}')" + } + + stages { + fleet_projects = ["projects/${google_project.project.project_id}"] + soak_duration = "30s" + } + + auto_upgrade_config { + rollout_creation_scope { + upgrade_types = [] + } + } + + depends_on = [google_container_cluster.primary] +} diff --git a/mmv1/third_party/terraform/services/gkehub2/resource_gke_hub_rollout_sequence_test.go b/mmv1/third_party/terraform/services/gkehub2/resource_gke_hub_rollout_sequence_test.go index fe2012e7688a..e2e904d2319e 100644 --- a/mmv1/third_party/terraform/services/gkehub2/resource_gke_hub_rollout_sequence_test.go +++ b/mmv1/third_party/terraform/services/gkehub2/resource_gke_hub_rollout_sequence_test.go @@ -37,3 +37,127 @@ func TestRolloutSequenceDurationDiffSuppress(t *testing.T) { } } } + +func TestShouldUpgradeRolloutSequence(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + minCPVer string + targetVer string + want bool + wantErr bool + }{ + { + name: "minor version bump, want upgrade", + minCPVer: "1.28.0-gke.0", + targetVer: "1.27.10-gke.1200", + want: true, + wantErr: false, + }, + { + name: "patch version bump, want upgrade", + minCPVer: "1.27.5-gke.0", + targetVer: "1.27.3-gke.100", + want: true, + wantErr: false, + }, + { + name: "GKE patch version bump, want upgrade", + minCPVer: "1.27.3-gke.200", + targetVer: "1.27.3-gke.100", + want: true, + wantErr: false, + }, + { + name: "patch version > GKE patch version, want upgrade", + minCPVer: "1.27.1", + targetVer: "1.27.1-gke.100", + want: true, + wantErr: false, + }, + { + name: "missing patch version implicitly padded with zero, want NO upgrade", + minCPVer: "1.27", + targetVer: "1.27.1", + want: false, + wantErr: false, + }, + { + name: "minor version drop, want NO upgrade", + minCPVer: "1.26.5-gke.100", + targetVer: "1.27.3-gke.100", + want: false, + wantErr: false, + }, + { + name: "patch version drop, want NO upgrade", + minCPVer: "1.27.2-gke.500", + targetVer: "1.27.3-gke.100", + want: false, + wantErr: false, + }, + { + name: "GKE patch version drop, want NO upgrade", + minCPVer: "1.27.3-gke.50", + targetVer: "1.27.3-gke.100", + want: false, + wantErr: false, + }, + { + name: "no version change, want NO upgrade", + minCPVer: "1.27.3-gke.100", + targetVer: "1.27.3-gke.100", + want: false, + wantErr: false, + }, + { + name: "empty target version, want upgrade", + minCPVer: "1.27.3-gke.100", + targetVer: "", + want: true, + wantErr: false, + }, + { + name: "empty min version, want NO upgrade", + minCPVer: "", + targetVer: "1.27.3-gke.100", + want: false, + wantErr: false, + }, + { + name: "malformed min version syntax, want error", + minCPVer: "invalid-semver", + targetVer: "1.27.3-gke.100", + want: false, + wantErr: true, + }, + { + name: "malformed target version syntax, want error", + minCPVer: "1.27.3-gke.100", + targetVer: "invalid-gke-rev", + want: false, + wantErr: true, + }, + { + name: "latest alias not supported, want error", + minCPVer: "latest", + targetVer: "1.27.3-gke.100", + want: false, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := shouldUpgradeRolloutSequence(tt.minCPVer, tt.targetVer) + if (err != nil) != tt.wantErr { + t.Errorf("shouldUpgradeRolloutSequence(%q, %q) error = %v, wantErr %v", tt.minCPVer, tt.targetVer, err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("shouldUpgradeRolloutSequence(%q, %q) = %v, want %v", tt.minCPVer, tt.targetVer, got, tt.want) + } + }) + } +} From dbafa9b55f0fe1246cf22699e9e73ccc93c1ce06 Mon Sep 17 00:00:00 2001 From: Vaibhav Rau Date: Wed, 22 Jul 2026 18:33:48 +0000 Subject: [PATCH 24/30] Add google_service_account_key list resource (#18242) --- .../list_google_service_account_key.go | 152 ++++++++++++++++++ .../list_google_service_account_key_test.go | 70 ++++++++ .../resource_google_service_account_key.go | 25 ++- .../google_service_account_key.html.markdown | 57 +++++++ 4 files changed, 303 insertions(+), 1 deletion(-) create mode 100644 mmv1/third_party/terraform/services/resourcemanager/list_google_service_account_key.go create mode 100644 mmv1/third_party/terraform/services/resourcemanager/list_google_service_account_key_test.go create mode 100644 mmv1/third_party/terraform/website/docs/list-resources/google_service_account_key.html.markdown diff --git a/mmv1/third_party/terraform/services/resourcemanager/list_google_service_account_key.go b/mmv1/third_party/terraform/services/resourcemanager/list_google_service_account_key.go new file mode 100644 index 000000000000..be7ce31e9b54 --- /dev/null +++ b/mmv1/third_party/terraform/services/resourcemanager/list_google_service_account_key.go @@ -0,0 +1,152 @@ +// Copyright (c) IBM Corp. 2014, 2026 +// SPDX-License-Identifier: MPL-2.0 + +package resourcemanager + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/list" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + "google.golang.org/api/iam/v1" + + "github.com/hashicorp/terraform-provider-google/google/registry" + "github.com/hashicorp/terraform-provider-google/google/services/iambeta" + "github.com/hashicorp/terraform-provider-google/google/tpgresource" + transport_tpg "github.com/hashicorp/terraform-provider-google/google/transport" +) + +func init() { + registry.FrameworkListResource{ + Name: "google_service_account_key", + ProductName: "resourcemanager", + Func: NewGoogleServiceAccountKeyListResource, + }.Register() +} + +type GoogleServiceAccountKeyListResource struct { + tpgresource.ListResourceMetadata +} + +// GoogleServiceAccountKeyListModel matches [ListResourceMetadata.ListConfigFields] (tfsdk names and types). +type GoogleServiceAccountKeyListModel struct { + ServiceAccountID types.String `tfsdk:"service_account_id"` +} + +func NewGoogleServiceAccountKeyListResource() list.ListResource { + listR := &GoogleServiceAccountKeyListResource{} + listR.TypeName = "google_service_account_key" + listR.SDKv2Resource = ResourceGoogleServiceAccountKey() + listR.ListConfigFields = []tpgresource.ListConfigField{{Name: "service_account_id", Kind: tpgresource.ListConfigKindString, Optional: false}} + return listR +} + +func (listR *GoogleServiceAccountKeyListResource) List(ctx context.Context, listReq list.ListRequest, stream *list.ListResultsStream) { + var data GoogleServiceAccountKeyListModel + diags := listReq.Config.Get(ctx, &data) + if diags.HasError() { + stream.Results = list.ListResultsStreamDiagnostics(diags) + return + } + if data.ServiceAccountID.IsNull() || data.ServiceAccountID.ValueString() == "" { + diags = append(diags, diag.NewErrorDiagnostic( + "Missing required argument", + `The "service_account_id" argument is required and must not be empty.`, + )) + stream.Results = list.ListResultsStreamDiagnostics(diags) + return + } + if listR.Client == nil { + diags = append(diags, diag.NewErrorDiagnostic( + "Provider not configured", + "The Google provider client is not available; ensure the provider is configured (e.g. credentials and default project).", + )) + stream.Results = list.ListResultsStreamDiagnostics(diags) + return + } + serviceAccountID := data.ServiceAccountID.ValueString() + + errServiceAccountKeyListStreamClosed := errors.New("stream closed") + stream.Results = func(push func(list.ListResult) bool) { + err := ListServiceAccountKeys(listR.Client, serviceAccountID, func(rd *schema.ResourceData) error { + result := listReq.NewListResult(ctx) + + if err := listR.SetResult(ctx, listReq.IncludeResource, &result, rd, "name"); err != nil { + return err + } + + if !push(result) { + return errServiceAccountKeyListStreamClosed + } + return nil + }) + // A closed stream is not an error: return without pushing again. + if err == nil || errors.Is(err, errServiceAccountKeyListStreamClosed) { + return + } + diags.AddError("API Error", err.Error()) + result := listReq.NewListResult(ctx) + result.Diagnostics = diags + push(result) + } +} + +func flattenGoogleServiceAccountKeyListItem(res map[string]interface{}, d *schema.ResourceData, config *transport_tpg.Config) error { + var key iam.ServiceAccountKey + if err := tpgresource.Convert(res, &key); err != nil { + return err + } + d.SetId(key.Name) + return flattenServiceAccountKey(d, config, &key) +} + +func ListServiceAccountKeys(config *transport_tpg.Config, serviceAccountID string, callback func(rd *schema.ResourceData) error) error { + if config == nil { + return fmt.Errorf("provider client is not configured") + } + d := ResourceGoogleServiceAccountKey().Data(&terraform.InstanceState{}) + if err := d.Set("service_account_id", serviceAccountID); err != nil { + return fmt.Errorf("error setting service_account_id on temporary resource data: %w", err) + } + + var ( + url string + err error + ) + if strings.Contains(serviceAccountID, "projects/") { + url = transport_tpg.BaseUrl(iambeta.Product, config) + serviceAccountID + "/keys" + } else { + url, err = tpgresource.ReplaceVars(d, config, transport_tpg.BaseUrl(iambeta.Product, config)+"projects/-/serviceAccounts/{{service_account_id}}/keys") + if err != nil { + return err + } + } + + billingProject := "" + if bp, err := tpgresource.GetBillingProject(d, config); err == nil { + billingProject = bp + } + + userAgent, err := tpgresource.GenerateUserAgentString(d, config.UserAgent) + if err != nil { + return err + } + + return transport_tpg.ListPages(transport_tpg.ListPagesOptions{ + Config: config, + TempData: d, + Resource: ResourceGoogleServiceAccountKey(), + ListURL: url, + BillingProject: billingProject, + UserAgent: userAgent, + ItemName: "keys", + Flattener: flattenGoogleServiceAccountKeyListItem, + Callback: callback, + }) +} diff --git a/mmv1/third_party/terraform/services/resourcemanager/list_google_service_account_key_test.go b/mmv1/third_party/terraform/services/resourcemanager/list_google_service_account_key_test.go new file mode 100644 index 000000000000..9d42f017a744 --- /dev/null +++ b/mmv1/third_party/terraform/services/resourcemanager/list_google_service_account_key_test.go @@ -0,0 +1,70 @@ +package resourcemanager_test + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/querycheck" + "github.com/hashicorp/terraform-plugin-testing/tfversion" + + "github.com/hashicorp/terraform-provider-google/google/acctest" + "github.com/hashicorp/terraform-provider-google/google/envvar" + _ "github.com/hashicorp/terraform-provider-google/google/services/resourcemanager" +) + +func TestAccListGoogleServiceAccountKey_queryIdentity(t *testing.T) { + t.Parallel() + + accountId := fmt.Sprintf("tf-test-%s", acctest.RandString(t, 10)) + project := envvar.GetTestProjectFromEnv() + + acctest.VcrTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_14_0), + }, + PreCheck: func() { acctest.AccTestPreCheck(t) }, + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories(t), + Steps: []resource.TestStep{ + { + Config: testAccListGoogleServiceAccountKey_setup(project, accountId), + }, + { + Query: true, + Config: testAccListGoogleServiceAccountKey_query(project, accountId), + QueryResultChecks: []querycheck.QueryResultCheck{ + querycheck.ExpectLengthAtLeast("google_service_account_key.all", 1), + querycheck.ExpectIdentity("google_service_account_key.all", map[string]knownvalue.Check{ + "name": knownvalue.NotNull(), + }), + }, + }, + }, + }) +} + +func testAccListGoogleServiceAccountKey_setup(project, accountId string) string { + return fmt.Sprintf(` +resource "google_service_account" "sa" { + project = "%s" + account_id = "%s" + display_name = "Test SA for key listing" +} + +resource "google_service_account_key" "key" { + service_account_id = google_service_account.sa.name +} +`, project, accountId) +} + +func testAccListGoogleServiceAccountKey_query(project, accountId string) string { + return fmt.Sprintf(` +list "google_service_account_key" "all" { + provider = google + config { + service_account_id = "projects/%s/serviceAccounts/%s@%s.iam.gserviceaccount.com" + } +} +`, project, accountId, project) +} diff --git a/mmv1/third_party/terraform/services/resourcemanager/resource_google_service_account_key.go b/mmv1/third_party/terraform/services/resourcemanager/resource_google_service_account_key.go index 96a4ea881680..f0197a0031bd 100644 --- a/mmv1/third_party/terraform/services/resourcemanager/resource_google_service_account_key.go +++ b/mmv1/third_party/terraform/services/resourcemanager/resource_google_service_account_key.go @@ -23,6 +23,18 @@ func ResourceGoogleServiceAccountKey() *schema.Resource { Update: resourceGoogleServiceAccountKeyUpdate, Delete: resourceGoogleServiceAccountKeyDelete, + Identity: &schema.ResourceIdentity{ + Version: 1, + SchemaFunc: func() map[string]*schema.Schema { + return map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + RequiredForImport: true, + }, + } + }, + }, + CustomizeDiff: customdiff.All( tpgresource.DefaultProviderDeletionPolicy("DELETE"), ), @@ -142,6 +154,11 @@ func resourceGoogleServiceAccountKeyCreate(d *schema.ResourceData, meta interfac } d.SetId(sak.Name) + if err := tpgresource.SetResourceIdentityAttributes(d, map[string]interface{}{ + "name": sak.Name, + }); err != nil { + return err + } // Data only available on create. if err := d.Set("valid_after", sak.ValidAfterTime); err != nil { return fmt.Errorf("Error setting valid_after: %s", err) @@ -192,7 +209,13 @@ func resourceGoogleServiceAccountKeyRead(d *schema.ResourceData, meta interface{ } } - return flattenServiceAccountKey(d, config, sak) + if err := flattenServiceAccountKey(d, config, sak); err != nil { + return err + } + + return tpgresource.SetResourceIdentityAttributes(d, map[string]interface{}{ + "name": sak.Name, + }) } func flattenServiceAccountKey(d *schema.ResourceData, config *transport_tpg.Config, sak *iam.ServiceAccountKey) error { diff --git a/mmv1/third_party/terraform/website/docs/list-resources/google_service_account_key.html.markdown b/mmv1/third_party/terraform/website/docs/list-resources/google_service_account_key.html.markdown new file mode 100644 index 000000000000..d6654d2f2358 --- /dev/null +++ b/mmv1/third_party/terraform/website/docs/list-resources/google_service_account_key.html.markdown @@ -0,0 +1,57 @@ +--- +subcategory: "Cloud Platform" +page_title: "google_service_account_key (list)" +description: |- + List Google Cloud IAM service account keys for a service account for use with + terraform query and .tfquery.hcl files. +--- + +# google_service_account_key (list) + +## Overview + +Lists service account keys belonging to a given service account. + +Use this list resource with +[`terraform query`](https://developer.hashicorp.com/terraform/cli/commands/query) +and **`.tfquery.hcl`** files. + +For how list resources work in this provider, file layout, Terraform version requirements, and +shared `list` block arguments, refer to the guide +[Use list resources with terraform query](https://registry.terraform.io/providers/hashicorp/google/latest/docs/guides/using_list_resources_with_terraform_query). + +## Example Usage + +```hcl +list "google_service_account_key" "all" { + provider = google + + config { + service_account_id = "projects/my-project/serviceAccounts/my-sa@my-project.iam.gserviceaccount.com" + } +} +``` + +Run `terraform query` from the directory that contains the `.tfquery.hcl` file. + +## Configuration (`config` block) + +* `service_account_id` - (Required) Parent service account to list keys for. This may be either + the full resource name (for example + `projects/my-project/serviceAccounts/my-sa@my-project.iam.gserviceaccount.com`) or the + service account email. + +## Identity Attributes + +By default each result includes **resource identity** for `google_service_account_key` (see +[Resource identity](https://developer.hashicorp.com/terraform/language/resources/identities)): + +* `service_account_id` - Parent service account identifier. +* `name` - Full key resource name, such as + `projects/my-project/serviceAccounts/my-sa@my-project.iam.gserviceaccount.com/keys/1234567890abcdef`. + +## Resource Attributes + +With `include_resource = true` on the `list` block, results also include the full resource-style +attributes documented for the managed +[`google_service_account_key` resource](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/google_service_account_key#attributes-reference). From 1b4ac978dac64a844034e468be5dfa6e73f4d68c Mon Sep 17 00:00:00 2001 From: Nikita Samartsev Date: Wed, 22 Jul 2026 19:30:39 +0000 Subject: [PATCH 25/30] Full migration `resource_compute_region_instance_template.go.tmpl` resource to use direct HTTP rather then a client library (#18138) Co-authored-by: Scott Suarez --- mmv1/provider/terraform_tgc.go | 31 +- mmv1/provider/terraform_tgc_next.go | 9 +- .../compute/compute_instance_helpers.go.tmpl | 28 +- .../compute_instance_http_helpers.go.tmpl | 471 ++++++++++++++++++ ...resource_compute_instance_template.go.tmpl | 185 +------ ...e_compute_region_instance_template.go.tmpl | 278 +++++------ 6 files changed, 654 insertions(+), 348 deletions(-) create mode 100644 mmv1/third_party/terraform/services/compute/compute_instance_http_helpers.go.tmpl diff --git a/mmv1/provider/terraform_tgc.go b/mmv1/provider/terraform_tgc.go index ec25e4837429..1c268a260c42 100644 --- a/mmv1/provider/terraform_tgc.go +++ b/mmv1/provider/terraform_tgc.go @@ -190,21 +190,22 @@ func (tgc TerraformGoogleConversion) CompileCommonFiles(outputFolder string, pro templateData := NewTemplateData(outputFolder, tgc.TargetVersionName, tgc.templateFS) resourceConverters := map[string]string{ - "converters/google/resources/resource_converters.go": "third_party/tgc/resource_converters.go.tmpl", - "converters/google/resources/services/bigquery/iam_bigquery_dataset.go": "third_party/terraform/services/bigquery/iam_bigquery_dataset.go.tmpl", - "converters/google/resources/services/compute/client.go": "third_party/terraform/services/compute/client.go.tmpl", - "converters/google/resources/services/compute/compute_instance.go": "third_party/tgc/services/compute/compute_instance.go.tmpl", - "converters/google/resources/services/compute/compute_instance_helpers.go": "third_party/terraform/services/compute/compute_instance_helpers.go.tmpl", - "converters/google/resources/services/compute/metadata.go": "third_party/terraform/services/compute/metadata.go.tmpl", - "converters/google/resources/services/kms/iam_kms_crypto_key.go": "third_party/terraform/services/kms/iam_kms_crypto_key.go.tmpl", - "converters/google/resources/services/kms/iam_kms_key_ring.go": "third_party/terraform/services/kms/iam_kms_key_ring.go.tmpl", - "converters/google/resources/services/pubsub/iam_pubsub_subscription.go": "third_party/terraform/services/pubsub/iam_pubsub_subscription.go.tmpl", - "converters/google/resources/services/resourcemanager/iam_folder.go": "third_party/terraform/services/resourcemanager/iam_folder.go.tmpl", - "converters/google/resources/services/resourcemanager/iam_organization.go": "third_party/terraform/services/resourcemanager/iam_organization.go.tmpl", - "converters/google/resources/services/resourcemanager/iam_project.go": "third_party/terraform/services/resourcemanager/iam_project.go.tmpl", - "converters/google/resources/services/spanner/client.go": "third_party/terraform/services/spanner/client.go", - "converters/google/resources/services/spanner/iam_spanner_database.go": "third_party/terraform/services/spanner/iam_spanner_database.go.tmpl", - "converters/google/resources/services/spanner/iam_spanner_instance.go": "third_party/terraform/services/spanner/iam_spanner_instance.go.tmpl", + "converters/google/resources/resource_converters.go": "third_party/tgc/resource_converters.go.tmpl", + "converters/google/resources/services/bigquery/iam_bigquery_dataset.go": "third_party/terraform/services/bigquery/iam_bigquery_dataset.go.tmpl", + "converters/google/resources/services/compute/client.go": "third_party/terraform/services/compute/client.go.tmpl", + "converters/google/resources/services/compute/compute_instance.go": "third_party/tgc/services/compute/compute_instance.go.tmpl", + "converters/google/resources/services/compute/compute_instance_helpers.go": "third_party/terraform/services/compute/compute_instance_helpers.go.tmpl", + "converters/google/resources/services/compute/compute_instance_http_helpers.go": "third_party/terraform/services/compute/compute_instance_http_helpers.go.tmpl", + "converters/google/resources/services/compute/metadata.go": "third_party/terraform/services/compute/metadata.go.tmpl", + "converters/google/resources/services/kms/iam_kms_crypto_key.go": "third_party/terraform/services/kms/iam_kms_crypto_key.go.tmpl", + "converters/google/resources/services/kms/iam_kms_key_ring.go": "third_party/terraform/services/kms/iam_kms_key_ring.go.tmpl", + "converters/google/resources/services/pubsub/iam_pubsub_subscription.go": "third_party/terraform/services/pubsub/iam_pubsub_subscription.go.tmpl", + "converters/google/resources/services/resourcemanager/iam_folder.go": "third_party/terraform/services/resourcemanager/iam_folder.go.tmpl", + "converters/google/resources/services/resourcemanager/iam_organization.go": "third_party/terraform/services/resourcemanager/iam_organization.go.tmpl", + "converters/google/resources/services/resourcemanager/iam_project.go": "third_party/terraform/services/resourcemanager/iam_project.go.tmpl", + "converters/google/resources/services/spanner/client.go": "third_party/terraform/services/spanner/client.go", + "converters/google/resources/services/spanner/iam_spanner_database.go": "third_party/terraform/services/spanner/iam_spanner_database.go.tmpl", + "converters/google/resources/services/spanner/iam_spanner_instance.go": "third_party/terraform/services/spanner/iam_spanner_instance.go.tmpl", } filteredFiles := make(map[string]string) diff --git a/mmv1/provider/terraform_tgc_next.go b/mmv1/provider/terraform_tgc_next.go index c3181dcec152..2b120d6c888a 100644 --- a/mmv1/provider/terraform_tgc_next.go +++ b/mmv1/provider/terraform_tgc_next.go @@ -192,10 +192,11 @@ func (tgc TerraformGoogleConversionNext) CompileCommonFiles(outputFolder string, "pkg/registry/registry.go": "third_party/terraform/registry/registry.go", // services - "pkg/services/compute/client.go": "third_party/terraform/services/compute/client.go.tmpl", - "pkg/services/compute/compute_instance_helpers.go": "third_party/terraform/services/compute/compute_instance_helpers.go.tmpl", - "pkg/services/compute/metadata.go": "third_party/terraform/services/compute/metadata.go.tmpl", - "pkg/services/container/client.go": "third_party/terraform/services/container/client.go.tmpl", + "pkg/services/compute/client.go": "third_party/terraform/services/compute/client.go.tmpl", + "pkg/services/compute/compute_instance_helpers.go": "third_party/terraform/services/compute/compute_instance_helpers.go.tmpl", + "pkg/services/compute/compute_instance_http_helpers.go": "third_party/terraform/services/compute/compute_instance_http_helpers.go.tmpl", + "pkg/services/compute/metadata.go": "third_party/terraform/services/compute/metadata.go.tmpl", + "pkg/services/container/client.go": "third_party/terraform/services/container/client.go.tmpl", // tfplan2cai "pkg/tfplan2cai/converters/resource_converters.go": "templates/tgc_next/tfplan2cai/resource_converters.go.tmpl", diff --git a/mmv1/third_party/terraform/services/compute/compute_instance_helpers.go.tmpl b/mmv1/third_party/terraform/services/compute/compute_instance_helpers.go.tmpl index cbef1d21c0f2..4f60e91791e0 100644 --- a/mmv1/third_party/terraform/services/compute/compute_instance_helpers.go.tmpl +++ b/mmv1/third_party/terraform/services/compute/compute_instance_helpers.go.tmpl @@ -188,6 +188,12 @@ func expandScheduling(v interface{}) (map[string]interface{}, error) { if v, ok := original["provisioning_model"]; ok { result["provisioningModel"] = v.(string) } + + omitDurations := false + if pm, ok := result["provisioningModel"].(string); ok && pm == "RESERVATION_BOUND" { + omitDurations = true + } + if v, ok := original["instance_termination_action"]; ok { result["instanceTerminationAction"] = v.(string) } @@ -199,7 +205,9 @@ func expandScheduling(v interface{}) (map[string]interface{}, error) { if err != nil { return nil, err } - result["maxRunDuration"] = transformedMaxRunDuration + if transformedMaxRunDuration != nil || !omitDurations { + result["maxRunDuration"] = transformedMaxRunDuration + } } if v, ok := original["on_instance_stop_action"]; ok { @@ -207,7 +215,9 @@ func expandScheduling(v interface{}) (map[string]interface{}, error) { if err != nil { return nil, err } - result["onInstanceStopAction"] = transformedOnInstanceStopAction + if transformedOnInstanceStopAction != nil || !omitDurations { + result["onInstanceStopAction"] = transformedOnInstanceStopAction + } } {{- if ne $.TargetVersionName "ga" }} if v, ok := original["host_error_timeout_seconds"]; ok { @@ -229,7 +239,9 @@ func expandScheduling(v interface{}) (map[string]interface{}, error) { if err != nil { return nil, err } - result["gracefulShutdown"] = transformedGracefulShutdown + if transformedGracefulShutdown != nil || !omitDurations { + result["gracefulShutdown"] = transformedGracefulShutdown + } } if v, ok := original["skip_guest_os_shutdown"]; ok { @@ -241,7 +253,9 @@ func expandScheduling(v interface{}) (map[string]interface{}, error) { if err != nil { return nil, err } - result["preemptionNoticeDuration"] = transformedPreemptionNoticeDuration + if transformedPreemptionNoticeDuration != nil || !omitDurations { + result["preemptionNoticeDuration"] = transformedPreemptionNoticeDuration + } } {{- end }} if v, ok := original["local_ssd_recovery_timeout"]; ok { @@ -249,7 +263,9 @@ func expandScheduling(v interface{}) (map[string]interface{}, error) { if err != nil { return nil, err } - result["localSsdRecoveryTimeout"] = transformedLocalSsdRecoveryTimeout + if transformedLocalSsdRecoveryTimeout != nil || !omitDurations { + result["localSsdRecoveryTimeout"] = transformedLocalSsdRecoveryTimeout + } } if v, ok := original["termination_time"]; ok && v.(string) != "" { result["terminationTime"] = v.(string) @@ -726,7 +742,7 @@ func flattenNetworkInterfaces(d *schema.ResourceData, config *transport_tpg.Conf "subnetwork": tpgresource.ConvertSelfLinkToV1(subnetwork), "subnetwork_project": subnet.Project, "access_config": ac, - "alias_ip_range": flattenAliasIpRange(d, aliasIpRanges, i), + "alias_ip_range": FlattenAliasIpRangeHTTP(d, aliasIpRanges, i), {{ if ne $.TargetVersionName `ga` -}} "alias_ipv6_range": flattenIpv6AliasRange(d, getInterfaceSlice(iface["aliasIpv6Ranges"]), i), {{- end }} diff --git a/mmv1/third_party/terraform/services/compute/compute_instance_http_helpers.go.tmpl b/mmv1/third_party/terraform/services/compute/compute_instance_http_helpers.go.tmpl new file mode 100644 index 000000000000..80ee1177d330 --- /dev/null +++ b/mmv1/third_party/terraform/services/compute/compute_instance_http_helpers.go.tmpl @@ -0,0 +1,471 @@ +package compute + +import ( + "fmt" + "reflect" + "strconv" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-provider-google/google/tpgresource" +) + +var DEFAULT_SCRATCH_DISK_SIZE_GB = 375 +var VALID_SCRATCH_DISK_SIZES_GB = []int{375, 3000, 3500, 7000, 14000} + +// FlattenAliasIpRangeHTTP replaces the legacy SDK-dependent flattenAliasIpRange. +// It operates directly on API JSON response maps, allowing all direct-HTTP compute resources to share it. +func FlattenAliasIpRangeHTTP(d *schema.ResourceData, ranges []interface{}, i int) []map[string]interface{} { + prefix := fmt.Sprintf("network_interface.%d", i) + + configData := []map[string]interface{}{} + for _, item := range d.Get(prefix + ".alias_ip_range").([]interface{}) { + configData = append(configData, item.(map[string]interface{})) + } + + apiData := make([]map[string]interface{}, 0, len(ranges)) + for _, raw := range ranges { + ipRange, ok := raw.(map[string]interface{}) + if !ok || ipRange == nil { + continue + } + + ipCidrRange, _ := ipRange["ipCidrRange"].(string) + subnetworkRangeName, _ := ipRange["subnetworkRangeName"].(string) + + apiData = append(apiData, map[string]interface{}{ + "ip_cidr_range": ipCidrRange, + "subnetwork_range_name": subnetworkRangeName, + }) + } + + // Preserve config ordering to prevent permadiffs + sorted, err := tpgresource.SortMapsByConfigOrder(configData, apiData, "ip_cidr_range") + if err != nil { + return apiData + } + return sorted +} + +func FlattenDisksHTTP(disks []interface{}, d *schema.ResourceData, defaultProject string) ([]map[string]interface{}, error) { + apiDisks := make([]map[string]interface{}, len(disks)) + for i, diskRaw := range disks { + disk := diskRaw.(map[string]interface{}) + configDisk := d.Get(fmt.Sprintf("disk.%d", i)).(map[string]any) + apiDisk, err := FlattenDiskHTTP(disk, configDisk, defaultProject) + if err != nil { + return nil, err + } + apiDisks[i] = apiDisk + } + return reorderDisks(d.Get("disk").([]interface{}), apiDisks), nil +} + +func FlattenDiskHTTP(disk map[string]interface{}, configDisk map[string]any, defaultProject string) (map[string]interface{}, error) { + diskMap := make(map[string]interface{}) + diskMap["source_image_encryption_key"] = configDisk["source_image_encryption_key"] + diskMap["source_snapshot"] = configDisk["source_snapshot"] + diskMap["source_snapshot_encryption_key"] = configDisk["source_snapshot_encryption_key"] + + if initParamsRaw, ok := disk["initializeParams"]; ok && initParamsRaw != nil { + initParams := initParamsRaw.(map[string]interface{}) + if srcImg, ok := initParams["sourceImage"].(string); ok && srcImg != "" { + path, err := resolveImageRefToRelativeURI(defaultProject, srcImg) + if err != nil { + return nil, err + } + diskMap["source_image"] = path + } else { + diskMap["source_image"] = "" + } + if v, ok := initParams["diskType"]; ok { diskMap["disk_type"] = v } + if v, ok := initParams["provisionedIops"]; ok { diskMap["provisioned_iops"] = ParseIntHTTP(v) } + if v, ok := initParams["provisionedThroughput"]; ok { diskMap["provisioned_throughput"] = ParseIntHTTP(v) } + if v, ok := initParams["diskName"]; ok { diskMap["disk_name"] = v } else { diskMap["disk_name"] = "" } + var diskLabels map[string]string + if lRaw, ok := initParams["labels"].(map[string]interface{}); ok { + diskLabels = make(map[string]string) + for k, v := range lRaw { + if str, ok := v.(string); ok { + diskLabels[k] = str + } + } + } + diskMap["labels"] = diskLabels + + var diskSizeGb int + if v, ok := disk["diskSizeGb"]; ok { diskSizeGb = ParseIntHTTP(v) } + var initDiskSizeGb int + if v, ok := initParams["diskSizeGb"]; ok { initDiskSizeGb = ParseIntHTTP(v) } + diskTypeStr, _ := disk["type"].(string) + + if diskSizeGb == 0 && initDiskSizeGb == 0 && diskTypeStr == "SCRATCH" { + diskMap["disk_size_gb"] = DEFAULT_SCRATCH_DISK_SIZE_GB + } else if diskSizeGb != 0 { + diskMap["disk_size_gb"] = diskSizeGb + } else { + diskMap["disk_size_gb"] = initDiskSizeGb + } + + var rPolicies []string + if rpRaw, ok := initParams["resourcePolicies"].([]interface{}); ok { + for _, v := range rpRaw { + if str, ok := v.(string); ok { + rPolicies = append(rPolicies, str) + } + } + } + diskMap["resource_policies"] = rPolicies + + var rmTags map[string]string + if rmtRaw, ok := initParams["resourceManagerTags"].(map[string]interface{}); ok { + rmTags = make(map[string]string) + for k, v := range rmtRaw { + if str, ok := v.(string); ok { + rmTags[k] = str + } + } + } + diskMap["resource_manager_tags"] = rmTags + if sp, ok := initParams["storagePool"].(string); ok && sp != "" { + diskMap["storage_pool"] = tpgresource.GetResourceNameFromSelfLink(sp) + } + } + + if encKeyRaw, ok := disk["diskEncryptionKey"]; ok && encKeyRaw != nil { + encKey := encKeyRaw.(map[string]interface{}) + encryption := make([]map[string]interface{}, 1) + encryption[0] = make(map[string]interface{}) + encryption[0]["kms_key_self_link"] = encKey["kmsKeyName"] + if diskEncryptionKey, ok := configDisk["disk_encryption_key"].([]interface{}); ok && len(diskEncryptionKey) > 0 { + if encryptionKeyMap, ok := diskEncryptionKey[0].(map[string]interface{}); ok { + if kmsSa, ok := encryptionKeyMap["kms_key_service_account"].(string); ok && kmsSa != "" { + encryption[0]["kms_key_service_account"] = kmsSa + } + } + } + diskMap["disk_encryption_key"] = encryption + } + + var guestOsFeaturesSlice []interface{} + if gofRaw, ok := disk["guestOsFeatures"].([]interface{}); ok { + for _, fRaw := range gofRaw { + if f, ok := fRaw.(map[string]interface{}); ok { + if typ, ok := f["type"].(string); ok && typ != "" { + guestOsFeaturesSlice = append(guestOsFeaturesSlice, map[string]interface{}{"type": typ}) + } + } + } + } + if v, ok := disk["autoDelete"]; ok { diskMap["auto_delete"] = v } else { diskMap["auto_delete"] = false } + if v, ok := disk["boot"]; ok { diskMap["boot"] = v } else { diskMap["boot"] = false } + if v, ok := disk["deviceName"]; ok { diskMap["device_name"] = v } else { diskMap["device_name"] = "" } + if v, ok := disk["interface"]; ok { diskMap["interface"] = v } else { diskMap["interface"] = "" } + if src, ok := disk["source"].(string); ok { + diskMap["source"] = tpgresource.ConvertSelfLinkToV1(src) + } else { + diskMap["source"] = "" + } + if v, ok := disk["mode"]; ok { diskMap["mode"] = v } else { diskMap["mode"] = "" } + if v, ok := disk["type"]; ok { diskMap["type"] = v } else { diskMap["type"] = "" } + diskMap["guest_os_features"] = flattenComputeInstanceGuestOsFeatures(guestOsFeaturesSlice) + diskMap["architecture"] = configDisk["architecture"] + + return diskMap, nil +} + +func FlattenSchedulingHTTP(resp map[string]interface{}) []map[string]interface{} { + schedulingMap := map[string]interface{}{} + v_ohm, _ := resp["onHostMaintenance"].(string) + schedulingMap["on_host_maintenance"] = v_ohm + v_p, _ := resp["preemptible"].(bool) + schedulingMap["preemptible"] = v_p + schedulingMap["min_node_cpus"] = ParseIntHTTP(resp["minNodeCpus"]) + v_pm, _ := resp["provisioningModel"].(string) + schedulingMap["provisioning_model"] = v_pm + v_ita, _ := resp["instanceTerminationAction"].(string) + schedulingMap["instance_termination_action"] = v_ita + schedulingMap["availability_domain"] = ParseIntHTTP(resp["availabilityDomain"]) + v_tt, _ := resp["terminationTime"].(string) + schedulingMap["termination_time"] = v_tt + if v, ok := resp["automaticRestart"]; ok { schedulingMap["automatic_restart"] = v } + v_sgos, _ := resp["skipGuestOsShutdown"].(bool) + schedulingMap["skip_guest_os_shutdown"] = v_sgos +{{- if ne $.TargetVersionName "ga" }} + if v, ok := resp["maintenanceInterval"]; ok { schedulingMap["maintenance_interval"] = v } + if v, ok := resp["hostErrorTimeoutSeconds"]; ok { schedulingMap["host_error_timeout_seconds"] = ParseIntHTTP(v) } +{{- end }} + + if maxDurRaw, ok := resp["maxRunDuration"]; ok && maxDurRaw != nil { + maxDur := maxDurRaw.(map[string]interface{}) + schedulingMap["max_run_duration"] = []map[string]interface{}{ + { + "seconds": ParseIntHTTP(maxDur["seconds"]), + "nanos": ParseIntHTTP(maxDur["nanos"]), + }, + } + } + + if stopRaw, ok := resp["onInstanceStopAction"]; ok && stopRaw != nil { + stop := stopRaw.(map[string]interface{}) + schedulingMap["on_instance_stop_action"] = []map[string]interface{}{ + { + "discard_local_ssd": stop["discardLocalSsd"], + }, + } + } + + if timeoutRaw, ok := resp["localSsdRecoveryTimeout"]; ok && timeoutRaw != nil { + timeout := timeoutRaw.(map[string]interface{}) + schedulingMap["local_ssd_recovery_timeout"] = []map[string]interface{}{ + { + "seconds": ParseIntHTTP(timeout["seconds"]), + "nanos": ParseIntHTTP(timeout["nanos"]), + }, + } + } + +{{- if ne $.TargetVersionName "ga" }} + if gracefulRaw, ok := resp["gracefulShutdown"]; ok && gracefulRaw != nil { + graceful := gracefulRaw.(map[string]interface{}) + graceMap := map[string]interface{}{ + "enabled": graceful["enabled"], + } + if maxDurRaw, ok := graceful["maxDuration"]; ok && maxDurRaw != nil { + maxDur := maxDurRaw.(map[string]interface{}) + graceMap["max_duration"] = []map[string]interface{}{ + { + "seconds": ParseIntHTTP(maxDur["seconds"]), + "nanos": ParseIntHTTP(maxDur["nanos"]), + }, + } + } + schedulingMap["graceful_shutdown"] = []map[string]interface{}{graceMap} + } +{{- end }} + + if pndRaw, ok := resp["preemptionNoticeDuration"]; ok && pndRaw != nil { + pnd := pndRaw.(map[string]interface{}) + schedulingMap["preemption_notice_duration"] = []map[string]interface{}{ + { + "seconds": ParseIntHTTP(pnd["seconds"]), + "nanos": ParseIntHTTP(pnd["nanos"]), + }, + } + } + + if naRaw, ok := resp["nodeAffinities"].([]interface{}); ok && naRaw != nil { + nodeAffinities := make([]map[string]interface{}, len(naRaw)) + for i, naItem := range naRaw { + na := naItem.(map[string]interface{}) + nodeAffinity := map[string]interface{}{ + "key": na["key"], + "operator": na["operator"], + } + if valsRaw, ok := na["values"].([]interface{}); ok { + nodeAffinity["values"] = valsRaw + } + nodeAffinities[i] = nodeAffinity + } + schedulingMap["node_affinities"] = nodeAffinities + } + + return []map[string]interface{}{schedulingMap} +} + +func ParseIntHTTP(v interface{}) int { + if v == nil { + return 0 + } + switch val := v.(type) { + case string: + parsed, _ := strconv.Atoi(val) + return parsed + case float64: + return int(val) + case int: + return val + case int64: + return int(val) + default: + return 0 + } +} + +func FlattenWorkloadIdentityConfigHTTP(v interface{}) []map[string]interface{} { + if v == nil { + return nil + } + config := v.(map[string]interface{}) + return []map[string]interface{}{ + { + "identity": config["identity"], + "identity_certificate_enabled": config["identityCertificateEnabled"], + }, + } +} + +type diskCharacteristics struct { + mode string + diskType string + diskSizeGb string + autoDelete bool + sourceImage string + provisionedIops string + provisionedThroughput string +} + +func diskCharacteristicsFromMap(m map[string]interface{}) diskCharacteristics { + dc := diskCharacteristics{} + if v := m["mode"]; v == nil || v.(string) == "" { + // mode has an apply-time default of READ_WRITE + dc.mode = "READ_WRITE" + } else { + dc.mode = v.(string) + } + + if v := m["disk_type"]; v != nil { + dc.diskType = v.(string) + } + + if v := m["disk_size_gb"]; v != nil { + // Terraform and GCP return ints as different types (int vs int64), so just + // use strings to compare for simplicity. + dc.diskSizeGb = fmt.Sprintf("%v", v) + } + + if v := m["auto_delete"]; v != nil { + dc.autoDelete = v.(bool) + } + + if v := m["source_image"]; v != nil { + dc.sourceImage = v.(string) + } + + if v := m["provisioned_iops"]; v != nil { + // Terraform and GCP return ints as different types (int vs int64), so just + // use strings to compare for simplicity. + dc.provisionedIops = fmt.Sprintf("%v", v) + } + + if v := m["provisioned_throughput"]; v != nil { + // Terraform and GCP return ints as different types (int vs int64), so just + // use strings to compare for simplicity. + dc.provisionedThroughput = fmt.Sprintf("%v", v) + } + + return dc +} + +func reorderDisks(configDisks []interface{}, apiDisks []map[string]interface{}) []map[string]interface{} { + if len(apiDisks) != len(configDisks) { + // There are different numbers of disks in state and returned from the API, so it's not + // worth trying to reorder them since it'll be a diff anyway. + return apiDisks + } + + result := make([]map[string]interface{}, len(apiDisks)) + + /* + Disks aren't necessarily returned from the API in the same order they were sent, so gather + information about the ones in state that we can use to map it back. We can't do this by + just looping over all of the disks, because you could end up matching things in the wrong + order. For example, if the config disks contain the following disks: + disk 1: auto delete = false, size = 10 + disk 2: auto delete = false, size = 10, device name = "disk 2" + disk 3: type = scratch + And the disks returned from the API are: + disk a: auto delete = false, size = 10, device name = "disk 2" + disk b: auto delete = false, size = 10, device name = "disk 1" + disk c: type = scratch + Then disk a will match disk 1, disk b won't match any disk, and c will match 3, making the + final order a, c, b, which is wrong. To get disk a to match disk 2, we have to go in order + of fields most specifically able to identify a disk to least. + */ + disksByDeviceName := map[string]int{} + scratchDisksByInterface := map[string][]int{} + attachedDisksBySource := map[string]int{} + attachedDisksByDiskName := map[string]int{} + attachedDisksByCharacteristics := []int{} + + for i, d := range configDisks { + if i == 0 { + // boot disk + continue + } + disk := d.(map[string]interface{}) + if v := disk["device_name"]; v.(string) != "" { + disksByDeviceName[v.(string)] = i + } else if v := disk["type"]; v.(string) == "SCRATCH" { + iface := disk["interface"].(string) + scratchDisksByInterface[iface] = append(scratchDisksByInterface[iface], i) + } else if v := disk["source"]; v.(string) != "" { + attachedDisksBySource[v.(string)] = i + } else if v := disk["disk_name"]; v.(string) != "" { + attachedDisksByDiskName[v.(string)] = i + } else { + attachedDisksByCharacteristics = append(attachedDisksByCharacteristics, i) + } + } + + // Align the disks, going from the most specific criteria to the least. + for _, apiDisk := range apiDisks { + // 1. This resource only works if the boot disk is the first one (which should be fixed + // separately), so put the boot disk first. + if apiDisk["boot"].(bool) { + result[0] = apiDisk + + // 2. All disks have a unique device name + } else if i, ok := disksByDeviceName[apiDisk["device_name"].(string)]; ok { + result[i] = apiDisk + + // 3. Scratch disks are all the same except device name and interface, so match them by + // interface. + } else if apiDisk["type"].(string) == "SCRATCH" { + iface := apiDisk["interface"].(string) + indexes := scratchDisksByInterface[iface] + if len(indexes) > 0 { + result[indexes[0]] = apiDisk + scratchDisksByInterface[iface] = indexes[1:] + } else { + result = append(result, apiDisk) + } + + // 4. Each attached disk will have a different source, so match by that. + } else if i, ok := attachedDisksBySource[apiDisk["source"].(string)]; ok { + result[i] = apiDisk + + // 5. If a disk was created for this resource via initializeParams, it will have a + // unique name. + } else if v, ok := apiDisk["disk_name"]; ok && attachedDisksByDiskName[v.(string)] != 0 { + result[attachedDisksByDiskName[v.(string)]] = apiDisk + + // 6. If no unique keys exist on this disk, then use a combination of its remaining + // characteristics to see whether it matches exactly. + } else { + found := false + for arrayIndex, i := range attachedDisksByCharacteristics { + configDisk := configDisks[i].(map[string]interface{}) + stateDc := diskCharacteristicsFromMap(configDisk) + readDc := diskCharacteristicsFromMap(apiDisk) + if reflect.DeepEqual(stateDc, readDc) { + result[i] = apiDisk + attachedDisksByCharacteristics = append(attachedDisksByCharacteristics[:arrayIndex], attachedDisksByCharacteristics[arrayIndex+1:]...) + found = true + break + } + } + if !found { + result = append(result, apiDisk) + } + } + } + + // Remove nils from map in case there were disks that could not be matched + ds := []map[string]interface{}{} + for _, d := range result { + if d != nil { + ds = append(ds, d) + } + } + return ds +} diff --git a/mmv1/third_party/terraform/services/compute/resource_compute_instance_template.go.tmpl b/mmv1/third_party/terraform/services/compute/resource_compute_instance_template.go.tmpl index 651acb7a281d..79e117fbd179 100644 --- a/mmv1/third_party/terraform/services/compute/resource_compute_instance_template.go.tmpl +++ b/mmv1/third_party/terraform/services/compute/resource_compute_instance_template.go.tmpl @@ -5,7 +5,6 @@ import ( "context" "encoding/json" "fmt" - "reflect" "strings" "strconv" "time" @@ -58,8 +57,6 @@ var ( } ) -var DEFAULT_SCRATCH_DISK_SIZE_GB = 375 -var VALID_SCRATCH_DISK_SIZES_GB = []int{375, 3000, 3500, 7000, 14000} func ResourceComputeInstanceTemplate() *schema.Resource { return &schema.Resource{ @@ -1875,58 +1872,6 @@ func resourceComputeInstanceTemplateUpdate(d *schema.ResourceData, meta interfac return resourceComputeInstanceTemplateRead(d, meta) } -type diskCharacteristics struct { - mode string - diskType string - diskSizeGb string - autoDelete bool - sourceImage string - provisionedIops string - provisionedThroughput string -} - -func diskCharacteristicsFromMap(m map[string]interface{}) diskCharacteristics { - dc := diskCharacteristics{} - if v := m["mode"]; v == nil || v.(string) == "" { - // mode has an apply-time default of READ_WRITE - dc.mode = "READ_WRITE" - } else { - dc.mode = v.(string) - } - - if v := m["disk_type"]; v != nil { - dc.diskType = v.(string) - } - - if v := m["disk_size_gb"]; v != nil { - // Terraform and GCP return ints as different types (int vs int64), so just - // use strings to compare for simplicity. - dc.diskSizeGb = fmt.Sprintf("%v", v) - } - - if v := m["auto_delete"]; v != nil { - dc.autoDelete = v.(bool) - } - - if v := m["source_image"]; v != nil { - dc.sourceImage = v.(string) - } - - if v := m["provisioned_iops"]; v != nil { - // Terraform and GCP return ints as different types (int vs int64), so just - // use strings to compare for simplicity. - dc.provisionedIops = fmt.Sprintf("%v", v) - } - - if v := m["provisioned_throughput"]; v != nil { - // Terraform and GCP return ints as different types (int vs int64), so just - // use strings to compare for simplicity. - dc.provisionedThroughput = fmt.Sprintf("%v", v) - } - - return dc -} - func flattenDisk(disk *compute.AttachedDisk, configDisk map[string]any, defaultProject string) (map[string]interface{}, error) { diskMap := make(map[string]interface{}) @@ -2001,120 +1946,6 @@ func flattenDisk(disk *compute.AttachedDisk, configDisk map[string]any, defaultP return diskMap, nil } -func reorderDisks(configDisks []interface{}, apiDisks []map[string]interface{}) []map[string]interface{} { - if len(apiDisks) != len(configDisks) { - // There are different numbers of disks in state and returned from the API, so it's not - // worth trying to reorder them since it'll be a diff anyway. - return apiDisks - } - - result := make([]map[string]interface{}, len(apiDisks)) - - /* - Disks aren't necessarily returned from the API in the same order they were sent, so gather - information about the ones in state that we can use to map it back. We can't do this by - just looping over all of the disks, because you could end up matching things in the wrong - order. For example, if the config disks contain the following disks: - disk 1: auto delete = false, size = 10 - disk 2: auto delete = false, size = 10, device name = "disk 2" - disk 3: type = scratch - And the disks returned from the API are: - disk a: auto delete = false, size = 10, device name = "disk 2" - disk b: auto delete = false, size = 10, device name = "disk 1" - disk c: type = scratch - Then disk a will match disk 1, disk b won't match any disk, and c will match 3, making the - final order a, c, b, which is wrong. To get disk a to match disk 2, we have to go in order - of fields most specifically able to identify a disk to least. - */ - disksByDeviceName := map[string]int{} - scratchDisksByInterface := map[string][]int{} - attachedDisksBySource := map[string]int{} - attachedDisksByDiskName := map[string]int{} - attachedDisksByCharacteristics := []int{} - - for i, d := range configDisks { - if i == 0 { - // boot disk - continue - } - disk := d.(map[string]interface{}) - if v := disk["device_name"]; v.(string) != "" { - disksByDeviceName[v.(string)] = i - } else if v := disk["type"]; v.(string) == "SCRATCH" { - iface := disk["interface"].(string) - scratchDisksByInterface[iface] = append(scratchDisksByInterface[iface], i) - } else if v := disk["source"]; v.(string) != "" { - attachedDisksBySource[v.(string)] = i - } else if v := disk["disk_name"]; v.(string) != "" { - attachedDisksByDiskName[v.(string)] = i - } else { - attachedDisksByCharacteristics = append(attachedDisksByCharacteristics, i) - } - } - - // Align the disks, going from the most specific criteria to the least. - for _, apiDisk := range apiDisks { - // 1. This resource only works if the boot disk is the first one (which should be fixed - // separately), so put the boot disk first. - if apiDisk["boot"].(bool) { - result[0] = apiDisk - - // 2. All disks have a unique device name - } else if i, ok := disksByDeviceName[apiDisk["device_name"].(string)]; ok { - result[i] = apiDisk - - // 3. Scratch disks are all the same except device name and interface, so match them by - // interface. - } else if apiDisk["type"].(string) == "SCRATCH" { - iface := apiDisk["interface"].(string) - indexes := scratchDisksByInterface[iface] - if len(indexes) > 0 { - result[indexes[0]] = apiDisk - scratchDisksByInterface[iface] = indexes[1:] - } else { - result = append(result, apiDisk) - } - - // 4. Each attached disk will have a different source, so match by that. - } else if i, ok := attachedDisksBySource[apiDisk["source"].(string)]; ok { - result[i] = apiDisk - - // 5. If a disk was created for this resource via initializeParams, it will have a - // unique name. - } else if v, ok := apiDisk["disk_name"]; ok && attachedDisksByDiskName[v.(string)] != 0 { - result[attachedDisksByDiskName[v.(string)]] = apiDisk - - // 6. If no unique keys exist on this disk, then use a combination of its remaining - // characteristics to see whether it matches exactly. - } else { - found := false - for arrayIndex, i := range attachedDisksByCharacteristics { - configDisk := configDisks[i].(map[string]interface{}) - stateDc := diskCharacteristicsFromMap(configDisk) - readDc := diskCharacteristicsFromMap(apiDisk) - if reflect.DeepEqual(stateDc, readDc) { - result[i] = apiDisk - attachedDisksByCharacteristics = append(attachedDisksByCharacteristics[:arrayIndex], attachedDisksByCharacteristics[arrayIndex+1:]...) - found = true - break - } - } - if !found { - result = append(result, apiDisk) - } - } - } - - // Remove nils from map in case there were disks that could not be matched - ds := []map[string]interface{}{} - for _, d := range result { - if d != nil { - ds = append(ds, d) - } - } - return ds -} - func flattenDisks(disks []*compute.AttachedDisk, d *schema.ResourceData, defaultProject string) ([]map[string]interface{}, error) { apiDisks := make([]map[string]interface{}, len(disks)) @@ -2330,12 +2161,16 @@ func resourceComputeInstanceTemplateRead(d *schema.ResourceData, meta interface{ // Workaroud: API doesn't update the scheduling.graceful_shutdown.max_duration.nanos field. // To avoid diff, we need to set the value from the state not from API response. if nanos, ok := d.GetOk("scheduling.0.graceful_shutdown.0.max_duration.0.nanos"); ok { - graceful_shutdown := scheduling[0]["graceful_shutdown"].([]interface{})[0].(map[string]interface{}) - max_duration := graceful_shutdown["max_duration"].([]interface{})[0].(map[string]interface{}) - max_duration["nanos"] = int64(nanos.(int)) - - graceful_shutdown["max_duration"] = []interface{}{max_duration} - scheduling[0]["graceful_shutdown"] = []interface{}{graceful_shutdown} + if len(scheduling) > 0 && scheduling[0]["graceful_shutdown"] != nil { + if gs, ok := scheduling[0]["graceful_shutdown"].([]interface{}); ok && len(gs) > 0 && gs[0] != nil { + if md, ok := gs[0].(map[string]interface{})["max_duration"].([]interface{}); ok && len(md) > 0 && md[0] != nil { + mdMap := md[0].(map[string]interface{}) + mdMap["nanos"] = int64(nanos.(int)) + gs[0].(map[string]interface{})["max_duration"] = []interface{}{mdMap} + scheduling[0]["graceful_shutdown"] = []interface{}{gs[0]} + } + } + } } {{- end }} if err = d.Set("scheduling", scheduling); err != nil { diff --git a/mmv1/third_party/terraform/services/compute/resource_compute_region_instance_template.go.tmpl b/mmv1/third_party/terraform/services/compute/resource_compute_region_instance_template.go.tmpl index 0d451b30891b..3065ea8f1cdb 100644 --- a/mmv1/third_party/terraform/services/compute/resource_compute_region_instance_template.go.tmpl +++ b/mmv1/third_party/terraform/services/compute/resource_compute_region_instance_template.go.tmpl @@ -2,9 +2,7 @@ package compute import ( - "encoding/json" "fmt" - "log" "strings" "time" @@ -18,11 +16,6 @@ import ( transport_tpg "github.com/hashicorp/terraform-provider-google/google/transport" "github.com/hashicorp/terraform-provider-google/google/verify" -{{ if eq $.TargetVersionName `ga` }} - "google.golang.org/api/compute/v1" -{{- else }} - compute "google.golang.org/api/compute/v0.beta" -{{- end }} ) func ResourceComputeRegionInstanceTemplate() *schema.Resource { @@ -1397,10 +1390,16 @@ func resourceComputeRegionInstanceTemplateCreate(d *schema.ResourceData, meta in serviceAccountsIface := expandServiceAccounts(d.Get("service_account").([]interface{})) instanceProperties := map[string]interface{}{ - "machineType": d.Get("machine_type").(string), - "disks": disks, - "networkInterfaces": networks, - "scheduling": scheduling, + "machineType": d.Get("machine_type").(string), + } + if len(disks) > 0 { + instanceProperties["disks"] = disks + } + if len(networks) > 0 { + instanceProperties["networkInterfaces"] = networks + } + if scheduling != nil { + instanceProperties["scheduling"] = scheduling } if d.Get("can_ip_forward").(bool) { instanceProperties["canIpForward"] = true @@ -1568,49 +1567,39 @@ func resourceComputeRegionInstanceTemplateRead(d *schema.ResourceData, meta inte default: numericId = fmt.Sprintf("%v", v) } - selfLink, ok := res["selfLink"].(string) - if !ok && res["selfLink"] != nil { - log.Printf("[WARN] resourceComputeRegionInstanceTemplateRead: unexpected type for selfLink: %T", res["selfLink"]) - } - delete(res, "id") + selfLink, _ := res["selfLink"].(string) - var schedulingMapFromResponse map[string]interface{} - if propsMap, ok := res["properties"].(map[string]interface{}); ok { - schedulingMapFromResponse, _ = propsMap["scheduling"].(map[string]interface{}) - delete(propsMap, "scheduling") + properties, ok := res["properties"].(map[string]interface{}) + if !ok { + properties = make(map[string]interface{}) } - resBytes, err := json.Marshal(res) - if err != nil { - return fmt.Errorf("Error marshaling instance template response: %s", err) - } - var instanceTemplate compute.InstanceTemplate - if err := json.Unmarshal(resBytes, &instanceTemplate); err != nil { - return fmt.Errorf("Error parsing instance template response: %s", err) - } - instanceTemplate.SelfLink = selfLink - - // Set the metadata fingerprint if there is one. - if instanceTemplate.Properties.Metadata != nil { - if err = d.Set("metadata_fingerprint", instanceTemplate.Properties.Metadata.Fingerprint); err != nil { - return fmt.Errorf("Error setting metadata_fingerprint: %s", err) + if properties["metadata"] != nil { + mdRaw, _ := properties["metadata"].(map[string]interface{}) + if fingerprint, ok := mdRaw["fingerprint"].(string); ok { + if err = d.Set("metadata_fingerprint", fingerprint); err != nil { + return fmt.Errorf("Error setting metadata_fingerprint: %s", err) + } } - metadata := instanceTemplate.Properties.Metadata - var metadataMap map[string]interface{} - if metadata != nil { - var convErr error - if metadataMap, convErr = tpgresource.ConvertToMap(metadata); convErr != nil { - return fmt.Errorf("Error converting metadata: %s", convErr) + _md := make(map[string]interface{}) + if itemsRaw, ok := mdRaw["items"].([]interface{}); ok { + for _, itemRaw := range itemsRaw { + if item, ok := itemRaw.(map[string]interface{}); ok { + k := item["key"].(string) + v := "" + if val, ok := item["value"].(string); ok { + v = val + } + _md[k] = v + } } } - _md := flattenMetadataBeta(metadataMap) if script, scriptExists := d.GetOk("metadata_startup_script"); scriptExists { if err = d.Set("metadata_startup_script", script); err != nil { return fmt.Errorf("Error setting metadata_startup_script: %s", err) } - delete(_md, "startup-script") } @@ -1624,12 +1613,8 @@ func resourceComputeRegionInstanceTemplateRead(d *schema.ResourceData, meta inte } {{ if ne $.TargetVersionName `ga` -}} - if instanceTemplate.Properties.PartnerMetadata != nil { - partnerMetadataMap, err := tpgresource.ConvertToMap(instanceTemplate.Properties.PartnerMetadata) - if err != nil { - return fmt.Errorf("Error converting partner metadata: %s", err) - } - partnerMetadata, err := flattenPartnerMetadata(convertPartnerMetadataFromCompute(partnerMetadataMap)) + if properties["partnerMetadata"] != nil { + partnerMetadata, err := flattenPartnerMetadata(properties["partnerMetadata"].(map[string]interface{})) if err != nil { return fmt.Errorf("Error parsing partner metadata: %s", err) } @@ -1640,37 +1625,64 @@ func resourceComputeRegionInstanceTemplateRead(d *schema.ResourceData, meta inte {{- end }} // Set the tags fingerprint if there is one. - if instanceTemplate.Properties.Tags != nil { - if err = d.Set("tags_fingerprint", instanceTemplate.Properties.Tags.Fingerprint); err != nil { - return fmt.Errorf("Error setting tags_fingerprint: %s", err) + if properties["tags"] != nil { + tags := properties["tags"].(map[string]interface{}) + if fingerprint, ok := tags["fingerprint"].(string); ok { + if err = d.Set("tags_fingerprint", fingerprint); err != nil { + return fmt.Errorf("Error setting tags_fingerprint: %s", err) + } + } + if items, ok := tags["items"].([]interface{}); ok { + if err = d.Set("tags", items); err != nil { + return fmt.Errorf("Error setting tags: %s", err) + } + } else { + if err = d.Set("tags", nil); err != nil { + return fmt.Errorf("Error setting empty tags: %s", err) + } } } else { if err := d.Set("tags_fingerprint", ""); err != nil { return fmt.Errorf("Error setting tags_fingerprint: %s", err) } + if err := d.Set("tags", nil); err != nil { + return fmt.Errorf("Error setting empty tags: %s", err) + } } - if instanceTemplate.Properties.Labels != nil { - if err := tpgresource.SetLabels(instanceTemplate.Properties.Labels, d, "labels"); err != nil { - return fmt.Errorf("Error setting labels: %s", err) + + var labels map[string]string + if properties["labels"] != nil { + labelsRaw := properties["labels"].(map[string]interface{}) + labels = make(map[string]string) + for k, v := range labelsRaw { + labels[k] = v.(string) } } - if err := tpgresource.SetLabels(instanceTemplate.Properties.Labels, d, "terraform_labels"); err != nil { + if err := tpgresource.SetLabels(labels, d, "labels"); err != nil { + return fmt.Errorf("Error setting labels: %s", err) + } + if err := tpgresource.SetLabels(labels, d, "terraform_labels"); err != nil { return fmt.Errorf("Error setting terraform_labels: %s", err) } - if err := d.Set("effective_labels", instanceTemplate.Properties.Labels); err != nil { + if err := d.Set("effective_labels", labels); err != nil { return fmt.Errorf("Error setting effective_labels: %s", err) } - if err = d.Set("self_link", instanceTemplate.SelfLink); err != nil { + + if err = d.Set("self_link", selfLink); err != nil { return fmt.Errorf("Error setting self_link: %s", err) } - if err := d.Set("creation_timestamp", instanceTemplate.CreationTimestamp); err != nil { - return fmt.Errorf("Error setting creation_timestamp: %s", err) + if ct, ok := res["creationTimestamp"].(string); ok { + if err := d.Set("creation_timestamp", ct); err != nil { + return fmt.Errorf("Error setting creation_timestamp: %s", err) + } } - if err = d.Set("name", instanceTemplate.Name); err != nil { - return fmt.Errorf("Error setting name: %s", err) + if n, ok := res["name"].(string); ok { + if err = d.Set("name", n); err != nil { + return fmt.Errorf("Error setting name: %s", err) + } } - if instanceTemplate.Properties.Disks != nil { - disks, err := flattenDisks(instanceTemplate.Properties.Disks, d, project) + if properties["disks"] != nil { + disks, err := FlattenDisksHTTP(properties["disks"].([]interface{}), d, project) if err != nil { return fmt.Errorf("error flattening disks: %s", err) } @@ -1678,164 +1690,134 @@ func resourceComputeRegionInstanceTemplateRead(d *schema.ResourceData, meta inte return fmt.Errorf("Error setting disk: %s", err) } } - if err = d.Set("description", instanceTemplate.Description); err != nil { + desc, _ := res["description"].(string) + if err = d.Set("description", desc); err != nil { return fmt.Errorf("Error setting description: %s", err) } - if err = d.Set("machine_type", instanceTemplate.Properties.MachineType); err != nil { + mt, _ := properties["machineType"].(string) + if err = d.Set("machine_type", mt); err != nil { return fmt.Errorf("Error setting machine_type: %s", err) } - if err = d.Set("min_cpu_platform", instanceTemplate.Properties.MinCpuPlatform); err != nil { + mcp, _ := properties["minCpuPlatform"].(string) + if err = d.Set("min_cpu_platform", mcp); err != nil { return fmt.Errorf("Error setting min_cpu_platform: %s", err) } - if err = d.Set("can_ip_forward", instanceTemplate.Properties.CanIpForward); err != nil { - return fmt.Errorf("Error setting can_ip_forward: %s", err) + if cif, ok := properties["canIpForward"].(bool); ok { + if err = d.Set("can_ip_forward", cif); err != nil { + return fmt.Errorf("Error setting can_ip_forward: %s", err) + } + } else { + if err = d.Set("can_ip_forward", false); err != nil { + return fmt.Errorf("Error setting can_ip_forward: %s", err) + } } - if err = d.Set("instance_description", instanceTemplate.Properties.Description); err != nil { + idesc, _ := properties["description"].(string) + if err = d.Set("instance_description", idesc); err != nil { return fmt.Errorf("Error setting instance_description: %s", err) } - if err = d.Set("key_revocation_action_type", instanceTemplate.Properties.KeyRevocationActionType); err != nil { + krat, _ := properties["keyRevocationActionType"].(string) + if err = d.Set("key_revocation_action_type", krat); err != nil { return fmt.Errorf("Error setting key_revocation_action_type: %s", err) } if err = d.Set("project", project); err != nil { return fmt.Errorf("Error setting project: %s", err) } var npcMap map[string]interface{} - if instanceTemplate.Properties.NetworkPerformanceConfig != nil { - var err error - npcMap, err = tpgresource.ConvertToMap(instanceTemplate.Properties.NetworkPerformanceConfig) - if err != nil { - return fmt.Errorf("Error converting network_performance_config: %s", err) - } + if properties["networkPerformanceConfig"] != nil { + npcMap = properties["networkPerformanceConfig"].(map[string]interface{}) } if err := d.Set("network_performance_config", flattenNetworkPerformanceConfig(npcMap)); err != nil { return err } - if instanceTemplate.Properties.NetworkInterfaces != nil { - networkInterfacesRaw, err := networkInterfacesToInterface(instanceTemplate.Properties.NetworkInterfaces) - if err != nil { - return err - } - networkInterfaces, region, _, _, err := flattenNetworkInterfaces(d, config, networkInterfacesRaw) + if properties["networkInterfaces"] != nil { + networkInterfacesRaw := properties["networkInterfaces"].([]interface{}) + networkInterfaces, regionIf, _, _, err := flattenNetworkInterfaces(d, config, networkInterfacesRaw) if err != nil { return err } if err = d.Set("network_interface", networkInterfaces); err != nil { return fmt.Errorf("Error setting network_interface: %s", err) } - // region is where to look up the subnetwork if there is one attached to the instance template - if region != "" { - if err = d.Set("region", region); err != nil { + if regionIf != "" { + if err = d.Set("region", regionIf); err != nil { return fmt.Errorf("Error setting region: %s", err) } } } - if schedulingMapFromResponse != nil { - scheduling := flattenScheduling(schedulingMapFromResponse) + if properties["scheduling"] != nil { + scheduling := FlattenSchedulingHTTP(properties["scheduling"].(map[string]interface{})) {{ if ne $.TargetVersionName `ga` }} - // Workaroud: API doesn't update the scheduling.graceful_shutdown.max_duration.nanos field. - // To avoid diff, we need to set the value from the state not from API response. if nanos, ok := d.GetOk("scheduling.0.graceful_shutdown.0.max_duration.0.nanos"); ok { - graceful_shutdown := scheduling[0]["graceful_shutdown"].([]interface{})[0].(map[string]interface{}) - max_duration := graceful_shutdown["max_duration"].([]interface{})[0].(map[string]interface{}) - max_duration["nanos"] = int64(nanos.(int)) - - graceful_shutdown["max_duration"] = []interface{}{max_duration} - scheduling[0]["graceful_shutdown"] = []interface{}{graceful_shutdown} + if len(scheduling) > 0 && scheduling[0]["graceful_shutdown"] != nil { + if gs, ok := scheduling[0]["graceful_shutdown"].([]map[string]interface{}); ok && len(gs) > 0 && gs[0] != nil { + if md, ok := gs[0]["max_duration"].([]map[string]interface{}); ok && len(md) > 0 && md[0] != nil { + md[0]["nanos"] = nanos.(int) + gs[0]["max_duration"] = []map[string]interface{}{md[0]} + scheduling[0]["graceful_shutdown"] = []map[string]interface{}{gs[0]} + } + } + } } {{- end }} if err = d.Set("scheduling", scheduling); err != nil { return fmt.Errorf("Error setting scheduling: %s", err) } } - if instanceTemplate.Properties.Tags != nil { - if err = d.Set("tags", instanceTemplate.Properties.Tags.Items); err != nil { - return fmt.Errorf("Error setting tags: %s", err) - } - } else { - if err = d.Set("tags", nil); err != nil { - return fmt.Errorf("Error setting empty tags: %s", err) - } - } - if instanceTemplate.Properties.ServiceAccounts != nil { - if err = d.Set("service_account", flattenServiceAccounts(serviceAccountsToInterface(instanceTemplate.Properties.ServiceAccounts))); err != nil { + if properties["serviceAccounts"] != nil { + if err = d.Set("service_account", flattenServiceAccounts(properties["serviceAccounts"].([]interface{}))); err != nil { return fmt.Errorf("Error setting service_account: %s", err) } } - if instanceTemplate.Properties.GuestAccelerators != nil { - if err = d.Set("guest_accelerator", flattenGuestAccelerators(guestAcceleratorsToInterface(instanceTemplate.Properties.GuestAccelerators))); err != nil { + if properties["guestAccelerators"] != nil { + if err = d.Set("guest_accelerator", flattenGuestAccelerators(properties["guestAccelerators"].([]interface{}))); err != nil { return fmt.Errorf("Error setting guest_accelerator: %s", err) } } - if sic := instanceTemplate.Properties.ShieldedInstanceConfig; sic != nil { - sicMap := map[string]interface{}{ - "enableSecureBoot": sic.EnableSecureBoot, - "enableVtpm": sic.EnableVtpm, - "enableIntegrityMonitoring": sic.EnableIntegrityMonitoring, - } - if err = d.Set("shielded_instance_config", flattenShieldedVmConfig(sicMap)); err != nil { + if sicRaw, ok := properties["shieldedInstanceConfig"]; ok && sicRaw != nil { + sic := sicRaw.(map[string]interface{}) + if err = d.Set("shielded_instance_config", flattenShieldedVmConfig(sic)); err != nil { return fmt.Errorf("Error setting shielded_instance_config: %s", err) } } - if instanceTemplate.Properties.ConfidentialInstanceConfig != nil { - cicMap, convErr := tpgresource.ConvertToMap(instanceTemplate.Properties.ConfidentialInstanceConfig) - if convErr != nil { - return fmt.Errorf("Error converting confidential_instance_config: %s", convErr) - } - if err = d.Set("confidential_instance_config", flattenConfidentialInstanceConfig(cicMap)); err != nil { + if cicRaw, ok := properties["confidentialInstanceConfig"]; ok && cicRaw != nil { + if err = d.Set("confidential_instance_config", flattenConfidentialInstanceConfig(cicRaw.(map[string]interface{}))); err != nil { return fmt.Errorf("Error setting confidential_instance_config: %s", err) } } - if instanceTemplate.Properties.AdvancedMachineFeatures != nil { - amfMap, err := tpgresource.ConvertToMap(instanceTemplate.Properties.AdvancedMachineFeatures) - if err != nil { - return fmt.Errorf("Error converting advanced_machine_features: %s", err) - } - if err = d.Set("advanced_machine_features", flattenAdvancedMachineFeatures(amfMap)); err != nil { + if amfRaw, ok := properties["advancedMachineFeatures"]; ok && amfRaw != nil { + if err = d.Set("advanced_machine_features", flattenAdvancedMachineFeatures(amfRaw.(map[string]interface{}))); err != nil { return fmt.Errorf("Error setting advanced_machine_features: %s", err) } } {{- if ne $.TargetVersionName "ga" }} - if instanceTemplate.Properties.DisplayDevice != nil { - ddMap, convErr := tpgresource.ConvertToMap(instanceTemplate.Properties.DisplayDevice) - if convErr != nil { - return fmt.Errorf("Error converting displayDevice: %s", convErr) - } - if err = d.Set("enable_display", flattenEnableDisplay(ddMap)); err != nil { + if ddRaw, ok := properties["displayDevice"]; ok && ddRaw != nil { + if err = d.Set("enable_display", flattenEnableDisplay(ddRaw.(map[string]interface{}))); err != nil { return fmt.Errorf("Error setting enable_display: %s", err) } } {{- end }} - if instanceTemplate.Properties.ResourcePolicies != nil { - if err = d.Set("resource_policies", instanceTemplate.Properties.ResourcePolicies); err != nil { + if rpRaw, ok := properties["resourcePolicies"]; ok && rpRaw != nil { + if err = d.Set("resource_policies", rpRaw); err != nil { return fmt.Errorf("Error setting resource_policies: %s", err) } } - if reservationAffinity := instanceTemplate.Properties.ReservationAffinity; reservationAffinity != nil { - reservationAffinityMap, err := tpgresource.ConvertToMap(reservationAffinity) - if err != nil { - return fmt.Errorf("Error converting reservation_affinity: %s", err) - } - if err = d.Set("reservation_affinity", flattenReservationAffinity(reservationAffinityMap)); err != nil { + if raRaw, ok := properties["reservationAffinity"]; ok && raRaw != nil { + if err = d.Set("reservation_affinity", flattenReservationAffinity(raRaw.(map[string]interface{}))); err != nil { return fmt.Errorf("Error setting reservation_affinity: %s", err) } } - if err := tpgresource.DeletionPolicyReadDefault(d, config, "DELETE"); err != nil{ return err } - if instanceTemplate.Properties.WorkloadIdentityConfig != nil { - wicMap, convErr := tpgresource.ConvertToMap(instanceTemplate.Properties.WorkloadIdentityConfig) - if convErr != nil { - return fmt.Errorf("Error converting workload_identity_config: %s", convErr) - } - if err = d.Set("workload_identity_config", flattenWorkloadIdentityConfig(wicMap)); err != nil { + if wic, ok := properties["workloadIdentityConfig"]; ok && wic != nil { + if err = d.Set("workload_identity_config", FlattenWorkloadIdentityConfigHTTP(wic)); err != nil { return fmt.Errorf("Error setting workload_identity_config: %s", err) } } From bb83124854e3ca5b1cbfd07010212312570b3ef2 Mon Sep 17 00:00:00 2001 From: Tavasya Ganpati Date: Wed, 22 Jul 2026 21:36:53 +0000 Subject: [PATCH 26/30] Add identity support for compute instance (#18321) --- .../compute/resource_compute_instance.go.tmpl | 44 +++++++++++++++++++ .../resource_compute_instance_test.go.tmpl | 34 ++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/mmv1/third_party/terraform/services/compute/resource_compute_instance.go.tmpl b/mmv1/third_party/terraform/services/compute/resource_compute_instance.go.tmpl index 75f30166ecf8..9e71b78abc05 100644 --- a/mmv1/third_party/terraform/services/compute/resource_compute_instance.go.tmpl +++ b/mmv1/third_party/terraform/services/compute/resource_compute_instance.go.tmpl @@ -245,6 +245,26 @@ func ResourceComputeInstance() *schema.Resource { Delete: schema.DefaultTimeout(20 * time.Minute), }, + Identity: &schema.ResourceIdentity{ + Version: 1, + SchemaFunc: func() map[string]*schema.Schema { + return map[string]*schema.Schema{ + "project": { + Type: schema.TypeString, + OptionalForImport: true, + }, + "zone": { + Type: schema.TypeString, + OptionalForImport: true, + }, + "name": { + Type: schema.TypeString, + RequiredForImport: true, + }, + } + }, + }, + // A compute instance is more or less a superset of a compute instance // template. Please attempt to maintain consistency with the // resource_compute_instance_template schema when updating this one. @@ -2213,6 +2233,14 @@ func resourceComputeInstanceCreate(d *schema.ResourceData, meta interface{}) err } } + if err := tpgresource.SetResourceIdentityAttributes(d, map[string]interface{}{ + "project": project, + "zone": z, + "name": instance.Name, + }); err != nil { + return err + } + return resourceComputeInstanceRead(d, meta) } @@ -2301,6 +2329,14 @@ func resourceComputeInstanceRead(d *schema.ResourceData, meta interface{}) error return err } + if err := tpgresource.SetResourceIdentityAttributes(d, map[string]interface{}{ + "project": project, + "zone": zone, + "name": instance.Name, + }); err != nil { + return err + } + return nil } @@ -4165,6 +4201,14 @@ func resourceComputeInstanceUpdate(d *schema.ResourceData, meta interface{}) err // We made it, disable partial mode d.Partial(false) + if err := tpgresource.SetResourceIdentityAttributes(d, map[string]interface{}{ + "project": project, + "zone": zone, + "name": instance.Name, + }); err != nil { + return err + } + return resourceComputeInstanceRead(d, meta) } diff --git a/mmv1/third_party/terraform/services/compute/resource_compute_instance_test.go.tmpl b/mmv1/third_party/terraform/services/compute/resource_compute_instance_test.go.tmpl index fff4f3534793..b53bc5f5aeee 100644 --- a/mmv1/third_party/terraform/services/compute/resource_compute_instance_test.go.tmpl +++ b/mmv1/third_party/terraform/services/compute/resource_compute_instance_test.go.tmpl @@ -13,6 +13,7 @@ import ( "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/plancheck" "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/hashicorp/terraform-plugin-testing/tfversion" "github.com/stretchr/testify/assert" "github.com/hashicorp/terraform-provider-google/google/acctest" @@ -385,6 +386,39 @@ func TestAccComputeInstance_basic5(t *testing.T) { }) } +func TestAccComputeInstance_importBlockWithResourceIdentity(t *testing.T) { + t.Parallel() + + instanceName := fmt.Sprintf("tf-test-%s", acctest.RandString(t, 10)) + project := envvar.GetTestProjectFromEnv() + zone := "us-central1-a" + + acctest.VcrTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_12_0), + }, + PreCheck: func() { acctest.AccTestPreCheck(t) }, + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories(t), + CheckDestroy: testAccCheckComputeInstanceDestroyProducer(t), + Steps: []resource.TestStep{ + { + Config: testAccComputeInstance_basic(instanceName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("google_compute_instance.foobar", "name", instanceName), + resource.TestCheckResourceAttr("google_compute_instance.foobar", "zone", zone), + resource.TestCheckResourceAttr("google_compute_instance.foobar", "project", project), + ), + }, + { + ResourceName: "google_compute_instance.foobar", + RefreshState: true, + ExpectNonEmptyPlan: true, + ImportStateKind: resource.ImportBlockWithResourceIdentity, + }, + }, + }) +} + func TestAccComputeInstance_metadataGceContainerDeclaration(t *testing.T) { t.Parallel() From de8d256108d5e780ace9fa49c621fae2a360a246 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Katarzyna=20Ka=C5=84ska?= Date: Wed, 22 Jul 2026 21:51:32 +0000 Subject: [PATCH 27/30] Add vector search data object (#18204) --- mmv1/products/vectorsearch/DataObject.yaml | 175 ++++++++++++++++++ .../vectorsearch_data_object_basic.tf.tmpl | 59 ++++++ ...torsearch_data_object_with_vectors.tf.tmpl | 72 +++++++ ...ch_data_object_with_vectors_update.tf.tmpl | 77 ++++++++ 4 files changed, 383 insertions(+) create mode 100644 mmv1/products/vectorsearch/DataObject.yaml create mode 100644 mmv1/templates/terraform/samples/services/vectorsearch/vectorsearch_data_object_basic.tf.tmpl create mode 100644 mmv1/templates/terraform/samples/services/vectorsearch/vectorsearch_data_object_with_vectors.tf.tmpl create mode 100644 mmv1/templates/terraform/samples/services/vectorsearch/vectorsearch_data_object_with_vectors_update.tf.tmpl diff --git a/mmv1/products/vectorsearch/DataObject.yaml b/mmv1/products/vectorsearch/DataObject.yaml new file mode 100644 index 000000000000..1bb2dc080522 --- /dev/null +++ b/mmv1/products/vectorsearch/DataObject.yaml @@ -0,0 +1,175 @@ +# Copyright 2026 Google Inc. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +--- +name: DataObject +description: |- + A DataObject is a single item of data (with optional vectors) stored in a + Vector Search Collection. Each DataObject conforms to the parent + Collection's `data_schema` and `vector_schema`. + + This resource always issues one `CreateDataObject` request per Terraform + resource block. It does NOT use the `batchCreate` REST endpoint -- + Terraform's resource lifecycle is inherently per-object, so batching + across resources is not modeled. When you use `for_each` or `count`, + Terraform will still issue individual requests, up to `-parallelism` + in parallel. + + For ingesting more than a few hundred items, prefer one of the + following out-of-band paths instead of Terraform: + + * `importDataObjects` (bulk ingest from Cloud Storage) -- highest + throughput, but only available *before* any Index is created on + the Collection. + * `batchCreate` (up to ~1000 items per call) -- available at any + time, but must be driven from your own client code, not Terraform. + + Once an Index exists on the Collection, `importDataObjects` is no + longer available and DataObjects must be created via `CreateDataObject` + (as this resource does) or via `batchCreate`. +base_url: projects/{{project}}/locations/{{location}}/collections/{{collection_id}}/dataObjects +self_link: projects/{{project}}/locations/{{location}}/collections/{{collection_id}}/dataObjects/{{data_object_id}} +create_url: projects/{{project}}/locations/{{location}}/collections/{{collection_id}}/dataObjects?dataObjectId={{data_object_id}} +update_mask: true +update_verb: PATCH +import_format: + - projects/{{project}}/locations/{{location}}/collections/{{collection_id}}/dataObjects/{{data_object_id}} +# NOTE: DataObject CRUD RPCs are synchronous (they return the resource +# directly, not a google.longrunning.Operation), so this resource has NO +# `async:` block and `autogen_async` is set to `false`. +autogen_status: RGF0YU9iamVjdA== +autogen_async: false +samples: + - name: vectorsearch_data_object_basic + primary_resource_id: example-data-object + steps: + - name: vectorsearch_data_object_basic + resource_id_vars: + data_object_id: example-data-object + collection_id: example-collection + - name: vectorsearch_data_object_with_vectors + primary_resource_id: example-vectors-data-object + steps: + - name: vectorsearch_data_object_with_vectors + resource_id_vars: + data_object_id: example-vectors-data-object + collection_id: example-vectors-collection + # Update step: exercises in-place updates of the DataObject's + # `data` and `vectors` fields (the only mutable properties on + # google_vector_search_data_object). + - name: vectorsearch_data_object_with_vectors_update + resource_id_vars: + data_object_id: example-vectors-data-object + collection_id: example-vectors-collection +parameters: + - name: location + type: String + required: true + description: Resource ID segment making up resource `name`. It identifies the resource within its parent collection as described in https://google.aip.dev/122. + immutable: true + url_param_only: true + - name: collectionId + type: String + required: true + description: |- + The ID of the parent Collection. + immutable: true + url_param_only: true + - name: dataObjectId + type: String + required: true + description: |- + ID of the DataObject to create. + The id must be 1-63 characters long, and comply with + [RFC1035](https://www.ietf.org/rfc/rfc1035.txt). + Specifically, it must be 1-63 characters long and match the regular + expression `[a-z](?:[-a-z0-9]{0,61}[a-z0-9])?`. + immutable: true + url_param_only: true +properties: + - name: name + type: String + description: Identifier. name of resource + output: true + - name: createTime + type: String + description: '[Output only] Create time stamp' + output: true + - name: updateTime + type: String + description: '[Output only] Update time stamp' + output: true + - name: data + type: String + description: |- + The JSON data of the DataObject. Must be a JSON object whose field + names match the fields defined in the parent Collection's + `data_schema`. + state_func: func(v interface{}) string { s, _ := structure.NormalizeJsonString(v); return s } + custom_flatten: templates/terraform/custom_flatten/json_schema.tmpl + custom_expand: templates/terraform/custom_expand/json_schema.tmpl + validation: + function: validation.StringIsJSON + - name: vectors + type: Map + key_name: field_name + default_from_api: true + description: |- + The vectors of the DataObject, keyed by the vector field name as + defined in the parent Collection's `vector_schema`. + + If a vector field is configured with a `vertex_embedding_config` on + the Collection, the server will populate the vector automatically + from the corresponding text in `data` and the field should be + omitted here. + value_type: + type: NestedObject + properties: + - name: dense + type: NestedObject + description: A dense vector. + conflicts: + - sparse + properties: + - name: values + type: Array + required: true + description: The float values of the dense vector. + item_type: + type: Double + - name: sparse + type: NestedObject + description: A sparse vector. + conflicts: + - dense + properties: + - name: values + type: Array + required: true + description: The non-zero float values of the sparse vector. + item_type: + type: Double + - name: indices + type: Array + required: true + description: |- + The indices corresponding to the entries in `values`. Must + have the same length as `values`. + item_type: + type: Integer + - name: etag + type: String + description: |- + The etag of the DataObject, used for optimistic concurrency + control on updates and deletes. + default_from_api: true diff --git a/mmv1/templates/terraform/samples/services/vectorsearch/vectorsearch_data_object_basic.tf.tmpl b/mmv1/templates/terraform/samples/services/vectorsearch/vectorsearch_data_object_basic.tf.tmpl new file mode 100644 index 000000000000..4c13128dd648 --- /dev/null +++ b/mmv1/templates/terraform/samples/services/vectorsearch/vectorsearch_data_object_basic.tf.tmpl @@ -0,0 +1,59 @@ +# NOTE: This resource issues one CreateDataObject request per block. +# It does NOT batch across resources. Terraform will parallelize a +# 'for_each' up to '-parallelism', but each item is still a separate +# HTTP call. +# +# For bulk ingestion of many items, prefer one of these out-of-band +# paths instead of Terraform: +# * 'importDataObjects' (from Cloud Storage) -- highest throughput, +# but only available *before* any Index is created on the Collection. +# * 'batchCreate' (up to ~1000 items per call) -- available at any +# time, but must be driven from client code, not Terraform. +resource "google_vector_search_collection" "parent" { + location = "us-central1" + collection_id = "{{index $.ResourceIdVars "collection_id"}}" + + display_name = "My Awesome Collection" + description = "This collection stores important data." + + data_schema = < Date: Wed, 22 Jul 2026 22:26:33 +0000 Subject: [PATCH 28/30] agents: fix release-notes link and streamline instructions in create-pr skill (#18358) --- .agents/skills/operations/create-pr/SKILL.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.agents/skills/operations/create-pr/SKILL.md b/.agents/skills/operations/create-pr/SKILL.md index 0e675a40390c..4391459ca1ea 100644 --- a/.agents/skills/operations/create-pr/SKILL.md +++ b/.agents/skills/operations/create-pr/SKILL.md @@ -79,7 +79,10 @@ git push -u "$FORK_REMOTE" "$BRANCH" Every PR must contain at least one release note block in the PR body. Refer to the official guide for detailed release note rules, categories, and examples: -* [docs/content/code-review/release-notes.md](../../../docs/content/code-review/release-notes.md) +* [docs/content/code-review/release-notes.md](../../../../docs/content/code-review/release-notes.md) + +> [!IMPORTANT] +> **Note to AI Agents:** View `docs/content/code-review/release-notes.md` from the repository root to determine the correct release note type (e.g., `enhancement`, `bug`, `none`, `new-resource`, `deprecation`, etc.) and follow the end-user impact guidelines. #### Release Note Block Format ```markdown @@ -88,8 +91,6 @@ CONTENT ``` ``` -Common types include `new-resource`, `new-datasource`, `new-list-resource`, `enhancement`, `bug`, `deprecation`, `breaking-change`, `note`, and `none`. - #### Sample PR Body Content ```markdown Summary of what changed and why in a few concise sentences. From dabd0d81c4732d73698fb021860a3f975fa8b73f Mon Sep 17 00:00:00 2001 From: Chris Hawk Date: Wed, 22 Jul 2026 22:52:15 +0000 Subject: [PATCH 29/30] Add google_vertex_ai_persistent_resource (#18273) --- .../products/vertexai/PersistentResource.yaml | 418 ++++++++++++++++++ .../vertex_ai_persistent_resource.tf.tmpl | 13 + ...ai_persistent_resource_autoscaling.tf.tmpl | 18 + ...i_persistent_resource_machine_spec.tf.tmpl | 19 + ...tex_ai_persistent_resource_network.tf.tmpl | 122 +++++ 5 files changed, 590 insertions(+) create mode 100644 mmv1/products/vertexai/PersistentResource.yaml create mode 100644 mmv1/templates/terraform/samples/services/vertexai/vertex_ai_persistent_resource.tf.tmpl create mode 100644 mmv1/templates/terraform/samples/services/vertexai/vertex_ai_persistent_resource_autoscaling.tf.tmpl create mode 100644 mmv1/templates/terraform/samples/services/vertexai/vertex_ai_persistent_resource_machine_spec.tf.tmpl create mode 100644 mmv1/templates/terraform/samples/services/vertexai/vertex_ai_persistent_resource_network.tf.tmpl diff --git a/mmv1/products/vertexai/PersistentResource.yaml b/mmv1/products/vertexai/PersistentResource.yaml new file mode 100644 index 000000000000..0549fddf2786 --- /dev/null +++ b/mmv1/products/vertexai/PersistentResource.yaml @@ -0,0 +1,418 @@ +# Copyright 2026 Google Inc. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +--- +name: PersistentResource +description: |- + Represents long-lasting resources that are dedicated to users to runs custom + workloads. A PersistentResource can have multiple node pools and each node + pool can have its own machine spec. +references: + guides: + api: https://cloud.google.com/vertex-ai/docs/reference/rest/v1/projects.locations.persistentResources +docs: +base_url: projects/{{project}}/locations/{{location}}/persistentResources +self_link: projects/{{project}}/locations/{{location}}/persistentResources/{{name}} +create_url: projects/{{project}}/locations/{{location}}/persistentResources?persistentResourceId={{name}} +import_format: + - projects/{{project}}/locations/{{location}}/persistentResources/{{name}} +async: + operation: + base_url: '{{op_id}}' + actions: + - create + - delete + result: + resource_inside_response: true +autogen_status: UGVyc2lzdGVudFJlc291cmNlQXV0b2dlblYyQWdlbnQ= +samples: + - name: vertex_ai_persistent_resource + primary_resource_id: persistent_resource + steps: + - name: vertex_ai_persistent_resource + resource_id_vars: + persistent_resource_id: example-persistent-resource + - name: vertex_ai_persistent_resource_autoscaling + primary_resource_id: persistent_resource + steps: + - name: vertex_ai_persistent_resource_autoscaling + resource_id_vars: + persistent_resource_id: example-persistent-resource + - name: vertex_ai_persistent_resource_machine_spec + exclude_test: true # Requires GPU quota + primary_resource_id: persistent_resource + steps: + - name: vertex_ai_persistent_resource_machine_spec + resource_id_vars: + persistent_resource_id: example-persistent-resource + - name: vertex_ai_persistent_resource_network + primary_resource_id: persistent_resource + external_providers: [time] + bootstrap_iam: + - member: "serviceAccount:service-{project_number}@gcp-sa-aiplatform.iam.gserviceaccount.com" + role: "roles/cloudkms.cryptoKeyEncrypterDecrypter" + - member: "serviceAccount:service-{project_number}@gcp-sa-aiplatform.iam.gserviceaccount.com" + role: "roles/compute.networkAdmin" + - member: "serviceAccount:service-{project_number}@gcp-sa-aiplatform.iam.gserviceaccount.com" + role: "roles/dns.peer" + steps: + - name: vertex_ai_persistent_resource_network + resource_id_vars: + persistent_resource_id: example-persistent-resource + network_name: vertex-network + address_name: vertex-ip-range + network_attachment_name: psc-attachment + subnetwork_name: psc-subnetwork + kms_key_name: example-key + resource_pool_id: vpr-resource-pool + test_vars_overrides: + kms_key_name: 'kms.BootstrapKMSKeyWithPurposeInLocationAndName(t, "ENCRYPT_DECRYPT", "us-central1", "tf-bootstrap-pr-key1").CryptoKey.Name' +parameters: + - name: location + type: String + description: The location of the PersistentResource. eg us-central1 + url_param_only: true + immutable: true +properties: + - name: name + type: String + description: |- + The ID to use for the PersistentResource, which become the final component + of the PersistentResource's resource name. + + The maximum length is 63 characters, and valid characters + are `/^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$/`. + required: true + immutable: true + custom_flatten: templates/terraform/custom_flatten/name_from_self_link.tmpl + - name: displayName + type: String + description: |- + The display name of the PersistentResource. + The name can be up to 128 characters long and can consist of any UTF-8 + characters. + - name: resourcePools + type: Array + required: true + description: The spec of the pools of different resources. + item_type: + type: NestedObject + properties: + - name: id + type: String + description: |- + The unique ID in a PersistentResource for referring to this resource pool. + User can specify it if necessary. Otherwise, it's generated + automatically. + immutable: true + default_from_api: true + - name: machineSpec + type: NestedObject + required: true + description: Specification of a single machine. + immutable: true + properties: + - name: machineType + type: String + description: |- + The type of the machine. + + See the [list of machine types supported for + prediction](https://cloud.google.com/vertex-ai/docs/predictions/configure-compute#machine-types) + + See the [list of machine types supported for custom + training](https://cloud.google.com/vertex-ai/docs/training/configure-compute#machine-types). + immutable: true + - name: acceleratorType + type: String + description: |- + The type of accelerator(s) that may be attached to the machine. + Possible values: + NVIDIA_TESLA_K80 + NVIDIA_TESLA_P100 + NVIDIA_TESLA_V100 + NVIDIA_TESLA_P4 + NVIDIA_TESLA_T4 + NVIDIA_TESLA_A100 + NVIDIA_A100_80GB + NVIDIA_L4 + NVIDIA_H100_80GB + NVIDIA_H100_MEGA_80GB + NVIDIA_H200_141GB + NVIDIA_B200 + NVIDIA_GB200 + NVIDIA_RTX_PRO_6000 + TPU_V2 + TPU_V3 + TPU_V4_POD + TPU_V5_LITEPOD + immutable: true + - name: acceleratorCount + type: Integer + description: The number of accelerators to attach to the machine. + immutable: true + - name: replicaCount + type: String + description: The total number of machines to use for this resource pool. + - name: usedReplicaCount + type: String + description: |- + The number of machines currently in use by training jobs for this resource + pool. Will replace idle_replica_count. + output: true + - name: diskSpec + type: NestedObject + description: Represents the spec of disk options. + default_from_api: true + immutable: true + properties: + - name: bootDiskSizeGb + type: Integer + description: Size in GB of the boot disk (default is 100GB). + default_from_api: true + immutable: true + - name: bootDiskType + type: String + description: |- + Type of the boot disk. For non-A3U machines, the default value is + "pd-ssd", for A3U machines, the default value is "hyperdisk-balanced". + Valid values: "pd-ssd" (Persistent Disk Solid State Drive), + "pd-standard" (Persistent Disk Hard Disk Drive) or "hyperdisk-balanced". + default_from_api: true + immutable: true + - name: autoscalingSpec + type: NestedObject + description: The min/max number of replicas allowed if enabling autoscaling + immutable: true + properties: + - name: minReplicaCount + type: String + immutable: true + description: |- + min replicas in the node pool, + must be ≤ replica_count and < max_replica_count or will throw error. + For autoscaling enabled Ray-on-Vertex, we allow min_replica_count of a + resource_pool to be 0 to match the OSS Ray + behavior(https://docs.ray.io/en/latest/cluster/vms/user-guides/configuring-autoscaling.html#cluster-config-parameters). + As for Persistent Resource, the min_replica_count must be > 0, we added + a corresponding validation inside + CreatePersistentResourceRequestValidator.java. + - name: maxReplicaCount + type: String + immutable: true + description: |- + max replicas in the node pool, + must be ≥ replica_count and > min_replica_count or will throw error + - name: labels + type: KeyValueLabels + immutable: true + description: |- + The labels with user-defined metadata to organize PersistentResource. + + Label keys and values can be no longer than 64 characters + (Unicode codepoints), can only contain lowercase letters, numeric + characters, underscores and dashes. International characters are allowed. + + See https://goo.gl/xmQnxf for more information and examples of labels. + - name: network + type: String + immutable: true + description: |- + The full name of the Compute Engine + [network](/compute/docs/networks-and-firewalls#networks) to peered with + Vertex AI to host the persistent resources. + For example, `projects/12345/global/networks/myVPC`. + [Format](/compute/docs/reference/rest/v1/networks/insert) + is of the form `projects/{project}/global/networks/{network}`. + Where {project} is a project number, as in `12345`, and {network} is a + network name. + + To specify this field, you must have already [configured VPC Network + Peering for Vertex + AI](https://cloud.google.com/vertex-ai/docs/general/vpc-peering). + + If this field is left unspecified, the resources aren't peered with any + network. + - name: pscInterfaceConfig + type: NestedObject + immutable: true + description: Configuration for PSC-I. + properties: + - name: networkAttachment + type: String + immutable: true + description: |- + The name of the Compute Engine + [network + attachment](https://cloud.google.com/vpc/docs/about-network-attachments) to + attach to the resource within the region and user project. + To specify this field, you must have already [created a network attachment] + (https://cloud.google.com/vpc/docs/create-manage-network-attachments#create-network-attachments). + This field is only used for resources using PSC-I. + - name: dnsPeeringConfigs + type: Array + description: |- + DNS peering configurations. When specified, Vertex AI will + attempt to configure DNS peering zones in the tenant project VPC + to resolve the specified domains using the target network's Cloud DNS. + The user must grant the dns.peer role to the Vertex AI Service Agent + on the target project. + item_type: + type: NestedObject + immutable: true + properties: + - name: domain + type: String + immutable: true + required: true + description: |- + The DNS name suffix of the zone being peered to, e.g., + "my-internal-domain.corp.". Must end with a dot. + - name: targetNetwork + type: String + immutable: true + required: true + description: |- + The VPC network name + in the target_project where the DNS zone specified by 'domain' is + visible. + - name: targetProject + type: String + immutable: true + required: true + description: |- + The project ID hosting the Cloud DNS managed zone that + contains the 'domain'. The Vertex AI Service Agent requires the + dns.peer role on this project. + - name: encryptionSpec + type: NestedObject + immutable: true + description: |- + Represents a customer-managed encryption key specification that can be + applied to a Vertex AI resource. + properties: + - name: kmsKeyName + type: String + required: true + description: |- + Resource name of the Cloud KMS key used to protect the resource. + + The Cloud KMS key must be in the same region as the resource. It must have + the format + `projects/{project}/locations/{location}/keyRings/{key_ring}/cryptoKeys/{crypto_key}`. + - name: resourceRuntimeSpec + type: NestedObject + immutable: true + description: |- + Configuration for the runtime on a PersistentResource instance. + properties: + - name: serviceAccountSpec + type: NestedObject + description: Configuration for the use of custom service account to run the workloads. + immutable: true + properties: + - name: enableCustomServiceAccount + type: Boolean + required: true + immutable: true + description: |- + If true, custom user-managed service account is enforced to run any + workloads (for example, Vertex Jobs) on the resource. + Otherwise, uses the [Vertex AI Custom Code Service + Agent](https://cloud.google.com/vertex-ai/docs/general/access-control#service-agents). + - name: reservedIpRanges + type: Array + immutable: true + description: |- + A list of names for the reserved IP ranges under the VPC network + that can be used for this persistent resource. + + If set, we will deploy the persistent resource within the provided IP + ranges. Otherwise, the persistent resource is deployed to any IP + ranges under the provided VPC network. + + Example: ['vertex-ai-ip-range']. + item_type: + type: String + - name: state + type: String + description: |- + The detailed state of a PersistentResource. + Possible values: + PROVISIONING + RUNNING + STOPPING + ERROR + REBOOTING + UPDATING + output: true + - name: error + type: NestedObject + description: |- + The `Status` type defines a logical error model that is suitable for + different programming environments, including REST APIs and RPC APIs. It is + used by [gRPC](https://github.com/grpc). Each `Status` message contains + three pieces of data: error code, error message, and error details. + + You can find out more about this error model and how to work with it in the + [API Design Guide](https://cloud.google.com/apis/design/errors). + output: true + properties: + - name: code + type: Integer + output: true + description: The status code, which should be an enum value of google.rpc.Code. + - name: message + type: String + output: true + description: |- + A developer-facing error message, which should be in English. Any + user-facing error message should be localized and sent in the + google.rpc.Status.details field, or localized by the client. + - name: createTime + type: String + description: Time when the PersistentResource was created. + output: true + - name: startTime + type: String + description: |- + Time when the PersistentResource for the first time entered the `RUNNING` + state. + output: true + - name: updateTime + type: String + description: Time when the PersistentResource was most recently updated. + output: true + - name: resourceRuntime + type: NestedObject + description: Persistent Cluster runtime information as output + output: true + properties: + - name: accessUris + type: KeyValuePairs + description: |- + URIs for user to connect to the Cluster. + Example: + { + "RAY_HEAD_NODE_INTERNAL_IP": "head-node-IP:10001" + "RAY_DASHBOARD_URI": "ray-dashboard-address:8888" + } + output: true + - name: satisfiesPzs + type: Boolean + description: Reserved for future use. + output: true + - name: satisfiesPzi + type: Boolean + description: Reserved for future use. + output: true diff --git a/mmv1/templates/terraform/samples/services/vertexai/vertex_ai_persistent_resource.tf.tmpl b/mmv1/templates/terraform/samples/services/vertexai/vertex_ai_persistent_resource.tf.tmpl new file mode 100644 index 000000000000..248a960a41d7 --- /dev/null +++ b/mmv1/templates/terraform/samples/services/vertexai/vertex_ai_persistent_resource.tf.tmpl @@ -0,0 +1,13 @@ +resource "google_vertex_ai_persistent_resource" "{{.PrimaryResourceId}}" { + name = "{{index $.ResourceIdVars "persistent_resource_id"}}" + location = "us-central1" + display_name = "Example persistent resource" + + resource_pools { + machine_spec { + machine_type = "n1-standard-4" + } + + replica_count = 1 + } +} diff --git a/mmv1/templates/terraform/samples/services/vertexai/vertex_ai_persistent_resource_autoscaling.tf.tmpl b/mmv1/templates/terraform/samples/services/vertexai/vertex_ai_persistent_resource_autoscaling.tf.tmpl new file mode 100644 index 000000000000..f39972c27c32 --- /dev/null +++ b/mmv1/templates/terraform/samples/services/vertexai/vertex_ai_persistent_resource_autoscaling.tf.tmpl @@ -0,0 +1,18 @@ +resource "google_vertex_ai_persistent_resource" "{{.PrimaryResourceId}}" { + name = "{{index $.ResourceIdVars "persistent_resource_id"}}" + location = "us-central1" + display_name = "Example persistent resource" + + resource_pools { + machine_spec { + machine_type = "n1-standard-4" + } + + replica_count = 1 + + autoscaling_spec { + min_replica_count = 1 + max_replica_count = 2 + } + } +} diff --git a/mmv1/templates/terraform/samples/services/vertexai/vertex_ai_persistent_resource_machine_spec.tf.tmpl b/mmv1/templates/terraform/samples/services/vertexai/vertex_ai_persistent_resource_machine_spec.tf.tmpl new file mode 100644 index 000000000000..c234ea2c441f --- /dev/null +++ b/mmv1/templates/terraform/samples/services/vertexai/vertex_ai_persistent_resource_machine_spec.tf.tmpl @@ -0,0 +1,19 @@ +resource "google_vertex_ai_persistent_resource" "{{.PrimaryResourceId}}" { + name = "{{index $.ResourceIdVars "persistent_resource_id"}}" + location = "us-central1" + + resource_pools { + machine_spec { + machine_type = "a3-highgpu-8g" + accelerator_count = 8 + accelerator_type = "NVIDIA_H100_80GB" + } + + replica_count = "1" + + disk_spec { + boot_disk_size_gb = 200 + boot_disk_type = "pd-ssd" + } + } +} \ No newline at end of file diff --git a/mmv1/templates/terraform/samples/services/vertexai/vertex_ai_persistent_resource_network.tf.tmpl b/mmv1/templates/terraform/samples/services/vertexai/vertex_ai_persistent_resource_network.tf.tmpl new file mode 100644 index 000000000000..5b035bdaebfa --- /dev/null +++ b/mmv1/templates/terraform/samples/services/vertexai/vertex_ai_persistent_resource_network.tf.tmpl @@ -0,0 +1,122 @@ +resource "google_vertex_ai_persistent_resource" "{{.PrimaryResourceId}}" { + name = "{{index $.ResourceIdVars "persistent_resource_id"}}" + location = "us-central1" + display_name = "test-persistent-resource-full" + + labels = { + env = "test" + } + + # Network peering for Vertex AI + network = "projects/${data.google_project.project.number}/global/networks/${google_compute_network.vertex_network.name}" + + # Reserved IP ranges under the VPC for the persistent resource + reserved_ip_ranges = [google_compute_global_address.vertex_range.name] + + # Customer-managed encryption key + encryption_spec { + kms_key_name = "{{index $.ResourceIdVars "kms_key_name"}}" + } + + # PSC-I configuration + psc_interface_config { + network_attachment = google_compute_network_attachment.psc_attachment.id + + dns_peering_configs { + domain = "example.com." + target_project = data.google_project.project.project_id + target_network = google_compute_network.vertex_network.name + } + } + + resource_pools { + id = "{{index $.ResourceIdVars "resource_pool_id"}}" + + replica_count = "1" + + machine_spec { + machine_type = "n1-standard-4" + } + + disk_spec { + boot_disk_size_gb = 200 + boot_disk_type = "pd-ssd" + } + } + + resource_runtime_spec { + service_account_spec { + enable_custom_service_account = true + } + } + + depends_on = [ + google_service_networking_connection.vertex_vpc_connection, + google_kms_crypto_key_iam_member.crypto_key, + # Give the google_vertex_ai_persistent_resource a change to fully delete + # before deleting other resources. + time_sleep.wait_for_deletion, + ] +} + +# VPC network for Vertex AI peering +resource "google_compute_network" "vertex_network" { + name = "{{index $.ResourceIdVars "network_name"}}" + auto_create_subnetworks = false +} + +# Service networking connection for Vertex AI +resource "google_service_networking_connection" "vertex_vpc_connection" { + network = google_compute_network.vertex_network.id + service = "servicenetworking.googleapis.com" + reserved_peering_ranges = [google_compute_global_address.vertex_range.name] +} + +# Reserved IP range for Vertex AI peering +resource "google_compute_global_address" "vertex_range" { + name = "{{index $.ResourceIdVars "address_name"}}" + purpose = "VPC_PEERING" + address_type = "INTERNAL" + prefix_length = 24 + network = google_compute_network.vertex_network.id +} + +resource "time_sleep" "wait_for_deletion" { + depends_on = [ + google_compute_network_attachment.psc_attachment, + google_service_networking_connection.vertex_vpc_connection + ] + destroy_duration = "300s" +} + +# Network attachment for PSC-I +resource "google_compute_network_attachment" "psc_attachment" { + name = "{{index $.ResourceIdVars "network_attachment_name"}}" + region = "us-central1" + connection_preference = "ACCEPT_MANUAL" + + subnetworks = [ + google_compute_subnetwork.psc_subnetwork.id + ] + + lifecycle { + ignore_changes = [producer_accept_lists] + } +} + +# Subnetwork for the network attachment +resource "google_compute_subnetwork" "psc_subnetwork" { + name = "{{index $.ResourceIdVars "subnetwork_name"}}" + region = "us-central1" + ip_cidr_range = "10.0.0.0/16" + network = google_compute_network.vertex_network.id +} + +# Grant Vertex AI service agent access to the KMS key +resource "google_kms_crypto_key_iam_member" "crypto_key" { + crypto_key_id = "{{index $.ResourceIdVars "kms_key_name"}}" + role = "roles/cloudkms.cryptoKeyEncrypterDecrypter" + member = "serviceAccount:service-${data.google_project.project.number}@gcp-sa-aiplatform.iam.gserviceaccount.com" +} + +data "google_project" "project" {} From d21e98811c2f3af2626ace06d69bbfddd395277f Mon Sep 17 00:00:00 2001 From: Chinemerem Date: Thu, 23 Jul 2026 09:20:56 -0700 Subject: [PATCH 30/30] Expose GKE desired_emulated_version update trigger in Cluster resource (#17765) Co-authored-by: Stephen Lewis (Burrows) --- .../resource_container_cluster.go.tmpl | 59 +++ .../resource_container_cluster_meta.yaml.tmpl | 5 + .../resource_container_cluster_test.go.tmpl | 444 ++++++++++++++++++ .../docs/r/container_cluster.html.markdown | 48 ++ 4 files changed, 556 insertions(+) diff --git a/mmv1/third_party/terraform/services/container/resource_container_cluster.go.tmpl b/mmv1/third_party/terraform/services/container/resource_container_cluster.go.tmpl index 10a165354d6c..835025bc043b 100644 --- a/mmv1/third_party/terraform/services/container/resource_container_cluster.go.tmpl +++ b/mmv1/third_party/terraform/services/container/resource_container_cluster.go.tmpl @@ -1246,6 +1246,33 @@ func ResourceContainerCluster() *schema.Resource { ValidateFunc: validation.StringInSlice([]string{"logging.googleapis.com", "logging.googleapis.com/kubernetes", "none"}, false), Description: `The logging service that the cluster should write logs to. Available options include logging.googleapis.com(Legacy Stackdriver), logging.googleapis.com/kubernetes(Stackdriver Kubernetes Engine Logging), and none. Defaults to logging.googleapis.com/kubernetes.`, }, + "rollback_safe_upgrade": { + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + Description: `Configuration for rollback-safe (two-step) upgrades.`, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "control_plane_soak_duration": { + Type: schema.TypeString, + Optional: true, + Description: `A user-defined period that the cluster remains in the rollbackable state. A duration in seconds with up to nine fractional digits, ending with 's'. Example: "604800s" for 7 days. Minimum is 6 hours, maximum is 7 days. If omitted, the two-step upgrade is skipped and a standard one-step upgrade is performed.`, + }, + }, + }, + }, + "desired_emulated_version": { + Type: schema.TypeString, + Optional: true, + ValidateFunc: validation.StringMatch(regexp.MustCompile(`^[0-9]+\.[0-9]+$`), "desired_emulated_version must be in major.minor format"), + Description: "The desired emulated version for the cluster. Used to complete a rollback-safe upgrade after a soak period. Must be in major.minor format (e.g., \"1.31\"). To complete the upgrade declaratively, set this field to the target minor version. Removing this field from your configuration will not trigger completion.", + }, + + "emulated_version": { + Type: schema.TypeString, + Computed: true, + Description: `The current emulated Kubernetes version running on the GKE cluster control plane.`, + }, "maintenance_policy": { Type: schema.TypeList, @@ -3738,6 +3765,9 @@ func resourceContainerClusterRead(d *schema.ResourceData, meta interface{}) erro if err := d.Set("master_version", cluster.CurrentMasterVersion); err != nil { return fmt.Errorf("Error setting master_version: %s", err) } + if err := d.Set("emulated_version", cluster.CurrentEmulatedVersion); err != nil { + return fmt.Errorf("Error setting emulated_version: %s", err) + } if err := d.Set("node_version", cluster.CurrentNodeVersion); err != nil { return fmt.Errorf("Error setting node_version: %s", err) } @@ -4709,6 +4739,24 @@ func resourceContainerClusterUpdate(d *schema.ResourceData, meta interface{}) er log.Printf("[INFO] GKE cluster %s legacy ABAC has been updated to %v", d.Id(), enabled) } + if d.HasChange("desired_emulated_version") { + emulatedVersion := d.Get("desired_emulated_version").(string) + if emulatedVersion != "" { + req := &container.UpdateClusterRequest{ + Update: &container.ClusterUpdate{ + DesiredEmulatedVersion: emulatedVersion, + }, + } + + updateF := updateFunc(req, "updating GKE master emulated version") + // Call update serially. + if err := transport_tpg.LockedCall(lockKey, updateF); err != nil { + return err + } + log.Printf("[INFO] GKE cluster %s: master emulated version has been updated to %s", d.Id(), emulatedVersion) + } + } + if d.HasChange("monitoring_service") || d.HasChange("logging_service") { logging := d.Get("logging_service").(string) monitoring := d.Get("monitoring_service").(string) @@ -4912,6 +4960,17 @@ func resourceContainerClusterUpdate(d *schema.ResourceData, meta interface{}) er }, } + if r, ok := d.GetOk("rollback_safe_upgrade"); ok { + rls := r.([]interface{}) + if len(rls) > 0 && rls[0] != nil { + req.Update.DesiredRollbackSafeUpgrade = &container.RollbackSafeUpgrade{} + rl := rls[0].(map[string]interface{}) + if soakDuration, ok := rl["control_plane_soak_duration"].(string); ok && soakDuration != "" { + req.Update.DesiredRollbackSafeUpgrade.ControlPlaneSoakDuration = soakDuration + } + } + } + updateF := updateFunc(req, "updating GKE master version") // Call update serially. if err := transport_tpg.LockedCall(lockKey, updateF); err != nil { diff --git a/mmv1/third_party/terraform/services/container/resource_container_cluster_meta.yaml.tmpl b/mmv1/third_party/terraform/services/container/resource_container_cluster_meta.yaml.tmpl index 176695184635..69cc45517b19 100644 --- a/mmv1/third_party/terraform/services/container/resource_container_cluster_meta.yaml.tmpl +++ b/mmv1/third_party/terraform/services/container/resource_container_cluster_meta.yaml.tmpl @@ -144,6 +144,8 @@ fields: - field: 'deletion_protection' provider_only: true - api_field: 'description' + - field: 'desired_emulated_version' + provider_only: true - field: 'dns_config.additive_vpc_scope_dns_domain' api_field: 'networkConfig.dnsConfig.additiveVpcScopeDnsDomain' - field: 'dns_config.cluster_dns' @@ -154,6 +156,8 @@ fields: api_field: 'networkConfig.dnsConfig.clusterDnsScope' - field: 'effective_labels' provider_only: true + - field: 'emulated_version' + api_field: 'currentEmulatedVersion' - field: 'enable_autopilot' api_field: 'autopilot.enabled' - field: 'enable_cilium_clusterwide_network_policy' @@ -905,6 +909,7 @@ fields: - api_field: 'resourceUsageExportConfig.enableNetworkEgressMetering' - field: 'resource_usage_export_config.enable_resource_consumption_metering' api_field: 'resourceUsageExportConfig.consumptionMeteringConfig.enabled' + - api_field: 'rollbackSafeUpgrade.controlPlaneSoakDuration' - api_field: 'secretManagerConfig.enabled' - api_field: 'secretManagerConfig.rotationConfig.enabled' - api_field: 'secretManagerConfig.rotationConfig.rotationInterval' diff --git a/mmv1/third_party/terraform/services/container/resource_container_cluster_test.go.tmpl b/mmv1/third_party/terraform/services/container/resource_container_cluster_test.go.tmpl index 4d68be526204..da21832b9613 100644 --- a/mmv1/third_party/terraform/services/container/resource_container_cluster_test.go.tmpl +++ b/mmv1/third_party/terraform/services/container/resource_container_cluster_test.go.tmpl @@ -19375,6 +19375,450 @@ resource "google_container_node_pool" "extra" { `, suffix, suffix, clusterName, skipRefresh, poolName) } +func TestAccContainerCluster_desiredEmulatedVersion(t *testing.T) { + t.Parallel() + clusterName := fmt.Sprintf("tf-test-cluster-%s", acctest.RandString(t, 10)) + networkName := tpgcompute.BootstrapSharedTestNetwork(t, "gke-cluster") + subnetworkName := tpgcompute.BootstrapSubnet(t, "gke-cluster", networkName) + + acctest.VcrTest(t, resource.TestCase{ + PreCheck: func() { acctest.AccTestPreCheck(t) }, + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories(t), + CheckDestroy: testAccCheckContainerClusterDestroyProducer(t), + Steps: []resource.TestStep{ + // Step 1 (Create): Spin up a standard cluster at STABLE default + { + Config: testAccContainerCluster_desiredEmulatedVersionBase(clusterName, networkName, subnetworkName), + }, + { + ResourceName: "google_container_cluster.primary", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"min_master_version", "deletion_protection"}, + }, + // Step 2 (Soak Upgrade): Upgrade master to target version (RAPID latest) and activate soak + { + Config: testAccContainerCluster_desiredEmulatedVersionSoak(clusterName, networkName, subnetworkName), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("google_container_cluster.primary", plancheck.ResourceActionUpdate), + }, + }, + Check: resource.ComposeTestCheckFunc( + // Assert that the computed emulated_version field is populated in state during soak + resource.TestCheckResourceAttrSet("google_container_cluster.primary", "emulated_version"), + ), + }, + { + ResourceName: "google_container_cluster.primary", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"min_master_version", "deletion_protection", "rollback_safe_upgrade"}, + }, + // Step 3 (Declarative Complete): Set desired_emulated_version to complete the upgrade + { + Config: testAccContainerCluster_desiredEmulatedVersionComplete(clusterName, networkName, subnetworkName), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("google_container_cluster.primary", plancheck.ResourceActionUpdate), + }, + }, + Check: resource.ComposeTestCheckFunc( + // Assert that the computed emulated_version field is cleared in state after completion + resource.TestCheckResourceAttr("google_container_cluster.primary", "emulated_version", ""), + testAccCheckContainerClusterEmulatedVersion(t, "google_container_cluster.primary"), + ), + }, + { + ResourceName: "google_container_cluster.primary", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"min_master_version", "deletion_protection", "desired_emulated_version", "rollback_safe_upgrade"}, + }, + }, + }) +} + +func testAccCheckContainerClusterEmulatedVersion(t *testing.T, resourceName string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("not found: %s", resourceName) + } + if rs.Primary.ID == "" { + return fmt.Errorf("no GKE cluster ID is set in state") + } + + config := acctest.GoogleProviderConfig(t) + clusterName := rs.Primary.Attributes["name"] + location := rs.Primary.Attributes["location"] + project := rs.Primary.Attributes["project"] + + // Retrieve the live GKE cluster object from Google's REST GKE API client + cluster, err := container.NewClient(config, config.UserAgent).Projects.Locations.Clusters.Get( + fmt.Sprintf("projects/%s/locations/%s/clusters/%s", project, location, clusterName)).Do() + if err != nil { + return fmt.Errorf("failed to get GKE cluster details from API: %v", err) + } + + // Verify GKE emulated version has advanced on the server side and is cleared post-upgrade + if cluster.CurrentEmulatedVersion != "" { + return fmt.Errorf("expected emulated version to be empty post-upgrade, but live GKE cluster has %q", cluster.CurrentEmulatedVersion) + } + + return nil + } +} + + +func testAccContainerCluster_desiredEmulatedVersionBase(clusterName, networkName, subnetworkName string) string { + return fmt.Sprintf(` +data "google_container_engine_versions" "central1" { + location = "us-central1" +} + +locals { + valid_minors = distinct([ + for v in data.google_container_engine_versions.central1.valid_master_versions : + regex("^[0-9]+\\.([0-9]+)\\.", v)[0] + ]) + target_minor = local.valid_minors[0] + base_minor = local.valid_minors[1] +} + +data "google_container_engine_versions" "base" { + location = "us-central1" + version_prefix = "1.${local.base_minor}." +} + +data "google_container_engine_versions" "target" { + location = "us-central1" + version_prefix = "1.${local.target_minor}." +} + +resource "google_container_cluster" "primary" { + name = "%s" + location = "us-central1" + initial_node_count = 1 + + release_channel { + channel = "RAPID" + } + + min_master_version = data.google_container_engine_versions.base.latest_master_version + deletion_protection = false + network = "%s" + subnetwork = "%s" + + node_config { + machine_type = "e2-standard-2" + } +} +`, clusterName, networkName, subnetworkName) +} + +func testAccContainerCluster_desiredEmulatedVersionSoak(clusterName, networkName, subnetworkName string) string { + return fmt.Sprintf(` +data "google_container_engine_versions" "central1" { + location = "us-central1" +} + +locals { + valid_minors = distinct([ + for v in data.google_container_engine_versions.central1.valid_master_versions : + regex("^[0-9]+\\.([0-9]+)\\.", v)[0] + ]) + target_minor = local.valid_minors[0] + base_minor = local.valid_minors[1] +} + +data "google_container_engine_versions" "base" { + location = "us-central1" + version_prefix = "1.${local.base_minor}." +} + +data "google_container_engine_versions" "target" { + location = "us-central1" + version_prefix = "1.${local.target_minor}." +} + +resource "google_container_cluster" "primary" { + name = "%s" + location = "us-central1" + initial_node_count = 1 + + release_channel { + channel = "RAPID" + } + + min_master_version = data.google_container_engine_versions.target.latest_master_version + deletion_protection = false + network = "%s" + subnetwork = "%s" + + rollback_safe_upgrade { + control_plane_soak_duration = "259200s" + } + + node_config { + machine_type = "e2-standard-2" + } +} +`, clusterName, networkName, subnetworkName) +} + +func testAccContainerCluster_desiredEmulatedVersionComplete(clusterName, networkName, subnetworkName string) string { + return fmt.Sprintf(` +data "google_container_engine_versions" "central1" { + location = "us-central1" +} + +locals { + valid_minors = distinct([ + for v in data.google_container_engine_versions.central1.valid_master_versions : + regex("^[0-9]+\\.([0-9]+)\\.", v)[0] + ]) + target_minor = local.valid_minors[0] + base_minor = local.valid_minors[1] +} + +data "google_container_engine_versions" "base" { + location = "us-central1" + version_prefix = "1.${local.base_minor}." +} + +data "google_container_engine_versions" "target" { + location = "us-central1" + version_prefix = "1.${local.target_minor}." +} + +resource "google_container_cluster" "primary" { + name = "%s" + location = "us-central1" + initial_node_count = 1 + + release_channel { + channel = "RAPID" + } + + min_master_version = data.google_container_engine_versions.target.latest_master_version + deletion_protection = false + network = "%s" + subnetwork = "%s" + + rollback_safe_upgrade { + control_plane_soak_duration = "259200s" + } + + desired_emulated_version = regex("^[0-9]+\\.[0-9]+", data.google_container_engine_versions.target.latest_master_version) + + node_config { + machine_type = "e2-standard-2" + } +} +`, clusterName, networkName, subnetworkName) +} + +func TestAccContainerCluster_desiredEmulatedVersionAutopilot(t *testing.T) { + t.Parallel() + clusterName := fmt.Sprintf("tf-test-cluster-auto-%s", acctest.RandString(t, 10)) + networkName := tpgcompute.BootstrapSharedTestNetwork(t, "gke-cluster") + subnetworkName := tpgcompute.BootstrapSubnet(t, "gke-cluster", networkName) + + acctest.VcrTest(t, resource.TestCase{ + PreCheck: func() { acctest.AccTestPreCheck(t) }, + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories(t), + CheckDestroy: testAccCheckContainerClusterDestroyProducer(t), + Steps: []resource.TestStep{ + // Step 1 (Create): Spin up a regional GKE Autopilot cluster at STABLE default + { + Config: testAccContainerCluster_desiredEmulatedVersionAutopilotBase(clusterName, networkName, subnetworkName), + }, + { + ResourceName: "google_container_cluster.primary", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"min_master_version", "deletion_protection", "node_pool.0.node_count"}, + }, + // Step 2 (Soak Upgrade): Upgrade regional master to target version (RAPID latest) with soak active + { + Config: testAccContainerCluster_desiredEmulatedVersionAutopilotSoak(clusterName, networkName, subnetworkName), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("google_container_cluster.primary", plancheck.ResourceActionUpdate), + }, + }, + Check: resource.ComposeTestCheckFunc( + // Assert that the computed emulated_version field is populated in state during soak + resource.TestCheckResourceAttrSet("google_container_cluster.primary", "emulated_version"), + ), + }, + { + ResourceName: "google_container_cluster.primary", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"min_master_version", "deletion_protection", "node_pool.0.node_count", "rollback_safe_upgrade"}, + }, + // Step 3 (Declarative Complete): Set desired_emulated_version to complete the upgrade + { + Config: testAccContainerCluster_desiredEmulatedVersionAutopilotComplete(clusterName, networkName, subnetworkName), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("google_container_cluster.primary", plancheck.ResourceActionUpdate), + }, + }, + Check: resource.ComposeTestCheckFunc( + // Assert that the computed emulated_version field is cleared in state after completion + resource.TestCheckResourceAttr("google_container_cluster.primary", "emulated_version", ""), + testAccCheckContainerClusterEmulatedVersion(t, "google_container_cluster.primary"), + ), + }, + { + ResourceName: "google_container_cluster.primary", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"min_master_version", "deletion_protection", "node_pool.0.node_count", "desired_emulated_version", "rollback_safe_upgrade"}, + }, + }, + }) +} + +func testAccContainerCluster_desiredEmulatedVersionAutopilotBase(clusterName, networkName, subnetworkName string) string { + return fmt.Sprintf(` +data "google_container_engine_versions" "central1" { + location = "us-central1" +} + +locals { + valid_minors = distinct([ + for v in data.google_container_engine_versions.central1.valid_master_versions : + regex("^[0-9]+\\.([0-9]+)\\.", v)[0] + ]) + target_minor = local.valid_minors[0] + base_minor = local.valid_minors[1] +} + +data "google_container_engine_versions" "base" { + location = "us-central1" + version_prefix = "1.${local.base_minor}." +} + +data "google_container_engine_versions" "target" { + location = "us-central1" + version_prefix = "1.${local.target_minor}." +} + +resource "google_container_cluster" "primary" { + name = "%s" + location = "us-central1" + enable_autopilot = true + min_master_version = data.google_container_engine_versions.base.latest_master_version + deletion_protection = false + network = "%s" + subnetwork = "%s" + ip_allocation_policy {} + + release_channel { + channel = "RAPID" + } +} +`, clusterName, networkName, subnetworkName) +} + +func testAccContainerCluster_desiredEmulatedVersionAutopilotSoak(clusterName, networkName, subnetworkName string) string { + return fmt.Sprintf(` +data "google_container_engine_versions" "central1" { + location = "us-central1" +} + +locals { + valid_minors = distinct([ + for v in data.google_container_engine_versions.central1.valid_master_versions : + regex("^[0-9]+\\.([0-9]+)\\.", v)[0] + ]) + target_minor = local.valid_minors[0] + base_minor = local.valid_minors[1] +} + +data "google_container_engine_versions" "base" { + location = "us-central1" + version_prefix = "1.${local.base_minor}." +} + +data "google_container_engine_versions" "target" { + location = "us-central1" + version_prefix = "1.${local.target_minor}." +} + +resource "google_container_cluster" "primary" { + name = "%s" + location = "us-central1" + enable_autopilot = true + min_master_version = data.google_container_engine_versions.target.latest_master_version + deletion_protection = false + network = "%s" + subnetwork = "%s" + ip_allocation_policy {} + + release_channel { + channel = "RAPID" + } + + rollback_safe_upgrade { + control_plane_soak_duration = "259200s" + } +} +`, clusterName, networkName, subnetworkName) +} + +func testAccContainerCluster_desiredEmulatedVersionAutopilotComplete(clusterName, networkName, subnetworkName string) string { + return fmt.Sprintf(` +data "google_container_engine_versions" "central1" { + location = "us-central1" +} + +locals { + valid_minors = distinct([ + for v in data.google_container_engine_versions.central1.valid_master_versions : + regex("^[0-9]+\\.([0-9]+)\\.", v)[0] + ]) + target_minor = local.valid_minors[0] + base_minor = local.valid_minors[1] +} + +data "google_container_engine_versions" "base" { + location = "us-central1" + version_prefix = "1.${local.base_minor}." +} + +data "google_container_engine_versions" "target" { + location = "us-central1" + version_prefix = "1.${local.target_minor}." +} + +resource "google_container_cluster" "primary" { + name = "%s" + location = "us-central1" + enable_autopilot = true + min_master_version = data.google_container_engine_versions.target.latest_master_version + deletion_protection = false + network = "%s" + subnetwork = "%s" + ip_allocation_policy {} + + release_channel { + channel = "RAPID" + } + + rollback_safe_upgrade { + control_plane_soak_duration = "259200s" + } + + desired_emulated_version = regex("^[0-9]+\\.[0-9]+", data.google_container_engine_versions.target.latest_master_version) +} +`, clusterName, networkName, subnetworkName) +} + func TestAccContainerCluster_withNodeReadinessConfig(t *testing.T) { t.Parallel() diff --git a/mmv1/third_party/terraform/website/docs/r/container_cluster.html.markdown b/mmv1/third_party/terraform/website/docs/r/container_cluster.html.markdown index ab281424e3e3..e0097c31f95f 100644 --- a/mmv1/third_party/terraform/website/docs/r/container_cluster.html.markdown +++ b/mmv1/third_party/terraform/website/docs/r/container_cluster.html.markdown @@ -95,6 +95,44 @@ resource "google_container_cluster" "primary" { } ``` +## Example Usage - Rollback-safe (Two-Step) Upgrades + +To perform a rollback-safe (two-step) control plane upgrade, you first specify a soak duration in the `rollback_safe_upgrade` block when changing the `min_master_version`. This upgrades the master but keeps the control plane emulating the older version. + +```hcl +resource "google_container_cluster" "primary" { + name = "my-gke-cluster" + location = "us-central1" + initial_node_count = 1 + min_master_version = "1.32.4-gke.200" # Upgrading to the 1.32 minor track + + # Phase 1: Explicitly opt-in to a rollback-safe upgrade + rollback_safe_upgrade { + control_plane_soak_duration = "604800s" # Soak for 7 days + } +} +``` + +After the soak period concludes, you can declaratively complete the upgrade by specifying the target `desired_emulated_version`. + +```hcl +resource "google_container_cluster" "primary" { + name = "my-gke-cluster" + location = "us-central1" + initial_node_count = 1 + min_master_version = "1.32.4-gke.200" + + rollback_safe_upgrade { + control_plane_soak_duration = "604800s" + } + + # Phase 2: Complete the upgrade (updates emulated_version to 1.32) + desired_emulated_version = "1.32" +} +``` + +~> **Note:** If you omit the `control_plane_soak_duration` field completely, GKE bypasses the two-step feature and performs a standard one-step upgrade. You must specify a duration between 6 hours and 7 days. + ## Argument Reference * `name` - (Required) The name of the cluster, unique within the project and @@ -254,6 +292,10 @@ Structure is [documented below](#nested_master_auth). to the datasource. A region can have a different set of supported versions than its corresponding zones, and not all zones in a region are guaranteed to support the same version. +* `rollback_safe_upgrade` - (Optional) Configuration for rollback-safe (two-step) upgrades. Structure is [documented below](#nested_rollback_safe_upgrade). + +* `desired_emulated_version` - (Optional) The desired emulated version for the cluster. Used to complete a rollback-safe upgrade after a soak period. Must be in major.minor format (e.g., "1.31"). To complete the upgrade declaratively, set this field to the target minor version. Removing this field from your configuration will not trigger completion. + * `monitoring_config` - (Optional) Monitoring configuration for the cluster. Structure is [documented below](#nested_monitoring_config). @@ -1003,6 +1045,10 @@ Structure is [documented below](#nested_additional_ip_ranges_config). * `NETWORK_TIER_STANDARD`: Standard network tier. +The `rollback_safe_upgrade` block supports: + +* `control_plane_soak_duration` - (Optional) A user-defined period that the cluster remains in the rollbackable state. A duration in seconds with up to nine fractional digits, ending with 's'. Example: "604800s" for 7 days. Minimum is 6 hours, maximum is 7 days. If omitted, the two-step upgrade is skipped and a standard one-step upgrade is performed. + The `master_auth` block supports: * `client_certificate_config` - (Required) Whether client certificate authorization is enabled for this cluster. For example: @@ -2065,6 +2111,8 @@ exported: * `enterprise_config.0.cluster_tier` - The effective tier of the cluster. +* `emulated_version` - The current emulated Kubernetes version running on the GKE cluster control plane. + ## Timeouts This resource provides the following