Skip to content

Commit 88954f2

Browse files
authored
Merge pull request #49 from platform9/pushkar/pcd-9783-blueprint-destroy
fix(resmgr): terraform destroy deletes the cluster blueprint (PCD-9783)
2 parents 9c3a978 + 47725da commit 88954f2

6 files changed

Lines changed: 140 additions & 26 deletions

File tree

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,25 @@ All notable changes to this project are documented here. The format is based on
44
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
55
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## [0.1.10] - 2026-09-03
8+
9+
### Changed
10+
11+
- **`pcd_cluster_blueprint`: `terraform destroy` now deletes the blueprint from PCD** (PCD-9783).
12+
Destroy used to be a no-op that only dropped the resource from state, so Terraform reported the
13+
blueprint destroyed while it lived on in the region. It now issues
14+
`DELETE /resmgr/v2/blueprint/<name>`; a blueprint that is already gone is success, and any other
15+
refusal fails the destroy instead of being hidden. Destroy whatever depends on the blueprint first
16+
(a `pcd_host_config` referencing it through `cluster_name`, and the clusters and host roles built
17+
on it). To stop managing an imported blueprint without deleting it, use `terraform state rm`.
18+
19+
### Security
20+
21+
- Bumped the indirect `google.golang.org/grpc` dependency to 1.83.1 (GHSA-vp52-pcj8-j9qc: heap
22+
memory exhaustion via HTTP/2 DATA frame fragmentation). As with the previous gRPC advisory, the
23+
provider's gRPC server only ever serves the local Terraform CLI over a private channel, so
24+
exposure was minimal.
25+
726
## [0.1.9] - 2026-08-18
827

928
### Fixed

docs/resources/cluster_blueprint.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
33
page_title: "pcd_cluster_blueprint Resource - PCD"
44
subcategory: "Cluster Blueprint"
55
description: |-
6-
Manages a PCD cluster blueprint — the shared, declarative configuration that virtualized clusters inherit (networking, image library, VM storage, and Cinder backends). PCD supports a single blueprint per region, so the common workflow is to terraform import the existing blueprint and then manage it. Changing name forces a new resource. Destroying this resource only removes it from Terraform state — it does not delete the region's blueprint (use the PCD UI for that).
6+
Manages a PCD cluster blueprint — the shared, declarative configuration that virtualized clusters inherit (networking, image library, VM storage, and Cinder backends). PCD supports a single blueprint per region, so the common workflow is to terraform import the existing blueprint and then manage it. Changing name forces a new resource. Destroying this resource deletes the blueprint from PCD, so destroy whatever depends on it first (a pcd_host_config referencing it through cluster_name, and the clusters and host roles built on it). To stop managing an imported blueprint without deleting it, remove it from state with terraform state rm instead.
77
---
88

99
# pcd_cluster_blueprint (Resource)
1010

11-
Manages a PCD cluster blueprint — the shared, declarative configuration that virtualized clusters inherit (networking, image library, VM storage, and Cinder backends). PCD supports a single blueprint per region, so the common workflow is to `terraform import` the existing blueprint and then manage it. Changing `name` forces a new resource. Destroying this resource only removes it from Terraform state — it does not delete the region's blueprint (use the PCD UI for that).
11+
Manages a PCD cluster blueprint — the shared, declarative configuration that virtualized clusters inherit (networking, image library, VM storage, and Cinder backends). PCD supports a single blueprint per region, so the common workflow is to `terraform import` the existing blueprint and then manage it. Changing `name` forces a new resource. Destroying this resource deletes the blueprint from PCD, so destroy whatever depends on it first (a `pcd_host_config` referencing it through `cluster_name`, and the clusters and host roles built on it). To stop managing an imported blueprint without deleting it, remove it from state with `terraform state rm` instead.
1212

