← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~cjwatson/launchpad-buildd/translation-operation into lp:launchpad-buildd

 

Colin Watson has proposed merging lp:~cjwatson/launchpad-buildd/translation-operation into lp:launchpad-buildd with lp:~cjwatson/launchpad-buildd/translation-merge-states as a prerequisite.

Commit message:
Convert generate-translation-templates to the new Operation framework.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad-buildd/translation-operation/+merge/330437
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad-buildd/translation-operation into lp:launchpad-buildd.
=== modified file 'MANIFEST.in'
--- MANIFEST.in	2017-08-23 00:13:20 +0000
+++ MANIFEST.in	2017-09-08 15:58:48 +0000
@@ -1,7 +1,6 @@
 include LICENSE
 include Makefile
 include bin/buildrecipe
-include bin/generate-translation-templates
 include bin/in-target
 include bin/sbuild-package
 include bin/slave-prep

=== removed file 'bin/generate-translation-templates'
--- bin/generate-translation-templates	2017-09-08 15:58:48 +0000
+++ bin/generate-translation-templates	1970-01-01 00:00:00 +0000
@@ -1,69 +0,0 @@
-#!/bin/sh
-#
-# Copyright 2010-2017 Canonical Ltd.  This software is licensed under the
-# GNU Affero General Public License version 3 (see the file LICENSE).
-
-# Buildd Slave tool to generate translation templates. Boiler plate code
-# copied from sbuild-package.
-
-# Expects build id as arg 1.
-# Expects branch url as arg 2.
-# Expects output tarball name as arg 3.
-
-# Must run as user with password-less sudo ability.
-
-exec 2>&1
-
-export LANG=C LC_ALL=C
-
-CHMOD=/bin/chmod
-CHROOT=/usr/sbin/chroot
-CP=/bin/cp
-INSTALL=/usr/bin/install
-MKDIR=/bin/mkdir
-SU=/bin/su
-SUDO=/usr/bin/sudo
-TOUCH=/usr/bin/touch
-
-BUILDID=$1
-BRANCH_URL=$2
-RESULT_NAME=$3
-
-BUILDD_HOME=/usr/share/launchpad-buildd
-BUILDD_LIB=/usr/lib/launchpad-buildd
-SLAVEBIN=$BUILDD_HOME/slavebin
-BUILD_CHROOT="$HOME/build-$BUILDID/chroot-autobuild"
-USER=$(whoami)
-
-# Debug output.
-echo "Running as $USER for build $BUILDID on $BRANCH_URL."
-echo "Results expected in $RESULT_NAME."
-
-BUILDD_PACKAGE=lpbuildd
-POTTERY=$BUILDD_PACKAGE/pottery
-# The script should be smarter about detecting the python version.
-PYMODULES=/usr/lib/pymodules/python2.7
-echo -n "Default Python in the chroot is: "
-$BUILD_CHROOT/usr/bin/python --version
-
-GENERATE_SCRIPT=$PYMODULES/$POTTERY/generate_translation_templates.py
-
-debug_exec() {
-   echo "Executing '$1'..."
-   $1 || echo "Got error $? from '$1'."
-}
-
-$SUDO $CHROOT $BUILD_CHROOT apt-get install -y bzr intltool || exit 200
-
-# Copy pottery files to chroot.
-debug_exec "$SUDO $MKDIR -vp $BUILD_CHROOT$PYMODULES/$BUILDD_PACKAGE"
-debug_exec "$SUDO $TOUCH $BUILD_CHROOT$PYMODULES/$BUILDD_PACKAGE/__init__.py"
-debug_exec "$SUDO $CP -vr $BUILDD_LIB/$POTTERY $BUILD_CHROOT$PYMODULES/$BUILDD_PACKAGE"
-debug_exec "$SUDO $CHMOD -v -R go+rX $BUILD_CHROOT$PYMODULES/$BUILDD_PACKAGE"
-debug_exec "$SUDO $CHMOD -v 755 $BUILD_CHROOT$GENERATE_SCRIPT"
-
-# Enter chroot, switch back to unprivileged user, execute the generate script.
-$SUDO $CHROOT $BUILD_CHROOT \
-  $SU - $USER \
-    -c "PYTHONPATH=$PYMODULES $GENERATE_SCRIPT $BRANCH_URL $RESULT_NAME" \
-  || exit 201

=== modified file 'debian/changelog'
--- debian/changelog	2017-09-08 15:58:48 +0000
+++ debian/changelog	2017-09-08 15:58:48 +0000
@@ -3,6 +3,7 @@
   * Refactor lpbuildd.pottery.intltool to avoid calling chdir.
   * Merge TranslationTemplatesBuildState.{INSTALL,GENERATE} into a single
     state.
+  * Convert generate-translation-templates to the new Operation framework.
 
  -- Colin Watson <cjwatson@xxxxxxxxxx>  Fri, 08 Sep 2017 13:42:17 +0100
 

=== modified file 'debian/launchpad-buildd.install'
--- debian/launchpad-buildd.install	2017-08-23 00:13:20 +0000
+++ debian/launchpad-buildd.install	2017-09-08 15:58:48 +0000
@@ -4,7 +4,6 @@
 sbuildrc				usr/share/launchpad-buildd
 template-buildd-slave.conf		usr/share/launchpad-buildd
 bin/buildrecipe				usr/share/launchpad-buildd/slavebin
-bin/generate-translation-templates	usr/share/launchpad-buildd/slavebin
 bin/in-target				usr/share/launchpad-buildd/slavebin
 bin/sbuild-package			usr/share/launchpad-buildd/slavebin
 bin/slave-prep				usr/share/launchpad-buildd/slavebin

=== modified file 'lpbuildd/pottery/intltool.py'
--- lpbuildd/pottery/intltool.py	2017-09-08 15:58:48 +0000
+++ lpbuildd/pottery/intltool.py	2017-09-08 15:58:48 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2010 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2017 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Functions to build PO templates on the build slave."""
@@ -13,27 +13,26 @@
     'find_potfiles_in',
     ]
 
-import errno
 import os.path
 import re
 import subprocess
-
-
-def find_potfiles_in(package_dir):
-    """Search the current directory and its subdirectories for POTFILES.in.
-
+import tempfile
+
+
+def find_potfiles_in(backend, package_dir):
+    """Search `package_dir` and its subdirectories for POTFILES.in.
+
+    :param backend: The `Backend` where work is done.
     :param package_dir: The directory to search.
     :returns: A list of names of directories that contain a file
         POTFILES.in, relative to `package_dir`.
     """
-    result_dirs = []
-    for dirpath, dirnames, dirfiles in os.walk(package_dir):
-        if "POTFILES.in" in dirfiles:
-            result_dirs.append(os.path.relpath(dirpath, package_dir))
-    return result_dirs
-
-
-def check_potfiles_in(path):
+    paths = backend.find(
+        package_dir, include_directories=False, name="POTFILES.in")
+    return [os.path.dirname(path) for path in paths]
+
+
+def check_potfiles_in(backend, path):
     """Check if the files listed in the POTFILES.in file exist.
 
     Running 'intltool-update -m' will perform this check and also take a
@@ -46,6 +45,7 @@
     all listed files exist. The presence of the 'notexist' file tells us
     that.
 
+    :param backend: The `Backend` where work is done.
     :param path: The directory where POTFILES.in resides.
     :returns: False if the directory does not exist, if an error occurred
         when executing intltool-update or if files are missing from
@@ -53,29 +53,24 @@
         actually exist.
     """
     # Abort nicely if the directory does not exist.
