diff --git a/builder/nutanix/config.go b/builder/nutanix/config.go index 6134c19..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" @@ -128,6 +129,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"` @@ -319,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.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/config_test.go b/builder/nutanix/config_test.go new file mode 100644 index 0000000..843591d --- /dev/null +++ b/builder/nutanix/config_test.go @@ -0,0 +1,53 @@ +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{}{ + "nutanix_username": "admin", + "nutanix_password": "password", + "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{}{ + "nutanix_username": "admin", + "nutanix_password": "password", + "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 1c8f409..e442f1c 100644 --- a/builder/nutanix/driver.go +++ b/builder/nutanix/driver.go @@ -755,6 +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() + if strings.EqualFold(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() diff --git a/builder/nutanix/step_shutdown_vm.go b/builder/nutanix/step_shutdown_vm.go index 5cab987..5b610b0 100644 --- a/builder/nutanix/step_shutdown_vm.go +++ b/builder/nutanix/step_shutdown_vm.go @@ -53,19 +53,23 @@ 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 { 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 {