This is an automated email from the ASF dual-hosted git repository.

harikrishna pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/cloudstack.git


The following commit(s) were added to refs/heads/main by this push:
     new b99a03092f2 Added Extension for MaaS integration in CloudStack (#11613)
b99a03092f2 is described below

commit b99a03092f2c3db846b858653ed634b7af39b0b7
Author: Harikrishna <[email protected]>
AuthorDate: Fri Oct 10 14:57:30 2025 +0530

    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]>
---
 debian/cloudstack-management.install               |   1 +
 .../resources/META-INF/db/schema-42100to42200.sql  |   3 +
 extensions/MaaS/maas.py                            | 263 +++++++++++++++++++++
 ui/src/components/widgets/Console.vue              |  44 +++-
 4 files changed, 302 insertions(+), 9 deletions(-)

diff --git a/debian/cloudstack-management.install 
b/debian/cloudstack-management.install
index b2a32bd93c1..befc7049c30 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/engine/schema/src/main/resources/META-INF/db/schema-42100to42200.sql 
b/engine/schema/src/main/resources/META-INF/db/schema-42100to42200.sql
index bdf3bc3a63c..5b9b4272263 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
@@ -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
 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', 'Baremetal Extension for 
Canonical MaaS written in Python', 'MaaS/maas.py');
+CALL `cloud`.`INSERT_EXTENSION_DETAIL_IF_NOT_EXISTS`('MaaS', 
'orchestratorrequirespreparevm', 'true', 0);
diff --git a/extensions/MaaS/maas.py b/extensions/MaaS/maas.py
new file mode 100755
index 00000000000..3d97cedb02f
--- /dev/null
+++ b/extensions/MaaS/maas.py
@@ -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()
diff --git a/ui/src/components/widgets/Console.vue 
b/ui/src/components/widgets/Console.vue
index 31105743795..edee7498991 100644
--- a/ui/src/components/widgets/Console.vue
+++ b/ui/src/components/widgets/Console.vue
@@ -17,9 +17,17 @@
 
 <template>
   <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">
-    <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"/>
       <copy-outlined v-else />
     </a-button>
@@ -49,11 +57,29 @@ export default {
     }
   },
   methods: {
-    consoleUrl () {
-      const params = {}
-      params.virtualmachineid = this.resource.id
-      postAPI('createConsoleEndpoint', params).then(json => {
-        this.url = (json && json.createconsoleendpointresponse) ? 
json.createconsoleendpointresponse.consoleendpoint.url : '#/exception/404'
+    async consoleUrl () {
+      try {
+        const externalUrl = this.resource?.details?.['External:console_url']
+        if (externalUrl) {
+          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 (this.copyUrlToClipboard) {
             this.$copyText(this.url)
@@ -69,9 +95,9 @@ export default {
             description: 
json.createconsoleendpointresponse.consoleendpoint.details
           })
         }
-      }).catch(error => {
+      } catch (error) {
         this.$notifyError(error)
-      })
+      }
     }
   },
   computed: {

Reply via email to