This is an automated email from the ASF dual-hosted git repository.

harikrishna pushed a commit to branch 4.22
in repository https://gitbox.apache.org/repos/asf/cloudstack.git


The following commit(s) were added to refs/heads/4.22 by this push:
     new c1c1b0e7659 extension: improve host vm power reporting (#11619)
c1c1b0e7659 is described below

commit c1c1b0e7659d82a1abeb450ff40a3013cc0084eb
Author: Abhishek Kumar <[email protected]>
AuthorDate: Fri Jan 30 14:07:22 2026 +0530

    extension: improve host vm power reporting (#11619)
    
    * extension/proxmox: improve host vm power reporting
    
    Add `statuses` action in extensions to report VM power states
    
    This PR introduces support for retrieving the power state of all VMs on a 
host directly from an extension using the new `statuses` action.
    When available, this provides a single aggregated response, reducing the 
need for multiple calls.
    
    If the extension does not implement `statuses`, the server will gracefully 
fall back to querying individual VMs using the existing `status` action.
    
    This helps with updating the host in CloudStack after out-of-band 
migrations for the VM.
    
    Signed-off-by: Abhishek Kumar <[email protected]>
    
    * address review
    
    Signed-off-by: Abhishek Kumar <[email protected]>
    
    ---------
    
    Signed-off-by: Abhishek Kumar <[email protected]>
---
 extensions/HyperV/hyperv.py                        |  24 +++
 extensions/Proxmox/proxmox.sh                      | 168 +++++++++++++--------
 .../ExternalPathPayloadProvisioner.java            |  65 +++++++-
 .../ExternalPathPayloadProvisionerTest.java        | 148 ++++++++++++++++++
 .../hypervisor/external/provisioner/provisioner.sh |  11 ++
 5 files changed, 345 insertions(+), 71 deletions(-)

diff --git a/extensions/HyperV/hyperv.py b/extensions/HyperV/hyperv.py
index c9b1d4da77e..8e2858d3cae 100755
--- a/extensions/HyperV/hyperv.py
+++ b/extensions/HyperV/hyperv.py
@@ -210,6 +210,29 @@ class HyperVManager:
             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 fc27f2f3075..459b744f44d 100755
--- a/extensions/Proxmox/proxmox.sh
+++ b/extensions/Proxmox/proxmox.sh
@@ -64,7 +64,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() {
@@ -206,6 +206,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"
@@ -331,71 +335,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 // "-")
+            }]
+        }
     '
 }
 
@@ -463,9 +498,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
@@ -492,6 +527,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 92205b13c6f..fa3f4de5026 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.StartCommand;
 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<String> TRIVIAL_ACTIONS = Arrays.asList(
-            "status"
+            "status", "statuses"
     );
 
     @Override
@@ -456,7 +457,7 @@ public class ExternalPathPayloadProvisioner extends 
ManagerBase implements Exter
     @Override
     public Map<String, HostVmStateReportEntry> getHostVmStateReport(long 
hostId, String extensionName,
                             String extensionRelativePath) {
-        final Map<String, HostVmStateReportEntry> vmStates = new HashMap<>();
+        Map<String, HostVmStateReportEntry> vmStates = new HashMap<>();
         String extensionPath = getExtensionCheckedPath(extensionName, 
extensionRelativePath);
         if (StringUtils.isEmpty(extensionPath)) {
             return vmStates;
@@ -466,14 +467,20 @@ public class ExternalPathPayloadProvisioner extends 
ManagerBase implements Exter
             logger.error("Host with ID: {} not found", hostId);
             return vmStates;
         }
+        Map<String, Map<String, String>> 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<UserVmVO> allVms = _uservmDao.listByHostId(hostId);
         allVms.addAll(_uservmDao.listByLastHostId(hostId));
         if (CollectionUtils.isEmpty(allVms)) {
             logger.debug("No VMs found for the {}", host);
             return vmStates;
         }
-        Map<String, Map<String, String>> 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 @@ public class ExternalPathPayloadProvisioner extends 
ManagerBase implements Exter
             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 @@ public class ExternalPathPayloadProvisioner extends 
ManagerBase implements Exter
         }
     }
 
