Merge ~travisghansen/maas:master into maas:master

Proposed by Travis Glenn Hansen
Status: Merged
Merge reported by: Lee Trager
Merged at revision: 13a6cadde0c989e676cc98f6d25264d1885b6509
Proposed branch: ~travisghansen/maas:master
Merge into: maas:master
Diff against target: 147 lines (+123/-0)
2 files modified
src/provisioningserver/drivers/power/registry.py (+2/-0)
src/provisioningserver/drivers/power/webhook.py (+121/-0)
Reviewer Review Type Date Requested Status
Lee Trager (community) Approve
MAAS Lander Needs Fixing
Review via email: mp+395743@code.launchpad.net

Commit message

Webhook Power Driver

Description of the change

This is a simple power driver that POSTs to an http(s) endpoint of the user's choosing. The endpoint receives a json payload which looks like:

{"action":"power_on","system_id":"<id>"}
OR
{"action":"power_off","system_id":"<id>"}
OR
{"action":"power_query","system_id":"<id>"}

The "power_query" POST expects a response of the following:
{"power_state": "on|off|unknown"}

SSL validation can be disabled to work with self-signed certs.

Authentication can be done by either setting a username/password for basic auth OR bearer auth by setting a token. Preference if given to the token if both token and username/password are set.

Of note, the URL can include query params.

I've tested this out by hacking the files into a snap and prototyping. In my simple use-case I'm configuring the endpoint to point to a Node-RED instance I have and then I wire everything up to control some crude/cheap iot power plugs (gosund devices with tasmota firmware flashed).

I'm completely new to maas (and python is not my strength) so I could be missing something entirely here so any tips are appreciated :)

To post a comment you must log in.
Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b master lp:~travisghansen/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas/job/branch-tester/8968/console
COMMIT: 64e77f06124644a038f2705826989b41be66580d

review: Needs Fixing
Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b master lp:~travisghansen/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas/job/branch-tester/8969/console
COMMIT: 23f40223494313dc3a298987b622604ef0387e98

review: Needs Fixing
Revision history for this message
Adam Collard (adam-collard) wrote :

Thanks for the contribution!

I haven't looked at the code per se but wanted to share output from the CI job:

ERROR: /run/build/maas/src/provisioningserver/drivers/power/webhook.py Imports are incorrectly sorted.
 --- /run/build/maas/src/provisioningserver/drivers/power/webhook.py:before 2021-01-04 19:19:49.596325
 +++ /run/build/maas/src/provisioningserver/drivers/power/webhook.py:after 2021-01-04 19:22:47.812771
 @@ -11,13 +11,13 @@
  import urllib.request

  from provisioningserver.drivers import make_ip_extractor, make_setting_field
 -from provisioningserver.logger import get_maas_logger
  from provisioningserver.drivers.power import (
      PowerActionError,
      PowerConnError,
      PowerDriver,
      PowerFatalError,
  )
 +from provisioningserver.logger import get_maas_logger

  maaslog = get_maas_logger("drivers.power.webhook")

 make: *** [Makefile:213: lint-py] Error 1

Revision history for this message
Travis Glenn Hansen (travisghansen) wrote :

Fun. Do you have a standard linter/code formatter I can use to sort it out (pun intended).

Revision history for this message
Adam Collard (adam-collard) wrote :

We do indeed!

`make format-py` is the easiest way to get it, else `bin/isort src/provisioningserver/drivers/power/webhook.py` for a more targetted fix.

In terms of formatting, we use https://github.com/psf/black

Revision history for this message
Travis Glenn Hansen (travisghansen) wrote :

I tried the make format-py and it updated basically everything, so I went with the isort command :)

Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b master lp:~travisghansen/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas/job/branch-tester/8992/console
COMMIT: a90321b475d79e7812d7a516e142bb3b7ddad855

review: Needs Fixing
Revision history for this message
Lee Trager (ltrager) wrote :

Thanks for the patch! Couple of things

1. I think this should have an optional "id" field. This will allow one webhook service to handle multiple machines. For example if you create a wake on LAN webhook the id would be the MAC address.
2. We require unit tests for all branches to land.
3. MAAS uses Twisted[1] for HTTP calls. This allows MAAS to perform other tasks while waiting for the response.
4. See other comments below

If Twisted looks to complicated you can skip 2 and 3 and I'll Twistify it and add unit tests in a follow up branch.

[1] https://twistedmatrix.com/documents/current/web/howto/client.html

review: Needs Fixing
Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b master lp:~travisghansen/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas/job/branch-tester/8993/console
COMMIT: 13a6cadde0c989e676cc98f6d25264d1885b6509

review: Needs Fixing
Revision history for this message
Travis Glenn Hansen (travisghansen) wrote :

Thanks for the review!

1. I considered this but figured it could be managed via the GET/query params within the URL itself.
2. I'll see what I can put together for that. Trying to setup a development environment has been...challenging. Even worse is trying to inject the code into a snap :( so testing for me generally has been quite challenging.
3. I based it on the code from recs.py which is using urllib

It would be great if you could twistify it as I'm sure I'll mess something up with the async/await stuff especially given how hard it is for me to *really* test changes. Is there a doc somewhere with instructions for running locally and what services are needed etc? Preferably something that is OS independent since I don't run ubuntu on my workstation.

Revision history for this message
Travis Glenn Hansen (travisghansen) wrote :

Any further guidance on this? I'd hate to see bitrot :)

Revision history for this message
Lee Trager (ltrager) wrote :

I created a follow on branch[1] which uses Twisted for the web calls and makes this a bit more extendable so it will work with multiple services. If that works I can land both.

[1] https://code.launchpad.net/~ltrager/maas/+git/maas/+merge/396049

Revision history for this message
Travis Glenn Hansen (travisghansen) wrote :

Awesome! I've commented over there to continue the conversation!

