Skip to content

Commit c1bac4a

Browse files
authored
Merge pull request #26 from platform9/feat/blockstorage-gaps
feat(blockstorage): volume_type, snapshot, volume_backup (close api-docs gaps)
2 parents bbcf514 + a84992f commit c1bac4a

12 files changed

Lines changed: 1169 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@ All notable changes to this project are documented here. The format is based on
5454
- Block storage (Cinder v3): `pcd_blockstorage_volume` resource (create/extend/import;
5555
code-complete — acceptance is blocked on the CE lab having no storage backend, see
5656
DECISIONS.md) and `pcd_blockstorage_volume` / `pcd_blockstorage_snapshot` data sources.
57+
- Block storage gap resources (close api-docs coverage): `pcd_blockstorage_volume_type`
58+
(name/description/`is_public`/`extra_specs`, with in-place spec add/change/remove via the
59+
extra-specs sub-API — needs no storage backend), `pcd_blockstorage_snapshot` (was
60+
data-source-only; now a managed resource with async wait-for-`available`), and
61+
`pcd_blockstorage_volume_backup` (backup/restore lifecycle, async waiter).
5762
- Load balancing (Octavia v2) — Phase 3: `pcd_lb_loadbalancer`, `pcd_lb_listener`,
5863
`pcd_lb_pool`, `pcd_lb_member`, `pcd_lb_monitor` resources and a `pcd_lb_loadbalancer`
5964
data source. Every child operation resolves the root load balancer and waits for its
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
terraform import pcd_blockstorage_snapshot.example <snapshot_id>
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
resource "pcd_blockstorage_volume" "example" {
2+
name = "tf-example-volume"
3+
size = 1
4+
}
5+
6+
resource "pcd_blockstorage_snapshot" "example" {
7+
name = "tf-example-snapshot"
8+
volume_id = pcd_blockstorage_volume.example.id
9+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
terraform import pcd_blockstorage_volume_backup.example <backup_id>
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
resource "pcd_blockstorage_volume" "example" {
2+
name = "tf-example-volume"
3+
size = 1
4+
}
5+
6+
resource "pcd_blockstorage_volume_backup" "example" {
7+
name = "tf-example-backup"
8+
volume_id = pcd_blockstorage_volume.example.id
9+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
terraform import pcd_blockstorage_volume_type.example <volume_type_id>
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
resource "pcd_blockstorage_volume_type" "example" {
2+
name = "tf-example-ssd"
3+
description = "SSD-backed storage tier"
4+
is_public = true
5+
6+
extra_specs = {
7+
volume_backend_name = "synology-iscsi"
8+
}
9+
}

internal/provider/provider.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,9 @@ func (p *pcdProvider) Resources(_ context.Context) []func() resource.Resource {
7676
compute.NewQuotasetResource,
7777
blockstorage.NewVolumeResource,
7878
blockstorage.NewQuotasetResource,
79+
blockstorage.NewVolumeTypeResource,
80+
blockstorage.NewSnapshotResource,
81+
blockstorage.NewBackupResource,
7982
loadbalancer.NewLoadBalancerResource,
8083
loadbalancer.NewListenerResource,
8184
loadbalancer.NewPoolResource,
Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
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_blockstorage_volume_backup_v3.go), adapted for
6+
// the terraform-plugin-framework and PCD.
7+
8+
package blockstorage
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/blockstorage/v3/backups"
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/booldefault"
23+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier"
24+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
25+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
26+
"github.com/hashicorp/terraform-plugin-framework/types"
27+
28+
"github.com/platform9/terraform-provider-pcd/internal/clients"
29+
)
30+
31+
var (
32+
_ resource.Resource = (*backupResource)(nil)
33+
_ resource.ResourceWithConfigure = (*backupResource)(nil)
34+
_ resource.ResourceWithImportState = (*backupResource)(nil)
35+
)
36+
37+
// NewBackupResource is the factory registered with the provider.
38+
func NewBackupResource() resource.Resource {
39+
return &backupResource{}
40+
}
41+
42+
type backupResource struct {
43+
config *clients.Config
44+
}
45+
46+
type backupModel struct {
47+
ID types.String `tfsdk:"id"`
48+
VolumeID types.String `tfsdk:"volume_id"`
49+
Name types.String `tfsdk:"name"`
50+
Description types.String `tfsdk:"description"`
51+
Force types.Bool `tfsdk:"force"`
52+
Incremental types.Bool `tfsdk:"incremental"`
53+
Container types.String `tfsdk:"container"`
54+
Size types.Int64 `tfsdk:"size"`
55+
Status types.String `tfsdk:"status"`
56+
Region types.String `tfsdk:"region"`
57+
}
58+
59+
func (r *backupResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
60+
resp.TypeName = req.ProviderTypeName + "_blockstorage_volume_backup"
61+
}
62+
63+
func (r *backupResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
64+
useState := []planmodifier.String{stringplanmodifier.UseStateForUnknown()}
65+
forceNew := []planmodifier.String{stringplanmodifier.RequiresReplace(), stringplanmodifier.UseStateForUnknown()}
66+
resp.Schema = schema.Schema{
67+
MarkdownDescription: "Manages a Cinder volume backup in PCD.",
68+
Attributes: map[string]schema.Attribute{
69+
"id": schema.StringAttribute{Computed: true, MarkdownDescription: "The backup ID.", PlanModifiers: useState},
70+
"volume_id": schema.StringAttribute{Required: true, MarkdownDescription: "The volume to back up. Changing this forces a new resource.", PlanModifiers: forceNew},
71+
"name": schema.StringAttribute{Optional: true, Computed: true, MarkdownDescription: "The name of the backup.", PlanModifiers: useState},
72+
"description": schema.StringAttribute{Optional: true, Computed: true, MarkdownDescription: "A description of the backup.", PlanModifiers: useState},
73+
"force": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool(false), MarkdownDescription: "Back up the volume even if it is attached/in-use. Changing this forces a new resource.", PlanModifiers: []planmodifier.Bool{boolplanmodifier.RequiresReplace()}},
74+
"incremental": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool(false), MarkdownDescription: "Whether to create an incremental backup. Changing this forces a new resource.", PlanModifiers: []planmodifier.Bool{boolplanmodifier.RequiresReplace()}},
75+
"container": schema.StringAttribute{Optional: true, Computed: true, MarkdownDescription: "The backup store container. Changing this forces a new resource.", PlanModifiers: forceNew},
76+
"size": schema.Int64Attribute{Computed: true, MarkdownDescription: "The size of the backup in GB."},
77+
"status": schema.StringAttribute{Computed: true, MarkdownDescription: "The Cinder status (e.g. available)."},
78+
"region": schema.StringAttribute{Optional: true, Computed: true, MarkdownDescription: "The region. Defaults to the provider's region.", PlanModifiers: useState},
79+
},
80+
}
81+
}
82+
83+
func (r *backupResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
84+
r.config = configureClient(req.ProviderData, &resp.Diagnostics)
85+
}
86+
87+
func (r *backupResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
88+
var plan backupModel
89+
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
90+
if resp.Diagnostics.HasError() {
91+
return
92+
}
93+
94+
client, err := r.config.BlockStorageV3Client()
95+
if err != nil {
96+
resp.Diagnostics.AddError("blockstorage: building v3 client", err.Error())
97+
return
98+
}
99+
100+
backup, err := backups.Create(ctx, client, backups.CreateOpts{
101+
VolumeID: plan.VolumeID.ValueString(),
102+
Name: plan.Name.ValueString(),
103+
Description: plan.Description.ValueString(),
104+
Force: plan.Force.ValueBool(),
105+
Incremental: plan.Incremental.ValueBool(),
106+
Container: plan.Container.ValueString(),
107+
}).Extract()
108+
if err != nil {
109+
resp.Diagnostics.AddError("blockstorage: creating volume backup", err.Error())
110+
return
111+
}
112+
113+
final, err := waitForBackupStatus(ctx, client, backup.ID, "available", 30*time.Minute)
114+
if err != nil {
115+
resp.Diagnostics.AddError("blockstorage: waiting for backup to become available", err.Error())
116+
return
117+
}
118+
119+
// Build state from the object the waiter already fetched rather than a second
120+
// Get, so a transient read failure can't orphan the created backup.
121+
resp.Diagnostics.Append(r.setState(&plan, final)...)
122+
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
123+
}
124+
125+
func (r *backupResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
126+
var state backupModel
127+
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
128+
if resp.Diagnostics.HasError() {
129+
return
130+
}
131+
132+
client, err := r.config.BlockStorageV3Client()
133+
if err != nil {
134+
resp.Diagnostics.AddError("blockstorage: building v3 client", err.Error())
135+
return
136+
}
137+
138+
notFound, diags := r.readIntoChecked(ctx, client, state.ID.ValueString(), &state)
139+
if notFound {
140+
resp.State.RemoveResource(ctx)
141+
return
142+
}
143+
resp.Diagnostics.Append(diags...)
144+
if resp.Diagnostics.HasError() {
145+
return
146+
}
147+
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
148+
}
149+
150+
func (r *backupResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
151+
var plan, state backupModel
152+
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
153+
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
154+
if resp.Diagnostics.HasError() {
155+
return
156+
}
157+
158+
client, err := r.config.BlockStorageV3Client()
159+
if err != nil {
160+
resp.Diagnostics.AddError("blockstorage: building v3 client", err.Error())
161+
return
162+
}
163+
164+
id := plan.ID.ValueString()
165+
if !plan.Name.Equal(state.Name) || !plan.Description.Equal(state.Description) {
166+
name := plan.Name.ValueString()
167+
desc := plan.Description.ValueString()
168+
if _, err := backups.Update(ctx, client, id, backups.UpdateOpts{Name: &name, Description: &desc}).Extract(); err != nil {
169+
resp.Diagnostics.AddError("blockstorage: updating volume backup", err.Error())
170+
return
171+
}
172+
}
173+
174+
resp.Diagnostics.Append(r.readInto(ctx, client, id, &plan)...)
175+
if resp.Diagnostics.HasError() {
176+
return
177+
}
178+
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
179+
}
180+
181+
func (r *backupResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
182+
var state backupModel
183+
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
184+
if resp.Diagnostics.HasError() {
185+
return
186+
}
187+
188+
client, err := r.config.BlockStorageV3Client()
189+
if err != nil {
190+
resp.Diagnostics.AddError("blockstorage: building v3 client", err.Error())
191+
return
192+
}
193+
194+
if err := backups.Delete(ctx, client, state.ID.ValueString()).ExtractErr(); err != nil {
195+
if gophercloud.ResponseCodeIs(err, http.StatusNotFound) {
196+
return
197+
}
198+
resp.Diagnostics.AddError("blockstorage: deleting volume backup", err.Error())
199+
return
200+
}
201+
if err := waitForBackupDeleted(ctx, client, state.ID.ValueString(), 20*time.Minute); err != nil {
202+
resp.Diagnostics.AddError("blockstorage: waiting for backup to delete", err.Error())
203+
}
204+
}
205+
206+
func (r *backupResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
207+
resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
208+
}
209+
210+
func (r *backupResource) readInto(ctx context.Context, client *gophercloud.ServiceClient, id string, m *backupModel) diag.Diagnostics {
211+
notFound, diags := r.readIntoChecked(ctx, client, id, m)
212+
if notFound {
213+
diags.AddError("blockstorage: reading volume backup", fmt.Sprintf("backup %s not found immediately after write", id))
214+
}
215+
return diags
216+
}
217+
218+
func (r *backupResource) readIntoChecked(ctx context.Context, client *gophercloud.ServiceClient, id string, m *backupModel) (notFound bool, diags diag.Diagnostics) {
219+
backup, err := backups.Get(ctx, client, id).Extract()
220+
if err != nil {
221+
if gophercloud.ResponseCodeIs(err, http.StatusNotFound) {
222+
return true, diags
223+
}
224+
diags.AddError("blockstorage: reading volume backup", err.Error())
225+
return false, diags
226+
}
227+
return false, r.setState(m, backup)
228+
}
229+
230+
// setState populates the model from a backup. force is create-only (never
231+
// returned by the API), so it is intentionally left untouched.
232+
func (r *backupResource) setState(m *backupModel, backup *backups.Backup) diag.Diagnostics {
233+
m.ID = types.StringValue(backup.ID)
234+
m.VolumeID = types.StringValue(backup.VolumeID)
235+
m.Name = types.StringValue(backup.Name)
236+
m.Description = types.StringValue(backup.Description)
237+
m.Container = types.StringValue(backup.Container)
238+
m.Incremental = types.BoolValue(backup.IsIncremental)
239+
m.Size = types.Int64Value(int64(backup.Size))
240+
m.Status = types.StringValue(backup.Status)
241+
if m.Region.IsNull() || m.Region.IsUnknown() {
242+
m.Region = types.StringValue(r.config.Region)
243+
}
244+
return nil
245+
}
246+
247+
func waitForBackupStatus(ctx context.Context, client *gophercloud.ServiceClient, id, target string, timeout time.Duration) (*backups.Backup, error) {
248+
deadline := time.Now().Add(timeout)
249+
for {
250+
backup, err := backups.Get(ctx, client, id).Extract()
251+
if err != nil {
252+
return nil, err
253+
}
254+
switch backup.Status {
255+
case target:
256+
return backup, nil
257+
case "error":
258+
return nil, fmt.Errorf("backup %s entered error state: %s", id, backup.FailReason)
259+
}
260+
if time.Now().After(deadline) {
261+
return nil, fmt.Errorf("timed out waiting for backup %s to reach %q (last status %q)", id, target, backup.Status)
262+
}
263+
select {
264+
case <-ctx.Done():
265+
return nil, ctx.Err()
266+
case <-time.After(3 * time.Second):
267+
}
268+
}
269+
}
270+
271+
func waitForBackupDeleted(ctx context.Context, client *gophercloud.ServiceClient, id string, timeout time.Duration) error {
272+
deadline := time.Now().Add(timeout)
273+
for {
274+
backup, err := backups.Get(ctx, client, id).Extract()
275+
if err != nil {
276+
if gophercloud.ResponseCodeIs(err, http.StatusNotFound) {
277+
return nil
278+
}
279+
return err
280+
}
281+
if backup.Status == "error_deleting" {
282+
return fmt.Errorf("backup %s entered error_deleting state", id)
283+
}
284+
if time.Now().After(deadline) {
285+
return fmt.Errorf("timed out waiting for backup %s to delete", id)
286+
}
287+
select {
288+
case <-ctx.Done():
289+
return ctx.Err()
290+
case <-time.After(3 * time.Second):
291+
}
292+
}
293+
}

0 commit comments

Comments
 (0)