mirror of
https://github.com/apache/cloudstack.git
synced 2025-10-26 01:32:18 +02:00
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 <rohityadav89@gmail.com>
This commit is contained in:
parent
df49c4f14b
commit
b99a03092f
1
debian/cloudstack-management.install
vendored
1
debian/cloudstack-management.install
vendored
@ -24,6 +24,7 @@
|
|||||||
/etc/cloudstack/management/config.json
|
/etc/cloudstack/management/config.json
|
||||||
/etc/cloudstack/extensions/Proxmox/proxmox.sh
|
/etc/cloudstack/extensions/Proxmox/proxmox.sh
|
||||||
/etc/cloudstack/extensions/HyperV/hyperv.py
|
/etc/cloudstack/extensions/HyperV/hyperv.py
|
||||||
|
/etc/cloudstack/extensions/MaaS/maas.py
|
||||||
/etc/default/cloudstack-management
|
/etc/default/cloudstack-management
|
||||||
/etc/security/limits.d/cloudstack-limits.conf
|
/etc/security/limits.d/cloudstack-limits.conf
|
||||||
/etc/sudoers.d/cloudstack
|
/etc/sudoers.d/cloudstack
|
||||||
|
|||||||
@ -47,3 +47,6 @@ CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.backup_repository', 'cross_zone_inst
|
|||||||
-- Updated display to false for password/token detail of the storage pool details
|
-- Updated display to false for password/token detail of the storage pool details
|
||||||
UPDATE `cloud`.`storage_pool_details` SET display = 0 WHERE name LIKE '%password%';
|
UPDATE `cloud`.`storage_pool_details` SET display = 0 WHERE name LIKE '%password%';
|
||||||
UPDATE `cloud`.`storage_pool_details` SET display = 0 WHERE name LIKE '%token%';
|
UPDATE `cloud`.`storage_pool_details` SET display = 0 WHERE name LIKE '%token%';
|
||||||
|
|
||||||
|
CALL `cloud`.`INSERT_EXTENSION_IF_NOT_EXISTS`('MaaS', 'Baremetal Extension for Canonical MaaS written in Python', 'MaaS/maas.py');
|
||||||
|
CALL `cloud`.`INSERT_EXTENSION_DETAIL_IF_NOT_EXISTS`('MaaS', 'orchestratorrequirespreparevm', 'true', 0);
|
||||||
|
|||||||
263
extensions/MaaS/maas.py
Executable file
263
extensions/MaaS/maas.py
Executable file
@ -0,0 +1,263 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
# or more contributor license agreements. See the NOTICE file
|
||||||
|
# distributed with this work for additional information
|
||||||
|
# regarding copyright ownership. The ASF licenses this file
|
||||||
|
# to you under the Apache License, Version 2.0 (the
|
||||||
|
# "License"); you may not use this file except in compliance
|
||||||
|
# with the License. You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing,
|
||||||
|
# software distributed under the License is distributed on an
|
||||||
|
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
# KIND, either express or implied. See the License for the
|
||||||
|
# specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from requests_oauthlib import OAuth1Session
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message):
|
||||||
|
print(json.dumps({"error": message}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def succeed(data):
|
||||||
|
print(json.dumps(data))
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
class MaasManager:
|
||||||
|
def __init__(self, config_path):
|
||||||
|
self.config_path = config_path
|
||||||
|
self.data = self.parse_json()
|
||||||
|
self.session = self.init_session()
|
||||||
|
|
||||||
|
def parse_json(self):
|
||||||
|
try:
|
||||||
|
with open(self.config_path, "r") as f:
|
||||||
|
json_data = json.load(f)
|
||||||
|
|
||||||
|
extension = json_data.get("externaldetails", {}).get("extension", {})
|
||||||
|
host = json_data.get("externaldetails", {}).get("host", {})
|
||||||
|
vm = json_data.get("externaldetails", {}).get("virtualmachine", {})
|
||||||
|
|
||||||
|
endpoint = host.get("endpoint") or extension.get("endpoint")
|
||||||
|
apikey = host.get("apikey") or extension.get("apikey")
|
||||||
|
|
||||||
|
details = json_data.get("cloudstack.vm.details", {}).get("details", {})
|
||||||
|
|
||||||
|
os_name = details.get("os") or vm.get("os")
|
||||||
|
architecture = details.get("architecture") or vm.get("architecture")
|
||||||
|
distro_series = details.get("distro_series") or vm.get("distro_series")
|
||||||
|
|
||||||
|
if not endpoint or not apikey:
|
||||||
|
fail("Missing MAAS endpoint or apikey")
|
||||||
|
|
||||||
|
if not endpoint.startswith("http://") and not endpoint.startswith("https://"):
|
||||||
|
endpoint = "http://" + endpoint
|
||||||
|
endpoint = endpoint.rstrip("/")
|
||||||
|
|
||||||
|
parts = apikey.split(":")
|
||||||
|
if len(parts) != 3:
|
||||||
|
fail("Invalid apikey format. Expected consumer:token:secret")
|
||||||
|
|
||||||
|
consumer, token, secret = parts
|
||||||
|
|
||||||
|
system_id = details.get("maas_system_id") or vm.get("maas_system_id", "")
|
||||||
|
|
||||||
|
vm_name = vm.get("vm_name") or json_data.get("cloudstack.vm.details", {}).get("name")
|
||||||
|
if not vm_name:
|
||||||
|
vm_name = f"cs-{system_id}" if system_id else "cs-unknown"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"endpoint": endpoint,
|
||||||
|
"consumer": consumer,
|
||||||
|
"token": token,
|
||||||
|
"secret": secret,
|
||||||
|
"distro_series": distro_series or "ubuntu/focal",
|
||||||
|
"os": os_name,
|
||||||
|
"architecture": architecture,
|
||||||
|
"system_id": system_id,
|
||||||
|
"vm_name": vm_name,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
fail(f"Error parsing JSON: {str(e)}")
|
||||||
|
|
||||||
|
def init_session(self):
|
||||||
|
return OAuth1Session(
|
||||||
|
self.data["consumer"],
|
||||||
|
resource_owner_key=self.data["token"],
|
||||||
|
resource_owner_secret=self.data["secret"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def call_maas(self, method, path, data=None, ignore_404=False):
|
||||||
|
if not path.startswith("/"):
|
||||||
|
path = "/" + path
|
||||||
|
url = f"{self.data['endpoint']}:5240/MAAS/api/2.0{path}"
|
||||||
|
resp = self.session.request(method, url, data=data)
|
||||||
|
|
||||||
|
if resp.status_code == 404 and ignore_404:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not resp.ok:
|
||||||
|
fail(f"MAAS API error: {resp.status_code} {resp.text}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
return resp.json() if resp.text else {}
|
||||||
|
except ValueError:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def prepare(self):
|
||||||
|
machines = self.call_maas("GET", "/machines/")
|
||||||
|
ready = [m for m in machines if m.get("status_name") == "Ready"]
|
||||||
|
if not ready:
|
||||||
|
fail("No Ready machines available")
|
||||||
|
|
||||||
|
sysid = self.data.get("system_id")
|
||||||
|
|
||||||
|
if sysid:
|
||||||
|
match = next((m for m in ready if m["system_id"] == sysid), None)
|
||||||
|
if not match:
|
||||||
|
fail(f"Provided system_id '{sysid}' not found among Ready machines")
|
||||||
|
system = match
|
||||||
|
else:
|
||||||
|
system = ready[0]
|
||||||
|
|
||||||
|
system_id = system["system_id"]
|
||||||
|
mac = system.get("interface_set", [{}])[0].get("mac_address")
|
||||||
|
hostname = system.get("hostname", "")
|
||||||
|
|
||||||
|
if not mac:
|
||||||
|
fail("No MAC address found")
|
||||||
|
|
||||||
|
# Load original JSON so we can update nics
|
||||||
|
with open(self.config_path, "r") as f:
|
||||||
|
json_data = json.load(f)
|
||||||
|
|
||||||
|
if json_data.get("cloudstack.vm.details", {}).get("nics"):
|
||||||
|
json_data["cloudstack.vm.details"]["nics"][0]["mac"] = mac
|
||||||
|
|
||||||
|
console_url = f"http://{self.data['endpoint'].replace('http://','').replace('https://','')}:5240/MAAS/r/machine/{system_id}/summary"
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"nics": json_data["cloudstack.vm.details"]["nics"],
|
||||||
|
"details": {
|
||||||
|
"External:mac_address": mac,
|
||||||
|
"External:maas_system_id": system_id,
|
||||||
|
"External:hostname": hostname,
|
||||||
|
"External:console_url": console_url,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
succeed(result)
|
||||||
|
|
||||||
|
def create(self):
|
||||||
|
sysid = self.data.get("system_id")
|
||||||
|
if not sysid:
|
||||||
|
fail("system_id missing for create")
|
||||||
|
|
||||||
|
ds = self.data.get("distro_series", None)
|
||||||
|
os_name = self.data.get("os")
|
||||||
|
arch = self.data.get("architecture")
|
||||||
|
|
||||||
|
deploy_payload = {"op": "deploy"}
|
||||||
|
|
||||||
|
if os_name or arch:
|
||||||
|
if os_name:
|
||||||
|
deploy_payload["os"] = os_name
|
||||||
|
if arch:
|
||||||
|
deploy_payload["architecture"] = arch
|
||||||
|
if ds:
|
||||||
|
deploy_payload["distro_series"] = ds
|
||||||
|
else:
|
||||||
|
deploy_payload["distro_series"] = ds or "ubuntu/focal"
|
||||||
|
|
||||||
|
deploy_payload["net-setup-method"] = "curtin"
|
||||||
|
|
||||||
|
self.call_maas("POST", f"/machines/{sysid}/", deploy_payload)
|
||||||
|
|
||||||
|
succeed({"status": "success", "message": "Instance created", "requested": deploy_payload})
|
||||||
|
|
||||||
|
def delete(self):
|
||||||
|
sysid = self.data.get("system_id")
|
||||||
|
if not sysid:
|
||||||
|
fail("system_id missing for delete")
|
||||||
|
|
||||||
|
self.call_maas("POST", f"/machines/{sysid}/", {"op": "release"}, ignore_404=True)
|
||||||
|
succeed({"status": "success", "message": f"Instance deleted or not found ({sysid})"})
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
sysid = self.data.get("system_id")
|
||||||
|
if not sysid:
|
||||||
|
fail("system_id missing for start")
|
||||||
|
self.call_maas("POST", f"/machines/{sysid}/", {"op": "power_on"})
|
||||||
|
succeed({"status": "success", "power_state": "PowerOn"})
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
sysid = self.data.get("system_id")
|
||||||
|
if not sysid:
|
||||||
|
fail("system_id missing for stop")
|
||||||
|
self.call_maas("POST", f"/machines/{sysid}/", {"op": "power_off"})
|
||||||
|
succeed({"status": "success", "power_state": "PowerOff"})
|
||||||
|
|
||||||
|
def reboot(self):
|
||||||
|
sysid = self.data.get("system_id")
|
||||||
|
if not sysid:
|
||||||
|
fail("system_id missing for reboot")
|
||||||
|
|
||||||
|
self.call_maas("POST", f"/machines/{sysid}/", {"op": "power_off"})
|
||||||
|
time.sleep(5)
|
||||||
|
self.call_maas("POST", f"/machines/{sysid}/", {"op": "power_on"})
|
||||||
|
|
||||||
|
succeed({"status": "success", "power_state": "PowerOn", "message": "Reboot completed"})
|
||||||
|
|
||||||
|
def status(self):
|
||||||
|
sysid = self.data.get("system_id")
|
||||||
|
if not sysid:
|
||||||
|
fail("system_id missing for status")
|
||||||
|
resp = self.call_maas("GET", f"/machines/{sysid}/")
|
||||||
|
state = resp.get("power_state", "")
|
||||||
|
if state == "on":
|
||||||
|
mapped = "PowerOn"
|
||||||
|
elif state == "off":
|
||||||
|
mapped = "PowerOff"
|
||||||
|
else:
|
||||||
|
mapped = "PowerUnknown"
|
||||||
|
succeed({"status": "success", "power_state": mapped})
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
fail("Usage: maas.py <action> <json-file-path>")
|
||||||
|
|
||||||
|
action = sys.argv[1].lower()
|
||||||
|
json_file = sys.argv[2]
|
||||||
|
|
||||||
|
try:
|
||||||
|
manager = MaasManager(json_file)
|
||||||
|
except FileNotFoundError:
|
||||||
|
fail(f"JSON file not found: {json_file}")
|
||||||
|
|
||||||
|
actions = {
|
||||||
|
"prepare": manager.prepare,
|
||||||
|
"create": manager.create,
|
||||||
|
"delete": manager.delete,
|
||||||
|
"start": manager.start,
|
||||||
|
"stop": manager.stop,
|
||||||
|
"reboot": manager.reboot,
|
||||||
|
"status": manager.status,
|
||||||
|
}
|
||||||
|
|
||||||
|
if action not in actions:
|
||||||
|
fail("Invalid action")
|
||||||
|
|
||||||
|
actions[action]()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@ -17,9 +17,17 @@
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<a
|
<a
|
||||||
v-if="['vm', 'systemvm', 'router', 'ilbvm', 'vnfapp'].includes($route.meta.name) && 'listVirtualMachines' in $store.getters.apis && 'createConsoleEndpoint' in $store.getters.apis"
|
v-if="['vm', 'systemvm', 'router', 'ilbvm', 'vnfapp'].includes($route.meta.name) &&
|
||||||
|
'listVirtualMachines' in $store.getters.apis &&
|
||||||
|
'createConsoleEndpoint' in $store.getters.apis"
|
||||||
@click="consoleUrl">
|
@click="consoleUrl">
|
||||||
<a-button style="margin-left: 5px" shape="circle" type="dashed" :size="size" :disabled="['Stopped', 'Restoring', 'Error', 'Destroyed'].includes(resource.state) || resource.hostcontrolstate === 'Offline'" >
|
<a-button
|
||||||
|
style="margin-left: 5px"
|
||||||
|
shape="circle"
|
||||||
|
type="dashed"
|
||||||
|
:size="size"
|
||||||
|
:disabled="['Stopped', 'Restoring', 'Error', 'Destroyed'].includes(resource.state) ||
|
||||||
|
resource.hostcontrolstate === 'Offline'">
|
||||||
<code-outlined v-if="!copyUrlToClipboard"/>
|
<code-outlined v-if="!copyUrlToClipboard"/>
|
||||||
<copy-outlined v-else />
|
<copy-outlined v-else />
|
||||||
</a-button>
|
</a-button>
|
||||||
@ -49,11 +57,29 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
consoleUrl () {
|
async consoleUrl () {
|
||||||
const params = {}
|
try {
|
||||||
params.virtualmachineid = this.resource.id
|
const externalUrl = this.resource?.details?.['External:console_url']
|
||||||
postAPI('createConsoleEndpoint', params).then(json => {
|
if (externalUrl) {
|
||||||
this.url = (json && json.createconsoleendpointresponse) ? json.createconsoleendpointresponse.consoleendpoint.url : '#/exception/404'
|
this.url = externalUrl
|
||||||
|
if (this.copyUrlToClipboard) {
|
||||||
|
this.$copyText(this.url)
|
||||||
|
this.$message.success({
|
||||||
|
content: this.$t('label.copied.clipboard')
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
window.open(this.url, '_blank')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = { virtualmachineid: this.resource.id }
|
||||||
|
const json = await postAPI('createConsoleEndpoint', params)
|
||||||
|
|
||||||
|
this.url = (json && json.createconsoleendpointresponse)
|
||||||
|
? json.createconsoleendpointresponse.consoleendpoint.url
|
||||||
|
: '#/exception/404'
|
||||||
|
|
||||||
if (json.createconsoleendpointresponse.consoleendpoint.success) {
|
if (json.createconsoleendpointresponse.consoleendpoint.success) {
|
||||||
if (this.copyUrlToClipboard) {
|
if (this.copyUrlToClipboard) {
|
||||||
this.$copyText(this.url)
|
this.$copyText(this.url)
|
||||||
@ -69,9 +95,9 @@ export default {
|
|||||||
description: json.createconsoleendpointresponse.consoleendpoint.details
|
description: json.createconsoleendpointresponse.consoleendpoint.details
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}).catch(error => {
|
} catch (error) {
|
||||||
this.$notifyError(error)
|
this.$notifyError(error)
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user