Merge lp:~lutostag/ubuntu/trusty/maas/1.5.4 into lp:ubuntu/trusty-proposed/maas
- Trusty (14.04)
- 1.5.4
- Merge into trusty-proposed
Proposed by
Greg Lutostanski
Status: | Merged |
---|---|
Merged at revision: | 65 |
Proposed branch: | lp:~lutostag/ubuntu/trusty/maas/1.5.4 |
Merge into: | lp:ubuntu/trusty-proposed/maas |
Diff against target: |
2056 lines (+1398/-41) 42 files modified
debian/changelog (+39/-0) debian/control (+2/-1) debian/extras/99-maas-sudoers (+1/-0) debian/maas-cluster-controller.postinst (+1/-0) docs/_templates/maas/layout.html (+6/-0) docs/_templates/maas/static/css/main.css (+1/-1) docs/_templates/maas/static/flasky.css_t (+6/-1) docs/changelog.rst (+31/-0) etc/maas/templates/commissioning-user-data/snippets/maas_api_helper.py (+2/-1) etc/maas/templates/dhcp/dhcpd.conf.template (+1/-0) etc/maas/templates/power/mscm.template (+15/-0) etc/maas/templates/pxe/config.commissioning.ppc64el.template (+6/-0) etc/maas/templates/pxe/config.install.ppc64el.template (+6/-0) src/apiclient/maas_client.py (+3/-1) src/maasserver/api.py (+26/-0) src/maasserver/enum.py (+4/-6) src/maasserver/models/nodegroup.py (+11/-0) src/maasserver/models/tests/test_node.py (+2/-2) src/maasserver/rpc/regionservice.py (+1/-1) src/maasserver/rpc/tests/test_regionservice.py (+2/-4) src/maasserver/start_up.py (+7/-3) src/maasserver/testing/tests/test_rabbit.py (+2/-1) src/maasserver/tests/test_api_nodegroup.py (+26/-0) src/maasserver/utils/dblocks.py (+31/-2) src/maasserver/utils/tests/test_dblocks.py (+18/-1) src/maastesting/factory.py (+9/-0) src/metadataserver/address.py (+2/-2) src/metadataserver/tests/test_address.py (+40/-0) src/provisioningserver/boot/__init__.py (+6/-0) src/provisioningserver/boot/powernv.py (+158/-0) src/provisioningserver/boot/tests/test_powernv.py (+337/-0) src/provisioningserver/dhcp/config.py (+24/-11) src/provisioningserver/driver/__init__.py (+14/-1) src/provisioningserver/drivers/hardware/mscm.py (+187/-0) src/provisioningserver/drivers/hardware/tests/test_mscm.py (+259/-0) src/provisioningserver/power/tests/test_poweraction.py (+11/-0) src/provisioningserver/power_schema.py (+13/-0) src/provisioningserver/tasks.py (+9/-1) src/provisioningserver/tests/test_tasks.py (+12/-0) src/provisioningserver/utils/__init__.py (+21/-0) src/provisioningserver/utils/tests/test_utils.py (+45/-0) versions.cfg (+1/-1) |
To merge this branch: | bzr merge lp:~lutostag/ubuntu/trusty/maas/1.5.4 |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Chuck Short (community) | Approve | ||
Review via email:
|
Commit message
Description of the change
Proposed update to bring maas bugfix release 1.5.4 into trusty via SRU.
Builds and installs fine.
To post a comment you must log in.
Revision history for this message

Chuck Short (zulcss) : | # |
review:
Approve
Revision history for this message

Greg Lutostanski (lutostag) wrote : | # |
Revision history for this message

Greg Lutostanski (lutostag) wrote : | # |
New changelog that clearly shows supported releases for install.
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file 'debian/changelog' |
2 | --- debian/changelog 2014-06-20 10:10:47 +0000 |
3 | +++ debian/changelog 2014-08-29 19:22:35 +0000 |
4 | @@ -1,3 +1,42 @@ |
5 | +maas (1.5.4+bzr2294-0ubuntu1) trusty-proposed; urgency=medium |
6 | + |
7 | + * New upstream bug fix release: |
8 | + - Package fails to install when the default route is through an |
9 | + aliased/tagged interface (LP: #1350235) |
10 | + - ERROR Nonce already used (LP: #1190986) |
11 | + - Add MAAS arm64/xgene support (LP: #1338851) |
12 | + - Add utopic support (LP: #1337437) |
13 | + - API documentation for nodegroup op=details missing parameter |
14 | + (LP: #1331982) |
15 | + - Reduce number of celery tasks emitted when updating a cluster controller |
16 | + (LP: #1324944) |
17 | + - Fix VirshSSH template which was referencing invalid attributes |
18 | + (LP: #1324966) |
19 | + - Fix a start up problems where a database lock was being taken outside of |
20 | + a transaction (LP: #1325640, LP: #1325759) |
21 | + - Reformat badly formatted Architecture error message (LP: #1301465) |
22 | + - Final changes to support ppc64el (now known as PowerNV) (LP: #1315154) |
23 | + - UI tweak to make navigation elements visible for documentation |
24 | + |
25 | + [ Greg Lutostanski ] |
26 | + * debian/control: |
27 | + - maas-provisioningserver not maas-cluster-controller depends on |
28 | + python-pexpect (LP: #1352273) |
29 | + |
30 | + [ Gavin Panella ] |
31 | + * debian/maas-cluster-controller.postinst |
32 | + - Allow maas-pserv to bind to all IPv6 addresses too. (LP: #1342302) |
33 | + |
34 | + [ Diogo Matsubara ] |
35 | + * debian/control: |
36 | + - python-maas-provisioningserver depends on python-paramiko (LP: #1334401) |
37 | + |
38 | + [ Raphaƫl Badin ] |
39 | + * debian/extras/99-maas-sudoers: |
40 | + - Add rule 'maas-dhcp-server stop' job. |
41 | + |
42 | + -- Greg Lutostanski <gregory.lutostanski@canonical.com> Fri, 29 Aug 2014 13:27:34 -0500 |
43 | + |
44 | maas (1.5.2+bzr2282-0ubuntu0.2) trusty-proposed; urgency=medium |
45 | |
46 | * debian/control: |
47 | |
48 | === modified file 'debian/control' |
49 | --- debian/control 2014-06-20 10:10:47 +0000 |
50 | +++ debian/control 2014-08-29 19:22:35 +0000 |
51 | @@ -99,6 +99,8 @@ |
52 | python-oops-amqp, |
53 | python-oops-datedir-repo, |
54 | python-oops-twisted, |
55 | + python-paramiko, |
56 | + python-pexpect, |
57 | python-pyparsing, |
58 | python-seamicroclient, |
59 | python-simplestreams, |
60 | @@ -159,7 +161,6 @@ |
61 | python-maas-provisioningserver (=${binary:Version}), |
62 | python-netaddr, |
63 | python-oauth, |
64 | - python-pexpect, |
65 | python-tempita, |
66 | python-twisted, |
67 | python-zope.interface, |
68 | |
69 | === modified file 'debian/extras/99-maas-sudoers' |
70 | --- debian/extras/99-maas-sudoers 2013-06-03 16:53:14 +0000 |
71 | +++ debian/extras/99-maas-sudoers 2014-08-29 19:22:35 +0000 |
72 | @@ -1,3 +1,4 @@ |
73 | maas ALL= NOPASSWD: /usr/sbin/service maas-dhcp-server restart |
74 | +maas ALL= NOPASSWD: /usr/sbin/service maas-dhcp-server stop |
75 | maas ALL= NOPASSWD: /usr/sbin/maas-provision |
76 | maas ALL= NOPASSWD: SETENV: /usr/sbin/maas-import-pxe-files |
77 | |
78 | === modified file 'debian/maas-cluster-controller.postinst' |
79 | --- debian/maas-cluster-controller.postinst 2014-04-03 13:45:02 +0000 |
80 | +++ debian/maas-cluster-controller.postinst 2014-08-29 19:22:35 +0000 |
81 | @@ -103,6 +103,7 @@ |
82 | fi |
83 | fi |
84 | echo '0.0.0.0/0:68,69' >/etc/authbind/byuid/$MAAS_UID |
85 | + echo '::/0,68-69' >>/etc/authbind/byuid/$MAAS_UID |
86 | chown maas:maas /etc/authbind/byuid/$MAAS_UID |
87 | chmod 700 /etc/authbind/byuid/$MAAS_UID |
88 | } |
89 | |
90 | === modified file 'docs/_templates/maas/layout.html' |
91 | --- docs/_templates/maas/layout.html 2014-04-15 14:41:32 +0000 |
92 | +++ docs/_templates/maas/layout.html 2014-08-29 19:22:35 +0000 |
93 | @@ -10,6 +10,12 @@ |
94 | <br/> |
95 | {% endblock %} |
96 | |
97 | +{# Remove 'modules' and 'index' from rellinks: they point to |
98 | + autogenerated code documentation pages that we don't want |
99 | + to advertise too much. |
100 | +#} |
101 | +{%- set rellinks = rellinks[2:] %} |
102 | + |
103 | {%- block footer %} |
104 | <footer class="global clearfix"> |
105 | <div class="legal clearfix"> |
106 | |
107 | === modified file 'docs/_templates/maas/static/css/main.css' |
108 | --- docs/_templates/maas/static/css/main.css 2014-02-15 12:08:23 +0000 |
109 | +++ docs/_templates/maas/static/css/main.css 2014-08-29 19:22:35 +0000 |
110 | @@ -9,7 +9,7 @@ |
111 | |
112 | div.document { |
113 | width: 984px; |
114 | - margin: 30px auto 0 auto; |
115 | + margin: 10px auto 0 auto; |
116 | } |
117 | |
118 | div.body h1 { |
119 | |
120 | === modified file 'docs/_templates/maas/static/flasky.css_t' |
121 | --- docs/_templates/maas/static/flasky.css_t 2013-10-10 17:07:51 +0000 |
122 | +++ docs/_templates/maas/static/flasky.css_t 2014-08-29 19:22:35 +0000 |
123 | @@ -25,7 +25,7 @@ |
124 | |
125 | div.document { |
126 | width: {{ page_width }}; |
127 | - margin: 30px auto 0 auto; |
128 | + margin: 10px auto 0 auto; |
129 | } |
130 | |
131 | div.documentwrapper { |
132 | @@ -69,6 +69,11 @@ |
133 | } |
134 | |
135 | div.related { |
136 | + width: {{ page_width }}; |
137 | + margin: 10px auto 0 auto; |
138 | +} |
139 | + |
140 | +div.related h3 { |
141 | display: none; |
142 | } |
143 | |
144 | |
145 | === modified file 'docs/changelog.rst' |
146 | --- docs/changelog.rst 2014-06-02 11:57:58 +0000 |
147 | +++ docs/changelog.rst 2014-08-29 19:22:35 +0000 |
148 | @@ -2,6 +2,37 @@ |
149 | Changelog |
150 | ========= |
151 | |
152 | +1.5.4 |
153 | +===== |
154 | + |
155 | +Bug fix update |
156 | +-------------- |
157 | + |
158 | + - Package fails to install when the default route is through an |
159 | + aliased/tagged interface (LP: #1350235) |
160 | + - ERROR Nonce already used (LP: #1190986) |
161 | + - Add MAAS arm64/xgene support (LP: #1338851) |
162 | + - Add utopic support (LP: #1337437) |
163 | + - API documentation for nodegroup op=details missing parameter |
164 | + (LP: #1331982) |
165 | + |
166 | + |
167 | +1.5.3 |
168 | +===== |
169 | + |
170 | +Bug fix update |
171 | +-------------- |
172 | + |
173 | + - Reduce number of celery tasks emitted when updating a cluster controller |
174 | + (LP: #1324944) |
175 | + - Fix VirshSSH template which was referencing invalid attributes |
176 | + (LP: #1324966) |
177 | + - Fix a start up problem where a database lock was being taken outside of |
178 | + a transaction (LP: #1325759) |
179 | + - Reformat badly formatted Architecture error message (LP: #1301465) |
180 | + - Final changes to support ppc64el (now known as PowerNV) (LP: #1315154) |
181 | + |
182 | + |
183 | 1.5.2 |
184 | ===== |
185 | |
186 | |
187 | === modified file 'etc/maas/templates/commissioning-user-data/snippets/maas_api_helper.py' |
188 | --- etc/maas/templates/commissioning-user-data/snippets/maas_api_helper.py 2013-10-10 17:07:51 +0000 |
189 | +++ etc/maas/templates/commissioning-user-data/snippets/maas_api_helper.py 2014-08-29 19:22:35 +0000 |
190 | @@ -12,6 +12,7 @@ |
191 | import sys |
192 | import time |
193 | import urllib2 |
194 | +import uuid |
195 | |
196 | import oauth.oauth as oauth |
197 | import yaml |
198 | @@ -60,7 +61,7 @@ |
199 | |
200 | params = { |
201 | 'oauth_version': "1.0", |
202 | - 'oauth_nonce': oauth.generate_nonce(), |
203 | + 'oauth_nonce': uuid.uuid4().get_hex(), |
204 | 'oauth_timestamp': timestamp, |
205 | 'oauth_token': token.key, |
206 | 'oauth_consumer_key': consumer.key, |
207 | |
208 | === modified file 'etc/maas/templates/dhcp/dhcpd.conf.template' |
209 | --- etc/maas/templates/dhcp/dhcpd.conf.template 2014-04-09 19:02:00 +0000 |
210 | +++ etc/maas/templates/dhcp/dhcpd.conf.template 2014-08-29 19:22:35 +0000 |
211 | @@ -6,6 +6,7 @@ |
212 | # the nodegroup's configuration in MAAS to trigger an update. |
213 | |
214 | option arch code 93 = unsigned integer 16; # RFC4578 |
215 | +option path-prefix code 210 = text; #RFC5071 |
216 | {{for dhcp_subnet in dhcp_subnets}} |
217 | subnet {{dhcp_subnet['subnet']}} netmask {{dhcp_subnet['subnet_mask']}} { |
218 | {{bootloader}} |
219 | |
220 | === added file 'etc/maas/templates/power/mscm.template' |
221 | --- etc/maas/templates/power/mscm.template 1970-01-01 00:00:00 +0000 |
222 | +++ etc/maas/templates/power/mscm.template 2014-08-29 19:22:35 +0000 |
223 | @@ -0,0 +1,15 @@ |
224 | +# -*- mode: shell-script -*- |
225 | +# |
226 | +# Control a system via Moonshot HP iLO Chassis Manager (MSCM). |
227 | + |
228 | +{{py: from provisioningserver.utils import escape_py_literal}} |
229 | +python - << END |
230 | +from provisioningserver.drivers.hardware.mscm import power_control_mscm |
231 | +power_control_mscm( |
232 | + {{escape_py_literal(power_address) | safe}}, |
233 | + {{escape_py_literal(power_user) | safe}}, |
234 | + {{escape_py_literal(power_pass) | safe}}, |
235 | + {{escape_py_literal(node_id) | safe}}, |
236 | + {{escape_py_literal(power_change) | safe}}, |
237 | +) |
238 | +END |
239 | |
240 | === added symlink 'etc/maas/templates/pxe/config.commissioning.arm64.template' |
241 | === target is u'config.commissioning.armhf.template' |
242 | === added file 'etc/maas/templates/pxe/config.commissioning.ppc64el.template' |
243 | --- etc/maas/templates/pxe/config.commissioning.ppc64el.template 1970-01-01 00:00:00 +0000 |
244 | +++ etc/maas/templates/pxe/config.commissioning.ppc64el.template 2014-08-29 19:22:35 +0000 |
245 | @@ -0,0 +1,6 @@ |
246 | +DEFAULT execute |
247 | + |
248 | +LABEL execute |
249 | + KERNEL {{kernel_params | kernel_path }} |
250 | + INITRD {{kernel_params | initrd_path }} |
251 | + APPEND {{kernel_params | kernel_command}} |
252 | |
253 | === added symlink 'etc/maas/templates/pxe/config.install.arm64.template' |
254 | === target is u'config.install.armhf.template' |
255 | === added file 'etc/maas/templates/pxe/config.install.ppc64el.template' |
256 | --- etc/maas/templates/pxe/config.install.ppc64el.template 1970-01-01 00:00:00 +0000 |
257 | +++ etc/maas/templates/pxe/config.install.ppc64el.template 2014-08-29 19:22:35 +0000 |
258 | @@ -0,0 +1,6 @@ |
259 | +DEFAULT execute |
260 | + |
261 | +LABEL execute |
262 | + KERNEL {{kernel_params | kernel_path }} |
263 | + INITRD {{kernel_params | initrd_path }} |
264 | + APPEND {{kernel_params | kernel_command}} |
265 | |
266 | === added symlink 'etc/maas/templates/pxe/config.xinstall.arm64.template' |
267 | === target is u'config.xinstall.armhf.template' |
268 | === added symlink 'etc/maas/templates/pxe/config.xinstall.ppc64el.template' |
269 | === target is u'config.install.ppc64el.template' |
270 | === modified file 'src/apiclient/maas_client.py' |
271 | --- src/apiclient/maas_client.py 2014-04-15 14:41:32 +0000 |
272 | +++ src/apiclient/maas_client.py 2014-08-29 19:22:35 +0000 |
273 | @@ -21,6 +21,7 @@ |
274 | import gzip |
275 | from io import BytesIO |
276 | import urllib2 |
277 | +import uuid |
278 | |
279 | from apiclient.encode_json import encode_json_data |
280 | from apiclient.multipart import encode_multipart_data |
281 | @@ -45,7 +46,8 @@ |
282 | with the signature. |
283 | """ |
284 | oauth_request = oauth.OAuthRequest.from_consumer_and_token( |
285 | - self.consumer_token, token=self.resource_token, http_url=url) |
286 | + self.consumer_token, token=self.resource_token, http_url=url, |
287 | + parameters={'oauth_nonce': uuid.uuid4().get_hex()}) |
288 | oauth_request.sign_request( |
289 | oauth.OAuthSignatureMethod_PLAINTEXT(), self.consumer_token, |
290 | self.resource_token) |
291 | |
292 | === modified file 'src/maasserver/api.py' |
293 | --- src/maasserver/api.py 2014-06-04 14:31:41 +0000 |
294 | +++ src/maasserver/api.py 2014-08-29 19:22:35 +0000 |
295 | @@ -1580,6 +1580,8 @@ |
296 | Returns a ``{system_id: {detail_type: xml, ...}, ...}`` map, |
297 | where ``detail_type`` is something like "lldp" or "lshw". |
298 | |
299 | + :param system_ids: System ids of nodes for which to get system details. |
300 | + |
301 | Note that this is returned as BSON and not JSON. This is for |
302 | efficiency, but mainly because JSON can't do binary content |
303 | without applying additional encoding like base-64. |
304 | @@ -1761,6 +1763,30 @@ |
305 | |
306 | return HttpResponse(status=httplib.OK) |
307 | |
308 | + @admin_method |
309 | + @operation(idempotent=False) |
310 | + def probe_and_enlist_mscm(self, request, uuid): |
311 | + """Add the nodes from a Moonshot HP iLO Chassis Manager (MSCM). |
312 | + |
313 | + :param host: IP Address for the MSCM. |
314 | + :type host: unicode |
315 | + :param username: The username for the MSCM. |
316 | + :type username: unicode |
317 | + :param password: The password for the MSCM. |
318 | + :type password: unicode |
319 | + |
320 | + """ |
321 | + nodegroup = get_object_or_404(NodeGroup, uuid=uuid) |
322 | + |
323 | + host = get_mandatory_param(request.data, 'host') |
324 | + username = get_mandatory_param(request.data, 'username') |
325 | + password = get_mandatory_param(request.data, 'password') |
326 | + |
327 | + nodegroup.enlist_nodes_from_mscm(host, username, password) |
328 | + |
329 | + return HttpResponse(status=httplib.OK) |
330 | + |
331 | + |
332 | DISPLAYED_NODEGROUPINTERFACE_FIELDS = ( |
333 | 'ip', 'management', 'interface', 'subnet_mask', |
334 | 'broadcast_ip', 'ip_range_low', 'ip_range_high') |
335 | |
336 | === modified file 'src/maasserver/enum.py' |
337 | --- src/maasserver/enum.py 2014-03-28 10:43:53 +0000 |
338 | +++ src/maasserver/enum.py 2014-08-29 19:22:35 +0000 |
339 | @@ -93,21 +93,19 @@ |
340 | #: |
341 | precise = 'precise' |
342 | #: |
343 | - quantal = 'quantal' |
344 | - #: |
345 | - raring = 'raring' |
346 | - #: |
347 | saucy = 'saucy' |
348 | #: |
349 | trusty = 'trusty' |
350 | + #: |
351 | + utopic = 'utopic' |
352 | + |
353 | |
354 | DISTRO_SERIES_CHOICES = ( |
355 | (DISTRO_SERIES.default, 'Default Ubuntu Release'), |
356 | (DISTRO_SERIES.precise, 'Ubuntu 12.04 LTS "Precise Pangolin"'), |
357 | - (DISTRO_SERIES.quantal, 'Ubuntu 12.10 "Quantal Quetzal"'), |
358 | - (DISTRO_SERIES.raring, 'Ubuntu 13.04 "Raring Ringtail"'), |
359 | (DISTRO_SERIES.saucy, 'Ubuntu 13.10 "Saucy Salamander"'), |
360 | (DISTRO_SERIES.trusty, 'Ubuntu 14.04 LTS "Trusty Tahr"'), |
361 | + (DISTRO_SERIES.utopic, 'Ubuntu 14.10 "Utopic Unicorn"'), |
362 | ) |
363 | |
364 | |
365 | |
366 | === modified file 'src/maasserver/models/nodegroup.py' |
367 | --- src/maasserver/models/nodegroup.py 2014-06-04 14:31:41 +0000 |
368 | +++ src/maasserver/models/nodegroup.py 2014-08-29 19:22:35 +0000 |
369 | @@ -42,6 +42,7 @@ |
370 | add_new_dhcp_host_map, |
371 | add_seamicro15k, |
372 | add_virsh, |
373 | + enlist_nodes_from_mscm, |
374 | enlist_nodes_from_ucsm, |
375 | import_boot_images, |
376 | report_boot_images, |
377 | @@ -307,6 +308,16 @@ |
378 | args = (url, username, password) |
379 | enlist_nodes_from_ucsm.apply_async(queue=self.uuid, args=args) |
380 | |
381 | + def enlist_nodes_from_mscm(self, host, username, password): |
382 | + """ Add the servers from a Moonshot HP iLO Chassis Manager. |
383 | + |
384 | + :param host: IP address for the MSCM. |
385 | + :param username: username for MSCM. |
386 | + :param password: password for MSCM. |
387 | + """ |
388 | + args = (host, username, password) |
389 | + enlist_nodes_from_mscm.apply_async(queue=self.uuid, args=args) |
390 | + |
391 | def add_dhcp_host_maps(self, new_leases): |
392 | if len(new_leases) > 0 and len(self.get_managed_interfaces()) > 0: |
393 | # XXX JeroenVermeulen 2012-08-21, bug=1039362: the DHCP |
394 | |
395 | === modified file 'src/maasserver/models/tests/test_node.py' |
396 | --- src/maasserver/models/tests/test_node.py 2014-04-15 14:41:32 +0000 |
397 | +++ src/maasserver/models/tests/test_node.py 2014-08-29 19:22:35 +0000 |
398 | @@ -162,7 +162,7 @@ |
399 | |
400 | def test_set_get_distro_series_returns_series(self): |
401 | node = factory.make_node() |
402 | - series = DISTRO_SERIES.quantal |
403 | + series = DISTRO_SERIES.utopic |
404 | node.set_distro_series(series) |
405 | self.assertEqual(series, node.get_distro_series()) |
406 | |
407 | @@ -474,7 +474,7 @@ |
408 | def test_release_clears_distro_series(self): |
409 | node = factory.make_node( |
410 | status=NODE_STATUS.ALLOCATED, owner=factory.make_user()) |
411 | - node.set_distro_series(series=DISTRO_SERIES.quantal) |
412 | + node.set_distro_series(series=DISTRO_SERIES.utopic) |
413 | node.release() |
414 | self.assertEqual("", node.distro_series) |
415 | |
416 | |
417 | === modified file 'src/maasserver/rpc/regionservice.py' |
418 | --- src/maasserver/rpc/regionservice.py 2014-04-03 13:45:02 +0000 |
419 | +++ src/maasserver/rpc/regionservice.py 2014-08-29 19:22:35 +0000 |
420 | @@ -325,8 +325,8 @@ |
421 | |
422 | @synchronous |
423 | @synchronised(lock) |
424 | + @transactional |
425 | @synchronised(locks.eventloop) |
426 | - @transactional |
427 | def prepare(self): |
428 | """Ensure that the ``eventloops`` table exists. |
429 | |
430 | |
431 | === modified file 'src/maasserver/rpc/tests/test_regionservice.py' |
432 | --- src/maasserver/rpc/tests/test_regionservice.py 2014-04-15 14:41:32 +0000 |
433 | +++ src/maasserver/rpc/tests/test_regionservice.py 2014-08-29 19:22:35 +0000 |
434 | @@ -49,6 +49,7 @@ |
435 | common, |
436 | exceptions, |
437 | ) |
438 | +from provisioningserver.rpc.interfaces import IConnection |
439 | from provisioningserver.rpc.region import ( |
440 | Identify, |
441 | ReportBootImages, |
442 | @@ -82,6 +83,7 @@ |
443 | from twisted.internet.threads import deferToThread |
444 | from twisted.protocols import amp |
445 | from twisted.python import log |
446 | +from zope.interface.verify import verifyObject |
447 | |
448 | |
449 | class TestRegionProtocol_Identify(MAASTestCase): |
450 | @@ -195,10 +197,6 @@ |
451 | return d.addCallback(check) |
452 | |
453 | |
454 | -from provisioningserver.rpc.interfaces import IConnection |
455 | -from zope.interface.verify import verifyObject |
456 | - |
457 | - |
458 | class TestRegionServer(MAASServerTestCase): |
459 | |
460 | def test_interfaces(self): |
461 | |
462 | === modified file 'src/maasserver/start_up.py' |
463 | --- src/maasserver/start_up.py 2014-04-09 19:02:00 +0000 |
464 | +++ src/maasserver/start_up.py 2014-08-29 19:22:35 +0000 |
465 | @@ -18,7 +18,10 @@ |
466 | |
467 | from textwrap import dedent |
468 | |
469 | -from django.db import connection |
470 | +from django.db import ( |
471 | + connection, |
472 | + transaction, |
473 | + ) |
474 | from maasserver import ( |
475 | eventloop, |
476 | locks, |
477 | @@ -51,8 +54,9 @@ |
478 | but this method uses file-based locking to ensure that the methods it calls |
479 | internally are not ran concurrently. |
480 | """ |
481 | - with locks.startup: |
482 | - inner_start_up() |
483 | + with transaction.atomic(): |
484 | + with locks.startup: |
485 | + inner_start_up() |
486 | |
487 | eventloop.start().wait(10) |
488 | |
489 | |
490 | === modified file 'src/maasserver/testing/tests/test_rabbit.py' |
491 | --- src/maasserver/testing/tests/test_rabbit.py 2013-10-04 12:33:05 +0000 |
492 | +++ src/maasserver/testing/tests/test_rabbit.py 2014-08-29 19:22:35 +0000 |
493 | @@ -26,7 +26,8 @@ |
494 | def test_patch(self): |
495 | config = RabbitServerResources( |
496 | hostname=factory.getRandomString(), |
497 | - port=factory.getRandomPort()) |
498 | + port=factory.getRandomPort(), |
499 | + dist_port=factory.getRandomPort()) |
500 | self.useFixture(config) |
501 | self.useFixture(RabbitServerSettings(config)) |
502 | self.assertEqual( |
503 | |
504 | === modified file 'src/maasserver/tests/test_api_nodegroup.py' |
505 | --- src/maasserver/tests/test_api_nodegroup.py 2014-04-15 14:41:32 +0000 |
506 | +++ src/maasserver/tests/test_api_nodegroup.py 2014-08-29 19:22:35 +0000 |
507 | @@ -460,6 +460,32 @@ |
508 | matcher = MockCalledOnceWith(queue=nodegroup.uuid, args=args) |
509 | self.assertThat(mock.apply_async, matcher) |
510 | |
511 | + def test_probe_and_enlist_mscm_adds_mscm(self): |
512 | + nodegroup = factory.make_node_group() |
513 | + host = 'http://host' |
514 | + username = factory.make_name('user') |
515 | + password = factory.make_name('password') |
516 | + self.become_admin() |
517 | + |
518 | + mock = self.patch(nodegroup_module, 'enlist_nodes_from_mscm') |
519 | + |
520 | + response = self.client.post( |
521 | + reverse('nodegroup_handler', args=[nodegroup.uuid]), |
522 | + { |
523 | + 'op': 'probe_and_enlist_mscm', |
524 | + 'host': host, |
525 | + 'username': username, |
526 | + 'password': password, |
527 | + }) |
528 | + |
529 | + self.assertEqual( |
530 | + httplib.OK, response.status_code, |
531 | + explain_unexpected_response(httplib.OK, response)) |
532 | + |
533 | + args = (host, username, password) |
534 | + matcher = MockCalledOnceWith(queue=nodegroup.uuid, args=args) |
535 | + self.assertThat(mock.apply_async, matcher) |
536 | + |
537 | |
538 | class TestNodeGroupAPIAuth(MAASServerTestCase): |
539 | """Authorization tests for nodegroup API.""" |
540 | |
541 | === modified file 'src/maasserver/utils/dblocks.py' |
542 | --- src/maasserver/utils/dblocks.py 2014-04-03 13:45:02 +0000 |
543 | +++ src/maasserver/utils/dblocks.py 2014-08-29 19:22:35 +0000 |
544 | @@ -14,6 +14,8 @@ |
545 | __metaclass__ = type |
546 | __all__ = [ |
547 | "DatabaseLock", |
548 | + "DatabaseLockAttemptOutsideTransaction", |
549 | + "DatabaseLockNotHeld", |
550 | ] |
551 | |
552 | from contextlib import closing |
553 | @@ -26,6 +28,19 @@ |
554 | classid = 20120116 |
555 | |
556 | |
557 | +class DatabaseLockAttemptOutsideTransaction(Exception): |
558 | + """A locking attempt was made outside of a transaction. |
559 | + |
560 | + :class:`DatabaseLock` should only be used within a transaction. |
561 | + Django agressively closes connections outside of atomic blocks to |
562 | + the extent that session-level locks are rendered unreliable at best. |
563 | + """ |
564 | + |
565 | + |
566 | +class DatabaseLockNotHeld(Exception): |
567 | + """A particular lock was not held.""" |
568 | + |
569 | + |
570 | class DatabaseLock(tuple): |
571 | """An advisory lock held in the database. |
572 | |
573 | @@ -58,12 +73,16 @@ |
574 | return super(cls, DatabaseLock).__new__(cls, (classid, objid)) |
575 | |
576 | def __enter__(self): |
577 | + if not connection.in_atomic_block: |
578 | + raise DatabaseLockAttemptOutsideTransaction(self) |
579 | with closing(connection.cursor()) as cursor: |
580 | cursor.execute("SELECT pg_advisory_lock(%s, %s)", self) |
581 | |
582 | def __exit__(self, *exc_info): |
583 | with closing(connection.cursor()) as cursor: |
584 | cursor.execute("SELECT pg_advisory_unlock(%s, %s)", self) |
585 | + if cursor.fetchone() != (True,): |
586 | + raise DatabaseLockNotHeld(self) |
587 | |
588 | def __repr__(self): |
589 | return b"<%s classid=%d objid=%d>" % ( |
590 | @@ -71,8 +90,18 @@ |
591 | |
592 | def is_locked(self): |
593 | stmt = ( |
594 | - "SELECT 1 FROM pg_locks" |
595 | - " WHERE classid = %s AND objid = %s AND granted" |
596 | + "SELECT 1 FROM pg_locks, pg_database" |
597 | + " WHERE pg_locks.locktype = 'advisory'" |
598 | + " AND pg_locks.classid = %s" |
599 | + " AND pg_locks.objid = %s" |
600 | + # objsubid is 2 when using the 2-argument version of the |
601 | + # pg_advisory_* locking functions. |
602 | + " AND pg_locks.objsubid = 2" |
603 | + " AND pg_locks.granted" |
604 | + # Advisory locks are local to each database so we join to |
605 | + # pg_databases to discover the OID of the currrent database. |
606 | + " AND pg_locks.database = pg_database.oid" |
607 | + " AND pg_database.datname = current_database()" |
608 | ) |
609 | with closing(connection.cursor()) as cursor: |
610 | cursor.execute(stmt, self) |
611 | |
612 | === modified file 'src/maasserver/utils/tests/test_dblocks.py' |
613 | --- src/maasserver/utils/tests/test_dblocks.py 2014-03-28 10:43:53 +0000 |
614 | +++ src/maasserver/utils/tests/test_dblocks.py 2014-08-29 19:22:35 +0000 |
615 | @@ -16,7 +16,10 @@ |
616 | |
617 | from contextlib import closing |
618 | |
619 | -from django.db import connection |
620 | +from django.db import ( |
621 | + connection, |
622 | + transaction, |
623 | + ) |
624 | from maasserver.utils import dblocks |
625 | from maastesting.testcase import MAASTestCase |
626 | |
627 | @@ -40,6 +43,7 @@ |
628 | lock = dblocks.DatabaseLock(self.getUniqueInteger()) |
629 | self.assertEqual(lock, (lock.classid, lock.objid)) |
630 | |
631 | + @transaction.atomic |
632 | def test_lock_actually_locked(self): |
633 | objid = self.getUniqueInteger() |
634 | lock = dblocks.DatabaseLock(objid) |
635 | @@ -55,6 +59,7 @@ |
636 | locks_released = locks_held - locks_held_after |
637 | self.assertEqual({objid}, locks_released) |
638 | |
639 | + @transaction.atomic |
640 | def test_is_locked(self): |
641 | objid = self.getUniqueInteger() |
642 | lock = dblocks.DatabaseLock(objid) |
643 | @@ -64,6 +69,18 @@ |
644 | self.assertTrue(lock.is_locked()) |
645 | self.assertFalse(lock.is_locked()) |
646 | |
647 | + def test_obtaining_lock_fails_when_outside_of_transaction(self): |
648 | + objid = self.getUniqueInteger() |
649 | + lock = dblocks.DatabaseLock(objid) |
650 | + self.assertRaises( |
651 | + dblocks.DatabaseLockAttemptOutsideTransaction, |
652 | + lock.__enter__) |
653 | + |
654 | + def test_releasing_lock_fails_when_lock_not_held(self): |
655 | + objid = self.getUniqueInteger() |
656 | + lock = dblocks.DatabaseLock(objid) |
657 | + self.assertRaises(dblocks.DatabaseLockNotHeld, lock.__exit__) |
658 | + |
659 | def test_repr(self): |
660 | lock = dblocks.DatabaseLock(self.getUniqueInteger()) |
661 | self.assertEqual( |
662 | |
663 | === modified file 'src/maastesting/factory.py' |
664 | --- src/maastesting/factory.py 2014-03-28 10:43:53 +0000 |
665 | +++ src/maastesting/factory.py 2014-08-29 19:22:35 +0000 |
666 | @@ -35,6 +35,7 @@ |
667 | from uuid import uuid1 |
668 | |
669 | from maastesting.fixtures import TempDirectory |
670 | +import mock |
671 | from netaddr import ( |
672 | IPAddress, |
673 | IPNetwork, |
674 | @@ -264,6 +265,14 @@ |
675 | |
676 | return tarball |
677 | |
678 | + def make_streams(self, stdin=None, stdout=None, stderr=None): |
679 | + """Make a fake return value for a SSHClient.exec_command.""" |
680 | + # stdout.read() is called so stdout can't be None. |
681 | + if stdout is None: |
682 | + stdout = mock.Mock() |
683 | + |
684 | + return (stdin, stdout, stderr) |
685 | + |
686 | |
687 | # Create factory singleton. |
688 | factory = Factory() |
689 | |
690 | === modified file 'src/metadataserver/address.py' |
691 | --- src/metadataserver/address.py 2013-10-10 17:07:51 +0000 |
692 | +++ src/metadataserver/address.py 2014-08-29 19:22:35 +0000 |
693 | @@ -63,13 +63,13 @@ |
694 | """ |
695 | route_lines = list(ip_route_output) |
696 | for line in route_lines: |
697 | - match = re.match('default\s+.*\sdev\s+(\w+)', line) |
698 | + match = re.match('default\s+.*\sdev\s+([^\s]+)', line) |
699 | if match is not None: |
700 | return match.groups()[0] |
701 | |
702 | # Still nothing? Try the first recognizable interface in the list. |
703 | for line in route_lines: |
704 | - match = re.match('\s*(?:\S+\s+)*dev\s+(\w+)', line) |
705 | + match = re.match('\s*(?:\S+\s+)*dev\s+([^\s]+)', line) |
706 | if match is not None: |
707 | return match.groups()[0] |
708 | return None |
709 | |
710 | === modified file 'src/metadataserver/tests/test_address.py' |
711 | --- src/metadataserver/tests/test_address.py 2013-10-10 17:07:51 +0000 |
712 | +++ src/metadataserver/tests/test_address.py 2014-08-29 19:22:35 +0000 |
713 | @@ -59,6 +59,26 @@ |
714 | self.assertEqual( |
715 | 'eth1', address.find_default_interface(sample_ip_route)) |
716 | |
717 | + def test_find_default_interface_finds_default_tagged_interface(self): |
718 | + sample_ip_route = [ |
719 | + "default via 10.20.64.1 dev eth0.2", |
720 | + "10.14.0.0/16 dev br0 proto kernel scope link src 10.14.4.1", |
721 | + "10.90.90.0/24 dev br0 proto kernel scope link src 10.90.90.1", |
722 | + "169.254.0.0/16 dev br0 scope link metric 1000", |
723 | + ] |
724 | + self.assertEqual( |
725 | + 'eth0.2', address.find_default_interface(sample_ip_route)) |
726 | + |
727 | + def test_find_default_interface_finds_default_aliased_interface(self): |
728 | + sample_ip_route = [ |
729 | + "default via 10.20.64.1 dev eth0:2", |
730 | + "10.14.0.0/16 dev br0 proto kernel scope link src 10.14.4.1", |
731 | + "10.90.90.0/24 dev br0 proto kernel scope link src 10.90.90.1", |
732 | + "169.254.0.0/16 dev br0 scope link metric 1000", |
733 | + ] |
734 | + self.assertEqual( |
735 | + 'eth0:2', address.find_default_interface(sample_ip_route)) |
736 | + |
737 | def test_find_default_interface_makes_a_guess_if_no_default(self): |
738 | sample_ip_route = [ |
739 | "10.0.0.0/24 dev eth2 proto kernel scope link src 10.0.0.11 " |
740 | @@ -69,6 +89,26 @@ |
741 | self.assertEqual( |
742 | 'eth2', address.find_default_interface(sample_ip_route)) |
743 | |
744 | + def test_find_default_tagged_interface_makes_a_guess_if_no_default(self): |
745 | + sample_ip_route = [ |
746 | + "10.0.0.0/24 dev eth2.4 proto kernel scope link src 10.0.0.11 " |
747 | + "metric 2", |
748 | + "10.1.0.0/24 dev virbr0 proto kernel scope link src 10.1.0.1", |
749 | + "10.1.1.0/24 dev virbr1 proto kernel scope link src 10.1.1.1", |
750 | + ] |
751 | + self.assertEqual( |
752 | + 'eth2.4', address.find_default_interface(sample_ip_route)) |
753 | + |
754 | + def test_find_default_aliased_interface_makes_a_guess_if_no_default(self): |
755 | + sample_ip_route = [ |
756 | + "10.0.0.0/24 dev eth2:4 proto kernel scope link src 10.0.0.11 " |
757 | + "metric 2", |
758 | + "10.1.0.0/24 dev virbr0 proto kernel scope link src 10.1.0.1", |
759 | + "10.1.1.0/24 dev virbr1 proto kernel scope link src 10.1.1.1", |
760 | + ] |
761 | + self.assertEqual( |
762 | + 'eth2:4', address.find_default_interface(sample_ip_route)) |
763 | + |
764 | def test_find_default_interface_returns_None_on_failure(self): |
765 | self.assertIsNone(address.find_default_interface([])) |
766 | |
767 | |
768 | === modified file 'src/provisioningserver/boot/__init__.py' |
769 | --- src/provisioningserver/boot/__init__.py 2014-06-02 11:57:58 +0000 |
770 | +++ src/provisioningserver/boot/__init__.py 2014-08-29 19:22:35 +0000 |
771 | @@ -99,6 +99,10 @@ |
772 | |
773 | __metaclass__ = ABCMeta |
774 | |
775 | + # Path prefix that is used for the pxelinux.cfg. Used for |
776 | + # the dhcpd.conf that is generated. |
777 | + path_prefix = None |
778 | + |
779 | @abstractproperty |
780 | def name(self): |
781 | """Name of the boot method.""" |
782 | @@ -223,12 +227,14 @@ |
783 | from provisioningserver.boot.pxe import PXEBootMethod |
784 | from provisioningserver.boot.uefi import UEFIBootMethod |
785 | from provisioningserver.boot.powerkvm import PowerKVMBootMethod |
786 | +from provisioningserver.boot.powernv import PowerNVBootMethod |
787 | |
788 | |
789 | builtin_boot_methods = [ |
790 | PXEBootMethod(), |
791 | UEFIBootMethod(), |
792 | PowerKVMBootMethod(), |
793 | + PowerNVBootMethod(), |
794 | ] |
795 | for method in builtin_boot_methods: |
796 | BootMethodRegistry.register_item(method.name, method) |
797 | |
798 | === added file 'src/provisioningserver/boot/powernv.py' |
799 | --- src/provisioningserver/boot/powernv.py 1970-01-01 00:00:00 +0000 |
800 | +++ src/provisioningserver/boot/powernv.py 2014-08-29 19:22:35 +0000 |
801 | @@ -0,0 +1,158 @@ |
802 | +# Copyright 2014 Canonical Ltd. This software is licensed under the |
803 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
804 | + |
805 | +"""PowerNV Boot Method""" |
806 | + |
807 | +from __future__ import ( |
808 | + absolute_import, |
809 | + print_function, |
810 | + unicode_literals, |
811 | + ) |
812 | + |
813 | +str = None |
814 | + |
815 | +__metaclass__ = type |
816 | +__all__ = [ |
817 | + 'PowerNVBootMethod', |
818 | + ] |
819 | + |
820 | +import re |
821 | + |
822 | +from provisioningserver.boot import ( |
823 | + BootMethod, |
824 | + BytesReader, |
825 | + get_parameters, |
826 | + ) |
827 | +from provisioningserver.boot.pxe import ( |
828 | + ARP_HTYPE, |
829 | + re_mac_address, |
830 | + ) |
831 | +from provisioningserver.kernel_opts import compose_kernel_command_line |
832 | +from provisioningserver.utils import find_mac_via_arp |
833 | +from tftp.backend import FilesystemReader |
834 | +from twisted.python.context import get |
835 | + |
836 | +# The pxelinux.cfg path is prefixed with the architecture for the |
837 | +# PowerNV nodes. This prefix is set by the path-prefix dhcpd option. |
838 | +# We assume that the ARP HTYPE (hardware type) that PXELINUX sends is |
839 | +# always Ethernet. |
840 | +re_config_file = r''' |
841 | + # Optional leading slash(es). |
842 | + ^/* |
843 | + ppc64el # PowerNV pxe prefix, set by dhcpd |
844 | + / |
845 | + pxelinux[.]cfg # PXELINUX expects this. |
846 | + / |
847 | + (?: # either a MAC |
848 | + {htype:02x} # ARP HTYPE. |
849 | + - |
850 | + (?P<mac>{re_mac_address.pattern}) # Capture MAC. |
851 | + | # or "default" |
852 | + default |
853 | + ) |
854 | + $ |
855 | +''' |
856 | + |
857 | +re_config_file = re_config_file.format( |
858 | + htype=ARP_HTYPE.ETHERNET, re_mac_address=re_mac_address) |
859 | +re_config_file = re.compile(re_config_file, re.VERBOSE) |
860 | + |
861 | + |
862 | +def format_bootif(mac): |
863 | + """Formats a mac address into the BOOTIF format, expected by |
864 | + the linux kernel.""" |
865 | + mac = mac.replace(':', '-') |
866 | + mac = mac.upper() |
867 | + return '%02x-%s' % (ARP_HTYPE.ETHERNET, mac) |
868 | + |
869 | + |
870 | +class PowerNVBootMethod(BootMethod): |
871 | + |
872 | + name = "powernv" |
873 | + template_subdir = "pxe" |
874 | + bootloader_path = "pxelinux.0" |
875 | + arch_octet = "00:0E" |
876 | + path_prefix = "ppc64el/" |
877 | + |
878 | + def get_remote_mac(self): |
879 | + """Gets the requestors MAC address from arp cache. |
880 | + |
881 | + This is used, when the pxelinux.cfg is requested without the mac |
882 | + address appended. This is needed to inject the BOOTIF into the |
883 | + pxelinux.cfg that is returned to the node. |
884 | + """ |
885 | + remote_host, remote_port = get("remote", (None, None)) |
886 | + return find_mac_via_arp(remote_host) |
887 | + |
888 | + def get_params(self, backend, path): |
889 | + """Gets the matching parameters from the requested path.""" |
890 | + match = re_config_file.match(path) |
891 | + if match is not None: |
892 | + return get_parameters(match) |
893 | + if path.lstrip('/').startswith(self.path_prefix): |
894 | + return {'path': path} |
895 | + return None |
896 | + |
897 | + def match_path(self, backend, path): |
898 | + """Checks path for the configuration file that needs to be |
899 | + generated. |
900 | + |
901 | + :param backend: requesting backend |
902 | + :param path: requested path |
903 | + :returns: dict of match params from path, None if no match |
904 | + """ |
905 | + params = self.get_params(backend, path) |
906 | + if params is None: |
907 | + return None |
908 | + params['arch'] = "ppc64el" |
909 | + if 'mac' not in params: |
910 | + mac = self.get_remote_mac() |
911 | + if mac is not None: |
912 | + params['mac'] = mac |
913 | + return params |
914 | + |
915 | + def get_reader(self, backend, kernel_params, **extra): |
916 | + """Render a configuration file as a unicode string. |
917 | + |
918 | + :param backend: requesting backend |
919 | + :param kernel_params: An instance of `KernelParameters`. |
920 | + :param extra: Allow for other arguments. This is a safety valve; |
921 | + parameters generated in another component (for example, see |
922 | + `TFTPBackend.get_config_reader`) won't cause this to break. |
923 | + """ |
924 | + # Due to the path prefix, all requested files from the client will |
925 | + # contain that prefix. Removing the prefix from the path will return |
926 | + # the correct path in the tftp root. |
927 | + if 'path' in extra: |
928 | + path = extra['path'] |
929 | + path = path.replace(self.path_prefix, '', 1) |
930 | + target_path = backend.base.descendant(path.split('/')) |
931 | + return FilesystemReader(target_path) |
932 | + |
933 | + # Return empty config for PowerNV local. PowerNV fails to |
934 | + # support the LOCALBOOT flag. Empty config will allow it |
935 | + # to select the first device. |
936 | + if kernel_params.purpose == 'local': |
937 | + return BytesReader("".encode("utf-8")) |
938 | + |
939 | + template = self.get_template( |
940 | + kernel_params.purpose, kernel_params.arch, |
941 | + kernel_params.subarch) |
942 | + namespace = self.compose_template_namespace(kernel_params) |
943 | + |
944 | + # Modify the kernel_command to inject the BOOTIF. PowerNV fails to |
945 | + # support the IPAPPEND pxelinux flag. |
946 | + def kernel_command(params): |
947 | + cmd_line = compose_kernel_command_line(params) |
948 | + if 'mac' in extra: |
949 | + mac = extra['mac'] |
950 | + mac = format_bootif(mac) |
951 | + return '%s BOOTIF=%s' % (cmd_line, mac) |
952 | + return cmd_line |
953 | + |
954 | + namespace['kernel_command'] = kernel_command |
955 | + return BytesReader(template.substitute(namespace).encode("utf-8")) |
956 | + |
957 | + def install_bootloader(self, destination): |
958 | + """Does nothing. No extra boot files are required. All of the boot |
959 | + files from PXEBootMethod will suffice.""" |
960 | |
961 | === added file 'src/provisioningserver/boot/tests/test_powernv.py' |
962 | --- src/provisioningserver/boot/tests/test_powernv.py 1970-01-01 00:00:00 +0000 |
963 | +++ src/provisioningserver/boot/tests/test_powernv.py 2014-08-29 19:22:35 +0000 |
964 | @@ -0,0 +1,337 @@ |
965 | +# Copyright 2014 Canonical Ltd. This software is licensed under the |
966 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
967 | + |
968 | +"""Tests for `provisioningserver.boot.powernv`.""" |
969 | + |
970 | +from __future__ import ( |
971 | + absolute_import, |
972 | + print_function, |
973 | + unicode_literals, |
974 | + ) |
975 | + |
976 | +str = None |
977 | + |
978 | +__metaclass__ = type |
979 | +__all__ = [] |
980 | + |
981 | +import os |
982 | +import re |
983 | + |
984 | +from maastesting.factory import factory |
985 | +from maastesting.testcase import MAASTestCase |
986 | +from provisioningserver.boot import BytesReader |
987 | +from provisioningserver.boot.powernv import ( |
988 | + ARP_HTYPE, |
989 | + format_bootif, |
990 | + PowerNVBootMethod, |
991 | + re_config_file, |
992 | + ) |
993 | +from provisioningserver.boot.tests.test_pxe import parse_pxe_config |
994 | +from provisioningserver.boot.tftppath import compose_image_path |
995 | +from provisioningserver.testing.config import set_tftp_root |
996 | +from provisioningserver.tests.test_kernel_opts import make_kernel_parameters |
997 | +from provisioningserver.tftp import TFTPBackend |
998 | +from testtools.matchers import ( |
999 | + IsInstance, |
1000 | + MatchesAll, |
1001 | + MatchesRegex, |
1002 | + Not, |
1003 | + StartsWith, |
1004 | + ) |
1005 | + |
1006 | + |
1007 | +def compose_config_path(mac): |
1008 | + """Compose the TFTP path for a PowerNV PXE configuration file. |
1009 | + |
1010 | + The path returned is relative to the TFTP root, as it would be |
1011 | + identified by clients on the network. |
1012 | + |
1013 | + :param mac: A MAC address, in IEEE 802 hyphen-separated form, |
1014 | + corresponding to the machine for which this configuration is |
1015 | + relevant. This relates to PXELINUX's lookup protocol. |
1016 | + :return: Path for the corresponding PXE config file as exposed over |
1017 | + TFTP. |
1018 | + """ |
1019 | + # Not using os.path.join: this is a TFTP path, not a native path. Yes, in |
1020 | + # practice for us they're the same. We always assume that the ARP HTYPE |
1021 | + # (hardware type) that PXELINUX sends is Ethernet. |
1022 | + return "ppc64el/pxelinux.cfg/{htype:02x}-{mac}".format( |
1023 | + htype=ARP_HTYPE.ETHERNET, mac=mac) |
1024 | + |
1025 | + |
1026 | +def get_example_path_and_components(): |
1027 | + """Return a plausible path and its components. |
1028 | + |
1029 | + The path is intended to match `re_config_file`, and the components are |
1030 | + the expected groups from a match. |
1031 | + """ |
1032 | + components = {"mac": factory.getRandomMACAddress("-")} |
1033 | + config_path = compose_config_path(components["mac"]) |
1034 | + return config_path, components |
1035 | + |
1036 | + |
1037 | +class TestPowerNVBootMethod(MAASTestCase): |
1038 | + |
1039 | + def make_tftp_root(self): |
1040 | + """Set, and return, a temporary TFTP root directory.""" |
1041 | + tftproot = self.make_dir() |
1042 | + self.useFixture(set_tftp_root(tftproot)) |
1043 | + return tftproot |
1044 | + |
1045 | + def test_compose_config_path_follows_maas_pxe_directory_layout(self): |
1046 | + name = factory.make_name('config') |
1047 | + self.assertEqual( |
1048 | + 'ppc64el/pxelinux.cfg/%02x-%s' % (ARP_HTYPE.ETHERNET, name), |
1049 | + compose_config_path(name)) |
1050 | + |
1051 | + def test_compose_config_path_does_not_include_tftp_root(self): |
1052 | + tftproot = self.make_tftp_root() |
1053 | + name = factory.make_name('config') |
1054 | + self.assertThat( |
1055 | + compose_config_path(name), |
1056 | + Not(StartsWith(tftproot))) |
1057 | + |
1058 | + def test_bootloader_path(self): |
1059 | + method = PowerNVBootMethod() |
1060 | + self.assertEqual('pxelinux.0', method.bootloader_path) |
1061 | + |
1062 | + def test_bootloader_path_does_not_include_tftp_root(self): |
1063 | + tftproot = self.make_tftp_root() |
1064 | + method = PowerNVBootMethod() |
1065 | + self.assertThat( |
1066 | + method.bootloader_path, |
1067 | + Not(StartsWith(tftproot))) |
1068 | + |
1069 | + def test_name(self): |
1070 | + method = PowerNVBootMethod() |
1071 | + self.assertEqual('powernv', method.name) |
1072 | + |
1073 | + def test_template_subdir(self): |
1074 | + method = PowerNVBootMethod() |
1075 | + self.assertEqual('pxe', method.template_subdir) |
1076 | + |
1077 | + def test_arch_octet(self): |
1078 | + method = PowerNVBootMethod() |
1079 | + self.assertEqual('00:0E', method.arch_octet) |
1080 | + |
1081 | + def test_path_prefix(self): |
1082 | + method = PowerNVBootMethod() |
1083 | + self.assertEqual('ppc64el/', method.path_prefix) |
1084 | + |
1085 | + |
1086 | +class TestPowerNVBootMethodMatchPath(MAASTestCase): |
1087 | + """Tests for |
1088 | + `provisioningserver.boot.powernv.PowerNVBootMethod.match_path`. |
1089 | + """ |
1090 | + |
1091 | + def test_match_path_pxe_config_with_mac(self): |
1092 | + method = PowerNVBootMethod() |
1093 | + config_path, expected = get_example_path_and_components() |
1094 | + params = method.match_path(None, config_path) |
1095 | + expected['arch'] = 'ppc64el' |
1096 | + self.assertEqual(expected, params) |
1097 | + |
1098 | + def test_match_path_pxe_config_without_mac(self): |
1099 | + method = PowerNVBootMethod() |
1100 | + fake_mac = factory.getRandomMACAddress() |
1101 | + self.patch(method, 'get_remote_mac').return_value = fake_mac |
1102 | + config_path = 'ppc64el/pxelinux.cfg/default' |
1103 | + params = method.match_path(None, config_path) |
1104 | + expected = { |
1105 | + 'arch': 'ppc64el', |
1106 | + 'mac': fake_mac, |
1107 | + } |
1108 | + self.assertEqual(expected, params) |
1109 | + |
1110 | + def test_match_path_pxe_prefix_request(self): |
1111 | + method = PowerNVBootMethod() |
1112 | + fake_mac = factory.getRandomMACAddress() |
1113 | + self.patch(method, 'get_remote_mac').return_value = fake_mac |
1114 | + file_path = 'ppc64el/file' |
1115 | + params = method.match_path(None, file_path) |
1116 | + expected = { |
1117 | + 'arch': 'ppc64el', |
1118 | + 'mac': fake_mac, |
1119 | + 'path': file_path, |
1120 | + } |
1121 | + self.assertEqual(expected, params) |
1122 | + |
1123 | + |
1124 | +class TestPowerNVBootMethodRenderConfig(MAASTestCase): |
1125 | + """Tests for |
1126 | + `provisioningserver.boot.powernv.PowerNVBootMethod.get_reader` |
1127 | + """ |
1128 | + |
1129 | + def test_get_reader_install(self): |
1130 | + # Given the right configuration options, the PXE configuration is |
1131 | + # correctly rendered. |
1132 | + method = PowerNVBootMethod() |
1133 | + params = make_kernel_parameters(self, purpose="install") |
1134 | + output = method.get_reader(backend=None, kernel_params=params) |
1135 | + # The output is a BytesReader. |
1136 | + self.assertThat(output, IsInstance(BytesReader)) |
1137 | + output = output.read(10000) |
1138 | + # The template has rendered without error. PXELINUX configurations |
1139 | + # typically start with a DEFAULT line. |
1140 | + self.assertThat(output, StartsWith("DEFAULT ")) |
1141 | + # The PXE parameters are all set according to the options. |
1142 | + image_dir = compose_image_path( |
1143 | + arch=params.arch, subarch=params.subarch, |
1144 | + release=params.release, label=params.label) |
1145 | + self.assertThat( |
1146 | + output, MatchesAll( |
1147 | + MatchesRegex( |
1148 | + r'.*^\s+KERNEL %s/di-kernel$' % re.escape(image_dir), |
1149 | + re.MULTILINE | re.DOTALL), |
1150 | + MatchesRegex( |
1151 | + r'.*^\s+INITRD %s/di-initrd$' % re.escape(image_dir), |
1152 | + re.MULTILINE | re.DOTALL), |
1153 | + MatchesRegex( |
1154 | + r'.*^\s+APPEND .+?$', |
1155 | + re.MULTILINE | re.DOTALL))) |
1156 | + |
1157 | + def test_get_reader_with_extra_arguments_does_not_affect_output(self): |
1158 | + # get_reader() allows any keyword arguments as a safety valve. |
1159 | + method = PowerNVBootMethod() |
1160 | + options = { |
1161 | + "backend": None, |
1162 | + "kernel_params": make_kernel_parameters(self, purpose="install"), |
1163 | + } |
1164 | + # Capture the output before sprinking in some random options. |
1165 | + output_before = method.get_reader(**options).read(10000) |
1166 | + # Sprinkle some magic in. |
1167 | + options.update( |
1168 | + (factory.make_name("name"), factory.make_name("value")) |
1169 | + for _ in range(10)) |
1170 | + # Capture the output after sprinking in some random options. |
1171 | + output_after = method.get_reader(**options).read(10000) |
1172 | + # The generated template is the same. |
1173 | + self.assertEqual(output_before, output_after) |
1174 | + |
1175 | + def test_get_reader_with_local_purpose(self): |
1176 | + # If purpose is "local", output should be empty string. |
1177 | + method = PowerNVBootMethod() |
1178 | + options = { |
1179 | + "backend": None, |
1180 | + "kernel_params": make_kernel_parameters(purpose="local"), |
1181 | + } |
1182 | + output = method.get_reader(**options).read(10000) |
1183 | + self.assertIn("", output) |
1184 | + |
1185 | + def test_get_reader_appends_bootif(self): |
1186 | + method = PowerNVBootMethod() |
1187 | + fake_mac = factory.getRandomMACAddress() |
1188 | + params = make_kernel_parameters(self, purpose="install") |
1189 | + output = method.get_reader( |
1190 | + backend=None, kernel_params=params, arch='ppc64el', mac=fake_mac) |
1191 | + output = output.read(10000) |
1192 | + config = parse_pxe_config(output) |
1193 | + expected = 'BOOTIF=%s' % format_bootif(fake_mac) |
1194 | + self.assertIn(expected, config['execute']['APPEND']) |
1195 | + |
1196 | + |
1197 | +class TestPowerNVBootMethodPathPrefix(MAASTestCase): |
1198 | + """Tests for |
1199 | + `provisioningserver.boot.powernv.PowerNVBootMethod.get_reader`. |
1200 | + """ |
1201 | + |
1202 | + def test_get_reader_path_prefix(self): |
1203 | + data = factory.getRandomString().encode("ascii") |
1204 | + temp_file = self.make_file(name="example", contents=data) |
1205 | + temp_dir = os.path.dirname(temp_file) |
1206 | + backend = TFTPBackend(temp_dir, "http://nowhere.example.com/") |
1207 | + method = PowerNVBootMethod() |
1208 | + options = { |
1209 | + 'backend': backend, |
1210 | + 'kernel_params': make_kernel_parameters(), |
1211 | + 'path': 'ppc64el/example', |
1212 | + } |
1213 | + reader = method.get_reader(**options) |
1214 | + self.addCleanup(reader.finish) |
1215 | + self.assertEqual(len(data), reader.size) |
1216 | + self.assertEqual(data, reader.read(len(data))) |
1217 | + self.assertEqual(b"", reader.read(1)) |
1218 | + |
1219 | + def test_get_reader_path_prefix_only_removes_first_occurrence(self): |
1220 | + data = factory.getRandomString().encode("ascii") |
1221 | + temp_dir = self.make_dir() |
1222 | + temp_subdir = os.path.join(temp_dir, 'ppc64el') |
1223 | + os.mkdir(temp_subdir) |
1224 | + factory.make_file(temp_subdir, "example", data) |
1225 | + backend = TFTPBackend(temp_dir, "http://nowhere.example.com/") |
1226 | + method = PowerNVBootMethod() |
1227 | + options = { |
1228 | + 'backend': backend, |
1229 | + 'kernel_params': make_kernel_parameters(), |
1230 | + 'path': 'ppc64el/ppc64el/example', |
1231 | + } |
1232 | + reader = method.get_reader(**options) |
1233 | + self.addCleanup(reader.finish) |
1234 | + self.assertEqual(len(data), reader.size) |
1235 | + self.assertEqual(data, reader.read(len(data))) |
1236 | + self.assertEqual(b"", reader.read(1)) |
1237 | + |
1238 | + |
1239 | +class TestPowerNVBootMethodRegex(MAASTestCase): |
1240 | + """Tests for |
1241 | + `provisioningserver.boot.powernv.PowerNVBootMethod.re_config_file`. |
1242 | + """ |
1243 | + |
1244 | + def test_re_config_file_is_compatible_with_config_path_generator(self): |
1245 | + # The regular expression for extracting components of the file path is |
1246 | + # compatible with the PXE config path generator. |
1247 | + for iteration in range(10): |
1248 | + config_path, args = get_example_path_and_components() |
1249 | + match = re_config_file.match(config_path) |
1250 | + self.assertIsNotNone(match, config_path) |
1251 | + self.assertEqual(args, match.groupdict()) |
1252 | + |
1253 | + def test_re_config_file_with_leading_slash(self): |
1254 | + # The regular expression for extracting components of the file path |
1255 | + # doesn't care if there's a leading forward slash; the TFTP server is |
1256 | + # easy on this point, so it makes sense to be also. |
1257 | + config_path, args = get_example_path_and_components() |
1258 | + # Ensure there's a leading slash. |
1259 | + config_path = "/" + config_path.lstrip("/") |
1260 | + match = re_config_file.match(config_path) |
1261 | + self.assertIsNotNone(match, config_path) |
1262 | + self.assertEqual(args, match.groupdict()) |
1263 | + |
1264 | + def test_re_config_file_without_leading_slash(self): |
1265 | + # The regular expression for extracting components of the file path |
1266 | + # doesn't care if there's no leading forward slash; the TFTP server is |
1267 | + # easy on this point, so it makes sense to be also. |
1268 | + config_path, args = get_example_path_and_components() |
1269 | + # Ensure there's no leading slash. |
1270 | + config_path = config_path.lstrip("/") |
1271 | + match = re_config_file.match(config_path) |
1272 | + self.assertIsNotNone(match, config_path) |
1273 | + self.assertEqual(args, match.groupdict()) |
1274 | + |
1275 | + def test_re_config_file_matches_classic_pxelinux_cfg(self): |
1276 | + # The default config path is simply "pxelinux.cfg" (without |
1277 | + # leading slash). The regex matches this. |
1278 | + mac = 'aa-bb-cc-dd-ee-ff' |
1279 | + match = re_config_file.match('ppc64el/pxelinux.cfg/01-%s' % mac) |
1280 | + self.assertIsNotNone(match) |
1281 | + self.assertEqual({'mac': mac}, match.groupdict()) |
1282 | + |
1283 | + def test_re_config_file_matches_pxelinux_cfg_with_leading_slash(self): |
1284 | + mac = 'aa-bb-cc-dd-ee-ff' |
1285 | + match = re_config_file.match('/ppc64el/pxelinux.cfg/01-%s' % mac) |
1286 | + self.assertIsNotNone(match) |
1287 | + self.assertEqual({'mac': mac}, match.groupdict()) |
1288 | + |
1289 | + def test_re_config_file_does_not_match_non_config_file(self): |
1290 | + self.assertIsNone(re_config_file.match('ppc64el/pxelinux.cfg/kernel')) |
1291 | + |
1292 | + def test_re_config_file_does_not_match_file_in_root(self): |
1293 | + self.assertIsNone(re_config_file.match('01-aa-bb-cc-dd-ee-ff')) |
1294 | + |
1295 | + def test_re_config_file_does_not_match_file_not_in_pxelinux_cfg(self): |
1296 | + self.assertIsNone(re_config_file.match('foo/01-aa-bb-cc-dd-ee-ff')) |
1297 | + |
1298 | + def test_re_config_file_with_default(self): |
1299 | + match = re_config_file.match('ppc64el/pxelinux.cfg/default') |
1300 | + self.assertIsNotNone(match) |
1301 | + self.assertEqual({'mac': None}, match.groupdict()) |
1302 | |
1303 | === modified file 'src/provisioningserver/dhcp/config.py' |
1304 | --- src/provisioningserver/dhcp/config.py 2014-04-03 13:45:02 +0000 |
1305 | +++ src/provisioningserver/dhcp/config.py 2014-08-29 19:22:35 +0000 |
1306 | @@ -33,16 +33,22 @@ |
1307 | |
1308 | # Used to generate the conditional bootloader behaviour |
1309 | CONDITIONAL_BOOTLOADER = """ |
1310 | -{behaviour} option arch = {arch_octet} {{ |
1311 | - filename \"{bootloader}\"; |
1312 | - }} |
1313 | +{{behaviour}} option arch = {{arch_octet}} { |
1314 | + filename \"{{bootloader}}\"; |
1315 | + {{if path_prefix}} |
1316 | + option path-prefix \"{{path_prefix}}\"; |
1317 | + {{endif}} |
1318 | + } |
1319 | """ |
1320 | |
1321 | # Used to generate the PXEBootLoader special case |
1322 | PXE_BOOTLOADER = """ |
1323 | -else {{ |
1324 | - filename \"{bootloader}\"; |
1325 | - }} |
1326 | +else { |
1327 | + filename \"{{bootloader}}\"; |
1328 | + {{if path_prefix}} |
1329 | + option path-prefix \"{{path_prefix}}\"; |
1330 | + {{endif}} |
1331 | + } |
1332 | """ |
1333 | |
1334 | |
1335 | @@ -55,9 +61,13 @@ |
1336 | behaviour = chain(["if"], repeat("elsif")) |
1337 | for name, method in BootMethodRegistry: |
1338 | if name != "pxe": |
1339 | - output += CONDITIONAL_BOOTLOADER.format( |
1340 | - behaviour=next(behaviour), arch_octet=method.arch_octet, |
1341 | - bootloader=method.bootloader_path).strip() + ' ' |
1342 | + output += tempita.sub( |
1343 | + CONDITIONAL_BOOTLOADER, |
1344 | + behaviour=next(behaviour), |
1345 | + arch_octet=method.arch_octet, |
1346 | + bootloader=method.bootloader_path, |
1347 | + path_prefix=method.path_prefix, |
1348 | + ).strip() + ' ' |
1349 | |
1350 | # The PXEBootMethod is used in an else statement for the generated |
1351 | # dhcpd config. This ensures that a booting node that does not |
1352 | @@ -65,8 +75,11 @@ |
1353 | # pxelinux can still boot. |
1354 | pxe_method = BootMethodRegistry.get_item('pxe') |
1355 | if pxe_method is not None: |
1356 | - output += PXE_BOOTLOADER.format( |
1357 | - bootloader=pxe_method.bootloader_path).strip() |
1358 | + output += tempita.sub( |
1359 | + PXE_BOOTLOADER, |
1360 | + bootloader=pxe_method.bootloader_path, |
1361 | + path_prefix=pxe_method.path_prefix, |
1362 | + ).strip() |
1363 | return output.strip() |
1364 | |
1365 | |
1366 | |
1367 | === modified file 'src/provisioningserver/driver/__init__.py' |
1368 | --- src/provisioningserver/driver/__init__.py 2014-06-02 11:57:58 +0000 |
1369 | +++ src/provisioningserver/driver/__init__.py 2014-08-29 19:22:35 +0000 |
1370 | @@ -134,12 +134,25 @@ |
1371 | Architecture(name="i386/generic", description="i386"), |
1372 | Architecture(name="amd64/generic", description="amd64"), |
1373 | Architecture( |
1374 | + name="arm64/generic", description="arm64/generic", |
1375 | + pxealiases=["arm"]), |
1376 | + Architecture( |
1377 | + name="arm64/xgene-uboot", description="arm64/xgene-uboot", |
1378 | + pxealiases=["arm"]), |
1379 | + Architecture( |
1380 | name="armhf/highbank", description="armhf/highbank", |
1381 | pxealiases=["arm"], kernel_options=["console=ttyAMA0"]), |
1382 | Architecture( |
1383 | name="armhf/generic", description="armhf/generic", |
1384 | pxealiases=["arm"], kernel_options=["console=ttyAMA0"]), |
1385 | - Architecture(name="ppc64el/generic", description="ppc64el"), |
1386 | + # PPC64EL needs a rootdelay for PowerNV. The disk controller |
1387 | + # in the hardware, takes a little bit longer to come up then |
1388 | + # the initrd wants to wait. Set this to 60 seconds, just to |
1389 | + # give the booting machine enough time. This doesn't slow down |
1390 | + # the booting process, it just increases the timeout. |
1391 | + Architecture( |
1392 | + name="ppc64el/generic", description="ppc64el", |
1393 | + kernel_options=['rootdelay=60']), |
1394 | ] |
1395 | for arch in builtin_architectures: |
1396 | ArchitectureRegistry.register_item(arch.name, arch) |
1397 | |
1398 | === added directory 'src/provisioningserver/drivers' |
1399 | === added file 'src/provisioningserver/drivers/__init__.py' |
1400 | === added directory 'src/provisioningserver/drivers/hardware' |
1401 | === added file 'src/provisioningserver/drivers/hardware/__init__.py' |
1402 | === added file 'src/provisioningserver/drivers/hardware/mscm.py' |
1403 | --- src/provisioningserver/drivers/hardware/mscm.py 1970-01-01 00:00:00 +0000 |
1404 | +++ src/provisioningserver/drivers/hardware/mscm.py 2014-08-29 19:22:35 +0000 |
1405 | @@ -0,0 +1,187 @@ |
1406 | +# Copyright 2014 Canonical Ltd. This software is licensed under the |
1407 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
1408 | + |
1409 | +"""Support for managing nodes via the Moonshot HP iLO Chassis Manager CLI. |
1410 | + |
1411 | +This module provides support for interacting with HP Moonshot iLO Chassis |
1412 | +Management (MSCM) CLI via SSH, and for using that support to allow MAAS to |
1413 | +manage systems via iLO. |
1414 | +""" |
1415 | + |
1416 | +from __future__ import ( |
1417 | + absolute_import, |
1418 | + print_function, |
1419 | + unicode_literals, |
1420 | + ) |
1421 | +str = None |
1422 | + |
1423 | +__metaclass__ = type |
1424 | +__all__ = [ |
1425 | + 'power_control_mscm', |
1426 | + 'probe_and_enlist_mscm', |
1427 | +] |
1428 | + |
1429 | +import re |
1430 | + |
1431 | +from paramiko import ( |
1432 | + AutoAddPolicy, |
1433 | + SSHClient, |
1434 | + ) |
1435 | +import provisioningserver.custom_hardware.utils as utils |
1436 | + |
1437 | + |
1438 | +cartridge_mapping = { |
1439 | + 'ProLiant Moonshot Cartridge': 'amd64/generic', |
1440 | + 'ProLiant m300 Server Cartridge': 'amd64/generic', |
1441 | + 'ProLiant m350 Server Cartridge': 'amd64/generic', |
1442 | + 'ProLiant m400 Server Cartridge': 'arm64/xgene-uboot', |
1443 | + 'ProLiant m500 Server Cartridge': 'amd64/generic', |
1444 | + 'ProLiant m710 Server Cartridge': 'amd64/generic', |
1445 | + 'ProLiant m800 Server Cartridge': 'armhf/keystone', |
1446 | + 'Default': 'arm64/generic', |
1447 | +} |
1448 | + |
1449 | + |
1450 | +class MSCM_CLI_API(object): |
1451 | + """An API for interacting with the Moonshot iLO CM CLI.""" |
1452 | + |
1453 | + def __init__(self, host, username, password): |
1454 | + """MSCM_CLI_API Constructor.""" |
1455 | + self.host = host |
1456 | + self.username = username |
1457 | + self.password = password |
1458 | + self._ssh = SSHClient() |
1459 | + self._ssh.set_missing_host_key_policy(AutoAddPolicy()) |
1460 | + |
1461 | + def _run_cli_command(self, command): |
1462 | + """Run a single command and return unparsed text from stdout.""" |
1463 | + self._ssh.connect( |
1464 | + self.host, username=self.username, password=self.password) |
1465 | + try: |
1466 | + _, stdout, _ = self._ssh.exec_command(command) |
1467 | + output = stdout.read() |
1468 | + finally: |
1469 | + self._ssh.close() |
1470 | + |
1471 | + return output |
1472 | + |
1473 | + def discover_nodes(self): |
1474 | + """Discover all available nodes. |
1475 | + |
1476 | + Example of stdout from running "show node list": |
1477 | + |
1478 | + 'show node list\r\r\nSlot ID Proc Manufacturer |
1479 | + Architecture Memory Power Health\r\n---- |
1480 | + ----- ---------------------- -------------------- |
1481 | + ------ ----- ------\r\n 01 c1n1 Intel Corporation |
1482 | + x86 Architecture 32 GB On OK \r\n 02 c2n1 |
1483 | + N/A No Asset Information \r\n\r\n' |
1484 | + |
1485 | + The regex 'c\d+n\d' is finding the node_id's c1-45n1-8 |
1486 | + """ |
1487 | + node_list = self._run_cli_command("show node list") |
1488 | + return re.findall(r'c\d+n\d', node_list) |
1489 | + |
1490 | + def get_node_macaddr(self, node_id): |
1491 | + """Get node MAC address(es). |
1492 | + |
1493 | + Example of stdout from running "show node macaddr <node_id>": |
1494 | + |
1495 | + 'show node macaddr c1n1\r\r\nSlot ID NIC 1 (Switch A) |
1496 | + NIC 2 (Switch B) NIC 3 (Switch A) NIC 4 (Switch B)\r\n |
1497 | + ---- ----- ----------------- ----------------- ----------------- |
1498 | + -----------------\r\n 1 c1n1 a0:1d:48:b5:04:34 a0:1d:48:b5:04:35 |
1499 | + a0:1d:48:b5:04:36 a0:1d:48:b5:04:37\r\n\r\n\r\n' |
1500 | + |
1501 | + The regex '[\:]'.join(['[0-9A-F]{1,2}'] * 6) is finding |
1502 | + the MAC Addresses for the given node_id. |
1503 | + """ |
1504 | + macs = self._run_cli_command("show node macaddr %s" % node_id) |
1505 | + return re.findall(r':'.join(['[0-9a-f]{2}'] * 6), macs) |
1506 | + |
1507 | + def get_node_arch(self, node_id): |
1508 | + """Get node architecture. |
1509 | + |
1510 | + Example of stdout from running "show node info <node_id>": |
1511 | + |
1512 | + 'show node info c1n1\r\r\n\r\nCartridge #1 \r\n Type: Compute\r\n |
1513 | + Manufacturer: HP\r\n Product Name: ProLiant m500 Server Cartridge\r\n' |
1514 | + |
1515 | + Parsing this retrieves 'ProLiant m500 Server Cartridge' |
1516 | + """ |
1517 | + node_detail = self._run_cli_command("show node info %s" % node_id) |
1518 | + cartridge = node_detail.split('Product Name: ')[1].splitlines()[0] |
1519 | + if cartridge in cartridge_mapping: |
1520 | + return cartridge_mapping[cartridge] |
1521 | + else: |
1522 | + return cartridge_mapping['Default'] |
1523 | + |
1524 | + def get_node_power_status(self, node_id): |
1525 | + """Get power state of node (on/off). |
1526 | + |
1527 | + Example of stdout from running "show node power <node_id>": |
1528 | + |
1529 | + 'show node power c1n1\r\r\n\r\nCartridge #1\r\n Node #1\r\n |
1530 | + Power State: On\r\n' |
1531 | + |
1532 | + Parsing this retrieves 'On' |
1533 | + """ |
1534 | + power_state = self._run_cli_command("show node power %s" % node_id) |
1535 | + return power_state.split('Power State: ')[1].splitlines()[0] |
1536 | + |
1537 | + def power_node_on(self, node_id): |
1538 | + """Power node on.""" |
1539 | + return self._run_cli_command("set node power on %s" % node_id) |
1540 | + |
1541 | + def power_node_off(self, node_id): |
1542 | + """Power node off.""" |
1543 | + return self._run_cli_command("set node power off force %s" % node_id) |
1544 | + |
1545 | + def configure_node_boot_m2(self, node_id): |
1546 | + """Configure HDD boot for node.""" |
1547 | + return self._run_cli_command("set node boot M.2 %s" % node_id) |
1548 | + |
1549 | + def configure_node_bootonce_pxe(self, node_id): |
1550 | + """Configure PXE boot for node once.""" |
1551 | + return self._run_cli_command("set node bootonce pxe %s" % node_id) |
1552 | + |
1553 | + |
1554 | +def power_control_mscm(host, username, password, node_id, power_change): |
1555 | + """Handle calls from the power template for nodes with a power type |
1556 | + of 'mscm'. |
1557 | + """ |
1558 | + mscm = MSCM_CLI_API(host, username, password) |
1559 | + power_status = mscm.get_node_power_status(node_id) |
1560 | + |
1561 | + if power_change == 'off': |
1562 | + mscm.power_node_off(node_id) |
1563 | + return |
1564 | + |
1565 | + if power_change != 'on': |
1566 | + raise AssertionError('Unexpected maas power mode.') |
1567 | + |
1568 | + if power_status == 'On': |
1569 | + mscm.power_node_off(node_id) |
1570 | + |
1571 | + mscm.configure_node_bootonce_pxe(node_id) |
1572 | + mscm.power_node_on(node_id) |
1573 | + |
1574 | + |
1575 | +def probe_and_enlist_mscm(host, username, password): |
1576 | + """ Extracts all of nodes from mscm, sets all of them to boot via HDD by, |
1577 | + default, sets them to bootonce via PXE, and then enlists them into MAAS. |
1578 | + """ |
1579 | + mscm = MSCM_CLI_API(host, username, password) |
1580 | + nodes = mscm.discover_nodes() |
1581 | + for node_id in nodes: |
1582 | + # Set default boot to HDD |
1583 | + mscm.configure_node_boot_m2(node_id) |
1584 | + params = { |
1585 | + 'power_address': host, |
1586 | + 'power_user': username, |
1587 | + 'power_pass': password, |
1588 | + 'node_id': node_id, |
1589 | + } |
1590 | + arch = mscm.get_node_arch(node_id) |
1591 | + macs = mscm.get_node_macaddr(node_id) |
1592 | + utils.create_node(macs, arch, 'mscm', params) |
1593 | |
1594 | === added directory 'src/provisioningserver/drivers/hardware/tests' |
1595 | === added file 'src/provisioningserver/drivers/hardware/tests/test_mscm.py' |
1596 | --- src/provisioningserver/drivers/hardware/tests/test_mscm.py 1970-01-01 00:00:00 +0000 |
1597 | +++ src/provisioningserver/drivers/hardware/tests/test_mscm.py 2014-08-29 19:22:35 +0000 |
1598 | @@ -0,0 +1,259 @@ |
1599 | +# Copyright 2014 Canonical Ltd. This software is licensed under the |
1600 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
1601 | + |
1602 | +"""Tests for ``provisioningserver.drivers.hardware.mscm``.""" |
1603 | + |
1604 | +from __future__ import ( |
1605 | + absolute_import, |
1606 | + print_function, |
1607 | + unicode_literals, |
1608 | + ) |
1609 | + |
1610 | +str = None |
1611 | + |
1612 | +__metaclass__ = type |
1613 | +__all__ = [] |
1614 | + |
1615 | +from random import randint |
1616 | +import re |
1617 | +from StringIO import StringIO |
1618 | + |
1619 | +from maastesting.factory import factory |
1620 | +from maastesting.matchers import MockCalledOnceWith |
1621 | +from maastesting.testcase import MAASTestCase |
1622 | +from mock import Mock |
1623 | +from provisioningserver.drivers.hardware.mscm import ( |
1624 | + cartridge_mapping, |
1625 | + MSCM_CLI_API, |
1626 | + power_control_mscm, |
1627 | + probe_and_enlist_mscm, |
1628 | + ) |
1629 | +import provisioningserver.custom_hardware.utils as utils |
1630 | + |
1631 | + |
1632 | +def make_mscm_api(): |
1633 | + """Make a MSCM_CLI_API object with randomized parameters.""" |
1634 | + host = factory.make_hostname('mscm') |
1635 | + username = factory.make_name('user') |
1636 | + password = factory.make_name('password') |
1637 | + return MSCM_CLI_API(host, username, password) |
1638 | + |
1639 | + |
1640 | +def make_node_id(): |
1641 | + """Make a node_id.""" |
1642 | + return 'c%sn%s' % (randint(1, 45), randint(1, 8)) |
1643 | + |
1644 | + |
1645 | +def make_show_node_list(length=10): |
1646 | + """Make a fake return value for discover_nodes.""" |
1647 | + return re.findall(r'c\d+n\d', ''.join(make_node_id() |
1648 | + for _ in xrange(length))) |
1649 | + |
1650 | + |
1651 | +def make_show_node_macaddr(length=10): |
1652 | + """Make a fake return value for get_node_macaddr.""" |
1653 | + return ''.join((factory.getRandomMACAddress() + ' ') |
1654 | + for _ in xrange(length)) |
1655 | + |
1656 | + |
1657 | +class TestRunCliCommand(MAASTestCase): |
1658 | + """Tests for ``MSCM_CLI_API.run_cli_command``.""" |
1659 | + |
1660 | + def test_returns_output(self): |
1661 | + api = make_mscm_api() |
1662 | + ssh_mock = self.patch(api, '_ssh') |
1663 | + expected = factory.make_name('output') |
1664 | + stdout = StringIO(expected) |
1665 | + streams = factory.make_streams(stdout=stdout) |
1666 | + ssh_mock.exec_command = Mock(return_value=streams) |
1667 | + output = api._run_cli_command(factory.make_name('command')) |
1668 | + self.assertEqual(expected, output) |
1669 | + |
1670 | + def test_connects_and_closes_ssh_client(self): |
1671 | + api = make_mscm_api() |
1672 | + ssh_mock = self.patch(api, '_ssh') |
1673 | + ssh_mock.exec_command = Mock(return_value=factory.make_streams()) |
1674 | + api._run_cli_command(factory.make_name('command')) |
1675 | + self.assertThat( |
1676 | + ssh_mock.connect, |
1677 | + MockCalledOnceWith( |
1678 | + api.host, username=api.username, password=api.password)) |
1679 | + self.assertThat(ssh_mock.close, MockCalledOnceWith()) |
1680 | + |
1681 | + def test_closes_when_exception_raised(self): |
1682 | + api = make_mscm_api() |
1683 | + ssh_mock = self.patch(api, '_ssh') |
1684 | + |
1685 | + def fail(): |
1686 | + raise Exception('fail') |
1687 | + |
1688 | + ssh_mock.exec_command = Mock(side_effect=fail) |
1689 | + command = factory.make_name('command') |
1690 | + self.assertRaises(Exception, api._run_cli_command, command) |
1691 | + self.assertThat(ssh_mock.close, MockCalledOnceWith()) |
1692 | + |
1693 | + |
1694 | +class TestDiscoverNodes(MAASTestCase): |
1695 | + """Tests for ``MSCM_CLI_API.discover_nodes``.""" |
1696 | + |
1697 | + def test_discover_nodes(self): |
1698 | + api = make_mscm_api() |
1699 | + ssh_mock = self.patch(api, '_ssh') |
1700 | + expected = make_show_node_list() |
1701 | + stdout = StringIO(expected) |
1702 | + streams = factory.make_streams(stdout=stdout) |
1703 | + ssh_mock.exec_command = Mock(return_value=streams) |
1704 | + output = api.discover_nodes() |
1705 | + self.assertEqual(expected, output) |
1706 | + |
1707 | + |
1708 | +class TestNodeMACAddress(MAASTestCase): |
1709 | + """Tests for ``MSCM_CLI_API.get_node_macaddr``.""" |
1710 | + |
1711 | + def test_get_node_macaddr(self): |
1712 | + api = make_mscm_api() |
1713 | + expected = make_show_node_macaddr() |
1714 | + cli_mock = self.patch(api, '_run_cli_command') |
1715 | + cli_mock.return_value = expected |
1716 | + node_id = make_node_id() |
1717 | + output = api.get_node_macaddr(node_id) |
1718 | + self.assertEqual(re.findall(r':'.join(['[0-9a-f]{2}'] * 6), |
1719 | + expected), output) |
1720 | + |
1721 | + |
1722 | +class TestNodeArch(MAASTestCase): |
1723 | + """Tests for ``MSCM_CLI_API.get_node_arch``.""" |
1724 | + |
1725 | + def test_get_node_arch(self): |
1726 | + api = make_mscm_api() |
1727 | + expected = '\r\n Product Name: ProLiant Moonshot Cartridge\r\n' |
1728 | + cli_mock = self.patch(api, '_run_cli_command') |
1729 | + cli_mock.return_value = expected |
1730 | + node_id = make_node_id() |
1731 | + output = api.get_node_arch(node_id) |
1732 | + key = expected.split('Product Name: ')[1].splitlines()[0] |
1733 | + self.assertEqual(cartridge_mapping[key], output) |
1734 | + |
1735 | + |
1736 | +class TestGetNodePowerStatus(MAASTestCase): |
1737 | + """Tests for ``MSCM_CLI_API.get_node_power_status``.""" |
1738 | + |
1739 | + def test_get_node_power_status(self): |
1740 | + api = make_mscm_api() |
1741 | + expected = '\r\n Node #1\r\n Power State: On\r\n' |
1742 | + cli_mock = self.patch(api, '_run_cli_command') |
1743 | + cli_mock.return_value = expected |
1744 | + node_id = make_node_id() |
1745 | + output = api.get_node_power_status(node_id) |
1746 | + self.assertEqual(expected.split('Power State: ')[1].splitlines()[0], |
1747 | + output) |
1748 | + |
1749 | + |
1750 | +class TestPowerAndConfigureNode(MAASTestCase): |
1751 | + """Tests for ``MSCM_CLI_API.configure_node_bootonce_pxe, |
1752 | + MSCM_CLI_API.power_node_on, and MSCM_CLI_API.power_node_off``. |
1753 | + """ |
1754 | + |
1755 | + scenarios = [ |
1756 | + ('power_node_on()', |
1757 | + dict(method='power_node_on')), |
1758 | + ('power_node_off()', |
1759 | + dict(method='power_node_off')), |
1760 | + ('configure_node_bootonce_pxe()', |
1761 | + dict(method='configure_node_bootonce_pxe')), |
1762 | + ] |
1763 | + |
1764 | + def test_returns_expected_outout(self): |
1765 | + api = make_mscm_api() |
1766 | + ssh_mock = self.patch(api, '_ssh') |
1767 | + expected = factory.make_name('output') |
1768 | + stdout = StringIO(expected) |
1769 | + streams = factory.make_streams(stdout=stdout) |
1770 | + ssh_mock.exec_command = Mock(return_value=streams) |
1771 | + output = getattr(api, self.method)(make_node_id()) |
1772 | + self.assertEqual(expected, output) |
1773 | + |
1774 | + |
1775 | +class TestPowerControlMSCM(MAASTestCase): |
1776 | + """Tests for ``power_control_ucsm``.""" |
1777 | + |
1778 | + def test_power_control_mscm_on_on(self): |
1779 | + # power_change and power_status are both 'on' |
1780 | + host = factory.make_hostname('mscm') |
1781 | + username = factory.make_name('user') |
1782 | + password = factory.make_name('password') |
1783 | + node_id = make_node_id() |
1784 | + bootonce_mock = self.patch(MSCM_CLI_API, 'configure_node_bootonce_pxe') |
1785 | + power_status_mock = self.patch(MSCM_CLI_API, 'get_node_power_status') |
1786 | + power_status_mock.return_value = 'On' |
1787 | + power_node_on_mock = self.patch(MSCM_CLI_API, 'power_node_on') |
1788 | + power_node_off_mock = self.patch(MSCM_CLI_API, 'power_node_off') |
1789 | + |
1790 | + power_control_mscm(host, username, password, node_id, |
1791 | + power_change='on') |
1792 | + self.assertThat(bootonce_mock, MockCalledOnceWith(node_id)) |
1793 | + self.assertThat(power_node_off_mock, MockCalledOnceWith(node_id)) |
1794 | + self.assertThat(power_node_on_mock, MockCalledOnceWith(node_id)) |
1795 | + |
1796 | + def test_power_control_mscm_on_off(self): |
1797 | + # power_change is 'on' and power_status is 'off' |
1798 | + host = factory.make_hostname('mscm') |
1799 | + username = factory.make_name('user') |
1800 | + password = factory.make_name('password') |
1801 | + node_id = make_node_id() |
1802 | + bootonce_mock = self.patch(MSCM_CLI_API, 'configure_node_bootonce_pxe') |
1803 | + power_status_mock = self.patch(MSCM_CLI_API, 'get_node_power_status') |
1804 | + power_status_mock.return_value = 'Off' |
1805 | + power_node_on_mock = self.patch(MSCM_CLI_API, 'power_node_on') |
1806 | + |
1807 | + power_control_mscm(host, username, password, node_id, |
1808 | + power_change='on') |
1809 | + self.assertThat(bootonce_mock, MockCalledOnceWith(node_id)) |
1810 | + self.assertThat(power_node_on_mock, MockCalledOnceWith(node_id)) |
1811 | + |
1812 | + def test_power_control_mscm_off_on(self): |
1813 | + # power_change is 'off' and power_status is 'on' |
1814 | + host = factory.make_hostname('mscm') |
1815 | + username = factory.make_name('user') |
1816 | + password = factory.make_name('password') |
1817 | + node_id = make_node_id() |
1818 | + power_status_mock = self.patch(MSCM_CLI_API, 'get_node_power_status') |
1819 | + power_status_mock.return_value = 'On' |
1820 | + power_node_off_mock = self.patch(MSCM_CLI_API, 'power_node_off') |
1821 | + |
1822 | + power_control_mscm(host, username, password, node_id, |
1823 | + power_change='off') |
1824 | + self.assertThat(power_node_off_mock, MockCalledOnceWith(node_id)) |
1825 | + |
1826 | + |
1827 | +class TestProbeAndEnlistMSCM(MAASTestCase): |
1828 | + """Tests for ``probe_and_enlist_mscm``.""" |
1829 | + |
1830 | + def test_probe_and_enlist(self): |
1831 | + host = factory.make_hostname('mscm') |
1832 | + username = factory.make_name('user') |
1833 | + password = factory.make_name('password') |
1834 | + node_id = make_node_id() |
1835 | + macs = make_show_node_macaddr(4) |
1836 | + arch = 'arm64/xgene-uboot' |
1837 | + discover_nodes_mock = self.patch(MSCM_CLI_API, 'discover_nodes') |
1838 | + discover_nodes_mock.return_value = [node_id] |
1839 | + boot_m2_mock = self.patch(MSCM_CLI_API, 'configure_node_boot_m2') |
1840 | + node_arch_mock = self.patch(MSCM_CLI_API, 'get_node_arch') |
1841 | + node_arch_mock.return_value = arch |
1842 | + node_macs_mock = self.patch(MSCM_CLI_API, 'get_node_macaddr') |
1843 | + node_macs_mock.return_value = macs |
1844 | + create_node_mock = self.patch(utils, 'create_node') |
1845 | + probe_and_enlist_mscm(host, username, password) |
1846 | + self.assertThat(discover_nodes_mock, MockCalledOnceWith()) |
1847 | + self.assertThat(boot_m2_mock, MockCalledOnceWith(node_id)) |
1848 | + self.assertThat(node_arch_mock, MockCalledOnceWith(node_id)) |
1849 | + self.assertThat(node_macs_mock, MockCalledOnceWith(node_id)) |
1850 | + params = { |
1851 | + 'power_address': host, |
1852 | + 'power_user': username, |
1853 | + 'power_pass': password, |
1854 | + 'node_id': node_id, |
1855 | + } |
1856 | + self.assertThat(create_node_mock, |
1857 | + MockCalledOnceWith(macs, arch, 'mscm', params)) |
1858 | |
1859 | === modified file 'src/provisioningserver/power/tests/test_poweraction.py' |
1860 | --- src/provisioningserver/power/tests/test_poweraction.py 2014-06-02 11:57:58 +0000 |
1861 | +++ src/provisioningserver/power/tests/test_poweraction.py 2014-08-29 19:22:35 +0000 |
1862 | @@ -220,3 +220,14 @@ |
1863 | power_user='bar', power_pass='baz', |
1864 | uuid=factory.getRandomUUID(), power_change='on') |
1865 | self.assertIn('power_control_ucsm', script) |
1866 | + |
1867 | + def test_mscm_renders_template(self): |
1868 | + # I'd like to assert that escape_py_literal is being used here, |
1869 | + # but it's not obvious how to mock things in the template |
1870 | + # rendering namespace so I passed on that. |
1871 | + action = PowerAction('mscm') |
1872 | + script = action.render_template( |
1873 | + action.get_template(), power_address='foo', |
1874 | + power_user='bar', power_pass='baz', |
1875 | + node_id='c1n1', power_change='on') |
1876 | + self.assertIn('power_control_mscm', script) |
1877 | |
1878 | === modified file 'src/provisioningserver/power_schema.py' |
1879 | --- src/provisioningserver/power_schema.py 2014-06-02 11:57:58 +0000 |
1880 | +++ src/provisioningserver/power_schema.py 2014-08-29 19:22:35 +0000 |
1881 | @@ -255,4 +255,17 @@ |
1882 | make_json_field('power_pass', "API password"), |
1883 | ], |
1884 | }, |
1885 | + { |
1886 | + 'name': 'mscm', |
1887 | + 'description': "Moonshot HP iLO Chassis Manager", |
1888 | + 'fields': [ |
1889 | + make_json_field('power_address', "IP for MSCM CLI API"), |
1890 | + make_json_field('power_user', "MSCM CLI API user"), |
1891 | + make_json_field('power_pass', "MSCM CLI API password"), |
1892 | + make_json_field( |
1893 | + 'node_id', |
1894 | + "Node ID - Must adhere to cXnY format " |
1895 | + "(X=cartridge number, Y=node number)."), |
1896 | + ], |
1897 | + }, |
1898 | ] |
1899 | |
1900 | === modified file 'src/provisioningserver/tasks.py' |
1901 | --- src/provisioningserver/tasks.py 2014-06-02 11:57:58 +0000 |
1902 | +++ src/provisioningserver/tasks.py 2014-08-29 19:22:35 +0000 |
1903 | @@ -58,6 +58,7 @@ |
1904 | set_up_options_conf, |
1905 | setup_rndc, |
1906 | ) |
1907 | +from provisioningserver.drivers.hardware.mscm import probe_and_enlist_mscm |
1908 | from provisioningserver.omshell import Omshell |
1909 | from provisioningserver.power.poweraction import ( |
1910 | PowerAction, |
1911 | @@ -493,5 +494,12 @@ |
1912 | @task |
1913 | @log_exception_text |
1914 | def enlist_nodes_from_ucsm(url, username, password): |
1915 | - """ See `maasserver.api.NodeGroupsHandler.enlist_nodes_from_ucsm`. """ |
1916 | + """ See `maasserver.api.NodeGroupHandler.enlist_nodes_from_ucsm`. """ |
1917 | probe_and_enlist_ucsm(url, username, password) |
1918 | + |
1919 | + |
1920 | +@task |
1921 | +@log_exception_text |
1922 | +def enlist_nodes_from_mscm(host, username, password): |
1923 | + """ See `maasserver.api.NodeGroupHandler.enlist_nodes_from_mscm`. """ |
1924 | + probe_and_enlist_mscm(host, username, password) |
1925 | |
1926 | === modified file 'src/provisioningserver/tests/test_tasks.py' |
1927 | --- src/provisioningserver/tests/test_tasks.py 2014-06-02 11:57:58 +0000 |
1928 | +++ src/provisioningserver/tests/test_tasks.py 2014-08-29 19:22:35 +0000 |
1929 | @@ -73,6 +73,7 @@ |
1930 | from provisioningserver.tags import MissingCredentials |
1931 | from provisioningserver.tasks import ( |
1932 | add_new_dhcp_host_map, |
1933 | + enlist_nodes_from_mscm, |
1934 | enlist_nodes_from_ucsm, |
1935 | import_boot_images, |
1936 | Omshell, |
1937 | @@ -661,3 +662,14 @@ |
1938 | mock = self.patch(tasks, 'probe_and_enlist_ucsm') |
1939 | enlist_nodes_from_ucsm(url, username, password) |
1940 | self.assertThat(mock, MockCalledOnceWith(url, username, password)) |
1941 | + |
1942 | + |
1943 | +class TestAddMSCM(PservTestCase): |
1944 | + |
1945 | + def test_enlist_nodes_from_mscm(self): |
1946 | + host = 'host' |
1947 | + username = 'username' |
1948 | + password = 'password' |
1949 | + mock = self.patch(tasks, 'probe_and_enlist_mscm') |
1950 | + enlist_nodes_from_mscm(host, username, password) |
1951 | + self.assertThat(mock, MockCalledOnceWith(host, username, password)) |
1952 | |
1953 | === modified file 'src/provisioningserver/utils/__init__.py' |
1954 | --- src/provisioningserver/utils/__init__.py 2014-04-15 14:41:32 +0000 |
1955 | +++ src/provisioningserver/utils/__init__.py 2014-08-29 19:22:35 +0000 |
1956 | @@ -829,3 +829,24 @@ |
1957 | if len(columns) == 5 and columns[2] == mac: |
1958 | return columns[0] |
1959 | return None |
1960 | + |
1961 | + |
1962 | +def find_mac_via_arp(ip): |
1963 | + """Find the MAC address for `ip` by reading the output of arp -n. |
1964 | + |
1965 | + Returns `None` if the IP is not found. |
1966 | + |
1967 | + We do this because we aren't necessarily the only DHCP server on the |
1968 | + network, so we can't check our own leases file and be guaranteed to find an |
1969 | + IP that matches. |
1970 | + |
1971 | + :param ip: The ip address, e.g. '192.168.1.1'. |
1972 | + """ |
1973 | + |
1974 | + output = call_capture_and_check(['arp', '-n']).split('\n') |
1975 | + |
1976 | + for line in sorted(output): |
1977 | + columns = line.split() |
1978 | + if len(columns) == 5 and columns[0] == ip: |
1979 | + return columns[2] |
1980 | + return None |
1981 | |
1982 | === modified file 'src/provisioningserver/utils/tests/test_utils.py' |
1983 | --- src/provisioningserver/utils/tests/test_utils.py 2014-04-15 14:41:32 +0000 |
1984 | +++ src/provisioningserver/utils/tests/test_utils.py 2014-08-29 19:22:35 +0000 |
1985 | @@ -75,6 +75,7 @@ |
1986 | ExternalProcessError, |
1987 | filter_dict, |
1988 | find_ip_via_arp, |
1989 | + find_mac_via_arp, |
1990 | get_all_interface_addresses, |
1991 | get_mtime, |
1992 | incremental_write, |
1993 | @@ -1322,6 +1323,50 @@ |
1994 | self.assertEqual("192.168.0.1", ip_address_observed) |
1995 | |
1996 | |
1997 | +class TestFindMACViaARP(MAASTestCase): |
1998 | + |
1999 | + def patch_call(self, output): |
2000 | + """Replace `call_capture_and_check` with one that returns `output`.""" |
2001 | + fake = self.patch(provisioningserver.utils, 'call_capture_and_check') |
2002 | + fake.return_value = output |
2003 | + return fake |
2004 | + |
2005 | + def test__resolves_IP_address_to_MAC(self): |
2006 | + sample = """\ |
2007 | + Address HWtype HWaddress Flags Mask Iface |
2008 | + 192.168.100.20 (incomplete) virbr1 |
2009 | + 192.168.0.104 (incomplete) eth0 |
2010 | + 192.168.0.5 (incomplete) eth0 |
2011 | + 192.168.0.2 (incomplete) eth0 |
2012 | + 192.168.0.100 (incomplete) eth0 |
2013 | + 192.168.122.20 ether 52:54:00:02:86:4b C virbr0 |
2014 | + 192.168.0.4 (incomplete) eth0 |
2015 | + 192.168.0.1 ether 90:f6:52:f6:17:92 C eth0 |
2016 | + """ |
2017 | + |
2018 | + call_capture_and_check = self.patch_call(sample) |
2019 | + mac_address_observed = find_mac_via_arp("192.168.122.20") |
2020 | + self.assertThat( |
2021 | + call_capture_and_check, |
2022 | + MockCalledOnceWith(['arp', '-n'])) |
2023 | + self.assertEqual("52:54:00:02:86:4b", mac_address_observed) |
2024 | + |
2025 | + def test__returns_consistent_output(self): |
2026 | + ip = factory.getRandomIPAddress() |
2027 | + macs = [ |
2028 | + '52:54:00:02:86:4b', |
2029 | + '90:f6:52:f6:17:92', |
2030 | + ] |
2031 | + lines = ['%s ether %s C eth0' % (ip, mac) for mac in macs] |
2032 | + self.patch_call('\n'.join(lines)) |
2033 | + one_result = find_mac_via_arp(ip) |
2034 | + self.patch_call('\n'.join(reversed(lines))) |
2035 | + other_result = find_mac_via_arp(ip) |
2036 | + |
2037 | + self.assertIn(one_result, macs) |
2038 | + self.assertEqual(one_result, other_result) |
2039 | + |
2040 | + |
2041 | class TestAsynchronousDecorator(MAASTestCase): |
2042 | |
2043 | run_tests_with = AsynchronousDeferredRunTest.make_factory(timeout=5) |
2044 | |
2045 | === modified file 'versions.cfg' |
2046 | --- versions.cfg 2014-03-28 10:43:53 +0000 |
2047 | +++ versions.cfg 2014-08-29 19:22:35 +0000 |
2048 | @@ -37,7 +37,7 @@ |
2049 | nose = 1.3 |
2050 | nose-subunit = 0.2 |
2051 | python-subunit = 0.0.7 |
2052 | -rabbitfixture = 0.3.4 |
2053 | +rabbitfixture = 0.3.5 |
2054 | sst = 0.2.2 |
2055 | testresources = 0.2.5 |
2056 | testscenarios = 0.4 |
For an SRU team member who may be looking at this, effected bugs fixed in SRU are:
1350235
1338851
1337437
1190986
1325759
1325640
1315154
1352273
1342302
1334401
1331982