Skip to content

Commit a027346

Browse files
authored
Merge pull request #20 from platform9/feat/quotas
feat(quotas): compute / network / blockstorage project quota resources
2 parents 7b9ad11 + d015c25 commit a027346

14 files changed

Lines changed: 1184 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,12 @@ All notable changes to this project are documented here. The format is based on
7171
rule types — `pcd_networking_qos_bandwidth_limit_rule`, `pcd_networking_qos_dscp_marking_rule`,
7272
`pcd_networking_qos_minimum_bandwidth_rule` — plus a `pcd_networking_qos_policy` data source.
7373
Rules are nested under a policy and imported by a composite `<qos_policy_id>/<rule_id>` ID.
74+
- Project quotas — Phase 3: `pcd_compute_quotaset` (Nova), `pcd_networking_quota` (Neutron), and
75+
`pcd_blockstorage_quotaset` (Cinder). Each manages the per-project quota limits for its service;
76+
only the fields you set are managed (omitted fields keep their server value), and destroying the
77+
resource stops managing the quotas without resetting them to defaults (matching the upstream
78+
provider). Imported by a composite `<project_id>/<region>` ID (legacy bare `<project_id>` is
79+
also accepted). Cinder per-volume-type quotas (`volume_type_quota`) are not yet implemented.
7480

