Skip to content

Commit 8cba4fe

Browse files
authored
Merge pull request #46 from platform9/pushkar/hostconfig-strand-guard
Refuse to delete a host config a host is still assigned to
2 parents 9ff0b64 + b4e7e70 commit 8cba4fe

6 files changed

Lines changed: 240 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,21 @@ All notable changes to this project are documented here. The format is based on
66

77
## [Unreleased]
88

9+
## [0.1.8] - 2026-08-18
10+
11+
### Changed
12+
13+
- `pcd_host_config`: destroying one is now refused while any host is still assigned to it, and the
14+
check is fail-closed — if resmgr will not answer, the delete does not proceed. PCD accepts that
15+
delete, keeps the assignment, and from then on refuses to remove it (404) and refuses every
16+
re-assignment (409 `HostToHostconfigConflict`); deleting the host record does not clear it and the
17+
id cannot be re-created, so the host can never be assigned a host configuration — never onboarded —
18+
again. Remove the `pcd_host_config_assignment` first. The guard will be lifted once resmgr refuses
19+
the unsafe delete itself.
20+
- `pcd_host_config_assignment`: destroying one now confirms the host has actually stopped reporting
21+
the host configuration instead of trusting the `204`, which resmgr returns whether or not it
22+
unbound anything. A binding Terraform believed was gone is what left a host strandable.
23+
924
## [0.1.7] - 2026-08-18
1025

1126
### Fixed

docs/resources/host_config.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
33
page_title: "pcd_host_config Resource - PCD"
44
subcategory: "Cluster Blueprint"
55
description: |-
6-
Manages a PCD host configuration: the mapping of traffic types (management, VM console, tunnels, image library, live migration, host liveness) to network interfaces, plus physical-network labels.
6+
Manages a PCD host configuration: the mapping of traffic types (management, VM console, Destroying one is refused while any host is still assigned to it: PCD leaves such a host unable to be assigned a host configuration ever again, so remove the pcd_host_config_assignment first.tunnels, image library, live migration, host liveness) to network interfaces, plus physical-network labels.
77
---
88

99
# pcd_host_config (Resource)
1010

11-
Manages a PCD host configuration: the mapping of traffic types (management, VM console, tunnels, image library, live migration, host liveness) to network interfaces, plus physical-network labels.
11+
Manages a PCD host configuration: the mapping of traffic types (management, VM console, Destroying one is refused while any host is still assigned to it: PCD leaves such a host unable to be assigned a host configuration ever again, so remove the `pcd_host_config_assignment` first.tunnels, image library, live migration, host liveness) to network interfaces, plus physical-network labels.
1212

1313
## Example Usage
1414

