Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
33 changes: 33 additions & 0 deletions internal/acctest/acctest.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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 ""
}
11 changes: 10 additions & 1 deletion internal/provider/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
295 changes: 295 additions & 0 deletions internal/services/identity/application_credential_resource.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading