diff --git a/proxmox/config_guest.go b/proxmox/config_guest.go index db25050c..ad4e7cc5 100644 --- a/proxmox/config_guest.go +++ b/proxmox/config_guest.go @@ -172,15 +172,28 @@ func GuestHasPendingChanges(vmr *VmRef, client *Client) (bool, error) { if err != nil { return false, err } - return keyExists(params, "pending"), nil + return keyExists(params, "pending") || keyExists(params, "delete"), nil } // Reboot the specified guest func GuestReboot(vmr *VmRef, client *Client) (err error) { - _, err = client.ShutdownVm(vmr) - if err != nil { + _, err = client.RebootVm(vmr) + return +} + +func GuestShutdown(vmr *VmRef, client *Client, force bool) (err error) { + if err = client.CheckVmRef(vmr); err != nil { return } + var params map[string]interface{} + if force { + params = map[string]interface{}{"forceStop": force} + } + _, err = client.PostWithTask(params, "/nodes/"+vmr.node+"/"+vmr.vmType+"/"+strconv.Itoa(vmr.vmId)+"/status/shutdown") + return +} + +func GuestStart(vmr *VmRef, client *Client) (err error) { _, err = client.StartVm(vmr) return } diff --git a/proxmox/config_qemu.go b/proxmox/config_qemu.go index 524086fa..ce33235c 100644 --- a/proxmox/config_qemu.go +++ b/proxmox/config_qemu.go @@ -90,6 +90,10 @@ type ConfigQemu struct { VmID int `json:"vmid,omitempty"` // TODO should be a custom type as there are limitations } +const ( + ConfigQemu_Error_UnableToUpdateWithoutReboot string = "unable to update vm without rebooting" +) + // Create - Tell Proxmox API to make the VM func (config ConfigQemu) Create(vmr *VmRef, client *Client) (err error) { _, err = config.setAdvanced(nil, false, vmr, client) @@ -854,35 +858,61 @@ func (newConfig ConfigQemu) setAdvanced(currentConfig *ConfigQemu, rebootIfNeede var params map[string]interface{} var exitStatus string - if currentConfig != nil { - // Update + if currentConfig != nil { // Update + url := "/nodes/" + vmr.node + "/" + vmr.vmType + "/" + strconv.Itoa(vmr.vmId) + "/config" + var itemsToDeleteBeforeUpdate string // this is for items that should be removed before they can be created again e.g. cloud-init disks. (convert to array when needed) + stopped := false + + var markedDisks qemuUpdateChanges if newConfig.Disks != nil && currentConfig.Disks != nil { - markedDisks := newConfig.Disks.markDiskChanges(*currentConfig.Disks) - // move disk to different storage or change disk format - for _, e := range markedDisks.Move { + markedDisks = *newConfig.Disks.markDiskChanges(*currentConfig.Disks) + for _, e := range markedDisks.Move { // move disk to different storage or change disk format _, err = e.move(true, vmr, client) if err != nil { return } } - // increase Disks in size - for _, e := range markedDisks.Resize { + for _, e := range markedDisks.Resize { // increase Disks in size _, err = e.resize(vmr, client) if err != nil { - return + return false, err } } - // Moving disks changes the disk id. we need to get the config again if any disk was moved - if len(markedDisks.Move) != 0 { - currentConfig, err = NewConfigQemuFromApi(vmr, client) - if err != nil { - return + itemsToDeleteBeforeUpdate = newConfig.Disks.cloudInitRemove(*currentConfig.Disks) + } + + if itemsToDeleteBeforeUpdate != "" { + err = client.Put(map[string]interface{}{"delete": itemsToDeleteBeforeUpdate}, url) + if err != nil { + return false, fmt.Errorf("error updating VM: %v", err) + } + // Deleteing these items can create pending changes + rebootRequired, err = GuestHasPendingChanges(vmr, client) + if err != nil { + return + } + if rebootRequired { // shutdown vm if reboot is required + if rebootIfNeeded { + if err = GuestShutdown(vmr, client, true); err != nil { + return + } + stopped = true + rebootRequired = false + } else { + return rebootRequired, errors.New(ConfigQemu_Error_UnableToUpdateWithoutReboot) } } } - // Migrate VM - if newConfig.Node != currentConfig.Node { + // TODO GuestHasPendingChanges() has the current vm config technically. We can use this to avoid an extra API call. + if len(markedDisks.Move) != 0 { // Moving disks changes the disk id. we need to get the config again if any disk was moved. + currentConfig, err = NewConfigQemuFromApi(vmr, client) + if err != nil { + return + } + } + + if newConfig.Node != currentConfig.Node { // Migrate VM vmr.SetNode(currentConfig.Node) _, err = client.MigrateNode(vmr, newConfig.Node, true) if err != nil { @@ -896,27 +926,39 @@ func (newConfig ConfigQemu) setAdvanced(currentConfig *ConfigQemu, rebootIfNeede if err != nil { return } - exitStatus, err = client.PutWithTask(params, "/nodes/"+vmr.node+"/"+vmr.vmType+"/"+strconv.Itoa(vmr.vmId)+"/config") + exitStatus, err = client.PutWithTask(params, url) if err != nil { return false, fmt.Errorf("error updating VM: %v, error status: %s (params: %v)", err, exitStatus, params) } - if !rebootRequired { + if !rebootRequired && !stopped { // only check if reboot is required if the vm is not already stopped rebootRequired, err = GuestHasPendingChanges(vmr, client) if err != nil { return } } - if rebootRequired && rebootIfNeeded { - if err = GuestReboot(vmr, client); err != nil { - return + if stopped { // start vm if it was stopped + if rebootIfNeeded { + if err = GuestStart(vmr, client); err != nil { + return + } + stopped = false + rebootRequired = false + } else { + return true, nil + } + } else if rebootRequired { // reboot vm if it is running + if rebootIfNeeded { + if err = GuestReboot(vmr, client); err != nil { + return + } + rebootRequired = false + } else { + return rebootRequired, nil } - rebootRequired = false } - } else { - // Create - + } else { // Create _, params, err = newConfig.mapToApiValues(ConfigQemu{}) if err != nil { return diff --git a/proxmox/config_qemu_disk.go b/proxmox/config_qemu_disk.go index 3d843f9e..88c506c0 100644 --- a/proxmox/config_qemu_disk.go +++ b/proxmox/config_qemu_disk.go @@ -1002,6 +1002,40 @@ type QemuStorages struct { VirtIO *QemuVirtIODisks `json:"virtio,omitempty"` } +// Return the cloud init disk that should be removed. +func (newStorages QemuStorages) cloudInitRemove(currentStorages QemuStorages) string { + newCloudInit := newStorages.listCloudInitDisk() + currentCloudInit := currentStorages.listCloudInitDisk() + if newCloudInit != "" && currentCloudInit != "" && newCloudInit != currentCloudInit { + return currentCloudInit + } + return "" +} + +func (q QemuStorages) listCloudInitDisk() string { + if q.Ide != nil { + if disk := q.Ide.listCloudInitDisk(); disk != "" { + return disk + } + } + if q.Sata != nil { + if disk := q.Sata.listCloudInitDisk(); disk != "" { + return disk + } + } + if q.Scsi != nil { + if disk := q.Scsi.listCloudInitDisk(); disk != "" { + return disk + } + } + if q.VirtIO != nil { + if disk := q.VirtIO.listCloudInitDisk(); disk != "" { + return disk + } + } + return "" +} + func (storages QemuStorages) mapToApiValues(currentStorages QemuStorages, vmID, linkedVmId uint, params map[string]interface{}) (delete string) { if storages.Ide != nil { delete = storages.Ide.mapToApiValues(currentStorages.Ide, vmID, linkedVmId, params, delete) diff --git a/proxmox/config_qemu_disk_ide.go b/proxmox/config_qemu_disk_ide.go index 2a02b6f9..758ef139 100644 --- a/proxmox/config_qemu_disk_ide.go +++ b/proxmox/config_qemu_disk_ide.go @@ -56,6 +56,16 @@ type QemuIdeDisks struct { Disk_3 *QemuIdeStorage `json:"3,omitempty"` } +func (q QemuIdeDisks) listCloudInitDisk() string { + diskMap := q.mapToIntMap() + for i := range diskMap { + if diskMap[i] != nil && diskMap[i].CloudInit != nil { + return "ide" + strconv.Itoa(int(i)) + } + } + return "" +} + func (disks QemuIdeDisks) mapToApiValues(currentDisks *QemuIdeDisks, vmID, LinkedVmId uint, params map[string]interface{}, delete string) string { tmpCurrentDisks := QemuIdeDisks{} if currentDisks != nil { diff --git a/proxmox/config_qemu_disk_sata.go b/proxmox/config_qemu_disk_sata.go index f5e364b4..eea90c6c 100644 --- a/proxmox/config_qemu_disk_sata.go +++ b/proxmox/config_qemu_disk_sata.go @@ -58,6 +58,16 @@ type QemuSataDisks struct { Disk_5 *QemuSataStorage `json:"5,omitempty"` } +func (q QemuSataDisks) listCloudInitDisk() string { + diskMap := q.mapToIntMap() + for i := range diskMap { + if diskMap[i] != nil && diskMap[i].CloudInit != nil { + return "sata" + strconv.Itoa(int(i)) + } + } + return "" +} + func (disks QemuSataDisks) mapToApiValues(currentDisks *QemuSataDisks, vmID, LinkedVmId uint, params map[string]interface{}, delete string) string { tmpCurrentDisks := QemuSataDisks{} if currentDisks != nil { diff --git a/proxmox/config_qemu_disk_scsi.go b/proxmox/config_qemu_disk_scsi.go index 41842fa7..623899ca 100644 --- a/proxmox/config_qemu_disk_scsi.go +++ b/proxmox/config_qemu_disk_scsi.go @@ -87,6 +87,16 @@ type QemuScsiDisks struct { Disk_30 *QemuScsiStorage `json:"30,omitempty"` } +func (q QemuScsiDisks) listCloudInitDisk() string { + diskMap := q.mapToIntMap() + for i := range diskMap { + if diskMap[i] != nil && diskMap[i].CloudInit != nil { + return "scsi" + strconv.Itoa(int(i)) + } + } + return "" +} + func (disks QemuScsiDisks) mapToApiValues(currentDisks *QemuScsiDisks, vmID, linkedVmId uint, params map[string]interface{}, delete string) string { tmpCurrentDisks := QemuScsiDisks{} if currentDisks != nil { diff --git a/proxmox/config_qemu_disk_test.go b/proxmox/config_qemu_disk_test.go index 4864d661..b8e465ac 100644 --- a/proxmox/config_qemu_disk_test.go +++ b/proxmox/config_qemu_disk_test.go @@ -834,6 +834,69 @@ func Test_qemuDiskShort_Validate(t *testing.T) { } } +func Test_QemuStorages_cloudInitRemove(t *testing.T) { + type testInput struct { + currentStorages QemuStorages + newStorages QemuStorages + } + tests := []struct { + name string + input testInput + output string + }{ + {name: "Different Slot, Different Type", + input: testInput{ + currentStorages: QemuStorages{ + Sata: &QemuSataDisks{Disk_2: &QemuSataStorage{CloudInit: &QemuCloudInitDisk{Format: QemuDiskFormat_Raw, Storage: "Test"}}}}, + newStorages: QemuStorages{ + Scsi: &QemuScsiDisks{Disk_8: &QemuScsiStorage{CloudInit: &QemuCloudInitDisk{Format: QemuDiskFormat_Raw, Storage: "Test"}}}}, + }, + output: "sata2", + }, + {name: "Different Slot, Same Type", + input: testInput{ + currentStorages: QemuStorages{ + Ide: &QemuIdeDisks{Disk_1: &QemuIdeStorage{CloudInit: &QemuCloudInitDisk{Format: QemuDiskFormat_Raw, Storage: "Test"}}}}, + newStorages: QemuStorages{ + Ide: &QemuIdeDisks{Disk_3: &QemuIdeStorage{CloudInit: &QemuCloudInitDisk{Format: QemuDiskFormat_Raw, Storage: "Test"}}}}, + }, + output: "ide1", + }, + {name: "Same Slot", + input: testInput{ + currentStorages: QemuStorages{ + Ide: &QemuIdeDisks{Disk_2: &QemuIdeStorage{CloudInit: &QemuCloudInitDisk{Format: QemuDiskFormat_Raw, Storage: "Test"}}}}, + newStorages: QemuStorages{ + Ide: &QemuIdeDisks{Disk_2: &QemuIdeStorage{CloudInit: &QemuCloudInitDisk{Format: QemuDiskFormat_Raw, Storage: "Test"}}}}, + }, + output: "", + }, + {name: "Same Slot, CloudInit Disk", + input: testInput{ + currentStorages: QemuStorages{ + Ide: &QemuIdeDisks{Disk_2: &QemuIdeStorage{CloudInit: &QemuCloudInitDisk{Format: QemuDiskFormat_Raw, Storage: "Test"}}}}, + newStorages: QemuStorages{ + Ide: &QemuIdeDisks{Disk_2: &QemuIdeStorage{Disk: &QemuIdeDisk{Format: QemuDiskFormat_Raw, Storage: "Test"}}}}, + }, + output: "", + }, + {name: "Same Slot, Disk CloudInit", + input: testInput{ + currentStorages: QemuStorages{ + Sata: &QemuSataDisks{Disk_4: &QemuSataStorage{Disk: &QemuSataDisk{Format: QemuDiskFormat_Raw, Storage: "Test"}}}}, + newStorages: QemuStorages{ + Sata: &QemuSataDisks{Disk_4: &QemuSataStorage{CloudInit: &QemuCloudInitDisk{Format: QemuDiskFormat_Raw, Storage: "Test"}}}}, + }, + output: "", + }, + } + for _, test := range tests { + t.Run(test.name, func(*testing.T) { + require.Equal(t, test.output, test.input.newStorages.cloudInitRemove(test.input.currentStorages), test.name) + }) + } +} + func Test_QemuStorages_markDiskChanges(t *testing.T) { format_Raw := QemuDiskFormat_Raw tests := []struct { diff --git a/proxmox/config_qemu_disk_virtio.go b/proxmox/config_qemu_disk_virtio.go index 27acb900..8e7f2827 100644 --- a/proxmox/config_qemu_disk_virtio.go +++ b/proxmox/config_qemu_disk_virtio.go @@ -70,6 +70,16 @@ type QemuVirtIODisks struct { Disk_15 *QemuVirtIOStorage `json:"15,omitempty"` } +func (q QemuVirtIODisks) listCloudInitDisk() string { + diskMap := q.mapToIntMap() + for i := range diskMap { + if diskMap[i] != nil && diskMap[i].CloudInit != nil { + return "virtio" + strconv.Itoa(int(i)) + } + } + return "" +} + func (disks QemuVirtIODisks) mapToApiValues(currentDisks *QemuVirtIODisks, vmID, linkedVmId uint, params map[string]interface{}, delete string) string { tmpCurrentDisks := QemuVirtIODisks{} if currentDisks != nil {