internal/services/resmgr/absence_internal_test.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ import (
77
"context"
88
"net/http"
99
"net/http/httptest"
10+
"strings"
1011
"testing"
12+
"time"
1113

1214
"github.com/gophercloud/gophercloud/v2"
1315
)
@@ -87,3 +89,90 @@ func TestIsNullJSON(t *testing.T) {
8789
}
8890
}
8991
}
92+
93+
// hostList serves a resmgr host list, switching to `then` after the first request so a
94+
// test can watch a binding clear.
95+
func hostList(t *testing.T, first, then string) *gophercloud.ServiceClient {
96+
t.Helper()
97+
var n int
98+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
99+
body := first
100+
if n > 0 && then != "" {
101+
body = then
102+
}
103+
n++
104+
w.Header().Set("Content-Type", "application/json")
105+
_, _ = w.Write([]byte(body))
106+
}))
107+
t.Cleanup(srv.Close)
108+
return &gophercloud.ServiceClient{ProviderClient: &gophercloud.ProviderClient{}, Endpoint: srv.URL + "/"}
109+
}
110+
111+
const twoHosts = `[{"id":"host-a","hostconfig_id":"hc-1"},{"id":"host-b","hostconfig_id":"hc-2"}]`
112+
113+
func TestHostsAssignedTo(t *testing.T) {
114+
for _, tc := range []struct {
115+
name, body, hostConfig string
116+
want []string
117+
}{
118+
{name: "one host carries it", body: twoHosts, hostConfig: "hc-1", want: []string{"host-a"}},
119+
{name: "nobody carries it", body: twoHosts, hostConfig: "hc-9"},
120+
{name: "an empty region", body: `[]`, hostConfig: "hc-1"},
121+
// getJSON reads a null body as absence; for a collection it is simply empty, and
122+
// mistaking the two here would report a bound host config as safe to delete.
123+
{name: "a null list is empty, not an error", body: `null`, hostConfig: "hc-1"},
124+
} {
125+
t.Run(tc.name, func(t *testing.T) {
126+
got, err := hostsAssignedTo(context.Background(), hostList(t, tc.body, ""), tc.hostConfig)
127+
if err != nil {
128+
t.Fatalf("unexpected error: %v", err)
129+
}
130+
if len(got) != len(tc.want) {
131+
t.Fatalf("got %v, want %v", got, tc.want)
132+
}
133+
for i := range got {
134+
if got[i] != tc.want[i] {
135+
t.Fatalf("got %v, want %v", got, tc.want)
136+
}
137+
}
138+
})
139+
}
140+
}
141+
142+
func TestHostsAssignedToSurfacesAFailedCheck(t *testing.T) {
143+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
144+
w.WriteHeader(http.StatusInternalServerError)
145+
}))
146+
defer srv.Close()
147+
client := &gophercloud.ServiceClient{ProviderClient: &gophercloud.ProviderClient{}, Endpoint: srv.URL + "/"}
148+
// The delete guard is fail-closed, so this error has to reach it rather than read as
149+
// "no hosts are assigned".
150+
if _, err := hostsAssignedTo(context.Background(), client, "hc-1"); err == nil {
151+
t.Fatal("got no error from a resmgr that would not answer; the guard would have deleted")
152+
}
153+
}
154+
155+
func TestWaitUnassigned(t *testing.T) {
156+
interval, timeout := unassignPollInterval, unassignPollTimeout
157+
unassignPollInterval, unassignPollTimeout = time.Millisecond, 50*time.Millisecond
158+
defer func() { unassignPollInterval, unassignPollTimeout = interval, timeout }()
159+
160+
t.Run("returns once the binding clears", func(t *testing.T) {
161+
client := hostList(t, twoHosts, `[{"id":"host-b","hostconfig_id":"hc-2"}]`)
162+
if err := waitUnassigned(context.Background(), client, "host-a", "hc-1"); err != nil {
163+
t.Fatalf("unexpected error: %v", err)
164+
}
165+
})
166+
167+
// resmgr answers the unassign 204 without necessarily doing it; believing that is what
168+
// leaves a binding behind for a later host-config delete to make permanent.
169+
t.Run("fails when resmgr never applies it", func(t *testing.T) {
170+
err := waitUnassigned(context.Background(), hostList(t, twoHosts, ""), "host-a", "hc-1")
171+
if err == nil {
172+
t.Fatal("got no error from a binding that never cleared")
173+
}
174+
if !strings.Contains(err.Error(), "host-a") || !strings.Contains(err.Error(), "hc-1") {
175+
t.Fatalf("error does not say what is still bound: %v", err)
176+
}
177+
})
178+
}

internal/services/resmgr/host_config_assignment_resource.go

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -134,12 +134,17 @@ func (r *hostConfigAssignmentResource) Delete(ctx context.Context, req resource.
134134
return
135135
}
136136

137-
url := client.ServiceURL("hosts", state.HostID.ValueString(), "hostconfig", state.HostConfigID.ValueString())
138-
if _, err := client.Delete(ctx, url, &gophercloud.RequestOpts{OkCodes: []int{200, 202, 204}}); err != nil {
139-
if isNotFound(err) {
140-
return
141-
}
137+
hostID, hostConfigID := state.HostID.ValueString(), state.HostConfigID.ValueString()
138+
url := client.ServiceURL("hosts", hostID, "hostconfig", hostConfigID)
139+
if _, err := client.Delete(ctx, url, &gophercloud.RequestOpts{OkCodes: []int{200, 202, 204}}); err != nil && !isNotFound(err) {
142140
resp.Diagnostics.AddError("resmgr: unassigning host config", err.Error())
141+
return
142+
}
143+
// A 204 here does not mean the binding is gone — resmgr answers the same whether or
144+
// not it unbound anything — so confirm against the host list before reporting the
145+
// assignment destroyed.
146+
if err := waitUnassigned(ctx, client, hostID, hostConfigID); err != nil {
147+
resp.Diagnostics.AddError("resmgr: host config still assigned", err.Error())
143148
}
144149
}
145150

