Skip to content

Commit 2092fab

Browse files
committed
fix(compute): read back computed instance fields; retry subnet delete
Live validation on a fully-featured CE lab (with a bootable image + working Cinder backend) surfaced several real bugs in pcd_compute_instance that were latent because the instance had never actually booted before. instance flatten now reads back the server-computed fields it previously ignored, so they are known after apply instead of erroring with "invalid result object": availability_zone, security_groups (deduplicated — Nova can list the default group once per port), and the network block's uuid/port. The network block's inner uuid/port also gain UseStateForUnknown so an in-place flavor change (resize) no longer re-plans them unknown and trips RequiresReplace, which was replacing the instance instead of resizing it. metadata gains UseStateForUnknown and its update is guarded, so an unset metadata map is no longer pushed to Nova as `metadata: None` (a 400). pcd_networking_subnet delete retries on 409 SubnetInUse to ride out Nova's asynchronous release of an instance's ports after the instance is deleted. The compute VM-boot acc tests (instance, interface_attach, volume_attach) now resolve an existing image by name via a data source (env PCD_ACC_IMAGE_NAME) and skip when it is unset, because PCD does not sync web-download (copy-from) images to the hypervisor's local Glance — only library-uploaded images boot. Validated live: instance boot + in-place resize + import, boot by image_name, interface_attach, volume_attach (on the Synology Cinder backend), and blockstorage volume.
1 parent bbcf514 commit 2092fab

7 files changed

Lines changed: 153 additions & 90 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,10 @@ All notable changes to this project are documented here. The format is based on
4242
(attach security groups to an unmanaged port, shared or exclusive via `enforce`); and
4343
`pcd_networking_floatingip_associate` (bind a pre-allocated floating IP to a port).
4444
- Compute (Nova v2): resources `pcd_compute_keypair`, `pcd_compute_flavor`,
45-
`pcd_compute_servergroup` (acceptance-tested); `pcd_compute_instance` (code-complete —
46-
boot verification is blocked on a lab image-library issue, see DECISIONS.md); data
45+
`pcd_compute_servergroup`, `pcd_compute_instance` (boot, in-place resize, import); data
4746
sources `pcd_compute_flavor`, `pcd_compute_keypair`, `pcd_compute_availability_zones`.
47+
The instance resource reads back all server-computed fields (availability zone,
48+
security groups, network) after apply, and only pushes metadata when the user manages it.
4849
- Compute follow-ups: `pcd_compute_flavor` gains settable `extra_specs` (added/changed/
4950
removed in place; the flavor's other attributes are now correctly immutable);
5051
`pcd_compute_instance` supports in-place **resize** on a flavor change and booting by

DECISIONS.md

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,28 @@ real backend fault):**
4242
- DNS `zone`/`recordset`: Designate returns `500 no_servers_configured` — the pool has no
4343
nameservers (BIND9 / `pools.yaml` from the runbook's Phase E step 7 not applied).
4444

45+
### Follow-up run (2026-07-13): compute + Cinder unblocked
46+
47+
After the lab operator uploaded a bootable image to the library and fixed the Cinder
48+
backend, the compute and block-storage suites were re-run. Block-storage volume passed.
49+
Compute boot passed once pointed at a library-synced image, and surfaced several real
50+
provider bugs in `pcd_compute_instance` (all fixed) that were latent because the instance
51+
had never actually booted before:
52+
- `flatten` did not read back `availability_zone`, `security_groups`, or the `network`
53+
block, so they stayed unknown after apply ("invalid result object"). Now populated
54+
(with the security-group list deduplicated, since Nova can repeat `default`).
55+
- The `network` block's inner `uuid`/`port` lacked `UseStateForUnknown`, so an in-place
56+
flavor change re-planned them unknown and tripped `RequiresReplace` — the resize
57+
replaced the instance instead of resizing. Fixed.
58+
- `metadata` was pushed on every update even when unset, sending Nova a nil map
59+
(`metadata: None` → 400). Fixed with `UseStateForUnknown` + a guarded update.
60+
- Separately, `pcd_networking_subnet` delete now retries on `409 SubnetInUse` to ride out
61+
Nova's asynchronous port release after an instance is deleted.
62+
63+
The compute VM-boot acc tests now resolve an existing image by name via a data source
64+
(env `PCD_ACC_IMAGE_NAME`); they skip when it is unset, since PCD does not sync
65+
web-download images to the hypervisor's local Glance.
66+
4567
| Family | Item | Status |
4668
|---|---|---|
4769
| Provider core | password auth + project scope, self-signed TLS (`insecure`), `pcd_identity_auth_scope` | **VALIDATED** |
@@ -61,12 +83,12 @@ real backend fault):**
6183
| Networking | `pcd_networking_floatingip_associate` | **VALIDATED** (2026-07-12) — associates a separately-allocated floating IP to a port; acc test builds the router path and skips unless `PCD_ACC_EXTERNAL_NETWORK` set. |
6284
| Compute | `pcd_compute_keypair`, `_flavor`, `_servergroup` | **VALIDATED** |
6385
| Compute (DS) | `pcd_compute_flavor`, `_keypair`, `_availability_zones` | **VALIDATED** |
64-
| Compute | `pcd_compute_instance` (boot) | **PENDING** — lab image-library gap: images don't reach the onboarded host's local library → nova returns HTTP 204 for image data. Create/schedule/wait/error-report verified; passes with a library-backed image. |
65-
| Compute | `pcd_compute_flavor` `extra_specs` | **PENDING**code-complete; in-place add/change/remove; other flavor attrs now correctly force replacement. Acc test written (create + in-place update + import). Not yet run live (credentials unavailable this session). No lab blocker expected — flavors work on the lab. |
66-
| Compute | `pcd_compute_instance` resize + `image_name` | **PENDING** — code-complete; flavor change → Nova resize/confirm (revert on failure); `image_name` resolved via Glance. Boot-blocked (same as instance boot above). |
67-
| Compute | `pcd_compute_interface_attach` | **PENDING** — code-complete; needs a booted instance (boot-blocked). Acc test written. |
68-
| Compute | `pcd_compute_volume_attach` | **PENDING** — code-complete; needs a booted instance **and** a Cinder backend (both lab-blocked). Best-effort volume waiter degrades gracefully without Cinder. |
69-
| Block storage | `pcd_blockstorage_volume` | **PENDING** — no Cinder storage backend on the lab (`storageBackends={}`); volumes go `creatingerror`. Create + waiter + error-detection verified. |
86+
| Compute | `pcd_compute_instance` (boot) | **VALIDATED** (2026-07-13) — boots against a library-synced image. The acc test resolves an existing image by name via a data source (env `PCD_ACC_IMAGE_NAME`), because PCD does not sync web-download (`copy-from`) images to the hypervisor's local Glance. |
87+
| Compute | `pcd_compute_flavor` `extra_specs` | **VALIDATED** — in-place add/change/remove; other flavor attrs force replacement. |
88+
| Compute | `pcd_compute_instance` resize + `image_name` | **VALIDATED** (2026-07-13) — flavor change resizes in place (verified same instance ID); `image_name` resolved via Glance. |
89+
| Compute | `pcd_compute_interface_attach` | **VALIDATED** (2026-07-13) — hot-attach a second NIC + import. |
90+
| Compute | `pcd_compute_volume_attach` | **VALIDATED** (2026-07-13) — boot + Cinder volume attach + import. |
91+
| Block storage | `pcd_blockstorage_volume` | **VALIDATED** (2026-07-13) — create on the `synology-iscsi` backend → availableextend → import. |
7092
| Block storage (DS) | `pcd_blockstorage_volume`, `_snapshot` | **PENDING** — untestable without volumes on this lab. |
7193
| Load balancing (Octavia) | `pcd_lb_loadbalancer`, `_listener`, `_pool`, `_member`, `_monitor` + `_loadbalancer` DS | **VALIDATED** (2026-07-12) — full-tree apply on OVN (lb + TCP listener + `SOURCE_IP_PORT` pool + member + TCP monitor + DS + rename + import). **PCD ships the OVN provider only** (`providers=[ovn]`, no amphora), which is L4 — use TCP/UDP/SCTP listeners and OVN pool algorithms; L7 policy/rule resources were removed (see backout entry). `loadbalancer_provider` was added (default `ovn`) because Octavia's server-side default `amphora` is not enabled — without it every create failed. |
7294
| 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). |

internal/services/compute/instance_resource.go

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"github.com/hashicorp/terraform-plugin-framework/resource"
2525
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
2626
"github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier"
27+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/mapplanmodifier"
2728
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
2829
"github.com/hashicorp/terraform-plugin-framework/resource/schema/setplanmodifier"
2930
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
@@ -98,7 +99,7 @@ func (r *instanceResource) Schema(_ context.Context, _ resource.SchemaRequest, r
9899
MarkdownDescription: "Names of security groups to associate. Changing this forces a new resource.",
99100
PlanModifiers: []planmodifier.Set{setplanmodifier.RequiresReplace(), setplanmodifier.UseStateForUnknown()},
100101
},
101-
"metadata": schema.MapAttribute{Optional: true, Computed: true, ElementType: types.StringType, MarkdownDescription: "Key-value metadata attached to the instance."},
102+
"metadata": schema.MapAttribute{Optional: true, Computed: true, ElementType: types.StringType, MarkdownDescription: "Key-value metadata attached to the instance.", PlanModifiers: []planmodifier.Map{mapplanmodifier.UseStateForUnknown()}},
102103
"user_data": schema.StringAttribute{Optional: true, MarkdownDescription: "User data (cloud-init) for the instance. Changing this forces a new resource.", PlanModifiers: fn},
103104
"availability_zone": schema.StringAttribute{Optional: true, Computed: true, MarkdownDescription: "Availability zone to launch in. Changing this forces a new resource.", PlanModifiers: fnC},
104105
"config_drive": schema.BoolAttribute{Optional: true, MarkdownDescription: "Whether to use a config drive. Changing this forces a new resource.", PlanModifiers: []planmodifier.Bool{}},
@@ -110,9 +111,9 @@ func (r *instanceResource) Schema(_ context.Context, _ resource.SchemaRequest, r
110111
"network": schema.ListNestedBlock{
111112
MarkdownDescription: "Networks to attach. Changing this forces a new resource.",
112113
NestedObject: schema.NestedBlockObject{Attributes: map[string]schema.Attribute{
113-
"uuid": schema.StringAttribute{Optional: true, Computed: true, MarkdownDescription: "Network UUID to attach to (required unless port is set)."},
114+
"uuid": schema.StringAttribute{Optional: true, Computed: true, MarkdownDescription: "Network UUID to attach to (required unless port is set).", PlanModifiers: stable},
114115
"name": schema.StringAttribute{Optional: true, MarkdownDescription: "Network name (informational)."},
115-
"port": schema.StringAttribute{Optional: true, Computed: true, MarkdownDescription: "Existing port to attach (required unless uuid is set)."},
116+
"port": schema.StringAttribute{Optional: true, Computed: true, MarkdownDescription: "Existing port to attach (required unless uuid is set).", PlanModifiers: stable},
116117
}},
117118
PlanModifiers: []planmodifier.List{listplanmodifier.RequiresReplace(), listplanmodifier.UseStateForUnknown()},
118119
},
@@ -273,13 +274,14 @@ func (r *instanceResource) Update(ctx context.Context, req resource.UpdateReques
273274
}
274275
}
275276

276-
if !plan.Metadata.Equal(state.Metadata) {
277-
var meta map[string]string
278-
if !plan.Metadata.IsNull() && !plan.Metadata.IsUnknown() {
279-
resp.Diagnostics.Append(plan.Metadata.ElementsAs(ctx, &meta, false)...)
280-
if resp.Diagnostics.HasError() {
281-
return
282-
}
277+
// Only push metadata when the user actually set it and it changed. A null or
278+
// unknown plan value means "unmanaged" — Nova rejects a nil metadata map
279+
// (metadata: None) with a 400.
280+
if !plan.Metadata.IsNull() && !plan.Metadata.IsUnknown() && !plan.Metadata.Equal(state.Metadata) {
281+
meta := map[string]string{}
282+
resp.Diagnostics.Append(plan.Metadata.ElementsAs(ctx, &meta, false)...)
283+
if resp.Diagnostics.HasError() {
284+
return
283285
}
284286
if _, err := servers.UpdateMetadata(ctx, client, plan.ID.ValueString(), servers.MetadataOpts(meta)).Extract(); err != nil {
285287
resp.Diagnostics.AddError("compute: updating instance metadata", err.Error())
@@ -390,6 +392,43 @@ func (r *instanceResource) flatten(ctx context.Context, server *servers.Server,
390392
diags = append(diags, d...)
391393
m.Metadata = metaMap
392394

395+
// availability_zone and security_groups are Optional+Computed: when the user
396+
// omits them the server assigns values, which must be read back or they stay
397+
// unknown after apply ("invalid result object").
398+
m.AvailabilityZone = types.StringValue(server.AvailabilityZone)
399+
400+
seenSG := make(map[string]bool, len(server.SecurityGroups))
401+
sgs := make([]string, 0, len(server.SecurityGroups))
402+
for _, sg := range server.SecurityGroups {
403+
// Nova can list the same security group once per attached port; dedupe so
404+
// the Set is valid.
405+
if name, ok := sg["name"].(string); ok && !seenSG[name] {
406+
seenSG[name] = true
407+
sgs = append(sgs, name)
408+
}
409+
}
410+
sgSet, d := types.SetValueFrom(ctx, types.StringType, sgs)
411+
diags = append(diags, d...)
412+
m.SecurityGroups = sgSet
413+
414+
// The network block's uuid/port are Optional+Computed; resolve any that the
415+
// user left unset (unknown) to a concrete value so the block is fully known.
416+
if !m.Network.IsNull() && !m.Network.IsUnknown() {
417+
var blocks []instanceNetworkModel
418+
diags = append(diags, m.Network.ElementsAs(ctx, &blocks, false)...)
419+
for i := range blocks {
420+
if blocks[i].UUID.IsNull() || blocks[i].UUID.IsUnknown() {
421+
blocks[i].UUID = types.StringValue("")
422+
}
423+
if blocks[i].Port.IsNull() || blocks[i].Port.IsUnknown() {
424+
blocks[i].Port = types.StringValue("")
425+
}
426+
}
427+
netList, d := types.ListValueFrom(ctx, m.Network.ElementType(ctx), blocks)
428+
diags = append(diags, d...)
429+
m.Network = netList
430+
}
431+
393432
if m.Region.IsNull() || m.Region.IsUnknown() {
394433
m.Region = types.StringValue(r.config.Region)
395434
}

internal/services/compute/instance_test.go

Lines changed: 34 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,24 @@ import (
1818
"github.com/platform9/terraform-provider-pcd/internal/acctest"
1919
)
2020

21-
// defaultTestImageURL is a small bootable qcow2 (CirrOS) fetched by Glance via
22-
// web-download, which bypasses the ingress upload-size cap. Override with
23-
// PCD_ACC_IMAGE_URL.
24-
const defaultTestImageURL = "https://download.cirros-cloud.net/0.6.2/cirros-0.6.2-x86_64-disk.img"
21+
// testAccBootImageName returns the name of a pre-synced bootable image to boot
22+
// VMs from, taken from PCD_ACC_IMAGE_NAME. PCD's image library does not propagate
23+
// web-download (copy-from) images to the hypervisor's local Glance, so the VM
24+
// boot tests require an image already uploaded to the library (e.g. a CirrOS
25+
// image). The test skips when the variable is unset.
26+
func testAccBootImageName(t *testing.T) string {
27+
t.Helper()
28+
name := os.Getenv("PCD_ACC_IMAGE_NAME")
29+
if name == "" {
30+
t.Skip("PCD_ACC_IMAGE_NAME not set; skipping VM-boot test (needs a bootable image already in the image library)")
31+
}
32+
return name
33+
}
2534

2635
// TestAccComputeInstance_basic boots a real VM through Terraform end to end:
2736
// network + subnet + Glance image (web-download) + keypair + instance on m1.tiny.
2837
func TestAccComputeInstance_basic(t *testing.T) {
29-
imageURL := os.Getenv("PCD_ACC_IMAGE_URL")
30-
if imageURL == "" {
31-
imageURL = defaultTestImageURL
32-
}
38+
imageName := testAccBootImageName(t)
3339
const rn = "pcd_compute_instance.test"
3440
var instanceID, flavorID string
3541

@@ -39,7 +45,7 @@ func TestAccComputeInstance_basic(t *testing.T) {
3945
CheckDestroy: testAccCheckInstanceDestroy(t),
4046
Steps: []resource.TestStep{
4147
{
42-
Config: testAccInstanceConfig(imageURL, "tf-acc-instance", "m1.tiny"),
48+
Config: testAccInstanceConfig(imageName, "tf-acc-instance", "m1.tiny"),
4349
Check: resource.ComposeAggregateTestCheckFunc(
4450
testAccCheckInstanceExists(t, rn),
4551
testAccCaptureID(rn, &instanceID),
@@ -51,12 +57,12 @@ func TestAccComputeInstance_basic(t *testing.T) {
5157
),
5258
},
5359
{
54-
Config: testAccInstanceConfig(imageURL, "tf-acc-instance-renamed", "m1.tiny"),
60+
Config: testAccInstanceConfig(imageName, "tf-acc-instance-renamed", "m1.tiny"),
5561
Check: resource.TestCheckResourceAttr(rn, "name", "tf-acc-instance-renamed"),
5662
},
5763
{
5864
// Resize: change flavor in place (same instance ID, new flavor_id).
59-
Config: testAccInstanceConfig(imageURL, "tf-acc-instance-renamed", "m1.small"),
65+
Config: testAccInstanceConfig(imageName, "tf-acc-instance-renamed", "m1.small"),
6066
Check: resource.ComposeAggregateTestCheckFunc(
6167
resource.TestCheckResourceAttrWith(rn, "id", func(v string) error {
6268
if v != instanceID {
@@ -79,10 +85,7 @@ func TestAccComputeInstance_basic(t *testing.T) {
7985
// TestAccComputeInstance_imageName boots an instance referencing its image by
8086
// name (resolved via Glance) instead of image_id.
8187
func TestAccComputeInstance_imageName(t *testing.T) {
82-
imageURL := os.Getenv("PCD_ACC_IMAGE_URL")
83-
if imageURL == "" {
84-
imageURL = defaultTestImageURL
85-
}
88+
imageName := testAccBootImageName(t)
8689
const rn = "pcd_compute_instance.test"
8790

8891
resource.Test(t, resource.TestCase{
@@ -91,20 +94,24 @@ func TestAccComputeInstance_imageName(t *testing.T) {
9194
CheckDestroy: testAccCheckInstanceDestroy(t),
9295
Steps: []resource.TestStep{
9396
{
94-
Config: testAccInstanceImageNameConfig(imageURL),
97+
Config: testAccInstanceImageNameConfig(imageName),
9598
Check: resource.ComposeAggregateTestCheckFunc(
9699
testAccCheckInstanceExists(t, rn),
97100
resource.TestCheckResourceAttr(rn, "status", "ACTIVE"),
98101
// image_id is Computed and gets populated from the resolved image_name.
99-
resource.TestCheckResourceAttrPair(rn, "image_id", "pcd_images_image.test", "id"),
102+
resource.TestCheckResourceAttrPair(rn, "image_id", "data.pcd_images_image.boot", "id"),
100103
),
101104
},
102105
},
103106
})
104107
}
105108

106-
func testAccInstanceConfig(imageURL, name, flavorName string) string {
109+
func testAccInstanceConfig(imageName, name, flavorName string) string {
107110
return fmt.Sprintf(`
111+
data "pcd_images_image" "boot" {
112+
name = %q
113+
}
114+
108115
resource "pcd_networking_network" "test" {
109116
name = "tf-acc-inst-net"
110117
}
@@ -114,20 +121,13 @@ resource "pcd_networking_subnet" "test" {
114121
cidr = "10.103.0.0/24"
115122
}
116123
117-
resource "pcd_images_image" "test" {
118-
name = "tf-acc-inst-img"
119-
container_format = "bare"
120-
disk_format = "qcow2"
121-
image_source_url = %q
122-
}
123-
124124
resource "pcd_compute_keypair" "test" {
125125
name = "tf-acc-inst-key"
126126
}
127127
128128
resource "pcd_compute_instance" "test" {
129129
name = %q
130-
image_id = pcd_images_image.test.id
130+
image_id = data.pcd_images_image.boot.id
131131
flavor_name = %q
132132
key_pair = pcd_compute_keypair.test.name
133133
@@ -137,11 +137,15 @@ resource "pcd_compute_instance" "test" {
137137
138138
depends_on = [pcd_networking_subnet.test]
139139
}
140-
`, imageURL, name, flavorName)
140+
`, imageName, name, flavorName)
141141
}
142142

143-
func testAccInstanceImageNameConfig(imageURL string) string {
143+
func testAccInstanceImageNameConfig(imageName string) string {
144144
return fmt.Sprintf(`
145+
data "pcd_images_image" "boot" {
146+
name = %[1]q
147+
}
148+
145149
resource "pcd_networking_network" "test" {
146150
name = "tf-acc-instn-net"
147151
}
@@ -151,16 +155,9 @@ resource "pcd_networking_subnet" "test" {
151155
cidr = "10.113.0.0/24"
152156
}
153157
154-
resource "pcd_images_image" "test" {
155-
name = "tf-acc-instn-img"
156-
container_format = "bare"
157-
disk_format = "qcow2"
158-
image_source_url = %q
159-
}
160-
161158
resource "pcd_compute_instance" "test" {
162159
name = "tf-acc-instn"
163-
image_name = pcd_images_image.test.name
160+
image_name = %[1]q
164161
flavor_name = "m1.tiny"
165162
166163
network {
@@ -169,7 +166,7 @@ resource "pcd_compute_instance" "test" {
169166
170167
depends_on = [pcd_networking_subnet.test]
171168
}
172-
`, imageURL)
169+
`, imageName)
173170
}
174171

175172
// testAccCaptureAttr records a resource attribute value for later comparison.

0 commit comments

Comments
 (0)