fix(provisioner): emit regions=[] not null so OpenTofu validator accepts zero-override request

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: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hatiyildiz 2026-05-04 13:26:11 +02:00 committed by Emrah Baysal
parent 10d1af8c91
commit 35f86b6fa4
2 changed files with 71 additions and 1 deletions

View File

@ -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
}

View File

@ -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