Merge lp:~blake-rouse/maas/maascli-multipart-upload into lp:~maas-committers/maas/trunk
- maascli-multipart-upload
- Merge into trunk
Status: | Merged | ||||
---|---|---|---|---|---|
Approved by: | Blake Rouse | ||||
Approved revision: | no longer in the source branch. | ||||
Merged at revision: | 2971 | ||||
Proposed branch: | lp:~blake-rouse/maas/maascli-multipart-upload | ||||
Merge into: | lp:~maas-committers/maas/trunk | ||||
Diff against target: |
676 lines (+498/-9) 11 files modified
src/maascli/actions/boot_resources_create.py (+186/-0) src/maascli/actions/tests/test_boot_resources_create.py (+207/-0) src/maascli/api.py (+22/-1) src/maascli/tests/test_api.py (+29/-0) src/maascli/utils.py (+15/-0) src/maasserver/api/boot_resources.py (+3/-1) src/maasserver/api/boot_source_selections.py (+2/-2) src/maasserver/api/boot_sources.py (+2/-2) src/maasserver/api/doc.py (+5/-3) src/maasserver/api/tests/test_doc.py (+12/-0) src/maasserver/models/bootresourcefile.py (+15/-0) |
||||
To merge this branch: | bzr merge lp:~blake-rouse/maas/maascli-multipart-upload | ||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Jason Hobbs (community) | Approve | ||
Review via email: mp+234142@code.launchpad.net |
Commit message
Modified the maascli to allow for custom Action classes based on the handler and action. Added the BootResourceCre
Also contains a drive by fix for LargeFile not being deleted correctly when the reference object is the last.
Description of the change
Blake Rouse (blake-rouse) : | # |
MAAS Lander (maas-lander) wrote : | # |
The attempt to merge lp:~blake-rouse/maas/maascli-multipart-upload into lp:maas failed. Below is the output from the failed tests.
Ign http://
Get:1 http://
Ign http://
Get:2 http://
Ign http://
Hit http://
Get:3 http://
Hit http://
Get:4 http://
Get:5 http://
Hit http://
Get:6 http://
Hit http://
Get:7 http://
Hit http://
Hit http://
Hit http://
Hit http://
Get:8 http://
Hit http://
Hit http://
Ign http://
Ign http://
Get:9 http://
Get:10 http://
Get:11 http://
Get:12 http://
Hit http://
Hit http://
Fetched 1,080 kB in 0s (1,770 kB/s)
Reading package lists...
sudo DEBIAN_
--
Preview Diff
1 | === added directory 'src/maascli/actions' | |||
2 | === added file 'src/maascli/actions/__init__.py' | |||
3 | === added file 'src/maascli/actions/boot_resources_create.py' | |||
4 | --- src/maascli/actions/boot_resources_create.py 1970-01-01 00:00:00 +0000 | |||
5 | +++ src/maascli/actions/boot_resources_create.py 2014-09-12 03:32:27 +0000 | |||
6 | @@ -0,0 +1,186 @@ | |||
7 | 1 | # Copyright 2014 Canonical Ltd. This software is licensed under the | ||
8 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | ||
9 | 3 | |||
10 | 4 | """MAAS Boot Resources Action.""" | ||
11 | 5 | |||
12 | 6 | from __future__ import ( | ||
13 | 7 | absolute_import, | ||
14 | 8 | print_function, | ||
15 | 9 | unicode_literals, | ||
16 | 10 | ) | ||
17 | 11 | |||
18 | 12 | str = None | ||
19 | 13 | |||
20 | 14 | __metaclass__ = type | ||
21 | 15 | __all__ = [ | ||
22 | 16 | 'action_class' | ||
23 | 17 | ] | ||
24 | 18 | |||
25 | 19 | from cStringIO import StringIO | ||
26 | 20 | import hashlib | ||
27 | 21 | import json | ||
28 | 22 | from urlparse import urljoin | ||
29 | 23 | |||
30 | 24 | from apiclient.multipart import ( | ||
31 | 25 | build_multipart_message, | ||
32 | 26 | encode_multipart_message, | ||
33 | 27 | ) | ||
34 | 28 | from maascli.api import ( | ||
35 | 29 | Action, | ||
36 | 30 | http_request, | ||
37 | 31 | ) | ||
38 | 32 | from maascli.command import CommandError | ||
39 | 33 | |||
40 | 34 | # Send 4MB of data per request. | ||
41 | 35 | CHUNK_SIZE = 1 << 22 | ||
42 | 36 | |||
43 | 37 | |||
44 | 38 | class BootResourcesCreateAction(Action): | ||
45 | 39 | """Provides custom logic to the boot-resources create action. | ||
46 | 40 | |||
47 | 41 | Command: maas username boot-resources create | ||
48 | 42 | |||
49 | 43 | The create command has the ability to upload the content in pieces, using | ||
50 | 44 | the upload_uri that is returned in the response. This class provides the | ||
51 | 45 | logic to upload over that API. | ||
52 | 46 | """ | ||
53 | 47 | |||
54 | 48 | def __call__(self, options): | ||
55 | 49 | # TODO: this is el-cheapo URI Template | ||
56 | 50 | # <http://tools.ietf.org/html/rfc6570> support; use uritemplate-py | ||
57 | 51 | # <https://github.com/uri-templates/uritemplate-py> here? | ||
58 | 52 | uri = self.uri.format(**vars(options)) | ||
59 | 53 | content = self.initial_request(uri, options) | ||
60 | 54 | |||
61 | 55 | # Get the created resource file for the boot resource | ||
62 | 56 | rfile = self.get_resource_file(content) | ||
63 | 57 | if rfile is None: | ||
64 | 58 | print("Failed to identify created resource.") | ||
65 | 59 | raise CommandError(2) | ||
66 | 60 | if rfile['complete']: | ||
67 | 61 | # File already existed in the database, so no | ||
68 | 62 | # reason to upload it. | ||
69 | 63 | return | ||
70 | 64 | |||
71 | 65 | # Upload content | ||
72 | 66 | data = dict(options.data) | ||
73 | 67 | upload_uri = urljoin(uri, rfile['upload_uri']) | ||
74 | 68 | self.upload_content( | ||
75 | 69 | upload_uri, data['content'], insecure=options.insecure) | ||
76 | 70 | |||
77 | 71 | def initial_request(self, uri, options): | ||
78 | 72 | """Performs the initial POST request, to create the boot resource.""" | ||
79 | 73 | # Bundle things up ready to throw over the wire. | ||
80 | 74 | body, headers = self.prepare_initial_payload(options.data) | ||
81 | 75 | |||
82 | 76 | # Headers are returned as a list, but they must be a dict for | ||
83 | 77 | # the signing machinery. | ||
84 | 78 | headers = dict(headers) | ||
85 | 79 | |||
86 | 80 | # Sign request if credentials have been provided. | ||
87 | 81 | if self.credentials is not None: | ||
88 | 82 | self.sign(uri, headers, self.credentials) | ||
89 | 83 | |||
90 | 84 | # Use httplib2 instead of urllib2 (or MAASDispatcher, which is based | ||
91 | 85 | # on urllib2) so that we get full control over HTTP method. TODO: | ||
92 | 86 | # create custom MAASDispatcher to use httplib2 so that MAASClient can | ||
93 | 87 | # be used. | ||
94 | 88 | response, content = http_request( | ||
95 | 89 | uri, self.method, body=body, headers=headers, | ||
96 | 90 | insecure=options.insecure) | ||
97 | 91 | |||
98 | 92 | # 2xx status codes are all okay. | ||
99 | 93 | if response.status // 100 != 2: | ||
100 | 94 | if options.debug: | ||
101 | 95 | self.print_debug(response) | ||
102 | 96 | self.print_response(response, content) | ||
103 | 97 | raise CommandError(2) | ||
104 | 98 | return content | ||
105 | 99 | |||
106 | 100 | def prepare_initial_payload(self, data): | ||
107 | 101 | """Return the body and headers for the initial request. | ||
108 | 102 | |||
109 | 103 | This is method is only used for the first request to MAAS. It | ||
110 | 104 | removes the passed content, and replaces it with the sha256 and size | ||
111 | 105 | of that content. | ||
112 | 106 | |||
113 | 107 | :param data: An iterable of ``name, value`` or ``name, opener`` | ||
114 | 108 | tuples (see `name_value_pair`) to pack into the body or | ||
115 | 109 | query, depending on the type of request. | ||
116 | 110 | """ | ||
117 | 111 | data = dict(data) | ||
118 | 112 | if 'content' not in data: | ||
119 | 113 | print('Missing content.') | ||
120 | 114 | raise CommandError(2) | ||
121 | 115 | |||
122 | 116 | content = data.pop('content') | ||
123 | 117 | size, sha256 = self.calculate_size_and_sha256(content) | ||
124 | 118 | data['size'] = '%s' % size | ||
125 | 119 | data['sha256'] = sha256 | ||
126 | 120 | |||
127 | 121 | data = [(key, value) for key, value in data.items()] | ||
128 | 122 | message = build_multipart_message(data) | ||
129 | 123 | headers, body = encode_multipart_message(message) | ||
130 | 124 | return body, headers | ||
131 | 125 | |||
132 | 126 | def calculate_size_and_sha256(self, content): | ||
133 | 127 | """Return the size and sha256 of the content.""" | ||
134 | 128 | size = 0 | ||
135 | 129 | sha256 = hashlib.sha256() | ||
136 | 130 | with content() as fd: | ||
137 | 131 | while True: | ||
138 | 132 | buf = fd.read(CHUNK_SIZE) | ||
139 | 133 | length = len(buf) | ||
140 | 134 | size += length | ||
141 | 135 | sha256.update(buf) | ||
142 | 136 | if length != CHUNK_SIZE: | ||
143 | 137 | break | ||
144 | 138 | return size, sha256.hexdigest() | ||
145 | 139 | |||
146 | 140 | def get_resource_file(self, content): | ||
147 | 141 | """Return the boot resource file for the created file.""" | ||
148 | 142 | data = json.loads(content) | ||
149 | 143 | if len(data['sets']) == 0: | ||
150 | 144 | # No sets returned, no way to get the resource file. | ||
151 | 145 | return None | ||
152 | 146 | newest_set = sorted(data['sets'].keys(), reverse=True)[0] | ||
153 | 147 | resource_set = data['sets'][newest_set] | ||
154 | 148 | if len(resource_set['files']) != 1: | ||
155 | 149 | # This api only supports uploading one file. If the set doesn't | ||
156 | 150 | # have just one file, then there is no way of knowing which file. | ||
157 | 151 | return None | ||
158 | 152 | _, rfile = resource_set['files'].popitem() | ||
159 | 153 | return rfile | ||
160 | 154 | |||
161 | 155 | def put_upload(self, upload_uri, data, insecure=False): | ||
162 | 156 | """Send PUT method to upload data.""" | ||
163 | 157 | headers = { | ||
164 | 158 | 'Content-Type': 'application/octet-stream', | ||
165 | 159 | 'Content-Length': '%s' % len(data), | ||
166 | 160 | } | ||
167 | 161 | if self.credentials is not None: | ||
168 | 162 | self.sign(upload_uri, headers, self.credentials) | ||
169 | 163 | # httplib2 expects the body to be file-like if its binary data | ||
170 | 164 | data = StringIO(data) | ||
171 | 165 | response, content = http_request( | ||
172 | 166 | upload_uri, 'PUT', body=data, headers=headers, | ||
173 | 167 | insecure=insecure) | ||
174 | 168 | if response.status != 200: | ||
175 | 169 | self.print_response(response, content) | ||
176 | 170 | raise CommandError(2) | ||
177 | 171 | |||
178 | 172 | def upload_content(self, upload_uri, content, insecure=False): | ||
179 | 173 | """Upload the content in chunks.""" | ||
180 | 174 | with content() as fd: | ||
181 | 175 | while True: | ||
182 | 176 | buf = fd.read(CHUNK_SIZE) | ||
183 | 177 | length = len(buf) | ||
184 | 178 | if length > 0: | ||
185 | 179 | self.put_upload(upload_uri, buf, insecure=insecure) | ||
186 | 180 | if length != CHUNK_SIZE: | ||
187 | 181 | break | ||
188 | 182 | |||
189 | 183 | |||
190 | 184 | # Each action sets this variable so the class can be picked up | ||
191 | 185 | # by get_action_class. | ||
192 | 186 | action_class = BootResourcesCreateAction | ||
193 | 0 | 187 | ||
194 | === added directory 'src/maascli/actions/tests' | |||
195 | === added file 'src/maascli/actions/tests/__init__.py' | |||
196 | === added file 'src/maascli/actions/tests/test_boot_resources_create.py' | |||
197 | --- src/maascli/actions/tests/test_boot_resources_create.py 1970-01-01 00:00:00 +0000 | |||
198 | +++ src/maascli/actions/tests/test_boot_resources_create.py 2014-09-12 03:32:27 +0000 | |||
199 | @@ -0,0 +1,207 @@ | |||
200 | 1 | # Copyright 2012-2014 Canonical Ltd. This software is licensed under the | ||
201 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | ||
202 | 3 | |||
203 | 4 | """Test for boot-resources create action.""" | ||
204 | 5 | |||
205 | 6 | from __future__ import ( | ||
206 | 7 | absolute_import, | ||
207 | 8 | print_function, | ||
208 | 9 | unicode_literals, | ||
209 | 10 | ) | ||
210 | 11 | |||
211 | 12 | str = None | ||
212 | 13 | |||
213 | 14 | __metaclass__ = type | ||
214 | 15 | __all__ = [] | ||
215 | 16 | |||
216 | 17 | from functools import partial | ||
217 | 18 | import hashlib | ||
218 | 19 | import json | ||
219 | 20 | import os | ||
220 | 21 | from random import randint | ||
221 | 22 | |||
222 | 23 | from apiclient.testing.credentials import make_api_credentials | ||
223 | 24 | import httplib2 | ||
224 | 25 | from maascli.actions import boot_resources_create | ||
225 | 26 | from maascli.actions.boot_resources_create import ( | ||
226 | 27 | BootResourcesCreateAction, | ||
227 | 28 | CHUNK_SIZE, | ||
228 | 29 | ) | ||
229 | 30 | from maascli.command import CommandError | ||
230 | 31 | from maastesting.factory import factory | ||
231 | 32 | from maastesting.fixtures import TempDirectory | ||
232 | 33 | from maastesting.matchers import MockCalledOnceWith | ||
233 | 34 | from maastesting.testcase import MAASTestCase | ||
234 | 35 | from mock import ( | ||
235 | 36 | ANY, | ||
236 | 37 | Mock, | ||
237 | 38 | sentinel, | ||
238 | 39 | ) | ||
239 | 40 | |||
240 | 41 | |||
241 | 42 | class TestBootResourcesCreateAction(MAASTestCase): | ||
242 | 43 | """Tests for `BootResourcesCreateAction`.""" | ||
243 | 44 | |||
244 | 45 | def configure_http_request(self, status, content): | ||
245 | 46 | response = httplib2.Response({'status': status}) | ||
246 | 47 | self.patch( | ||
247 | 48 | boot_resources_create, | ||
248 | 49 | 'http_request').return_value = (response, content) | ||
249 | 50 | |||
250 | 51 | def make_boot_resources_create_action(self): | ||
251 | 52 | action_name = "create".encode("ascii") | ||
252 | 53 | action_bases = (BootResourcesCreateAction,) | ||
253 | 54 | action_ns = { | ||
254 | 55 | "action": {'method': 'POST'}, | ||
255 | 56 | "handler": {'uri': '/api/1.0/boot-resources/', 'params': []}, | ||
256 | 57 | "profile": {'credentials': make_api_credentials()} | ||
257 | 58 | } | ||
258 | 59 | action_class = type(action_name, action_bases, action_ns) | ||
259 | 60 | action = action_class(Mock()) | ||
260 | 61 | self.patch(action, 'print_debug') | ||
261 | 62 | self.patch(action, 'print_response') | ||
262 | 63 | return action | ||
263 | 64 | |||
264 | 65 | def make_content(self, size=None): | ||
265 | 66 | tmpdir = self.useFixture(TempDirectory()).path | ||
266 | 67 | if size is None: | ||
267 | 68 | size = randint(1024, 2048) | ||
268 | 69 | data = factory.make_bytes(size) | ||
269 | 70 | content_path = os.path.join(tmpdir, 'content') | ||
270 | 71 | with open(content_path, 'wb') as stream: | ||
271 | 72 | stream.write(data) | ||
272 | 73 | sha256 = hashlib.sha256() | ||
273 | 74 | sha256.update(data) | ||
274 | 75 | return size, sha256.hexdigest(), partial(open, content_path, "rb") | ||
275 | 76 | |||
276 | 77 | def test_initial_request_returns_content(self): | ||
277 | 78 | content = factory.make_name('content') | ||
278 | 79 | self.configure_http_request(200, content) | ||
279 | 80 | action = self.make_boot_resources_create_action() | ||
280 | 81 | self.patch(action, 'prepare_initial_payload').return_value = ("", {}) | ||
281 | 82 | self.assertEqual( | ||
282 | 83 | content, action.initial_request(sentinel.uri, Mock())) | ||
283 | 84 | |||
284 | 85 | def test_initial_request_raises_CommandError_on_error(self): | ||
285 | 86 | self.configure_http_request(500, factory.make_name('content')) | ||
286 | 87 | action = self.make_boot_resources_create_action() | ||
287 | 88 | self.patch(action, 'prepare_initial_payload').return_value = ("", {}) | ||
288 | 89 | self.assertRaises( | ||
289 | 90 | CommandError, | ||
290 | 91 | action.initial_request, sentinel.uri, Mock()) | ||
291 | 92 | |||
292 | 93 | def test_prepare_initial_payload_raises_CommandError_missing_content(self): | ||
293 | 94 | action = self.make_boot_resources_create_action() | ||
294 | 95 | self.patch(boot_resources_create, 'print') | ||
295 | 96 | self.assertRaises( | ||
296 | 97 | CommandError, | ||
297 | 98 | action.prepare_initial_payload, [('invalid', '')]) | ||
298 | 99 | |||
299 | 100 | def test_prepare_initial_payload_adds_size_and_sha256(self): | ||
300 | 101 | size, sha256, stream = self.make_content() | ||
301 | 102 | action = self.make_boot_resources_create_action() | ||
302 | 103 | mock_build_message = self.patch( | ||
303 | 104 | boot_resources_create, | ||
304 | 105 | 'build_multipart_message') | ||
305 | 106 | self.patch( | ||
306 | 107 | boot_resources_create, | ||
307 | 108 | 'encode_multipart_message').return_value = (None, None) | ||
308 | 109 | action.prepare_initial_payload([('content', stream)]) | ||
309 | 110 | self.assertThat( | ||
310 | 111 | mock_build_message, | ||
311 | 112 | MockCalledOnceWith([('sha256', sha256), ('size', '%s' % size)])) | ||
312 | 113 | |||
313 | 114 | def test_get_resource_file_returns_None_when_no_sets(self): | ||
314 | 115 | content = { | ||
315 | 116 | 'sets': {} | ||
316 | 117 | } | ||
317 | 118 | action = self.make_boot_resources_create_action() | ||
318 | 119 | self.assertIsNone(action.get_resource_file(json.dumps(content))) | ||
319 | 120 | |||
320 | 121 | def test_get_resource_file_returns_None_when_no_files(self): | ||
321 | 122 | content = { | ||
322 | 123 | 'sets': { | ||
323 | 124 | '20140910': { | ||
324 | 125 | 'files': {} | ||
325 | 126 | }, | ||
326 | 127 | }, | ||
327 | 128 | } | ||
328 | 129 | action = self.make_boot_resources_create_action() | ||
329 | 130 | self.assertIsNone(action.get_resource_file(json.dumps(content))) | ||
330 | 131 | |||
331 | 132 | def test_get_resource_file_returns_None_when_more_than_one_file(self): | ||
332 | 133 | content = { | ||
333 | 134 | 'sets': { | ||
334 | 135 | '20140910': { | ||
335 | 136 | 'files': { | ||
336 | 137 | 'root-image.gz': {}, | ||
337 | 138 | 'root-tgz': {}, | ||
338 | 139 | } | ||
339 | 140 | }, | ||
340 | 141 | }, | ||
341 | 142 | } | ||
342 | 143 | action = self.make_boot_resources_create_action() | ||
343 | 144 | self.assertIsNone(action.get_resource_file(json.dumps(content))) | ||
344 | 145 | |||
345 | 146 | def test_get_resource_file_returns_file_from_newest_set(self): | ||
346 | 147 | filename = factory.make_name('file') | ||
347 | 148 | content = { | ||
348 | 149 | 'sets': { | ||
349 | 150 | '20140910': { | ||
350 | 151 | 'files': { | ||
351 | 152 | filename: { | ||
352 | 153 | 'name': filename, | ||
353 | 154 | }, | ||
354 | 155 | }, | ||
355 | 156 | }, | ||
356 | 157 | '20140909': { | ||
357 | 158 | 'files': { | ||
358 | 159 | 'other': { | ||
359 | 160 | 'name': 'other', | ||
360 | 161 | }, | ||
361 | 162 | }, | ||
362 | 163 | }, | ||
363 | 164 | }, | ||
364 | 165 | } | ||
365 | 166 | action = self.make_boot_resources_create_action() | ||
366 | 167 | self.assertEqual( | ||
367 | 168 | {'name': filename}, | ||
368 | 169 | action.get_resource_file(json.dumps(content))) | ||
369 | 170 | |||
370 | 171 | def test_put_upload_raise_CommandError_if_status_not_200(self): | ||
371 | 172 | self.configure_http_request(500, '') | ||
372 | 173 | action = self.make_boot_resources_create_action() | ||
373 | 174 | self.assertRaises( | ||
374 | 175 | CommandError, | ||
375 | 176 | action.put_upload, sentinel.upload_uri, '') | ||
376 | 177 | |||
377 | 178 | def test_put_upload_sends_content_type_and_length_headers(self): | ||
378 | 179 | response = httplib2.Response({'status': 200}) | ||
379 | 180 | mock_request = self.patch(boot_resources_create, 'http_request') | ||
380 | 181 | mock_request.return_value = (response, '') | ||
381 | 182 | action = self.make_boot_resources_create_action() | ||
382 | 183 | self.patch(action, 'sign') | ||
383 | 184 | data = factory.make_bytes() | ||
384 | 185 | action.put_upload(sentinel.upload_uri, data) | ||
385 | 186 | headers = { | ||
386 | 187 | 'Content-Type': 'application/octet-stream', | ||
387 | 188 | 'Content-Length': '%s' % len(data), | ||
388 | 189 | } | ||
389 | 190 | self.assertThat( | ||
390 | 191 | mock_request, | ||
391 | 192 | MockCalledOnceWith( | ||
392 | 193 | sentinel.upload_uri, 'PUT', body=ANY, | ||
393 | 194 | headers=headers, insecure=False)) | ||
394 | 195 | |||
395 | 196 | def test_upload_content_calls_put_upload_with_sizeof_CHUNK_SIZE(self): | ||
396 | 197 | size = CHUNK_SIZE * 2 | ||
397 | 198 | size, sha256, stream = self.make_content(size=size) | ||
398 | 199 | action = self.make_boot_resources_create_action() | ||
399 | 200 | mock_upload = self.patch(action, 'put_upload') | ||
400 | 201 | action.upload_content(sentinel.upload_uri, stream) | ||
401 | 202 | |||
402 | 203 | call_data_sizes = [ | ||
403 | 204 | len(call[0][1]) | ||
404 | 205 | for call in mock_upload.call_args_list | ||
405 | 206 | ] | ||
406 | 207 | self.assertEqual([CHUNK_SIZE, CHUNK_SIZE], call_data_sizes) | ||
407 | 0 | 208 | ||
408 | === modified file 'src/maascli/api.py' | |||
409 | --- src/maascli/api.py 2014-08-13 21:49:35 +0000 | |||
410 | +++ src/maascli/api.py 2014-09-12 03:32:27 +0000 | |||
411 | @@ -53,6 +53,7 @@ | |||
412 | 53 | handler_command_name, | 53 | handler_command_name, |
413 | 54 | parse_docstring, | 54 | parse_docstring, |
414 | 55 | safe_name, | 55 | safe_name, |
415 | 56 | try_import_module, | ||
416 | 56 | ) | 57 | ) |
417 | 57 | 58 | ||
418 | 58 | 59 | ||
419 | @@ -402,12 +403,32 @@ | |||
420 | 402 | sys.exit(0) | 403 | sys.exit(0) |
421 | 403 | 404 | ||
422 | 404 | 405 | ||
423 | 406 | def get_action_class(handler, action): | ||
424 | 407 | """Return custom action handler class.""" | ||
425 | 408 | handler_name = handler_command_name(handler["name"]).replace('-', '_') | ||
426 | 409 | action_name = '%s_%s' % ( | ||
427 | 410 | handler_name, | ||
428 | 411 | safe_name(action["name"]).replace('-', '_')) | ||
429 | 412 | action_module = try_import_module('maascli.actions.%s' % action_name) | ||
430 | 413 | if action_module is not None: | ||
431 | 414 | return action_module.action_class | ||
432 | 415 | return None | ||
433 | 416 | |||
434 | 417 | |||
435 | 418 | def get_action_class_bases(handler, action): | ||
436 | 419 | """Return the base classes for the dynamic class.""" | ||
437 | 420 | action_class = get_action_class(handler, action) | ||
438 | 421 | if action_class is not None: | ||
439 | 422 | return (action_class,) | ||
440 | 423 | return (Action,) | ||
441 | 424 | |||
442 | 425 | |||
443 | 405 | def register_actions(profile, handler, parser): | 426 | def register_actions(profile, handler, parser): |
444 | 406 | """Register a handler's actions.""" | 427 | """Register a handler's actions.""" |
445 | 407 | for action in handler["actions"]: | 428 | for action in handler["actions"]: |
446 | 408 | help_title, help_body = parse_docstring(action["doc"]) | 429 | help_title, help_body = parse_docstring(action["doc"]) |
447 | 409 | action_name = safe_name(action["name"]).encode("ascii") | 430 | action_name = safe_name(action["name"]).encode("ascii") |
449 | 410 | action_bases = (Action,) | 431 | action_bases = get_action_class_bases(handler, action) |
450 | 411 | action_ns = { | 432 | action_ns = { |
451 | 412 | "action": action, | 433 | "action": action, |
452 | 413 | "handler": handler, | 434 | "handler": handler, |
453 | 414 | 435 | ||
454 | === modified file 'src/maascli/tests/test_api.py' | |||
455 | --- src/maascli/tests/test_api.py 2014-08-13 21:49:35 +0000 | |||
456 | +++ src/maascli/tests/test_api.py 2014-09-12 03:32:27 +0000 | |||
457 | @@ -24,6 +24,7 @@ | |||
458 | 24 | 24 | ||
459 | 25 | import httplib2 | 25 | import httplib2 |
460 | 26 | from maascli import api | 26 | from maascli import api |
461 | 27 | from maascli.actions.boot_resources_create import BootResourcesCreateAction | ||
462 | 27 | from maascli.command import CommandError | 28 | from maascli.command import CommandError |
463 | 28 | from maascli.config import ProfileConfig | 29 | from maascli.config import ProfileConfig |
464 | 29 | from maascli.parser import ArgumentParser | 30 | from maascli.parser import ArgumentParser |
465 | @@ -179,6 +180,34 @@ | |||
466 | 179 | "disable the certificate check.") | 180 | "disable the certificate check.") |
467 | 180 | self.assertEqual(error_expected, "%s" % error) | 181 | self.assertEqual(error_expected, "%s" % error) |
468 | 181 | 182 | ||
469 | 183 | def test_get_action_class_returns_None_for_unknown_handler(self): | ||
470 | 184 | handler = {'name': factory.make_name('handler')} | ||
471 | 185 | action = {'name': 'create'} | ||
472 | 186 | self.assertIsNone(api.get_action_class(handler, action)) | ||
473 | 187 | |||
474 | 188 | def test_get_action_class_returns_BootResourcesCreateAction_class(self): | ||
475 | 189 | # Test uses BootResourcesCreateAction as its know to exist. | ||
476 | 190 | handler = {'name': 'BootResourcesHandler'} | ||
477 | 191 | action = {'name': 'create'} | ||
478 | 192 | self.assertEqual( | ||
479 | 193 | BootResourcesCreateAction, | ||
480 | 194 | api.get_action_class(handler, action)) | ||
481 | 195 | |||
482 | 196 | def test_get_action_class_bases_returns_Action(self): | ||
483 | 197 | handler = {'name': factory.make_name('handler')} | ||
484 | 198 | action = {'name': 'create'} | ||
485 | 199 | self.assertEqual( | ||
486 | 200 | (api.Action,), | ||
487 | 201 | api.get_action_class_bases(handler, action)) | ||
488 | 202 | |||
489 | 203 | def test_get_action_class_bases_returns_BootResourcesCreateAction(self): | ||
490 | 204 | # Test uses BootResourcesCreateAction as its know to exist. | ||
491 | 205 | handler = {'name': 'BootResourcesHandler'} | ||
492 | 206 | action = {'name': 'create'} | ||
493 | 207 | self.assertEqual( | ||
494 | 208 | (BootResourcesCreateAction,), | ||
495 | 209 | api.get_action_class_bases(handler, action)) | ||
496 | 210 | |||
497 | 182 | 211 | ||
498 | 183 | class TestIsResponseTextual(MAASTestCase): | 212 | class TestIsResponseTextual(MAASTestCase): |
499 | 184 | """Tests for `is_response_textual`.""" | 213 | """Tests for `is_response_textual`.""" |
500 | 185 | 214 | ||
501 | === modified file 'src/maascli/utils.py' | |||
502 | --- src/maascli/utils.py 2013-10-07 09:31:09 +0000 | |||
503 | +++ src/maascli/utils.py 2014-09-12 03:32:27 +0000 | |||
504 | @@ -25,6 +25,7 @@ | |||
505 | 25 | getdoc, | 25 | getdoc, |
506 | 26 | ) | 26 | ) |
507 | 27 | import re | 27 | import re |
508 | 28 | import sys | ||
509 | 28 | from urlparse import urlparse | 29 | from urlparse import urlparse |
510 | 29 | 30 | ||
511 | 30 | 31 | ||
512 | @@ -107,3 +108,17 @@ | |||
513 | 107 | if re.search("/api/[0-9.]+/?$", url.path) is None: | 108 | if re.search("/api/[0-9.]+/?$", url.path) is None: |
514 | 108 | url = url._replace(path=url.path + "api/1.0/") | 109 | url = url._replace(path=url.path + "api/1.0/") |
515 | 109 | return url.geturl() | 110 | return url.geturl() |
516 | 111 | |||
517 | 112 | |||
518 | 113 | def import_module(import_str): | ||
519 | 114 | """Import a module.""" | ||
520 | 115 | __import__(import_str) | ||
521 | 116 | return sys.modules[import_str] | ||
522 | 117 | |||
523 | 118 | |||
524 | 119 | def try_import_module(import_str, default=None): | ||
525 | 120 | """Try to import a module.""" | ||
526 | 121 | try: | ||
527 | 122 | return import_module(import_str) | ||
528 | 123 | except ImportError: | ||
529 | 124 | return default | ||
530 | 110 | 125 | ||
531 | === modified file 'src/maasserver/api/boot_resources.py' | |||
532 | --- src/maasserver/api/boot_resources.py 2014-09-03 01:05:32 +0000 | |||
533 | +++ src/maasserver/api/boot_resources.py 2014-09-12 03:32:27 +0000 | |||
534 | @@ -273,12 +273,14 @@ | |||
535 | 273 | 273 | ||
536 | 274 | read = create = delete = None | 274 | read = create = delete = None |
537 | 275 | 275 | ||
538 | 276 | hidden = True | ||
539 | 277 | |||
540 | 276 | @admin_method | 278 | @admin_method |
541 | 277 | def update(self, request, id, file_id): | 279 | def update(self, request, id, file_id): |
542 | 278 | """Upload piece of boot resource file.""" | 280 | """Upload piece of boot resource file.""" |
543 | 279 | resource = get_object_or_404(BootResource, id=id) | 281 | resource = get_object_or_404(BootResource, id=id) |
544 | 280 | rfile = get_object_or_404(BootResourceFile, id=file_id) | 282 | rfile = get_object_or_404(BootResourceFile, id=file_id) |
546 | 281 | size = request.META.get('CONTENT_LENGTH', 0) | 283 | size = int(request.META.get('CONTENT_LENGTH', '0')) |
547 | 282 | data = request.body | 284 | data = request.body |
548 | 283 | if size == 0: | 285 | if size == 0: |
549 | 284 | raise MAASAPIBadRequest("Missing data.") | 286 | raise MAASAPIBadRequest("Missing data.") |
550 | 285 | 287 | ||
551 | === modified file 'src/maasserver/api/boot_source_selections.py' | |||
552 | --- src/maasserver/api/boot_source_selections.py 2014-08-22 15:21:48 +0000 | |||
553 | +++ src/maasserver/api/boot_source_selections.py 2014-09-12 03:32:27 +0000 | |||
554 | @@ -101,7 +101,7 @@ | |||
555 | 101 | be set globally for the whole region and clusters. This api is now | 101 | be set globally for the whole region and clusters. This api is now |
556 | 102 | deprecated, and only exists for backwards compatibility. | 102 | deprecated, and only exists for backwards compatibility. |
557 | 103 | """ | 103 | """ |
559 | 104 | deprecated = True | 104 | hidden = True |
560 | 105 | 105 | ||
561 | 106 | def read(self, request, uuid, boot_source_id, id): | 106 | def read(self, request, uuid, boot_source_id, id): |
562 | 107 | """Read a boot source selection.""" | 107 | """Read a boot source selection.""" |
563 | @@ -175,7 +175,7 @@ | |||
564 | 175 | be set globally for the whole region and clusters. This api is now | 175 | be set globally for the whole region and clusters. This api is now |
565 | 176 | deprecated, and only exists for backwards compatibility. | 176 | deprecated, and only exists for backwards compatibility. |
566 | 177 | """ | 177 | """ |
568 | 178 | deprecated = True | 178 | hidden = True |
569 | 179 | 179 | ||
570 | 180 | def read(self, request, uuid, boot_source_id): | 180 | def read(self, request, uuid, boot_source_id): |
571 | 181 | """List boot source selections. | 181 | """List boot source selections. |
572 | 182 | 182 | ||
573 | === modified file 'src/maasserver/api/boot_sources.py' | |||
574 | --- src/maasserver/api/boot_sources.py 2014-08-22 15:21:48 +0000 | |||
575 | +++ src/maasserver/api/boot_sources.py 2014-09-12 03:32:27 +0000 | |||
576 | @@ -119,7 +119,7 @@ | |||
577 | 119 | be set globally for the whole region and clusters. This api is now | 119 | be set globally for the whole region and clusters. This api is now |
578 | 120 | deprecated, and only exists for backwards compatibility. | 120 | deprecated, and only exists for backwards compatibility. |
579 | 121 | """ | 121 | """ |
581 | 122 | deprecated = True | 122 | hidden = True |
582 | 123 | 123 | ||
583 | 124 | def read(self, request, uuid, id): | 124 | def read(self, request, uuid, id): |
584 | 125 | """Read a boot source.""" | 125 | """Read a boot source.""" |
585 | @@ -186,7 +186,7 @@ | |||
586 | 186 | be set globally for the whole region and clusters. This api is now | 186 | be set globally for the whole region and clusters. This api is now |
587 | 187 | deprecated, and only exists for backwards compatibility. | 187 | deprecated, and only exists for backwards compatibility. |
588 | 188 | """ | 188 | """ |
590 | 189 | deprecated = True | 189 | hidden = True |
591 | 190 | 190 | ||
592 | 191 | def read(self, request, uuid): | 191 | def read(self, request, uuid): |
593 | 192 | """List boot sources. | 192 | """List boot sources. |
594 | 193 | 193 | ||
595 | === modified file 'src/maasserver/api/doc.py' | |||
596 | --- src/maasserver/api/doc.py 2014-08-22 15:21:48 +0000 | |||
597 | +++ src/maasserver/api/doc.py 2014-09-12 03:32:27 +0000 | |||
598 | @@ -43,12 +43,14 @@ | |||
599 | 43 | Handlers are of type :class:`HandlerMetaClass`, and must define a | 43 | Handlers are of type :class:`HandlerMetaClass`, and must define a |
600 | 44 | `resource_uri` method. | 44 | `resource_uri` method. |
601 | 45 | 45 | ||
602 | 46 | Handlers that have the attribute hidden set to True, will not be returned. | ||
603 | 47 | |||
604 | 46 | :rtype: Generator, yielding handlers. | 48 | :rtype: Generator, yielding handlers. |
605 | 47 | """ | 49 | """ |
606 | 48 | p_has_resource_uri = lambda resource: ( | 50 | p_has_resource_uri = lambda resource: ( |
607 | 49 | getattr(resource.handler, "resource_uri", None) is not None) | 51 | getattr(resource.handler, "resource_uri", None) is not None) |
610 | 50 | p_is_not_deprecated = lambda resource: ( | 52 | p_is_not_hidden = lambda resource: ( |
611 | 51 | getattr(resource.handler, "deprecated", False)) | 53 | getattr(resource.handler, "hidden", False)) |
612 | 52 | for pattern in resolver.url_patterns: | 54 | for pattern in resolver.url_patterns: |
613 | 53 | if isinstance(pattern, RegexURLResolver): | 55 | if isinstance(pattern, RegexURLResolver): |
614 | 54 | accumulate_api_resources(pattern, accumulator) | 56 | accumulate_api_resources(pattern, accumulator) |
615 | @@ -56,7 +58,7 @@ | |||
616 | 56 | if isinstance(pattern.callback, Resource): | 58 | if isinstance(pattern.callback, Resource): |
617 | 57 | resource = pattern.callback | 59 | resource = pattern.callback |
618 | 58 | if p_has_resource_uri(resource) and \ | 60 | if p_has_resource_uri(resource) and \ |
620 | 59 | not p_is_not_deprecated(resource): | 61 | not p_is_not_hidden(resource): |
621 | 60 | accumulator.add(resource) | 62 | accumulator.add(resource) |
622 | 61 | else: | 63 | else: |
623 | 62 | raise AssertionError( | 64 | raise AssertionError( |
624 | 63 | 65 | ||
625 | === modified file 'src/maasserver/api/tests/test_doc.py' | |||
626 | --- src/maasserver/api/tests/test_doc.py 2014-08-20 23:54:33 +0000 | |||
627 | +++ src/maasserver/api/tests/test_doc.py 2014-09-12 03:32:27 +0000 | |||
628 | @@ -88,6 +88,18 @@ | |||
629 | 88 | module.urlpatterns = patterns("", url("^metal", resource)) | 88 | module.urlpatterns = patterns("", url("^metal", resource)) |
630 | 89 | self.assertSetEqual({resource}, find_api_resources(module)) | 89 | self.assertSetEqual({resource}, find_api_resources(module)) |
631 | 90 | 90 | ||
632 | 91 | def test_urlpatterns_with_resource_hidden(self): | ||
633 | 92 | # Resources for handlers with resource_uri attributes are discovered | ||
634 | 93 | # in a urlconf module and returned, unless hidden = True. | ||
635 | 94 | handler = type(b"\m/", (BaseHandler,), { | ||
636 | 95 | "resource_uri": True, | ||
637 | 96 | "hidden": True, | ||
638 | 97 | }) | ||
639 | 98 | resource = Resource(handler) | ||
640 | 99 | module = self.make_module() | ||
641 | 100 | module.urlpatterns = patterns("", url("^metal", resource)) | ||
642 | 101 | self.assertSetEqual(set(), find_api_resources(module)) | ||
643 | 102 | |||
644 | 91 | def test_nested_urlpatterns_with_handler(self): | 103 | def test_nested_urlpatterns_with_handler(self): |
645 | 92 | # Resources are found in nested urlconfs. | 104 | # Resources are found in nested urlconfs. |
646 | 93 | handler = type(b"\m/", (BaseHandler,), {"resource_uri": True}) | 105 | handler = type(b"\m/", (BaseHandler,), {"resource_uri": True}) |
647 | 94 | 106 | ||
648 | === modified file 'src/maasserver/models/bootresourcefile.py' | |||
649 | --- src/maasserver/models/bootresourcefile.py 2014-08-31 02:47:48 +0000 | |||
650 | +++ src/maasserver/models/bootresourcefile.py 2014-09-12 03:32:27 +0000 | |||
651 | @@ -20,6 +20,8 @@ | |||
652 | 20 | CharField, | 20 | CharField, |
653 | 21 | ForeignKey, | 21 | ForeignKey, |
654 | 22 | ) | 22 | ) |
655 | 23 | from django.db.models.signals import post_delete | ||
656 | 24 | from django.dispatch import receiver | ||
657 | 23 | from maasserver import DefaultMeta | 25 | from maasserver import DefaultMeta |
658 | 24 | from maasserver.enum import ( | 26 | from maasserver.enum import ( |
659 | 25 | BOOT_RESOURCE_FILE_TYPE, | 27 | BOOT_RESOURCE_FILE_TYPE, |
660 | @@ -70,3 +72,16 @@ | |||
661 | 70 | 72 | ||
662 | 71 | def __repr__(self): | 73 | def __repr__(self): |
663 | 72 | return "<BootResourceFile %s/%s>" % (self.filename, self.filetype) | 74 | return "<BootResourceFile %s/%s>" % (self.filename, self.filetype) |
664 | 75 | |||
665 | 76 | |||
666 | 77 | @receiver(post_delete) | ||
667 | 78 | def delete_large_file(sender, instance, **kwargs): | ||
668 | 79 | """Call delete on the LargeFile, now that the relation has been removed. | ||
669 | 80 | If this was the only resource file referencing this LargeFile then it will | ||
670 | 81 | be delete. | ||
671 | 82 | |||
672 | 83 | This is done using the `post_delete` signal because only then has the | ||
673 | 84 | relation been removed. | ||
674 | 85 | """ | ||
675 | 86 | if sender == BootResourceFile: | ||
676 | 87 | instance.largefile.delete() |
I have comments and questions inline but I don't think any of them are blockers.