Skip to content

Commit 29be0f7

Browse files
committed
fix: preserve optional nested objects on readback
Refs: #1294
1 parent 7c1148d commit 29be0f7

File tree

4 files changed

+140
-1
lines changed

4 files changed

+140
-1
lines changed

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,8 @@ Example:
193193

194194
Terraform state reflects what the user cares about, not the entire API response.
195195

196+
For generated XML -> model conversions, see `internal/codegen/README.md`, especially the "Preserve User Intent For Optional Nested Objects" section. That generator-level rule is authoritative for optional nested object readback behavior.
197+
196198
**Computed** (not Optional) - Always read from API
197199
- Examples: `id`, `key`, `allocation`, `physical`
198200
- `model.Key = types.StringValue(xml.Key)`

internal/codegen/README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,26 @@ The generator has two separate responsibilities:
6363

6464
Keep those layers separate. The parser should not accumulate ad hoc Terraform exceptions based on struct names. If a field needs special Terraform behavior, prefer a policy rule after reflection rather than embedding one-off conditionals into the reflector.
6565

66+
### Preserve User Intent For Optional Nested Objects
67+
68+
`Preserve user intent` is a generator-level contract, not a resource-by-resource preference.
69+
70+
For optional nested objects, XML omission and user omission are not the same thing:
71+
72+
- If the user did not configure an optional nested object, state should keep it `null`.
73+
- If the user configured an optional nested object and libvirt omits that object when reading XML back, the conversion should preserve the planned object unless we have an explicit policy that omission means the field was cleared.
74+
- Do not silently collapse an explicitly configured nested object to `null` only because the readback XML does not echo it.
75+
76+
This rule exists to prevent Terraform `Provider produced inconsistent result after apply` errors for fields such as `alias`, where libvirt may not round-trip the object exactly even though the user configured it explicitly.
77+
78+
In practice, this means the XML -> model conversion for optional nested objects must distinguish:
79+
80+
- `plan field is null`: user does not care, keep state `null`
81+
- `plan field is non-null` and `xml field is present`: convert XML back into state
82+
- `plan field is non-null` and `xml field is absent`: preserve the planned value unless an explicit override says otherwise
83+
84+
When changing converter templates, treat this as an invariant that applies uniformly across generated nested object fields. Do not patch individual resources to compensate for generator behavior unless the field truly needs special semantics.
85+
6686
### Policy rules
6787

6888
Policy should be applied in an ordered pass over `StructIR` / `FieldIR`:

internal/codegen/templates/convert.go.tmpl

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -470,7 +470,14 @@ func {{ .Name }}ToXML(ctx context.Context, model *{{ .Name }}Model) (*libvirtxml
470470
model.{{ .GoName }} = types.ObjectNull({{ .NestedStruct.Name }}AttributeTypes())
471471
}
472472
} else {
473-
model.{{ .GoName }} = types.ObjectNull({{ .NestedStruct.Name }}AttributeTypes())
473+
// Preserve explicit user intent for optional nested objects when the API omits
474+
// the field on readback. This avoids collapsing configured objects such as
475+
// aliases to null and triggering "inconsistent result after apply" errors.
476+
if plan != nil && !plan.{{ .GoName }}.IsNull() && !plan.{{ .GoName }}.IsUnknown() {
477+
model.{{ .GoName }} = plan.{{ .GoName }}
478+
} else {
479+
model.{{ .GoName }} = types.ObjectNull({{ .NestedStruct.Name }}AttributeTypes())
480+
}
474481
}
475482
} else {
476483
model.{{ .GoName }} = types.ObjectNull({{ .NestedStruct.Name }}AttributeTypes())

internal/provider/domain_resource_test.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1577,6 +1577,46 @@ func TestAccDomainResource_console(t *testing.T) {
15771577
})
15781578
}
15791579

