Skip to content

Commit 2af828d

Browse files
committed
feat(blockstorage): add pcd_blockstorage_volume + volume/snapshot data sources
Cinder v3. Code-complete; the volume create/status-waiter/error-detection path is verified against the lab (the waiter correctly catches the backend ERROR). The full acceptance test can't pass on the CE lab because it has no storage backend configured (storageBackends={}), so volumes go creating -> error. It will pass on a cloud with a working Cinder backend. Tracked in DECISIONS.md. - pcd_blockstorage_volume: create (wait available), in-place extend on size increase, name/description/metadata update, import. - pcd_blockstorage_volume / pcd_blockstorage_snapshot data sources. - clients: BlockStorageV3Client (Cinder).
1 parent cc8208a commit 2af828d

9 files changed

Lines changed: 753 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ All notable changes to this project are documented here. The format is based on
3535
`pcd_compute_servergroup` (acceptance-tested); `pcd_compute_instance` (code-complete —
3636
boot verification is blocked on a lab image-library issue, see DECISIONS.md); data
3737
sources `pcd_compute_flavor`, `pcd_compute_keypair`, `pcd_compute_availability_zones`.
38+
- Block storage (Cinder v3): `pcd_blockstorage_volume` resource (create/extend/import;
39+
code-complete — acceptance is blocked on the CE lab having no storage backend, see
40+
DECISIONS.md) and `pcd_blockstorage_volume` / `pcd_blockstorage_snapshot` data sources.
3841

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

DECISIONS.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,16 @@
33
Records deviations from the PCD-1070 implementation plan and material findings that
44
change scope. The plan's Section 3 decisions remain locked unless noted here.
55

6+
## 2026-07-11 — blockstorage: no Cinder storage backend on the CE lab
7+
8+
Volume creation on the CE lab goes `creating → error` immediately: the cluster
9+
blueprint has `storageBackends={}` (none configured) and only a `__DEFAULT__` volume
10+
type with nothing to fulfill it. So `pcd_blockstorage_volume`'s acceptance test cannot
11+
pass on this lab (same class of lab gap as the compute image-library issue). The
12+
resource **code is verified up to the backend**: create + status waiter + error
13+
detection all work (the waiter correctly reports the ERROR state). It will pass on a
14+
cloud with a working storage backend. The volume/snapshot data sources ship alongside.
15+
616
## 2026-07-11 — BLOCKER: hypervisor pcd-iso-test cannot spawn instances
717

818
Nova boots fail: an instance goes BUILD → ERROR with *"Exceeded maximum number of

internal/clients/config.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,17 @@ func (c *Config) ComputeV2Client() (*gophercloud.ServiceClient, error) {
224224
return client, nil
225225
}
226226

227+
// BlockStorageV3Client returns a Cinder v3 service client, honoring an
228+
// endpoint_overrides entry for the "volumev3" service type if present.
229+
func (c *Config) BlockStorageV3Client() (*gophercloud.ServiceClient, error) {
230+
client, err := openstack.NewBlockStorageV3(c.Provider, c.endpointOpts())
231+
if err != nil {
232+
return nil, fmt.Errorf("pcd: creating block storage v3 client: %w", err)
233+
}
234+
c.applyOverride(client, "volumev3")
235+
return client, nil
236+
}
237+
227238
// applyOverride points a service client at an operator-supplied endpoint when
228239
// endpoint_overrides names its service type.
229240
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
@@ -10,6 +10,7 @@ import (
1010
"github.com/hashicorp/terraform-plugin-framework/provider"
1111
"github.com/hashicorp/terraform-plugin-framework/resource"
1212

13+
"github.com/platform9/terraform-provider-pcd/internal/services/blockstorage"
1314
"github.com/platform9/terraform-provider-pcd/internal/services/compute"
1415
"github.com/platform9/terraform-provider-pcd/internal/services/identity"
1516
"github.com/platform9/terraform-provider-pcd/internal/services/images"
@@ -56,6 +57,7 @@ func (p *pcdProvider) Resources(_ context.Context) []func() resource.Resource {
5657
compute.NewInstanceResource,
5758
compute.NewFlavorResource,
5859
compute.NewServergroupResource,
60+
blockstorage.NewVolumeResource,
5961
}
6062
}
6163