1313
## Example Usage
1414

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,8 @@ require (
7979
golang.org/x/text v0.40.0 // indirect
8080
golang.org/x/tools v0.47.0 // indirect
8181
google.golang.org/appengine v1.6.8 // indirect
82-
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
83-
google.golang.org/grpc v1.82.1 // indirect
82+
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
83+
google.golang.org/grpc v1.83.1 // indirect
8484
google.golang.org/protobuf v1.36.11 // indirect
8585
gopkg.in/yaml.v2 v2.4.0 // indirect
8686
)

go.sum

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -216,16 +216,16 @@ go.abhg.dev/goldmark/frontmatter v0.2.0 h1:P8kPG0YkL12+aYk2yU3xHv4tcXzeVnN+gU0tJ
216216
go.abhg.dev/goldmark/frontmatter v0.2.0/go.mod h1:XqrEkZuM57djk7zrlRUB02x8I5J0px76YjkOzhB4YlU=
217217
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
218218
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
219-
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
220-
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
221-
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
222-
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
223-
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
224-
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
225-
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
226-
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
227-
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
228-
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
219+
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
220+
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
221+
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
222+
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
223+
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
224+
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
225+
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
226+
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
227+
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
228+
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
229229
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
230230
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
231231
golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
@@ -284,10 +284,10 @@ gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E
284284
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
285285
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
286286
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
287-
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
288-
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
289-
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
290-
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
287+
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
288+
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
289+
google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y=
290+
google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
291291
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
292292
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
293293
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Copyright (c) Platform9 Systems, Inc.
2+
// SPDX-License-Identifier: MPL-2.0
3+
4+
package resmgr
5+
6+
import (
7+
"context"
8+
"net/http"
9+
"net/http/httptest"
10+
"testing"
11+
12+
"github.com/gophercloud/gophercloud/v2"
13+
)
14+
15+
// A `terraform destroy` of a pcd_cluster_blueprint used to remove it from state
16+
// without ever calling resmgr, so the blueprint outlived the destroy (PCD-9783).
17+
// The delete has to reach DELETE /resmgr/v2/blueprint/<name>.
18+
func TestDeleteBlueprintCallsTheAPI(t *testing.T) {
19+
var method, path string
20+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
21+
method, path = r.Method, r.URL.Path
22+
w.WriteHeader(http.StatusNoContent)
23+
}))
24+
defer srv.Close()
25+
client := &gophercloud.ServiceClient{ProviderClient: &gophercloud.ProviderClient{}, Endpoint: srv.URL + "/"}
26+
27+
if err := deleteBlueprint(context.Background(), client, "ts-bp"); err != nil {
28+
t.Fatalf("unexpected error: %v", err)
29+
}
30+
if method != http.MethodDelete || path != "/blueprint/ts-bp" {
31+
t.Fatalf("got %s %s, want DELETE /blueprint/ts-bp", method, path)
32+
}
33+
}
34+
35+
func TestDeleteBlueprintStatusHandling(t *testing.T) {
36+
for _, tc := range []struct {
37+
name string
38+
status int
39+
wantErr bool
40+
}{
41+
{name: "200 is success", status: http.StatusOK},
42+
{name: "202 is success", status: http.StatusAccepted},
43+
{name: "204 is success", status: http.StatusNoContent},
44+
// Already gone is the outcome destroy wants; it must not fail the destroy.
45+
{name: "404 is already gone", status: http.StatusNotFound},
46+
// Anything else means the blueprint is still there, and a destroy that
47+
// reports success over it is the very bug being fixed.
48+
{name: "409 is an error", status: http.StatusConflict, wantErr: true},
49+
{name: "500 is an error", status: http.StatusInternalServerError, wantErr: true},
50+
} {
51+
t.Run(tc.name, func(t *testing.T) {
52+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
53+
w.WriteHeader(tc.status)
54+
}))
55+
defer srv.Close()
56+
client := &gophercloud.ServiceClient{ProviderClient: &gophercloud.ProviderClient{}, Endpoint: srv.URL + "/"}
57+
58+
err := deleteBlueprint(context.Background(), client, "ts-bp")
59+
if tc.wantErr && err == nil {
60+
t.Fatalf("status %d: got no error; destroy would report success over a blueprint that still exists", tc.status)
61+
}
62+
if !tc.wantErr && err != nil {
63+
t.Fatalf("status %d: unexpected error: %v", tc.status, err)
64+
}
65+
})
66+
}
67+
}

