Skip to content

Commit c5b5f0c

Browse files
authored
Merge pull request #3 from platform9/feat/identity-resources
feat(identity): Phase 1 identity resources and data sources
2 parents 6c2523a + 8383c56 commit c5b5f0c

18 files changed

Lines changed: 2412 additions & 1 deletion

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ All notable changes to this project are documented here. The format is based on
2222
- Acceptance test harness (`internal/acctest`: protocol-6 provider factory + `PreCheck`)
2323
and the first acceptance test for `pcd_identity_auth_scope` (passes against the CE lab).
2424
- `test.yml` CI: build, vet, gofmt, unit tests, and `terraform fmt` on examples.
25+
- Identity (Keystone v3) resources: `pcd_identity_project`, `pcd_identity_role`,
26+
`pcd_identity_user`, `pcd_identity_role_assignment`, `pcd_identity_application_credential`.
27+
- Identity data sources: `pcd_identity_project`, `pcd_identity_user`, `pcd_identity_role`.
2528

2629
### Known gaps
2730
- `cloud` (clouds.yaml) is declared but not yet implemented; it errors if set.

internal/acctest/acctest.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@
77
package acctest
88

99
import (
10+
"context"
1011
"os"
1112
"testing"
1213

1314
"github.com/hashicorp/terraform-plugin-framework/providerserver"
1415
"github.com/hashicorp/terraform-plugin-go/tfprotov6"
1516

17+
"github.com/platform9/terraform-provider-pcd/internal/clients"
1618
"github.com/platform9/terraform-provider-pcd/internal/provider"
1719
)
1820

@@ -41,3 +43,34 @@ func PreCheck(t *testing.T) {
4143
t.Skipf("PCD acceptance tests require a reachable lab; missing env: %v", missing)
4244
}
4345
}
46+
47+
// LabConfig returns an authenticated client built from the OS_* environment, for
48+
// use in CheckDestroy/CheckExists helpers that query the API out of band.
49+
func LabConfig(t *testing.T) *clients.Config {
50+
t.Helper()
51+
cfg := &clients.Config{
52+
AuthURL: os.Getenv("OS_AUTH_URL"),
53+
Region: os.Getenv("OS_REGION_NAME"),
54+
Username: os.Getenv("OS_USERNAME"),
55+
Password: os.Getenv("OS_PASSWORD"),
56+
TenantName: firstEnv("OS_PROJECT_NAME", "OS_TENANT_NAME"),
57+
TenantID: firstEnv("OS_PROJECT_ID", "OS_TENANT_ID"),
58+
UserDomainID: os.Getenv("OS_USER_DOMAIN_ID"),
59+
ProjectDomainID: os.Getenv("OS_PROJECT_DOMAIN_ID"),
60+
Insecure: os.Getenv("OS_INSECURE") != "",
61+
AllowReauth: true,
62+
}
63+
if err := cfg.Authenticate(context.Background()); err != nil {
64+
t.Fatalf("acctest: authenticate to lab: %v", err)
65+
}
66+
return cfg
67+
}
68+
69+
func firstEnv(keys ...string) string {
70+
for _, k := range keys {
71+
if v := os.Getenv(k); v != "" {
72+
return v
73+
}
74+
}
75+
return ""
76+
}

internal/provider/provider.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,20 @@ func (p *pcdProvider) Metadata(_ context.Context, _ provider.MetadataRequest, re
3636
}
3737

3838
func (p *pcdProvider) Resources(_ context.Context) []func() resource.Resource {
39-
return nil
39+
return []func() resource.Resource{
40+
identity.NewProjectResource,
41+
identity.NewRoleResource,
42+
identity.NewUserResource,
43+
identity.NewRoleAssignmentResource,
44+
identity.NewApplicationCredentialResource,
45+
}
4046
}
4147

