Merge lp:~jtv/maas/bug-1025582-task into lp:maas/trunk

Proposed by Jeroen T. Vermeulen on 2012-09-12
Status: Merged
Approved by: Jeroen T. Vermeulen on 2012-09-14
Approved revision: 1003
Merged at revision: 999
Proposed branch: lp:~jtv/maas/bug-1025582-task
Merge into: lp:maas/trunk
Prerequisite: lp:~jtv/maas/bug-1025582-api
Diff against target: 661 lines (+467/-16)
10 files modified
etc/celeryconfig.py (+7/-0)
src/apiclient/testing/credentials.py (+26/-0)
src/maasserver/tests/test_api.py (+16/-0)
src/provisioningserver/boot_images.py (+68/-0)
src/provisioningserver/pxe/tests/test_tftppath.py (+134/-0)
src/provisioningserver/pxe/tftppath.py (+84/-0)
src/provisioningserver/tasks.py (+12/-0)
src/provisioningserver/tests/test_auth.py (+3/-11)
src/provisioningserver/tests/test_boot_images.py (+80/-0)
src/provisioningserver/tests/test_tasks.py (+37/-5)
To merge this branch: bzr merge lp:~jtv/maas/bug-1025582-task
Reviewer Review Type Date Requested Status
Julian Edwards (community) 2012-09-12 Approve on 2012-09-14
Review via email: mp+123900@code.launchpad.net

Commit Message

Have the master worker report its available boot images.

This creates a new task for the master worker, and runs it on a 5-minute schedule. The task scans the TFTP filesystem tree (which is on the same system as the master worker) for boot images, and reports what it finds to the API. In a subsequent branch, the UI will report missing images as a persistent error.

Description of the Change

See commit message. Pre-implementation call with Julian.

There's a lot of testing going on, especially for the helpers that drill down into multiple directories at once. It seems a bit weird in places but it was an effective way of getting a flat listing of all boot images' parameters. Any suggestions for tests that I could sensibly drop are welcome.

This branch is to be backported to 1.1.

Jeroen

To post a comment you must log in.
lp:~jtv/maas/bug-1025582-task updated on 2012-09-13
999. By Jeroen T. Vermeulen on 2012-09-13

Merge review changes from pre-predecessor branch.

Gavin Panella (allenap) wrote :

> The task scans the TFTP filesystem tree (which is on the same system
> as the master worker) for boot images, ...

It /may/ be on the same filesystem, but there's no guarantee. The TFTP
filesystem tree needs to be mirrored to each cluster controller. I
guess where there's a worker there's a cluster controller, because
they're bundled as one?

This doesn't take into account future cluster controllers. The region
asks the cluster for a machine matching $constraints and then to put
$release on it; the cluster could decide to use an different,
hardware-tuned, image, instead of the one from cloud images. Though I
don't think this is a requirement right now, we need to bear a very
much more intelligence-at-the-edge approach in mind, imo.

Julian Edwards (julian-edwards) wrote :

On Thursday 13 September 2012 08:05:30 you wrote:
> > The task scans the TFTP filesystem tree (which is on the same system
> > as the master worker) for boot images, ...
>
> It /may/ be on the same filesystem, but there's no guarantee. The TFTP
> filesystem tree needs to be mirrored to each cluster controller. I
> guess where there's a worker there's a cluster controller, because
> they're bundled as one?

That is our implementation of the cluster controller, yes. So we can make
this guarantee.

> This doesn't take into account future cluster controllers. The region
> asks the cluster for a machine matching $constraints and then to put
> $release on it; the cluster could decide to use an different,
> hardware-tuned, image, instead of the one from cloud images. Though I
> don't think this is a requirement right now, we need to bear a very
> much more intelligence-at-the-edge approach in mind, imo.

It's the direction in which we're heading, yes. The only thing that can know
about this is the cluster (pserv) which is why it's reporting it via the API.

Everything else is an implementation detail.

Julian Edwards (julian-edwards) wrote :

FWIW I don't like the commit message, it seems like a lot of it should be in the description. It should really just state the change happening (and why). Also... conflicts :(

lp:~jtv/maas/bug-1025582-task updated on 2012-09-14
1000. By Jeroen T. Vermeulen on 2012-09-14

Merge trunk and conflict resolution from predecessor branch, resolve another conflict.

1001. By Jeroen T. Vermeulen on 2012-09-14

Revert some debugging changes.

Julian Edwards (julian-edwards) wrote :

Nice testing!

54 >>>>>>> MERGE-SOURCE

Stray merge marker.

277 + [arch, subarch, release, purpose] = path

You don't need the [ and ] here.

Is it always guaranteed to have four elements? Otherwise the unpacking will blow up.

278 + return {
279 + 'architecture': arch,
280 + 'subarchitecture': subarch,
281 + 'release': release,
282 + 'purpose': purpose,
283 + }

How about:

    return dict(
        architecture=arch, subarchitecture=subarch, release=release, purpose=purpose)

I also wonder if a named tuple might be better so you could do something like ``path.architecture``, but I'll leave it up to you.

310 === modified file 'src/provisioningserver/tasks.py'

[lots of imports]

I really think we should keep tasks.py very lean, and import functions from elsewhere that do the actual work. It'll keep things more structured, and then tasks.py becomes an inclusion point, a bit like models/__init__.py.

373 + task_logger.info("Not reporting boot images: don't have API URL yet.")
377 + task_logger.info("Not reporting boot images: don't have API key yet.")

I think these would be better as debugging logs.

review: Approve
lp:~jtv/maas/bug-1025582-task updated on 2012-09-14
1002. By Jeroen T. Vermeulen on 2012-09-14

Some review changes.

1003. By Jeroen T. Vermeulen on 2012-09-14

Review changes: move task out into its own module (occasioning creation of apiclient.testing), log missing info from server at debug level.

Jeroen T. Vermeulen (jtv) wrote :

> Nice testing!
>
> 54 >>>>>>> MERGE-SOURCE
>
> Stray merge marker.

I so hope those were separate comments and not sarcasm. :) The marker was just a bit of diff artifact inherited from a conflict in the parent branch.

> 277 + [arch, subarch, release, purpose] = path
>
> You don't need the [ and ] here.

Fixed.

> Is it always guaranteed to have four elements? Otherwise the unpacking will
> blow up.

Yes, it is — but I could have documented it. Now I have.

> 278 + return {
> 279 + 'architecture': arch,
> 280 + 'subarchitecture': subarch,
> 281 + 'release': release,
> 282 + 'purpose': purpose,
> 283 + }
>
> How about:
>
> return dict(
> architecture=arch, subarchitecture=subarch, release=release,
> purpose=purpose)

Done.

> I also wonder if a named tuple might be better so you could do something like
> ``path.architecture``, but I'll leave it up to you.

It's certainly tempting. In this particular case though I'm translating from a list to a dict — I don't think a named tuple would have made that much better.

> 310 === modified file 'src/provisioningserver/tasks.py'
>
> [lots of imports]
>
> I really think we should keep tasks.py very lean, and import functions from
> elsewhere that do the actual work. It'll keep things more structured, and
> then tasks.py becomes an inclusion point, a bit like models/__init__.py.

You're right. I was just too lazy to create a separate module for this — and it didn't quite fit in any that we had.

I've now given this its own module, and reduced the task test to a single integration test. Unfortunately one helper in test_tasks was now needed in too many places; I put it in a brand-new maasclient.testing.