internal/services/resmgr/host_config_resource.go

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ package resmgr
55

66
import (
77
"context"
8+
"fmt"
9+
"strings"
810

911
"github.com/gophercloud/gophercloud/v2"
1012
"github.com/hashicorp/terraform-plugin-framework/diag"
@@ -72,7 +74,7 @@ func (r *hostConfigResource) Schema(_ context.Context, _ resource.SchemaRequest,
7274
return schema.StringAttribute{Optional: true, Computed: true, MarkdownDescription: desc, PlanModifiers: useState}
7375
}
7476
resp.Schema = schema.Schema{
75-
MarkdownDescription: "Manages a PCD host configuration: the mapping of traffic types (management, VM console, " +
77+
MarkdownDescription: "Manages a PCD host configuration: the mapping of traffic types (management, VM console, Destroying one is refused while any host is still assigned to it: PCD leaves such a host unable to be assigned a host configuration ever again, so remove the `pcd_host_config_assignment` first." +
7678
"tunnels, image library, live migration, host liveness) to network interfaces, plus physical-network labels.",
7779
Attributes: map[string]schema.Attribute{
7880
"id": schema.StringAttribute{Computed: true, MarkdownDescription: "The host configuration ID.", PlanModifiers: useState},
@@ -204,7 +206,46 @@ func (r *hostConfigResource) Delete(ctx context.Context, req resource.DeleteRequ
204206
return
205207
}
206208

207-
if _, err := client.Delete(ctx, client.ServiceURL("hostconfigs", state.ID.ValueString()), &gophercloud.RequestOpts{OkCodes: []int{200, 202, 204}}); err != nil {
209+
// Deleting a host configuration a host is still assigned to cannot be undone, so it is
210+
// checked rather than attempted. resmgr accepts the delete, keeps the assignment, and
211+
// from then on refuses to remove it (404 — it validates that the target host config
212+
// exists before unbinding) and refuses every re-assignment (409
213+
// HostToHostconfigConflict). Deleting the host record does not clear it either, and a
214+
// host configuration cannot be re-created under the id that was removed: the host can
215+
// never be assigned one again, so it can never be onboarded again.
216+
//
217+
// The guard is deliberately fail-closed. If the check itself cannot be completed the
218+
// delete does not proceed — an unverified assumption here costs a hypervisor.
219+
id := state.ID.ValueString()
220+
assigned, err := hostsAssignedTo(ctx, client, id)
221+
if err != nil {
222+
resp.Diagnostics.AddError(
223+
"resmgr: cannot confirm the host configuration is unused",
224+
fmt.Sprintf("Host configuration %s was not deleted because the check for hosts still "+
225+
"assigned to it could not be completed: %s\n\nDeleting one while a host is still "+
226+
"assigned to it strands that host permanently, so Terraform will not do it on an "+
227+
"unverified answer. Retry when resmgr is reachable.", id, err),
228+
)
229+
return
230+
}
231+
if len(assigned) > 0 {
232+
resp.Diagnostics.AddError(
233+
"Host configuration is still assigned to a host",
234+
fmt.Sprintf("Host configuration %s is still assigned to %s, and deleting it now would "+
235+
"leave that host unable to be assigned any host configuration ever again: resmgr "+
236+
"would refuse both the unassign (404) and every re-assignment (409), with no way "+
237+
"back.\n\nRemove the assignment first, then delete the host configuration:\n"+
238+
" - destroy the pcd_host_config_assignment resource that binds them, or\n"+
239+
" - DELETE /resmgr/v2/hosts/<host_id>/hostconfig/%s if the assignment was made "+
240+
"outside Terraform, then confirm hostconfig_id has cleared in GET /resmgr/v2/hosts.\n\n"+
241+
"This guard is here because of a defect in PCD, not in your configuration, and will "+
242+
"be lifted once resmgr refuses the unsafe delete itself.",
243+
id, strings.Join(assigned, ", "), id),
244+
)
245+
return
246+
}
247+
248+
if _, err := client.Delete(ctx, client.ServiceURL("hostconfigs", id), &gophercloud.RequestOpts{OkCodes: []int{200, 202, 204}}); err != nil {
208249
if isNotFound(err) {
209250
return
210251
}

internal/services/resmgr/resmgr.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"fmt"
2424
"io"
2525
"net/http"
26+
"time"
2627

2728
"github.com/gophercloud/gophercloud/v2"
2829
"github.com/hashicorp/terraform-plugin-framework/diag"
@@ -84,6 +85,86 @@ func getJSON(ctx context.Context, client *gophercloud.ServiceClient, url string,
8485
return json.Unmarshal(raw, out)
8586
}
8687

88+
// getJSONList issues an authenticated GET for a collection. Unlike getJSON, a null or
89+
// empty body is an empty collection rather than an absence: the distinction getJSON
90+
// draws only makes sense for a request naming one object.
91+
func getJSONList(ctx context.Context, client *gophercloud.ServiceClient, url string, out any) error {
92+
var raw json.RawMessage
93+
if _, err := client.Get(ctx, url, &raw, &gophercloud.RequestOpts{OkCodes: []int{200}}); err != nil {
94+
if errors.Is(err, io.EOF) {
95+
return nil
96+
}
97+
return err
98+
}
99+
if isNullJSON(raw) {
100+
return nil
101+
}
102+
return json.Unmarshal(raw, out)
103+
}
104+
105+
// hostsAssignedTo returns the ids of the hosts resmgr still reports as carrying the
106+
// given host configuration. The host *list* is the authority: the per-host endpoints
107+
// answer 404 for minutes after a host is deauthorised, while the list keeps reporting
108+
// the host and its hostconfig_id throughout.
109+
func hostsAssignedTo(ctx context.Context, client *gophercloud.ServiceClient, hostConfigID string) ([]string, error) {
110+
var hosts []hostAPI
111+
if err := getJSONList(ctx, client, client.ServiceURL("hosts"), &hosts); err != nil {
112+
return nil, err
113+
}
114+
var assigned []string
115+
for _, h := range hosts {
116+
if h.HostConfigID == hostConfigID {
117+
assigned = append(assigned, h.ID)
118+
}
119+
}
120+
return assigned, nil
121+
}
122+
123+
// unassignPollInterval / unassignPollTimeout bound the wait for an unassign to show up
124+
// in the host list. Short: this confirms a write resmgr has already accepted.
125+
// var, not const, so a test can drive the clock instead of sleeping through it.
126+
var (
127+
unassignPollInterval = 5 * time.Second
128+
unassignPollTimeout = 60 * time.Second
129+
)
130+
131+
// waitUnassigned blocks until the host stops reporting the host configuration.
132+
//
133+
// resmgr answers the unassign 204 whether or not it unbound anything, and 404 while a
134+
// freshly deauthorised host is not describable, so the status code proves nothing. A
135+
// binding Terraform believes is gone while resmgr still holds it is what later strands
136+
// the host: the next apply cannot re-create the assignment (409) and deleting the host
137+
// configuration in that state makes it permanent.
138+
func waitUnassigned(ctx context.Context, client *gophercloud.ServiceClient, hostID, hostConfigID string) error {
139+
deadline := time.Now().Add(unassignPollTimeout)
140+
for {
141+
assigned, err := hostsAssignedTo(ctx, client, hostConfigID)
142+
if err != nil {
143+
return fmt.Errorf("checking whether host %s still carries host config %s: %w", hostID, hostConfigID, err)
144+
}
145+
still := false
146+
for _, h := range assigned {
147+
if h == hostID {
148+
still = true
149+
break
150+
}
151+
}
152+
if !still {
153+
return nil
154+
}
155+
if time.Now().After(deadline) {
156+
return fmt.Errorf("host %s still reports host config %s %s after the unassign was accepted; "+
157+
"resmgr did not apply it, and deleting the host config while this stands would leave the "+
158+
"host unable to be assigned one again", hostID, hostConfigID, unassignPollTimeout)
159+
}
160+
select {
161+
case <-ctx.Done():
162+
return ctx.Err()
163+
case <-time.After(unassignPollInterval):
164+
}
165+
}
166+
}
167+
87168
// postJSON issues an authenticated POST with a JSON body, optionally decoding
88169
// the response into out (pass nil to ignore the body).
89170
func postJSON(ctx context.Context, client *gophercloud.ServiceClient, url string, body, out any) error {

0 commit comments

Comments
 (0)