Skip to content

Commit 3990deb

Browse files
authored
Merge pull request #7 from platform9/feat/blockstorage
feat(blockstorage): Cinder volume resource and volume/snapshot data sources
2 parents 4303ab8 + 2af828d commit 3990deb

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)