Skip to content

Commit b99a030

Browse files
Added Extension for MaaS integration in CloudStack (#11613)
* Adding extension support for Baremetal MaaS * Update engine/schema/src/main/resources/META-INF/db/schema-42100to42200.sql --------- Co-authored-by: Rohit Yadav <[email protected]>
1 parent df49c4f commit b99a030

File tree

4 files changed

+302
-9
lines changed

4 files changed

+302
-9
lines changed

debian/cloudstack-management.install

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
/etc/cloudstack/management/config.json
2525
/etc/cloudstack/extensions/Proxmox/proxmox.sh
2626
/etc/cloudstack/extensions/HyperV/hyperv.py
27+
/etc/cloudstack/extensions/MaaS/maas.py
2728
/etc/default/cloudstack-management
2829
/etc/security/limits.d/cloudstack-limits.conf
2930
/etc/sudoers.d/cloudstack

engine/schema/src/main/resources/META-INF/db/schema-42100to42200.sql

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,6 @@ CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.backup_repository', 'cross_zone_inst
4747
-- Updated display to false for password/token detail of the storage pool details
4848
UPDATE `cloud`.`storage_pool_details` SET display = 0 WHERE name LIKE '%password%';
4949
UPDATE `cloud`.`storage_pool_details` SET display = 0 WHERE name LIKE '%token%';
50+
51+
CALL `cloud`.`INSERT_EXTENSION_IF_NOT_EXISTS`('MaaS', 'Baremetal Extension for Canonical MaaS written in Python', 'MaaS/maas.py');
52+
CALL `cloud`.`INSERT_EXTENSION_DETAIL_IF_NOT_EXISTS`('MaaS', 'orchestratorrequirespreparevm', 'true', 0);

extensions/MaaS/maas.py

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
#!/usr/bin/env python3
2+
# Licensed to the Apache Software Foundation (ASF) under one
3+
# or more contributor license agreements. See the NOTICE file
4+
# distributed with this work for additional information
5+
# regarding copyright ownership. The ASF licenses this file
6+
# to you under the Apache License, Version 2.0 (the
7+
# "License"); you may not use this file except in compliance
8+
# with the License. You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing,
13+
# software distributed under the License is distributed on an
14+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
# KIND, either express or implied. See the License for the
16+
# specific language governing permissions and limitations
17+
# under the License.
18+
19+
import sys
20+
import json
21+
import time
22+
from requests_oauthlib import OAuth1Session
23+
24+
25+
def fail(message):
26+
print(json.dumps({"error": message}))
27+
sys.exit(1)
28+
29+
30+
def succeed(data):
31+
print(json.dumps(data))
32+
sys.exit(0)
33+
34+
35+
class MaasManager:
36+
def __init__(self, config_path):
37+
self.config_path = config_path
38+
self.data = self.parse_json()
39+
self.session = self.init_session()
40+
41+
def parse_json(self):
42+
try:
43+
with open(self.config_path, "r") as f:
44+
json_data = json.load(f)
45+
46+
extension = json_data.get("externaldetails", {}).get("extension", {})
47+
host = json_data.get("externaldetails", {}).get("host", {})
48+
vm = json_data.get("externaldetails", {}).get("virtualmachine", {})
49+
50+
endpoint = host.get("endpoint") or extension.get("endpoint")
51+
apikey = host.get("apikey") or extension.get("apikey")
52+
53+
details = json_data.get("cloudstack.vm.details", {}).get("details", {})
54+
55+
os_name = details.get("os") or vm.get("os")
56+
architecture = details.get("architecture") or vm.get("architecture")
57+
distro_series = details.get("distro_series") or vm.get("distro_series")
58+
59+
if not endpoint or not apikey:
60+
fail("Missing MAAS endpoint or apikey")
61+
62+
if not endpoint.startswith("http://") and not endpoint.startswith("https://"):
63+
endpoint = "http://" + endpoint
64+
endpoint = endpoint.rstrip("/")
65+
66+
parts = apikey.split(":")
67+
if len(parts) != 3:
68+
fail("Invalid apikey format. Expected consumer:token:secret")
69+
70+
consumer, token, secret = parts
71+
72+
system_id = details.get("maas_system_id") or vm.get("maas_system_id", "")
73+
74+
vm_name = vm.get("vm_name") or json_data.get("cloudstack.vm.details", {}).get("name")
75+
if not vm_name:
76+
vm_name = f"cs-{system_id}" if system_id else "cs-unknown"
77+
78+
return {
79+
"endpoint": endpoint,
80+
"consumer": consumer,
81+
"token": token,
82+
"secret": secret,
83+
"distro_series": distro_series or "ubuntu/focal",
84+
"os": os_name,
85+
"architecture": architecture,
86+
"system_id": system_id,
87+
"vm_name": vm_name,
88+
}
89+
except Exception as e:
90+
fail(f"Error parsing JSON: {str(e)}")
91+
92+
def init_session(self):
93+
return OAuth1Session(
94+
self.data["consumer"],
95+
resource_owner_key=self.data["token"],
96+
resource_owner_secret=self.data["secret"],
97+
)
98+
99+
def call_maas(self, method, path, data=None, ignore_404=False):
100+
if not path.startswith("/"):
101+
path = "/" + path
102+
url = f"{self.data['endpoint']}:5240/MAAS/api/2.0{path}"
103+
resp = self.session.request(method, url, data=data)
104+
105+
if resp.status_code == 404 and ignore_404:
106+
return None
107+
108+
if not resp.ok:
109+
fail(f"MAAS API error: {resp.status_code} {resp.text}")
110+
111+
try:
112+
return resp.json() if resp.text else {}
113+
except ValueError:
114+
return {}
115+
116+
def prepare(self):
117+
machines = self.call_maas("GET", "/machines/")
118+
ready = [m for m in machines if m.get("status_name") == "Ready"]
119+
if not ready:
120+
fail("No Ready machines available")
121+
122+
sysid = self.data.get("system_id")
123+
124+
if sysid:
125+
match = next((m for m in ready if m["system_id"] == sysid), None)
126+
if not match:
127+
fail(f"Provided system_id '{sysid}' not found among Ready machines")
128+
system = match
129+
else:
130+
system = ready[0]
131+
132+
system_id = system["system_id"]
133+
mac = system.get("interface_set", [{}])[0].get("mac_address")
134+
hostname = system.get("hostname", "")
135+
136+
if not mac:
137+
fail("No MAC address found")
138+
139+
# Load original JSON so we can update nics
140+
with open(self.config_path, "r") as f:
141+
json_data = json.load(f)
142+
143+
if json_data.get("cloudstack.vm.details", {}).get("nics"):
144+
json_data["cloudstack.vm.details"]["nics"][0]["mac"] = mac
145+
146+
console_url = f"http://{self.data['endpoint'].replace('http://','').replace('https://','')}:5240/MAAS/r/machine/{system_id}/summary"
147+
148+
result = {
149+
"nics": json_data["cloudstack.vm.details"]["nics"],
150+
"details": {
151+
"External:mac_address": mac,
152+
"External:maas_system_id": system_id,
153+
"External:hostname": hostname,
154+
"External:console_url": console_url,
155+
},
156+
}
157+
succeed(result)
158+
159+
def create(self):
160+
sysid = self.data.get("system_id")
161+
if not sysid:
162+
fail("system_id missing for create")
163+
164+
ds = self.data.get("distro_series", None)
165+
os_name = self.data.get("os")
166+
arch = self.data.get("architecture")
167+
168+
deploy_payload = {"op": "deploy"}
169+
170+
if os_name or arch:
171+
if os_name:
172+
deploy_payload["os"] = os_name
173+
if arch:
174+
deploy_payload["architecture"] = arch
175+
if ds:
176+
deploy_payload["distro_series"] = ds
177+
else:
178+
deploy_payload["distro_series"] = ds or "ubuntu/focal"
179+
180+
deploy_payload["net-setup-method"] = "curtin"
181+
182+
self.call_maas("POST", f"/machines/{sysid}/", deploy_payload)
183+
184+
succeed({"status": "success", "message": "Instance created", "requested": deploy_payload})
185+
186+
def delete(self):
187+
sysid = self.data.get("system_id")
188+
if not sysid:
189+
fail("system_id missing for delete")
190+
191+
self.call_maas("POST", f"/machines/{sysid}/", {"op": "release"}, ignore_404=True)
192+
succeed({"status": "success", "message": f"Instance deleted or not found ({sysid})"})
193+
194+
def start(self):
195+
sysid = self.data.get("system_id")
196+
if not sysid:
197+
fail("system_id missing for start")
198+
self.call_maas("POST", f"/machines/{sysid}/", {"op": "power_on"})
199+
succeed({"status": "success", "power_state": "PowerOn"})
200+
201+
def stop(self):
202+
sysid = self.data.get("system_id")
203+
if not sysid:
204+
fail("system_id missing for stop")
205+
self.call_maas("POST", f"/machines/{sysid}/", {"op": "power_off"})
206+
succeed({"status": "success", "power_state": "PowerOff"})
207+
208+
def reboot(self):
209+
sysid = self.data.get("system_id")
210+
if not sysid:
211+
fail("system_id missing for reboot")
212+
213+
self.call_maas("POST", f"/machines/{sysid}/", {"op": "power_off"})
214+
time.sleep(5)
215+
self.call_maas("POST", f"/machines/{sysid}/", {"op": "power_on"})
216+
217+
succeed({"status": "success", "power_state": "PowerOn", "message": "Reboot completed"})
218+
219+
def status(self):
220+
sysid = self.data.get("system_id")
221+
if not sysid:
222+
fail("system_id missing for status")
223+
resp = self.call_maas("GET", f"/machines/{sysid}/")
224+
state = resp.get("power_state", "")
225+
if state == "on":
226+
mapped = "PowerOn"
227+
elif state == "off":
228+
mapped = "PowerOff"
229+
else:
230+
mapped = "PowerUnknown"
231+
succeed({"status": "success", "power_state": mapped})
232+
233+
234+
def main():
235+
if len(sys.argv) < 3:
236+
fail("Usage: maas.py <action> <json-file-path>")
237+
238+
action = sys.argv[1].lower()
239+
json_file = sys.argv[2]
240+
241+
try:
242+
manager = MaasManager(json_file)
243+
except FileNotFoundError:
244+
fail(f"JSON file not found: {json_file}")
245+
246+
actions = {
247+
"prepare": manager.prepare,
248+
"create": manager.create,
249+
"delete": manager.delete,
250+
"start": manager.start,
251+
"stop": manager.stop,
252+
"reboot": manager.reboot,
253+
"status": manager.status,
254+
}
255+
256+
if action not in actions:
257+
fail("Invalid action")
258+
259+
actions[action]()
260+
261+
262+
if __name__ == "__main__":
263+
main()

ui/src/components/widgets/Console.vue

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,17 @@
1717

1818
<template>
1919
<a
20-
v-if="['vm', 'systemvm', 'router', 'ilbvm', 'vnfapp'].includes($route.meta.name) && 'listVirtualMachines' in $store.getters.apis && 'createConsoleEndpoint' in $store.getters.apis"
20+
v-if="['vm', 'systemvm', 'router', 'ilbvm', 'vnfapp'].includes($route.meta.name) &&
21+
'listVirtualMachines' in $store.getters.apis &&
22+
'createConsoleEndpoint' in $store.getters.apis"
2123
@click="consoleUrl">
22-
<a-button style="margin-left: 5px" shape="circle" type="dashed" :size="size" :disabled="['Stopped', 'Restoring', 'Error', 'Destroyed'].includes(resource.state) || resource.hostcontrolstate === 'Offline'" >
24+
<a-button
25+
style="margin-left: 5px"
26+
shape="circle"
27+
type="dashed"
28+
:size="size"
29+
:disabled="['Stopped', 'Restoring', 'Error', 'Destroyed'].includes(resource.state) ||
30+
resource.hostcontrolstate === 'Offline'">
2331
<code-outlined v-if="!copyUrlToClipboard"/>
2432
<copy-outlined v-else />
2533
</a-button>
@@ -49,11 +57,29 @@ export default {
4957
}
5058
},
5159
methods: {
52-
consoleUrl () {
53-
const params = {}
54-
params.virtualmachineid = this.resource.id
55-
postAPI('createConsoleEndpoint', params).then(json => {
56-
this.url = (json && json.createconsoleendpointresponse) ? json.createconsoleendpointresponse.consoleendpoint.url : '#/exception/404'
60+
async consoleUrl () {
61+
try {
62+
const externalUrl = this.resource?.details?.['External:console_url']
63+
if (externalUrl) {
64+
this.url = externalUrl
65+
if (this.copyUrlToClipboard) {
66+
this.$copyText(this.url)
67+
this.$message.success({
68+
content: this.$t('label.copied.clipboard')
69+
})
70+
} else {
71+
window.open(this.url, '_blank')
72+
}
73+
return
74+
}
75+
76+
const params = { virtualmachineid: this.resource.id }
77+
const json = await postAPI('createConsoleEndpoint', params)
78+
79+
this.url = (json && json.createconsoleendpointresponse)
80+
? json.createconsoleendpointresponse.consoleendpoint.url
81+
: '#/exception/404'
82+
5783
if (json.createconsoleendpointresponse.consoleendpoint.success) {
5884
if (this.copyUrlToClipboard) {
5985
this.$copyText(this.url)
@@ -69,9 +95,9 @@ export default {
6995
description: json.createconsoleendpointresponse.consoleendpoint.details
7096
})
7197
}
72-
}).catch(error => {
98+
} catch (error) {
7399
this.$notifyError(error)
74-
})
100+
}
75101
}
76102
},
77103
computed: {

0 commit comments

Comments
 (0)