Skip to content

Commit d9c0f74

Browse files
committed
Merge branch 'main' into 422-vmware-to-kvm-improvements
2 parents 83c6585 + b99a030 commit d9c0f74

File tree

12 files changed

+619
-291
lines changed

12 files changed

+619
-291
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
@@ -77,3 +77,6 @@ CREATE TABLE IF NOT EXISTS `cloud`.`import_vm_task`(
7777
CONSTRAINT `fk_import_vm_task__import_host_id` FOREIGN KEY `fk_import_vm_task__import_host_id` (`import_host_id`) REFERENCES `host`(`id`) ON DELETE CASCADE,
7878
INDEX `i_import_vm_task__zone_id`(`zone_id`)
7979
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
80+
81+
CALL `cloud`.`INSERT_EXTENSION_IF_NOT_EXISTS`('MaaS', 'Baremetal Extension for Canonical MaaS written in Python', 'MaaS/maas.py');
82+
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()

plugins/metrics/src/main/java/org/apache/cloudstack/metrics/MetricsServiceImpl.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
import org.apache.cloudstack.api.response.UserVmResponse;
6060
import org.apache.cloudstack.api.response.VolumeResponse;
6161
import org.apache.cloudstack.api.response.ZoneResponse;
62+
import org.apache.cloudstack.backup.dao.BackupRepositoryDao;
6263
import org.apache.cloudstack.cluster.ClusterDrsAlgorithm;
6364
import org.apache.cloudstack.context.CallContext;
6465
import org.apache.cloudstack.framework.config.ConfigKey;
@@ -158,6 +159,8 @@ public class MetricsServiceImpl extends MutualExclusiveIdsManagerBase implements
158159
@Inject
159160
private ImageStoreDao imageStoreDao;
160161
@Inject
162+
BackupRepositoryDao backupRepositoryDao;
163+
@Inject
161164
private VMInstanceDao vmInstanceDao;
162165
@Inject
163166
private DomainRouterDao domainRouterDao;
@@ -557,6 +560,7 @@ public InfrastructureResponse listInfrastructure() {
557560
response.setHosts(hostCountAndCpuSockets.first());
558561
response.setStoragePools(storagePoolDao.countAll());
559562
response.setImageStores(imageStoreDao.countAllImageStores());
563+
response.setBackupRepositories(backupRepositoryDao.countAll());
560564
response.setObjectStores(objectStoreDao.countAllObjectStores());
561565
response.setSystemvms(vmInstanceDao.countByTypes(VirtualMachine.Type.ConsoleProxy, VirtualMachine.Type.SecondaryStorageVm));
562566
response.setRouters(domainRouterDao.countAllByRole(VirtualRouter.Role.VIRTUAL_ROUTER));

plugins/metrics/src/main/java/org/apache/cloudstack/response/InfrastructureResponse.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ public class InfrastructureResponse extends BaseResponse {
4747
@Param(description = "Number of images stores")
4848
private Integer imageStores;
4949

50+
@SerializedName("backuprepositories")
51+
@Param(description = "Number of backup repositories", since = "4.22.0")
52+
private Integer backupRepositories;
53+
5054
@SerializedName("objectstores")
5155
@Param(description = "Number of object stores")
5256
private Integer objectStores;
@@ -103,6 +107,10 @@ public void setImageStores(final Integer imageStores) {
103107
this.imageStores = imageStores;
104108
}
105109

110+
public void setBackupRepositories(Integer backupRepositories) {
111+
this.backupRepositories = backupRepositories;
112+
}
113+
106114
public void setSystemvms(final Integer systemvms) {
107115
this.systemvms = systemvms;
108116
}

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)