Merge lp:~ltrager/maas/combined_script_output into lp:~maas-committers/maas/trunk

Proposed by Lee Trager
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
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-remote-scripts now captures stdout, stderr, and the combined output as three separate files. The metadataservice has been modified to allow uploading and storing all three files in the ScriptResult model. Uploaded files ending in .err will be stored as the ScriptResult's stderr output, files ending in .out will be stored as the ScriptResult's stdout output, all other files will be stored as the ScriptResult's combined output.

This also fixes 1664285 to return results base64 encoded and 1665478 to show the status as testing.

To post a comment you must log in.
Revision history for this message
Gavin Panella (allenap) wrote :

All looks okay, but there needs to be a test that shows we're dealing with the problematic behaviour you saw with dhclient.

review: Needs Fixing
Revision history for this message
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.

Revision history for this message
Lee Trager (ltrager) :
Revision history for this message
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.

Revision history for this message
Gavin Panella (allenap) wrote :

> finished_writing_pipes is needed for two reasons. First if the script
> 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_output_from_completed_process because c_s_o, at present,
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_script_output that
consistently, over a few thousand iterations, passes these tests.

See the diff at https://code.launchpad.net/~allenap/maas/ltrager--combined_script_output/+merge/318096.

review: Needs Fixing
Revision history for this message
Lee Trager (ltrager) wrote :

Thanks for the review and reworking the tests. The updated capture_script_output definitely fixes some edge cases I was missing as shown by the new unit tests. When I testing the updated code in my test environment I noticed only about half of lshw's output was being captured. In the previous version the finished_writing_pipes dict was used to make sure capture_script_output waited until all output was written. I modified the code from your branch to do something similar. If any data has been written by the process capture_script_output waits until it detects EOF. This means any script which forks can't write to stdout or stderr. The only script we have which forks is 00-maas-06-dhcp-unconfigured-ifaces and that does not write to stdout or stderr. I added a new unit test for this, test__captures_all_output.

I was also wondering if we want to land reruning the tests 20 times or if that was just for testing?

Revision history for this message
Gavin Panella (allenap) wrote :

The new test, test__captures_all_output, fails on my machine 3 times in
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_all_output(self):
        proc = Popen(("lshw", "-xml"), stdout=PIPE, stderr=PIPE)
        returncode, stdout, stderr, combined = self.capture(proc)
        self.assertThat(returncode, Equals(0), stderr)
        # This is a complete XML document; we've captured all output.
        self.assertThat(etree.fromstring(stdout).tag, Equals("list"))

However, this hangs. Killing with ctrl-c always shows that it's hanging
at:

    chunk = key.fileobj.read(PIPE_BUF)

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/fs/pipe-max-size, so
that file is now read on import and the figure used by
_select_script_output for read() calls on raw file objects.

An updated diff can be see at https://code.launchpad.net/~allenap/maas/ltrager--combined_script_output/+merge/318235.

review: Needs Fixing
Revision history for this message
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.

Revision history for this message
Gavin Panella (allenap) wrote :

Cool! After all the effort we put in I feel like bottling it and putting it somewhere safe.

review: Approve
Revision history for this message
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-rodents.maas
stdout-1
WARNING: could not look up hostname for unglamorous-rodents.maas
-----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?

Revision history for this message
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:

-------00-maas-07-block-devices---------

[
{
"RO": "1",
"ID_PATH": "/dev/disk/by-id/wwn-0x60000000000000000e00000000080001",
"MODEL": "VIRTUAL-DISK",
"RM": "0",
"PATH": "/dev/sdb",
"BLOCK_SIZE": "1024",
"SERIAL": "60000000000000000e00000000080001",
"SIZE": "187834368",
"ROTA": "1",
"NAME": "sdb"
},
]

---------00-maas-07-block-devices.err---------
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:

-------00-maas-07-block-devices---------
sudo: unable to resolve host node01
sudo: unable to resolve host node01
[
{
"RO": "1",
"ID_PATH": "/dev/disk/by-id/wwn-0x60000000000000000e00000000080001",
"MODEL": "VIRTUAL-DISK",
"RM": "0",
"PATH": "/dev/sdb",
"BLOCK_SIZE": "1024",
"SERIAL": "60000000000000000e00000000080001",
"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>
------------------

------------------

review: Needs Information
Revision history for this message
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/metadataserver/models/scriptresult.py:147

Revision history for this message
Mike Pontillo (mpontillo) wrote :

Thanks for the clarification. Sounds good to me!

review: Approve
Revision history for this message
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)

review: Needs Information
Revision history for this message
Lee Trager (ltrager) wrote :

API and websocket have been updated to show both stdout and stderr.

Revision history for this message
Andres Rodriguez (andreserl) wrote :

I'm good with this now!

review: Approve
Revision history for this message
MAAS Lander (maas-lander) wrote :
Download full text (1.8 MiB)

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://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial InRelease
Get:2 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-updates InRelease [102 kB]
Get:3 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-backports InRelease [102 kB]
Get:4 http://security.ubuntu.com/ubuntu xenial-security InRelease [102 kB]
Fetched 306 kB in 0s (640 kB/s)
Reading package lists...
sudo DEBIAN_FRONTEND=noninteractive apt-get -y \
    --no-install-recommends install apache2 archdetect-deb authbind avahi-utils bash bind9 bind9utils build-essential bzr bzr-builddeb chromium-browser chromium-chromedriver curl daemontools debhelper dh-apport dh-systemd distro-info dnsutils firefox freeipmi-tools git gjs ipython isc-dhcp-common isc-dhcp-server libjs-angularjs libjs-jquery libjs-jquery-hotkeys libjs-yui3-full libjs-yui3-min libnss-wrapper libpq-dev make nodejs-legacy npm postgresql psmisc pxelinux python3-all python3-apt python3-attr python3-bson python3-convoy python3-crochet python3-cssselect python3-curtin python3-dev python3-distro-info python3-django python3-django-nose python3-django-piston3 python3-dnspython python3-docutils python3-formencode python3-hivex python3-httplib2 python3-jinja2 python3-jsonschema python3-lxml python3-netaddr python3-netifaces python3-novaclient python3-oauth python3-oauthlib python3-openssl python3-paramiko python3-petname python3-pexpect python3-psycopg2 python3-pyinotify python3-pyparsing python3-pyvmomi python3-requests python3-seamicroclient python3-setuptools python3-simplestreams python3-sphinx python3-tempita python3-twisted python3-txtftp python3-tz python3-yaml python3-zope.interface python-bson python-crochet python-django python-django-piston python-djorm-ext-pgarray python-formencode python-lxml python-netaddr python-netifaces python-pocket-lint python-psycopg2 python-simplejson python-tempita python-twisted python-yaml socat syslinux-common tgt ubuntu-cloudimage-keyring wget xvfb
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~rc+dfsg-1ubuntu2).
build-essential is already the newest version (12.1ubuntu2).
debhelper is already the newest version (9.20160115ubuntu3).
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+dfsg-11ubuntu1).
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...

Revision history for this message
MAAS Lander (maas-lander) wrote :
Download full text (1.8 MiB)

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://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial InRelease
Get:2 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-updates InRelease [102 kB]
Get:3 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-backports InRelease [102 kB]
Get:4 http://security.ubuntu.com/ubuntu xenial-security InRelease [102 kB]
Fetched 306 kB in 0s (593 kB/s)
Reading package lists...
sudo DEBIAN_FRONTEND=noninteractive apt-get -y \
    --no-install-recommends install apache2 archdetect-deb authbind avahi-utils bash bind9 bind9utils build-essential bzr bzr-builddeb chromium-browser chromium-chromedriver curl daemontools debhelper dh-apport dh-systemd distro-info dnsutils firefox freeipmi-tools git gjs ipython isc-dhcp-common isc-dhcp-server libjs-angularjs libjs-jquery libjs-jquery-hotkeys libjs-yui3-full libjs-yui3-min libnss-wrapper libpq-dev make nodejs-legacy npm postgresql psmisc pxelinux python3-all python3-apt python3-attr python3-bson python3-convoy python3-crochet python3-cssselect python3-curtin python3-dev python3-distro-info python3-django python3-django-nose python3-django-piston3 python3-dnspython python3-docutils python3-formencode python3-hivex python3-httplib2 python3-jinja2 python3-jsonschema python3-lxml python3-netaddr python3-netifaces python3-novaclient python3-oauth python3-oauthlib python3-openssl python3-paramiko python3-petname python3-pexpect python3-psycopg2 python3-pyinotify python3-pyparsing python3-pyvmomi python3-requests python3-seamicroclient python3-setuptools python3-simplestreams python3-sphinx python3-tempita python3-twisted python3-txtftp python3-tz python3-yaml python3-zope.interface python-bson python-crochet python-django python-django-piston python-djorm-ext-pgarray python-formencode python-lxml python-netaddr python-netifaces python-pocket-lint python-psycopg2 python-simplejson python-tempita python-twisted python-yaml socat syslinux-common tgt ubuntu-cloudimage-keyring wget xvfb
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~rc+dfsg-1ubuntu2).
build-essential is already the newest version (12.1ubuntu2).
debhelper is already the newest version (9.20160115ubuntu3).
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+dfsg-11ubuntu1).
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...

Revision history for this message
MAAS Lander (maas-lander) wrote :
Download full text (1.8 MiB)

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://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial InRelease
Get:2 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-updates InRelease [102 kB]
Get:3 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-backports InRelease [102 kB]
Get:4 http://security.ubuntu.com/ubuntu xenial-security InRelease [102 kB]
Fetched 306 kB in 0s (617 kB/s)
Reading package lists...
sudo DEBIAN_FRONTEND=noninteractive apt-get -y \
    --no-install-recommends install apache2 archdetect-deb authbind avahi-utils bash bind9 bind9utils build-essential bzr bzr-builddeb chromium-browser chromium-chromedriver curl daemontools debhelper dh-apport dh-systemd distro-info dnsutils firefox freeipmi-tools git gjs ipython isc-dhcp-common isc-dhcp-server libjs-angularjs libjs-jquery libjs-jquery-hotkeys libjs-yui3-full libjs-yui3-min libnss-wrapper libpq-dev make nodejs-legacy npm postgresql psmisc pxelinux python3-all python3-apt python3-attr python3-bson python3-convoy python3-crochet python3-cssselect python3-curtin python3-dev python3-distro-info python3-django python3-django-nose python3-django-piston3 python3-dnspython python3-docutils python3-formencode python3-hivex python3-httplib2 python3-jinja2 python3-jsonschema python3-lxml python3-netaddr python3-netifaces python3-novaclient python3-oauth python3-oauthlib python3-openssl python3-paramiko python3-petname python3-pexpect python3-psycopg2 python3-pyinotify python3-pyparsing python3-pyvmomi python3-requests python3-seamicroclient python3-setuptools python3-simplestreams python3-sphinx python3-tempita python3-twisted python3-txtftp python3-tz python3-yaml python3-zope.interface python-bson python-crochet python-django python-django-piston python-djorm-ext-pgarray python-formencode python-lxml python-netaddr python-netifaces python-pocket-lint python-psycopg2 python-simplejson python-tempita python-twisted python-yaml socat syslinux-common tgt ubuntu-cloudimage-keyring wget xvfb
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~rc+dfsg-1ubuntu2).
build-essential is already the newest version (12.1ubuntu2).
debhelper is already the newest version (9.20160115ubuntu3).
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+dfsg-11ubuntu1).
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

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
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"))