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 <abhishek.mrt22@gmail.com>

* address review

Signed-off-by: Abhishek Kumar <abhishek.mrt22@gmail.com>

---------

Signed-off-by: Abhishek Kumar <abhishek.mrt22@gmail.com>
This commit is contained in:
Abhishek Kumar 2026-01-30 14:07:22 +05:30 committed by GitHub
parent 81f16b6261
commit c1c1b0e765
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 339 additions and 65 deletions

View File

@ -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,

View File

@ -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
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
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)"
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
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 nodes 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
# Derive host from nodes 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"
}
}'
}
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",
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;
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 // "-")
}]
}
{
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
;;

View File

@ -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,

View File

@ -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());
}
}

View File

@ -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"
;;