Skip to content

Commit bc826b8

Browse files
committed
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]>
1 parent 50fe265 commit bc826b8

File tree

5 files changed

+324
-51
lines changed

5 files changed

+324
-51
lines changed

extensions/HyperV/hyperv.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,29 @@ def status(self):
210210
power_state = "poweroff"
211211
succeed({"status": "success", "power_state": power_state})
212212

213+
def statuses(self):
214+
command = 'Get-VM | Select-Object Name, State | ConvertTo-Json'
215+
output = self.run_ps(command)
216+
if not output or output.strip() in ("", "null"):
217+
vms = []
218+
else:
219+
try:
220+
vms = json.loads(output)
221+
except json.JSONDecodeError:
222+
fail("Failed to parse VM status output: " + output)
223+
power_state = {}
224+
if isinstance(vms, dict):
225+
vms = [vms]
226+
for vm in vms:
227+
state = vm["State"].strip().lower()
228+
if state == "running":
229+
power_state[vm["Name"]] = "poweron"
230+
elif state == "off":
231+
power_state[vm["Name"]] = "poweroff"
232+
else:
233+
power_state[vm["Name"]] = "unknown"
234+
succeed({"status": "success", "power_state": power_state})
235+
213236
def delete(self):
214237
try:
215238
self.run_ps_int(f'Remove-VM -Name "{self.data["vmname"]}" -Force')
@@ -286,6 +309,7 @@ def main():
286309
"reboot": manager.reboot,
287310
"delete": manager.delete,
288311
"status": manager.status,
312+
"statuses": manager.statuses,
289313
"getconsole": manager.get_console,
290314
"suspend": manager.suspend,
291315
"resume": manager.resume,

extensions/Proxmox/proxmox.sh

Lines changed: 83 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ parse_json() {
6060
token="${host_token:-$extension_token}"
6161
secret="${host_secret:-$extension_secret}"
6262

63-
check_required_fields vm_internal_name url user token secret node
63+
check_required_fields url user token secret node
6464
}
6565