4248
func (p *pcdProvider) DataSources(_ context.Context) []func() datasource.DataSource {
4349
return []func() datasource.DataSource{
4450
identity.NewAuthScopeDataSource,
51+
identity.NewProjectDataSource,
52+
identity.NewUserDataSource,
53+
identity.NewRoleDataSource,
4554
}
4655
}
Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
// Copyright (c) Platform9 Systems, Inc.
2+
// SPDX-License-Identifier: MPL-2.0
3+
//
4+
// Ported from terraform-provider-openstack v3.4.0
5+
// (openstack/resource_openstack_identity_application_credential_v3.go), adapted
6+
// for the terraform-plugin-framework and PCD.
7+
8+
package identity
9+
10+
import (
11+
"context"
12+
"fmt"
13+
"net/http"
14+
"time"
15+
16+
"github.com/gophercloud/gophercloud/v2"
17+
"github.com/gophercloud/gophercloud/v2/openstack/identity/v3/applicationcredentials"
18+
"github.com/gophercloud/gophercloud/v2/openstack/identity/v3/tokens"
19+
"github.com/hashicorp/terraform-plugin-framework/diag"
20+
"github.com/hashicorp/terraform-plugin-framework/path"
21+
"github.com/hashicorp/terraform-plugin-framework/resource"
22+
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
23+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault"
24+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier"
25+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
26+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/setplanmodifier"
27+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
28+
"github.com/hashicorp/terraform-plugin-framework/types"
29+
30+
"github.com/platform9/terraform-provider-pcd/internal/clients"
31+
)
32+
33+
var (
34+
_ resource.Resource = (*appCredResource)(nil)
35+
_ resource.ResourceWithConfigure = (*appCredResource)(nil)
36+
_ resource.ResourceWithImportState = (*appCredResource)(nil)
37+
)
38+
39+
// NewApplicationCredentialResource is the factory registered with the provider.
40+
func NewApplicationCredentialResource() resource.Resource {
41+
return &appCredResource{}
42+
}
43+
44+
type appCredResource struct {
45+
config *clients.Config
46+
}
47+
48+
type appCredModel struct {
49+
ID types.String `tfsdk:"id"`
50+
Name types.String `tfsdk:"name"`
51+
Description types.String `tfsdk:"description"`
52+
Secret types.String `tfsdk:"secret"`
53+
ProjectID types.String `tfsdk:"project_id"`
54+
Roles types.Set `tfsdk:"roles"`
55+
ExpiresAt types.String `tfsdk:"expires_at"`
56+
Unrestricted types.Bool `tfsdk:"unrestricted"`
57+
Region types.String `tfsdk:"region"`
58+
}
59+
60+
func (r *appCredResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
61+
resp.TypeName = req.ProviderTypeName + "_identity_application_credential"
62+
}
63+
64+
func (r *appCredResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
65+
forceNewString := []planmodifier.String{stringplanmodifier.RequiresReplace(), stringplanmodifier.UseStateForUnknown()}
66+
resp.Schema = schema.Schema{
67+
MarkdownDescription: "Manages an application credential for the authenticated user. Application " +
68+
"credentials are immutable — any change forces a new resource.",
69+
Attributes: map[string]schema.Attribute{
70+
"id": schema.StringAttribute{
71+
Computed: true,
72+
MarkdownDescription: "The application credential ID.",
73+
PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
74+
},
75+
"name": schema.StringAttribute{
76+
Required: true,
77+
MarkdownDescription: "The name of the application credential.",
78+
PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()},
79+
},
80+
"description": schema.StringAttribute{
81+
Optional: true,
82+
Computed: true,
83+
MarkdownDescription: "A description of the application credential.",
84+
PlanModifiers: forceNewString,
85+
},
86+
"secret": schema.StringAttribute{
87+
Optional: true,
88+
Computed: true,
89+
Sensitive: true,
90+
MarkdownDescription: "The secret. If omitted, one is generated and returned on create only.",
91+
PlanModifiers: forceNewString,
92+
},
93+
"project_id": schema.StringAttribute{
94+
Computed: true,
95+
MarkdownDescription: "The project the credential is scoped to.",
96+
PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
97+
},
98+
"roles": schema.SetAttribute{
99+
Optional: true,
100+
Computed: true,
101+
ElementType: types.StringType,
102+
MarkdownDescription: "Role names the credential is limited to. Defaults to all of the user's roles.",
103+
PlanModifiers: []planmodifier.Set{setplanmodifier.RequiresReplace(), setplanmodifier.UseStateForUnknown()},
104+
},
105+
"expires_at": schema.StringAttribute{
106+
Optional: true,
107+
MarkdownDescription: "RFC3339 expiry timestamp. If omitted, the credential does not expire.",
108+
PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()},
109+
},
110+
"unrestricted": schema.BoolAttribute{
111+
Optional: true,
112+
Computed: true,
113+
Default: booldefault.StaticBool(false),
114+
MarkdownDescription: "Whether the credential may be used to create/delete other application credentials and trusts.",
115+
PlanModifiers: []planmodifier.Bool{boolplanmodifier.RequiresReplace()},
116+
},
117+
"region": schema.StringAttribute{
118+
Optional: true,
119+
Computed: true,
120+
MarkdownDescription: "The region. Defaults to the provider's region.",
121+
PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
122+
},
123+
},
124+
}
125+
}
126+
127+
func (r *appCredResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
128+
r.config = configureClient(req.ProviderData, &resp.Diagnostics)
129+
}
130+
131+
func (r *appCredResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
132+
var plan appCredModel
133+
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
134+
if resp.Diagnostics.HasError() {
135+
return
136+
}
137+
138+
client, err := r.config.IdentityV3Client()
139+
if err != nil {
140+
resp.Diagnostics.AddError("identity: building v3 client", err.Error())
141+
return
142+
}
143+
userID, err := currentUserID(ctx, client)
144+
if err != nil {
145+
resp.Diagnostics.AddError("identity: resolving current user", err.Error())
146+
return
147+
}
148+
149+
var roleList []applicationcredentials.Role
150+
if !plan.Roles.IsNull() && !plan.Roles.IsUnknown() {
151+
var names []string
152+
resp.Diagnostics.Append(plan.Roles.ElementsAs(ctx, &names, false)...)
153+
if resp.Diagnostics.HasError() {
154+
return
155+
}
156+
for _, n := range names {
157+
roleList = append(roleList, applicationcredentials.Role{Name: n})
158+
}
159+
}
160+
161+
opts := applicationcredentials.CreateOpts{
162+
Name: plan.Name.ValueString(),
163+
Description: plan.Description.ValueString(),
164+
Unrestricted: plan.Unrestricted.ValueBool(),
165+
Secret: plan.Secret.ValueString(),
166+
Roles: roleList,
167+
}
168+
if v := plan.ExpiresAt.ValueString(); v != "" {
169+
ts, perr := time.Parse(time.RFC3339, v)
170+
if perr != nil {
171+
resp.Diagnostics.AddError("identity: invalid expires_at", fmt.Sprintf("must be RFC3339: %s", perr))
172+
return
173+
}
174+
opts.ExpiresAt = &ts
175+
}
176+
177+
ac, err := applicationcredentials.Create(ctx, client, userID, opts).Extract()
178+
if err != nil {
179+
resp.Diagnostics.AddError("identity: creating application credential", err.Error())
180+
return
181+
}
182+
183+
// The secret is only ever returned here; capture it into state.
184+
plan.Secret = types.StringValue(ac.Secret)
185+
resp.Diagnostics.Append(r.flatten(ctx, ac, &plan)...)
186+
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
187+
}
188+
189+
func (r *appCredResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
190+
var state appCredModel
191+
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
192+
if resp.Diagnostics.HasError() {
193+
return
194+
}
195+
196+
client, err := r.config.IdentityV3Client()
197+
if err != nil {
198+
resp.Diagnostics.AddError("identity: building v3 client", err.Error())
199+
return
200+
}
201+
userID, err := currentUserID(ctx, client)
202+
if err != nil {
203+
resp.Diagnostics.AddError("identity: resolving current user", err.Error())
204+
return
205+
}
206+
207+
ac, err := applicationcredentials.Get(ctx, client, userID, state.ID.ValueString()).Extract()
208+
if err != nil {
209+
if gophercloud.ResponseCodeIs(err, http.StatusNotFound) {
210+
resp.Diagnostics.AddWarning("Application credential not found",
211+
fmt.Sprintf("Application credential %s no longer exists and was removed from state.", state.ID.ValueString()))
212+
resp.State.RemoveResource(ctx)
213+
return
214+
}
215+
resp.Diagnostics.AddError("identity: reading application credential", err.Error())
216+
return
217+
}
218+
219+
// secret and expires_at are preserved from prior state (never read back).
220+
resp.Diagnostics.Append(r.flatten(ctx, ac, &state)...)
221+
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
222+
}
223+
224+
// Update is required by the interface but never invoked (every attribute forces
225+
// replacement).
226+
func (r *appCredResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
227+
var plan appCredModel
228+
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
229+
if resp.Diagnostics.HasError() {
230+
return
231+
}
232+
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
233+
}
234+
235+
func (r *appCredResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
236+
var state appCredModel
237+
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
238+
if resp.Diagnostics.HasError() {
239+
return
240+
}
241+
242+
client, err := r.config.IdentityV3Client()
243+
if err != nil {
244+
resp.Diagnostics.AddError("identity: building v3 client", err.Error())
245+
return
246+
}
247+
userID, err := currentUserID(ctx, client)
248+
if err != nil {
249+
resp.Diagnostics.AddError("identity: resolving current user", err.Error())
250+
return
251+
}
252+
253+
if err := applicationcredentials.Delete(ctx, client, userID, state.ID.ValueString()).ExtractErr(); err != nil {
254+
if gophercloud.ResponseCodeIs(err, http.StatusNotFound) {
255+
return
256+
}
257+
resp.Diagnostics.AddError("identity: deleting application credential", err.Error())
258+
}
259+
}
260+
261+
func (r *appCredResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
262+
resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
263+
}
264+
265+
// flatten copies server-known fields onto the model; secret and expires_at are
266+
// left untouched (write-only / not returned).
267+
func (r *appCredResource) flatten(ctx context.Context, ac *applicationcredentials.ApplicationCredential, m *appCredModel) (diags diag.Diagnostics) {
268+
m.ID = types.StringValue(ac.ID)
269+
m.Name = types.StringValue(ac.Name)
270+
m.Description = types.StringValue(ac.Description)
271+
m.ProjectID = types.StringValue(ac.ProjectID)
272+
m.Unrestricted = types.BoolValue(ac.Unrestricted)
273+
274+
names := make([]string, 0, len(ac.Roles))
275+
for _, ro := range ac.Roles {
276+
names = append(names, ro.Name)
277+
}
278+
roles, d := types.SetValueFrom(ctx, types.StringType, names)
279+
diags = append(diags, d...)
280+
m.Roles = roles
281+
282+
if m.Region.IsNull() || m.Region.IsUnknown() {
283+
m.Region = types.StringValue(r.config.Region)
284+
}
285+
return diags
286+
}
287+
288+
// currentUserID returns the user ID of the token the provider is using.
289+
func currentUserID(ctx context.Context, client *gophercloud.ServiceClient) (string, error) {
290+
user, err := tokens.Get(ctx, client, client.Token()).ExtractUser()
291+
if err != nil {
292+
return "", err
293+
}
294+
return user.ID, nil
295+
}

0 commit comments

Comments
 (0)