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:
parent
fc9f8f53f6
commit
c7744d79d3
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user