diff --git a/mmv1/provider/terraform.go b/mmv1/provider/terraform.go index 7731668970f1..88cdd5c0924f 100644 --- a/mmv1/provider/terraform.go +++ b/mmv1/provider/terraform.go @@ -369,7 +369,6 @@ func (t *Terraform) GenerateIamPolicyLegacy(object api.Resource, templateData Te targetFolder := t.makeFolder(outputFolder, t.FolderName(), "services", t.Product.ApiName) targetFilePath := path.Join(targetFolder, fmt.Sprintf("iam_%s.go", t.ResourceGoFilename(object))) templateData.GenerateIamPolicyFile(targetFilePath, object) - // Only generate test if testable examples exist. examples := google.Reject(object.Examples, func(e *resource.Examples) bool { return e.ExcludeTest @@ -401,7 +400,6 @@ func (t *Terraform) GenerateIamPolicy(object api.Resource, templateData Template targetFolder := t.makeFolder(outputFolder, t.FolderName(), "services", t.Product.ApiName) targetFilePath := path.Join(targetFolder, fmt.Sprintf("iam_%s.go", t.ResourceGoFilename(object))) templateData.GenerateIamPolicyFile(targetFilePath, object) - // Only generate test if testable example configs exist. samples := google.Reject(object.Samples, func(s *resource.Sample) bool { return s.ExcludeTest @@ -955,19 +953,25 @@ func (t *Terraform) generateResourcesForVersion(products []*api.Product) { } var iamClassName string + var iamMemberListImport string + var iamMemberListFunc string iamPolicy := object.IamPolicy if iamPolicy != nil && !iamPolicy.Exclude { t.IAMResourceCount += 3 if slices.Index(product.ORDER, iamPolicy.MinVersion) <= slices.Index(product.ORDER, t.TargetVersionName) { iamClassName = fmt.Sprintf("%s.%s", service, object.ResourceName()) + iamMemberListImport = service + iamMemberListFunc = fmt.Sprintf("New%sIamMemberListResource", object.ResourceName()) } } t.ResourcesForVersion = append(t.ResourcesForVersion, map[string]string{ - "TerraformName": object.TerraformName(), - "ResourceName": resourceName, - "IamClassName": iamClassName, + "TerraformName": object.TerraformName(), + "ResourceName": resourceName, + "IamClassName": iamClassName, + "IamMemberListImport": iamMemberListImport, + "IamMemberListFunc": iamMemberListFunc, }) } } diff --git a/mmv1/templates/terraform/examples/base_configs/iam_test_file.go.tmpl b/mmv1/templates/terraform/examples/base_configs/iam_test_file.go.tmpl index 04acb378ddd8..065690400e65 100644 --- a/mmv1/templates/terraform/examples/base_configs/iam_test_file.go.tmpl +++ b/mmv1/templates/terraform/examples/base_configs/iam_test_file.go.tmpl @@ -89,7 +89,7 @@ func TestAcc{{ $.ResourceName }}IamMemberGenerated(t *testing.T) { acctest.VcrTest(t, resource.TestCase{ {{- if not $.IamPolicy.ExcludeImportTest }} TerraformVersionChecks: []tfversion.TerraformVersionCheck{ - tfversion.RequireAbove(tfversion.Version1_12_0), // resource identity min version + tfversion.SkipBelow(tfversion.Version1_12_0), // resource identity min version }, {{- end }} PreCheck: func() { acctest.AccTestPreCheck(t) }, @@ -117,6 +117,47 @@ func TestAcc{{ $.ResourceName }}IamMemberGenerated(t *testing.T) { ImportState: true, ImportStateVerify: true, }, +<<<<<<< HEAD + { + ResourceName: "{{ $.IamTerraformName }}_member.foo", + ImportState: true, + ImportStateKind: resource.ImportBlockWithResourceIdentity, + }, +{{- end }} + }, + }) +} + +{{- if not $.IamPolicy.ExcludeImportTest }} +// TestAcc{{ $.ResourceName }}IamMemberIdentityImportGenerated imports {{ $.IamTerraformName }}_member using +// Terraform resource identity (import block). Requires Terraform 1.12+. +func TestAcc{{ $.ResourceName }}IamMemberIdentityImportGenerated(t *testing.T) { + t.Parallel() +{{ template "IamTestSetup" $ }} + + acctest.VcrTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_12_0), + }, + PreCheck: func() { acctest.AccTestPreCheck(t) }, +{{- if eq $.MinVersionObj.Name "beta" }} + ProtoV5ProviderFactories: acctest.ProtoV5ProviderBetaFactories(t), +{{- else }} + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories(t), +{{- end }} +{{- if $example.ExternalProviders }} + ExternalProviders: map[string]resource.ExternalProvider{ + {{- range $provider := $example.ExternalProviders }} + "{{$provider}}": {}, + {{- end }} + }, +{{- end }} + Steps: []resource.TestStep{ + { + Config: testAcc{{ $.ResourceName }}IamMember_basicGenerated(context), + }, +======= +>>>>>>> e126969d1 (add resourceIdentityImport step in first TestAcc instead of adding a new TestAcc) { ResourceName: "{{ $.IamTerraformName }}_member.foo", ImportState: true, diff --git a/mmv1/templates/terraform/iam_policy.go.tmpl b/mmv1/templates/terraform/iam_policy.go.tmpl index d42844b5512a..cc4cdbaa06c4 100644 --- a/mmv1/templates/terraform/iam_policy.go.tmpl +++ b/mmv1/templates/terraform/iam_policy.go.tmpl @@ -366,4 +366,4 @@ func (u *{{ $.ResourceName }}IamUpdater) GetMutexKey() string { func (u *{{ $.ResourceName }}IamUpdater) DescribeResource() string { return fmt.Sprintf("{{ lower $.ProductMetadata.Name }} {{ lower $.Name }} %q", u.GetResourceId()) -} +} \ No newline at end of file diff --git a/mmv1/templates/terraform/samples/base_configs/iam_test_file.go.tmpl b/mmv1/templates/terraform/samples/base_configs/iam_test_file.go.tmpl index 56404c3701b9..af51ac2f7847 100644 --- a/mmv1/templates/terraform/samples/base_configs/iam_test_file.go.tmpl +++ b/mmv1/templates/terraform/samples/base_configs/iam_test_file.go.tmpl @@ -92,7 +92,7 @@ func TestAcc{{ $.ResourceName }}IamMemberGenerated(t *testing.T) { acctest.VcrTest(t, resource.TestCase{ {{- if not $.IamPolicy.ExcludeImportTest }} TerraformVersionChecks: []tfversion.TerraformVersionCheck{ - tfversion.RequireAbove(tfversion.Version1_12_0), // resource identity min version + tfversion.SkipBelow(tfversion.Version1_12_0), // resource identity min version }, {{- end }} PreCheck: func() { acctest.AccTestPreCheck(t) }, diff --git a/mmv1/third_party/terraform/fwprovider/framework_provider.go.tmpl b/mmv1/third_party/terraform/fwprovider/framework_provider.go.tmpl index 78494578dfbc..2822d5067687 100644 --- a/mmv1/third_party/terraform/fwprovider/framework_provider.go.tmpl +++ b/mmv1/third_party/terraform/fwprovider/framework_provider.go.tmpl @@ -17,7 +17,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" - + sdkprovider "github.com/hashicorp/terraform-provider-google/google/provider" "github.com/hashicorp/terraform-provider-google/google/fwvalidators" "github.com/hashicorp/terraform-provider-google/google/functions" "github.com/hashicorp/terraform-provider-google/google/fwmodels" @@ -216,6 +216,17 @@ func (p *FrameworkProvider) Schema(_ context.Context, _ provider.SchemaRequest, transport_tpg.CustomEndpointValidator(), }, }, + + // Generated Products. Although these will only be _populated_ for registered products, they + // must always be present to match the ProviderModel struct. + {{- range $product := $.Products }} + "{{ underscore $product.Name }}_custom_endpoint": &schema.StringAttribute{ + Optional: true, + Validators: []validator.String{ + transport_tpg.CustomEndpointValidator(), + }, + }, + {{- end }} }, Blocks: map[string]schema.Block{ "batching": schema.ListNestedBlock{ @@ -259,14 +270,6 @@ func (p *FrameworkProvider) Schema(_ context.Context, _ provider.SchemaRequest, }, }, } - for _, p := range registry.ListProducts() { - resp.Schema.Attributes[p.CustomEndpointField] = &schema.StringAttribute{ - Optional: true, - Validators: []validator.String{ - transport_tpg.CustomEndpointValidator(), - }, - } - } } // Configure prepares the metadata/'meta' required for data sources and resources to function. @@ -330,7 +333,10 @@ func (p *FrameworkProvider) EphemeralResources(_ context.Context) []func() ephem } func (p *FrameworkProvider) ListResources(_ context.Context) []func() list.ListResource { - return registry.FrameworkListResourceFuncs() + listResources := registry.FrameworkListResourceFuncs() + listResources = append(listResources, sdkprovider.IamMemberListResources()...) + + return listResources } func (p *FrameworkProvider) GenerateResourceConfig(context.Context, any) (any, error) { diff --git a/mmv1/third_party/terraform/go.mod b/mmv1/third_party/terraform/go.mod index 26bfddcd1e91..c3c6627a72f3 100644 --- a/mmv1/third_party/terraform/go.mod +++ b/mmv1/third_party/terraform/go.mod @@ -131,4 +131,4 @@ require ( google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect -) +) \ No newline at end of file diff --git a/mmv1/third_party/terraform/go.sum b/mmv1/third_party/terraform/go.sum index 74bc1aabf6ea..4da0c0d4736b 100644 --- a/mmv1/third_party/terraform/go.sum +++ b/mmv1/third_party/terraform/go.sum @@ -472,4 +472,4 @@ gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= rsc.io/binaryregexp v0.2.0 h1:HfqmD5MEmC0zvwBuF187nq9mdnXjXsSivRiXN7SmRkE= -rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= \ No newline at end of file diff --git a/mmv1/third_party/terraform/main.go.tmpl b/mmv1/third_party/terraform/main.go.tmpl index d3b62681ef8e..04c5db2d2684 100644 --- a/mmv1/third_party/terraform/main.go.tmpl +++ b/mmv1/third_party/terraform/main.go.tmpl @@ -14,6 +14,73 @@ import ( "github.com/hashicorp/terraform-provider-google/google/provider" ) +type muxWithListResourceServer struct { + tfprotov5.ProviderServer + listServer tfprotov5.ProviderServerWithListResource +} + +func (s muxWithListResourceServer) GetMetadata(ctx context.Context, req *tfprotov5.GetMetadataRequest) (*tfprotov5.GetMetadataResponse, error) { + resp, err := s.ProviderServer.GetMetadata(ctx, req) + if err != nil { + return nil, err + } + + listResp, listErr := s.listServer.GetMetadata(ctx, req) + if listErr != nil || listResp == nil { + return resp, nil + } + + if resp == nil { + resp = &tfprotov5.GetMetadataResponse{} + } + existing := make(map[string]struct{}, len(resp.ListResources)) + for _, lr := range resp.ListResources { + existing[lr.TypeName] = struct{}{} + } + for _, lr := range listResp.ListResources { + if _, ok := existing[lr.TypeName]; ok { + continue + } + resp.ListResources = append(resp.ListResources, lr) + existing[lr.TypeName] = struct{}{} + } + return resp, nil +} + +func (s muxWithListResourceServer) GetProviderSchema(ctx context.Context, req *tfprotov5.GetProviderSchemaRequest) (*tfprotov5.GetProviderSchemaResponse, error) { + resp, err := s.ProviderServer.GetProviderSchema(ctx, req) + if err != nil { + return nil, err + } + + listResp, listErr := s.listServer.GetProviderSchema(ctx, req) + if listErr != nil || listResp == nil { + return resp, nil + } + + if resp == nil { + resp = &tfprotov5.GetProviderSchemaResponse{} + } + if resp.ListResourceSchemas == nil { + resp.ListResourceSchemas = map[string]*tfprotov5.Schema{} + } + for typeName, schema := range listResp.ListResourceSchemas { + if _, ok := resp.ListResourceSchemas[typeName]; ok { + continue + } + resp.ListResourceSchemas[typeName] = schema + } + return resp, nil +} + +func (s muxWithListResourceServer) ValidateListResourceConfig(ctx context.Context, req *tfprotov5.ValidateListResourceConfigRequest) (*tfprotov5.ValidateListResourceConfigResponse, error) { + return s.listServer.ValidateListResourceConfig(ctx, req) +} + +func (s muxWithListResourceServer) ListResource(ctx context.Context, req *tfprotov5.ListResourceRequest) (*tfprotov5.ListResourceServerStream, error) { + return s.listServer.ListResource(ctx, req) +} + func main() { var debug bool @@ -23,9 +90,15 @@ func main() { // primary is the SDKv2 implementation of the provider primary := provider.Provider() + frameworkServerFactory := providerserver.NewProtocol5(fwprovider.New(primary)) + frameworkServer := frameworkServerFactory() + listServer, ok := frameworkServer.(tfprotov5.ProviderServerWithListResource) + if !ok { + log.Fatal("framework provider does not implement list resource RPCs") + } providers := []func() tfprotov5.ProviderServer{ primary.GRPCProvider, // sdk provider - providerserver.NewProtocol5(fwprovider.New(primary)), // framework provider + func() tfprotov5.ProviderServer { return frameworkServer }, // framework provider singleton } // use the muxer @@ -42,7 +115,12 @@ func main() { err = tf5server.Serve( "registry.terraform.io/hashicorp/google{{- if ne $.TargetVersionName "ga" -}}-{{$.TargetVersionName}}{{- end }}", - muxServer.ProviderServer, + func() tfprotov5.ProviderServer { + return muxWithListResourceServer{ + ProviderServer: muxServer.ProviderServer(), + listServer: listServer, + } + }, serveOpts..., ) diff --git a/mmv1/third_party/terraform/provider/iam_member_list_resources.go b/mmv1/third_party/terraform/provider/iam_member_list_resources.go new file mode 100644 index 000000000000..eb809ff8b5d4 --- /dev/null +++ b/mmv1/third_party/terraform/provider/iam_member_list_resources.go @@ -0,0 +1,12 @@ +package provider + +import ( + "github.com/hashicorp/terraform-plugin-framework/list" + "github.com/hashicorp/terraform-provider-google/google/services/resourcemanager" +) + +func IamMemberListResources() []func() list.ListResource { + return []func() list.ListResource{ + resourcemanager.NewProjectIamMemberListResource, + } +} diff --git a/mmv1/third_party/terraform/services/resourcemanager/iam_folder.go.tmpl b/mmv1/third_party/terraform/services/resourcemanager/iam_folder.go.tmpl index 9530a5b94593..fa1255f22959 100644 --- a/mmv1/third_party/terraform/services/resourcemanager/iam_folder.go.tmpl +++ b/mmv1/third_party/terraform/services/resourcemanager/iam_folder.go.tmpl @@ -49,8 +49,9 @@ func FolderIdParseFunc(d *schema.ResourceData, _ *transport_tpg.Config) error { return nil } -// FolderIamParentResourceIdentityParser returns the parent folder's id -func FolderIamParentResourceIdentityParser(d *schema.ResourceData, identity *schema.IdentityData, _ *transport_tpg.Config) (string, error) { +// FolderIamParentParentResourceIdentityParser returns the canonical folder id for google_folder_iam_member +// import via resource identity (same shape as FolderIamUpdater.GetResourceId()). +func FolderIamParentParentResourceIdentityParser(d *schema.ResourceData, identity *schema.IdentityData, _ *transport_tpg.Config) (string, error) { v, ok := identity.GetOk("folder") if !ok { return "", fmt.Errorf("import identity is missing attribute %q", "folder") @@ -171,7 +172,7 @@ func init() { Name: "google_folder_iam_member", ProductName: "resourcemanager", Type: registry.SchemaTypeIAMResource, - Schema: tpgiamresource.ResourceIamMember(IamFolderSchema, NewFolderIamUpdater, FolderIdParseFunc, tpgiamresource.IamWithBatching, tpgiamresource.IamWithParentResourceIdentity(FolderIamParentResourceIdentityParser)), + Schema: tpgiamresource.ResourceIamMember(IamFolderSchema, NewFolderIamUpdater, FolderIdParseFunc, tpgiamresource.IamWithBatching, tpgiamresource.IamWithParentResourceIdentity(FolderIamParentParentResourceIdentityParser)), }.Register() registry.Schema{ Name: "google_folder_iam_policy", @@ -186,4 +187,4 @@ func init() { Schema: tpgiamresource.DataSourceIamPolicy(IamFolderSchema, NewFolderIamUpdater), }.Register() } -{{- end}} +{{- end}} \ No newline at end of file diff --git a/mmv1/third_party/terraform/services/resourcemanager/iam_project.go.tmpl b/mmv1/third_party/terraform/services/resourcemanager/iam_project.go.tmpl index 5613b7a5575c..026f0926b754 100644 --- a/mmv1/third_party/terraform/services/resourcemanager/iam_project.go.tmpl +++ b/mmv1/third_party/terraform/services/resourcemanager/iam_project.go.tmpl @@ -13,6 +13,8 @@ import ( "github.com/hashicorp/terraform-provider-google/google/tpgresource" transport_tpg "github.com/hashicorp/terraform-provider-google/google/transport" "google.golang.org/api/cloudresourcemanager/v1" + + "github.com/hashicorp/terraform-plugin-framework/list" ) var IamProjectSchema = map[string]*schema.Schema{ @@ -45,8 +47,9 @@ func ProjectIdParseFunc(d *schema.ResourceData, _ *transport_tpg.Config) error { return nil } -// ProjectIamParentParentResourceIdentityParser returns the parent project's id -func ProjectIamParentResourceIdentityParser(d *schema.ResourceData, identity *schema.IdentityData, _ *transport_tpg.Config) (string, error) { +// ProjectIamParentParentResourceIdentityParser returns the project id for google_project_iam_member import +// via resource identity (same shape as ProjectIamUpdater.GetResourceId()). +func ProjectIamParentParentResourceIdentityParser(d *schema.ResourceData, identity *schema.IdentityData, _ *transport_tpg.Config) (string, error) { v, ok := identity.GetOk("project") if !ok { return "", fmt.Errorf("import identity is missing attribute %q", "project") @@ -58,6 +61,25 @@ func ProjectIamParentResourceIdentityParser(d *schema.ResourceData, identity *sc return s, nil } +// NewProjectIamMemberListResource returns the list implementation for google_project_iam_member. +func NewProjectIamMemberListResource() list.ListResource { + return tpgiamresource.NewIamMemberListResource( + "google_project_iam_member", + tpgiamresource.ResourceIamMember( + IamProjectSchema, + NewProjectIamUpdater, + ProjectIdParseFunc, + tpgiamresource.IamWithBatching, + tpgiamresource.IamWithParentResourceIdentity(ProjectIamParentParentResourceIdentityParser), + ), + NewProjectIamUpdater, + tpgiamresource.IamMemberListCallConfig{ + EnableRoleFilter: true, + EnableMemberFilter: true, + }, + ) +} + func (u *ProjectIamUpdater) GetResourceIamPolicy() (*cloudresourcemanager.Policy, error) { projectId := tpgresource.GetResourceNameFromSelfLink(u.resourceId) @@ -134,7 +156,12 @@ func init() { Name: "google_project_iam_member", ProductName: "resourcemanager", Type: registry.SchemaTypeIAMResource, - Schema: tpgiamresource.ResourceIamMember(IamProjectSchema, NewProjectIamUpdater, ProjectIdParseFunc, tpgiamresource.IamWithBatching, tpgiamresource.IamWithParentResourceIdentity(ProjectIamParentResourceIdentityParser)), + Schema: tpgiamresource.ResourceIamMember(IamProjectSchema, + NewProjectIamUpdater, + ProjectIdParseFunc, + tpgiamresource.IamWithBatching, + tpgiamresource.IamWithParentResourceIdentity(ProjectIamParentParentResourceIdentityParser), + ), }.Register() registry.Schema{ Name: "google_project_iam_binding", @@ -155,4 +182,4 @@ func init() { Schema: tpgiamresource.DataSourceIamPolicy(IamProjectSchema, NewProjectIamUpdater), }.Register() } -{{- end}} +{{- end}} \ No newline at end of file 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 new file mode 100644 index 000000000000..b38be68bfb46 --- /dev/null +++ b/mmv1/third_party/terraform/services/resourcemanager/resource_google_project_iam_member_list_test.go @@ -0,0 +1,114 @@ +package resourcemanager_test + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "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" +) + +func TestAccProjectIamMemberList_basic(t *testing.T) { + t.Parallel() + + project := envvar.GetTestProjectFromEnv() + role := "roles/compute.instanceAdmin" + member := "user:admin@hashicorptest.com" + + acctest.VcrTest(t, resource.TestCase{ + PreCheck: func() { acctest.AccTestPreCheck(t) }, + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories(t), + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + // List resources require Terraform >= 1.14.0 (terraform query / .tfquery.hcl). + tfversion.SkipBelow(tfversion.Version1_14_0), + }, + Steps: []resource.TestStep{ + { + Config: testAccProjectIamMemberCreate(project, role, member), + }, + + { + Query: true, + Config: testAccProjectIamMemberListQuery(project), + QueryResultChecks: []querycheck.QueryResultCheck{ + querycheck.ExpectLengthAtLeast("google_project_iam_member.test", 1), + }, + }, + }, + }) +} + +// test with optional filters +func TestAccProjectIamMemberList_filter(t *testing.T) { + t.Parallel() + + project := envvar.GetTestProjectFromEnv() + role := "roles/compute.instanceAdmin" + member := "user:admin@hashicorptest.com" + + acctest.VcrTest(t, resource.TestCase{ + PreCheck: func() { acctest.AccTestPreCheck(t) }, + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories(t), + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_14_0), + }, + Steps: []resource.TestStep{ + { + Config: testAccProjectIamMemberCreate(project, role, member), + }, + + { + Query: true, + Config: testAccProjectIamMemberListQueryWithFilters(project, role, member), + + QueryResultChecks: []querycheck.QueryResultCheck{ + querycheck.ExpectLength("google_project_iam_member.test", 1), + }, + }, + }, + }) +} + +func testAccProjectIamMemberCreate(project, role, member string) string { + return fmt.Sprintf(` +resource "google_project_iam_member" "test" { + project = %q + role = %q + member = %q +} +`, project, role, member) +} + +func testAccProjectIamMemberListQuery(project string) string { + return fmt.Sprintf(` +list "google_project_iam_member" "test" { + provider = google + + # include_resource allows result.resource.* fields to be present in query output + include_resource = true + + config { + project = %q + } +} +`, project) +} + +func testAccProjectIamMemberListQueryWithFilters(project, role, member string) string { + return fmt.Sprintf(` +list "google_project_iam_member" "test" { + provider = google + include_resource = true + + config { + project = %q + role = %q + member = %q + } +} +`, project, role, member) +} diff --git a/mmv1/third_party/terraform/tpgiamresource/iam.go.tmpl b/mmv1/third_party/terraform/tpgiamresource/iam.go.tmpl index b1d6f8e6ea0a..62b612c21afc 100644 --- a/mmv1/third_party/terraform/tpgiamresource/iam.go.tmpl +++ b/mmv1/third_party/terraform/tpgiamresource/iam.go.tmpl @@ -432,7 +432,8 @@ type IamSettings struct { StateUpgraders []schema.StateUpgrader SchemaVersion int CreateTimeOut int64 - // ParentResourceIdentityParser, when non-nil, enables ResourceIdentity for this IAM resource + // ParentResourceIdentityParser, when non-nil, enables Terraform resource identity for this IAM + // member resource (identity schema, create/read identity sync, and import when d.Id() is empty). ParentResourceIdentityParser ParentResourceIdFromIdentityParserFunc } @@ -450,7 +451,7 @@ func IamWithDeprecationMessage(message string) func(s *IamSettings) { } } -// IamWithParentResourceIdentity enables import with resource identity block when parser is non-nil. +// IamWithParentResourceIdentity sets the parser that derives the canonical resource id from import identity when d.Id() is empty. func IamWithParentResourceIdentity(parser ParentResourceIdFromIdentityParserFunc) func(*IamSettings) { return func(s *IamSettings) { s.ParentResourceIdentityParser = parser @@ -543,7 +544,6 @@ func missingBindingsMap(aMap, bMap map[iamBindingKey]map[string]struct{}) map[ia func MissingBindings(a, b []*cloudresourcemanager.Binding) []*cloudresourcemanager.Binding { aMap := createIamBindingsMap(a) bMap := createIamBindingsMap(b) - var results []*cloudresourcemanager.Binding for key, membersSet := range missingBindingsMap(aMap, bMap) { members := make([]string, 0, len(membersSet)) @@ -575,7 +575,7 @@ func ConvertToIdentitySchema(parentSchema map[string]*schema.Schema) map[string] } // Copies IAM parent attributes from resource state into identity -func populateIamParentIdentity(identity *schema.IdentityData, d *schema.ResourceData, parentSchema map[string]*schema.Schema) { +func PopulateIamParentIdentity(identity *schema.IdentityData, d *schema.ResourceData, parentSchema map[string]*schema.Schema) { for attr := range parentSchema { identity.Set(attr, d.Get(attr)) } diff --git a/mmv1/third_party/terraform/tpgiamresource/iam_resource_identity.go b/mmv1/third_party/terraform/tpgiamresource/iam_resource_identity.go index eae52a1d88d7..218d2490685c 100644 --- a/mmv1/third_party/terraform/tpgiamresource/iam_resource_identity.go +++ b/mmv1/third_party/terraform/tpgiamresource/iam_resource_identity.go @@ -16,7 +16,7 @@ type IamIdentityParam struct { } // IamResourceIdentityConfig holds all the per-resource data needed to parse an -// IAM import identity into the parent resource's id +// IAM import identity into a canonical resource id. type IamResourceIdentityConfig struct { Params []IamIdentityParam UriFormat string // fmt.Sprintf format producing the canonical resource id @@ -29,7 +29,7 @@ var DefaultConfigValueFuncs = map[string]func(tpgresource.TerraformResourceData, "location": tpgresource.GetLocation, } -// ParseIamResourceIdentity resolves an IAM import identity into the parent +// ParseIamResourceIdentity resolves an IAM import identity into a canonical // resource id string (the same shape as the IAM updater's GetResourceId()). func ParseIamResourceIdentity( d *schema.ResourceData, diff --git a/mmv1/third_party/terraform/tpgiamresource/resource_iam_member.go b/mmv1/third_party/terraform/tpgiamresource/resource_iam_member.go index b6f6b89fa491..8dcbb08e06de 100644 --- a/mmv1/third_party/terraform/tpgiamresource/resource_iam_member.go +++ b/mmv1/third_party/terraform/tpgiamresource/resource_iam_member.go @@ -191,7 +191,7 @@ func iamMemberImport(newUpdaterFunc NewResourceIamUpdaterFunc, resourceIdParser } // setIamMemberIdFromParentResourceIdentity converts a resource-identity import into -// the `id` (`{resource} {role} {member} [condition_title]`) consumed +// the canonical id (`{resource} {role} {member} [condition_title]`) consumed // by iamMemberImport. No-op if there is no identity parser or d already has // an id. func setIamMemberIdFromParentResourceIdentity(d *schema.ResourceData, config *transport_tpg.Config, parentResourceIdentityParser ParentResourceIdFromIdentityParserFunc) error { @@ -240,11 +240,11 @@ func setIamMemberIdFromParentResourceIdentity(d *schema.ResourceData, config *tr } // setIamMemberResourceIdentity sets parent attributes from state plus role/member/condition_title. -// ParentResourceIdentityParser is only identity→id (for import); it cannot derive parent +// ParentResourceIdentityParser is only identity→canonical id (for import); it cannot derive parent // fields from updater.GetResourceId(). Those fields must come from the same state the updater // used, so they stay consistent with GetResourceId() and round-trip through ParentResourceIdentityParser. func setIamMemberResourceIdentity(identity *schema.IdentityData, d *schema.ResourceData, parentSpecificSchema map[string]*schema.Schema, role, member, conditionTitle string) { - populateIamParentIdentity(identity, d, parentSpecificSchema) + PopulateIamParentIdentity(identity, d, parentSpecificSchema) identity.Set("role", role) identity.Set("member", tpgresource.NormalizeIamPrincipalCasing(member)) if conditionTitle != "" { diff --git a/mmv1/third_party/terraform/tpgiamresource/resource_iam_member_list.go b/mmv1/third_party/terraform/tpgiamresource/resource_iam_member_list.go new file mode 100644 index 000000000000..3ad6e8a1fb84 --- /dev/null +++ b/mmv1/third_party/terraform/tpgiamresource/resource_iam_member_list.go @@ -0,0 +1,299 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +// IAM list resources enumerate rows for google_*_iam_member instances by reading +// IAM policies on one or more GCP resources (policy targets). +// +// When IamMemberListCallConfig.ListUrlFunc is set, List() uses transport.ListCall to +// discover multiple targets (e.g. all disks in a zone), then reads IAM for each. +// Otherwise a single target is built from the list block. + +package tpgiamresource + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/list" + listschema "github.com/hashicorp/terraform-plugin-framework/list/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "google.golang.org/api/cloudresourcemanager/v1" + + "github.com/hashicorp/terraform-provider-google/google/tpgresource" + transport_tpg "github.com/hashicorp/terraform-provider-google/google/transport" +) + +var _ list.ListResource = &IamMemberListResource{} +var _ list.ListResourceWithRawV5Schemas = &IamMemberListResource{} +var _ list.ListResourceWithConfigure = &IamMemberListResource{} + +// IamMemberListCallConfig holds resource-specific pieces for transport.ListCall. +type IamMemberListCallConfig struct { + ListUrlFunc func(rd *schema.ResourceData, config *transport_tpg.Config) (string, error) + Flattener func(item map[string]interface{}, d *schema.ResourceData, config *transport_tpg.Config) error + ItemName string // JSON key for items array (default "items") + ResourceNameField string // identity key filled by list API, excluded from list block + EnableRoleFilter bool + EnableMemberFilter bool +} + +// IamMemberListResource lists IAM member rows by reading IAM policies on one or more policy targets. +type IamMemberListResource struct { + tpgresource.ListResourceMetadata + + typeName string + memberResource *schema.Resource + iamResourceSchema map[string]*schema.Schema // parent-identifying fields (project, zone, name, …) + listBlockSchema map[string]*schema.Schema // iamResourceSchema minus ResourceNameField + listCallConfig IamMemberListCallConfig + newUpdater NewResourceIamUpdaterFunc +} + +func NewIamMemberListResource(typeName string, memberResource *schema.Resource, newUpdater NewResourceIamUpdaterFunc, listCallConfig IamMemberListCallConfig) list.ListResource { + if memberResource.Identity == nil { + panic("tpgiamresource: NewIamMemberListResource requires a memberResource with identity (use IamWithResourceIdentity)") + } + iamResourceSchema, listBlockSchema := tpgresource.DeriveListSchemas(memberResource.Schema, IamMemberBaseSchema, listCallConfig.ResourceNameField) + return &IamMemberListResource{ + typeName: typeName, + memberResource: memberResource, + iamResourceSchema: iamResourceSchema, + listBlockSchema: listBlockSchema, + listCallConfig: listCallConfig, + newUpdater: newUpdater, + } +} + +func (r *IamMemberListResource) Metadata(_ context.Context, _ resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = r.typeName +} + +func (r *IamMemberListResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + r.Defaults(req, resp) +} + +func (r *IamMemberListResource) RawV5Schemas(ctx context.Context, _ list.RawV5SchemaRequest, resp *list.RawV5SchemaResponse) { + resp.ProtoV5Schema = r.memberResource.ProtoSchema(ctx)() + if fn := r.memberResource.ProtoIdentitySchema(ctx); fn != nil { + resp.ProtoV5IdentitySchema = fn() + } +} + +func (r *IamMemberListResource) ListResourceConfigSchema(_ context.Context, _ list.ListResourceSchemaRequest, resp *list.ListResourceSchemaResponse) { + s := tpgresource.SdkSchemaToListSchema(r.listBlockSchema) + + if r.listCallConfig.EnableRoleFilter { + s.Attributes["role"] = listschema.StringAttribute{ + Optional: true, + Description: "Optional client-side filter for IAM role.", + } + } + + if r.listCallConfig.EnableMemberFilter { + s.Attributes["member"] = listschema.StringAttribute{ + Optional: true, + Description: "Optional client-side filter for IAM member.", + } + } + resp.Schema = s +} + +// discoverPolicyTargets returns one ResourceData per GCP resource whose IAM policy should be read. +func (r *IamMemberListResource) discoverPolicyTargets(ctx context.Context, req list.ListRequest) ([]*schema.ResourceData, error) { + baseRd := r.memberResource.TestResourceData() + if diags := tpgresource.ApplyListBlockConfig(ctx, req, r.listBlockSchema, baseRd); diags.HasError() { + return nil, fmt.Errorf("%s", diags.Errors()[0].Detail()) + } + + if r.listCallConfig.ListUrlFunc == nil { + return []*schema.ResourceData{baseRd}, nil + } + + listUrl, err := r.listCallConfig.ListUrlFunc(baseRd, r.Client) + if err != nil { + return nil, fmt.Errorf("building list URL: %w", err) + } + + var targets []*schema.ResourceData + if err := transport_tpg.ListCall(transport_tpg.ListCallOptions{ + Config: r.Client, + TempData: baseRd, + Url: listUrl, + UserAgent: r.Client.UserAgent, + ItemName: r.listCallConfig.ItemName, + Flattener: r.listCallConfig.Flattener, + Callback: func(temp *schema.ResourceData) error { + rd := r.memberResource.TestResourceData() + tpgresource.CopyResourceDataFields(rd, temp, r.iamResourceSchema) + targets = append(targets, rd) + return nil + }, + }); err != nil { + return nil, fmt.Errorf("listing resources: %w", err) + } + return targets, nil +} + +func (r *IamMemberListResource) List(ctx context.Context, req list.ListRequest, stream *list.ListResultsStream) { + policyTargets, err := r.discoverPolicyTargets(ctx, req) + if err != nil { + var diags diag.Diagnostics + diags.AddError("Error discovering policy targets", err.Error()) + stream.Results = list.ListResultsStreamDiagnostics(diags) + return + } + + roleFilter, memberFilter, diags := r.readFilters(ctx, req) + if diags.HasError() { + stream.Results = list.ListResultsStreamDiagnostics(diags) + return + } + + stream.Results = func(yield func(list.ListResult) bool) { + var yielded int64 + for _, targetRd := range policyTargets { + if req.Limit > 0 && yielded >= req.Limit { + return + } + updater, err := r.newUpdater(targetRd, r.Client) + if err != nil { + res := req.NewListResult(ctx) + res.Diagnostics.AddError("API Error", err.Error()) + if !yield(res) { + return + } + continue + } + p, err := iamPolicyReadWithRetry(updater) + if err != nil { + res := req.NewListResult(ctx) + res.Diagnostics.AddError("API Error", err.Error()) + if !yield(res) { + return + } + continue + } + + if !r.yieldPolicyMembers(ctx, req, targetRd, updater, p, roleFilter, memberFilter, &yielded, yield) { + return + } + } + } +} + +// yieldPolicyMembers yields one list result per IAM binding member for a single policy target. +func (r *IamMemberListResource) yieldPolicyMembers(ctx context.Context, req list.ListRequest, targetRd *schema.ResourceData, updater ResourceIamUpdater, p *cloudresourcemanager.Policy, roleFilter string, memberFilter string, yielded *int64, yield func(list.ListResult) bool) bool { + for _, binding := range p.Bindings { + if roleFilter != "" && binding.Role != roleFilter { + continue + } + for _, mem := range binding.Members { + normalized := tpgresource.NormalizeIamPrincipalCasing(mem) + + if memberFilter != "" && normalized != memberFilter { + continue + } + if strings.HasPrefix(mem, "deleted:") { + continue + } + if req.Limit > 0 && *yielded >= req.Limit { + return true + } + res, err := r.buildMemberResult(ctx, req, targetRd, updater, binding, normalized, p.Etag) + if err != nil { + res = req.NewListResult(ctx) + res.Diagnostics.AddError("Error building IAM member result", err.Error()) + } + *yielded++ + if !yield(res) { + return false + } + } + } + return true +} + +// buildMemberResult populates a ResourceData for one binding member and converts it to a ListResult. +func (r *IamMemberListResource) buildMemberResult(ctx context.Context, req list.ListRequest, targetRd *schema.ResourceData, updater ResourceIamUpdater, binding *cloudresourcemanager.Binding, member, etag string) (list.ListResult, error) { + rd := r.memberResource.TestResourceData() + tpgresource.CopyResourceDataFields(rd, targetRd, r.iamResourceSchema) + + normalized := tpgresource.NormalizeIamPrincipalCasing(member) + for k, v := range map[string]interface{}{ + "role": binding.Role, + "member": normalized, + "condition": FlattenIamCondition(binding.Condition), + "etag": etag, + } { + if err := rd.Set(k, v); err != nil { + return list.ListResult{}, fmt.Errorf("set %s: %w", k, err) + } + } + + id := updater.GetResourceId() + "/" + binding.Role + "/" + normalized + if k := conditionKeyFromCondition(binding.Condition); !k.Empty() { + id += "/" + k.String() + } + rd.SetId(id) + + identity, err := rd.Identity() + if err != nil { + return list.ListResult{}, fmt.Errorf("identity: %w", err) + } + condTitle := "" + if binding.Condition != nil { + condTitle = binding.Condition.Title + } + setIamMemberResourceIdentity(identity, rd, r.iamResourceSchema, binding.Role, member, condTitle) + + res := req.NewListResult(ctx) + tfIdent, err := rd.TfTypeIdentityState() + if err != nil { + return list.ListResult{}, fmt.Errorf("identity state: %w", err) + } + if err := res.Identity.Set(ctx, *tfIdent); err != nil { + return list.ListResult{}, fmt.Errorf("set identity: %v", err) + } + + if req.IncludeResource { + tfRes, err := rd.TfTypeResourceState() + if err != nil { + return list.ListResult{}, fmt.Errorf("resource state: %w", err) + } + if err := res.Resource.Set(ctx, *tfRes); err != nil { + return list.ListResult{}, fmt.Errorf("set resource: %v", err) + } + } + + res.DisplayName = fmt.Sprintf("%s %s %s", updater.DescribeResource(), binding.Role, normalized) + return res, nil +} + +func (r *IamMemberListResource) readFilters(ctx context.Context, req list.ListRequest) (string, string, diag.Diagnostics) { + var diags diag.Diagnostics + roleFilter := "" + memberFilter := "" + + if r.listCallConfig.EnableRoleFilter { + var v types.String + diags.Append(req.Config.GetAttribute(ctx, path.Root("role"), &v)...) + if !v.IsNull() && !v.IsUnknown() { + roleFilter = v.ValueString() + } + } + + if r.listCallConfig.EnableMemberFilter { + var v types.String + diags.Append(req.Config.GetAttribute(ctx, path.Root("member"), &v)...) + if !v.IsNull() && !v.IsUnknown() { + memberFilter = v.ValueString() + } + } + + return roleFilter, memberFilter, diags +} diff --git a/mmv1/third_party/terraform/tpgresource/list_resource.go b/mmv1/third_party/terraform/tpgresource/list_resource.go index 2dec34b042aa..d7a0ffabe2cd 100644 --- a/mmv1/third_party/terraform/tpgresource/list_resource.go +++ b/mmv1/third_party/terraform/tpgresource/list_resource.go @@ -9,8 +9,10 @@ import ( "fmt" "log" + fwdiag "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/list" listschema "github.com/hashicorp/terraform-plugin-framework/list/schema" + "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" @@ -248,3 +250,66 @@ func (listR *ListResourceMetadata) SetResult(ctx context.Context, includeResourc return nil } + +// SdkSchemaToListSchema converts an SDK schema map to a plugin-framework list schema. +// Required SDK attributes become Required list attributes; everything else becomes Optional. +func SdkSchemaToListSchema(sdkSchema map[string]*schema.Schema) listschema.Schema { + attrs := make(map[string]listschema.Attribute, len(sdkSchema)) + for name, sch := range sdkSchema { + attr := listschema.StringAttribute{Description: sch.Description} + if sch.Required { + attr.Required = true + } else { + attr.Optional = true + } + attrs[name] = attr + } + return listschema.Schema{Attributes: attrs} +} + +// ApplyListBlockConfig reads string attributes from the Terraform list-block config +// and sets them on the given ResourceData. +func ApplyListBlockConfig(ctx context.Context, req list.ListRequest, attrSchema map[string]*schema.Schema, rd *schema.ResourceData) fwdiag.Diagnostics { + var diags fwdiag.Diagnostics + for attrName := range attrSchema { + var v types.String + diags.Append(req.Config.GetAttribute(ctx, path.Root(attrName), &v)...) + if diags.HasError() { + return diags + } + if v.IsNull() || v.IsUnknown() { + continue + } + if err := rd.Set(attrName, v.ValueString()); err != nil { + diags.AddError("Error setting list block attribute", fmt.Sprintf("%s: %v", attrName, err)) + return diags + } + } + return diags +} + +// CopyResourceDataFields copies the values of the given schema keys from src to dst. +func CopyResourceDataFields(dst, src *schema.ResourceData, fields map[string]*schema.Schema) { + for k := range fields { + _ = dst.Set(k, src.Get(k)) + } +} + +// DeriveListSchemas splits a resource's full schema into two maps by stripping out baseSchema +// keys and optionally excluding resourceNameField from the list block. +// - resourceSchema: all keys in fullSchema that are NOT in baseSchema +// - listBlockSchema: same as resourceSchema minus resourceNameField (if non-empty) +func DeriveListSchemas(fullSchema map[string]*schema.Schema, baseSchema map[string]*schema.Schema, resourceNameField string) (resourceSchema, listBlockSchema map[string]*schema.Schema) { + resourceSchema = make(map[string]*schema.Schema, len(fullSchema)) + listBlockSchema = make(map[string]*schema.Schema, len(fullSchema)) + for k, v := range fullSchema { + if _, isBase := baseSchema[k]; isBase { + continue + } + resourceSchema[k] = v + if k != resourceNameField { + listBlockSchema[k] = v + } + } + return +} diff --git a/mmv1/third_party/terraform/transport/transport.go b/mmv1/third_party/terraform/transport/transport.go index d6dae5f69c6c..67c0f57c9eda 100644 --- a/mmv1/third_party/terraform/transport/transport.go +++ b/mmv1/third_party/terraform/transport/transport.go @@ -118,6 +118,76 @@ func SendRequest(opt SendRequestOptions) (map[string]interface{}, error) { return result, nil } +// ListCallOptions configures paginated LIST API calls that return items under a JSON array key (default "items"). +type ListCallOptions struct { + Config *Config + TempData *schema.ResourceData + Url string + BillingProject string + UserAgent string + ItemName string + Filter string + Flattener func(item map[string]interface{}, d *schema.ResourceData, config *Config) error + Callback func(rd *schema.ResourceData) error +} + +// ListCall performs GET requests with optional filter and pageToken, invoking Flattener then Callback per item. +func ListCall(opts ListCallOptions) error { + if opts.ItemName == "" { + opts.ItemName = "items" + } + + params := make(map[string]string) + if opts.Filter != "" { + params["filter"] = opts.Filter + } + + for { + url, err := AddQueryParams(opts.Url, params) + if err != nil { + return err + } + + headers := make(http.Header) + res, err := SendRequest(SendRequestOptions{ + Config: opts.Config, + Method: "GET", + Project: opts.BillingProject, + RawURL: url, + UserAgent: opts.UserAgent, + Headers: headers, + ErrorRetryPredicates: []RetryErrorPredicateFunc{Is429RetryableQuotaError}, + }) + if err != nil { + return HandleNotFoundError(err, opts.TempData, opts.ItemName) + } + + if v, ok := res[opts.ItemName].([]interface{}); ok { + for _, item := range v { + itemMap, ok := item.(map[string]interface{}) + if !ok { + return fmt.Errorf("expected item to be map[string]interface{}, got %T", item) + } + + err = opts.Flattener(itemMap, opts.TempData, opts.Config) + if err != nil { + return fmt.Errorf("Error flattening list item: %w", err) + } + err = opts.Callback(opts.TempData) + if err != nil { + return err + } + } + } + tok, ok := res["nextPageToken"] + if !ok || tok.(string) == "" { + break + } + params["pageToken"] = tok.(string) + } + return nil +} + func AddQueryParams(rawurl string, params map[string]string) (string, error) { u, err := url.Parse(rawurl) if err != nil {