Merge lp:~allenap/pyjuju/maas-anon-urls-2 into lp:pyjuju

Proposed by Gavin Panella
Status: Rejected
Rejected by: Gavin Panella
Proposed branch: lp:~allenap/pyjuju/maas-anon-urls-2
Merge into: lp:pyjuju
Prerequisite: lp:~jtv/pyjuju/maas-anon-urls
Diff against target: 325 lines (+119/-133)
2 files modified
juju/providers/maas/files.py (+25/-30)
juju/providers/maas/tests/test_files.py (+94/-103)
To merge this branch: bzr merge lp:~allenap/pyjuju/maas-anon-urls-2
Reviewer Review Type Date Requested Status
Kapil Thangavelu (community) Needs Fixing
Review via email: mp+150450@code.launchpad.net

Description of the change

When requesting a file from MAAS using the new-style URLs, it's necessary to differentiate between a 404 response that means "this file does not exist" and "no handler found for this path". In the latter case it means that we ought to fall back to the legacy URL (?op=get&filename=...); in the former it means we ought to immediately report the 404.

This was a result of a long discussion between Raphaël and myself. We haven't documented this yet, so please ask me questions (it is late here so I'm too lazy to document the whole story). However, I suspect this is not the end of the story...

To post a comment you must log in.
Revision history for this message
Kapil Thangavelu (hazmat) wrote :

get_url is a required method of the storage interface, its used when deploying charms to store a reference in the charm state for download by agents. it was happy coincidence that the change to deferred return get_url worked in jeroen's branches. The removal gives me some concern, Has this code branch been tested in practice?

The use of a response header to distinguish framework vs handler response is fine.. but speling bug numbers as part of the protocol values feels quite strange.

should jeroen's branches not be in review given this branch?

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

> get_url is a required method of the storage interface, its used when
> deploying charms to store a reference in the charm state for
> download by agents.

Ah yes, so it is.

> The use of a response header to distinguish framework vs handler
> response is fine.. but speling bug numbers as part of the protocol
> values feels quite strange.

We've been talking a lot about how to do this in another way. We're
stuck with a tricky problem that is proving hard to resolve. You can
read more at http://goo.gl/9r3nD (Google Docs, Canonical only) if
you're interested. It is a work in progress.

I'll mark this branch and Jeroen's as work in progress too. We may
resurrect them later, but for now I'll move them out of the limelight.

Thanks for your comments Kapil!

Unmerged revisions

652. By Gavin Panella

Better docs, clean-ups.

651. By Gavin Panella

Remove MAASFileStorage.get_url; it is unused.

650. By Gavin Panella

Reimplement MAASFileStorage.get using fetch.

649. By Gavin Panella