> 373 + task_logger.info("Not reporting boot images: don't have API URL
> yet.")
> 377 + task_logger.info("Not reporting boot images: don't have API key
> yet.")
>
> I think these would be better as debugging logs.

Fair enough. Done.

Jeroen

Jeroen T. Vermeulen (jtv) wrote :

I've moved some text from the commit message to the description. Still experimenting with this new distribution of responsibilities between the two.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'etc/celeryconfig.py'
2--- etc/celeryconfig.py 2012-08-24 08:39:18 +0000
3+++ etc/celeryconfig.py 2012-09-14 11:06:18 +0000
4@@ -72,4 +72,11 @@
5 'task': 'provisioningserver.tasks.upload_dhcp_leases',
6 'schedule': timedelta(minutes=1),
7 },
8+
9+ # XXX JeroenVermeulen 2012-09-12, bug=1039366: this task should run
10+ # only on the master worker.
11+ 'report-boot-images': {
12+ 'task': 'provisioningserver.report_boot_images',
13+ 'schedule': timedelta(minutes=5),
14+ },
15 }
16
17=== added directory 'src/apiclient/testing'
18=== added file 'src/apiclient/testing/__init__.py'
19=== added file 'src/apiclient/testing/credentials.py'
20--- src/apiclient/testing/credentials.py 1970-01-01 00:00:00 +0000
21+++ src/apiclient/testing/credentials.py 2012-09-14 11:06:18 +0000
22@@ -0,0 +1,26 @@
23+# Copyright 2012 Canonical Ltd. This software is licensed under the
24+# GNU Affero General Public License version 3 (see the file LICENSE).
25+
26+"""Testing facilities for API credentials."""
27+
28+from __future__ import (
29+ absolute_import,
30+ print_function,
31+ unicode_literals,
32+ )
33+
34+__metaclass__ = type
35+__all__ = [
36+ 'make_api_credentials',
37+ ]
38+
39+from maastesting.factory import factory
40+
41+
42+def make_api_credentials():
43+ """Create a tuple of fake API credentials."""
44+ return (
45+ factory.make_name('consumer-key'),
46+ factory.make_name('resource-token'),
47+ factory.make_name('resource-secret'),
48+ )
49
50=== modified file 'src/maasserver/tests/test_api.py'
51--- src/maasserver/tests/test_api.py 2012-09-14 10:10:43 +0000
52+++ src/maasserver/tests/test_api.py 2012-09-14 11:06:18 +0000
53@@ -108,6 +108,7 @@
54 )
55 from provisioningserver.kernel_opts import KernelParameters
56 from provisioningserver.omshell import Omshell
57+from provisioningserver.pxe import tftppath
58 from testresources import FixtureResource
59 from testtools.matchers import (
60 Contains,
61@@ -2698,6 +2699,10 @@
62
63 class TestBootImagesAPI(APITestCase):
64
65+ resources = (
66+ ('celery', FixtureResource(CeleryFixture())),
67+ )
68+
69 def report_images(self, images, client=None):
70 if client is None:
71 client = self.client
72@@ -2751,6 +2756,17 @@
73 (httplib.OK, "OK"),
74 (response.status_code, response.content))
75
76+ def test_worker_calls_report_boot_images(self):
77+ refresh_worker(NodeGroup.objects.ensure_master())
78+ self.patch(MAASClient, 'post')
79+ self.patch(tftppath, 'list_boot_images', Mock(return_value=[]))
80+
81+ tasks.report_boot_images.delay()
82+
83+ MAASClient.post.assert_called_once_with(
84+ reverse('boot_images_handler').lstrip('/'), 'report_boot_images',
85+ images=json.dumps([]))
86+
87
88 class TestDescribe(AnonAPITestCase):
89 """Tests for the `describe` view."""
90
91=== added file 'src/provisioningserver/boot_images.py'
92--- src/provisioningserver/boot_images.py 1970-01-01 00:00:00 +0000
93+++ src/provisioningserver/boot_images.py 2012-09-14 11:06:18 +0000
94@@ -0,0 +1,68 @@
95+# Copyright 2012 Canonical Ltd. This software is licensed under the
96+# GNU Affero General Public License version 3 (see the file LICENSE).
97+
98+"""Dealing with boot images.
99+
100+Most of the lower-level logic is in the `tftppath` module, because it must
101+correspond closely to the structure of the TFTP filesystem hierarchy.
102+"""
103+
104+from __future__ import (
105+ absolute_import,
106+ print_function,
107+ unicode_literals,
108+ )
109+
110+__metaclass__ = type
111+__all__ = [
112+ 'report_to_server',
113+ ]
114+
115+import json
116+
117+from apiclient.maas_client import (
118+ MAASClient,
119+ MAASDispatcher,
120+ MAASOAuth,
121+ )
122+from provisioningserver.auth import (
123+ get_recorded_api_credentials,
124+ get_recorded_maas_url,
125+ )
126+from provisioningserver.config import Config
127+from provisioningserver.logging import task_logger
128+from provisioningserver.pxe import tftppath
129+
130+
131+def get_cached_knowledge():
132+ """Return cached items required to report to the server.
133+
134+ :return: Tuple of cached items: (maas_url, api_credentials). Either may
135+ be None if the information has not been received from the server yet.
136+ """
137+ maas_url = get_recorded_maas_url()
138+ if maas_url is None:
139+ task_logger.debug("Not reporting boot images: don't have API URL yet.")
140+ api_credentials = get_recorded_api_credentials()
141+ if api_credentials is None:
142+ task_logger.debug("Not reporting boot images: don't have API key yet.")
143+ return maas_url, api_credentials
144+
145+
146+def submit(maas_url, api_credentials, images):
147+ """Submit images to server."""
148+ MAASClient(MAASOAuth(*api_credentials), MAASDispatcher(), maas_url).post(
149+ 'api/1.0/boot-images/', 'report_boot_images',
150+ images=json.dumps(images))
151+
152+
153+def report_to_server():
154+ """For master worker only: report available netboot images."""
155+ maas_url, api_credentials = get_cached_knowledge()
156+ if not all([maas_url, api_credentials]):
157+ return
158+
159+ images = tftppath.list_boot_images(
160+ Config.load_from_cache()['tftp']['root'])
161+
162+ submit(maas_url, api_credentials, images)
163
164=== modified file 'src/provisioningserver/pxe/tests/test_tftppath.py'
165--- src/provisioningserver/pxe/tests/test_tftppath.py 2012-08-30 10:37:26 +0000
166+++ src/provisioningserver/pxe/tests/test_tftppath.py 2012-09-14 11:06:18 +0000
167@@ -21,6 +21,11 @@
168 compose_bootloader_path,
169 compose_config_path,
170 compose_image_path,
171+ drill_down,
172+ extend_path,
173+ is_visible_subdir,
174+ list_boot_images,
175+ list_subdirs,
176 locate_tftp_path,
177 )
178 from provisioningserver.testing.config import ConfigFixture
179@@ -38,6 +43,28 @@
180 self.config = {"tftp": {"root": self.tftproot}}
181 self.useFixture(ConfigFixture(self.config))
182
183+ def make_boot_image_params(self):
184+ """Create a dict of boot-image parameters, as in list_boot_images."""
185+ return {
186+ 'architecture': factory.make_name('architecture'),
187+ 'subarchitecture': factory.make_name('subarchitecture'),
188+ 'release': factory.make_name('release'),
189+ 'purpose': factory.make_name('purpose'),
190+ }
191+
192+ def make_image_dir(self, image_params, tftproot):
193+ """Fake a boot image matching `image_params` under `tftproot`."""
194+ image_dir = locate_tftp_path(
195+ compose_image_path(
196+ arch=image_params['architecture'],
197+ subarch=image_params['subarchitecture'],
198+ release=image_params['release'],
199+ purpose=image_params['purpose']),
200+ tftproot)
201+ os.makedirs(image_dir)
202+ factory.make_file(image_dir, 'linux')
203+ factory.make_file(image_dir, 'initrd.gz')
204+
205 def test_compose_config_path_follows_maas_pxe_directory_layout(self):
206 name = factory.make_name('config')
207 self.assertEqual(
208@@ -85,3 +112,110 @@
209 def test_locate_tftp_path_returns_root_when_path_is_None(self):
210 self.assertEqual(
211 self.tftproot, locate_tftp_path(None, tftproot=self.tftproot))
212+
213+ def test_list_boot_images_copes_with_empty_directory(self):
214+ self.assertItemsEqual([], list_boot_images(self.tftproot))
215+
216+ def test_list_boot_images_copes_with_unexpected_files(self):
217+ os.makedirs(os.path.join(self.tftproot, factory.make_name('empty')))
218+ factory.make_file(self.tftproot)
219+ self.assertItemsEqual([], list_boot_images(self.tftproot))
220+
221+ def test_list_boot_images_finds_boot_image(self):
222+ image = self.make_boot_image_params()
223+ self.make_image_dir(image, self.tftproot)
224+ self.assertItemsEqual([image], list_boot_images(self.tftproot))
225+
226+ def test_list_boot_images_enumerates_boot_images(self):
227+ images = [self.make_boot_image_params() for counter in range(3)]
228+ for image in images:
229+ self.make_image_dir(image, self.tftproot)
230+ self.assertItemsEqual(images, list_boot_images(self.tftproot))
231+
232+ def test_is_visible_subdir_ignores_regular_files(self):
233+ plain_file = self.make_file()
234+ self.assertFalse(
235+ is_visible_subdir(
236+ os.path.dirname(plain_file), os.path.basename(plain_file)))
237+
238+ def test_is_visible_subdir_ignores_hidden_directories(self):
239+ base_dir = self.make_dir()
240+ hidden_dir = factory.make_name('.')
241+ os.makedirs(os.path.join(base_dir, hidden_dir))
242+ self.assertFalse(is_visible_subdir(base_dir, hidden_dir))
243+
244+ def test_is_visible_subdir_recognizes_subdirectory(self):
245+ base_dir = self.make_dir()
246+ subdir = factory.make_name('subdir')
247+ os.makedirs(os.path.join(base_dir, subdir))
248+ self.assertTrue(is_visible_subdir(base_dir, subdir))
249+
250+ def test_list_subdirs_lists_empty_directory(self):
251+ self.assertItemsEqual([], list_subdirs(self.make_dir()))
252+
253+ def test_list_subdirs_lists_subdirs(self):
254+ base_dir = self.make_dir()
255+ factory.make_file(base_dir, factory.make_name('plain-file'))
256+ subdir = factory.make_name('subdir')
257+ os.makedirs(os.path.join(base_dir, subdir))
258+ self.assertItemsEqual([subdir], list_subdirs(base_dir))
259+
260+ def test_extend_path_finds_path_extensions(self):
261+ base_dir = self.make_dir()
262+ subdirs = [
263+ factory.make_name('subdir-%d' % counter)
264+ for counter in range(3)]
265+ for subdir in subdirs:
266+ os.makedirs(os.path.join(base_dir, subdir))
267+ self.assertItemsEqual(
268+ [[os.path.basename(base_dir), subdir] for subdir in subdirs],
269+ extend_path(
270+ os.path.dirname(base_dir), [os.path.basename(base_dir)]))
271+
272+ def test_extend_path_builds_on_given_paths(self):
273+ base_dir = self.make_dir()
274+ lower_dir = factory.make_name('lower')
275+ subdir = factory.make_name('sub')
276+ os.makedirs(os.path.join(base_dir, lower_dir, subdir))
277+ self.assertEqual(
278+ [[lower_dir, subdir]],
279+ extend_path(base_dir, [lower_dir]))
280+
281+ def test_extend_path_stops_if_no_subdirs_found(self):
282+ self.assertItemsEqual([], extend_path(self.make_dir(), []))
283+
284+ def test_drill_down_follows_directory_tree(self):
285+ base_dir = self.make_dir()
286+ lower_dir = factory.make_name('lower')
287+ os.makedirs(os.path.join(base_dir, lower_dir))
288+ subdirs = [
289+ factory.make_name('subdir-%d' % counter)
290+ for counter in range(3)]
291+ for subdir in subdirs:
292+ os.makedirs(os.path.join(base_dir, lower_dir, subdir))
293+ self.assertItemsEqual(
294+ [[lower_dir, subdir] for subdir in subdirs],
295+ drill_down(base_dir, [[lower_dir]]))
296+
297+ def test_drill_down_ignores_subdir_not_in_path(self):
298+ base_dir = self.make_dir()
299+ irrelevant_dir = factory.make_name('irrelevant')
300+ irrelevant_subdir = factory.make_name('subdir')
301+ relevant_dir = factory.make_name('relevant')
302+ relevant_subdir = factory.make_name('subdir')
303+ os.makedirs(os.path.join(base_dir, irrelevant_dir, irrelevant_subdir))
304+ os.makedirs(os.path.join(base_dir, relevant_dir, relevant_subdir))
305+ self.assertEqual(
306+ [[relevant_dir, relevant_subdir]],
307+ drill_down(base_dir, [[relevant_dir]]))
308+
309+ def test_drill_down_drops_paths_that_do_not_go_deep_enough(self):
310+ base_dir = self.make_dir()
311+ shallow_dir = factory.make_name('shallow')
312+ os.makedirs(os.path.join(base_dir, shallow_dir))
313+ deep_dir = factory.make_name('deep')
314+ subdir = factory.make_name('sub')
315+ os.makedirs(os.path.join(base_dir, deep_dir, subdir))
316+ self.assertEqual(
317+ [[deep_dir, subdir]],
318+ drill_down(base_dir, [[shallow_dir], [deep_dir]]))
319
320=== modified file 'src/provisioningserver/pxe/tftppath.py'
321--- src/provisioningserver/pxe/tftppath.py 2012-08-30 10:50:31 +0000
322+++ src/provisioningserver/pxe/tftppath.py 2012-09-14 11:06:18 +0000
323@@ -14,6 +14,7 @@
324 'compose_bootloader_path',
325 'compose_config_path',
326 'compose_image_path',
327+ 'list_boot_images',
328 'locate_tftp_path',
329 ]
330
331@@ -83,3 +84,86 @@
332 if path is None:
333 return tftproot
334 return os.path.join(tftproot, path.lstrip('/'))
335+
336+
337+def is_visible_subdir(directory, subdir):
338+ """Is `subdir` a non-hidden sub-directory of `directory`?"""
339+ if subdir.startswith('.'):
340+ return False
341+ else:
342+ return os.path.isdir(os.path.join(directory, subdir))
343+
344+
345+def list_subdirs(directory):
346+ """Return a list of non-hidden directories in `directory`."""
347+ return [
348+ subdir
349+ for subdir in os.listdir(directory)
350+ if is_visible_subdir(directory, subdir)]
351+
352+
353+def extend_path(directory, path):
354+ """Dig one directory level deeper on `os.path.join(directory, *path)`.
355+
356+ If `path` is a list of consecutive path elements drilling down from
357+ `directory`, return a list of sub-directory paths leading one step
358+ further down.
359+
360+ :param directory: Base directory that `path` is relative to.
361+ :param path: A path to a subdirectory of `directory`, represented as
362+ a list of path elements relative to `directory`.
363+ :return: A list of paths that go one sub-directory level further
364+ down from `path`.
365+ """
366+ return [
367+ path + [subdir]
368+ for subdir in list_subdirs(os.path.join(directory, *path))]
369+
370+
371+def drill_down(directory, paths):
372+ """Find the extensions of `paths` one level deeper into the filesystem.
373+
374+ :param directory: Base directory that each path in `paths` is relative to.
375+ :param paths: A list of "path lists." Each path list is a list of
376+ path elements drilling down into the filesystem from `directory`.
377+ :return: A list of paths, each of which drills one level deeper down into
378+ the filesystem hierarchy than the originals in `paths`.
379+ """
380+ return sum([extend_path(directory, path) for path in paths], [])
381+
382+
383+def extract_image_params(path):
384+ """Represent a list of TFTP path elements as a boot-image dict.
385+
386+ The path must consist of a full [architecture, subarchitecture, release,
387+ purpose] that identify a kind of boot that we may need an image for.
388+ """
389+ arch, subarch, release, purpose = path
390+ return dict(
391+ architecture=arch, subarchitecture=subarch, release=release,
392+ purpose=purpose)
393+
394+
395+def list_boot_images(tftproot):
396+ """List the available boot images.
397+
398+ :param tftproot: TFTP root directory.
399+ :return: An iterable of dicts, describing boot images as consumed by
400+ the report_boot_images API call.
401+ """
402+ # The sub-directories directly under tftproot, if they contain
403+ # images, represent architectures.
404+ potential_archs = list_subdirs(tftproot)
405+
406+ # Starting point for iteration: paths that contain only the
407+ # top-level subdirectory of tftproot, i.e. the architecture name.
408+ paths = [[subdir] for subdir in potential_archs]
409+
410+ # Extend paths deeper into the filesystem, through the levels that
411+ # represent sub-architecture, release, and purpose. Any directory
412+ # that doesn't extend this deep isn't a boot image.
413+ for level in ['subarch', 'release', 'purpose']:
414+ paths = drill_down(tftproot, paths)
415+
416+ # Each path we find this way should be a boot image.
417+ return [extract_image_params(path) for path in paths]
418
419=== modified file 'src/provisioningserver/tasks.py'
420--- src/provisioningserver/tasks.py 2012-09-10 14:27:00 +0000
421+++ src/provisioningserver/tasks.py 2012-09-14 11:06:18 +0000
422@@ -32,6 +32,7 @@
423
424 from celery.task import task
425 from celeryconfig import DHCP_CONFIG_FILE
426+from provisioningserver import boot_images
427 from provisioningserver.auth import (
428 record_api_credentials,
429 record_maas_url,
430@@ -320,3 +321,14 @@
431 def restart_dhcp_server():
432 """Restart the DHCP server."""
433 check_call(['sudo', 'service', 'isc-dhcp-server', 'restart'])
434+
435+
436+# =====================================================================
437+# Boot images-related tasks
438+# =====================================================================
439+
440+
441+@task
442+def report_boot_images():
443+ """For master worker only: report available netboot images."""
444+ boot_images.report_to_server()
445
446=== modified file 'src/provisioningserver/tests/test_auth.py'
447--- src/provisioningserver/tests/test_auth.py 2012-09-10 14:27:00 +0000
448+++ src/provisioningserver/tests/test_auth.py 2012-09-14 11:06:18 +0000
449@@ -13,6 +13,7 @@
450 __all__ = []
451
452 from apiclient.creds import convert_tuple_to_string
453+from apiclient.testing.credentials import make_api_credentials
454 from maastesting.factory import factory
455 from provisioningserver import (
456 auth,
457@@ -21,25 +22,16 @@
458 from provisioningserver.testing.testcase import PservTestCase
459
460
461-def make_credentials():
462- """Produce a tuple of API credentials."""
463- return (
464- factory.make_name('consumer-key'),
465- factory.make_name('resource-token'),
466- factory.make_name('resource-secret'),
467- )
468-
469-
470 class TestAuth(PservTestCase):
471
472 def test_record_api_credentials_records_credentials_string(self):
473- creds_string = convert_tuple_to_string(make_credentials())
474+ creds_string = convert_tuple_to_string(make_api_credentials())
475 auth.record_api_credentials(creds_string)
476 self.assertEqual(
477 creds_string, cache.cache.get(auth.API_CREDENTIALS_CACHE_KEY))
478
479 def test_get_recorded_api_credentials_returns_credentials_as_tuple(self):
480- creds = make_credentials()
481+ creds = make_api_credentials()
482 auth.record_api_credentials(convert_tuple_to_string(creds))
483 self.assertEqual(creds, auth.get_recorded_api_credentials())
484
485
486=== added file 'src/provisioningserver/tests/test_boot_images.py'
487--- src/provisioningserver/tests/test_boot_images.py 1970-01-01 00:00:00 +0000
488+++ src/provisioningserver/tests/test_boot_images.py 2012-09-14 11:06:18 +0000
489@@ -0,0 +1,80 @@
490+# Copyright 2012 Canonical Ltd. This software is licensed under the
491+# GNU Affero General Public License version 3 (see the file LICENSE).
492+
493+"""Tests for reporting of boot images."""
494+
495+from __future__ import (
496+ absolute_import,
497+ print_function,
498+ unicode_literals,
499+ )
500+
501+__metaclass__ = type
502+__all__ = []
503+
504+import json
505+
506+from apiclient.maas_client import MAASClient
507+from apiclient.testing.credentials import make_api_credentials
508+from maastesting.factory import factory
509+from mock import Mock
510+from provisioningserver import boot_images
511+from provisioningserver.auth import (
512+ record_api_credentials,
513+ record_maas_url,
514+ )
515+from provisioningserver.pxe import tftppath
516+from provisioningserver.testing.config import ConfigFixture
517+from provisioningserver.testing.testcase import PservTestCase
518+
519+
520+class TestBootImagesTasks(PservTestCase):
521+
522+ def setUp(self):
523+ super(TestBootImagesTasks, self).setUp()
524+ self.useFixture(ConfigFixture({'tftp': {'root': self.make_dir()}}))
525+
526+ def make_image_params(self):
527+ """Create a dict of parameters describing a boot image."""
528+ return {
529+ 'architecture': factory.make_name('architecture'),
530+ 'subarchitecture': factory.make_name('subarchitecture'),
531+ 'release': factory.make_name('release'),
532+ 'purpose': factory.make_name('purpose'),
533+ }
534+
535+ def set_maas_url(self):
536+ record_maas_url(
537+ 'http://127.0.0.1/%s' % factory.make_name('path'))
538+
539+ def set_api_credentials(self):
540+ record_api_credentials(':'.join(make_api_credentials()))
541+
542+ def test_sends_boot_images_to_server(self):
543+ self.set_maas_url()
544+ self.set_api_credentials()
545+ image = self.make_image_params()
546+ self.patch(tftppath, 'list_boot_images', Mock(return_value=[image]))
547+ self.patch(MAASClient, 'post')
548+
549+ boot_images.report_to_server()
550+
551+ self.assertItemsEqual(
552+ [image],
553+ json.loads(MAASClient.post.call_args[1]['images']))
554+
555+ def test_does_nothing_without_maas_url(self):
556+ self.set_api_credentials()
557+ self.patch(
558+ tftppath, 'list_boot_images',
559+ Mock(return_value=self.make_image_params()))
560+ boot_images.report_to_server()
561+ self.assertItemsEqual([], tftppath.list_boot_images.call_args_list)
562+
563+ def test_does_nothing_without_credentials(self):
564+ self.set_maas_url()
565+ self.patch(
566+ tftppath, 'list_boot_images',
567+ Mock(return_value=self.make_image_params()))
568+ boot_images.report_to_server()
569+ self.assertItemsEqual([], tftppath.list_boot_images.call_args_list)
570
571=== modified file 'src/provisioningserver/tests/test_tasks.py'
572--- src/provisioningserver/tests/test_tasks.py 2012-09-12 09:46:16 +0000
573+++ src/provisioningserver/tests/test_tasks.py 2012-09-14 11:06:18 +0000
574@@ -13,6 +13,7 @@
575 __all__ = []
576
577 from datetime import datetime
578+import json
579 import os
580 import random
581 from subprocess import (
582@@ -21,6 +22,8 @@
583 )
584
585 from apiclient.creds import convert_tuple_to_string
586+from apiclient.maas_client import MAASClient
587+from apiclient.testing.credentials import make_api_credentials
588 from celeryconfig import DHCP_CONFIG_FILE
589 from maastesting.celery import CeleryFixture
590 from maastesting.factory import factory
591@@ -49,6 +52,7 @@
592 )
593 from provisioningserver.enum import POWER_TYPE
594 from provisioningserver.power.poweraction import PowerActionFail
595+from provisioningserver.pxe import tftppath
596 from provisioningserver.tasks import (
597 add_new_dhcp_host_map,
598 Omshell,
599@@ -56,6 +60,7 @@
600 power_on,
601 refresh_secrets,
602 remove_dhcp_host_map,
603+ report_boot_images,
604 restart_dhcp_server,
605 rndc_command,
606 RNDC_COMMAND_MAX_RETRY,
607@@ -66,6 +71,7 @@
608 write_full_dns_config,
609 )
610 from provisioningserver.testing import network_infos
611+from provisioningserver.testing.config import ConfigFixture
612 from provisioningserver.testing.testcase import PservTestCase
613 from testresources import FixtureResource
614 from testtools.matchers import (
615@@ -103,11 +109,7 @@
616 self.assertEqual(maas_url, auth.get_recorded_maas_url())
617
618 def test_updates_api_credentials(self):
619- credentials = (
620- factory.make_name('key'),
621- factory.make_name('token'),
622- factory.make_name('secret'),
623- )
624+ credentials = make_api_credentials()
625 refresh_secrets(
626 api_credentials=convert_tuple_to_string(credentials))
627 self.assertEqual(credentials, auth.get_recorded_api_credentials())
628@@ -423,3 +425,33 @@
629 FileExists(),
630 FileExists(),
631 )))
632+
633+
634+class TestBootImagesTasks(PservTestCase):
635+
636+ resources = (
637+ ("celery", FixtureResource(CeleryFixture())),
638+ )
639+
640+ def make_image_params(self):
641+ """Create a dict of parameters describing a boot image."""
642+ return {
643+ 'architecture': factory.make_name('architecture'),
644+ 'subarchitecture': factory.make_name('subarchitecture'),
645+ 'release': factory.make_name('release'),
646+ 'purpose': factory.make_name('purpose'),
647+ }
648+
649+ def test_sends_boot_images_to_server(self):
650+ self.useFixture(ConfigFixture({'tftp': {'root': self.make_dir()}}))
651+ auth.record_maas_url('http://127.0.0.1/%s' % factory.make_name('path'))
652+ auth.record_api_credentials(':'.join(make_api_credentials()))
653+ image = self.make_image_params()
654+ self.patch(tftppath, 'list_boot_images', Mock(return_value=[image]))
655+ self.patch(MAASClient, 'post')
656+
657+ report_boot_images.delay()
658+
659+ self.assertItemsEqual(
660+ [image],
661+ json.loads(MAASClient.post.call_args[1]['images']))