-    if not os.path.isdir(path):
+    if not backend.isdir(path):
         return False
     # Remove stale files from a previous run of intltool-update -m.
-    for unlink_name in ['missing', 'notexist']:
-        try:
-            os.unlink(os.path.join(path, unlink_name))
-        except OSError as e:
-            # It's ok if the files are missing.
-            if e.errno != errno.ENOENT:
-                raise
+    backend.run(
+        ["rm", "-f"] +
+        [os.path.join(path, name) for name in ("missing", "notexist")])
     with open("/dev/null", "w") as devnull:
         try:
-            subprocess.check_call(
+            backend.run(
                 ["/usr/bin/intltool-update", "-m"],
                 stdout=devnull, stderr=devnull, cwd=path)
         except subprocess.CalledProcessError:
             return False
 
-    notexist = os.path.join(path, "notexist")
-    return not os.access(notexist, os.R_OK)
-
-
-def find_intltool_dirs(package_dir):
+    return not backend.path_exists(os.path.join(path, "notexist"))
+
+
+def find_intltool_dirs(backend, package_dir):
     """Search for directories with intltool structure.
 
     `package_dir` and its subdirectories are searched. An 'intltool
@@ -83,12 +78,13 @@
     files listed in that POTFILES.in do actually exist. The latter
     condition makes sure that the file is not stale.
 
+    :param backend: The `Backend` where work is done.
     :param package_dir: The directory to search.
     :returns: A list of directory names, relative to `package_dir`.
     """
     return sorted(
-        podir for podir in find_potfiles_in(package_dir)
-        if check_potfiles_in(os.path.join(package_dir, podir)))
+        podir for podir in find_potfiles_in(backend, package_dir)
+        if check_potfiles_in(backend, os.path.join(package_dir, podir)))
 
 
 def _get_AC_PACKAGE_NAME(config_file):
@@ -127,7 +123,7 @@
     return substitution.replace(subst_value)
 
 
-def get_translation_domain(dirname):
+def get_translation_domain(backend, dirname):
     """Get the translation domain for this PO directory.
 
     Imitates some of the behavior of intltool-update to find out which
@@ -156,10 +152,12 @@
     config_files = []
     for filename, varname, keep_trying in locations:
         path = os.path.join(dirname, filename)
-        if not os.access(path, os.R_OK):
+        if not backend.path_exists(path):
             # Skip non-existent files.
             continue
-        config_files.append(ConfigFile(path))
+        with tempfile.NamedTemporaryFile() as local_file:
+            backend.copy_out(path, local_file.name)
+            config_files.append(ConfigFile(local_file.file))
         new_value = config_files[-1].getVariable(varname)
         if new_value is not None:
             value = new_value
@@ -181,7 +179,7 @@
     return value
 
 
-def generate_pot(podir, domain):
+def generate_pot(backend, podir, domain):
     """Generate one PO template using intltool.
 
     Although 'intltool-update -p' can try to find out the translation domain
@@ -190,6 +188,7 @@
     "has an additional effect: the name of current working directory is no
     more  limited  to 'po' or 'po-*'." We don't want that limit either.
 
+    :param backend: The `Backend` where work is done.
     :param podir: The PO directory in which to build template.
     :param domain: The translation domain to use as the name of the template.
       If it is None or empty, 'messages.pot' will be used.
@@ -199,7 +198,7 @@
         domain = "messages"
     with open("/dev/null", "w") as devnull:
         try:
-            subprocess.check_call(
+            backend.run(
                 ["/usr/bin/intltool-update", "-p", "-g", domain],
                 stdout=devnull, stderr=devnull, cwd=podir)
             return True
@@ -207,13 +206,13 @@
             return False
 
 
-def generate_pots(package_dir):
+def generate_pots(backend, package_dir):
     """Top-level function to generate all PO templates in a package."""
     potpaths = []
-    for podir in find_intltool_dirs(package_dir):
+    for podir in find_intltool_dirs(backend, package_dir):
         full_podir = os.path.join(package_dir, podir)
-        domain = get_translation_domain(full_podir)
-        if generate_pot(full_podir, domain):
+        domain = get_translation_domain(backend, full_podir)
+        if generate_pot(backend, full_podir, domain):
             potpaths.append(os.path.join(podir, domain + ".pot"))
     return potpaths
 

=== modified file 'lpbuildd/pottery/tests/test_intltool.py'
--- lpbuildd/pottery/tests/test_intltool.py	2017-09-08 15:58:48 +0000
+++ lpbuildd/pottery/tests/test_intltool.py	2017-09-08 15:58:48 +0000
@@ -25,7 +25,10 @@
     generate_pots,
     get_translation_domain,
     )
-from lpbuildd.tests.fakeslave import FakeMethod
+from lpbuildd.tests.fakeslave import (
+    FakeMethod,
+    UncontainedBackend,
+    )
 
 
 class SetupTestPackageMixin:
@@ -75,71 +78,86 @@
     def test_detect_potfiles_in(self):
         # Find POTFILES.in in a package with multiple dirs when only one has
         # POTFILES.in.
+        backend = UncontainedBackend("1")
         package_dir = self.prepare_package("intltool_POTFILES_in_1")
-        dirs = find_potfiles_in(package_dir)
+        dirs = find_potfiles_in(backend, package_dir)
         self.assertThat(dirs, MatchesSetwise(Equals("po-intltool")))
 
     def test_detect_potfiles_in_module(self):
         # Find POTFILES.in in a package with POTFILES.in at different levels.
+        backend = UncontainedBackend("1")
         package_dir = self.prepare_package("intltool_POTFILES_in_2")
-        dirs = find_potfiles_in(package_dir)
+        dirs = find_potfiles_in(backend, package_dir)
         self.assertThat(
             dirs, MatchesSetwise(Equals("po"), Equals("module1/po")))
 
     def test_check_potfiles_in_content_ok(self):
         # Ideally all files listed in POTFILES.in exist in the source package.
+        backend = UncontainedBackend("1")
         package_dir = self.prepare_package("intltool_single_ok")
-        self.assertTrue(check_potfiles_in(os.path.join(package_dir, "po")))
+        self.assertTrue(
+            check_potfiles_in(backend, os.path.join(package_dir, "po")))
 
     def test_check_potfiles_in_content_ok_file_added(self):
         # If a file is not listed in POTFILES.in, the file is still good for
         # our purposes.
+        backend = UncontainedBackend("1")
         package_dir = self.prepare_package("intltool_single_ok")
         added_path = os.path.join(package_dir, "src/sourcefile_new.c")
         with open(added_path, "w") as added_file:
             added_file.write("/* Test file. */")
-        self.assertTrue(check_potfiles_in(os.path.join(package_dir, "po")))
+        self.assertTrue(
+            check_potfiles_in(backend, os.path.join(package_dir, "po")))
 
     def test_check_potfiles_in_content_not_ok_file_removed(self):
         # If a file is missing that is listed in POTFILES.in, the file
         # intltool structure is probably broken and cannot be used for
         # our purposes.
+        backend = UncontainedBackend("1")
         package_dir = self.prepare_package("intltool_single_ok")
         os.remove(os.path.join(package_dir, "src/sourcefile1.c"))