7581
- Registry documentation generation wired via `tfplugindocs` (`make generate`) — renders
7682
`docs/` for every resource and data source plus the provider index from schema
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
terraform import pcd_blockstorage_quotaset.example <project_id>/<region>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
data "pcd_identity_project" "demo" {
2+
name = "demo"
3+
}
4+
5+
resource "pcd_blockstorage_quotaset" "example" {
6+
project_id = data.pcd_identity_project.demo.id
7+
volumes = 50
8+
snapshots = 50
9+
gigabytes = 1000
10+
backups = 20
11+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
terraform import pcd_compute_quotaset.example <project_id>/<region>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
data "pcd_identity_project" "demo" {
2+
name = "demo"
3+
}
4+
5+
resource "pcd_compute_quotaset" "example" {
6+
project_id = data.pcd_identity_project.demo.id
7+
cores = 32
8+
ram = 65536
9+
instances = 20
10+
key_pairs = 10
11+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
terraform import pcd_networking_quota.example <project_id>/<region>
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
data "pcd_identity_project" "demo" {
2+
name = "demo"
3+
}
4+
5+
resource "pcd_networking_quota" "example" {
6+
project_id = data.pcd_identity_project.demo.id
7+
network = 20
8+
port = 200
9+
router = 10
10+
subnet = 20
11+
security_group = 20
12+
floatingip = 50
13+
}

internal/provider/provider.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,16 @@ func (p *pcdProvider) Resources(_ context.Context) []func() resource.Resource {
6666
networking.NewQoSBandwidthLimitRuleResource,
6767
networking.NewQoSDSCPMarkingRuleResource,
6868
networking.NewQoSMinimumBandwidthRuleResource,
69+
networking.NewQuotaResource,
6970
compute.NewKeypairResource,
7071
compute.NewInstanceResource,
7172
compute.NewFlavorResource,
7273
compute.NewServergroupResource,
7374
compute.NewInterfaceAttachResource,
7475
compute.NewVolumeAttachResource,
76+
compute.NewQuotasetResource,
7577
blockstorage.NewVolumeResource,
78+
blockstorage.NewQuotasetResource,
7679
loadbalancer.NewLoadBalancerResource,
7780
loadbalancer.NewListenerResource,
7881
loadbalancer.NewPoolResource,
Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
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_quotaset_v3.go), adapted for the
6+
// terraform-plugin-framework and PCD. The upstream resource also exposes a
7+
// per-volume-type quota map (volume_type_quota); that is deliberately deferred
8+
// (see DECISIONS.md) and this resource manages the scalar quotas only.
9+
10+
package blockstorage
11+
12+
import (
13+
"context"
14+
"fmt"
15+
"net/http"
16+
"strings"
17+
18+
"github.com/gophercloud/gophercloud/v2"
19+
"github.com/gophercloud/gophercloud/v2/openstack/blockstorage/v3/quotasets"
20+
"github.com/hashicorp/terraform-plugin-framework/diag"
21+
"github.com/hashicorp/terraform-plugin-framework/path"
22+
"github.com/hashicorp/terraform-plugin-framework/resource"
23+
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
24+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier"
25+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
26+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
27+
"github.com/hashicorp/terraform-plugin-framework/types"
28+
29+
"github.com/platform9/terraform-provider-pcd/internal/clients"
30+
)
31+
32+
var (
33+
_ resource.Resource = (*quotasetResource)(nil)
34+
_ resource.ResourceWithConfigure = (*quotasetResource)(nil)
35+
_ resource.ResourceWithImportState = (*quotasetResource)(nil)
36+
)
37+
38+
// NewQuotasetResource is the factory registered with the provider.
39+
func NewQuotasetResource() resource.Resource {
40+
return &quotasetResource{}
41+
}
42+
43+
type quotasetResource struct {
44+
config *clients.Config
45+
}
46+
47+
type quotasetModel struct {
48+
ID types.String `tfsdk:"id"`
49+
ProjectID types.String `tfsdk:"project_id"`
50+
Region types.String `tfsdk:"region"`
51+
Volumes types.Int64 `tfsdk:"volumes"`
52+
Snapshots types.Int64 `tfsdk:"snapshots"`
53+
Gigabytes types.Int64 `tfsdk:"gigabytes"`
54+
PerVolumeGigabytes types.Int64 `tfsdk:"per_volume_gigabytes"`
55+
Backups types.Int64 `tfsdk:"backups"`
56+
BackupGigabytes types.Int64 `tfsdk:"backup_gigabytes"`
57+
Groups types.Int64 `tfsdk:"groups"`
58+
}
59+
60+
func (r *quotasetResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
61+
resp.TypeName = req.ProviderTypeName + "_blockstorage_quotaset"
62+
}
63+
64+
// quotaIntAttr builds the schema for a single Optional+Computed quota field. The
65+
// server echoes every field back, so each is Computed; UseStateForUnknown keeps
66+
// fields the user does not manage stable instead of churning on every plan.
67+
func quotaIntAttr(desc string) schema.Int64Attribute {
68+
return schema.Int64Attribute{
69+
Optional: true,
70+
Computed: true,
71+
MarkdownDescription: desc,
72+
PlanModifiers: []planmodifier.Int64{int64planmodifier.UseStateForUnknown()},
73+
}
74+
}
75+
76+
func (r *quotasetResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
77+
resp.Schema = schema.Schema{
78+
MarkdownDescription: "Manages the Cinder (block storage) quotas for a project. Only the quota fields you set " +
79+
"are managed; fields you omit keep their server value. Destroying this resource stops managing the quotas " +
80+
"but does not reset them to their defaults.",
81+
Attributes: map[string]schema.Attribute{
82+
"id": schema.StringAttribute{Computed: true, MarkdownDescription: "The composite `<project_id>/<region>` ID.", PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}},
83+
"project_id": schema.StringAttribute{
84+
Required: true,
85+
MarkdownDescription: "The project (tenant) the quotas apply to. Changing this forces a new resource.",
86+
PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()},
87+
},
88+
"region": schema.StringAttribute{
89+
Optional: true,
90+
Computed: true,
91+
MarkdownDescription: "The region. Defaults to the provider's region. Changing this forces a new resource.",
92+
PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace(), stringplanmodifier.UseStateForUnknown()},
93+
},
94+
"volumes": quotaIntAttr("Quota for the number of volumes."),
95+
"snapshots": quotaIntAttr("Quota for the number of snapshots."),
96+
"gigabytes": quotaIntAttr("Quota for total volume storage in gigabytes."),
97+
"per_volume_gigabytes": quotaIntAttr("Quota for the size of a single volume in gigabytes."),
98+
"backups": quotaIntAttr("Quota for the number of backups."),
99+
"backup_gigabytes": quotaIntAttr("Quota for total backup storage in gigabytes."),
100+
"groups": quotaIntAttr("Quota for the number of volume groups."),
101+
},
102+
}
103+
}
104+
105+
func (r *quotasetResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
106+
r.config = configureClient(req.ProviderData, &resp.Diagnostics)
107+
}
108+
109+
// quotaPtrIfSet returns a *int for a known, non-null value, or nil so the field
110+
// is omitted from the request (leaving the server value unchanged).
111+
func quotaPtrIfSet(v types.Int64) *int {
112+
if v.IsNull() || v.IsUnknown() {
113+
return nil
114+
}
115+
i := int(v.ValueInt64())
116+
return &i
117+
}
118+
119+
// quotaPtrIfChanged returns a *int only when the planned value differs from
120+
// state (and is known), so an in-place update sends only changed fields.
121+
func quotaPtrIfChanged(plan, state types.Int64) *int {
122+
if plan.IsUnknown() || plan.Equal(state) {
123+
return nil
124+
}
125+
i := int(plan.ValueInt64())
126+
return &i
127+
}
128+
129+
func (r *quotasetResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
130+
var plan quotasetModel
131+
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
132+
if resp.Diagnostics.HasError() {
133+
return
134+
}
135+
136+
client, err := r.config.BlockStorageV3Client()
137+
if err != nil {
138+
resp.Diagnostics.AddError("blockstorage: building v3 client", err.Error())
139+
return
140+
}
141+
142+
region := plan.Region.ValueString()
143+
if plan.Region.IsNull() || plan.Region.IsUnknown() {
144+
region = r.config.Region
145+
}
146+
projectID := plan.ProjectID.ValueString()
147+
148+
opts := quotasets.UpdateOpts{
149+
Volumes: quotaPtrIfSet(plan.Volumes),
150+
Snapshots: quotaPtrIfSet(plan.Snapshots),
151+
Gigabytes: quotaPtrIfSet(plan.Gigabytes),
152+
PerVolumeGigabytes: quotaPtrIfSet(plan.PerVolumeGigabytes),
153+
Backups: quotaPtrIfSet(plan.Backups),
154+
BackupGigabytes: quotaPtrIfSet(plan.BackupGigabytes),
155+
Groups: quotaPtrIfSet(plan.Groups),
156+
}
157+
158+
if _, err := quotasets.Update(ctx, client, projectID, opts).Extract(); err != nil {
159+
resp.Diagnostics.AddError("blockstorage: setting quotas", err.Error())
160+
return
161+
}
162+
163+
resp.Diagnostics.Append(r.readInto(ctx, client, projectID, region, &plan)...)
164+
if resp.Diagnostics.HasError() {
165+
return
166+
}
167+
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
168+
}
169+
170+
func (r *quotasetResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
171+
var state quotasetModel
172+
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
173+
if resp.Diagnostics.HasError() {
174+
return
175+
}
176+
177+
client, err := r.config.BlockStorageV3Client()
178+
if err != nil {
179+
resp.Diagnostics.AddError("blockstorage: building v3 client", err.Error())
180+
return
181+
}
182+
183+
region := state.Region.ValueString()
184+
if state.Region.IsNull() || state.Region.IsUnknown() {
185+
region = r.config.Region
186+
}
187+
qs, err := quotasets.Get(ctx, client, state.ProjectID.ValueString()).Extract()
188+
if err != nil {
189+
if gophercloud.ResponseCodeIs(err, http.StatusNotFound) {
190+
resp.State.RemoveResource(ctx)
191+
return
192+
}
193+
resp.Diagnostics.AddError("blockstorage: reading quotas", err.Error())
194+
return
195+
}
196+
197+
setQuotaState(&state, region, qs)
198+
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
199+
}
200+
201+
func (r *quotasetResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
202+
var plan, state quotasetModel
203+
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
204+
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
205+
if resp.Diagnostics.HasError() {
206+
return
207+
}
208+
209+
client, err := r.config.BlockStorageV3Client()
210+
if err != nil {
211+
resp.Diagnostics.AddError("blockstorage: building v3 client", err.Error())
212+
return
213+
}
214+
215+
region := state.Region.ValueString()
216+
if state.Region.IsNull() || state.Region.IsUnknown() {
217+
region = r.config.Region
218+
}
219+
projectID := state.ProjectID.ValueString()
220+
221+
opts := quotasets.UpdateOpts{
222+
Volumes: quotaPtrIfChanged(plan.Volumes, state.Volumes),
223+
Snapshots: quotaPtrIfChanged(plan.Snapshots, state.Snapshots),
224+
Gigabytes: quotaPtrIfChanged(plan.Gigabytes, state.Gigabytes),
225+
PerVolumeGigabytes: quotaPtrIfChanged(plan.PerVolumeGigabytes, state.PerVolumeGigabytes),
226+
Backups: quotaPtrIfChanged(plan.Backups, state.Backups),
227+
BackupGigabytes: quotaPtrIfChanged(plan.BackupGigabytes, state.BackupGigabytes),
228+
Groups: quotaPtrIfChanged(plan.Groups, state.Groups),
229+
}
230+
231+
if _, err := quotasets.Update(ctx, client, projectID, opts).Extract(); err != nil {
232+
resp.Diagnostics.AddError("blockstorage: updating quotas", err.Error())
233+
return
234+
}
235+
236+
resp.Diagnostics.Append(r.readInto(ctx, client, projectID, region, &plan)...)
237+
if resp.Diagnostics.HasError() {
238+
return
239+
}
240+
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
241+
}
242+
243+
// Delete intentionally makes no API call. Following the upstream provider, the
244+
// resource is removed from state without resetting the project's quotas to
245+
// their default values.
246+
func (r *quotasetResource) Delete(_ context.Context, _ resource.DeleteRequest, _ *resource.DeleteResponse) {
247+
}
248+
249+
func (r *quotasetResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
250+
parts := strings.SplitN(req.ID, "/", 2)
251+
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), parts[0])...)
252+
if len(parts) == 2 && parts[1] != "" {
253+
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("region"), parts[1])...)
254+
}
255+
}
256+
257+
func (r *quotasetResource) readInto(ctx context.Context, client *gophercloud.ServiceClient, projectID, region string, m *quotasetModel) diag.Diagnostics {
258+
var diags diag.Diagnostics
259+
qs, err := quotasets.Get(ctx, client, projectID).Extract()
260+
if err != nil {
261+
diags.AddError("blockstorage: reading quotas", err.Error())
262+
return diags
263+
}
264+
setQuotaState(m, region, qs)
265+
return diags
266+
}
267+
268+
func setQuotaState(m *quotasetModel, region string, qs *quotasets.QuotaSet) {
269+
m.ID = types.StringValue(fmt.Sprintf("%s/%s", m.ProjectID.ValueString(), region))
270+
m.Region = types.StringValue(region)
271+
m.Volumes = types.Int64Value(int64(qs.Volumes))
272+
m.Snapshots = types.Int64Value(int64(qs.Snapshots))
273+
m.Gigabytes = types.Int64Value(int64(qs.Gigabytes))
274+
m.PerVolumeGigabytes = types.Int64Value(int64(qs.PerVolumeGigabytes))
275+
m.Backups = types.Int64Value(int64(qs.Backups))
276+
m.BackupGigabytes = types.Int64Value(int64(qs.BackupGigabytes))
277+
m.Groups = types.Int64Value(int64(qs.Groups))
278+
}

0 commit comments

Comments
 (0)