From db0391cc07a728a916d930ff9b3e056454827d1e Mon Sep 17 00:00:00 2001 From: Harikrishna Patnala Date: Tue, 9 Sep 2025 10:45:23 +0530 Subject: [PATCH 01/12] Adding extension support for Baremetal MaaS --- debian/cloudstack-management.install | 1 + extensions/MaaS/maas.py | 209 +++++++++++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 extensions/MaaS/maas.py diff --git a/debian/cloudstack-management.install b/debian/cloudstack-management.install index b2a32bd93c14..befc7049c30e 100644 --- a/debian/cloudstack-management.install +++ b/debian/cloudstack-management.install @@ -24,6 +24,7 @@ /etc/cloudstack/management/config.json /etc/cloudstack/extensions/Proxmox/proxmox.sh /etc/cloudstack/extensions/HyperV/hyperv.py +/etc/cloudstack/extensions/MaaS/maas.py /etc/default/cloudstack-management /etc/security/limits.d/cloudstack-limits.conf /etc/sudoers.d/cloudstack diff --git a/extensions/MaaS/maas.py b/extensions/MaaS/maas.py new file mode 100644 index 000000000000..93316bf0f567 --- /dev/null +++ b/extensions/MaaS/maas.py @@ -0,0 +1,209 @@ +#!/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 +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", {}) + + endpoint = host.get("endpoint") or extension.get("endpoint") + apikey = host.get("apikey") or extension.get("apikey") + distro_series = host.get("distro_series") or extension.get("distro_series") or "ubuntu" + + if not endpoint or not apikey: + fail("Missing MAAS endpoint or apikey") + + # normalize endpoint + if not endpoint.startswith("http://") and not endpoint.startswith("https://"): + endpoint = "http://" + endpoint + endpoint = endpoint.rstrip("/") + + # split api key + parts = apikey.split(":") + if len(parts) != 3: + fail("Invalid apikey format. Expected consumer:token:secret") + + consumer, token, secret = parts + return { + "endpoint": endpoint, + "consumer": consumer, + "token": token, + "secret": secret, + "distro_series": distro_series, + "system_id": json_data.get("cloudstack.vm.details", {}).get("details", {}).get("maas_system_id", ""), + "vm_name": json_data.get("cloudstack.vm.details", {}).get("name", ""), + "memory": json_data.get("cloudstack.vm.details", {}).get("minRam", ""), + "cpus": json_data.get("cloudstack.vm.details", {}).get("cpus", ""), + "nics": json_data.get("cloudstack.vm.details", {}).get("nics", []), + } + 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): + 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 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") + + system = ready[0] + system_id = system["system_id"] + mac = system.get("interface_set", [{}])[0].get("mac_address") + + 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 + + result = { + "nics": json_data["cloudstack.vm.details"]["nics"], + "details": {"External:mac_address": mac, "maas_system_id": system_id}, + } + succeed(result) + + def create(self): + sysid = self.data.get("system_id") + if not sysid: + fail("system_id missing for create") + self.call_maas( + "POST", + f"/machines/{sysid}/", + {"op": "deploy", "distro_series": self.data["distro_series"]}, + ) + succeed({"status": "success", "message": f"Instance created with {self.data['distro_series']}"}) + + 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"}) + succeed({"status": "success", "message": "Instance deleted"}) + + 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_cycle"}) + succeed({"status": "success", "power_state": "PowerOn"}) + + 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 = 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() From 9e552f69c5fbe8dcc281161bc07c1dadb053da25 Mon Sep 17 00:00:00 2001 From: Harikrishna Patnala Date: Thu, 11 Sep 2025 13:40:01 +0530 Subject: [PATCH 02/12] Added sql entries --- .../src/main/resources/META-INF/db/schema-42100to42200.sql | 3 +++ 1 file changed, 3 insertions(+) diff --git a/engine/schema/src/main/resources/META-INF/db/schema-42100to42200.sql b/engine/schema/src/main/resources/META-INF/db/schema-42100to42200.sql index d6087ed9a5f0..da154136dfc2 100644 --- a/engine/schema/src/main/resources/META-INF/db/schema-42100to42200.sql +++ b/engine/schema/src/main/resources/META-INF/db/schema-42100to42200.sql @@ -38,3 +38,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 UPDATE `cloud`.`storage_pool_details` SET display = 0 WHERE name LIKE '%password%'; UPDATE `cloud`.`storage_pool_details` SET display = 0 WHERE name LIKE '%token%'; + +CALL `cloud`.`INSERT_EXTENSION_IF_NOT_EXISTS`('MaaS', 'Sample extension for Baremetal MaaS written in python', 'MaaS/maas.py'); +CALL `cloud`.`INSERT_EXTENSION_DETAIL_IF_NOT_EXISTS`('MaaS', 'orchestratorrequirespreparevm', 'true', 0); From 1f4bbbe51ed7d122e23b3fb7627698b4d1e1f6f1 Mon Sep 17 00:00:00 2001 From: Harikrishna Patnala Date: Thu, 11 Sep 2025 16:36:26 +0530 Subject: [PATCH 03/12] Make distro_series param read from template or VM details --- extensions/MaaS/maas.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/extensions/MaaS/maas.py b/extensions/MaaS/maas.py index 93316bf0f567..488c9f2b0685 100644 --- a/extensions/MaaS/maas.py +++ b/extensions/MaaS/maas.py @@ -47,7 +47,11 @@ def parse_json(self): endpoint = host.get("endpoint") or extension.get("endpoint") apikey = host.get("apikey") or extension.get("apikey") - distro_series = host.get("distro_series") or extension.get("distro_series") or "ubuntu" + distro_series = ( + json_data.get("cloudstack.vm.details", {}) + .get("details", {}) + .get("distro_series", "ubuntu") + ) if not endpoint or not apikey: fail("Missing MAAS endpoint or apikey") From 6d811e93f35d0e94161eae4181fe04c480194b50 Mon Sep 17 00:00:00 2001 From: Harikrishna Patnala Date: Mon, 15 Sep 2025 18:34:07 +0530 Subject: [PATCH 04/12] Fix script --- extensions/MaaS/maas.py | 4 ---- 1 file changed, 4 deletions(-) mode change 100644 => 100755 extensions/MaaS/maas.py diff --git a/extensions/MaaS/maas.py b/extensions/MaaS/maas.py old mode 100644 new mode 100755 index 488c9f2b0685..2c39477fbb66 --- a/extensions/MaaS/maas.py +++ b/extensions/MaaS/maas.py @@ -74,10 +74,6 @@ def parse_json(self): "secret": secret, "distro_series": distro_series, "system_id": json_data.get("cloudstack.vm.details", {}).get("details", {}).get("maas_system_id", ""), - "vm_name": json_data.get("cloudstack.vm.details", {}).get("name", ""), - "memory": json_data.get("cloudstack.vm.details", {}).get("minRam", ""), - "cpus": json_data.get("cloudstack.vm.details", {}).get("cpus", ""), - "nics": json_data.get("cloudstack.vm.details", {}).get("nics", []), } except Exception as e: fail(f"Error parsing JSON: {str(e)}") From b3ef746e161e19753e5f024be6b75526b8e93672 Mon Sep 17 00:00:00 2001 From: Harikrishna Patnala Date: Thu, 25 Sep 2025 11:13:06 +0530 Subject: [PATCH 05/12] Fix system id usage --- extensions/MaaS/maas.py | 58 +++++++++++++++++++++++++++++++++++------ 1 file changed, 50 insertions(+), 8 deletions(-) diff --git a/extensions/MaaS/maas.py b/extensions/MaaS/maas.py index 2c39477fbb66..c950c93c7ce9 100755 --- a/extensions/MaaS/maas.py +++ b/extensions/MaaS/maas.py @@ -44,36 +44,48 @@ def parse_json(self): 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") distro_series = ( json_data.get("cloudstack.vm.details", {}) .get("details", {}) - .get("distro_series", "ubuntu") + .get("distro_series", None) ) if not endpoint or not apikey: fail("Missing MAAS endpoint or apikey") - # normalize endpoint if not endpoint.startswith("http://") and not endpoint.startswith("https://"): endpoint = "http://" + endpoint endpoint = endpoint.rstrip("/") - # split api key parts = apikey.split(":") if len(parts) != 3: fail("Invalid apikey format. Expected consumer:token:secret") consumer, token, secret = parts + + system_id = ( + json_data.get("cloudstack.vm.details", {}) + .get("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, - "system_id": json_data.get("cloudstack.vm.details", {}).get("details", {}).get("maas_system_id", ""), + "distro_series": distro_series or "ubuntu/focal", + "system_id": system_id, + "vm_name": vm_name, } except Exception as e: fail(f"Error parsing JSON: {str(e)}") @@ -103,9 +115,19 @@ def prepare(self): if not ready: fail("No Ready machines available") - system = ready[0] + 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") @@ -119,7 +141,11 @@ def prepare(self): result = { "nics": json_data["cloudstack.vm.details"]["nics"], - "details": {"External:mac_address": mac, "maas_system_id": system_id}, + "details": { + "External:mac_address": mac, + "External:maas_system_id": system_id, + "External:hostname": hostname, + }, } succeed(result) @@ -127,10 +153,26 @@ def create(self): sysid = self.data.get("system_id") if not sysid: fail("system_id missing for create") + + ds = self.data.get("distro_series", "ubuntu/focal") + vm_name = self.data.get("vm_name") + + # Cloud-init userdata to disable netplan, flush IPs on ens35, and run dhclient + userdata = """#cloud-config +network: + config: disabled +runcmd: + - [ sh, -c, "dhclient -v -4 ens35 || true" ] +""" + self.call_maas( "POST", f"/machines/{sysid}/", - {"op": "deploy", "distro_series": self.data["distro_series"]}, + { + "op": "deploy", + "distro_series": ds, + "userdata": userdata, + }, ) succeed({"status": "success", "message": f"Instance created with {self.data['distro_series']}"}) From 9a53d801c5baa8f0d64aec710d74bcdaf96e7b24 Mon Sep 17 00:00:00 2001 From: Harikrishna Patnala Date: Fri, 26 Sep 2025 08:04:50 +0530 Subject: [PATCH 06/12] Added redirect url for MaaS instance page --- extensions/MaaS/maas.py | 3 ++ ui/src/components/widgets/Console.vue | 44 +++++++++++++++++++++------ 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/extensions/MaaS/maas.py b/extensions/MaaS/maas.py index c950c93c7ce9..1541d6779460 100755 --- a/extensions/MaaS/maas.py +++ b/extensions/MaaS/maas.py @@ -139,12 +139,15 @@ def prepare(self): 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) diff --git a/ui/src/components/widgets/Console.vue b/ui/src/components/widgets/Console.vue index 311057437958..edee7498991a 100644 --- a/ui/src/components/widgets/Console.vue +++ b/ui/src/components/widgets/Console.vue @@ -17,9 +17,17 @@