fix(provisioner): omit empty control_plane_size/worker_size from tfvars so variables.tf defaults take effect

Live failure on otech85 (DID a3c32a2b82758007, 2026-05-04 11:04:27Z): the
autopilot zero-touch verification cycle launched against PR #741's new
cost-optimized defaults (cpx21 CP + cpx31 workers) tripped a tofu plan
failure 7 seconds in. Root cause: writeTfvars unconditionally emitted

  "control_plane_size": "",
  "worker_size":        "",

into tofu.auto.tfvars.json when the request had no per-region SKU
overrides. The empty strings overrode the variables.tf defaults
("cpx21" / "cpx31") with "" and failed the SKU regex validator at
plan time:

  control_plane_size must match Hetzner server-type naming
  (cxNN | cpxNN | ccxNN | caxNN).

Fix: emit the singular SKU keys only when non-empty. Operator overrides
(both legacy singular fields and Regions[0] mirror) round-trip
unchanged; zero-override request bodies now flow through without
keys, leaving the variables.tf defaults to take effect.

Tests:
- TestWriteTfvars_OmitsEmptySingularSizes — proves the keys are absent
  when ControlPlaneSize/WorkerSize are "" (the autopilot path)
- TestWriteTfvars_EmitsSingularSizesWhenSet — proves operator overrides
  still round-trip (regression guard)

Both pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hatiyildiz 2026-05-04 13:08:43 +02:00
parent fc9f8f53f6
commit c7744d79d3
2 changed files with 117 additions and 4 deletions

View File

@ -852,10 +852,17 @@ func writeTfvars(deployDir string, req Request) error {
// to the OpenTofu module's for_each iteration when the multi-
// region wiring is activated; collapsing it back to single-SKU
// here would break the architectural shape the wizard intends.
"control_plane_size": req.ControlPlaneSize,
"worker_size": req.WorkerSize,
"worker_count": req.WorkerCount,
"ha_enabled": req.HAEnabled,
//
// IMPORTANT: control_plane_size / worker_size are conditionally
// inserted below (after the literal map) when non-empty. An empty
// string written to tofu.auto.tfvars.json OVERRIDES the variables.tf
// default ("cpx21" / "cpx31") with "" — and "" fails the SKU regex
// validator at plan time ("control_plane_size must match Hetzner
// server-type naming"). Writing the keys only when set lets the
// default-cost-optimized variables.tf defaults take effect for
// zero-override request bodies.
"worker_count": req.WorkerCount,
"ha_enabled": req.HAEnabled,
// Per-region payload — emitted as a list of objects so the
// OpenTofu module can iterate (variable "regions" in
@ -959,6 +966,18 @@ func writeTfvars(deployDir string, req Request) error {
"object_storage_bucket_name": req.ObjectStorageBucket,
}
// Conditionally include singular SKU fields. variables.tf in
// infra/hetzner/ declares "cpx21" / "cpx31" defaults for the
// cost-optimized 1× CP + 2× worker topology; writing an empty
// string here would override the default with "" and fail the
// SKU regex validator at `tofu plan`. Only emit when set.
if strings.TrimSpace(req.ControlPlaneSize) != "" {
vars["control_plane_size"] = req.ControlPlaneSize
}
if strings.TrimSpace(req.WorkerSize) != "" {
vars["worker_size"] = req.WorkerSize
}
raw, err := json.MarshalIndent(vars, "", " ")
if err != nil {
return err

View File

@ -403,3 +403,97 @@ func TestRequest_ObjectStorageSecretKey_Serialized(t *testing.T) {
t.Fatalf("ObjectStorageSecretKey must serialise to wire (wizard payload depends on it):\n%s", raw)
}
}
// TestWriteTfvars_OmitsEmptySingularSizes proves writeTfvars does NOT emit
// "control_plane_size": "" / "worker_size": "" when the legacy singular
// fields are empty. An empty string in tofu.auto.tfvars.json overrides the
// variables.tf default ("cpx21" / "cpx31") with "" — which fails the SKU
// regex validator at `tofu plan`. Live failure surfaced on otech85
// (DID a3c32a2b82758007, 2026-05-04 11:04:27Z) when the autopilot launched
// the cost-optimized-defaults verification cycle without per-request
// SKU overrides.
func TestWriteTfvars_OmitsEmptySingularSizes(t *testing.T) {
dir, err := os.MkdirTemp("", "writeTfvars-*")
if err != nil {
t.Fatalf("mkdir: %v", err)
}
defer os.RemoveAll(dir)
// Zero-override request: every singular SKU field empty, WorkerCount
// set explicitly to 2 (the wizard default). Every required identity
// + secret field populated so writeTfvars can run.
req := Request{
SovereignFQDN: "otech85.omani.works",
OrgName: "Acme",
OrgEmail: "ops@acme.io",
HetznerToken: "tok",
HetznerProjectID: "p1",
Region: "fsn1",
WorkerCount: 2,
// ControlPlaneSize / WorkerSize intentionally empty.
}
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 tfvars: %v", err)
}
var parsed map[string]any
if err := json.Unmarshal(raw, &parsed); err != nil {
t.Fatalf("parse tfvars: %v", err)
}
if _, ok := parsed["control_plane_size"]; ok {
t.Fatalf("control_plane_size MUST be omitted when empty (variables.tf default cpx21 takes effect). Got: %s", string(raw))
}
if _, ok := parsed["worker_size"]; ok {
t.Fatalf("worker_size MUST be omitted when empty (variables.tf default cpx31 takes effect). Got: %s", string(raw))
}
// worker_count is always emitted (zero is a valid solo-Sovereign
// choice; the wizard always sends 2 by default).
if v, ok := parsed["worker_count"]; !ok || v.(float64) != 2 {
t.Fatalf("worker_count must be emitted with the request value (2). Got: %v", parsed["worker_count"])
}
}
// 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
// operator overrides.
func TestWriteTfvars_EmitsSingularSizesWhenSet(t *testing.T) {
dir, err := os.MkdirTemp("", "writeTfvars-*")
if err != nil {
t.Fatalf("mkdir: %v", err)
}
defer os.RemoveAll(dir)
req := Request{
SovereignFQDN: "otech85.omani.works",
OrgName: "Acme",
OrgEmail: "ops@acme.io",
HetznerToken: "tok",
HetznerProjectID: "p1",
Region: "fsn1",
ControlPlaneSize: "cpx52",
WorkerSize: "cpx41",
WorkerCount: 3,
}
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 tfvars: %v", err)
}
var parsed map[string]any
if err := json.Unmarshal(raw, &parsed); err != nil {
t.Fatalf("parse tfvars: %v", err)
}
if v, _ := parsed["control_plane_size"].(string); v != "cpx52" {
t.Fatalf("control_plane_size must round-trip operator override: got %q want cpx52", v)
}
if v, _ := parsed["worker_size"].(string); v != "cpx41" {
t.Fatalf("worker_size must round-trip operator override: got %q want cpx41", v)
}
}