Merge lp:~jtv/pyjuju/maas-anon-urls into lp:pyjuju

Proposed by Jeroen T. Vermeulen
Status: Work in progress
Proposed branch: lp:~jtv/pyjuju/maas-anon-urls
Merge into: lp:pyjuju
Prerequisite: lp:~jtv/pyjuju/makefile-fixups
Diff against target: 460 lines (+265/-72)
4 files modified
Makefile (+27/-17)
README (+1/-1)
juju/providers/maas/files.py (+79/-10)
juju/providers/maas/tests/test_files.py (+158/-44)
To merge this branch: bzr merge lp:~jtv/pyjuju/maas-anon-urls
Reviewer Review Type Date Requested Status
Gavin Panella (community) Approve
Kapil Thangavelu Pending
Review via email: mp+149911@code.launchpad.net

Commit message

Change file storage in the MAAS provider so that MAAS gets the chance to specify a file's download URL.

Description of the change

This change was discussed with all of Red Squad. For the full background, see bug 1123986 and https://docs.google.com/a/canonical.com/document/d/1JuqpXf8U8BZu2pIoTfk6qozl1RM-xvUV0diiK43JsLA/

Currently when the MAAS provider is asked for the download URL of an uploaded file, it just computes a standard URL for that file on the MAAS API. But in order to be able to separate namespaces for different users (and juju environments), we need to give MAAS more room to assign its own URLs.

And so, this change makes get_url() ask the MAAS API for the correct URL. If it finds it's talking to an older MAAS which doesn't support that, it still falls back to the old way of doing things. But if MAAS provides a URL for the file, that's the one that it will pass on to nodes that need access.

I went with inlineCallbacks here, even though many people dislike it for its unhelpful tracebacks. I did that because it allowed me to break up functions along traditional lines of abstraction. I'm adding just enough complexity that a hodgepodge of nested function definitions and clever chaining was making it hard to see what was going on.

You'll notice that get_url now returns Deferred, instead of the URL itself. Luckily the code (well, outside the MAAS provider itself of course!) does not make assumptions about this. It correctly runs the function through the Twisted reactor. There is no documentation to say that that is wrong, so I can only infer that this was always intended to be possible.

Possible, yes, but not optimal. There would be other ways of getting at the file data when talking to a new MAAS (it's actually contained in the file information that get_url downloads). And we might cache the knowledge of which MAAS we're talking to, and we might cache URLs, and so on. But we believe those to be secondary concerns. Our reasons are:

 1. juju-core will not have any of these problems. It will go straight to the file information and return the file's contents, or its URL, immediately.

 2. We urgently need to fix an inability to run two juju environments on one MAAS, so that takes precedence.

 3. Amdahl's Law: if this isn't the bottleneck, it's not the right place to optimize.

 4. Knuth's Law: optimizing the process would also increase complexity and thus risk.

My biggest worry is in the error handling. Development was strictly TDD, but with this much layercake in the way there is a risk of "circular reasoning" between the test expectations and the test doubles. We'll have to test thoroughly in the Q/A lab. In particular, the "not found" case will be exercised when we test the new provider code against an old MAAS. Since both of the "404" situations that come up go through the same code, that one scenario will validate both.

Jeroen

To post a comment you must log in.
Revision history for this message
Raphaël Badin (rvb) wrote :

158 + meta_url = self._base_url + name

This generates urls like "/MAAS/api/1.0/files/provider-state" (note the missing slash at the end of the url) that gets redirected to "/MAAS/api/1.0/files/provider-state/". (See this extract from apache2's log: http://paste.ubuntu.com/1705131/). It's probably better to generate the proper url directly.

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

[1]

166 + attributes = json.loads(body)
167 + anon_url = attributes.get('anon_url')
168 + if anon_url is None:
169 + # MAAS doesn't provide an anonymous download URL. Generate it
170 + # the old way. If the file was uploaded by a legacy provider,
171 + # MAAS will honour this legacy URL.
172 + returnValue(compose_legacy_file_url(self._base_url, name))
173 + else:
174 + # Success. Return the anonymous file download URL that MAAS
175 + # sent us.
176 + returnValue(anon_url)

The 'anon_url' does not include the host part (see the discussion on bug 1131692). You need to change that part to something along the lines of https://pastebin.canonical.com/85388/ plus s/anon_url/anon_resource_uri/ (to account for the fix I just committed in lp:~rvb/maas/bug-1123986-key-api).

[2]

With that change, I was able to successfully deploy bootrap an environment and deploy a mysql and a mediawiki charms. But look at the record of the calls to the file storage API endpoint: https://pastebin.canonical.com/85392/. Line 13 and 27, you can see the bootstrap node still using the old version of the anonymous urls. Note that the nodes themselves use the new version of the anonymous urls.

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

Looks good! [1] and [2] were head-scratchers but I don't want to block
you on resolving these, hence +1.

[1]

+    def _download(self, url, intention=None):
...
+        try:
+            body = yield self.dispatch_query(url)
+        except Error:
+            if hasattr(self.client, 'status'):
+                # This is an error response from the server.  We'll
+                # handle it by looking at the http status code.
+                pass
...
+        status = int(self.client.status)
+        if status == httplib.NOT_FOUND:
+            raise FileNotFound(url)
+        if (status < 200) or (status > 299):
+            # Something else went wrong.  Pass it on.
+            raise ProviderError("%s: unexpected HTTP %s" % (intention, status))

Would this last conditional be needed if this block were moved up to
where the `pass` is made?

        try:
            body = yield self.dispatch_query(url)
        except Error:
            if hasattr(self.client, 'status'):
                # This is an error response from the server.  We'll
                # handle it by looking at the http status code.
                status = int(self.client.status)
                if status == httplib.NOT_FOUND:
                    raise FileNotFound(url)
                else:
                    # Something else went wrong.  Pass it on.
                    raise ProviderError(...
            else:
                raise

[2]

+        except Error:
+            if hasattr(self.client, 'status'):
+                # This is an error response from the server.  We'll
+                # handle it by looking at the http status code.
+                pass
+            else:
+                # This is a client-side failure.  Pass it on.
+                raise

This got me wondering...

Error always has a status attribute. In twisted.web.client it only
ever appears to be raised when there's a status to report, which
implies that some response has been received.

Also, client.status (where client is an instance of HTTPClientFactory)
is set when twisted.web.http.HTTPClient.lineReceived calls
gotStatus(), when the first line back from the server is received, but
is not reset when there's a 30[123] redirect, so it's not a wholly
reliable test for a client-side failure.

Might the following do what you need?

        try:
            body = yield self.dispatch_query(url)
        except Error as error:
            if int(error.status) == httplib.NOT_FOUND:
                raise FileNotFound(url)
            else:
                # Something else went wrong.  Pass it on.
                raise ProviderError(
                    "%s: unexpected HTTP %s" % (intention, status))
        except Exception as e:
            raise convert_unknown_error(e)

        returnValue(body)

Btw, I like the intention stuff. I think it'll make user's lives a
little nicer.

[3]

+        anon_url = attributes.get('anon_url')

The handling of this will need to change; rvba's changing this to
anon_resource_uri (iirc, better check), which also means you'll need
to join it to self._base_url.

[4]

+        try:
+            yield storage.get_url("kaboom")
+        except ProviderError:
+    ...

Read more...

review: Approve
Revision history for this message
Jeroen T. Vermeulen (jtv) wrote :

> Looks good! [1] and [2] were head-scratchers but I don't want to block
> you on resolving these, hence +1.

Thanks. It's a holiday today but people are waiting for this so I'm pushing it through now, and then I will enjoy my evening in a guilt-free fashion. :)

> +    def _download(self, url, intention=None):

> +        if (status < 200) or (status > 299):
> +            # Something else went wrong.  Pass it on.
> +            raise ProviderError("%s: unexpected HTTP %s" % (intention,
> status))
>
> Would this last conditional be needed if this block were moved up to
> where the `pass` is made?

Probably not, actually! Twisted's documentation has a way of not answering important questions, so I was a bit extra conservative with this. But now that I've looked at the code, I think it'd be OK to do error processing only in the exception case.

My remaining worry is that as far as I can see, Twisted would also raise an error for "No content" and a bunch of other 2xx responses. But as far as I know we don't need that anywhere, and an unwarranted error that we can make the code ignore is a lot more benign than a silent failure that we should be checking for.

> Error always has a status attribute. In twisted.web.client it only
> ever appears to be raised when there's a status to report, which
> implies that some response has been received.
>
> Also, client.status (where client is an instance of HTTPClientFactory)
> is set when twisted.web.http.HTTPClient.lineReceived calls
> gotStatus(), when the first line back from the server is received, but
> is not reset when there's a 30[123] redirect, so it's not a wholly
> reliable test for a client-side failure.
>
> Might the following do what you need?

[…]

Yup, that looks like what I need. Thanks — I used it.

> Btw, I like the intention stuff. I think it'll make user's lives a
> little nicer.

Glad to hear it. Error reporting is a field that gets nowhere near the attention it deserves, whether in theory or in practice.

> +        anon_url = attributes.get('anon_url')
>
> The handling of this will need to change; rvba's changing this to
> anon_resource_uri (iirc, better check), which also means you'll need
> to join it to self._base_url.

Yes, Raphaël already noted that and I've made the necessary changes.

> Fwiw, testtools.ExpectedException can help here, assuming we're
> allowed testtools (ah, maybe not):

That's what I thought at the time I wrote it, but alas: no testtools!

Jeroen

Revision history for this message
Jeroen T. Vermeulen (jtv) wrote :

The review changes exposed a weakness in the test setup: all instances of FakeFileStorageReturning404 used the same Deferred instance (the one that failed with an http not-found error). Once that had failed once, any other test calls using the same fake file storage would set their client status to 404, but would not return/raise (depending on your choice of Twisted execution model) an error. That was enough to make the old code see a 404, but not the new code which relies on these errors.

One instance went away when I patched out a call to get_url that was irrelevant to the test (reducing the number of 404s incurred from two to one). The other required me to hide the fake filestorage factory in another factory. There are probably nicer ways of doing this, such as making it a class, or refactoring the test helpers yet again. For now, I went with the simplest change that sprang to mind.

Unmerged revisions

648. By Jeroen T. Vermeulen

Hide the FakeFileStorage factory for 404s in another factory. Sigh.

647. By Jeroen T. Vermeulen

A single 404 isn't enough to trigger an exception on a get() any more. Patch out the get_url call where 404s are ignored.

646. By Jeroen T. Vermeulen

Tiny fixup.

645. By Jeroen T. Vermeulen

Merge trunk.

644. By Jeroen T. Vermeulen

Simplified Twisted http error handling, as per review.

643. By Jeroen T. Vermeulen

anon_url has become anon_resource_uri.

642. By Jeroen T. Vermeulen

Review changes.

641. By Jeroen T. Vermeulen

Tweaks to error handling.

640. By Jeroen T. Vermeulen

Extract download method.

639. By Jeroen T. Vermeulen

Get lint-checking etc. to work.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'Makefile'
2--- Makefile 2012-09-27 01:43:32 +0000
3+++ Makefile 2013-02-25 09:42:19 +0000
4@@ -21,24 +21,34 @@
5 etags:
6 @ctags -e --python-kinds=-iv -R juju
7
8-modified=$(shell bzr status -S |grep -P '^\s*M' | awk '{print $$2;}'| grep -P ".py$$")
9-check:
10- @test -n "$(modified)" && echo $(modified) | xargs $(PEP8) --repeat
11- @test -n "$(modified)" && echo $(modified) | xargs pyflakes
12-
13-
14-modified=$(shell bzr status -S -r ancestor:$(JUJU_TRUNK) |grep -P '^\s*M' | awk '{print $$2;}'| grep -P ".py$$")
15-review:
16- #@test -n "$(modified)" && echo $(modified) | xargs $(PEP8) --repeat
17- @test -n "$(modified)" && echo $(modified) | xargs pyflakes
18-
19-
20-modified=$(shell bzr status -S -r branch::prev |grep -P '^\s*\+?[MN]' | awk '{print $$2;}'| grep -P "test_.*\.py$$")
21+
22+present_pep8=$(shell which $(PEP8))
23+present_pyflakes=$(shell which pyflakes)
24+warn_missing_linters:
25+ @test -n "$(present_pep8)" || echo "WARNING: $(PEP8) not installed."
26+ @test -n "$(present_pyflakes)" || echo "WARNING: pyflakes not installed."
27+
28+
29+# "check": Check uncommitted changes for lint.
30+check_changes=$(shell bzr status -S | grep '^[ +]*[MN]' | awk '{print $$2;}' | grep "\\.py$$")
31+check: warn_missing_linters
32+ @test -z $(present_pep8) || (echo $(check_changes) | xargs -r $(PEP8) --repeat)
33+ @test -z $(present_pyflakes) || (echo $(check_changes) | xargs -r pyflakes)
34+
35+
36+# "review": Check all changes compared to trunk for lint.
37+review_changes=$(shell bzr status -S -r ancestor:$(JUJU_TRUNK) | grep '^[ +]*[MN]' | awk '{print $$2;}' | grep "\\.py$$")
38+review: warn_missing_linters
39+ #@test -z $(present_pep8) || (echo $(review_changes) | xargs -r $(PEP8) --repeat)
40+ @test -z $(present_pyflakes) || (echo $(review_changes) | xargs -r pyflakes)
41+
42+
43+ptests_changes=$(shell bzr status -S -r branch::prev | grep -P '^[ +]*[MN]' | awk '{print $$2;}'| grep "test_.*\\.py$$")
44 ptests:
45- @test -n "$(modified)" && echo $(modified) | xargs ./test
46+ @echo $(ptests_changes) | xargs -r ./test
47
48-modified=$(shell bzr status -S -r ancestor:$(JUJU_TRUNK)/|grep -P 'test.*\.py' |awk '{print $$2;}')
49+btests_changes=$(shell bzr status -S -r ancestor:$(JUJU_TRUNK)/ | grep "test.*\\.py$$" | awk '{print $$2;}')
50 btests:
51- @./test $(modified)
52+ @./test $(btests_changes)
53
54-.PHONY: tags check review
55+.PHONY: tags check review warn_missing_linters
56
57=== modified file 'README'
58--- README 2012-07-01 18:36:56 +0000
59+++ README 2013-02-25 09:42:19 +0000
60@@ -9,7 +9,7 @@
61
62 The juju bug tracker is at https://bugs.launchpad.net/juju
63
64-Documentation for getting setup and running juju can be found at
65+Documentation for getting set up and running juju can be found at
66 http://juju.ubuntu.com/docs
67
68 ====
69
70=== modified file 'juju/providers/maas/files.py'
71--- juju/providers/maas/files.py 2012-03-27 11:26:50 +0000
72+++ juju/providers/maas/files.py 2013-02-25 09:42:19 +0000
73@@ -5,9 +5,11 @@
74
75 from cStringIO import StringIO
76 import httplib
77+import json
78 import mimetypes
79 import random
80 import string
81+from twisted.internet.defer import (inlineCallbacks, returnValue)
82 from twisted.web.error import Error
83 import urllib
84 from urlparse import urljoin
85@@ -79,6 +81,17 @@
86 return body, headers
87
88
89+def compose_legacy_file_url(base_url, filename):
90+ """Produce a legacy anonymous URL for an uploaded file.
91+
92+ With newer MAAS versions, the region controller provides the anonymous
93+ download URL.
94+ """
95+ params = {"op": "get", "filename": filename}
96+ param_string = urllib.urlencode(params)
97+ return base_url + "?" + param_string
98+
99+
100 class MAASFileStorage(MAASOAuthConnection):
101 """A file storage abstraction for MAAS."""
102
103@@ -90,6 +103,34 @@
104 self._auth = config["maas-oauth"]
105 super(MAASFileStorage, self).__init__(self._auth)
106
107+ @inlineCallbacks
108+ def _download(self, url, intention=None):
109+ """Download the resource at the given URL, using a http "GET".
110+
111+ Pass an "intention" string to tell a reader of the logs, in the
112+ even of an error, what it was you were trying to do.
113+
114+ Returns the response body if successful. Otherwise, returns either
115+ FileNotFound on the URL (if the server responded with "not found"),
116+ or a Twisted Error.
117+ """
118+ if intention is None:
119+ intention = "GET %s" % url
120+
121+ try:
122+ body = yield self.dispatch_query(url)
123+ except Error as e:
124+ if int(e.status) == httplib.NOT_FOUND:
125+ raise FileNotFound(url)
126+ else:
127+ raise ProviderError(
128+ "%s: unexpected HTTP %s" % (intention, e.status))
129+ except Exception as e:
130+ raise convert_unknown_error(e)
131+ else:
132+ returnValue(body)
133+
134+ @inlineCallbacks
135 def get_url(self, name):
136 """Return a URL that can be used to access a stored file.
137
138@@ -97,10 +138,33 @@
139 :return: a URL
140 :rtype: str
141 """
142- params = {"op": "get", "filename": name}
143- param_string = urllib.urlencode(params)
144- return self._base_url + "?" + param_string
145-
146+ # Request metadata for the file, including its anonymous URL.
147+ # This is a later addition to the MAAS API, so it may not be
148+ # available. If it isn't, we fall back on the "legacy" way of
149+ # composing URLs right here in the provider.
150+ # Adding a trailing slash here saves a step as the API would
151+ # otherwise redirect to this same URL anyway.
152+ meta_url = self._base_url + name + "/"
153+ try:
154+ body = yield self._download(meta_url, "Get file information")
155+ except FileNotFound:
156+ # This MAAS version doesn't provide anonymous file URLs.
157+ # Fall back to the legacy URL.
158+ returnValue(compose_legacy_file_url(self._base_url, name))
159+
160+ attributes = json.loads(body)
161+ anon_url = attributes.get('anon_resource_uri')
162+ if anon_url is None:
163+ # MAAS doesn't provide an anonymous download URL. Generate it
164+ # the old way. If the file was uploaded by a legacy provider,
165+ # MAAS will honour this legacy URL.
166+ returnValue(compose_legacy_file_url(self._base_url, name))
167+ else:
168+ # Success. Return the anonymous file download URL that MAAS
169+ # sent us.
170+ returnValue(urljoin(self._base_url, anon_url))
171+
172+ @inlineCallbacks
173 def get(self, name):
174 """Get a file object from the MAAS server.
175
176@@ -109,12 +173,17 @@
177 :rtype: :class:`twisted.internet.defer.Deferred`
178 :raises: :exc:`juju.errors.FileNotFound` if the file doesn't exist
179 """
180- url = self.get_url(name)
181- d = self.dispatch_query(url)
182- d.addCallback(StringIO)
183- error_tab = {httplib.NOT_FOUND: FileNotFound(url)}
184- d.addErrback(_convert_error, "GET", url, error_tab)
185- return d
186+ url = yield self.get_url(name)
187+
188+ try:
189+ body = yield self._download(url, "Download %s" % name)
190+ except FileNotFound:
191+ # Don't raise a FileNotFound with the full URL in it. Anonymous
192+ # URLs can be accessed by anyone, and privacy of the files relies
193+ # on nobody knowing the URL itself. Cite the filename instead.
194+ raise FileNotFound(name)
195+
196+ returnValue(StringIO(body))
197
198 def put(self, name, file_object):
199 """Upload a file to MAAS.
200
201=== modified file 'juju/providers/maas/tests/test_files.py'
202--- juju/providers/maas/tests/test_files.py 2012-03-27 11:26:50 +0000
203+++ juju/providers/maas/tests/test_files.py 2013-02-25 09:42:19 +0000
204@@ -3,13 +3,16 @@
205
206 """Tests for juju.providers.maas.files"""
207
208+from functools import partial
209 import httplib
210 from io import BytesIO
211+import json
212 import re
213 from textwrap import dedent
214 from twisted.internet import defer
215 from twisted.web import error
216-from urlparse import urlparse
217+from urllib import urlencode
218+from urlparse import parse_qs, urlparse
219
220 from juju.errors import (
221 FileNotFound, ProviderError, ProviderInteractionError)
222@@ -20,41 +23,42 @@
223 class FakeFileStorage(object):
224 """A fake http client to MAAS so MAASFileStorage tests can operate."""
225
226- def __init__(self, url, method='GET', postdata=None, headers=None):
227+ def __init__(self, url, method='GET', postdata=None, headers=None,
228+ fake_status=httplib.OK, fake_deferred=None):
229+ """This constructor is written with partial application in mind.
230+
231+ The fake_status and fake_deferred parameters determine the outcome
232+ of any request: "status" is set to fake_status (if given) and
233+ "deferred" is set to fake_deferred (if given, or defaults to success).
234+
235+ In cases where you need a factory of FakeFileStorage objects with
236+ these attributes set, use functools.partial to generate a factory with
237+ the fake_* parameters pre-set.
238+ """
239 # Store passed data for later inspection.
240 self.headers = headers
241 self.url = url
242 self.data = postdata
243 self.action = method
244
245- func = getattr(self, method.lower())
246- self.deferred = func()
247-
248- def get(self):
249- return defer.succeed("blah")
250-
251- def post(self):
252- return defer.succeed("blah")
253-
254-
255-class FakeFileStorageReturning404(FakeFileStorage):
256-
257- def get(self):
258- self.status = str(httplib.NOT_FOUND)
259- return defer.fail(error.Error(self.status, "this is a 404", ""))
260-
261-
262-class FakeFileStorageReturningUnexpectedError(FakeFileStorage):
263-
264- def get(self):
265- return defer.fail(ZeroDivisionError("numpty"))
266-
267-
268-class FakeFileStorageWithErrorOnAddingFile(FakeFileStorage):
269-
270- def post(self):
271- self.status = str(httplib.UNAUTHORIZED)
272- return defer.fail(error.Error(self.status, "this is a 401", ""))
273+ self.status = str(fake_status)
274+ if fake_deferred is None:
275+ self.deferred = defer.succeed("{}")
276+ else:
277+ self.deferred = fake_deferred
278+
279+
280+def set_up_for_404():
281+ """Return a FakeFileStorage factory that simulates a "Not Found" response.
282+ """
283+ # This could be done with a simple global variable, except each
284+ # instance of the factory must have its own instance of the Deferred
285+ # that produces the error, or the client code won't fail after the
286+ # Deferred's first run.
287+ return partial(
288+ FakeFileStorage, fake_status=httplib.NOT_FOUND,
289+ fake_deferred=defer.fail(
290+ error.Error(str(httplib.NOT_FOUND), "this is a 404", "")))
291
292
293 class TestMAASFileAPIFunctions(TestCase):
294@@ -96,16 +100,114 @@
295 self.assertEqual(expected_headers, headers)
296
297
298+def compute_legacy_file_url(maas_path, filename):
299+ """Return (path, query) for a legacy file URL as computed by the provider.
300+
301+ :param maas_path: Base path (no networking part!) for the MAAS API. Must
302+ represent a directory, and so end in a slash.
303+ :param filename: Name of the file you want a URL for.
304+ :return: Full absolute path part of the file's URL, and a query dict.
305+ """
306+ assert maas_path.endswith('/')
307+ return (
308+ maas_path + 'files/',
309+ {'op': ['get'], 'filename': [filename]},
310+ )
311+
312+
313+def compute_anon_file_url(maas_path, file_key):
314+ """Return (path, query) for an anonymous file URL.
315+
316+ :param maas_path: Base path (no networking part!) for the MAAS API. Must
317+ represent a directory, and so end in a slash.
318+ :param file_key: Unique key of the file you want a URL for.
319+ :return: Full absolute path part of the file's anonymous download URL, and
320+ a query dict.
321+ """
322+ assert maas_path.endswith('/')
323+ return (
324+ maas_path + 'files/',
325+ {'op': ['get_by_key'], 'key': [file_key]},
326+ )
327+
328+
329+def parse_file_url(url):
330+ """Return (path, query) for a file URL."""
331+ urlparts = urlparse(url)
332+ return urlparts.path, parse_qs(urlparts.query)
333+
334+
335+def compose_file_url(path, query):
336+ """Put the pieces (path and query) of a file URL back together."""
337+ return "%s?%s" % (path, urlencode(query, doseq=True))
338+
339+
340 class TestMAASFileStorage(TestCase):
341
342- def test_get_url(self):
343- # get_url should return the base URL plus the op params for a
344- # file name.
345- storage = MAASFileStorage(CONFIG)
346- url = storage.get_url("foofile")
347- urlparts = urlparse(url)
348- self.assertEqual("/maas/api/1.0/files/", urlparts.path)
349- self.assertEqual("filename=foofile&op=get", urlparts.query)
350+ @defer.inlineCallbacks
351+ def test_get_url_uses_anon_resource_uri_from_server(self):
352+ # MAAS normally provides a URL for the file, as one of the file
353+ # object's attributes, and get_url returns it.
354+ file_path, file_params = compute_anon_file_url('/maas/api/1.0/', "987")
355+
356+ # Simulated FileStorage that contains an anon_resource_uri entry.
357+ response = {
358+ 'anon_resource_uri': compose_file_url(file_path, file_params),
359+ }
360+ fake_storage = partial(
361+ FakeFileStorage, fake_deferred=defer.succeed(json.dumps(response)))
362+ self.setup_connection(MAASFileStorage, fake_storage)
363+
364+ url = yield MAASFileStorage(CONFIG).get_url("foofile")
365+
366+ self.assertEqual((file_path, file_params), parse_file_url(url))
367+
368+ @defer.inlineCallbacks
369+ def test_get_url_reports_failure_while_asking_maas(self):
370+ status = str(httplib.NOT_IMPLEMENTED)
371+ response = defer.fail(error.Error(status, "deliberate failure", ""))
372+ get_error = partial(
373+ FakeFileStorage, fake_status=status, fake_deferred=response)
374+ self.setup_connection(MAASFileStorage, get_error)
375+ storage = MAASFileStorage(CONFIG)
376+
377+ try:
378+ yield storage.get_url("kaboom")
379+ except ProviderError:
380+ # This is what we expect: an error response from the server means
381+ # that the Deferred returned by get_url fails.
382+ pass
383+ except:
384+ # Any other error? That's unexpected. Pass it on.
385+ raise
386+ else:
387+ self.fail("Error was not propagated.")
388+
389+ @defer.inlineCallbacks
390+ def test_get_url_falls_back_on_legacy_url_with_older_maas_api(self):
391+ self.setup_connection(MAASFileStorage, set_up_for_404())
392+ storage = MAASFileStorage(CONFIG)
393+
394+ url = yield storage.get_url("foofile")
395+
396+ self.assertEqual(
397+ compute_legacy_file_url('/maas/api/1.0/', 'foofile'),
398+ parse_file_url(url))
399+
400+ @defer.inlineCallbacks
401+ def test_get_url_gives_legacy_url_if_server_omits_anon_resource_uri(self):
402+ # If the server does not return a URL for a file, get_url composes
403+ # its own "legacy" URL, for compatibility with existing uploads.
404+ # The legacy URL consists of the base URL plus the op params for
405+ # retrieving the named file.
406+ self.setup_connection(MAASFileStorage, FakeFileStorage)
407+ storage = MAASFileStorage(CONFIG)
408+
409+ url = yield storage.get_url("foofile")
410+
411+ self.assertEqual(
412+ compute_legacy_file_url('/maas/api/1.0/', 'foofile'),
413+ parse_file_url(url))
414
415 def test_get_succeeds(self):
416 self.setup_connection(MAASFileStorage, FakeFileStorage)
417@@ -125,16 +227,24 @@
418 d.addErrback(self.fail)
419 return d
420
421- def test_get_with_bad_filename(self):
422- self.setup_connection(MAASFileStorage, FakeFileStorageReturning404)
423+ def test_get_fails_if_file_does_not_exist(self):
424+ self.setup_connection(MAASFileStorage, set_up_for_404())
425 storage = MAASFileStorage(CONFIG)
426+ # Patch out get_url. It will swallow the 404 that our fake file
427+ # storage returns. We want the 404 to hit the later request for
428+ # the actual file.
429+ self.patch(storage, 'get_url', lambda name: "/file/info/")
430+
431 d = storage.get("foo")
432
433 return self.assertFailure(d, FileNotFound)
434
435 def test_get_with_unexpected_response(self):
436- self.setup_connection(
437- MAASFileStorage, FakeFileStorageReturningUnexpectedError)
438+ unexpected_error = partial(
439+ FakeFileStorage,
440+ fake_deferred=defer.fail(ZeroDivisionError("numpty")))
441+
442+ self.setup_connection(MAASFileStorage, unexpected_error)
443 storage = MAASFileStorage(CONFIG)
444 d = storage.get("foo")
445
446@@ -151,8 +261,12 @@
447 return d
448
449 def test_put_with_error_returned(self):
450- self.setup_connection(
451- MAASFileStorage, FakeFileStorageWithErrorOnAddingFile)
452+ status = str(httplib.UNAUTHORIZED)
453+ response = defer.fail(error.Error(status, "this is a 401", ""))
454+ post_error = partial(
455+ FakeFileStorage, fake_status=status, fake_deferred=response)
456+ self.setup_connection(MAASFileStorage, post_error)
457+
458 storage = MAASFileStorage(CONFIG)
459 fileObj = BytesIO("some data")
460 d = storage.put("foo", fileObj)

Subscribers

People subscribed via source and target branches

to status/vote changes: