Merge lp:~cjwatson/launchpad/refactor-run-parts-subprocess into lp:launchpad

Proposed by Colin Watson
Status: Merged
Merged at revision: 18552
Proposed branch: lp:~cjwatson/launchpad/refactor-run-parts-subprocess
Merge into: lp:launchpad
Diff against target: 723 lines (+257/-289)
4 files modified
lib/lp/archivepublisher/run_parts.py (+87/-0)
lib/lp/archivepublisher/scripts/publish_ftpmaster.py (+21/-116)
lib/lp/archivepublisher/tests/test_publish_ftpmaster.py (+36/-173)
lib/lp/archivepublisher/tests/test_run_parts.py (+113/-0)
To merge this branch: bzr merge lp:~cjwatson/launchpad/refactor-run-parts-subprocess
Reviewer Review Type Date Requested Status
William Grant code Approve
Review via email: mp+336298@code.launchpad.net

Commit message

Refactor publish-ftpmaster to use subprocess.call rather than os.system.

Description of the change

As usual, avoiding unnecessary use of the shell makes things safer and simpler.

To post a comment you must log in.
Revision history for this message
William Grant (wgrant) :
review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file 'lib/lp/archivepublisher/run_parts.py'
2--- lib/lp/archivepublisher/run_parts.py 1970-01-01 00:00:00 +0000
3+++ lib/lp/archivepublisher/run_parts.py 2018-01-18 15:33:21 +0000
4@@ -0,0 +1,87 @@
5+# Copyright 2011-2018 Canonical Ltd. This software is licensed under the
6+# GNU Affero General Public License version 3 (see the file LICENSE).
7+
8+"""Publisher support for running programs from a plug-in directory."""
9+
10+from __future__ import absolute_import, print_function, unicode_literals
11+
12+__metaclass__ = type
13+__all__ = [
14+ 'execute_subprocess',
15+ 'find_run_parts_dir',
16+ 'run_parts',
17+ ]
18+
19+import os
20+try:
21+ from shlex import quote as shell_quote
22+except ImportError:
23+ from pipes import quote as shell_quote
24+import subprocess
25+
26+from lp.services.config import config
27+from lp.services.scripts.base import LaunchpadScriptFailure
28+from lp.services.utils import file_exists
29+
30+
31+def find_run_parts_dir(distribution_name, parts):
32+ """Find the requested run-parts directory, if it exists."""
33+ run_parts_location = config.archivepublisher.run_parts_location
34+ if not run_parts_location:
35+ return None
36+
37+ parts_dir = os.path.join(run_parts_location, distribution_name, parts)
38+ if file_exists(parts_dir):
39+ return parts_dir
40+ else:
41+ return None
42+
43+
44+def execute_subprocess(args, log=None, failure=None, **kwargs):
45+ """Run `args`, handling logging and failures.
46+
47+ :param args: Program argument array.
48+ :param log: An optional logger.
49+ :param failure: Raise `failure` as an exception if the command returns a
50+ nonzero value. If omitted, nonzero return values are ignored.
51+ :param kwargs: Other keyword arguments passed on to `subprocess.call`.
52+ """
53+ if log is not None:
54+ log.debug("Executing: %s", " ".join(shell_quote(arg) for arg in args))
55+ retval = subprocess.call(args, **kwargs)
56+ if retval != 0:
57+ if log is not None:
58+ log.debug("Command returned %d.", retval)
59+ if failure is not None:
60+ if log is not None:
61+ log.debug("Command failed: %s", failure)
62+ raise failure
63+
64+
65+def run_parts(distribution_name, parts, log=None, env=None):
66+ """Execute run-parts.
67+
68+ :param distribution_name: The name of the distribution to execute
69+ run-parts scripts for.
70+ :param parts: The run-parts directory to execute:
71+ "publish-distro.d" or "finalize.d".
72+ :param log: An optional logger.
73+ :param env: A dict of additional environment variables to pass to the
74+ scripts in the run-parts directory, or None.
75+ """
76+ parts_dir = find_run_parts_dir(distribution_name, parts)
77+ if parts_dir is None:
78+ if log is not None:
79+ log.debug("Skipping run-parts %s: not configured.", parts)
80+ return
81+ cmd = ["run-parts", "--", parts_dir]
82+ failure = LaunchpadScriptFailure(
83+ "Failure while executing run-parts %s." % parts_dir)
84+ full_env = dict(os.environ)
85+ if env is not None:
86+ full_env.update(env)
87+ scripts_dir = os.path.join(config.root, "cronscripts", "publishing")
88+ path_elements = full_env.get("PATH", "").split(os.pathsep)
89+ path_elements.append(scripts_dir)
90+ full_env["PATH"] = os.pathsep.join(path_elements)
91+ execute_subprocess(cmd, log=None, failure=failure, env=full_env)
92
93=== modified file 'lib/lp/archivepublisher/scripts/publish_ftpmaster.py'
94--- lib/lp/archivepublisher/scripts/publish_ftpmaster.py 2016-12-05 22:16:25 +0000
95+++ lib/lp/archivepublisher/scripts/publish_ftpmaster.py 2018-01-18 15:33:21 +0000
96@@ -1,4 +1,4 @@
97-# Copyright 2011-2016 Canonical Ltd. This software is licensed under the
98+# Copyright 2011-2018 Canonical Ltd. This software is licensed under the
99 # GNU Affero General Public License version 3 (see the file LICENSE).
100
101 """Master distro publishing script."""
102@@ -22,6 +22,10 @@
103 cannot_modify_suite,
104 GLOBAL_PUBLISHER_LOCK,
105 )
106+from lp.archivepublisher.run_parts import (
107+ execute_subprocess,
108+ run_parts,
109+ )
110 from lp.archivepublisher.scripts.processaccepted import ProcessAccepted
111 from lp.archivepublisher.scripts.publishdistro import PublishDistro
112 from lp.registry.interfaces.distribution import IDistributionSet
113@@ -30,7 +34,6 @@
114 pocketsuffix,
115 )
116 from lp.registry.interfaces.series import SeriesStatus
117-from lp.services.config import config
118 from lp.services.database.bulk import load_related
119 from lp.services.osutils import ensure_directory_exists
120 from lp.services.scripts.base import (
121@@ -61,43 +64,6 @@
122 if archive.purpose in ARCHIVES_TO_PUBLISH]
123
124
125-def compose_shell_boolean(boolean_value):
126- """Represent a boolean value as "yes" or "no"."""
127- boolean_text = {
128- True: "yes",
129- False: "no",
130- }
131- return boolean_text[boolean_value]
132-
133-
134-def shell_quote(literal):
135- """Escape `literal` for use in a double-quoted shell string.
136-
137- This is a pretty naive substitution: it doesn't deal well with
138- non-ASCII characters or special characters.
139- """
140- # Characters that need backslash-escaping. Do the backslash itself
141- # first; any escapes we introduced *before* the backslash would have
142- # their own backslashes escaped afterwards when we got to the
143- # backslash itself.
144- special_characters = '\\"$`\n'
145- for escapee in special_characters:
146- literal = literal.replace(escapee, '\\' + escapee)
147- return '"%s"' % literal
148-
149-
150-def compose_env_string(*env_dicts):
151- """Turn dict(s) into a series of shell parameter assignments.
152-
153- Values in later dicts override any values with the same respective
154- keys in earlier dicts.
155- """
156- env = {}
157- for env_dict in env_dicts:
158- env.update(env_dict)
159- return ' '.join(['='.join(pair) for pair in env.iteritems()])
160-
161-
162 def get_backup_dists(archive_config):
163 """Return the path of an archive's backup dists directory."""
164 return os.path.join(archive_config.archiveroot + "-distscopy", "dists")
165@@ -122,31 +88,6 @@
166 return get_dists(archive_config) + ".in-progress"
167
168
169-def extend_PATH():
170- """Produce env dict for extending $PATH.
171-
172- Adds the Launchpad source tree's cronscripts/publishing to the
173- existing $PATH.
174-
175- :return: a dict suitable for passing to compose_env_string.
176- """
177- scripts_dir = os.path.join(config.root, "cronscripts", "publishing")
178- return {"PATH": '"$PATH":%s' % shell_quote(scripts_dir)}
179-
180-
181-def find_run_parts_dir(distro, parts):
182- """Find the requested run-parts directory, if it exists."""
183- run_parts_location = config.archivepublisher.run_parts_location
184- if not run_parts_location:
185- return
186-
187- parts_dir = os.path.join(run_parts_location, distro.name, parts)
188- if file_exists(parts_dir):
189- return parts_dir
190- else:
191- return None
192-
193-
194 def map_distro_pubconfigs(distro):
195 """Return dict mapping archive purpose for distro's pub configs.
196
197@@ -238,26 +179,6 @@
198 "Distribution %s not found." % self.options.distribution)
199 self.distributions = [distro]
200
201- def executeShell(self, command_line, failure=None):
202- """Run `command_line` through a shell.
203-
204- This won't just load an external program and run it; the command
205- line goes through the full shell treatment including variable
206- substitutions, output redirections, and so on.
207-
208- :param command_line: Shell command.
209- :param failure: Raise `failure` as an exception if the shell
210- command returns a nonzero value. If omitted, nonzero return
211- values are ignored.
212- """
213- self.logger.debug("Executing: %s" % command_line)
214- retval = os.system(command_line)
215- if retval != 0:
216- self.logger.debug("Command returned %d.", retval)
217- if failure is not None:
218- self.logger.debug("Command failed: %s", failure)
219- raise failure
220-
221 def getConfigs(self):
222 """Set up configuration objects for archives to be published.
223
224@@ -370,8 +291,9 @@
225 for purpose, archive_config in self.configs[distribution].iteritems():
226 dists = get_dists(archive_config)
227 backup_dists = get_backup_dists(archive_config)
228- self.executeShell(
229- "rsync -aH --delete '%s/' '%s'" % (dists, backup_dists),
230+ execute_subprocess(
231+ ["rsync", "-aH", "--delete", "%s/" % dists, backup_dists],
232+ log=self.logger,
233 failure=LaunchpadScriptFailure(
234 "Failed to rsync new dists for %s." % purpose.title))
235
236@@ -469,12 +391,13 @@
237 """Execute the publish-distro hooks."""
238 archive_config = self.configs[distribution][archive.purpose]
239 env = {
240- 'ARCHIVEROOT': shell_quote(archive_config.archiveroot),
241- 'DISTSROOT': shell_quote(get_backup_dists(archive_config)),
242+ 'ARCHIVEROOT': archive_config.archiveroot,
243+ 'DISTSROOT': get_backup_dists(archive_config),
244 }
245 if archive_config.overrideroot is not None:
246- env["OVERRIDEROOT"] = shell_quote(archive_config.overrideroot)
247- self.runParts(distribution, 'publish-distro.d', env)
248+ env["OVERRIDEROOT"] = archive_config.overrideroot
249+ run_parts(
250+ distribution.name, 'publish-distro.d', log=self.logger, env=env)
251
252 def installDists(self, distribution):
253 """Put the new dists into place, as near-atomically as possible.
254@@ -499,40 +422,22 @@
255 def clearEmptyDirs(self, distribution):
256 """Clear out any redundant empty directories."""
257 for archive_config in self.configs[distribution].itervalues():
258- self.executeShell(
259- "find '%s' -type d -empty | xargs -r rmdir"
260- % archive_config.archiveroot)
261-
262- def runParts(self, distribution, parts, env):
263- """Execute run-parts.
264-
265- :param distribution: `Distribution` to execute run-parts scripts for.
266- :param parts: The run-parts directory to execute:
267- "publish-distro.d" or "finalize.d".
268- :param env: A dict of environment variables to pass to the
269- scripts in the run-parts directory.
270- """
271- parts_dir = find_run_parts_dir(distribution, parts)
272- if parts_dir is None:
273- self.logger.debug("Skipping run-parts %s: not configured.", parts)
274- return
275- env_string = compose_env_string(env, extend_PATH())
276- self.executeShell(
277- "env %s run-parts -- '%s'" % (env_string, parts_dir),
278- failure=LaunchpadScriptFailure(
279- "Failure while executing run-parts %s." % parts_dir))
280+ execute_subprocess(
281+ ["find", archive_config.archiveroot, "-type", "d", "-empty",
282+ "-delete"],
283+ log=self.logger)
284
285 def runFinalizeParts(self, distribution, security_only=False):
286 """Run the finalize.d parts to finalize publication."""
287- archive_roots = shell_quote(' '.join([
288+ archive_roots = ' '.join([
289 archive_config.archiveroot
290- for archive_config in self.configs[distribution].itervalues()]))
291+ for archive_config in self.configs[distribution].itervalues()])
292
293 env = {
294- 'SECURITY_UPLOAD_ONLY': compose_shell_boolean(security_only),
295+ 'SECURITY_UPLOAD_ONLY': 'yes' if security_only else 'no',
296 'ARCHIVEROOTS': archive_roots,
297 }
298- self.runParts(distribution, 'finalize.d', env)
299+ run_parts(distribution.name, 'finalize.d', log=self.logger, env=env)
300
301 def publishSecurityUploads(self, distribution):
302 """Quickly process just the pending security uploads.
303
304=== modified file 'lib/lp/archivepublisher/tests/test_publish_ftpmaster.py'
305--- lib/lp/archivepublisher/tests/test_publish_ftpmaster.py 2016-12-05 22:16:25 +0000
306+++ lib/lp/archivepublisher/tests/test_publish_ftpmaster.py 2018-01-18 15:33:21 +0000
307@@ -1,4 +1,4 @@
308-# Copyright 2011-2016 Canonical Ltd. This software is licensed under the
309+# Copyright 2011-2018 Canonical Ltd. This software is licensed under the
310 # GNU Affero General Public License version 3 (see the file LICENSE).
311
312 """Test publish-ftpmaster cron script."""
313@@ -13,6 +13,8 @@
314 from apt_pkg import TagFile
315 from fixtures import MonkeyPatch
316 from testtools.matchers import (
317+ ContainsDict,
318+ Equals,
319 MatchesException,
320 MatchesStructure,
321 Not,
322@@ -26,20 +28,16 @@
323 from lp.archivepublisher.config import getPubConfig
324 from lp.archivepublisher.interfaces.publisherconfig import IPublisherConfigSet
325 from lp.archivepublisher.scripts.publish_ftpmaster import (
326- compose_env_string,
327- compose_shell_boolean,
328- find_run_parts_dir,
329 get_working_dists,
330 newer_mtime,
331 PublishFTPMaster,
332- shell_quote,
333 )
334+from lp.archivepublisher.tests.test_run_parts import RunPartsMixin
335 from lp.registry.interfaces.pocket import (
336 PackagePublishingPocket,
337 pocketsuffix,
338 )
339 from lp.registry.interfaces.series import SeriesStatus
340-from lp.services.config import config
341 from lp.services.database.interfaces import IMasterStore
342 from lp.services.log.logger import (
343 BufferLogger,
344@@ -60,10 +58,7 @@
345 TestCaseWithFactory,
346 )
347 from lp.testing.fakemethod import FakeMethod
348-from lp.testing.layers import (
349- LaunchpadZopelessLayer,
350- ZopelessDatabaseLayer,
351- )
352+from lp.testing.layers import LaunchpadZopelessLayer
353
354
355 def path_exists(*path_components):
356@@ -133,26 +128,6 @@
357 class HelpersMixin:
358 """Helpers for the PublishFTPMaster tests."""
359
360- def enableRunParts(self, parts_directory=None):
361- """Set up for run-parts execution.
362-
363- :param parts_directory: Base location for the run-parts directories.
364- If omitted, a temporary directory will be used.
365- """
366- if parts_directory is None:
367- parts_directory = self.makeTemporaryDirectory()
368- os.makedirs(os.path.join(
369- parts_directory, "ubuntu", "publish-distro.d"))
370- os.makedirs(os.path.join(parts_directory, "ubuntu", "finalize.d"))
371- self.parts_directory = parts_directory
372-
373- config.push("run-parts", dedent("""\
374- [archivepublisher]
375- run_parts_location: %s
376- """ % parts_directory))
377-
378- self.addCleanup(config.pop, "run-parts")
379-
380 def makeDistroWithPublishDirectory(self):
381 """Create a `Distribution` for testing.
382
383@@ -178,44 +153,6 @@
384 self.makeTemporaryDirectory())
385
386
387-class TestPublishFTPMasterHelpers(TestCase):
388-
389- def test_compose_env_string_iterates_env_dict(self):
390- env = {
391- "A": "1",
392- "B": "2",
393- }
394- env_string = compose_env_string(env)
395- self.assertIn(env_string, ["A=1 B=2", "B=2 A=1"])
396-
397- def test_compose_env_string_combines_env_dicts(self):
398- env1 = {"A": "1"}
399- env2 = {"B": "2"}
400- env_string = compose_env_string(env1, env2)
401- self.assertIn(env_string, ["A=1 B=2", "B=2 A=1"])
402-
403- def test_compose_env_string_overrides_repeated_keys(self):
404- self.assertEqual("A=2", compose_env_string({"A": "1"}, {"A": "2"}))
405-
406- def test_shell_quote_quotes_string(self):
407- self.assertEqual('"x"', shell_quote("x"))
408-
409- def test_shell_quote_escapes_string(self):
410- self.assertEqual('"\\\\"', shell_quote("\\"))
411-
412- def test_shell_quote_does_not_escape_its_own_escapes(self):
413- self.assertEqual('"\\$"', shell_quote("$"))
414-
415- def test_shell_quote_escapes_entire_string(self):
416- self.assertEqual('"\\$\\$\\$"', shell_quote("$$$"))
417-
418- def test_compose_shell_boolean_shows_True_as_yes(self):
419- self.assertEqual("yes", compose_shell_boolean(True))
420-
421- def test_compose_shell_boolean_shows_False_as_no(self):
422- self.assertEqual("no", compose_shell_boolean(False))
423-
424-
425 class TestNewerMtime(TestCase):
426
427 def setUp(self):
428@@ -256,34 +193,8 @@
429 self.assertTrue(newer_mtime(self.a, self.b))
430
431
432-class TestFindRunPartsDir(TestCaseWithFactory, HelpersMixin):
433- layer = ZopelessDatabaseLayer
434-
435- def test_find_run_parts_dir_finds_runparts_directory(self):
436- self.enableRunParts()
437- ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
438- self.assertEqual(
439- os.path.join(
440- config.root, self.parts_directory, "ubuntu", "finalize.d"),
441- find_run_parts_dir(ubuntu, "finalize.d"))
442-
443- def test_find_run_parts_dir_ignores_blank_config(self):
444- self.enableRunParts("")
445- ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
446- self.assertIs(None, find_run_parts_dir(ubuntu, "finalize.d"))
447-
448- def test_find_run_parts_dir_ignores_none_config(self):
449- self.enableRunParts("none")
450- ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
451- self.assertIs(None, find_run_parts_dir(ubuntu, "finalize.d"))
452-
453- def test_find_run_parts_dir_ignores_nonexistent_directory(self):
454- self.enableRunParts()
455- distro = self.factory.makeDistribution()
456- self.assertIs(None, find_run_parts_dir(distro, "finalize.d"))
457-
458-
459-class TestPublishFTPMasterScript(TestCaseWithFactory, HelpersMixin):
460+class TestPublishFTPMasterScript(
461+ TestCaseWithFactory, RunPartsMixin, HelpersMixin):
462 layer = LaunchpadZopelessLayer
463
464 # Location of shell script.
465@@ -552,12 +463,14 @@
466 script = self.makeScript(distro)
467 script.setUp()
468 script.setUpDirs()
469- script.runParts = FakeMethod()
470+ run_parts_fixture = self.useFixture(MonkeyPatch(
471+ "lp.archivepublisher.scripts.publish_ftpmaster.run_parts",
472+ FakeMethod()))
473 script.publishDistroArchive(distro, distro.main_archive)
474- self.assertEqual(1, script.runParts.call_count)
475- args, kwargs = script.runParts.calls[0]
476- run_distro, parts_dir, env = args
477- self.assertEqual(distro, run_distro)
478+ self.assertEqual(1, run_parts_fixture.new_value.call_count)
479+ args, _ = run_parts_fixture.new_value.calls[0]
480+ run_distro_name, parts_dir = args
481+ self.assertEqual(distro.name, run_distro_name)
482 self.assertEqual("publish-distro.d", parts_dir)
483
484 def test_runPublishDistroParts_passes_parameters(self):
485@@ -565,14 +478,19 @@
486 script = self.makeScript(distro)
487 script.setUp()
488 script.setUpDirs()
489- script.runParts = FakeMethod()
490+ run_parts_fixture = self.useFixture(MonkeyPatch(
491+ "lp.archivepublisher.scripts.publish_ftpmaster.run_parts",
492+ FakeMethod()))
493 script.runPublishDistroParts(distro, distro.main_archive)
494- args, kwargs = script.runParts.calls[0]
495- run_distro, parts_dir, env = args
496- required_parameters = set([
497- "ARCHIVEROOT", "DISTSROOT", "OVERRIDEROOT"])
498- missing_parameters = required_parameters.difference(set(env.keys()))
499- self.assertEqual(set(), missing_parameters)
500+ _, kwargs = run_parts_fixture.new_value.calls[0]
501+ distro_config = get_pub_config(distro)
502+ self.assertThat(kwargs["env"], ContainsDict({
503+ "ARCHIVEROOT": Equals(get_archive_root(distro_config)),
504+ "DISTSROOT": Equals(
505+ os.path.join(get_distscopy_root(distro_config), "dists")),
506+ "OVERRIDEROOT": Equals(
507+ get_archive_root(distro_config) + "-overrides"),
508+ }))
509
510 def test_clearEmptyDirs_cleans_up_empty_directories(self):
511 distro = self.makeDistroWithPublishDirectory()
512@@ -621,67 +539,16 @@
513 script.options.distribution = self.factory.getUniqueString()
514 self.assertRaises(LaunchpadScriptFailure, script.processOptions)
515
516- def test_runParts_runs_parts(self):
517- self.enableRunParts()
518- script = self.makeScript(self.prepareUbuntu())
519- script.setUp()
520- distro = script.distributions[0]
521- script.executeShell = FakeMethod()
522- script.runParts(distro, "finalize.d", {})
523- self.assertEqual(1, script.executeShell.call_count)
524- args, kwargs = script.executeShell.calls[-1]
525- command_line, = args
526- self.assertIn("run-parts", command_line)
527- self.assertIn(
528- os.path.join(self.parts_directory, "ubuntu/finalize.d"),
529- command_line)
530-
531- def test_runParts_passes_parameters(self):
532- self.enableRunParts()
533- script = self.makeScript(self.prepareUbuntu())
534- script.setUp()
535- distro = script.distributions[0]
536- script.executeShell = FakeMethod()
537- key = self.factory.getUniqueString()
538- value = self.factory.getUniqueString()
539- script.runParts(distro, "finalize.d", {key: value})
540- args, kwargs = script.executeShell.calls[-1]
541- command_line, = args
542- self.assertIn("%s=%s" % (key, value), command_line)
543-
544- def test_executeShell_executes_shell_command(self):
545- distro = self.makeDistroWithPublishDirectory()
546- script = self.makeScript(distro)
547- marker = os.path.join(
548- get_pub_config(distro).root_dir, "marker")
549- script.executeShell("touch %s" % marker)
550- self.assertTrue(file_exists(marker))
551-
552- def test_executeShell_reports_failure_if_requested(self):
553- distro = self.makeDistroWithPublishDirectory()
554- script = self.makeScript(distro)
555-
556- class ArbitraryFailure(Exception):
557- """Some exception that's not likely to come from elsewhere."""
558-
559- self.assertRaises(
560- ArbitraryFailure,
561- script.executeShell, "/bin/false", failure=ArbitraryFailure())
562-
563- def test_executeShell_does_not_report_failure_if_not_requested(self):
564- distro = self.makeDistroWithPublishDirectory()
565- script = self.makeScript(distro)
566- # The test is that this does not fail:
567- script.executeShell("/bin/false")
568-
569 def test_runFinalizeParts_passes_parameters(self):
570 script = self.makeScript(self.prepareUbuntu())
571 script.setUp()
572 distro = script.distributions[0]
573- script.runParts = FakeMethod()
574+ run_parts_fixture = self.useFixture(MonkeyPatch(
575+ "lp.archivepublisher.scripts.publish_ftpmaster.run_parts",
576+ FakeMethod()))
577 script.runFinalizeParts(distro)
578- args, kwargs = script.runParts.calls[0]
579- run_distro, parts_dir, env = args
580+ _, kwargs = run_parts_fixture.new_value.calls[0]
581+ env = kwargs["env"]
582 required_parameters = set(["ARCHIVEROOTS", "SECURITY_UPLOAD_ONLY"])
583 missing_parameters = required_parameters.difference(set(env.keys()))
584 self.assertEqual(set(), missing_parameters)
585@@ -798,15 +665,11 @@
586 self.assertContentEqual(
587 [distro.main_archive, partner_archive], published_archives)
588
589- def test_runFinalizeParts_quotes_archiveroots(self):
590- # Passing ARCHIVEROOTS to the finalize.d scripts is a bit
591- # difficult because the variable holds multiple values in a
592- # single, double-quoted string. Escaping and quoting a sequence
593- # of escaped and quoted items won't work.
594- # This test establishes how a script can sanely deal with the
595- # list. It'll probably go wrong if the configured archive root
596- # contains spaces and such, but should work with Unix-sensible
597- # paths.
598+ def test_runFinalizeParts_passes_archiveroots_correctly(self):
599+ # The ARCHIVEROOTS environment variable may contain spaces, and
600+ # these are passed through correctly. It'll go wrong if the
601+ # configured archive root contains whitespace, but works with
602+ # Unix-sensible paths.
603 distro = self.makeDistroWithPublishDirectory()
604 self.factory.makeArchive(
605 distribution=distro, purpose=ArchivePurpose.PARTNER)
606
607=== added file 'lib/lp/archivepublisher/tests/test_run_parts.py'
608--- lib/lp/archivepublisher/tests/test_run_parts.py 1970-01-01 00:00:00 +0000
609+++ lib/lp/archivepublisher/tests/test_run_parts.py 2018-01-18 15:33:21 +0000
610@@ -0,0 +1,113 @@
611+# Copyright 2011-2018 Canonical Ltd. This software is licensed under the
612+# GNU Affero General Public License version 3 (see the file LICENSE).
613+
614+"""Test publisher run-parts handling."""
615+
616+from __future__ import absolute_import, print_function, unicode_literals
617+
618+__metaclass__ = type
619+
620+import os.path
621+
622+from fixtures import MonkeyPatch
623+from testtools.matchers import (
624+ ContainsDict,
625+ Equals,
626+ FileExists,
627+ )
628+
629+from lp.archivepublisher.run_parts import (
630+ execute_subprocess,
631+ find_run_parts_dir,
632+ run_parts,
633+ )
634+from lp.services.config import config
635+from lp.services.log.logger import DevNullLogger
636+from lp.testing import TestCase
637+from lp.testing.fakemethod import FakeMethod
638+
639+
640+class RunPartsMixin:
641+ """Helper for run-parts tests."""
642+
643+ def enableRunParts(self, parts_directory=None):
644+ """Set up for run-parts execution.
645+
646+ :param parts_directory: Base location for the run-parts directories.
647+ If omitted, a temporary directory will be used.
648+ """
649+ if parts_directory is None:
650+ parts_directory = self.makeTemporaryDirectory()
651+ os.makedirs(os.path.join(
652+ parts_directory, "ubuntu", "publish-distro.d"))
653+ os.makedirs(os.path.join(parts_directory, "ubuntu", "finalize.d"))
654+ self.parts_directory = parts_directory
655+ self.pushConfig("archivepublisher", run_parts_location=parts_directory)
656+
657+
658+class TestFindRunPartsDir(TestCase, RunPartsMixin):
659+
660+ def test_finds_runparts_directory(self):
661+ self.enableRunParts()
662+ self.assertEqual(
663+ os.path.join(
664+ config.root, self.parts_directory, "ubuntu", "finalize.d"),
665+ find_run_parts_dir("ubuntu", "finalize.d"))
666+
667+ def test_ignores_blank_config(self):
668+ self.enableRunParts("")
669+ self.assertIs(None, find_run_parts_dir("ubuntu", "finalize.d"))
670+
671+ def test_ignores_none_config(self):
672+ self.enableRunParts("none")
673+ self.assertIs(None, find_run_parts_dir("ubuntu", "finalize.d"))
674+
675+ def test_ignores_nonexistent_directory(self):
676+ self.enableRunParts()
677+ self.assertIs(None, find_run_parts_dir("nonexistent", "finalize.d"))
678+
679+
680+class TestExecuteSubprocess(TestCase):
681+
682+ def test_executes_shell_command(self):
683+ marker = os.path.join(self.makeTemporaryDirectory(), "marker")
684+ execute_subprocess(["touch", marker])
685+ self.assertThat(marker, FileExists())
686+
687+ def test_reports_failure_if_requested(self):
688+ class ArbitraryFailure(Exception):
689+ """Some exception that's not likely to come from elsewhere."""
690+
691+ self.assertRaises(
692+ ArbitraryFailure,
693+ execute_subprocess, ["/bin/false"], failure=ArbitraryFailure())
694+
695+ def test_does_not_report_failure_if_not_requested(self):
696+ # The test is that this does not fail:
697+ execute_subprocess(["/bin/false"])
698+
699+
700+class TestRunParts(TestCase, RunPartsMixin):
701+
702+ def test_runs_parts(self):
703+ self.enableRunParts()
704+ execute_subprocess_fixture = self.useFixture(MonkeyPatch(
705+ "lp.archivepublisher.run_parts.execute_subprocess", FakeMethod()))
706+ run_parts("ubuntu", "finalize.d", log=DevNullLogger(), env={})
707+ self.assertEqual(1, execute_subprocess_fixture.new_value.call_count)
708+ args, kwargs = execute_subprocess_fixture.new_value.calls[-1]
709+ self.assertEqual(
710+ (["run-parts", "--",
711+ os.path.join(self.parts_directory, "ubuntu/finalize.d")],),
712+ args)
713+
714+ def test_passes_parameters(self):
715+ self.enableRunParts()
716+ execute_subprocess_fixture = self.useFixture(MonkeyPatch(
717+ "lp.archivepublisher.run_parts.execute_subprocess", FakeMethod()))
718+ key = self.factory.getUniqueString()
719+ value = self.factory.getUniqueString()
720+ run_parts(
721+ "ubuntu", "finalize.d", log=DevNullLogger(), env={key: value})
722+ args, kwargs = execute_subprocess_fixture.new_value.calls[-1]
723+ self.assertThat(kwargs["env"], ContainsDict({key: Equals(value)}))