]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
src/script: add a script to help build ceph using containers 59841/head
authorJohn Mulligan <jmulligan@redhat.com>
Tue, 20 Aug 2024 19:01:05 +0000 (15:01 -0400)
committerJohn Mulligan <jmulligan@redhat.com>
Wed, 5 Feb 2025 20:13:06 +0000 (15:13 -0500)
The build-with-container script tries to encapsulate nearly all major
build tasks using docker/podman containers. If there's no build image
locally it will create one for your. It provides targets for building
(make), testing (make check), building rpm packages or deb packages and
is designed to be fairly easily extended.

View the comment at the top of the source file for usage details.

Signed-off-by: John Mulligan <jmulligan@redhat.com>
src/script/build-with-container.py [new file with mode: 0644]

diff --git a/src/script/build-with-container.py b/src/script/build-with-container.py
new file mode 100644 (file)
index 0000000..359a83b
--- /dev/null
@@ -0,0 +1,820 @@
+#!/usr/bin/python3
+"""build-with-container.py - Build Ceph in a Containerized environment.
+
+build-with-container.py is a self-contained python script meant to assist
+with building and testing the Ceph Project source in a (OCI) container.
+
+Benefits of building ceph in a container:
+* you do not need ceph dependencies installed on your personal system(s)
+* you can build for other distributions than the on you are running
+* you can cache the image and save time downloading dependencies
+* you can build for multiple different distros on the same build hardware
+* you can make experiemental changes to the build scripts, dependency
+  packages, compliers, etc. and test them before submitting the changes
+  to a real CI job
+* it's cool!
+
+This script requires python3 and either podman or docker.
+
+Currently we assume our audience is developers wishing to build and test ceph
+locally. As such there are a number of predefined execution steps that loosely
+map to a number of development tasks. You can specify one or more execution
+steps with the `--execution/-e <step>` option. Other commonly needed options
+may include:
+* --distro/-d <distro> - Abbreviated name for base distribution & container image
+* --build-dir/-b <dir> - Relative path to output directory for builds
+
+For example:
+  build-with-container.py -d centos9 -e build
+  # the same as running build-with-container.py without any arguments
+
+  Selects CentOS (stream) 9 as the base image and will automatically (if
+  needed) build a new container image with the ceph dependencies. It will then
+  run a configure and build step to compile the ceph sources.
+
+Example 2:
+  build-with-container.py -d ubuntu22.04 -e test -b build.ub2204
+
+  Selects Ubuntu 22.04 as the base image and then automatically (if needed)
+  builds a container image with the ceph dependencies. It then will configure
+  and make the "make test" dependencies and run the "make test" suite.
+
+Example 3:
+  build-with-container.py -d centos9 -e rpm
+
+  Again, will build a new container image with dependencies if needed.
+  Then it will create a source rpm and then build binary rpms from
+  that source RPM.
+
+Example 4:
+  build-with-container.py -d ubuntu24.04 -e debs
+
+  If needed, build a new container image with ubuntu24.04 and ceph dependencies.
+  Then build ceph deb packages.
+
+Example 5:
+  $EDITOR ./my-cool-script.sh && chmod +x ./my-cool-script.sh
+  build-with-container.py -d centos9 -b build.hacks -e build -e custom -- /ceph/my-cool-script.sh
+
+  If needed, build a new container image with centos 9 stream and ceph dependencies.
+  Then build ceph sources. Then run a custom script from the ceph source dir
+  in the container.
+
+
+The command comes with built-in help. Specify the --help option to print
+general command help. Specify the --help-build-steps option to list all
+the executable build steps with short descriptions of what they do.
+"""
+
+import argparse
+import contextlib
+import enum
+import glob
+import logging
+import os
+import pathlib
+import shlex
+import shutil
+import subprocess
+import sys
+
+log = logging.getLogger()
+
+
+try:
+    from enum import StrEnum
+except ImportError:
+    class StrEnum(str, enum.Enum):
+        def __str__(self):
+            return self.value
+
+
+class DistroKind(StrEnum):
+    CENTOS10 = "centos10"
+    CENTOS8 = "centos8"
+    CENTOS9 = "centos9"
+    FEDORA41 = 'fedora41'
+    UBUNTU2204 = "ubuntu22.04"
+    UBUNTU2404 = "ubuntu24.04"
+
+    @classmethod
+    def uses_dnf(cls):
+        return {cls.CENTOS8, cls.CENTOS9, cls.CENTOS10, cls.FEDORA41}
+
+
+class DefaultImage(StrEnum):
+    CENTOS10 = "quay.io/centos/centos:stream10"
+    CENTOS8 = "quay.io/centos/centos:stream8"
+    CENTOS9 = "quay.io/centos/centos:stream9"
+    FEDORA41 = 'registry.fedoraproject.org/fedora:41'
+    UBUNTU2204 = "docker.io/ubuntu:22.04"
+    UBUNTU2404 = "docker.io/ubuntu:24.04"
+
+
+class CommandFailed(Exception):
+    pass
+
+
+class DidNotExecute(Exception):
+    pass
+
+
+def _cmdstr(cmd):
+    return " ".join(shlex.quote(c) for c in cmd)
+
+
+def _run(cmd, *args, **kwargs):
+    ctx = kwargs.pop("ctx", None)
+    if ctx and ctx.dry_run:
+        log.info("(dry-run) Not Executing command: %s", _cmdstr(cmd))
+        # because we can not return a result (as we did nothing)
+        # raise a specific exception to be caught by higher layer
+        raise DidNotExecute(cmd)
+
+    log.info("Executing command: %s", _cmdstr(cmd))
+    return subprocess.run(cmd, *args, **kwargs)
+
+
+def _container_cmd(ctx, args, *, workdir=None, interactive=False):
+    rm_container = not ctx.cli.keep_container
+    cmd = [
+        ctx.container_engine,
+        "run",
+        "--name=ceph_build",
+    ]
+    if interactive:
+        cmd.append("-it")
+    if rm_container:
+        cmd.append("--rm")
+    if "podman" in ctx.container_engine:
+        cmd.append("--pids-limit=-1")
+    if ctx.map_user:
+        cmd.append("--user=0")
+    if workdir:
+        cmd.append(f"--workdir={workdir}")
+    cwd = pathlib.Path(".").absolute()
+    cmd += [
+        f"--volume={cwd}:{ctx.cli.homedir}:Z",
+        f"-eHOMEDIR={ctx.cli.homedir}",
+    ]
+    if ctx.cli.build_dir:
+        cmd.append(f"-eBUILD_DIR={ctx.cli.build_dir}")
+    if ctx.cli.ccache_dir:
+        ccdir = str(ctx.cli.ccache_dir).format(
+            homedir=ctx.cli.homedir or '',
+            build_dir=ctx.cli.build_dir or '',
+            distro=ctx.cli.distro or '',
+        )
+        cmd.append(f"-eCCACHE_DIR={ccdir}")
+        cmd.append(f"-eCCACHE_BASEDIR={ctx.cli.homedir}")
+    for extra_arg in ctx.cli.extra or []:
+        cmd.append(extra_arg)
+    cmd.append(ctx.image_name)
+    cmd.extend(args)
+    return cmd
+
+
+def _git_command(ctx, args):
+    cmd = ["git"]
+    cmd.extend(args)
+    return cmd
+
+
+def _git_current_branch(ctx):
+    cmd = _git_command(ctx, ["rev-parse", "--abbrev-ref", "HEAD"])
+    res = _run(cmd, check=True, capture_output=True)
+    return res.stdout.decode("utf8").strip()
+
+
+def _git_current_sha(ctx, short=True):
+    args = ["rev-parse"]
+    if short:
+        args.append("--short")
+    args.append("HEAD")
+    cmd = _git_command(ctx, args)
+    res = _run(cmd, check=True, capture_output=True)
+    return res.stdout.decode("utf8").strip()
+
+
+class Steps(StrEnum):
+    DNF_CACHE = "dnfcache"
+    BUILD_CONTAINER = "build-container"
+    CONTAINER = "container"
+    CONFIGURE = "configure"
+    BUILD = "build"
+    BUILD_TESTS = "buildtests"
+    TESTS = "tests"
+    CUSTOM = "custom"
+    SOURCE_RPM = "source-rpm"
+    RPM = "rpm"
+    DEBS = "debs"
+    INTERACTIVE = "interactive"
+
+
+class ImageSource(StrEnum):
+    CACHE = "cache"
+    PULL = "pull"
+    BUILD = "build"
+
+    @classmethod
+    def argument(cls, value):
+        try:
+            return {cls(v) for v in value.split(",")}
+        except Exception:
+            raise argparse.ArgumentTypeError(
+                f"the argument must be one of {cls.hint()}"
+                " or a comma delimited list of those values"
+            )
+
+    @classmethod
+    def hint(cls):
+        return ", ".join(s.value for s in cls)
+
+
+class Context:
+    """Command context."""
+
+    def __init__(self, cli):
+        self.cli = cli
+        self._engine = None
+        self.distro_cache_name = ""
+
+    @property
+    def container_engine(self):
+        if self._engine is not None:
+            return self._engine
+        if self.cli.container_engine:
+            return self.cli.container_engine
+
+        for ctr_eng in ["podman", "docker"]:
+            if shutil.which(ctr_eng):
+                break
+        else:
+            raise RuntimeError("no container engine found")
+        log.debug("found container engine: %r", ctr_eng)
+        self._engine = ctr_eng
+        return self._engine
+
+    @property
+    def image_name(self):
+        base = self.cli.image_repo or "ceph-build"
+        return f"{base}:{self.target_tag()}"
+
+    def target_tag(self):
+        if self.cli.tag:
+            return self.cli.tag
+        try:
+            branch = _git_current_branch(self).replace("/", "-")
+        except subprocess.CalledProcessError:
+            branch = "UNKNOWN"
+        return f"{branch}.{self.cli.distro}"
+
+    @property
+    def from_image(self):
+        if self.cli.base_image:
+            return self.cli.base_image
+        distro_images = {
+            fld.value: getattr(DefaultImage, fld.name).value
+            for fld in DistroKind
+        }
+        return distro_images[self.cli.distro]
+
+    @property
+    def dnf_cache_dir(self):
+        if self.cli.dnf_cache_path and self.distro_cache_name:
+            return (
+                pathlib.Path(self.cli.dnf_cache_path) / self.distro_cache_name
+            )
+        return None
+
+    @property
+    def map_user(self):
+        # TODO: detect if uid mapping is needed
+        return os.getuid() != 0
+
+    @property
+    def dry_run(self):
+        return self.cli.dry_run
+
+    @contextlib.contextmanager
+    def user_command(self):
+        """Handle subprocess execptions raised by commands we expect to be fallible.
+        Helps hide traceback noise when just running commands.
+        """
+        try:
+            yield
+        except subprocess.SubprocessError as err:
+            if self.cli.debug:
+                raise
+            raise CommandFailed() from err
+        except DidNotExecute:
+            pass
+
+
+class Builder:
+    """Organize and manage the build steps."""
+
+    _steps = {}
+
+    def __init__(self):
+        self._did_steps = set()
+
+    def wants(self, step, ctx, *, force=False, top=False):
+        log.info("want to execute build step: %s", step)
+        if ctx.cli.no_prereqs and not top:
+            log.info("Running prerequisite steps disabled")
+            return
+        if step in self._did_steps:
+            log.info("step already done: %s", step)
+            return
+        self._steps[step](ctx)
+        self._did_steps.add(step)
+        log.info("step done: %s", step)
+
+    def available_steps(self):
+        return [str(k) for k in self._steps]
+
+    @classmethod
+    def set(self, step):
+        def wrap(f):
+            self._steps[step] = f
+            f._for_step = step
+            return f
+
+        return wrap
+
+    @classmethod
+    def docs(cls):
+        for step, func in cls._steps.items():
+            yield str(step), getattr(func, "__doc__", "")
+
+
+@Builder.set(Steps.DNF_CACHE)
+def dnf_cache_dir(ctx):
+    """Set up a DNF cache directory for reuse across container builds."""
+    if ctx.cli.distro not in DistroKind.uses_dnf():
+        return
+    if not ctx.cli.dnf_cache_path:
+        return
+
+    ctx.distro_cache_name = f"_ceph_{ctx.cli.distro}"
+    cache_dir = ctx.dnf_cache_dir
+    (cache_dir / "lib").mkdir(parents=True, exist_ok=True)
+    (cache_dir / "cache").mkdir(parents=True, exist_ok=True)
+    (cache_dir / ".DNF_CACHE").touch(exist_ok=True)
+
+
+@Builder.set(Steps.BUILD_CONTAINER)
+def build_container(ctx):
+    """Generate a build environment container image."""
+    ctx.build.wants(Steps.DNF_CACHE, ctx)
+    cmd = [
+        ctx.container_engine,
+        "build",
+        "-t",
+        ctx.image_name,
+        f"--build-arg=JENKINS_HOME={ctx.cli.homedir}",
+    ]
+    if ctx.cli.distro:
+        cmd.append(f"--build-arg=DISTRO={ctx.from_image}")
+    if ctx.dnf_cache_dir:
+        cmd += [
+            f"--volume={ctx.dnf_cache_dir}/lib:/var/lib/dnf:Z",
+            f"--volume={ctx.dnf_cache_dir}:/var/cache/dnf:Z",
+            "--build-arg=CLEAN_DNF=no",
+        ]
+    if ctx.cli.homedir:
+        cwd = pathlib.Path(".").absolute()
+        cmd.append(f"--volume={cwd}:{ctx.cli.homedir}:Z")
+    cmd += ["-f", ctx.cli.containerfile, ctx.cli.containerdir]
+    with ctx.user_command():
+        _run(cmd, check=True, ctx=ctx)
+
+
+@Builder.set(Steps.CONTAINER)
+def get_container(ctx):
+    """Build or fetch a container image that we will build in."""
+    inspect_cmd = [
+        ctx.container_engine,
+        "image",
+        "inspect",
+        ctx.image_name,
+    ]
+    pull_cmd = [
+        ctx.container_engine,
+        "pull",
+        ctx.image_name,
+    ]
+    allowed = ctx.cli.image_sources or ImageSource
+    if ImageSource.CACHE in allowed:
+        res = _run(inspect_cmd, check=False, capture_output=True)
+        if res.returncode == 0:
+            log.info("Container image %s present", ctx.image_name)
+            return
+        log.info("Container image %s not present", ctx.image_name)
+    if ImageSource.PULL in allowed:
+        res = _run(pull_cmd, check=False, capture_output=True)
+        if res.returncode == 0:
+            log.info("Container image %s pulled successfully", ctx.image_name)
+            return
+    log.info("Container image %s needed", ctx.image_name)
+    if ImageSource.BUILD in allowed:
+        ctx.build.wants(Steps.BUILD_CONTAINER, ctx)
+        return
+    raise ValueError("no available image sources")
+
+
+@Builder.set(Steps.CONFIGURE)
+def bc_configure(ctx):
+    """Configure the build"""
+    ctx.build.wants(Steps.CONTAINER, ctx)
+    cmd = _container_cmd(
+        ctx,
+        [
+            "bash",
+            "-c",
+            f"cd {ctx.cli.homedir} && source ./src/script/run-make.sh && has_build_dir || configure",
+        ],
+    )
+    with ctx.user_command():
+        _run(cmd, check=True, ctx=ctx)
+
+
+@Builder.set(Steps.BUILD)
+def bc_build(ctx):
+    """Execute a standard build."""
+    ctx.build.wants(Steps.CONFIGURE, ctx)
+    cmd = _container_cmd(
+        ctx,
+        [
+            "bash",
+            "-c",
+            f"cd {ctx.cli.homedir} && source ./src/script/run-make.sh && build vstart",
+        ],
+    )
+    with ctx.user_command():
+        _run(cmd, check=True, ctx=ctx)
+
+
+@Builder.set(Steps.BUILD_TESTS)
+def bc_build_tests(ctx):
+    """Build the tests."""
+    ctx.build.wants(Steps.CONFIGURE, ctx)
+    cmd = _container_cmd(
+        ctx,
+        [
+            "bash",
+            "-c",
+            f"cd {ctx.cli.homedir} && source ./src/script/run-make.sh && build tests",
+        ],
+    )
+    with ctx.user_command():
+        _run(cmd, check=True, ctx=ctx)
+
+
+@Builder.set(Steps.TESTS)
+def bc_run_tests(ctx):
+    """Execute the tests."""
+    ctx.build.wants(Steps.BUILD_TESTS, ctx)
+    cmd = _container_cmd(
+        ctx,
+        [
+            "bash",
+            "-c",
+            f"cd {ctx.cli.homedir} && source ./run-make-check.sh && build && run",
+        ],
+    )
+    with ctx.user_command():
+        _run(cmd, check=True, ctx=ctx)
+
+
+@Builder.set(Steps.SOURCE_RPM)
+def bc_make_source_rpm(ctx):
+    """Build SPRMs."""
+    ctx.build.wants(Steps.CONTAINER, ctx)
+    cmd = _container_cmd(
+        ctx,
+        [
+            "bash",
+            "-c",
+            f"cd {ctx.cli.homedir} && ./make-srpm.sh",
+        ],
+    )
+    with ctx.user_command():
+        _run(cmd, check=True, ctx=ctx)
+
+
+@Builder.set(Steps.RPM)
+def bc_build_rpm(ctx):
+    """Build RPMs from SRPM."""
+    srpm_glob = "ceph*.src.rpm"
+    if ctx.cli.rpm_match_sha:
+        head_sha = _git_current_sha(ctx)
+        srpm_glob = f"ceph*.g{head_sha}.*.src.rpm"
+    paths = glob.glob(srpm_glob)
+    if len(paths) > 1:
+        raise RuntimeError(
+            "too many matching source rpms"
+            f" (rename or remove unwanted files matching {srpm_glob} in the"
+            " ceph dir and try again)"
+        )
+    if not paths:
+        # no matches. build a new srpm
+        ctx.build.wants(Steps.SOURCE_RPM, ctx)
+        paths = glob.glob(srpm_glob)
+        assert paths
+    srpm_path = pathlib.Path(ctx.cli.homedir) / paths[0]
+    topdir = pathlib.Path(ctx.cli.homedir) / "rpmbuild"
+    if ctx.cli.build_dir:
+        topdir = (
+            pathlib.Path(ctx.cli.homedir) / ctx.cli.build_dir / "rpmbuild"
+        )
+    cmd = _container_cmd(
+        ctx,
+        [
+            "bash",
+            "-c",
+            f"set -x; mkdir -p {topdir} && rpmbuild --rebuild -D'_topdir {topdir}' {srpm_path}",
+        ],
+    )
+    with ctx.user_command():
+        _run(cmd, check=True, ctx=ctx)
+
+
+@Builder.set(Steps.DEBS)
+def bc_make_debs(ctx):
+    """Build debian/ubuntu packages."""
+    ctx.build.wants(Steps.CONTAINER, ctx)
+    basedir = pathlib.Path(ctx.cli.homedir) / "debs"
+    if ctx.cli.build_dir:
+        basedir = pathlib.Path(ctx.cli.homedir) / ctx.cli.build_dir
+    cmd = _container_cmd(
+        ctx,
+        [
+            "bash",
+            "-c",
+            f"mkdir -p {basedir} && cd {ctx.cli.homedir} && ./make-debs.sh {basedir}",
+        ],
+    )
+    with ctx.user_command():
+        _run(cmd, check=True, ctx=ctx)
+
+
+@Builder.set(Steps.CUSTOM)
+def bc_custom(ctx):
+    """Run a custom build command."""
+    ctx.build.wants(Steps.CONTAINER, ctx)
+    if not ctx.cli.remaining_args:
+        raise RuntimeError(
+            "no command line arguments provided:"
+            " specify command after '--' on the command line"
+        )
+    cc = " ".join(ctx.cli.remaining_args)
+    log.info("Custom command: %r", cc)
+    cmd = _container_cmd(
+        ctx,
+        [
+            "bash",
+            "-c",
+            cc,
+        ],
+        workdir=ctx.cli.homedir,
+    )
+    with ctx.user_command():
+        _run(cmd, check=True, ctx=ctx)
+
+
+@Builder.set(Steps.INTERACTIVE)
+def bc_interactive(ctx):
+    """Start an interactive shell in the build container."""
+    ctx.build.wants(Steps.CONTAINER, ctx)
+    cmd = _container_cmd(
+        ctx,
+        [],
+        workdir=ctx.cli.homedir,
+        interactive=True,
+    )
+    with ctx.user_command():
+        _run(cmd, check=False, ctx=ctx)
+
+
+class ArgumentParser(argparse.ArgumentParser):
+    def parse_my_args(self, args=None, namespace=None):
+        """Parse argument up to the '--' term and then stop parsing.
+        Returns a tuple of the parsed args and then remaining args.
+        """
+        args = sys.argv[1:] if args is None else list(args)
+        if "--" in args:
+            idx = args.index("--")
+            my_args, rest = args[:idx], args[idx + 1 :]
+        else:
+            my_args, rest = args, []
+        return self.parse_args(my_args, namespace=namespace), rest
+
+
+def parse_cli(build_step_names):
+    parser = ArgumentParser(
+        description=__doc__,
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+    )
+    parser.add_argument(
+        "--debug",
+        action="store_true",
+        help="Emit debugging level logging and tracebacks",
+    )
+    parser.add_argument(
+        "--container-engine",
+        help="Select container engine to use (eg. podman, docker)",
+    )
+    parser.add_argument(
+        "--cwd",
+        help="Change working directory before executing commands",
+    )
+    parser.add_argument(
+        "--distro",
+        "-d",
+        choices=[str(f) for f in DistroKind],
+        default=str(DistroKind.CENTOS9),
+        help="Specify a distro short name",
+    )
+    parser.add_argument(
+        "--tag",
+        "-t",
+        help="Specify a container tag",
+    )
+    parser.add_argument(
+        "--image-repo",
+        help="Specify a container image repository",
+    )
+    parser.add_argument(
+        "--image-sources",
+        "-I",
+        type=ImageSource.argument,
+        help="Specify a set of valid image sources. "
+        f"May be a comma separated list of {ImageSource.hint()}",
+    )
+    parser.add_argument(
+        "--base-image",
+        help=(
+            "Supply a custom base image to use instead of the default"
+            " image for the source distro."
+        ),
+    )
+    parser.add_argument(
+        "--homedir",
+        default="/ceph",
+        help="Container image home/build dir",
+    )
+    parser.add_argument(
+        "--dnf-cache-path",
+        help="DNF caching using provided base dir",
+    )
+    parser.add_argument(
+        "--build-dir",
+        "-b",
+        help=(
+            "Specify a build directory relative to the home dir"
+            " (the ceph source root)"
+        ),
+    )
+    parser.add_argument(
+        "--ccache-dir",
+        help=(
+            "Specify a directory (within the container) to save ccache"
+            " output"
+        ),
+    )
+    parser.add_argument(
+        "--extra",
+        "-x",
+        action="append",
+        help="Specify an extra argument to pass to container command",
+    )
+    parser.add_argument(
+        "--keep-container",
+        action="store_true",
+        help="Skip removing container after executing command",
+    )
+    parser.add_argument(
+        "--containerfile",
+        default="Dockerfile.build",
+        help="Specify the path to a (build) container file",
+    )
+    parser.add_argument(
+        "--containerdir",
+        default=".",
+        help="Specify the path to container context dir",
+    )
+    parser.add_argument(
+        "--no-prereqs",
+        "-P",
+        action="store_true",
+        help="Do not execute any prerequisite steps. Only execute specified steps",
+    )
+    parser.add_argument(
+        "--rpm-no-match-sha",
+        dest="rpm_match_sha",
+        action="store_false",
+        help=(
+            "Do not try to build RPM packages that match the SHA of the current"
+            " git checkout. Use any source RPM available."
+        ),
+    )
+    parser.add_argument(
+        "--execute",
+        "-e",
+        dest="steps",
+        action="append",
+        choices=build_step_names,
+        help="Execute the target build step(s)",
+    )
+    parser.add_argument(
+        "--dry-run",
+        action="store_true",
+        help="Do not execute key commands, print and continue if possible",
+    )
+    parser.add_argument(
+        "--help-build-steps",
+        action="store_true",
+        help="Print executable build steps and brief descriptions",
+    )
+    cli, rest = parser.parse_my_args()
+    if cli.help_build_steps:
+        print("Executable Build Steps")
+        print("======================")
+        print("")
+
+        for step_name, doc in sorted(Builder().docs()):
+            print(step_name)
+            print(" " * 5, doc)
+            print("")
+        sys.exit(0)
+    if rest and rest[0] == "--":
+        rest[:] = rest[1:]
+    cli.remaining_args = rest
+    return cli
+
+
+def _src_root():
+    return pathlib.Path(__file__).parent.parent.parent.absolute()
+
+
+class ColorFormatter(logging.Formatter):
+    _yellow = "\x1b[33;20m"
+    _red = "\x1b[31;20m"
+    _reset = "\x1b[0m"
+
+    def format(self, record):
+        res = super().format(record)
+        if record.levelno == logging.WARNING:
+            res = self._yellow + res + self._reset
+        if record.levelno == logging.ERROR:
+            res = self._red + res + self._reset
+        return res
+
+
+def _setup_logging(cli):
+    level = logging.DEBUG if cli.debug else logging.INFO
+    logger = logging.getLogger()
+    logger.setLevel(level)
+    handler = logging.StreamHandler()
+    fmt = "{asctime}: {levelname}: {message}"
+    if sys.stdout.isatty() and sys.stderr.isatty():
+        formatter = ColorFormatter(fmt, style="{")
+    else:
+        formatter = logging.Formatter(fmt, style="{")
+    handler.setFormatter(formatter)
+    handler.setLevel(level)
+    logger.addHandler(handler)
+
+
+def main():
+    builder = Builder()
+    cli = parse_cli(builder.available_steps())
+    _setup_logging(cli)
+
+    os.chdir(cli.cwd or _src_root())
+    ctx = Context(cli)
+    ctx.build = builder
+    try:
+        for step in cli.steps or [Steps.BUILD]:
+            ctx.build.wants(step, ctx, top=True)
+    except CommandFailed as err:
+        err_cause = getattr(err, "__cause__", None)
+        if err_cause:
+            log.error("Command failed: %s", err_cause)
+        else:
+            log.error("Command failed!")
+        log.warning(
+            "🚧 the command may have faild due to circumstances"
+            " beyond the influence of this build script. For example: a"
+            " complier error caused by a source code change."
+            " Pay careful attention to the output generated by the command"
+            " before reporting this as a problem with the"
+            " build-with-container.py script. ðŸš§"
+        )
+        sys.exit(1)
+
+
+if __name__ == "__main__":
+    main()