Merge lp:~ltrager/maas/combined_script_output into lp:~maas-committers/maas/trunk
- combined_script_output
- Merge into trunk
Status: | Merged |
---|---|
Approved by: | Andres Rodriguez |
Approved revision: | no longer in the source branch. |
Merged at revision: | 5764 |
Proposed branch: | lp:~ltrager/maas/combined_script_output |
Merge into: | lp:~maas-committers/maas/trunk |
Diff against target: |
1193 lines (+448/-73) 17 files modified
src/maasserver/api/results.py (+21/-3) src/maasserver/api/tests/test_commissioning.py (+32/-4) src/maasserver/enum.py (+2/-2) src/maasserver/migrations/builtin/maasserver/0106_testing_status.py (+2/-2) src/maasserver/testing/factory.py (+8/-3) src/maasserver/websockets/handlers/node.py (+13/-2) src/maasserver/websockets/handlers/tests/test_machine.py (+16/-0) src/maasserver/websockets/tests/test_protocol.py (+1/-1) src/metadataserver/api.py (+9/-6) src/metadataserver/migrations/builtin/0006_scriptresult_combined_output.py (+23/-0) src/metadataserver/models/scriptresult.py (+7/-2) src/metadataserver/models/tests/test_scriptresult.py (+32/-2) src/metadataserver/tests/test_api.py (+79/-25) src/metadataserver/tests/test_api_status.py (+5/-5) src/metadataserver/tests/test_api_twisted.py (+5/-5) src/metadataserver/user_data/templates/snippets/maas_run_remote_scripts.py (+64/-6) src/metadataserver/user_data/templates/snippets/tests/test_maas_run_remote_scripts.py (+129/-5) |
To merge this branch: | bzr merge lp:~ltrager/maas/combined_script_output |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Andres Rodriguez (community) | Approve | ||
Mike Pontillo (community) | Approve | ||
Gavin Panella (community) | Approve | ||
Review via email: mp+317926@code.launchpad.net |
Commit message
Capture stdout, stderr, and the combined output of all scripts run.
maas-run-
This also fixes 1664285 to return results base64 encoded and 1665478 to show the status as testing.
Description of the change
Lee Trager (ltrager) wrote : | # |
Thanks for the review I've added a test which forks sleep for 20s and makes sure it doesn't block.
Lee Trager (ltrager) : | # |
Blake Rouse (blake-rouse) wrote : | # |
It might be a good idea to run a set of builtin scripts as tests as well. That way we are 100% sure our builtin tests dont regress.
Gavin Panella (allenap) wrote : | # |
> finished_
> doesn't output anything we never get a zero byte chunk so the loop
> never exists. Adding proc.poll() fixes that but I found the loop would
> often exit while data was still coming in. So all tests ran but the
> lshw result was incomplete. I've added a test which forks sleep and
> doesn't output anything which should test both conditions.
>
> I also tried the code you have above while commissioning but it causes
> 00-maas-01-cpuinfo to become defunct and never proceed.
Ah, okay, I think I understand.
I've put some code at lp:~allenap/maas/ltrager--combined_script_output.
This refactors the existing tests (it does not ultimately change their
behaviour) and adds three new. The only one that really matters is
test__captures_
will discard output from an already-completed process. The tests also
iterate 20 times to shake out additional failures: some of the other
tests fail for this same underlying reason.
This branch also has a version of capture_
consistently, over a few thousand iterations, passes these tests.
See the diff at https:/
Lee Trager (ltrager) wrote : | # |
Thanks for the review and reworking the tests. The updated capture_
I was also wondering if we want to land reruning the tests 20 times or if that was just for testing?
Gavin Panella (allenap) wrote : | # |
The new test, test__captures_
4, because lshw reports a different frequency for my CPU each time
around.
However, lshw will produce XML with the -xml flag, and we can check that
the output is well-formed to be reasonably sure that we've captured
everything. Hence:
def test__captures_
proc = Popen(("lshw", "-xml"), stdout=PIPE, stderr=PIPE)
returncode, stdout, stderr, combined = self.capture(proc)
# This is a complete XML document; we've captured all output.
However, this hangs. Killing with ctrl-c always shows that it's hanging
at:
chunk = key.fileobj.
which, eventually, after some more reading and experimentation, made me
realise that we should be reading from the _raw_ file object. There's
more on why in the code.
It also occurred to me that we should make sure to read each pipe's
buffer in full each time. PIPE_BUF is only 4096, which is an outdated
figure: the default buffer size on Linux is now 65536 bytes; see
pipe(7). It can be adjusted up to 1048576 bytes, or even more by
privileged processes.
To get the size of a buffer we need to do fcntl(fd, F_GETPIPE_SZ), but
F_GETPIPE_SZ is not defined in Python so there's no "proper" way to find
out the size of a pipe's buffer from Python. I decided to assume that
we'll always be working with buffers of no more than 1048576 bytes. To
be honest, I expect that we will only ever work with 65536 byte buffers,
but I want this to be as defensively coded as I can make it.
The 1048576 figure is actually defined in /proc/sys/
that file is now read on import and the figure used by
_select_
An updated diff can be see at https:/
Lee Trager (ltrager) wrote : | # |
Thanks again for the help. I do my development in a VM that doesn't do CPU scaling which is why the previous lshw test worked for me. I've merged your changed and verified commissioning works and captures everything in my test environment.
Gavin Panella (allenap) wrote : | # |
Cool! After all the effort we put in I feel like bottling it and putting it somewhere safe.
Mike Pontillo (mpontillo) wrote : | # |
I won't block this branch from landing, but I do have one question on this.
Some context: I was just looking at gathering information from `dmidecode` during commissioning. The `dmidecode` binary is chatty. At first I was thinking I should run several commissioning scripts: one to dump the data, and one to present it in various formats. But that's inconsistent with the other scripts.
Currently I have a single script with some different sections I can identify later when parsing the file, such as:
-----BEGIN FOO-----
stdout-0
stdout-1
-----END FOO-----
I started to worry, though: what if stderr output is unintentionally interspersed, such as:
-----BEGIN FOO-----
stdout-0
WARNING: could not look up hostname for unglamorous-
stdout-1
WARNING: could not look up hostname for unglamorous-
-----END FOO-----
That will mess up my parser if it happens, but the previous behavior would have been for the output to be safely hidden in the .err file, no?
Andres Rodriguez (andreserl) wrote : | # |
Uhmmmm this brings a good point because there are major differences between what a commissioning script output vs a testing script output.
The goal of the commissioning output being stored, is in reality a *result* that can be processed by MAAS.
For example:
-------
[
{
"RO": "1",
"ID_PATH": "/dev/disk/
"MODEL": "VIRTUAL-DISK",
"RM": "0",
"PATH": "/dev/sdb",
"BLOCK_SIZE": "1024",
"SERIAL": "60000000000000
"SIZE": "187834368",
"ROTA": "1",
"NAME": "sdb"
},
]
-------
sudo: unable to resolve host node01
sudo: unable to resolve host node01
sudo: unable to resolve host node01
Combining both of the above would obviously break processing of output, for example, this would be really wrong:
-------
sudo: unable to resolve host node01
sudo: unable to resolve host node01
[
{
"RO": "1",
"ID_PATH": "/dev/disk/
"MODEL": "VIRTUAL-DISK",
"RM": "0",
"PATH": "/dev/sdb",
"BLOCK_SIZE": "1024",
"SERIAL": "60000000000000
"SIZE": "187834368",
"ROTA": "1",
"NAME": "sdb"
},
]
sudo: unable to resolve host node01
-------
So, I'd imagine that we care about two things:
1. The *result*
2. The script *log*
So, for commissioning we only care about the *result*, i.e:
* a list of block devices
* list of interfaces
* lshw output (the result of lshw)
* true/false/none
* etc.
For 'Testing' we care about the *result* & the script *output* as a **log**.
Result examples:
pass/fail (for testing scripts that we dont need to gather any data other than pass/fail)
<output> (which could be, say, the performance of disk writing/reading).
Output log:
-----smartctl-----
smartctl --XYZ
<output>
smartctl --ABC
<output>
------------------
------------------
Lee Trager (ltrager) wrote : | # |
What this branch does is capture the combined output in addition to stdout and stderr. The combined output is what we give to the user by default. MAAS still runs the hooks on the pure stdout data[1]. So this change will not effect commissioning scripts.
[1] See src/metadataser
Mike Pontillo (mpontillo) wrote : | # |
Thanks for the clarification. Sounds good to me!
Andres Rodriguez (andreserl) wrote : | # |
On the API, what's the output the "data" field? Is this the combined output? If so, this is a regression for commissioning as we would be changing the output.
That said, this makes me realize that while "data" for a commissioning script is the *result* for a testing script would be random output... this feels like testing should have :
1. Result of a test, like passed/failed or the for example performance values
2. Log of the script run and the whole output of what's running (similar to our Jenkins log)
Lee Trager (ltrager) wrote : | # |
API and websocket have been updated to show both stdout and stderr.
Andres Rodriguez (andreserl) wrote : | # |
I'm good with this now!
MAAS Lander (maas-lander) wrote : | # |
The attempt to merge lp:~ltrager/maas/combined_script_output 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 (640 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-neti...
MAAS Lander (maas-lander) wrote : | # |
The attempt to merge lp:~ltrager/maas/combined_script_output 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 (593 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-neti...
MAAS Lander (maas-lander) wrote : | # |
The attempt to merge lp:~ltrager/maas/combined_script_output 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 (617 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-neti...
Preview Diff
1 | === modified file 'src/maasserver/api/results.py' |
2 | --- src/maasserver/api/results.py 2017-02-17 14:23:04 +0000 |
3 | +++ src/maasserver/api/results.py 2017-02-28 23:55:43 +0000 |
4 | @@ -7,6 +7,9 @@ |
5 | 'NodeResultsHandler', |
6 | ] |
7 | |
8 | +from base64 import b64encode |
9 | + |
10 | +from django.core.urlresolvers import reverse |
11 | from formencode.validators import Int |
12 | from maasserver.api.support import OperationsHandler |
13 | from maasserver.api.utils import ( |
14 | @@ -27,7 +30,7 @@ |
15 | model = ScriptResult |
16 | fields = ( |
17 | 'name', 'script_result', 'result_type', 'updated', 'created', |
18 | - 'node', 'data') |
19 | + 'node', 'data', 'resource_uri') |
20 | |
21 | def read(self, request): |
22 | """List NodeResult visible to the user, optionally filtered. |
23 | @@ -63,6 +66,8 @@ |
24 | # Convert to a set; it's used for membership testing. |
25 | names = set(names) |
26 | |
27 | + resource_uri = reverse('commissioning_scripts_handler') |
28 | + |
29 | results = [] |
30 | for script_set in script_sets: |
31 | if (result_type is not None and |
32 | @@ -74,6 +79,17 @@ |
33 | SCRIPT_STATUS.TIMEOUT, SCRIPT_STATUS.ABORTED)): |
34 | if names is not None and script_result.name not in names: |
35 | continue |
36 | + # MAAS stores stdout, stderr, and the combined output. The |
37 | + # metadata API determine which field uploaded data should go |
38 | + # into based on the extention of the uploaded file. .out goes |
39 | + # to stdout, .err goes to stderr, otherwise its assumed the |
40 | + # data is combined. Curtin uploads the installation log as |
41 | + # install.log so its stored as a combined result. This ensures |
42 | + # a result is always returned. |
43 | + if script_result.stdout != b'': |
44 | + data = b64encode(script_result.stdout) |
45 | + else: |
46 | + data = b64encode(script_result.output) |
47 | results.append({ |
48 | 'created': script_result.created, |
49 | 'updated': script_result.updated, |
50 | @@ -82,7 +98,8 @@ |
51 | 'script_result': script_result.exit_status, |
52 | 'result_type': script_set.result_type, |
53 | 'node': {'system_id': script_set.node.system_id}, |
54 | - 'data': script_result.stdout.decode('utf-8'), |
55 | + 'data': data, |
56 | + 'resource_uri': resource_uri, |
57 | }) |
58 | if script_result.stderr != b'': |
59 | results.append({ |
60 | @@ -93,7 +110,8 @@ |
61 | 'script_result': script_result.exit_status, |
62 | 'result_type': script_set.result_type, |
63 | 'node': {'system_id': script_set.node.system_id}, |
64 | - 'data': script_result.stderr.decode('utf-8'), |
65 | + 'data': b64encode(script_result.stderr), |
66 | + 'resource_uri': resource_uri, |
67 | }) |
68 | |
69 | return results |
70 | |
71 | === modified file 'src/maasserver/api/tests/test_commissioning.py' |
72 | --- src/maasserver/api/tests/test_commissioning.py 2017-02-17 14:23:04 +0000 |
73 | +++ src/maasserver/api/tests/test_commissioning.py 2017-02-28 23:55:43 +0000 |
74 | @@ -5,7 +5,10 @@ |
75 | |
76 | __all__ = [] |
77 | |
78 | -from base64 import b64encode |
79 | +from base64 import ( |
80 | + b64decode, |
81 | + b64encode, |
82 | +) |
83 | import http.client |
84 | from itertools import chain |
85 | import random |
86 | @@ -153,9 +156,11 @@ |
87 | class NodeCommissionResultHandlerAPITest(APITestCase.ForUser): |
88 | |
89 | def store_result( |
90 | - self, script_result, stdout=None, stderr=None, |
91 | + self, script_result, output=None, stdout=None, stderr=None, |
92 | **kwargs): |
93 | # Create a test store_result which doesn't run the script hooks. |
94 | + if output is not None: |
95 | + script_result.output = Bin(output.encode('utf-8')) |
96 | if stdout is not None: |
97 | script_result.stdout = Bin(stdout.encode('utf-8')) |
98 | if stderr is not None: |
99 | @@ -195,7 +200,7 @@ |
100 | len(parsed_results)) |
101 | self.assertItemsEqual([ |
102 | 'created', 'updated', 'id', 'name', 'script_result', 'result_type', |
103 | - 'node', 'data'], parsed_result.keys()) |
104 | + 'node', 'data', 'resource_uri'], parsed_result.keys()) |
105 | self.assertEquals(script_result.id, parsed_result['id']) |
106 | self.assertEquals(script_result.name, parsed_result['name']) |
107 | self.assertEquals( |
108 | @@ -205,7 +210,7 @@ |
109 | {'system_id': script_set.node.system_id}, |
110 | parsed_result['node']) |
111 | self.assertEquals( |
112 | - script_result.stdout.decode('utf-8'), parsed_result['data']) |
113 | + script_result.stdout, b64decode(parsed_result['data'])) |
114 | |
115 | def test_list_returns_with_multiple_empty_data(self): |
116 | node = factory.make_Node(with_empty_script_sets=True) |
117 | @@ -253,6 +258,29 @@ |
118 | ], |
119 | [parsed_result['data'] for parsed_result in parsed_results]) |
120 | |
121 | + def test_list_returns_output_if_stdout_empty(self): |
122 | + node = factory.make_Node(with_empty_script_sets=True) |
123 | + for script_result in chain( |
124 | + node.current_commissioning_script_set, |
125 | + node.current_testing_script_set, |
126 | + node.current_installation_script_set): |
127 | + self.store_result( |
128 | + script_result, exit_status=0, output=factory.make_string()) |
129 | + |
130 | + url = reverse('node_results_handler') |
131 | + response = self.client.get(url, {'system_id': [node.system_id]}) |
132 | + self.assertThat(response, HasStatusCode(http.client.OK)) |
133 | + parsed_results = json_load_bytes(response.content) |
134 | + self.assertItemsEqual( |
135 | + [ |
136 | + b64encode(script_result.output).decode() |
137 | + for script_result in chain( |
138 | + node.current_commissioning_script_set, |
139 | + node.current_testing_script_set, |
140 | + node.current_installation_script_set) |
141 | + ], |
142 | + [parsed_result['data'] for parsed_result in parsed_results]) |
143 | + |
144 | def test_list_shows_all_latest_results(self): |
145 | # XXX ltrager 2017-01-23 - Needed until builtin tests are added. |
146 | factory.make_Script(script_type=SCRIPT_TYPE.TESTING) |
147 | |
148 | === modified file 'src/maasserver/enum.py' |
149 | --- src/maasserver/enum.py 2017-02-17 21:27:46 +0000 |
150 | +++ src/maasserver/enum.py 2017-02-28 23:55:43 +0000 |
151 | @@ -126,8 +126,8 @@ |
152 | (NODE_STATUS.FAILED_ENTERING_RESCUE_MODE, "Failed to enter rescue mode"), |
153 | (NODE_STATUS.EXITING_RESCUE_MODE, "Exiting rescue mode"), |
154 | (NODE_STATUS.FAILED_EXITING_RESCUE_MODE, "Failed to exit rescue mode"), |
155 | - (NODE_STATUS.TESTING, "Testing node"), |
156 | - (NODE_STATUS.FAILED_TESTING, "Tests failed on node"), |
157 | + (NODE_STATUS.TESTING, "Testing"), |
158 | + (NODE_STATUS.FAILED_TESTING, "Failed testing"), |
159 | ) |
160 | |
161 | |
162 | |
163 | === modified file 'src/maasserver/migrations/builtin/maasserver/0106_testing_status.py' |
164 | --- src/maasserver/migrations/builtin/maasserver/0106_testing_status.py 2017-01-09 02:12:00 +0000 |
165 | +++ src/maasserver/migrations/builtin/maasserver/0106_testing_status.py 2017-02-28 23:55:43 +0000 |
166 | @@ -17,11 +17,11 @@ |
167 | migrations.AlterField( |
168 | model_name='node', |
169 | name='previous_status', |
170 | - field=models.IntegerField(editable=False, default=0, choices=[(0, 'New'), (1, 'Commissioning'), (2, 'Failed commissioning'), (3, 'Missing'), (4, 'Ready'), (5, 'Reserved'), (10, 'Allocated'), (9, 'Deploying'), (6, 'Deployed'), (7, 'Retired'), (8, 'Broken'), (11, 'Failed deployment'), (12, 'Releasing'), (13, 'Releasing failed'), (14, 'Disk erasing'), (15, 'Failed disk erasing'), (16, 'Rescue mode'), (17, 'Entering rescue mode'), (18, 'Failed to enter rescue mode'), (19, 'Exiting rescue mode'), (20, 'Failed to exit rescue mode'), (21, 'Testing node'), (22, 'Tests failed on node')]), |
171 | + field=models.IntegerField(editable=False, default=0, choices=[(0, 'New'), (1, 'Commissioning'), (2, 'Failed commissioning'), (3, 'Missing'), (4, 'Ready'), (5, 'Reserved'), (10, 'Allocated'), (9, 'Deploying'), (6, 'Deployed'), (7, 'Retired'), (8, 'Broken'), (11, 'Failed deployment'), (12, 'Releasing'), (13, 'Releasing failed'), (14, 'Disk erasing'), (15, 'Failed disk erasing'), (16, 'Rescue mode'), (17, 'Entering rescue mode'), (18, 'Failed to enter rescue mode'), (19, 'Exiting rescue mode'), (20, 'Failed to exit rescue mode'), (21, 'Testing'), (22, 'Failed testing')]), |
172 | ), |
173 | migrations.AlterField( |
174 | model_name='node', |
175 | name='status', |
176 | - field=models.IntegerField(editable=False, default=0, choices=[(0, 'New'), (1, 'Commissioning'), (2, 'Failed commissioning'), (3, 'Missing'), (4, 'Ready'), (5, 'Reserved'), (10, 'Allocated'), (9, 'Deploying'), (6, 'Deployed'), (7, 'Retired'), (8, 'Broken'), (11, 'Failed deployment'), (12, 'Releasing'), (13, 'Releasing failed'), (14, 'Disk erasing'), (15, 'Failed disk erasing'), (16, 'Rescue mode'), (17, 'Entering rescue mode'), (18, 'Failed to enter rescue mode'), (19, 'Exiting rescue mode'), (20, 'Failed to exit rescue mode'), (21, 'Testing node'), (22, 'Tests failed on node')]), |
177 | + field=models.IntegerField(editable=False, default=0, choices=[(0, 'New'), (1, 'Commissioning'), (2, 'Failed commissioning'), (3, 'Missing'), (4, 'Ready'), (5, 'Reserved'), (10, 'Allocated'), (9, 'Deploying'), (6, 'Deployed'), (7, 'Retired'), (8, 'Broken'), (11, 'Failed deployment'), (12, 'Releasing'), (13, 'Releasing failed'), (14, 'Disk erasing'), (15, 'Failed disk erasing'), (16, 'Rescue mode'), (17, 'Entering rescue mode'), (18, 'Failed to enter rescue mode'), (19, 'Exiting rescue mode'), (20, 'Failed to exit rescue mode'), (21, 'Testing'), (22, 'Failed testing')]), |
178 | ), |
179 | ] |
180 | |
181 | === modified file 'src/maasserver/testing/factory.py' |
182 | --- src/maasserver/testing/factory.py 2017-02-17 21:27:46 +0000 |
183 | +++ src/maasserver/testing/factory.py 2017-02-28 23:55:43 +0000 |
184 | @@ -675,8 +675,8 @@ |
185 | |
186 | def make_ScriptResult( |
187 | self, script_set=None, script=None, script_version=None, |
188 | - status=None, exit_status=None, script_name=None, stdout=None, |
189 | - stderr=None, result=None): |
190 | + status=None, exit_status=None, script_name=None, output=None, |
191 | + stdout=None, stderr=None, result=None): |
192 | if script_set is None: |
193 | script_set = self.make_ScriptSet() |
194 | if script is None and script_name is None: |
195 | @@ -685,6 +685,8 @@ |
196 | status = self.pick_choice(SCRIPT_STATUS_CHOICES) |
197 | if status in (SCRIPT_STATUS.PENDING, SCRIPT_STATUS.RUNNING): |
198 | # Pending and running script results shouldn't have results stored. |
199 | + if output is None: |
200 | + output = b'' |
201 | if stdout is None: |
202 | stdout = b'' |
203 | if stderr is None: |
204 | @@ -694,6 +696,8 @@ |
205 | else: |
206 | if exit_status is None: |
207 | exit_status = random.randint(0, 255) |
208 | + if output is None: |
209 | + output = self.make_string().encode('utf-8') |
210 | if stdout is None: |
211 | stdout = self.make_string().encode('utf-8') |
212 | if stderr is None: |
213 | @@ -706,7 +710,8 @@ |
214 | script_set=script_set, script=script, |
215 | script_version=script_version, status=status, |
216 | exit_status=exit_status, script_name=script_name, |
217 | - stdout=Bin(stdout), stderr=Bin(stderr), result=result) |
218 | + output=Bin(output), stdout=Bin(stdout), stderr=Bin(stderr), |
219 | + result=result) |
220 | |
221 | def make_MAC(self): |
222 | """Generate a random MAC address, in the form of a MAC object.""" |
223 | |
224 | === modified file 'src/maasserver/websockets/handlers/node.py' |
225 | --- src/maasserver/websockets/handlers/node.py 2017-02-17 14:23:04 +0000 |
226 | +++ src/maasserver/websockets/handlers/node.py 2017-02-28 23:55:43 +0000 |
227 | @@ -423,12 +423,23 @@ |
228 | # updated when the UI has an understanding of script_sets and |
229 | # script_results. |
230 | for script_result in script_set: |
231 | + # MAAS stores stdout, stderr, and the combined output. The |
232 | + # metadata API determine which field uploaded data should go |
233 | + # into based on the extention of the uploaded file. .out goes |
234 | + # to stdout, .err goes to stderr, otherwise its assumed the |
235 | + # data is combined. Curtin uploads the installation log as |
236 | + # install.log so its stored as a combined result. This ensures |
237 | + # a result is always returned. |
238 | + if script_result.stdout != b'': |
239 | + data = script_result.stdout |
240 | + else: |
241 | + data = script_result.output |
242 | ret.append({ |
243 | 'id': script_result.id, |
244 | 'result': script_result.exit_status, |
245 | 'name': script_result.name, |
246 | - 'data': script_result.stdout, |
247 | - 'line_count': len(script_result.stdout.splitlines()), |
248 | + 'data': data, |
249 | + 'line_count': len(data.splitlines()), |
250 | 'created': dehydrate_datetime(script_result.updated), |
251 | }) |
252 | if script_result.stderr != b'': |
253 | |
254 | === modified file 'src/maasserver/websockets/handlers/tests/test_machine.py' |
255 | --- src/maasserver/websockets/handlers/tests/test_machine.py 2017-02-17 14:23:04 +0000 |
256 | +++ src/maasserver/websockets/handlers/tests/test_machine.py 2017-02-28 23:55:43 +0000 |
257 | @@ -784,6 +784,22 @@ |
258 | 'created': dehydrate_datetime(script_result.updated), |
259 | }], handler.dehydrate_script_set(script_result.script_set)) |
260 | |
261 | + def test_dehydrate_script_set_returns_output_if_stdout_empty(self): |
262 | + owner = factory.make_User() |
263 | + handler = MachineHandler(owner, {}) |
264 | + script_result = factory.make_ScriptResult( |
265 | + status=SCRIPT_STATUS.PASSED, stdout=''.encode(), |
266 | + stderr=''.encode(), output=factory.make_string().encode()) |
267 | + self.assertDictEqual( |
268 | + { |
269 | + 'id': script_result.id, |
270 | + 'result': script_result.exit_status, |
271 | + 'name': script_result.name, |
272 | + 'data': script_result.output, |
273 | + 'line_count': len(script_result.output.splitlines()), |
274 | + 'created': dehydrate_datetime(script_result.updated), |
275 | + }, handler.dehydrate_script_set(script_result.script_set)[0]) |
276 | + |
277 | def test_dehydrate_events_only_includes_lastest_50(self): |
278 | owner = factory.make_User() |
279 | node = factory.make_Node(owner=owner) |
280 | |
281 | === modified file 'src/maasserver/websockets/tests/test_protocol.py' |
282 | --- src/maasserver/websockets/tests/test_protocol.py 2017-02-24 19:08:57 +0000 |
283 | +++ src/maasserver/websockets/tests/test_protocol.py 2017-02-28 23:55:43 +0000 |
284 | @@ -495,7 +495,7 @@ |
285 | script_set = node.current_commissioning_script_set |
286 | script_result = script_set.find_script_result( |
287 | script_name=LSHW_OUTPUT_NAME) |
288 | - script_result.store_result(exit_status=0, stdout=fake_lshw) |
289 | + script_result.store_result(exit_status=0, output=fake_lshw) |
290 | return node |
291 | |
292 | @wait_for_reactor |
293 | |
294 | === modified file 'src/metadataserver/api.py' |
295 | --- src/metadataserver/api.py 2017-02-28 21:20:49 +0000 |
296 | +++ src/metadataserver/api.py 2017-02-28 23:55:43 +0000 |
297 | @@ -215,15 +215,18 @@ |
298 | script_result_id = get_optional_param( |
299 | request, 'script_result_id', None, Int) |
300 | |
301 | - # The .err indicates this should be stored in the STDERR column of the |
302 | - # ScriptResult. When finding or creating a ScriptResult don't include the |
303 | - # .err in the name. If given, we look up by script_result_id along with |
304 | - # the name to allow .err in the name. |
305 | - if script_name.endswith('.err'): |
306 | + # The .out or .err indicates this should be stored in the stdout or stderr |
307 | + # column of ScriptResult. If neither are given put it in the combined |
308 | + # output column. If given, we look up by script_result_id along with the |
309 | + # name to allow .out or .err in the name. |
310 | + if script_name.endswith('.out'): |
311 | + script_name = script_name[0:-4] |
312 | + key = 'stdout' |
313 | + elif script_name.endswith('.err'): |
314 | script_name = script_name[0:-4] |
315 | key = 'stderr' |
316 | else: |
317 | - key = 'stdout' |
318 | + key = 'output' |
319 | |
320 | try: |
321 | script_result = script_set.scriptresult_set.get(id=script_result_id) |
322 | |
323 | === added file 'src/metadataserver/migrations/builtin/0006_scriptresult_combined_output.py' |
324 | --- src/metadataserver/migrations/builtin/0006_scriptresult_combined_output.py 1970-01-01 00:00:00 +0000 |
325 | +++ src/metadataserver/migrations/builtin/0006_scriptresult_combined_output.py 2017-02-28 23:55:43 +0000 |
326 | @@ -0,0 +1,23 @@ |
327 | +# -*- coding: utf-8 -*- |
328 | +from __future__ import unicode_literals |
329 | + |
330 | +from django.db import ( |
331 | + migrations, |
332 | + models, |
333 | +) |
334 | +import metadataserver.fields |
335 | + |
336 | + |
337 | +class Migration(migrations.Migration): |
338 | + |
339 | + dependencies = [ |
340 | + ('metadataserver', '0005_store_powerstate_on_scriptset_creation'), |
341 | + ] |
342 | + |
343 | + operations = [ |
344 | + migrations.AddField( |
345 | + model_name='scriptresult', |
346 | + name='output', |
347 | + field=metadataserver.fields.BinaryField(blank=True, default=b'', max_length=1048576), |
348 | + ), |
349 | + ] |
350 | |
351 | === modified file 'src/metadataserver/models/scriptresult.py' |
352 | --- src/metadataserver/models/scriptresult.py 2017-01-24 08:38:40 +0000 |
353 | +++ src/metadataserver/models/scriptresult.py 2017-02-28 23:55:43 +0000 |
354 | @@ -62,6 +62,8 @@ |
355 | script_name = CharField( |
356 | max_length=255, unique=False, editable=False, null=True) |
357 | |
358 | + output = BinaryField(max_length=1024 * 1024, blank=True, default=b'') |
359 | + |
360 | stdout = BinaryField(max_length=1024 * 1024, blank=True, default=b'') |
361 | |
362 | stderr = BinaryField(max_length=1024 * 1024, blank=True, default=b'') |
363 | @@ -82,8 +84,8 @@ |
364 | return "%s/%s" % (self.script_set.node.system_id, self.name) |
365 | |
366 | def store_result( |
367 | - self, exit_status, stdout=None, stderr=None, result=None, |
368 | - script_version_id=None): |
369 | + self, exit_status, output=None, stdout=None, stderr=None, |
370 | + result=None, script_version_id=None): |
371 | # Don't allow ScriptResults to be overwritten unless the node is a |
372 | # controller. Controllers are allowed to overwrite their results to |
373 | # prevent new ScriptSets being created everytime a controller starts. |
374 | @@ -94,6 +96,7 @@ |
375 | # inform MAAS the Script was being run, it just uploaded results. |
376 | assert self.status in ( |
377 | SCRIPT_STATUS.PENDING, SCRIPT_STATUS.RUNNING) |
378 | + assert self.output == b'' |
379 | assert self.stdout == b'' |
380 | assert self.stderr == b'' |
381 | assert self.result == '' |
382 | @@ -104,6 +107,8 @@ |
383 | self.status = SCRIPT_STATUS.PASSED |
384 | else: |
385 | self.status = SCRIPT_STATUS.FAILED |
386 | + if output is not None: |
387 | + self.output = Bin(output) |
388 | if stdout is not None: |
389 | self.stdout = Bin(stdout) |
390 | if stderr is not None: |
391 | |
392 | === modified file 'src/metadataserver/models/tests/test_scriptresult.py' |
393 | --- src/metadataserver/models/tests/test_scriptresult.py 2017-01-24 08:22:16 +0000 |
394 | +++ src/metadataserver/models/tests/test_scriptresult.py 2017-02-28 23:55:43 +0000 |
395 | @@ -53,6 +53,12 @@ |
396 | self.assertRaises( |
397 | AssertionError, script_result.store_result, random.randint(0, 255)) |
398 | |
399 | + def test_store_result_only_allows_when_output_is_blank(self): |
400 | + script_result = factory.make_ScriptResult( |
401 | + status=SCRIPT_STATUS.RUNNING, output=factory.make_bytes()) |
402 | + self.assertRaises( |
403 | + AssertionError, script_result.store_result, random.randint(0, 255)) |
404 | + |
405 | def test_store_result_only_allows_when_stdout_is_blank(self): |
406 | script_result = factory.make_ScriptResult( |
407 | status=SCRIPT_STATUS.RUNNING, stdout=factory.make_bytes()) |
408 | @@ -79,16 +85,18 @@ |
409 | script_result = factory.make_ScriptResult( |
410 | script_set=script_set, status=SCRIPT_STATUS.PASSED) |
411 | exit_status = random.randint(0, 255) |
412 | + output = factory.make_bytes() |
413 | stdout = factory.make_bytes() |
414 | stderr = factory.make_bytes() |
415 | result = factory.make_string() |
416 | |
417 | script_result.store_result( |
418 | random.randint(0, 255), factory.make_bytes(), factory.make_bytes(), |
419 | - factory.make_string()) |
420 | - script_result.store_result(exit_status, stdout, stderr, result) |
421 | + factory.make_bytes(), factory.make_string()) |
422 | + script_result.store_result(exit_status, output, stdout, stderr, result) |
423 | |
424 | self.assertEquals(exit_status, script_result.exit_status) |
425 | + self.assertEquals(output, script_result.output) |
426 | self.assertEquals(stdout, script_result.stdout) |
427 | self.assertEquals(stderr, script_result.stderr) |
428 | self.assertEquals(result, script_result.result) |
429 | @@ -98,6 +106,7 @@ |
430 | script_result.store_result(0) |
431 | self.assertEquals(SCRIPT_STATUS.PASSED, script_result.status) |
432 | self.assertEquals(0, script_result.exit_status) |
433 | + self.assertEquals(b'', script_result.output) |
434 | self.assertEquals(b'', script_result.stdout) |
435 | self.assertEquals(b'', script_result.stderr) |
436 | self.assertEquals('', script_result.result) |
437 | @@ -110,6 +119,22 @@ |
438 | script_result.store_result(exit_status) |
439 | self.assertEquals(SCRIPT_STATUS.FAILED, script_result.status) |
440 | self.assertEquals(exit_status, script_result.exit_status) |
441 | + self.assertEquals(b'', script_result.output) |
442 | + self.assertEquals(b'', script_result.stdout) |
443 | + self.assertEquals(b'', script_result.stderr) |
444 | + self.assertEquals('', script_result.result) |
445 | + self.assertEquals( |
446 | + script_result.script.script, script_result.script_version) |
447 | + |
448 | + def test_store_result_stores_output(self): |
449 | + script_result = factory.make_ScriptResult(status=SCRIPT_STATUS.RUNNING) |
450 | + exit_status = random.randint(0, 255) |
451 | + output = factory.make_bytes() |
452 | + |
453 | + script_result.store_result(exit_status, output=output) |
454 | + |
455 | + self.assertEquals(exit_status, script_result.exit_status) |
456 | + self.assertEquals(output, script_result.output) |
457 | self.assertEquals(b'', script_result.stdout) |
458 | self.assertEquals(b'', script_result.stderr) |
459 | self.assertEquals('', script_result.result) |
460 | @@ -124,6 +149,7 @@ |
461 | script_result.store_result(exit_status, stdout=stdout) |
462 | |
463 | self.assertEquals(exit_status, script_result.exit_status) |
464 | + self.assertEquals(b'', script_result.output) |
465 | self.assertEquals(stdout, script_result.stdout) |
466 | self.assertEquals(b'', script_result.stderr) |
467 | self.assertEquals('', script_result.result) |
468 | @@ -138,6 +164,7 @@ |
469 | script_result.store_result(exit_status, stderr=stderr) |
470 | |
471 | self.assertEquals(exit_status, script_result.exit_status) |
472 | + self.assertEquals(b'', script_result.output) |
473 | self.assertEquals(b'', script_result.stdout) |
474 | self.assertEquals(stderr, script_result.stderr) |
475 | self.assertEquals('', script_result.result) |
476 | @@ -152,6 +179,7 @@ |
477 | script_result.store_result(exit_status, result=json.dumps(result)) |
478 | |
479 | self.assertEquals(exit_status, script_result.exit_status) |
480 | + self.assertEquals(b'', script_result.output) |
481 | self.assertEquals(b'', script_result.stdout) |
482 | self.assertEquals(b'', script_result.stderr) |
483 | self.assertEqual(result, script_result.result) |
484 | @@ -171,6 +199,7 @@ |
485 | exit_status, script_version_id=old_version.id) |
486 | |
487 | self.assertEquals(exit_status, script_result.exit_status) |
488 | + self.assertEquals(b'', script_result.output) |
489 | self.assertEquals(b'', script_result.stdout) |
490 | self.assertEquals(b'', script_result.stderr) |
491 | self.assertEquals('', script_result.result) |
492 | @@ -185,6 +214,7 @@ |
493 | script_result.store_result(exit_status) |
494 | |
495 | self.assertEquals(exit_status, script_result.exit_status) |
496 | + self.assertEquals(b'', script_result.output) |
497 | self.assertEquals(b'', script_result.stdout) |
498 | self.assertEquals(b'', script_result.stderr) |
499 | self.assertEquals('', script_result.result) |
500 | |
501 | === modified file 'src/metadataserver/tests/test_api.py' |
502 | --- src/metadataserver/tests/test_api.py 2017-02-17 14:23:04 +0000 |
503 | +++ src/metadataserver/tests/test_api.py 2017-02-28 23:55:43 +0000 |
504 | @@ -235,6 +235,50 @@ |
505 | self.assertEqual( |
506 | EVENT_TYPES.REQUEST_CONTROLLER_REFRESH, event.type.name) |
507 | |
508 | + def test_process_file_creates_new_entry_for_output(self): |
509 | + results = {} |
510 | + script_result = factory.make_ScriptResult(status=SCRIPT_STATUS.RUNNING) |
511 | + output = factory.make_string() |
512 | + request = {'exit_status': random.randint(0, 255)} |
513 | + |
514 | + process_file( |
515 | + results, script_result.script_set, script_result.name, output, |
516 | + request) |
517 | + |
518 | + self.assertDictEqual( |
519 | + { |
520 | + 'exit_status': request['exit_status'], |
521 | + 'output': output, |
522 | + }, |
523 | + results[script_result]) |
524 | + |
525 | + def test_process_file_creates_adds_field_for_output(self): |
526 | + results = {} |
527 | + script_result = factory.make_ScriptResult(status=SCRIPT_STATUS.RUNNING) |
528 | + output = factory.make_string() |
529 | + stdout = factory.make_string() |
530 | + stderr = factory.make_string() |
531 | + request = {'exit_status': random.randint(0, 255)} |
532 | + |
533 | + process_file( |
534 | + results, script_result.script_set, '%s.out' % script_result.name, |
535 | + stdout, request) |
536 | + process_file( |
537 | + results, script_result.script_set, '%s.err' % script_result.name, |
538 | + stderr, request) |
539 | + process_file( |
540 | + results, script_result.script_set, script_result.name, output, |
541 | + request) |
542 | + |
543 | + self.assertDictEqual( |
544 | + { |
545 | + 'exit_status': request['exit_status'], |
546 | + 'output': output, |
547 | + 'stdout': stdout, |
548 | + 'stderr': stderr, |
549 | + }, |
550 | + results[script_result]) |
551 | + |
552 | def test_process_file_creates_new_entry_for_stdout(self): |
553 | results = {} |
554 | script_result = factory.make_ScriptResult(status=SCRIPT_STATUS.RUNNING) |
555 | @@ -242,8 +286,8 @@ |
556 | request = {'exit_status': random.randint(0, 255)} |
557 | |
558 | process_file( |
559 | - results, script_result.script_set, script_result.name, stdout, |
560 | - request) |
561 | + results, script_result.script_set, '%s.out' % script_result.name, |
562 | + stdout, request) |
563 | |
564 | self.assertDictEqual( |
565 | { |
566 | @@ -255,20 +299,25 @@ |
567 | def test_process_file_creates_adds_field_for_stdout(self): |
568 | results = {} |
569 | script_result = factory.make_ScriptResult(status=SCRIPT_STATUS.RUNNING) |
570 | + output = factory.make_string() |
571 | stdout = factory.make_string() |
572 | stderr = factory.make_string() |
573 | request = {'exit_status': random.randint(0, 255)} |
574 | |
575 | process_file( |
576 | + results, script_result.script_set, script_result.name, output, |
577 | + request) |
578 | + process_file( |
579 | results, script_result.script_set, '%s.err' % script_result.name, |
580 | stderr, request) |
581 | process_file( |
582 | - results, script_result.script_set, script_result.name, stdout, |
583 | - request) |
584 | + results, script_result.script_set, '%s.out' % script_result.name, |
585 | + stdout, request) |
586 | |
587 | self.assertDictEqual( |
588 | { |
589 | 'exit_status': request['exit_status'], |
590 | + 'output': output, |
591 | 'stdout': stdout, |
592 | 'stderr': stderr, |
593 | }, |
594 | @@ -294,20 +343,25 @@ |
595 | def test_process_file_creates_adds_field_for_stderr(self): |
596 | results = {} |
597 | script_result = factory.make_ScriptResult(status=SCRIPT_STATUS.RUNNING) |
598 | + output = factory.make_string() |
599 | stdout = factory.make_string() |
600 | stderr = factory.make_string() |
601 | request = {'exit_status': random.randint(0, 255)} |
602 | |
603 | process_file( |
604 | - results, script_result.script_set, script_result.name, stdout, |
605 | + results, script_result.script_set, script_result.name, output, |
606 | request) |
607 | process_file( |
608 | + results, script_result.script_set, '%s.out' % script_result.name, |
609 | + stdout, request) |
610 | + process_file( |
611 | results, script_result.script_set, '%s.err' % script_result.name, |
612 | stderr, request) |
613 | |
614 | self.assertDictEqual( |
615 | { |
616 | 'exit_status': request['exit_status'], |
617 | + 'output': output, |
618 | 'stdout': stdout, |
619 | 'stderr': stderr, |
620 | }, |
621 | @@ -316,7 +370,7 @@ |
622 | def test_process_file_finds_script_result_by_id(self): |
623 | results = {} |
624 | script_result = factory.make_ScriptResult(status=SCRIPT_STATUS.RUNNING) |
625 | - stdout = factory.make_string() |
626 | + output = factory.make_string() |
627 | request = { |
628 | 'exit_status': random.randint(0, 255), |
629 | 'script_result_id': script_result.id, |
630 | @@ -324,19 +378,19 @@ |
631 | |
632 | process_file( |
633 | results, script_result.script_set, |
634 | - factory.make_name('script_name'), stdout, request) |
635 | + factory.make_name('script_name'), output, request) |
636 | |
637 | self.assertDictEqual( |
638 | { |
639 | 'exit_status': request['exit_status'], |
640 | - 'stdout': stdout, |
641 | + 'output': output, |
642 | }, |
643 | results[script_result]) |
644 | |
645 | def test_process_file_adds_script_version_id(self): |
646 | results = {} |
647 | script_result = factory.make_ScriptResult(status=SCRIPT_STATUS.RUNNING) |
648 | - stdout = factory.make_string() |
649 | + output = factory.make_string() |
650 | request = { |
651 | 'exit_status': random.randint(0, 255), |
652 | 'script_result_id': script_result.id, |
653 | @@ -345,12 +399,12 @@ |
654 | |
655 | process_file( |
656 | results, script_result.script_set, |
657 | - factory.make_name('script_name'), stdout, request) |
658 | + factory.make_name('script_name'), output, request) |
659 | |
660 | self.assertDictEqual( |
661 | { |
662 | 'exit_status': request['exit_status'], |
663 | - 'stdout': stdout, |
664 | + 'output': output, |
665 | 'script_version_id': script_result.script.script_id, |
666 | }, |
667 | results[script_result]) |
668 | @@ -359,10 +413,10 @@ |
669 | results = {} |
670 | script_name = factory.make_name('script_name') |
671 | script_set = factory.make_ScriptSet() |
672 | - stdout = factory.make_string() |
673 | + output = factory.make_string() |
674 | request = {'exit_status': random.randint(0, 255)} |
675 | |
676 | - process_file(results, script_set, script_name, stdout, request) |
677 | + process_file(results, script_set, script_name, output, request) |
678 | script_result, value = list(results.items())[0] |
679 | |
680 | self.assertEquals(script_name, script_result.name) |
681 | @@ -370,7 +424,7 @@ |
682 | self.assertDictEqual( |
683 | { |
684 | 'exit_status': request['exit_status'], |
685 | - 'stdout': stdout, |
686 | + 'output': output, |
687 | }, |
688 | value) |
689 | |
690 | @@ -380,13 +434,13 @@ |
691 | script = factory.make_Script() |
692 | script.script = script.script.update(factory.make_string()) |
693 | script_set = factory.make_ScriptSet() |
694 | - stdout = factory.make_string() |
695 | + output = factory.make_string() |
696 | request = { |
697 | 'exit_status': random.randint(0, 255), |
698 | 'script_version_id': script.script.id, |
699 | } |
700 | |
701 | - process_file(results, script_set, script_name, stdout, request) |
702 | + process_file(results, script_set, script_name, output, request) |
703 | |
704 | script_result, value = list(results.items())[0] |
705 | |
706 | @@ -394,7 +448,7 @@ |
707 | self.assertDictEqual( |
708 | { |
709 | 'exit_status': request['exit_status'], |
710 | - 'stdout': stdout, |
711 | + 'output': output, |
712 | 'script_version_id': script.script.id, |
713 | }, |
714 | value) |
715 | @@ -1308,7 +1362,7 @@ |
716 | http.client.OK, response.status_code, response.content) |
717 | script_result = reload_object(script_result) |
718 | self.assertEqual(exit_status, script_result.exit_status) |
719 | - self.assertEqual(output, script_result.stdout.decode('utf-8')) |
720 | + self.assertEqual(output, script_result.output.decode('utf-8')) |
721 | |
722 | def test_signaling_accepts_WORKING_status(self): |
723 | node = factory.make_Node( |
724 | @@ -1487,7 +1541,7 @@ |
725 | files={script_result.name: text}) |
726 | script_result = reload_object(script_result) |
727 | self.assertEqual(http.client.OK, response.status_code) |
728 | - self.assertEqual(text, script_result.stdout) |
729 | + self.assertEqual(text, script_result.output) |
730 | |
731 | def test_signal_stores_binary(self): |
732 | unicode_text = '<\u2621>' |
733 | @@ -1504,7 +1558,7 @@ |
734 | files={script_result.name: unicode_text.encode('utf-8')}) |
735 | script_result = reload_object(script_result) |
736 | self.assertEqual(http.client.OK, response.status_code) |
737 | - self.assertEqual(unicode_text.encode("utf-8"), script_result.stdout) |
738 | + self.assertEqual(unicode_text.encode("utf-8"), script_result.output) |
739 | |
740 | def test_signal_stores_multiple_files(self): |
741 | node = factory.make_Node( |
742 | @@ -1528,7 +1582,7 @@ |
743 | self.assertEqual( |
744 | contents, |
745 | { |
746 | - script_result.name: reload_object(script_result).stdout |
747 | + script_result.name: reload_object(script_result).output |
748 | for script_result in script_results |
749 | }) |
750 | |
751 | @@ -1551,7 +1605,7 @@ |
752 | files={script_result.name: contents.encode('utf-8')}) |
753 | script_result = reload_object(script_result) |
754 | self.assertEqual(http.client.OK, response.status_code) |
755 | - self.assertEqual(size_limit, len(script_result.stdout)) |
756 | + self.assertEqual(size_limit, len(script_result.output)) |
757 | |
758 | def test_signal_stores_virtual_tag_on_node_if_virtual(self): |
759 | node = factory.make_Node( |
760 | @@ -1560,7 +1614,7 @@ |
761 | content = 'qemu'.encode('utf-8') |
762 | response = call_signal( |
763 | client, script_result=0, |
764 | - files={'00-maas-02-virtuality': content}) |
765 | + files={'00-maas-02-virtuality.out': content}) |
766 | self.assertEqual(http.client.OK, response.status_code) |
767 | node = reload_object(node) |
768 | self.assertEqual( |
769 | @@ -1575,7 +1629,7 @@ |
770 | content = 'none'.encode('utf-8') |
771 | response = call_signal( |
772 | client, script_result=0, |
773 | - files={'00-maas-02-virtuality': content}) |
774 | + files={'00-maas-02-virtuality.out': content}) |
775 | self.assertEqual(http.client.OK, response.status_code) |
776 | node = reload_object(node) |
777 | self.assertEqual( |
778 | @@ -1588,7 +1642,7 @@ |
779 | content = 'none'.encode('utf-8') |
780 | response = call_signal( |
781 | client, script_result=0, |
782 | - files={'00-maas-02-virtuality': content}) |
783 | + files={'00-maas-02-virtuality.out': content}) |
784 | self.assertEqual(http.client.OK, response.status_code) |
785 | node = reload_object(node) |
786 | self.assertEqual(0, len(node.tags.all())) |
787 | |
788 | === modified file 'src/metadataserver/tests/test_api_status.py' |
789 | --- src/metadataserver/tests/test_api_status.py 2017-02-17 14:23:04 +0000 |
790 | +++ src/metadataserver/tests/test_api_status.py 2017-02-28 23:55:43 +0000 |
791 | @@ -391,7 +391,7 @@ |
792 | } |
793 | response = call_status(client, node, payload) |
794 | self.assertEqual(http.client.OK, response.status_code) |
795 | - self.assertEqual(contents, reload_object(script_result).stdout) |
796 | + self.assertEqual(contents, reload_object(script_result).output) |
797 | |
798 | def test_status_with_file_invalid_statuses_fails(self): |
799 | """Adding files should fail for every status that's neither |
800 | @@ -476,7 +476,7 @@ |
801 | self.assertEqual( |
802 | target_status, reload_object(node).status) |
803 | # Check the node result. |
804 | - self.assertEqual(contents, reload_object(script_result).stdout) |
805 | + self.assertEqual(contents, reload_object(script_result).output) |
806 | |
807 | def test_status_with_results_succeeds(self): |
808 | """Adding a script result should succeed""" |
809 | @@ -509,7 +509,7 @@ |
810 | response = call_status(client, node, payload) |
811 | self.assertEqual(http.client.OK, response.status_code) |
812 | script_result = reload_object(script_result) |
813 | - self.assertEqual(contents, script_result.stdout) |
814 | + self.assertEqual(contents, script_result.output) |
815 | self.assertEqual(-42, script_result.exit_status) |
816 | |
817 | def test_status_with_results_no_exit_status_defaults_to_zero(self): |
818 | @@ -617,7 +617,7 @@ |
819 | 'description': 'Commissioning', |
820 | 'files': [ |
821 | { |
822 | - "path": "00-maas-02-virtuality", |
823 | + "path": "00-maas-02-virtuality.out", |
824 | "encoding": "base64", |
825 | "content": encode_as_base64(content), |
826 | } |
827 | @@ -648,7 +648,7 @@ |
828 | 'description': 'Commissioning', |
829 | 'files': [ |
830 | { |
831 | - "path": "00-maas-02-virtuality", |
832 | + "path": "00-maas-02-virtuality.out", |
833 | "encoding": "base64", |
834 | "content": encode_as_base64(content), |
835 | } |
836 | |
837 | === modified file 'src/metadataserver/tests/test_api_twisted.py' |
838 | --- src/metadataserver/tests/test_api_twisted.py 2017-02-28 19:23:48 +0000 |
839 | +++ src/metadataserver/tests/test_api_twisted.py 2017-02-28 23:55:43 +0000 |
840 | @@ -532,7 +532,7 @@ |
841 | ] |
842 | } |
843 | self.processMessage(node, payload) |
844 | - self.assertEqual(contents, reload_object(script_result).stdout) |
845 | + self.assertEqual(contents, reload_object(script_result).output) |
846 | |
847 | def test_status_with_file_invalid_statuses_fails(self): |
848 | """Adding files should fail for every status that's neither |
849 | @@ -613,7 +613,7 @@ |
850 | self.assertEqual( |
851 | target_status, reload_object(node).status) |
852 | # Check the node result. |
853 | - self.assertEqual(contents, reload_object(script_result).stdout) |
854 | + self.assertEqual(contents, reload_object(script_result).output) |
855 | |
856 | def test_status_with_results_succeeds(self): |
857 | """Adding a script result should succeed""" |
858 | @@ -645,7 +645,7 @@ |
859 | } |
860 | self.processMessage(node, payload) |
861 | script_result = reload_object(script_result) |
862 | - self.assertEqual(contents, script_result.stdout) |
863 | + self.assertEqual(contents, script_result.output) |
864 | self.assertEqual(-42, script_result.exit_status) |
865 | |
866 | def test_status_with_results_no_exit_status_defaults_to_zero(self): |
867 | @@ -692,7 +692,7 @@ |
868 | 'timestamp': datetime.utcnow(), |
869 | 'files': [ |
870 | { |
871 | - "path": "00-maas-02-virtuality", |
872 | + "path": "00-maas-02-virtuality.out", |
873 | "encoding": "base64", |
874 | "content": encode_as_base64(content), |
875 | } |
876 | @@ -722,7 +722,7 @@ |
877 | 'timestamp': datetime.utcnow(), |
878 | 'files': [ |
879 | { |
880 | - "path": "00-maas-02-virtuality", |
881 | + "path": "00-maas-02-virtuality.out", |
882 | "encoding": "base64", |
883 | "content": encode_as_base64(content), |
884 | } |
885 | |
886 | === modified file 'src/metadataserver/user_data/templates/snippets/maas_run_remote_scripts.py' |
887 | --- src/metadataserver/user_data/templates/snippets/maas_run_remote_scripts.py 2017-02-17 14:23:04 +0000 |
888 | +++ src/metadataserver/user_data/templates/snippets/maas_run_remote_scripts.py 2017-02-28 23:55:43 +0000 |
889 | @@ -4,7 +4,11 @@ |
890 | from io import BytesIO |
891 | import json |
892 | import os |
893 | -from subprocess import run |
894 | +import selectors |
895 | +from subprocess import ( |
896 | + PIPE, |
897 | + Popen, |
898 | +) |
899 | import sys |
900 | import tarfile |
901 | from threading import ( |
902 | @@ -13,6 +17,11 @@ |
903 | ) |
904 | import time |
905 | |
906 | +# See fcntl(2), re. F_SETPIPE_SZ. By requesting this many bytes from a pipe on |
907 | +# each read we can be sure that we are always draining its buffer completely. |
908 | +with open("/proc/sys/fs/pipe-max-size") as _pms: |
909 | + PIPE_MAX_SIZE = int(_pms.read()) |
910 | + |
911 | |
912 | try: |
913 | from maas_api_helper import ( |
914 | @@ -57,6 +66,51 @@ |
915 | tar.extractall(scripts_dir) |
916 | |
917 | |
918 | +def capture_script_output(proc, combined_path, stdout_path, stderr_path): |
919 | + """Capture stdout and stderr from `proc`. |
920 | + |
921 | + Standard output is written to a file named by `stdout_path`, and standard |
922 | + error is written to a file named by `stderr_path`. Both are also written |
923 | + to a file named by `combined_path`. |
924 | + |
925 | + If the given subprocess forks additional processes, and these write to the |
926 | + same stdout and stderr, their output will be captured only as long as |
927 | + `proc` is running. |
928 | + |
929 | + :return: The exit code of `proc`. |
930 | + """ |
931 | + with open(stdout_path, 'wb') as out, open(stderr_path, 'wb') as err: |
932 | + with open(combined_path, 'wb') as combined: |
933 | + with selectors.DefaultSelector() as selector: |
934 | + selector.register(proc.stdout, selectors.EVENT_READ, out) |
935 | + selector.register(proc.stderr, selectors.EVENT_READ, err) |
936 | + while selector.get_map() and proc.poll() is None: |
937 | + # Select with a short timeout so that we don't tight loop. |
938 | + _select_script_output(selector, combined, 0.1) |
939 | + else: |
940 | + # Process has finished or has closed stdout and stderr. |
941 | + # Process anything still sitting in the latter's buffers. |
942 | + _select_script_output(selector, combined, 0.0) |
943 | + |
944 | + # Always wait for the process to finish. |
945 | + return proc.wait() |
946 | + |
947 | + |
948 | +def _select_script_output(selector, combined, timeout): |
949 | + """Helper for `capture_script_output`.""" |
950 | + for key, event in selector.select(timeout): |
951 | + if event & selectors.EVENT_READ: |
952 | + # Read from the _raw_ file. Ordinarily Python blocks until a |
953 | + # read(n) returns n bytes or the stream reaches end-of-file, |
954 | + # but here we only want to get what's there without blocking. |
955 | + chunk = key.fileobj.raw.read(PIPE_MAX_SIZE) |
956 | + if len(chunk) == 0: # EOF |
957 | + selector.unregister(key.fileobj) |
958 | + else: |
959 | + key.data.write(chunk) |
960 | + combined.write(chunk) |
961 | + |
962 | + |
963 | def run_scripts(url, creds, scripts_dir, out_dir, scripts): |
964 | """Run and report results for the given scripts.""" |
965 | fail_count = 0 |
966 | @@ -82,17 +136,21 @@ |
967 | # and capture the output to the filesystem to avoid that and help with |
968 | # debugging. |
969 | script_path = os.path.join(scripts_dir, script['path']) |
970 | - stdout_path = os.path.join(out_dir, script['name']) |
971 | + combined_path = os.path.join(out_dir, script['name']) |
972 | + stdout_name = '%s.out' % script['name'] |
973 | + stdout_path = os.path.join(out_dir, stdout_name) |
974 | stderr_name = '%s.err' % script['name'] |
975 | stderr_path = os.path.join(out_dir, stderr_name) |
976 | - proc = run( |
977 | - "%s > %s 2> %s" % (script_path, stdout_path, stderr_path), |
978 | - shell=True) |
979 | + |
980 | + proc = Popen(script_path, stdout=PIPE, stderr=PIPE) |
981 | + capture_script_output(proc, combined_path, stdout_path, stderr_path) |
982 | + |
983 | if proc.returncode != 0: |
984 | fail_count += 1 |
985 | args['exit_status'] = proc.returncode |
986 | args['files'] = { |
987 | - script['name']: open(stdout_path, 'rb').read(), |
988 | + script['name']: open(combined_path, 'rb').read(), |
989 | + stdout_name: open(stdout_path, 'rb').read(), |
990 | stderr_name: open(stderr_path, 'rb').read(), |
991 | } |
992 | |
993 | |
994 | === modified file 'src/metadataserver/user_data/templates/snippets/maas_wedge_autodetect.sh' (properties changed: +x to -x) |
995 | === modified file 'src/metadataserver/user_data/templates/snippets/tests/test_maas_run_remote_scripts.py' |
996 | --- src/metadataserver/user_data/templates/snippets/tests/test_maas_run_remote_scripts.py 2017-02-17 14:23:04 +0000 |
997 | +++ src/metadataserver/user_data/templates/snippets/tests/test_maas_run_remote_scripts.py 2017-02-28 23:55:43 +0000 |
998 | @@ -8,10 +8,17 @@ |
999 | from io import BytesIO |
1000 | import json |
1001 | import os |
1002 | +from pathlib import Path |
1003 | import random |
1004 | +from subprocess import ( |
1005 | + PIPE, |
1006 | + Popen, |
1007 | +) |
1008 | import tarfile |
1009 | +import time |
1010 | from unittest.mock import ANY |
1011 | |
1012 | +from lxml import etree |
1013 | from maastesting.factory import factory |
1014 | from maastesting.fixtures import TempDirectory |
1015 | from maastesting.matchers import ( |
1016 | @@ -22,10 +29,16 @@ |
1017 | from maastesting.testcase import MAASTestCase |
1018 | from snippets import maas_run_remote_scripts |
1019 | from snippets.maas_run_remote_scripts import ( |
1020 | + capture_script_output, |
1021 | download_and_extract_tar, |
1022 | run_scripts, |
1023 | run_scripts_from_metadata, |
1024 | ) |
1025 | +from testtools.matchers import ( |
1026 | + Equals, |
1027 | + MatchesAny, |
1028 | + MatchesListwise, |
1029 | +) |
1030 | |
1031 | |
1032 | class TestMaasRunRemoteScripts(MAASTestCase): |
1033 | @@ -44,12 +57,16 @@ |
1034 | |
1035 | def make_script_output(self, scripts, scripts_dir): |
1036 | for script in scripts: |
1037 | + output = factory.make_string() |
1038 | stdout = factory.make_string() |
1039 | stderr = factory.make_string() |
1040 | + script['output'] = output.encode() |
1041 | script['stdout'] = stdout.encode() |
1042 | script['stderr'] = stderr.encode() |
1043 | - stdout_path = os.path.join(scripts_dir, script['name']) |
1044 | + output_path = os.path.join(scripts_dir, script['name']) |
1045 | + stdout_path = os.path.join(scripts_dir, '%s.out' % script['name']) |
1046 | stderr_path = os.path.join(scripts_dir, '%s.err' % script['name']) |
1047 | + open(output_path, 'w').write(output) |
1048 | open(stdout_path, 'w').write(stdout) |
1049 | open(stderr_path, 'w').write(stderr) |
1050 | |
1051 | @@ -127,7 +144,9 @@ |
1052 | def test_run_scripts(self): |
1053 | scripts_dir = self.useFixture(TempDirectory()).path |
1054 | mock_signal = self.patch(maas_run_remote_scripts, 'signal') |
1055 | - mock_run = self.patch(maas_run_remote_scripts, 'run') |
1056 | + mock_popen = self.patch(maas_run_remote_scripts, 'Popen') |
1057 | + mock_capture_script_output = self.patch( |
1058 | + maas_run_remote_scripts, 'capture_script_output') |
1059 | scripts = self.make_scripts() |
1060 | self.make_script_output(scripts, scripts_dir) |
1061 | |
1062 | @@ -146,11 +165,13 @@ |
1063 | 'error': 'Starting %s [1/1]' % scripts[0]['name'], |
1064 | } |
1065 | self.assertThat(mock_signal, MockAnyCall(**args)) |
1066 | - self.assertThat(mock_run, MockCalledOnce()) |
1067 | + self.assertThat(mock_popen, MockCalledOnce()) |
1068 | + self.assertThat(mock_capture_script_output, MockCalledOnce()) |
1069 | # This is a MagicMock |
1070 | args['exit_status'] = ANY |
1071 | args['files'] = { |
1072 | - scripts[0]['name']: scripts[0]['stdout'], |
1073 | + scripts[0]['name']: scripts[0]['output'], |
1074 | + '%s.out' % scripts[0]['name']: scripts[0]['stdout'], |
1075 | '%s.err' % scripts[0]['name']: scripts[0]['stderr'], |
1076 | } |
1077 | args['error'] = 'Finished %s [1/1]: 1' % scripts[0]['name'] |
1078 | @@ -159,7 +180,8 @@ |
1079 | def test_run_scripts_signals_failure(self): |
1080 | scripts_dir = self.useFixture(TempDirectory()).path |
1081 | mock_signal = self.patch(maas_run_remote_scripts, 'signal') |
1082 | - self.patch(maas_run_remote_scripts, 'run') |
1083 | + self.patch(maas_run_remote_scripts, 'Popen') |
1084 | + self.patch(maas_run_remote_scripts, 'capture_script_output') |
1085 | scripts = self.make_scripts() |
1086 | self.make_script_output(scripts, scripts_dir) |
1087 | |
1088 | @@ -175,3 +197,105 @@ |
1089 | mock_signal, |
1090 | MockAnyCall( |
1091 | None, None, 'FAILED', '1 scripts failed to run')) |
1092 | + |
1093 | + |
1094 | +class TestCaptureScriptOutput(MAASTestCase): |
1095 | + |
1096 | + # Iterate multiple times to shake out spurious failures. |
1097 | + scenarios = [ |
1098 | + ("iteration %d" % iteration, {}) |
1099 | + for iteration in range(1, 21) |
1100 | + ] |
1101 | + |
1102 | + def capture(self, proc): |
1103 | + scripts_dir = Path(self.useFixture(TempDirectory()).path) |
1104 | + combined_path = scripts_dir.joinpath("combined") |
1105 | + stdout_path = scripts_dir.joinpath("stdout") |
1106 | + stderr_path = scripts_dir.joinpath("stderr") |
1107 | + |
1108 | + returncode = capture_script_output( |
1109 | + proc, str(combined_path), str(stdout_path), str(stderr_path)) |
1110 | + |
1111 | + return ( |
1112 | + returncode, |
1113 | + stdout_path.read_text(), |
1114 | + stderr_path.read_text(), |
1115 | + combined_path.read_text(), |
1116 | + ) |
1117 | + |
1118 | + def test__captures_script_output(self): |
1119 | + proc = Popen( |
1120 | + 'echo "stdout"; echo "stderr" 1>&2', stdout=PIPE, stderr=PIPE, |
1121 | + shell=True) |
1122 | + self.assertThat( |
1123 | + self.capture(proc), MatchesListwise(( |
1124 | + Equals(0), Equals("stdout\n"), Equals("stderr\n"), |
1125 | + # The writes to stdout and stderr occur so close in time that |
1126 | + # they may be received in any order. |
1127 | + MatchesAny( |
1128 | + Equals("stdout\nstderr\n"), |
1129 | + Equals("stderr\nstdout\n"), |
1130 | + ), |
1131 | + ))) |
1132 | + |
1133 | + def test__does_not_wait_for_forked_process(self): |
1134 | + start_time = time.time() |
1135 | + proc = Popen('sleep 6 &', stdout=PIPE, stderr=PIPE, shell=True) |
1136 | + self.assertThat( |
1137 | + self.capture(proc), MatchesListwise(( |
1138 | + Equals(0), Equals(""), Equals(""), Equals(""), |
1139 | + ))) |
1140 | + # A forked process should continue running after capture_script_output |
1141 | + # returns. capture_script_output should not block on the forked call. |
1142 | + self.assertLess(time.time() - start_time, 3) |
1143 | + |
1144 | + def test__captures_output_from_completed_process(self): |
1145 | + # Write to both stdout and stderr. |
1146 | + proc = Popen( |
1147 | + 'echo -n foo >&1 && echo -n bar >&2', |
1148 | + stdout=PIPE, stderr=PIPE, shell=True) |
1149 | + # Wait for it to finish before capturing. |
1150 | + self.assertEquals(0, proc.wait()) |
1151 | + # Capturing now still gets foo and bar. |
1152 | + self.assertThat( |
1153 | + self.capture(proc), MatchesListwise(( |
1154 | + Equals(0), Equals("foo"), Equals("bar"), |
1155 | + # The writes to stdout and stderr occur so close in time that |
1156 | + # they may be received in any order. |
1157 | + MatchesAny(Equals("foobar"), Equals("barfoo")), |
1158 | + ))) |
1159 | + |
1160 | + def test__captures_stderr_after_stdout_closes(self): |
1161 | + # Write to stdout, close stdout, then write to stderr. |
1162 | + proc = Popen( |
1163 | + 'echo -n foo >&1 && exec 1>&- && echo -n bar >&2', |
1164 | + stdout=PIPE, stderr=PIPE, shell=True) |
1165 | + # Capturing gets the bar even after stdout is closed. |
1166 | + self.assertThat( |
1167 | + self.capture(proc), MatchesListwise(( |
1168 | + Equals(0), Equals("foo"), Equals("bar"), |
1169 | + # The writes to stdout and stderr occur so close in time that |
1170 | + # they may be received in any order. |
1171 | + MatchesAny(Equals("foobar"), Equals("barfoo")), |
1172 | + ))) |
1173 | + |
1174 | + def test__captures_stdout_after_stderr_closes(self): |
1175 | + # Write to stderr, close stderr, then write to stdout. |
1176 | + proc = Popen( |
1177 | + 'echo -n bar >&2 && exec 2>&- && echo -n foo >&1', |
1178 | + stdout=PIPE, stderr=PIPE, shell=True) |
1179 | + # Capturing gets the foo even after stderr is closed. |
1180 | + self.assertThat( |
1181 | + self.capture(proc), MatchesListwise(( |
1182 | + Equals(0), Equals("foo"), Equals("bar"), |
1183 | + # The writes to stdout and stderr occur so close in time that |
1184 | + # they may be received in any order. |
1185 | + MatchesAny(Equals("foobar"), Equals("barfoo")), |
1186 | + ))) |
1187 | + |
1188 | + def test__captures_all_output(self): |
1189 | + proc = Popen(("lshw", "-xml"), stdout=PIPE, stderr=PIPE) |
1190 | + returncode, stdout, stderr, combined = self.capture(proc) |
1191 | + self.assertThat(returncode, Equals(0), stderr) |
1192 | + # This is a complete XML document; we've captured all output. |
1193 | + self.assertThat(etree.fromstring(stdout).tag, Equals("list")) |
All looks okay, but there needs to be a test that shows we're dealing with the problematic behaviour you saw with dhclient.