1580+
func TestAccDomainResource_consoleAndSerialAlias(t *testing.T) {
1581+
resource.Test(t, resource.TestCase{
1582+
PreCheck: func() { testAccPreCheck(t) },
1583+
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
1584+
CheckDestroy: testAccCheckDomainDestroy,
1585+
Steps: []resource.TestStep{
1586+
{
1587+
Config: testAccDomainResourceConfigConsoleAndSerialAlias("test-domain-console-serial-alias"),
1588+
Check: resource.ComposeAggregateTestCheckFunc(
1589+
resource.TestCheckResourceAttr("libvirt_domain.test", "name", "test-domain-console-serial-alias"),
1590+
resource.TestCheckResourceAttr("libvirt_domain.test", "running", "false"),
1591+
resource.TestCheckResourceAttr("libvirt_domain.test", "devices.serials.#", "1"),
1592+
resource.TestCheckResourceAttr("libvirt_domain.test", "devices.serials.0.alias.name", "serial0"),
1593+
resource.TestCheckResourceAttr("libvirt_domain.test", "devices.serials.0.protocol.type", "telnet"),
1594+
resource.TestCheckResourceAttr("libvirt_domain.test", "devices.serials.0.source.tcp.host", "127.0.0.1"),
1595+
resource.TestCheckResourceAttr("libvirt_domain.test", "devices.serials.0.source.tcp.mode", "bind"),
1596+
resource.TestCheckResourceAttr("libvirt_domain.test", "devices.serials.0.source.tcp.service", "12345"),
1597+
resource.TestCheckResourceAttr("libvirt_domain.test", "devices.serials.0.source.tcp.tls", "no"),
1598+
resource.TestCheckResourceAttr("libvirt_domain.test", "devices.serials.0.target.type", "isa-serial"),
1599+
resource.TestCheckResourceAttr("libvirt_domain.test", "devices.serials.0.target.port", "0"),
1600+
resource.TestCheckResourceAttr("libvirt_domain.test", "devices.serials.0.target.model.name", "isa-serial"),
1601+
resource.TestCheckResourceAttr("libvirt_domain.test", "devices.consoles.#", "1"),
1602+
resource.TestCheckResourceAttr("libvirt_domain.test", "devices.consoles.0.alias.name", "serial0"),
1603+
resource.TestCheckResourceAttr("libvirt_domain.test", "devices.consoles.0.protocol.type", "telnet"),
1604+
resource.TestCheckResourceAttr("libvirt_domain.test", "devices.consoles.0.source.tcp.host", "127.0.0.1"),
1605+
resource.TestCheckResourceAttr("libvirt_domain.test", "devices.consoles.0.source.tcp.mode", "bind"),
1606+
resource.TestCheckResourceAttr("libvirt_domain.test", "devices.consoles.0.source.tcp.service", "12345"),
1607+
resource.TestCheckResourceAttr("libvirt_domain.test", "devices.consoles.0.source.tcp.tls", "no"),
1608+
resource.TestCheckResourceAttr("libvirt_domain.test", "devices.consoles.0.target.type", "serial"),
1609+
resource.TestCheckResourceAttr("libvirt_domain.test", "devices.consoles.0.target.port", "0"),
1610+
),
1611+
},
1612+
{
1613+
Config: testAccDomainResourceConfigConsoleAndSerialAlias("test-domain-console-serial-alias"),
1614+
PlanOnly: true,
1615+
},
1616+
},
1617+
})
1618+
}
1619+
15801620
func testAccDomainResourceConfigConsole(name string) string {
15811621
return fmt.Sprintf(`
15821622
resource "libvirt_domain" "test" {
@@ -1610,6 +1650,76 @@ resource "libvirt_domain" "test" {
16101650
`, name)
16111651
}
16121652

1653+
func testAccDomainResourceConfigConsoleAndSerialAlias(name string) string {
1654+
return fmt.Sprintf(`
1655+
resource "libvirt_domain" "test" {
1656+
name = %[1]q
1657+
running = false
1658+
memory = 512
1659+
memory_unit = "MiB"
1660+
vcpu = 1
1661+
type = "kvm"
1662+
1663+
os = {
1664+
type = "hvm"
1665+
type_arch = "x86_64"
1666+
type_machine = "q35"
1667+
}
1668+
1669+
devices = {
1670+
serials = [
1671+
{
1672+
alias = {
1673+
name = "serial0"
1674+
}
1675+
source = {
1676+
tcp = {
1677+
mode = "bind"
1678+
host = "127.0.0.1"
1679+
service = "12345"
1680+
tls = "no"
1681+
}
1682+
}
1683+
protocol = {
1684+
type = "telnet"
1685+
}
1686+
target = {
1687+
type = "isa-serial"
1688+
port = 0
1689+
model = {
1690+
name = "isa-serial"
1691+
}
1692+
}
1693+
}
1694+
]
1695+
1696+
consoles = [
1697+
{
1698+
alias = {
1699+
name = "serial0"
1700+
}
1701+
source = {
1702+
tcp = {
1703+
mode = "bind"
1704+
host = "127.0.0.1"
1705+
service = "12345"
1706+
tls = "no"
1707+
}
1708+
}
1709+
protocol = {
1710+
type = "telnet"
1711+
}
1712+
target = {
1713+
type = "serial"
1714+
port = 0
1715+
}
1716+
}
1717+
]
1718+
}
1719+
}
1720+
`, name)
1721+
}
1722+
16131723
func TestAccDomainResource_autostart(t *testing.T) {
16141724
resource.Test(t, resource.TestCase{
16151725
PreCheck: func() { testAccPreCheck(t) },

0 commit comments

Comments
 (0)