-        self.assertFalse(check_potfiles_in(os.path.join(package_dir, "po")))
+        self.assertFalse(
+            check_potfiles_in(backend, os.path.join(package_dir, "po")))
 
     def test_check_potfiles_in_wrong_directory(self):
         # Passing in the wrong directory will cause the check to fail
         # gracefully and return False.
+        backend = UncontainedBackend("1")
         package_dir = self.prepare_package("intltool_single_ok")
-        self.assertFalse(check_potfiles_in(os.path.join(package_dir, "foo")))
+        self.assertFalse(
+            check_potfiles_in(backend, os.path.join(package_dir, "foo")))
 
     def test_find_intltool_dirs(self):
         # Complete run: find all directories with intltool structure.
+        backend = UncontainedBackend("1")
         package_dir = self.prepare_package("intltool_full_ok")
         self.assertEqual(
-            ["po-module1", "po-module2"], find_intltool_dirs(package_dir))
+            ["po-module1", "po-module2"],
+            find_intltool_dirs(backend, package_dir))
 
     def test_find_intltool_dirs_broken(self):
         # Complete run: part of the intltool structure is broken.
+        backend = UncontainedBackend("1")
         package_dir = self.prepare_package("intltool_full_ok")
         os.remove(os.path.join(package_dir, "src/module1/sourcefile1.c"))
         self.assertEqual(
-            ["po-module2"], find_intltool_dirs(package_dir))
+            ["po-module2"], find_intltool_dirs(backend, package_dir))
 
 
 class TestIntltoolDomain(TestCase, SetupTestPackageMixin):
 
     def test_get_translation_domain_makevars(self):
         # Find a translation domain in Makevars.
+        backend = UncontainedBackend("1")
         package_dir = self.prepare_package("intltool_domain_makevars")
         self.assertEqual(
             "translationdomain",
-            get_translation_domain(os.path.join(package_dir, "po")))
+            get_translation_domain(backend, os.path.join(package_dir, "po")))
 
     def test_get_translation_domain_makevars_subst_1(self):
         # Find a translation domain in Makevars, substituted from
         # Makefile.in.in.
+        backend = UncontainedBackend("1")
         package_dir = self.prepare_package(
             "intltool_domain_base",
             {
@@ -148,11 +166,12 @@
             })
         self.assertEqual(
             "packagename-in-in",
-            get_translation_domain(os.path.join(package_dir, "po")))
+            get_translation_domain(backend, os.path.join(package_dir, "po")))
 
     def test_get_translation_domain_makevars_subst_2(self):
         # Find a translation domain in Makevars, substituted from
         # configure.ac.
+        backend = UncontainedBackend("1")
         package_dir = self.prepare_package(
             "intltool_domain_base",
             {
@@ -162,21 +181,23 @@
             })
         self.assertEqual(
             "packagename-ac",
-            get_translation_domain(os.path.join(package_dir, "po")))
+            get_translation_domain(backend, os.path.join(package_dir, "po")))
 
     def test_get_translation_domain_makefile_in_in(self):
         # Find a translation domain in Makefile.in.in.
+        backend = UncontainedBackend("1")
         package_dir = self.prepare_package("intltool_domain_makefile_in_in")
         self.assertEqual(
             "packagename-in-in",
-            get_translation_domain(os.path.join(package_dir, "po")))
+            get_translation_domain(backend, os.path.join(package_dir, "po")))
 
     def test_get_translation_domain_configure_ac(self):
         # Find a translation domain in configure.ac.
+        backend = UncontainedBackend("1")
         package_dir = self.prepare_package("intltool_domain_configure_ac")
         self.assertEqual(
             "packagename-ac",
-            get_translation_domain(os.path.join(package_dir, "po")))
+            get_translation_domain(backend, os.path.join(package_dir, "po")))
 
     def prepare_ac_init(self, parameters):
         # Prepare test for various permutations of AC_INIT parameters
@@ -192,113 +213,127 @@
 
     def test_get_translation_domain_configure_ac_init(self):
         # Find a translation domain in configure.ac in AC_INIT.
+        backend = UncontainedBackend("1")
         package_dir = self.prepare_ac_init(
             "packagename-ac-init, 1.0, http://bug.org";)
         self.assertEqual(
             "packagename-ac-init",
-            get_translation_domain(os.path.join(package_dir, "po")))
+            get_translation_domain(backend, os.path.join(package_dir, "po")))
 
     def test_get_translation_domain_configure_ac_init_single_param(self):
         # Find a translation domain in configure.ac in AC_INIT.
+        backend = UncontainedBackend("1")
         package_dir = self.prepare_ac_init("[Just 1 param]")
         self.assertIsNone(
-            get_translation_domain(os.path.join(package_dir, "po")))
+            get_translation_domain(backend, os.path.join(package_dir, "po")))
 
     def test_get_translation_domain_configure_ac_init_brackets(self):
         # Find a translation domain in configure.ac in AC_INIT with brackets.
+        backend = UncontainedBackend("1")
         package_dir = self.prepare_ac_init(
             "[packagename-ac-init], 1.0, http://bug.org";)
         self.assertEqual(
             "packagename-ac-init",
-            get_translation_domain(os.path.join(package_dir, "po")))
+            get_translation_domain(backend, os.path.join(package_dir, "po")))
 
     def test_get_translation_domain_configure_ac_init_tarname(self):
         # Find a translation domain in configure.ac in AC_INIT tar name
         # parameter.
+        backend = UncontainedBackend("1")
         package_dir = self.prepare_ac_init(
             "[Package name], 1.0, http://bug.org, [package-tarname]")
         self.assertEqual(
             "package-tarname",
-            get_translation_domain(os.path.join(package_dir, "po")))
+            get_translation_domain(backend, os.path.join(package_dir, "po")))
 
     def test_get_translation_domain_configure_ac_init_multiline(self):
         # Find a translation domain in configure.ac in AC_INIT when it
         # spans multiple lines.
+        backend = UncontainedBackend("1")
         package_dir = self.prepare_ac_init(
             "[packagename-ac-init],\n    1.0,\n    http://bug.org";)
         self.assertEqual(
             "packagename-ac-init",
-            get_translation_domain(os.path.join(package_dir, "po")))
+            get_translation_domain(backend, os.path.join(package_dir, "po")))
 
     def test_get_translation_domain_configure_ac_init_multiline_tarname(self):
         # Find a translation domain in configure.ac in AC_INIT tar name
         # parameter that is on a different line.
+        backend = UncontainedBackend("1")
         package_dir = self.prepare_ac_init(
             "[Package name], 1.0,\n    http://bug.org, [package-tarname]")
         self.assertEqual(
             "package-tarname",
-            get_translation_domain(os.path.join(package_dir, "po")))
+            get_translation_domain(backend, os.path.join(package_dir, "po")))
 
     def test_get_translation_domain_configure_in(self):
         # Find a translation domain in configure.in.
+        backend = UncontainedBackend("1")
         package_dir = self.prepare_package("intltool_domain_configure_in")
         self.assertEqual(
             "packagename-in",
-            get_translation_domain(os.path.join(package_dir, "po")))
+            get_translation_domain(backend, os.path.join(package_dir, "po")))
 
     def test_get_translation_domain_makefile_in_in_substitute(self):
         # Find a translation domain in Makefile.in.in with substitution from
         # configure.ac.