internal/services/resmgr/blueprint_resource.go

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,10 @@ func (r *blueprintResource) Schema(_ context.Context, _ resource.SchemaRequest,
6464
MarkdownDescription: "Manages a PCD cluster blueprint — the shared, declarative configuration that virtualized " +
6565
"clusters inherit (networking, image library, VM storage, and Cinder backends). PCD supports a single " +
6666
"blueprint per region, so the common workflow is to `terraform import` the existing blueprint and then " +
67-
"manage it. Changing `name` forces a new resource. Destroying this resource only removes it from " +
68-
"Terraform state — it does not delete the region's blueprint (use the PCD UI for that).",
67+
"manage it. Changing `name` forces a new resource. Destroying this resource deletes the blueprint from " +
68+
"PCD, so destroy whatever depends on it first (a `pcd_host_config` referencing it through `cluster_name`, " +
69+
"and the clusters and host roles built on it). To stop managing an imported blueprint without deleting " +
70+
"it, remove it from state with `terraform state rm` instead.",
6971
Attributes: map[string]schema.Attribute{
7072
"name": schema.StringAttribute{Required: true, MarkdownDescription: "The blueprint (cluster) name. Changing this forces a new resource.", PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}},
7173
// networking_type and enable_distributed_routing are not user-configurable
@@ -266,12 +268,38 @@ func knownStr(v types.String) bool { return !v.IsNull() && !v.IsUnknown() }
266268
func knownBool(v types.Bool) bool { return !v.IsNull() && !v.IsUnknown() }
267269
func knownObj(v types.Object) bool { return !v.IsNull() && !v.IsUnknown() }
268270

269-
// Delete removes the blueprint from Terraform state WITHOUT calling the API. PCD
270-
// supports a single blueprint per region and it is normally imported, so a
271-
// `terraform destroy` should stop managing it rather than physically delete the
272-
// region's cluster-defining blueprint (which every host and cluster depends on).
273-
// Remove it from the PCD UI if you truly need to delete it.
274-
func (r *blueprintResource) Delete(_ context.Context, _ resource.DeleteRequest, _ *resource.DeleteResponse) {
271+
// Delete removes the blueprint from PCD. It used to be a no-op that only dropped
272+
// the resource from state, so a `terraform destroy` reported success while the
273+
// blueprint lived on (PCD-9783). A blueprint that is already gone is the outcome
274+
// destroy wants, so a 404 is success; anything else leaves it standing and is
275+
// surfaced. To stop managing an imported blueprint without deleting it, use
276+
// `terraform state rm` rather than destroy.
277+
func (r *blueprintResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
278+
var state blueprintResourceModel
279+
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
280+
if resp.Diagnostics.HasError() {
281+
return
282+
}
283+
284+
client, err := r.config.ResmgrV2Client()
285+
if err != nil {
286+
resp.Diagnostics.AddError("resmgr: building client", err.Error())
287+
return
288+
}
289+
290+
if err := deleteBlueprint(ctx, client, state.Name.ValueString()); err != nil {
291+
resp.Diagnostics.AddError("resmgr: deleting blueprint", err.Error())
292+
}
293+
}
294+
295+
// deleteBlueprint issues DELETE /resmgr/v2/blueprint/<name>. A 404 means the
296+
// blueprint is already gone and is reported as success.
297+
func deleteBlueprint(ctx context.Context, client *gophercloud.ServiceClient, name string) error {
298+
_, err := client.Delete(ctx, client.ServiceURL("blueprint", name), &gophercloud.RequestOpts{OkCodes: []int{200, 202, 204}})
299+
if err != nil && isNotFound(err) {
300+
return nil
301+
}
302+
return err
275303
}
276304

277305
func (r *blueprintResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {

0 commit comments

Comments
 (0)