Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions mmv1/api/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,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"`

Expand Down Expand Up @@ -715,6 +717,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" }) {
Comment thread
c2thorn marked this conversation as resolved.
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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we be length checking keys here? if for some reason it's zero, should it just go ahead and error out instead of propagating through?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think a length check is necessary since we do that within the Capture function. in generated resources the capture method takes the value returned from `ListResultDisplayNameKeyStrings1

func (c *ListDisplayName) Capture(resourceAddr string, attrCandidates []string) resource.TestCheckFunc {
return func(s *terraform.State) error {
rs, ok := s.RootModule().Resources[resourceAddr]
if !ok {
return fmt.Errorf("resource not found in state: %s", resourceAddr)
}
for _, k := range attrCandidates {
if v, ok := rs.Primary.Attributes[k]; ok && v != "" {
c.value = v
return nil
}
}
return fmt.Errorf("no display name attribute found in state for resource %s; tried %v", resourceAddr, attrCandidates)
}
}

}

func (r Resource) SensitiveProps() []*Type {
props := r.AllNestedProperties(r.RootProperties())
return google.Select(props, func(p *Type) bool {
Expand Down
1 change: 1 addition & 0 deletions mmv1/products/cloudrun/Service.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import_format:
datasource_experimental:
generate: true
exclude_test: true
generate_list_resource: true
timeouts:
insert_minutes: 20
update_minutes: 20
Expand Down
18 changes: 18 additions & 0 deletions mmv1/provider/template_data.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) GenerateTestFileLegacy(filePath string, resource api.Resource) {
templatePath := "templates/terraform/examples/base_configs/test_file.go.tmpl"
templates := []string{
Expand Down Expand Up @@ -282,6 +290,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{
Expand Down
37 changes: 37 additions & 0 deletions mmv1/provider/terraform.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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
Expand Down
124 changes: 124 additions & 0 deletions mmv1/templates/terraform/list_resource.go.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
{{/* The license inside this block applies to this file
Copyright 2024 Google LLC. All Rights Reserved.
Comment thread
BBBmau marked this conversation as resolved.
Outdated

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{}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unrelated to your PR, but why is the official name a list RESOURCE

In my mind resources vs data sources were separated by the CUD parts of CRUD

isn't this more of a list data source? :)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good question, it's been tricky navigating naming since i do agree we aren't technically creating a resource but more calling existing resources (which exactly matches what data source is)

List Source probably would've been better? Reviewed the list resources docs and mentions the following

List resources are an abstraction that allows Terraform to search for a specific resource type within a given scope, for example listing all EC2 instances within an AWS account or all virtual networks within a resource group. Taking a list configuration as input, remote objects are retrieved and returned to Terraform as list result data which is displayed to the user.


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" $ }}
54 changes: 54 additions & 0 deletions mmv1/templates/terraform/list_resource.html.markdown.tmpl
Original file line number Diff line number Diff line change
@@ -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).
Loading
Loading