launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #22110
[Merge] lp:~cjwatson/launchpad/refactor-run-parts-subprocess into lp:launchpad
Colin Watson has proposed merging lp:~cjwatson/launchpad/refactor-run-parts-subprocess into lp:launchpad.
Commit message:
Refactor publish-ftpmaster to use subprocess.call rather than os.system.
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/refactor-run-parts-subprocess/+merge/336298
As usual, avoiding unnecessary use of the shell makes things safer and simpler.
--
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/refactor-run-parts-subprocess into lp:launchpad.
=== modified file 'lib/lp/archivepublisher/scripts/publish_ftpmaster.py'
--- lib/lp/archivepublisher/scripts/publish_ftpmaster.py 2016-12-05 22:16:25 +0000
+++ lib/lp/archivepublisher/scripts/publish_ftpmaster.py 2018-01-18 13:58:30 +0000
@@ -1,4 +1,4 @@
-# Copyright 2011-2016 Canonical Ltd. This software is licensed under the
+# Copyright 2011-2018 Canonical Ltd. This software is licensed under the
# GNU Affero General Public License version 3 (see the file LICENSE).
"""Master distro publishing script."""
@@ -11,7 +11,12 @@
from datetime import datetime
import math
import os
+try:
+ from shlex import quote as shell_quote
+except ImportError:
+ from pipes import quote as shell_quote
import shutil
+import subprocess
from pytz import utc
from zope.component import getUtility
@@ -61,43 +66,6 @@
if archive.purpose in ARCHIVES_TO_PUBLISH]
-def compose_shell_boolean(boolean_value):
- """Represent a boolean value as "yes" or "no"."""
- boolean_text = {
- True: "yes",
- False: "no",
- }
- return boolean_text[boolean_value]
-
-
-def shell_quote(literal):
- """Escape `literal` for use in a double-quoted shell string.
-
- This is a pretty naive substitution: it doesn't deal well with
- non-ASCII characters or special characters.
- """
- # Characters that need backslash-escaping. Do the backslash itself
- # first; any escapes we introduced *before* the backslash would have
- # their own backslashes escaped afterwards when we got to the
- # backslash itself.
- special_characters = '\\"$`\n'
- for escapee in special_characters:
- literal = literal.replace(escapee, '\\' + escapee)
- return '"%s"' % literal
-
-
-def compose_env_string(*env_dicts):
- """Turn dict(s) into a series of shell parameter assignments.
-
- Values in later dicts override any values with the same respective
- keys in earlier dicts.
- """
- env = {}
- for env_dict in env_dicts:
- env.update(env_dict)
- return ' '.join(['='.join(pair) for pair in env.iteritems()])
-
-
def get_backup_dists(archive_config):
"""Return the path of an archive's backup dists directory."""
return os.path.join(archive_config.archiveroot + "-distscopy", "dists")
@@ -122,31 +90,78 @@
return get_dists(archive_config) + ".in-progress"
-def extend_PATH():
- """Produce env dict for extending $PATH.
-
- Adds the Launchpad source tree's cronscripts/publishing to the
- existing $PATH.
-
- :return: a dict suitable for passing to compose_env_string.
- """
- scripts_dir = os.path.join(config.root, "cronscripts", "publishing")
- return {"PATH": '"$PATH":%s' % shell_quote(scripts_dir)}
-
-
-def find_run_parts_dir(distro, parts):
+def find_run_parts_dir(distribution_name, parts):
"""Find the requested run-parts directory, if it exists."""
run_parts_location = config.archivepublisher.run_parts_location
if not run_parts_location:
return
- parts_dir = os.path.join(run_parts_location, distro.name, parts)
+ parts_dir = os.path.join(run_parts_location, distribution_name, parts)
if file_exists(parts_dir):
return parts_dir
else:
return None
+def extend_PATH(env):
+ """Extend $PATH in an environment dictionary.
+
+ Adds the Launchpad source tree's cronscripts/publishing to the
+ existing $PATH.
+ """
+ scripts_dir = os.path.join(config.root, "cronscripts", "publishing")
+ path_elements = env.get("PATH", "").split(os.pathsep)
+ path_elements.append(scripts_dir)
+ env["PATH"] = os.pathsep.join(path_elements)
+
+
+def execute_subprocess(args, log=None, failure=None, **kwargs):
+ """Run `args`, handling logging and failures.
+
+ :param args: Program argument array.
+ :param log: An optional logger.
+ :param failure: Raise `failure` as an exception if the command returns a
+ nonzero value. If omitted, nonzero return values are ignored.
+ :param kwargs: Other keyword arguments passed on to `subprocess.call`.
+ """
+ if log is not None:
+ log.debug("Executing: %s", " ".join(shell_quote(arg) for arg in args))
+ retval = subprocess.call(args, **kwargs)
+ if retval != 0:
+ if log is not None:
+ log.debug("Command returned %d.", retval)
+ if failure is not None:
+ if log is not None:
+ log.debug("Command failed: %s", failure)
+ raise failure
+
+
+def run_parts(distribution_name, parts, log=None, env=None):
+ """Execute run-parts.
+
+ :param distribution_name: The name of the distribution to execute
+ run-parts scripts for.
+ :param parts: The run-parts directory to execute:
+ "publish-distro.d" or "finalize.d".
+ :param log: An optional logger.
+ :param env: A dict of additional environment variables to pass to the
+ scripts in the run-parts directory, or None.
+ """
+ parts_dir = find_run_parts_dir(distribution_name, parts)
+ if parts_dir is None:
+ if log is not None:
+ log.debug("Skipping run-parts %s: not configured.", parts)
+ return
+ cmd = ["run-parts", "--", parts_dir]
+ failure = LaunchpadScriptFailure(
+ "Failure while executing run-parts %s." % parts_dir)
+ full_env = dict(os.environ)
+ if env is not None:
+ full_env.update(env)
+ extend_PATH(full_env)
+ execute_subprocess(cmd, log=None, failure=failure, env=full_env)
+
+
def map_distro_pubconfigs(distro):
"""Return dict mapping archive purpose for distro's pub configs.
@@ -238,26 +253,6 @@
"Distribution %s not found." % self.options.distribution)
self.distributions = [distro]
- def executeShell(self, command_line, failure=None):
- """Run `command_line` through a shell.
-
- This won't just load an external program and run it; the command
- line goes through the full shell treatment including variable
- substitutions, output redirections, and so on.
-
- :param command_line: Shell command.
- :param failure: Raise `failure` as an exception if the shell
- command returns a nonzero value. If omitted, nonzero return
- values are ignored.
- """
- self.logger.debug("Executing: %s" % command_line)
- retval = os.system(command_line)
- if retval != 0:
- self.logger.debug("Command returned %d.", retval)
- if failure is not None:
- self.logger.debug("Command failed: %s", failure)
- raise failure
-
def getConfigs(self):
"""Set up configuration objects for archives to be published.
@@ -370,8 +365,9 @@
for purpose, archive_config in self.configs[distribution].iteritems():
dists = get_dists(archive_config)
backup_dists = get_backup_dists(archive_config)
- self.executeShell(
- "rsync -aH --delete '%s/' '%s'" % (dists, backup_dists),
+ execute_subprocess(
+ ["rsync", "-aH", "--delete", "%s/" % dists, backup_dists],
+ log=self.logger,
failure=LaunchpadScriptFailure(
"Failed to rsync new dists for %s." % purpose.title))
@@ -469,12 +465,13 @@
"""Execute the publish-distro hooks."""
archive_config = self.configs[distribution][archive.purpose]
env = {
- 'ARCHIVEROOT': shell_quote(archive_config.archiveroot),
- 'DISTSROOT': shell_quote(get_backup_dists(archive_config)),
+ 'ARCHIVEROOT': archive_config.archiveroot,
+ 'DISTSROOT': get_backup_dists(archive_config),
}
if archive_config.overrideroot is not None:
- env["OVERRIDEROOT"] = shell_quote(archive_config.overrideroot)
- self.runParts(distribution, 'publish-distro.d', env)
+ env["OVERRIDEROOT"] = archive_config.overrideroot
+ run_parts(
+ distribution.name, 'publish-distro.d', log=self.logger, env=env)
def installDists(self, distribution):
"""Put the new dists into place, as near-atomically as possible.
@@ -499,40 +496,22 @@
def clearEmptyDirs(self, distribution):
"""Clear out any redundant empty directories."""
for archive_config in self.configs[distribution].itervalues():
- self.executeShell(
- "find '%s' -type d -empty | xargs -r rmdir"
- % archive_config.archiveroot)
-
- def runParts(self, distribution, parts, env):
- """Execute run-parts.
-
- :param distribution: `Distribution` to execute run-parts scripts for.
- :param parts: The run-parts directory to execute:
- "publish-distro.d" or "finalize.d".
- :param env: A dict of environment variables to pass to the
- scripts in the run-parts directory.
- """
- parts_dir = find_run_parts_dir(distribution, parts)
- if parts_dir is None:
- self.logger.debug("Skipping run-parts %s: not configured.", parts)
- return
- env_string = compose_env_string(env, extend_PATH())
- self.executeShell(
- "env %s run-parts -- '%s'" % (env_string, parts_dir),
- failure=LaunchpadScriptFailure(
- "Failure while executing run-parts %s." % parts_dir))
+ execute_subprocess(
+ ["find", archive_config.archiveroot, "-type", "d", "-empty",
+ "-delete"],
+ log=self.logger)
def runFinalizeParts(self, distribution, security_only=False):
"""Run the finalize.d parts to finalize publication."""
- archive_roots = shell_quote(' '.join([
+ archive_roots = ' '.join([
archive_config.archiveroot
- for archive_config in self.configs[distribution].itervalues()]))
+ for archive_config in self.configs[distribution].itervalues()])
env = {
- 'SECURITY_UPLOAD_ONLY': compose_shell_boolean(security_only),
+ 'SECURITY_UPLOAD_ONLY': 'yes' if security_only else 'no',
'ARCHIVEROOTS': archive_roots,
}
- self.runParts(distribution, 'finalize.d', env)
+ run_parts(distribution.name, 'finalize.d', log=self.logger, env=env)
def publishSecurityUploads(self, distribution):
"""Quickly process just the pending security uploads.
=== modified file 'lib/lp/archivepublisher/tests/test_publish_ftpmaster.py'
--- lib/lp/archivepublisher/tests/test_publish_ftpmaster.py 2016-12-05 22:16:25 +0000
+++ lib/lp/archivepublisher/tests/test_publish_ftpmaster.py 2018-01-18 13:58:30 +0000
@@ -1,4 +1,4 @@
-# Copyright 2011-2016 Canonical Ltd. This software is licensed under the
+# Copyright 2011-2018 Canonical Ltd. This software is licensed under the
# GNU Affero General Public License version 3 (see the file LICENSE).
"""Test publish-ftpmaster cron script."""
@@ -13,6 +13,8 @@
from apt_pkg import TagFile
from fixtures import MonkeyPatch
from testtools.matchers import (
+ ContainsDict,
+ Equals,
MatchesException,
MatchesStructure,
Not,
@@ -26,13 +28,12 @@
from lp.archivepublisher.config import getPubConfig
from lp.archivepublisher.interfaces.publisherconfig import IPublisherConfigSet
from lp.archivepublisher.scripts.publish_ftpmaster import (
- compose_env_string,
- compose_shell_boolean,
+ execute_subprocess,
find_run_parts_dir,
get_working_dists,
newer_mtime,
PublishFTPMaster,
- shell_quote,
+ run_parts,
)
from lp.registry.interfaces.pocket import (
PackagePublishingPocket,
@@ -60,10 +61,7 @@
TestCaseWithFactory,
)
from lp.testing.fakemethod import FakeMethod
-from lp.testing.layers import (
- LaunchpadZopelessLayer,
- ZopelessDatabaseLayer,
- )
+from lp.testing.layers import LaunchpadZopelessLayer
def path_exists(*path_components):
@@ -178,42 +176,49 @@
self.makeTemporaryDirectory())
-class TestPublishFTPMasterHelpers(TestCase):
-
- def test_compose_env_string_iterates_env_dict(self):
- env = {
- "A": "1",
- "B": "2",
- }
- env_string = compose_env_string(env)
- self.assertIn(env_string, ["A=1 B=2", "B=2 A=1"])
-
- def test_compose_env_string_combines_env_dicts(self):
- env1 = {"A": "1"}
- env2 = {"B": "2"}
- env_string = compose_env_string(env1, env2)
- self.assertIn(env_string, ["A=1 B=2", "B=2 A=1"])
-
- def test_compose_env_string_overrides_repeated_keys(self):
- self.assertEqual("A=2", compose_env_string({"A": "1"}, {"A": "2"}))
-
- def test_shell_quote_quotes_string(self):
- self.assertEqual('"x"', shell_quote("x"))
-
- def test_shell_quote_escapes_string(self):
- self.assertEqual('"\\\\"', shell_quote("\\"))
-
- def test_shell_quote_does_not_escape_its_own_escapes(self):
- self.assertEqual('"\\$"', shell_quote("$"))
-
- def test_shell_quote_escapes_entire_string(self):
- self.assertEqual('"\\$\\$\\$"', shell_quote("$$$"))
-
- def test_compose_shell_boolean_shows_True_as_yes(self):
- self.assertEqual("yes", compose_shell_boolean(True))
-
- def test_compose_shell_boolean_shows_False_as_no(self):
- self.assertEqual("no", compose_shell_boolean(False))
+class TestPublishFTPMasterHelpers(TestCase, HelpersMixin):
+
+ def test_execute_subprocess_executes_shell_command(self):
+ marker = os.path.join(self.makeTemporaryDirectory(), "marker")
+ execute_subprocess(["touch", marker])
+ self.assertTrue(file_exists(marker))
+
+ def test_execute_subprocess_reports_failure_if_requested(self):
+ class ArbitraryFailure(Exception):
+ """Some exception that's not likely to come from elsewhere."""
+
+ self.assertRaises(
+ ArbitraryFailure,
+ execute_subprocess, ["/bin/false"], failure=ArbitraryFailure())
+
+ def test_execute_subprocess_does_not_report_failure_if_not_requested(self):
+ # The test is that this does not fail:
+ execute_subprocess(["/bin/false"])
+
+ def test_run_parts_runs_parts(self):
+ self.enableRunParts()
+ execute_subprocess_fixture = self.useFixture(MonkeyPatch(
+ "lp.archivepublisher.scripts.publish_ftpmaster.execute_subprocess",
+ FakeMethod()))
+ run_parts("ubuntu", "finalize.d", log=DevNullLogger(), env={})
+ self.assertEqual(1, execute_subprocess_fixture.new_value.call_count)
+ args, kwargs = execute_subprocess_fixture.new_value.calls[-1]
+ self.assertEqual(
+ (["run-parts", "--",
+ os.path.join(self.parts_directory, "ubuntu/finalize.d")],),
+ args)
+
+ def test_run_parts_passes_parameters(self):
+ self.enableRunParts()
+ execute_subprocess_fixture = self.useFixture(MonkeyPatch(
+ "lp.archivepublisher.scripts.publish_ftpmaster.execute_subprocess",
+ FakeMethod()))
+ key = self.factory.getUniqueString()
+ value = self.factory.getUniqueString()
+ run_parts(
+ "ubuntu", "finalize.d", log=DevNullLogger(), env={key: value})
+ args, kwargs = execute_subprocess_fixture.new_value.calls[-1]
+ self.assertThat(kwargs["env"], ContainsDict({key: Equals(value)}))
class TestNewerMtime(TestCase):
@@ -256,31 +261,26 @@
self.assertTrue(newer_mtime(self.a, self.b))
-class TestFindRunPartsDir(TestCaseWithFactory, HelpersMixin):
- layer = ZopelessDatabaseLayer
+class TestFindRunPartsDir(TestCase, HelpersMixin):
def test_find_run_parts_dir_finds_runparts_directory(self):
self.enableRunParts()
- ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
self.assertEqual(
os.path.join(
config.root, self.parts_directory, "ubuntu", "finalize.d"),
- find_run_parts_dir(ubuntu, "finalize.d"))
+ find_run_parts_dir("ubuntu", "finalize.d"))
def test_find_run_parts_dir_ignores_blank_config(self):
self.enableRunParts("")
- ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
- self.assertIs(None, find_run_parts_dir(ubuntu, "finalize.d"))
+ self.assertIs(None, find_run_parts_dir("ubuntu", "finalize.d"))
def test_find_run_parts_dir_ignores_none_config(self):
self.enableRunParts("none")
- ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
- self.assertIs(None, find_run_parts_dir(ubuntu, "finalize.d"))
+ self.assertIs(None, find_run_parts_dir("ubuntu", "finalize.d"))
def test_find_run_parts_dir_ignores_nonexistent_directory(self):
self.enableRunParts()
- distro = self.factory.makeDistribution()
- self.assertIs(None, find_run_parts_dir(distro, "finalize.d"))
+ self.assertIs(None, find_run_parts_dir("nonexistent", "finalize.d"))
class TestPublishFTPMasterScript(TestCaseWithFactory, HelpersMixin):
@@ -552,12 +552,14 @@
script = self.makeScript(distro)
script.setUp()
script.setUpDirs()
- script.runParts = FakeMethod()
+ run_parts_fixture = self.useFixture(MonkeyPatch(
+ "lp.archivepublisher.scripts.publish_ftpmaster.run_parts",
+ FakeMethod()))
script.publishDistroArchive(distro, distro.main_archive)
- self.assertEqual(1, script.runParts.call_count)
- args, kwargs = script.runParts.calls[0]
- run_distro, parts_dir, env = args
- self.assertEqual(distro, run_distro)
+ self.assertEqual(1, run_parts_fixture.new_value.call_count)
+ args, _ = run_parts_fixture.new_value.calls[0]
+ run_distro_name, parts_dir = args
+ self.assertEqual(distro.name, run_distro_name)
self.assertEqual("publish-distro.d", parts_dir)
def test_runPublishDistroParts_passes_parameters(self):
@@ -565,14 +567,19 @@
script = self.makeScript(distro)
script.setUp()
script.setUpDirs()
- script.runParts = FakeMethod()
+ run_parts_fixture = self.useFixture(MonkeyPatch(
+ "lp.archivepublisher.scripts.publish_ftpmaster.run_parts",
+ FakeMethod()))
script.runPublishDistroParts(distro, distro.main_archive)
- args, kwargs = script.runParts.calls[0]
- run_distro, parts_dir, env = args
- required_parameters = set([
- "ARCHIVEROOT", "DISTSROOT", "OVERRIDEROOT"])
- missing_parameters = required_parameters.difference(set(env.keys()))
- self.assertEqual(set(), missing_parameters)
+ _, kwargs = run_parts_fixture.new_value.calls[0]
+ distro_config = get_pub_config(distro)
+ self.assertThat(kwargs["env"], ContainsDict({
+ "ARCHIVEROOT": Equals(get_archive_root(distro_config)),
+ "DISTSROOT": Equals(
+ os.path.join(get_distscopy_root(distro_config), "dists")),
+ "OVERRIDEROOT": Equals(
+ get_archive_root(distro_config) + "-overrides"),
+ }))
def test_clearEmptyDirs_cleans_up_empty_directories(self):
distro = self.makeDistroWithPublishDirectory()
@@ -621,67 +628,16 @@
script.options.distribution = self.factory.getUniqueString()
self.assertRaises(LaunchpadScriptFailure, script.processOptions)
- def test_runParts_runs_parts(self):
- self.enableRunParts()
- script = self.makeScript(self.prepareUbuntu())
- script.setUp()
- distro = script.distributions[0]
- script.executeShell = FakeMethod()
- script.runParts(distro, "finalize.d", {})
- self.assertEqual(1, script.executeShell.call_count)
- args, kwargs = script.executeShell.calls[-1]
- command_line, = args
- self.assertIn("run-parts", command_line)
- self.assertIn(
- os.path.join(self.parts_directory, "ubuntu/finalize.d"),
- command_line)
-
- def test_runParts_passes_parameters(self):
- self.enableRunParts()
- script = self.makeScript(self.prepareUbuntu())
- script.setUp()
- distro = script.distributions[0]
- script.executeShell = FakeMethod()
- key = self.factory.getUniqueString()
- value = self.factory.getUniqueString()
- script.runParts(distro, "finalize.d", {key: value})
- args, kwargs = script.executeShell.calls[-1]
- command_line, = args
- self.assertIn("%s=%s" % (key, value), command_line)
-
- def test_executeShell_executes_shell_command(self):
- distro = self.makeDistroWithPublishDirectory()
- script = self.makeScript(distro)
- marker = os.path.join(
- get_pub_config(distro).root_dir, "marker")
- script.executeShell("touch %s" % marker)
- self.assertTrue(file_exists(marker))
-
- def test_executeShell_reports_failure_if_requested(self):
- distro = self.makeDistroWithPublishDirectory()
- script = self.makeScript(distro)
-
- class ArbitraryFailure(Exception):
- """Some exception that's not likely to come from elsewhere."""
-
- self.assertRaises(
- ArbitraryFailure,
- script.executeShell, "/bin/false", failure=ArbitraryFailure())
-
- def test_executeShell_does_not_report_failure_if_not_requested(self):
- distro = self.makeDistroWithPublishDirectory()
- script = self.makeScript(distro)
- # The test is that this does not fail:
- script.executeShell("/bin/false")
-
def test_runFinalizeParts_passes_parameters(self):
script = self.makeScript(self.prepareUbuntu())
script.setUp()
distro = script.distributions[0]
- script.runParts = FakeMethod()
+ run_parts_fixture = self.useFixture(MonkeyPatch(
+ "lp.archivepublisher.scripts.publish_ftpmaster.run_parts",
+ FakeMethod()))
script.runFinalizeParts(distro)
- args, kwargs = script.runParts.calls[0]
- run_distro, parts_dir, env = args
+ _, kwargs = run_parts_fixture.new_value.calls[0]
+ env = kwargs["env"]
required_parameters = set(["ARCHIVEROOTS", "SECURITY_UPLOAD_ONLY"])
missing_parameters = required_parameters.difference(set(env.keys()))
self.assertEqual(set(), missing_parameters)
@@ -798,15 +754,11 @@
self.assertContentEqual(
[distro.main_archive, partner_archive], published_archives)
- def test_runFinalizeParts_quotes_archiveroots(self):
- # Passing ARCHIVEROOTS to the finalize.d scripts is a bit
- # difficult because the variable holds multiple values in a
- # single, double-quoted string. Escaping and quoting a sequence
- # of escaped and quoted items won't work.
- # This test establishes how a script can sanely deal with the
- # list. It'll probably go wrong if the configured archive root
- # contains spaces and such, but should work with Unix-sensible
- # paths.
+ def test_runFinalizeParts_passes_archiveroots_correctly(self):
+ # The ARCHIVEROOTS environment variable may contain spaces, and
+ # these are passed through correctly. It'll go wrong if the
+ # configured archive root contains whitespace, but works with
+ # Unix-sensible paths.
distro = self.makeDistroWithPublishDirectory()
self.factory.makeArchive(
distribution=distro, purpose=ArchivePurpose.PARTNER)
Follow ups