6666
urlencode() {
@@ -202,6 +202,10 @@ prepare() {
202202

203203
create() {
204204
if [[ -z "$vm_name" ]]; then
205+
if [[ -z "$vm_internal_name" ]]; then
206+
echo '{"error":"Missing required fields: vm_internal_name"}'
207+
exit 1
208+
fi
205209
vm_name="$vm_internal_name"
206210
fi
207211
validate_name "VM" "$vm_name"
@@ -325,50 +329,81 @@ get_node_host() {
325329
echo "$host"
326330
}
327331

328-
get_console() {
329-
check_required_fields node vmid
330-
331-
local api_resp port ticket
332-
if ! api_resp="$(call_proxmox_api POST "/nodes/${node}/qemu/${vmid}/vncproxy")"; then
333-
echo "$api_resp" | jq -c '{status:"error", error:(.errors.curl // (.errors|tostring))}'
334-
exit 1
335-
fi
336-
337-
port="$(echo "$api_resp" | jq -re '.data.port // empty' 2>/dev/null || true)"
338-
ticket="$(echo "$api_resp" | jq -re '.data.ticket // empty' 2>/dev/null || true)"
339-
340-
if [[ -z "$port" || -z "$ticket" ]]; then
341-
jq -n --arg raw "$api_resp" \
342-
'{status:"error", error:"Proxmox response missing port/ticket", upstream:$raw}'
343-
exit 1
344-
fi
345-
346-
# Derive host from node’s network info
347-
local host
348-
host="$(get_node_host)"
349-
if [[ -z "$host" ]]; then
350-
jq -n --arg msg "Could not determine host IP for node $node" \
351-
'{status:"error", error:$msg}'
352-
exit 1
353-
fi
354-
355-
jq -n \
356-
--arg host "$host" \
357-
--arg port "$port" \
358-
--arg password "$ticket" \
359-
--argjson passwordonetimeuseonly true \
360-
'{
361-
status: "success",
362-
message: "Console retrieved",
363-
console: {
364-
host: $host,
365-
port: $port,
366-
password: $password,
367-
passwordonetimeuseonly: $passwordonetimeuseonly,
368-
protocol: "vnc"
369-
}
370-
}'
371-
}
332+
get_console() {
333+
check_required_fields node vmid
334+
335+
local api_resp port ticket
336+
if ! api_resp="$(call_proxmox_api POST "/nodes/${node}/qemu/${vmid}/vncproxy")"; then
337+
echo "$api_resp" | jq -c '{status:"error", error:(.errors.curl // (.errors|tostring))}'
338+
exit 1
339+
fi
340+
341+
port="$(echo "$api_resp" | jq -re '.data.port // empty' 2>/dev/null || true)"
342+
ticket="$(echo "$api_resp" | jq -re '.data.ticket // empty' 2>/dev/null || true)"
343+
344+
if [[ -z "$port" || -z "$ticket" ]]; then
345+
jq -n --arg raw "$api_resp" \
346+
'{status:"error", error:"Proxmox response missing port/ticket", upstream:$raw}'
347+
exit 1
348+
fi
349+
350+
# Derive host from node’s network info
351+
local host
352+
host="$(get_node_host)"
353+
if [[ -z "$host" ]]; then
354+
jq -n --arg msg "Could not determine host IP for node $node" \
355+
'{status:"error", error:$msg}'
356+
exit 1
357+
fi
358+
359+
jq -n \
360+
--arg host "$host" \
361+
--arg port "$port" \
362+
--arg password "$ticket" \
363+
--argjson passwordonetimeuseonly true \
364+
'{
365+
status: "success",
366+
message: "Console retrieved",
367+
console: {
368+
host: $host,
369+
port: $port,
370+
password: $password,
371+
passwordonetimeuseonly: $passwordonetimeuseonly,
372+
protocol: "vnc"
373+
}
374+
}'
375+
}
376+
377+
statuses() {
378+
local response
379+
response=$(call_proxmox_api GET "/nodes/${node}/qemu")
380+
381+
if [[ -z "$response" ]]; then
382+
echo '{"status":"error","message":"empty response from Proxmox API"}'
383+
return 1
384+
fi
385+
386+
if ! echo "$response" | jq empty >/dev/null 2>&1; then
387+
echo '{"status":"error","message":"invalid JSON response from Proxmox API"}'
388+
return 1
389+
fi
390+
391+
echo "$response" | jq -c '
392+
def map_state(s):
393+
if s=="running" then "poweron"
394+
elif s=="stopped" then "poweroff"
395+
else "unknown" end;
396+
397+
{
398+
status: "success",
399+
power_state: (
400+
.data
401+
| map(select(.template != 1))
402+
| map({ ( (.name // (.vmid|tostring)) ): map_state(.status) })
403+
| add // {}
404+
)
405+
}'
406+
}
372407

373408
list_snapshots() {
374409
snapshot_response=$(call_proxmox_api GET "/nodes/${node}/qemu/${vmid}/snapshot")
@@ -486,6 +521,9 @@ case $action in
486521
status)
487522
status
488523
;;
524+
statuses)
525+
statuses
526+
;;
489527
getconsole)
490528
get_console
491529
;;

plugins/hypervisors/external/src/main/java/org/apache/cloudstack/hypervisor/external/provisioner/ExternalPathPayloadProvisioner.java

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
import com.cloud.agent.api.StopAnswer;
7272
import com.cloud.agent.api.StopCommand;
7373
import com.cloud.agent.api.to.VirtualMachineTO;
74+
import com.cloud.host.Host;
7475
import com.cloud.host.HostVO;
7576
import com.cloud.host.dao.HostDao;
7677
import com.cloud.hypervisor.ExternalProvisioner;
@@ -128,7 +129,7 @@ public class ExternalPathPayloadProvisioner extends ManagerBase implements Exter
128129
private ExecutorService payloadCleanupExecutor;
129130
private ScheduledExecutorService payloadCleanupScheduler;
130131
private static final List<String> TRIVIAL_ACTIONS = Arrays.asList(
131-
"status"
132+
"status", "statuses"
132133
);
133134

134135
@Override
@@ -456,7 +457,7 @@ public StopAnswer expungeInstance(String hostName, String extensionName, String
456457
@Override
457458
public Map<String, HostVmStateReportEntry> getHostVmStateReport(long hostId, String extensionName,
458459
String extensionRelativePath) {
459-
final Map<String, HostVmStateReportEntry> vmStates = new HashMap<>();
460+
Map<String, HostVmStateReportEntry> vmStates = new HashMap<>();
460461
String extensionPath = getExtensionCheckedPath(extensionName, extensionRelativePath);
461462
if (StringUtils.isEmpty(extensionPath)) {
462463
return vmStates;
@@ -466,14 +467,20 @@ public Map<String, HostVmStateReportEntry> getHostVmStateReport(long hostId, Str
466467
logger.error("Host with ID: {} not found", hostId);
467468
return vmStates;
468469
}
470+
Map<String, Map<String, String>> accessDetails =
471+
extensionsManager.getExternalAccessDetails(host, null);
472+
vmStates = getVmPowerStates(host, accessDetails, extensionName, extensionPath);
473+
if (vmStates != null) {
474+
logger.debug("Found {} VMs on the host {}", vmStates.size(), host);
475+
return vmStates;
476+
}
477+
vmStates = new HashMap<>();
469478
List<UserVmVO> allVms = _uservmDao.listByHostId(hostId);
470479
allVms.addAll(_uservmDao.listByLastHostId(hostId));
471480
if (CollectionUtils.isEmpty(allVms)) {
472481
logger.debug("No VMs found for the {}", host);
473482
return vmStates;
474483
}
475-
Map<String, Map<String, String>> accessDetails =
476-
extensionsManager.getExternalAccessDetails(host, null);
477484
for (UserVmVO vm: allVms) {
478485
VirtualMachine.PowerState powerState = getVmPowerState(vm, accessDetails, extensionName, extensionPath);
479486
vmStates.put(vm.getInstanceName(), new HostVmStateReportEntry(powerState, "host-" + hostId));
@@ -714,7 +721,7 @@ protected VirtualMachine.PowerState parsePowerStateFromResponse(UserVmVO userVmV
714721
return getPowerStateFromString(response);
715722
}
716723
try {
717-
JsonObject jsonObj = new JsonParser().parse(response).getAsJsonObject();
724+
JsonObject jsonObj = JsonParser.parseString(response).getAsJsonObject();
718725
String powerState = jsonObj.has("power_state") ? jsonObj.get("power_state").getAsString() : null;
719726
return getPowerStateFromString(powerState);
720727
} catch (Exception e) {
@@ -724,7 +731,7 @@ protected VirtualMachine.PowerState parsePowerStateFromResponse(UserVmVO userVmV
724731
}
725732
}
726733

727-
private VirtualMachine.PowerState getVmPowerState(UserVmVO userVmVO, Map<String, Map<String, String>> accessDetails,
734+
protected VirtualMachine.PowerState getVmPowerState(UserVmVO userVmVO, Map<String, Map<String, String>> accessDetails,
728735
String extensionName, String extensionPath) {
729736
VirtualMachineTO virtualMachineTO = getVirtualMachineTO(userVmVO);
730737
accessDetails.put(ApiConstants.VIRTUAL_MACHINE, virtualMachineTO.getExternalDetails());
@@ -740,6 +747,46 @@ private VirtualMachine.PowerState getVmPowerState(UserVmVO userVmVO, Map<String,
740747
}
741748
return parsePowerStateFromResponse(userVmVO, result.second());
742749
}
750+
751+
protected Map<String, HostVmStateReportEntry> getVmPowerStates(Host host,
752+
Map<String, Map<String, String>> accessDetails, String extensionName, String extensionPath) {
753+
Map<String, Object> modifiedDetails = loadAccessDetails(accessDetails, null);
754+
logger.debug("Trying to get VM power statuses from the external system for {}", host);
755+
Pair<Boolean, String> result = getInstanceStatusesOnExternalSystem(extensionName, extensionPath,
756+
host.getName(), modifiedDetails, AgentManager.Wait.value());
757+
if (!result.first()) {
758+
logger.warn("Failure response received while trying to fetch the power statuses for {} : {}",
759+
host, result.second());
760+
return null;
761+
}
762+
if (StringUtils.isBlank(result.second())) {
763+
logger.warn("Empty response while trying to fetch VM power statuses for host: {}", host);
764+
return null;
765+
}
766+
try {
767+
JsonObject jsonObj = JsonParser.parseString(result.second()).getAsJsonObject();
768+
if (!jsonObj.has("status") || !"success".equalsIgnoreCase(jsonObj.get("status").getAsString())) {
769+
logger.warn("Invalid status in response while trying to fetch VM power statuses for host: {}: {}",
770+
host, result.second());
771+
return null;
772+
}
773+
if (!jsonObj.has("power_state") || !jsonObj.get("power_state").isJsonObject()) {
774+
logger.warn("Missing or invalid power_state in response for host: {}: {}", host, result.second());
775+
return null;
776+
}
777+
JsonObject powerStates = jsonObj.getAsJsonObject("power_state");
778+
Map<String, HostVmStateReportEntry> states = new HashMap<>();
779+
for (Map.Entry<String, com.google.gson.JsonElement> entry : powerStates.entrySet()) {
780+
VirtualMachine.PowerState powerState = getPowerStateFromString(entry.getValue().getAsString());
781+
states.put(entry.getKey(), new HostVmStateReportEntry(powerState, "host-" + host.getId()));
782+
}
783+
return states;
784+
} catch (Exception e) {
785+
logger.warn("Failed to parse VM power statuses response for host: {}: {}", host, e.getMessage());
786+
return null;
787+
}
788+
}
789+
743790
public Pair<Boolean, String> prepareExternalProvisioningInternal(String extensionName, String filename,
744791
String vmUUID, Map<String, Object> accessDetails, int wait) {
745792
return executeExternalCommand(extensionName, "prepare", accessDetails, wait,
@@ -783,6 +830,12 @@ public Pair<Boolean, String> getInstanceStatusOnExternalSystem(String extensionN
783830
String.format("Failed to get the instance power status %s on external system", vmUUID), filename);
784831
}
785832

833+
public Pair<Boolean, String> getInstanceStatusesOnExternalSystem(String extensionName, String filename,
834+
String hostName, Map<String, Object> accessDetails, int wait) {
835+
return executeExternalCommand(extensionName, "statuses", accessDetails, wait,
836+
String.format("Failed to get the %s instances power status on external system", hostName), filename);
837+
}
838+
786839
public Pair<Boolean, String> getInstanceConsoleOnExternalSystem(String extensionName, String filename,
787840
String vmUUID, Map<String, Object> accessDetails, int wait) {
788841
return executeExternalCommand(extensionName, "getconsole", accessDetails, wait,

0 commit comments

Comments
 (0)