Merge ~adacre/maas:eaton_driver into maas:master

Proposed by Alex Dacre
Status: Superseded
Proposed branch: ~adacre/maas:eaton_driver
Merge into: maas:master
Diff against target: 324 lines (+294/-0)
3 files modified
src/provisioningserver/drivers/power/eaton.py (+131/-0)
src/provisioningserver/drivers/power/registry.py (+2/-0)
src/provisioningserver/drivers/power/tests/test_eaton.py (+161/-0)
Reviewer Review Type Date Requested Status
MAAS Lander Needs Fixing
MAAS Maintainers Pending
Review via email: mp+377135@code.launchpad.net

Description of the change

Adds support for Eaton branded switched PDUs. Based heavily off of the APC driver.

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

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

STATUS: FAILED
LOG: http://maas-ci-jenkins.internal:8080/job/maas/job/branch-tester/7045/console
COMMIT: 2d27119ed35b95072331ffdd3aa802e0406e651f

review: Needs Fixing
Revision history for this message
Alex Dacre (adacre) wrote :

Not sure which tests are failing - the tests added as part of this power driver are passing:

adacre@adacre-ubuntu:~/maas$ bin/test.parallel src/provisioningserver/drivers/power/tests/test_eaton.py
WARNING: bin/test.region.legacy will _never_ be selected when using
selectors at the command-line. Run bin/test.region.legacy directly.
Tests running...
SUCCESS: provisioningserver.drivers.power.tests.test_eaton.TestEatonPowerDriver.test_missing_packages (0.03s)
SUCCESS: provisioningserver.drivers.power.tests.test_eaton.TestEatonPowerDriver.test_no_missing_packages (0.01s)
SUCCESS: provisioningserver.drivers.power.tests.test_eaton.TestEatonPowerDriver.test_power_query_returns_power_state_off (0.01s)
SUCCESS: provisioningserver.drivers.power.tests.test_eaton.TestEatonPowerDriver.test_power_query_returns_power_state_on (0.00s)
SUCCESS: provisioningserver.drivers.power.tests.test_eaton.TestEatonPowerDriver.test_run_process_crashes_on_external_process_error (0.01s)
SUCCESS: provisioningserver.drivers.power.tests.test_eaton.TestEatonPowerDriver.test_run_process_crashes_on_no_power_state_match_found (0.01s)
SUCCESS: provisioningserver.drivers.power.tests.test_eaton.TestEatonPowerDriver.test_power_off_calls_run_process (0.01s)
SUCCESS: provisioningserver.drivers.power.tests.test_eaton.TestEatonPowerDriver.test_power_on_calls_run_process (0.00s)
SUCCESS: provisioningserver.drivers.power.tests.test_eaton.TestEatonPowerDriver.test_power_query_crashes_for_uknown_power_state (0.00s)
SUCCESS: provisioningserver.drivers.power.tests.test_eaton.TestEatonPowerDriver.test_run_process_calls_command_and_returns_output (0.00s)

Ran 10 tests in 2.121s
OK

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

> UNIT TESTS
> -b eaton_driver lp:~adacre/maas/+git/maas into -b master lp:~maas-
> committers/maas
>
> STATUS: FAILED
> LOG: http://maas-ci-jenkins.internal:8080/job/maas/job/branch-
> tester/7045/console
> COMMIT: 2d27119ed35b95072331ffdd3aa802e0406e651f

The test failure here is a lint issue, can you please use `make format-py` to reformat your modules, and ensure that `make lint` passes?

Unmerged commits

de4a88f... by Alex Dacre

Add eaton power driver

address linter issue

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/src/provisioningserver/drivers/power/eaton.py b/src/provisioningserver/drivers/power/eaton.py
2new file mode 100644
3index 0000000..21c5843
4--- /dev/null
5+++ b/src/provisioningserver/drivers/power/eaton.py
6@@ -0,0 +1,131 @@
7+"""Eaton Power Driver.
8+
9+Support for managing Eaton PDU outlets via SNMP.
10+"""
11+
12+__all__ = []
13+
14+import re
15+from time import sleep
16+
17+from provisioningserver.drivers import (
18+ make_ip_extractor,
19+ make_setting_field,
20+ SETTING_SCOPE,
21+)
22+from provisioningserver.drivers.power import PowerActionError, PowerDriver
23+from provisioningserver.utils import shell
24+
25+
26+class EatonState:
27+ OFF = "0"
28+ ON = "1"
29+
30+
31+class EatonFunction:
32+ QUERY = "2"
33+ OFF = "3"
34+ ON = "4"
35+
36+
37+class EatonPowerDriver(PowerDriver):
38+
39+ name = "eaton"
40+ chassis = True
41+ description = "Eaton PDU"
42+ settings = [
43+ make_setting_field("power_address", "IP for Eaton PDU", required=True),
44+ make_setting_field(
45+ "node_outlet",
46+ "Eaton PDU node outlet number (1-24)",
47+ scope=SETTING_SCOPE.NODE,
48+ required=True,
49+ ),
50+ make_setting_field(
51+ "power_on_delay", "Power ON outlet delay (seconds)", default="5"
52+ ),
53+ ]
54+ ip_extractor = make_ip_extractor("power_address")
55+ queryable = False
56+
57+ def detect_missing_packages(self):
58+ binary, package = ["snmpset", "snmp"]
59+ if not shell.has_command_available(binary):
60+ return [package]
61+ return []
62+
63+ def run_process(self, *command):
64+ """Run SNMP command in subprocess."""
65+ result = shell.run_command(*command)
66+ if result.returncode != 0:
67+ raise PowerActionError(
68+ "Eaton Power Driver external process error for command %s: %s"
69+ % ("".join(command), result.stderr)
70+ )
71+ match = re.search(r"INTEGER:\s*([0-1])", result.stdout)
72+ if match is None:
73+ raise PowerActionError(
74+ "Eaton Power Driver unable to extract outlet power state"
75+ " from: %s" % result.stdout
76+ )
77+ else:
78+ return match.group(1)
79+
80+ def power_on(self, system_id, context):
81+ """Power on Eaton outlet."""
82+ if self.power_query(system_id, context) == "on":
83+ self.power_off(system_id, context)
84+ sleep(float(context["power_on_delay"]))
85+ self.run_process(
86+ "snmpset",
87+ *_get_common_args(
88+ context["power_address"],
89+ EatonFunction.ON,
90+ context["node_outlet"],
91+ ),
92+ "i",
93+ "0",
94+ )
95+
96+ def power_off(self, system_id, context):
97+ """Power off Eaton outlet."""
98+ self.run_process(
99+ "snmpset",
100+ *_get_common_args(
101+ context["power_address"],
102+ EatonFunction.OFF,
103+ context["node_outlet"],
104+ ),
105+ "i",
106+ "0",
107+ )
108+
109+ def power_query(self, system_id, context):
110+ """Power query for Eaton outlet."""
111+ power_state = self.run_process(
112+ "snmpget",
113+ *_get_common_args(
114+ context["power_address"],
115+ EatonFunction.QUERY,
116+ context["node_outlet"],
117+ ),
118+ )
119+ if power_state == EatonState.OFF:
120+ return "off"
121+ elif power_state == EatonState.ON:
122+ return "on"
123+ else:
124+ raise PowerActionError(
125+ "Eaton Power Driver retrieved unknown power state: %r"
126+ % power_state
127+ )
128+
129+
130+def _get_common_args(address, function, outlet):
131+ return [
132+ "-c",
133+ "private",
134+ "-v1",
135+ address,
136+ f"1.3.6.1.4.1.534.6.6.7.6.6.1.{function}.0.{outlet}",
137+ ]
138diff --git a/src/provisioningserver/drivers/power/registry.py b/src/provisioningserver/drivers/power/registry.py
139index 8ea5b27..a5e9a7d 100644
140--- a/src/provisioningserver/drivers/power/registry.py
141+++ b/src/provisioningserver/drivers/power/registry.py
142@@ -12,6 +12,7 @@ from provisioningserver.drivers.power import JSON_POWER_DRIVERS_SCHEMA
143 from provisioningserver.drivers.power.amt import AMTPowerDriver
144 from provisioningserver.drivers.power.apc import APCPowerDriver
145 from provisioningserver.drivers.power.dli import DLIPowerDriver
146+from provisioningserver.drivers.power.eaton import EatonPowerDriver
147 from provisioningserver.drivers.power.hmc import HMCPowerDriver
148 from provisioningserver.drivers.power.ipmi import IPMIPowerDriver
149 from provisioningserver.drivers.power.manual import ManualPowerDriver
150@@ -52,6 +53,7 @@ power_drivers = [
151 AMTPowerDriver(),
152 APCPowerDriver(),
153 DLIPowerDriver(),
154+ EatonPowerDriver(),
155 HMCPowerDriver(),
156 IPMIPowerDriver(),
157 ManualPowerDriver(),
158diff --git a/src/provisioningserver/drivers/power/tests/test_eaton.py b/src/provisioningserver/drivers/power/tests/test_eaton.py
159new file mode 100644
160index 0000000..9550827
161--- /dev/null
162+++ b/src/provisioningserver/drivers/power/tests/test_eaton.py
163@@ -0,0 +1,161 @@
164+"""Tests for `provisioningserver.drivers.power.eaton`."""
165+
166+__all__ = []
167+
168+from testtools.matchers import Equals
169+
170+from maastesting.factory import factory
171+from maastesting.matchers import MockCalledOnceWith
172+from maastesting.testcase import MAASTestCase
173+from provisioningserver.drivers.power import eaton as eaton_module
174+from provisioningserver.drivers.power import PowerActionError
175+from provisioningserver.utils.shell import has_command_available, ProcessResult
176+
177+COMMON_ARGS = "-c private -v1 {} 1.3.6.1.4.1.534.6.6.7.6.6.1.{}.0.{}"
178+COMMON_OUTPUT = "iso.3.6.1.4.1.534.6.6.7.6.6.1.%s.0.%s = INTEGER: 0\n"
179+
180+
181+class TestEatonPowerDriver(MAASTestCase):
182+ def make_context(self):
183+ return {
184+ "power_address": factory.make_name("power_address"),
185+ "node_outlet": factory.make_name("node_outlet"),
186+ "power_on_delay": "5",
187+ }
188+
189+ def test_missing_packages(self):
190+ mock = self.patch(has_command_available)
191+ mock.return_value = False
192+ driver = eaton_module.EatonPowerDriver()
193+ missing = driver.detect_missing_packages()
194+ self.assertItemsEqual(["snmp"], missing)
195+
196+ def test_no_missing_packages(self):
197+ mock = self.patch(has_command_available)
198+ mock.return_value = True
199+ driver = eaton_module.EatonPowerDriver()
200+ missing = driver.detect_missing_packages()
201+ self.assertItemsEqual([], missing)
202+
203+ def patch_run_command(self, stdout="", stderr="", returncode=0):
204+ mock_run_command = self.patch(eaton_module.shell, "run_command")
205+ mock_run_command.return_value = ProcessResult(
206+ stdout=stdout, stderr=stderr, returncode=returncode
207+ )
208+ return mock_run_command
209+
210+ def test_run_process_calls_command_and_returns_output(self):
211+ driver = eaton_module.EatonPowerDriver()
212+ context = self.make_context()
213+ command = ["snmpget"] + COMMON_ARGS.format(
214+ context["power_address"],
215+ eaton_module.EatonFunction.QUERY,
216+ context["node_outlet"],
217+ ).split()
218+ mock_run_command = self.patch_run_command(
219+ stdout=COMMON_OUTPUT
220+ % (eaton_module.EatonFunction.QUERY, context["node_outlet"]),
221+ stderr="error_output",
222+ )
223+ output = driver.run_process(*command)
224+ mock_run_command.assert_called_once_with(*command)
225+ self.expectThat(output, Equals(eaton_module.EatonState.OFF))
226+
227+ def test_run_process_crashes_on_external_process_error(self):
228+ driver = eaton_module.EatonPowerDriver()
229+ self.patch_run_command(returncode=1)
230+ self.assertRaises(
231+ PowerActionError, driver.run_process, factory.make_name("command")
232+ )
233+
234+ def test_run_process_crashes_on_no_power_state_match_found(self):
235+ driver = eaton_module.EatonPowerDriver()
236+ self.patch_run_command(stdout="Error")
237+ self.assertRaises(
238+ PowerActionError, driver.run_process, factory.make_name("command")
239+ )
240+
241+ def test_power_on_calls_run_process(self):
242+ driver = eaton_module.EatonPowerDriver()
243+ system_id = factory.make_name("system_id")
244+ context = self.make_context()
245+ mock_power_query = self.patch(driver, "power_query")
246+ mock_power_query.return_value = "on"
247+ self.patch(driver, "power_off")
248+ mock_sleep = self.patch(eaton_module, "sleep")
249+ mock_run_process = self.patch(driver, "run_process")
250+ driver.power_on(system_id, context)
251+
252+ self.expectThat(
253+ mock_power_query, MockCalledOnceWith(system_id, context)
254+ )
255+ self.expectThat(
256+ mock_sleep, MockCalledOnceWith(float(context["power_on_delay"]))
257+ )
258+ command = (
259+ ["snmpset"]
260+ + COMMON_ARGS.format(
261+ context["power_address"],
262+ eaton_module.EatonFunction.ON,
263+ context["node_outlet"],
264+ ).split()
265+ + ["i", "0"]
266+ )
267+ mock_run_process.assert_called_once_with(*command)
268+
269+ def test_power_off_calls_run_process(self):
270+ driver = eaton_module.EatonPowerDriver()
271+ system_id = factory.make_name("system_id")
272+ context = self.make_context()
273+ mock_run_process = self.patch(driver, "run_process")
274+ driver.power_off(system_id, context)
275+ command = (
276+ ["snmpset"]
277+ + COMMON_ARGS.format(
278+ context["power_address"],
279+ eaton_module.EatonFunction.OFF,
280+ context["node_outlet"],
281+ ).split()
282+ + ["i", "0"]
283+ )
284+ mock_run_process.assert_called_once_with(*command)
285+
286+ def test_power_query_returns_power_state_on(self):
287+ driver = eaton_module.EatonPowerDriver()
288+ system_id = factory.make_name("system_id")
289+ context = self.make_context()
290+ mock_run_process = self.patch(driver, "run_process")
291+ mock_run_process.return_value = eaton_module.EatonState.ON
292+ result = driver.power_query(system_id, context)
293+ command = ["snmpget"] + COMMON_ARGS.format(
294+ context["power_address"],
295+ eaton_module.EatonFunction.QUERY,
296+ context["node_outlet"],
297+ ).split()
298+ mock_run_process.assert_called_once_with(*command)
299+ self.expectThat(result, Equals("on"))
300+
301+ def test_power_query_returns_power_state_off(self):
302+ driver = eaton_module.EatonPowerDriver()
303+ system_id = factory.make_name("system_id")
304+ context = self.make_context()
305+ mock_run_process = self.patch(driver, "run_process")
306+ mock_run_process.return_value = eaton_module.EatonState.OFF
307+ result = driver.power_query(system_id, context)
308+ command = ["snmpget"] + COMMON_ARGS.format(
309+ context["power_address"],
310+ eaton_module.EatonFunction.QUERY,
311+ context["node_outlet"],
312+ ).split()
313+ mock_run_process.assert_called_once_with(*command)
314+ self.expectThat(result, Equals("off"))
315+
316+ def test_power_query_crashes_for_uknown_power_state(self):
317+ driver = eaton_module.EatonPowerDriver()
318+ system_id = factory.make_name("system_id")
319+ context = self.make_context()
320+ mock_run_process = self.patch(driver, "run_process")
321+ mock_run_process.return_value = "Error"
322+ self.assertRaises(
323+ PowerActionError, driver.power_query, system_id, context
324+ )

Subscribers

People subscribed via source and target branches