From 490bf2289c8a5c40607bf9e1a3dbd24f9b231ea9 Mon Sep 17 00:00:00 2001 From: Andrew Sumner Date: Tue, 5 May 2026 20:50:29 +1200 Subject: [PATCH 1/6] fix: tolerate communicator disconnect during shutdown_command When the shutdown command triggers a VM power-off (e.g. sysprep /generalize /oobe /shutdown), the communicator drops before it can read the exit status. Previously this was treated as a fatal error, halting the build. Now log a warning and fall through to the power- state polling loop, which detects the VM is off and proceeds to image capture. --- builder/nutanix/step_shutdown_vm.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/builder/nutanix/step_shutdown_vm.go b/builder/nutanix/step_shutdown_vm.go index 5cab987..66793c8 100644 --- a/builder/nutanix/step_shutdown_vm.go +++ b/builder/nutanix/step_shutdown_vm.go @@ -53,10 +53,13 @@ func (s *StepShutdown) Run(ctx context.Context, state multistep.StateBag) multis log.Printf("executing shutdown command: %s", s.Command) cmd := &packersdk.RemoteCmd{Command: s.Command} if err := cmd.RunWithUi(ctx, comm, ui); err != nil { - err := fmt.Errorf("failed to send shutdown command: %s", err) - state.Put("error", err) - ui.Error(err.Error()) - return multistep.ActionHalt + // Communicator errors during shutdown are expected — the + // shutdown command (e.g. sysprep /shutdown) may power off + // the VM before the communicator can read the exit status. + // Log a warning and fall through to the power-state polling + // loop, which will detect the VM is off. + log.Printf("shutdown command returned error (expected if VM is shutting down): %s", err) + ui.Say("Shutdown command disconnected (expected during sysprep/shutdown). Waiting for VM to power off...") } } else { From c8bc59b2ecfe34d21ddbd748d39d826ec2578217 Mon Sep 17 00:00:00 2001 From: Andrew Sumner Date: Tue, 5 May 2026 22:59:32 +1200 Subject: [PATCH 2/6] fix: set InstallType=FRESH for Windows guest customization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without this, the Sysprep guest customization defaults to PREPARED, which tells Nutanix to auto-restart the VM after shutdown to apply the customization on first boot. For Packer ISO builds, the unattend is for a fresh install — the VM should stay off after sysprep so Packer can capture the disk. --- builder/nutanix/driver.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/builder/nutanix/driver.go b/builder/nutanix/driver.go index 1c8f409..086d566 100644 --- a/builder/nutanix/driver.go +++ b/builder/nutanix/driver.go @@ -755,6 +755,8 @@ func (d *NutanixDriver) CreateRequest(ctx context.Context, vmConfig VmConfig, st log.Printf("CloudInit configured for Linux VM") } else if vmConfig.OSType == "Windows" { sysprep := vmmModels.NewSysprep() + installType := vmmModels.INSTALLTYPE_FRESH + sysprep.InstallType = &installType unattendXml := vmmModels.NewUnattendxml() unattendXml.Value = &vmConfig.UserData sysprep.SysprepScript = vmmModels.NewOneOfSysprepSysprepScript() From 96083c0135a8ffb110581e70a4f2eec537fdee6e Mon Sep 17 00:00:00 2001 From: Andrew Sumner Date: Wed, 6 May 2026 02:50:42 +1200 Subject: [PATCH 3/6] fix: tolerate PowerOff errors when VM already shut down When sysprep runs as a provisioner with /shutdown, the VM may be off before StepShutdown calls PowerOff(). Log a warning and fall through to the polling loop instead of halting. --- builder/nutanix/step_shutdown_vm.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/builder/nutanix/step_shutdown_vm.go b/builder/nutanix/step_shutdown_vm.go index 66793c8..5b610b0 100644 --- a/builder/nutanix/step_shutdown_vm.go +++ b/builder/nutanix/step_shutdown_vm.go @@ -65,10 +65,11 @@ func (s *StepShutdown) Run(ctx context.Context, state multistep.StateBag) multis } else { ui.Say("Halting the virtual machine...") if err := driver.PowerOff(ctx, vmUUID); err != nil { - err := fmt.Errorf("error stopping VM: %s", err) - state.Put("error", err) - ui.Error(err.Error()) - return multistep.ActionHalt + // PowerOff may fail if the VM is already off (e.g. sysprep + // /shutdown ran as the last provisioner). Log a warning and + // fall through to the polling loop. + log.Printf("PowerOff returned error (may be expected if VM already shut down): %s", err) + ui.Say("PowerOff returned error — checking if VM is already off...") } } } else { From 739b6226ea28536692be3e4d041d4f56e6280d1b Mon Sep 17 00:00:00 2001 From: Andrew Sumner Date: Wed, 6 May 2026 10:20:08 +1200 Subject: [PATCH 4/6] feat: add windows_install_type config for guest customization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exposes the Nutanix V4 API InstallType field as a Packer config option (windows_install_type). Defaults to PREPARED (existing behaviour for template/clone deployments). Set to FRESH for ISO-based builds where the unattend is for a fresh install — prevents Nutanix from auto- restarting the VM after sysprep shutdown. --- builder/nutanix/config.go | 1 + builder/nutanix/config.hcl2spec.go | 4 ++++ builder/nutanix/driver.go | 8 ++++++-- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/builder/nutanix/config.go b/builder/nutanix/config.go index 6134c19..698dcf9 100644 --- a/builder/nutanix/config.go +++ b/builder/nutanix/config.go @@ -128,6 +128,7 @@ type VmConfig struct { Core int64 `mapstructure:"core" json:"core" required:"false"` MemoryMB int64 `mapstructure:"memory_mb" json:"memory_mb" required:"false"` UserData string `mapstructure:"user_data" json:"user_data" required:"false"` + WindowsInstallType string `mapstructure:"windows_install_type" json:"windows_install_type" required:"false"` VMCategories []Category `mapstructure:"vm_categories" required:"false"` Project string `mapstructure:"project" required:"false"` GPU []GPU `mapstructure:"gpu" required:"false"` diff --git a/builder/nutanix/config.hcl2spec.go b/builder/nutanix/config.hcl2spec.go index 6478af3..5170caf 100644 --- a/builder/nutanix/config.hcl2spec.go +++ b/builder/nutanix/config.hcl2spec.go @@ -159,6 +159,7 @@ type FlatConfig struct { Core *int64 `mapstructure:"core" json:"core" required:"false" cty:"core" hcl:"core"` MemoryMB *int64 `mapstructure:"memory_mb" json:"memory_mb" required:"false" cty:"memory_mb" hcl:"memory_mb"` UserData *string `mapstructure:"user_data" json:"user_data" required:"false" cty:"user_data" hcl:"user_data"` + WindowsInstallType *string `mapstructure:"windows_install_type" json:"windows_install_type" required:"false" cty:"windows_install_type" hcl:"windows_install_type"` VMCategories []FlatCategory `mapstructure:"vm_categories" required:"false" cty:"vm_categories" hcl:"vm_categories"` Project *string `mapstructure:"project" required:"false" cty:"project" hcl:"project"` GPU []FlatGPU `mapstructure:"gpu" required:"false" cty:"gpu" hcl:"gpu"` @@ -282,6 +283,7 @@ func (*FlatConfig) HCL2Spec() map[string]hcldec.Spec { "core": &hcldec.AttrSpec{Name: "core", Type: cty.Number, Required: false}, "memory_mb": &hcldec.AttrSpec{Name: "memory_mb", Type: cty.Number, Required: false}, "user_data": &hcldec.AttrSpec{Name: "user_data", Type: cty.String, Required: false}, + "windows_install_type": &hcldec.AttrSpec{Name: "windows_install_type", Type: cty.String, Required: false}, "vm_categories": &hcldec.BlockListSpec{TypeName: "vm_categories", Nested: hcldec.ObjectSpec((*FlatCategory)(nil).HCL2Spec())}, "project": &hcldec.AttrSpec{Name: "project", Type: cty.String, Required: false}, "gpu": &hcldec.BlockListSpec{TypeName: "gpu", Nested: hcldec.ObjectSpec((*FlatGPU)(nil).HCL2Spec())}, @@ -447,6 +449,7 @@ type FlatVmConfig struct { Core *int64 `mapstructure:"core" json:"core" required:"false" cty:"core" hcl:"core"` MemoryMB *int64 `mapstructure:"memory_mb" json:"memory_mb" required:"false" cty:"memory_mb" hcl:"memory_mb"` UserData *string `mapstructure:"user_data" json:"user_data" required:"false" cty:"user_data" hcl:"user_data"` + WindowsInstallType *string `mapstructure:"windows_install_type" json:"windows_install_type" required:"false" cty:"windows_install_type" hcl:"windows_install_type"` VMCategories []FlatCategory `mapstructure:"vm_categories" required:"false" cty:"vm_categories" hcl:"vm_categories"` Project *string `mapstructure:"project" required:"false" cty:"project" hcl:"project"` GPU []FlatGPU `mapstructure:"gpu" required:"false" cty:"gpu" hcl:"gpu"` @@ -481,6 +484,7 @@ func (*FlatVmConfig) HCL2Spec() map[string]hcldec.Spec { "core": &hcldec.AttrSpec{Name: "core", Type: cty.Number, Required: false}, "memory_mb": &hcldec.AttrSpec{Name: "memory_mb", Type: cty.Number, Required: false}, "user_data": &hcldec.AttrSpec{Name: "user_data", Type: cty.String, Required: false}, + "windows_install_type": &hcldec.AttrSpec{Name: "windows_install_type", Type: cty.String, Required: false}, "vm_categories": &hcldec.BlockListSpec{TypeName: "vm_categories", Nested: hcldec.ObjectSpec((*FlatCategory)(nil).HCL2Spec())}, "project": &hcldec.AttrSpec{Name: "project", Type: cty.String, Required: false}, "gpu": &hcldec.BlockListSpec{TypeName: "gpu", Nested: hcldec.ObjectSpec((*FlatGPU)(nil).HCL2Spec())}, diff --git a/builder/nutanix/driver.go b/builder/nutanix/driver.go index 086d566..8b2be11 100644 --- a/builder/nutanix/driver.go +++ b/builder/nutanix/driver.go @@ -755,8 +755,12 @@ func (d *NutanixDriver) CreateRequest(ctx context.Context, vmConfig VmConfig, st log.Printf("CloudInit configured for Linux VM") } else if vmConfig.OSType == "Windows" { sysprep := vmmModels.NewSysprep() - installType := vmmModels.INSTALLTYPE_FRESH - sysprep.InstallType = &installType + if vmConfig.WindowsInstallType == "FRESH" { + sysprep.InstallType = vmmModels.INSTALLTYPE_FRESH.Ref() + } else { + sysprep.InstallType = vmmModels.INSTALLTYPE_PREPARED.Ref() + } + log.Printf("Windows Sysprep InstallType: %s", sysprep.InstallType.GetName()) unattendXml := vmmModels.NewUnattendxml() unattendXml.Value = &vmConfig.UserData sysprep.SysprepScript = vmmModels.NewOneOfSysprepSysprepScript() From 79a65a1f50cea5ef301a39b32c4a5d4e5cdb6b73 Mon Sep 17 00:00:00 2001 From: Andrew Sumner Date: Tue, 12 May 2026 10:58:34 +1200 Subject: [PATCH 5/6] fix: validate windows_install_type and use case-insensitive comparison - Validate windows_install_type in Prepare(), reject values other than FRESH or PREPARED - Use strings.EqualFold for case-insensitive comparison in sysprep logic - Add TestPrepareRejectsInvalidWindowsInstallType test --- builder/nutanix/config.go | 9 +++++++ builder/nutanix/config_test.go | 49 ++++++++++++++++++++++++++++++++++ builder/nutanix/driver.go | 2 +- 3 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 builder/nutanix/config_test.go diff --git a/builder/nutanix/config.go b/builder/nutanix/config.go index 698dcf9..77b6a63 100644 --- a/builder/nutanix/config.go +++ b/builder/nutanix/config.go @@ -7,6 +7,7 @@ import ( "fmt" "log" "net" + "strings" "time" "github.com/hashicorp/packer-plugin-sdk/bootcommand" @@ -320,6 +321,14 @@ func (c *Config) Prepare(raws ...interface{}) ([]string, error) { errs = packersdk.MultiErrorAppend(errs, fmt.Errorf("missing nutanix_password")) } + switch strings.ToUpper(c.VmConfig.WindowsInstallType) { + case "", "PREPARED", "FRESH": + // ok + default: + errs = packersdk.MultiErrorAppend(errs, + fmt.Errorf("windows_install_type must be FRESH or PREPARED, got %q", c.VmConfig.WindowsInstallType)) + } + if c.VmConfig.VMName == "" { p := fmt.Sprintf("Packer-%s", random.String(random.PossibleAlphaNumUpper, 8)) log.Println("No vmname assigned, setting to " + p) diff --git a/builder/nutanix/config_test.go b/builder/nutanix/config_test.go new file mode 100644 index 0000000..3fd5551 --- /dev/null +++ b/builder/nutanix/config_test.go @@ -0,0 +1,49 @@ +package nutanix + +import ( + "strings" + "testing" +) + +func minimalValidConfig(extra map[string]interface{}) map[string]interface{} { + cfg := map[string]interface{}{ + "nutanix_endpoint": "pc.example.com", + "nutanix_username": "admin", + "nutanix_password": "password", + "cluster_name": "cluster-1", + "os_type": "Linux", + "communicator": "none", + "vm_disks": []map[string]interface{}{ + { + "image_type": "DISK_IMAGE", + "source_image_name": "img", + "disk_size_gb": 40, + }, + }, + } + for k, v := range extra { + cfg[k] = v + } + return cfg +} + +func TestPrepareRejectsInvalidWindowsInstallType(t *testing.T) { + c := &Config{} + _, err := c.Prepare(minimalValidConfig(map[string]interface{}{ + "windows_install_type": "fresh", + })) + if err != nil { + t.Fatalf("expected case-insensitive match to succeed, got: %v", err) + } + + c2 := &Config{} + _, err = c2.Prepare(minimalValidConfig(map[string]interface{}{ + "windows_install_type": "INVALID", + })) + if err == nil { + t.Fatal("expected Prepare to fail with invalid windows_install_type") + } + if !strings.Contains(err.Error(), "windows_install_type must be FRESH or PREPARED") { + t.Errorf("unexpected error: %v", err) + } +} diff --git a/builder/nutanix/driver.go b/builder/nutanix/driver.go index 8b2be11..e442f1c 100644 --- a/builder/nutanix/driver.go +++ b/builder/nutanix/driver.go @@ -755,7 +755,7 @@ func (d *NutanixDriver) CreateRequest(ctx context.Context, vmConfig VmConfig, st log.Printf("CloudInit configured for Linux VM") } else if vmConfig.OSType == "Windows" { sysprep := vmmModels.NewSysprep() - if vmConfig.WindowsInstallType == "FRESH" { + if strings.EqualFold(vmConfig.WindowsInstallType, "FRESH") { sysprep.InstallType = vmmModels.INSTALLTYPE_FRESH.Ref() } else { sysprep.InstallType = vmmModels.INSTALLTYPE_PREPARED.Ref() From 64a65f4ecf2dbbc0579cff71a0164ddbb53ac8e9 Mon Sep 17 00:00:00 2001 From: Andrew Sumner Date: Mon, 8 Jun 2026 12:22:57 +1200 Subject: [PATCH 6/6] test: make windows_install_type test supply explicit auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TestPrepareRejectsInvalidWindowsInstallType previously relied on the default nutanix_username/nutanix_password set by minimalValidConfig. Pass them explicitly so the test is self-contained and does not depend on helper defaults — also avoids a behavioural conflict when this PR is rebased against the API-key PR (#358), whose minimalValidConfig omits default credentials so its auth-combination tests can work. --- builder/nutanix/config_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/builder/nutanix/config_test.go b/builder/nutanix/config_test.go index 3fd5551..843591d 100644 --- a/builder/nutanix/config_test.go +++ b/builder/nutanix/config_test.go @@ -30,6 +30,8 @@ func minimalValidConfig(extra map[string]interface{}) map[string]interface{} { func TestPrepareRejectsInvalidWindowsInstallType(t *testing.T) { c := &Config{} _, err := c.Prepare(minimalValidConfig(map[string]interface{}{ + "nutanix_username": "admin", + "nutanix_password": "password", "windows_install_type": "fresh", })) if err != nil { @@ -38,6 +40,8 @@ func TestPrepareRejectsInvalidWindowsInstallType(t *testing.T) { c2 := &Config{} _, err = c2.Prepare(minimalValidConfig(map[string]interface{}{ + "nutanix_username": "admin", + "nutanix_password": "password", "windows_install_type": "INVALID", })) if err == nil {