diff --git a/mmv1/api/resource.go b/mmv1/api/resource.go index 02c8a003954c..7a8a9277b4c3 100644 --- a/mmv1/api/resource.go +++ b/mmv1/api/resource.go @@ -250,6 +250,8 @@ type Resource struct { // EXPERIMENTAL: If true, resource should be autogenerated as a data source Datasource *resource.Datasource `yaml:"datasource_experimental,omitempty"` + GenerateListResource bool `yaml:"generate_list_resource,omitempty"` + // If true, skip sweeper generation for this resource ExcludeSweeper bool `yaml:"exclude_sweeper,omitempty"` @@ -729,6 +731,25 @@ func (r Resource) IdentityProperties() []*Type { return props } +func (r Resource) ListScopeProperties() []*Type { + scope := r.ExtractIdentifiers(r.CollectionUrl()) + return google.Select(r.IdentityProperties(), func(p *Type) bool { + return slices.Contains(scope, google.Underscore(p.Name)) + }) +} + +func (r Resource) ListResultDisplayNameKeyStrings() []string { + var keys []string + if slices.ContainsFunc(r.RootProperties(), func(p *Type) bool { return p.Name == "display_name" }) { + keys = append(keys, "display_name") + } + markers := regexp.MustCompile(`\{\{(\w+)\}\}`).FindAllStringSubmatch(r.IdFormat, -1) + if len(markers) > 0 { + keys = append(keys, markers[len(markers)-1][1]) + } + return keys +} + func (r Resource) SensitiveProps() []*Type { props := r.AllNestedProperties(r.RootProperties()) return google.Select(props, func(p *Type) bool { diff --git a/mmv1/products/cloudrun/Service.yaml b/mmv1/products/cloudrun/Service.yaml index 6f8fe3a5cadc..4edfa4adde72 100644 --- a/mmv1/products/cloudrun/Service.yaml +++ b/mmv1/products/cloudrun/Service.yaml @@ -33,6 +33,7 @@ import_format: datasource_experimental: generate: true exclude_test: true +generate_list_resource: true timeouts: insert_minutes: 20 update_minutes: 20 diff --git a/mmv1/provider/template_data.go b/mmv1/provider/template_data.go index 2dae5646d37d..61d7476d7e91 100644 --- a/mmv1/provider/template_data.go +++ b/mmv1/provider/template_data.go @@ -122,6 +122,14 @@ func (td *TemplateData) GenerateDocumentationFile(filePath string, resource api. td.GenerateFile(filePath, templatePath, resource, false, templates...) } +func (td *TemplateData) GenerateListResourceDocumentationFile(filePath string, resource api.Resource) { + templatePath := "templates/terraform/list_resource.html.markdown.tmpl" + templates := []string{ + templatePath, + } + td.GenerateFile(filePath, templatePath, resource, false, templates...) +} + func (td *TemplateData) GenerateDataSourceDocumentationFile(filePath string, resource api.Resource) { templatePath := "templates/terraform/datasource.html.markdown.tmpl" templates := []string{ @@ -292,6 +300,16 @@ func (td *TemplateData) GenerateIamPolicyTestFile(filePath string, resource api. td.GenerateFile(filePath, templatePath, resource, true, templates...) } +// GenerateQueryTestFile emits a Terraform query-mode acceptance test for list resources (generate_list_resource). +func (td *TemplateData) GenerateQueryTestFile(filePath string, resource api.Resource) { + templatePath := "templates/terraform/samples/base_configs/query_test_file.go.tmpl" + templates := []string{ + templatePath, + "templates/terraform/env_var_context.go.tmpl", + } + td.GenerateFile(filePath, templatePath, resource, true, templates...) +} + func (td *TemplateData) GenerateSweeperFile(filePath string, resource api.Resource) { templatePath := "templates/terraform/sweeper_file.go.tmpl" templates := []string{ diff --git a/mmv1/provider/terraform.go b/mmv1/provider/terraform.go index b773d0f85ad0..fa74ad1c5c11 100644 --- a/mmv1/provider/terraform.go +++ b/mmv1/provider/terraform.go @@ -155,12 +155,38 @@ func (t *Terraform) GenerateResource(object api.Resource, templateData TemplateD targetFilePath := path.Join(targetFolder, fmt.Sprintf("resource_%s.go", t.ResourceGoFilename(object))) templateData.GenerateResourceFile(targetFilePath, object) } + + t.GenerateListResource(object, templateData, targetFolder) } if generateDocs { targetFolder := t.makeFolder(outputFolder, "website", "docs", "r") targetFilePath := path.Join(targetFolder, fmt.Sprintf("%s.html.markdown", t.FullResourceName(object))) templateData.GenerateDocumentationFile(targetFilePath, object) + + if object.GenerateListResource { + listDocFolder := t.makeFolder(outputFolder, "website", "docs", "list-resources") + listDocFilePath := path.Join(listDocFolder, fmt.Sprintf("%s.html.markdown", object.TerraformName())) + templateData.GenerateListResourceDocumentationFile(listDocFilePath, object) + } + } +} + +func (t *Terraform) GenerateListResource(object api.Resource, templateData TemplateData, targetFolder string) { + if object.GenerateListResource { + if object.ExcludeIdentityGeneration { + log.Fatalf("generate_list_resource requires identity support; remove exclude_identity_generation from resource %q or disable generate_list_resource", object.Name) + } + if object.ExcludeRead { + log.Fatalf("generate_list_resource requires read support; remove exclude_read from resource %q or disable generate_list_resource", object.Name) + } + targetFilePath := path.Join(targetFolder, fmt.Sprintf("list_%s.go", t.ResourceGoFilename(object))) + templateData.GenerateFile(targetFilePath, "templates/terraform/list_resource.go.tmpl", object, true, + "templates/terraform/list_resource.go.tmpl", + "templates/terraform/list_resource_method.go.tmpl", + ) + + t.GenerateListResourceQueryTest(object, templateData, targetFolder) } } @@ -249,6 +275,17 @@ func (t *Terraform) GenerateResourceTests(object api.Resource, templateData Temp templateData.GenerateTestFile(targetFilePath, object) } +func (t *Terraform) GenerateListResourceQueryTest(object api.Resource, templateData TemplateData, targetFolder string) { + if object.Samples != nil && object.Examples != nil { + log.Fatalf("Both Samples and Examples block exist in %v", object.Name) + } + if object.Samples == nil || !t.hasEligibleSample(object) { + return + } + targetFilePath := path.Join(targetFolder, fmt.Sprintf("list_%s_generated_test.go", t.ResourceGoFilename(object))) + templateData.GenerateQueryTestFile(targetFilePath, object) +} + func (t *Terraform) GenerateResourceSweeper(object api.Resource, templateData TemplateData, outputFolder string) { if !object.ShouldGenerateSweepers() { return diff --git a/mmv1/templates/terraform/list_resource.go.tmpl b/mmv1/templates/terraform/list_resource.go.tmpl new file mode 100644 index 000000000000..3e2cfc4a794a --- /dev/null +++ b/mmv1/templates/terraform/list_resource.go.tmpl @@ -0,0 +1,124 @@ +{{/* The license inside this block applies to this file + Copyright 2026 Google LLC. All Rights Reserved. + + 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. */ -}} +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: MPL-2.0 + +{{$.CodeHeader TemplatePath}} + +package {{ lower $.ProductMetadata.Name }} + +import ( + "context" + "errors" + "fmt" + "net/http" + + "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" + + "{{ $.ImportPath }}/registry" + "{{ $.ImportPath }}/tpgresource" + transport_tpg "{{ $.ImportPath }}/transport" +) + +func init() { + registry.FrameworkListResource{ + Name: "{{ $.TerraformName }}", + ProductName: "{{ $.ProductMetadata.Name }}", + Func: New{{ $.ResourceName -}}ListResource, + }.Register() +} + +var _ list.ListResource = &{{ $.ResourceName -}}ListResource{} + +type {{ $.ResourceName -}}ListResource struct { + tpgresource.ListResourceMetadata +} + +func New{{ $.ResourceName -}}ListResource() list.ListResource { + listR := &{{ $.ResourceName -}}ListResource{} + listR.TypeName = "{{ $.TerraformName }}" + listR.SDKv2Resource = Resource{{ $.ResourceName -}}() + listR.ListConfigFields = []tpgresource.ListConfigField{ +{{- range $scope := $.ListScopeProperties }} + {Name: "{{ underscore $scope.Name }}", Kind: tpgresource.ListConfigKindString, Optional: {{ if $scope.Required }}false{{ else }}true{{ end }}}, +{{- end }} + } + return listR +} + +// {{ $.ResourceName }}ListModel matches ListResourceMetadata.ListConfigFields (tfsdk names and types). +type {{ $.ResourceName -}}ListModel struct { +{{- range $scope := $.ListScopeProperties }} + {{ $scope.TitlelizeProperty }} types.String `tfsdk:"{{ underscore $scope.Name }}"` +{{- end }} +} + +func (listR *{{ $.ResourceName -}}ListResource) List(ctx context.Context, listReq list.ListRequest, stream *list.ListResultsStream) { + var data {{ $.ResourceName -}}ListModel + diags := listReq.Config.Get(ctx, &data) + if diags.HasError() { + 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 + } + +{{- range $scope := $.ListScopeProperties }} +{{- if or (eq $scope.Name "project") (eq $scope.Name "region") (eq $scope.Name "zone") (eq $scope.Name "location") }} + {{ $scope.CamelizeProperty }} := listR.Get{{ $scope.TitlelizeProperty }}(data.{{ $scope.TitlelizeProperty }}) +{{- else }} + {{ $scope.CamelizeProperty }} := data.{{ $scope.TitlelizeProperty }}.ValueString() +{{- end }} +{{- end }} + + stream.Results = func(push func(list.ListResult) bool) { + err := List{{ $.ResourceName }}s( + listR.Client, +{{- range $scope := $.ListScopeProperties }} + {{ $scope.CamelizeProperty }}, +{{- end }} + func(rd *schema.ResourceData) error { + result := listReq.NewListResult(ctx) + + if err := listR.SetResult(ctx, listReq.IncludeResource, &result, rd{{- range $k := $.ListResultDisplayNameKeyStrings }}, "{{ $k }}"{{- end }}); err != nil { + return err + } + + if !push(result) { + return errors.New("stream closed") + } + return nil + }, + ) + if err != nil { + diags.AddError("API Error", err.Error()) + result := listReq.NewListResult(ctx) + result.Diagnostics = diags + push(result) + } + } +} + +{{ template "listResourceMethod" $ }} diff --git a/mmv1/templates/terraform/list_resource.html.markdown.tmpl b/mmv1/templates/terraform/list_resource.html.markdown.tmpl new file mode 100644 index 000000000000..e99ce4564186 --- /dev/null +++ b/mmv1/templates/terraform/list_resource.html.markdown.tmpl @@ -0,0 +1,54 @@ +{{- /* Copyright 2024 Google LLC. All Rights Reserved. + + 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. */ -}} +--- +{{$.MarkdownHeader TemplatePath}} +subcategory: "{{$.ProductMetadata.DisplayName}}" +description: |- + List {{$.ProductMetadata.DisplayName}} {{lower $.Name}} resources in a project for use with terraform query + and .tfquery.hcl files. +--- + +# {{$.TerraformName}} (list) + +Lists [`{{$.TerraformName}}`](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/{{$.TerraformName}}) resources for use 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 (Google Cloud provider)](https://registry.terraform.io/providers/hashicorp/google/latest/docs/guides/using_list_resources_with_terraform_query). + +## Example + +```hcl +list "{{$.TerraformName}}" "all" { + provider = {{ if eq $.MinVersion "beta" }}google-beta{{ else }}google{{ end }} + + config { +{{- range $scope := $.ListScopeProperties }} + {{ underscore $scope.Name }} = {{ if $scope.Required }}"..."{{ else }}"..." # Optional{{ end }} +{{- end }} + } +} +``` + +Run `terraform query` from the directory that contains the `.tfquery.hcl` file. + +## Configuration (`config` block) + +{{- range $scope := $.ListScopeProperties }} +* `{{ underscore $scope.Name }}` - ({{ if $scope.Required }}Required{{ else }}Optional{{ end }}){{ if $scope.Description }} {{ $scope.Description }}{{ end }} +{{- end }} + +## Results + +By default each result includes **resource identity** for [`{{$.TerraformName}}`](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/{{$.TerraformName}}) (see [Resource identity](https://developer.hashicorp.com/terraform/language/resources/identities)). + +With `include_resource = true` on the `list` block, results also include the full resource-style attributes documented for the managed [`{{$.TerraformName}}` resource](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/{{$.TerraformName}}#attributes-reference). diff --git a/mmv1/templates/terraform/list_resource_method.go.tmpl b/mmv1/templates/terraform/list_resource_method.go.tmpl new file mode 100644 index 000000000000..65f6d9661562 --- /dev/null +++ b/mmv1/templates/terraform/list_resource_method.go.tmpl @@ -0,0 +1,114 @@ +{{/* The license inside this block applies to this file + Copyright 2024 Google LLC. All Rights Reserved. + + 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. */ -}} +{{ define "listResourceMethod" }} +func List{{ $.ResourceName }}s(config *transport_tpg.Config, +{{- range $scope := $.ListScopeProperties }} + {{ $scope.CamelizeProperty }} string, +{{- end }} + callback func(*schema.ResourceData) error, +) error { + resourceData := Resource{{ $.ResourceName }}().Data(&terraform.InstanceState{}) +{{- range $scope := $.ListScopeProperties }} + if {{ $scope.CamelizeProperty }} != "" { + if err := resourceData.Set("{{ underscore $scope.Name }}", {{ $scope.CamelizeProperty }}); err != nil { + return fmt.Errorf("error setting {{ underscore $scope.Name }} on temporary resource data: %w", err) + } + } +{{- end }} + + url, err := tpgresource.ReplaceVars{{if $.LegacyLongFormProject -}}ForId{{ end -}}(resourceData, config, "{{"{{"}}{{$.ProductMetadata.Name}}BasePath{{"}}"}}{{$.BaseUrl}}") + if err != nil { + return err + } + +{{- if $.HasProject }} + billingProject := project +{{- else }} + billingProject := "" +{{- end }} + if bp, err := tpgresource.GetBillingProject(resourceData, config); err == nil { + billingProject = bp + } + + userAgent, err := tpgresource.GenerateUserAgentString(resourceData, config.UserAgent) + if err != nil { + return err + } + + return transport_tpg.ListPages(transport_tpg.ListPagesOptions{ + Config: config, + TempData: resourceData, + Resource: Resource{{ $.ResourceName -}}(), + ListURL: url, + BillingProject: billingProject, + UserAgent: userAgent, + ItemName: "{{ $.CollectionUrlKey }}", + Flattener: func(res map[string]interface{}, d *schema.ResourceData, config *transport_tpg.Config) error { + headers := make(http.Header) + var err error +{{- if $.CustomCode.PostRead }} + {{ customTemplate $ $.CustomCode.PostRead false -}} +{{- end }} +{{- if $.NestedQuery }} + res, err = flattenNested{{ $.ResourceName -}}(d, config, res) + if err != nil { + return err + } + if res == nil { + return fmt.Errorf("error matching nested {{ $.ResourceName }} from list response") + } +{{- end }} +{{- if $.CustomCode.Decoder }} + res, err = resource{{ $.ResourceName -}}Decoder(d, config, res) + if err != nil { + return err + } + if res == nil { + return fmt.Errorf("error decoding {{ $.ResourceName }} from list response") + } +{{- end }} + if err = Resource{{ $.ResourceName }}Flatten(d, config, res, config, {{ if $.HasProject }}project, {{ end }}userAgent, billingProject, url, headers); err != nil { + return err + } +{{- /* url_param_only identity fields (e.g. name) are not GettableProperties, so the flattener does not Set them. Copy from res → d here so SetId/ListResultDisplayName can find them. Integer ids sometimes arrive as strings and need coercion. */ -}} +{{- range $id := $.IdentityProperties }} +{{- if $id.ApiName }} + if v, ok := res["{{ $id.ApiName }}"]; ok && v != nil { + {{- if eq $id.Type "Integer" }} + if s, ok := v.(string); ok { + i, err := strconv.Atoi(s) + if err != nil { + return fmt.Errorf("error coercing {{ underscore $id.Name }}: %w", err) + } + v = i + } + {{- end }} + if err := d.Set("{{ underscore $id.Name }}", v); err != nil { + return fmt.Errorf("error setting {{ underscore $id.Name }}: %w", err) + } + } +{{- end }} +{{- end }} + id, err := tpgresource.ReplaceVars{{if $.LegacyLongFormProject -}}ForId{{ end -}}(d, config, "{{ $.IdFormat -}}") + if err != nil { + return fmt.Errorf("error constructing id: %w", err) + } + d.SetId(id) + return nil + }, + Callback: callback, + }) +} +{{ end }} diff --git a/mmv1/templates/terraform/samples/base_configs/query_test_file.go.tmpl b/mmv1/templates/terraform/samples/base_configs/query_test_file.go.tmpl new file mode 100644 index 000000000000..1c17f070a9b0 --- /dev/null +++ b/mmv1/templates/terraform/samples/base_configs/query_test_file.go.tmpl @@ -0,0 +1,137 @@ +{{/* The license inside this block applies to this file + Copyright 2024 Google LLC. All Rights Reserved. + + 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. */ -}} +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: MPL-2.0 + +{{$.CodeHeader TemplatePath}} + +package {{ lower $.ProductMetadata.Name }}_test + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/querycheck" + "github.com/hashicorp/terraform-plugin-testing/querycheck/queryfilter" + "github.com/hashicorp/terraform-plugin-testing/tfversion" + + "{{ $.ImportPath }}/acctest" + "{{ $.ImportPath }}/envvar" +) + +var ( + _ = envvar.TestEnvVar +) + +{{ $sample := $.FirstTestConfig.Sample }} +{{ $step := $.FirstTestConfig.Step }} +func TestAcc{{ $.ResourceName }}ListQuery_generated(t *testing.T) { + t.Parallel() + + {{- if $sample.SkipVcr }} + acctest.SkipIfVcr(t) + {{- end }} + + {{- if $sample.BootstrapIam }} + acctest.BootstrapIamMembers(t, []acctest.IamMember{ + {{- range $iam := $sample.BootstrapIam }} + { + Member: "{{$iam.Member}}", + Role: "{{$iam.Role}}", + }, + {{- end}} + }) + {{- end }} + + randomSuffix := acctest.RandString(t, 10) + context := map[string]interface{}{ + {{- template "EnvVarContext" dict "TestEnvVars" $step.TestEnvVars "HasNewLine" false}} + {{- range $varKey, $varVal := $step.TestContextVars }} + "{{$varKey}}": {{$varVal}}, + {{- end }} + {{- range $scope := $.ListScopeProperties }} + {{- $n := underscore $scope.Name }} + {{- if or (eq $n "region") (eq $n "location") }} + "{{$n}}": envvar.GetTestRegionFromEnv(), + {{- else if eq $n "zone" }} + "zone": envvar.GetTestZoneFromEnv(), + {{- end }} + {{- end }} + "random_suffix": randomSuffix, + } + + var listDisplayName acctest.ListDisplayName + acctest.VcrTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_14_0), + }, + PreCheck: func() { acctest.AccTestPreCheck(t) }, +{{- if $.VersionedProvider $sample.MinVersion }} + ProtoV5ProviderFactories: acctest.ProtoV5ProviderBetaFactories(t), +{{- else }} + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories(t), + {{- if $sample.ExternalProviders }} + ExternalProviders: map[string]resource.ExternalProvider{ + {{- range $provider := $sample.ExternalProviders }} + "{{$provider}}": {}, + {{- end }} + }, + {{- end }} +{{- end }} +{{- if not $.ExcludeDelete }} + CheckDestroy: testAccCheck{{ $.ResourceName }}DestroyProducer(t), +{{- end }} + Steps: []resource.TestStep{ + { + Config: testAcc{{ $step.TestStepSlug $.ProductMetadata.Name $.Name }}(context), + Check: resource.ComposeTestCheckFunc( + listDisplayName.Capture( + "{{ $sample.ResourceType $.TerraformName }}.{{ $sample.PrimaryResourceId }}", + []string{ + {{- range $key := $.ListResultDisplayNameKeyStrings }} + "{{ $key }}", + {{- end }} + }, + ), + ), + }, + { + Query: true, + Config: testAcc{{ $step.TestStepSlug $.ProductMetadata.Name $.Name }}ListQuery(context), + QueryResultChecks: []querycheck.QueryResultCheck{ + querycheck.ExpectLengthAtLeast("{{ $sample.ResourceType $.TerraformName }}.list_query", 1), + querycheck.ExpectResourceDisplayName( + "{{ $sample.ResourceType $.TerraformName }}.list_query", + queryfilter.ByDisplayName(listDisplayName.CheckValue()), + listDisplayName.CheckValue(), + ), + }, + }, + }, + }) +} + +func testAcc{{ $step.TestStepSlug $.ProductMetadata.Name $.Name }}ListQuery(context map[string]interface{}) string { + return acctest.Nprintf(` +list "{{ $sample.ResourceType $.TerraformName }}" "list_query" { + provider = {{ if $.VersionedProvider $sample.MinVersion }}google-beta{{ else }}google{{ end }} + config { +{{- range $scope := $.ListScopeProperties }} + {{ underscore $scope.Name }} = "%{{"{"}}{{ underscore $scope.Name }}{{"}"}}" +{{- end }} + } +} +`, context) +}