Revision history for this message
Lee Trager (ltrager) :
review: Approve
Revision history for this message
MAAS Lander (maas-lander) wrote :

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/src/provisioningserver/drivers/power/registry.py b/src/provisioningserver/drivers/power/registry.py
2index 93db204..91baa68 100644
3--- a/src/provisioningserver/drivers/power/registry.py
4+++ b/src/provisioningserver/drivers/power/registry.py
5@@ -25,6 +25,7 @@ from provisioningserver.drivers.power.redfish import RedfishPowerDriver
6 from provisioningserver.drivers.power.seamicro import SeaMicroPowerDriver
7 from provisioningserver.drivers.power.ucsm import UCSMPowerDriver
8 from provisioningserver.drivers.power.vmware import VMwarePowerDriver
9+from provisioningserver.drivers.power.webhook import WebhookPowerDriver
10 from provisioningserver.drivers.power.wedge import WedgePowerDriver
11 from provisioningserver.utils.registry import Registry
12
13@@ -65,6 +66,7 @@ power_drivers = [
14 SeaMicroPowerDriver(),
15 UCSMPowerDriver(),
16 VMwarePowerDriver(),
17+ WebhookPowerDriver(),
18 WedgePowerDriver(),
19 ]
20 for driver in power_drivers:
21diff --git a/src/provisioningserver/drivers/power/webhook.py b/src/provisioningserver/drivers/power/webhook.py
22new file mode 100644
23index 0000000..ad7e5b6
24--- /dev/null
25+++ b/src/provisioningserver/drivers/power/webhook.py
26@@ -0,0 +1,121 @@
27+# Copyright 2016 Canonical Ltd. This software is licensed under the
28+# GNU Affero General Public License version 3 (see the file LICENSE).
29+
30+"""Generic Webhook Power Driver."""
31+
32+import base64
33+import json
34+import ssl
35+import urllib.error
36+import urllib.parse
37+import urllib.request
38+
39+from provisioningserver.drivers import make_ip_extractor, make_setting_field
40+from provisioningserver.drivers.power import (
41+ PowerActionError,
42+ PowerConnError,
43+ PowerDriver,
44+ PowerFatalError,
45+)
46+
47+SSL_INSECURE_YES="y"
48+SSL_INSECURE_NO="n"
49+
50+SSL_INSECURE_CHOICES = [
51+ [SSL_INSECURE_NO, "No"],
52+ [SSL_INSECURE_YES, "Yes"]]
53+
54+
55+class WebhookPowerDriver(PowerDriver):
56+
57+ name = "webhook"
58+ chassis = False
59+ can_probe = False
60+ description = "Webhook"
61+ settings = [
62+ make_setting_field("power_url", "URL", required=True),
63+ make_setting_field("power_insecure_ssl", "Allow insecure SSL connections", field_type='choice', required=True,
64+ choices=SSL_INSECURE_CHOICES, default=SSL_INSECURE_NO),
65+ make_setting_field("power_user", "Power user"),
66+ make_setting_field(
67+ "power_pass", "Power password", field_type="password"
68+ ),
69+ make_setting_field(
70+ "power_token", "Power token", field_type="password"
71+ ),
72+ ]
73+
74+ #ip_extractor = make_ip_extractor("power_address")
75+ ip_extractor = None
76+
77+ def post(self, context, params={}):
78+ """Dispatch a POST request to webhook."""
79+ url = context.get("power_url")
80+ data = json.dumps(params).encode('utf-8')
81+ req = urllib.request.Request(url, data, method="POST")
82+
83+ if context.get("power_token") and len(context.get("power_token")) > 0:
84+ authString = "{token}".format(token = context.get("power_token")).encode("utf-8")
85+ authString = str(authString, "utf-8")
86+ req.add_header("Authorization", "Bearer %s" % authString)
87+ elif context.get("power_user") and len(context.get("power_user")) > 0 and context.get("power_pass") and len(context.get("power_pass")) > 0:
88+ authString = base64.urlsafe_b64encode("{username}:{password}".format(username = context.get("power_user"), password = context.get("power_pass")).encode("utf-8"))
89+ authString = str(authString, "utf-8")
90+ req.add_header("Authorization", "Basic %s" % authString)
91+
92+ req.add_header('content-type', 'application/json')
93+
94+ myssl = None
95+ if context.get("power_insecure_ssl") and context.get("power_insecure_ssl") == SSL_INSECURE_YES:
96+ myssl = ssl.create_default_context();
97+ myssl.check_hostname=False
98+ myssl.verify_mode=ssl.CERT_NONE
99+ try:
100+ res = urllib.request.urlopen(req, context=myssl)
101+ except urllib.error.HTTPError as e:
102+ raise PowerConnError(
103+ "Could not make proper connection to Webhook."
104+ " HTTP error code: %s" % e.code
105+ )
106+ except urllib.error.URLError as e:
107+ raise PowerConnError(
108+ "Could not make proper connection to Webhook."
109+ " Server could not be reached: %s" % e.reason
110+ )
111+ else:
112+ body = res.read();
113+ if not body or len(body) == 0:
114+ return dict()
115+ return json.loads(body)
116+
117+ def detect_missing_packages(self):
118+ # uses pure-python http client - nothing to look for!
119+ return []
120+
121+ def power_on(self, system_id, context):
122+ """Power on webhook."""
123+ try:
124+ self.post(context, {"action": "power_on", "system_id": system_id})
125+ except PowerConnError:
126+ raise PowerActionError("Webhook Driver unable to power on")
127+
128+ def power_off(self, system_id, context):
129+ """Power off webhook."""
130+ try:
131+ self.post(context, {"action": "power_off", "system_id": system_id})
132+ except PowerConnError:
133+ raise PowerActionError("Webhook Driver unable to power off")
134+
135+ def power_query(self, system_id, context):
136+ """Power query webhook."""
137+ try:
138+ res = self.post(context, {"action": "power_query", "system_id": system_id})
139+ except PowerConnError:
140+ raise PowerActionError("Webhook Driver unable to power query")
141+ else:
142+ if res["power_state"] == "off":
143+ return "off"
144+ elif res["power_state"] == "on":
145+ return "on"
146+ else:
147+ return "unknown"

Subscribers

People subscribed via source and target branches