@@ -73,5 +75,7 @@ func (p *pcdProvider) DataSources(_ context.Context) []func() datasource.DataSou
7375
compute.NewFlavorDataSource,
7476
compute.NewKeypairDataSource,
7577
compute.NewAvailabilityZonesDataSource,
78+
blockstorage.NewVolumeDataSource,
79+
blockstorage.NewSnapshotDataSource,
7680
}
7781
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Copyright (c) Platform9 Systems, Inc.
2+
// SPDX-License-Identifier: MPL-2.0
3+
4+
// Package blockstorage implements the pcd_blockstorage_* resources and data
5+
// sources (Cinder v3), ported from terraform-provider-openstack v3.4.0.
6+
package blockstorage
7+
8+
import (
9+
"fmt"
10+
11+
"github.com/hashicorp/terraform-plugin-framework/diag"
12+
13+
"github.com/platform9/terraform-provider-pcd/internal/clients"
14+
)
15+
16+
// configureClient extracts the shared *clients.Config from ProviderData.
17+
func configureClient(providerData any, diags *diag.Diagnostics) *clients.Config {
18+
if providerData == nil {
19+
return nil
20+
}
21+
config, ok := providerData.(*clients.Config)
22+
if !ok {
23+
diags.AddError(
24+
"Unexpected provider data type",
25+
fmt.Sprintf("Expected *clients.Config, got %T. This is a bug in the provider.", providerData),
26+
)
27+
return nil
28+
}
29+
return config
30+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
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/data_source_openstack_blockstorage_snapshot_v3.go), adapted for the
6+
// terraform-plugin-framework and PCD.
7+
8+
package blockstorage
9+
10+
import (
11+
"context"
12+
"fmt"
13+
14+
"github.com/gophercloud/gophercloud/v2/openstack/blockstorage/v3/snapshots"
15+
"github.com/hashicorp/terraform-plugin-framework/datasource"
16+
"github.com/hashicorp/terraform-plugin-framework/datasource/schema"
17+
"github.com/hashicorp/terraform-plugin-framework/types"
18+
19+
"github.com/platform9/terraform-provider-pcd/internal/clients"
20+
)
21+
22+
var (
23+
_ datasource.DataSource = (*snapshotDataSource)(nil)
24+
_ datasource.DataSourceWithConfigure = (*snapshotDataSource)(nil)
25+
)
26+
27+
// NewSnapshotDataSource is the factory registered with the provider.
28+
func NewSnapshotDataSource() datasource.DataSource {
29+
return &snapshotDataSource{}
30+
}
31+
32+
type snapshotDataSource struct {
33+
config *clients.Config
34+
}
35+
36+
type snapshotDataSourceModel struct {
37+
ID types.String `tfsdk:"id"`
38+
SnapshotID types.String `tfsdk:"snapshot_id"`
39+
Name types.String `tfsdk:"name"`
40+
VolumeID types.String `tfsdk:"volume_id"`
41+
Size types.Int64 `tfsdk:"size"`
42+
Status types.String `tfsdk:"status"`
43+
Description types.String `tfsdk:"description"`
44+
Region types.String `tfsdk:"region"`
45+
}
46+
47+
func (d *snapshotDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
48+
resp.TypeName = req.ProviderTypeName + "_blockstorage_snapshot"
49+
}
50+
51+
func (d *snapshotDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
52+
resp.Schema = schema.Schema{
53+
MarkdownDescription: "Look up a Cinder volume snapshot by ID or filters.",
54+
Attributes: map[string]schema.Attribute{
55+
"id": schema.StringAttribute{Computed: true, MarkdownDescription: "The snapshot ID."},
56+
"snapshot_id": schema.StringAttribute{Optional: true, MarkdownDescription: "Look up by snapshot ID (takes precedence over filters)."},
57+
"name": schema.StringAttribute{Optional: true, MarkdownDescription: "Filter by name."},
58+
"volume_id": schema.StringAttribute{Optional: true, Computed: true, MarkdownDescription: "Filter by (and report) the source volume."},
59+
"size": schema.Int64Attribute{Computed: true, MarkdownDescription: "Size in GB."},
60+
"status": schema.StringAttribute{Computed: true, MarkdownDescription: "The Cinder status."},
61+
"description": schema.StringAttribute{Computed: true, MarkdownDescription: "The snapshot description."},
62+
"region": schema.StringAttribute{Optional: true, Computed: true, MarkdownDescription: "The region. Defaults to the provider's region."},
63+
},
64+
}
65+
}
66+
67+
func (d *snapshotDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
68+
d.config = configureClient(req.ProviderData, &resp.Diagnostics)
69+
}
70+
71+
func (d *snapshotDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) {
72+
var data snapshotDataSourceModel
73+
resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
74+
if resp.Diagnostics.HasError() {
75+
return
76+
}
77+
78+
client, err := d.config.BlockStorageV3Client()
79+
if err != nil {
80+
resp.Diagnostics.AddError("blockstorage: building v3 client", err.Error())
81+
return
82+
}
83+
84+
var snap *snapshots.Snapshot
85+
if v := data.SnapshotID.ValueString(); v != "" {
86+
snap, err = snapshots.Get(ctx, client, v).Extract()
87+
if err != nil {
88+
resp.Diagnostics.AddError("blockstorage: getting snapshot", err.Error())
89+
return
90+
}
91+
} else {
92+
pages, err := snapshots.List(client, snapshots.ListOpts{
93+
Name: data.Name.ValueString(),
94+
VolumeID: data.VolumeID.ValueString(),
95+
}).AllPages(ctx)
96+
if err != nil {
97+
resp.Diagnostics.AddError("blockstorage: listing snapshots", err.Error())
98+
return
99+
}
100+
all, err := snapshots.ExtractSnapshots(pages)
101+
if err != nil {
102+
resp.Diagnostics.AddError("blockstorage: extracting snapshots", err.Error())
103+
return
104+
}
105+
switch len(all) {
106+
case 0:
107+
resp.Diagnostics.AddError("No snapshot found", "No snapshot matched the given criteria.")
108+
return
109+
case 1:
110+
snap = &all[0]
111+
default:
112+
resp.Diagnostics.AddError("Multiple snapshots found", fmt.Sprintf("%d snapshots matched; refine the criteria.", len(all)))
113+
return
114+
}
115+
}
116+
117+
data.ID = types.StringValue(snap.ID)
118+
data.SnapshotID = types.StringValue(snap.ID)
119+
data.Name = types.StringValue(snap.Name)
120+
data.VolumeID = types.StringValue(snap.VolumeID)
121+
data.Size = types.Int64Value(int64(snap.Size))
122+
data.Status = types.StringValue(snap.Status)
123+
data.Description = types.StringValue(snap.Description)
124+
if data.Region.IsNull() || data.Region.IsUnknown() {
125+
data.Region = types.StringValue(d.config.Region)
126+
}
127+
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
128+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
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/data_source_openstack_blockstorage_volume_v3.go), adapted for the
6+
// terraform-plugin-framework and PCD.
7+
8+
package blockstorage
9+
10+
import (
11+
"context"
12+
"fmt"
13+
14+
"github.com/gophercloud/gophercloud/v2/openstack/blockstorage/v3/volumes"
15+
"github.com/hashicorp/terraform-plugin-framework/datasource"
16+
"github.com/hashicorp/terraform-plugin-framework/datasource/schema"
17+
"github.com/hashicorp/terraform-plugin-framework/types"
18+
19+
"github.com/platform9/terraform-provider-pcd/internal/clients"
20+
)
21+
22+
var (
23+
_ datasource.DataSource = (*volumeDataSource)(nil)
24+
_ datasource.DataSourceWithConfigure = (*volumeDataSource)(nil)
25+
)
26+
27+
// NewVolumeDataSource is the factory registered with the provider.
28+
func NewVolumeDataSource() datasource.DataSource {
29+
return &volumeDataSource{}
30+
}
31+
32+
type volumeDataSource struct {
33+
config *clients.Config
34+
}
35+
36+
type volumeDataSourceModel struct {
37+
ID types.String `tfsdk:"id"`
38+
VolumeID types.String `tfsdk:"volume_id"`
39+
Name types.String `tfsdk:"name"`
40+
Size types.Int64 `tfsdk:"size"`
41+
Status types.String `tfsdk:"status"`
42+
VolumeType types.String `tfsdk:"volume_type"`
43+
AvailabilityZone types.String `tfsdk:"availability_zone"`
44+
Bootable types.Bool `tfsdk:"bootable"`
45+
Region types.String `tfsdk:"region"`
46+
}
47+
48+
func (d *volumeDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
49+
resp.TypeName = req.ProviderTypeName + "_blockstorage_volume"
50+
}
51+
52+
func (d *volumeDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
53+
resp.Schema = schema.Schema{
54+
MarkdownDescription: "Look up a Cinder volume by ID or name.",
55+
Attributes: map[string]schema.Attribute{
56+
"id": schema.StringAttribute{Computed: true, MarkdownDescription: "The volume ID."},
57+
"volume_id": schema.StringAttribute{Optional: true, MarkdownDescription: "Look up by volume ID (takes precedence over name)."},
58+
"name": schema.StringAttribute{Optional: true, MarkdownDescription: "Look up by name."},
59+
"size": schema.Int64Attribute{Computed: true, MarkdownDescription: "Size in GB."},
60+
"status": schema.StringAttribute{Computed: true, MarkdownDescription: "The Cinder status."},
61+
"volume_type": schema.StringAttribute{Computed: true, MarkdownDescription: "The volume type."},
62+
"availability_zone": schema.StringAttribute{Computed: true, MarkdownDescription: "The availability zone."},
63+
"bootable": schema.BoolAttribute{Computed: true, MarkdownDescription: "Whether the volume is bootable."},
64+
"region": schema.StringAttribute{Optional: true, Computed: true, MarkdownDescription: "The region. Defaults to the provider's region."},
65+
},
66+
}
67+
}
68+
69+
func (d *volumeDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
70+
d.config = configureClient(req.ProviderData, &resp.Diagnostics)
71+
}
72+
73+
func (d *volumeDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) {
74+
var data volumeDataSourceModel
75+
resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
76+
if resp.Diagnostics.HasError() {
77+
return
78+
}
79+
80+
client, err := d.config.BlockStorageV3Client()
81+
if err != nil {
82+
resp.Diagnostics.AddError("blockstorage: building v3 client", err.Error())
83+
return
84+
}
85+
86+
var vol *volumes.Volume
87+
if v := data.VolumeID.ValueString(); v != "" {
88+
vol, err = volumes.Get(ctx, client, v).Extract()
89+
if err != nil {
90+
resp.Diagnostics.AddError("blockstorage: getting volume", err.Error())
91+
return
92+
}
93+
} else {
94+
pages, err := volumes.List(client, volumes.ListOpts{Name: data.Name.ValueString()}).AllPages(ctx)
95+
if err != nil {
96+
resp.Diagnostics.AddError("blockstorage: listing volumes", err.Error())
97+
return
98+
}
99+
all, err := volumes.ExtractVolumes(pages)
100+
if err != nil {
101+
resp.Diagnostics.AddError("blockstorage: extracting volumes", err.Error())
102+
return
103+
}
104+
switch len(all) {
105+
case 0:
106+
resp.Diagnostics.AddError("No volume found", "No volume matched the given criteria.")
107+
return
108+
case 1:
109+
vol = &all[0]
110+
default:
111+
resp.Diagnostics.AddError("Multiple volumes found", fmt.Sprintf("%d volumes matched; refine the criteria.", len(all)))
112+
return
113+
}
114+
}
115+
116+
data.ID = types.StringValue(vol.ID)
117+
data.VolumeID = types.StringValue(vol.ID)
118+
data.Name = types.StringValue(vol.Name)
119+
data.Size = types.Int64Value(int64(vol.Size))
120+
data.Status = types.StringValue(vol.Status)
121+
data.VolumeType = types.StringValue(vol.VolumeType)
122+
data.AvailabilityZone = types.StringValue(vol.AvailabilityZone)
123+
data.Bootable = types.BoolValue(vol.Bootable == "true")
124+
if data.Region.IsNull() || data.Region.IsUnknown() {
125+
data.Region = types.StringValue(d.config.Region)
126+
}
127+
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
128+
}

0 commit comments

Comments
 (0)