fix(provisioner): emit regions=[] not null so OpenTofu validator accepts zero-override request (#743)
Live failure on otech86 (DID 103c52d08510006f, 2026-05-04 11:12:43Z). After PR #742 fixed the empty SKU strings in tfvars, the next blocker appeared: writeTfvars was emitting `"regions": null` (Go nil slice marshals to JSON null) when the request had no per-region overrides. OpenTofu's variables.tf carries a validation block: validation { condition = alltrue([ for r in var.regions : contains(["hetzner", "huawei", "oci", "aws", "azure"], r.provider) ]) } The `for r in var.regions` iteration fails on null with: Error: Iteration over null value on variables.tf line 217, in variable "regions": The variables.tf default `[]` is what the validator expects; emit that shape explicitly via a coalesceRegions(req.Regions) helper that turns nil into an empty slice. Operator overrides round-trip unchanged. Tests: - TestWriteTfvars_EmitsRegionsAsEmptyArrayNotNull — proves regions serialises as JSON `[]`, never `null`, when the request has no per-region overrides. Builds on PR #742. Co-authored-by: hatiyildiz <hatiyildiz@openova.io> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
10d1af8c91
commit
8989ce7659
@ -870,7 +870,15 @@ func writeTfvars(deployDir string, req Request) error {
|
||||
// unused by main.tf; the for_each that consumes it lives behind
|
||||
// the multi-region activation work). Structural-correct today;
|
||||
// no-op at apply time for solo deployments where len(regions)<=1.
|
||||
"regions": req.Regions,
|
||||
//
|
||||
// IMPORTANT: emit empty slice [] (not nil) when the request has
|
||||
// no per-region overrides. Go's nil slice marshals as JSON null
|
||||
// — and OpenTofu's validation block (`for r in var.regions`)
|
||||
// chokes on null with "Error: Iteration over null value" at
|
||||
// `tofu plan`. Live failure on otech86 (DID 103c52d08510006f,
|
||||
// 2026-05-04 11:12:43Z). The variables.tf default = [] is what
|
||||
// the validator expects; emit that shape explicitly.
|
||||
"regions": coalesceRegions(req.Regions),
|
||||
|
||||
// SSH key — module creates an hcloud_ssh_key from this and attaches
|
||||
// to all servers. We never generate keys here; sovereign-admin
|
||||
@ -1028,3 +1036,17 @@ func env(key, def string) string {
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// coalesceRegions normalises a nil RegionSpec slice to an empty slice so
|
||||
// JSON marshalling emits `[]` instead of `null`. The OpenTofu module's
|
||||
// `variable "regions"` validator runs `for r in var.regions` which fails
|
||||
// on null with "Error: Iteration over null value" but accepts an empty
|
||||
// list (the variables.tf default). Live failure on otech86 (DID
|
||||
// 103c52d08510006f, 2026-05-04 11:12:43Z) when the autopilot zero-touch
|
||||
// cycle launched without any per-region overrides.
|
||||
func coalesceRegions(rs []RegionSpec) []RegionSpec {
|
||||
if rs == nil {
|
||||
return []RegionSpec{}
|
||||
}
|
||||
return rs
|
||||
}
|
||||
|
||||
@ -457,6 +457,54 @@ func TestWriteTfvars_OmitsEmptySingularSizes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteTfvars_EmitsRegionsAsEmptyArrayNotNull proves writeTfvars
|
||||
// emits an empty JSON array for `regions` when the request has no
|
||||
// per-region overrides — never JSON null. The OpenTofu module's
|
||||
// variables.tf has a validation block (`for r in var.regions`) that
|
||||
// fails on null with "Error: Iteration over null value" but accepts
|
||||
// an empty list. Live failure on otech86 (DID 103c52d08510006f,
|
||||
// 2026-05-04 11:12:43Z).
|
||||
func TestWriteTfvars_EmitsRegionsAsEmptyArrayNotNull(t *testing.T) {
|
||||
dir, err := os.MkdirTemp("", "writeTfvars-*")
|
||||
if err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
req := Request{
|
||||
SovereignFQDN: "otech86.omani.works",
|
||||
OrgName: "Acme",
|
||||
OrgEmail: "ops@acme.io",
|
||||
HetznerToken: "tok",
|
||||
HetznerProjectID: "p1",
|
||||
Region: "fsn1",
|
||||
WorkerCount: 2,
|
||||
// Regions intentionally nil — the legacy singular path.
|
||||
}
|
||||
if err := writeTfvars(dir, req); err != nil {
|
||||
t.Fatalf("writeTfvars: %v", err)
|
||||
}
|
||||
raw, err := os.ReadFile(dir + "/tofu.auto.tfvars.json")
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
// Must contain `"regions": []` — never `"regions": null`.
|
||||
if strings.Contains(string(raw), `"regions": null`) {
|
||||
t.Fatalf("regions must serialise as [] (not null) so OpenTofu's `for r in var.regions` validator accepts the input. Got:\n%s", string(raw))
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
regions, ok := parsed["regions"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("regions must be a JSON array, got %T (%v)", parsed["regions"], parsed["regions"])
|
||||
}
|
||||
if len(regions) != 0 {
|
||||
t.Fatalf("regions must be empty when request has none, got %d entries", len(regions))
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteTfvars_EmitsSingularSizesWhenSet proves writeTfvars DOES emit
|
||||
// the singular SKU fields when the operator sets them explicitly. Guards
|
||||
// against a regression where an over-eager omission rule drops legitimate
|
||||
|
||||
Loading…
Reference in New Issue
Block a user