#!/usr/bin/python3 # SPDX-License-Identifier: MIT """mdbp backend using mmdebstrap""" import argparse import contextlib import ctypes import ctypes.util import functools import os import pathlib import shlex import shutil import subprocess import sys import typing import debian.debian_support from .common import JsonObject, buildjson, clean_dir, compute_env, \ download_dsc, get_dsc_files, json_load, parse_dsc, profile_option libc = ctypes.CDLL(ctypes.util.find_library("c")) def unshare_network() -> None: """The unshare(2) the network namespace. Errors are raised as OSError.""" if libc.unshare(0x40000000) < 0: raise OSError("unshare() failed", ctypes.get_errno()) def set_uids(username: str) -> None: """Look up the given user in /etc/passwd (e.g. after chroot) and drop privileges to this user.""" with open("/etc/passwd", "r") as f: for line in f: parts = line.strip().split(":") if parts[0] == username: os.setgid(int(parts[3])) os.setuid(int(parts[2])) return raise OSError("user %s not found in /etc/passwd" % username) def priv_drop(cmd: typing.List[str], *, chroot: typing.Optional[pathlib.Path] = None, chdir: typing.Union[None, str, pathlib.PurePath] = None, setuid: typing.Optional[str] = None, privnet: bool = False, env: typing.Optional[typing.Dict[str, str]] = None) -> None: """Invoke the given command as a subprocess with the given confinements. * A chroot target can be specified. * When chdir is set, chdir to it after chrooting. Should be absolute. * When setuid is set, change the user id and group id. Names allowed. * When privnet is True, create a new network namespace with an active loopback interface. * Environment variables from env will be set. A non-zero exit code is raised as a CalledProcessError. """ def preexec_fn() -> None: if privnet: unshare_network() subprocess.check_call(["ip", "link", "set", "dev", "lo", "up"]) if chroot: os.chroot(chroot) if chroot or chdir: os.chdir(chdir or "/") if setuid: set_uids(setuid) subprocess.check_call(cmd, preexec_fn=preexec_fn, env=env) def native_architecture() -> str: """Return the native architecture as returned by dpkg.""" return subprocess.check_output(["dpkg", "--print-architecture"], encoding="ascii").strip() def build_subdir(dsc: debian.deb822.Dsc) -> str: """Compute the subdirectory that dpkg-source normally extracts to.""" ver = debian.debian_support.BaseVersion(dsc["Version"]).upstream_version assert ver is not None # please mypy return "%s-%s" % (dsc["Source"], ver) def hook_main(build: JsonObject, chroot: pathlib.Path) -> None: """The entry point for the --hook-helper invocation run from mmdebstrap.""" builddir = pathlib.PurePath(build.get("build_path", "/build")) fullbuilddir = chroot / builddir.relative_to("/") if "source_package_path" in build["input"]: dscpath = fullbuilddir / \ pathlib.PurePath(build["input"]["source_package_path"]).name elif "source_package_url" in build["input"]: dscpath = download_dsc(build["input"], fullbuilddir) priv_drop(["chown", "-R", "build:build", "."], chroot=chroot, chdir=builddir) apt_get = ["apt-get", "--yes", "-oAPT::Keep-Downloaded-Packages=false"] if "sourcename" in build["input"]: sourcename = build["input"]["sourcename"] with contextlib.suppress(KeyError): sourcename += "=" + build["input"]["version"] priv_drop([*apt_get, "--only-source", "--download-only", "source", sourcename], chroot=chroot, chdir=builddir, setuid="build") [dscpath] = fullbuilddir.glob(build["input"]["sourcename"] + "_*.dsc") dsc = parse_dsc(dscpath) subdir = build_subdir(dsc) priv_drop(["dpkg-source", "--no-check", "--extract", dscpath.name, subdir], setuid="build", chroot=chroot, chdir=builddir) for path in [*get_dsc_files(dscpath, dsc), dscpath]: path.unlink() hostarch = build.get("host_architecture") or \ build.get("build_architecture") or \ native_architecture() type_map: typing.Dict[typing.Any, typing.Sequence[str]] = dict( any=["--arch-only"], all=["--indep-only"] ) cmd = [ *apt_get, "build-dep", *("--host-architecture", hostarch), *type_map.get(build.get("type"), ()), *profile_option(build, "--build-profiles"), "./", ] try: priv_drop(cmd, chroot=chroot, chdir=builddir / subdir) except subprocess.CalledProcessError: if build.get("bd-uninstallable-explainer") != "apt" or \ not build["output"].get("log", True): raise cmd[-1:-1] = ['-oDebug::pkgProblemResolver=true', '-oDebug::pkgDepCache::Marker=1', '-oDebug::pkgDepCache::AutoInstall=1', '-oDebug::BuildDeps=1'] priv_drop(cmd, chroot=chroot, chdir=builddir / subdir) try: priv_drop( [ "dpkg-buildpackage", "-uc", "--host-arch=" + hostarch, "--build=" + build.get("type", "binary"), *profile_option(build, "--build-profiles="), ], chroot=chroot, setuid="build", privnet=not build.get("network") in ("enable", "try-enable"), chdir=builddir / subdir, env=compute_env(build), ) except subprocess.CalledProcessError as cpe: sys.exit(cpe.returncode) shutil.rmtree(fullbuilddir / subdir) if build.get("lintian", {}).get("run"): priv_drop([*apt_get, "install", "lintian"], chroot=chroot) priv_drop(["lintian", *build["lintian"].get("options", ()), "%s_%s.changes" % (dscpath.stem, hostarch)], chroot=chroot, setuid="build", chdir=builddir) clean_dir(fullbuilddir, build["output"].get("artifacts", ["*"])) class RawStoreAction(argparse.Action): """An action that stores the raw value in addition to the type-parsed value. An additional "raw_"-prefixed attribute is added to the namespace carrying the value prior to passing it through the type function.""" # pylint: disable=W0622 def __init__(self, *args: typing.Any, type: typing.Callable[[str], typing.Any], **kwargs: typing.Any) -> None: def raw_type(value: typing.Any) -> typing.Any: return (value, type(value)) kwargs["type"] = functools.wraps(type)(raw_type) super().__init__(*args, **kwargs) def __call__(self, parser: argparse.ArgumentParser, namespace: argparse.Namespace, rawvalue: typing.Any, option_string: typing.Optional[str]=None) -> None: setattr(namespace, "raw_" + self.dest, rawvalue[0]) setattr(namespace, self.dest, rawvalue[1]) def main() -> None: """Entry point for mdbp-mmdebstrap backend""" if len(sys.argv) == 4 and sys.argv[1] == "--hook-helper": hook_main( json_load(pathlib.Path(sys.argv[2]).open("r", encoding="utf8")), pathlib.Path(sys.argv[3]), ) return parser = argparse.ArgumentParser() parser.add_argument("--mirror", type=str, action="store", default="http://deb.debian.org/debian", help="mirror url to fetch packages from") parser.add_argument("buildjson", type=buildjson, action=RawStoreAction) args = parser.parse_args() build = args.buildjson if build.get("bd-uninstallable-explainer") not in (None, "apt"): raise ValueError("bd-uinstallable-explainer %r not supported" % build.get("bd-uinstallable-explainer")) buildarch = build.get("build_architecture") or native_architecture() hostarch = build.get("host_architecture") or buildarch if buildarch == hostarch: buildessential = set(("build-essential", "fakeroot")) else: buildessential = set(("crossbuild-essential-" + hostarch, "libc-dev:" + hostarch, "libstdc++-dev:" + hostarch, "fakeroot")) builddir = build.get("build_path", "/build") enablelog = build["output"].get("log", True) cmd = [ "mmdebstrap", "--verbose" if enablelog else "--quiet", "--mode=unshare", "--variant=apt", "--architectures=" + ",".join(dict.fromkeys((buildarch, hostarch))), "--include=" + ",".join(buildessential), '--essential-hook=echo man-db man-db/auto-update boolean false | ' \ 'chroot "$1" debconf-set-selections', '--customize-hook=chroot "$1" useradd --user-group --create-home ' '--home-dir %s build --skel /nonexistent' % shlex.quote(builddir), *(["--customize-hook=copy-in " + shlex.join([ build["input"]["source_package_path"], *map(str, get_dsc_files(pathlib.Path( build["input"]["source_package_path"]))), builddir])] if "source_package_path" in build["input"] else ()), '--customize-hook=mdbp-mmdebstrap --hook-helper %s "$1"' % shlex.quote(args.raw_buildjson), *(["--customize-hook=sync-out " + shlex.join([builddir, build["output"]["directory"]])] if build["output"].get("artifacts", ["*"]) else ()), build["distribution"], "/dev/null", args.mirror, *(["deb-src %s %s main" % (args.mirror, build["distribution"])] if "sourcename" in build["input"] else ()), *build.get("extrarepositories", ()), ] with subprocess.Popen( cmd, stdout=None if enablelog else subprocess.DEVNULL, stderr=subprocess.STDOUT if enablelog else subprocess.DEVNULL, ) as proc: sys.exit(proc.wait()) if __name__ == "__main__": main()