diff --git a/extensions/HyperV/hyperv.py b/extensions/HyperV/hyperv.py index c9b1d4da77e3..8e2858d3cae5 100755 --- a/extensions/HyperV/hyperv.py +++ b/extensions/HyperV/hyperv.py @@ -210,6 +210,29 @@ def status(self): power_state = "poweroff" succeed({"status": "success", "power_state": power_state}) + def statuses(self): + command = 'Get-VM | Select-Object Name, State | ConvertTo-Json' + output = self.run_ps(command) + if not output or output.strip() in ("", "null"): + vms = [] + else: + try: + vms = json.loads(output) + except json.JSONDecodeError: + fail("Failed to parse VM status output: " + output) + power_state = {} + if isinstance(vms, dict): + vms = [vms] + for vm in vms: + state = vm["State"].strip().lower() + if state == "running": + power_state[vm["Name"]] = "poweron" + elif state == "off": + power_state[vm["Name"]] = "poweroff" + else: + power_state[vm["Name"]] = "unknown" + succeed({"status": "success", "power_state": power_state}) + def delete(self): try: self.run_ps_int(f'Remove-VM -Name "{self.data["vmname"]}" -Force') @@ -286,6 +309,7 @@ def main(): "reboot": manager.reboot, "delete": manager.delete, "status": manager.status, + "statuses": manager.statuses, "getconsole": manager.get_console, "suspend": manager.suspend, "resume": manager.resume, diff --git a/extensions/Proxmox/proxmox.sh b/extensions/Proxmox/proxmox.sh index 23f30311e2b7..5db2b0ce4c55 100755 --- a/extensions/Proxmox/proxmox.sh +++ b/extensions/Proxmox/proxmox.sh @@ -60,7 +60,7 @@ parse_json() { token="${host_token:-$extension_token}" secret="${host_secret:-$extension_secret}" - check_required_fields vm_internal_name url user token secret node + check_required_fields url user token secret node } urlencode() { @@ -202,6 +202,10 @@ prepare() { create() { if [[ -z "$vm_name" ]]; then + if [[ -z "$vm_internal_name" ]]; then + echo '{"error":"Missing required fields: vm_internal_name"}' + exit 1 + fi vm_name="$vm_internal_name" fi validate_name "VM" "$vm_name" @@ -325,71 +329,102 @@ get_node_host() { echo "$host" } - get_console() { - check_required_fields node vmid - - local api_resp port ticket - if ! api_resp="$(call_proxmox_api POST "/nodes/${node}/qemu/${vmid}/vncproxy")"; then - echo "$api_resp" | jq -c '{status:"error", error:(.errors.curl // (.errors|tostring))}' - exit 1 - fi - - port="$(echo "$api_resp" | jq -re '.data.port // empty' 2>/dev/null || true)" - ticket="$(echo "$api_resp" | jq -re '.data.ticket // empty' 2>/dev/null || true)" - - if [[ -z "$port" || -z "$ticket" ]]; then - jq -n --arg raw "$api_resp" \ - '{status:"error", error:"Proxmox response missing port/ticket", upstream:$raw}' - exit 1 - fi - - # Derive host from node’s network info - local host - host="$(get_node_host)" - if [[ -z "$host" ]]; then - jq -n --arg msg "Could not determine host IP for node $node" \ - '{status:"error", error:$msg}' - exit 1 - fi - - jq -n \ - --arg host "$host" \ - --arg port "$port" \ - --arg password "$ticket" \ - --argjson passwordonetimeuseonly true \ - '{ - status: "success", - message: "Console retrieved", - console: { - host: $host, - port: $port, - password: $password, - passwordonetimeuseonly: $passwordonetimeuseonly, - protocol: "vnc" - } - }' - } +get_console() { + check_required_fields node vmid -list_snapshots() { - snapshot_response=$(call_proxmox_api GET "/nodes/${node}/qemu/${vmid}/snapshot") - echo "$snapshot_response" | jq ' - def to_date: - if . == "-" then "-" - elif . == null then "-" - else (. | tonumber | strftime("%Y-%m-%d %H:%M:%S")) - end; + local api_resp port ticket + if ! api_resp="$(call_proxmox_api POST "/nodes/${node}/qemu/${vmid}/vncproxy")"; then + echo "$api_resp" | jq -c '{status:"error", error:(.errors.curl // (.errors|tostring))}' + exit 1 + fi + + port="$(echo "$api_resp" | jq -re '.data.port // empty' 2>/dev/null || true)" + ticket="$(echo "$api_resp" | jq -re '.data.ticket // empty' 2>/dev/null || true)" + + if [[ -z "$port" || -z "$ticket" ]]; then + jq -n --arg raw "$api_resp" \ + '{status:"error", error:"Proxmox response missing port/ticket", upstream:$raw}' + exit 1 + fi + + # Derive host from node’s network info + local host + host="$(get_node_host)" + if [[ -z "$host" ]]; then + jq -n --arg msg "Could not determine host IP for node $node" \ + '{status:"error", error:$msg}' + exit 1 + fi + + jq -n \ + --arg host "$host" \ + --arg port "$port" \ + --arg password "$ticket" \ + --argjson passwordonetimeuseonly true \ + '{ + status: "success", + message: "Console retrieved", + console: { + host: $host, + port: $port, + password: $password, + passwordonetimeuseonly: $passwordonetimeuseonly, + protocol: "vnc" + } + }' +} + +statuses() { + local response + response=$(call_proxmox_api GET "/nodes/${node}/qemu") + + if [[ -z "$response" ]]; then + echo '{"status":"error","message":"empty response from Proxmox API"}' + return 1 + fi + + if ! echo "$response" | jq empty >/dev/null 2>&1; then + echo '{"status":"error","message":"invalid JSON response from Proxmox API"}' + return 1 + fi + + echo "$response" | jq -c ' + def map_state(s): + if s=="running" then "poweron" + elif s=="stopped" then "poweroff" + else "unknown" end; { status: "success", - printmessage: "true", - message: [.data[] | { - name: .name, - snaptime: ((.snaptime // "-") | to_date), - description: .description, - parent: (.parent // "-"), - vmstate: (.vmstate // "-") - }] - } + power_state: ( + .data + | map(select(.template != 1)) + | map({ ( (.name // (.vmid|tostring)) ): map_state(.status) }) + | add // {} + ) + }' +} + +list_snapshots() { + snapshot_response=$(call_proxmox_api GET "/nodes/${node}/qemu/${vmid}/snapshot") + echo "$snapshot_response" | jq ' + def to_date: + if . == "-" then "-" + elif . == null then "-" + else (. | tonumber | strftime("%Y-%m-%d %H:%M:%S")) + end; + + { + status: "success", + printmessage: "true", + message: [.data[] | { + name: .name, + snaptime: ((.snaptime // "-") | to_date), + description: .description, + parent: (.parent // "-"), + vmstate: (.vmstate // "-") + }] + } ' } @@ -457,9 +492,9 @@ parse_json "$parameters" || exit 1 cleanup_vm=0 cleanup() { - if (( cleanup_vm == 1 )); then - execute_and_wait DELETE "/nodes/${node}/qemu/${vmid}" - fi + if (( cleanup_vm == 1 )); then + execute_and_wait DELETE "/nodes/${node}/qemu/${vmid}" + fi } trap cleanup EXIT @@ -486,6 +521,9 @@ case $action in status) status ;; + statuses) + statuses + ;; getconsole) get_console ;; diff --git a/plugins/hypervisors/external/src/main/java/org/apache/cloudstack/hypervisor/external/provisioner/ExternalPathPayloadProvisioner.java b/plugins/hypervisors/external/src/main/java/org/apache/cloudstack/hypervisor/external/provisioner/ExternalPathPayloadProvisioner.java index 92205b13c6ff..fa3f4de50265 100644 --- a/plugins/hypervisors/external/src/main/java/org/apache/cloudstack/hypervisor/external/provisioner/ExternalPathPayloadProvisioner.java +++ b/plugins/hypervisors/external/src/main/java/org/apache/cloudstack/hypervisor/external/provisioner/ExternalPathPayloadProvisioner.java @@ -71,6 +71,7 @@ import com.cloud.agent.api.StopAnswer; import com.cloud.agent.api.StopCommand; import com.cloud.agent.api.to.VirtualMachineTO; +import com.cloud.host.Host; import com.cloud.host.HostVO; import com.cloud.host.dao.HostDao; import com.cloud.hypervisor.ExternalProvisioner; @@ -128,7 +129,7 @@ public class ExternalPathPayloadProvisioner extends ManagerBase implements Exter private ExecutorService payloadCleanupExecutor; private ScheduledExecutorService payloadCleanupScheduler; private static final List TRIVIAL_ACTIONS = Arrays.asList( - "status" + "status", "statuses" ); @Override @@ -456,7 +457,7 @@ public StopAnswer expungeInstance(String hostName, String extensionName, String @Override public Map getHostVmStateReport(long hostId, String extensionName, String extensionRelativePath) { - final Map vmStates = new HashMap<>(); + Map vmStates = new HashMap<>(); String extensionPath = getExtensionCheckedPath(extensionName, extensionRelativePath); if (StringUtils.isEmpty(extensionPath)) { return vmStates; @@ -466,14 +467,20 @@ public Map getHostVmStateReport(long hostId, Str logger.error("Host with ID: {} not found", hostId); return vmStates; } + Map> accessDetails = + extensionsManager.getExternalAccessDetails(host, null); + vmStates = getVmPowerStates(host, accessDetails, extensionName, extensionPath); + if (vmStates != null) { + logger.debug("Found {} VMs on the host {}", vmStates.size(), host); + return vmStates; + } + vmStates = new HashMap<>(); List allVms = _uservmDao.listByHostId(hostId); allVms.addAll(_uservmDao.listByLastHostId(hostId)); if (CollectionUtils.isEmpty(allVms)) { logger.debug("No VMs found for the {}", host); return vmStates; } - Map> accessDetails = - extensionsManager.getExternalAccessDetails(host, null); for (UserVmVO vm: allVms) { VirtualMachine.PowerState powerState = getVmPowerState(vm, accessDetails, extensionName, extensionPath); vmStates.put(vm.getInstanceName(), new HostVmStateReportEntry(powerState, "host-" + hostId)); @@ -714,7 +721,7 @@ protected VirtualMachine.PowerState parsePowerStateFromResponse(UserVmVO userVmV return getPowerStateFromString(response); } try { - JsonObject jsonObj = new JsonParser().parse(response).getAsJsonObject(); + JsonObject jsonObj = JsonParser.parseString(response).getAsJsonObject(); String powerState = jsonObj.has("power_state") ? jsonObj.get("power_state").getAsString() : null; return getPowerStateFromString(powerState); } catch (Exception e) { @@ -724,7 +731,7 @@ protected VirtualMachine.PowerState parsePowerStateFromResponse(UserVmVO userVmV } } - private VirtualMachine.PowerState getVmPowerState(UserVmVO userVmVO, Map> accessDetails, + protected VirtualMachine.PowerState getVmPowerState(UserVmVO userVmVO, Map> accessDetails, String extensionName, String extensionPath) { VirtualMachineTO virtualMachineTO = getVirtualMachineTO(userVmVO); accessDetails.put(ApiConstants.VIRTUAL_MACHINE, virtualMachineTO.getExternalDetails()); @@ -740,6 +747,46 @@ private VirtualMachine.PowerState getVmPowerState(UserVmVO userVmVO, Map getVmPowerStates(Host host, + Map> accessDetails, String extensionName, String extensionPath) { + Map modifiedDetails = loadAccessDetails(accessDetails, null); + logger.debug("Trying to get VM power statuses from the external system for {}", host); + Pair result = getInstanceStatusesOnExternalSystem(extensionName, extensionPath, + host.getName(), modifiedDetails, AgentManager.Wait.value()); + if (!result.first()) { + logger.warn("Failure response received while trying to fetch the power statuses for {} : {}", + host, result.second()); + return null; + } + if (StringUtils.isBlank(result.second())) { + logger.warn("Empty response while trying to fetch VM power statuses for host: {}", host); + return null; + } + try { + JsonObject jsonObj = JsonParser.parseString(result.second()).getAsJsonObject(); + if (!jsonObj.has("status") || !"success".equalsIgnoreCase(jsonObj.get("status").getAsString())) { + logger.warn("Invalid status in response while trying to fetch VM power statuses for host: {}: {}", + host, result.second()); + return null; + } + if (!jsonObj.has("power_state") || !jsonObj.get("power_state").isJsonObject()) { + logger.warn("Missing or invalid power_state in response for host: {}: {}", host, result.second()); + return null; + } + JsonObject powerStates = jsonObj.getAsJsonObject("power_state"); + Map states = new HashMap<>(); + for (Map.Entry entry : powerStates.entrySet()) { + VirtualMachine.PowerState powerState = getPowerStateFromString(entry.getValue().getAsString()); + states.put(entry.getKey(), new HostVmStateReportEntry(powerState, "host-" + host.getId())); + } + return states; + } catch (Exception e) { + logger.warn("Failed to parse VM power statuses response for host: {}: {}", host, e.getMessage()); + return null; + } + } + public Pair prepareExternalProvisioningInternal(String extensionName, String filename, String vmUUID, Map accessDetails, int wait) { return executeExternalCommand(extensionName, "prepare", accessDetails, wait, @@ -783,6 +830,12 @@ public Pair getInstanceStatusOnExternalSystem(String extensionN String.format("Failed to get the instance power status %s on external system", vmUUID), filename); } + public Pair getInstanceStatusesOnExternalSystem(String extensionName, String filename, + String hostName, Map accessDetails, int wait) { + return executeExternalCommand(extensionName, "statuses", accessDetails, wait, + String.format("Failed to get the %s instances power status on external system", hostName), filename); + } + public Pair getInstanceConsoleOnExternalSystem(String extensionName, String filename, String vmUUID, Map accessDetails, int wait) { return executeExternalCommand(extensionName, "getconsole", accessDetails, wait, diff --git a/plugins/hypervisors/external/src/test/java/org/apache/cloudstack/hypervisor/external/provisioner/ExternalPathPayloadProvisionerTest.java b/plugins/hypervisors/external/src/test/java/org/apache/cloudstack/hypervisor/external/provisioner/ExternalPathPayloadProvisionerTest.java index d0a396f7a94c..e8ab92c986e9 100644 --- a/plugins/hypervisors/external/src/test/java/org/apache/cloudstack/hypervisor/external/provisioner/ExternalPathPayloadProvisionerTest.java +++ b/plugins/hypervisors/external/src/test/java/org/apache/cloudstack/hypervisor/external/provisioner/ExternalPathPayloadProvisionerTest.java @@ -79,6 +79,7 @@ import com.cloud.agent.api.StopAnswer; import com.cloud.agent.api.StopCommand; import com.cloud.agent.api.to.VirtualMachineTO; +import com.cloud.host.Host; import com.cloud.host.HostVO; import com.cloud.host.dao.HostDao; import com.cloud.hypervisor.Hypervisor; @@ -761,6 +762,37 @@ public void getVirtualMachineTOReturnsNullWhenVmIsNull() { assertNull(result); } + @Test + public void getVmPowerStatesReturnsValidStatesWhenResponseIsSuccessful() { + Host host = mock(Host.class); + when(host.getId()).thenReturn(1L); + when(host.getName()).thenReturn("test-host"); + + Map> accessDetails = new HashMap<>(); + doReturn(new Pair<>(true, "{\"status\":\"success\",\"power_state\":{\"vm1\":\"PowerOn\",\"vm2\":\"PowerOff\"}}")) + .when(provisioner).getInstanceStatusesOnExternalSystem(anyString(), anyString(), anyString(), anyMap(), anyInt()); + + Map result = provisioner.getVmPowerStates(host, accessDetails, "test-extension", "test-path"); + + assertNotNull(result); + assertEquals(2, result.size()); + assertEquals(VirtualMachine.PowerState.PowerOn, result.get("vm1").getState()); + assertEquals(VirtualMachine.PowerState.PowerOff, result.get("vm2").getState()); + } + + @Test + public void getVmPowerStatesReturnsNullWhenResponseIsFailure() { + Host host = mock(Host.class); + when(host.getName()).thenReturn("test-host"); + + Map> accessDetails = new HashMap<>(); + doReturn(new Pair<>(false, "Error")).when(provisioner) + .getInstanceStatusesOnExternalSystem(anyString(), anyString(), anyString(), anyMap(), anyInt()); + + Map result = provisioner.getVmPowerStates(host, accessDetails, "test-extension", "test-path"); + assertNull(result); + } + @Test public void getVirtualMachineTOReturnsValidTOWhenVmIsNotNull() { VirtualMachine vm = mock(VirtualMachine.class); @@ -986,4 +1018,120 @@ public void getExtensionConfigureErrorReturnsMessageWhenHostNameIsNull() { String result = provisioner.getExtensionConfigureError("test-extension", null); assertEquals("Extension: test-extension not configured", result); } + + @Test + public void getVmPowerStatesReturnsNullWhenResponseIsEmpty() { + Host host = mock(Host.class); + when(host.getName()).thenReturn("test-host"); + + Map> accessDetails = new HashMap<>(); + doReturn(new Pair<>(true, "")).when(provisioner) + .getInstanceStatusesOnExternalSystem(anyString(), anyString(), anyString(), anyMap(), anyInt()); + + Map result = provisioner.getVmPowerStates(host, accessDetails, "test-extension", "test-path"); + + assertNull(result); + } + + @Test + public void getVmPowerStatesReturnsNullWhenResponseHasInvalidStatus() { + Host host = mock(Host.class); + when(host.getName()).thenReturn("test-host"); + + Map> accessDetails = new HashMap<>(); + doReturn(new Pair<>(true, "{\"status\":\"failure\"}")).when(provisioner) + .getInstanceStatusesOnExternalSystem(anyString(), anyString(), anyString(), anyMap(), anyInt()); + + Map result = provisioner.getVmPowerStates(host, accessDetails, "test-extension", "test-path"); + + assertNull(result); + } + + @Test + public void getVmPowerStatesReturnsNullWhenPowerStateIsMissing() { + Host host = mock(Host.class); + when(host.getName()).thenReturn("test-host"); + + Map> accessDetails = new HashMap<>(); + doReturn(new Pair<>(true, "{\"status\":\"success\"}")).when(provisioner) + .getInstanceStatusesOnExternalSystem(anyString(), anyString(), anyString(), anyMap(), anyInt()); + + Map result = provisioner.getVmPowerStates(host, accessDetails, "test-extension", "test-path"); + + assertNull(result); + } + + @Test + public void getVmPowerStatesReturnsNullWhenResponseIsMalformed() { + Host host = mock(Host.class); + when(host.getName()).thenReturn("test-host"); + + Map> accessDetails = new HashMap<>(); + doReturn(new Pair<>(true, "{status:success")).when(provisioner) + .getInstanceStatusesOnExternalSystem(anyString(), anyString(), anyString(), anyMap(), anyInt()); + + Map result = provisioner.getVmPowerStates(host, accessDetails, "test-extension", "test-path"); + + assertNull(result); + } + + @Test + public void getInstanceStatusesOnExternalSystemReturnsSuccessWhenCommandExecutesSuccessfully() { + doReturn(new Pair<>(true, "success")).when(provisioner) + .executeExternalCommand(eq("test-extension"), eq("statuses"), anyMap(), eq(30), anyString(), eq("test-file")); + + Pair result = provisioner.getInstanceStatusesOnExternalSystem( + "test-extension", "test-file", "test-host", new HashMap<>(), 30); + + assertTrue(result.first()); + assertEquals("success", result.second()); + } + + @Test + public void getInstanceStatusesOnExternalSystemReturnsFailureWhenCommandFails() { + doReturn(new Pair<>(false, "error")).when(provisioner) + .executeExternalCommand(eq("test-extension"), eq("statuses"), anyMap(), eq(30), anyString(), eq("test-file")); + + Pair result = provisioner.getInstanceStatusesOnExternalSystem( + "test-extension", "test-file", "test-host", new HashMap<>(), 30); + + assertFalse(result.first()); + assertEquals("error", result.second()); + } + + @Test + public void getInstanceStatusesOnExternalSystemHandlesEmptyResponse() { + doReturn(new Pair<>(true, "")).when(provisioner) + .executeExternalCommand(eq("test-extension"), eq("statuses"), anyMap(), eq(30), anyString(), eq("test-file")); + + Pair result = provisioner.getInstanceStatusesOnExternalSystem( + "test-extension", "test-file", "test-host", new HashMap<>(), 30); + + assertTrue(result.first()); + assertEquals("", result.second()); + } + + @Test + public void getInstanceStatusesOnExternalSystemHandlesNullResponse() { + doReturn(new Pair<>(true, null)).when(provisioner) + .executeExternalCommand(eq("test-extension"), eq("statuses"), anyMap(), eq(30), anyString(), eq("test-file")); + + Pair result = provisioner.getInstanceStatusesOnExternalSystem( + "test-extension", "test-file", "test-host", new HashMap<>(), 30); + + assertTrue(result.first()); + assertNull(result.second()); + } + + @Test + public void getInstanceStatusesOnExternalSystemHandlesInvalidFilePath() { + doReturn(new Pair<>(false, "File not found")).when(provisioner) + .executeExternalCommand(eq("test-extension"), eq("statuses"), anyMap(), eq(30), anyString(), eq("invalid-file")); + + Pair result = provisioner.getInstanceStatusesOnExternalSystem( + "test-extension", "invalid-file", "test-host", new HashMap<>(), 30); + + assertFalse(result.first()); + assertEquals("File not found", result.second()); + } } diff --git a/scripts/vm/hypervisor/external/provisioner/provisioner.sh b/scripts/vm/hypervisor/external/provisioner/provisioner.sh index f067d892f1f3..c92ac36f4669 100755 --- a/scripts/vm/hypervisor/external/provisioner/provisioner.sh +++ b/scripts/vm/hypervisor/external/provisioner/provisioner.sh @@ -99,6 +99,14 @@ status() { echo '{"status": "success", "power_state": "poweron"}' } +statuses() { + parse_json "$1" || exit 1 + # This external system can not return an output like the following: + # {"status":"success","power_state":{"i-3-23-VM":"poweroff","i-2-25-VM":"poweron"}} + # CloudStack can fallback to retrieving the power state of the single VM using the "status" action + echo '{"status": "error", "message": "Not supported"}' +} + get_console() { parse_json "$1" || exit 1 local response @@ -145,6 +153,9 @@ case $action in status) status "$parameters" ;; + statuses) + statuses "$parameters" + ;; getconsole) get_console "$parameters" ;;