Skip to content

Commit e5c5a64

Browse files
Adding extension support for Baremetal MaaS
1 parent ec533cd commit e5c5a64

File tree

2 files changed

+210
-0
lines changed

2 files changed

+210
-0
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

extensions/MaaS/maas.py

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
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+
from requests_oauthlib import OAuth1Session
22+
23+
24+
def fail(message):
25+
print(json.dumps({"error": message}))
26+
sys.exit(1)
27+
28+
29+
def succeed(data):
30+
print(json.dumps(data))
31+
sys.exit(0)
32+
33+
34+
class MaasManager:
35+
def __init__(self, config_path):
36+
self.config_path = config_path
37+
self.data = self.parse_json()
38+
self.session = self.init_session()
39+
40+
def parse_json(self):
41+
try:
42+
with open(self.config_path, "r") as f:
43+
json_data = json.load(f)
44+
45+
extension = json_data.get("externaldetails", {}).get("extension", {})
46+
host = json_data.get("externaldetails", {}).get("host", {})
47+
48+
endpoint = host.get("endpoint") or extension.get("endpoint")
49+
apikey = host.get("apikey") or extension.get("apikey")
50+
distro_series = host.get("distro_series") or extension.get("distro_series") or "ubuntu"
51+
52+
if not endpoint or not apikey:
53+
fail("Missing MAAS endpoint or apikey")
54+
55+
# normalize endpoint
56+
if not endpoint.startswith("http://") and not endpoint.startswith("https://"):
57+
endpoint = "http://" + endpoint
58+
endpoint = endpoint.rstrip("/")
59+
60+
# split api key
61+
parts = apikey.split(":")
62+
if len(parts) != 3:
63+
fail("Invalid apikey format. Expected consumer:token:secret")
64+
65+
consumer, token, secret = parts
66+
return {
67+
"endpoint": endpoint,
68+
"consumer": consumer,
69+
"token": token,
70+
"secret": secret,
71+
"distro_series": distro_series,
72+
"system_id": json_data.get("cloudstack.vm.details", {}).get("details", {}).get("maas_system_id", ""),
73+
"vm_name": json_data.get("cloudstack.vm.details", {}).get("name", ""),
74+
"memory": json_data.get("cloudstack.vm.details", {}).get("minRam", ""),
75+
"cpus": json_data.get("cloudstack.vm.details", {}).get("cpus", ""),
76+
"nics": json_data.get("cloudstack.vm.details", {}).get("nics", []),
77+
}
78+
except Exception as e:
79+
fail(f"Error parsing JSON: {str(e)}")
80+
81+
def init_session(self):
82+
return OAuth1Session(
83+
self.data["consumer"],
84+
resource_owner_key=self.data["token"],
85+
resource_owner_secret=self.data["secret"],
86+
)
87+
88+
def call_maas(self, method, path, data=None):
89+
if not path.startswith("/"):
90+
path = "/" + path
91+
url = f"{self.data['endpoint']}:5240/MAAS/api/2.0{path}"
92+
resp = self.session.request(method, url, data=data)
93+
if not resp.ok:
94+
fail(f"MAAS API error: {resp.status_code} {resp.text}")
95+
try:
96+
return resp.json() if resp.text else {}
97+
except ValueError:
98+
return {}
99+
100+
def prepare(self):
101+
machines = self.call_maas("GET", "/machines/")
102+
ready = [m for m in machines if m.get("status_name") == "Ready"]
103+
if not ready:
104+
fail("No Ready machines available")
105+
106+
system = ready[0]
107+
system_id = system["system_id"]
108+
mac = system.get("interface_set", [{}])[0].get("mac_address")
109+
110+
if not mac:
111+
fail("No MAC address found")
112+
113+
# Load original JSON so we can update nics
114+
with open(self.config_path, "r") as f:
115+
json_data = json.load(f)
116+
117+
if json_data.get("cloudstack.vm.details", {}).get("nics"):
118+
json_data["cloudstack.vm.details"]["nics"][0]["mac"] = mac
119+
120+
result = {
121+
"nics": json_data["cloudstack.vm.details"]["nics"],
122+
"details": {"External:mac_address": mac, "maas_system_id": system_id},
123+
}
124+
succeed(result)
125+
126+
def create(self):
127+
sysid = self.data.get("system_id")
128+
if not sysid:
129+
fail("system_id missing for create")
130+
self.call_maas(
131+
"POST",
132+
f"/machines/{sysid}/",
133+
{"op": "deploy", "distro_series": self.data["distro_series"]},
134+
)
135+
succeed({"status": "success", "message": f"Instance created with {self.data['distro_series']}"})
136+
137+
def delete(self):
138+
sysid = self.data.get("system_id")
139+
if not sysid:
140+
fail("system_id missing for delete")
141+
self.call_maas("POST", f"/machines/{sysid}/", {"op": "release"})
142+
succeed({"status": "success", "message": "Instance deleted"})
143+
144+
def start(self):
145+
sysid = self.data.get("system_id")
146+
if not sysid:
147+
fail("system_id missing for start")
148+
self.call_maas("POST", f"/machines/{sysid}/", {"op": "power_on"})
149+
succeed({"status": "success", "power_state": "PowerOn"})
150+
151+
def stop(self):
152+
sysid = self.data.get("system_id")
153+
if not sysid:
154+
fail("system_id missing for stop")
155+
self.call_maas("POST", f"/machines/{sysid}/", {"op": "power_off"})
156+
succeed({"status": "success", "power_state": "PowerOff"})
157+
158+
def reboot(self):
159+
sysid = self.data.get("system_id")
160+
if not sysid:
161+
fail("system_id missing for reboot")
162+
self.call_maas("POST", f"/machines/{sysid}/", {"op": "power_cycle"})
163+
succeed({"status": "success", "power_state": "PowerOn"})
164+
165+
def status(self):
166+
sysid = self.data.get("system_id")
167+
if not sysid:
168+
fail("system_id missing for status")
169+
resp = self.call_maas("GET", f"/machines/{sysid}/")
170+
state = resp.get("power_state", "")
171+
if state == "on":
172+
mapped = "PowerOn"
173+
elif state == "off":
174+
mapped = "PowerOff"
175+
else:
176+
mapped = "PowerUnknown"
177+
succeed({"status": "success", "power_state": mapped})
178+
179+
180+
def main():
181+
if len(sys.argv) < 3:
182+
fail("Usage: maas.py <action> <json-file-path>")
183+
184+
action = sys.argv[1].lower()
185+
json_file = sys.argv[2]
186+
187+
try:
188+
manager = MaasManager(json_file)
189+
except FileNotFoundError:
190+
fail(f"JSON file not found: {json_file}")
191+
192+
actions = {
193+
"prepare": manager.prepare,
194+
"create": manager.create,
195+
"delete": manager.delete,
196+
"start": manager.start,
197+
"stop": manager.stop,
198+
"reboot": manager.reboot,
199+
"status": manager.status,
200+
}
201+
202+
if action not in actions:
203+
fail("Invalid action")
204+
205+
actions[action]()
206+
207+
208+
if __name__ == "__main__":
209+
main()

0 commit comments

Comments
 (0)