-    private VirtualMachine.PowerState getVmPowerState(UserVmVO userVmVO, 
Map<String, Map<String, String>> accessDetails,
+    protected VirtualMachine.PowerState getVmPowerState(UserVmVO userVmVO, 
Map<String, Map<String, String>> accessDetails,
                   String extensionName, String extensionPath) {
         VirtualMachineTO virtualMachineTO = getVirtualMachineTO(userVmVO);
         accessDetails.put(ApiConstants.VIRTUAL_MACHINE, 
virtualMachineTO.getExternalDetails());
@@ -740,6 +747,46 @@ public class ExternalPathPayloadProvisioner extends 
ManagerBase implements Exter
         }
         return parsePowerStateFromResponse(userVmVO, result.second());
     }
+
+    protected Map<String, HostVmStateReportEntry> getVmPowerStates(Host host,
+                   Map<String, Map<String, String>> accessDetails, String 
extensionName, String extensionPath) {
+        Map<String, Object> modifiedDetails = loadAccessDetails(accessDetails, 
null);
+        logger.debug("Trying to get VM power statuses from the external system 
for {}", host);
+        Pair<Boolean, String> 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<String, HostVmStateReportEntry> states = new HashMap<>();
+            for (Map.Entry<String, com.google.gson.JsonElement> 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<Boolean, String> prepareExternalProvisioningInternal(String 
extensionName, String filename,
                              String vmUUID, Map<String, Object> accessDetails, 
int wait) {
         return executeExternalCommand(extensionName, "prepare", accessDetails, 
wait,
@@ -783,6 +830,12 @@ public class ExternalPathPayloadProvisioner extends 
ManagerBase implements Exter
                 String.format("Failed to get the instance power status %s on 
external system", vmUUID), filename);
     }
 
+    public Pair<Boolean, String> getInstanceStatusesOnExternalSystem(String 
extensionName, String filename,
+                               String hostName, Map<String, Object> 
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<Boolean, String> getInstanceConsoleOnExternalSystem(String 
extensionName, String filename,
                             String vmUUID, Map<String, Object> 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 d0a396f7a94..e8ab92c986e 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.StartCommand;
 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 class ExternalPathPayloadProvisionerTest {
         assertNull(result);
     }
 
+    @Test
+    public void getVmPowerStatesReturnsValidStatesWhenResponseIsSuccessful() {
+        Host host = mock(Host.class);
+        when(host.getId()).thenReturn(1L);
+        when(host.getName()).thenReturn("test-host");
+
+        Map<String, Map<String, String>> accessDetails = new HashMap<>();
+        doReturn(new Pair<>(true, 
"{\"status\":\"success\",\"power_state\":{\"vm1\":\"PowerOn\",\"vm2\":\"PowerOff\"}}"))
+            
.when(provisioner).getInstanceStatusesOnExternalSystem(anyString(), 
anyString(), anyString(), anyMap(), anyInt());
+
+        Map<String, HostVmStateReportEntry> 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<String, Map<String, String>> accessDetails = new HashMap<>();
+        doReturn(new Pair<>(false, "Error")).when(provisioner)
+            .getInstanceStatusesOnExternalSystem(anyString(), anyString(), 
anyString(), anyMap(), anyInt());
+
+        Map<String, HostVmStateReportEntry> 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 class ExternalPathPayloadProvisionerTest {
         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<String, Map<String, String>> accessDetails = new HashMap<>();
+        doReturn(new Pair<>(true, "")).when(provisioner)
+            .getInstanceStatusesOnExternalSystem(anyString(), anyString(), 
anyString(), anyMap(), anyInt());
+
+        Map<String, HostVmStateReportEntry> 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<String, Map<String, String>> accessDetails = new HashMap<>();
+        doReturn(new Pair<>(true, 
"{\"status\":\"failure\"}")).when(provisioner)
+            .getInstanceStatusesOnExternalSystem(anyString(), anyString(), 
anyString(), anyMap(), anyInt());
+
+        Map<String, HostVmStateReportEntry> 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<String, Map<String, String>> accessDetails = new HashMap<>();
+        doReturn(new Pair<>(true, 
"{\"status\":\"success\"}")).when(provisioner)
+            .getInstanceStatusesOnExternalSystem(anyString(), anyString(), 
anyString(), anyMap(), anyInt());
+
+        Map<String, HostVmStateReportEntry> 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<String, Map<String, String>> accessDetails = new HashMap<>();
+        doReturn(new Pair<>(true, "{status:success")).when(provisioner)
+            .getInstanceStatusesOnExternalSystem(anyString(), anyString(), 
anyString(), anyMap(), anyInt());
+
+        Map<String, HostVmStateReportEntry> 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<Boolean, String> 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<Boolean, String> 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<Boolean, String> 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<Boolean, String> 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<Boolean, String> 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 f067d892f1f..c92ac36f466 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"
         ;;

Reply via email to