Skip to content

Commit f4e66ea

Browse files
authored
Merge pull request #20 from platform9/feat/quotas
feat(quotas): compute / network / blockstorage project quota resources
2 parents d112b8a + 476ea44 commit f4e66ea

15 files changed

Lines changed: 1207 additions & 1 deletion

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

DECISIONS.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,34 @@ yet passable on this lab (reason noted). Generated registry docs are not committ
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). |
4141
| 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). |
42-
| Network QoS (Neutron) | `pcd_networking_qos_policy`, `_qos_bandwidth_limit_rule`, `_qos_dscp_marking_rule`, `_qos_minimum_bandwidth_rule` + `_qos_policy` DS | **PENDING** — Phase 3, code-complete; rules nested under a policy with composite `<policy_id>/<rule_id>` import, tags via the attributes-tags extension (`policies` type), `ForceNew` on `qos_policy_id`. Full-tree acc test (policy + all three rules + data source + import) + examples written. Depends only on the Neutron `qos` extension (no compute/storage backend), so this should pass live — not yet run this session (credentials unavailable). |
42+
| Network QoS (Neutron) | `pcd_networking_qos_policy`, `_qos_bandwidth_limit_rule`, `_qos_dscp_marking_rule`, `_qos_minimum_bandwidth_rule` + `_qos_policy` DS | **PENDING** — Phase 3, code-complete; rules nested under a policy with composite `<policy_id>/<rule_id>` import, tags via the attributes-tags extension (`qos/policies` type), `ForceNew` on `qos_policy_id`. Full-tree acc test (policy + all three rules + data source + import) + examples written. Depends only on the Neutron `qos` extension (no compute/storage backend), so this should pass live — not yet run this session (credentials unavailable). |
43+
| Project quotas | `pcd_compute_quotaset` (Nova), `pcd_networking_quota` (Neutron), `pcd_blockstorage_quotaset` (Cinder) | **PENDING** — Phase 3, code-complete; every quota field `Optional+Computed` with `UseStateForUnknown` (partial management — only user-set/changed fields are PUT via `*int` omitempty; server echoes the rest). No create API (Create = Update+read). **Delete is a deliberate no-op** (matches upstream `RemoveFromState`: destroying stops management without resetting quotas). Composite `<project_id>/<region>` id with legacy bare-`project_id` import tolerance; `project_id`/`region` are `ForceNew`. Per-service acc test (create project → set quotas → verify via API → update → import) + examples written. Needs live validation on the fresh CE lab (credentials unavailable this session). **Scope note:** matches upstream field-for-field except two deliberate deferrals — see the Deferred section. |
4344

4445
Both PENDING items are lab-side configuration gaps (Platform9 / lab-ops), not provider
4546
defects; their acceptance tests flip green on a properly-configured PCD cloud.
4647

4748

49+
## 2026-07-12 — quotas: scope and deferrals
50+
51+
The three quota resources match the upstream terraform-provider-openstack resources
52+
field-for-field, with three deliberate choices worth recording:
53+
54+
- **Delete is a no-op.** Upstream's quota resources use `schema.RemoveFromState` and make
55+
no API call on destroy; the project keeps whatever quota values it had. We match this
56+
(the framework `Delete` returns without calling the API). Rationale: quotas are a
57+
property of a pre-existing project, not an object the provider created, so "un-managing"
58+
them should not silently reset a project's limits to defaults. gophercloud exposes a
59+
`Delete` (reset-to-defaults) on all three packages; we intentionally do not call it.
60+
- **`volume_type_quota` deferred (Cinder).** The upstream `blockstorage_quotaset_v3`
61+
exposes per-volume-type quotas as a string→string map routed through gophercloud's
62+
`UpdateOpts.Extra`, with echo-on-write and read-back-only-for-configured-keys to avoid
63+
perpetual diffs. This is the single most bug-prone part of the port; it is deferred to a
64+
follow-up so the scalar quotaset ships clean. `pcd_blockstorage_quotaset` manages the
65+
scalar Cinder quotas only for now.
66+
- **Upstream-omitted gophercloud fields left out.** Nova's `force` update flag and
67+
Neutron's `trunk` quota exist in gophercloud but are not in the upstream schemas; we omit
68+
them to match upstream exactly. Easy to add later if wanted.
69+
4870
## 2026-07-11 — blockstorage: no Cinder storage backend on the CE lab
4971

5072
Volume creation on the CE lab goes `creating → error` immediately: the cluster
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)