Merge ~ltrager/maas:proxmox_2.9 into maas:2.9

Proposed by Lee Trager
Status: Merged
Approved by: Lee Trager
Approved revision: b88f47ea13f82e6231296016f807591f65470c8e
Merge reported by: MAAS Lander
Merged at revision: not available
Proposed branch: ~ltrager/maas:proxmox_2.9
Merge into: maas:2.9
Diff against target: 651 lines (+616/-1)
3 files modified
src/provisioningserver/drivers/power/proxmox.py (+194/-0)
src/provisioningserver/drivers/power/registry.py (+3/-1)
src/provisioningserver/drivers/power/tests/test_proxmox.py (+419/-0)
Reviewer Review Type Date Requested Status
Lee Trager (community) Approve
MAAS Lander unittests Pending
Review via email: mp+396618@code.launchpad.net

Commit message

LP: #1805799 - Add Proxmox power driver

Backport of 07ce9a2

To post a comment you must log in.
Revision history for this message
Lee Trager (ltrager) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/src/provisioningserver/drivers/power/proxmox.py b/src/provisioningserver/drivers/power/proxmox.py
2new file mode 100644
3index 0000000..c0e6ca1
4--- /dev/null
5+++ b/src/provisioningserver/drivers/power/proxmox.py
6@@ -0,0 +1,194 @@
7+# Copyright 2021 Canonical Ltd. This software is licensed under the
8+# GNU Affero General Public License version 3 (see the file LICENSE).
9+
10+"""Generic Proxmox Power Driver."""
11+
12+from io import BytesIO
13+import json
14+
15+from twisted.internet.defer import inlineCallbacks, succeed
16+from twisted.web.client import FileBodyProducer
17+
18+from provisioningserver.drivers import (
19+ make_ip_extractor,
20+ make_setting_field,
21+ SETTING_SCOPE,
22+)
23+from provisioningserver.drivers.power import PowerActionError
24+from provisioningserver.drivers.power.webhook import (
25+ SSL_INSECURE_CHOICES,
26+ SSL_INSECURE_NO,
27+ WebhookPowerDriver,
28+)
29+from provisioningserver.utils.twisted import asynchronous
30+
31+
32+class ProxmoxPowerDriver(WebhookPowerDriver):
33+
34+ name = "proxmox"
35+ chassis = False
36+ # XXX ltrager - 2021-01-11 - Support for probing and Pods could be added.
37+ can_probe = False
38+ description = "Proxmox"
39+ settings = [
40+ make_setting_field(
41+ "power_address", "Proxmox host name or IP", required=True
42+ ),
43+ make_setting_field(
44+ "power_user", "Proxmox username, including realm", required=True
45+ ),
46+ make_setting_field(
47+ "power_pass",
48+ "Proxmox password, required if a token name and secret aren't "
49+ "given",
50+ field_type="password",
51+ ),
52+ make_setting_field("power_token_name", "Proxmox API token name"),
53+ make_setting_field(
54+ "power_token_secret",
55+ "Proxmox API token secret",
56+ field_type="password",
57+ ),
58+ make_setting_field(
59+ "power_vm_name", "Node ID", scope=SETTING_SCOPE.NODE
60+ ),
61+ make_setting_field(
62+ "power_verify_ssl",
63+ "Verify SSL connections with system CA certificates",
64+ field_type="choice",
65+ required=True,
66+ choices=SSL_INSECURE_CHOICES,
67+ default=SSL_INSECURE_NO,
68+ ),
69+ ]
70+
71+ ip_extractor = make_ip_extractor("power_address")
72+
73+ def _get_url(self, context, endpoint, params=None):
74+ uri = f"https://{context['power_address']}:8006/api2/json/{endpoint}"
75+ if params:
76+ uri = "%s?%s" % (
77+ uri,
78+ "&".join([f"{key}={value}" for key, value in params.items()]),
79+ )
80+ return uri.encode()
81+
82+ def _login(self, system_id, context):
83+ power_token_name = context.get("power_token_name")
84+ if power_token_name:
85+ if "!" not in power_token_name:
86+ # The username must be included with the token name. Proxmox
87+ # docs doesn't make this obvious and the UI includes it.
88+ power_token_name = (
89+ f"{context['power_user']}!{power_token_name}"
90+ )
91+ return succeed(
92+ {
93+ b"Authorization": [
94+ f"PVEAPIToken={power_token_name}="
95+ f"{context['power_token_secret']}".encode()
96+ ]
97+ }
98+ )
99+
100+ d = self._webhook_request(
101+ b"POST",
102+ self._get_url(context, "access/ticket"),
103+ # Proxmox doesn't support basic HTTP authentication. Don't pass
104+ # the context so an authorization header isn't created.
105+ self._make_auth_headers(
106+ system_id,
107+ {},
108+ {b"Content-Type": [b"application/json; charset=utf-8"]},
109+ ),
110+ context.get("power_verify_ssl") is True,
111+ FileBodyProducer(
112+ BytesIO(
113+ json.dumps(
114+ {
115+ "username": context["power_user"],
116+ "password": context["power_pass"],
117+ }
118+ ).encode()
119+ )
120+ ),
121+ )
122+
123+ def cb(response_data):
124+ parsed_data = json.loads(response_data)
125+ return {
126+ b"Cookie": [
127+ f"PVEAuthCookie={parsed_data['data']['ticket']}".encode()
128+ ],
129+ b"CSRFPreventionToken": [
130+ parsed_data["data"]["CSRFPreventionToken"].encode()
131+ ],
132+ }
133+
134+ d.addCallback(cb)
135+ return d
136+
137+ def _find_vm(self, system_id, context, extra_headers):
138+ power_vm_name = context["power_vm_name"]
139+ d = self._webhook_request(
140+ b"GET",
141+ self._get_url(context, "cluster/resources", {"type": "vm"}),
142+ self._make_auth_headers(system_id, {}, extra_headers),
143+ context.get("power_verify_ssl") is True,
144+ )
145+
146+ def cb(response_data):
147+ parsed_data = json.loads(response_data)
148+ for vm in parsed_data["data"]:
149+ if power_vm_name in (str(vm.get("vmid")), vm.get("name")):
150+ return vm
151+ raise PowerActionError("Unable to find virtual machine")
152+
153+ d.addCallback(cb)
154+ return d
155+
156+ @asynchronous
157+ @inlineCallbacks
158+ def power_on(self, system_id, context):
159+ extra_headers = yield self._login(system_id, context)
160+ vm = yield self._find_vm(system_id, context, extra_headers)
161+ if vm["status"] != "running":
162+ yield self._webhook_request(
163+ b"POST",
164+ self._get_url(
165+ context,
166+ f"nodes/{vm['node']}/{vm['type']}/{vm['vmid']}/"
167+ "status/start",
168+ ),
169+ self._make_auth_headers(system_id, {}, extra_headers),
170+ context.get("power_verify_ssl") is True,
171+ )
172+
173+ @asynchronous
174+ @inlineCallbacks
175+ def power_off(self, system_id, context):
176+ extra_headers = yield self._login(system_id, context)
177+ vm = yield self._find_vm(system_id, context, extra_headers)
178+ if vm["status"] != "stopped":
179+ yield self._webhook_request(
180+ b"POST",
181+ self._get_url(
182+ context,
183+ f"nodes/{vm['node']}/{vm['type']}/{vm['vmid']}/"
184+ "status/stop",
185+ ),
186+ self._make_auth_headers(system_id, {}, extra_headers),
187+ context.get("power_verify_ssl") is True,
188+ )
189+
190+ @asynchronous
191+ @inlineCallbacks
192+ def power_query(self, system_id, context):
193+ extra_headers = yield self._login(system_id, context)
194+ vm = yield self._find_vm(system_id, context, extra_headers)
195+ if vm["status"] == "running":
196+ return "on"
197+ elif vm["status"] == "stopped":
198+ return "off"
199+ else:
200+ return "unknown"
201diff --git a/src/provisioningserver/drivers/power/registry.py b/src/provisioningserver/drivers/power/registry.py
202index 91baa68..5eb0b66 100644
203--- a/src/provisioningserver/drivers/power/registry.py
204+++ b/src/provisioningserver/drivers/power/registry.py
205@@ -1,4 +1,4 @@
206-# Copyright 2017-2020 Canonical Ltd. This software is licensed under the
207+# Copyright 2017-2021 Canonical Ltd. This software is licensed under the
208 # GNU Affero General Public License version 3 (see the file LICENSE).
209
210 """Load all power drivers."""
211@@ -20,6 +20,7 @@ from provisioningserver.drivers.power.mscm import MSCMPowerDriver
212 from provisioningserver.drivers.power.msftocs import MicrosoftOCSPowerDriver
213 from provisioningserver.drivers.power.nova import NovaPowerDriver
214 from provisioningserver.drivers.power.openbmc import OpenBMCPowerDriver
215+from provisioningserver.drivers.power.proxmox import ProxmoxPowerDriver
216 from provisioningserver.drivers.power.recs import RECSPowerDriver
217 from provisioningserver.drivers.power.redfish import RedfishPowerDriver
218 from provisioningserver.drivers.power.seamicro import SeaMicroPowerDriver
219@@ -61,6 +62,7 @@ power_drivers = [
220 MicrosoftOCSPowerDriver(),
221 NovaPowerDriver(),
222 OpenBMCPowerDriver(),
223+ ProxmoxPowerDriver(),
224 RECSPowerDriver(),
225 RedfishPowerDriver(),
226 SeaMicroPowerDriver(),
227diff --git a/src/provisioningserver/drivers/power/tests/test_proxmox.py b/src/provisioningserver/drivers/power/tests/test_proxmox.py
228new file mode 100644
229index 0000000..7c32b78
230--- /dev/null
231+++ b/src/provisioningserver/drivers/power/tests/test_proxmox.py
232@@ -0,0 +1,419 @@
233+# Copyright 2021 Canonical Ltd. This software is licensed under the
234+# GNU Affero General Public License version 3 (see the file LICENSE).
235+
236+"""Tests for `provisioningserver.drivers.power.proxmox`."""
237+import json
238+import random
239+from unittest.mock import ANY
240+
241+from testtools import ExpectedException
242+from twisted.internet.defer import inlineCallbacks, succeed
243+
244+from maastesting.factory import factory
245+from maastesting.matchers import MockCalledOnceWith, MockNotCalled
246+from maastesting.testcase import MAASTestCase, MAASTwistedRunTest
247+from provisioningserver.drivers.power import PowerActionError
248+import provisioningserver.drivers.power.proxmox as proxmox_module
249+
250+
251+class TestProxmoxPowerDriver(MAASTestCase):
252+
253+ run_tests_with = MAASTwistedRunTest.make_factory(timeout=5)
254+
255+ def setUp(self):
256+ super().setUp()
257+ self.proxmox = proxmox_module.ProxmoxPowerDriver()
258+ self.mock_webhook_request = self.patch(
259+ self.proxmox, "_webhook_request"
260+ )
261+
262+ def test_get_url(self):
263+ power_address = factory.make_name("power_address")
264+ endpoint = factory.make_name("endpoint")
265+ self.assertEqual(
266+ f"https://{power_address}:8006/api2/json/{endpoint}".encode(),
267+ self.proxmox._get_url({"power_address": power_address}, endpoint),
268+ )
269+
270+ def test_get_url_params(self):
271+ power_address = factory.make_name("power_address")
272+ endpoint = factory.make_name("endpoint")
273+ params = {
274+ factory.make_name("key"): factory.make_name("value")
275+ for _ in range(3)
276+ }
277+ params_str = "&".join(
278+ [f"{key}={value}" for key, value in params.items()]
279+ )
280+ self.assertEqual(
281+ f"https://{power_address}:8006/api2/json/{endpoint}?{params_str}".encode(),
282+ self.proxmox._get_url(
283+ {"power_address": power_address}, endpoint, params
284+ ),
285+ )
286+
287+ @inlineCallbacks
288+ def test_login(self):
289+ system_id = factory.make_name("system_id")
290+ context = {
291+ "power_address": factory.make_name("power_address"),
292+ "power_user": factory.make_name("power_user"),
293+ "power_pass": factory.make_name("power_pass"),
294+ }
295+ ticket = factory.make_name("ticket")
296+ token = factory.make_name("token")
297+ self.mock_webhook_request.return_value = succeed(
298+ json.dumps(
299+ {
300+ "data": {
301+ "ticket": ticket,
302+ "CSRFPreventionToken": token,
303+ }
304+ }
305+ )
306+ )
307+
308+ extra_headers = yield self.proxmox._login(system_id, context)
309+
310+ self.assertEqual(
311+ {
312+ b"Cookie": [f"PVEAuthCookie={ticket}".encode()],
313+ b"CSRFPreventionToken": [token.encode()],
314+ },
315+ extra_headers,
316+ )
317+ self.assertThat(
318+ self.mock_webhook_request,
319+ MockCalledOnceWith(
320+ b"POST",
321+ self.proxmox._get_url(context, "access/ticket"),
322+ self.proxmox._make_auth_headers(
323+ system_id,
324+ {},
325+ {b"Content-Type": [b"application/json; charset=utf-8"]},
326+ ),
327+ False,
328+ # unittest doesn't know how to compare FileBodyProducer
329+ ANY,
330+ ),
331+ )
332+
333+ @inlineCallbacks
334+ def test_login_uses_api_token(self):
335+ system_id = factory.make_name("system_id")
336+ power_user = factory.make_name("power_user")
337+ context = {
338+ "power_address": factory.make_name("power_address"),
339+ "power_user": power_user,
340+ "power_pass": factory.make_name("power_pass"),
341+ "power_token_name": f"{power_user}!{factory.make_name('power_token_name')}",
342+ "power_token_secret": factory.make_name("power_token_secret"),
343+ }
344+
345+ extra_headers = yield self.proxmox._login(system_id, context)
346+
347+ self.assertEqual(
348+ {
349+ b"Authorization": [
350+ f"PVEAPIToken={context['power_token_name']}="
351+ f"{context['power_token_secret']}".encode()
352+ ]
353+ },
354+ extra_headers,
355+ )
356+ self.assertThat(self.mock_webhook_request, MockNotCalled())
357+
358+ @inlineCallbacks
359+ def test_login_uses_api_token_adds_username(self):
360+ system_id = factory.make_name("system_id")
361+ context = {
362+ "power_address": factory.make_name("power_address"),
363+ "power_user": factory.make_name("power_user"),
364+ "power_pass": factory.make_name("power_pass"),
365+ "power_token_name": factory.make_name("power_token_name"),
366+ "power_token_secret": factory.make_name("power_token_secret"),
367+ }
368+
369+ extra_headers = yield self.proxmox._login(system_id, context)
370+
371+ self.assertEqual(
372+ {
373+ b"Authorization": [
374+ f"PVEAPIToken={context['power_user']}!"
375+ f"{context['power_token_name']}="
376+ f"{context['power_token_secret']}".encode()
377+ ]
378+ },
379+ extra_headers,
380+ )
381+ self.assertThat(self.mock_webhook_request, MockNotCalled())
382+
383+ @inlineCallbacks
384+ def test_find_vm(self):
385+ system_id = factory.make_name("system_id")
386+ context = {
387+ "power_address": factory.make_name("power_address"),
388+ "power_vm_name": factory.make_name("power_vm_name"),
389+ }
390+ vm = {random.choice(["vmid", "name"]): context["power_vm_name"]}
391+ extra_headers = {
392+ factory.make_name("key").encode(): [
393+ factory.make_name("value").encode()
394+ ]
395+ for _ in range(3)
396+ }
397+ self.mock_webhook_request.return_value = succeed(
398+ json.dumps({"data": [vm]})
399+ )
400+
401+ found_vm = yield self.proxmox._find_vm(
402+ system_id, context, extra_headers
403+ )
404+
405+ self.assertEqual(vm, found_vm)
406+ self.assertThat(
407+ self.mock_webhook_request,
408+ MockCalledOnceWith(
409+ b"GET",
410+ self.proxmox._get_url(
411+ context, "cluster/resources", {"type": "vm"}
412+ ),
413+ self.proxmox._make_auth_headers(system_id, {}, extra_headers),
414+ False,
415+ ),
416+ )
417+
418+ @inlineCallbacks
419+ def test_find_vm_doesnt_find_vm(self):
420+ system_id = factory.make_name("system_id")
421+ context = {
422+ "power_address": factory.make_name("power_address"),
423+ "power_vm_name": factory.make_name("power_vm_name"),
424+ }
425+ vm = {
426+ random.choice(["vmid", "name"]): factory.make_name(
427+ "another_power_vm_name"
428+ )
429+ }
430+ extra_headers = {
431+ factory.make_name("key").encode(): [
432+ factory.make_name("value").encode()
433+ ]
434+ for _ in range(3)
435+ }
436+ self.mock_webhook_request.return_value = succeed(
437+ json.dumps({"data": [vm]})
438+ )
439+
440+ with ExpectedException(PowerActionError):
441+ yield self.proxmox._find_vm(system_id, context, extra_headers)
442+ self.assertThat(
443+ self.mock_webhook_request,
444+ MockCalledOnceWith(
445+ b"GET",
446+ self.proxmox._get_url(
447+ context, "cluster/resources", {"type": "vm"}
448+ ),
449+ self.proxmox._make_auth_headers(system_id, {}, extra_headers),
450+ False,
451+ ),
452+ )
453+
454+ @inlineCallbacks
455+ def test_power_on(self):
456+ system_id = factory.make_name("system_id")
457+ context = {"power_address": factory.make_name("power_address")}
458+ extra_headers = {
459+ factory.make_name("key").encode(): [
460+ factory.make_name("value").encode()
461+ ]
462+ for _ in range(3)
463+ }
464+ vm = {
465+ "node": factory.make_name("node"),
466+ "type": factory.make_name("type"),
467+ "vmid": factory.make_name("vmid"),
468+ "status": "stopped",
469+ }
470+ self.patch(self.proxmox, "_login").return_value = succeed(
471+ extra_headers
472+ )
473+ self.patch(self.proxmox, "_find_vm").return_value = succeed(vm)
474+
475+ yield self.proxmox.power_on(system_id, context)
476+
477+ self.assertThat(
478+ self.mock_webhook_request,
479+ MockCalledOnceWith(
480+ b"POST",
481+ self.proxmox._get_url(
482+ context,
483+ f"nodes/{vm['node']}/{vm['type']}/{vm['vmid']}/"
484+ "status/start",
485+ ),
486+ self.proxmox._make_auth_headers(system_id, {}, extra_headers),
487+ False,
488+ ),
489+ )
490+
491+ @inlineCallbacks
492+ def test_power_on_not_called_if_on(self):
493+ system_id = factory.make_name("system_id")
494+ context = {"power_address": factory.make_name("power_address")}
495+ extra_headers = {
496+ factory.make_name("key").encode(): [
497+ factory.make_name("value").encode()
498+ ]
499+ for _ in range(3)
500+ }
501+ vm = {
502+ "node": factory.make_name("node"),
503+ "type": factory.make_name("type"),
504+ "vmid": factory.make_name("vmid"),
505+ "status": "running",
506+ }
507+ self.patch(self.proxmox, "_login").return_value = succeed(
508+ extra_headers
509+ )
510+ self.patch(self.proxmox, "_find_vm").return_value = succeed(vm)
511+
512+ yield self.proxmox.power_on(system_id, context)
513+
514+ self.assertThat(self.mock_webhook_request, MockNotCalled())
515+
516+ @inlineCallbacks
517+ def test_power_off(self):
518+ system_id = factory.make_name("system_id")
519+ context = {"power_address": factory.make_name("power_address")}
520+ extra_headers = {
521+ factory.make_name("key").encode(): [
522+ factory.make_name("value").encode()
523+ ]
524+ for _ in range(3)
525+ }
526+ vm = {
527+ "node": factory.make_name("node"),
528+ "type": factory.make_name("type"),
529+ "vmid": factory.make_name("vmid"),
530+ "status": "running",
531+ }
532+ self.patch(self.proxmox, "_login").return_value = succeed(
533+ extra_headers
534+ )
535+ self.patch(self.proxmox, "_find_vm").return_value = succeed(vm)
536+
537+ yield self.proxmox.power_off(system_id, context)
538+
539+ self.assertThat(
540+ self.mock_webhook_request,
541+ MockCalledOnceWith(
542+ b"POST",
543+ self.proxmox._get_url(
544+ context,
545+ f"nodes/{vm['node']}/{vm['type']}/{vm['vmid']}/"
546+ "status/stop",
547+ ),
548+ self.proxmox._make_auth_headers(system_id, {}, extra_headers),
549+ False,
550+ ),
551+ )
552+
553+ @inlineCallbacks
554+ def test_power_off_not_called_if_off(self):
555+ system_id = factory.make_name("system_id")
556+ context = {"power_address": factory.make_name("power_address")}
557+ extra_headers = {
558+ factory.make_name("key").encode(): [
559+ factory.make_name("value").encode()
560+ ]
561+ for _ in range(3)
562+ }
563+ vm = {
564+ "node": factory.make_name("node"),
565+ "type": factory.make_name("type"),
566+ "vmid": factory.make_name("vmid"),
567+ "status": "stopped",
568+ }
569+ self.patch(self.proxmox, "_login").return_value = succeed(
570+ extra_headers
571+ )
572+ self.patch(self.proxmox, "_find_vm").return_value = succeed(vm)
573+
574+ yield self.proxmox.power_off(system_id, context)
575+
576+ self.assertThat(self.mock_webhook_request, MockNotCalled())
577+
578+ @inlineCallbacks
579+ def test_power_query_on(self):
580+ system_id = factory.make_name("system_id")
581+ context = {"power_address": factory.make_name("power_address")}
582+ extra_headers = {
583+ factory.make_name("key").encode(): [
584+ factory.make_name("value").encode()
585+ ]
586+ for _ in range(3)
587+ }
588+ vm = {
589+ "node": factory.make_name("node"),
590+ "type": factory.make_name("type"),
591+ "vmid": factory.make_name("vmid"),
592+ "status": "running",
593+ }
594+ self.patch(self.proxmox, "_login").return_value = succeed(
595+ extra_headers
596+ )
597+ self.patch(self.proxmox, "_find_vm").return_value = succeed(vm)
598+
599+ status = yield self.proxmox.power_query(system_id, context)
600+
601+ self.assertEqual("on", status)
602+
603+ @inlineCallbacks
604+ def test_power_query_off(self):
605+ system_id = factory.make_name("system_id")
606+ context = {"power_address": factory.make_name("power_address")}
607+ extra_headers = {
608+ factory.make_name("key").encode(): [
609+ factory.make_name("value").encode()
610+ ]
611+ for _ in range(3)
612+ }
613+ vm = {
614+ "node": factory.make_name("node"),
615+ "type": factory.make_name("type"),
616+ "vmid": factory.make_name("vmid"),
617+ "status": "stopped",
618+ }
619+ self.patch(self.proxmox, "_login").return_value = succeed(
620+ extra_headers
621+ )
622+ self.patch(self.proxmox, "_find_vm").return_value = succeed(vm)
623+
624+ status = yield self.proxmox.power_query(system_id, context)
625+
626+ self.assertEqual("off", status)
627+
628+ @inlineCallbacks
629+ def test_power_query_unknown(self):
630+ system_id = factory.make_name("system_id")
631+ context = {"power_address": factory.make_name("power_address")}
632+ extra_headers = {
633+ factory.make_name("key").encode(): [
634+ factory.make_name("value").encode()
635+ ]
636+ for _ in range(3)
637+ }
638+ vm = {
639+ "node": factory.make_name("node"),
640+ "type": factory.make_name("type"),
641+ "vmid": factory.make_name("vmid"),
642+ "status": factory.make_name("status"),
643+ }
644+ self.patch(self.proxmox, "_login").return_value = succeed(
645+ extra_headers
646+ )
647+ self.patch(self.proxmox, "_find_vm").return_value = succeed(vm)
648+
649+ status = yield self.proxmox.power_query(system_id, context)
650+
651+ self.assertEqual("unknown", status)

Subscribers

People subscribed via source and target branches