diff --git a/CHANGELOG.md b/CHANGELOG.md index ebbda67..1ebbb2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,9 @@ All notable changes to this project are documented here. The format is based on - Acceptance test harness (`internal/acctest`: protocol-6 provider factory + `PreCheck`) and the first acceptance test for `pcd_identity_auth_scope` (passes against the CE lab). - `test.yml` CI: build, vet, gofmt, unit tests, and `terraform fmt` on examples. +- Identity (Keystone v3) resources: `pcd_identity_project`, `pcd_identity_role`, + `pcd_identity_user`, `pcd_identity_role_assignment`, `pcd_identity_application_credential`. +- Identity data sources: `pcd_identity_project`, `pcd_identity_user`, `pcd_identity_role`. ### Known gaps - `cloud` (clouds.yaml) is declared but not yet implemented; it errors if set. diff --git a/internal/acctest/acctest.go b/internal/acctest/acctest.go index 8a849e1..0589233 100644 --- a/internal/acctest/acctest.go +++ b/internal/acctest/acctest.go @@ -7,12 +7,14 @@ package acctest import ( + "context" "os" "testing" "github.com/hashicorp/terraform-plugin-framework/providerserver" "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/platform9/terraform-provider-pcd/internal/clients" "github.com/platform9/terraform-provider-pcd/internal/provider" ) @@ -41,3 +43,34 @@ func PreCheck(t *testing.T) { t.Skipf("PCD acceptance tests require a reachable lab; missing env: %v", missing) } } + +// LabConfig returns an authenticated client built from the OS_* environment, for +// use in CheckDestroy/CheckExists helpers that query the API out of band. +func LabConfig(t *testing.T) *clients.Config { + t.Helper() + cfg := &clients.Config{ + AuthURL: os.Getenv("OS_AUTH_URL"), + Region: os.Getenv("OS_REGION_NAME"), + Username: os.Getenv("OS_USERNAME"), + Password: os.Getenv("OS_PASSWORD"), + TenantName: firstEnv("OS_PROJECT_NAME", "OS_TENANT_NAME"), + TenantID: firstEnv("OS_PROJECT_ID", "OS_TENANT_ID"), + UserDomainID: os.Getenv("OS_USER_DOMAIN_ID"), + ProjectDomainID: os.Getenv("OS_PROJECT_DOMAIN_ID"), + Insecure: os.Getenv("OS_INSECURE") != "", + AllowReauth: true, + } + if err := cfg.Authenticate(context.Background()); err != nil { + t.Fatalf("acctest: authenticate to lab: %v", err) + } + return cfg +} + +func firstEnv(keys ...string) string { + for _, k := range keys { + if v := os.Getenv(k); v != "" { + return v + } + } + return "" +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 606ceaf..1b62def 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -36,11 +36,20 @@ func (p *pcdProvider) Metadata(_ context.Context, _ provider.MetadataRequest, re } func (p *pcdProvider) Resources(_ context.Context) []func() resource.Resource { - return nil + return []func() resource.Resource{ + identity.NewProjectResource, + identity.NewRoleResource, + identity.NewUserResource, + identity.NewRoleAssignmentResource, + identity.NewApplicationCredentialResource, + } } func (p *pcdProvider) DataSources(_ context.Context) []func() datasource.DataSource { return []func() datasource.DataSource{ identity.NewAuthScopeDataSource, + identity.NewProjectDataSource, + identity.NewUserDataSource, + identity.NewRoleDataSource, } } diff --git a/internal/services/identity/application_credential_resource.go b/internal/services/identity/application_credential_resource.go new file mode 100644 index 0000000..4c710b9 --- /dev/null +++ b/internal/services/identity/application_credential_resource.go @@ -0,0 +1,295 @@ +// Copyright (c) Platform9 Systems, Inc. +// SPDX-License-Identifier: MPL-2.0 +// +// Ported from terraform-provider-openstack v3.4.0 +// (openstack/resource_openstack_identity_application_credential_v3.go), adapted +// for the terraform-plugin-framework and PCD. + +package identity + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/gophercloud/gophercloud/v2" + "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/applicationcredentials" + "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/tokens" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/setplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/platform9/terraform-provider-pcd/internal/clients" +) + +var ( + _ resource.Resource = (*appCredResource)(nil) + _ resource.ResourceWithConfigure = (*appCredResource)(nil) + _ resource.ResourceWithImportState = (*appCredResource)(nil) +) + +// NewApplicationCredentialResource is the factory registered with the provider. +func NewApplicationCredentialResource() resource.Resource { + return &appCredResource{} +} + +type appCredResource struct { + config *clients.Config +} + +type appCredModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Description types.String `tfsdk:"description"` + Secret types.String `tfsdk:"secret"` + ProjectID types.String `tfsdk:"project_id"` + Roles types.Set `tfsdk:"roles"` + ExpiresAt types.String `tfsdk:"expires_at"` + Unrestricted types.Bool `tfsdk:"unrestricted"` + Region types.String `tfsdk:"region"` +} + +func (r *appCredResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_identity_application_credential" +} + +func (r *appCredResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + forceNewString := []planmodifier.String{stringplanmodifier.RequiresReplace(), stringplanmodifier.UseStateForUnknown()} + resp.Schema = schema.Schema{ + MarkdownDescription: "Manages an application credential for the authenticated user. Application " + + "credentials are immutable — any change forces a new resource.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The application credential ID.", + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + }, + "name": schema.StringAttribute{ + Required: true, + MarkdownDescription: "The name of the application credential.", + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, + }, + "description": schema.StringAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: "A description of the application credential.", + PlanModifiers: forceNewString, + }, + "secret": schema.StringAttribute{ + Optional: true, + Computed: true, + Sensitive: true, + MarkdownDescription: "The secret. If omitted, one is generated and returned on create only.", + PlanModifiers: forceNewString, + }, + "project_id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The project the credential is scoped to.", + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + }, + "roles": schema.SetAttribute{ + Optional: true, + Computed: true, + ElementType: types.StringType, + MarkdownDescription: "Role names the credential is limited to. Defaults to all of the user's roles.", + PlanModifiers: []planmodifier.Set{setplanmodifier.RequiresReplace(), setplanmodifier.UseStateForUnknown()}, + }, + "expires_at": schema.StringAttribute{ + Optional: true, + MarkdownDescription: "RFC3339 expiry timestamp. If omitted, the credential does not expire.", + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, + }, + "unrestricted": schema.BoolAttribute{ + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + MarkdownDescription: "Whether the credential may be used to create/delete other application credentials and trusts.", + PlanModifiers: []planmodifier.Bool{boolplanmodifier.RequiresReplace()}, + }, + "region": schema.StringAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: "The region. Defaults to the provider's region.", + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + }, + }, + } +} + +func (r *appCredResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + r.config = configureClient(req.ProviderData, &resp.Diagnostics) +} + +func (r *appCredResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan appCredModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + client, err := r.config.IdentityV3Client() + if err != nil { + resp.Diagnostics.AddError("identity: building v3 client", err.Error()) + return + } + userID, err := currentUserID(ctx, client) + if err != nil { + resp.Diagnostics.AddError("identity: resolving current user", err.Error()) + return + } + + var roleList []applicationcredentials.Role + if !plan.Roles.IsNull() && !plan.Roles.IsUnknown() { + var names []string + resp.Diagnostics.Append(plan.Roles.ElementsAs(ctx, &names, false)...) + if resp.Diagnostics.HasError() { + return + } + for _, n := range names { + roleList = append(roleList, applicationcredentials.Role{Name: n}) + } + } + + opts := applicationcredentials.CreateOpts{ + Name: plan.Name.ValueString(), + Description: plan.Description.ValueString(), + Unrestricted: plan.Unrestricted.ValueBool(), + Secret: plan.Secret.ValueString(), + Roles: roleList, + } + if v := plan.ExpiresAt.ValueString(); v != "" { + ts, perr := time.Parse(time.RFC3339, v) + if perr != nil { + resp.Diagnostics.AddError("identity: invalid expires_at", fmt.Sprintf("must be RFC3339: %s", perr)) + return + } + opts.ExpiresAt = &ts + } + + ac, err := applicationcredentials.Create(ctx, client, userID, opts).Extract() + if err != nil { + resp.Diagnostics.AddError("identity: creating application credential", err.Error()) + return + } + + // The secret is only ever returned here; capture it into state. + plan.Secret = types.StringValue(ac.Secret) + resp.Diagnostics.Append(r.flatten(ctx, ac, &plan)...) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *appCredResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state appCredModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + client, err := r.config.IdentityV3Client() + if err != nil { + resp.Diagnostics.AddError("identity: building v3 client", err.Error()) + return + } + userID, err := currentUserID(ctx, client) + if err != nil { + resp.Diagnostics.AddError("identity: resolving current user", err.Error()) + return + } + + ac, err := applicationcredentials.Get(ctx, client, userID, state.ID.ValueString()).Extract() + if err != nil { + if gophercloud.ResponseCodeIs(err, http.StatusNotFound) { + resp.Diagnostics.AddWarning("Application credential not found", + fmt.Sprintf("Application credential %s no longer exists and was removed from state.", state.ID.ValueString())) + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("identity: reading application credential", err.Error()) + return + } + + // secret and expires_at are preserved from prior state (never read back). + resp.Diagnostics.Append(r.flatten(ctx, ac, &state)...) + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +// Update is required by the interface but never invoked (every attribute forces +// replacement). +func (r *appCredResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan appCredModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *appCredResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state appCredModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + client, err := r.config.IdentityV3Client() + if err != nil { + resp.Diagnostics.AddError("identity: building v3 client", err.Error()) + return + } + userID, err := currentUserID(ctx, client) + if err != nil { + resp.Diagnostics.AddError("identity: resolving current user", err.Error()) + return + } + + if err := applicationcredentials.Delete(ctx, client, userID, state.ID.ValueString()).ExtractErr(); err != nil { + if gophercloud.ResponseCodeIs(err, http.StatusNotFound) { + return + } + resp.Diagnostics.AddError("identity: deleting application credential", err.Error()) + } +} + +func (r *appCredResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} + +// flatten copies server-known fields onto the model; secret and expires_at are +// left untouched (write-only / not returned). +func (r *appCredResource) flatten(ctx context.Context, ac *applicationcredentials.ApplicationCredential, m *appCredModel) (diags diag.Diagnostics) { + m.ID = types.StringValue(ac.ID) + m.Name = types.StringValue(ac.Name) + m.Description = types.StringValue(ac.Description) + m.ProjectID = types.StringValue(ac.ProjectID) + m.Unrestricted = types.BoolValue(ac.Unrestricted) + + names := make([]string, 0, len(ac.Roles)) + for _, ro := range ac.Roles { + names = append(names, ro.Name) + } + roles, d := types.SetValueFrom(ctx, types.StringType, names) + diags = append(diags, d...) + m.Roles = roles + + if m.Region.IsNull() || m.Region.IsUnknown() { + m.Region = types.StringValue(r.config.Region) + } + return diags +} + +// currentUserID returns the user ID of the token the provider is using. +func currentUserID(ctx context.Context, client *gophercloud.ServiceClient) (string, error) { + user, err := tokens.Get(ctx, client, client.Token()).ExtractUser() + if err != nil { + return "", err + } + return user.ID, nil +} diff --git a/internal/services/identity/application_credential_resource_test.go b/internal/services/identity/application_credential_resource_test.go new file mode 100644 index 0000000..90c2139 --- /dev/null +++ b/internal/services/identity/application_credential_resource_test.go @@ -0,0 +1,102 @@ +// Copyright (c) Platform9 Systems, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package identity_test + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/gophercloud/gophercloud/v2" + "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/applicationcredentials" + "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/tokens" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + + "github.com/platform9/terraform-provider-pcd/internal/acctest" +) + +func TestAccIdentityApplicationCredentialResource_basic(t *testing.T) { + const resourceName = "pcd_identity_application_credential.test" + name := "tf-acc-appcred" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories, + CheckDestroy: testAccCheckAppCredDestroy(t), + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf("resource \"pcd_identity_application_credential\" \"test\" {\n name = %q\n}\n", name), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckAppCredExists(t, resourceName), + resource.TestCheckResourceAttr(resourceName, "name", name), + resource.TestCheckResourceAttrSet(resourceName, "secret"), + resource.TestCheckResourceAttrSet(resourceName, "project_id"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"secret", "expires_at"}, + }, + }, + }) +} + +func testAccCurrentUserID(client *gophercloud.ServiceClient) (string, error) { + u, err := tokens.Get(context.Background(), client, client.Token()).ExtractUser() + if err != nil { + return "", err + } + return u.ID, nil +} + +func testAccCheckAppCredExists(t *testing.T, n string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[n] + if !ok { + return fmt.Errorf("not found in state: %s", n) + } + client, err := acctest.LabConfig(t).IdentityV3Client() + if err != nil { + return err + } + uid, err := testAccCurrentUserID(client) + if err != nil { + return err + } + if _, err := applicationcredentials.Get(context.Background(), client, uid, rs.Primary.ID).Extract(); err != nil { + return fmt.Errorf("application credential %s not found via API: %w", rs.Primary.ID, err) + } + return nil + } +} + +func testAccCheckAppCredDestroy(t *testing.T) resource.TestCheckFunc { + return func(s *terraform.State) error { + client, err := acctest.LabConfig(t).IdentityV3Client() + if err != nil { + return err + } + uid, err := testAccCurrentUserID(client) + if err != nil { + return err + } + for _, rs := range s.RootModule().Resources { + if rs.Type != "pcd_identity_application_credential" { + continue + } + _, err := applicationcredentials.Get(context.Background(), client, uid, rs.Primary.ID).Extract() + if err == nil { + return fmt.Errorf("application credential %s still exists", rs.Primary.ID) + } + if !gophercloud.ResponseCodeIs(err, http.StatusNotFound) { + return fmt.Errorf("unexpected error checking application credential %s: %w", rs.Primary.ID, err) + } + } + return nil + } +} diff --git a/internal/services/identity/data_sources_test.go b/internal/services/identity/data_sources_test.go new file mode 100644 index 0000000..a77bab6 --- /dev/null +++ b/internal/services/identity/data_sources_test.go @@ -0,0 +1,69 @@ +// Copyright (c) Platform9 Systems, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package identity_test + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + + "github.com/platform9/terraform-provider-pcd/internal/acctest" +) + +// TestAccIdentityDataSources_byName creates a project, role, and user, then looks +// each up by name and asserts the data source resolves to the same ID. +func TestAccIdentityDataSources_byName(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories, + CheckDestroy: resource.ComposeAggregateTestCheckFunc( + testAccCheckProjectDestroy(t), + testAccCheckRoleDestroy(t), + testAccCheckUserDestroy(t), + ), + Steps: []resource.TestStep{ + { + Config: testAccIdentityDataSourcesConfig, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrPair( + "data.pcd_identity_project.by_name", "id", + "pcd_identity_project.test", "id"), + resource.TestCheckResourceAttrPair( + "data.pcd_identity_role.by_name", "id", + "pcd_identity_role.test", "id"), + resource.TestCheckResourceAttrPair( + "data.pcd_identity_user.by_name", "id", + "pcd_identity_user.test", "id"), + ), + }, + }, + }) +} + +const testAccIdentityDataSourcesConfig = ` +resource "pcd_identity_project" "test" { + name = "tf-acc-ds-project" +} + +data "pcd_identity_project" "by_name" { + name = pcd_identity_project.test.name +} + +resource "pcd_identity_role" "test" { + name = "tf-acc-ds-role" +} + +data "pcd_identity_role" "by_name" { + name = pcd_identity_role.test.name +} + +resource "pcd_identity_user" "test" { + name = "tf-acc-ds-user" + password = "Tf-Acc-Passw0rd!" +} + +data "pcd_identity_user" "by_name" { + name = pcd_identity_user.test.name +} +` diff --git a/internal/services/identity/identity.go b/internal/services/identity/identity.go new file mode 100644 index 0000000..ba8bcd9 --- /dev/null +++ b/internal/services/identity/identity.go @@ -0,0 +1,32 @@ +// Copyright (c) Platform9 Systems, Inc. +// SPDX-License-Identifier: MPL-2.0 + +// Package identity implements the pcd_identity_* resources and data sources +// (Keystone v3), ported from terraform-provider-openstack v3.4.0. +package identity + +import ( + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/diag" + + "github.com/platform9/terraform-provider-pcd/internal/clients" +) + +// configureClient extracts the shared *clients.Config from a resource or data +// source ProviderData, adding a diagnostic on a type mismatch. Returns nil when +// ProviderData is nil (the provider is not yet configured). +func configureClient(providerData any, diags *diag.Diagnostics) *clients.Config { + if providerData == nil { + return nil + } + config, ok := providerData.(*clients.Config) + if !ok { + diags.AddError( + "Unexpected provider data type", + fmt.Sprintf("Expected *clients.Config, got %T. This is a bug in the provider.", providerData), + ) + return nil + } + return config +} diff --git a/internal/services/identity/project_data_source.go b/internal/services/identity/project_data_source.go new file mode 100644 index 0000000..d1919c8 --- /dev/null +++ b/internal/services/identity/project_data_source.go @@ -0,0 +1,144 @@ +// Copyright (c) Platform9 Systems, Inc. +// SPDX-License-Identifier: MPL-2.0 +// +// Ported from terraform-provider-openstack v3.4.0 +// (openstack/data_source_openstack_identity_project_v3.go), adapted for the +// terraform-plugin-framework and PCD. + +package identity + +import ( + "context" + "fmt" + + "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/projects" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/platform9/terraform-provider-pcd/internal/clients" +) + +var ( + _ datasource.DataSource = (*projectDataSource)(nil) + _ datasource.DataSourceWithConfigure = (*projectDataSource)(nil) +) + +// NewProjectDataSource is the factory registered with the provider. +func NewProjectDataSource() datasource.DataSource { + return &projectDataSource{} +} + +type projectDataSource struct { + config *clients.Config +} + +type projectDataSourceModel struct { + ID types.String `tfsdk:"id"` + ProjectID types.String `tfsdk:"project_id"` + Name types.String `tfsdk:"name"` + Description types.String `tfsdk:"description"` + DomainID types.String `tfsdk:"domain_id"` + Enabled types.Bool `tfsdk:"enabled"` + IsDomain types.Bool `tfsdk:"is_domain"` + ParentID types.String `tfsdk:"parent_id"` + Tags types.Set `tfsdk:"tags"` + Region types.String `tfsdk:"region"` +} + +func (d *projectDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_identity_project" +} + +func (d *projectDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Look up a project (tenant) in PCD's Keystone identity service by name or ID.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{Computed: true, MarkdownDescription: "The project ID."}, + "project_id": schema.StringAttribute{Optional: true, MarkdownDescription: "Look up the project by ID (takes precedence over name)."}, + "name": schema.StringAttribute{Optional: true, MarkdownDescription: "Look up the project by name."}, + "domain_id": schema.StringAttribute{Optional: true, Computed: true, MarkdownDescription: "Restrict the lookup to (and report) this domain."}, + "description": schema.StringAttribute{Computed: true, MarkdownDescription: "The project description."}, + "enabled": schema.BoolAttribute{Computed: true, MarkdownDescription: "Whether the project is enabled."}, + "is_domain": schema.BoolAttribute{Computed: true, MarkdownDescription: "Whether the project behaves as a domain."}, + "parent_id": schema.StringAttribute{Computed: true, MarkdownDescription: "The parent project ID."}, + "tags": schema.SetAttribute{Computed: true, ElementType: types.StringType, MarkdownDescription: "Tags applied to the project."}, + "region": schema.StringAttribute{Optional: true, Computed: true, MarkdownDescription: "The region. Defaults to the provider's region."}, + }, + } +} + +func (d *projectDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + d.config = configureClient(req.ProviderData, &resp.Diagnostics) +} + +func (d *projectDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var data projectDataSourceModel + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + client, err := d.config.IdentityV3Client() + if err != nil { + resp.Diagnostics.AddError("identity: building v3 client", err.Error()) + return + } + + var project *projects.Project + if v := data.ProjectID.ValueString(); v != "" { + project, err = projects.Get(ctx, client, v).Extract() + if err != nil { + resp.Diagnostics.AddError("identity: getting project by id", err.Error()) + return + } + } else { + pages, err := projects.List(client, projects.ListOpts{ + Name: data.Name.ValueString(), + DomainID: data.DomainID.ValueString(), + }).AllPages(ctx) + if err != nil { + resp.Diagnostics.AddError("identity: listing projects", err.Error()) + return + } + all, err := projects.ExtractProjects(pages) + if err != nil { + resp.Diagnostics.AddError("identity: extracting projects", err.Error()) + return + } + switch len(all) { + case 0: + resp.Diagnostics.AddError("No project found", "No project matched the given criteria.") + return + case 1: + project = &all[0] + default: + resp.Diagnostics.AddError("Multiple projects found", + fmt.Sprintf("%d projects matched; refine name/domain_id to select exactly one.", len(all))) + return + } + } + + data.ID = types.StringValue(project.ID) + data.ProjectID = types.StringValue(project.ID) + data.Name = types.StringValue(project.Name) + data.Description = types.StringValue(project.Description) + data.DomainID = types.StringValue(project.DomainID) + data.Enabled = types.BoolValue(project.Enabled) + data.IsDomain = types.BoolValue(project.IsDomain) + data.ParentID = types.StringValue(project.ParentID) + + tagVals := project.Tags + if tagVals == nil { + tagVals = []string{} + } + tags, diags := types.SetValueFrom(ctx, types.StringType, tagVals) + resp.Diagnostics.Append(diags...) + data.Tags = tags + + if data.Region.IsNull() || data.Region.IsUnknown() { + data.Region = types.StringValue(d.config.Region) + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} diff --git a/internal/services/identity/project_resource.go b/internal/services/identity/project_resource.go new file mode 100644 index 0000000..210f605 --- /dev/null +++ b/internal/services/identity/project_resource.go @@ -0,0 +1,310 @@ +// Copyright (c) Platform9 Systems, Inc. +// SPDX-License-Identifier: MPL-2.0 +// +// Ported from terraform-provider-openstack v3.4.0 +// (openstack/resource_openstack_identity_project_v3.go), adapted for the +// terraform-plugin-framework and PCD. + +package identity + +import ( + "context" + "fmt" + "net/http" + + "github.com/gophercloud/gophercloud/v2" + "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/projects" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/setplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/platform9/terraform-provider-pcd/internal/clients" +) + +var ( + _ resource.Resource = (*projectResource)(nil) + _ resource.ResourceWithConfigure = (*projectResource)(nil) + _ resource.ResourceWithImportState = (*projectResource)(nil) +) + +// NewProjectResource is the factory registered with the provider. +func NewProjectResource() resource.Resource { + return &projectResource{} +} + +type projectResource struct { + config *clients.Config +} + +type projectModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Description types.String `tfsdk:"description"` + DomainID types.String `tfsdk:"domain_id"` + Enabled types.Bool `tfsdk:"enabled"` + IsDomain types.Bool `tfsdk:"is_domain"` + ParentID types.String `tfsdk:"parent_id"` + Tags types.Set `tfsdk:"tags"` + Region types.String `tfsdk:"region"` +} + +func (r *projectResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_identity_project" +} + +func (r *projectResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Manages a project (tenant) in PCD's Keystone identity service.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The project ID.", + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + }, + "name": schema.StringAttribute{ + Required: true, + MarkdownDescription: "The name of the project.", + }, + "description": schema.StringAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: "A description of the project.", + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + }, + "domain_id": schema.StringAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: "The domain the project belongs to. Changing this forces a new resource.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + }, + "enabled": schema.BoolAttribute{ + Optional: true, + Computed: true, + Default: booldefault.StaticBool(true), + MarkdownDescription: "Whether the project is enabled. Defaults to true.", + }, + "is_domain": schema.BoolAttribute{ + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + MarkdownDescription: "Whether this project behaves as a domain. Changing this forces a new resource.", + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.RequiresReplace(), + boolplanmodifier.UseStateForUnknown(), + }, + }, + "parent_id": schema.StringAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: "The parent project ID. Changing this forces a new resource.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + }, + "tags": schema.SetAttribute{ + Optional: true, + Computed: true, + ElementType: types.StringType, + MarkdownDescription: "A set of string tags applied to the project.", + PlanModifiers: []planmodifier.Set{setplanmodifier.UseStateForUnknown()}, + }, + "region": schema.StringAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: "The region in which to manage the project. Defaults to the provider's region.", + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + }, + }, + } +} + +func (r *projectResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + config, ok := req.ProviderData.(*clients.Config) + if !ok { + resp.Diagnostics.AddError( + "Unexpected provider data type", + fmt.Sprintf("Expected *clients.Config, got %T. This is a bug in the provider.", req.ProviderData), + ) + return + } + r.config = config +} + +func (r *projectResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan projectModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + client, err := r.config.IdentityV3Client() + if err != nil { + resp.Diagnostics.AddError("identity: building v3 client", err.Error()) + return + } + + enabled := plan.Enabled.ValueBool() + isDomain := plan.IsDomain.ValueBool() + + var tags []string + if !plan.Tags.IsNull() && !plan.Tags.IsUnknown() { + resp.Diagnostics.Append(plan.Tags.ElementsAs(ctx, &tags, false)...) + if resp.Diagnostics.HasError() { + return + } + } + + createOpts := projects.CreateOpts{ + Name: plan.Name.ValueString(), + Description: plan.Description.ValueString(), + DomainID: plan.DomainID.ValueString(), + Enabled: &enabled, + IsDomain: &isDomain, + ParentID: plan.ParentID.ValueString(), + Tags: tags, + } + + project, err := projects.Create(ctx, client, createOpts).Extract() + if err != nil { + resp.Diagnostics.AddError("identity: creating project", err.Error()) + return + } + + resp.Diagnostics.Append(r.flatten(ctx, project, &plan)...) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *projectResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state projectModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + client, err := r.config.IdentityV3Client() + if err != nil { + resp.Diagnostics.AddError("identity: building v3 client", err.Error()) + return + } + + project, err := projects.Get(ctx, client, state.ID.ValueString()).Extract() + if err != nil { + if gophercloud.ResponseCodeIs(err, http.StatusNotFound) { + resp.Diagnostics.AddWarning( + "Project not found", + fmt.Sprintf("Project %s no longer exists and was removed from state.", state.ID.ValueString()), + ) + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("identity: reading project", err.Error()) + return + } + + resp.Diagnostics.Append(r.flatten(ctx, project, &state)...) + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *projectResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan projectModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + client, err := r.config.IdentityV3Client() + if err != nil { + resp.Diagnostics.AddError("identity: building v3 client", err.Error()) + return + } + + enabled := plan.Enabled.ValueBool() + description := plan.Description.ValueString() + + var tags []string + if !plan.Tags.IsNull() && !plan.Tags.IsUnknown() { + resp.Diagnostics.Append(plan.Tags.ElementsAs(ctx, &tags, false)...) + if resp.Diagnostics.HasError() { + return + } + } + + updateOpts := projects.UpdateOpts{ + Name: plan.Name.ValueString(), + Description: &description, + Enabled: &enabled, + Tags: &tags, + } + + project, err := projects.Update(ctx, client, plan.ID.ValueString(), updateOpts).Extract() + if err != nil { + resp.Diagnostics.AddError("identity: updating project", err.Error()) + return + } + + resp.Diagnostics.Append(r.flatten(ctx, project, &plan)...) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *projectResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state projectModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + client, err := r.config.IdentityV3Client() + if err != nil { + resp.Diagnostics.AddError("identity: building v3 client", err.Error()) + return + } + + if err := projects.Delete(ctx, client, state.ID.ValueString()).ExtractErr(); err != nil { + if gophercloud.ResponseCodeIs(err, http.StatusNotFound) { + return + } + resp.Diagnostics.AddError("identity: deleting project", err.Error()) + } +} + +func (r *projectResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} + +// flatten copies a gophercloud project onto the Terraform model. +func (r *projectResource) flatten(ctx context.Context, p *projects.Project, m *projectModel) (diags diag.Diagnostics) { + m.ID = types.StringValue(p.ID) + m.Name = types.StringValue(p.Name) + m.Description = types.StringValue(p.Description) + m.DomainID = types.StringValue(p.DomainID) + m.Enabled = types.BoolValue(p.Enabled) + m.IsDomain = types.BoolValue(p.IsDomain) + m.ParentID = types.StringValue(p.ParentID) + + tagVals := p.Tags + if tagVals == nil { + tagVals = []string{} + } + tags, d := types.SetValueFrom(ctx, types.StringType, tagVals) + diags = append(diags, d...) + m.Tags = tags + + if m.Region.IsNull() || m.Region.IsUnknown() { + m.Region = types.StringValue(r.config.Region) + } + return diags +} diff --git a/internal/services/identity/project_resource_test.go b/internal/services/identity/project_resource_test.go new file mode 100644 index 0000000..f96496b --- /dev/null +++ b/internal/services/identity/project_resource_test.go @@ -0,0 +1,106 @@ +// Copyright (c) Platform9 Systems, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package identity_test + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/gophercloud/gophercloud/v2" + "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/projects" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + + "github.com/platform9/terraform-provider-pcd/internal/acctest" +) + +func TestAccIdentityProjectResource_basic(t *testing.T) { + const resourceName = "pcd_identity_project.test" + name := "tf-acc-identity-project" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories, + CheckDestroy: testAccCheckProjectDestroy(t), + Steps: []resource.TestStep{ + { + Config: testAccProjectConfig(name, "initial description", true), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckProjectExists(t, resourceName), + resource.TestCheckResourceAttr(resourceName, "name", name), + resource.TestCheckResourceAttr(resourceName, "description", "initial description"), + resource.TestCheckResourceAttr(resourceName, "enabled", "true"), + resource.TestCheckResourceAttrSet(resourceName, "id"), + resource.TestCheckResourceAttrSet(resourceName, "domain_id"), + ), + }, + { + Config: testAccProjectConfig(name, "updated description", false), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "description", "updated description"), + resource.TestCheckResourceAttr(resourceName, "enabled", "false"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func testAccProjectConfig(name, description string, enabled bool) string { + return fmt.Sprintf(` +resource "pcd_identity_project" "test" { + name = %q + description = %q + enabled = %t +} +`, name, description, enabled) +} + +func testAccCheckProjectExists(t *testing.T, n string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[n] + if !ok { + return fmt.Errorf("not found in state: %s", n) + } + if rs.Primary.ID == "" { + return fmt.Errorf("no ID set for %s", n) + } + client, err := acctest.LabConfig(t).IdentityV3Client() + if err != nil { + return err + } + if _, err := projects.Get(context.Background(), client, rs.Primary.ID).Extract(); err != nil { + return fmt.Errorf("project %s not found via API: %w", rs.Primary.ID, err) + } + return nil + } +} + +func testAccCheckProjectDestroy(t *testing.T) resource.TestCheckFunc { + return func(s *terraform.State) error { + client, err := acctest.LabConfig(t).IdentityV3Client() + if err != nil { + return err + } + for _, rs := range s.RootModule().Resources { + if rs.Type != "pcd_identity_project" { + continue + } + _, err := projects.Get(context.Background(), client, rs.Primary.ID).Extract() + if err == nil { + return fmt.Errorf("project %s still exists", rs.Primary.ID) + } + if !gophercloud.ResponseCodeIs(err, http.StatusNotFound) { + return fmt.Errorf("unexpected error checking project %s: %w", rs.Primary.ID, err) + } + } + return nil + } +} diff --git a/internal/services/identity/role_assignment_resource.go b/internal/services/identity/role_assignment_resource.go new file mode 100644 index 0000000..392a4a3 --- /dev/null +++ b/internal/services/identity/role_assignment_resource.go @@ -0,0 +1,278 @@ +// Copyright (c) Platform9 Systems, Inc. +// SPDX-License-Identifier: MPL-2.0 +// +// Ported from terraform-provider-openstack v3.4.0 +// (openstack/resource_openstack_identity_role_assignment_v3.go), adapted for the +// terraform-plugin-framework and PCD. + +package identity + +import ( + "context" + "fmt" + "strings" + + "github.com/gophercloud/gophercloud/v2" + "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/roles" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/platform9/terraform-provider-pcd/internal/clients" +) + +var ( + _ resource.Resource = (*roleAssignmentResource)(nil) + _ resource.ResourceWithConfigure = (*roleAssignmentResource)(nil) + _ resource.ResourceWithImportState = (*roleAssignmentResource)(nil) +) + +// NewRoleAssignmentResource is the factory registered with the provider. +func NewRoleAssignmentResource() resource.Resource { + return &roleAssignmentResource{} +} + +type roleAssignmentResource struct { + config *clients.Config +} + +type roleAssignmentModel struct { + ID types.String `tfsdk:"id"` + RoleID types.String `tfsdk:"role_id"` + UserID types.String `tfsdk:"user_id"` + GroupID types.String `tfsdk:"group_id"` + ProjectID types.String `tfsdk:"project_id"` + DomainID types.String `tfsdk:"domain_id"` + Region types.String `tfsdk:"region"` +} + +func (r *roleAssignmentResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_identity_role_assignment" +} + +func (r *roleAssignmentResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + forceNew := []planmodifier.String{stringplanmodifier.RequiresReplace()} + resp.Schema = schema.Schema{ + MarkdownDescription: "Assigns a role to a user or group on a project or domain. Assignments are " + + "immutable: any change forces a new resource. Exactly one of `user_id`/`group_id` and exactly " + + "one of `project_id`/`domain_id` must be set.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "Composite ID: `domain_id/project_id/group_id/user_id/role_id`.", + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + }, + "role_id": schema.StringAttribute{ + Required: true, + MarkdownDescription: "The role to assign.", + PlanModifiers: forceNew, + }, + "user_id": schema.StringAttribute{ + Optional: true, + MarkdownDescription: "The user to assign the role to (mutually exclusive with group_id).", + PlanModifiers: forceNew, + }, + "group_id": schema.StringAttribute{ + Optional: true, + MarkdownDescription: "The group to assign the role to (mutually exclusive with user_id).", + PlanModifiers: forceNew, + }, + "project_id": schema.StringAttribute{ + Optional: true, + MarkdownDescription: "The project the assignment is scoped to (mutually exclusive with domain_id).", + PlanModifiers: forceNew, + }, + "domain_id": schema.StringAttribute{ + Optional: true, + MarkdownDescription: "The domain the assignment is scoped to (mutually exclusive with project_id).", + PlanModifiers: forceNew, + }, + "region": schema.StringAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: "The region. Defaults to the provider's region.", + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + }, + }, + } +} + +func (r *roleAssignmentResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + r.config = configureClient(req.ProviderData, &resp.Diagnostics) +} + +func (r *roleAssignmentResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan roleAssignmentModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + roleID := plan.RoleID.ValueString() + userID := plan.UserID.ValueString() + groupID := plan.GroupID.ValueString() + projectID := plan.ProjectID.ValueString() + domainID := plan.DomainID.ValueString() + + if (userID == "") == (groupID == "") { + resp.Diagnostics.AddError("Invalid role assignment", "Exactly one of user_id or group_id must be set.") + } + if (projectID == "") == (domainID == "") { + resp.Diagnostics.AddError("Invalid role assignment", "Exactly one of project_id or domain_id must be set.") + } + if resp.Diagnostics.HasError() { + return + } + + client, err := r.config.IdentityV3Client() + if err != nil { + resp.Diagnostics.AddError("identity: building v3 client", err.Error()) + return + } + + if err := roles.Assign(ctx, client, roleID, roles.AssignOpts{ + UserID: userID, + GroupID: groupID, + ProjectID: projectID, + DomainID: domainID, + }).ExtractErr(); err != nil { + resp.Diagnostics.AddError("identity: assigning role", err.Error()) + return + } + + plan.ID = types.StringValue(buildRoleAssignmentID(domainID, projectID, groupID, userID, roleID)) + if plan.Region.IsNull() || plan.Region.IsUnknown() { + plan.Region = types.StringValue(r.config.Region) + } + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *roleAssignmentResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state roleAssignmentModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + domainID, projectID, groupID, userID, roleID, err := parseRoleAssignmentID(state.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("identity: parsing role assignment id", err.Error()) + return + } + + client, err := r.config.IdentityV3Client() + if err != nil { + resp.Diagnostics.AddError("identity: building v3 client", err.Error()) + return + } + + exists, err := roleAssignmentExists(ctx, client, domainID, projectID, groupID, userID, roleID) + if err != nil { + resp.Diagnostics.AddError("identity: reading role assignment", err.Error()) + return + } + if !exists { + resp.Diagnostics.AddWarning("Role assignment not found", + fmt.Sprintf("Role assignment %s no longer exists and was removed from state.", state.ID.ValueString())) + resp.State.RemoveResource(ctx) + return + } + + // Repopulate attributes from the ID (supports import) and keep them stable. + state.RoleID = types.StringValue(roleID) + state.UserID = optionalString(userID) + state.GroupID = optionalString(groupID) + state.ProjectID = optionalString(projectID) + state.DomainID = optionalString(domainID) + if state.Region.IsNull() || state.Region.IsUnknown() { + state.Region = types.StringValue(r.config.Region) + } + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +// Update is required by the interface but never invoked: every attribute forces +// replacement. It defensively persists the plan. +func (r *roleAssignmentResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan roleAssignmentModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *roleAssignmentResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state roleAssignmentModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + domainID, projectID, groupID, userID, roleID, err := parseRoleAssignmentID(state.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("identity: parsing role assignment id", err.Error()) + return + } + + client, err := r.config.IdentityV3Client() + if err != nil { + resp.Diagnostics.AddError("identity: building v3 client", err.Error()) + return + } + + if err := roles.Unassign(ctx, client, roleID, roles.UnassignOpts{ + UserID: userID, + GroupID: groupID, + ProjectID: projectID, + DomainID: domainID, + }).ExtractErr(); err != nil { + if gophercloud.ResponseCodeIs(err, 404) { + return + } + resp.Diagnostics.AddError("identity: unassigning role", err.Error()) + } +} + +func (r *roleAssignmentResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} + +func roleAssignmentExists(ctx context.Context, client *gophercloud.ServiceClient, domainID, projectID, groupID, userID, roleID string) (bool, error) { + pages, err := roles.ListAssignments(client, roles.ListAssignmentsOpts{ + RoleID: roleID, + ScopeDomainID: domainID, + ScopeProjectID: projectID, + UserID: userID, + GroupID: groupID, + }).AllPages(ctx) + if err != nil { + return false, err + } + all, err := roles.ExtractRoleAssignments(pages) + if err != nil { + return false, err + } + return len(all) > 0, nil +} + +func buildRoleAssignmentID(domainID, projectID, groupID, userID, roleID string) string { + return strings.Join([]string{domainID, projectID, groupID, userID, roleID}, "/") +} + +func parseRoleAssignmentID(id string) (domainID, projectID, groupID, userID, roleID string, err error) { + parts := strings.SplitN(id, "/", 5) + if len(parts) != 5 { + return "", "", "", "", "", fmt.Errorf("expected id in the form domain_id/project_id/group_id/user_id/role_id, got %q", id) + } + return parts[0], parts[1], parts[2], parts[3], parts[4], nil +} + +func optionalString(v string) types.String { + if v == "" { + return types.StringNull() + } + return types.StringValue(v) +} diff --git a/internal/services/identity/role_assignment_resource_test.go b/internal/services/identity/role_assignment_resource_test.go new file mode 100644 index 0000000..1f8ead9 --- /dev/null +++ b/internal/services/identity/role_assignment_resource_test.go @@ -0,0 +1,137 @@ +// Copyright (c) Platform9 Systems, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package identity_test + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/gophercloud/gophercloud/v2" + "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/roles" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + + "github.com/platform9/terraform-provider-pcd/internal/acctest" +) + +func TestAccIdentityRoleAssignmentResource_basic(t *testing.T) { + const resourceName = "pcd_identity_role_assignment.test" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories, + CheckDestroy: resource.ComposeAggregateTestCheckFunc( + testAccCheckRoleAssignmentDestroy(t), + testAccCheckProjectDestroy(t), + testAccCheckUserDestroy(t), + testAccCheckRoleDestroy(t), + ), + Steps: []resource.TestStep{ + { + Config: testAccRoleAssignmentConfig, + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckRoleAssignmentExists(t, resourceName), + resource.TestCheckResourceAttrSet(resourceName, "user_id"), + resource.TestCheckResourceAttrSet(resourceName, "project_id"), + resource.TestCheckResourceAttrSet(resourceName, "role_id"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +const testAccRoleAssignmentConfig = ` +resource "pcd_identity_project" "test" { + name = "tf-acc-ra-project" +} + +resource "pcd_identity_user" "test" { + name = "tf-acc-ra-user" + password = "Tf-Acc-Passw0rd!" +} + +resource "pcd_identity_role" "test" { + name = "tf-acc-ra-role" +} + +resource "pcd_identity_role_assignment" "test" { + user_id = pcd_identity_user.test.id + project_id = pcd_identity_project.test.id + role_id = pcd_identity_role.test.id +} +` + +func testAccCheckRoleAssignmentExists(t *testing.T, n string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[n] + if !ok { + return fmt.Errorf("not found in state: %s", n) + } + client, err := acctest.LabConfig(t).IdentityV3Client() + if err != nil { + return err + } + found, err := roleAssignmentPresent(client, rs.Primary.ID) + if err != nil { + return err + } + if !found { + return fmt.Errorf("role assignment %s not found via API", rs.Primary.ID) + } + return nil + } +} + +func testAccCheckRoleAssignmentDestroy(t *testing.T) resource.TestCheckFunc { + return func(s *terraform.State) error { + client, err := acctest.LabConfig(t).IdentityV3Client() + if err != nil { + return err + } + for _, rs := range s.RootModule().Resources { + if rs.Type != "pcd_identity_role_assignment" { + continue + } + found, err := roleAssignmentPresent(client, rs.Primary.ID) + if err != nil { + return err + } + if found { + return fmt.Errorf("role assignment %s still exists", rs.Primary.ID) + } + } + return nil + } +} + +// roleAssignmentPresent parses a composite id (domain/project/group/user/role) +// and reports whether that assignment currently exists. +func roleAssignmentPresent(client *gophercloud.ServiceClient, id string) (bool, error) { + parts := strings.SplitN(id, "/", 5) + if len(parts) != 5 { + return false, fmt.Errorf("bad role assignment id: %q", id) + } + pages, err := roles.ListAssignments(client, roles.ListAssignmentsOpts{ + ScopeDomainID: parts[0], + ScopeProjectID: parts[1], + GroupID: parts[2], + UserID: parts[3], + RoleID: parts[4], + }).AllPages(context.Background()) + if err != nil { + return false, err + } + all, err := roles.ExtractRoleAssignments(pages) + if err != nil { + return false, err + } + return len(all) > 0, nil +} diff --git a/internal/services/identity/role_data_source.go b/internal/services/identity/role_data_source.go new file mode 100644 index 0000000..44b0ac5 --- /dev/null +++ b/internal/services/identity/role_data_source.go @@ -0,0 +1,121 @@ +// Copyright (c) Platform9 Systems, Inc. +// SPDX-License-Identifier: MPL-2.0 +// +// Ported from terraform-provider-openstack v3.4.0 +// (openstack/data_source_openstack_identity_role_v3.go), adapted for the +// terraform-plugin-framework and PCD. + +package identity + +import ( + "context" + "fmt" + + "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/roles" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/platform9/terraform-provider-pcd/internal/clients" +) + +var ( + _ datasource.DataSource = (*roleDataSource)(nil) + _ datasource.DataSourceWithConfigure = (*roleDataSource)(nil) +) + +// NewRoleDataSource is the factory registered with the provider. +func NewRoleDataSource() datasource.DataSource { + return &roleDataSource{} +} + +type roleDataSource struct { + config *clients.Config +} + +type roleDataSourceModel struct { + ID types.String `tfsdk:"id"` + RoleID types.String `tfsdk:"role_id"` + Name types.String `tfsdk:"name"` + DomainID types.String `tfsdk:"domain_id"` + Region types.String `tfsdk:"region"` +} + +func (d *roleDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_identity_role" +} + +func (d *roleDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Look up a role in PCD's Keystone identity service by name or ID.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{Computed: true, MarkdownDescription: "The role ID."}, + "role_id": schema.StringAttribute{Optional: true, MarkdownDescription: "Look up the role by ID (takes precedence over name)."}, + "name": schema.StringAttribute{Optional: true, MarkdownDescription: "Look up the role by name."}, + "domain_id": schema.StringAttribute{Optional: true, Computed: true, MarkdownDescription: "Restrict the lookup to (and report) this domain."}, + "region": schema.StringAttribute{Optional: true, Computed: true, MarkdownDescription: "The region. Defaults to the provider's region."}, + }, + } +} + +func (d *roleDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + d.config = configureClient(req.ProviderData, &resp.Diagnostics) +} + +func (d *roleDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var data roleDataSourceModel + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + client, err := d.config.IdentityV3Client() + if err != nil { + resp.Diagnostics.AddError("identity: building v3 client", err.Error()) + return + } + + var role *roles.Role + if v := data.RoleID.ValueString(); v != "" { + role, err = roles.Get(ctx, client, v).Extract() + if err != nil { + resp.Diagnostics.AddError("identity: getting role by id", err.Error()) + return + } + } else { + pages, err := roles.List(client, roles.ListOpts{ + Name: data.Name.ValueString(), + DomainID: data.DomainID.ValueString(), + }).AllPages(ctx) + if err != nil { + resp.Diagnostics.AddError("identity: listing roles", err.Error()) + return + } + all, err := roles.ExtractRoles(pages) + if err != nil { + resp.Diagnostics.AddError("identity: extracting roles", err.Error()) + return + } + switch len(all) { + case 0: + resp.Diagnostics.AddError("No role found", "No role matched the given criteria.") + return + case 1: + role = &all[0] + default: + resp.Diagnostics.AddError("Multiple roles found", + fmt.Sprintf("%d roles matched; refine name/domain_id to select exactly one.", len(all))) + return + } + } + + data.ID = types.StringValue(role.ID) + data.RoleID = types.StringValue(role.ID) + data.Name = types.StringValue(role.Name) + data.DomainID = types.StringValue(role.DomainID) + if data.Region.IsNull() || data.Region.IsUnknown() { + data.Region = types.StringValue(d.config.Region) + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} diff --git a/internal/services/identity/role_resource.go b/internal/services/identity/role_resource.go new file mode 100644 index 0000000..8750be7 --- /dev/null +++ b/internal/services/identity/role_resource.go @@ -0,0 +1,201 @@ +// Copyright (c) Platform9 Systems, Inc. +// SPDX-License-Identifier: MPL-2.0 +// +// Ported from terraform-provider-openstack v3.4.0 +// (openstack/resource_openstack_identity_role_v3.go), adapted for the +// terraform-plugin-framework and PCD. + +package identity + +import ( + "context" + "fmt" + "net/http" + + "github.com/gophercloud/gophercloud/v2" + "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/roles" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/platform9/terraform-provider-pcd/internal/clients" +) + +var ( + _ resource.Resource = (*roleResource)(nil) + _ resource.ResourceWithConfigure = (*roleResource)(nil) + _ resource.ResourceWithImportState = (*roleResource)(nil) +) + +// NewRoleResource is the factory registered with the provider. +func NewRoleResource() resource.Resource { + return &roleResource{} +} + +type roleResource struct { + config *clients.Config +} + +type roleModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + DomainID types.String `tfsdk:"domain_id"` + Region types.String `tfsdk:"region"` +} + +func (r *roleResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_identity_role" +} + +func (r *roleResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Manages a role in PCD's Keystone identity service.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The role ID.", + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + }, + "name": schema.StringAttribute{ + Required: true, + MarkdownDescription: "The name of the role.", + }, + "domain_id": schema.StringAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: "The domain the role belongs to (empty for a global role). Changing this forces a new resource.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + }, + "region": schema.StringAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: "The region in which to manage the role. Defaults to the provider's region.", + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + }, + }, + } +} + +func (r *roleResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + r.config = configureClient(req.ProviderData, &resp.Diagnostics) +} + +func (r *roleResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan roleModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + client, err := r.config.IdentityV3Client() + if err != nil { + resp.Diagnostics.AddError("identity: building v3 client", err.Error()) + return + } + + role, err := roles.Create(ctx, client, roles.CreateOpts{ + Name: plan.Name.ValueString(), + DomainID: plan.DomainID.ValueString(), + }).Extract() + if err != nil { + resp.Diagnostics.AddError("identity: creating role", err.Error()) + return + } + + r.flatten(role, &plan) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *roleResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state roleModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + client, err := r.config.IdentityV3Client() + if err != nil { + resp.Diagnostics.AddError("identity: building v3 client", err.Error()) + return + } + + role, err := roles.Get(ctx, client, state.ID.ValueString()).Extract() + if err != nil { + if gophercloud.ResponseCodeIs(err, http.StatusNotFound) { + resp.Diagnostics.AddWarning("Role not found", + fmt.Sprintf("Role %s no longer exists and was removed from state.", state.ID.ValueString())) + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("identity: reading role", err.Error()) + return + } + + r.flatten(role, &state) + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *roleResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan roleModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + client, err := r.config.IdentityV3Client() + if err != nil { + resp.Diagnostics.AddError("identity: building v3 client", err.Error()) + return + } + + role, err := roles.Update(ctx, client, plan.ID.ValueString(), roles.UpdateOpts{ + Name: plan.Name.ValueString(), + }).Extract() + if err != nil { + resp.Diagnostics.AddError("identity: updating role", err.Error()) + return + } + + r.flatten(role, &plan) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *roleResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state roleModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + client, err := r.config.IdentityV3Client() + if err != nil { + resp.Diagnostics.AddError("identity: building v3 client", err.Error()) + return + } + + if err := roles.Delete(ctx, client, state.ID.ValueString()).ExtractErr(); err != nil { + if gophercloud.ResponseCodeIs(err, http.StatusNotFound) { + return + } + resp.Diagnostics.AddError("identity: deleting role", err.Error()) + } +} + +func (r *roleResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} + +func (r *roleResource) flatten(role *roles.Role, m *roleModel) { + m.ID = types.StringValue(role.ID) + m.Name = types.StringValue(role.Name) + m.DomainID = types.StringValue(role.DomainID) + if m.Region.IsNull() || m.Region.IsUnknown() { + m.Region = types.StringValue(r.config.Region) + } +} diff --git a/internal/services/identity/role_resource_test.go b/internal/services/identity/role_resource_test.go new file mode 100644 index 0000000..b63bfee --- /dev/null +++ b/internal/services/identity/role_resource_test.go @@ -0,0 +1,87 @@ +// Copyright (c) Platform9 Systems, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package identity_test + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/gophercloud/gophercloud/v2" + "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/roles" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + + "github.com/platform9/terraform-provider-pcd/internal/acctest" +) + +func TestAccIdentityRoleResource_basic(t *testing.T) { + const resourceName = "pcd_identity_role.test" + name := "tf-acc-identity-role" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories, + CheckDestroy: testAccCheckRoleDestroy(t), + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf("resource \"pcd_identity_role\" \"test\" {\n name = %q\n}\n", name), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckRoleExists(t, resourceName), + resource.TestCheckResourceAttr(resourceName, "name", name), + resource.TestCheckResourceAttrSet(resourceName, "id"), + ), + }, + { + Config: fmt.Sprintf("resource \"pcd_identity_role\" \"test\" {\n name = %q\n}\n", name+"-updated"), + Check: resource.TestCheckResourceAttr(resourceName, "name", name+"-updated"), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func testAccCheckRoleExists(t *testing.T, n string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[n] + if !ok { + return fmt.Errorf("not found in state: %s", n) + } + client, err := acctest.LabConfig(t).IdentityV3Client() + if err != nil { + return err + } + if _, err := roles.Get(context.Background(), client, rs.Primary.ID).Extract(); err != nil { + return fmt.Errorf("role %s not found via API: %w", rs.Primary.ID, err) + } + return nil + } +} + +func testAccCheckRoleDestroy(t *testing.T) resource.TestCheckFunc { + return func(s *terraform.State) error { + client, err := acctest.LabConfig(t).IdentityV3Client() + if err != nil { + return err + } + for _, rs := range s.RootModule().Resources { + if rs.Type != "pcd_identity_role" { + continue + } + _, err := roles.Get(context.Background(), client, rs.Primary.ID).Extract() + if err == nil { + return fmt.Errorf("role %s still exists", rs.Primary.ID) + } + if !gophercloud.ResponseCodeIs(err, http.StatusNotFound) { + return fmt.Errorf("unexpected error checking role %s: %w", rs.Primary.ID, err) + } + } + return nil + } +} diff --git a/internal/services/identity/user_data_source.go b/internal/services/identity/user_data_source.go new file mode 100644 index 0000000..ae3cd1b --- /dev/null +++ b/internal/services/identity/user_data_source.go @@ -0,0 +1,130 @@ +// Copyright (c) Platform9 Systems, Inc. +// SPDX-License-Identifier: MPL-2.0 +// +// Ported from terraform-provider-openstack v3.4.0 +// (openstack/data_source_openstack_identity_user_v3.go), adapted for the +// terraform-plugin-framework and PCD. + +package identity + +import ( + "context" + "fmt" + + "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/users" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/platform9/terraform-provider-pcd/internal/clients" +) + +var ( + _ datasource.DataSource = (*userDataSource)(nil) + _ datasource.DataSourceWithConfigure = (*userDataSource)(nil) +) + +// NewUserDataSource is the factory registered with the provider. +func NewUserDataSource() datasource.DataSource { + return &userDataSource{} +} + +type userDataSource struct { + config *clients.Config +} + +type userDataSourceModel struct { + ID types.String `tfsdk:"id"` + UserID types.String `tfsdk:"user_id"` + Name types.String `tfsdk:"name"` + Description types.String `tfsdk:"description"` + DomainID types.String `tfsdk:"domain_id"` + DefaultProjectID types.String `tfsdk:"default_project_id"` + Enabled types.Bool `tfsdk:"enabled"` + Region types.String `tfsdk:"region"` +} + +func (d *userDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_identity_user" +} + +func (d *userDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Look up a user in PCD's Keystone identity service by name or ID.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{Computed: true, MarkdownDescription: "The user ID."}, + "user_id": schema.StringAttribute{Optional: true, MarkdownDescription: "Look up the user by ID (takes precedence over name)."}, + "name": schema.StringAttribute{Optional: true, MarkdownDescription: "Look up the user by name."}, + "domain_id": schema.StringAttribute{Optional: true, Computed: true, MarkdownDescription: "Restrict the lookup to (and report) this domain."}, + "description": schema.StringAttribute{Computed: true, MarkdownDescription: "The user description."}, + "default_project_id": schema.StringAttribute{Computed: true, MarkdownDescription: "The user's default project ID."}, + "enabled": schema.BoolAttribute{Computed: true, MarkdownDescription: "Whether the user is enabled."}, + "region": schema.StringAttribute{Optional: true, Computed: true, MarkdownDescription: "The region. Defaults to the provider's region."}, + }, + } +} + +func (d *userDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + d.config = configureClient(req.ProviderData, &resp.Diagnostics) +} + +func (d *userDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var data userDataSourceModel + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + client, err := d.config.IdentityV3Client() + if err != nil { + resp.Diagnostics.AddError("identity: building v3 client", err.Error()) + return + } + + var user *users.User + if v := data.UserID.ValueString(); v != "" { + user, err = users.Get(ctx, client, v).Extract() + if err != nil { + resp.Diagnostics.AddError("identity: getting user by id", err.Error()) + return + } + } else { + pages, err := users.List(client, users.ListOpts{ + Name: data.Name.ValueString(), + DomainID: data.DomainID.ValueString(), + }).AllPages(ctx) + if err != nil { + resp.Diagnostics.AddError("identity: listing users", err.Error()) + return + } + all, err := users.ExtractUsers(pages) + if err != nil { + resp.Diagnostics.AddError("identity: extracting users", err.Error()) + return + } + switch len(all) { + case 0: + resp.Diagnostics.AddError("No user found", "No user matched the given criteria.") + return + case 1: + user = &all[0] + default: + resp.Diagnostics.AddError("Multiple users found", + fmt.Sprintf("%d users matched; refine name/domain_id to select exactly one.", len(all))) + return + } + } + + data.ID = types.StringValue(user.ID) + data.UserID = types.StringValue(user.ID) + data.Name = types.StringValue(user.Name) + data.Description = types.StringValue(user.Description) + data.DomainID = types.StringValue(user.DomainID) + data.DefaultProjectID = types.StringValue(user.DefaultProjectID) + data.Enabled = types.BoolValue(user.Enabled) + if data.Region.IsNull() || data.Region.IsUnknown() { + data.Region = types.StringValue(d.config.Region) + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} diff --git a/internal/services/identity/user_resource.go b/internal/services/identity/user_resource.go new file mode 100644 index 0000000..feb2326 --- /dev/null +++ b/internal/services/identity/user_resource.go @@ -0,0 +1,251 @@ +// Copyright (c) Platform9 Systems, Inc. +// SPDX-License-Identifier: MPL-2.0 +// +// Ported from terraform-provider-openstack v3.4.0 +// (openstack/resource_openstack_identity_user_v3.go), adapted for the +// terraform-plugin-framework and PCD. + +package identity + +import ( + "context" + "fmt" + "net/http" + + "github.com/gophercloud/gophercloud/v2" + "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/users" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/platform9/terraform-provider-pcd/internal/clients" +) + +var ( + _ resource.Resource = (*userResource)(nil) + _ resource.ResourceWithConfigure = (*userResource)(nil) + _ resource.ResourceWithImportState = (*userResource)(nil) +) + +// NewUserResource is the factory registered with the provider. +func NewUserResource() resource.Resource { + return &userResource{} +} + +type userResource struct { + config *clients.Config +} + +type userModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Description types.String `tfsdk:"description"` + DomainID types.String `tfsdk:"domain_id"` + DefaultProjectID types.String `tfsdk:"default_project_id"` + Enabled types.Bool `tfsdk:"enabled"` + Password types.String `tfsdk:"password"` + Region types.String `tfsdk:"region"` +} + +func (r *userResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_identity_user" +} + +func (r *userResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Manages a user in PCD's Keystone identity service.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The user ID.", + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + }, + "name": schema.StringAttribute{ + Required: true, + MarkdownDescription: "The name of the user.", + }, + "description": schema.StringAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: "A description of the user.", + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + }, + "domain_id": schema.StringAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: "The domain the user belongs to. Changing this forces a new resource.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + }, + "default_project_id": schema.StringAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: "The default project the user is scoped to.", + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + }, + "enabled": schema.BoolAttribute{ + Optional: true, + Computed: true, + Default: booldefault.StaticBool(true), + MarkdownDescription: "Whether the user is enabled. Defaults to true.", + }, + "password": schema.StringAttribute{ + Optional: true, + Sensitive: true, + MarkdownDescription: "The user's password. Write-only: it is never read back from the API.", + }, + "region": schema.StringAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: "The region in which to manage the user. Defaults to the provider's region.", + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + }, + }, + } +} + +func (r *userResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + r.config = configureClient(req.ProviderData, &resp.Diagnostics) +} + +func (r *userResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan userModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + client, err := r.config.IdentityV3Client() + if err != nil { + resp.Diagnostics.AddError("identity: building v3 client", err.Error()) + return + } + + enabled := plan.Enabled.ValueBool() + user, err := users.Create(ctx, client, users.CreateOpts{ + Name: plan.Name.ValueString(), + Description: plan.Description.ValueString(), + DomainID: plan.DomainID.ValueString(), + DefaultProjectID: plan.DefaultProjectID.ValueString(), + Enabled: &enabled, + Password: plan.Password.ValueString(), + }).Extract() + if err != nil { + resp.Diagnostics.AddError("identity: creating user", err.Error()) + return + } + + r.flatten(user, &plan) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *userResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state userModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + client, err := r.config.IdentityV3Client() + if err != nil { + resp.Diagnostics.AddError("identity: building v3 client", err.Error()) + return + } + + user, err := users.Get(ctx, client, state.ID.ValueString()).Extract() + if err != nil { + if gophercloud.ResponseCodeIs(err, http.StatusNotFound) { + resp.Diagnostics.AddWarning("User not found", + fmt.Sprintf("User %s no longer exists and was removed from state.", state.ID.ValueString())) + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("identity: reading user", err.Error()) + return + } + + r.flatten(user, &state) + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *userResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state userModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + client, err := r.config.IdentityV3Client() + if err != nil { + resp.Diagnostics.AddError("identity: building v3 client", err.Error()) + return + } + + enabled := plan.Enabled.ValueBool() + description := plan.Description.ValueString() + opts := users.UpdateOpts{ + Name: plan.Name.ValueString(), + Description: &description, + DefaultProjectID: plan.DefaultProjectID.ValueString(), + Enabled: &enabled, + } + // Only send a password when it actually changed (it is never read back). + if !plan.Password.IsNull() && plan.Password.ValueString() != state.Password.ValueString() { + opts.Password = plan.Password.ValueString() + } + + user, err := users.Update(ctx, client, plan.ID.ValueString(), opts).Extract() + if err != nil { + resp.Diagnostics.AddError("identity: updating user", err.Error()) + return + } + + r.flatten(user, &plan) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *userResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state userModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + client, err := r.config.IdentityV3Client() + if err != nil { + resp.Diagnostics.AddError("identity: building v3 client", err.Error()) + return + } + + if err := users.Delete(ctx, client, state.ID.ValueString()).ExtractErr(); err != nil { + if gophercloud.ResponseCodeIs(err, http.StatusNotFound) { + return + } + resp.Diagnostics.AddError("identity: deleting user", err.Error()) + } +} + +func (r *userResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} + +// flatten copies a gophercloud user onto the model. The password is write-only +// and intentionally preserved from prior state, never read from the API. +func (r *userResource) flatten(u *users.User, m *userModel) { + m.ID = types.StringValue(u.ID) + m.Name = types.StringValue(u.Name) + m.Description = types.StringValue(u.Description) + m.DomainID = types.StringValue(u.DomainID) + m.DefaultProjectID = types.StringValue(u.DefaultProjectID) + m.Enabled = types.BoolValue(u.Enabled) + if m.Region.IsNull() || m.Region.IsUnknown() { + m.Region = types.StringValue(r.config.Region) + } +} diff --git a/internal/services/identity/user_resource_test.go b/internal/services/identity/user_resource_test.go new file mode 100644 index 0000000..0708815 --- /dev/null +++ b/internal/services/identity/user_resource_test.go @@ -0,0 +1,103 @@ +// Copyright (c) Platform9 Systems, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package identity_test + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/gophercloud/gophercloud/v2" + "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/users" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + + "github.com/platform9/terraform-provider-pcd/internal/acctest" +) + +func TestAccIdentityUserResource_basic(t *testing.T) { + const resourceName = "pcd_identity_user.test" + name := "tf-acc-identity-user" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories, + CheckDestroy: testAccCheckUserDestroy(t), + Steps: []resource.TestStep{ + { + Config: testAccUserConfig(name, "initial", true), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckUserExists(t, resourceName), + resource.TestCheckResourceAttr(resourceName, "name", name), + resource.TestCheckResourceAttr(resourceName, "description", "initial"), + resource.TestCheckResourceAttr(resourceName, "enabled", "true"), + ), + }, + { + Config: testAccUserConfig(name, "updated", false), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "description", "updated"), + resource.TestCheckResourceAttr(resourceName, "enabled", "false"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"password"}, // write-only, never read back + }, + }, + }) +} + +func testAccUserConfig(name, description string, enabled bool) string { + return fmt.Sprintf(` +resource "pcd_identity_user" "test" { + name = %q + description = %q + enabled = %t + password = "Tf-Acc-Passw0rd!" +} +`, name, description, enabled) +} + +func testAccCheckUserExists(t *testing.T, n string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[n] + if !ok { + return fmt.Errorf("not found in state: %s", n) + } + client, err := acctest.LabConfig(t).IdentityV3Client() + if err != nil { + return err + } + if _, err := users.Get(context.Background(), client, rs.Primary.ID).Extract(); err != nil { + return fmt.Errorf("user %s not found via API: %w", rs.Primary.ID, err) + } + return nil + } +} + +func testAccCheckUserDestroy(t *testing.T) resource.TestCheckFunc { + return func(s *terraform.State) error { + client, err := acctest.LabConfig(t).IdentityV3Client() + if err != nil { + return err + } + for _, rs := range s.RootModule().Resources { + if rs.Type != "pcd_identity_user" { + continue + } + _, err := users.Get(context.Background(), client, rs.Primary.ID).Extract() + if err == nil { + return fmt.Errorf("user %s still exists", rs.Primary.ID) + } + if !gophercloud.ResponseCodeIs(err, http.StatusNotFound) { + return fmt.Errorf("unexpected error checking user %s: %w", rs.Primary.ID, err) + } + } + return nil + } +}