Merge lp:~stefankrupop/maas/recs into lp:maas/trunk

Proposed by Stefan Krupop on 2017-05-19
Status: Merged
Approved by: Newell Jensen on 2017-06-21
Approved revision: 6064
Merged at revision: 6103
Proposed branch: lp:~stefankrupop/maas/recs
Merge into: lp:maas/trunk
Diff against target: 1092 lines (+932/-15)
8 files modified
src/maasserver/api/machines.py (+14/-11)
src/maasserver/api/tests/test_machines.py (+7/-4)
src/maasserver/static/js/angular/controllers/add_hardware.js (+38/-0)
src/provisioningserver/drivers/power/recs.py (+345/-0)
src/provisioningserver/drivers/power/registry.py (+2/-0)
src/provisioningserver/drivers/power/tests/test_recs.py (+465/-0)
src/provisioningserver/rpc/clusterservice.py (+7/-0)
src/provisioningserver/rpc/tests/test_clusterservice.py (+54/-0)
To merge this branch: bzr merge lp:~stefankrupop/maas/recs
Reviewer Review Type Date Requested Status
Newell Jensen Approve on 2017-06-21
Stefan Krupop (community) Resubmit on 2017-06-21
Mike Pontillo 2017-05-19 Needs Information on 2017-06-07
Review via email: mp+324310@code.launchpad.net

Commit message

adds power management support for christmann RECS|Box servers (https://embedded.christmann.info/).

Description of the change

This branch adds power management support for christmann RECS|Box servers (https://embedded.christmann.info/). Supports adding a chassis and controlling power of nodes.

Currently, trying to add a chassis via the WebGUI runs into bug #1572060 (https://bugs.launchpad.net/maas/+bug/1572060). I was unsure whether I should remove the port field because of this, but left it in for now.

Originally, I developed the change last year against some older MAAS version and based it on the Seamicro 15k driver. As I am normally not a Python developer, I hope I did not mess up the update too much. Please bear with me ;)

To post a comment you must log in.
Mike Pontillo (mpontillo) wrote :

Thanks for your contribution. Unfortunately there is legal due diligence that must happen before we can consider merging your work. Would you please follow the instructions here in order to sign our contributor agreement?

    https://www.ubuntu.com/legal/contributors

We would be happy to consider your contributions after the agreement is in place.

With regard to the code, (since Python is an interpreted language) the MAAS team coding standards require that each line of code be covered by unit tests. If you look in the `tests/` directory you should find `test_*.py` files that roughly correspond to the Python source in `../*.py`. That is us to accept this proposal, you would need to amend the tests for any files that have changed, and also create a new test file for your new driver.

As an aside, it would be tricky for the MAAS team to officially support this type of server, since we do not have test hardware that would allow us to ensure that your contribution works in the future, as more changes are made to MAAS and/or the RECS|Box APIs. I don't think we should block your contribution based on that, but this is also something that deserves discussion; perhaps our organizations' respective business development teams should talk to see if we can better collaborate.

review: Needs Fixing
lp:~stefankrupop/maas/recs updated on 2017-06-06
6058. By Stefan Krupop on 2017-05-22

Added tests for RECS power driver

6059. By Stefan Krupop on 2017-06-06

- Added hardware driver tests
- Fixed lint problems

6060. By Stefan Krupop on 2017-06-06

Added some missing tests

Stefan Krupop (stefankrupop) wrote :

Ok, I have signed the agreement and also added the required tests.
Coverage for the new parts should be 100 %.
I also improved error handling a bit and made sure there are no lint errors left.

Kind regards,
Stefan

review: Resubmit
Mike Pontillo (mpontillo) wrote :

Thanks for the fixes! I see that the tests are passing after I merge your branch to trunk.

I think the next step is to talk to the team about how to support christmann RECS|Box in MAAS, given that we have no way to test it directly. As it stands now, we risk breaking your code unintentionally. What is your role with regard to the christmann RECS|Box product, and can we expect that this code will be maintained long-term?

My other question is, I notice that the power type is just 'recs', is that specific enough for 'christmann RECS|Box' or might it be confused with another meaning of "RECS"?

If you could e-mail me directly so we can start that conversation, that would be appreciated. You can find my @canonical.com address on my Launchpad profile[1]. Thanks in advance!

[1]: https://launchpad.net/~mpontillo

review: Needs Information
Newell Jensen (newell-jensen) wrote :

Stefan,

See my inline comments for some minor fixes. Additionally, I see that you have a provisioningserver/drivers/hardware/recs.py and a provisioningserver/drivers/power/recs.py. We have been making an effort to put everything into power/* and the only reason there is still code in hardware/* is because we just haven't gotten around to migrating that last code over.

Please move the code that you have in the hardware directory into the power directory. There are other drivers in power directory that you can look at combine all functionality into one.

After this consolidation I will take a closer look at your code.

review: Needs Fixing
lp:~stefankrupop/maas/recs updated on 2017-06-20
6061. By Stefan Krupop on 2017-06-16

- Some minor fixes, mainly comments

6062. By Stefan Krupop on 2017-06-19

Merged code from drivers/hardware into drivers/power/

6063. By Stefan Krupop on 2017-06-20

Changed power type from "recs" to "recs_box"

Stefan Krupop (stefankrupop) wrote :

I now moved driver and tests from drivers/hardware/ to drivers/power/. I also changed the power type to "recs_box".

Kind regards,
Stefan

review: Resubmit
Newell Jensen (newell-jensen) wrote :

Looks good. Thanks for the changes (moving from hardware to driver folder). I have one inline comment and once you fix this I will set to approved.

review: Needs Fixing
lp:~stefankrupop/maas/recs updated on 2017-06-21
6064. By Stefan Krupop on 2017-06-21

Removed unnecessary test

Stefan Krupop (stefankrupop) wrote :

Ok, I removed the unnecessary test. Coverage is still 100 % :)

Kind regards,
Stefan

review: Resubmit
Newell Jensen (newell-jensen) wrote :

Looks good, thanks for the contribution :)

review: Approve
Andres Rodriguez (andreserl) wrote :

fwiw, this branch is missing a commit message

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/maasserver/api/machines.py'
2--- src/maasserver/api/machines.py 2017-05-17 10:08:56 +0000
3+++ src/maasserver/api/machines.py 2017-06-21 15:18:01 +0000
4@@ -1524,6 +1524,7 @@
5 msftocs is the type for the Microsoft OCS Chassis Manager.
6 powerkvm is the type for Virtual Machines on Power KVM,
7 managed by Virsh.
8+ recs_box is the type for the christmann RECS|Box servers.
9 seamicro15k is the type for the Seamicro 1500 Chassis.
10 ucsm is the type for the Cisco UCS Manager.
11 virsh is the type for virtual machines managed by Virsh.
12@@ -1535,13 +1536,13 @@
13 :type url: unicode
14
15 :param username: The username used to access the chassis. This field
16- is required for the seamicro15k, vmware, mscm, msftocs, and ucsm
17- chassis types.
18+ is required for the recs_box, seamicro15k, vmware, mscm, msftocs,
19+ and ucsm chassis types.
20 :type username: unicode
21
22 :param password: The password used to access the chassis. This field
23- is required for the seamicro15k, vmware, mscm, msftocs, and ucsm
24- chassis types.
25+ is required for the recs_box, seamicro15k, vmware, mscm, msftocs,
26+ and ucsm chassis types.
27 :type password: unicode
28
29 :param accept_all: If true, all enlisted machines will be
30@@ -1568,8 +1569,8 @@
31 restapi, or restapi2.
32 :type power_control: unicode
33
34- The following are optional if you are adding a vmware or msftocs
35- chassis.
36+ The following are optional if you are adding a recs_box, vmware or
37+ msftocs chassis.
38
39 :param port: The port to use when accessing the chassis.
40 :type port: integer
41@@ -1591,12 +1592,13 @@
42 chassis_type = get_mandatory_param(
43 request.POST, 'chassis_type',
44 validator=validators.OneOf([
45- 'mscm', 'msftocs', 'powerkvm', 'seamicro15k', 'ucsm', 'virsh',
46- 'vmware']))
47+ 'mscm', 'msftocs', 'powerkvm', 'recs_box', 'seamicro15k',
48+ 'ucsm', 'virsh', 'vmware']))
49 hostname = get_mandatory_param(request.POST, 'hostname')
50
51 if chassis_type in (
52- 'mscm', 'msftocs', 'seamicro15k', 'ucsm', 'vmware'):
53+ 'mscm', 'msftocs', 'recs_box', 'seamicro15k', 'ucsm',
54+ 'vmware'):
55 username = get_mandatory_param(request.POST, 'username')
56 password = get_mandatory_param(request.POST, 'password')
57 else:
58@@ -1633,9 +1635,10 @@
59 chassis_type, content_type=(
60 "text/plain; charset=%s" % settings.DEFAULT_CHARSET))
61
62- # Only available with vmware or msftocs
63+ # Only available with vmware, recs_box or msftocs
64 port = get_optional_param(request.POST, 'port')
65- if port is not None and chassis_type not in ('msftocs', 'vmware'):
66+ if port is not None and chassis_type not in ('msftocs', 'recs_box',
67+ 'vmware'):
68 return HttpResponseBadRequest(
69 "port is unavailable with the %s chassis type" %
70 chassis_type, content_type=(
71
72=== modified file 'src/maasserver/api/tests/test_machines.py'
73--- src/maasserver/api/tests/test_machines.py 2017-05-05 22:08:46 +0000
74+++ src/maasserver/api/tests/test_machines.py 2017-06-21 15:18:01 +0000
75@@ -1817,7 +1817,8 @@
76 accessible_by_url.return_value = rack
77 self.patch(rack, 'add_chassis')
78 for chassis_type in (
79- 'mscm', 'msftocs', 'seamicro15k', 'ucsm', 'vmware'):
80+ 'mscm', 'msftocs', 'recs_box', 'seamicro15k', 'ucsm',
81+ 'vmware'):
82 response = self.client.post(
83 reverse('machines_handler'),
84 {
85@@ -1838,7 +1839,8 @@
86 accessible_by_url.return_value = rack
87 self.patch(rack, 'add_chassis')
88 for chassis_type in (
89- 'mscm', 'msftocs', 'seamicro15k', 'ucsm', 'vmware'):
90+ 'mscm', 'msftocs', 'recs_box', 'seamicro15k', 'ucsm',
91+ 'vmware'):
92 response = self.client.post(
93 reverse('machines_handler'),
94 {
95@@ -1957,7 +1959,8 @@
96 def test_POST_add_chassis_only_allows_prefix_filter_on_virtual_chassis(
97 self):
98 self.become_admin()
99- for chassis_type in ('mscm', 'msftocs', 'seamicro15k', 'ucsm'):
100+ for chassis_type in ('mscm', 'msftocs', 'recs_box', 'seamicro15k',
101+ 'ucsm'):
102 response = self.client.post(
103 reverse('machines_handler'),
104 {
105@@ -2050,7 +2053,7 @@
106 username = factory.make_name('username')
107 password = factory.make_name('password')
108 port = "%s" % random.randint(0, 65535)
109- for chassis_type in ('msftocs', 'vmware'):
110+ for chassis_type in ('msftocs', 'recs_box', 'vmware'):
111 response = self.client.post(
112 reverse('machines_handler'), {
113 'op': 'add_chassis',
114
115=== modified file 'src/maasserver/static/js/angular/controllers/add_hardware.js'
116--- src/maasserver/static/js/angular/controllers/add_hardware.js 2017-05-25 13:56:07 +0000
117+++ src/maasserver/static/js/angular/controllers/add_hardware.js 2017-06-21 15:18:01 +0000
118@@ -100,6 +100,44 @@
119 fields: virshFields
120 },
121 {
122+ name: 'recs_box',
123+ description: 'Christmann RECS|Box',
124+ fields: [
125+ {
126+ name: 'hostname',
127+ label: 'Hostname',
128+ field_type: 'string',
129+ "default": '',
130+ choices: [],
131+ required: true
132+ },
133+ {
134+ name: 'port',
135+ label: 'Port',
136+ field_type: 'string',
137+ "default": '80',
138+ choices: [],
139+ required: false
140+ },
141+ {
142+ name: 'username',
143+ label: 'Username',
144+ field_type: 'string',
145+ "default": '',
146+ choices: [],
147+ required: true
148+ },
149+ {
150+ name: 'password',
151+ label: 'Password',
152+ field_type: 'string',
153+ "default": '',
154+ choices: [],
155+ required: true
156+ }
157+ ]
158+ },
159+ {
160 name: 'seamicro15k',
161 description: 'SeaMicro 15000',
162 fields: [
163
164=== added file 'src/provisioningserver/drivers/power/recs.py'
165--- src/provisioningserver/drivers/power/recs.py 1970-01-01 00:00:00 +0000
166+++ src/provisioningserver/drivers/power/recs.py 2017-06-21 15:18:01 +0000
167@@ -0,0 +1,345 @@
168+# Copyright 2017 christmann informationstechnik + medien GmbH & Co. KG. This
169+# software is licensed under the GNU Affero General Public License version 3
170+# (see the file LICENSE).
171+
172+"""Christmann RECS|Box Power Driver."""
173+
174+__all__ = []
175+
176+from typing import Optional
177+import urllib.error
178+import urllib.parse
179+import urllib.request
180+
181+from lxml.etree import fromstring
182+from provisioningserver.drivers import (
183+ make_ip_extractor,
184+ make_setting_field,
185+ SETTING_SCOPE,
186+)
187+from provisioningserver.drivers.power import (
188+ PowerConnError,
189+ PowerDriver,
190+)
191+from provisioningserver.logger import get_maas_logger
192+from provisioningserver.rpc.utils import (
193+ commission_node,
194+ create_node,
195+)
196+from provisioningserver.utils import typed
197+from provisioningserver.utils.twisted import synchronous
198+
199+
200+maaslog = get_maas_logger("drivers.power.recs")
201+
202+
203+def extract_recs_parameters(context):
204+ ip = context.get('power_address')
205+ port = context.get('power_port')
206+ username = context.get('power_user')
207+ password = context.get('power_pass')
208+ node_id = context.get('node_id')
209+ return ip, port, username, password, node_id
210+
211+
212+class RECSError(Exception):
213+ """Failure talking to a RECS_Master."""
214+
215+
216+class RECSAPI:
217+ """API to communicate with a RECS_Master"""
218+
219+ def __init__(self, ip, port, username, password):
220+ """
221+ :param ip: The IP address of the RECS_Master
222+ e.g.: "192.168.0.1"
223+ :type ip: string
224+ :param port: The http port to connect to the RECS_Master,
225+ e.g.: "80"
226+ :type port: string
227+ :param username: The username for authentication to RECS_Master,
228+ e.g.: "admin"
229+ :type username: string
230+ :param password: The password for authentication to the RECS_Master,
231+ e.g.: "admin"
232+ :type password: string
233+ """
234+ self.ip = ip
235+ self.port = port
236+ self.username = username
237+ self.password = password
238+
239+ def build_url(self, command, params=[]):
240+ url = 'http://%s:%s/REST/' % (self.ip, self.port)
241+ params = filter(None, params)
242+ return urllib.parse.urljoin(url, command) + '?' + '&'.join(params)
243+
244+ def extract_from_response(self, response, attribute):
245+ """Extract attribute from first element in response."""
246+ root = fromstring(response)
247+ return root.attrib.get(attribute)
248+
249+ def get(self, command, params=[]):
250+ """Dispatch a GET request to a RECS_Master."""
251+ url = self.build_url(command, params)
252+ authinfo = urllib.request.HTTPPasswordMgrWithDefaultRealm()
253+ authinfo.add_password(None, url, self.username, self.password)
254+ proxy_handler = urllib.request.ProxyHandler({})
255+ auth_handler = urllib.request.HTTPBasicAuthHandler(authinfo)
256+ opener = urllib.request.build_opener(proxy_handler, auth_handler)
257+ urllib.request.install_opener(opener)
258+ try:
259+ response = urllib.request.urlopen(url)
260+ except urllib.error.HTTPError as e:
261+ raise PowerConnError(
262+ "Could not make proper connection to RECS|Box."
263+ " HTTP error code: %s" % e.code)
264+ except urllib.error.URLError as e:
265+ raise PowerConnError(
266+ "Could not make proper connection to RECS|Box."
267+ " Server could not be reached: %s" % e.reason)
268+ else:
269+ return response.read()
270+
271+ def post(self, command, urlparams=[], params={}):
272+ """Dispatch a POST request to a RECS_Master."""
273+ url = self.build_url(command, urlparams)
274+ authinfo = urllib.request.HTTPPasswordMgrWithDefaultRealm()
275+ authinfo.add_password(None, url, self.username, self.password)
276+ proxy_handler = urllib.request.ProxyHandler({})
277+ auth_handler = urllib.request.HTTPBasicAuthHandler(authinfo)
278+ opener = urllib.request.build_opener(proxy_handler, auth_handler)
279+ urllib.request.install_opener(opener)
280+ data = urllib.parse.urlencode(params).encode()
281+ req = urllib.request.Request(url, data, method='POST')
282+ try:
283+ response = urllib.request.urlopen(req)
284+ except urllib.error.HTTPError as e:
285+ raise PowerConnError(
286+ "Could not make proper connection to RECS|Box."
287+ " HTTP error code: %s" % e.code)
288+ except urllib.error.URLError as e:
289+ raise PowerConnError(
290+ "Could not make proper connection to RECS|Box."
291+ " Server could not be reached: %s" % e.reason)
292+ else:
293+ return response.read()
294+
295+ def put(self, command, urlparams=[], params={}):
296+ """Dispatch a PUT request to a RECS_Master."""
297+ url = self.build_url(command, urlparams)
298+ authinfo = urllib.request.HTTPPasswordMgrWithDefaultRealm()
299+ authinfo.add_password(None, url, self.username, self.password)
300+ proxy_handler = urllib.request.ProxyHandler({})
301+ auth_handler = urllib.request.HTTPBasicAuthHandler(authinfo)
302+ opener = urllib.request.build_opener(proxy_handler, auth_handler)
303+ urllib.request.install_opener(opener)
304+ data = urllib.parse.urlencode(params).encode()
305+ req = urllib.request.Request(url, data, method='PUT')
306+ try:
307+ response = urllib.request.urlopen(req)
308+ except urllib.error.HTTPError as e:
309+ raise PowerConnError(
310+ "Could not make proper connection to RECS|Box."
311+ " HTTP error code: %s" % e.code)
312+ except urllib.error.URLError as e:
313+ raise PowerConnError(
314+ "Could not make proper connection to RECS|Box."
315+ " Server could not be reached: %s" % e.reason)
316+ else:
317+ return response.read()
318+
319+ def get_node_power_state(self, nodeid):
320+ """Gets the power state of the node."""
321+ return self.extract_from_response(
322+ self.get('node/%s' % nodeid), 'state')
323+
324+ def _set_power(self, nodeid, action):
325+ """Set power for node."""
326+ self.post('node/%s/manage/%s' % (nodeid, action))
327+
328+ def set_power_off_node(self, nodeid):
329+ """Turns power to node off."""
330+ return self._set_power(nodeid, 'power_off')
331+
332+ def set_power_on_node(self, nodeid):
333+ """Turns power to node on."""
334+ return self._set_power(nodeid, 'power_on')
335+
336+ def set_boot_source(self, nodeid, source, persistent):
337+ """Set boot source of node."""
338+ self.put('node/%s/manage/set_bootsource' % nodeid,
339+ params={'source': source, 'persistent': persistent})
340+
341+ def get_nodes(self):
342+ """Gets available nodes.
343+
344+ Returns dictionary of node IDs, their corresponding
345+ MAC Addresses and architecture.
346+ """
347+ nodes = {}
348+ xmldata = self.get('node')
349+ root = fromstring(xmldata)
350+
351+ # Iterate over all node Elements
352+ for node_info in root:
353+ macs = []
354+ # Add both MACs if available
355+ macs.append(node_info.attrib.get('macAddressMgmt'))
356+ macs.append(node_info.attrib.get('macAddressCompute'))
357+ macs = list(filter(None, macs))
358+ if macs:
359+ # Retrive node id
360+ nodeid = node_info.attrib.get('id')
361+ # Retrive architecture
362+ arch = node_info.attrib.get('architecture')
363+ # Add data for node
364+ nodes[nodeid] = {'macs': macs, 'arch': arch}
365+
366+ return nodes
367+
368+
369+class RECSPowerDriver(PowerDriver):
370+
371+ name = 'recs_box'
372+ description = "Christmann RECS|Box Power Driver"
373+ settings = [
374+ make_setting_field(
375+ 'node_id', "Node ID", scope=SETTING_SCOPE.NODE,
376+ required=True),
377+ make_setting_field('power_address', "Power address", required=True),
378+ make_setting_field('power_port', "Power port"),
379+ make_setting_field('power_user', "Power user"),
380+ make_setting_field(
381+ 'power_pass', "Power password", field_type='password'),
382+ ]
383+ ip_extractor = make_ip_extractor('power_address')
384+
385+ def power_control_recs(
386+ self, ip, port, username, password, node_id, power_change):
387+ """Control the power state for the given node."""
388+
389+ port = 8000 if port is None or port == 0 else port
390+ api = RECSAPI(ip, port, username, password)
391+
392+ if power_change == 'on':
393+ api.set_power_on_node(node_id)
394+ elif power_change == 'off':
395+ api.set_power_off_node(node_id)
396+ else:
397+ raise RECSError(
398+ "Unexpected MAAS power mode: %s" % power_change)
399+
400+ def power_state_recs(self, ip, port, username, password, node_id):
401+ """Return the power state for the given node."""
402+
403+ port = 8000 if port is None or port == 0 else port
404+ api = RECSAPI(ip, port, username, password)
405+
406+ try:
407+ power_state = api.get_node_power_state(node_id)
408+ except urllib.error.HTTPError as e:
409+ raise RECSError(
410+ "Failed to retrieve power state. HTTP error code: %s" % e.code)
411+ except urllib.error.URLError as e:
412+ raise RECSError(
413+ "Failed to retrieve power state. Server not reachable: %s"
414+ % e.reason)
415+
416+ if power_state == '1':
417+ return 'on'
418+ return 'off'
419+
420+ def set_boot_source_recs(
421+ self, ip, port, username, password, node_id, source, persistent):
422+ """Control the boot source for the given node."""
423+
424+ port = 8000 if port is None or port == 0 else port
425+ api = RECSAPI(ip, port, username, password)
426+
427+ api.set_boot_source(node_id, source, persistent)
428+
429+ def detect_missing_packages(self):
430+ # uses urllib http client - nothing to look for!
431+ return []
432+
433+ def power_on(self, system_id, context):
434+ """Power on RECS node."""
435+ power_change = 'on'
436+ ip, port, username, password, node_id = (
437+ extract_recs_parameters(context))
438+
439+ # Set default (persistent) boot to HDD
440+ self.set_boot_source_recs(
441+ ip, port, username, password, node_id, "HDD", True)
442+ # Set next boot to PXE
443+ self.set_boot_source_recs(
444+ ip, port, username, password, node_id, "PXE", False)
445+ self.power_control_recs(
446+ ip, port, username, password, node_id, power_change)
447+
448+ def power_off(self, system_id, context):
449+ """Power off RECS node."""
450+ power_change = 'off'
451+ ip, port, username, password, node_id = (
452+ extract_recs_parameters(context))
453+ self.power_control_recs(
454+ ip, port, username, password, node_id, power_change)
455+
456+ def power_query(self, system_id, context):
457+ """Power query RECS node."""
458+ ip, port, username, password, node_id = (
459+ extract_recs_parameters(context))
460+ return self.power_state_recs(ip, port, username, password, node_id)
461+
462+
463+@synchronous
464+@typed
465+def probe_and_enlist_recs(
466+ user: str, ip: str, port: Optional[int], username: Optional[str],
467+ password: Optional[str], accept_all: bool=False, domain: str=None):
468+ maaslog.info("Probing for RECS servers as %s@%s", username, ip)
469+
470+ port = 80 if port is None or port == 0 else port
471+ api = RECSAPI(ip, port, username, password)
472+
473+ try:
474+ # if get_nodes works, we have access to the system
475+ nodes = api.get_nodes()
476+ except urllib.error.HTTPError as e:
477+ raise RECSError(
478+ "Failed to probe nodes for RECS_Master with ip=%s "
479+ "port=%s, username=%s, password=%s. HTTP error code: %s"
480+ % (ip, port, username, password, e.code))
481+ except urllib.error.URLError as e:
482+ raise RECSError(
483+ "Failed to probe nodes for RECS_Master with ip=%s "
484+ "port=%s, username=%s, password=%s. "
485+ "Server could not be reached: %s"
486+ % (ip, port, username, password, e.reason))
487+
488+ for node_id, data in nodes.items():
489+ params = {
490+ 'power_address': ip,
491+ 'power_port': port,
492+ 'power_user': username,
493+ 'power_pass': password,
494+ 'node_id': node_id
495+ }
496+ arch = 'amd64'
497+ if data['arch'] == 'arm':
498+ arch = 'armhf'
499+
500+ maaslog.info(
501+ "Creating RECS node %s with MACs: %s", node_id, data['macs'])
502+
503+ # Set default (persistent) boot to HDD
504+ api.set_boot_source(node_id, "HDD", True)
505+ # Set next boot to PXE
506+ api.set_boot_source(node_id, "PXE", False)
507+
508+ system_id = create_node(
509+ data['macs'], arch, 'recs_box', params, domain).wait(30)
510+
511+ if accept_all:
512+ commission_node(system_id, user).wait(30)
513
514=== modified file 'src/provisioningserver/drivers/power/registry.py'
515--- src/provisioningserver/drivers/power/registry.py 2017-01-26 22:02:18 +0000
516+++ src/provisioningserver/drivers/power/registry.py 2017-06-21 15:18:01 +0000
517@@ -21,6 +21,7 @@
518 from provisioningserver.drivers.power.mscm import MSCMPowerDriver
519 from provisioningserver.drivers.power.msftocs import MicrosoftOCSPowerDriver
520 from provisioningserver.drivers.power.nova import NovaPowerDriver
521+from provisioningserver.drivers.power.recs import RECSPowerDriver
522 from provisioningserver.drivers.power.seamicro import SeaMicroPowerDriver
523 from provisioningserver.drivers.power.ucsm import UCSMPowerDriver
524 from provisioningserver.drivers.power.virsh import VirshPowerDriver
525@@ -59,6 +60,7 @@
526 MSCMPowerDriver(),
527 MicrosoftOCSPowerDriver(),
528 NovaPowerDriver(),
529+ RECSPowerDriver(),
530 SeaMicroPowerDriver(),
531 UCSMPowerDriver(),
532 VirshPowerDriver(),
533
534=== added file 'src/provisioningserver/drivers/power/tests/test_recs.py'
535--- src/provisioningserver/drivers/power/tests/test_recs.py 1970-01-01 00:00:00 +0000
536+++ src/provisioningserver/drivers/power/tests/test_recs.py 2017-06-21 15:18:01 +0000
537@@ -0,0 +1,465 @@
538+# Copyright 2017 christmann informationstechnik + medien GmbH & Co. KG. This
539+# software is licensed under the GNU Affero General Public License version 3
540+# (see the file LICENSE).
541+
542+"""Tests for `provisioningserver.drivers.power.recs`."""
543+
544+__all__ = []
545+
546+from io import StringIO
547+from textwrap import dedent
548+from unittest.mock import (
549+ call,
550+ Mock,
551+)
552+import urllib.parse
553+
554+from maastesting.factory import factory
555+from maastesting.matchers import (
556+ MockCalledOnceWith,
557+ MockCallsMatch,
558+ MockNotCalled,
559+)
560+from maastesting.testcase import (
561+ MAASTestCase,
562+ MAASTwistedRunTest,
563+)
564+from provisioningserver.drivers.power import (
565+ PowerConnError,
566+ recs as recs_module,
567+)
568+from provisioningserver.drivers.power.recs import (
569+ extract_recs_parameters,
570+ probe_and_enlist_recs,
571+ RECSAPI,
572+ RECSError,
573+ RECSPowerDriver,
574+)
575+from provisioningserver.utils.shell import has_command_available
576+from provisioningserver.utils.twisted import asynchronous
577+from testtools.matchers import Equals
578+from testtools.testcase import ExpectedException
579+from twisted.internet.defer import inlineCallbacks
580+from twisted.internet.threads import deferToThread
581+
582+
583+class TestRECSPowerDriver(MAASTestCase):
584+ """Tests for RECS|Box custom hardware."""
585+
586+ run_tests_with = MAASTwistedRunTest.make_factory(timeout=5)
587+
588+ def test_no_missing_packages(self):
589+ mock = self.patch(has_command_available)
590+ mock.return_value = True
591+ driver = RECSPowerDriver()
592+ missing = driver.detect_missing_packages()
593+ self.assertItemsEqual([], missing)
594+
595+ def make_context(self):
596+ ip = factory.make_name('power_address')
597+ port = factory.pick_port()
598+ username = factory.make_name('power_user')
599+ password = factory.make_name('power_pass')
600+ node_id = factory.make_name('node_id')
601+ context = {
602+ 'power_address': ip,
603+ 'power_port': port,
604+ 'power_user': username,
605+ 'power_pass': password,
606+ 'node_id': node_id,
607+ }
608+ return ip, port, username, password, node_id, context
609+
610+ def test_extract_recs_parameters_extracts_parameters(self):
611+ ip, port, username, password, node_id, context = self.make_context()
612+
613+ self.assertItemsEqual(
614+ (ip, port, username, password, node_id),
615+ extract_recs_parameters(context))
616+
617+ def test_power_off_calls_power_control_recs(self):
618+ ip, port, username, password, node_id, context = self.make_context()
619+ recs_power_driver = RECSPowerDriver()
620+ power_control_recs_mock = self.patch(
621+ recs_power_driver, 'power_control_recs')
622+ recs_power_driver.power_off(context['node_id'], context)
623+
624+ self.assertThat(
625+ power_control_recs_mock, MockCalledOnceWith(
626+ ip, port, username, password, node_id, 'off'))
627+
628+ def test_power_on_calls_power_control_recs(self):
629+ ip, port, username, password, node_id, context = self.make_context()
630+ recs_power_driver = RECSPowerDriver()
631+ power_control_recs_mock = self.patch(
632+ recs_power_driver, 'power_control_recs')
633+ set_boot_source_recs_mock = self.patch(
634+ recs_power_driver, 'set_boot_source_recs')
635+ recs_power_driver.power_on(context['node_id'], context)
636+
637+ self.assertThat(
638+ power_control_recs_mock, MockCalledOnceWith(
639+ ip, port, username, password, node_id, 'on'))
640+ self.assertThat(
641+ set_boot_source_recs_mock, MockCallsMatch(
642+ call(ip, port, username, password, node_id, 'HDD', True),
643+ call(ip, port, username, password, node_id, 'PXE', False)))
644+
645+ def test_power_query_calls_power_state_recs(self):
646+ ip, port, username, password, node_id, context = self.make_context()
647+ recs_power_driver = RECSPowerDriver()
648+ power_state_recs_mock = self.patch(
649+ recs_power_driver, 'power_state_recs')
650+ recs_power_driver.power_query(context['node_id'], context)
651+
652+ self.assertThat(
653+ power_state_recs_mock, MockCalledOnceWith(
654+ ip, port, username, password, node_id))
655+
656+ def test_extract_from_response_finds_element_content(self):
657+ ip, port, username, password, node_id, context = self.make_context()
658+ api = RECSAPI(ip, port, username, password)
659+ response = dedent("""
660+ <node health="OK" id="RCU_84055620466592_BB_1_0" state="0" />
661+ """)
662+ attribute = 'id'
663+ expected = 'RCU_84055620466592_BB_1_0'
664+ output = api.extract_from_response(response, attribute)
665+ self.assertThat(output, Equals(expected))
666+
667+ def test_get_gets_response(self):
668+ ip, port, username, password, node_id, context = self.make_context()
669+ api = RECSAPI(ip, port, username, password)
670+ command = factory.make_string()
671+ params = [factory.make_string() for _ in range(3)]
672+ expected = dedent("""
673+ <node health="OK" id="RCU_84055620466592_BB_1_0" state="0" />
674+ """)
675+ response = StringIO(expected)
676+ self.patch(urllib.request, "urlopen", Mock(return_value=response))
677+ output = api.get(command, params)
678+ self.assertEquals(output, expected)
679+
680+ def test_get_crashes_on_http_error(self):
681+ ip, port, username, password, node_id, context = self.make_context()
682+ api = RECSAPI(ip, port, username, password)
683+ command = factory.make_string()
684+ mock_urlopen = self.patch(urllib.request, "urlopen")
685+ mock_urlopen.side_effect = urllib.error.HTTPError(
686+ None, None, None, None, None)
687+ self.assertRaises(
688+ PowerConnError, api.get, command, context)
689+
690+ def test_get_crashes_on_url_error(self):
691+ ip, port, username, password, node_id, context = self.make_context()
692+ api = RECSAPI(ip, port, username, password)
693+ command = factory.make_string()
694+ mock_urlopen = self.patch(urllib.request, "urlopen")
695+ mock_urlopen.side_effect = urllib.error.URLError("URL Error")
696+ self.assertRaises(
697+ PowerConnError, api.get, command, context)
698+
699+ def test_post_gets_response(self):
700+ ip, port, username, password, node_id, context = self.make_context()
701+ api = RECSAPI(ip, port, username, password)
702+ command = factory.make_string()
703+ params = {factory.make_string(): factory.make_string()
704+ for _ in range(3)}
705+ expected = dedent("""
706+ <node health="OK" id="RCU_84055620466592_BB_1_0" state="0" />
707+ """)
708+ response = StringIO(expected)
709+ self.patch(urllib.request, "urlopen", Mock(return_value=response))
710+ output = api.post(command, params=params)
711+ self.assertEquals(output, expected)
712+
713+ def test_post_crashes_on_http_error(self):
714+ ip, port, username, password, node_id, context = self.make_context()
715+ api = RECSAPI(ip, port, username, password)
716+ command = factory.make_string()
717+ mock_urlopen = self.patch(urllib.request, "urlopen")
718+ mock_urlopen.side_effect = urllib.error.HTTPError(
719+ None, None, None, None, None)
720+ self.assertRaises(
721+ PowerConnError, api.post, command, context)
722+
723+ def test_post_crashes_on_url_error(self):
724+ ip, port, username, password, node_id, context = self.make_context()
725+ api = RECSAPI(ip, port, username, password)
726+ command = factory.make_string()
727+ mock_urlopen = self.patch(urllib.request, "urlopen")
728+ mock_urlopen.side_effect = urllib.error.URLError("URL Error")
729+ self.assertRaises(
730+ PowerConnError, api.post, command, context)
731+
732+ def test_put_gets_response(self):
733+ ip, port, username, password, node_id, context = self.make_context()
734+ api = RECSAPI(ip, port, username, password)
735+ command = factory.make_string()
736+ params = {factory.make_string(): factory.make_string()
737+ for _ in range(3)}
738+ expected = dedent("""
739+ <node health="OK" id="RCU_84055620466592_BB_1_0" state="0" />
740+ """)
741+ response = StringIO(expected)
742+ self.patch(urllib.request, "urlopen", Mock(return_value=response))
743+ output = api.put(command, params=params)
744+ self.assertEquals(output, expected)
745+
746+ def test_put_crashes_on_http_error(self):
747+ ip, port, username, password, node_id, context = self.make_context()
748+ api = RECSAPI(ip, port, username, password)
749+ command = factory.make_string()
750+ mock_urlopen = self.patch(urllib.request, "urlopen")
751+ mock_urlopen.side_effect = urllib.error.HTTPError(
752+ None, None, None, None, None)
753+ self.assertRaises(
754+ PowerConnError, api.put, command, context)
755+
756+ def test_put_crashes_on_url_error(self):
757+ ip, port, username, password, node_id, context = self.make_context()
758+ api = RECSAPI(ip, port, username, password)
759+ command = factory.make_string()
760+ mock_urlopen = self.patch(urllib.request, "urlopen")
761+ mock_urlopen.side_effect = urllib.error.URLError("URL Error")
762+ self.assertRaises(
763+ PowerConnError, api.put, command, context)
764+
765+ def test_get_node_power_state_returns_state(self):
766+ ip, port, username, password, node_id, context = self.make_context()
767+ api = RECSAPI(ip, port, username, password)
768+ expected = dedent("""
769+ <node health="OK" id="RCU_84055620466592_BB_1_0" state="1" />
770+ """)
771+ response = StringIO(expected)
772+ self.patch(urllib.request, "urlopen", Mock(return_value=response))
773+ state = api.get_node_power_state("RCU_84055620466592_BB_1_0")
774+ self.assertEquals(state, '1')
775+
776+ def test_set_boot_source_sets_device(self):
777+ ip, port, username, password, node_id, context = self.make_context()
778+ api = RECSAPI(ip, port, username, password)
779+ boot_source = '2'
780+ boot_persistent = 'false'
781+ params = {'source': boot_source, 'persistent': boot_persistent}
782+ mock_put = self.patch(api, "put")
783+ api.set_boot_source(node_id, boot_source, boot_persistent)
784+ self.assertThat(
785+ mock_put,
786+ MockCalledOnceWith(
787+ 'node/%s/manage/set_bootsource' % node_id, params=params))
788+
789+ def test_set_boot_source_recs_calls_set_boot_source(self):
790+ ip, port, username, password, node_id, context = self.make_context()
791+ recs_power_driver = RECSPowerDriver()
792+ mock_set_boot_source = self.patch(RECSAPI, "set_boot_source")
793+ boot_source = 'HDD'
794+ boot_persistent = 'false'
795+ recs_power_driver.set_boot_source_recs(
796+ ip, port, username, password, node_id, boot_source, boot_persistent
797+ )
798+ self.assertThat(
799+ mock_set_boot_source,
800+ MockCalledOnceWith(node_id, boot_source, boot_persistent)
801+ )
802+
803+ def test_get_nodes_gets_nodes(self):
804+ ip, port, username, password, node_id, context = self.make_context()
805+ api = RECSAPI(ip, port, username, password)
806+ response = dedent("""
807+ <nodeList>
808+ <node architecture="x86" baseBoardId="RCU_84055620466592_BB_1"
809+ health="OK" id="RCU_84055620466592_BB_1_0"
810+ ipAddressMgmt="169.254.94.58"
811+ macAddressMgmt="02:00:4c:4f:4f:50"
812+ subnetMaskMgmt="255.255.0.0"
813+ />
814+ <node architecture="x86" baseBoardId="RCU_84055620466592_BB_2"
815+ health="OK" id="RCU_84055620466592_BB_2_0"
816+ ipAddressCompute="169.254.94.59"
817+ macAddressCompute="02:00:4c:4f:4f:51"
818+ subnetMaskCompute="255.255.0.0"
819+ />
820+ <node architecture="arm" baseBoardId="RCU_84055620466592_BB_3"
821+ health="OK" id="RCU_84055620466592_BB_3_2"
822+ ipAddressMgmt="169.254.94.60"
823+ macAddressMgmt="02:00:4c:4f:4f:52"
824+ subnetMaskMgmt="255.255.0.0"
825+ ipAddressCompute="169.254.94.61"
826+ macAddressCompute="02:00:4c:4f:4f:53"
827+ subnetMaskCompute="255.255.0.0"
828+ />
829+ <node architecture="x86" baseBoardId="RCU_84055620466592_BB_4"
830+ health="OK" id="RCU_84055620466592_BB_4_0"
831+ />
832+ </nodeList>
833+ """)
834+ mock_get = self.patch(
835+ api, "get", Mock(return_value=response))
836+ expected = {'RCU_84055620466592_BB_1_0': {
837+ 'macs': ['02:00:4c:4f:4f:50'], 'arch': 'x86'},
838+ 'RCU_84055620466592_BB_2_0': {
839+ 'macs': ['02:00:4c:4f:4f:51'], 'arch': 'x86'},
840+ 'RCU_84055620466592_BB_3_2': {
841+ 'macs': ['02:00:4c:4f:4f:52', '02:00:4c:4f:4f:53'],
842+ 'arch': 'arm'}}
843+ output = api.get_nodes()
844+
845+ self.expectThat(output, Equals(expected))
846+ self.expectThat(
847+ mock_get, MockCalledOnceWith('node'))
848+
849+ def test_power_on_powers_on_node(self):
850+ ip, port, username, password, node_id, context = self.make_context()
851+ api = RECSAPI(ip, port, username, password)
852+ mock_post = self.patch(api, "post")
853+ api.set_power_on_node(node_id)
854+ self.assertThat(
855+ mock_post, MockCalledOnceWith(
856+ 'node/%s/manage/power_on' % node_id))
857+
858+ def test_power_off_powers_off_node(self):
859+ ip, port, username, password, node_id, context = self.make_context()
860+ api = RECSAPI(ip, port, username, password)
861+ mock_post = self.patch(api, "post")
862+ api.set_power_off_node(node_id)
863+ self.assertThat(
864+ mock_post, MockCalledOnceWith(
865+ 'node/%s/manage/power_off' % node_id))
866+
867+ def test_power_state_recs_calls_get_node_power_state_on(self):
868+ ip, port, username, password, node_id, context = self.make_context()
869+ recs_power_driver = RECSPowerDriver()
870+ mock_get_node_power_state = self.patch(RECSAPI, "get_node_power_state",
871+ Mock(return_value='1'))
872+ state = recs_power_driver.power_state_recs(
873+ ip, port, username, password, node_id)
874+ self.assertThat(mock_get_node_power_state, MockCalledOnceWith(node_id))
875+ self.assertThat(state, Equals('on'))
876+
877+ def test_power_state_recs_calls_get_node_power_state_off(self):
878+ ip, port, username, password, node_id, context = self.make_context()
879+ recs_power_driver = RECSPowerDriver()
880+ mock_get_node_power_state = self.patch(RECSAPI, "get_node_power_state",
881+ Mock(return_value='0'))
882+ state = recs_power_driver.power_state_recs(
883+ ip, port, username, password, node_id)
884+ self.assertThat(mock_get_node_power_state, MockCalledOnceWith(node_id))
885+ self.assertThat(state, Equals('off'))
886+
887+ def test_power_state_recs_crashes_on_http_error(self):
888+ ip, port, username, password, node_id, context = self.make_context()
889+ recs_power_driver = RECSPowerDriver()
890+ mock_get_node_power_state = self.patch(RECSAPI, "get_node_power_state",
891+ Mock(return_value='0'))
892+ mock_get_node_power_state.side_effect = urllib.error.HTTPError(
893+ None, None, None, None, None)
894+ self.assertRaises(
895+ RECSError, recs_power_driver.power_state_recs, ip, port, username,
896+ password, node_id)
897+
898+ def test_power_state_recs_crashes_on_url_error(self):
899+ ip, port, username, password, node_id, context = self.make_context()
900+ recs_power_driver = RECSPowerDriver()
901+ mock_get_node_power_state = self.patch(RECSAPI, "get_node_power_state",
902+ Mock(return_value='0'))
903+ mock_get_node_power_state.side_effect = urllib.error.URLError(
904+ "URL Error")
905+ self.assertRaises(
906+ RECSError, recs_power_driver.power_state_recs, ip, port, username,
907+ password, node_id)
908+
909+ def test_power_control_recs_calls_set_power(self):
910+ ip, port, username, password, node_id, context = self.make_context()
911+ recs_power_driver = RECSPowerDriver()
912+ mock_set_power = self.patch(RECSAPI, "_set_power")
913+ recs_power_driver.power_control_recs(
914+ ip, port, username, password, node_id, 'on')
915+ recs_power_driver.power_control_recs(
916+ ip, port, username, password, node_id, 'off')
917+ self.assertThat(
918+ mock_set_power, MockCallsMatch(
919+ call(node_id, 'power_on'),
920+ call(node_id, 'power_off')))
921+
922+ def test_power_control_recs_crashes_on_invalid_action(self):
923+ ip, port, username, password, node_id, context = self.make_context()
924+ recs_power_driver = RECSPowerDriver()
925+ self.assertRaises(
926+ RECSError, recs_power_driver.power_control_recs, ip, port,
927+ username, password, node_id, factory.make_name('action'))
928+
929+ @inlineCallbacks
930+ def test_probe_and_enlist_recs_probes_and_enlists(self):
931+ user = factory.make_name('user')
932+ ip, port, username, password, node_id, context = self.make_context()
933+ domain = factory.make_name('domain')
934+ macs = [factory.make_mac_address() for _ in range(3)]
935+ mock_get_nodes = self.patch(RECSAPI, "get_nodes")
936+ mock_get_nodes.return_value = {node_id: {
937+ 'macs': macs, 'arch': 'amd64'}}
938+ self.patch(RECSAPI, "set_boot_source")
939+ mock_create_node = self.patch(recs_module, "create_node")
940+ mock_create_node.side_effect = asynchronous(lambda *args: node_id)
941+ mock_commission_node = self.patch(recs_module, "commission_node")
942+
943+ yield deferToThread(
944+ probe_and_enlist_recs, user, ip, int(port), username, password,
945+ True, domain)
946+
947+ self.expectThat(
948+ mock_create_node, MockCalledOnceWith(
949+ macs, 'amd64', 'recs_box', context, domain))
950+ self.expectThat(
951+ mock_commission_node, MockCalledOnceWith(node_id, user))
952+
953+ @inlineCallbacks
954+ def test_probe_and_enlist_recs_probes_and_enlists_no_commission(self):
955+ user = factory.make_name('user')
956+ ip, port, username, password, node_id, context = self.make_context()
957+ domain = factory.make_name('domain')
958+ macs = [factory.make_mac_address() for _ in range(3)]
959+ mock_get_nodes = self.patch(RECSAPI, "get_nodes")
960+ mock_get_nodes.return_value = {node_id: {
961+ 'macs': macs, 'arch': 'arm'}}
962+ self.patch(RECSAPI, "set_boot_source")
963+ mock_create_node = self.patch(recs_module, "create_node")
964+ mock_create_node.side_effect = asynchronous(lambda *args: node_id)
965+ mock_commission_node = self.patch(recs_module, "commission_node")
966+
967+ yield deferToThread(
968+ probe_and_enlist_recs, user, ip, int(port), username, password,
969+ False, domain)
970+
971+ self.expectThat(
972+ mock_create_node, MockCalledOnceWith(
973+ macs, 'armhf', 'recs_box', context, domain))
974+ self.expectThat(
975+ mock_commission_node, MockNotCalled())
976+
977+ @inlineCallbacks
978+ def test_probe_and_enlist_recs_get_nodes_failure_http_error(self):
979+ user = factory.make_name('user')
980+ ip, port, username, password, node_id, context = self.make_context()
981+ domain = factory.make_name('domain')
982+ mock_get_nodes = self.patch(RECSAPI, "get_nodes")
983+ mock_get_nodes.side_effect = urllib.error.HTTPError(
984+ None, None, None, None, None)
985+
986+ with ExpectedException(RECSError):
987+ yield deferToThread(
988+ probe_and_enlist_recs, user, ip, int(port), username, password,
989+ True, domain)
990+
991+ @inlineCallbacks
992+ def test_probe_and_enlist_recs_get_nodes_failure_url_error(self):
993+ user = factory.make_name('user')
994+ ip, port, username, password, node_id, context = self.make_context()
995+ domain = factory.make_name('domain')
996+ mock_get_nodes = self.patch(RECSAPI, "get_nodes")
997+ mock_get_nodes.side_effect = urllib.error.URLError("URL Error")
998+
999+ with ExpectedException(RECSError):
1000+ yield deferToThread(
1001+ probe_and_enlist_recs, user, ip, int(port), username, password,
1002+ True, domain)
1003
1004=== modified file 'src/provisioningserver/rpc/clusterservice.py'
1005--- src/provisioningserver/rpc/clusterservice.py 2017-05-30 18:36:16 +0000
1006+++ src/provisioningserver/rpc/clusterservice.py 2017-06-21 15:18:01 +0000
1007@@ -39,6 +39,7 @@
1008 from provisioningserver.drivers.hardware.vmware import probe_vmware_and_enlist
1009 from provisioningserver.drivers.power.mscm import probe_and_enlist_mscm
1010 from provisioningserver.drivers.power.msftocs import probe_and_enlist_msftocs
1011+from provisioningserver.drivers.power.recs import probe_and_enlist_recs
1012 from provisioningserver.drivers.power.registry import PowerDriverRegistry
1013 from provisioningserver.logger import (
1014 get_maas_logger,
1015@@ -572,6 +573,12 @@
1016 user, hostname, username, password, port, protocol,
1017 prefix_filter, accept_all, domain)
1018 d.addErrback(partial(catch_probe_and_enlist_error, "VMware"))
1019+ elif chassis_type == 'recs_box':
1020+ d = deferToThread(
1021+ probe_and_enlist_recs,
1022+ user, hostname, port, username, password, accept_all, domain)
1023+ d.addErrback(
1024+ partial(catch_probe_and_enlist_error, "RECS|Box"))
1025 elif chassis_type == 'seamicro15k':
1026 d = deferToThread(
1027 probe_seamicro15k_and_enlist,
1028
1029=== modified file 'src/provisioningserver/rpc/tests/test_clusterservice.py'
1030--- src/provisioningserver/rpc/tests/test_clusterservice.py 2017-05-31 08:45:53 +0000
1031+++ src/provisioningserver/rpc/tests/test_clusterservice.py 2017-06-21 15:18:01 +0000
1032@@ -2644,6 +2644,60 @@
1033 "Failed to probe and enlist %s nodes: %s",
1034 "VMware", fake_error))
1035
1036+ def test_chassis_type_recs_calls_probe_and_enlist_recs(self):
1037+ mock_deferToThread = self.patch_autospec(
1038+ clusterservice, 'deferToThread')
1039+ user = factory.make_name('user')
1040+ hostname = factory.make_hostname()
1041+ username = factory.make_name('username')
1042+ password = factory.make_name('password')
1043+ accept_all = factory.pick_bool()
1044+ domain = factory.make_name('domain')
1045+ port = randint(2000, 4000)
1046+ call_responder(Cluster(), cluster.AddChassis, {
1047+ 'user': user,
1048+ 'chassis_type': 'recs_box',
1049+ 'hostname': hostname,
1050+ 'username': username,
1051+ 'password': password,
1052+ 'accept_all': accept_all,
1053+ 'domain': domain,
1054+ 'port': port,
1055+ })
1056+ self.assertThat(
1057+ mock_deferToThread, MockCalledOnceWith(
1058+ clusterservice.probe_and_enlist_recs, user, hostname, port,
1059+ username, password, accept_all, domain))
1060+
1061+ def test_chassis_type_recs_logs_error_to_maaslog(self):
1062+ fake_error = factory.make_name('error')
1063+ self.patch(clusterservice, 'maaslog')
1064+ mock_deferToThread = self.patch_autospec(
1065+ clusterservice, 'deferToThread')
1066+ mock_deferToThread.return_value = fail(Exception(fake_error))
1067+ user = factory.make_name('user')
1068+ hostname = factory.make_hostname()
1069+ username = factory.make_name('username')
1070+ password = factory.make_name('password')
1071+ accept_all = factory.pick_bool()
1072+ domain = factory.make_name('domain')
1073+ port = randint(2000, 4000)
1074+ call_responder(Cluster(), cluster.AddChassis, {
1075+ 'user': user,
1076+ 'chassis_type': 'recs_box',
1077+ 'hostname': hostname,
1078+ 'username': username,
1079+ 'password': password,
1080+ 'accept_all': accept_all,
1081+ 'domain': domain,
1082+ 'port': port,
1083+ })
1084+ self.assertThat(
1085+ clusterservice.maaslog.error,
1086+ MockAnyCall(
1087+ "Failed to probe and enlist %s nodes: %s",
1088+ "RECS|Box", fake_error))
1089+
1090 def test_chassis_type_seamicro15k_calls_probe_seamicro15k_and_enlist(self):
1091 mock_deferToThread = self.patch_autospec(
1092 clusterservice, 'deferToThread')