Skip to content

Commit adfc284

Browse files
committed
refactor: apply codegen field policies after reflection
1 parent 39432d3 commit adfc284

File tree

6 files changed

+231
-103
lines changed

6 files changed

+231
-103
lines changed

internal/codegen/README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ The generator follows the global XML <-> HCL mapping rules in `docs/schema-mappi
1616

1717
- Inputs: libvirtxml reflection, docs registry (`internal/codegen/docs/*.yaml` from docindex/docgen), small config hooks
1818
- IR builder: normalizes struct metadata, tracks optionality, and carries doc strings
19+
- Field policy layer: applies Terraform-specific semantics after reflection (for example top-level identities, immutability, and exact path overrides)
1920
- Generators: templates render models/schemas/converters into `internal/generated/*.gen.go`
2021
- Orchestration: `main.go` wires the pieces and runs gofmt
2122

@@ -53,6 +54,48 @@ The generator follows the global XML <-> HCL mapping rules in `docs/schema-mappi
5354

5455
- Resources embed the generated models/schemas and call the conversions; add/override resource-specific fields (IDs, create helpers) manually
5556

57+
## Field Policy Design
58+
59+
The generator has two separate responsibilities:
60+
61+
1. Structural analysis: reflect libvirtxml structs into IR using facts from Go types and, over time, RNG metadata. This layer should answer questions like "is this a pointer?", "is this an XML attribute?", and "is this nested or repeated?".
62+
2. Terraform policy: decide how those reflected fields behave in Terraform schemas and conversions. This layer owns decisions like `Computed`, `Required`, `RequiresReplace`, and "preserve user intent" behavior.
63+
64+
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.
65+
66+
### Policy rules
67+
68+
Policy should be applied in an ordered pass over `StructIR` / `FieldIR`:
69+
70+
- Generic scope-aware rules first
71+
- Exact path overrides second
72+
- Resource-specific fallbacks last
73+
74+
Examples:
75+
76+
- Top-level resource identity fields such as `id`, `uuid`, and `key` are provider-managed and should usually be `Computed`
77+
- Top-level `name` and some top-level `type` fields are immutable inputs and should usually be `Required` plus `RequiresReplace`
78+
- Nested `id` fields are not automatically provider-managed; many are part of libvirt configuration and should keep their reflected semantics unless an explicit override says otherwise
79+
- Reported-only fields such as storage pool `capacity` / `allocation` / `available` should be handled by explicit policy rules, not inferred from field names alone
80+
81+
### Override strategy
82+
83+
When the default rules are not enough, use explicit overrides keyed by full Terraform or XML path rather than helper functions like `isUserManagedFooStruct`.
84+
85+
Good override targets:
86+
87+
- `storage_pool.capacity`
88+
- `storage_pool.allocation`
89+
- `storage_volume.physical`
90+
91+
Avoid:
92+
93+
- Struct-name allowlists for one field
94+
- Field-name heuristics that ignore nesting scope
95+
- Mixing reflection logic with provider semantics in the same function
96+
97+
This keeps the generator predictable, makes exceptions easy to audit, and prevents the parser from turning into a collection of special cases.
98+
5699
## Documentation tools
57100

58101
- `cmd/docindex`: scrape `/usr/share/doc/libvirt/html` into an index used for prompting. Run `go run ./internal/codegen/cmd/docindex --input /usr/share/doc/libvirt/html --output internal/codegen/docs/.index.json`.

internal/codegen/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/dmacvicar/terraform-provider-libvirt/v2/internal/codegen/docregistry"
1010
"github.com/dmacvicar/terraform-provider-libvirt/v2/internal/codegen/generator"
1111
"github.com/dmacvicar/terraform-provider-libvirt/v2/internal/codegen/parser"
12+
"github.com/dmacvicar/terraform-provider-libvirt/v2/internal/codegen/policy"
1213
"github.com/dmacvicar/terraform-provider-libvirt/v2/internal/util/stringutil"
1314
"libvirt.org/go/libvirtxml"
1415
)
@@ -97,6 +98,8 @@ func run() error {
9798
}
9899
}
99100

101+
policy.ApplyFieldPolicies(structs)
102+
100103
// Silently generate code
101104

102105
// Generate one file per struct

internal/codegen/parser/libvirtxml.go

Lines changed: 0 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -106,9 +106,6 @@ func (r *LibvirtXMLReflector) ReflectStruct(structType reflect.Type) (*generator
106106
}
107107
}
108108

109-
// Apply universal and resource-specific patterns
110-
r.applyFieldPatterns(structType.Name(), ir.Fields)
111-
112109
// Post-process: expand chardata+attribute fields into separate flattened fields
113110
// Example: <memory unit='KiB'>524288</memory> → memory (value), memory_unit (attr)
114111
// <vcpu placement='static'>2</vcpu> → vcpu (value), vcpu_placement (attr)
@@ -182,109 +179,9 @@ func (r *LibvirtXMLReflector) ReflectStruct(structType reflect.Type) (*generator
182179
}
183180
ir.Fields = expandedFields
184181

185-
// Apply patterns again after field expansion
186-
r.applyFieldPatterns(structType.Name(), ir.Fields)
187-
188182
return ir, nil
189183
}
190184

191-
// applyFieldPatterns applies universal and resource-specific field patterns
192-
func (r *LibvirtXMLReflector) applyFieldPatterns(structName string, fields []*generator.FieldIR) {
193-
for _, field := range fields {
194-
// Universal patterns (apply to ALL resources)
195-
switch field.TFName {
196-
case "uuid", "id", "key":
197-
if field.TFName == "id" && isUserManagedVLANTagIDStruct(structName) {
198-
field.IsComputed = false
199-
field.IsOptional = false
200-
field.IsRequired = true
201-
field.PlanModifier = "RequiresReplace"
202-
continue
203-
}
204-
205-
field.IsComputed = true
206-
field.IsOptional = false
207-
field.IsRequired = false
208-
field.PlanModifier = "UseStateForUnknown"
209-
210-
case "name":
211-
// At resource root level, name is required and immutable
212-
if structName == "StoragePool" || structName == "Domain" || structName == "Network" || structName == "StorageVolume" {
213-
field.IsRequired = true
214-
field.IsOptional = false
215-
field.PlanModifier = "RequiresReplace"
216-
}
217-
218-
case "type":
219-
// At resource root level, type is required and immutable
220-
if structName == "StoragePool" || structName == "Domain" || structName == "Network" {
221-
field.IsRequired = true
222-
field.IsOptional = false
223-
field.PlanModifier = "RequiresReplace"
224-
}
225-
}
226-
227-
// Resource-specific patterns
228-
if structName == "StoragePool" {
229-
// Skip unit fields - they stay optional
230-
if field.IsFlattenedUnit {
231-
continue
232-
}
233-
234-
switch field.TFName {
235-
case "capacity":
236-
field.IsComputed = true
237-
field.IsOptional = false
238-
field.IsRequired = false
239-
field.PlanModifier = "UseStateForUnknown"
240-
// Pool capacity is purely reported by libvirt; always read from XML.
241-
field.PreserveUserIntent = false
242-
243-
case "allocation", "available":
244-
field.IsComputed = true
245-
field.IsOptional = false
246-
field.IsRequired = false
247-
// Purely informational; always read from XML.
248-
field.PreserveUserIntent = false
249-
}
250-
}
251-
252-
if structName == "StorageVolume" {
253-
// Skip unit fields - they stay optional
254-
if field.IsFlattenedUnit {
255-
continue
256-
}
257-
258-
switch field.TFName {
259-
case "capacity":
260-
field.IsComputed = true
261-
field.IsOptional = false
262-
field.IsRequired = false
263-
// Keep PreserveUserIntent = true (set by analyzeField for pointer fields)
264-
// so that when the user specifies capacity with a capacity_unit, the
265-
// plan value is preserved on readback instead of the bytes-normalised
266-
// value that libvirt returns (fixes issue #1253).
267-
268-
case "allocation", "physical":
269-
field.IsComputed = true
270-
field.IsOptional = false
271-
field.IsRequired = false
272-
// Purely informational; always read from XML.
273-
field.PreserveUserIntent = false
274-
}
275-
}
276-
}
277-
}
278-
279-
func isUserManagedVLANTagIDStruct(structName string) bool {
280-
switch structName {
281-
case "NetworkVLANTag", "DomainInterfaceVLanTag", "NetworkPortVLANTag":
282-
return true
283-
default:
284-
return false
285-
}
286-
}
287-
288185
func (r *LibvirtXMLReflector) analyzeField(structName string, field reflect.StructField) (*generator.FieldIR, error) {
289186
// Skip XMLName fields (used by encoding/xml)
290187
if field.Name == "XMLName" {
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package policy
2+
3+
import "github.com/dmacvicar/terraform-provider-libvirt/v2/internal/codegen/generator"
4+
5+
// ApplyFieldPolicies mutates the IR with Terraform-specific schema/conversion
6+
// semantics after structural reflection is complete.
7+
func ApplyFieldPolicies(structs []*generator.StructIR) {
8+
for _, s := range structs {
9+
applyStructPolicies(s)
10+
}
11+
}
12+
13+
func applyStructPolicies(s *generator.StructIR) {
14+
for _, field := range s.Fields {
15+
if field.IsExcluded || field.IsCycle {
16+
continue
17+
}
18+
19+
applyTopLevelIdentityPolicy(s, field)
20+
applyTopLevelImmutabilityPolicy(s, field)
21+
applyReportedFieldPolicy(s, field)
22+
}
23+
}
24+
25+
func applyTopLevelIdentityPolicy(s *generator.StructIR, field *generator.FieldIR) {
26+
if !s.IsTopLevel {
27+
return
28+
}
29+
30+
switch field.TFName {
31+
case "uuid", "id", "key":
32+
field.IsComputed = true
33+
field.IsOptional = false
34+
field.IsRequired = false
35+
field.PlanModifier = "UseStateForUnknown"
36+
}
37+
}
38+
39+
func applyTopLevelImmutabilityPolicy(s *generator.StructIR, field *generator.FieldIR) {
40+
if !s.IsTopLevel {
41+
return
42+
}
43+
44+
switch field.TFName {
45+
case "name":
46+
field.IsRequired = true
47+
field.IsOptional = false
48+
field.IsComputed = false
49+
field.PlanModifier = "RequiresReplace"
50+
case "type":
51+
if s.Name != "StorageVolume" {
52+
field.IsRequired = true
53+
field.IsOptional = false
54+
field.IsComputed = false
55+
field.PlanModifier = "RequiresReplace"
56+
}
57+
}
58+
}
59+
60+
func applyReportedFieldPolicy(s *generator.StructIR, field *generator.FieldIR) {
61+
if field.IsFlattenedUnit {
62+
return
63+
}
64+
65+
switch s.Name {
66+
case "StoragePool":
67+
switch field.TFName {
68+
case "capacity":
69+
field.IsComputed = true
70+
field.IsOptional = false
71+
field.IsRequired = false
72+
field.PlanModifier = "UseStateForUnknown"
73+
field.PreserveUserIntent = false
74+
case "allocation", "available":
75+
field.IsComputed = true
76+
field.IsOptional = false
77+
field.IsRequired = false
78+
field.PreserveUserIntent = false
79+
}
80+
case "StorageVolume":
81+
switch field.TFName {
82+
case "capacity":
83+
field.IsComputed = true
84+
field.IsOptional = false
85+
field.IsRequired = false
86+
case "allocation", "physical":
87+
field.IsComputed = true
88+
field.IsOptional = false
89+
field.IsRequired = false
90+
field.PreserveUserIntent = false
91+
}
92+
}
93+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package policy
2+
3+
import (
4+
"testing"
5+
6+
"github.com/dmacvicar/terraform-provider-libvirt/v2/internal/codegen/generator"
7+
)
8+
9+
func TestApplyFieldPoliciesMarksTopLevelIdentityComputed(t *testing.T) {
10+
root := &generator.StructIR{
11+
Name: "Network",
12+
IsTopLevel: true,
13+
Fields: []*generator.FieldIR{
14+
{TFName: "id", IsRequired: true},
15+
},
16+
}
17+
18+
ApplyFieldPolicies([]*generator.StructIR{root})
19+
20+
field := root.Fields[0]
21+
if !field.IsComputed {
22+
t.Fatal("expected top-level id to be computed")
23+
}
24+
if field.IsRequired {
25+
t.Fatal("expected top-level id to not be required")
26+
}
27+
if field.PlanModifier != "UseStateForUnknown" {
28+
t.Fatalf("expected top-level id plan modifier UseStateForUnknown, got %q", field.PlanModifier)
29+
}
30+
}
31+
32+
func TestApplyFieldPoliciesLeavesNestedIdentityUserManaged(t *testing.T) {
33+
nested := &generator.StructIR{
34+
Name: "DomainAudio",
35+
Fields: []*generator.FieldIR{
36+
{TFName: "id", IsRequired: true},
37+
},
38+
}
39+
40+
ApplyFieldPolicies([]*generator.StructIR{nested})
41+
42+
field := nested.Fields[0]
43+
if field.IsComputed {
44+
t.Fatal("expected nested id to remain user-managed")
45+
}
46+
if !field.IsRequired {
47+
t.Fatal("expected nested id to remain required")
48+
}
49+
if field.PlanModifier != "" {
50+
t.Fatalf("expected nested id to have no plan modifier, got %q", field.PlanModifier)
51+
}
52+
}

internal/regression/vlan_tag_schema_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,43 @@ func TestDomainInterfaceVLanTagIDIsRequired(t *testing.T) {
4646
t.Fatal("expected domain interface VLAN tag id to not be computed")
4747
}
4848
}
49+
50+
func TestDomainAudioIDIsRequired(t *testing.T) {
51+
attr := generated.DomainAudioSchemaAttribute()
52+
nested, ok := attr.(schema.SingleNestedAttribute)
53+
if !ok {
54+
t.Fatalf("expected SingleNestedAttribute, got %T", attr)
55+
}
56+
57+
idAttr, ok := nested.Attributes["id"].(schema.Int64Attribute)
58+
if !ok {
59+
t.Fatalf("expected Int64Attribute for domain audio id, got %T", nested.Attributes["id"])
60+
}
61+
62+
if !idAttr.Required {
63+
t.Fatal("expected domain audio id to be required")
64+
}
65+
if idAttr.Computed {
66+
t.Fatal("expected domain audio id to not be computed")
67+
}
68+
}
69+
70+
func TestDomainCPUMemoryTuneNodeIDIsRequired(t *testing.T) {
71+
attr := generated.DomainCPUMemoryTuneNodeSchemaAttribute()
72+
nested, ok := attr.(schema.SingleNestedAttribute)
73+
if !ok {
74+
t.Fatalf("expected SingleNestedAttribute, got %T", attr)
75+
}
76+
77+
idAttr, ok := nested.Attributes["id"].(schema.Int64Attribute)
78+
if !ok {
79+
t.Fatalf("expected Int64Attribute for domain CPU memory tune node id, got %T", nested.Attributes["id"])
80+
}
81+
82+
if !idAttr.Required {
83+
t.Fatal("expected domain CPU memory tune node id to be required")
84+
}
85+
if idAttr.Computed {
86+
t.Fatal("expected domain CPU memory tune node id to not be computed")
87+
}
88+
}

0 commit comments

Comments
 (0)