← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~twom/launchpad-buildd:add-charm-build into launchpad-buildd:master

 

Tom Wardill has proposed merging ~twom/launchpad-buildd:add-charm-build into launchpad-buildd:master.

Commit message:
Add charm building

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~twom/launchpad-buildd/+git/launchpad-buildd/+merge/403811

Add building for charms using charmcraft with local network support.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~twom/launchpad-buildd:add-charm-build into launchpad-buildd:master.
diff --git a/debian/changelog b/debian/changelog
index 26c21ed..6543629 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,3 +1,9 @@
+launchpad-buildd (197) UNRELEASED; urgency=medium
+
+  * Add charm building support
+
+ -- Tom Wardill <tom.wardill@xxxxxxxxxxxxx>  Mon, 07 Jun 2021 10:22:50 +0100
+
 launchpad-buildd (196) bionic; urgency=medium
 
   * Handle symlinks in OCI image files
diff --git a/lpbuildd/buildd-slave.tac b/lpbuildd/buildd-slave.tac
index 3299b0d..7c1947e 100644
--- a/lpbuildd/buildd-slave.tac
+++ b/lpbuildd/buildd-slave.tac
@@ -23,6 +23,7 @@ from twisted.web import (
 
 from lpbuildd.binarypackage import BinaryPackageBuildManager
 from lpbuildd.builder import XMLRPCBuilder
+from lpbuildd.charm import CharmBuildManager
 from lpbuildd.oci import OCIBuildManager
 from lpbuildd.livefs import LiveFilesystemBuildManager
 from lpbuildd.log import RotatableFileLogObserver
@@ -47,6 +48,7 @@ builder.registerManager(
 builder.registerManager(LiveFilesystemBuildManager, "livefs")
 builder.registerManager(SnapBuildManager, "snap")
 builder.registerManager(OCIBuildManager, "oci")
+builder.registerManager(CharmBuildManager, "charm")
 
 application = service.Application('Builder')
 application.addComponent(
diff --git a/lpbuildd/charm.py b/lpbuildd/charm.py
new file mode 100644
index 0000000..a38c26f
--- /dev/null
+++ b/lpbuildd/charm.py
@@ -0,0 +1,91 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+from __future__ import print_function
+
+__metaclass__ = type
+
+import os
+
+from lpbuildd.debian import (
+    DebianBuildState,
+    DebianBuildManager,
+    )
+
+
+RETCODE_SUCCESS = 0
+RETCODE_FAILURE_INSTALL = 200
+RETCODE_FAILURE_BUILD = 201
+
+
+class CharmBuildState(DebianBuildState):
+    BUILD_CHARM = "BUILD_CHARM"
+
+
+class CharmBuildManager(DebianBuildManager):
+    """Build a charm."""
+
+    backend_name = "lxd"
+    initial_build_state = CharmBuildState.BUILD_CHARM
+
+    @property
+    def needs_sanitized_logs(self):
+        return True
+
+    def initiate(self, files, chroot, extra_args):
+        """Initiate a build with a given set of files and chroot."""
+        self.name = extra_args["name"]
+        self.branch = extra_args.get("branch")
+        self.git_repository = extra_args.get("git_repository")
+        self.git_path = extra_args.get("git_path")
+        self.build_path = extra_args.get("build_path")
+
+        super(CharmBuildManager, self).initiate(files, chroot, extra_args)
+
+    def doRunBuild(self):
+        """Run the process to build the charm."""
+        args = []
+        if self.branch is not None:
+            args.extend(["--branch", self.branch])
+        if self.git_repository is not None:
+            args.extend(["--git-repository", self.git_repository])
+        if self.git_path is not None:
+            args.extend(["--git-path", self.git_path])
+        if self.build_path is not None:
+            args.extend(["--build-path", self.build_path])
+        args.append(self.name)
+        self.runTargetSubProcess("build-charm", *args)
+
+    def iterate_BUILD_CHARM(self, retcode):
+        """Finished building the charm."""
+        if retcode == RETCODE_SUCCESS:
+            print("Returning build status: OK")
+            return self.deferGatherResults()
+        elif (retcode >= RETCODE_FAILURE_INSTALL and
+              retcode <= RETCODE_FAILURE_BUILD):
+            if not self.alreadyfailed:
+                self._builder.buildFail()
+                print("Returning build status: Build failed.")
+            self.alreadyfailed = True
+        else:
+            if not self.alreadyfailed:
+                self._builder.builderFail()
+                print("Returning build status: Builder failed.")
+            self.alreadyfailed = True
+        self.doReapProcesses(self._state)
+
+    def iterateReap_BUILD_CHARM(self, retcode):
+        """Finished reaping after building the charm."""
+        self._state = DebianBuildState.UMOUNT
+        self.doUnmounting()
+
+    def gatherResults(self):
+        """Gather the results of the build and add them to the file cache."""
+        output_path = os.path.join("/home/buildd", self.name)
+        if self.backend.path_exists(output_path):
+            for entry in sorted(self.backend.listdir(output_path)):
+                path = os.path.join(output_path, entry)
+                if self.backend.islink(path):
+                    continue
+                if entry.endswith(".charm") or entry.endswith(".manifest"):
+                    self.addWaitingFileFromBackend(path)
diff --git a/lpbuildd/oci.py b/lpbuildd/oci.py
index 7a17939..53198c8 100644
--- a/lpbuildd/oci.py
+++ b/lpbuildd/oci.py
@@ -59,7 +59,7 @@ class OCIBuildManager(SnapBuildProxyMixin, DebianBuildManager):
         super(OCIBuildManager, self).initiate(files, chroot, extra_args)
 
     def doRunBuild(self):
-        """Run the process to build the snap."""
+        """Run the process to build the OCI image."""
         args = []
         args.extend(self.startProxy())
         if self.revocation_endpoint:
diff --git a/lpbuildd/target/backend.py b/lpbuildd/target/backend.py
index 8356542..eb2cb2e 100644
--- a/lpbuildd/target/backend.py
+++ b/lpbuildd/target/backend.py
@@ -13,6 +13,10 @@ class BackendException(Exception):
     pass
 
 
+class InvalidBuildFilePath(Exception):
+    pass
+
+
 class Backend:
     """A backend implementation for the environment where we run builds."""
 
diff --git a/lpbuildd/target/build_charm.py b/lpbuildd/target/build_charm.py
new file mode 100644
index 0000000..f5eec28
--- /dev/null
+++ b/lpbuildd/target/build_charm.py
@@ -0,0 +1,116 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+from __future__ import print_function
+import functools
+
+__metaclass__ = type
+
+from collections import OrderedDict
+import logging
+import os
+import sys
+
+from lpbuildd.target.backend import InvalidBuildFilePath
+from lpbuildd.target.operation import Operation
+from lpbuildd.target.vcs import VCSOperationMixin
+
+
+RETCODE_FAILURE_INSTALL = 200
+RETCODE_FAILURE_BUILD = 201
+
+
+logger = logging.getLogger(__name__)
+
+
+class BuildCharm(VCSOperationMixin, Operation):
+
+    description = "Build a charm."
+
+    # charmcraft is a snap, so we'll need these
+    core_snap_names = ["core", "core20"]
+
+    @classmethod
+    def add_arguments(cls, parser):
+        super(BuildCharm, cls).add_arguments(parser)
+        parser.add_argument(
+            "--build-path", default=".",
+            help="location of charm to build.")
+        parser.add_argument("name", help="name of charm to build")
+
+    def __init__(self, args, parser):
+        super(BuildCharm, self).__init__(args, parser)
+        self.bin = os.path.dirname(sys.argv[0])
+        self.buildd_path = os.path.join("/home/buildd", self.args.name)
+
+    def _check_path_escape(self, path_to_check):
+        """Check the build file path doesn't escape the build directory."""
+        build_file_path = os.path.realpath(
+            os.path.join(self.buildd_path, path_to_check))
+        common_path = os.path.commonprefix((build_file_path, self.buildd_path))
+        if common_path != self.buildd_path:
+            raise InvalidBuildFilePath("Invalid build file path.")
+
+    def run_build_command(self, args, env=None, build_path=None, **kwargs):
+        """Run a build command in the target.
+
+        :param args: the command and arguments to run.
+        :param env: dictionary of additional environment variables to set.
+        :param kwargs: any other keyword arguments to pass to Backend.run.
+        """
+        full_env = OrderedDict()
+        full_env["LANG"] = "C.UTF-8"
+        full_env["SHELL"] = "/bin/sh"
+        if env:
+            full_env.update(env)
+        return self.backend.run(
+            args, cwd=self.buildd_path, env=full_env, **kwargs)
+
+    def install(self):
+        logger.info("Running install phase")
+        deps = []
+        if self.args.backend == "lxd":
+            # udev is installed explicitly to work around
+            # https://bugs.launchpad.net/snapd/+bug/1731519.
+            for dep in "snapd", "fuse", "squashfuse", "udev":
+                if self.backend.is_package_available(dep):
+                    deps.append(dep)
+        deps.extend(self.vcs_deps)
+        self.backend.run(["apt-get", "-y", "install"] + deps)
+        for snap_name in self.core_snap_names:
+            self.backend.run(["snap", "install", snap_name])
+        self.backend.run(
+            ["snap", "install", "charmcraft"])
+        # The charmcraft snap can't see /build, so we have to do our work under
+        # /home/buildd instead.  Make sure it exists.
+        self.backend.run(["mkdir", "-p", "/home/buildd"])
+
+    def repo(self):
+        """Collect git or bzr branch."""
+        logger.info("Running repo phase...")
+        self.vcs_fetch(self.args.name, cwd="/home/buildd")
+
+    def build(self):
+        logger.info("Running build phase...")
+        build_context_path = os.path.join(
+            "/home/buildd",
+            self.args.name,
+            self.args.build_path)
+        self._check_path_escape(build_context_path)
+        args = ["charmcraft", "build", "-f", build_context_path]
+        self.run_build_command(args)
+
+    def run(self):
+        try:
+            self.install()
+        except Exception:
+            logger.exception('Install failed')
+            return RETCODE_FAILURE_INSTALL
+        try:
+            self.repo()
+            self.build()
+        except Exception:
+            logger.exception('Build failed')
+            return RETCODE_FAILURE_BUILD
+        return 0
+
diff --git a/lpbuildd/target/build_oci.py b/lpbuildd/target/build_oci.py
index af56671..275afea 100644
--- a/lpbuildd/target/build_oci.py
+++ b/lpbuildd/target/build_oci.py
@@ -12,6 +12,7 @@ import sys
 import tempfile
 from textwrap import dedent
 
+from lpbuildd.target.backend import InvalidBuildFilePath
 from lpbuildd.target.operation import Operation
 from lpbuildd.target.snapbuildproxy import SnapBuildProxyOperationMixin
 from lpbuildd.target.snapstore import SnapStoreOperationMixin
@@ -25,10 +26,6 @@ RETCODE_FAILURE_BUILD = 201
 logger = logging.getLogger(__name__)
 
 
-class InvalidBuildFilePath(Exception):
-    pass
-
-
 class BuildOCI(SnapBuildProxyOperationMixin, VCSOperationMixin,
                SnapStoreOperationMixin, Operation):
 
@@ -47,7 +44,7 @@ class BuildOCI(SnapBuildProxyOperationMixin, VCSOperationMixin,
             help="A docker build ARG in the format of key=value. "
                  "This option can be repeated many times. For example: "
                  "--build-arg VAR1=A --build-arg VAR2=B")
-        parser.add_argument("name", help="name of snap to build")
+        parser.add_argument("name", help="name of image to build")
 
     def __init__(self, args, parser):
         super(BuildOCI, self).__init__(args, parser)
diff --git a/lpbuildd/target/cli.py b/lpbuildd/target/cli.py
index 94b291b..85e5b26 100644
--- a/lpbuildd/target/cli.py
+++ b/lpbuildd/target/cli.py
@@ -14,6 +14,7 @@ from lpbuildd.target.apt import (
     OverrideSourcesList,
     Update,
     )
+from lpbuildd.target.build_charm import BuildCharm
 from lpbuildd.target.build_oci import BuildOCI
 from lpbuildd.target.build_livefs import BuildLiveFS
 from lpbuildd.target.build_snap import BuildSnap
@@ -51,6 +52,7 @@ def configure_logging():
 operations = {
     "add-trusted-keys": AddTrustedKeys,
     "build-oci": BuildOCI,
+    "build-charm": BuildCharm,
     "buildlivefs": BuildLiveFS,
     "buildsnap": BuildSnap,
     "generate-translation-templates": GenerateTranslationTemplates,
diff --git a/lpbuildd/target/tests/test_build_charm.py b/lpbuildd/target/tests/test_build_charm.py
new file mode 100644
index 0000000..b3a2a99
--- /dev/null
+++ b/lpbuildd/target/tests/test_build_charm.py
@@ -0,0 +1,342 @@
+# Copyright 2019 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+__metaclass__ = type
+
+import os
+import subprocess
+
+from fixtures import (
+    FakeLogger,
+    TempDir,
+    )
+from testtools.matchers import (
+    AnyMatch,
+    Equals,
+    Is,
+    MatchesAll,
+    MatchesDict,
+    MatchesListwise,
+    )
+from testtools.testcase import TestCase
+
+from lpbuildd.target.backend import InvalidBuildFilePath
+from lpbuildd.target.build_charm import (
+    RETCODE_FAILURE_BUILD,
+    RETCODE_FAILURE_INSTALL,
+    )
+from lpbuildd.tests.fakebuilder import FakeMethod
+from lpbuildd.target.cli import parse_args
+
+
+class RanCommand(MatchesListwise):
+
+    def __init__(self, args, echo=None, cwd=None, input_text=None,
+                 get_output=None, **env):
+        kwargs_matcher = {}
+        if echo is not None:
+            kwargs_matcher["echo"] = Is(echo)
+        if cwd:
+            kwargs_matcher["cwd"] = Equals(cwd)
+        if input_text:
+            kwargs_matcher["input_text"] = Equals(input_text)
+        if get_output is not None:
+            kwargs_matcher["get_output"] = Is(get_output)
+        if env:
+            kwargs_matcher["env"] = MatchesDict(
+                {key: Equals(value) for key, value in env.items()})
+        super(RanCommand, self).__init__(
+            [Equals((args,)), MatchesDict(kwargs_matcher)])
+
+
+class RanAptGet(RanCommand):
+
+    def __init__(self, *args):
+        super(RanAptGet, self).__init__(["apt-get", "-y"] + list(args))
+
+
+class RanBuildCommand(RanCommand):
+
+    def __init__(self, args, **kwargs):
+        kwargs.setdefault("LANG", "C.UTF-8")
+        kwargs.setdefault("SHELL", "/bin/sh")
+        super(RanBuildCommand, self).__init__(args, **kwargs)
+
+
+class TestBuildCharm(TestCase):
+
+    def test_run_build_command_no_env(self):
+        args = [
+            "build-charm",
+            "--backend=fake", "--series=xenial", "--arch=amd64", "1",
+            "--branch", "lp:foo", "test-image",
+            ]
+        build_charm = parse_args(args=args).operation
+        build_charm.run_build_command(["echo", "hello world"])
+        self.assertThat(build_charm.backend.run.calls, MatchesListwise([
+            RanBuildCommand(
+                ["echo", "hello world"],
+                cwd="/home/buildd/test-image"),
+            ]))
+
+    def test_run_build_command_env(self):
+        args = [
+            "build-charm",
+            "--backend=fake", "--series=xenial", "--arch=amd64", "1",
+            "--branch", "lp:foo", "test-image",
+            ]
+        build_charm = parse_args(args=args).operation
+        build_charm.run_build_command(
+            ["echo", "hello world"], env={"FOO": "bar baz"})
+        self.assertThat(build_charm.backend.run.calls, MatchesListwise([
+            RanBuildCommand(
+                ["echo", "hello world"],
+                FOO="bar baz",
+                cwd="/home/buildd/test-image")
+            ]))
+
+    def test_install_bzr(self):
+        args = [
+            "build-charm",
+            "--backend=fake", "--series=xenial", "--arch=amd64", "1",
+            "--branch", "lp:foo", "test-image"
+            ]
+        build_charm = parse_args(args=args).operation
+        build_charm.install()
+        self.assertThat(build_charm.backend.run.calls, MatchesListwise([
+            RanAptGet("install", "bzr"),
+            RanCommand(["snap", "install", "core"]),
+            RanCommand(["snap", "install", "core20"]),
+            RanCommand(["snap", "install", "charmcraft"]),
+            RanCommand(["mkdir", "-p", "/home/buildd"]),
+            ]))
+
+    def test_install_git(self):
+        args = [
+            "build-charm",
+            "--backend=fake", "--series=xenial", "--arch=amd64", "1",
+            "--git-repository", "lp:foo", "test-image"
+            ]
+        build_charm = parse_args(args=args).operation
+        build_charm.install()
+        self.assertThat(build_charm.backend.run.calls, MatchesListwise([
+            RanAptGet("install", "git"),
+            RanCommand(["snap", "install", "core"]),
+            RanCommand(["snap", "install", "core20"]),
+            RanCommand(["snap", "install", "charmcraft"]),
+            RanCommand(["mkdir", "-p", "/home/buildd"]),
+            ]))
+
+    def test_repo_bzr(self):
+        args = [
+            "build-charm",
+            "--backend=fake", "--series=xenial", "--arch=amd64", "1",
+            "--branch", "lp:foo", "test-image",
+            ]
+        build_charm = parse_args(args=args).operation
+        build_charm.backend.build_path = self.useFixture(TempDir()).path
+        build_charm.backend.run = FakeMethod()
+        build_charm.repo()
+        self.assertThat(build_charm.backend.run.calls, MatchesListwise([
+            RanBuildCommand(
+                ["bzr", "branch", "lp:foo", "test-image"], cwd="/home/buildd"),
+            ]))
+
+    def test_repo_git(self):
+        args = [
+            "build-charm",
+            "--backend=fake", "--series=xenial", "--arch=amd64", "1",
+            "--git-repository", "lp:foo", "test-image",
+            ]
+        build_charm = parse_args(args=args).operation
+        build_charm.backend.build_path = self.useFixture(TempDir()).path
+        build_charm.backend.run = FakeMethod()
+        build_charm.repo()
+        self.assertThat(build_charm.backend.run.calls, MatchesListwise([
+            RanBuildCommand(
+                ["git", "clone", "lp:foo", "test-image"], cwd="/home/buildd"),
+            RanBuildCommand(
+                ["git", "submodule", "update", "--init", "--recursive"],
+                cwd="/home/buildd/test-image"),
+            ]))
+
+    def test_repo_git_with_path(self):
+        args = [
+            "build-charm",
+            "--backend=fake", "--series=xenial", "--arch=amd64", "1",
+            "--git-repository", "lp:foo", "--git-path", "next", "test-image",
+            ]
+        build_charm = parse_args(args=args).operation
+        build_charm.backend.build_path = self.useFixture(TempDir()).path
+        build_charm.backend.run = FakeMethod()
+        build_charm.repo()
+        self.assertThat(build_charm.backend.run.calls, MatchesListwise([
+            RanBuildCommand(
+                ["git", "clone", "-b", "next", "lp:foo", "test-image"],
+                cwd="/home/buildd"),
+            RanBuildCommand(
+                ["git", "submodule", "update", "--init", "--recursive"],
+                cwd="/home/buildd/test-image"),
+            ]))
+
+    def test_repo_git_with_tag_path(self):
+        args = [
+            "build-charm",
+            "--backend=fake", "--series=xenial", "--arch=amd64", "1",
+            "--git-repository", "lp:foo", "--git-path", "refs/tags/1.0",
+            "test-image",
+            ]
+        build_charm = parse_args(args=args).operation
+        build_charm.backend.build_path = self.useFixture(TempDir()).path
+        build_charm.backend.run = FakeMethod()
+        build_charm.repo()
+        self.assertThat(build_charm.backend.run.calls, MatchesListwise([
+            RanBuildCommand(
+                ["git", "clone", "-b", "1.0", "lp:foo", "test-image"],
+                cwd="/home/buildd"),
+            RanBuildCommand(
+                ["git", "submodule", "update", "--init", "--recursive"],
+                cwd="/home/buildd/test-image"),
+            ]))
+
+    def test_build(self):
+        args = [
+            "build-charm",
+            "--backend=fake", "--series=xenial", "--arch=amd64", "1",
+            "--branch", "lp:foo", "test-image",
+            ]
+        build_charm = parse_args(args=args).operation
+        build_charm.backend.add_dir('/build/test-directory')
+        build_charm.build()
+        self.assertThat(build_charm.backend.run.calls, MatchesListwise([
+            RanBuildCommand(
+                ["charmcraft", "build", "-f",
+                 "/home/buildd/test-image/."],
+                cwd="/home/buildd/test-image"),
+            ]))
+
+    def test_build_with_path(self):
+        args = [
+            "build-charm",
+            "--backend=fake", "--series=xenial", "--arch=amd64", "1",
+            "--branch", "lp:foo", "--build-path", "build-aux/",
+            "test-image",
+            ]
+        build_charm = parse_args(args=args).operation
+        build_charm.backend.add_dir('/build/test-directory')
+        build_charm.build()
+        self.assertThat(build_charm.backend.run.calls, MatchesListwise([
+            RanBuildCommand(
+                ["charmcraft", "build", "-f",
+                 "/home/buildd/test-image/build-aux/"],
+                cwd="/home/buildd/test-image"),
+            ]))
+
+    def test_run_succeeds(self):
+        args = [
+            "build-charm",
+            "--backend=fake", "--series=xenial", "--arch=amd64", "1",
+            "--branch", "lp:foo", "test-image",
+            ]
+        build_charm = parse_args(args=args).operation
+        build_charm.backend.build_path = self.useFixture(TempDir()).path
+        build_charm.backend.run = FakeMethod()
+        self.assertEqual(0, build_charm.run())
+        self.assertThat(build_charm.backend.run.calls, MatchesAll(
+            AnyMatch(RanAptGet("install", "bzr"),),
+            AnyMatch(RanBuildCommand(
+                ["bzr", "branch", "lp:foo", "test-image"],
+                cwd="/home/buildd")),
+            AnyMatch(RanBuildCommand(
+                ["charmcraft", "build", "-f",
+                 "/home/buildd/test-image/."],
+                cwd="/home/buildd/test-image")),
+            ))
+
+    def test_run_install_fails(self):
+        class FailInstall(FakeMethod):
+            def __call__(self, run_args, *args, **kwargs):
+                super(FailInstall, self).__call__(run_args, *args, **kwargs)
+                if run_args[0] == "apt-get":
+                    raise subprocess.CalledProcessError(1, run_args)
+
+        self.useFixture(FakeLogger())
+        args = [
+            "build-charm",
+            "--backend=fake", "--series=xenial", "--arch=amd64", "1",
+            "--branch", "lp:foo", "test-image",
+            ]
+        build_charm = parse_args(args=args).operation
+        build_charm.backend.run = FailInstall()
+        self.assertEqual(RETCODE_FAILURE_INSTALL, build_charm.run())
+
+    def test_run_repo_fails(self):
+        class FailRepo(FakeMethod):
+            def __call__(self, run_args, *args, **kwargs):
+                super(FailRepo, self).__call__(run_args, *args, **kwargs)
+                if run_args[:2] == ["bzr", "branch"]:
+                    raise subprocess.CalledProcessError(1, run_args)
+
+        self.useFixture(FakeLogger())
+        args = [
+            "build-charm",
+            "--backend=fake", "--series=xenial", "--arch=amd64", "1",
+            "--branch", "lp:foo", "test-image",
+            ]
+        build_charm = parse_args(args=args).operation
+        build_charm.backend.run = FailRepo()
+        self.assertEqual(RETCODE_FAILURE_BUILD, build_charm.run())
+
+    def test_run_build_fails(self):
+        class FailBuild(FakeMethod):
+            def __call__(self, run_args, *args, **kwargs):
+                super(FailBuild, self).__call__(run_args, *args, **kwargs)
+                if run_args[0] == "charmcraft":
+                    raise subprocess.CalledProcessError(1, run_args)
+
+        self.useFixture(FakeLogger())
+        args = [
+            "build-charm",
+            "--backend=fake", "--series=xenial", "--arch=amd64", "1",
+            "--branch", "lp:foo", "test-image",
+            ]
+        build_charm = parse_args(args=args).operation
+        build_charm.backend.build_path = self.useFixture(TempDir()).path
+        build_charm.backend.run = FailBuild()
+        self.assertEqual(RETCODE_FAILURE_BUILD, build_charm.run())
+
+    def test_build_with_invalid_build_path_parent(self):
+        args = [
+            "build-charm",
+            "--backend=fake", "--series=xenial", "--arch=amd64", "1",
+            "--branch", "lp:foo", "--build-path", "../",
+            "test-image",
+            ]
+        build_charm = parse_args(args=args).operation
+        build_charm.backend.add_dir('/build/test-directory')
+        self.assertRaises(InvalidBuildFilePath, build_charm.build)
+
+    def test_build_with_invalid_build_path_absolute(self):
+        args = [
+            "build-charm",
+            "--backend=fake", "--series=xenial", "--arch=amd64", "1",
+            "--branch", "lp:foo", "--build-path", "/etc",
+            "test-image",
+            ]
+        build_charm = parse_args(args=args).operation
+        build_charm.backend.add_dir('/build/test-directory')
+        self.assertRaises(InvalidBuildFilePath, build_charm.build)
+
+    def test_build_with_invalid_build_path_symlink(self):
+        args = [
+            "build-charm",
+            "--backend=fake", "--series=xenial", "--arch=amd64", "1",
+            "--branch", "lp:foo", "--build-path", "build/",
+            "test-image",
+            ]
+        build_charm = parse_args(args=args).operation
+        build_charm.buildd_path = self.useFixture(TempDir()).path
+        os.symlink(
+            '/etc/hosts',
+            os.path.join(build_charm.buildd_path, 'build'))
+        self.assertRaises(InvalidBuildFilePath, build_charm.build)
diff --git a/lpbuildd/target/tests/test_build_oci.py b/lpbuildd/target/tests/test_build_oci.py
index e58344f..3b9966a 100644
--- a/lpbuildd/target/tests/test_build_oci.py
+++ b/lpbuildd/target/tests/test_build_oci.py
@@ -24,8 +24,8 @@ from testtools.matchers import (
     MatchesListwise,
     )
 
+from lpbuildd.target.backend import InvalidBuildFilePath
 from lpbuildd.target.build_oci import (
-    InvalidBuildFilePath,
     RETCODE_FAILURE_BUILD,
     RETCODE_FAILURE_INSTALL,
     )
diff --git a/lpbuildd/tests/test_charm.py b/lpbuildd/tests/test_charm.py
new file mode 100644
index 0000000..bdb2724
--- /dev/null
+++ b/lpbuildd/tests/test_charm.py
@@ -0,0 +1,139 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+__metaclass__ = type
+
+import os
+
+from fixtures import (
+    EnvironmentVariable,
+    TempDir,
+    )
+from testtools import TestCase
+from testtools.deferredruntest import AsynchronousDeferredRunTest
+from twisted.internet import defer
+
+from lpbuildd.charm import CharmBuildManager, CharmBuildState
+from lpbuildd.tests.fakebuilder import FakeBuilder
+from lpbuildd.tests.matchers import HasWaitingFiles
+
+
+class MockBuildManager(CharmBuildManager):
+    def __init__(self, *args, **kwargs):
+        super(MockBuildManager, self).__init__(*args, **kwargs)
+        self.commands = []
+        self.iterators = []
+
+    def runSubProcess(self, path, command, iterate=None, env=None):
+        self.commands.append([path] + command)
+        if iterate is None:
+            iterate = self.iterate
+        self.iterators.append(iterate)
+        return 0
+
+
+class TestCharmBuildManagerIteration(TestCase):
+    """Run CharmBuildManager through its iteration steps."""
+
+    run_tests_with = AsynchronousDeferredRunTest.make_factory(timeout=5)
+
+    def setUp(self):
+        super(TestCharmBuildManagerIteration, self).setUp()
+        self.working_dir = self.useFixture(TempDir()).path
+        builder_dir = os.path.join(self.working_dir, "builder")
+        home_dir = os.path.join(self.working_dir, "home")
+        for dir in (builder_dir, home_dir):
+            os.mkdir(dir)
+        self.useFixture(EnvironmentVariable("HOME", home_dir))
+        self.builder = FakeBuilder(builder_dir)
+        self.buildid = "123"
+        self.buildmanager = MockBuildManager(self.builder, self.buildid)
+        self.buildmanager._cachepath = self.builder._cachepath
+
+    def getState(self):
+        """Retrieve build manager's state."""
+        return self.buildmanager._state
+
+    @defer.inlineCallbacks
+    def startBuild(self, args=None, options=None):
+        # The build manager's iterate() kicks off the consecutive states
+        # after INIT.
+        extra_args = {
+            "series": "xenial",
+            "arch_tag": "i386",
+            "name": "test-charm",
+            }
+        if args is not None:
+            extra_args.update(args)
+        original_backend_name = self.buildmanager.backend_name
+        self.buildmanager.backend_name = "fake"
+        self.buildmanager.initiate({}, "chroot.tar.gz", extra_args)
+        self.buildmanager.backend_name = original_backend_name
+
+        # Skip states that are done in DebianBuildManager to the state
+        # directly before BUILD_CHARM.
+        self.buildmanager._state = CharmBuildState.UPDATE
+
+        # BUILD_OCI: Run the builder's payload to build the charm.
+        yield self.buildmanager.iterate(0)
+        self.assertEqual(CharmBuildState.BUILD_CHARM, self.getState())
+        expected_command = [
+            "sharepath/bin/in-target", "in-target", "build-charm",
+            "--backend=lxd", "--series=xenial", "--arch=i386", self.buildid,
+            ]
+        if options is not None:
+            expected_command.extend(options)
+        expected_command.append("test-charm")
+        self.assertEqual(expected_command, self.buildmanager.commands[-1])
+        self.assertEqual(
+            self.buildmanager.iterate, self.buildmanager.iterators[-1])
+        self.assertFalse(self.builder.wasCalled("chrootFail"))
+
+    @defer.inlineCallbacks
+    def test_iterate(self):
+        # The build manager iterates a normal build from start to finish.
+        args = {
+            "git_repository": "https://git.launchpad.dev/~example/+git/charm";,
+            "git_path": "master",
+            }
+        expected_options = [
+            "--git-repository", "https://git.launchpad.dev/~example/+git/charm";,
+            "--git-path", "master",
+            ]
+        yield self.startBuild(args, expected_options)
+
+        log_path = os.path.join(self.buildmanager._cachepath, "buildlog")
+        with open(log_path, "w") as log:
+            log.write("I am a build log.")
+
+        self.buildmanager.backend.add_file(
+            "/home/buildd/test-charm/test-charm_0_all.charm",
+            b"I am charming.")
+
+        # After building the package, reap processes.
+        yield self.buildmanager.iterate(0)
+        expected_command = [
+            "sharepath/bin/in-target", "in-target", "scan-for-processes",
+            "--backend=lxd", "--series=xenial", "--arch=i386", self.buildid,
+            ]
+
+        self.assertEqual(CharmBuildState.BUILD_CHARM, self.getState())
+        self.assertEqual(expected_command, self.buildmanager.commands[-1])
+        self.assertNotEqual(
+            self.buildmanager.iterate, self.buildmanager.iterators[-1])
+        self.assertFalse(self.builder.wasCalled("buildFail"))
+        self.assertThat(self.builder, HasWaitingFiles.byEquality({
+            "test-charm_0_all.charm": b"I am charming.",
+            }))
+
+        # Control returns to the DebianBuildManager in the UMOUNT state.
+        self.buildmanager.iterateReap(self.getState(), 0)
+        expected_command = [
+            "sharepath/bin/in-target", "in-target", "umount-chroot",
+            "--backend=lxd", "--series=xenial", "--arch=i386", self.buildid,
+            ]
+        self.assertEqual(CharmBuildState.UMOUNT, self.getState())
+        self.assertEqual(expected_command, self.buildmanager.commands[-1])
+        self.assertEqual(
+            self.buildmanager.iterate, self.buildmanager.iterators[-1])
+        self.assertFalse(self.builder.wasCalled("buildFail"))
diff --git a/lpbuildd/tests/test_oci.py b/lpbuildd/tests/test_oci.py
index 78b0dbb..7fc0b3a 100644
--- a/lpbuildd/tests/test_oci.py
+++ b/lpbuildd/tests/test_oci.py
@@ -89,7 +89,7 @@ class TestOCIBuildManagerIteration(TestCase):
         # directly before BUILD_OCI.
         self.buildmanager._state = OCIBuildState.UPDATE
 
-        # BUILD_OCI: Run the builder's payload to build the snap package.
+        # BUILD_OCI: Run the builder's payload to build the OCI image.
         yield self.buildmanager.iterate(0)
         self.assertEqual(OCIBuildState.BUILD_OCI, self.getState())
         expected_command = [