New method MAASFileStorage.fetch, which will soon replace get and get_url.

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.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'juju/providers/maas/files.py'
2--- juju/providers/maas/files.py 2013-02-25 22:23:23 +0000
3+++ juju/providers/maas/files.py 2013-02-25 22:23:23 +0000
4@@ -3,6 +3,7 @@
5
6 """File Storage API client for MAAS."""
7
8+from base64 import b64decode
9 from cStringIO import StringIO
10 import httplib
11 import json
12@@ -131,38 +132,34 @@
13 returnValue(body)
14
15 @inlineCallbacks
16- def get_url(self, name):
17- """Return a URL that can be used to access a stored file.
18+ def fetch(self, name):
19+ """Get a file's URL and contents from the MAAS server.
20
21- :param unicode name: the file path for which to provide a URL
22- :return: a URL
23- :rtype: str
24+ :param unicode name: path to the desired file
25+ :return: a byte string of the file's contents
26+ :rtype: :class:`twisted.internet.defer.Deferred`
27+ :raises: :exc:`juju.errors.FileNotFound` if the file doesn't exist
28 """
29- # Request metadata for the file, including its anonymous URL.
30- # This is a later addition to the MAAS API, so it may not be
31- # available. If it isn't, we fall back on the "legacy" way of
32- # composing URLs right here in the provider.
33- # Adding a trailing slash here saves a step as the API would
34- # otherwise redirect to this same URL anyway.
35- meta_url = self._base_url + name + "/"
36+ url = self._base_url + name + "/"
37 try:
38- body = yield self._download(meta_url, "Get file information")
39+ body = yield self._download(url, "Get file & information")
40 except FileNotFound:
41- # This MAAS version doesn't provide anonymous file URLs.
42- # Fall back to the legacy URL.
43- returnValue(compose_legacy_file_url(self._base_url, name))
44-
45- attributes = json.loads(body)
46- anon_url = attributes.get('anon_resource_uri')
47- if anon_url is None:
48- # MAAS doesn't provide an anonymous download URL. Generate it
49- # the old way. If the file was uploaded by a legacy provider,
50- # MAAS will honour this legacy URL.
51- returnValue(compose_legacy_file_url(self._base_url, name))
52+ workarounds = self.client.response_headers.get("Workaround", "")
53+ if "bug1123986" in workarounds.split():
54+ # This is a recent MAAS server. This 404 was generated by a
55+ # handler in MAAS that did not find the requested file, not by
56+ # Django itself, unable to route the request.
57+ raise
58+ else:
59+ # This is an older MAAS server. This 404 was generated because
60+ # MAAS did not know how to route the request.
61+ url = compose_legacy_file_url(self._base_url, name)
62+ body = yield self._download(url, "Get file content")
63+ returnValue((url, body))
64 else:
65- # Success. Return the anonymous file download URL that MAAS
66- # sent us.
67- returnValue(urljoin(self._base_url, anon_url))
68+ attributes = json.loads(body)
69+ content = attributes["content"]
70+ returnValue((url, b64decode(content)))
71
72 @inlineCallbacks
73 def get(self, name):
74@@ -173,10 +170,8 @@
75 :rtype: :class:`twisted.internet.defer.Deferred`
76 :raises: :exc:`juju.errors.FileNotFound` if the file doesn't exist
77 """
78- url = yield self.get_url(name)
79-
80 try:
81- body = yield self._download(url, "Download %s" % name)
82+ url, body = yield self.fetch(name)
83 except FileNotFound:
84 # Don't raise a FileNotFound with the full URL in it. Anonymous
85 # URLs can be accessed by anyone, and privacy of the files relies
86
87=== modified file 'juju/providers/maas/tests/test_files.py'
88--- juju/providers/maas/tests/test_files.py 2013-02-25 22:23:23 +0000
89+++ juju/providers/maas/tests/test_files.py 2013-02-25 22:23:23 +0000
90@@ -3,6 +3,7 @@
91
92 """Tests for juju.providers.maas.files"""
93
94+from base64 import b64encode
95 from functools import partial
96 import httplib
97 from io import BytesIO
98@@ -14,8 +15,7 @@
99 from urllib import urlencode
100 from urlparse import parse_qs, urlparse
101
102-from juju.errors import (
103- FileNotFound, ProviderError, ProviderInteractionError)
104+from juju.errors import FileNotFound, ProviderError
105 from juju.providers.maas.files import encode_multipart_data, MAASFileStorage
106 from juju.providers.maas.tests.testing import CONFIG, TestCase
107
108@@ -24,7 +24,8 @@
109 """A fake http client to MAAS so MAASFileStorage tests can operate."""
110
111 def __init__(self, url, method='GET', postdata=None, headers=None,
112- fake_status=httplib.OK, fake_deferred=None):
113+ fake_status=httplib.OK, fake_deferred=None,
114+ fake_response_headers=None):
115 """This constructor is written with partial application in mind.
116
117 The fake_status and fake_deferred parameters determine the outcome
118@@ -47,6 +48,10 @@
119 else:
120 self.deferred = fake_deferred
121
122+ # Don't set a default; let tests blow up with AttributeError instead.
123+ if fake_response_headers is not None:
124+ self.response_headers = fake_response_headers
125+
126
127 def set_up_for_404():
128 """Return a FakeFileStorage factory that simulates a "Not Found" response.
129@@ -145,110 +150,96 @@
130 class TestMAASFileStorage(TestCase):
131
132 @defer.inlineCallbacks
133- def test_get_url_uses_anon_resource_uri_from_server(self):
134- # MAAS normally provides a URL for the file, as one of the file
135- # object's attributes, and get_url returns it.
136- file_path, file_params = compute_anon_file_url('/maas/api/1.0/', "987")
137-
138- # Simulated FileStorage that contains an anon_resource_uri entry.
139- response = {
140- 'anon_resource_uri': compose_file_url(file_path, file_params),
141- }
142+ def test_fetch_returns_url_and_content_from_new_maas(self):
143+ # Recent MAAS versions have a different way of accessing the file
144+ # storage, and return a JSON document containing file meta data and
145+ # its content.
146+ response = {'content': b64encode("fred")}
147 fake_storage = partial(
148 FakeFileStorage, fake_deferred=defer.succeed(json.dumps(response)))
149 self.setup_connection(MAASFileStorage, fake_storage)
150-
151- url = yield MAASFileStorage(CONFIG).get_url("foofile")
152-
153- self.assertEqual((file_path, file_params), parse_file_url(url))
154-
155- @defer.inlineCallbacks
156- def test_get_url_reports_failure_while_asking_maas(self):
157- status = str(httplib.NOT_IMPLEMENTED)
158- response = defer.fail(error.Error(status, "deliberate failure", ""))
159- get_error = partial(
160- FakeFileStorage, fake_status=status, fake_deferred=response)
161- self.setup_connection(MAASFileStorage, get_error)
162- storage = MAASFileStorage(CONFIG)
163-
164- try:
165- yield storage.get_url("kaboom")
166- except ProviderError:
167- # This is what we expect: an error response from the server means
168- # that the Deferred returned by get_url fails.
169- pass
170- except:
171- # Any other error? That's unexpected. Pass it on.
172- raise
173- else:
174- self.fail("Error was not propagated.")
175-
176- @defer.inlineCallbacks
177- def test_get_url_falls_back_on_legacy_url_with_older_maas_api(self):
178- self.setup_connection(MAASFileStorage, set_up_for_404())
179- storage = MAASFileStorage(CONFIG)
180-
181- url = yield storage.get_url("foofile")
182-
183- self.assertEqual(
184- compute_legacy_file_url('/maas/api/1.0/', 'foofile'),
185- parse_file_url(url))
186-
187- @defer.inlineCallbacks
188- def test_get_url_gives_legacy_url_if_server_omits_anon_resource_uri(self):
189- # If the server does not return a URL for a file, get_url composes
190- # its own "legacy" URL, for compatibility with existing uploads.
191- # The legacy URL consists of the base URL plus the op params for
192- # retrieving the named file.
193- self.setup_connection(MAASFileStorage, FakeFileStorage)
194- storage = MAASFileStorage(CONFIG)
195-
196- url = yield storage.get_url("foofile")
197-
198- self.assertEqual(
199- compute_legacy_file_url('/maas/api/1.0/', 'foofile'),
200- parse_file_url(url))
201-
202- def test_get_succeeds(self):
203- self.setup_connection(MAASFileStorage, FakeFileStorage)
204- storage = MAASFileStorage(CONFIG)
205- d = storage.get("foo")
206-
207- def check(value):
208- # The underlying code returns a StringIO but because
209- # implementations of StringIO and cStringIO are completely
210- # different the only reasonable thing to do here is to
211- # check to see if the returned object has a "read" method.
212- attr = getattr(value, "read")
213- self.assertIsNot(None, attr)
214- self.assertTrue(value.read)
215-
216- d.addCallback(check)
217- d.addErrback(self.fail)
218- return d
219-
220- def test_get_fails_if_file_does_not_exist(self):
221- self.setup_connection(MAASFileStorage, set_up_for_404())
222- storage = MAASFileStorage(CONFIG)
223- # Patch out get_url. It will swallow the 404 that our fake file
224- # storage returns. We want the 404 to hit the later request for
225- # the actual file.
226- self.patch(storage, 'get_url', lambda name: "/file/info/")
227-
228- d = storage.get("foo")
229-
230- return self.assertFailure(d, FileNotFound)
231-
232- def test_get_with_unexpected_response(self):
233- unexpected_error = partial(
234+ url, content = yield MAASFileStorage(CONFIG).fetch("foofile")
235+ self.assertEqual(
236+ ("/maas/api/1.0/files/foofile/", {}),
237+ parse_file_url(url))
238+ self.assertEqual("fred", content)
239+
240+ def test_fetch_from_new_maas_not_found(self):
241+ # Recent MAAS versions include a workaround header so that clients can
242+ # discern the difference between a routing failure and a file that has
243+ # looked for but not found.
244+ fake_storage = partial(
245 FakeFileStorage,
246- fake_deferred=defer.fail(ZeroDivisionError("numpty")))
247-
248- self.setup_connection(MAASFileStorage, unexpected_error)
249- storage = MAASFileStorage(CONFIG)
250- d = storage.get("foo")
251-
252- return self.assertFailure(d, ProviderInteractionError)
253+ fake_status=httplib.NOT_FOUND,
254+ fake_deferred=defer.fail(FileNotFound("foo")),
255+ fake_response_headers={"Workaround": "bug1123986"})
256+ self.setup_connection(MAASFileStorage, fake_storage)
257+ return self.assertFailure(
258+ MAASFileStorage(CONFIG).fetch("foofile"),
259+ FileNotFound)
260+
261+ @defer.inlineCallbacks
262+ def test_fetch_returns_url_and_content_from_old_maas(self):
263+ # An older MAAS instance returns a 404 to the first request because
264+ # there is no handler for the path. Juju falls back to the query
265+ # parameter based URL and tries again.
266+ fake_storage = [
267+ partial(
268+ FakeFileStorage,
269+ fake_status=httplib.NOT_FOUND,
270+ fake_deferred=defer.fail(FileNotFound("foo")),
271+ fake_response_headers={}),
272+ partial(
273+ FakeFileStorage,
274+ fake_deferred=defer.succeed("fred")),
275+ ]
276+ fake_storage_factory = lambda *args, **kwargs: (
277+ fake_storage.pop(0)(*args, **kwargs))
278+ self.setup_connection(MAASFileStorage, fake_storage_factory)
279+ url, content = yield MAASFileStorage(CONFIG).fetch("foofile")
280+ self.assertEqual(
281+ ("/maas/api/1.0/files/",
282+ {"filename": ["foofile"], "op": ["get"]}),
283+ parse_file_url(url))
284+ self.assertEqual("fred", content)
285+
286+ def test_fetch_from_old_maas_not_found(self):
287+ # An older MAAS instance returns a 404 to the first request because
288+ # there is no handler for the path. Juju falls back to the query
289+ # parameter based URL and tries again.
290+ fake_storage = [
291+ partial(
292+ FakeFileStorage,
293+ fake_status=httplib.NOT_FOUND,
294+ fake_deferred=defer.fail(FileNotFound("foo")),
295+ fake_response_headers={}),
296+ partial(
297+ FakeFileStorage,
298+ fake_status=httplib.NOT_FOUND,
299+ fake_deferred=defer.fail(FileNotFound("foo"))),
300+ ]
301+ fake_storage_factory = lambda *args, **kwargs: (
302+ fake_storage.pop(0)(*args, **kwargs))
303+ self.setup_connection(MAASFileStorage, fake_storage_factory)
304+ self.assertFailure(
305+ MAASFileStorage(CONFIG).fetch("foofile"),
306+ FileNotFound)
307+
308+ @defer.inlineCallbacks
309+ def test_get_is_simple_wrapper_around_fetch(self):
310+ storage = MAASFileStorage(CONFIG)
311+ storage.fetch = lambda name: defer.succeed(
312+ ("/url/of/" + name, "Content of " + name))
313+ reader = yield storage.get("some-name")
314+ self.assertEqual("Content of some-name", reader.read())
315+
316+ @defer.inlineCallbacks
317+ def test_get_returns_only_file_name_when_file_not_found(self):
318+ storage = MAASFileStorage(CONFIG)
319+ storage.fetch = lambda name: defer.fail(FileNotFound("/path/file"))
320+ error = yield self.assertFailure(
321+ storage.get("some-name"), FileNotFound)
322+ self.assertEqual("some-name", error.path)
323
324 def test_put_succeeds(self):
325 self.setup_connection(MAASFileStorage, FakeFileStorage)

Subscribers

People subscribed via source and target branches

to status/vote changes: