Merge lp:~gmb/maas-test/add-reporting into lp:maas-test
- add-reporting
- Merge into trunk
Status: | Merged |
---|---|
Approved by: | Graham Binns |
Approved revision: | 116 |
Merged at revision: | 98 |
Proposed branch: | lp:~gmb/maas-test/add-reporting |
Merge into: | lp:maas-test |
Diff against target: |
592 lines (+482/-11) 7 files modified
maastest/cases.py (+5/-0) maastest/console.py (+4/-5) maastest/main.py (+14/-6) maastest/report.py (+150/-0) maastest/tests/test_report.py (+263/-0) maastest/tests/test_utils.py (+31/-0) maastest/utils.py (+15/-0) |
To merge this branch: | bzr merge lp:~gmb/maas-test/add-reporting |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Gavin Panella (community) | Approve | ||
MAAS Maintainers | Pending | ||
Review via email: mp+198067@code.launchpad.net |
Commit message
Add reporting-via Launchpad.
Description of the change
This branch adds reporting-
To make this work, I've:
- Added a way of buffering the logs and test results so that we can save them or email them.
- Added a report.py module, which builds the blob and uploads it to Launchpad (lots of this was cargo-culted from elsewhere).
- Added tests for the above.
As a drive-by, I've also fixed the memory-limit bug that I discovered earlier this week (on i386 systems, qemu errors if --memory is > 2047).
One of the functions in report.py isn't tested, annoyingly, and I'll work on fixing that, but I'd rather get this code landed _now_.
Raphaël Badin (rvb) wrote : | # |
Raphaël Badin (rvb) wrote : | # |
[8]
448 +class BufferingOutput
449 +
This, I think, deserves a docstring.
[9]
8 +from maastest import utils
9 +import sys
10 import unittest
11
12 -from maastest import utils
13 -
14 -
Why moving "from maastest import utils"? I suggest using MAAS' ./utilities/
[10]
1 === modified file 'maastest/
[...]
23 + if output_stream is None:
24 + output_stream = sys.stderr
Is that used?
[11]
92 + log_file_path = os.path.join(
93 + BaseDirectory.
94 + with open(log_file_path, 'w') as log_file:
95 + log_file.
Looks like multiple runs (even if testing different nodes) will all write to the same output log. I think we should include something in the name (a timestamp? the BMC's MAC address if provided? Both?) of the output file. Also, in this case, it's probably worth putting them all in ~/.maas-test/logs or something. Finally, but up to you really, I'd move that logic into a dedicated helper.
[12]
159 +def create_
[...]
168 + results_payload = make_file_payload(
169 + 'maas-test.log', test_results.
178 +def build_upload_
[...]
187 + form_blob_
188 + 'Content-
189 + form_blob_
81 + encoded_results = (
82 + output_
Why are we encoding stuff in ascii and later on in "UTF-8"? This might be my ignorance of what's going on inside Launchpad speaking but I'd like to better understand what's happening here… (It's probably worth a comment.)
[13]
165 + launchpad_
Again, I'm confused by the "truncated" subject because my ignorance of what's happening on the Launchpad side… but why is the 'subject' like this? (It's probably worth a comment.)
Raphaël Badin (rvb) wrote : | # |
[14]
Another remark (which, again, could probably be addressed in a follow-up branch): having an option to force maas-test to write the report into a file instead of sending it to LP is probably a good idea (in addition to the "--no-report" option, see [7]).
Graham Binns (gmb) wrote : | # |
> [0]
>
> 40 + # We specifically use 2047 for the memory size here
> because
> 41 + # using 2048 causes qemu to raise an error on i386 hosts.
> 42 + "--memory", "2047",
>
> This will conflict with what's in trunk now. Please merge trunk.
Done.
>
> [1]
>
> The module-level docstrings of the new modules need to be changed.
Done.
> [2]
>
> maastest/main.py
>
> 80 + from maastest import report
> 81 + encoded_results = (
> 82 + output_
> 83 + blob = report.
> 84 + blob_token = report.
> [...]
> 100 + "Visit https:/
> 101 + "to file a bug and complete the maas-test reporting
> process."
> 102 + % blob_token)
>
> Wouldn't it be better if all of this was in a separate helper method so that
> it can be easily tested?
Yes, Done.
> [3]
>
> 27 +__all__ = [
> 128 + "upload_blob",
> 129 + ]
>
> 'create_
Fixed.
> [4]
>
> This while thing is a pretty serious change to what maas-test does. A good
> one I mean, but a serious one and I don't see any changes to the
> documentation. Maybe you're planning to do it in a follow-up branch but it's
> pretty important that this behavior is documented.
I was planning to do it in a follow-up branch, for the sake of keeping this one as bloated as it is right now.
> [5]
>
> Questions about testing: what the code does is pretty tricky as it interacts
> with an external system (Launchpad). Can I ask what kind of testing you've
> done already? Could we have sort of a QA plan for this? In particular, I
> think it's pretty important to make sure that everything works fine when maas-
> test can't access Launchpad; I don't think there is provision for that in the
> code: what happens if upload_
I've done plenty of testing of how it interacts with Launchpad, but only when Launchpad was available (foolishly enough). It now handles correctly not being able to connect to Launchpad.
I'll add more in a subsequent branch.
> [6]
>
> This code doesn't use the optionally-provided proxy option. This does not
> seem right to me but I'd like to hear your thoughts about this.
No, you're right it, it doesn't. That's fixed now by setting the http(s)_proxy in os.environ (though I now feel the proxy setup is done in slightly the wrong place, but that's a concern for another time).
> [7]
>
> Maybe something we can do in a follow-up branch but, although I think it's
> sane to do the reporting by default, I'm also convinced it would be pretty
> useful to have a "--no-report" option.
There'll be a follow up branch for that.
Graham Binns (gmb) wrote : | # |
> [8]
>
> 448 +class BufferingOutput
> 449 +
>
> This, I think, deserves a docstring.
>
> [9]
>
> 8 +from maastest import utils
> 9 +import sys
> 10 import unittest
> 11
> 12 -from maastest import utils
> 13 -
> 14 -
>
> Why moving "from maastest import utils"? I suggest using MAAS' ./utilities
> /format-imports script on this file to get the imports sorted (and nicely
> regrouped based on where they come from).
Good point. I'll do that in another branch; it reformats imports in most of the .py files under /maastest
> [10]
>
> 1 === modified file 'maastest/
> [...]
> 23 + if output_stream is None:
> 24 + output_stream = sys.stderr
>
> Is that used?
Nope. Removed.
> [11]
>
> 92 + log_file_path = os.path.join(
> 93 + BaseDirectory.
> test.log')
> 94 + with open(log_file_path, 'w') as log_file:
> 95 + log_file.
>
> Looks like multiple runs (even if testing different nodes) will all write to
> the same output log. I think we should include something in the name (a
> timestamp? the BMC's MAC address if provided? Both?) of the output file.
> Also, in this case, it's probably worth putting them all in ~/.maas-test/logs
> or something. Finally, but up to you really, I'd move that logic into a
> dedicated helper.
Done (timestamp; I'll add the BMC MAC in another branch). Also moved to a helper.
> [12]
>
> 159 +def create_
> [...]
> 168 + results_payload = make_file_payload(
> 169 + 'maas-test.log', test_results.
>
> 178 +def build_upload_
> [...]
> 187 + form_blob_
> 188 + 'Content-
> filename="x"')
> 189 + form_blob_
>
> 81 + encoded_results = (
> 82 + output_
>
> Why are we encoding stuff in ascii and later on in "UTF-8"? This might be my
> ignorance of what's going on inside Launchpad speaking but I'd like to better
> understand what's happening here… (It's probably worth a comment.)
No, that's cargo-culted code. I've no idea why it happens but I've removed it and it seems still to work.
> [13]
>
> 165 + launchpad_
>
> Again, I'm confused by the "truncated" subject because my ignorance of what's
> happening on the Launchpad side… but why is the 'subject' like this? (It's
> probably worth a comment.)
This is so that someone can fill in the details of what the test was run against, but I guess we could just leave it blank and trust them to be smart enough to use a sensible subject line.
Graham Binns (gmb) wrote : | # |
> [14]
>
> Another remark (which, again, could probably be addressed in a follow-up
> branch): having an option to force maas-test to write the report into a file
> instead of sending it to LP is probably a good idea (in addition to the "--no-
> report" option, see [7]).
Yes, I'll do that in my follow-up branch.
Gavin Panella (allenap) wrote : | # |
Looks good. This is excellent stuff to get reporting working with
Launchpad, and it's apparent that a lot of work has gone into this.
Things like the Launchpad blob prep and upload code could have been
reviewed and landed independently, but things don't always work out as
clear cut as we'd like. I have more comments to pile onto the ones that
Raffers has already made, but I'm not going to binary fissionate
post-midnight-
[1]
@@ -70,6 +71,8 @@
# just use that proxy for everything.
+ os.environ[
+ os.environ[
elif cls.args.
# The user has passed --disable-cache, so don't start polipo
# and don't try to use an external proxy.
@@ -81,6 +84,8 @@
proxy = LocalProxyFixture()
+ os.environ[
+ os.environ[
Can you use fixtures.
from fixtures import EnvironmentVariable
...
@classmethod
def useProxy(cls, url):
@classmethod
def setUpClass(cls):
# TODO: series and architecture should be script parameters.
if cls.args.http_proxy is not None:
# The user has passed an external proxy; don't start polipo,
# just use that proxy for everything.
elif cls.args.
# The user has passed --disable-cache, so don't start polipo
# and don't try to use an external proxy.
else:
# The default case: start polipo and use that as our caching
# proxy.
proxy = LocalProxyFixture()
[2]
cls.machine = KVMFixture(
You've not changed this, but it seem odd now to set the proxy in the
environment *and* pass it into KVMFixture. Also, KVMFixture has a few
commands where it sets http_proxy in the environment, for example:
return [
'sudo', 'http_proxy=%s' % self.proxy_url,
] + list(packages)
Now that we're setting these in the environme...
Graham Binns (gmb) wrote : | # |
> Looks good. This is excellent stuff to get reporting working with
> Launchpad, and it's apparent that a lot of work has gone into this.
> Things like the Launchpad blob prep and upload code could have been
> reviewed and landed independently, but things don't always work out as
> clear cut as we'd like. I have more comments to pile onto the ones that
> Raffers has already made, but I'm not going to binary fissionate
> post-midnight-
Best review comment ever.
> [1]
>
> @@ -70,6 +71,8 @@
> # just use that proxy for everything.
> proxy_url = cls.args.http_proxy
> logging.info("Using external proxy %s." % proxy_url)
> + os.environ[
> + os.environ[
> elif cls.args.
> # The user has passed --disable-cache, so don't start polipo
> # and don't try to use an external proxy.
> @@ -81,6 +84,8 @@
> proxy = LocalProxyFixture()
> cls.fixtures.
> proxy_url = proxy.get_url()
> + os.environ[
> + os.environ[
>
> Can you use fixtures.
I don't think so, because (rather filthily; I don't like it and it needs
refactoring in some way) doing the os.environ dance means that when we
do the reporting, which happens outside of the TestCase, the proxy still
gets used automatically by urllib2. Of course, we could handle it
manually, but that doesn't feel any nicer.
> [2]
>
> cls.machine = KVMFixture(
> series=
> proxy_url=
> archives=
>
> You've not changed this, but it seem odd now to set the proxy in the
> environment *and* pass it into KVMFixture.
That's true, and I'm going to address that in a follow-up branch.
> Also, KVMFixture has a few commands where it sets http_proxy in the
> environment, for example:
>
> return [
> 'sudo', 'http_proxy=%s' % self.proxy_url,
> 'DEBIAN_
> 'apt-get', 'install', '-y'
> ] + list(packages)
>
> Now that we're setting these in the environment, it ought to work to do:
>
> return [
> 'sudo', '-E',
> ...
>
> to pass through the caller's environment.
>
> Don't do this now though, just wondering what your thoughts are.
That would work for some of the commands but not all of them. For
example, as Raffers and I discovered earlier this week,
uvtool-
which doesn't handle having an https_proxy at all well (in point of
fact, it pukes on your shoes should you specify one). So we could use -E
in some places but not others. I'm fine with that if it's just for the
sync command, where we're downloading the remote images.VGV
> [3]
>
> + results_payload = make_file_payload(
> + 'maas-test.log', test_results.
>...
Preview Diff
1 | === modified file 'maastest/cases.py' |
2 | --- maastest/cases.py 2013-12-12 16:23:47 +0000 |
3 | +++ maastest/cases.py 2013-12-17 07:45:37 +0000 |
4 | @@ -17,6 +17,7 @@ |
5 | import httplib |
6 | import json |
7 | import logging |
8 | +import os |
9 | import sys |
10 | |
11 | from fixtures import Fixture |
12 | @@ -70,6 +71,8 @@ |
13 | # just use that proxy for everything. |
14 | proxy_url = cls.args.http_proxy |
15 | logging.info("Using external proxy %s." % proxy_url) |
16 | + os.environ['http_proxy'] = cls.args.http_proxy |
17 | + os.environ['https_proxy'] = cls.args.https_proxy |
18 | elif cls.args.disable_cache: |
19 | # The user has passed --disable-cache, so don't start polipo |
20 | # and don't try to use an external proxy. |
21 | @@ -81,6 +84,8 @@ |
22 | proxy = LocalProxyFixture() |
23 | cls.fixtures.useFixture(proxy) |
24 | proxy_url = proxy.get_url() |
25 | + os.environ['http_proxy'] = proxy_url |
26 | + os.environ['https_proxy'] = proxy_url |
27 | |
28 | cls.machine = KVMFixture( |
29 | series=cls.args.maas_series, architecture=architecture, |
30 | |
31 | === modified file 'maastest/console.py' |
32 | --- maastest/console.py 2013-12-05 14:02:09 +0000 |
33 | +++ maastest/console.py 2013-12-17 07:45:37 +0000 |
34 | @@ -14,12 +14,11 @@ |
35 | "run_console", |
36 | ] |
37 | |
38 | +from maastest import utils |
39 | import unittest |
40 | |
41 | -from maastest import utils |
42 | - |
43 | - |
44 | -def run_console(testcase): |
45 | + |
46 | +def run_console(testcase, output_stream): |
47 | """Run the tests found on `testcase`, reporting to the console. |
48 | |
49 | :type testcase: `unittest.TestCase` |
50 | @@ -28,5 +27,5 @@ |
51 | loader = utils.CasesLoader() |
52 | suite = loader.loadTestsFromTestCase(testcase) |
53 | runner = unittest.TextTestRunner( |
54 | - verbosity=2, descriptions=False, failfast=True) |
55 | + verbosity=2, descriptions=False, failfast=True, stream=output_stream) |
56 | return runner.run(suite) |
57 | |
58 | === modified file 'maastest/main.py' |
59 | --- maastest/main.py 2013-12-06 11:59:53 +0000 |
60 | +++ maastest/main.py 2013-12-17 07:45:37 +0000 |
61 | @@ -37,9 +37,14 @@ |
62 | from maastest.parser import prepare_parser |
63 | args = prepare_parser().parse_args(argv) |
64 | |
65 | + from maastest import utils |
66 | import logging |
67 | + # We tie everything onto one output stream so that we can capture |
68 | + # things for reporting. |
69 | + output_stream = utils.BufferingOutputStream(sys.stdout) |
70 | logging.basicConfig( |
71 | - level=logging.DEBUG, format='%(asctime)s %(levelname)s %(message)s') |
72 | + level=logging.DEBUG, format='%(asctime)s %(levelname)s %(message)s', |
73 | + stream=output_stream) |
74 | |
75 | import os |
76 | if os.geteuid() != 0: |
77 | @@ -86,15 +91,18 @@ |
78 | # up when a KeyboardInterrupt exception is raised. |
79 | # This does miss out on test-level and module-level tearDowns. |
80 | try: |
81 | - result = run_console(ConfiguredTestMAAS) |
82 | + result = run_console(ConfiguredTestMAAS, output_stream) |
83 | except KeyboardInterrupt: |
84 | ConfiguredTestMAAS.tearDownClass() |
85 | raise |
86 | + from maastest import report |
87 | + encoded_results = ( |
88 | + output_stream.buffer.getvalue().encode('utf-8')) |
89 | + report.report_test_results(encoded_results) |
90 | + if result.wasSuccessful(): |
91 | + return RETURN_CODES.SUCCESS |
92 | else: |
93 | - if result.wasSuccessful(): |
94 | - return RETURN_CODES.SUCCESS |
95 | - else: |
96 | - return RETURN_CODES.TEST_FAILED |
97 | + return RETURN_CODES.TEST_FAILED |
98 | |
99 | |
100 | if __name__ == '__main__': |
101 | |
102 | === added file 'maastest/report.py' |
103 | --- maastest/report.py 1970-01-01 00:00:00 +0000 |
104 | +++ maastest/report.py 2013-12-17 07:45:37 +0000 |
105 | @@ -0,0 +1,150 @@ |
106 | +# Copyright 2013 Canonical Ltd. This software is licensed under the |
107 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
108 | + |
109 | +"""Reporting functions for MAAS test.""" |
110 | + |
111 | +from __future__ import ( |
112 | + absolute_import, |
113 | + print_function, |
114 | + unicode_literals, |
115 | + ) |
116 | + |
117 | +__metaclass__ = type |
118 | +__all__ = [ |
119 | + "create_launchpad_blob", |
120 | + "upload_blob", |
121 | + ] |
122 | + |
123 | + |
124 | +from apiclient import multipart |
125 | +from io import BytesIO |
126 | +from urllib2 import ( |
127 | + HTTPSHandler, |
128 | + Request, |
129 | + build_opener, |
130 | + ) |
131 | +import email.mime |
132 | +import logging |
133 | +from datetime import datetime |
134 | +import os |
135 | +from xdg import BaseDirectory |
136 | + |
137 | + |
138 | +# This is largely cargo-culted from apiclient.multipart because |
139 | +# Launchpad only pays attention to the first Content-Disposition header |
140 | +# on an attachment, and if we want the content to actually be attached |
141 | +# to the bug rather than lost in the ether we have to make it |
142 | +# Content-Disposition: attachment. By default, |
143 | +# apiclient.multipart.make_file_payload() gives the payload a |
144 | +# Content-Disposition of "form-data". |
145 | +def make_file_payload(name, content): |
146 | + payload = email.mime.application.MIMEApplication(content) |
147 | + payload.add_header( |
148 | + "Content-Disposition", "attachment", name=name, filename=name) |
149 | + names = name, getattr(content, "name", None) |
150 | + payload.set_type(multipart.get_content_type(*names)) |
151 | + return payload |
152 | + |
153 | + |
154 | +def create_launchpad_blob(test_results): |
155 | + """Create an RFC822-formatted blob of data to upload to Launchpad. |
156 | + """ |
157 | + launchpad_form_data = multipart.build_multipart_message([]) |
158 | + launchpad_form_data['tags'] = 'maas-test-results' |
159 | + launchpad_form_data['private'] = 'yes' |
160 | + launchpad_form_data['subscribers'] = "private-canonical-maas" |
161 | + |
162 | + results_payload = make_file_payload( |
163 | + 'maas-test.log', test_results.encode('ascii')) |
164 | + launchpad_form_data.attach(results_payload) |
165 | + |
166 | + return launchpad_form_data |
167 | + |
168 | + |
169 | +# And this is largely cargo-culted from apport.crashdb_impl.launchpad, |
170 | +# because it does all the form-submission dance that we need to do to |
171 | +# make this actually work. |
172 | +def build_upload_mime_multipart(blob): |
173 | + launchpad_form_data = email.mime.multipart.MIMEMultipart() |
174 | + |
175 | + submit = email.mime.Text.MIMEText('1') |
176 | + submit.add_header( |
177 | + 'Content-Disposition', 'form-data; name="FORM_SUBMIT"') |
178 | + launchpad_form_data.attach(submit) |
179 | + |
180 | + form_blob_field = email.mime.Base.MIMEBase('application', 'octet-stream') |
181 | + form_blob_field.add_header( |
182 | + 'Content-Disposition', 'form-data; name="field.blob"; filename="x"') |
183 | + form_blob_field.set_payload(blob.as_string().encode('ascii')) |
184 | + launchpad_form_data.attach(form_blob_field) |
185 | + |
186 | + return launchpad_form_data |
187 | + |
188 | + |
189 | +def build_upload_request(url, launchpad_form_data): |
190 | + data_flat = BytesIO() |
191 | + generator = email.generator.Generator(data_flat, mangle_from_=False) |
192 | + generator.flatten(launchpad_form_data) |
193 | + |
194 | + # do the request; we need to explicitly set the content type here, as it |
195 | + # defaults to x-www-form-urlencoded |
196 | + request = Request(url, data_flat.getvalue()) |
197 | + request.add_header( |
198 | + 'Content-Type', |
199 | + 'multipart/form-data; boundary=' + launchpad_form_data.get_boundary()) |
200 | + |
201 | + return request |
202 | + |
203 | + |
204 | +def upload_blob(blob, hostname='launchpad.net'): |
205 | + """Upload blob (file-like object) to Launchpad. |
206 | + |
207 | + :param blob: |
208 | + """ |
209 | + token = None |
210 | + url = 'https://%s/+storeblob' % hostname |
211 | + form = build_upload_mime_multipart(blob) |
212 | + request = build_upload_request(url, form) |
213 | + opener = build_opener(HTTPSHandler) |
214 | + try: |
215 | + result = opener.open(request) |
216 | + except Exception as error: |
217 | + logging.error("Unable to connect to Launchpad: %s" % error.message) |
218 | + return None |
219 | + else: |
220 | + token = result.info().get('X-Launchpad-Blob-Token') |
221 | + return token |
222 | + |
223 | + |
224 | +def write_test_results(log_file_name, results, log_dir=None): |
225 | + if log_dir is None: |
226 | + log_file_path = os.path.join( |
227 | + BaseDirectory.save_config_path('maas-test'), |
228 | + 'logs', log_file_name) |
229 | + else: |
230 | + log_file_path = os.path.join(log_dir, log_file_name) |
231 | + with open(log_file_path, 'w') as log_file: |
232 | + log_file.write(results) |
233 | + logging.info("Test log saved to %s." % log_file_path) |
234 | + |
235 | + |
236 | +def report_test_results(results, now=None): |
237 | + blob = create_launchpad_blob(results) |
238 | + blob_token = upload_blob(blob) |
239 | + |
240 | + if blob_token is None: |
241 | + # For some reason the upload failed, so we save the contents of |
242 | + # the buffer to a file and tell the user where it is. |
243 | + logging.info("Unable to upload test results to Launchpad.") |
244 | + if now is None: |
245 | + now = datetime.now() |
246 | + |
247 | + log_file_name = ( |
248 | + "maas-test.%s.log" % now.strftime("%Y-%m-%d_%H:%M:%S")) |
249 | + write_test_results(log_file_name, results) |
250 | + else: |
251 | + logging.info("Test results uploaded to Launchpad.") |
252 | + logging.info( |
253 | + "Visit https://bugs.launchpad.net/maas-test/+filebug/%s " |
254 | + "to file a bug and complete the maas-test reporting process." |
255 | + % blob_token) |
256 | |
257 | === added file 'maastest/tests/test_report.py' |
258 | --- maastest/tests/test_report.py 1970-01-01 00:00:00 +0000 |
259 | +++ maastest/tests/test_report.py 2013-12-17 07:45:37 +0000 |
260 | @@ -0,0 +1,263 @@ |
261 | +# Copyright 2012 Canonical Ltd. This software is licensed under the |
262 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
263 | + |
264 | +"""Tests for `maastest.report`.""" |
265 | + |
266 | +from __future__ import ( |
267 | + absolute_import, |
268 | + print_function, |
269 | + unicode_literals, |
270 | + ) |
271 | + |
272 | +__metaclass__ = type |
273 | +__all__ = [] |
274 | + |
275 | + |
276 | +from fixtures import TempDir |
277 | +import base64 |
278 | +import email |
279 | +import io |
280 | +import os.path |
281 | +from maastest import report |
282 | +import testtools |
283 | +import urllib2 |
284 | +import logging |
285 | +import mock |
286 | +from datetime import datetime |
287 | + |
288 | + |
289 | +class TestMakeFilePayload(testtools.TestCase): |
290 | + |
291 | + def test_returns_MIMEApplication(self): |
292 | + payload = report.make_file_payload( |
293 | + self.getUniqueString(), self.getUniqueString()) |
294 | + self.assertIsInstance( |
295 | + payload, email.mime.application.MIMEApplication) |
296 | + |
297 | + def test_sets_content_disposition_header(self): |
298 | + filename = self.getUniqueString() |
299 | + payload = report.make_file_payload( |
300 | + filename, self.getUniqueString()) |
301 | + expected_disposition = ( |
302 | + 'attachment; name="%(name)s"; filename="%(name)s"' % |
303 | + {'name': filename}) |
304 | + self.assertEqual( |
305 | + expected_disposition, payload['Content-Disposition']) |
306 | + |
307 | + def test_sets_payload(self): |
308 | + content = self.getUniqueString() |
309 | + filename = self.getUniqueString() |
310 | + payload = report.make_file_payload(filename, content) |
311 | + encoded_content = base64.b64encode(content) |
312 | + self.assertEqual( |
313 | + encoded_content, |
314 | + payload.get_payload().replace("\n", "")) |
315 | + |
316 | + |
317 | +class TestCreateLaunchpadBlob(testtools.TestCase): |
318 | + |
319 | + def test_returns_MIMEMultipart(self): |
320 | + blob = report.create_launchpad_blob(self.getUniqueString()) |
321 | + self.assertIsInstance(blob, email.mime.multipart.MIMEMultipart) |
322 | + |
323 | + def test_sets_tags_header(self): |
324 | + blob = report.create_launchpad_blob(self.getUniqueString()) |
325 | + self.assertEqual("maas-test-results", blob['tags']) |
326 | + |
327 | + def test_sets_private_header(self): |
328 | + blob = report.create_launchpad_blob(self.getUniqueString()) |
329 | + self.assertEqual("yes", blob['private']) |
330 | + |
331 | + def test_sets_subscribers_header(self): |
332 | + blob = report.create_launchpad_blob(self.getUniqueString()) |
333 | + self.assertEqual("private-canonical-maas", blob['subscribers']) |
334 | + |
335 | + def test_attaches_results_as_payload(self): |
336 | + results = self.getUniqueString() |
337 | + blob = report.create_launchpad_blob(results) |
338 | + expected_payload = report.make_file_payload("maas-test.log", results) |
339 | + actual_payload = blob.get_payload()[0] |
340 | + self.assertEqual( |
341 | + expected_payload['Content-Disposition'], |
342 | + actual_payload['Content-Disposition']) |
343 | + self.assertEqual( |
344 | + expected_payload.get_payload(), actual_payload.get_payload()) |
345 | + |
346 | + |
347 | +class TestBuildUploadMIMEMultipart(testtools.TestCase): |
348 | + |
349 | + def setUp(self): |
350 | + super(TestBuildUploadMIMEMultipart, self).setUp() |
351 | + self.launchpad_blob = report.create_launchpad_blob( |
352 | + self.getUniqueString()) |
353 | + |
354 | + def test_returns_MIMEMultipart(self): |
355 | + upload_multipart = report.build_upload_mime_multipart( |
356 | + self.launchpad_blob) |
357 | + self.assertIsInstance( |
358 | + upload_multipart, |
359 | + email.mime.multipart.MIMEMultipart) |
360 | + |
361 | + def test_adds_submit_payload(self): |
362 | + upload_multipart = report.build_upload_mime_multipart( |
363 | + self.launchpad_blob) |
364 | + submit_payload = upload_multipart.get_payload()[0] |
365 | + self.assertIsInstance(submit_payload, email.mime.Text.MIMEText) |
366 | + self.assertEqual( |
367 | + submit_payload['Content-Disposition'], |
368 | + 'form-data; name="FORM_SUBMIT"') |
369 | + self.assertEqual('1', submit_payload.get_payload()) |
370 | + |
371 | + def test_adds_blob_as_payload(self): |
372 | + upload_multipart = report.build_upload_mime_multipart( |
373 | + self.launchpad_blob) |
374 | + blob_payload = upload_multipart.get_payload()[1] |
375 | + self.assertEqual( |
376 | + blob_payload['Content-Disposition'], |
377 | + 'form-data; name="field.blob"; filename="x"') |
378 | + self.assertEqual( |
379 | + self.launchpad_blob.as_string().encode('ascii'), |
380 | + blob_payload.get_payload()) |
381 | + |
382 | + |
383 | +class TestBuildUploadRequest(testtools.TestCase): |
384 | + |
385 | + def setUp(self): |
386 | + super(TestBuildUploadRequest, self).setUp() |
387 | + self.launchpad_blob = report.create_launchpad_blob( |
388 | + self.getUniqueString()) |
389 | + self.launchpad_form_data = report.build_upload_mime_multipart( |
390 | + self.launchpad_blob) |
391 | + |
392 | + def test_returns_http_request(self): |
393 | + request = report.build_upload_request( |
394 | + "http://example.com", self.launchpad_form_data) |
395 | + self.assertIsInstance(request, urllib2.Request) |
396 | + |
397 | + def test_sets_request_content_type_header(self): |
398 | + request = report.build_upload_request( |
399 | + "http://example.com", self.launchpad_form_data) |
400 | + self.assertRegexpMatches( |
401 | + request.get_header("Content-type"), |
402 | + 'multipart/form-data; boundary=.*') |
403 | + |
404 | + def test_sets_request_data(self): |
405 | + flat_buffer = io.BytesIO() |
406 | + generator = email.generator.Generator(flat_buffer, mangle_from_=False) |
407 | + generator.flatten(self.launchpad_form_data) |
408 | + flattened_data = flat_buffer.getvalue() |
409 | + |
410 | + request = report.build_upload_request( |
411 | + "http://example.com", self.launchpad_form_data) |
412 | + self.assertEqual(flattened_data, request.data) |
413 | + |
414 | + |
415 | +class TestUploadBlob(testtools.TestCase): |
416 | + |
417 | + def setUp(self): |
418 | + super(TestUploadBlob, self).setUp() |
419 | + self.mock_opener = mock.MagicMock() |
420 | + self.mock_result = mock.MagicMock() |
421 | + self.mock_opener.open = mock.MagicMock(return_value=self.mock_result) |
422 | + self.patch( |
423 | + report, 'build_opener', |
424 | + mock.MagicMock(return_value=self.mock_opener)) |
425 | + |
426 | + def test_builds_opener(self): |
427 | + results = self.getUniqueString() |
428 | + blob = report.create_launchpad_blob(results) |
429 | + mock_build_opener = mock.MagicMock(return_value=self.mock_opener) |
430 | + self.patch(report, 'build_opener', mock_build_opener) |
431 | + report.upload_blob(blob) |
432 | + mock_build_opener.assert_has_calls( |
433 | + [mock.call(urllib2.HTTPSHandler)]) |
434 | + |
435 | + def test_opens_url(self): |
436 | + results = self.getUniqueString() |
437 | + blob = report.create_launchpad_blob(results) |
438 | + mock_request = mock.MagicMock() |
439 | + mock_build_upload_request = mock.MagicMock( |
440 | + return_value=mock_request) |
441 | + self.patch(report, 'build_upload_request', mock_build_upload_request) |
442 | + report.upload_blob(blob) |
443 | + self.mock_opener.open.assert_has_calls([mock.call(mock_request)]) |
444 | + |
445 | + def test_returns_token(self): |
446 | + token = self.getUniqueString() |
447 | + mock_info = mock.MagicMock( |
448 | + return_value={'X-Launchpad-Blob-Token': token}) |
449 | + self.mock_result.info = mock_info |
450 | + |
451 | + blob = report.create_launchpad_blob(self.getUniqueString()) |
452 | + self.assertEqual(token, report.upload_blob(blob)) |
453 | + |
454 | + def test_returns_none_if_request(self): |
455 | + self.mock_opener.open.return_value = None |
456 | + self.mock_opener.open.side_effect = Exception(self.getUniqueString()) |
457 | + mock_error = mock.MagicMock() |
458 | + self.patch(logging, 'error', mock_error) |
459 | + |
460 | + blob = report.create_launchpad_blob(self.getUniqueString()) |
461 | + expected_message = ( |
462 | + 'Unable to connect to Launchpad: %s' % |
463 | + self.mock_opener.open.side_effect.message) |
464 | + token = report.upload_blob(blob) |
465 | + mock_error.assert_has_calls([mock.call(expected_message)]) |
466 | + self.assertEqual(None, token) |
467 | + |
468 | + |
469 | +class TestReportTestResults(testtools.TestCase): |
470 | + |
471 | + def test_uploads_blob(self): |
472 | + token = self.getUniqueString() |
473 | + blob = email.mime.multipart.MIMEMultipart() |
474 | + mock_logger = mock.MagicMock() |
475 | + mock_create_launchpad_blob = mock.MagicMock(return_value=blob) |
476 | + mock_upload_blob = mock.MagicMock(return_value=token) |
477 | + |
478 | + self.patch(logging, 'info', mock_logger) |
479 | + self.patch(report, 'create_launchpad_blob', mock_create_launchpad_blob) |
480 | + self.patch(report, 'upload_blob', mock_upload_blob) |
481 | + |
482 | + report.report_test_results(self.getUniqueString()) |
483 | + mock_upload_blob.assert_has_calls([mock.call(blob)]) |
484 | + mock_logger.assert_has_calls([ |
485 | + mock.call("Test results uploaded to Launchpad."), |
486 | + mock.call( |
487 | + "Visit https://bugs.launchpad.net/maas-test/+filebug/%s " |
488 | + "to file a bug and complete the maas-test reporting process." |
489 | + % token) |
490 | + ]) |
491 | + |
492 | + def test_saves_log_if_upload_fails(self): |
493 | + blob = email.mime.multipart.MIMEMultipart() |
494 | + mock_logger = mock.MagicMock() |
495 | + mock_create_launchpad_blob = mock.MagicMock(return_value=blob) |
496 | + mock_upload_blob = mock.MagicMock(return_value=None) |
497 | + mock_write_test_results = mock.MagicMock() |
498 | + |
499 | + self.patch(logging, 'info', mock_logger) |
500 | + self.patch(report, 'create_launchpad_blob', mock_create_launchpad_blob) |
501 | + self.patch(report, 'upload_blob', mock_upload_blob) |
502 | + self.patch(report, 'write_test_results', mock_write_test_results) |
503 | + |
504 | + results = self.getUniqueString() |
505 | + now =datetime(2013, 12, 17, 7, 18, 0) |
506 | + now_string = now.strftime("%Y-%m-%d_%H:%M:%S") |
507 | + expected_filename = 'maas-test.%s.log' % now_string |
508 | + report.report_test_results(results, now=now) |
509 | + mock_write_test_results.assert_has_calls([ |
510 | + mock.call(expected_filename, results)]) |
511 | + |
512 | + |
513 | +class TestWriteTestResults(testtools.TestCase): |
514 | + |
515 | + def test_writes_results_to_file(self): |
516 | + temp_dir = self.useFixture(TempDir()).path |
517 | + results = self.getUniqueString() |
518 | + log_file_name = self.getUniqueString() |
519 | + |
520 | + report.write_test_results(log_file_name, results, temp_dir) |
521 | + |
522 | + with open(os.path.join(temp_dir, log_file_name), 'r') as log_file: |
523 | + self.assertEqual(results, log_file.read().strip()) |
524 | |
525 | === modified file 'maastest/tests/test_utils.py' |
526 | --- maastest/tests/test_utils.py 2013-12-06 07:47:07 +0000 |
527 | +++ maastest/tests/test_utils.py 2013-12-17 07:45:37 +0000 |
528 | @@ -24,6 +24,7 @@ |
529 | from maastest.utils import binary_content |
530 | import mock |
531 | from six import text_type |
532 | +from StringIO import StringIO |
533 | import testtools |
534 | from testtools.matchers import ( |
535 | MatchesRegex, |
536 | @@ -364,3 +365,33 @@ |
537 | self.assertEqual( |
538 | ["test_333", "test_111", "test_222"], |
539 | [test._testMethodName for test in suite]) |
540 | + |
541 | + |
542 | +class TestBufferingOutputStream(testtools.TestCase): |
543 | + |
544 | + def test_writes_to_stream(self): |
545 | + stream = StringIO() |
546 | + buffering_stream = utils.BufferingOutputStream(stream) |
547 | + output_string = self.getUniqueString() |
548 | + buffering_stream.write(output_string) |
549 | + self.assertEqual(output_string, stream.getvalue()) |
550 | + |
551 | + def test_buffers_output(self): |
552 | + stream = StringIO() |
553 | + buffering_stream = utils.BufferingOutputStream(stream) |
554 | + buffering_stream.write(self.getUniqueString()) |
555 | + self.assertEqual( |
556 | + stream.getvalue(), buffering_stream.buffer.getvalue()) |
557 | + |
558 | + def test_init_sets_values(self): |
559 | + stream = StringIO() |
560 | + buffering_stream = utils.BufferingOutputStream(stream) |
561 | + self.assertEqual(stream, buffering_stream.stream) |
562 | + self.assertIsInstance(buffering_stream.buffer, StringIO) |
563 | + |
564 | + def test_attributes_looked_up_on_stream(self): |
565 | + stream = StringIO() |
566 | + some_string = self.getUniqueString() |
567 | + stream.foo_bar_baz = mock.MagicMock(return_value=some_string) |
568 | + buffering_stream = utils.BufferingOutputStream(stream) |
569 | + self.assertEqual(some_string, buffering_stream.foo_bar_baz()) |
570 | |
571 | === modified file 'maastest/utils.py' |
572 | --- maastest/utils.py 2013-12-06 07:47:07 +0000 |
573 | +++ maastest/utils.py 2013-12-17 07:45:37 +0000 |
574 | @@ -254,3 +254,18 @@ |
575 | """A wrapper around raw_input() that allows us to sanely mock it. |
576 | """ |
577 | return input(message) |
578 | + |
579 | + |
580 | +class BufferingOutputStream: |
581 | + |
582 | + def __init__(self, stream): |
583 | + from StringIO import StringIO |
584 | + self.buffer = StringIO() |
585 | + self.stream = stream |
586 | + |
587 | + def __getattr__(self, attr): |
588 | + return getattr(self.stream, attr) |
589 | + |
590 | + def write(self, stuff): |
591 | + self.buffer.write(stuff) |
592 | + self.stream.write(stuff) |
[0]
40 + # We specifically use 2047 for the memory size here because
41 + # using 2048 causes qemu to raise an error on i386 hosts.
42 + "--memory", "2047",
This will conflict with what's in trunk now. Please merge trunk.
[1]
The module-level docstrings of the new modules need to be changed.
[2]
maastest/main.py
80 + from maastest import report stream. buffer. getvalue( ).encode( 'utf-8' )) create_ launchpad_ blob(encoded_ results) upload_ blob(blob) /bugs.launchpad .net/maas- test/+filebug/ %s "
81 + encoded_results = (
82 + output_
83 + blob = report.
84 + blob_token = report.
[...]
100 + "Visit https:/
101 + "to file a bug and complete the maas-test reporting process."
102 + % blob_token)
Wouldn't it be better if all of this was in a separate helper method so that it can be easily tested?
[3]
27 +__all__ = [
128 + "upload_blob",
129 + ]
'create_ launchpad_ blob' is missing from the list.
[4]
This while thing is a pretty serious change to what maas-test does. A good one I mean, but a serious one and I don't see any changes to the documentation. Maybe you're planning to do it in a follow-up branch but it's pretty important that this behavior is documented.
[5]
Questions about testing: what the code does is pretty tricky as it interacts with an external system (Launchpad). Can I ask what kind of testing you've done already? Could we have sort of a QA plan for this? In particular, I think it's pretty important to make sure that everything works fine when maas-test can't access Launchpad; I don't think there is provision for that in the code: what happens if upload_ blob.opener. open(request) errors for instance?
[6]
This code doesn't use the optionally-provided proxy option. This does not seem right to me but I'd like to hear your thoughts about this.
[7]
Maybe something we can do in a follow-up branch but, although I think it's sane to do the reporting by default, I'm also convinced it would be pretty useful to have a "--no-report" option.