From 476259bc18f05fb71a8e2a60bcf3fd8329fc6cfa Mon Sep 17 00:00:00 2001 From: eding Date: Sun, 23 Aug 2026 19:56:10 +0200 Subject: [PATCH 1/3] fix: handle ANSI Terraform empty state --- internal/bootstrap/bootstrap.go | 9 +++------ internal/bootstrap/bootstrap_test.go | 4 ++-- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index a11eb04..d40da1e 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -110,13 +110,10 @@ func listTerraformStateResources(terraformDir string, environment []string) ([]s return strings.Fields(string(state)), nil } +var ansiEscapeSequence = regexp.MustCompile(`\x1b\[[0-?]*[ -/]*[@-~]`) + func terraformNoStateFile(stderr []byte) bool { - for _, line := range strings.Split(string(stderr), "\n") { - if strings.TrimSpace(line) == "No state file was found!" { - return true - } - } - return false + return strings.Contains(strings.ToLower(ansiEscapeSequence.ReplaceAllString(string(stderr), "")), "no state file") } var destroyTalosVMs = func(terraformDir string, environment []string) error { diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go index 1c8b72e..e6df307 100644 --- a/internal/bootstrap/bootstrap_test.go +++ b/internal/bootstrap/bootstrap_test.go @@ -911,11 +911,11 @@ func TestTerraformPlanPathIsAbsolute(t *testing.T) { } } -func TestTerraformStateResourcesAllowsNoStateFile(t *testing.T) { +func TestTerraformStateResourcesAllowsANSINoStateFile(t *testing.T) { original := runTerraformStateList t.Cleanup(func() { runTerraformStateList = original }) runTerraformStateList = func(string, []string) ([]byte, []byte, error) { - return nil, []byte("No state file was found!\n"), errors.New("exit status 1") + return nil, []byte("\x1b[31mError:\x1b[0m \x1b[31mNO STATE\x1b[0m FILE was found!\n"), errors.New("exit status 1") } resources, err := listTerraformStateResources("terraform", nil) -- 2.43.7 From 634f4157167d20a9b84e529875486a0069a45883 Mon Sep 17 00:00:00 2001 From: eding Date: Sun, 23 Aug 2026 20:14:21 +0200 Subject: [PATCH 2/3] fix: import retained Talos VMs on reconcile --- internal/bootstrap/bootstrap.go | 21 +++++++--- internal/bootstrap/bootstrap_test.go | 59 ++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index d40da1e..c4fb45d 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -120,6 +120,8 @@ var destroyTalosVMs = func(terraformDir string, environment []string) error { return utils.RunCommandInDirEnv(terraformDir, environment, "terraform", "destroy", "-auto-approve", "-input=false", "-target=proxmox_virtual_environment_vm.vm") } +var runTerraform = utils.RunCommandInDirEnv + var webhookTargetTimeout = 70 * time.Minute var webhookTargetPollInterval = 2 * time.Second @@ -1280,7 +1282,7 @@ func ensureLifecycleIdentity(terraformDir string, cfg config.Config) error { func (r Runner) reconcileTerraform(terraformDir string) error { environment := []string{"TF_VAR_proxmox_api_token=" + r.Config.Talos.Proxmox.APITokenID + "=" + r.Config.Talos.Proxmox.APITokenSecret} - if err := utils.RunCommandInDirEnv(terraformDir, environment, "terraform", "init", "-input=false"); err != nil { + if err := runTerraform(terraformDir, environment, "terraform", "init", "-input=false"); err != nil { return err } if err := importNetworkBridges(terraformDir, environment, r.Config); err != nil { @@ -1290,19 +1292,28 @@ func (r Runner) reconcileTerraform(terraformDir string) error { if err := r.rebuildTalosVMs(terraformDir, environment); err != nil { return err } + } else if err := r.importConfiguredTalosVMs(terraformDir, environment); err != nil { + return err } planPath, err := terraformPlanPath(terraformDir, r.Config.ClusterID) if err != nil { return err } defer os.Remove(planPath) - if err := utils.RunCommandInDirEnv(terraformDir, environment, "terraform", "plan", "-input=false", "-out="+planPath); err != nil { + if err := runTerraform(terraformDir, environment, "terraform", "plan", "-input=false", "-out="+planPath); err != nil { return err } - return utils.RunCommandInDirEnv(terraformDir, environment, "terraform", "apply", "-input=false", "-auto-approve", planPath) + return runTerraform(terraformDir, environment, "terraform", "apply", "-input=false", "-auto-approve", planPath) } func (r Runner) rebuildTalosVMs(terraformDir string, environment []string) error { + if err := r.importConfiguredTalosVMs(terraformDir, environment); err != nil { + return err + } + return destroyTalosVMs(terraformDir, environment) +} + +func (r Runner) importConfiguredTalosVMs(terraformDir string, environment []string) error { if err := verifyTalosVMs(r.Config); err != nil { return err } @@ -1323,7 +1334,7 @@ func (r Runner) rebuildTalosVMs(terraformDir string, environment []string) error if err := verifyStateTalosVMs(resources, r.Config, true); err != nil { return err } - return destroyTalosVMs(terraformDir, environment) + return nil } type terraformImport struct { @@ -1400,7 +1411,7 @@ func importTerraformResources(terraformDir string, environment []string, imports if resources[resource.Address] { continue } - if err := utils.RunCommandInDirEnv(terraformDir, environment, "terraform", "import", "-input=false", resource.Address, resource.ID); err != nil { + if err := runTerraform(terraformDir, environment, "terraform", "import", "-input=false", resource.Address, resource.ID); err != nil { return fmt.Errorf("import Terraform resource: %w", err) } } diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go index e6df307..b11332f 100644 --- a/internal/bootstrap/bootstrap_test.go +++ b/internal/bootstrap/bootstrap_test.go @@ -946,6 +946,65 @@ func TestVerifyStateTalosVMsRejectsForeignAndMissingVMs(t *testing.T) { } } +func TestReconcileTerraformImportsConfiguredTalosVMsBeforePlan(t *testing.T) { + originalVerify := verifyTalosVMs + originalStateList := runTerraformStateList + originalRun := runTerraform + t.Cleanup(func() { + verifyTalosVMs = originalVerify + runTerraformStateList = originalStateList + runTerraform = originalRun + }) + + cfg := config.Config{ClusterID: "test-cluster", Talos: config.TalosConfig{ + Nodes: []config.TalosNode{{Name: "cp-01", ProxmoxNode: "pve", VMID: 100, Role: "controlplane"}}, + }} + importedAddress := talosVMImports(cfg)[0].Address + var events, state []string + verifyTalosVMs = func(got config.Config) error { + if len(got.Talos.Nodes) != 1 || got.Talos.Nodes[0].Name != "cp-01" || got.Talos.Nodes[0].ProxmoxNode != "pve" || got.Talos.Nodes[0].VMID != 100 { + t.Fatal("Talos VM identity verification used the wrong config") + } + events = append(events, "verify") + return nil + } + runTerraformStateList = func(string, []string) ([]byte, []byte, error) { + return []byte(strings.Join(state, "\n")), nil, nil + } + runTerraform = func(_ string, _ []string, name string, args ...string) error { + if name != "terraform" { + t.Fatalf("unexpected command %q", name) + } + switch args[0] { + case "init": + events = append(events, "init") + case "import": + if len(args) != 4 || args[2] != importedAddress || args[3] != "pve/100" { + t.Fatalf("unexpected Talos VM import: %q", args) + } + events = append(events, "import") + state = append(state, importedAddress) + case "plan": + if len(state) != 1 || state[0] != importedAddress { + return errors.New("VM create collision") + } + events = append(events, "plan") + case "apply": + events = append(events, "apply") + default: + t.Fatalf("unexpected Terraform operation %q", args[0]) + } + return nil + } + + if err := (Runner{Config: cfg, Mode: Reconcile}).reconcileTerraform(t.TempDir()); err != nil { + t.Fatal(err) + } + if strings.Join(events, ",") != "init,verify,import,plan,apply" { + t.Fatalf("Terraform phase order = %q", events) + } +} + func TestEnsureLifecycleIdentityStoresMetadataUnderGeneratedDirectory(t *testing.T) { repo := t.TempDir() terraformDir := filepath.Join(repo, "terraform") -- 2.43.7 From 1ae08491b646d586937deaeb45329391920d6ee7 Mon Sep 17 00:00:00 2001 From: eding Date: Sun, 23 Aug 2026 20:22:09 +0200 Subject: [PATCH 3/3] fix: keep VM changes rebuild-only --- internal/bootstrap/bootstrap.go | 15 +++++- internal/bootstrap/bootstrap_test.go | 76 ++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index c4fb45d..d059faf 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -1300,12 +1300,25 @@ func (r Runner) reconcileTerraform(terraformDir string) error { return err } defer os.Remove(planPath) - if err := runTerraform(terraformDir, environment, "terraform", "plan", "-input=false", "-out="+planPath); err != nil { + planArgs := []string{"plan", "-input=false"} + if r.Mode == Reconcile { + planArgs = append(planArgs, terraformReconcileTargets(r.Config)...) + } + planArgs = append(planArgs, "-out="+planPath) + if err := runTerraform(terraformDir, environment, "terraform", planArgs...); err != nil { return err } return runTerraform(terraformDir, environment, "terraform", "apply", "-input=false", "-auto-approve", planPath) } +func terraformReconcileTargets(cfg config.Config) []string { + targets := []string{"-target=proxmox_virtual_environment_download_file.talos_iso"} + if cfg.Talos.Cluster.ManageNetworkBridges { + targets = append(targets, "-target=proxmox_virtual_environment_network_linux_bridge.cluster_bridge") + } + return targets +} + func (r Runner) rebuildTalosVMs(terraformDir string, environment []string) error { if err := r.importConfiguredTalosVMs(terraformDir, environment); err != nil { return err diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go index b11332f..bcf17ff 100644 --- a/internal/bootstrap/bootstrap_test.go +++ b/internal/bootstrap/bootstrap_test.go @@ -911,6 +911,17 @@ func TestTerraformPlanPathIsAbsolute(t *testing.T) { } } +func TestTerraformReconcileTargetsStageImageAndManagedBridgesOnly(t *testing.T) { + if targets := strings.Join(terraformReconcileTargets(config.Config{}), " "); targets != "-target=proxmox_virtual_environment_download_file.talos_iso" { + t.Fatalf("default reconcile targets = %q", targets) + } + cfg := config.Config{Talos: config.TalosConfig{Cluster: config.TalosClusterConfig{ManageNetworkBridges: true}}} + targets := strings.Join(terraformReconcileTargets(cfg), " ") + if !strings.Contains(targets, "-target=proxmox_virtual_environment_download_file.talos_iso") || !strings.Contains(targets, "-target=proxmox_virtual_environment_network_linux_bridge.cluster_bridge") || strings.Contains(targets, "proxmox_virtual_environment_vm.vm") { + t.Fatalf("managed bridge reconcile targets = %q", targets) + } +} + func TestTerraformStateResourcesAllowsANSINoStateFile(t *testing.T) { original := runTerraformStateList t.Cleanup(func() { runTerraformStateList = original }) @@ -988,8 +999,14 @@ func TestReconcileTerraformImportsConfiguredTalosVMsBeforePlan(t *testing.T) { if len(state) != 1 || state[0] != importedAddress { return errors.New("VM create collision") } + if strings.Contains(strings.Join(args, " "), "proxmox_virtual_environment_vm.vm") || !strings.Contains(strings.Join(args, " "), "-target=proxmox_virtual_environment_download_file.talos_iso") { + t.Fatalf("reconcile plan did not exclude the Talos VM: %q", args) + } events = append(events, "plan") case "apply": + if strings.Contains(strings.Join(args, " "), "proxmox_virtual_environment_vm.vm") { + t.Fatalf("reconcile apply did not exclude the Talos VM: %q", args) + } events = append(events, "apply") default: t.Fatalf("unexpected Terraform operation %q", args[0]) @@ -1005,6 +1022,65 @@ func TestReconcileTerraformImportsConfiguredTalosVMsBeforePlan(t *testing.T) { } } +func TestRebuildTerraformRetainsFullTalosVMLifecycle(t *testing.T) { + originalVerify := verifyTalosVMs + originalStateList := runTerraformStateList + originalRun := runTerraform + originalDestroy := destroyTalosVMs + t.Cleanup(func() { + verifyTalosVMs = originalVerify + runTerraformStateList = originalStateList + runTerraform = originalRun + destroyTalosVMs = originalDestroy + }) + + cfg := config.Config{ClusterID: "test-cluster", Talos: config.TalosConfig{ + Nodes: []config.TalosNode{{Name: "cp-01", ProxmoxNode: "pve", VMID: 100, Role: "controlplane"}}, + }} + importedAddress := talosVMImports(cfg)[0].Address + var events, state []string + verifyTalosVMs = func(config.Config) error { + events = append(events, "verify") + return nil + } + runTerraformStateList = func(string, []string) ([]byte, []byte, error) { + return []byte(strings.Join(state, "\n")), nil, nil + } + destroyTalosVMs = func(_ string, _ []string) error { + events = append(events, "destroy") + return nil + } + runTerraform = func(_ string, _ []string, name string, args ...string) error { + if name != "terraform" { + t.Fatalf("unexpected command %q", name) + } + switch args[0] { + case "init": + events = append(events, "init") + case "import": + state = append(state, importedAddress) + events = append(events, "import") + case "plan": + if strings.Contains(strings.Join(args, " "), "-target=") { + t.Fatalf("rebuild plan must retain the full VM lifecycle: %q", args) + } + events = append(events, "plan") + case "apply": + events = append(events, "apply") + default: + t.Fatalf("unexpected Terraform operation %q", args[0]) + } + return nil + } + + if err := (Runner{Config: cfg, Mode: Rebuild}).reconcileTerraform(t.TempDir()); err != nil { + t.Fatal(err) + } + if strings.Join(events, ",") != "init,verify,import,destroy,plan,apply" { + t.Fatalf("Terraform phase order = %q", events) + } +} + func TestEnsureLifecycleIdentityStoresMetadataUnderGeneratedDirectory(t *testing.T) { repo := t.TempDir() terraformDir := filepath.Join(repo, "terraform") -- 2.43.7