]> git.ipfire.org Git - thirdparty/mkosi.git/commitdiff
Move Debian and Ubuntu to DistributionInstaller
authorJoerg Behrmann <behrmann@physik.fu-berlin.de>
Tue, 4 Oct 2022 16:06:38 +0000 (18:06 +0200)
committerJoerg Behrmann <behrmann@physik.fu-berlin.de>
Thu, 24 Nov 2022 14:51:33 +0000 (15:51 +0100)
mkosi/__init__.py
mkosi/distributions/debian.py [new file with mode: 0644]
mkosi/distributions/ubuntu.py [new file with mode: 0644]

index 58cac8f82f19459dd057add05faba7f74e3684a3..e4068263e96c8c68ef7bfcec132cd6ceedb7a12e 100644 (file)
@@ -110,7 +110,6 @@ from .install import (
     copy_path,
     install_skeleton_trees,
     open_close,
-    write_resource,
 )
 from .manifest import Manifest
 from .mounts import mount, mount_api_vfs, mount_bind, mount_overlay, mount_tmpfs
@@ -394,43 +393,6 @@ def fedora_release_cmp(a: str, b: str) -> int:
     return anum - bnum
 
 
-# Debian calls their architectures differently, so when calling debootstrap we
-# will have to map to their names
-# uname -m -> dpkg --print-architecture
-DEBIAN_ARCHITECTURES = {
-    "aarch64": "arm64",
-    "armhfp": "armhf",
-    "armv7l": "armhf",
-    "ia64": "ia64",
-    "mips64": "mipsel",
-    "m68k": "m68k",
-    "parisc64": "hppa",
-    "ppc64": "ppc64",
-    "ppc64le": "ppc64el",
-    "riscv64:": "riscv64",
-    "s390x": "s390x",
-    "x86": "i386",
-    "x86_64": "amd64",
-}
-
-# And the kernel package names have yet another format, so adjust accordingly
-# uname -m -> linux-image-$arch
-DEBIAN_KERNEL_ARCHITECTURES = {
-    "aarch64": "arm64",
-    "armhfp": "armmp",
-    "alpha": "alpha-generic",
-    "ia64": "itanium",
-    "m68k": "m68k",
-    "parisc64": "parisc64",
-    "ppc": "powerpc",
-    "ppc64": "powerpc64",
-    "ppc64le": "powerpc64le",
-    "riscv64:": "riscv64",
-    "s390x": "s390x",
-    "x86": "i386",
-    "x86_64": "amd64",
-}
-
 # EFI has its own conventions too
 EFI_ARCHITECTURES = {
     "x86_64": "x64",
@@ -1434,8 +1396,6 @@ def mount_cache(state: MkosiState) -> Iterator[None]:
         # We mount both the YUM and the DNF cache in this case, as YUM might
         # just be redirected to DNF even if we invoke the former
         cache_paths = ["var/cache/yum", "var/cache/dnf"]
-    elif state.config.distribution in (Distribution.debian, Distribution.ubuntu):
-        cache_paths = ["var/cache/apt/archives"]
     elif state.config.distribution == Distribution.gentoo:
         cache_paths = ["var/cache/binpkgs"]
     elif state.config.distribution == Distribution.opensuse:
@@ -2200,261 +2160,6 @@ def install_centos_variant(state: MkosiState) -> None:
         run_workspace_command(state, cmdline)
 
 
-def debootstrap_knows_arg(arg: str) -> bool:
-    return bytes("invalid option", "UTF-8") not in run(["debootstrap", arg],
-                                                       stdout=subprocess.PIPE, check=False).stdout
-
-
-@contextlib.contextmanager
-def mount_apt_local_mirror(state: MkosiState) -> Iterator[None]:
-    # Ensure apt inside the image can see the local mirror outside of it
-    mirror = state.config.local_mirror or state.config.mirror
-    if not mirror or not mirror.startswith("file:"):
-        yield
-        return
-
-    # Strip leading '/' as Path() does not behave well when concatenating
-    mirror_dir = mirror[5:].lstrip("/")
-
-    with complete_step("Mounting apt local mirror…", "Unmounting apt local mirror…"):
-        with mount_bind(Path("/") / mirror_dir, state.root / mirror_dir):
-            yield
-
-
-def invoke_apt(
-    state: MkosiState,
-    subcommand: str,
-    operation: str,
-    extra: Iterable[str],
-    **kwargs: Any,
-) -> CompletedProcess:
-
-    config_file = state.workspace / "apt.conf"
-    debarch = DEBIAN_ARCHITECTURES[state.config.architecture]
-
-    if not config_file.exists():
-        config_file.write_text(
-            dedent(
-                f"""\
-                Dir "{state.root}";
-                DPkg::Chroot-Directory "{state.root}";
-                """
-            )
-        )
-
-    cmdline = [
-        f"/usr/bin/apt-{subcommand}",
-        "-o", f"APT::Architecture={debarch}",
-        "-o", "dpkg::install::recursive::minimum=1000",
-        operation,
-        *extra,
-    ]
-    env = dict(
-        APT_CONFIG=f"{config_file}",
-        DEBIAN_FRONTEND="noninteractive",
-        DEBCONF_NONINTERACTIVE_SEEN="true",
-        INITRD="No",
-    )
-
-    with mount_apt_local_mirror(state), mount_api_vfs(state.root):
-        return run(cmdline, env=env, text=True, **kwargs)
-
-
-def add_apt_auxiliary_repos(state: MkosiState, repos: Set[str]) -> None:
-    if state.config.release in ("unstable", "sid"):
-        return
-
-    updates = f"deb {state.config.mirror} {state.config.release}-updates {' '.join(repos)}"
-    state.root.joinpath(f"etc/apt/sources.list.d/{state.config.release}-updates.list").write_text(f"{updates}\n")
-
-    # Security updates repos are never mirrored
-    if state.config.distribution == Distribution.ubuntu:
-        if state.config.architecture == "x86" or state.config.architecture == "x86_64":
-            security = f"deb http://security.ubuntu.com/ubuntu/ {state.config.release}-security {' '.join(repos)}"
-        else:
-            security = f"deb http://ports.ubuntu.com/ {state.config.release}-security {' '.join(repos)}"
-    elif state.config.release in ("stretch", "buster"):
-        security = f"deb http://security.debian.org/debian-security/ {state.config.release}/updates main"
-    else:
-        security = f"deb https://security.debian.org/debian-security {state.config.release}-security main"
-
-    state.root.joinpath(f"etc/apt/sources.list.d/{state.config.release}-security.list").write_text(f"{security}\n")
-
-
-def add_apt_package_if_exists(state: MkosiState, extra_packages: Set[str], package: str) -> None:
-    if invoke_apt(state, "cache", "search", ["--names-only", f"^{package}$"], stdout=subprocess.PIPE).stdout.strip():
-        add_packages(state.config, extra_packages, package)
-
-
-def install_debian_or_ubuntu(state: MkosiState) -> None:
-    # Either the image builds or it fails and we restart, we don't need safety fsyncs when bootstrapping
-    # Add it before debootstrap, as the second stage already uses dpkg from the chroot
-    dpkg_io_conf = state.root / "etc/dpkg/dpkg.cfg.d/unsafe_io"
-    os.makedirs(dpkg_io_conf.parent, mode=0o755, exist_ok=True)
-    dpkg_io_conf.write_text("force-unsafe-io\n")
-
-    repos = set(state.config.repositories) or {"main"}
-    # Ubuntu needs the 'universe' repo to install 'dracut'
-    if state.config.distribution == Distribution.ubuntu and state.config.bootable:
-        repos.add("universe")
-
-    # debootstrap fails if a base image is used with an already populated root, so skip it.
-    if state.config.base_image is None:
-        cmdline: List[PathString] = [
-            "debootstrap",
-            "--variant=minbase",
-            "--include=ca-certificates",
-            "--merged-usr",
-            f"--components={','.join(repos)}",
-        ]
-
-        debarch = DEBIAN_ARCHITECTURES[state.config.architecture]
-        cmdline += [f"--arch={debarch}"]
-
-        # Let's use --no-check-valid-until only if debootstrap knows it
-        if debootstrap_knows_arg("--no-check-valid-until"):
-            cmdline += ["--no-check-valid-until"]
-
-        if not state.config.repository_key_check:
-            cmdline += ["--no-check-gpg"]
-
-        mirror = state.config.local_mirror or state.config.mirror
-        assert mirror is not None
-        cmdline += [state.config.release, state.root, mirror]
-        run(cmdline)
-
-    # Install extra packages via the secondary APT run, because it is smarter and can deal better with any
-    # conflicts. dbus and libpam-systemd are optional dependencies for systemd in debian so we include them
-    # explicitly.
-    extra_packages: Set[str] = set()
-    add_packages(state.config, extra_packages, "systemd", "systemd-sysv", "dbus", "libpam-systemd")
-    extra_packages.update(state.config.packages)
-
-    if state.do_run_build_script:
-        extra_packages.update(state.config.build_packages)
-
-    if not state.do_run_build_script and state.config.bootable:
-        add_packages(state.config, extra_packages, "dracut")
-
-        # Don't pull in a kernel if users specify one, but otherwise try to pick a default
-        # one - linux-generic is a global metapackage in Ubuntu, but Debian doesn't have one,
-        # so try to infer from the architecture.
-        if state.config.distribution == Distribution.ubuntu:
-            if ("linux-generic" not in extra_packages and
-                not any(package.startswith("linux-image") for package in extra_packages)):
-                add_packages(state.config, extra_packages, "linux-generic")
-        elif state.config.distribution == Distribution.debian:
-            if not any(package.startswith("linux-image") for package in extra_packages):
-                add_packages(state.config, extra_packages, f"linux-image-{DEBIAN_KERNEL_ARCHITECTURES[state.config.architecture]}")
-
-        if state.config.output_format == OutputFormat.gpt_btrfs:
-            add_packages(state.config, extra_packages, "btrfs-progs")
-
-    if not state.do_run_build_script and state.config.ssh:
-        add_packages(state.config, extra_packages, "openssh-server")
-
-    # Debian policy is to start daemons by default. The policy-rc.d script can be used choose which ones to
-    # start. Let's install one that denies all daemon startups.
-    # See https://people.debian.org/~hmh/invokerc.d-policyrc.d-specification.txt for more information.
-    # Note: despite writing in /usr/sbin, this file is not shipped by the OS and instead should be managed by
-    # the admin.
-    policyrcd = state.root / "usr/sbin/policy-rc.d"
-    policyrcd.write_text("#!/bin/sh\nexit 101\n")
-    policyrcd.chmod(0o755)
-
-    doc_paths = [
-        "/usr/share/locale",
-        "/usr/share/doc",
-        "/usr/share/man",
-        "/usr/share/groff",
-        "/usr/share/info",
-        "/usr/share/lintian",
-        "/usr/share/linda",
-    ]
-    if not state.config.with_docs:
-        # Remove documentation installed by debootstrap
-        cmdline = ["/bin/rm", "-rf", *doc_paths]
-        run_workspace_command(state, cmdline)
-        # Create dpkg.cfg to ignore documentation on new packages
-        dpkg_nodoc_conf = state.root / "etc/dpkg/dpkg.cfg.d/01_nodoc"
-        with dpkg_nodoc_conf.open("w") as f:
-            f.writelines(f"path-exclude {d}/*\n" for d in doc_paths)
-
-    if not state.do_run_build_script and state.config.bootable and state.config.with_unified_kernel_images and state.config.base_image is None:
-        # systemd-boot won't boot unified kernel images generated without a BUILD_ID or VERSION_ID in
-        # /etc/os-release. Build one with the mtime of os-release if we don't find them.
-        with state.root.joinpath("etc/os-release").open("r+") as f:
-            os_release = f.read()
-            if "VERSION_ID" not in os_release and "BUILD_ID" not in os_release:
-                f.write(f"BUILD_ID=mkosi-{state.config.release}\n")
-
-    if not state.config.local_mirror:
-        add_apt_auxiliary_repos(state, repos)
-    else:
-        # Add a single local offline repository, and then remove it after apt has ran
-        state.root.joinpath("etc/apt/sources.list.d/mirror.list").write_text(f"deb [trusted=yes] {state.config.local_mirror} {state.config.release} main\n")
-
-    install_skeleton_trees(state, False, late=True)
-
-    invoke_apt(state, "get", "update", ["--assume-yes"])
-
-    if state.config.bootable and not state.do_run_build_script and state.get_partition(PartitionIdentifier.esp):
-        add_apt_package_if_exists(state, extra_packages, "systemd-boot")
-
-    # systemd-resolved was split into a separate package
-    add_apt_package_if_exists(state, extra_packages, "systemd-resolved")
-
-    invoke_apt(state, "get", "install", ["--assume-yes", "--no-install-recommends", *extra_packages])
-
-    # Now clean up and add the real repositories, so that the image is ready
-    if state.config.local_mirror:
-        main_repo = f"deb {state.config.mirror} {state.config.release} {' '.join(repos)}\n"
-        state.root.joinpath("etc/apt/sources.list").write_text(main_repo)
-        state.root.joinpath("etc/apt/sources.list.d/mirror.list").unlink()
-        add_apt_auxiliary_repos(state, repos)
-
-    policyrcd.unlink()
-    dpkg_io_conf.unlink()
-    if not state.config.with_docs and state.config.base_image is not None:
-        # Don't ship dpkg config files in extensions, they belong with dpkg in the base image.
-        dpkg_nodoc_conf.unlink() # type: ignore
-
-    if state.config.base_image is None:
-        # Debian still has pam_securetty module enabled, disable it in the base image.
-        disable_pam_securetty(state.root)
-
-    if (state.config.distribution == Distribution.debian and "systemd" in extra_packages and
-            ("systemd-resolved" not in extra_packages)):
-        # The default resolv.conf points to 127.0.0.1, and resolved is disabled, fix it in
-        # the base image.
-        # TODO: use missing_ok=True when we drop Python << 3.8
-        if state.root.joinpath("etc/resolv.conf").exists():
-            state.root.joinpath("etc/resolv.conf").unlink()
-        state.root.joinpath("etc/resolv.conf").symlink_to("../run/systemd/resolve/resolv.conf")
-        run(["systemctl", "--root", state.root, "enable", "systemd-resolved"])
-
-    write_resource(state.root / "etc/kernel/install.d/50-mkosi-dpkg-reconfigure-dracut.install",
-                   "mkosi.resources", "dpkg-reconfigure-dracut.install", executable=True)
-
-    # Debian/Ubuntu use a different path to store the locale so let's make sure that path is a symlink to
-    # etc/locale.conf.
-    try:
-        state.root.joinpath("etc/default/locale").unlink()
-    except FileNotFoundError:
-        pass
-    state.root.joinpath("etc/default/locale").symlink_to("../locale.conf")
-
-
-@complete_step("Installing Debian…")
-def install_debian(state: MkosiState) -> None:
-    install_debian_or_ubuntu(state)
-
-
-@complete_step("Installing Ubuntu…")
-def install_ubuntu(state: MkosiState) -> None:
-    install_debian_or_ubuntu(state)
-
-
 @complete_step("Installing openSUSE…")
 def install_opensuse(state: MkosiState) -> None:
     release = state.config.release.strip('"')
@@ -2598,8 +2303,6 @@ def install_distribution(state: MkosiState, cached: bool) -> None:
         install = {
             Distribution.fedora: install_fedora,
             Distribution.mageia: install_mageia,
-            Distribution.debian: install_debian,
-            Distribution.ubuntu: install_ubuntu,
             Distribution.opensuse: install_opensuse,
             Distribution.openmandriva: install_openmandriva,
             Distribution.gentoo: install_gentoo,
@@ -2627,8 +2330,6 @@ def remove_packages(state: MkosiState) -> None:
         remove = lambda p: state.installer.remove_packages(state, p) # type: ignore
     elif (state.config.distribution.package_type == PackageType.rpm):
         remove = lambda p: invoke_dnf(state, 'remove', p)
-    elif state.config.distribution.package_type == PackageType.deb:
-        remove = lambda p: invoke_apt(state, "get", "purge", ["--assume-yes", "--auto-remove", *p])
     else:
         die(f"Removing packages is not supported for {state.config.distribution}")
 
@@ -3523,8 +3224,6 @@ def gen_kernel_images(state: MkosiState) -> Iterator[Tuple[str, Path]]:
             _, kimg_path = ARCHITECTURES[state.config.architecture]
 
             kimg = Path(f"usr/src/linux-{kver.name}") / kimg_path
-        elif state.config.distribution in (Distribution.debian, Distribution.ubuntu):
-            kimg = Path(f"boot/vmlinuz-{kver.name}")
         else:
             kimg = Path("lib/modules") / kver.name / "vmlinuz"
 
diff --git a/mkosi/distributions/debian.py b/mkosi/distributions/debian.py
new file mode 100644 (file)
index 0000000..a3bdf21
--- /dev/null
@@ -0,0 +1,323 @@
+# SPDX-License-Identifier: LGPL-2.1+
+
+import contextlib
+import os
+import subprocess
+from pathlib import Path
+from textwrap import dedent
+from typing import TYPE_CHECKING, Any, Iterable, Iterator, List, Set
+
+from ..backend import (
+    MkosiState,
+    OutputFormat,
+    PartitionIdentifier,
+    PathString,
+    add_packages,
+    complete_step,
+    disable_pam_securetty,
+    run,
+    run_workspace_command,
+)
+from ..install import install_skeleton_trees, write_resource
+from ..mounts import mount_api_vfs, mount_bind
+from . import DistributionInstaller
+
+if TYPE_CHECKING:
+    CompletedProcess = subprocess.CompletedProcess[Any]
+else:
+    CompletedProcess = subprocess.CompletedProcess
+
+
+class DebianInstaller(DistributionInstaller):
+    needs_skeletons_after_bootstrap = True
+    repositories_for_boot: Set[str] = set()
+
+    @classmethod
+    def _add_default_kernel_package(cls, state: MkosiState, extra_packages: Set[str]) -> None:
+        # Don't pull in a kernel if users specify one, but otherwise try to pick a default
+        # one - try to infer from the architecture.
+        if not any(package.startswith("linux-image") for package in extra_packages):
+            add_packages(state.config, extra_packages, f"linux-image-{DEBIAN_KERNEL_ARCHITECTURES[state.config.architecture]}")
+
+    @classmethod
+    def _fixup_resolved(cls, state: MkosiState, extra_packages: Set[str]) -> None:
+        if "systemd" in extra_packages and "systemd-resolved" not in extra_packages:
+            # The default resolv.conf points to 127.0.0.1, and resolved is disabled, fix it in
+            # the base image.
+            # TODO: use missing_ok=True when we drop Python << 3.8
+            if state.root.joinpath("etc/resolv.conf").exists():
+                state.root.joinpath("etc/resolv.conf").unlink()
+            state.root.joinpath("etc/resolv.conf").symlink_to("../run/systemd/resolve/resolv.conf")
+            run(["systemctl", "--root", state.root, "enable", "systemd-resolved"])
+
+    @classmethod
+    def cache_path(cls) -> List[str]:
+        return ["var/cache/apt/archives"]
+
+    @staticmethod
+    def kernel_image(name: str, architecture: str) -> Path:
+        return Path(f"boot/vmlinuz-{name}")
+
+    @classmethod
+    def install(cls, state: "MkosiState") -> None:
+        # Either the image builds or it fails and we restart, we don't need safety fsyncs when bootstrapping
+        # Add it before debootstrap, as the second stage already uses dpkg from the chroot
+        dpkg_io_conf = state.root / "etc/dpkg/dpkg.cfg.d/unsafe_io"
+        os.makedirs(dpkg_io_conf.parent, mode=0o755, exist_ok=True)
+        dpkg_io_conf.write_text("force-unsafe-io\n")
+
+        repos = set(state.config.repositories) or {"main"}
+        # Ubuntu needs the 'universe' repo to install 'dracut'
+        if state.config.bootable:
+            repos |= cls.repositories_for_boot
+
+        # debootstrap fails if a base image is used with an already populated root, so skip it.
+        if state.config.base_image is None:
+            cmdline: List[PathString] = [
+                "debootstrap",
+                "--variant=minbase",
+                "--include=ca-certificates",
+                "--merged-usr",
+                f"--components={','.join(repos)}",
+            ]
+
+            debarch = DEBIAN_ARCHITECTURES[state.config.architecture]
+            cmdline += [f"--arch={debarch}"]
+
+            # Let's use --no-check-valid-until only if debootstrap knows it
+            if debootstrap_knows_arg("--no-check-valid-until"):
+                cmdline += ["--no-check-valid-until"]
+
+            if not state.config.repository_key_check:
+                cmdline += ["--no-check-gpg"]
+
+            mirror = state.config.local_mirror or state.config.mirror
+            assert mirror is not None
+            cmdline += [state.config.release, state.root, mirror]
+            run(cmdline)
+
+        # Install extra packages via the secondary APT run, because it is smarter and can deal better with any
+        # conflicts. dbus and libpam-systemd are optional dependencies for systemd in debian so we include them
+        # explicitly.
+        extra_packages: Set[str] = set()
+        add_packages(state.config, extra_packages, "systemd", "systemd-sysv", "dbus", "libpam-systemd")
+        extra_packages.update(state.config.packages)
+
+        if state.do_run_build_script:
+            extra_packages.update(state.config.build_packages)
+
+        if not state.do_run_build_script and state.config.bootable:
+            add_packages(state.config, extra_packages, "dracut")
+            cls._add_default_kernel_package(state, extra_packages)
+
+            if state.config.output_format == OutputFormat.gpt_btrfs:
+                add_packages(state.config, extra_packages, "btrfs-progs")
+
+        if not state.do_run_build_script and state.config.ssh:
+            add_packages(state.config, extra_packages, "openssh-server")
+
+        # Debian policy is to start daemons by default. The policy-rc.d script can be used choose which ones to
+        # start. Let's install one that denies all daemon startups.
+        # See https://people.debian.org/~hmh/invokerc.d-policyrc.d-specification.txt for more information.
+        # Note: despite writing in /usr/sbin, this file is not shipped by the OS and instead should be managed by
+        # the admin.
+        policyrcd = state.root / "usr/sbin/policy-rc.d"
+        policyrcd.write_text("#!/bin/sh\nexit 101\n")
+        policyrcd.chmod(0o755)
+
+        doc_paths = [
+            "/usr/share/locale",
+            "/usr/share/doc",
+            "/usr/share/man",
+            "/usr/share/groff",
+            "/usr/share/info",
+            "/usr/share/lintian",
+            "/usr/share/linda",
+        ]
+        if not state.config.with_docs:
+            # Remove documentation installed by debootstrap
+            cmdline = ["/bin/rm", "-rf", *doc_paths]
+            run_workspace_command(state, cmdline)
+            # Create dpkg.cfg to ignore documentation on new packages
+            dpkg_nodoc_conf = state.root / "etc/dpkg/dpkg.cfg.d/01_nodoc"
+            with dpkg_nodoc_conf.open("w") as f:
+                f.writelines(f"path-exclude {d}/*\n" for d in doc_paths)
+
+        if not state.do_run_build_script and state.config.bootable and state.config.with_unified_kernel_images and state.config.base_image is None:
+            # systemd-boot won't boot unified kernel images generated without a BUILD_ID or VERSION_ID in
+            # /etc/os-release. Build one with the mtime of os-release if we don't find them.
+            with state.root.joinpath("etc/os-release").open("r+") as f:
+                os_release = f.read()
+                if "VERSION_ID" not in os_release and "BUILD_ID" not in os_release:
+                    f.write(f"BUILD_ID=mkosi-{state.config.release}\n")
+
+        if not state.config.local_mirror:
+            cls._add_apt_auxiliary_repos(state, repos)
+        else:
+            # Add a single local offline repository, and then remove it after apt has ran
+            state.root.joinpath("etc/apt/sources.list.d/mirror.list").write_text(f"deb [trusted=yes] {state.config.local_mirror} {state.config.release} main\n")
+
+        install_skeleton_trees(state, False, late=True)
+
+        invoke_apt(state, "get", "update", ["--assume-yes"])
+
+        if state.config.bootable and not state.do_run_build_script and state.get_partition(PartitionIdentifier.esp):
+            add_apt_package_if_exists(state, extra_packages, "systemd-boot")
+
+        # systemd-resolved was split into a separate package
+        add_apt_package_if_exists(state, extra_packages, "systemd-resolved")
+
+        invoke_apt(state, "get", "install", ["--assume-yes", "--no-install-recommends", *extra_packages])
+
+        # Now clean up and add the real repositories, so that the image is ready
+        if state.config.local_mirror:
+            main_repo = f"deb {state.config.mirror} {state.config.release} {' '.join(repos)}\n"
+            state.root.joinpath("etc/apt/sources.list").write_text(main_repo)
+            state.root.joinpath("etc/apt/sources.list.d/mirror.list").unlink()
+            cls._add_apt_auxiliary_repos(state, repos)
+
+        policyrcd.unlink()
+        dpkg_io_conf.unlink()
+        if not state.config.with_docs and state.config.base_image is not None:
+            # Don't ship dpkg config files in extensions, they belong with dpkg in the base image.
+            dpkg_nodoc_conf.unlink() # type: ignore
+
+        if state.config.base_image is None:
+            # Debian still has pam_securetty module enabled, disable it in the base image.
+            disable_pam_securetty(state.root)
+
+        cls._fixup_resolved(state, extra_packages)
+
+        write_resource(state.root / "etc/kernel/install.d/50-mkosi-dpkg-reconfigure-dracut.install",
+                       "mkosi.resources", "dpkg-reconfigure-dracut.install", executable=True)
+
+        # Debian/Ubuntu use a different path to store the locale so let's make sure that path is a symlink to
+        # etc/locale.conf.
+        try:
+            state.root.joinpath("etc/default/locale").unlink()
+        except FileNotFoundError:
+            pass
+        state.root.joinpath("etc/default/locale").symlink_to("../locale.conf")
+
+    @classmethod
+    def _add_apt_auxiliary_repos(cls, state: MkosiState, repos: Set[str]) -> None:
+        if state.config.release in ("unstable", "sid"):
+            return
+
+        updates = f"deb {state.config.mirror} {state.config.release}-updates {' '.join(repos)}"
+        state.root.joinpath(f"etc/apt/sources.list.d/{state.config.release}-updates.list").write_text(f"{updates}\n")
+
+        # Security updates repos are never mirrored
+        if state.config.release in ("stretch", "buster"):
+            security = f"deb http://security.debian.org/debian-security/ {state.config.release}/updates main"
+        else:
+            security = f"deb https://security.debian.org/debian-security {state.config.release}-security main"
+
+        state.root.joinpath(f"etc/apt/sources.list.d/{state.config.release}-security.list").write_text(f"{security}\n")
+
+    @classmethod
+    def remove_packages(cls, state: MkosiState, remove: List[str]) -> None:
+        invoke_apt(state, "get", "purge", ["--assume-yes", "--auto-remove", *remove])
+
+
+# Debian calls their architectures differently, so when calling debootstrap we
+# will have to map to their names
+# uname -m -> dpkg --print-architecture
+DEBIAN_ARCHITECTURES = {
+    "aarch64": "arm64",
+    "armhfp": "armhf",
+    "armv7l": "armhf",
+    "ia64": "ia64",
+    "mips64": "mipsel",
+    "m68k": "m68k",
+    "parisc64": "hppa",
+    "ppc64": "ppc64",
+    "ppc64le": "ppc64el",
+    "riscv64:": "riscv64",
+    "s390x": "s390x",
+    "x86": "i386",
+    "x86_64": "amd64",
+}
+
+# And the kernel package names have yet another format, so adjust accordingly
+# uname -m -> linux-image-$arch
+DEBIAN_KERNEL_ARCHITECTURES = {
+    "aarch64": "arm64",
+    "armhfp": "armmp",
+    "alpha": "alpha-generic",
+    "ia64": "itanium",
+    "m68k": "m68k",
+    "parisc64": "parisc64",
+    "ppc": "powerpc",
+    "ppc64": "powerpc64",
+    "ppc64le": "powerpc64le",
+    "riscv64:": "riscv64",
+    "s390x": "s390x",
+    "x86": "i386",
+    "x86_64": "amd64",
+}
+
+
+def debootstrap_knows_arg(arg: str) -> bool:
+    return bytes("invalid option", "UTF-8") not in run(["debootstrap", arg],
+                                                       stdout=subprocess.PIPE, check=False).stdout
+
+
+@contextlib.contextmanager
+def mount_apt_local_mirror(state: MkosiState) -> Iterator[None]:
+    # Ensure apt inside the image can see the local mirror outside of it
+    mirror = state.config.local_mirror or state.config.mirror
+    if not mirror or not mirror.startswith("file:"):
+        yield
+        return
+
+    # Strip leading '/' as Path() does not behave well when concatenating
+    mirror_dir = mirror[5:].lstrip("/")
+
+    with complete_step("Mounting apt local mirror…", "Unmounting apt local mirror…"):
+        with mount_bind(Path("/") / mirror_dir, state.root / mirror_dir):
+            yield
+
+
+def invoke_apt(
+    state: MkosiState,
+    subcommand: str,
+    operation: str,
+    extra: Iterable[str],
+    **kwargs: Any,
+) -> CompletedProcess:
+
+    config_file = state.workspace / "apt.conf"
+    debarch = DEBIAN_ARCHITECTURES[state.config.architecture]
+
+    if not config_file.exists():
+        config_file.write_text(
+            dedent(
+                f"""\
+                Dir "{state.root}";
+                DPkg::Chroot-Directory "{state.root}";
+                """
+            )
+        )
+
+    cmdline = [
+        f"/usr/bin/apt-{subcommand}",
+        "-o", f"APT::Architecture={debarch}",
+        "-o", "dpkg::install::recursive::minimum=1000",
+        operation,
+        *extra,
+    ]
+    env = dict(
+        APT_CONFIG=f"{config_file}",
+        DEBIAN_FRONTEND="noninteractive",
+        DEBCONF_NONINTERACTIVE_SEEN="true",
+        INITRD="No",
+    )
+
+    with mount_apt_local_mirror(state), mount_api_vfs(state.root):
+        return run(cmdline, env=env, text=True, **kwargs)
+
+
+def add_apt_package_if_exists(state: MkosiState, extra_packages: Set[str], package: str) -> None:
+    if invoke_apt(state, "cache", "search", ["--names-only", f"^{package}$"], stdout=subprocess.PIPE).stdout.strip():
+        add_packages(state.config, extra_packages, package)
diff --git a/mkosi/distributions/ubuntu.py b/mkosi/distributions/ubuntu.py
new file mode 100644 (file)
index 0000000..9429274
--- /dev/null
@@ -0,0 +1,34 @@
+# SPDX-License-Identifier: LGPL-2.1+
+
+from typing import Set
+
+from ..backend import MkosiState, add_packages
+from .debian import DebianInstaller
+
+
+class UbuntuInstaller(DebianInstaller):
+    repositories_for_boot = {"universe"}
+
+    @classmethod
+    def _add_default_kernel_package(cls, state: MkosiState, extra_packages: Set[str]) -> None:
+        # use the global metapckage linux-generic if the user didn't pick one
+        if ("linux-generic" not in extra_packages and
+            not any(package.startswith("linux-image") for package in extra_packages)):
+            add_packages(state.config, extra_packages, "linux-generic")
+
+    @classmethod
+    def _add_apt_auxiliary_repos(cls, state: MkosiState, repos: Set[str]) -> None:
+        if state.config.release in ("unstable", "sid"):
+            return
+
+        updates = f"deb {state.config.mirror} {state.config.release}-updates {' '.join(repos)}"
+        state.root.joinpath(f"etc/apt/sources.list.d/{state.config.release}-updates.list").write_text(f"{updates}\n")
+
+        # Security updates repos are never mirrored
+        security = f"deb http://security.ubuntu.com/ubuntu/ {state.config.release}-security {' '.join(repos)}"
+
+        state.root.joinpath(f"etc/apt/sources.list.d/{state.config.release}-security.list").write_text(f"{security}\n")
+
+    @classmethod
+    def _fixup_resolved(cls, state: MkosiState, extra_packages: Set[str]) -> None:
+        pass