Merge lp:~gmb/maas-test/add-reporting into lp:maas-test

Proposed by Graham Binns
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
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-by-Launchpad to maas-test.

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_.

To post a comment you must log in.
Revision history for this message
Raphaël Badin (rvb) 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.

[1]

The module-level docstrings of the new modules need to be changed.

[2]

maastest/main.py

80 + from maastest import report
81 + encoded_results = (
82 + output_stream.buffer.getvalue().encode('utf-8'))
83 + blob = report.create_launchpad_blob(encoded_results)
84 + blob_token = report.upload_blob(blob)
[...]
100 + "Visit https://bugs.launchpad.net/maas-test/+filebug/%s "
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.

Revision history for this message
Raphaël Badin (rvb) wrote :

[8]

448 +class BufferingOutputStream:
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).

[10]

1 === modified file 'maastest/console.py'
[...]
23 + if output_stream is None:
24 + output_stream = sys.stderr

Is that used?

[11]

92 + log_file_path = os.path.join(
93 + BaseDirectory.save_config_path('maas-test'), 'maas-test.log')
94 + with open(log_file_path, 'w') as log_file:
95 + log_file.write(encoded_results)

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_launchpad_blob(test_results):
[...]
168 + results_payload = make_file_payload(
169 + 'maas-test.log', test_results.encode('ascii'))

178 +def build_upload_mime_multipart(blob):
[...]
187 + form_blob_field.add_header(
188 + 'Content-Disposition', 'form-data; name="field.blob"; filename="x"')
189 + form_blob_field.set_payload(blob.as_string().encode('ascii'))

81 + encoded_results = (
82 + output_stream.buffer.getvalue().encode('utf-8'))

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_form_data['subject'] = "MAAS Test results for: "

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.)

Revision history for this message
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]).

Revision history for this message
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_stream.buffer.getvalue().encode('utf-8'))
> 83 + blob = report.create_launchpad_blob(encoded_results)
> 84 + blob_token = report.upload_blob(blob)
> [...]
> 100 + "Visit https://bugs.launchpad.net/maas-test/+filebug/%s "
> 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_launchpad_blob' is missing from the list.

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_blob.opener.open(request) errors for instance?

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.

Revision history for this message
Graham Binns (gmb) wrote :

> [8]
>
> 448 +class BufferingOutputStream:
> 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/console.py'
> [...]
> 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.save_config_path('maas-test'), 'maas-
> test.log')
> 94 + with open(log_file_path, 'w') as log_file:
> 95 + log_file.write(encoded_results)
>
> 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_launchpad_blob(test_results):
> [...]
> 168 + results_payload = make_file_payload(
> 169 + 'maas-test.log', test_results.encode('ascii'))
>
> 178 +def build_upload_mime_multipart(blob):
> [...]
> 187 + form_blob_field.add_header(
> 188 + 'Content-Disposition', 'form-data; name="field.blob";
> filename="x"')
> 189 + form_blob_field.set_payload(blob.as_string().encode('ascii'))
>
> 81 + encoded_results = (
> 82 + output_stream.buffer.getvalue().encode('utf-8'))
>
> 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_form_data['subject'] = "MAAS Test results for: "
>
> 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.

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

Revision history for this message
Gavin Panella (allenap) wrote :
Download full text (7.8 KiB)

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-snacking mogwai if you disagree with them.

[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['http_proxy'] = cls.args.http_proxy
+            os.environ['https_proxy'] = cls.args.https_proxy
         elif cls.args.disable_cache:
             # 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.useFixture(proxy)
             proxy_url = proxy.get_url()
+            os.environ['http_proxy'] = proxy_url
+            os.environ['https_proxy'] = proxy_url

Can you use fixtures.EnvironmentVariable here?

    from fixtures import EnvironmentVariable
    ...

    @classmethod
    def useProxy(cls, url):
        cls.fixtures.useFixture(EnvironmentVariable("http_proxy", url))
        cls.fixtures.useFixture(EnvironmentVariable("https_proxy", url))

    @classmethod
    def setUpClass(cls):
        super(TestMAAS, cls).setUpClass()

        # TODO: series and architecture should be script parameters.
        architecture = utils.determine_vm_architecture()

        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.
            proxy_url = cls.args.http_proxy
            cls.useProxy(proxy_url)
            logging.info("Using external proxy %s." % proxy_url)
        elif cls.args.disable_cache:
            # The user has passed --disable-cache, so don't start polipo
            # and don't try to use an external proxy.
            proxy_url = ''
            cls.useProxy(None)
            logging.info("Caching disabled.")
        else:
            # The default case: start polipo and use that as our caching
            # proxy.
            proxy = LocalProxyFixture()
            cls.fixtures.useFixture(proxy)
            proxy_url = proxy.get_url()
            cls.useProxy(proxy_url)

[2]

        cls.machine = KVMFixture(
            series=cls.args.maas_series, architecture=architecture,
            proxy_url=proxy_url, direct_interface=cls.args.interface,
            archives=cls.args.archive)

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,
            'DEBIAN_FRONTEND=noninteractive',
            'apt-get', 'install', '-y'
            ] + list(packages)

Now that we're setting these in the environme...

Read more...

review: Approve
Revision history for this message
Graham Binns (gmb) wrote :
Download full text (7.5 KiB)

> 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-snacking mogwai if you disagree with them.

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['http_proxy'] = cls.args.http_proxy
> + os.environ['https_proxy'] = cls.args.https_proxy
> elif cls.args.disable_cache:
> # 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.useFixture(proxy)
> proxy_url = proxy.get_url()
> + os.environ['http_proxy'] = proxy_url
> + os.environ['https_proxy'] = proxy_url
>
> Can you use fixtures.EnvironmentVariable here?

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=cls.args.maas_series, architecture=architecture,
> proxy_url=proxy_url, direct_interface=cls.args.interface,
> archives=cls.args.archive)
>
> 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_FRONTEND=noninteractive',
> '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-simplestreams, to which we shell out, uses the requests module,
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.encode('ascii'))
>...

Read more...

Preview Diff

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

Subscribers

People subscribed via source and target branches