+        backend = UncontainedBackend("1")
         package_dir = self.prepare_package(
             "intltool_domain_makefile_in_in_substitute")
         self.assertEqual(
             "domainname-ac-in-in",
-            get_translation_domain(os.path.join(package_dir, "po")))
+            get_translation_domain(backend, os.path.join(package_dir, "po")))
 
     def test_get_translation_domain_makefile_in_in_substitute_same_name(self):
         # Find a translation domain in Makefile.in.in with substitution from
         # configure.ac from a variable with the same name as in
         # Makefile.in.in.
+        backend = UncontainedBackend("1")
         package_dir = self.prepare_package(
             "intltool_domain_makefile_in_in_substitute_same_name")
         self.assertEqual(
             "packagename-ac-in-in",
-            get_translation_domain(os.path.join(package_dir, "po")))
+            get_translation_domain(backend, os.path.join(package_dir, "po")))
 
     def test_get_translation_domain_makefile_in_in_substitute_same_file(self):
         # Find a translation domain in Makefile.in.in with substitution from
         # the same file.
+        backend = UncontainedBackend("1")
         package_dir = self.prepare_package(
             "intltool_domain_makefile_in_in_substitute_same_file")
         self.assertEqual(
             "domain-in-in-in-in",
-            get_translation_domain(os.path.join(package_dir, "po")))
+            get_translation_domain(backend, os.path.join(package_dir, "po")))
 
     def test_get_translation_domain_makefile_in_in_substitute_broken(self):
         # Find no translation domain in Makefile.in.in when the substitution
         # cannot be fulfilled.
+        backend = UncontainedBackend("1")
         package_dir = self.prepare_package(
             "intltool_domain_makefile_in_in_substitute_broken")
         self.assertIsNone(
-            get_translation_domain(os.path.join(package_dir, "po")))
+            get_translation_domain(backend, os.path.join(package_dir, "po")))
 
     def test_get_translation_domain_configure_in_substitute_version(self):
         # Find a translation domain in configure.in with Makefile-style
         # substitution from the same file.
+        backend = UncontainedBackend("1")
         package_dir = self.prepare_package(
             "intltool_domain_configure_in_substitute_version")
         self.assertEqual(
             "domainname-in42",
-            get_translation_domain(os.path.join(package_dir, "po")))
+            get_translation_domain(backend, os.path.join(package_dir, "po")))
 
 
 class TestGenerateTemplates(TestCase, SetupTestPackageMixin):
 
     def test_generate_pot(self):
         # Generate a given PO template.
