Skip to content

Commit 3c5ef84

Browse files
authored
Merge pull request #28 from platform9/fix/compute-image-datasource
fix(compute): read back computed instance fields + boot tests from a synced image
2 parents ec85607 + 2092fab commit 3c5ef84

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
@@ -45,9 +45,10 @@ All notable changes to this project are documented here. The format is based on
4545
(attach security groups to an unmanaged port, shared or exclusive via `enforce`); and
4646
`pcd_networking_floatingip_associate` (bind a pre-allocated floating IP to a port).
4747
- Compute (Nova v2): resources `pcd_compute_keypair`, `pcd_compute_flavor`,
48-
`pcd_compute_servergroup` (acceptance-tested); `pcd_compute_instance` (code-complete —
49-
boot verification is blocked on a lab image-library issue, see DECISIONS.md); data
48+
`pcd_compute_servergroup`, `pcd_compute_instance` (boot, in-place resize, import); data
5049
sources `pcd_compute_flavor`, `pcd_compute_keypair`, `pcd_compute_availability_zones`.
50+
The instance resource reads back all server-computed fields (availability zone,
51+
security groups, network) after apply, and only pushes metadata when the user manages it.
5152
- Compute follow-ups: `pcd_compute_flavor` gains settable `extra_specs` (added/changed/
5253
removed in place; the flavor's other attributes are now correctly immutable);
5354
`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)