Skip to content

Commit 4af1b6d

Browse files
authored
Merge pull request #18 from platform9/feat/keymanager-barbican
feat(keymanager): Barbican key-manager family (pcd_keymanager_secret, _container) — Phase 3
2 parents 0db6712 + 0e12517 commit 4af1b6d

16 files changed

Lines changed: 988 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ All notable changes to this project are documented here. The format is based on
6363
- DNS (Designate v2) — Phase 3: `pcd_dns_zone` and `pcd_dns_recordset` resources plus a
6464
`pcd_dns_zone` data source. Zone and recordset create/update/delete are asynchronous,
6565
so applies wait for the object to reach `ACTIVE` (and to disappear after delete).
66+
- Key management (Barbican v1) — Phase 3: `pcd_keymanager_secret` (write-only, sensitive
67+
`payload`) and `pcd_keymanager_container` (grouped secrets) resources plus a
68+
`pcd_keymanager_secret` data source (optionally fetches the payload). Barbican identifies
69+
objects by URL refs; the resources expose the full ref and use the bare UUID as the ID.
6670

6771
- Registry documentation generation wired via `tfplugindocs` (`make generate`) — renders
6872
`docs/` for every resource and data source plus the provider index from schema

DECISIONS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ yet passable on this lab (reason noted). Generated registry docs are not committ
3838
| Block storage (DS) | `pcd_blockstorage_volume`, `_snapshot` | **PENDING** — untestable without volumes on this lab. |
3939
| Load balancing (Octavia) | `pcd_lb_loadbalancer`, `_listener`, `_pool`, `_member`, `_monitor`, `_l7policy`, `_l7rule` + `_loadbalancer` DS | **PENDING** — Phase 3, code-complete; per-LB wait-for-`ACTIVE` lifecycle, root-LB resolution for every child, echo-only churny fields. Full-tree acc test + examples written. Octavia is live on the lab (Step 0), but LB provisioning needs a working amphora/provider driver; not yet run live (credentials unavailable this session). |
4040
| DNS (Designate) | `pcd_dns_zone`, `pcd_dns_recordset` + `pcd_dns_zone` DS | **PENDING** — Phase 3, code-complete; async create/update/delete → wait-for-`ACTIVE`/404. Acc test (zone + recordset + import) + examples written. Designate is live on the lab (Step 0) and DNS needs no compute/storage backend, so this should pass live — not yet run this session (credentials unavailable). |
41+
| Key management (Barbican) | `pcd_keymanager_secret`, `pcd_keymanager_container` + `pcd_keymanager_secret` DS | **PENDING** — Phase 3, code-complete; write-only echo-only `payload`, URL-ref→UUID id handling, wait-for-`ACTIVE` only on create-with-payload. Acc test (secret + container + data source + import) + examples written. Barbican is live on the lab (Step 0) and needs no compute/storage backend, so this should pass live — not yet run this session (credentials unavailable). |
4142

4243
Both PENDING items are lab-side configuration gaps (Platform9 / lab-ops), not provider
4344
defects; their acceptance tests flip green on a properly-configured PCD cloud.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
data "pcd_keymanager_secret" "example" {
2+
name = "tf-example-passphrase"
3+
payload_content_type = "text/plain"
4+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
terraform import pcd_keymanager_container.example <id>
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
resource "pcd_keymanager_secret" "example" {
2+
name = "tf-example-tls-key"
3+
payload = "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
4+
payload_content_type = "text/plain"
5+
}
6+
7+
resource "pcd_keymanager_container" "example" {
8+
name = "tf-example-container"
9+
type = "generic"
10+
11+
secret_refs {
12+
name = "private_key"
13+
secret_ref = pcd_keymanager_secret.example.secret_ref
14+
}
15+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
terraform import pcd_keymanager_secret.example <id>
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
resource "pcd_keymanager_secret" "example" {
2+
name = "tf-example-passphrase"
3+
secret_type = "passphrase"
4+
payload = "super-secret-value"
5+
payload_content_type = "text/plain"
6+
}

internal/clients/config.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,17 @@ func (c *Config) DNSV2Client() (*gophercloud.ServiceClient, error) {
257257
return client, nil
258258
}
259259

260+
// KeyManagerV1Client returns a Barbican (key manager) v1 service client, honoring
261+
// an endpoint_overrides entry for the "key-manager" service type if present.
262+
func (c *Config) KeyManagerV1Client() (*gophercloud.ServiceClient, error) {
263+
client, err := openstack.NewKeyManagerV1(c.Provider, c.endpointOpts())
264+
if err != nil {
265+
return nil, fmt.Errorf("pcd: creating key manager v1 client: %w", err)
266+
}
267+
c.applyOverride(client, "key-manager")
268+
return client, nil
269+
}
270+
260271
// applyOverride points a service client at an operator-supplied endpoint when
261272
// endpoint_overrides names its service type.
262273
func (c *Config) applyOverride(client *gophercloud.ServiceClient, serviceType string) {

internal/provider/provider.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/platform9/terraform-provider-pcd/internal/services/dns"
1616
"github.com/platform9/terraform-provider-pcd/internal/services/identity"
1717
"github.com/platform9/terraform-provider-pcd/internal/services/images"
18+
"github.com/platform9/terraform-provider-pcd/internal/services/keymanager"
1819
"github.com/platform9/terraform-provider-pcd/internal/services/loadbalancer"
1920
"github.com/platform9/terraform-provider-pcd/internal/services/networking"
2021
)
@@ -77,6 +78,8 @@ func (p *pcdProvider) Resources(_ context.Context) []func() resource.Resource {
7778
loadbalancer.NewL7RuleResource,
7879
dns.NewZoneResource,
7980
dns.NewRecordSetResource,
81+
keymanager.NewSecretResource,
82+
keymanager.NewContainerResource,
8083
}
8184
}
8285

@@ -103,5 +106,6 @@ func (p *pcdProvider) DataSources(_ context.Context) []func() datasource.DataSou
103106
blockstorage.NewSnapshotDataSource,
104107
loadbalancer.NewLoadBalancerDataSource,
105108
dns.NewZoneDataSource,
109+
keymanager.NewSecretDataSource,
106110
}
107111
}
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
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_keymanager_container_v1.go), adapted for the
6+
// terraform-plugin-framework and PCD.
7+
8+
package keymanager
9+
10+
import (
11+
"context"
12+
"fmt"
13+
"net/http"
14+
15+
"github.com/gophercloud/gophercloud/v2"
16+
"github.com/gophercloud/gophercloud/v2/openstack/keymanager/v1/containers"
17+
"github.com/hashicorp/terraform-plugin-framework/attr"
18+
"github.com/hashicorp/terraform-plugin-framework/diag"
19+
"github.com/hashicorp/terraform-plugin-framework/path"
20+
"github.com/hashicorp/terraform-plugin-framework/resource"
21+
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
22+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
23+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/setplanmodifier"
24+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
25+
"github.com/hashicorp/terraform-plugin-framework/types"
26+
27+
"github.com/platform9/terraform-provider-pcd/internal/clients"
28+
)
29+
30+
var (
31+
_ resource.Resource = (*containerResource)(nil)
32+
_ resource.ResourceWithConfigure = (*containerResource)(nil)
33+
_ resource.ResourceWithImportState = (*containerResource)(nil)
34+
)
35+
36+
var (
37+
containerSecretRefObjType = types.ObjectType{AttrTypes: map[string]attr.Type{
38+
"secret_ref": types.StringType,
39+
"name": types.StringType,
40+
}}
41+
containerConsumerObjType = types.ObjectType{AttrTypes: map[string]attr.Type{
42+
"name": types.StringType,
43+
"url": types.StringType,
44+
}}
45+
)
46+
47+
// NewContainerResource is the factory registered with the provider.
48+
func NewContainerResource() resource.Resource {
49+
return &containerResource{}
50+
}
51+
52+
type containerResource struct {
53+
config *clients.Config
54+
}
55+
56+
type containerModel struct {
57+
ID types.String `tfsdk:"id"`
58+
Name types.String `tfsdk:"name"`
59+
Type types.String `tfsdk:"type"`
60+
SecretRefs types.Set `tfsdk:"secret_refs"`
61+
ContainerRef types.String `tfsdk:"container_ref"`
62+
Status types.String `tfsdk:"status"`
63+
Consumers types.List `tfsdk:"consumers"`
64+
CreatedAt types.String `tfsdk:"created_at"`
65+
Region types.String `tfsdk:"region"`
66+
}
67+
68+
type containerSecretRefModel struct {
69+
SecretRef types.String `tfsdk:"secret_ref"`
70+
Name types.String `tfsdk:"name"`
71+
}
72+
73+
type containerConsumerModel struct {
74+
Name types.String `tfsdk:"name"`
75+
URL types.String `tfsdk:"url"`
76+
}
77+
78+
func (r *containerResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
79+
resp.TypeName = req.ProviderTypeName + "_keymanager_container"
80+
}
81+
82+
func (r *containerResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
83+
useState := []planmodifier.String{stringplanmodifier.UseStateForUnknown()}
84+
forceNew := []planmodifier.String{stringplanmodifier.RequiresReplace()}
85+
forceNewC := []planmodifier.String{stringplanmodifier.RequiresReplace(), stringplanmodifier.UseStateForUnknown()}
86+
resp.Schema = schema.Schema{
87+
MarkdownDescription: "Manages a container in PCD's Barbican key manager — a named grouping of secrets " +
88+
"(generic, RSA, or certificate). Containers are immutable; any change forces a new resource.",
89+
Attributes: map[string]schema.Attribute{
90+
"id": schema.StringAttribute{Computed: true, MarkdownDescription: "The container UUID.", PlanModifiers: useState},
91+
"name": schema.StringAttribute{Optional: true, Computed: true, MarkdownDescription: "The name of the container. Changing this forces a new resource.", PlanModifiers: forceNewC},
92+
"type": schema.StringAttribute{Required: true, MarkdownDescription: "The container type: generic, rsa, or certificate. Changing this forces a new resource.", PlanModifiers: forceNew},
93+
"secret_refs": schema.SetNestedAttribute{
94+
Optional: true,
95+
Computed: true,
96+
MarkdownDescription: "The secrets in the container. Changing these forces a new resource.",
97+
NestedObject: schema.NestedAttributeObject{Attributes: map[string]schema.Attribute{
98+
"secret_ref": schema.StringAttribute{Required: true, MarkdownDescription: "The full secret reference URL."},
99+
"name": schema.StringAttribute{Optional: true, MarkdownDescription: "A label for the secret within the container (e.g. private_key)."},
100+
}},
101+
PlanModifiers: []planmodifier.Set{setplanmodifier.RequiresReplace(), setplanmodifier.UseStateForUnknown()},
102+
},
103+
"container_ref": schema.StringAttribute{Computed: true, MarkdownDescription: "The full Barbican container reference URL.", PlanModifiers: useState},
104+
"status": schema.StringAttribute{Computed: true, MarkdownDescription: "The container status (e.g. ACTIVE).", PlanModifiers: useState},
105+
"consumers": schema.ListNestedAttribute{
106+
Computed: true,
107+
MarkdownDescription: "Services consuming this container.",
108+
NestedObject: schema.NestedAttributeObject{Attributes: map[string]schema.Attribute{
109+
"name": schema.StringAttribute{Computed: true, MarkdownDescription: "The consumer name."},
110+
"url": schema.StringAttribute{Computed: true, MarkdownDescription: "The consumer URL."},
111+
}},
112+
},
113+
"created_at": schema.StringAttribute{Computed: true, MarkdownDescription: "Creation timestamp (RFC3339).", PlanModifiers: useState},
114+
"region": schema.StringAttribute{Optional: true, Computed: true, MarkdownDescription: "The region. Defaults to the provider's region.", PlanModifiers: useState},
115+
},
116+
}
117+
}
118+
119+
func (r *containerResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
120+
r.config = configureClient(req.ProviderData, &resp.Diagnostics)
121+
}
122+
123+
func (r *containerResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
124+
var plan containerModel
125+
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
126+
if resp.Diagnostics.HasError() {
127+
return
128+
}
129+
130+
client, err := r.config.KeyManagerV1Client()
131+
if err != nil {
132+
resp.Diagnostics.AddError("keymanager: building v1 client", err.Error())
133+
return
134+
}
135+
136+
createOpts := containers.CreateOpts{
137+
Type: containers.ContainerType(plan.Type.ValueString()),
138+
Name: plan.Name.ValueString(),
139+
}
140+
if !plan.SecretRefs.IsNull() && !plan.SecretRefs.IsUnknown() {
141+
var refs []containerSecretRefModel
142+
resp.Diagnostics.Append(plan.SecretRefs.ElementsAs(ctx, &refs, false)...)
143+
if resp.Diagnostics.HasError() {
144+
return
145+
}
146+
for _, ref := range refs {
147+
createOpts.SecretRefs = append(createOpts.SecretRefs, containers.SecretRef{
148+
SecretRef: ref.SecretRef.ValueString(),
149+
Name: ref.Name.ValueString(),
150+
})
151+
}
152+
}
153+
154+
container, err := containers.Create(ctx, client, createOpts).Extract()
155+
if err != nil {
156+
resp.Diagnostics.AddError("keymanager: creating container", err.Error())
157+
return
158+
}
159+
160+
_, readDiags := r.readInto(ctx, client, refToID(container.ContainerRef), &plan)
161+
resp.Diagnostics.Append(readDiags...)
162+
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
163+
}
164+
165+
func (r *containerResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
166+
var state containerModel
167+
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
168+
if resp.Diagnostics.HasError() {
169+
return
170+
}
171+
172+
client, err := r.config.KeyManagerV1Client()
173+
if err != nil {
174+
resp.Diagnostics.AddError("keymanager: building v1 client", err.Error())
175+
return
176+
}
177+
178+
notFound, diags := r.readInto(ctx, client, state.ID.ValueString(), &state)
179+
if notFound {
180+
resp.Diagnostics.AddWarning("Container not found",
181+
fmt.Sprintf("Container %s no longer exists and was removed from state.", state.ID.ValueString()))
182+
resp.State.RemoveResource(ctx)
183+
return
184+
}
185+
resp.Diagnostics.Append(diags...)
186+
if resp.Diagnostics.HasError() {
187+
return
188+
}
189+
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
190+
}
191+
192+
// Update is required by the interface but never invoked (every attribute forces replacement).
193+
func (r *containerResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
194+
var plan containerModel
195+
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
196+
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
197+
}
198+
199+
func (r *containerResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
200+
var state containerModel
201+
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
202+
if resp.Diagnostics.HasError() {
203+
return
204+
}
205+
206+
client, err := r.config.KeyManagerV1Client()
207+
if err != nil {
208+
resp.Diagnostics.AddError("keymanager: building v1 client", err.Error())
209+
return
210+
}
211+
212+
if err := containers.Delete(ctx, client, state.ID.ValueString()).ExtractErr(); err != nil {
213+
if gophercloud.ResponseCodeIs(err, http.StatusNotFound) {
214+
return
215+
}
216+
resp.Diagnostics.AddError("keymanager: deleting container", err.Error())
217+
}
218+
}
219+
220+
func (r *containerResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
221+
resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
222+
}
223+
224+
// readInto refreshes a container. secret_refs is echo-only (it holds the user's
225+
// requested set and is ForceNew); it is populated from the server only when unset
226+
// (import). consumers is refreshed every read (it can change out-of-band).
227+
func (r *containerResource) readInto(ctx context.Context, client *gophercloud.ServiceClient, id string, m *containerModel) (notFound bool, diags diag.Diagnostics) {
228+
container, err := containers.Get(ctx, client, id).Extract()
229+
if err != nil {
230+
if gophercloud.ResponseCodeIs(err, http.StatusNotFound) {
231+
return true, diags
232+
}
233+
diags.AddError("keymanager: reading container", err.Error())
234+
return false, diags
235+
}
236+
237+
m.ID = types.StringValue(id)
238+
m.ContainerRef = types.StringValue(container.ContainerRef)
239+
m.Status = types.StringValue(container.Status)
240+
m.CreatedAt = types.StringValue(formatTime(container.Created))
241+
if unset(m.Name) {
242+
m.Name = types.StringValue(container.Name)
243+
}
244+
if unset(m.Type) {
245+
m.Type = types.StringValue(container.Type)
246+
}
247+
248+
if m.SecretRefs.IsNull() || m.SecretRefs.IsUnknown() {
249+
refs := make([]containerSecretRefModel, 0, len(container.SecretRefs))
250+
for _, sr := range container.SecretRefs {
251+
refs = append(refs, containerSecretRefModel{
252+
SecretRef: types.StringValue(sr.SecretRef),
253+
Name: types.StringValue(sr.Name),
254+
})
255+
}
256+
refSet, d := types.SetValueFrom(ctx, containerSecretRefObjType, refs)
257+
diags = append(diags, d...)
258+
m.SecretRefs = refSet
259+
}
260+
261+
consumers := make([]containerConsumerModel, 0, len(container.Consumers))
262+
for _, c := range container.Consumers {
263+
consumers = append(consumers, containerConsumerModel{
264+
Name: types.StringValue(c.Name),
265+
URL: types.StringValue(c.URL),
266+
})
267+
}
268+
consList, d := types.ListValueFrom(ctx, containerConsumerObjType, consumers)
269+
diags = append(diags, d...)
270+
m.Consumers = consList
271+
272+
if m.Region.IsNull() || m.Region.IsUnknown() {
273+
m.Region = types.StringValue(r.config.Region)
274+
}
275+
return false, diags
276+
}

0 commit comments

Comments
 (0)