+        backend = UncontainedBackend("1")
         package_dir = self.prepare_package("intltool_full_ok")
         self.assertTrue(
-            generate_pot(os.path.join(package_dir, "po-module1"), "module1"),
+            generate_pot(
+                backend, os.path.join(package_dir, "po-module1"), "module1"),
             "PO template generation failed.")
         expected_path = "po-module1/module1.pot"
         self.assertTrue(
@@ -307,9 +342,11 @@
 
     def test_generate_pot_no_domain(self):
         # Generate a generic PO template.
+        backend = UncontainedBackend("1")
         package_dir = self.prepare_package("intltool_full_ok")
         self.assertTrue(
-            generate_pot(os.path.join(package_dir, "po-module1"), None),
+            generate_pot(
+                backend, os.path.join(package_dir, "po-module1"), None),
             "PO template generation failed.")
         expected_path = "po-module1/messages.pot"
         self.assertTrue(
@@ -318,9 +355,10 @@
 
     def test_generate_pot_empty_domain(self):
         # Generate a generic PO template.
+        backend = UncontainedBackend("1")
         package_dir = self.prepare_package("intltool_full_ok")
         self.assertTrue(
-            generate_pot(os.path.join(package_dir, "po-module1"), ""),
+            generate_pot(backend, os.path.join(package_dir, "po-module1"), ""),
             "PO template generation failed.")
         expected_path = "po-module1/messages.pot"
         self.assertTrue(
@@ -329,11 +367,13 @@
 
     def test_generate_pot_not_intltool(self):
         # Fail when not an intltool setup.
+        backend = UncontainedBackend("1")
         package_dir = self.prepare_package("intltool_full_ok")
         # Cripple the setup.
         os.remove(os.path.join(package_dir, "po-module1/POTFILES.in"))
         self.assertFalse(
-            generate_pot(os.path.join(package_dir, "po-module1"), "nothing"),
+            generate_pot(
+                backend, os.path.join(package_dir, "po-module1"), "nothing"),
             "PO template generation should have failed.")
         not_expected_path = "po-module1/nothing.pot"
         self.assertFalse(
@@ -342,12 +382,13 @@
 
     def test_generate_pots(self):
         # Generate all PO templates in the package.
+        backend = UncontainedBackend("1")
         package_dir = self.prepare_package("intltool_full_ok")
         expected_paths = [
             'po-module1/packagename-module1.pot',
             'po-module2/packagename-module2.pot',
             ]
-        pots_list = generate_pots(package_dir)
+        pots_list = generate_pots(backend, package_dir)
         self.assertEqual(expected_paths, pots_list)
         for expected_path in expected_paths:
             self.assertTrue(

=== modified file 'lpbuildd/target/backend.py'
--- lpbuildd/target/backend.py	2017-08-29 08:50:41 +0000
+++ lpbuildd/target/backend.py	2017-09-08 15:58:48 +0000
@@ -88,6 +88,18 @@
         except subprocess.CalledProcessError:
             return False
 
+    def isdir(self, path):
+        """Test whether a path is a directory in the target environment.
+
+        :param path: the path to test, relative to the target environment's
+            root.
+        """
+        try:
+            self.run(["test", "-d", path])
+            return True
+        except subprocess.CalledProcessError:
+            return False
+
     def islink(self, path):
         """Test whether a file is a symbolic link in the target environment.
 
@@ -100,19 +112,37 @@
         except subprocess.CalledProcessError:
             return False
 
+    def find(self, path, max_depth=None, include_directories=True, name=None):
+        """Find entries in `path`.
+
+        :param path: the path to the directory to search.
+        :param max_depth: do not descend more than this number of directory
+            levels: as with find(1), 1 includes the contents of `path`, 2
+            includes the contents of its subdirectories, etc.
+        :param include_directories: include entries representing
+            directories.
+        :param name: only include entries whose name is equal to this.
+        """
+        cmd = ["find", path, "-mindepth", "1"]
+        if max_depth is not None:
+            cmd.extend(["-maxdepth", str(max_depth)])
+        if not include_directories:
+            cmd.extend(["!", "-type", "d"])
+        if name is not None:
+            cmd.extend(["-name", name])
+        cmd.extend(["-printf", "%P\\0"])
+        paths = self.run(cmd, get_output=True).rstrip(b"\0").split(b"\0")
+        # XXX cjwatson 2017-08-04: Use `os.fsdecode` instead once we're on
+        # Python 3.
+        return [p.decode("UTF-8") for p in paths]
+
     def listdir(self, path):
         """List a directory in the target environment.
 
         :param path: the path to the directory to list, relative to the
             target environment's root.
         """
-        paths = self.run(
-            ["find", path, "-mindepth", "1", "-maxdepth", "1",
-             "-printf", "%P\\0"],
-            get_output=True).rstrip(b"\0").split(b"\0")
-        # XXX cjwatson 2017-08-04: Use `os.fsdecode` instead once we're on
-        # Python 3.
-        return [p.decode("UTF-8") for p in paths]
+        return self.find(path, max_depth=1)
 
     def is_package_available(self, package):
         """Test whether a package is available in the target environment.
@@ -158,6 +188,10 @@
         # Only for use in tests.
         from lpbuildd.tests.fakeslave import FakeBackend
         backend_factory = FakeBackend
+    elif name == "uncontained":
+        # Only for use in tests.
+        from lpbuildd.tests.fakeslave import UncontainedBackend
+        backend_factory = UncontainedBackend
     else:
         raise KeyError("Unknown backend: %s" % name)
     return backend_factory(build_id, series=series, arch=arch)

=== modified file 'lpbuildd/target/cli.py'
--- lpbuildd/target/cli.py	2017-08-23 00:13:20 +0000
+++ lpbuildd/target/cli.py	2017-09-08 15:58:48 +0000
@@ -16,6 +16,9 @@
     )
 from lpbuildd.target.build_livefs import BuildLiveFS
 from lpbuildd.target.build_snap import BuildSnap
+from lpbuildd.target.generate_translation_templates import (
+    GenerateTranslationTemplates,
+    )
 from lpbuildd.target.lifecycle import (
     Create,
     KillProcesses,
@@ -48,6 +51,7 @@
     "add-trusted-keys": AddTrustedKeys,
     "buildlivefs": BuildLiveFS,
     "buildsnap": BuildSnap,
+    "generate-translation-templates": GenerateTranslationTemplates,
     "override-sources-list": OverrideSourcesList,
     "mount-chroot": Start,
     "remove-build": Remove,

=== renamed file 'lpbuildd/pottery/generate_translation_templates.py' => 'lpbuildd/target/generate_translation_templates.py'
--- lpbuildd/pottery/generate_translation_templates.py	2017-07-27 16:12:45 +0000
+++ lpbuildd/target/generate_translation_templates.py	2017-09-08 15:58:48 +0000
@@ -1,4 +1,3 @@
-#! /usr/bin/python
 # Copyright 2010-2017 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
@@ -6,59 +5,53 @@
 
 __metaclass__ = type
 
+import logging
 import os.path
-import subprocess
-import sys
-import tarfile
-
-import logging
 
 from lpbuildd.pottery import intltool
-
-
-class GenerateTranslationTemplates:
+from lpbuildd.target.operation import Operation
+
+
+logger = logging.getLogger(__name__)
+
+
+RETCODE_FAILURE_INSTALL = 200
+RETCODE_FAILURE_BUILD = 201
+
+
+class GenerateTranslationTemplates(Operation):
     """Script to generate translation templates from a branch."""
 
-    def __init__(self, branch_spec, result_name, work_dir, log_file=None):
-        """Prepare to generate templates for a branch.
-
-        :param branch_spec: Either a branch URL or the path of a local
-            branch.  URLs are recognized by the occurrence of ':'.  In
-            the case of a URL, this will make up a path for the branch
-            and check out the branch to there.
-        :param result_name: The name of the result tarball. Should end in
-            .tar.gz.
-        :param work_dir: The directory to work in. Must exist.
-        :param log_file: File-like object to log to. If None, defaults to
-            stderr.
-        """
-        self.work_dir = work_dir
-        self.branch_spec = branch_spec
-        self.result_name = result_name
-        self.logger = self._setupLogger(log_file)
-
-    def _setupLogger(self, log_file):
-        """Sets up and returns a logger."""
-        if log_file is None:
-            log_file = sys.stderr
-        logger = logging.getLogger("generate-templates")
-        logger.setLevel(logging.DEBUG)
-        ch = logging.StreamHandler(log_file)
-        ch.setLevel(logging.DEBUG)
-        logger.addHandler(ch)
-        return logger
+    description = "Generate templates for a branch."
+
+    @classmethod
+    def add_arguments(cls, parser):
+        super(GenerateTranslationTemplates, cls).add_arguments(parser)
+        parser.add_argument(
+            "branch_spec", help=(
+                "A branch URL or the path of a local branch.  URLs are "
+                "recognised by the occurrence of ':'.  In the case of a URL, "
+                "this will make up a path for the branch and check out the "
+                "branch to there."))
+        parser.add_argument(
+            "result_name",
+            help="The name of the result tarball.  Should end in '.tar.gz'.")
+
+    def __init__(self, args, parser):
+        super(GenerateTranslationTemplates, self).__init__(args, parser)
+        self.work_dir = os.environ["HOME"]
 
     def _getBranch(self):
         """Set `self.branch_dir`, and check out branch if needed."""
-        if ':' in self.branch_spec:
+        if ':' in self.args.branch_spec:
             # This is a branch URL.  Check out the branch.
             self.branch_dir = os.path.join(self.work_dir, 'source-tree')
-            self.logger.info("Getting remote branch %s..." % self.branch_spec)
-            self._checkout(self.branch_spec)
+            logger.info("Getting remote branch %s..." % self.args.branch_spec)
+            self._checkout(self.args.branch_spec)
         else:
             # This is a local filesystem path.  Use the branch in-place.
-            self.logger.info("Using local branch %s..." % self.branch_spec)
-            self.branch_dir = self.branch_spec
+            logger.info("Using local branch %s..." % self.args.branch_spec)
+            self.branch_dir = self.args.branch_spec
 
     def _checkout(self, branch_url):
         """Check out a source branch to generate from.
@@ -66,47 +59,41 @@
         The branch is checked out to the location specified by
         `self.branch_dir`.
         """
-        self.logger.info(
+        logger.info(
             "Exporting branch %s to %s..." % (branch_url, self.branch_dir))
-        subprocess.check_call(
+        self.backend.run(
             ["bzr", "export", "-q", "-d", branch_url, self.branch_dir])
-        self.logger.info("Exporting branch done.")
+        logger.info("Exporting branch done.")
 
     def _makeTarball(self, files):
         """Put the given files into a tarball in the working directory."""
-        tarname = os.path.join(self.work_dir, self.result_name)
-        self.logger.info("Making tarball with templates in %s..." % tarname)
-        tarball = tarfile.open(tarname, 'w|gz')
+        tarname = os.path.join(self.work_dir, self.args.result_name)
+        logger.info("Making tarball with templates in %s..." % tarname)
+        cmd = ["tar", "-C", self.branch_dir, "-czf", tarname]
         files = [name for name in files if not name.endswith('/')]
         for path in files:
             full_path = os.path.join(self.branch_dir, path)
-            self.logger.info("Adding template %s..." % full_path)
-            tarball.add(full_path, path)
-        tarball.close()
-        self.logger.info("Tarball generated.")
+            logger.info("Adding template %s..." % full_path)
+            cmd.append(path)
+        self.backend.run(cmd)
+        logger.info("Tarball generated.")
 
-    def generate(self):
+    def run(self):
         """Do It.  Generate templates."""
-        self.logger.info("Generating templates for %s." % self.branch_spec)
-        self._getBranch()
-        pots = intltool.generate_pots(self.branch_dir)
-        self.logger.info("Generated %d templates." % len(pots))
-        if len(pots) > 0:
-            self._makeTarball(pots)
+        try:
+            self.backend.run(["apt-get", "-y", "install", "bzr", "intltool"])
+        except Exception:
+            logger.exception("Install failed")
+            return RETCODE_FAILURE_INSTALL
+        try:
+            logger.info(
+                "Generating templates for %s." % self.args.branch_spec)
+            self._getBranch()
+            pots = intltool.generate_pots(self.backend, self.branch_dir)
+            logger.info("Generated %d templates." % len(pots))
+            if len(pots) > 0:
+                self._makeTarball(pots)
+        except Exception:
+            logger.exception("Build failed")
+            return RETCODE_FAILURE_BUILD
         return 0
-
-
-if __name__ == '__main__':
-    if len(sys.argv) < 3:
-        print("Usage: %s branch resultname [workdir]" % sys.argv[0])
-        print("  'branch' is a branch URL or directory.")
-        print("  'resultname' is the name of the result tarball.")
-        print("  'workdir' is a directory, defaults to HOME.")
-        sys.exit(1)
-    if len(sys.argv) == 4:
-        workdir = sys.argv[3]
-    else:
-        workdir = os.environ['HOME']
-    script = GenerateTranslationTemplates(
-        sys.argv[1], sys.argv[2], workdir)
-    sys.exit(script.generate())

=== modified file 'lpbuildd/target/operation.py'
--- lpbuildd/target/operation.py	2017-08-23 00:22:11 +0000
+++ lpbuildd/target/operation.py	2017-09-08 15:58:48 +0000
@@ -16,7 +16,7 @@
     @classmethod
     def add_arguments(cls, parser):
         parser.add_argument(
-            "--backend", choices=["chroot", "lxd", "fake"],
+            "--backend", choices=["chroot", "lxd", "fake", "uncontained"],
             help="use this type of backend")
         parser.add_argument(
             "--series", metavar="SERIES", help="operate on series SERIES")

=== renamed file 'lpbuildd/pottery/tests/dummy_templates.tar.gz' => 'lpbuildd/target/tests/dummy_templates.tar.gz'
=== modified file 'lpbuildd/target/tests/test_chroot.py'
--- lpbuildd/target/tests/test_chroot.py	2017-08-29 08:50:41 +0000
+++ lpbuildd/target/tests/test_chroot.py	2017-09-08 15:58:48 +0000
@@ -173,6 +173,24 @@
             expected_args,
             [proc._args["args"] for proc in processes_fixture.procs])
 
+    def test_isdir(self):
+        self.useFixture(EnvironmentVariable("HOME", "/expected/home"))
+        processes_fixture = self.useFixture(FakeProcesses())
+        test_proc_infos = iter([{}, {"returncode": 1}])
+        processes_fixture.add(lambda _: next(test_proc_infos), name="sudo")
+        self.assertTrue(Chroot("1", "xenial", "amd64").isdir("/dir"))
+        self.assertFalse(Chroot("1", "xenial", "amd64").isdir("/file"))
+
+        expected_args = [
+            ["sudo", "/usr/sbin/chroot",
+             "/expected/home/build-1/chroot-autobuild",
+             "linux64", "test", "-d", path]
+            for path in ("/dir", "/file")
+            ]
+        self.assertEqual(
+            expected_args,
+            [proc._args["args"] for proc in processes_fixture.procs])
+
     def test_islink(self):
         self.useFixture(EnvironmentVariable("HOME", "/expected/home"))
         processes_fixture = self.useFixture(FakeProcesses())
@@ -191,6 +209,46 @@
             expected_args,
             [proc._args["args"] for proc in processes_fixture.procs])
 
+    def test_find(self):
+        self.useFixture(EnvironmentVariable("HOME", "/expected/home"))
+        processes_fixture = self.useFixture(FakeProcesses())
+        test_proc_infos = iter([
+            {"stdout": io.BytesIO(b"foo\0bar\0bar/bar\0bar/baz\0")},
+            {"stdout": io.BytesIO(b"foo\0bar\0")},
+            {"stdout": io.BytesIO(b"foo\0bar/bar\0bar/baz\0")},
+            {"stdout": io.BytesIO(b"bar\0bar/bar\0")},
+            ])
+        processes_fixture.add(lambda _: next(test_proc_infos), name="sudo")
+        self.assertEqual(
+            ["foo", "bar", "bar/bar", "bar/baz"],
+            Chroot("1", "xenial", "amd64").find("/path"))
+        self.assertEqual(
+            ["foo", "bar"],
+            Chroot("1", "xenial", "amd64").find("/path", max_depth=1))
+        self.assertEqual(
+            ["foo", "bar/bar", "bar/baz"],
+            Chroot("1", "xenial", "amd64").find(
+                "/path", include_directories=False))
+        self.assertEqual(
+            ["bar", "bar/bar"],
+            Chroot("1", "xenial", "amd64").find("/path", name="bar"))
+
+        find_prefix = [
+            "sudo", "/usr/sbin/chroot",
+            "/expected/home/build-1/chroot-autobuild",
+            "linux64", "find", "/path", "-mindepth", "1",
+            ]
+        find_suffix = ["-printf", "%P\\0"]
+        expected_args = [
+            find_prefix + find_suffix,
+            find_prefix + ["-maxdepth", "1"] + find_suffix,
+            find_prefix + ["!", "-type", "d"] + find_suffix,
+            find_prefix + ["-name", "bar"] + find_suffix,
+            ]
+        self.assertEqual(
+            expected_args,
+            [proc._args["args"] for proc in processes_fixture.procs])
+
     def test_listdir(self):
         self.useFixture(EnvironmentVariable("HOME", "/expected/home"))
         processes_fixture = self.useFixture(FakeProcesses())

=== renamed file 'lpbuildd/pottery/tests/test_generate_translation_templates.py' => 'lpbuildd/target/tests/test_generate_translation_templates.py'
--- lpbuildd/pottery/tests/test_generate_translation_templates.py	2017-07-27 16:12:45 +0000
+++ lpbuildd/target/tests/test_generate_translation_templates.py	2017-09-08 15:58:48 +0000
@@ -4,55 +4,72 @@
 __metaclass__ = type
 
 import os
-from StringIO import StringIO
 import subprocess
-import sys
 import tarfile
 
 from fixtures import (
     EnvironmentVariable,
+    FakeLogger,
     TempDir,
     )
 from testtools import TestCase
 from testtools.matchers import (
+    ContainsDict,
+    EndsWith,
     Equals,
+    MatchesListwise,
     MatchesSetwise,
     )
 
-from lpbuildd import pottery
-from lpbuildd.pottery.generate_translation_templates import (
-    GenerateTranslationTemplates,
-    )
+from lpbuildd.target.cli import parse_args
 from lpbuildd.tests.fakeslave import FakeMethod
 
 
+class MatchesCall(MatchesListwise):
+
+    def __init__(self, *args, **kwargs):
+        super(MatchesCall, self).__init__([
+            Equals(args),
+            ContainsDict(
+                {name: Equals(value) for name, value in kwargs.items()})])
+
+
 class TestGenerateTranslationTemplates(TestCase):
     """Test generate-translation-templates script."""
 
     result_name = "translation-templates.tar.gz"
 
+    def setUp(self):
+        super(TestGenerateTranslationTemplates, self).setUp()
+        self.home_dir = self.useFixture(TempDir()).path
+        self.useFixture(EnvironmentVariable("HOME", self.home_dir))
+        self.logger = self.useFixture(FakeLogger())
+
     def test_getBranch_url(self):
         # If passed a branch URL, the template generation script will
         # check out that branch into a directory called "source-tree."
-        branch_url = 'lp://~my/translation/branch'
-
-        generator = GenerateTranslationTemplates(
-            branch_url, self.result_name, self.useFixture(TempDir()).path,
-            log_file=StringIO())
+        args = [
+            "generate-translation-templates",
+            "--backend=fake", "--series=xenial", "--arch=amd64", "1",
+            "lp://~my/translation/branch", self.result_name,
+            ]
+        generator = parse_args(args=args).operation
         generator._checkout = FakeMethod()
         generator._getBranch()
 
         self.assertEqual(1, generator._checkout.call_count)
-        self.assertTrue(generator.branch_dir.endswith('source-tree'))
+        self.assertThat(generator.branch_dir, EndsWith("source-tree"))
 
     def test_getBranch_dir(self):
         # If passed a branch directory, the template generation script
         # works directly in that directory.
-        branch_dir = '/home/me/branch'
-
-        generator = GenerateTranslationTemplates(
-            branch_dir, self.result_name, self.useFixture(TempDir()).path,
-            log_file=StringIO())
+        branch_dir = "/home/me/branch"
+        args = [
+            "generate-translation-templates",
+            "--backend=fake", "--series=xenial", "--arch=amd64", "1",
+            branch_dir, self.result_name,
+            ]
+        generator = parse_args(args=args).operation
         generator._checkout = FakeMethod()
         generator._getBranch()
 
@@ -97,9 +114,12 @@
         marker_text = "Ceci n'est pas cet branch."
         branch_url = self._createBranch({'marker.txt': marker_text})
 
-        generator = GenerateTranslationTemplates(
-            branch_url, self.result_name, self.useFixture(TempDir()).path,
-            log_file=StringIO())
+        args = [
+            "generate-translation-templates",
+            "--backend=uncontained", "--series=xenial", "--arch=amd64", "1",
+            branch_url, self.result_name,
+            ]
+        generator = parse_args(args=args).operation
         generator._getBranch()
 
         marker_path = os.path.join(generator.branch_dir, 'marker.txt')
@@ -108,8 +128,7 @@
 
     def test_templates_tarball(self):
         # Create a tarball from pot files.
-        workdir = self.useFixture(TempDir()).path
-        branchdir = os.path.join(workdir, 'branchdir')
+        branchdir = os.path.join(self.home_dir, 'branchdir')
         dummy_tar = os.path.join(
             os.path.dirname(__file__), 'dummy_templates.tar.gz')
         with tarfile.open(dummy_tar, 'r|*') as tar:
@@ -118,24 +137,47 @@
                 member.name
                 for member in tar.getmembers() if not member.isdir()]
 
-        generator = GenerateTranslationTemplates(
-            branchdir, self.result_name, workdir, log_file=StringIO())
+        args = [
+            "generate-translation-templates",
+            "--backend=uncontained", "--series=xenial", "--arch=amd64", "1",
+            branchdir, self.result_name,
+            ]
+        generator = parse_args(args=args).operation
         generator._getBranch()
         generator._makeTarball(potnames)
-        result_path = os.path.join(workdir, self.result_name)
+        result_path = os.path.join(self.home_dir, self.result_name)
         with tarfile.open(result_path, 'r|*') as tar:
             tarnames = tar.getnames()
         self.assertThat(tarnames, MatchesSetwise(*(map(Equals, potnames))))
 
-    def test_script(self):
-        tempdir = self.useFixture(TempDir()).path
-        workdir = self.useFixture(TempDir()).path
-        command = [
-            sys.executable,
-            os.path.join(
-                os.path.dirname(pottery.__file__),
-                'generate_translation_templates.py'),
-            tempdir, self.result_name, workdir]
-        retval = subprocess.call(
-            command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
-        self.assertEqual(0, retval)
+    def test_run(self):
+        # Install dependencies and generate a templates tarball.
+        branch_url = "lp:~my/branch"
+        branch_dir = os.path.join(self.home_dir, "source-tree")
+        po_dir = os.path.join(branch_dir, "po")
+        result_path = os.path.join(self.home_dir, self.result_name)
+
+        args = [
+            "generate-translation-templates",
+            "--backend=fake", "--series=xenial", "--arch=amd64", "1",
+            branch_url, self.result_name,
+            ]
+        generator = parse_args(args=args).operation
+        generator.backend.add_file(os.path.join(po_dir, "POTFILES.in"), "")
+        generator.backend.add_file(
+            os.path.join(po_dir, "Makevars"), "DOMAIN = test\n")
+        generator.run()
+        self.assertThat(generator.backend.run.calls, MatchesListwise([
+            MatchesCall(["apt-get", "-y", "install", "bzr", "intltool"]),
+            MatchesCall(
+                ["bzr", "export", "-q", "-d", "lp:~my/branch", branch_dir]),
+            MatchesCall(
+                ["rm", "-f",
+                 os.path.join(po_dir, "missing"),
+                 os.path.join(po_dir, "notexist")]),
+            MatchesCall(["/usr/bin/intltool-update", "-m"], cwd=po_dir),
+            MatchesCall(
+                ["/usr/bin/intltool-update", "-p", "-g", "test"], cwd=po_dir),
+            MatchesCall(
+                ["tar", "-C", branch_dir, "-czf", result_path, "po/test.pot"]),
+            ]))

=== modified file 'lpbuildd/target/tests/test_lxd.py'
--- lpbuildd/target/tests/test_lxd.py	2017-09-08 00:04:23 +0000
+++ lpbuildd/target/tests/test_lxd.py	2017-09-08 15:58:48 +0000
@@ -397,6 +397,22 @@
             expected_args,
             [proc._args["args"] for proc in processes_fixture.procs])
 
+    def test_isdir(self):
+        processes_fixture = self.useFixture(FakeProcesses())
+        test_proc_infos = iter([{}, {"returncode": 1}])
+        processes_fixture.add(lambda _: next(test_proc_infos), name="lxc")
+        self.assertTrue(LXD("1", "xenial", "amd64").isdir("/dir"))
+        self.assertFalse(LXD("1", "xenial", "amd64").isdir("/file"))
+
+        expected_args = [
+            ["lxc", "exec", "lp-xenial-amd64", "--",
+             "linux64", "test", "-d", path]
+            for path in ("/dir", "/file")
+            ]
+        self.assertEqual(
+            expected_args,
+            [proc._args["args"] for proc in processes_fixture.procs])
+
     def test_islink(self):
         processes_fixture = self.useFixture(FakeProcesses())
         test_proc_infos = iter([{}, {"returncode": 1}])
@@ -413,6 +429,45 @@
             expected_args,
             [proc._args["args"] for proc in processes_fixture.procs])
 
+    def test_find(self):
+        self.useFixture(EnvironmentVariable("HOME", "/expected/home"))
+        processes_fixture = self.useFixture(FakeProcesses())
+        test_proc_infos = iter([
+            {"stdout": io.BytesIO(b"foo\0bar\0bar/bar\0bar/baz\0")},
+            {"stdout": io.BytesIO(b"foo\0bar\0")},
+            {"stdout": io.BytesIO(b"foo\0bar/bar\0bar/baz\0")},
+            {"stdout": io.BytesIO(b"bar\0bar/bar\0")},
+            ])
+        processes_fixture.add(lambda _: next(test_proc_infos), name="lxc")
+        self.assertEqual(
+            ["foo", "bar", "bar/bar", "bar/baz"],
+            LXD("1", "xenial", "amd64").find("/path"))
+        self.assertEqual(
+            ["foo", "bar"],
+            LXD("1", "xenial", "amd64").find("/path", max_depth=1))
+        self.assertEqual(
+            ["foo", "bar/bar", "bar/baz"],
+            LXD("1", "xenial", "amd64").find(
+                "/path", include_directories=False))
+        self.assertEqual(
+            ["bar", "bar/bar"],
+            LXD("1", "xenial", "amd64").find("/path", name="bar"))
+
+        find_prefix = [
+            "lxc", "exec", "lp-xenial-amd64", "--",
+            "linux64", "find", "/path", "-mindepth", "1",
+            ]
+        find_suffix = ["-printf", "%P\\0"]
+        expected_args = [
+            find_prefix + find_suffix,
+            find_prefix + ["-maxdepth", "1"] + find_suffix,
+            find_prefix + ["!", "-type", "d"] + find_suffix,
+            find_prefix + ["-name", "bar"] + find_suffix,
+            ]
+        self.assertEqual(
+            expected_args,
+            [proc._args["args"] for proc in processes_fixture.procs])
+
     def test_listdir(self):
         processes_fixture = self.useFixture(FakeProcesses())
         processes_fixture.add(

=== modified file 'lpbuildd/tests/fakeslave.py'
--- lpbuildd/tests/fakeslave.py	2017-08-05 09:43:43 +0000
+++ lpbuildd/tests/fakeslave.py	2017-09-08 15:58:48 +0000
@@ -3,16 +3,23 @@
 
 __metaclass__ = type
 __all__ = [
+    'FakeBackend',
     'FakeMethod',
     'FakeSlave',
+    'UncontainedBackend',
     ]
 
 import hashlib
 import os
 import shutil
 import stat
+import subprocess
 
 from lpbuildd.target.backend import Backend
+from lpbuildd.util import (
+    set_personality,
+    shell_escape,
+    )
 
 
 class FakeMethod:
@@ -162,11 +169,75 @@
         except KeyError:
             return False
 
+    def isdir(self, path):
+        _, mode = self.backend_fs.get(path, (b"", 0))
+        return stat.S_ISDIR(mode)
+
     def islink(self, path):
         _, mode = self.backend_fs.get(path, (b"", 0))
         return stat.S_ISLNK(mode)
 
-    def listdir(self, path):
+    def find(self, path, max_depth=None, include_directories=True, name=None):
+        def match(backend_path, mode):
+            rel_path = os.path.relpath(backend_path, path)
+            if rel_path == os.sep or os.path.dirname(rel_path) == os.pardir:
+                return False
+            if max_depth is not None:
+                if rel_path.count(os.sep) + 1 > max_depth:
+                    return False
+            if not include_directories:
+                if stat.S_ISDIR(mode):
+                    return False
+            if name is not None:
+                if os.path.basename(rel_path) != name:
+                    return False
+            return True
+
         return [
-            os.path.basename(p) for p in self.backend_fs
-            if os.path.dirname(p) == path]
+            os.path.relpath(backend_path, path)
+            for backend_path, (_, mode) in self.backend_fs.items()
+            if match(backend_path, mode)]
+
+
+class UncontainedBackend(Backend):
+    """A partial backend implementation with no containment."""
+
+    def run(self, args, env=None, input_text=None, get_output=False,
+            echo=False, **kwargs):
+        """See `Backend`."""
+        if env:
+            args = ["env"] + [
+                "%s=%s" % (key, shell_escape(value))
+                for key, value in env.items()] + args
+        if self.arch is not None:
+            args = set_personality(args, self.arch, series=self.series)
+        if input_text is None and not get_output:
+            subprocess.check_call(args, **kwargs)
+        else:
+            if get_output:
+                kwargs["stdout"] = subprocess.PIPE
+            proc = subprocess.Popen(args, stdin=subprocess.PIPE, **kwargs)
+            output, _ = proc.communicate(input_text)
+            if proc.returncode:
+                raise subprocess.CalledProcessError(proc.returncode, args)
+            if get_output:
+                return output
+
+    def _copy(self, source_path, target_path):
+        if source_path == target_path:
+            raise Exception(
+                "TrivialBackend copy operations require source_path and "
+                "target_path to differ.")
+        subprocess.check_call(
+            ["cp", "--preserve=timestamps", source_path, target_path])
+
+    def copy_in(self, source_path, target_path):
+        """See `Backend`."""
+        self._copy(source_path, target_path)
+
+    def copy_out(self, source_path, target_path):
+        """See `Backend`."""
+        self._copy(source_path, target_path)
+
+    def remove(self):
+        raise NotImplementedError

=== modified file 'lpbuildd/tests/test_translationtemplatesbuildmanager.py'
--- lpbuildd/tests/test_translationtemplatesbuildmanager.py	2017-09-08 15:58:48 +0000
+++ lpbuildd/tests/test_translationtemplatesbuildmanager.py	2017-09-08 15:58:48 +0000
@@ -11,11 +11,13 @@
     )
 from testtools import TestCase
 
+from lpbuildd.target.generate_translation_templates import (
+    RETCODE_FAILURE_BUILD,
+    RETCODE_FAILURE_INSTALL,
+    )
 from lpbuildd.tests.fakeslave import FakeSlave
 from lpbuildd.tests.matchers import HasWaitingFiles
 from lpbuildd.translationtemplates import (
-    RETCODE_FAILURE_BUILD,
-    RETCODE_FAILURE_INSTALL,
     TranslationTemplatesBuildManager,
     TranslationTemplatesBuildState,
     )
@@ -76,9 +78,11 @@
         self.assertEqual(
             TranslationTemplatesBuildState.GENERATE, self.getState())
         expected_command = [
-            'sharepath/slavebin/generate-translation-templates',
-            'sharepath/slavebin/generate-translation-templates',
-            self.buildid, url, 'resultarchive'
+            'sharepath/slavebin/in-target', 'in-target',
+            'generate-translation-templates',
+            '--backend=chroot', '--series=xenial', '--arch=i386',
+            self.buildid,
+            url, 'resultarchive',
             ]
         self.assertEqual(expected_command, self.buildmanager.commands[-1])
         self.assertEqual(

=== modified file 'lpbuildd/translationtemplates.py'
--- lpbuildd/translationtemplates.py	2017-09-08 15:58:48 +0000
+++ lpbuildd/translationtemplates.py	2017-09-08 15:58:48 +0000
@@ -9,10 +9,10 @@
     DebianBuildManager,
     DebianBuildState,
     )
-
-
-RETCODE_FAILURE_INSTALL = 200
-RETCODE_FAILURE_BUILD = 201
+from lpbuildd.target.generate_translation_templates import (
+    RETCODE_FAILURE_BUILD,
+    RETCODE_FAILURE_INSTALL,
+    )
 
 
 class TranslationTemplatesBuildState(DebianBuildState):
@@ -31,8 +31,6 @@
 
     def __init__(self, slave, buildid):
         super(TranslationTemplatesBuildManager, self).__init__(slave, buildid)
-        self._generatepath = os.path.join(
-            self._slavebin, "generate-translation-templates")
         self._resultname = slave._config.get(
             "translationtemplatesmanager", "resultarchive")
 
@@ -45,10 +43,9 @@
 
     def doGenerate(self):
         """Generate templates."""
-        command = [
-            self._generatepath,
-            self._buildid, self._branch_url, self._resultname]
-        self.runSubProcess(self._generatepath, command)
+        self.runTargetSubProcess(
+            "generate-translation-templates",
+            self._branch_url, self._resultname)
 
     # Satisfy DebianPackageManager's needs without having a misleading
     # method name here.