Merge lp:~therve/landscape-client/sources-list-plugin into lp:~landscape/landscape-client/trunk

Proposed by Thomas Herve
Status: Merged
Approved by: Kevin McDermott
Approved revision: 335
Merged at revision: 324
Proposed branch: lp:~therve/landscape-client/sources-list-plugin
Merge into: lp:~landscape/landscape-client/trunk
Diff against target: 548 lines (+516/-2)
4 files modified
landscape/manager/aptsources.py (+155/-0)
landscape/manager/config.py (+1/-1)
landscape/manager/tests/test_aptsources.py (+359/-0)
landscape/manager/tests/test_config.py (+1/-1)
To merge this branch: bzr merge lp:~therve/landscape-client/sources-list-plugin
Reviewer Review Type Date Requested Status
Kevin McDermott (community) Approve
Free Ekanayaka (community) Approve
Review via email: mp+57481@code.launchpad.net

Description of the change

Here it is. I think it's a fairly simple branch. It imports the gpg keys, and manages the sources.list. Nothing too fancy here. It relies on the full key being available in the server, so it means probably connecting to keys.ubuntu.com in Landscape. I think it's better than expected the clients to be able to open that weird port.

There is also no invalidation of keys. If someone feels it's important we can try to do it in a later branch (I think it implies creating a table to keep track of the added keys, so it's a little bit more involved).

To post a comment you must log in.
329. By Thomas Herve

Add the plugin in all plugin list

330. By Thomas Herve

Remove temporary files

Revision history for this message
Free Ekanayaka (free.ekanayaka) wrote :
Download full text (3.2 KiB)

Nice work, nothing really blocking so feel free to address what you think is worth. +1

[1]

+ deferred.addCallback(self._handle_process_error)
+ deferred.addCallback(lambda ignore, path=path: os.unlink(path))

Probably not I huge deal, but I suspect that the key file is not removed if the process fails?

[2]

+ if not line.strip() or line.startswith("#"):

I think comments can start also after some blanks, so maybe:

+ if not line.strip() or line.strip().startswith("#"):

[3]

+class SourcesList(ManagerPlugin):

and

+ "repositories", self._wrap_handle_repositories)

It would be nice for the plugin and message name to match a bit more (that's often the cases for other plugins/messages). I'd suggest "class AptSources" and "apt-sources-replace" or something. The name "reposiories" sounds a bit generic, in case we want to add other operations later. This not blocking though.

[4]

+class ProcessError(Exception):
+ """Exception raised when running a process fails."""

and

+ def run_process(self, command, args):

This seems generic enough that it could be made a function under landscape.lib.process, like:

class ProcessError(Exception):
    """Exception raised when running a process fails."""

    def __init__(self, out, err):
        super(ProcessError, self).__init__("%s\n%s" % (out, err))

def run_process(self, command, args):
    """Run the process in an asynchronous fashion."""

    def handle_process_error(result):
        """
        Turn a failed process command (code != 0) to a C{ProcessError}.
        """
        out, err, code = result
        if code:
            raise ProcessError(out, err)
        else:
            return result

    def handle_process_failure(failure):
        """
        Turn a signaled process command to a C{ProcessError}.
        """
        out, err, signal = failure.value
        raise ProcessError(out, err)

    deferred = getProcessOutputAndValue(command, args)
    deferred.addCallback(handle_process_error)
    deferred.addErrback(handle_process_failure)
    return deferred

You could test run_process separately, and have the tests for SourcesList simply shows that ProcessError failures are handled correctly:

    def test_failed_import_no_changes(self):
        """
        If the C{apt-key} command failed for some reasons, the current
        repositories aren't changed.
        """

        def run_process(command, args):
            return fail(ProcessError("nok", "some error"))

        self.sourceslist.run_process = run_process

        sources = file(self.sourceslist.SOURCES_LIST, "w")
        sources.write("oki\n\ndoki\n#comment\n")
        sources.close()

        self.manager.dispatch_message(
            {"type": "repositories", "sources": [], "gpg-keys": ["key"],
             "operation-id": 1})

        self.assertEqual(
            "oki\n\ndoki\n#comment\n",
            file(self.sourceslist.SOURCES_LIST).read())

        service = self.broker_service
        self.assertMessages(service.message_store.get_pending_messages(),
                            [{"type": "operation-result",
                              'result-te...

Read more...

review: Approve
331. By Thomas Herve

Remove temporary files after failures

332. By Thomas Herve

Handle comments not starting the line

333. By Thomas Herve

Rename message type

334. By Thomas Herve

Rename plugin

Revision history for this message
Thomas Herve (therve) wrote :

Thanks a lot for the careful review Free. I've handled 1 to 3, but I'll postpone 4 until we have a usecase.

335. By Thomas Herve

Run the reporter at the end of the message handling

Revision history for this message
Kevin McDermott (bigkevmcd) wrote :

Ok, looks fine to me

+1

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file 'landscape/manager/aptsources.py'
2--- landscape/manager/aptsources.py 1970-01-01 00:00:00 +0000
3+++ landscape/manager/aptsources.py 2011-04-15 08:07:29 +0000
4@@ -0,0 +1,155 @@
5+import glob
6+import os
7+import tempfile
8+
9+from twisted.internet.defer import succeed
10+from twisted.internet.utils import getProcessOutputAndValue
11+
12+from landscape.manager.plugin import ManagerPlugin, SUCCEEDED, FAILED
13+from landscape.package.reporter import find_reporter_command
14+
15+
16+class ProcessError(Exception):
17+ """Exception raised when running a process fails."""
18+
19+
20+class AptSources(ManagerPlugin):
21+ """A plugin managing sources.list content."""
22+
23+ SOURCES_LIST = "/etc/apt/sources.list"
24+ SOURCES_LIST_D = "/etc/apt/sources.list.d"
25+
26+ def register(self, registry):
27+ super(AptSources, self).register(registry)
28+ registry.register_message(
29+ "apt-sources-replace", self._wrap_handle_repositories)
30+
31+ def run_process(self, command, args):
32+ """
33+ Run the process in an asynchronous fashion, to be overriden in tests.
34+ """
35+ return getProcessOutputAndValue(command, args)
36+
37+ def _wrap_handle_repositories(self, message):
38+ """
39+ Wrap C{_handle_repositories} to generate an activity result based on
40+ the returned value.
41+ """
42+ deferred = self._handle_repositories(message)
43+
44+ operation_result = {"type": "operation-result",
45+ "operation-id": message["operation-id"]}
46+
47+ def success(ignored):
48+ operation_result["status"] = SUCCEEDED
49+ return operation_result
50+
51+ def fail(failure):
52+ operation_result["status"] = FAILED
53+ text = "%s: %s" % (failure.type.__name__, failure.value)
54+ operation_result["result-text"] = text
55+ return operation_result
56+
57+ deferred.addCallbacks(success, fail)
58+ deferred.addBoth(lambda result:
59+ self.manager.broker.send_message(result, urgent=True))
60+
61+ def _handle_process_error(self, result):
62+ """
63+ Turn a failed process command (code != 0) to a C{ProcessError}.
64+ """
65+ out, err, code = result
66+ if code:
67+ raise ProcessError("%s\n%s" % (out, err))
68+
69+ def _handle_process_failure(self, failure):
70+ """
71+ Turn a signaled process command to a C{ProcessError}.
72+ """
73+ if not failure.check(ProcessError):
74+ out, err, signal = failure.value
75+ raise ProcessError("%s\n%s" % (out, err))
76+ else:
77+ return failure
78+
79+ def _remove_and_continue(self, passthrough, path):
80+ """
81+ Remove the temporary file created for the process, and forward the
82+ result.
83+ """
84+ os.unlink(path)
85+ return passthrough
86+
87+ def _handle_repositories(self, message):
88+ """
89+ Handle a list of repositories to set on the machine.
90+
91+ The format is the following:
92+
93+ {"sources": [
94+ {"name": "repository-name",
95+ "content":
96+ "deb http://archive.ubuntu.com/ubuntu/ maverick main\n\
97+ "deb-src http://archive.ubuntu.com/ubuntu/ maverick main"}
98+ {"name": "repository-name-dev",
99+ "content":
100+ "deb http://archive.ubuntu.com/ubuntu/ maverick universe\n\
101+ "deb-src http://archive.ubuntu.com/ubuntu/ maverick universe"}],
102+ "gpg-keys": ["-----BEGIN PGP PUBLIC KEY BLOCK-----\n\
103+ XXXX
104+ -----END PGP PUBLIC KEY BLOCK-----",
105+ "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\
106+ YYY
107+ -----END PGP PUBLIC KEY BLOCK-----"]}
108+ """
109+ deferred = succeed(None)
110+ for key in message["gpg-keys"]:
111+ fd, path = tempfile.mkstemp()
112+ os.close(fd)
113+ key_file = file(path, "w")
114+ key_file.write(key)
115+ key_file.close()
116+ deferred.addCallback(
117+ lambda ignore, path=path:
118+ self.run_process("/usr/bin/apt-key", ["add", path]))
119+ deferred.addCallback(self._handle_process_error)
120+ deferred.addBoth(self._remove_and_continue, path)
121+ deferred.addErrback(self._handle_process_failure)
122+ return deferred.addCallback(
123+ self._handle_sources, message["sources"])
124+
125+ def _handle_sources(self, ignored, sources):
126+ """Handle sources repositories."""
127+ fd, path = tempfile.mkstemp()
128+ os.close(fd)
129+ new_sources = file(path, "w")
130+ for line in file(self.SOURCES_LIST):
131+ stripped_line = line.strip()
132+ if not stripped_line or stripped_line.startswith("#"):
133+ new_sources.write(line)
134+ else:
135+ new_sources.write("#%s" % line)
136+ new_sources.close()
137+ os.rename(path, self.SOURCES_LIST)
138+
139+ for filename in glob.glob(os.path.join(self.SOURCES_LIST_D, "*.list")):
140+ os.rename(filename, "%s.save" % filename)
141+
142+ for source in sources:
143+ filename = os.path.join(self.SOURCES_LIST_D,
144+ "landscape-%s.list" % source["name"])
145+ sources_file = file(filename, "w")
146+ sources_file.write(source["content"])
147+ sources_file.close()
148+ return self._run_reporter()
149+
150+ def _run_reporter(self):
151+ """Once the repositories are modified, trigger a reporter run."""
152+ reporter = find_reporter_command()
153+
154+ # Force a smart-update run, because the sources.list has changed
155+ args = ["--force-smart-update"]
156+
157+ if self.registry.config.config is not None:
158+ args.append("--config=%s" % self.registry.config.config)
159+ return self.run_process(reporter, args)
160
161=== modified file 'landscape/manager/config.py'
162--- landscape/manager/config.py 2010-08-06 17:57:42 +0000
163+++ landscape/manager/config.py 2011-04-15 08:07:29 +0000
164@@ -5,7 +5,7 @@
165
166
167 ALL_PLUGINS = ["ProcessKiller", "PackageManager", "UserManager",
168- "ShutdownManager", "Eucalyptus"]
169+ "ShutdownManager", "Eucalyptus", "AptSources"]
170
171
172 class ManagerConfiguration(Configuration):
173
174=== added file 'landscape/manager/tests/test_aptsources.py'
175--- landscape/manager/tests/test_aptsources.py 1970-01-01 00:00:00 +0000
176+++ landscape/manager/tests/test_aptsources.py 2011-04-15 08:07:29 +0000
177@@ -0,0 +1,359 @@
178+import os
179+
180+from twisted.internet.defer import Deferred
181+
182+from landscape.manager.aptsources import AptSources
183+from landscape.manager.plugin import SUCCEEDED, FAILED
184+
185+from landscape.lib.twisted_util import gather_results
186+from landscape.tests.helpers import LandscapeTest, ManagerHelper
187+from landscape.package.reporter import find_reporter_command
188+
189+
190+class AptSourcesTests(LandscapeTest):
191+ helpers = [ManagerHelper]
192+
193+ def setUp(self):
194+ super(AptSourcesTests, self).setUp()
195+ self.sourceslist = AptSources()
196+ self.sources_path = self.makeDir()
197+ self.sourceslist.SOURCES_LIST = os.path.join(self.sources_path,
198+ "sources.list")
199+ sources_d = os.path.join(self.sources_path, "sources.list.d")
200+ os.mkdir(sources_d)
201+ self.sourceslist.SOURCES_LIST_D = sources_d
202+ self.manager.add(self.sourceslist)
203+
204+ sources = file(self.sourceslist.SOURCES_LIST, "w")
205+ sources.write("\n")
206+ sources.close()
207+
208+ service = self.broker_service
209+ service.message_store.set_accepted_types(["operation-result"])
210+
211+ self.sourceslist.run_process = lambda cmd, args: None
212+
213+ def test_comment_sources_list(self):
214+ """
215+ When getting a repository message, L{AptSources} comments the whole
216+ sources.list file.
217+ """
218+ sources = file(self.sourceslist.SOURCES_LIST, "w")
219+ sources.write("oki\n\ndoki\n#comment\n # other comment\n")
220+ sources.close()
221+
222+ self.manager.dispatch_message(
223+ {"type": "apt-sources-replace", "sources": [], "gpg-keys": [],
224+ "operation-id": 1})
225+
226+ self.assertEqual(
227+ "#oki\n\n#doki\n#comment\n # other comment\n",
228+ file(self.sourceslist.SOURCES_LIST).read())
229+
230+ service = self.broker_service
231+ self.assertMessages(service.message_store.get_pending_messages(),
232+ [{"type": "operation-result",
233+ "status": SUCCEEDED, "operation-id": 1}])
234+
235+ def test_random_failures(self):
236+ """
237+ If a failure happens during the manipulation of sources, the activity
238+ is reported as FAILED with the error message.
239+ """
240+ self.sourceslist.SOURCES_LIST = "/doesntexist"
241+
242+ self.manager.dispatch_message(
243+ {"type": "apt-sources-replace", "sources": [], "gpg-keys": [],
244+ "operation-id": 1})
245+
246+ msg = "IOError: [Errno 2] No such file or directory: '/doesntexist'"
247+ service = self.broker_service
248+ self.assertMessages(service.message_store.get_pending_messages(),
249+ [{"type": "operation-result",
250+ "result-text": msg, "status": FAILED,
251+ "operation-id": 1}])
252+
253+ def test_rename_sources_list_d(self):
254+ """
255+ The sources files in sources.list.d are renamed to .save when a message
256+ is received.
257+ """
258+ sources1 = file(
259+ os.path.join(self.sourceslist.SOURCES_LIST_D, "file1.list"), "w")
260+ sources1.write("ok\n")
261+ sources1.close()
262+
263+ sources2 = file(
264+ os.path.join(self.sourceslist.SOURCES_LIST_D,
265+ "file2.list.save"), "w")
266+ sources2.write("ok\n")
267+ sources2.close()
268+
269+ self.manager.dispatch_message(
270+ {"type": "apt-sources-replace", "sources": [], "gpg-keys": [],
271+ "operation-id": 1})
272+
273+ self.assertFalse(
274+ os.path.exists(
275+ os.path.join(self.sourceslist.SOURCES_LIST_D, "file1.list")))
276+
277+ self.assertTrue(
278+ os.path.exists(
279+ os.path.join(self.sourceslist.SOURCES_LIST_D,
280+ "file1.list.save")))
281+
282+ self.assertTrue(
283+ os.path.exists(
284+ os.path.join(self.sourceslist.SOURCES_LIST_D,
285+ "file2.list.save")))
286+
287+ def test_create_landscape_sources(self):
288+ """
289+ For every sources listed in the sources field of the message,
290+ C{AptSources} creates a file with the content in sources.list.d.
291+ """
292+ sources = [{"name": "dev", "content": "oki\n"},
293+ {"name": "lucid", "content": "doki\n"}]
294+ self.manager.dispatch_message(
295+ {"type": "apt-sources-replace", "sources": sources, "gpg-keys": [],
296+ "operation-id": 1})
297+
298+ dev_file = os.path.join(self.sourceslist.SOURCES_LIST_D,
299+ "landscape-dev.list")
300+ self.assertTrue(os.path.exists(dev_file))
301+ self.assertEqual("oki\n", file(dev_file).read())
302+
303+ lucid_file = os.path.join(self.sourceslist.SOURCES_LIST_D,
304+ "landscape-lucid.list")
305+ self.assertTrue(os.path.exists(lucid_file))
306+ self.assertEqual("doki\n", file(lucid_file).read())
307+
308+ def test_import_gpg_keys(self):
309+ """
310+ C{AptSources} runs a process with apt-key for every keys in the
311+ message.
312+ """
313+ deferred = Deferred()
314+
315+ def run_process(command, args):
316+ self.assertEqual("/usr/bin/apt-key", command)
317+ self.assertEqual("add", args[0])
318+ filename = args[1]
319+ self.assertEqual("Some key content", file(filename).read())
320+ deferred.callback(("ok", "", 0))
321+ return deferred
322+
323+ self.sourceslist.run_process = run_process
324+
325+ self.manager.dispatch_message(
326+ {"type": "apt-sources-replace", "sources": [],
327+ "gpg-keys": ["Some key content"], "operation-id": 1})
328+
329+ return deferred
330+
331+ def test_import_delete_temporary_files(self):
332+ """
333+ The files created to be imported by C{apt-key} are removed after the
334+ import.
335+ """
336+ deferred = Deferred()
337+ filenames = []
338+
339+ def run_process(command, args):
340+ if not filenames:
341+ filenames.append(args[1])
342+ deferred.callback(("ok", "", 0))
343+ return deferred
344+
345+ self.sourceslist.run_process = run_process
346+
347+ self.manager.dispatch_message(
348+ {"type": "apt-sources-replace", "sources": [],
349+ "gpg-keys": ["Some key content"], "operation-id": 1})
350+
351+ self.assertFalse(os.path.exists(filenames[0]))
352+
353+ return deferred
354+
355+ def test_failed_import_delete_temporary_files(self):
356+ """
357+ The files created to be imported by C{apt-key} are removed after the
358+ import, even if there is a failure.
359+ """
360+ deferred = Deferred()
361+ filenames = []
362+
363+ def run_process(command, args):
364+ filenames.append(args[1])
365+ deferred.callback(("error", "", 1))
366+ return deferred
367+
368+ self.sourceslist.run_process = run_process
369+
370+ self.manager.dispatch_message(
371+ {"type": "apt-sources-replace", "sources": [],
372+ "gpg-keys": ["Some key content"], "operation-id": 1})
373+
374+ self.assertFalse(os.path.exists(filenames[0]))
375+
376+ return deferred
377+
378+ def test_failed_import_reported(self):
379+ """
380+ If the C{apt-key} command failed for some reasons, the output of the
381+ command is reported and the activity fails.
382+ """
383+ deferred = Deferred()
384+
385+ def run_process(command, args):
386+ deferred.callback(("nok", "some error", 1))
387+ return deferred
388+
389+ self.sourceslist.run_process = run_process
390+
391+ self.manager.dispatch_message(
392+ {"type": "apt-sources-replace", "sources": [], "gpg-keys": ["key"],
393+ "operation-id": 1})
394+
395+ service = self.broker_service
396+ msg = "ProcessError: nok\nsome error"
397+ self.assertMessages(service.message_store.get_pending_messages(),
398+ [{"type": "operation-result",
399+ "result-text": msg, "status": FAILED,
400+ "operation-id": 1}])
401+ return deferred
402+
403+ def test_signaled_import_reported(self):
404+ """
405+ If the C{apt-key} fails with a signal, the output of the command is
406+ reported and the activity fails.
407+ """
408+ deferred = Deferred()
409+
410+ def run_process(command, args):
411+ deferred.errback(("nok", "some error", 1))
412+ return deferred
413+
414+ self.sourceslist.run_process = run_process
415+
416+ self.manager.dispatch_message(
417+ {"type": "apt-sources-replace", "sources": [], "gpg-keys": ["key"],
418+ "operation-id": 1})
419+
420+ service = self.broker_service
421+ msg = "ProcessError: nok\nsome error"
422+ self.assertMessages(service.message_store.get_pending_messages(),
423+ [{"type": "operation-result",
424+ "result-text": msg, "status": FAILED,
425+ "operation-id": 1}])
426+ return deferred
427+
428+ def test_failed_import_no_changes(self):
429+ """
430+ If the C{apt-key} command failed for some reasons, the current
431+ repositories aren't changed.
432+ """
433+ deferred = Deferred()
434+
435+ def run_process(command, args):
436+ deferred.callback(("nok", "some error", 1))
437+ return deferred
438+
439+ self.sourceslist.run_process = run_process
440+
441+ sources = file(self.sourceslist.SOURCES_LIST, "w")
442+ sources.write("oki\n\ndoki\n#comment\n")
443+ sources.close()
444+
445+ self.manager.dispatch_message(
446+ {"type": "apt-sources-replace", "sources": [], "gpg-keys": ["key"],
447+ "operation-id": 1})
448+
449+ self.assertEqual(
450+ "oki\n\ndoki\n#comment\n",
451+ file(self.sourceslist.SOURCES_LIST).read())
452+
453+ return deferred
454+
455+ def test_multiple_import_sequential(self):
456+ """
457+ If multiple keys are specified, the imports run sequentially, not in
458+ parallel.
459+ """
460+ deferred1 = Deferred()
461+ deferred2 = Deferred()
462+ deferreds = [deferred1, deferred2]
463+
464+ def run_process(command, args):
465+ if not deferreds:
466+ return None
467+ return deferreds.pop(0)
468+
469+ self.sourceslist.run_process = run_process
470+
471+ self.manager.dispatch_message(
472+ {"type": "apt-sources-replace", "sources": [],
473+ "gpg-keys": ["key1", "key2"], "operation-id": 1})
474+
475+ self.assertEqual(1, len(deferreds))
476+ deferred1.callback(("ok", "", 0))
477+
478+ self.assertEqual(0, len(deferreds))
479+ deferred2.callback(("ok", "", 0))
480+
481+ service = self.broker_service
482+ self.assertMessages(service.message_store.get_pending_messages(),
483+ [{"type": "operation-result",
484+ "status": SUCCEEDED, "operation-id": 1}])
485+ return gather_results(deferreds)
486+
487+ def test_multiple_import_failure(self):
488+ """
489+ If multiple keys are specified, and that the first one fails, the error
490+ is correctly reported.
491+ """
492+ deferred1 = Deferred()
493+ deferred2 = Deferred()
494+ deferreds = [deferred1, deferred2]
495+
496+ def run_process(command, args):
497+ return deferreds.pop(0)
498+
499+ self.sourceslist.run_process = run_process
500+
501+ self.manager.dispatch_message(
502+ {"type": "apt-sources-replace", "sources": [],
503+ "gpg-keys": ["key1", "key2"], "operation-id": 1})
504+
505+ deferred1.callback(("error", "", 1))
506+ deferred2.callback(("error", "", 1))
507+
508+ msg = "ProcessError: error\n"
509+ service = self.broker_service
510+ self.assertMessages(service.message_store.get_pending_messages(),
511+ [{"type": "operation-result",
512+ "result-text": msg, "status": FAILED,
513+ "operation-id": 1}])
514+ return gather_results(deferreds)
515+
516+ def test_run_reporter(self):
517+ """
518+ After receiving a message, L{AptSources} triggers a reporter run to
519+ have the new packages reported to the server.
520+ """
521+ deferred = Deferred()
522+
523+ def run_process(command, args):
524+ self.assertEqual(find_reporter_command(), command)
525+ self.assertEqual(["--force-smart-update", "--config=%s" %
526+ self.manager.config.config], args)
527+ deferred.callback(("ok", "", 0))
528+ return deferred
529+
530+ self.sourceslist.run_process = run_process
531+
532+ self.manager.dispatch_message(
533+ {"type": "apt-sources-replace", "sources": [], "gpg-keys": [],
534+ "operation-id": 1})
535+
536+ return deferred
537
538=== modified file 'landscape/manager/tests/test_config.py'
539--- landscape/manager/tests/test_config.py 2010-08-06 17:57:42 +0000
540+++ landscape/manager/tests/test_config.py 2011-04-15 08:07:29 +0000
541@@ -12,7 +12,7 @@
542 def test_plugin_factories(self):
543 """By default all plugins are enabled."""
544 self.assertEqual(["ProcessKiller", "PackageManager", "UserManager",
545- "ShutdownManager", "Eucalyptus"],
546+ "ShutdownManager", "Eucalyptus", "AptSources"],
547 ALL_PLUGINS)
548 self.assertEqual(ALL_PLUGINS, self.config.plugin_factories)
549

Subscribers

People subscribed via source and target branches

to all changes: