Merge lp:~ltrager/maas/combined_result_refresh into lp:~maas-committers/maas/trunk
- combined_result_refresh
- Merge into trunk
Status: | Merged | ||||||||
---|---|---|---|---|---|---|---|---|---|
Approved by: | Lee Trager | ||||||||
Approved revision: | no longer in the source branch. | ||||||||
Merged at revision: | 5780 | ||||||||
Proposed branch: | lp:~ltrager/maas/combined_result_refresh | ||||||||
Merge into: | lp:~maas-committers/maas/trunk | ||||||||
Diff against target: |
772 lines (+359/-180) 8 files modified
src/metadataserver/user_data/templates/snippets/maas_run_remote_scripts.py (+2/-51) src/metadataserver/user_data/templates/snippets/tests/test_maas_run_remote_scripts.py (+0/-115) src/provisioningserver/refresh/__init__.py (+48/-10) src/provisioningserver/refresh/maas_api_helper.py (+52/-0) src/provisioningserver/refresh/node_info_scripts.py (+26/-1) src/provisioningserver/refresh/tests/test_maas_api_helper.py (+111/-0) src/provisioningserver/refresh/tests/test_node_info_scripts.py (+26/-3) src/provisioningserver/refresh/tests/test_refresh.py (+94/-0) |
||||||||
To merge this branch: | bzr merge lp:~ltrager/maas/combined_result_refresh | ||||||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Andres Rodriguez (community) | Approve | ||
Review via email: mp+318854@code.launchpad.net |
Commit message
Send combined commissioning results in addtion to stdout and stderr when refreshing.
Description of the change
MAAS Lander (maas-lander) wrote : | # |
The attempt to merge lp:~ltrager/maas/combined_result_refresh into lp:maas failed. Below is the output from the failed tests.
Hit:1 http://
Get:2 http://
Get:3 http://
Get:4 http://
Fetched 306 kB in 0s (612 kB/s)
Reading package lists...
sudo DEBIAN_
--no-
Reading package lists...
Building dependency tree...
Reading state information...
authbind is already the newest version (2.1.1+nmu1).
avahi-utils is already the newest version (0.6.32~
build-essential is already the newest version (12.1ubuntu2).
debhelper is already the newest version (9.20160115ubun
distro-info is already the newest version (0.14build1).
git is already the newest version (1:2.7.4-0ubuntu1).
libjs-angularjs is already the newest version (1.2.28-1ubuntu2).
libjs-jquery is already the newest version (1.11.3+dfsg-4).
libjs-yui3-full is already the newest version (3.5.1-1ubuntu3).
libjs-yui3-min is already the newest version (3.5.1-1ubuntu3).
make is already the newest version (4.1-6).
postgresql is already the newest version (9.5+173).
psmisc is already the newest version (22.21-2.1build1).
pxelinux is already the newest version (3:6.03+
python-formencode is already the newest version (1.3.0-0ubuntu5).
python-lxml is already the newest version (3.5.0-1build1).
python-netaddr is already the newest version (0.7.18-1).
python-net...
Preview Diff
1 | === modified file 'src/metadataserver/user_data/templates/snippets/maas_run_remote_scripts.py' |
2 | --- src/metadataserver/user_data/templates/snippets/maas_run_remote_scripts.py 2017-03-02 21:53:33 +0000 |
3 | +++ src/metadataserver/user_data/templates/snippets/maas_run_remote_scripts.py 2017-03-03 03:26:15 +0000 |
4 | @@ -4,7 +4,6 @@ |
5 | from io import BytesIO |
6 | import json |
7 | import os |
8 | -import selectors |
9 | from subprocess import ( |
10 | PIPE, |
11 | Popen, |
12 | @@ -17,11 +16,6 @@ |
13 | ) |
14 | import time |
15 | |
16 | -# See fcntl(2), re. F_SETPIPE_SZ. By requesting this many bytes from a pipe on |
17 | -# each read we can be sure that we are always draining its buffer completely. |
18 | -with open("/proc/sys/fs/pipe-max-size") as _pms: |
19 | - PIPE_MAX_SIZE = int(_pms.read()) |
20 | - |
21 | |
22 | try: |
23 | from maas_api_helper import ( |
24 | @@ -30,6 +24,7 @@ |
25 | read_config, |
26 | signal, |
27 | SignalException, |
28 | + capture_script_output, |
29 | ) |
30 | except ImportError: |
31 | # For running unit tests. |
32 | @@ -39,6 +34,7 @@ |
33 | read_config, |
34 | signal, |
35 | SignalException, |
36 | + capture_script_output, |
37 | ) |
38 | |
39 | |
40 | @@ -66,51 +62,6 @@ |
41 | tar.extractall(scripts_dir) |
42 | |
43 | |
44 | -def capture_script_output(proc, combined_path, stdout_path, stderr_path): |
45 | - """Capture stdout and stderr from `proc`. |
46 | - |
47 | - Standard output is written to a file named by `stdout_path`, and standard |
48 | - error is written to a file named by `stderr_path`. Both are also written |
49 | - to a file named by `combined_path`. |
50 | - |
51 | - If the given subprocess forks additional processes, and these write to the |
52 | - same stdout and stderr, their output will be captured only as long as |
53 | - `proc` is running. |
54 | - |
55 | - :return: The exit code of `proc`. |
56 | - """ |
57 | - with open(stdout_path, 'wb') as out, open(stderr_path, 'wb') as err: |
58 | - with open(combined_path, 'wb') as combined: |
59 | - with selectors.DefaultSelector() as selector: |
60 | - selector.register(proc.stdout, selectors.EVENT_READ, out) |
61 | - selector.register(proc.stderr, selectors.EVENT_READ, err) |
62 | - while selector.get_map() and proc.poll() is None: |
63 | - # Select with a short timeout so that we don't tight loop. |
64 | - _select_script_output(selector, combined, 0.1) |
65 | - else: |
66 | - # Process has finished or has closed stdout and stderr. |
67 | - # Process anything still sitting in the latter's buffers. |
68 | - _select_script_output(selector, combined, 0.0) |
69 | - |
70 | - # Always wait for the process to finish. |
71 | - return proc.wait() |
72 | - |
73 | - |
74 | -def _select_script_output(selector, combined, timeout): |
75 | - """Helper for `capture_script_output`.""" |
76 | - for key, event in selector.select(timeout): |
77 | - if event & selectors.EVENT_READ: |
78 | - # Read from the _raw_ file. Ordinarily Python blocks until a |
79 | - # read(n) returns n bytes or the stream reaches end-of-file, |
80 | - # but here we only want to get what's there without blocking. |
81 | - chunk = key.fileobj.raw.read(PIPE_MAX_SIZE) |
82 | - if len(chunk) == 0: # EOF |
83 | - selector.unregister(key.fileobj) |
84 | - else: |
85 | - key.data.write(chunk) |
86 | - combined.write(chunk) |
87 | - |
88 | - |
89 | def run_scripts(url, creds, scripts_dir, out_dir, scripts): |
90 | """Run and report results for the given scripts.""" |
91 | fail_count = 0 |
92 | |
93 | === modified file 'src/metadataserver/user_data/templates/snippets/tests/test_maas_run_remote_scripts.py' |
94 | --- src/metadataserver/user_data/templates/snippets/tests/test_maas_run_remote_scripts.py 2017-03-02 21:53:33 +0000 |
95 | +++ src/metadataserver/user_data/templates/snippets/tests/test_maas_run_remote_scripts.py 2017-03-03 03:26:15 +0000 |
96 | @@ -8,17 +8,10 @@ |
97 | from io import BytesIO |
98 | import json |
99 | import os |
100 | -from pathlib import Path |
101 | import random |
102 | -from subprocess import ( |
103 | - PIPE, |
104 | - Popen, |
105 | -) |
106 | import tarfile |
107 | -import time |
108 | from unittest.mock import ANY |
109 | |
110 | -from lxml import etree |
111 | from maastesting.factory import factory |
112 | from maastesting.fixtures import TempDirectory |
113 | from maastesting.matchers import ( |
114 | @@ -29,16 +22,10 @@ |
115 | from maastesting.testcase import MAASTestCase |
116 | from snippets import maas_run_remote_scripts |
117 | from snippets.maas_run_remote_scripts import ( |
118 | - capture_script_output, |
119 | download_and_extract_tar, |
120 | run_scripts, |
121 | run_scripts_from_metadata, |
122 | ) |
123 | -from testtools.matchers import ( |
124 | - Equals, |
125 | - MatchesAny, |
126 | - MatchesListwise, |
127 | -) |
128 | |
129 | |
130 | class TestMaasRunRemoteScripts(MAASTestCase): |
131 | @@ -302,105 +289,3 @@ |
132 | mock_signal, |
133 | MockAnyCall( |
134 | None, None, 'FAILED', '1 scripts failed to run')) |
135 | - |
136 | - |
137 | -class TestCaptureScriptOutput(MAASTestCase): |
138 | - |
139 | - # Iterate multiple times to shake out spurious failures. |
140 | - scenarios = [ |
141 | - ("iteration %d" % iteration, {}) |
142 | - for iteration in range(1, 21) |
143 | - ] |
144 | - |
145 | - def capture(self, proc): |
146 | - scripts_dir = Path(self.useFixture(TempDirectory()).path) |
147 | - combined_path = scripts_dir.joinpath("combined") |
148 | - stdout_path = scripts_dir.joinpath("stdout") |
149 | - stderr_path = scripts_dir.joinpath("stderr") |
150 | - |
151 | - returncode = capture_script_output( |
152 | - proc, str(combined_path), str(stdout_path), str(stderr_path)) |
153 | - |
154 | - return ( |
155 | - returncode, |
156 | - stdout_path.read_text(), |
157 | - stderr_path.read_text(), |
158 | - combined_path.read_text(), |
159 | - ) |
160 | - |
161 | - def test__captures_script_output(self): |
162 | - proc = Popen( |
163 | - 'echo "stdout"; echo "stderr" 1>&2', stdout=PIPE, stderr=PIPE, |
164 | - shell=True) |
165 | - self.assertThat( |
166 | - self.capture(proc), MatchesListwise(( |
167 | - Equals(0), Equals("stdout\n"), Equals("stderr\n"), |
168 | - # The writes to stdout and stderr occur so close in time that |
169 | - # they may be received in any order. |
170 | - MatchesAny( |
171 | - Equals("stdout\nstderr\n"), |
172 | - Equals("stderr\nstdout\n"), |
173 | - ), |
174 | - ))) |
175 | - |
176 | - def test__does_not_wait_for_forked_process(self): |
177 | - start_time = time.time() |
178 | - proc = Popen('sleep 6 &', stdout=PIPE, stderr=PIPE, shell=True) |
179 | - self.assertThat( |
180 | - self.capture(proc), MatchesListwise(( |
181 | - Equals(0), Equals(""), Equals(""), Equals(""), |
182 | - ))) |
183 | - # A forked process should continue running after capture_script_output |
184 | - # returns. capture_script_output should not block on the forked call. |
185 | - self.assertLess(time.time() - start_time, 3) |
186 | - |
187 | - def test__captures_output_from_completed_process(self): |
188 | - # Write to both stdout and stderr. |
189 | - proc = Popen( |
190 | - 'echo -n foo >&1 && echo -n bar >&2', |
191 | - stdout=PIPE, stderr=PIPE, shell=True) |
192 | - # Wait for it to finish before capturing. |
193 | - self.assertEquals(0, proc.wait()) |
194 | - # Capturing now still gets foo and bar. |
195 | - self.assertThat( |
196 | - self.capture(proc), MatchesListwise(( |
197 | - Equals(0), Equals("foo"), Equals("bar"), |
198 | - # The writes to stdout and stderr occur so close in time that |
199 | - # they may be received in any order. |
200 | - MatchesAny(Equals("foobar"), Equals("barfoo")), |
201 | - ))) |
202 | - |
203 | - def test__captures_stderr_after_stdout_closes(self): |
204 | - # Write to stdout, close stdout, then write to stderr. |
205 | - proc = Popen( |
206 | - 'echo -n foo >&1 && exec 1>&- && echo -n bar >&2', |
207 | - stdout=PIPE, stderr=PIPE, shell=True) |
208 | - # Capturing gets the bar even after stdout is closed. |
209 | - self.assertThat( |
210 | - self.capture(proc), MatchesListwise(( |
211 | - Equals(0), Equals("foo"), Equals("bar"), |
212 | - # The writes to stdout and stderr occur so close in time that |
213 | - # they may be received in any order. |
214 | - MatchesAny(Equals("foobar"), Equals("barfoo")), |
215 | - ))) |
216 | - |
217 | - def test__captures_stdout_after_stderr_closes(self): |
218 | - # Write to stderr, close stderr, then write to stdout. |
219 | - proc = Popen( |
220 | - 'echo -n bar >&2 && exec 2>&- && echo -n foo >&1', |
221 | - stdout=PIPE, stderr=PIPE, shell=True) |
222 | - # Capturing gets the foo even after stderr is closed. |
223 | - self.assertThat( |
224 | - self.capture(proc), MatchesListwise(( |
225 | - Equals(0), Equals("foo"), Equals("bar"), |
226 | - # The writes to stdout and stderr occur so close in time that |
227 | - # they may be received in any order. |
228 | - MatchesAny(Equals("foobar"), Equals("barfoo")), |
229 | - ))) |
230 | - |
231 | - def test__captures_all_output(self): |
232 | - proc = Popen(("lshw", "-xml"), stdout=PIPE, stderr=PIPE) |
233 | - returncode, stdout, stderr, combined = self.capture(proc) |
234 | - self.assertThat(returncode, Equals(0), stderr) |
235 | - # This is a complete XML document; we've captured all output. |
236 | - self.assertThat(etree.fromstring(stdout).tag, Equals("list")) |
237 | |
238 | === modified file 'src/provisioningserver/refresh/__init__.py' |
239 | --- src/provisioningserver/refresh/__init__.py 2017-01-28 00:51:47 +0000 |
240 | +++ src/provisioningserver/refresh/__init__.py 2017-03-03 03:26:15 +0000 |
241 | @@ -5,11 +5,15 @@ |
242 | import os |
243 | import socket |
244 | import stat |
245 | -import subprocess |
246 | +from subprocess import ( |
247 | + PIPE, |
248 | + Popen, |
249 | +) |
250 | import tempfile |
251 | |
252 | from provisioningserver.logger import get_maas_logger |
253 | from provisioningserver.refresh.maas_api_helper import ( |
254 | + capture_script_output, |
255 | MD_VERSION, |
256 | signal, |
257 | SignalException, |
258 | @@ -123,7 +127,10 @@ |
259 | total_scripts = len(scripts) |
260 | current_script = 1 |
261 | failed_scripts = [] |
262 | - for script_name, builtin_script in scripts.items(): |
263 | + out_dir = os.path.join(tmpdir, 'out') |
264 | + os.makedirs(out_dir) |
265 | + for script_name in sorted(scripts.keys()): |
266 | + builtin_script = scripts[script_name] |
267 | signal_wrapper( |
268 | url, creds, 'WORKING', 'Starting %s [%d/%d]' % |
269 | (script_name, current_script, total_scripts)) |
270 | @@ -135,17 +142,48 @@ |
271 | st = os.stat(script_path) |
272 | os.chmod(script_path, st.st_mode | stat.S_IEXEC) |
273 | |
274 | - # Execute script and store stdout/stderr |
275 | - proc = subprocess.Popen( |
276 | - script_path, stdout=subprocess.PIPE, stderr=subprocess.PIPE) |
277 | - stdout, stderr = proc.communicate() |
278 | + # If we pipe the output of the subprocess and the subprocess also |
279 | + # creates a subprocess we end up dead locking. Spawn a shell process |
280 | + # and capture the output to the filesystem to avoid that and help with |
281 | + # debugging. |
282 | + combined_path = os.path.join(out_dir, script_name) |
283 | + stdout_name = '%s.out' % script_name |
284 | + stdout_path = os.path.join(out_dir, stdout_name) |
285 | + stderr_name = '%s.err' % script_name |
286 | + stderr_path = os.path.join(out_dir, stderr_name) |
287 | + |
288 | + try: |
289 | + proc = Popen(script_path, stdout=PIPE, stderr=PIPE) |
290 | + except OSError as e: |
291 | + if isinstance(e.errno, int) and e.errno != 0: |
292 | + exit_status = e.errno |
293 | + else: |
294 | + # 2 is the return code bash gives when it can't execute. |
295 | + exit_status = 2 |
296 | + result = str(e).encode() |
297 | + if result == b'': |
298 | + result = b'Unable to execute script' |
299 | + files = { |
300 | + script_name: result, |
301 | + stderr_name: result, |
302 | + } |
303 | + else: |
304 | + capture_script_output( |
305 | + proc, combined_path, stdout_path, stderr_path) |
306 | + |
307 | + exit_status = proc.returncode |
308 | + files = { |
309 | + script_name: open(combined_path, 'rb').read(), |
310 | + stdout_name: open(stdout_path, 'rb').read(), |
311 | + stderr_name: open(stderr_path, 'rb').read(), |
312 | + } |
313 | + |
314 | signal_wrapper( |
315 | url, creds, |
316 | "WORKING", "Finished %s [%d/%d]: %d" % |
317 | - (script_name, current_script, total_scripts, proc.returncode), |
318 | - files={script_name: stdout, "%s.err" % script_name: stderr}, |
319 | - exit_status=proc.returncode) |
320 | - if proc.returncode != 0: |
321 | + (script_name, current_script, total_scripts, exit_status), |
322 | + files=files, exit_status=exit_status) |
323 | + if exit_status != 0: |
324 | failed_scripts.append(script_name) |
325 | current_script += 1 |
326 | return failed_scripts |
327 | |
328 | === modified file 'src/provisioningserver/refresh/maas_api_helper.py' |
329 | --- src/provisioningserver/refresh/maas_api_helper.py 2017-01-28 00:51:47 +0000 |
330 | +++ src/provisioningserver/refresh/maas_api_helper.py 2017-03-03 03:26:15 +0000 |
331 | @@ -14,6 +14,7 @@ |
332 | import json |
333 | import mimetypes |
334 | import random |
335 | +import selectors |
336 | import socket |
337 | import string |
338 | import sys |
339 | @@ -29,6 +30,12 @@ |
340 | MD_VERSION = '2012-03-01' |
341 | |
342 | |
343 | +# See fcntl(2), re. F_SETPIPE_SZ. By requesting this many bytes from a pipe on |
344 | +# each read we can be sure that we are always draining its buffer completely. |
345 | +with open("/proc/sys/fs/pipe-max-size") as _pms: |
346 | + PIPE_MAX_SIZE = int(_pms.read()) |
347 | + |
348 | + |
349 | def oauth_headers(url, consumer_key, token_key, token_secret, consumer_secret, |
350 | clockskew=0): |
351 | """Build OAuth headers using given credentials.""" |
352 | @@ -257,3 +264,48 @@ |
353 | raise SignalException(str(exc)) |
354 | except Exception as exc: |
355 | raise SignalException("Unexpected error [%s]" % exc) |
356 | + |
357 | + |
358 | +def capture_script_output(proc, combined_path, stdout_path, stderr_path): |
359 | + """Capture stdout and stderr from `proc`. |
360 | + |
361 | + Standard output is written to a file named by `stdout_path`, and standard |
362 | + error is written to a file named by `stderr_path`. Both are also written |
363 | + to a file named by `combined_path`. |
364 | + |
365 | + If the given subprocess forks additional processes, and these write to the |
366 | + same stdout and stderr, their output will be captured only as long as |
367 | + `proc` is running. |
368 | + |
369 | + :return: The exit code of `proc`. |
370 | + """ |
371 | + with open(stdout_path, 'wb') as out, open(stderr_path, 'wb') as err: |
372 | + with open(combined_path, 'wb') as combined: |
373 | + with selectors.DefaultSelector() as selector: |
374 | + selector.register(proc.stdout, selectors.EVENT_READ, out) |
375 | + selector.register(proc.stderr, selectors.EVENT_READ, err) |
376 | + while selector.get_map() and proc.poll() is None: |
377 | + # Select with a short timeout so that we don't tight loop. |
378 | + _select_script_output(selector, combined, 0.1) |
379 | + else: |
380 | + # Process has finished or has closed stdout and stderr. |
381 | + # Process anything still sitting in the latter's buffers. |
382 | + _select_script_output(selector, combined, 0.0) |
383 | + |
384 | + # Always wait for the process to finish. |
385 | + return proc.wait() |
386 | + |
387 | + |
388 | +def _select_script_output(selector, combined, timeout): |
389 | + """Helper for `capture_script_output`.""" |
390 | + for key, event in selector.select(timeout): |
391 | + if event & selectors.EVENT_READ: |
392 | + # Read from the _raw_ file. Ordinarily Python blocks until a |
393 | + # read(n) returns n bytes or the stream reaches end-of-file, |
394 | + # but here we only want to get what's there without blocking. |
395 | + chunk = key.fileobj.raw.read(PIPE_MAX_SIZE) |
396 | + if len(chunk) == 0: # EOF |
397 | + selector.unregister(key.fileobj) |
398 | + else: |
399 | + key.data.write(chunk) |
400 | + combined.write(chunk) |
401 | |
402 | === modified file 'src/provisioningserver/refresh/node_info_scripts.py' |
403 | --- src/provisioningserver/refresh/node_info_scripts.py 2017-02-27 14:19:44 +0000 |
404 | +++ src/provisioningserver/refresh/node_info_scripts.py 2017-03-03 03:26:15 +0000 |
405 | @@ -1,4 +1,4 @@ |
406 | -# Copyright 2016 Canonical Ltd. This software is licensed under the |
407 | +# Copyright 2016-2017 Canonical Ltd. This software is licensed under the |
408 | # GNU Affero General Public License version 3 (see the file LICENSE). |
409 | |
410 | """Builtin node info scripts.""" |
411 | @@ -353,6 +353,7 @@ |
412 | import json |
413 | import os |
414 | import shlex |
415 | + import sys |
416 | from subprocess import check_output |
417 | |
418 | # XXX: Set LC_* and LANG environment variables to C.UTF-8 explicitly. |
419 | @@ -366,6 +367,30 @@ |
420 | return os.path.join(dev_disk_byid, link) |
421 | return None |
422 | |
423 | + running_dir = os.path.dirname(__file__) |
424 | + # Results are stored differently when being run as part of node |
425 | + # commissioning vs controller refresh. |
426 | + virtuality_result_paths = [ |
427 | + os.path.join(running_dir, '..', '..', 'out', '00-maas-02-virtuality'), |
428 | + os.path.join(running_dir, 'out', '00-maas-02-virtuality'), |
429 | + ] |
430 | + # This script doesn't work in containers as they don't have any block |
431 | + # device. If the virtuality script detected its in one don't report |
432 | + # anything. |
433 | + for virtuality_result_path in virtuality_result_paths: |
434 | + if not os.path.exists(virtuality_result_path): |
435 | + continue |
436 | + virtuality_result = open(virtuality_result_path, 'r').read().strip() |
437 | + # Names from man SYSTEMD-DETECT-VIRT(1) |
438 | + if virtuality_result in { |
439 | + 'openvz', 'lxc', 'lxc-libvirt', 'systemd-nspawn', 'docker', |
440 | + 'rkt'}: |
441 | + print( |
442 | + 'Unable to detect block devices while running in container!', |
443 | + file=sys.stderr) |
444 | + print('[]') |
445 | + return |
446 | + |
447 | # Grab the block devices from lsblk. Excludes RAM devices |
448 | # (default for lsblk), floppy disks, and loopback devices. |
449 | blockdevs = [] |
450 | |
451 | === modified file 'src/provisioningserver/refresh/tests/test_maas_api_helper.py' |
452 | --- src/provisioningserver/refresh/tests/test_maas_api_helper.py 2017-01-28 00:51:47 +0000 |
453 | +++ src/provisioningserver/refresh/tests/test_maas_api_helper.py 2017-03-03 03:26:15 +0000 |
454 | @@ -9,12 +9,19 @@ |
455 | from email.utils import formatdate |
456 | from io import StringIO |
457 | import json |
458 | +from pathlib import Path |
459 | import random |
460 | import re |
461 | +from subprocess import ( |
462 | + PIPE, |
463 | + Popen, |
464 | +) |
465 | import time |
466 | import urllib |
467 | |
468 | +from lxml import etree |
469 | from maastesting.factory import factory |
470 | +from maastesting.fixtures import TempDirectory |
471 | from maastesting.matchers import ( |
472 | GreaterThanOrEqual, |
473 | LessThanOrEqual, |
474 | @@ -28,7 +35,9 @@ |
475 | AfterPreprocessing, |
476 | Equals, |
477 | MatchesAll, |
478 | + MatchesAny, |
479 | MatchesDict, |
480 | + MatchesListwise, |
481 | ) |
482 | |
483 | |
484 | @@ -481,3 +490,105 @@ |
485 | self.assertRaises( |
486 | maas_api_helper.SignalException, |
487 | maas_api_helper.signal, None, None, status) |
488 | + |
489 | + |
490 | +class TestCaptureScriptOutput(MAASTestCase): |
491 | + |
492 | + # Iterate multiple times to shake out spurious failures. |
493 | + scenarios = [ |
494 | + ("iteration %d" % iteration, {}) |
495 | + for iteration in range(1, 21) |
496 | + ] |
497 | + |
498 | + def capture(self, proc): |
499 | + scripts_dir = Path(self.useFixture(TempDirectory()).path) |
500 | + combined_path = scripts_dir.joinpath("combined") |
501 | + stdout_path = scripts_dir.joinpath("stdout") |
502 | + stderr_path = scripts_dir.joinpath("stderr") |
503 | + |
504 | + returncode = maas_api_helper.capture_script_output( |
505 | + proc, str(combined_path), str(stdout_path), str(stderr_path)) |
506 | + |
507 | + return ( |
508 | + returncode, |
509 | + stdout_path.read_text(), |
510 | + stderr_path.read_text(), |
511 | + combined_path.read_text(), |
512 | + ) |
513 | + |
514 | + def test__captures_script_output(self): |
515 | + proc = Popen( |
516 | + 'echo "stdout"; echo "stderr" 1>&2', stdout=PIPE, stderr=PIPE, |
517 | + shell=True) |
518 | + self.assertThat( |
519 | + self.capture(proc), MatchesListwise(( |
520 | + Equals(0), Equals("stdout\n"), Equals("stderr\n"), |
521 | + # The writes to stdout and stderr occur so close in time that |
522 | + # they may be received in any order. |
523 | + MatchesAny( |
524 | + Equals("stdout\nstderr\n"), |
525 | + Equals("stderr\nstdout\n"), |
526 | + ), |
527 | + ))) |
528 | + |
529 | + def test__does_not_wait_for_forked_process(self): |
530 | + start_time = time.time() |
531 | + proc = Popen('sleep 6 &', stdout=PIPE, stderr=PIPE, shell=True) |
532 | + self.assertThat( |
533 | + self.capture(proc), MatchesListwise(( |
534 | + Equals(0), Equals(""), Equals(""), Equals(""), |
535 | + ))) |
536 | + # A forked process should continue running after capture_script_output |
537 | + # returns. capture_script_output should not block on the forked call. |
538 | + self.assertLess(time.time() - start_time, 3) |
539 | + |
540 | + def test__captures_output_from_completed_process(self): |
541 | + # Write to both stdout and stderr. |
542 | + proc = Popen( |
543 | + 'echo -n foo >&1 && echo -n bar >&2', |
544 | + stdout=PIPE, stderr=PIPE, shell=True) |
545 | + # Wait for it to finish before capturing. |
546 | + self.assertEquals(0, proc.wait()) |
547 | + # Capturing now still gets foo and bar. |
548 | + self.assertThat( |
549 | + self.capture(proc), MatchesListwise(( |
550 | + Equals(0), Equals("foo"), Equals("bar"), |
551 | + # The writes to stdout and stderr occur so close in time that |
552 | + # they may be received in any order. |
553 | + MatchesAny(Equals("foobar"), Equals("barfoo")), |
554 | + ))) |
555 | + |
556 | + def test__captures_stderr_after_stdout_closes(self): |
557 | + # Write to stdout, close stdout, then write to stderr. |
558 | + proc = Popen( |
559 | + 'echo -n foo >&1 && exec 1>&- && echo -n bar >&2', |
560 | + stdout=PIPE, stderr=PIPE, shell=True) |
561 | + # Capturing gets the bar even after stdout is closed. |
562 | + self.assertThat( |
563 | + self.capture(proc), MatchesListwise(( |
564 | + Equals(0), Equals("foo"), Equals("bar"), |
565 | + # The writes to stdout and stderr occur so close in time that |
566 | + # they may be received in any order. |
567 | + MatchesAny(Equals("foobar"), Equals("barfoo")), |
568 | + ))) |
569 | + |
570 | + def test__captures_stdout_after_stderr_closes(self): |
571 | + # Write to stderr, close stderr, then write to stdout. |
572 | + proc = Popen( |
573 | + 'echo -n bar >&2 && exec 2>&- && echo -n foo >&1', |
574 | + stdout=PIPE, stderr=PIPE, shell=True) |
575 | + # Capturing gets the foo even after stderr is closed. |
576 | + self.assertThat( |
577 | + self.capture(proc), MatchesListwise(( |
578 | + Equals(0), Equals("foo"), Equals("bar"), |
579 | + # The writes to stdout and stderr occur so close in time that |
580 | + # they may be received in any order. |
581 | + MatchesAny(Equals("foobar"), Equals("barfoo")), |
582 | + ))) |
583 | + |
584 | + def test__captures_all_output(self): |
585 | + proc = Popen(("lshw", "-xml"), stdout=PIPE, stderr=PIPE) |
586 | + returncode, stdout, stderr, combined = self.capture(proc) |
587 | + self.assertThat(returncode, Equals(0), stderr) |
588 | + # This is a complete XML document; we've captured all output. |
589 | + self.assertThat(etree.fromstring(stdout).tag, Equals("list")) |
590 | |
591 | === modified file 'src/provisioningserver/refresh/tests/test_node_info_scripts.py' |
592 | --- src/provisioningserver/refresh/tests/test_node_info_scripts.py 2017-02-17 14:23:04 +0000 |
593 | +++ src/provisioningserver/refresh/tests/test_node_info_scripts.py 2017-03-03 03:26:15 +0000 |
594 | @@ -5,7 +5,6 @@ |
595 | |
596 | __all__ = [] |
597 | |
598 | -from functools import partial |
599 | from inspect import getsource |
600 | from io import StringIO |
601 | import json |
602 | @@ -18,11 +17,13 @@ |
603 | check_output, |
604 | STDOUT, |
605 | ) |
606 | +import sys |
607 | from textwrap import dedent |
608 | import time |
609 | from unittest.mock import call |
610 | |
611 | from maastesting.factory import factory |
612 | +from maastesting.fixtures import TempDirectory |
613 | from maastesting.matchers import ( |
614 | MockCalledOnceWith, |
615 | MockCallsMatch, |
616 | @@ -115,6 +116,8 @@ |
617 | source = dedent(getsource(function)) |
618 | modcode = compile(source, "isolated.py", "exec") |
619 | namespace = {} if namespace is None else namespace |
620 | + if '__file__' not in namespace: |
621 | + namespace['__file__'] = __file__ |
622 | exec(modcode, namespace) |
623 | return namespace[function.__name__] |
624 | |
625 | @@ -373,9 +376,18 @@ |
626 | return output.encode("ascii") |
627 | |
628 | def call_gather_physical_block_devices( |
629 | - self, dev_disk_byid='/dev/disk/by-id/'): |
630 | + self, dev_disk_byid='/dev/disk/by-id/', file_path=None): |
631 | output = StringIO() |
632 | - namespace = {"print": partial(print, file=output)} |
633 | + |
634 | + def neutered_print(*args, **kwargs): |
635 | + file = kwargs.pop('file', None) |
636 | + if file is not None and file == sys.stderr: |
637 | + return |
638 | + return print(*args, **kwargs, file=output) |
639 | + |
640 | + namespace = {"print": neutered_print} |
641 | + if file_path is not None: |
642 | + namespace['__file__'] = file_path |
643 | gather_physical_block_devices = isolate_function( |
644 | node_info_module.gather_physical_block_devices, namespace) |
645 | gather_physical_block_devices(dev_disk_byid=dev_disk_byid) |
646 | @@ -885,6 +897,17 @@ |
647 | ] |
648 | self.assertEqual(names, device_names) |
649 | |
650 | + def test__quietly_exits_in_container(self): |
651 | + script_dir = self.useFixture(TempDirectory()).path |
652 | + script_path = os.path.join(script_dir, '00-maas-07-block-devices') |
653 | + virtuality_result_path = os.path.join(script_dir, 'out') |
654 | + os.makedirs(virtuality_result_path) |
655 | + virtuality_result_path = os.path.join( |
656 | + virtuality_result_path, '00-maas-02-virtuality') |
657 | + open(virtuality_result_path, 'w').write('lxc\n') |
658 | + self.assertItemsEqual( |
659 | + [], self.call_gather_physical_block_devices(file_path=script_path)) |
660 | + |
661 | |
662 | class TestVirtualityScript(MAASTestCase): |
663 | """Tests for `VIRTUALITY_SCRIPT`.""" |
664 | |
665 | === modified file 'src/provisioningserver/refresh/tests/test_refresh.py' |
666 | --- src/provisioningserver/refresh/tests/test_refresh.py 2017-01-28 00:51:47 +0000 |
667 | +++ src/provisioningserver/refresh/tests/test_refresh.py 2017-03-03 03:26:15 +0000 |
668 | @@ -236,10 +236,104 @@ |
669 | exit_status=0, |
670 | files={ |
671 | script_name: b'test script\n', |
672 | + '%s.out' % script_name: b'test script\n', |
673 | '%s.err' % script_name: b'', |
674 | }, |
675 | )) |
676 | |
677 | + def test_refresh_signals_failure_on_unexecutable_script(self): |
678 | + signal = self.patch(refresh, 'signal') |
679 | + script_name = factory.make_name('script_name') |
680 | + self.patch_scripts_failure(script_name) |
681 | + mock_popen = self.patch(refresh, 'Popen') |
682 | + mock_popen.side_effect = OSError(8, 'Exec format error') |
683 | + |
684 | + system_id = factory.make_name('system_id') |
685 | + consumer_key = factory.make_name('consumer_key') |
686 | + token_key = factory.make_name('token_key') |
687 | + token_secret = factory.make_name('token_secret') |
688 | + url = factory.make_url() |
689 | + |
690 | + refresh.refresh(system_id, consumer_key, token_key, token_secret, url) |
691 | + self.assertThat(signal, MockAnyCall( |
692 | + "%s/metadata/%s/" % (url, MD_VERSION), |
693 | + { |
694 | + 'consumer_secret': '', |
695 | + 'consumer_key': consumer_key, |
696 | + 'token_key': token_key, |
697 | + 'token_secret': token_secret, |
698 | + }, |
699 | + 'WORKING', |
700 | + 'Finished %s [1/1]: 8' % script_name, |
701 | + exit_status=8, |
702 | + files={ |
703 | + script_name: b'[Errno 8] Exec format error', |
704 | + '%s.err' % script_name: b'[Errno 8] Exec format error', |
705 | + }, |
706 | + )) |
707 | + |
708 | + def test_refresh_signals_failure_on_unexecutable_script_no_errno(self): |
709 | + signal = self.patch(refresh, 'signal') |
710 | + script_name = factory.make_name('script_name') |
711 | + self.patch_scripts_failure(script_name) |
712 | + mock_popen = self.patch(refresh, 'Popen') |
713 | + mock_popen.side_effect = OSError() |
714 | + |
715 | + system_id = factory.make_name('system_id') |
716 | + consumer_key = factory.make_name('consumer_key') |
717 | + token_key = factory.make_name('token_key') |
718 | + token_secret = factory.make_name('token_secret') |
719 | + url = factory.make_url() |
720 | + |
721 | + refresh.refresh(system_id, consumer_key, token_key, token_secret, url) |
722 | + self.assertThat(signal, MockAnyCall( |
723 | + "%s/metadata/%s/" % (url, MD_VERSION), |
724 | + { |
725 | + 'consumer_secret': '', |
726 | + 'consumer_key': consumer_key, |
727 | + 'token_key': token_key, |
728 | + 'token_secret': token_secret, |
729 | + }, |
730 | + 'WORKING', |
731 | + 'Finished %s [1/1]: 2' % script_name, |
732 | + exit_status=2, |
733 | + files={ |
734 | + script_name: b'Unable to execute script', |
735 | + '%s.err' % script_name: b'Unable to execute script', |
736 | + }, |
737 | + )) |
738 | + |
739 | + def test_refresh_signals_failure_on_unexecutable_script_baderrno(self): |
740 | + signal = self.patch(refresh, 'signal') |
741 | + script_name = factory.make_name('script_name') |
742 | + self.patch_scripts_failure(script_name) |
743 | + mock_popen = self.patch(refresh, 'Popen') |
744 | + mock_popen.side_effect = OSError(0, 'Exec format error') |
745 | + |
746 | + system_id = factory.make_name('system_id') |
747 | + consumer_key = factory.make_name('consumer_key') |
748 | + token_key = factory.make_name('token_key') |
749 | + token_secret = factory.make_name('token_secret') |
750 | + url = factory.make_url() |
751 | + |
752 | + refresh.refresh(system_id, consumer_key, token_key, token_secret, url) |
753 | + self.assertThat(signal, MockAnyCall( |
754 | + "%s/metadata/%s/" % (url, MD_VERSION), |
755 | + { |
756 | + 'consumer_secret': '', |
757 | + 'consumer_key': consumer_key, |
758 | + 'token_key': token_key, |
759 | + 'token_secret': token_secret, |
760 | + }, |
761 | + 'WORKING', |
762 | + 'Finished %s [1/1]: 2' % script_name, |
763 | + exit_status=2, |
764 | + files={ |
765 | + script_name: b'[Errno 0] Exec format error', |
766 | + '%s.err' % script_name: b'[Errno 0] Exec format error', |
767 | + }, |
768 | + )) |
769 | + |
770 | def test_refresh_signals_finished(self): |
771 | signal = self.patch(refresh, 'signal') |
772 | script_name = factory.make_name('script_name') |
lgtm! it mostly seems moving stuff around.