Skip to content

Commit 928972f

Browse files
authored
extension/proxmox: add console access for instances (#11601)
This PR introduces console access support for instances deployed using Orchestrator Extensions, available via either VNC or a direct URL. - CloudStack queries the extension using the getconsole action. - For VNC-based access, the extension must return host/port/ticket details. CloudStack then forwards these to the Console Proxy VM (CPVM) in the instance’s zone. It is assumed that the CPVM can reach the specified host and port. - For direct URL access, the extension returns a console URL with the protocol set to `direct`. The URL is then provided directly to the user. - The built-in Proxmox Orchestrator Extension now supports console access via VNC. The extension calls the Proxmox API to fetch console details and returns them in the required format. Also, adds changes to send caller details to the extension payload. ``` # cat /var/lib/cloudstack/management/extensions/Proxmox/02b650f6-bb98-49cb-8cac-82b7a78f43a2.json | jq { "caller": { "roleid": "6b86674b-7e61-11f0-ba77-1e00c8000158", "rolename": "Root Admin", "name": "admin", "roletype": "Admin", "id": "93567ed9-7e61-11f0-ba77-1e00c8000158", "type": "ADMIN" }, "virtualmachineid": "126f4562-1f0f-4313-875e-6150cabeb72f", ... ``` Documentation PR: apache/cloudstack-documentation#560 --------- Signed-off-by: Abhishek Kumar <[email protected]>
1 parent ec533cd commit 928972f

File tree

31 files changed

+1714
-234
lines changed

31 files changed

+1714
-234
lines changed

api/src/main/java/org/apache/cloudstack/api/ApiConstants.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ public class ApiConstants {
8080
public static final String BYTES_WRITE_RATE_MAX = "byteswriteratemax";
8181
public static final String BYTES_WRITE_RATE_MAX_LENGTH = "byteswriteratemaxlength";
8282
public static final String BYPASS_VLAN_OVERLAP_CHECK = "bypassvlanoverlapcheck";
83+
public static final String CALLER = "caller";
8384
public static final String CAPACITY = "capacity";
8485
public static final String CATEGORY = "category";
8586
public static final String CAN_REVERT = "canrevert";

api/src/main/java/org/apache/cloudstack/api/command/user/consoleproxy/CreateConsoleEndpointCmd.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import org.apache.cloudstack.context.CallContext;
3636
import org.apache.cloudstack.utils.consoleproxy.ConsoleAccessUtils;
3737
import org.apache.commons.collections.MapUtils;
38+
import org.apache.commons.lang3.ObjectUtils;
3839

3940
import javax.inject.Inject;
4041
import java.util.Map;
@@ -86,6 +87,10 @@ private CreateConsoleEndpointResponse createResponse(ConsoleEndpoint endpoint) {
8687
}
8788

8889
private ConsoleEndpointWebsocketResponse createWebsocketResponse(ConsoleEndpoint endpoint) {
90+
if (ObjectUtils.allNull(endpoint.getWebsocketHost(), endpoint.getWebsocketPort(), endpoint.getWebsocketPath(),
91+
endpoint.getWebsocketToken(), endpoint.getWebsocketExtra())) {
92+
return null;
93+
}
8994
ConsoleEndpointWebsocketResponse wsResponse = new ConsoleEndpointWebsocketResponse();
9095
wsResponse.setHost(endpoint.getWebsocketHost());
9196
wsResponse.setPort(endpoint.getWebsocketPort());
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The ASF licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
package com.cloud.agent.api;
19+
20+
public class GetExternalConsoleAnswer extends Answer {
21+
22+
private String url;
23+
private String host;
24+
private Integer port;
25+
@LogLevel(LogLevel.Log4jLevel.Off)
26+
private String password;
27+
private String protocol;
28+
private boolean passwordOneTimeUseOnly;
29+
30+
public GetExternalConsoleAnswer(Command command, String details) {
31+
super(command, false, details);
32+
}
33+
34+
public GetExternalConsoleAnswer(Command command, String url, String host, Integer port, String password,
35+
boolean passwordOneTimeUseOnly, String protocol) {
36+
super(command, true, "");
37+
this.url = url;
38+
this.host = host;
39+
this.port = port;
40+
this.password = password;
41+
this.passwordOneTimeUseOnly = passwordOneTimeUseOnly;
42+
this.protocol = protocol;
43+
}
44+
45+
public String getUrl() {
46+
return url;
47+
}
48+
49+
public String getHost() {
50+
return host;
51+
}
52+
53+
public Integer getPort() {
54+
return port;
55+
}
56+
57+
public String getPassword() {
58+
return password;
59+
}
60+
61+
public String getProtocol() {
62+
return protocol;
63+
}
64+
65+
public boolean isPasswordOneTimeUseOnly() {
66+
return passwordOneTimeUseOnly;
67+
}
68+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The ASF licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
package com.cloud.agent.api;
19+
20+
import com.cloud.agent.api.to.VirtualMachineTO;
21+
22+
public class GetExternalConsoleCommand extends Command {
23+
String vmName;
24+
VirtualMachineTO vm;
25+
protected boolean executeInSequence;
26+
27+
public GetExternalConsoleCommand(String vmName, VirtualMachineTO vm) {
28+
this.vmName = vmName;
29+
this.vm = vm;
30+
this.executeInSequence = false;
31+
}
32+
33+
public String getVmName() {
34+
return this.vmName;
35+
}
36+
37+
public void setVirtualMachine(VirtualMachineTO vm) {
38+
this.vm = vm;
39+
}
40+
41+
public VirtualMachineTO getVirtualMachine() {
42+
return vm;
43+
}
44+
45+
@Override
46+
public boolean executeInSequence() {
47+
return executeInSequence;
48+
}
49+
50+
public void setExecuteInSequence(boolean executeInSequence) {
51+
this.executeInSequence = executeInSequence;
52+
}
53+
}

core/src/main/java/com/cloud/agent/api/RunCustomActionCommand.java

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,12 @@
2121

2222
import java.util.Map;
2323

24+
import com.cloud.agent.api.to.VirtualMachineTO;
25+
2426
public class RunCustomActionCommand extends Command {
2527

2628
String actionName;
27-
Long vmId;
29+
VirtualMachineTO vmTO;
2830
Map<String, Object> parameters;
2931

3032
public RunCustomActionCommand(String actionName) {
@@ -36,12 +38,12 @@ public String getActionName() {
3638
return actionName;
3739
}
3840

39-
public Long getVmId() {
40-
return vmId;
41+
public VirtualMachineTO getVmTO() {
42+
return vmTO;
4143
}
4244

43-
public void setVmId(Long vmId) {
44-
this.vmId = vmId;
45+
public void setVmTO(VirtualMachineTO vmTO) {
46+
this.vmTO = vmTO;
4547
}
4648

4749
public Map<String, Object> getParameters() {

engine/components-api/src/main/java/com/cloud/hypervisor/ExternalProvisioner.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818

1919
import java.util.Map;
2020

21+
import com.cloud.agent.api.GetExternalConsoleAnswer;
22+
import com.cloud.agent.api.GetExternalConsoleCommand;
2123
import com.cloud.agent.api.HostVmStateReportEntry;
2224
import com.cloud.agent.api.PrepareExternalProvisioningAnswer;
2325
import com.cloud.agent.api.PrepareExternalProvisioningCommand;
@@ -57,5 +59,7 @@ public interface ExternalProvisioner extends Manager {
5759

5860
Map<String, HostVmStateReportEntry> getHostVmStateReport(long hostId, String extensionName, String extensionRelativePath);
5961

62+
GetExternalConsoleAnswer getInstanceConsole(String hostGuid, String extensionName, String extensionRelativePath, GetExternalConsoleCommand cmd);
63+
6064
RunCustomActionAnswer runCustomAction(String hostGuid, String extensionName, String extensionRelativePath, RunCustomActionCommand cmd);
6165
}

extensions/HyperV/hyperv.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525

2626

2727
def fail(message):
28-
print(json.dumps({"error": message}))
28+
print(json.dumps({"status": "error", "error": message}))
2929
sys.exit(1)
3030

3131

@@ -220,6 +220,9 @@ def delete(self):
220220
fail(str(e))
221221
succeed({"status": "success", "message": "Instance deleted"})
222222

223+
def get_console(self):
224+
fail("Operation not supported")
225+
223226
def suspend(self):
224227
self.run_ps(f'Suspend-VM -Name "{self.data["vmname"]}"')
225228
succeed({"status": "success", "message": "Instance suspended"})
@@ -283,6 +286,7 @@ def main():
283286
"reboot": manager.reboot,
284287
"delete": manager.delete,
285288
"status": manager.status,
289+
"getconsole": manager.get_console,
286290
"suspend": manager.suspend,
287291
"resume": manager.resume,
288292
"listsnapshots": manager.list_snapshots,

extensions/Proxmox/proxmox.sh

Lines changed: 99 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
parse_json() {
2020
local json_string="$1"
21-
echo "$json_string" | jq '.' > /dev/null || { echo '{"error":"Invalid JSON input"}'; exit 1; }
21+
echo "$json_string" | jq '.' > /dev/null || { echo '{"status": "error", "error": "Invalid JSON input"}'; exit 1; }
2222

2323
local -A details
2424
while IFS="=" read -r key value; do
@@ -112,9 +112,14 @@ call_proxmox_api() {
112112
curl_opts+=(-d "$data")
113113
fi
114114

115-
#echo curl "${curl_opts[@]}" "https://${url}:8006/api2/json${path}" >&2
116115
response=$(curl "${curl_opts[@]}" "https://${url}:8006/api2/json${path}")
116+
local status=$?
117+
if [[ $status -ne 0 ]]; then
118+
echo "{\"errors\":{\"curl\":\"API call failed with status $status: $(echo "$response" | jq -Rsa . | jq -r .)\"}}"
119+
return $status
120+
fi
117121
echo "$response"
122+
return 0
118123
}
119124

120125
wait_for_proxmox_task() {
@@ -129,7 +134,7 @@ wait_for_proxmox_task() {
129134
local now
130135
now=$(date +%s)
131136
if (( now - start_time > timeout )); then
132-
echo '{"error":"Timeout while waiting for async task"}'
137+
echo '{"status": "error", "error":"Timeout while waiting for async task"}'
133138
exit 1
134139
fi
135140

@@ -139,7 +144,7 @@ wait_for_proxmox_task() {
139144
if [[ -z "$status_response" || "$status_response" == *'"errors":'* ]]; then
140145
local msg
141146
msg=$(echo "$status_response" | jq -r '.message // "Unknown error"')
142-
echo "{\"error\":\"$msg\"}"
147+
echo "{\"status\": \"error\", \"error\": \"$msg\"}"
143148
exit 1
144149
fi
145150

@@ -285,6 +290,86 @@ status() {
285290
echo "{\"status\": \"success\", \"power_state\": \"$powerstate\"}"
286291
}
287292

293+
get_node_host() {
294+
check_required_fields node
295+
local net_json host
296+
297+
if ! net_json="$(call_proxmox_api GET "/nodes/${node}/network")"; then
298+
echo ""
299+
return 1
300+
fi
301+
302+
# Prefer a static non-bridge IP
303+
host="$(echo "$net_json" | jq -r '
304+
.data
305+
| map(select(
306+
(.type // "") != "bridge" and
307+
(.type // "") != "bond" and
308+
(.method // "") == "static" and
309+
((.address // .cidr // "") != "")
310+
))
311+
| map(.address // (.cidr | split("/")[0]))
312+
| .[0] // empty
313+
' 2>/dev/null)"
314+
315+
# Fallback: first interface with a CIDR
316+
if [[ -z "$host" ]]; then
317+
host="$(echo "$net_json" | jq -r '
318+
.data
319+
| map(select((.cidr // "") != ""))
320+
| map(.cidr | split("/")[0])
321+
| .[0] // empty
322+
' 2>/dev/null)"
323+
fi
324+
325+
echo "$host"
326+
}
327+
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+
}
372+
288373
list_snapshots() {
289374
snapshot_response=$(call_proxmox_api GET "/nodes/${node}/qemu/${vmid}/snapshot")
290375
echo "$snapshot_response" | jq '
@@ -356,7 +441,12 @@ parameters_file="$2"
356441
wait_time=$3
357442

358443
if [[ -z "$action" || -z "$parameters_file" ]]; then
359-
echo '{"error":"Missing required arguments"}'
444+
echo '{"status": "error", "error": "Missing required arguments"}'
445+
exit 1
446+
fi
447+
448+
if [[ ! -r "$parameters_file" ]]; then
449+
echo '{"status": "error", "error": "File not found or unreadable"}'
360450
exit 1
361451
fi
362452

@@ -396,6 +486,9 @@ case $action in
396486
status)
397487
status
398488
;;
489+
getconsole)
490+
get_console
491+
;;
399492
ListSnapshots)
400493
list_snapshots
401494
;;
@@ -409,7 +502,7 @@ case $action in
409502
delete_snapshot
410503
;;
411504
*)
412-
echo '{"error":"Invalid action"}'
505+
echo '{"status": "error", "error": "Invalid action"}'
413506
exit 1
414507
;;
415508
esac

0 commit comments

Comments
 (0)