├── mkosi ├── resources │ ├── __init__.py │ ├── mkosi-initrd │ │ ├── mkosi.conf.d │ │ │ ├── 20-stub.conf │ │ │ ├── 10-opensuse.conf │ │ │ ├── 10-centos-fedora.conf │ │ │ ├── 10-debian-ubuntu.conf │ │ │ └── 10-arch.conf │ │ ├── mkosi.extra │ │ │ └── usr │ │ │ │ └── lib │ │ │ │ ├── systemd │ │ │ │ ├── system-preset │ │ │ │ │ └── 99-mkosi.preset │ │ │ │ └── system │ │ │ │ │ └── systemd-cryptsetup@.service.d │ │ │ │ │ └── credential.conf │ │ │ │ └── udev │ │ │ │ └── rules.d │ │ │ │ ├── 10-mkosi-initrd-dm.rules │ │ │ │ └── 10-mkosi-initrd-md.rules │ │ └── mkosi.conf │ ├── repart │ │ └── definitions │ │ │ ├── confext.repart.d │ │ │ ├── 30-root-verity-sig.conf │ │ │ ├── 20-root-verity.conf │ │ │ └── 10-root.conf │ │ │ ├── portable.repart.d │ │ │ ├── 30-root-verity-sig.conf │ │ │ ├── 20-root-verity.conf │ │ │ └── 10-root.conf │ │ │ └── sysext.repart.d │ │ │ ├── 30-root-verity-sig.conf │ │ │ ├── 20-root-verity.conf │ │ │ └── 10-root.conf │ └── mkosi-tools │ │ ├── mkosi.prepare.chroot │ │ ├── mkosi.conf.d │ │ ├── 10-fedora │ │ │ ├── mkosi.conf.d │ │ │ │ └── 10-uefi.conf │ │ │ └── mkosi.conf │ │ ├── 10-centos-fedora │ │ │ ├── mkosi.conf.d │ │ │ │ └── 10-uefi.conf │ │ │ └── mkosi.conf │ │ ├── 10-centos.conf │ │ ├── 10-arch.conf │ │ ├── 10-opensuse.conf │ │ └── 10-debian-ubuntu.conf │ │ └── mkosi.conf ├── pager.py ├── distributions │ ├── custom.py │ ├── alma.py │ ├── rocky.py │ ├── ubuntu.py │ ├── rhel_ubi.py │ ├── arch.py │ ├── mageia.py │ ├── openmandriva.py │ ├── rhel.py │ ├── opensuse.py │ ├── __init__.py │ ├── fedora.py │ ├── gentoo.py │ └── debian.py ├── burn.py ├── types.py ├── __main__.py ├── state.py ├── installer │ ├── __init__.py │ ├── rpm.py │ ├── zypper.py │ ├── pacman.py │ ├── apt.py │ └── dnf.py ├── partition.py ├── log.py ├── archive.py ├── tree.py ├── util.py ├── mounts.py ├── kmod.py ├── bubblewrap.py └── versioncomp.py ├── tests ├── .gitignore ├── test_sysext.py ├── conftest.py ├── test_boot.py └── __init__.py ├── MANIFEST.in ├── mkosi.md ├── mkosi-initrd ├── mkosi.conf.d ├── 15-x86-64.conf ├── 30-rpm │ ├── mkosi.postinst │ ├── mkosi.conf │ ├── mkosi.build.chroot │ └── mkosi.prepare ├── 20-debian │ ├── mkosi.conf.d │ │ ├── 20-arm64.conf │ │ └── 20-x86-64.conf │ └── mkosi.conf ├── 20-fedora │ ├── mkosi.conf.d │ │ └── 20-uefi.conf │ └── mkosi.conf ├── 20-centos-fedora │ ├── mkosi.conf.d │ │ ├── 20-x86-64.conf │ │ └── 20-uefi.conf │ └── mkosi.conf ├── 15-bootable.conf ├── 20-debian-ubuntu │ ├── mkosi.conf.d │ │ └── 20-x86-64.conf │ └── mkosi.conf ├── 20-rhel-ubi.conf ├── 20-centos.conf ├── 20-ubuntu.conf ├── 20-arch.conf └── 20-opensuse.conf ├── tools ├── make-man-page.sh ├── generate-zipapp.sh └── do-a-release.sh ├── mkosi.prepare.chroot ├── mkosi.extra └── usr │ └── lib │ └── systemd │ ├── system-preset │ ├── 99-mkosi.preset │ └── 00-mkosi.preset │ ├── mkosi-check-and-shutdown.sh │ └── system │ └── mkosi-check-and-shutdown.service ├── .editorconfig ├── .gitignore ├── .dir-locals.el ├── bin └── mkosi ├── .github └── workflows │ ├── differential-shellcheck.yml │ ├── codeql.yml │ └── ci.yml ├── docs ├── initrd.md ├── bootable.md ├── distribution-policy.md ├── sysext.md └── building-rpms-from-source.md ├── mkosi.conf ├── pyproject.toml ├── action.yaml ├── README.md └── kernel-install └── 50-mkosi.install /mkosi/resources/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/.gitignore: -------------------------------------------------------------------------------- 1 | /*.pyc 2 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | -------------------------------------------------------------------------------- /mkosi.md: -------------------------------------------------------------------------------- 1 | mkosi/resources/mkosi.md -------------------------------------------------------------------------------- /mkosi-initrd: -------------------------------------------------------------------------------- 1 | mkosi/resources/mkosi-initrd -------------------------------------------------------------------------------- /mkosi.conf.d/15-x86-64.conf: -------------------------------------------------------------------------------- 1 | [Match] 2 | Architecture=x86-64 3 | 4 | [Content] 5 | @BiosBootloader=grub 6 | -------------------------------------------------------------------------------- /mkosi.conf.d/30-rpm/mkosi.postinst: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # SPDX-License-Identifier: LGPL-2.1-or-later 3 | set -e 4 | 5 | rpm --install "$OUTPUTDIR"/*mkosi*.rpm 6 | -------------------------------------------------------------------------------- /tools/make-man-page.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # SPDX-License-Identifier: LGPL-2.1+ 3 | set -ex 4 | 5 | pandoc -t man -s -o mkosi/resources/mkosi.1 mkosi/resources/mkosi.md 6 | -------------------------------------------------------------------------------- /mkosi.conf.d/30-rpm/mkosi.conf: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1-or-later 2 | 3 | [Match] 4 | BuildSources=rpm 5 | Distribution=fedora 6 | 7 | [Content] 8 | Packages=rpm-build 9 | -------------------------------------------------------------------------------- /mkosi/resources/mkosi-initrd/mkosi.conf.d/20-stub.conf: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1-or-later 2 | 3 | [Match] 4 | Format=uki 5 | Distribution=!arch 6 | 7 | [Content] 8 | Packages=systemd-boot 9 | -------------------------------------------------------------------------------- /mkosi.conf.d/20-debian/mkosi.conf.d/20-arm64.conf: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1-or-later 2 | 3 | [Match] 4 | Architecture=arm64 5 | 6 | [Content] 7 | Packages= 8 | linux-image-cloud-arm64 9 | -------------------------------------------------------------------------------- /mkosi.prepare.chroot: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # SPDX-License-Identifier: LGPL-2.1-or-later 3 | 4 | if [ "$1" = "final" ] && command -v pacman-key; then 5 | pacman-key --init 6 | pacman-key --populate 7 | fi 8 | -------------------------------------------------------------------------------- /mkosi.conf.d/20-debian/mkosi.conf.d/20-x86-64.conf: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1-or-later 2 | 3 | [Match] 4 | Architecture=x86-64 5 | 6 | [Content] 7 | Packages= 8 | linux-image-cloud-amd64 9 | -------------------------------------------------------------------------------- /mkosi.extra/usr/lib/systemd/system-preset/99-mkosi.preset: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1-or-later 2 | 3 | # Make sure that services are disabled by default (primarily for Debian/Ubuntu). 4 | disable * 5 | -------------------------------------------------------------------------------- /mkosi/resources/repart/definitions/confext.repart.d/30-root-verity-sig.conf: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1-or-later 2 | [Partition] 3 | Type=root-verity-sig 4 | Verity=signature 5 | VerityMatchKey=root 6 | -------------------------------------------------------------------------------- /mkosi/resources/repart/definitions/portable.repart.d/30-root-verity-sig.conf: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1-or-later 2 | [Partition] 3 | Type=root-verity-sig 4 | Verity=signature 5 | VerityMatchKey=root 6 | -------------------------------------------------------------------------------- /mkosi/resources/repart/definitions/sysext.repart.d/30-root-verity-sig.conf: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1-or-later 2 | [Partition] 3 | Type=root-verity-sig 4 | Verity=signature 5 | VerityMatchKey=root 6 | -------------------------------------------------------------------------------- /mkosi/resources/repart/definitions/confext.repart.d/20-root-verity.conf: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1-or-later 2 | [Partition] 3 | Type=root-verity 4 | Verity=hash 5 | VerityMatchKey=root 6 | Minimize=best 7 | -------------------------------------------------------------------------------- /mkosi/resources/repart/definitions/portable.repart.d/20-root-verity.conf: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1-or-later 2 | [Partition] 3 | Type=root-verity 4 | Verity=hash 5 | VerityMatchKey=root 6 | Minimize=best 7 | -------------------------------------------------------------------------------- /mkosi/resources/repart/definitions/sysext.repart.d/20-root-verity.conf: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1-or-later 2 | [Partition] 3 | Type=root-verity 4 | Verity=hash 5 | VerityMatchKey=root 6 | Minimize=best 7 | -------------------------------------------------------------------------------- /mkosi.conf.d/20-fedora/mkosi.conf.d/20-uefi.conf: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1-or-later 2 | 3 | [Match] 4 | Architecture=|x86-64 5 | Architecture=|arm64 6 | 7 | [Content] 8 | Packages= 9 | sbsigntools 10 | -------------------------------------------------------------------------------- /mkosi.conf.d/20-centos-fedora/mkosi.conf.d/20-x86-64.conf: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1-or-later 2 | 3 | [Match] 4 | Architecture=x86-64 5 | 6 | [Content] 7 | Packages= 8 | microcode_ctl 9 | grub2-pc 10 | -------------------------------------------------------------------------------- /mkosi/resources/mkosi-tools/mkosi.prepare.chroot: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # SPDX-License-Identifier: LGPL-2.1-or-later 3 | 4 | if [ "$1" = "final" ] && command -v pacman-key; then 5 | pacman-key --init 6 | pacman-key --populate 7 | fi 8 | -------------------------------------------------------------------------------- /mkosi/resources/repart/definitions/confext.repart.d/10-root.conf: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1-or-later 2 | [Partition] 3 | Type=root 4 | Format=erofs 5 | CopyFiles=/etc/ 6 | Verity=data 7 | VerityMatchKey=root 8 | Minimize=best 9 | -------------------------------------------------------------------------------- /mkosi/resources/repart/definitions/portable.repart.d/10-root.conf: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1-or-later 2 | [Partition] 3 | Type=root 4 | Format=erofs 5 | CopyFiles=/ 6 | Verity=data 7 | VerityMatchKey=root 8 | Minimize=best 9 | -------------------------------------------------------------------------------- /mkosi/resources/repart/definitions/sysext.repart.d/10-root.conf: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1-or-later 2 | [Partition] 3 | Type=root 4 | Format=erofs 5 | CopyFiles=/usr/ 6 | Verity=data 7 | VerityMatchKey=root 8 | Minimize=best 9 | -------------------------------------------------------------------------------- /mkosi/resources/mkosi-initrd/mkosi.extra/usr/lib/systemd/system-preset/99-mkosi.preset: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1-or-later 2 | 3 | # Make sure that services are disabled by default (primarily for Debian/Ubuntu). 4 | disable * 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | trim_trailing_whitespace = true 7 | charset = utf-8 8 | indent_style = space 9 | indent_size = 4 10 | 11 | [*.yaml,*.yml] 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /mkosi.conf.d/15-bootable.conf: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1-or-later 2 | 3 | [Match] 4 | Format=|disk 5 | Format=|directory 6 | 7 | [Match] 8 | Architecture=|x86-64 9 | Architecture=|arm64 10 | 11 | [Content] 12 | @Bootable=yes 13 | -------------------------------------------------------------------------------- /mkosi.conf.d/20-debian-ubuntu/mkosi.conf.d/20-x86-64.conf: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1-or-later 2 | 3 | [Match] 4 | Architecture=x86-64 5 | 6 | [Content] 7 | Packages= 8 | amd64-microcode 9 | grub-pc 10 | intel-microcode 11 | -------------------------------------------------------------------------------- /mkosi.conf.d/20-rhel-ubi.conf: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1-or-later 2 | 3 | [Match] 4 | Distribution=rhel-ubi 5 | 6 | [Distribution] 7 | @Release=9 8 | 9 | [Content] 10 | Bootable=no 11 | Packages= 12 | systemd 13 | systemd-udev 14 | -------------------------------------------------------------------------------- /mkosi/resources/mkosi-tools/mkosi.conf.d/10-fedora/mkosi.conf.d/10-uefi.conf: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1-or-later 2 | 3 | [Match] 4 | HostArchitecture=|x86-64 5 | HostArchitecture=|arm64 6 | 7 | [Content] 8 | Packages= 9 | sbsigntools 10 | -------------------------------------------------------------------------------- /mkosi.conf.d/20-debian/mkosi.conf: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1-or-later 2 | 3 | [Match] 4 | Distribution=debian 5 | 6 | [Distribution] 7 | @Release=unstable 8 | Repositories=non-free-firmware 9 | 10 | [Content] 11 | Packages= 12 | linux-perf 13 | -------------------------------------------------------------------------------- /mkosi.conf.d/20-centos-fedora/mkosi.conf.d/20-uefi.conf: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1-or-later 2 | 3 | [Match] 4 | Architecture=|x86-64 5 | Architecture=|arm64 6 | 7 | [Content] 8 | Packages= 9 | systemd-boot 10 | pesign 11 | edk2-ovmf 12 | -------------------------------------------------------------------------------- /mkosi/resources/mkosi-tools/mkosi.conf.d/10-centos-fedora/mkosi.conf.d/10-uefi.conf: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1-or-later 2 | 3 | [Match] 4 | HostArchitecture=|x86-64 5 | HostArchitecture=|arm64 6 | 7 | [Content] 8 | Packages= 9 | edk2-ovmf 10 | pesign 11 | -------------------------------------------------------------------------------- /mkosi/resources/mkosi-tools/mkosi.conf.d/10-centos.conf: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1-or-later 2 | 3 | [Match] 4 | Distribution=|centos 5 | Distribution=|alma 6 | Distribution=|rocky 7 | Distribution=|rhel 8 | 9 | [Distribution] 10 | Repositories= 11 | epel 12 | epel-next 13 | -------------------------------------------------------------------------------- /mkosi.conf.d/20-centos.conf: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1-or-later 2 | 3 | [Match] 4 | Distribution=|centos 5 | Distribution=|alma 6 | Distribution=|rocky 7 | 8 | [Distribution] 9 | @Release=9 10 | Repositories=epel 11 | epel-next 12 | 13 | [Content] 14 | Packages=linux-firmware 15 | -------------------------------------------------------------------------------- /mkosi.extra/usr/lib/systemd/mkosi-check-and-shutdown.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # SPDX-License-Identifier: LGPL-2.1-or-later 3 | set -eux 4 | 5 | systemctl --failed --no-legend | tee /failed-services 6 | 7 | # Exit with non-zero EC if the /failed-services file is not empty (we have -e set) 8 | [[ ! -s /failed-services ]] 9 | -------------------------------------------------------------------------------- /mkosi.conf.d/20-ubuntu.conf: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1-or-later 2 | 3 | [Match] 4 | Distribution=ubuntu 5 | 6 | [Distribution] 7 | @Release=lunar 8 | Repositories=universe 9 | 10 | [Content] 11 | Packages= 12 | linux-image-generic # TODO: Switch to linux-virtual once it supports reading credentials from SMBIOS. 13 | linux-tools-generic 14 | -------------------------------------------------------------------------------- /mkosi/resources/mkosi-initrd/mkosi.extra/usr/lib/udev/rules.d/10-mkosi-initrd-dm.rules: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0-only 2 | # Copied from https://github.com/dracutdevs/dracut/blob/059/modules.d/90dm/11-dm.rules 3 | 4 | SUBSYSTEM!="block", GOTO="dm_end" 5 | KERNEL!="dm-[0-9]*", GOTO="dm_end" 6 | ACTION!="add|change", GOTO="dm_end" 7 | OPTIONS+="db_persist" 8 | LABEL="dm_end" 9 | -------------------------------------------------------------------------------- /tools/generate-zipapp.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | BUILDDIR=$(mktemp -d -q) 4 | cleanup() { 5 | rm -rf "$BUILDDIR" 6 | } 7 | trap cleanup EXIT 8 | 9 | mkdir -p builddir 10 | 11 | cp -r mkosi "${BUILDDIR}/" 12 | 13 | python3 -m zipapp \ 14 | -p "/usr/bin/env python3" \ 15 | -o builddir/mkosi \ 16 | -m mkosi.__main__:main \ 17 | "$BUILDDIR" 18 | -------------------------------------------------------------------------------- /mkosi.conf.d/20-fedora/mkosi.conf: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1-or-later 2 | 3 | [Match] 4 | Distribution=fedora 5 | 6 | [Distribution] 7 | @Release=39 8 | 9 | [Content] 10 | Packages= 11 | amd-ucode-firmware 12 | archlinux-keyring 13 | btrfs-progs 14 | dnf5 15 | dnf5-plugins 16 | fedora-review 17 | pacman 18 | systemd-ukify 19 | zypper 20 | -------------------------------------------------------------------------------- /mkosi/resources/mkosi-initrd/mkosi.extra/usr/lib/systemd/system/systemd-cryptsetup@.service.d/credential.conf: -------------------------------------------------------------------------------- 1 | [Service] 2 | ImportCredential=cryptsetup.* 3 | # Compat with older systemd versions that don't support ImportCredential=. 4 | LoadCredential=cryptsetup.passphrase 5 | LoadCredential=cryptsetup.fido2-pin 6 | LoadCredential=cryptsetup.tpm2-pin 7 | LoadCredential=cryptsetup.luks2-pin 8 | LoadCredential=cryptsetup.pkcs11-pin 9 | -------------------------------------------------------------------------------- /mkosi.conf.d/30-rpm/mkosi.build.chroot: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # SPDX-License-Identifier: LGPL-2.1-or-later 3 | set -ex 4 | 5 | rpmbuild \ 6 | -bb \ 7 | --build-in-place \ 8 | $([ "$WITH_TESTS" = "0" ] && echo --nocheck) \ 9 | --define "_topdir $PWD" \ 10 | --define "_sourcedir rpm" \ 11 | --define "_rpmdir $OUTPUTDIR" \ 12 | --define "_build_name_fmt %%{NAME}-%%{VERSION}-%%{RELEASE}.%%{ARCH}.rpm" \ 13 | rpm/mkosi.spec 14 | -------------------------------------------------------------------------------- /mkosi.extra/usr/lib/systemd/system/mkosi-check-and-shutdown.service: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1-or-later 2 | [Unit] 3 | Description=Check if any service failed and then shut down the machine 4 | After=multi-user.target network-online.target 5 | Requires=multi-user.target 6 | SuccessAction=exit 7 | FailureAction=exit 8 | SuccessActionExitStatus=123 9 | 10 | [Service] 11 | Type=oneshot 12 | ExecStart=/usr/lib/systemd/mkosi-check-and-shutdown.sh 13 | -------------------------------------------------------------------------------- /mkosi/resources/mkosi-tools/mkosi.conf.d/10-fedora/mkosi.conf: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1-or-later 2 | 3 | [Match] 4 | Distribution=fedora 5 | 6 | [Content] 7 | Packages= 8 | archlinux-keyring 9 | btrfs-progs 10 | dnf5 11 | dnf5-plugins 12 | erofs-utils 13 | pacman 14 | qemu-system-aarch64-core 15 | qemu-system-ppc-core 16 | qemu-system-s390x-core 17 | systemd-ukify 18 | zypper 19 | -------------------------------------------------------------------------------- /tools/do-a-release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # SPDX-License-Identifier: LGPL-2.1+ 3 | 4 | if [ -z "$1" ] ; then 5 | echo "Version number not specified." 6 | exit 1 7 | fi 8 | 9 | if ! git diff-index --quiet HEAD; then 10 | echo "Repo has modified files." 11 | exit 1 12 | fi 13 | 14 | sed -r -i "s/^version = \".*\"$/version = \"$1\"/" pyproject.toml 15 | sed -r -i "s/^__version__ = \".*\"$/__version__ = \"$1\"/" mkosi/config.py 16 | 17 | git add -p pyproject.toml mkosi 18 | 19 | git commit -m "Release $1" 20 | 21 | git tag -s "v$1" -m "mkosi $1" 22 | -------------------------------------------------------------------------------- /mkosi/resources/mkosi-initrd/mkosi.conf.d/10-opensuse.conf: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1-or-later 2 | 3 | [Match] 4 | Distribution=opensuse 5 | 6 | [Content] 7 | Packages= 8 | # Various libraries that are dlopen'ed by systemd 9 | libfido2-1 10 | tpm2-0-tss 11 | 12 | # File system checkers for supported root file systems 13 | e2fsprogs 14 | xfsprogs 15 | 16 | # fsck.btrfs is a dummy, checking is done in the kernel. 17 | 18 | RemoveFiles= 19 | /usr/share/locale/* 20 | /usr/etc/services 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.cache-pre-dev 2 | *.cache-pre-inst 3 | .cache 4 | .mkosi.1 5 | .mypy_cache/ 6 | .project 7 | .pydevproject 8 | .pytest_cache/ 9 | /.mkosi-* 10 | /SHA256SUMS 11 | /SHA256SUMS.gpg 12 | /build 13 | /dist 14 | /mkosi.build 15 | /mkosi.egg-info 16 | /mkosi.cache 17 | /mkosi.output 18 | /mkosi.extra 19 | !mkosi.extra/usr/lib/systemd/mkosi-check-and-shutdown.sh 20 | !mkosi.extra/usr/lib/systemd/system/mkosi-check-and-shutdown.service 21 | !mkosi.extra/usr/lib/systemd/system-preset/*-mkosi.preset 22 | /mkosi.nspawn 23 | /mkosi.rootpw 24 | mkosi.local.conf 25 | /mkosi.key 26 | /mkosi.crt 27 | __pycache__ 28 | -------------------------------------------------------------------------------- /mkosi/pager.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1+ 2 | 3 | import os 4 | import pydoc 5 | from typing import Optional 6 | 7 | 8 | def page(text: str, enabled: Optional[bool]) -> None: 9 | if enabled: 10 | # Initialize less options from $MKOSI_LESS or provide a suitable fallback. 11 | # F: don't page if one screen 12 | # X: do not clear screen 13 | # M: verbose prompt 14 | # K: quit on ^C 15 | # R: allow rich formatting 16 | os.environ["LESS"] = os.getenv("MKOSI_LESS", "FXMKR") 17 | pydoc.pager(text) 18 | else: 19 | print(text) 20 | -------------------------------------------------------------------------------- /mkosi/resources/mkosi-tools/mkosi.conf.d/10-arch.conf: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1-or-later 2 | 3 | [Match] 4 | Distribution=arch 5 | 6 | [Content] 7 | Packages= 8 | apt 9 | archlinux-keyring 10 | base 11 | btrfs-progs 12 | curl 13 | debian-archive-keyring 14 | edk2-ovmf 15 | erofs-utils 16 | openssh 17 | pacman 18 | pesign 19 | python-cryptography 20 | qemu-base 21 | sbsigntools 22 | shadow 23 | squashfs-tools 24 | systemd-ukify 25 | ubuntu-keyring 26 | xz 27 | -------------------------------------------------------------------------------- /mkosi/resources/mkosi-tools/mkosi.conf.d/10-centos-fedora/mkosi.conf: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1-or-later 2 | 3 | [Match] 4 | Distribution=|centos 5 | Distribution=|alma 6 | Distribution=|rocky 7 | Distribution=|rhel 8 | Distribution=|fedora 9 | 10 | [Content] 11 | Packages= 12 | apt 13 | curl-minimal 14 | debian-keyring 15 | distribution-gpg-keys 16 | dnf-plugins-core 17 | openssh-clients 18 | python3-cryptography 19 | qemu-kvm-core 20 | shadow-utils 21 | squashfs-tools 22 | systemd-container 23 | systemd-udev 24 | ubu-keyring 25 | xz 26 | -------------------------------------------------------------------------------- /mkosi/resources/mkosi-tools/mkosi.conf: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1-or-later 2 | 3 | [Output] 4 | Format=directory 5 | ManifestFormat= 6 | 7 | [Content] 8 | Bootable=no 9 | Packages= 10 | bash 11 | bubblewrap 12 | ca-certificates 13 | coreutils 14 | cpio 15 | diffutils 16 | dnf 17 | dosfstools 18 | e2fsprogs 19 | kmod 20 | less 21 | mtools 22 | nano 23 | openssl 24 | socat 25 | strace 26 | swtpm 27 | systemd 28 | tar 29 | util-linux 30 | virtiofsd 31 | xfsprogs 32 | zstd 33 | -------------------------------------------------------------------------------- /mkosi/resources/mkosi-tools/mkosi.conf.d/10-opensuse.conf: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1-or-later 2 | 3 | [Match] 4 | Distribution=opensuse 5 | 6 | [Content] 7 | Packages= 8 | btrfs-progs 9 | ca-certificates-mozilla 10 | curl 11 | distribution-gpg-keys 12 | dnf-plugins-core 13 | erofs-utils 14 | grep 15 | openssh-clients 16 | ovmf 17 | pesign 18 | qemu-headless 19 | sbsigntools 20 | shadow 21 | squashfs 22 | systemd-boot 23 | systemd-container 24 | systemd-coredump 25 | systemd-experimental 26 | xz 27 | zypper 28 | -------------------------------------------------------------------------------- /.dir-locals.el: -------------------------------------------------------------------------------- 1 | ; Sets emacs variables based on mode. 2 | ; A list of (major-mode . ((var1 . value1) (var2 . value2))) 3 | ; Mode can be nil, which gives default values. 4 | 5 | ; Note that we set a wider line width source files, but for everything else we 6 | ; stick to a more conservative 79 characters. 7 | 8 | ; NOTE: Keep this file in sync with .editorconfig. 9 | 10 | ((python-mode . ((indent-tabs-mode . nil) 11 | (tab-width . 4) 12 | (fill-column . 99))) 13 | (sh-mode . ((sh-basic-offset . 4) 14 | (sh-indentation . 4))) 15 | (nil . ((indent-tabs-mode . nil) 16 | (tab-width . 4) 17 | (fill-column . 79))) ) 18 | -------------------------------------------------------------------------------- /mkosi/resources/mkosi-initrd/mkosi.conf.d/10-centos-fedora.conf: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1-or-later 2 | 3 | [Match] 4 | Distribution=|fedora 5 | Distribution=|centos 6 | Distribution=|alma 7 | Distribution=|rocky 8 | Distribution=|rhel 9 | 10 | [Content] 11 | Packages= 12 | # Various libraries that are dlopen'ed by systemd 13 | libfido2 14 | tpm2-tss 15 | 16 | # File system checkers for supported root file systems 17 | /usr/sbin/fsck.ext4 18 | /usr/sbin/fsck.xfs 19 | 20 | # fsck.btrfs is a dummy, checking is done in the kernel. 21 | 22 | RemovePackages= 23 | # Various packages pull in shadow-utils to create users, we can remove it afterwards 24 | shadow-utils 25 | -------------------------------------------------------------------------------- /bin/mkosi: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # SPDX-License-Identifier: LGPL-2.1+ 3 | set -e 4 | PYTHONPATH="$(dirname "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")")" 5 | export PYTHONPATH 6 | 7 | if [ -z "$MKOSI_INTERPRETER" ]; then 8 | # Note the check seems to be inverted here because the if branch is executed when the exit status is 0 9 | # which is equal to "False" in python. 10 | if python3 -c "import sys; sys.exit(sys.version_info < (3, 9))"; then 11 | MKOSI_INTERPRETER=python3 12 | elif command -v python3.9 >/dev/null; then 13 | MKOSI_INTERPRETER=python3.9 14 | else 15 | echo "mkosi needs python 3.9 or newer (found $(python3 --version))" 16 | exit 1 17 | fi 18 | fi 19 | 20 | exec "$MKOSI_INTERPRETER" -B -m mkosi "$@" 21 | -------------------------------------------------------------------------------- /.github/workflows/differential-shellcheck.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # https://github.com/redhat-plumbers-in-action/differential-shellcheck#readme 3 | 4 | name: Differential ShellCheck 5 | on: 6 | push: 7 | branches: 8 | - main 9 | pull_request: 10 | branches: 11 | - main 12 | 13 | permissions: 14 | contents: read 15 | 16 | jobs: 17 | lint: 18 | runs-on: ubuntu-latest 19 | 20 | permissions: 21 | security-events: write 22 | 23 | steps: 24 | - name: Repository checkout 25 | uses: actions/checkout@v3 26 | with: 27 | fetch-depth: 0 28 | 29 | - name: Differential ShellCheck 30 | uses: redhat-plumbers-in-action/differential-shellcheck@v4 31 | with: 32 | token: ${{ secrets.GITHUB_TOKEN }} 33 | -------------------------------------------------------------------------------- /mkosi/resources/mkosi-tools/mkosi.conf.d/10-debian-ubuntu.conf: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1-or-later 2 | 3 | [Match] 4 | Distribution=|debian 5 | Distribution=|ubuntu 6 | 7 | [Content] 8 | Packages= 9 | apt 10 | archlinux-keyring 11 | btrfs-progs 12 | curl 13 | debian-archive-keyring 14 | erofs-utils 15 | libtss2-dev 16 | makepkg 17 | openssh-client 18 | ovmf 19 | pacman-package-manager 20 | pesign 21 | python3-cryptography 22 | python3-pefile 23 | qemu-system 24 | sbsigntool 25 | squashfs-tools 26 | systemd-boot 27 | systemd-container 28 | systemd-coredump 29 | ubuntu-keyring 30 | uidmap 31 | xz-utils 32 | zypper 33 | -------------------------------------------------------------------------------- /tests/test_sysext.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1+ 2 | 3 | from pathlib import Path 4 | 5 | import pytest 6 | 7 | from . import Image 8 | 9 | pytestmark = pytest.mark.integration 10 | 11 | 12 | def test_sysext(config: Image.Config) -> None: 13 | with Image( 14 | config, 15 | options=[ 16 | "--incremental", 17 | "--clean-package-metadata=no", 18 | "--format=directory", 19 | ], 20 | ) as image: 21 | image.build() 22 | 23 | with Image( 24 | image.config, 25 | options=[ 26 | "--directory", "", 27 | "--base-tree", Path(image.output_dir.name) / "image", 28 | "--overlay", 29 | "--package=dnsmasq", 30 | "--format=disk", 31 | ], 32 | ) as sysext: 33 | sysext.build() 34 | 35 | -------------------------------------------------------------------------------- /mkosi/resources/mkosi-initrd/mkosi.conf.d/10-debian-ubuntu.conf: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1-or-later 2 | 3 | [Match] 4 | Distribution=|debian 5 | Distribution=|ubuntu 6 | 7 | [Content] 8 | Packages= 9 | kmod # Not pulled in as a dependency on Debian/Ubuntu 10 | dmsetup # Not pulled in as a dependency on Debian/Ubuntu 11 | 12 | # xfsprogs pulls in python on Debian (???) and XFS generally 13 | # isn't used on Debian so we don't install xfsprogs. 14 | e2fsprogs 15 | 16 | # Various libraries that are dlopen'ed by systemd 17 | libfido2-1 18 | ^libtss2-esys-[0-9\.]+-0$ 19 | libtss2-rc0 20 | libtss2-mu0 21 | libtss2-tcti-device0 22 | 23 | RemovePackages= 24 | # TODO: Remove dpkg if dash ever loses its dependency on it. 25 | # dpkg 26 | 27 | RemoveFiles= 28 | /usr/share/locale/* 29 | -------------------------------------------------------------------------------- /docs/initrd.md: -------------------------------------------------------------------------------- 1 | # Building a custom initrd and using it in a mkosi image 2 | 3 | Building an image with a mkosi-built initrd is a two step process, because you will build two images - the initrd and your distribution image. 4 | 1. Build an initrd image using the `cpio` output format with the same target distributions as you want to use for your distribution image. mkosi compresses the `cpio` output format by default. 5 | ``` 6 | [Output] 7 | Format=cpio 8 | 9 | [Content] 10 | Packages=systemd 11 | udev 12 | kmod 13 | ``` 14 | 2. Invoke `mkosi` passing the initrd image via the `--initrd` option or add the `Initrd=` option to your mkosi config when building your distribution image. 15 | ```bash 16 | mkosi --initrd= ... 17 | ``` 18 | This will build an image using the provided initrd image. 19 | mkosi will add the kernel modules found in the distribution image to this initrd. 20 | 21 | -------------------------------------------------------------------------------- /mkosi.extra/usr/lib/systemd/system-preset/00-mkosi.preset: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1-or-later 2 | 3 | # mkosi adds its own ssh units via the --ssh switch so disable the default ones. 4 | disable ssh.service 5 | disable sshd.service 6 | 7 | # Make sure dbus-broker is started by default on Debian/Ubuntu. 8 | enable dbus-broker.service 9 | 10 | # Make sure we have networking available. 11 | enable systemd-networkd.service 12 | enable systemd-networkd-wait-online.service 13 | enable systemd-resolved.service 14 | 15 | # We install dnf in some images but it's only going to be used rarely, 16 | # so let's not have dnf create its cache. 17 | disable dnf-makecache.* 18 | 19 | # The rpmdb is already in the right location, don't try to migrate it. 20 | disable rpmdb-migrate.service 21 | 22 | # We have journald to receive audit data so let's make sure we're not running auditd as well 23 | disable auditd.service 24 | 25 | # systemd-timesyncd is not enabled by default in the default systemd preset so enable it here instead. 26 | enable systemd-timesyncd.service 27 | -------------------------------------------------------------------------------- /mkosi.conf.d/30-rpm/mkosi.prepare: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # SPDX-License-Identifier: LGPL-2.1-or-later 3 | set -e 4 | 5 | if [ "$1" = "build" ]; then 6 | DEPS="--buildrequires" 7 | else 8 | DEPS="--requires" 9 | fi 10 | 11 | mkosi-chroot \ 12 | rpmspec \ 13 | --query \ 14 | "$DEPS" \ 15 | --define "_topdir ." \ 16 | --define "_sourcedir rpm" \ 17 | rpm/mkosi.spec | 18 | grep -E -v "mkosi" | 19 | xargs -d '\n' dnf install --best 20 | 21 | if [ "$1" = "build" ]; then 22 | until mkosi-chroot \ 23 | rpmbuild \ 24 | -bd \ 25 | --build-in-place \ 26 | --define "_topdir ." \ 27 | --define "_sourcedir rpm" \ 28 | --define "_build_name_fmt %%{NAME}-%%{VERSION}-%%{RELEASE}.%%{ARCH}.rpm" \ 29 | rpm/mkosi.spec 30 | do 31 | EXIT_STATUS=$? 32 | if [ $EXIT_STATUS -ne 11 ]; then 33 | exit $EXIT_STATUS 34 | fi 35 | 36 | dnf builddep SRPMS/mkosi-*.buildreqs.nosrc.rpm 37 | rm SRPMS/mkosi-*.buildreqs.nosrc.rpm 38 | done 39 | fi 40 | -------------------------------------------------------------------------------- /mkosi/distributions/custom.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1+ 2 | 3 | from collections.abc import Sequence 4 | 5 | from mkosi.config import Architecture 6 | from mkosi.distributions import DistributionInstaller 7 | from mkosi.log import die 8 | from mkosi.state import MkosiState 9 | 10 | 11 | class Installer(DistributionInstaller): 12 | @classmethod 13 | def architecture(cls, arch: Architecture) -> str: 14 | return str(arch) 15 | 16 | @classmethod 17 | def setup(cls, state: MkosiState) -> None: 18 | pass 19 | 20 | @classmethod 21 | def install(cls, state: MkosiState) -> None: 22 | pass 23 | 24 | @classmethod 25 | def install_packages(cls, state: MkosiState, packages: Sequence[str]) -> None: 26 | if packages: 27 | die("Installing packages is not supported for custom distributions'") 28 | 29 | @classmethod 30 | def remove_packages(cls, state: MkosiState, packages: Sequence[str]) -> None: 31 | if packages: 32 | die("Removing packages is not supported for custom distributions") 33 | -------------------------------------------------------------------------------- /mkosi.conf.d/20-arch.conf: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1-or-later 2 | 3 | [Match] 4 | Distribution=arch 5 | 6 | [Content] 7 | ShimBootloader=unsigned 8 | Packages= 9 | apt 10 | archlinux-keyring 11 | base 12 | bash 13 | btrfs-progs 14 | bubblewrap 15 | ca-certificates 16 | coreutils 17 | cpio 18 | curl 19 | debian-archive-keyring 20 | dnf 21 | dosfstools 22 | e2fsprogs 23 | edk2-ovmf 24 | erofs-utils 25 | grub 26 | iproute 27 | iputils 28 | linux 29 | mtools 30 | openssh 31 | openssl 32 | pacman 33 | perf 34 | pesign 35 | python-cryptography 36 | qemu-base 37 | sbsigntools 38 | shadow 39 | shim 40 | socat 41 | squashfs-tools 42 | strace 43 | swtpm 44 | systemd 45 | systemd-ukify 46 | tar 47 | ukify 48 | util-linux 49 | virtiofsd 50 | xfsprogs 51 | xz 52 | zstd 53 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # vi: ts=2 sw=2 et: 3 | # 4 | name: "CodeQL" 5 | 6 | on: 7 | push: 8 | branches: 9 | - main 10 | pull_request: 11 | branches: 12 | - main 13 | 14 | permissions: 15 | contents: read 16 | 17 | jobs: 18 | analyze: 19 | name: Analyze 20 | runs-on: ubuntu-22.04 21 | concurrency: 22 | group: ${{ github.workflow }}-${{ matrix.language }}-${{ github.ref }} 23 | cancel-in-progress: true 24 | permissions: 25 | actions: read 26 | security-events: write 27 | 28 | strategy: 29 | fail-fast: false 30 | matrix: 31 | language: ['python'] 32 | 33 | steps: 34 | - name: Checkout repository 35 | uses: actions/checkout@v3 36 | 37 | - name: Initialize CodeQL 38 | uses: github/codeql-action/init@v2 39 | with: 40 | languages: ${{ matrix.language }} 41 | queries: +security-extended,security-and-quality 42 | 43 | - name: Autobuild 44 | uses: github/codeql-action/autobuild@v2 45 | 46 | - name: Perform CodeQL Analysis 47 | uses: github/codeql-action/analyze@v2 48 | -------------------------------------------------------------------------------- /mkosi/burn.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1+ 2 | 3 | import os 4 | import sys 5 | 6 | from mkosi.config import MkosiArgs, MkosiConfig, OutputFormat 7 | from mkosi.log import complete_step, die 8 | from mkosi.run import run 9 | 10 | 11 | def run_burn(args: MkosiArgs, config: MkosiConfig) -> None: 12 | if config.output_format not in (OutputFormat.disk, OutputFormat.esp): 13 | die(f"{config.output_format} images cannot be burned to disk") 14 | 15 | fname = config.output_dir_or_cwd() / config.output 16 | 17 | if len(args.cmdline) != 1: 18 | die("Expected device argument.") 19 | 20 | device = args.cmdline[0] 21 | 22 | cmd = [ 23 | "systemd-repart", 24 | "--no-pager", 25 | "--pretty=no", 26 | "--offline=yes", 27 | "--empty=force", 28 | "--dry-run=no", 29 | f"--copy-from={fname}", 30 | device, 31 | ] 32 | 33 | with complete_step("Burning 🔥🔥🔥 to medium…", "Burnt. 🔥🔥🔥"): 34 | run( 35 | cmd, 36 | stdin=sys.stdin, 37 | stdout=sys.stdout, 38 | env=os.environ, 39 | log=False, 40 | ) 41 | -------------------------------------------------------------------------------- /mkosi/resources/mkosi-initrd/mkosi.extra/usr/lib/udev/rules.d/10-mkosi-initrd-md.rules: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0-only 2 | # Copied from https://github.com/dracutdevs/dracut/blob/059/modules.d/90mdraid/59-persistent-storage-md.rules 3 | 4 | SUBSYSTEM!="block", GOTO="md_end" 5 | ACTION!="add|change", GOTO="md_end" 6 | # Also don't process disks that are slated to be a multipath device 7 | ENV{DM_MULTIPATH_DEVICE_PATH}=="1", GOTO="md_end" 8 | 9 | KERNEL!="md[0-9]*|md_d[0-9]*|md/*", KERNEL!="md*", GOTO="md_end" 10 | 11 | # partitions have no md/{array_state,metadata_version} 12 | ENV{DEVTYPE}=="partition", GOTO="md_ignore_state" 13 | 14 | # container devices have a metadata version of e.g. 'external:ddf' and 15 | # never leave state 'inactive' 16 | ATTR{md/metadata_version}=="external:[A-Za-z]*", ATTR{md/array_state}=="inactive", GOTO="md_ignore_state" 17 | TEST!="md/array_state", GOTO="md_end" 18 | ATTR{md/array_state}=="|clear|inactive", GOTO="md_end" 19 | 20 | LABEL="md_ignore_state" 21 | 22 | IMPORT{program}="/sbin/mdadm --detail --export $tempnode" 23 | IMPORT{builtin}="blkid" 24 | OPTIONS+="link_priority=100" 25 | OPTIONS+="watch" 26 | OPTIONS+="db_persist" 27 | LABEL="md_end" 28 | -------------------------------------------------------------------------------- /mkosi.conf: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1-or-later 2 | 3 | [Output] 4 | # These images are (among other things) used for running mkosi which means we need some disk space available so 5 | # default to directory output where disk space isn't a problem. 6 | @Format=directory 7 | @CacheDirectory=mkosi.cache 8 | @OutputDirectory=mkosi.output 9 | 10 | [Content] 11 | Autologin=yes 12 | @ShimBootloader=signed 13 | BuildSourcesEphemeral=yes 14 | 15 | Packages= 16 | attr 17 | autoconf 18 | automake 19 | ca-certificates 20 | gcc 21 | gdb 22 | gettext 23 | git 24 | less 25 | libtool 26 | make 27 | nano 28 | pkg-config 29 | strace 30 | 31 | InitrdPackages= 32 | less 33 | 34 | RemoveFiles= 35 | # The grub install plugin doesn't play nice with booting from virtiofs. 36 | /usr/lib/kernel/install.d/20-grub.install 37 | # The dracut install plugin doesn't honor KERNEL_INSTALL_INITRD_GENERATOR. 38 | /usr/lib/kernel/install.d/50-dracut.install 39 | 40 | # Make sure that SELinux doesn't run in enforcing mode even if it's pulled in as a dependency. 41 | KernelCommandLine=console=ttyS0 enforcing=0 42 | -------------------------------------------------------------------------------- /mkosi/types.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1+ 2 | 3 | import subprocess 4 | from pathlib import Path 5 | from typing import IO, TYPE_CHECKING, Any, Protocol, TypeVar, Union 6 | 7 | # These types are only generic during type checking and not at runtime, leading 8 | # to a TypeError during compilation. 9 | # Let's be as strict as we can with the description for the usage we have. 10 | if TYPE_CHECKING: 11 | CompletedProcess = subprocess.CompletedProcess[str] 12 | Popen = subprocess.Popen[str] 13 | else: 14 | CompletedProcess = subprocess.CompletedProcess 15 | Popen = subprocess.Popen 16 | 17 | # Borrowed from https://github.com/python/typeshed/blob/3d14016085aed8bcf0cf67e9e5a70790ce1ad8ea/stdlib/3/subprocess.pyi#L24 18 | _FILE = Union[None, int, IO[Any]] 19 | PathString = Union[Path, str] 20 | 21 | # Borrowed from 22 | # https://github.com/python/typeshed/blob/ec52bf1adde1d3183d0595d2ba982589df48dff1/stdlib/_typeshed/__init__.pyi#L19 23 | # and 24 | # https://github.com/python/typeshed/blob/ec52bf1adde1d3183d0595d2ba982589df48dff1/stdlib/_typeshed/__init__.pyi#L224 25 | _T_co = TypeVar("_T_co", covariant=True) 26 | 27 | class SupportsRead(Protocol[_T_co]): 28 | def read(self, __length: int = ...) -> _T_co: ... 29 | -------------------------------------------------------------------------------- /mkosi/__main__.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1+ 2 | # PYTHON_ARGCOMPLETE_OK 3 | 4 | import faulthandler 5 | import shutil 6 | import signal 7 | import sys 8 | from types import FrameType 9 | from typing import Optional 10 | 11 | from mkosi import run_verb 12 | from mkosi.config import parse_config 13 | from mkosi.log import log_setup 14 | from mkosi.run import run, uncaught_exception_handler 15 | from mkosi.util import INVOKING_USER 16 | 17 | 18 | def onsigterm(signal: int, frame: Optional[FrameType]) -> None: 19 | raise KeyboardInterrupt() 20 | 21 | 22 | @uncaught_exception_handler() 23 | def main() -> None: 24 | signal.signal(signal.SIGTERM, onsigterm) 25 | 26 | log_setup() 27 | # Ensure that the name and home of the user we are running as are resolved as early as possible. 28 | INVOKING_USER.init() 29 | args, images = parse_config(sys.argv[1:]) 30 | 31 | if args.debug: 32 | faulthandler.enable() 33 | 34 | try: 35 | run_verb(args, images) 36 | finally: 37 | if sys.stderr.isatty() and shutil.which("tput"): 38 | run(["tput", "cnorm"], check=False) 39 | run(["tput", "smam"], check=False) 40 | 41 | 42 | if __name__ == "__main__": 43 | main() 44 | -------------------------------------------------------------------------------- /docs/bootable.md: -------------------------------------------------------------------------------- 1 | # Building a bootable image on different distros 2 | 3 | To build a bootable image, you'll need to install a list of packages that differs depending on the 4 | distribution. We give an overview here of what's needed to generate a bootable image for some common 5 | distributions: 6 | 7 | ## Arch 8 | 9 | ``` 10 | [Content] 11 | Packages=linux 12 | systemd 13 | ``` 14 | 15 | ## Fedora 16 | 17 | ``` 18 | [Content] 19 | Packages=kernel 20 | systemd 21 | systemd-boot 22 | udev 23 | util-linux 24 | ``` 25 | 26 | ## CentOS 27 | 28 | ``` 29 | [Content] 30 | Packages=kernel 31 | systemd 32 | systemd-boot 33 | udev 34 | ``` 35 | 36 | ## Debian 37 | 38 | ``` 39 | [Content] 40 | Packages=linux-image-generic 41 | systemd 42 | systemd-boot 43 | systemd-sysv 44 | udev 45 | dbus 46 | ``` 47 | 48 | ## Ubuntu 49 | 50 | ``` 51 | [Content] 52 | Repositories=main,universe 53 | Packages=linux-image-generic 54 | systemd 55 | systemd-sysv 56 | udev 57 | dbus 58 | ``` 59 | 60 | ## Opensuse 61 | 62 | ``` 63 | [Content] 64 | Packages=kernel-default 65 | systemd 66 | udev 67 | ``` 68 | -------------------------------------------------------------------------------- /mkosi.conf.d/20-opensuse.conf: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1-or-later 2 | 3 | [Match] 4 | Distribution=opensuse 5 | 6 | [Distribution] 7 | @Release=tumbleweed 8 | 9 | [Content] 10 | Packages= 11 | bash 12 | btrfs-progs 13 | bubblewrap 14 | ca-certificates 15 | coreutils 16 | cpio 17 | curl 18 | distribution-gpg-keys 19 | dnf 20 | dosfstools 21 | e2fsprogs 22 | erofs-utils 23 | grep 24 | grub2-i386-pc 25 | iproute 26 | iputils 27 | kernel-kvmsmall 28 | mtools 29 | openssh-clients 30 | openssh-server 31 | openssl 32 | ovmf 33 | pesign 34 | perf 35 | qemu-headless 36 | sbsigntools 37 | shadow 38 | shim 39 | socat 40 | squashfs 41 | strace 42 | swtpm 43 | systemd 44 | systemd-boot 45 | systemd-container 46 | systemd-coredump 47 | systemd-experimental 48 | tar 49 | ucode-amd 50 | ucode-intel 51 | udev 52 | util-linux 53 | virtiofsd 54 | xfsprogs 55 | xz 56 | zstd 57 | zypper 58 | -------------------------------------------------------------------------------- /mkosi/distributions/alma.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1+ 2 | 3 | from mkosi.distributions import centos, join_mirror 4 | from mkosi.installer.rpm import RpmRepository, find_rpm_gpgkey 5 | from mkosi.state import MkosiState 6 | 7 | 8 | class Installer(centos.Installer): 9 | @classmethod 10 | def pretty_name(cls) -> str: 11 | return "AlmaLinux" 12 | 13 | @staticmethod 14 | def gpgurls(state: MkosiState) -> tuple[str, ...]: 15 | return ( 16 | find_rpm_gpgkey( 17 | state, 18 | f"RPM-GPG-KEY-AlmaLinux-{state.config.release}", 19 | f"https://repo.almalinux.org/almalinux/RPM-GPG-KEY-AlmaLinux-{state.config.release}", 20 | ), 21 | ) 22 | 23 | @classmethod 24 | def repository_variants(cls, state: MkosiState, repo: str) -> list[RpmRepository]: 25 | if state.config.mirror: 26 | url = f"baseurl={join_mirror(state.config.mirror, f'almalinux/$releasever/{repo}/$basearch/os')}" 27 | else: 28 | url = f"mirrorlist=https://mirrors.almalinux.org/mirrorlist/$releasever/{repo.lower()}" 29 | 30 | return [RpmRepository(repo, url, cls.gpgurls(state))] 31 | 32 | @classmethod 33 | def sig_repositories(cls, state: MkosiState) -> list[RpmRepository]: 34 | return [] 35 | -------------------------------------------------------------------------------- /mkosi/distributions/rocky.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1+ 2 | 3 | from mkosi.distributions import centos, join_mirror 4 | from mkosi.installer.rpm import RpmRepository, find_rpm_gpgkey 5 | from mkosi.state import MkosiState 6 | 7 | 8 | class Installer(centos.Installer): 9 | @classmethod 10 | def pretty_name(cls) -> str: 11 | return "Rocky Linux" 12 | 13 | @staticmethod 14 | def gpgurls(state: MkosiState) -> tuple[str, ...]: 15 | return ( 16 | find_rpm_gpgkey( 17 | state, 18 | f"RPM-GPG-KEY-Rocky-{state.config.release}", 19 | f"https://download.rockylinux.org/pub/rocky/RPM-GPG-KEY-Rocky-{state.config.release}", 20 | ), 21 | ) 22 | 23 | @classmethod 24 | def repository_variants(cls, state: MkosiState, repo: str) -> list[RpmRepository]: 25 | if state.config.mirror: 26 | url = f"baseurl={join_mirror(state.config.mirror, f'rocky/$releasever/{repo}/$basearch/os')}" 27 | else: 28 | url = f"mirrorlist=https://mirrors.rockylinux.org/mirrorlist?arch=$basearch&repo={repo}-$releasever" 29 | 30 | return [RpmRepository(repo, url, cls.gpgurls(state))] 31 | 32 | @classmethod 33 | def sig_repositories(cls, state: MkosiState) -> list[RpmRepository]: 34 | return [] 35 | -------------------------------------------------------------------------------- /mkosi.conf.d/20-debian-ubuntu/mkosi.conf: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1-or-later 2 | 3 | [Match] 4 | Distribution=|debian 5 | Distribution=|ubuntu 6 | 7 | [Content] 8 | Packages= 9 | apt 10 | archlinux-keyring 11 | bash 12 | btrfs-progs 13 | bubblewrap 14 | ca-certificates 15 | coreutils 16 | cpio 17 | curl 18 | dbus-broker 19 | debian-archive-keyring 20 | dnf 21 | dosfstools 22 | e2fsprogs 23 | erofs-utils 24 | iproute2 25 | iputils-ping 26 | libtss2-dev 27 | makepkg 28 | mtools 29 | openssh-client 30 | openssh-server 31 | openssl 32 | ovmf 33 | pacman-package-manager 34 | pesign 35 | python3-cryptography 36 | python3-pefile 37 | qemu-system 38 | sbsigntool 39 | shim-signed 40 | socat 41 | squashfs-tools 42 | strace 43 | swtpm 44 | systemd 45 | systemd-boot 46 | systemd-container 47 | systemd-coredump 48 | systemd-resolved 49 | systemd-sysv 50 | tar 51 | tzdata 52 | udev 53 | uidmap 54 | util-linux 55 | xfsprogs 56 | xz-utils 57 | zstd 58 | zypper 59 | -------------------------------------------------------------------------------- /mkosi/resources/mkosi-initrd/mkosi.conf.d/10-arch.conf: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1-or-later 2 | 3 | [Match] 4 | Distribution=arch 5 | 6 | [Content] 7 | Packages= 8 | gzip # For compressed keymap unpacking by loadkeys 9 | 10 | e2fsprogs 11 | xfsprogs 12 | 13 | # Various libraries that are dlopen'ed by systemd 14 | libfido2 15 | tpm2-tss 16 | 17 | RemoveFiles= 18 | # Arch Linux doesn't split their gcc-libs package so we manually remove 19 | # unneeded stuff here to make sure it doesn't end up in the initrd. 20 | /usr/lib/libgfortran.so* 21 | /usr/lib/libgo.so* 22 | /usr/lib/libgomp.so* 23 | /usr/lib/libgphobos.so* 24 | /usr/lib/libobjc.so* 25 | /usr/lib/libasan.so* 26 | /usr/lib/libtsan.so* 27 | /usr/lib/liblsan.so* 28 | /usr/lib/libubsan.so* 29 | /usr/lib/libstdc++.so* 30 | /usr/lib/libgdruntime.so* 31 | 32 | # Remove all files that are only required for development. 33 | /usr/lib/*.a 34 | /usr/include/* 35 | 36 | /usr/share/i18n/* 37 | /usr/share/hwdata/* 38 | /usr/share/iana-etc/* 39 | /usr/share/doc/* 40 | /usr/share/man/* 41 | /usr/share/locale/* 42 | /usr/share/info/* 43 | /usr/share/gtk-doc/* 44 | -------------------------------------------------------------------------------- /mkosi.conf.d/20-centos-fedora/mkosi.conf: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1-or-later 2 | 3 | [Match] 4 | Distribution=|centos 5 | Distribution=|alma 6 | Distribution=|rocky 7 | Distribution=|fedora 8 | 9 | [Content] 10 | Packages= 11 | apt 12 | bash 13 | bubblewrap 14 | ca-certificates 15 | centos-packager 16 | coreutils 17 | cpio 18 | curl-minimal 19 | debian-keyring 20 | distribution-gpg-keys 21 | dnf 22 | dnf-plugins-core 23 | dosfstools 24 | e2fsprogs 25 | erofs-utils 26 | fedora-packager 27 | fedora-packager-kerberos 28 | iproute 29 | iputils 30 | kernel-core 31 | mock 32 | mock-centos-sig-configs 33 | mock-core-configs 34 | mtools 35 | openssh-clients 36 | openssh-server 37 | openssl 38 | perf 39 | python3-cryptography 40 | qemu-kvm-core 41 | rpm-build 42 | rpminspect 43 | rpminspect-data-centos 44 | rpminspect-data-fedora 45 | shadow-utils 46 | shim 47 | socat 48 | squashfs-tools 49 | strace 50 | swtpm 51 | systemd 52 | systemd-container 53 | systemd-networkd 54 | systemd-resolved 55 | systemd-udev 56 | tar 57 | util-linux 58 | virtiofsd 59 | xfsprogs 60 | xz 61 | zstd 62 | -------------------------------------------------------------------------------- /mkosi/state.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1+ 2 | 3 | from pathlib import Path 4 | 5 | from mkosi.config import MkosiArgs, MkosiConfig 6 | from mkosi.tree import make_tree 7 | from mkosi.util import umask 8 | 9 | 10 | class MkosiState: 11 | """State related properties.""" 12 | 13 | def __init__(self, args: MkosiArgs, config: MkosiConfig, workspace: Path) -> None: 14 | self.args = args 15 | self.config = config 16 | self.workspace = workspace 17 | 18 | with umask(~0o755): 19 | # Using a btrfs subvolume as the upperdir in an overlayfs results in EXDEV so make sure we create 20 | # the root directory as a regular directory if the Overlay= option is enabled. 21 | if config.overlay: 22 | self.root.mkdir() 23 | else: 24 | make_tree(self.root, use_subvolumes=self.config.use_subvolumes) 25 | 26 | self.staging.mkdir() 27 | self.pkgmngr.mkdir() 28 | self.install_dir.mkdir(exist_ok=True) 29 | self.cache_dir.mkdir(parents=True, exist_ok=True) 30 | 31 | @property 32 | def root(self) -> Path: 33 | return self.workspace / "root" 34 | 35 | @property 36 | def staging(self) -> Path: 37 | return self.workspace / "staging" 38 | 39 | @property 40 | def pkgmngr(self) -> Path: 41 | return self.workspace / "pkgmngr" 42 | 43 | @property 44 | def cache_dir(self) -> Path: 45 | return self.config.cache_dir or (self.workspace / "cache") 46 | 47 | @property 48 | def install_dir(self) -> Path: 49 | return self.workspace / "dest" 50 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1+ 2 | from typing import Any, cast 3 | 4 | import pytest 5 | 6 | from mkosi.config import parse_config 7 | from mkosi.distributions import Distribution, detect_distribution 8 | 9 | from . import Image 10 | 11 | 12 | def pytest_addoption(parser: Any) -> None: 13 | parser.addoption( 14 | "-D", 15 | "--distribution", 16 | metavar="DISTRIBUTION", 17 | help="Run the integration tests for the given distribution.", 18 | default=detect_distribution()[0], 19 | type=Distribution, 20 | choices=[Distribution(d) for d in Distribution.values()], 21 | ) 22 | parser.addoption( 23 | "-R", 24 | "--release", 25 | metavar="RELEASE", 26 | help="Run the integration tests for the given release.", 27 | ) 28 | parser.addoption( 29 | "-T", 30 | "--tools-tree-distribution", 31 | metavar="DISTRIBUTION", 32 | help="Use the given tools tree distribution to build the integration test images", 33 | type=Distribution, 34 | choices=[Distribution(d) for d in Distribution.values()], 35 | ) 36 | 37 | 38 | @pytest.fixture(scope="session") 39 | def config(request: Any) -> Image.Config: 40 | distribution = cast(Distribution, request.config.getoption("--distribution")) 41 | release = cast(str, request.config.getoption("--release") or parse_config(["-d", str(distribution)])[1][0].release) 42 | return Image.Config( 43 | distribution=distribution, 44 | release=release, 45 | tools_tree_distribution=cast(Distribution, request.config.getoption("--tools-tree-distribution")), 46 | ) 47 | -------------------------------------------------------------------------------- /docs/distribution-policy.md: -------------------------------------------------------------------------------- 1 | # Adding new distributions 2 | 3 | Merging support for a new distribution in mkosi depends on a few 4 | factors. Not all of these are required but depending on how many of 5 | these requirements are satisfied, the chances of us merging support for 6 | your distribution will improve: 7 | 8 | 1. Is the distribution somewhat popular? mkosi's goal is not to support 9 | every distribution under the sun, the distribution should have a 10 | substantial amount of users. 11 | 2. Does the distribution differentiate itself somehow from the 12 | distributions that are already supported? We're generally not 13 | interested in supporting distributions that only consist of minimal 14 | configuration changes to another distribution. 15 | 3. Is there a long-term maintainer for the distribution in mkosi? When 16 | proposing support for a new distribution, we expect you to be the 17 | maintainer for the distribution and to respond when pinged for 18 | support on distribution specific issues. 19 | 4. Does the distribution use a custom package manager or one of the 20 | already supported ones (apt, dnf, pacman, zypper)? Supporting new 21 | package managers in mkosi is generally a lot of work. We can support 22 | new ones if needed for a new distribution, but we will insist on the 23 | package manager having a somewhat sane design, with official support 24 | for building in a chroot and running unprivileged in a user namespace 25 | being the bare minimum features we expect from any new package 26 | manager. 27 | 28 | We will only consider new distributions that satisfy all or most of 29 | these requirements. However, you can still use mkosi with the 30 | distribution by setting the `Distribution` setting to `custom` and 31 | implementing either providing the rootfs via a skeleton tree or base 32 | tree, or by providing the rootfs via a prepare script. 33 | -------------------------------------------------------------------------------- /mkosi/resources/mkosi-initrd/mkosi.conf: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1-or-later 2 | 3 | [Output] 4 | @Output=initrd 5 | @Format=cpio 6 | ManifestFormat= 7 | 8 | [Content] 9 | Bootable=no 10 | MakeInitrd=yes 11 | @CleanPackageMetadata=yes 12 | Packages= 13 | systemd # sine qua non 14 | udev 15 | util-linux # Make sure we pull in util-linux instead of util-linux-core as the latter does not 16 | # include sulogin which is required for emergency logins 17 | bash # for emergency logins 18 | less # this makes 'systemctl' much nicer to use ;) 19 | p11-kit # dl-opened by systemd 20 | lvm2 21 | 22 | RemoveFiles= 23 | # we don't need this after the binary catalogs have been built 24 | /usr/lib/systemd/catalog 25 | /etc/udev/hwdb.d 26 | /usr/lib/udev/hwdb.d 27 | 28 | # this is not needed by anything updated in the last 20 years 29 | /etc/services 30 | 31 | # Including kernel images in the initrd is generally not useful. 32 | # This also stops mkosi from extracting the kernel image out of the image as a separate output. 33 | /usr/lib/modules/*/vmlinuz* 34 | /usr/lib/modules/*/System.map 35 | 36 | # Configure locale explicitly so that all other locale data is stripped on distros whose package manager supports it. 37 | @Locale=C.UTF-8 38 | WithDocs=no 39 | 40 | # Make sure various core modules are always included in the initrd. 41 | KernelModulesInclude= 42 | btrfs 43 | dm-crypt 44 | dm-integrity 45 | dm-verity 46 | erofs 47 | ext4 48 | loop 49 | overlay 50 | squashfs 51 | vfat 52 | xfs 53 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "setuptools-scm"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "mkosi" 7 | authors = [ 8 | {name = "mkosi contributors", email = "systemd-devel@lists.freedesktop.org"}, 9 | ] 10 | version = "19" 11 | description = "Build Bespoke OS Images" 12 | readme = "README.md" 13 | requires-python = ">=3.9" 14 | license = {file = "LICENSE"} 15 | 16 | [project.optional-dependencies] 17 | bootable = [ 18 | "pefile >= 2021.9.3", 19 | ] 20 | 21 | [project.scripts] 22 | mkosi = "mkosi.__main__:main" 23 | 24 | [tool.setuptools] 25 | packages = [ 26 | "mkosi", 27 | "mkosi.distributions", 28 | "mkosi.installer", 29 | "mkosi.resources", 30 | ] 31 | 32 | [tool.setuptools.package-data] 33 | "mkosi.resources" = ["repart/**/*", "mkosi.md", "mkosi.1", "mkosi-initrd/**/*", "mkosi-tools/**/*"] 34 | 35 | [tool.isort] 36 | profile = "black" 37 | include_trailing_comma = true 38 | multi_line_output = 3 39 | py_version = "39" 40 | 41 | [tool.pyright] 42 | pythonVersion = "3.9" 43 | 44 | [tool.mypy] 45 | python_version = 3.9 46 | # belonging to --strict 47 | warn_unused_configs = true 48 | disallow_any_generics = true 49 | disallow_subclassing_any = true 50 | disallow_untyped_calls = true 51 | disallow_untyped_defs = true 52 | disallow_untyped_decorators = true 53 | disallow_incomplete_defs = true 54 | check_untyped_defs = true 55 | no_implicit_optional = true 56 | warn_redundant_casts = true 57 | warn_unused_ignores = false 58 | warn_return_any = true 59 | no_implicit_reexport = true 60 | # extra options not in --strict 61 | pretty = true 62 | show_error_codes = true 63 | show_column_numbers = true 64 | warn_unreachable = true 65 | allow_redefinition = true 66 | strict_equality = true 67 | 68 | [[tool.mypy.overrides]] 69 | module = ["argcomplete"] 70 | ignore_missing_imports = true 71 | 72 | [tool.ruff] 73 | target-version = "py39" 74 | line-length = 119 75 | select = ["E", "F", "I", "UP"] 76 | 77 | [tool.pytest.ini_options] 78 | markers = [ 79 | "integration: mark a test as an integration test." 80 | ] 81 | addopts = "-m \"not integration\"" 82 | -------------------------------------------------------------------------------- /mkosi/installer/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1+ 2 | 3 | import os 4 | 5 | from mkosi.bubblewrap import apivfs_cmd 6 | from mkosi.config import ConfigFeature 7 | from mkosi.installer.apt import apt_cmd 8 | from mkosi.installer.dnf import dnf_cmd 9 | from mkosi.installer.pacman import pacman_cmd 10 | from mkosi.installer.rpm import rpm_cmd 11 | from mkosi.installer.zypper import zypper_cmd 12 | from mkosi.state import MkosiState 13 | from mkosi.tree import rmtree 14 | from mkosi.types import PathString 15 | 16 | 17 | def clean_package_manager_metadata(state: MkosiState) -> None: 18 | """ 19 | Remove package manager metadata 20 | 21 | Try them all regardless of the distro: metadata is only removed if 22 | the package manager is not present in the image. 23 | """ 24 | 25 | if state.config.clean_package_metadata == ConfigFeature.disabled: 26 | return 27 | 28 | always = state.config.clean_package_metadata == ConfigFeature.enabled 29 | 30 | for tool, paths in (("rpm", ["var/lib/rpm", "usr/lib/sysimage/rpm"]), 31 | ("dnf5", ["usr/lib/sysimage/libdnf5"]), 32 | ("dpkg", ["var/lib/dpkg"]), 33 | ("pacman", ["var/lib/pacman"])): 34 | for bin in ("bin", "sbin"): 35 | if not always and os.access(state.root / "usr" / bin / tool, mode=os.F_OK, follow_symlinks=False): 36 | break 37 | else: 38 | for p in paths: 39 | rmtree(state.root / p) 40 | 41 | 42 | def package_manager_scripts(state: MkosiState) -> dict[str, list[PathString]]: 43 | return { 44 | "pacman": apivfs_cmd(state.root) + pacman_cmd(state), 45 | "zypper": apivfs_cmd(state.root) + zypper_cmd(state), 46 | "dnf" : apivfs_cmd(state.root) + dnf_cmd(state), 47 | "rpm" : apivfs_cmd(state.root) + rpm_cmd(state), 48 | } | { 49 | command: apivfs_cmd(state.root) + apt_cmd(state, command) for command in ( 50 | "apt", 51 | "apt-cache", 52 | "apt-cdrom", 53 | "apt-config", 54 | "apt-extracttemplates", 55 | "apt-get", 56 | "apt-key", 57 | "apt-mark", 58 | "apt-sortpkgs", 59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /mkosi/distributions/ubuntu.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1+ 2 | 3 | from mkosi.config import Architecture 4 | from mkosi.distributions import debian 5 | from mkosi.state import MkosiState 6 | 7 | 8 | class Installer(debian.Installer): 9 | @classmethod 10 | def pretty_name(cls) -> str: 11 | return "Ubuntu" 12 | 13 | @classmethod 14 | def default_release(cls) -> str: 15 | return "lunar" 16 | 17 | @staticmethod 18 | def repositories(state: MkosiState, local: bool = True) -> list[str]: 19 | if state.config.local_mirror and local: 20 | return [f"deb [trusted=yes] {state.config.local_mirror} {state.config.release} main"] 21 | 22 | archives = ("deb", "deb-src") 23 | 24 | if state.config.architecture in (Architecture.x86, Architecture.x86_64): 25 | mirror = state.config.mirror or "http://archive.ubuntu.com/ubuntu" 26 | else: 27 | mirror = state.config.mirror or "http://ports.ubuntu.com" 28 | 29 | signedby = "[signed-by=/usr/share/keyrings/ubuntu-archive-keyring.gpg]" 30 | 31 | # From kinetic onwards, the usr-is-merged package is available in universe and is required by 32 | # mkosi to set up a proper usr-merged system so we add the universe repository unconditionally. 33 | components = ["main"] + (["universe"] if state.config.release not in ("focal", "jammy") else []) 34 | components = ' '.join((*components, *state.config.repositories)) 35 | 36 | repos = [ 37 | f"{archive} {signedby} {mirror} {state.config.release} {components}" 38 | for archive in archives 39 | ] 40 | 41 | repos += [ 42 | f"{archive} {signedby} {mirror} {state.config.release}-updates {components}" 43 | for archive in archives 44 | ] 45 | 46 | # Security updates repos are never mirrored. But !x86 are on the ports server. 47 | if state.config.architecture in [Architecture.x86, Architecture.x86_64]: 48 | mirror = "http://security.ubuntu.com/ubuntu/" 49 | else: 50 | mirror = "http://ports.ubuntu.com/" 51 | 52 | repos += [ 53 | f"{archive} {signedby} {mirror} {state.config.release}-security {components}" 54 | for archive in archives 55 | ] 56 | 57 | return repos 58 | -------------------------------------------------------------------------------- /mkosi/distributions/rhel_ubi.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1+ 2 | 3 | from collections.abc import Iterable 4 | 5 | from mkosi.distributions import centos, join_mirror 6 | from mkosi.installer.rpm import RpmRepository, find_rpm_gpgkey 7 | from mkosi.state import MkosiState 8 | 9 | 10 | class Installer(centos.Installer): 11 | @classmethod 12 | def pretty_name(cls) -> str: 13 | return "RHEL UBI" 14 | 15 | @staticmethod 16 | def gpgurls(state: MkosiState) -> tuple[str, ...]: 17 | major = int(float(state.config.release)) 18 | 19 | return ( 20 | find_rpm_gpgkey( 21 | state, 22 | f"RPM-GPG-KEY-redhat{major}-release", 23 | "https://access.redhat.com/security/data/fd431d51.txt", 24 | ), 25 | ) 26 | 27 | @classmethod 28 | def repository_variants(cls, state: MkosiState, repo: str) -> Iterable[RpmRepository]: 29 | if state.config.local_mirror: 30 | yield RpmRepository(repo, f"baseurl={state.config.local_mirror}", cls.gpgurls(state)) 31 | else: 32 | mirror = state.config.mirror or "https://cdn-ubi.redhat.com/content/public/ubi/dist/" 33 | 34 | v = state.config.release 35 | yield RpmRepository( 36 | f"ubi-{v}-{repo}-rpms", 37 | f"baseurl={join_mirror(mirror, f'ubi{v}/{v}/$basearch/{repo}/os')}", 38 | cls.gpgurls(state), 39 | ) 40 | yield RpmRepository( 41 | f"ubi-{v}-{repo}-debug-rpms", 42 | f"baseurl={join_mirror(mirror, f'ubi{v}/{v}/$basearch/{repo}/debug')}", 43 | cls.gpgurls(state), 44 | enabled=False, 45 | ) 46 | yield RpmRepository( 47 | f"ubi-{v}-{repo}-source", 48 | f"baseurl={join_mirror(mirror, f'ubi{v}/{v}/$basearch/{repo}/source')}", 49 | cls.gpgurls(state), 50 | enabled=False, 51 | ) 52 | 53 | @classmethod 54 | def repositories(cls, state: MkosiState) -> Iterable[RpmRepository]: 55 | yield from cls.repository_variants(state, "baseos") 56 | yield from cls.repository_variants(state, "appstream") 57 | yield from cls.repository_variants(state, "codeready-builder") 58 | yield from cls.epel_repositories(state) 59 | -------------------------------------------------------------------------------- /mkosi/partition.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import json 3 | import subprocess 4 | from collections.abc import Mapping, Sequence 5 | from pathlib import Path 6 | from typing import Any, Optional 7 | 8 | from mkosi.log import die 9 | from mkosi.run import run 10 | 11 | 12 | @dataclasses.dataclass(frozen=True) 13 | class Partition: 14 | type: str 15 | uuid: str 16 | partno: Optional[int] 17 | split_path: Optional[Path] 18 | roothash: Optional[str] 19 | 20 | @classmethod 21 | def from_dict(cls, dict: Mapping[str, Any]) -> "Partition": 22 | return cls( 23 | type=dict["type"], 24 | uuid=dict["uuid"], 25 | partno=int(partno) if (partno := dict.get("partno")) else None, 26 | split_path=Path(p) if ((p := dict.get("split_path")) and p != "-") else None, 27 | roothash=dict.get("roothash"), 28 | ) 29 | 30 | GRUB_BOOT_PARTITION_UUID = "21686148-6449-6e6f-744e-656564454649" 31 | 32 | 33 | def find_partitions(image: Path) -> list[Partition]: 34 | output = json.loads(run(["systemd-repart", "--json=short", image], 35 | stdout=subprocess.PIPE, stderr=subprocess.DEVNULL).stdout) 36 | return [Partition.from_dict(d) for d in output] 37 | 38 | 39 | def finalize_roothash(partitions: Sequence[Partition]) -> Optional[str]: 40 | roothash = usrhash = None 41 | 42 | for p in partitions: 43 | if (h := p.roothash) is None: 44 | continue 45 | 46 | if not (p.type.startswith("usr") or p.type.startswith("root")): 47 | die(f"Found roothash property on unexpected partition type {p.type}") 48 | 49 | # When there's multiple verity enabled root or usr partitions, the first one wins. 50 | if p.type.startswith("usr"): 51 | usrhash = usrhash or h 52 | else: 53 | roothash = roothash or h 54 | 55 | return f"roothash={roothash}" if roothash else f"usrhash={usrhash}" if usrhash else None 56 | 57 | 58 | def finalize_root(partitions: Sequence[Partition]) -> Optional[str]: 59 | root = finalize_roothash(partitions) 60 | if not root: 61 | root = next((f"root=PARTUUID={p.uuid}" for p in partitions if p.type.startswith("root")), None) 62 | if not root: 63 | root = next((f"mount.usr=PARTUUID={p.uuid}" for p in partitions if p.type.startswith("usr")), None) 64 | 65 | return root 66 | -------------------------------------------------------------------------------- /tests/test_boot.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1+ 2 | 3 | import os 4 | 5 | import pytest 6 | 7 | from mkosi.config import OutputFormat 8 | from mkosi.distributions import Distribution 9 | from mkosi.qemu import find_virtiofsd 10 | 11 | from . import Image 12 | 13 | pytestmark = pytest.mark.integration 14 | 15 | 16 | @pytest.mark.parametrize("format", OutputFormat) 17 | def test_boot(config: Image.Config, format: OutputFormat) -> None: 18 | with Image( 19 | config, 20 | options=[ 21 | "--kernel-command-line=systemd.unit=mkosi-check-and-shutdown.service", 22 | "--incremental", 23 | "--ephemeral", 24 | ], 25 | ) as image: 26 | if image.config.distribution == Distribution.rhel_ubi and format in (OutputFormat.esp, OutputFormat.uki): 27 | pytest.skip("Cannot build RHEL-UBI images with format 'esp' or 'uki'") 28 | 29 | options = ["--format", str(format)] 30 | 31 | image.summary(options) 32 | image.genkey() 33 | image.build(options=options) 34 | 35 | if format in (OutputFormat.disk, OutputFormat.directory) and os.getuid() == 0: 36 | # systemd-resolved is enabled by default in Arch/Debian/Ubuntu (systemd default preset) but fails 37 | # to start in a systemd-nspawn container with --private-users so we mask it out here to avoid CI 38 | # failures. 39 | # FIXME: Remove when Arch/Debian/Ubuntu ship systemd v253 40 | args = ["systemd.mask=systemd-resolved.service"] if format == OutputFormat.directory else [] 41 | image.boot(options=options, args=args) 42 | 43 | if ( 44 | image.config.distribution == Distribution.ubuntu and 45 | format in (OutputFormat.cpio, OutputFormat.uki, OutputFormat.esp) 46 | ): 47 | # https://bugs.launchpad.net/ubuntu/+source/linux-kvm/+bug/2045561 48 | pytest.skip("Cannot boot Ubuntu UKI/cpio images in qemu until we switch back to linux-kvm") 49 | 50 | if image.config.distribution == Distribution.rhel_ubi: 51 | return 52 | 53 | if format in (OutputFormat.tar, OutputFormat.none) or format.is_extension_image(): 54 | return 55 | 56 | if format == OutputFormat.directory and not find_virtiofsd(): 57 | return 58 | 59 | image.qemu(options=options) 60 | 61 | if format != OutputFormat.disk: 62 | return 63 | 64 | image.qemu(options=options + ["--qemu-firmware=bios"]) 65 | -------------------------------------------------------------------------------- /mkosi/installer/rpm.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1+ 2 | 3 | import os 4 | import shutil 5 | import subprocess 6 | from pathlib import Path 7 | from typing import NamedTuple, Optional 8 | 9 | from mkosi.bubblewrap import bwrap 10 | from mkosi.state import MkosiState 11 | from mkosi.tree import rmtree 12 | from mkosi.types import PathString 13 | 14 | 15 | class RpmRepository(NamedTuple): 16 | id: str 17 | url: str 18 | gpgurls: tuple[str, ...] 19 | enabled: bool = True 20 | sslcacert: Optional[Path] = None 21 | sslclientkey: Optional[Path] = None 22 | sslclientcert: Optional[Path] = None 23 | 24 | 25 | def find_rpm_gpgkey(state: MkosiState, key: str, url: str) -> str: 26 | gpgpath = next(Path("/usr/share/distribution-gpg-keys").rglob(key), None) 27 | if gpgpath: 28 | return f"file://{gpgpath}" 29 | 30 | gpgpath = next(Path(state.pkgmngr / "etc/pki/rpm-gpg").rglob(key), None) 31 | if gpgpath: 32 | return f"file://{Path('/') / gpgpath.relative_to(state.pkgmngr)}" 33 | 34 | return url 35 | 36 | 37 | def setup_rpm(state: MkosiState) -> None: 38 | confdir = state.pkgmngr / "etc/rpm" 39 | confdir.mkdir(parents=True, exist_ok=True) 40 | if not (confdir / "macros.lang").exists() and state.config.locale: 41 | (confdir / "macros.lang").write_text(f"%_install_langs {state.config.locale}") 42 | 43 | plugindir = Path(bwrap(state, ["rpm", "--eval", "%{__plugindir}"], stdout=subprocess.PIPE).stdout.strip()) 44 | if plugindir.exists(): 45 | with (confdir / "macros.disable-plugins").open("w") as f: 46 | for plugin in plugindir.iterdir(): 47 | f.write(f"%__transaction_{plugin.stem} %{{nil}}\n") 48 | 49 | 50 | def fixup_rpmdb_location(root: Path) -> None: 51 | # On Debian, rpm/dnf ship with a patch to store the rpmdb under ~/ so it needs to be copied back in the 52 | # right location, otherwise the rpmdb will be broken. See: https://bugs.debian.org/1004863. We also 53 | # replace it with a symlink so that any further rpm operations immediately use the correct location. 54 | rpmdb_home = root / "root/.rpmdb" 55 | if not rpmdb_home.exists() or rpmdb_home.is_symlink(): 56 | return 57 | 58 | # Take into account the new location in F36 59 | rpmdb = root / "usr/lib/sysimage/rpm" 60 | if not rpmdb.exists(): 61 | rpmdb = root / "var/lib/rpm" 62 | rmtree(rpmdb) 63 | shutil.move(rpmdb_home, rpmdb) 64 | rpmdb_home.symlink_to(os.path.relpath(rpmdb, start=rpmdb_home.parent)) 65 | 66 | 67 | def rpm_cmd(state: MkosiState) -> list[PathString]: 68 | return ["env", "HOME=/", "rpm", "--root", state.root] 69 | -------------------------------------------------------------------------------- /docs/sysext.md: -------------------------------------------------------------------------------- 1 | # Building system extensions with mkosi 2 | 3 | [System extension](https://uapi-group.org/specifications/specs/extension_image/) 4 | images may – dynamically at runtime — extend the base system with an 5 | overlay containing additional files. 6 | 7 | To build system extensions with mkosi, we first have to create a base 8 | image on top of which we can build our extension. 9 | 10 | To keep things manageable, we'll use mkosi's support for building 11 | multiple images so that we can build our base image and system extension 12 | in one go. 13 | 14 | Start by creating a temporary directory with a base configuration file 15 | `mkosi.conf` with some shared settings: 16 | 17 | ```conf 18 | [Output] 19 | OutputDirectory=mkosi.output 20 | CacheDirectory=mkosi.cache 21 | ``` 22 | 23 | From now on we'll assume all steps are executed inside the temporary 24 | directory. 25 | 26 | Now let's continue with the base image definition by writing the 27 | following to `mkosi.images/base/mkosi.conf`: 28 | 29 | ```conf 30 | [Output] 31 | Format=directory 32 | 33 | [Content] 34 | CleanPackageMetadata=no 35 | Packages=systemd 36 | udev 37 | ``` 38 | 39 | We use the `directory` output format here instead of the `disk` output 40 | so that we can build our extension without needing root privileges. 41 | 42 | Now that we have our base image, we can define a sysext that builds on 43 | top of it by writing the following to `mkosi.images/btrfs/mkosi.conf`: 44 | 45 | ```conf 46 | [Config] 47 | Dependencies=base 48 | 49 | [Output] 50 | Format=sysext 51 | Overlay=yes 52 | 53 | [Content] 54 | BaseTrees=%O/base 55 | Packages=btrfs-progs 56 | ``` 57 | 58 | `BaseTrees=` point to our base image and `Overlay=yes` instructs mkosi 59 | to only package the files added on top of the base tree. 60 | 61 | We can't sign the extension image without a key, so let's generate one 62 | with `mkosi genkey` (or write your own private key and certificate 63 | yourself to `mkosi.key` and `mkosi.crt` respectively). Note that this 64 | key will need to be loaded into your kernel keyring either at build time 65 | or via MOK for systemd to accept the system extension at runtime as 66 | trusted. 67 | 68 | Finally, you can build the base image and the extensions by running 69 | `mkosi -f`. You'll find `btrfs.raw` in `mkosi.output` which is the 70 | extension image. 71 | 72 | If you want to package up the base image into another format, for 73 | example an initrd, we can do that by adding the following to 74 | `mkosi.images/initrd/mkosi.conf`: 75 | 76 | ```conf 77 | [Config] 78 | Dependencies=base 79 | 80 | [Output] 81 | Format=cpio 82 | 83 | [Content] 84 | MakeInitrd=yes 85 | BaseTrees=%O/base 86 | ``` 87 | 88 | If we now run `mkosi -f` again, we'll find `initrd.cpio.zst` in 89 | `mkosi.output` with its accompanying extension still in `btrfs.raw`. 90 | -------------------------------------------------------------------------------- /mkosi/installer/zypper.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1+ 2 | import textwrap 3 | from collections.abc import Sequence 4 | 5 | from mkosi.bubblewrap import apivfs_cmd, bwrap 6 | from mkosi.config import yes_no 7 | from mkosi.installer.rpm import RpmRepository, fixup_rpmdb_location, setup_rpm 8 | from mkosi.state import MkosiState 9 | from mkosi.types import PathString 10 | from mkosi.util import sort_packages 11 | 12 | 13 | def setup_zypper(state: MkosiState, repos: Sequence[RpmRepository]) -> None: 14 | config = state.pkgmngr / "etc/zypp/zypp.conf" 15 | config.parent.mkdir(exist_ok=True, parents=True) 16 | 17 | # rpm.install.excludedocs can only be configured in zypp.conf so we append 18 | # to any user provided config file. Let's also bump the refresh delay to 19 | # the same default as dnf which is 48 hours. 20 | with config.open("a") as f: 21 | f.write( 22 | textwrap.dedent( 23 | f""" 24 | [main] 25 | rpm.install.excludedocs = {yes_no(not state.config.with_docs)} 26 | repo.refresh.delay = {48 * 60} 27 | """ 28 | ) 29 | ) 30 | 31 | repofile = state.pkgmngr / "etc/zypp/repos.d/mkosi.repo" 32 | if not repofile.exists(): 33 | repofile.parent.mkdir(exist_ok=True, parents=True) 34 | with repofile.open("w") as f: 35 | for repo in repos: 36 | f.write( 37 | textwrap.dedent( 38 | f"""\ 39 | [{repo.id}] 40 | name={repo.id} 41 | {repo.url} 42 | gpgcheck=1 43 | enabled={int(repo.enabled)} 44 | autorefresh=1 45 | keeppackages=1 46 | """ 47 | ) 48 | ) 49 | 50 | for i, url in enumerate(repo.gpgurls): 51 | f.write("gpgkey=" if i == 0 else len("gpgkey=") * " ") 52 | f.write(f"{url}\n") 53 | 54 | setup_rpm(state) 55 | 56 | 57 | def zypper_cmd(state: MkosiState) -> list[PathString]: 58 | return [ 59 | "env", 60 | "ZYPP_CONF=/etc/zypp/zypp.conf", 61 | "HOME=/", 62 | "zypper", 63 | f"--installroot={state.root}", 64 | f"--cache-dir={state.cache_dir / 'cache/zypp'}", 65 | "--gpg-auto-import-keys" if state.config.repository_key_check else "--no-gpg-checks", 66 | "--non-interactive", 67 | ] 68 | 69 | 70 | def invoke_zypper( 71 | state: MkosiState, 72 | verb: str, 73 | packages: Sequence[str], 74 | options: Sequence[str] = (), 75 | apivfs: bool = True, 76 | ) -> None: 77 | cmd = apivfs_cmd(state.root) if apivfs else [] 78 | bwrap(state, cmd + zypper_cmd(state) + [verb, *options, *sort_packages(packages)], 79 | network=True, env=state.config.environment) 80 | 81 | fixup_rpmdb_location(state.root) 82 | -------------------------------------------------------------------------------- /mkosi/installer/pacman.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1+ 2 | import textwrap 3 | from collections.abc import Iterable, Sequence 4 | from typing import NamedTuple 5 | 6 | from mkosi.bubblewrap import apivfs_cmd, bwrap 7 | from mkosi.state import MkosiState 8 | from mkosi.types import PathString 9 | from mkosi.util import sort_packages, umask 10 | 11 | 12 | class PacmanRepository(NamedTuple): 13 | id: str 14 | url: str 15 | 16 | 17 | def setup_pacman(state: MkosiState, repositories: Iterable[PacmanRepository]) -> None: 18 | if state.config.repository_key_check: 19 | sig_level = "Required DatabaseOptional" 20 | else: 21 | # If we are using a single local mirror built on the fly there 22 | # will be no signatures 23 | sig_level = "Never" 24 | 25 | # Create base layout for pacman and pacman-key 26 | with umask(~0o755): 27 | (state.root / "var/lib/pacman").mkdir(exist_ok=True, parents=True) 28 | 29 | (state.cache_dir / "cache/pacman/pkg").mkdir(parents=True, exist_ok=True) 30 | 31 | config = state.pkgmngr / "etc/pacman.conf" 32 | if config.exists(): 33 | return 34 | 35 | config.parent.mkdir(exist_ok=True, parents=True) 36 | 37 | with config.open("w") as f: 38 | f.write( 39 | textwrap.dedent( 40 | f"""\ 41 | [options] 42 | SigLevel = {sig_level} 43 | LocalFileSigLevel = Optional 44 | ParallelDownloads = 5 45 | """ 46 | ) 47 | ) 48 | 49 | for repo in repositories: 50 | f.write( 51 | textwrap.dedent( 52 | f"""\ 53 | 54 | [{repo.id}] 55 | Server = {repo.url} 56 | """ 57 | ) 58 | ) 59 | 60 | if any((state.pkgmngr / "etc/pacman.d/").glob("*.conf")): 61 | f.write( 62 | textwrap.dedent( 63 | """\ 64 | 65 | Include = /etc/pacman.d/*.conf 66 | """ 67 | ) 68 | ) 69 | 70 | 71 | def pacman_cmd(state: MkosiState) -> list[PathString]: 72 | return [ 73 | "pacman", 74 | "--root", state.root, 75 | "--logfile=/dev/null", 76 | "--cachedir", state.cache_dir / "cache/pacman/pkg", 77 | "--hookdir", state.root / "etc/pacman.d/hooks", 78 | "--arch", state.config.distribution.architecture(state.config.architecture), 79 | "--color", "auto", 80 | "--noconfirm", 81 | ] 82 | 83 | 84 | def invoke_pacman( 85 | state: MkosiState, 86 | operation: str, 87 | options: Sequence[str] = (), 88 | packages: Sequence[str] = (), 89 | apivfs: bool = True, 90 | ) -> None: 91 | cmd = apivfs_cmd(state.root) if apivfs else [] 92 | bwrap(state, cmd + pacman_cmd(state) + [operation, *options, *sort_packages(packages)], 93 | network=True, env=state.config.environment) 94 | -------------------------------------------------------------------------------- /mkosi/distributions/arch.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1+ 2 | 3 | from collections.abc import Sequence 4 | 5 | from mkosi.config import Architecture 6 | from mkosi.distributions import Distribution, DistributionInstaller, PackageType 7 | from mkosi.installer.pacman import PacmanRepository, invoke_pacman, setup_pacman 8 | from mkosi.log import die 9 | from mkosi.state import MkosiState 10 | 11 | 12 | class Installer(DistributionInstaller): 13 | @classmethod 14 | def pretty_name(cls) -> str: 15 | return "Arch Linux" 16 | 17 | @classmethod 18 | def filesystem(cls) -> str: 19 | return "ext4" 20 | 21 | @classmethod 22 | def package_type(cls) -> PackageType: 23 | return PackageType.pkg 24 | 25 | @classmethod 26 | def default_release(cls) -> str: 27 | return "rolling" 28 | 29 | @classmethod 30 | def default_tools_tree_distribution(cls) -> Distribution: 31 | return Distribution.arch 32 | 33 | @classmethod 34 | def setup(cls, state: MkosiState) -> None: 35 | if state.config.local_mirror: 36 | repos = [PacmanRepository("core", state.config.local_mirror)] 37 | else: 38 | repos = [] 39 | 40 | if state.config.architecture == Architecture.arm64: 41 | url = f"{state.config.mirror or 'http://mirror.archlinuxarm.org'}/$arch/$repo" 42 | else: 43 | url = f"{state.config.mirror or 'https://geo.mirror.pkgbuild.com'}/$repo/os/$arch" 44 | 45 | # Testing repositories have to go before regular ones to to take precedence. 46 | for id in ( 47 | "core-testing", 48 | "core-testing-debug", 49 | "extra-testing", 50 | "extra-testing-debug", 51 | "core-debug", 52 | "extra-debug", 53 | ): 54 | if id in state.config.repositories: 55 | repos += [PacmanRepository(id, url)] 56 | 57 | for id in ("core", "extra"): 58 | repos += [PacmanRepository(id, url)] 59 | 60 | setup_pacman(state, repos) 61 | 62 | @classmethod 63 | def install(cls, state: MkosiState) -> None: 64 | cls.install_packages(state, ["filesystem"], apivfs=False) 65 | 66 | @classmethod 67 | def install_packages(cls, state: MkosiState, packages: Sequence[str], apivfs: bool = True) -> None: 68 | invoke_pacman( 69 | state, 70 | "--sync", 71 | ["--refresh", "--needed", "--assume-installed", "initramfs"], 72 | packages, 73 | apivfs=apivfs, 74 | ) 75 | 76 | @classmethod 77 | def remove_packages(cls, state: MkosiState, packages: Sequence[str]) -> None: 78 | invoke_pacman(state, "--remove", ["--nosave", "--recursive"], packages) 79 | 80 | @classmethod 81 | def architecture(cls, arch: Architecture) -> str: 82 | a = { 83 | Architecture.x86_64 : "x86_64", 84 | Architecture.arm64 : "aarch64", 85 | }.get(arch) 86 | 87 | if not a: 88 | die(f"Architecture {a} is not supported by Arch Linux") 89 | 90 | return a 91 | 92 | -------------------------------------------------------------------------------- /mkosi/log.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1+ 2 | 3 | import contextlib 4 | import contextvars 5 | import logging 6 | import os 7 | import sys 8 | from collections.abc import Iterator 9 | from typing import Any, NoReturn, Optional 10 | 11 | # This global should be initialized after parsing arguments 12 | ARG_DEBUG = contextvars.ContextVar("debug", default=False) 13 | ARG_DEBUG_SHELL = contextvars.ContextVar("debug-shell", default=False) 14 | LEVEL = 0 15 | 16 | 17 | class Style: 18 | bold = "\033[0;1;39m" if sys.stderr.isatty() else "" 19 | gray = "\033[0;38;5;245m" if sys.stderr.isatty() else "" 20 | red = "\033[31;1m" if sys.stderr.isatty() else "" 21 | yellow = "\033[33;1m" if sys.stderr.isatty() else "" 22 | reset = "\033[0m" if sys.stderr.isatty() else "" 23 | 24 | 25 | def die(message: str, 26 | *, 27 | hint: Optional[str] = None) -> NoReturn: 28 | logging.error(f"{message}") 29 | if hint: 30 | logging.info(f"({hint})") 31 | sys.exit(1) 32 | 33 | 34 | def log_step(text: str) -> None: 35 | prefix = " " * LEVEL 36 | 37 | if sys.exc_info()[0]: 38 | # We are falling through exception handling blocks. 39 | # De-emphasize this step here, so the user can tell more 40 | # easily which step generated the exception. The exception 41 | # or error will only be printed after we finish cleanup. 42 | logging.info(f"{prefix}({text})") 43 | else: 44 | logging.info(f"{prefix}{Style.bold}{text}{Style.reset}") 45 | 46 | 47 | def log_notice(text: str) -> None: 48 | logging.info(f"{Style.bold}{text}{Style.reset}") 49 | 50 | 51 | @contextlib.contextmanager 52 | def complete_step(text: str, text2: Optional[str] = None) -> Iterator[list[Any]]: 53 | global LEVEL 54 | 55 | log_step(text) 56 | 57 | LEVEL += 1 58 | try: 59 | args: list[Any] = [] 60 | yield args 61 | finally: 62 | LEVEL -= 1 63 | assert LEVEL >= 0 64 | 65 | if text2 is not None: 66 | log_step(text2.format(*args)) 67 | 68 | 69 | class MkosiFormatter(logging.Formatter): 70 | def __init__(self, fmt: Optional[str] = None, *args: Any, **kwargs: Any) -> None: 71 | fmt = fmt or "%(message)s" 72 | 73 | self.formatters = { 74 | logging.DEBUG: logging.Formatter(f"‣ {Style.gray}{fmt}{Style.reset}"), 75 | logging.INFO: logging.Formatter(f"‣ {fmt}"), 76 | logging.WARNING: logging.Formatter(f"‣ {Style.yellow}{fmt}{Style.reset}"), 77 | logging.ERROR: logging.Formatter(f"‣ {Style.red}{fmt}{Style.reset}"), 78 | logging.CRITICAL: logging.Formatter(f"‣ {Style.red}{Style.bold}{fmt}{Style.reset}"), 79 | } 80 | 81 | super().__init__(fmt, *args, **kwargs) 82 | 83 | def format(self, record: logging.LogRecord) -> str: 84 | return self.formatters[record.levelno].format(record) 85 | 86 | 87 | def log_setup() -> None: 88 | handler = logging.StreamHandler(stream=sys.stderr) 89 | handler.setFormatter(MkosiFormatter()) 90 | 91 | logging.getLogger().addHandler(handler) 92 | logging.getLogger().setLevel(logging.getLevelName(os.getenv("SYSTEMD_LOG_LEVEL", "info").upper())) 93 | -------------------------------------------------------------------------------- /mkosi/distributions/mageia.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1+ 2 | 3 | import shutil 4 | from collections.abc import Sequence 5 | 6 | from mkosi.config import Architecture 7 | from mkosi.distributions import ( 8 | Distribution, 9 | DistributionInstaller, 10 | PackageType, 11 | join_mirror, 12 | ) 13 | from mkosi.installer.dnf import invoke_dnf, setup_dnf 14 | from mkosi.installer.rpm import RpmRepository, find_rpm_gpgkey 15 | from mkosi.log import die 16 | from mkosi.state import MkosiState 17 | 18 | 19 | class Installer(DistributionInstaller): 20 | @classmethod 21 | def pretty_name(cls) -> str: 22 | return "Mageia" 23 | 24 | @classmethod 25 | def filesystem(cls) -> str: 26 | return "ext4" 27 | 28 | @classmethod 29 | def package_type(cls) -> PackageType: 30 | return PackageType.rpm 31 | 32 | @classmethod 33 | def default_release(cls) -> str: 34 | return "cauldron" 35 | 36 | @classmethod 37 | def default_tools_tree_distribution(cls) -> Distribution: 38 | return Distribution.mageia 39 | 40 | @classmethod 41 | def setup(cls, state: MkosiState) -> None: 42 | gpgurls = ( 43 | find_rpm_gpgkey( 44 | state, 45 | "RPM-GPG-KEY-Mageia", 46 | "https://mirrors.kernel.org/mageia/distrib/$releasever/$basearch/media/core/release/media_info/pubkey", 47 | ), 48 | ) 49 | 50 | repos = [] 51 | 52 | if state.config.local_mirror: 53 | repos += [RpmRepository("core-release", f"baseurl={state.config.local_mirror}", gpgurls)] 54 | elif state.config.mirror: 55 | url = f"baseurl={join_mirror(state.config.mirror, 'distrib/$releasever/$basearch/media/core/')}" 56 | repos += [ 57 | RpmRepository("core-release", f"{url}/release", gpgurls), 58 | RpmRepository("core-updates", f"{url}/updates/", gpgurls) 59 | ] 60 | else: 61 | url = "mirrorlist=https://www.mageia.org/mirrorlist/?release=$releasever&arch=$basearch§ion=core" 62 | repos += [ 63 | RpmRepository("core-release", f"{url}&repo=release", gpgurls), 64 | RpmRepository("core-updates", f"{url}&repo=updates", gpgurls) 65 | ] 66 | 67 | setup_dnf(state, repos) 68 | 69 | @classmethod 70 | def install(cls, state: MkosiState) -> None: 71 | cls.install_packages(state, ["filesystem"], apivfs=False) 72 | 73 | @classmethod 74 | def install_packages(cls, state: MkosiState, packages: Sequence[str], apivfs: bool = True) -> None: 75 | invoke_dnf(state, "install", packages, apivfs=apivfs) 76 | 77 | for d in state.root.glob("boot/vmlinuz-*"): 78 | kver = d.name.removeprefix("vmlinuz-") 79 | vmlinuz = state.root / "usr/lib/modules" / kver / "vmlinuz" 80 | if not vmlinuz.exists(): 81 | shutil.copy2(d, vmlinuz) 82 | 83 | @classmethod 84 | def remove_packages(cls, state: MkosiState, packages: Sequence[str]) -> None: 85 | invoke_dnf(state, "remove", packages) 86 | 87 | @classmethod 88 | def architecture(cls, arch: Architecture) -> str: 89 | a = { 90 | Architecture.x86_64 : "x86_64", 91 | Architecture.arm64 : "aarch64", 92 | }.get(arch) 93 | 94 | if not a: 95 | die(f"Architecture {a} is not supported by Mageia") 96 | 97 | return a 98 | -------------------------------------------------------------------------------- /mkosi/distributions/openmandriva.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1+ 2 | 3 | import shutil 4 | from collections.abc import Sequence 5 | 6 | from mkosi.config import Architecture 7 | from mkosi.distributions import ( 8 | Distribution, 9 | DistributionInstaller, 10 | PackageType, 11 | join_mirror, 12 | ) 13 | from mkosi.installer.dnf import invoke_dnf, setup_dnf 14 | from mkosi.installer.rpm import RpmRepository, find_rpm_gpgkey 15 | from mkosi.log import die 16 | from mkosi.state import MkosiState 17 | 18 | 19 | class Installer(DistributionInstaller): 20 | @classmethod 21 | def pretty_name(cls) -> str: 22 | return "OpenMandriva" 23 | 24 | @classmethod 25 | def filesystem(cls) -> str: 26 | return "ext4" 27 | 28 | @classmethod 29 | def package_type(cls) -> PackageType: 30 | return PackageType.rpm 31 | 32 | @classmethod 33 | def default_release(cls) -> str: 34 | return "cooker" 35 | 36 | @classmethod 37 | def default_tools_tree_distribution(cls) -> Distribution: 38 | return Distribution.openmandriva 39 | 40 | @classmethod 41 | def setup(cls, state: MkosiState) -> None: 42 | mirror = state.config.mirror or "http://mirror.openmandriva.org" 43 | 44 | gpgurls = ( 45 | find_rpm_gpgkey( 46 | state, 47 | "RPM-GPG-KEY-OpenMandriva", 48 | "https://raw.githubusercontent.com/OpenMandrivaAssociation/openmandriva-repos/master/RPM-GPG-KEY-OpenMandriva", 49 | ), 50 | ) 51 | 52 | repos = [] 53 | 54 | if state.config.local_mirror: 55 | repos += [RpmRepository("main-release", f"baseurl={state.config.local_mirror}", gpgurls)] 56 | else: 57 | url = f"baseurl={join_mirror(mirror, '$releasever/repository/$basearch/main')}" 58 | repos += [ 59 | RpmRepository("main-release", f"{url}/release", gpgurls), 60 | RpmRepository("main-updates", f"{url}/updates", gpgurls), 61 | ] 62 | 63 | setup_dnf(state, repos) 64 | 65 | @classmethod 66 | def install(cls, state: MkosiState) -> None: 67 | cls.install_packages(state, ["filesystem"], apivfs=False) 68 | 69 | @classmethod 70 | def install_packages(cls, state: MkosiState, packages: Sequence[str], apivfs: bool = True) -> None: 71 | invoke_dnf(state, "install", packages, apivfs=apivfs) 72 | 73 | for d in state.root.glob("boot/vmlinuz-*"): 74 | kver = d.name.removeprefix("vmlinuz-") 75 | vmlinuz = state.root / "usr/lib/modules" / kver / "vmlinuz" 76 | # Openmandriva symlinks /usr/lib/modules//vmlinuz to /boot/vmlinuz-, so get rid of the symlink 77 | # and put the actual vmlinuz in /usr/lib/modules/. 78 | if vmlinuz.is_symlink(): 79 | vmlinuz.unlink() 80 | if not vmlinuz.exists(): 81 | shutil.copy2(d, vmlinuz) 82 | 83 | @classmethod 84 | def remove_packages(cls, state: MkosiState, packages: Sequence[str]) -> None: 85 | invoke_dnf(state, "remove", packages) 86 | 87 | @classmethod 88 | def architecture(cls, arch: Architecture) -> str: 89 | a = { 90 | Architecture.x86_64 : "x86_64", 91 | Architecture.arm64 : "aarch64", 92 | Architecture.riscv64 : "riscv64", 93 | }.get(arch) 94 | 95 | if not a: 96 | die(f"Architecture {a} is not supported by OpenMandriva") 97 | 98 | return a 99 | -------------------------------------------------------------------------------- /mkosi/archive.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1+ 2 | 3 | import os 4 | import shutil 5 | from collections.abc import Iterable 6 | from pathlib import Path 7 | from typing import Optional 8 | 9 | from mkosi.bubblewrap import bwrap 10 | from mkosi.log import log_step 11 | from mkosi.mounts import finalize_passwd_mounts 12 | from mkosi.state import MkosiState 13 | 14 | 15 | def tar_binary() -> str: 16 | # Some distros (Mandriva) install BSD tar as "tar", hence prefer 17 | # "gtar" if it exists, which should be GNU tar wherever it exists. 18 | # We are interested in exposing same behaviour everywhere hence 19 | # it's preferable to use the same implementation of tar 20 | # everywhere. In particular given the limited/different SELinux 21 | # support in BSD tar and the different command line syntax 22 | # compared to GNU tar. 23 | return "gtar" if shutil.which("gtar") else "tar" 24 | 25 | 26 | def cpio_binary() -> str: 27 | return "gcpio" if shutil.which("gcpio") else "cpio" 28 | 29 | 30 | def tar_exclude_apivfs_tmp() -> list[str]: 31 | return [ 32 | "--exclude", "./dev/*", 33 | "--exclude", "./proc/*", 34 | "--exclude", "./sys/*", 35 | "--exclude", "./tmp/*", 36 | "--exclude", "./run/*", 37 | "--exclude", "./var/tmp/*", 38 | ] 39 | 40 | 41 | def make_tar(state: MkosiState, src: Path, dst: Path) -> None: 42 | log_step(f"Creating tar archive {dst}…") 43 | bwrap( 44 | state, 45 | [ 46 | tar_binary(), 47 | "--create", 48 | "--file", dst, 49 | "--directory", src, 50 | "--acls", 51 | "--selinux", 52 | # --xattrs implies --format=pax 53 | "--xattrs", 54 | # PAX format emits additional headers for atime, ctime and mtime 55 | # that would make the archive non-reproducible. 56 | "--pax-option=delete=atime,delete=ctime,delete=mtime", 57 | "--sparse", 58 | "--force-local", 59 | *tar_exclude_apivfs_tmp(), 60 | ".", 61 | ], 62 | # Make sure tar uses user/group information from the root directory instead of the host. 63 | options=finalize_passwd_mounts(src) if (src / "etc/passwd").exists() else [], 64 | ) 65 | 66 | 67 | def extract_tar(state: MkosiState, src: Path, dst: Path, log: bool = True) -> None: 68 | if log: 69 | log_step(f"Extracting tar archive {src}…") 70 | bwrap( 71 | state, 72 | [ 73 | tar_binary(), 74 | "--extract", 75 | "--file", src, 76 | "--directory", dst, 77 | "--keep-directory-symlink", 78 | "--no-overwrite-dir", 79 | "--same-permissions", 80 | "--same-owner" if (dst / "etc/passwd").exists() else "--numeric-owner", 81 | "--same-order", 82 | "--acls", 83 | "--selinux", 84 | "--xattrs", 85 | "--force-local", 86 | *tar_exclude_apivfs_tmp(), 87 | ], 88 | # Make sure tar uses user/group information from the root directory instead of the host. 89 | options=finalize_passwd_mounts(dst) if (dst / "etc/passwd").exists() else [], 90 | ) 91 | 92 | 93 | def make_cpio(state: MkosiState, src: Path, dst: Path, files: Optional[Iterable[Path]] = None) -> None: 94 | if not files: 95 | files = src.rglob("*") 96 | files = sorted(files) 97 | 98 | log_step(f"Creating cpio archive {dst}…") 99 | bwrap( 100 | state, 101 | [ 102 | cpio_binary(), 103 | "--create", 104 | "--reproducible", 105 | "--null", 106 | "--format=newc", 107 | "--quiet", 108 | "--directory", src, 109 | "-O", dst, 110 | ], 111 | input="\0".join(os.fspath(f.relative_to(src)) for f in files), 112 | # Make sure cpio uses user/group information from the root directory instead of the host. 113 | options=finalize_passwd_mounts(dst), 114 | ) 115 | -------------------------------------------------------------------------------- /mkosi/distributions/rhel.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1+ 2 | 3 | from collections.abc import Iterable 4 | from pathlib import Path 5 | from typing import Any, Optional 6 | 7 | from mkosi.distributions import centos, join_mirror 8 | from mkosi.installer.rpm import RpmRepository, find_rpm_gpgkey 9 | from mkosi.log import die 10 | from mkosi.state import MkosiState 11 | 12 | 13 | class Installer(centos.Installer): 14 | @classmethod 15 | def pretty_name(cls) -> str: 16 | return "RHEL" 17 | 18 | @staticmethod 19 | def gpgurls(state: MkosiState) -> tuple[str, ...]: 20 | major = int(float(state.config.release)) 21 | 22 | return ( 23 | find_rpm_gpgkey( 24 | state, 25 | f"RPM-GPG-KEY-redhat{major}-release", 26 | "https://access.redhat.com/security/data/fd431d51.txt", 27 | ), 28 | ) 29 | 30 | @staticmethod 31 | def sslcacert(state: MkosiState) -> Optional[Path]: 32 | if state.config.mirror: 33 | return None 34 | 35 | p = Path("etc/rhsm/ca/redhat-uep.pem") 36 | if (state.pkgmngr / p).exists(): 37 | p = state.pkgmngr / p 38 | elif (Path("/") / p).exists(): 39 | p = Path("/") / p 40 | else: 41 | die("redhat-uep.pem certificate not found in host system or package manager tree") 42 | 43 | return p 44 | 45 | @staticmethod 46 | def sslclientkey(state: MkosiState) -> Optional[Path]: 47 | if state.config.mirror: 48 | return None 49 | 50 | pattern = "etc/pki/entitlement/*-key.pem" 51 | 52 | p = next((p for p in sorted(state.pkgmngr.glob(pattern))), None) 53 | if not p: 54 | p = next((p for p in Path("/").glob(pattern)), None) 55 | if not p: 56 | die("Entitlement key not found in host system or package manager tree") 57 | 58 | return p 59 | 60 | @staticmethod 61 | def sslclientcert(state: MkosiState) -> Optional[Path]: 62 | if state.config.mirror: 63 | return None 64 | 65 | pattern = "etc/pki/entitlement/*.pem" 66 | 67 | p = next((p for p in sorted(state.pkgmngr.glob(pattern)) if "key" not in p.name), None) 68 | if not p: 69 | p = next((p for p in sorted(Path("/").glob(pattern)) if "key" not in p.name), None) 70 | if not p: 71 | die("Entitlement certificate not found in host system or package manager tree") 72 | 73 | return p 74 | 75 | @classmethod 76 | def repository_variants(cls, state: MkosiState, repo: str) -> Iterable[RpmRepository]: 77 | if state.config.local_mirror: 78 | yield RpmRepository(repo, f"baseurl={state.config.local_mirror}", cls.gpgurls(state)) 79 | else: 80 | mirror = state.config.mirror or "https://cdn.redhat.com/content/dist/" 81 | 82 | common: dict[str, Any] = dict( 83 | gpgurls=cls.gpgurls(state), 84 | sslcacert=cls.sslcacert(state), 85 | sslclientcert=cls.sslclientcert(state), 86 | sslclientkey=cls.sslclientkey(state), 87 | ) 88 | 89 | v = state.config.release 90 | major = int(float(v)) 91 | yield RpmRepository( 92 | f"rhel-{v}-{repo}-rpms", 93 | f"baseurl={join_mirror(mirror, f'rhel{major}/{v}/$basearch/{repo}/os')}", 94 | enabled=True, 95 | **common, 96 | ) 97 | yield RpmRepository( 98 | f"rhel-{v}-{repo}-debug-rpms", 99 | f"baseurl={join_mirror(mirror, f'rhel{major}/{v}/$basearch/{repo}/debug')}", 100 | enabled=False, 101 | **common, 102 | ) 103 | yield RpmRepository( 104 | f"rhel-{v}-{repo}-source", 105 | f"baseurl={join_mirror(mirror, f'rhel{major}/{v}/$basearch/{repo}/source')}", 106 | enabled=False, 107 | **common, 108 | ) 109 | 110 | @classmethod 111 | def repositories(cls, state: MkosiState) -> Iterable[RpmRepository]: 112 | yield from cls.repository_variants(state, "baseos") 113 | yield from cls.repository_variants(state, "appstream") 114 | yield from cls.repository_variants(state, "codeready-builder") 115 | yield from cls.epel_repositories(state) 116 | -------------------------------------------------------------------------------- /mkosi/installer/apt.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1+ 2 | import shutil 3 | import textwrap 4 | from collections.abc import Sequence 5 | 6 | from mkosi.bubblewrap import apivfs_cmd, bwrap 7 | from mkosi.state import MkosiState 8 | from mkosi.types import PathString 9 | from mkosi.util import sort_packages, umask 10 | 11 | 12 | def setup_apt(state: MkosiState, repos: Sequence[str]) -> None: 13 | (state.pkgmngr / "etc/apt").mkdir(exist_ok=True, parents=True) 14 | (state.pkgmngr / "etc/apt/apt.conf.d").mkdir(exist_ok=True, parents=True) 15 | (state.pkgmngr / "etc/apt/preferences.d").mkdir(exist_ok=True, parents=True) 16 | (state.pkgmngr / "etc/apt/sources.list.d").mkdir(exist_ok=True, parents=True) 17 | 18 | # TODO: Drop once apt 2.5.4 is widely available. 19 | with umask(~0o755): 20 | (state.root / "var/lib/dpkg").mkdir(parents=True, exist_ok=True) 21 | (state.root / "var/lib/dpkg/status").touch() 22 | 23 | (state.cache_dir / "lib/apt").mkdir(exist_ok=True, parents=True) 24 | (state.cache_dir / "cache/apt").mkdir(exist_ok=True, parents=True) 25 | 26 | # We have a special apt.conf outside of pkgmngr dir that only configures "Dir::Etc" that we pass to APT_CONFIG to 27 | # tell apt it should read config files from /etc/apt in case this is overridden by distributions. This is required 28 | # because apt parses CLI configuration options after parsing its configuration files and as such we can't use CLI 29 | # options to tell apt where to look for configuration files. 30 | config = state.workspace / "apt.conf" 31 | if not config.exists(): 32 | config.write_text( 33 | textwrap.dedent( 34 | """\ 35 | Dir::Etc "etc/apt"; 36 | """ 37 | ) 38 | ) 39 | 40 | sources = state.pkgmngr / "etc/apt/sources.list" 41 | if not sources.exists(): 42 | with sources.open("w") as f: 43 | for repo in repos: 44 | f.write(f"{repo}\n") 45 | 46 | 47 | def apt_cmd(state: MkosiState, command: str) -> list[PathString]: 48 | debarch = state.config.distribution.architecture(state.config.architecture) 49 | 50 | cmdline: list[PathString] = [ 51 | "env", 52 | f"APT_CONFIG={state.workspace / 'apt.conf'}", 53 | "DEBIAN_FRONTEND=noninteractive", 54 | "DEBCONF_INTERACTIVE_SEEN=true", 55 | "INITRD=No", 56 | command, 57 | "-o", f"APT::Architecture={debarch}", 58 | "-o", f"APT::Architectures={debarch}", 59 | "-o", f"APT::Install-Recommends={str(state.config.with_recommends).lower()}", 60 | "-o", "APT::Immediate-Configure=off", 61 | "-o", "APT::Get::Assume-Yes=true", 62 | "-o", "APT::Get::AutomaticRemove=true", 63 | "-o", "APT::Get::Allow-Change-Held-Packages=true", 64 | "-o", "APT::Get::Allow-Remove-Essential=true", 65 | "-o", "APT::Sandbox::User=root", 66 | "-o", f"Dir::Cache={state.cache_dir / 'cache/apt'}", 67 | "-o", f"Dir::State={state.cache_dir / 'lib/apt'}", 68 | "-o", f"Dir::State::Status={state.root / 'var/lib/dpkg/status'}", 69 | "-o", f"Dir::Log={state.workspace}", 70 | "-o", f"Dir::Bin::DPkg={shutil.which('dpkg')}", 71 | "-o", "Debug::NoLocking=true", 72 | "-o", f"DPkg::Options::=--root={state.root}", 73 | "-o", "DPkg::Options::=--force-unsafe-io", 74 | "-o", "DPkg::Options::=--force-architecture", 75 | "-o", "DPkg::Options::=--force-depends", 76 | "-o", "DPkg::Use-Pty=false", 77 | "-o", "DPkg::Install::Recursive::Minimum=1000", 78 | "-o", "pkgCacheGen::ForceEssential=,", 79 | ] 80 | 81 | if not state.config.with_docs: 82 | cmdline += [ 83 | "-o", "DPkg::Options::=--path-exclude=/usr/share/doc/*", 84 | "-o", "DPkg::Options::=--path-include=/usr/share/doc/*/copyright", 85 | "-o", "DPkg::Options::=--path-exclude=/usr/share/man/*", 86 | "-o", "DPkg::Options::=--path-exclude=/usr/share/groff/*", 87 | "-o", "DPkg::Options::=--path-exclude=/usr/share/info/*", 88 | ] 89 | 90 | return cmdline 91 | 92 | 93 | def invoke_apt( 94 | state: MkosiState, 95 | command: str, 96 | operation: str, 97 | packages: Sequence[str] = (), 98 | apivfs: bool = True, 99 | ) -> None: 100 | cmd = apivfs_cmd(state.root) if apivfs else [] 101 | bwrap(state, cmd + apt_cmd(state, command) + [operation, *sort_packages(packages)], 102 | network=True, env=state.config.environment) 103 | -------------------------------------------------------------------------------- /action.yaml: -------------------------------------------------------------------------------- 1 | name: setup-mkosi 2 | description: Install mkosi and all its dependencies 3 | 4 | runs: 5 | using: composite 6 | steps: 7 | 8 | - name: Permit unprivileged access to kvm, vhost-vsock and vhost-net devices 9 | shell: bash 10 | run: | 11 | sudo mkdir -p /etc/tmpfiles.d 12 | sudo cp /usr/lib/tmpfiles.d/static-nodes-permissions.conf /etc/tmpfiles.d/ 13 | sudo sed -i '/kvm/s/0660/0666/g' /etc/tmpfiles.d/static-nodes-permissions.conf 14 | sudo sed -i '/vhost/s/0660/0666/g' /etc/tmpfiles.d/static-nodes-permissions.conf 15 | sudo tee /etc/udev/rules.d/99-kvm4all.rules <<- EOF 16 | KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm" 17 | KERNEL=="vhost-vsock", GROUP="kvm", MODE="0666", OPTIONS+="static_node=vhost-vsock" 18 | KERNEL=="vhost-net", GROUP="kvm", MODE="0666", OPTIONS+="static_node=vhost-net" 19 | EOF 20 | sudo udevadm control --reload-rules 21 | sudo modprobe kvm 22 | sudo modprobe vhost_vsock 23 | sudo modprobe vhost_net 24 | [[ -e /dev/kvm ]] && sudo udevadm trigger --name-match=kvm 25 | sudo udevadm trigger --name-match=vhost-vsock 26 | sudo udevadm trigger --name-match=vhost-net 27 | [[ -e /dev/kvm ]] && sudo chmod 666 /dev/kvm 28 | sudo chmod 666 /dev/vhost-vsock 29 | sudo chmod 666 /dev/vhost-net 30 | lsmod 31 | [[ -e /dev/kvm ]] && ls -l /dev/kvm 32 | ls -l /dev/vhost-* 33 | 34 | - name: Dependencies 35 | shell: bash 36 | run: | 37 | # For archlinux-keyring and pacman 38 | sudo add-apt-repository ppa:michel-slm/kernel-utils 39 | sudo apt-get update 40 | sudo apt-get install --assume-yes --no-install-recommends \ 41 | archlinux-keyring \ 42 | btrfs-progs \ 43 | bubblewrap \ 44 | debian-archive-keyring \ 45 | dnf \ 46 | e2fsprogs \ 47 | erofs-utils \ 48 | mtools \ 49 | ovmf \ 50 | pacman-package-manager \ 51 | python3-pefile \ 52 | python3-pyelftools \ 53 | qemu-system-x86 \ 54 | squashfs-tools \ 55 | swtpm \ 56 | systemd-container \ 57 | xfsprogs \ 58 | zypper 59 | 60 | sudo pacman-key --init 61 | sudo pacman-key --populate archlinux 62 | 63 | - name: Update systemd 64 | shell: bash 65 | working-directory: ${{ github.action_path }} 66 | run: | 67 | echo "deb-src http://archive.ubuntu.com/ubuntu/ $(lsb_release -cs) main restricted universe multiverse" | sudo tee -a /etc/apt/sources.list 68 | sudo apt-get update 69 | sudo apt-get build-dep systemd 70 | sudo apt-get install --assume-yes --no-install-recommends libfdisk-dev libtss2-dev 71 | 72 | git clone https://github.com/systemd/systemd-stable --single-branch --branch=v255-stable --depth=1 systemd 73 | meson setup systemd/build systemd \ 74 | -D repart=true \ 75 | -D efi=true \ 76 | -D bootloader=true \ 77 | -D ukify=true \ 78 | -D firstboot=true \ 79 | -D blkid=true \ 80 | -D openssl=true \ 81 | -D tpm2=true 82 | 83 | BINARIES=( 84 | bootctl 85 | kernel-install 86 | systemctl 87 | systemd-dissect 88 | systemd-firstboot 89 | systemd-measure 90 | systemd-nspawn 91 | systemd-repart 92 | udevadm 93 | ukify 94 | ) 95 | 96 | ninja -C systemd/build ${BINARIES[@]} 97 | 98 | for BINARY in "${BINARIES[@]}"; do 99 | sudo ln -svf $PWD/systemd/build/$BINARY /usr/bin/$BINARY 100 | $BINARY --version 101 | done 102 | 103 | # Make sure we have mkfs.xfs that can handle spaces in protofiles. 104 | # TODO: Drop when we move to the next Ubuntu LTS. 105 | - name: Update xfsprogs 106 | shell: bash 107 | working-directory: ${{ github.action_path }} 108 | run: | 109 | sudo apt-get install --assume-yes --no-install-recommends \ 110 | make \ 111 | gcc \ 112 | autoconf \ 113 | automake \ 114 | libtool \ 115 | libdevmapper-dev \ 116 | libblkid-dev \ 117 | libicu-dev \ 118 | libedit-dev \ 119 | libinih-dev \ 120 | liburcu-dev \ 121 | uuid-dev 122 | 123 | git clone --single-branch --branch v6.4.0 https://git.kernel.org/pub/scm/fs/xfs/xfsprogs-dev.git 124 | cd xfsprogs-dev 125 | make -j $(nproc) 126 | sudo make install 127 | 128 | - name: Install 129 | shell: bash 130 | run: sudo ln -svf ${{ github.action_path }}/bin/mkosi /usr/bin/mkosi 131 | -------------------------------------------------------------------------------- /mkosi/tree.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1+ 2 | 3 | import errno 4 | import shutil 5 | import subprocess 6 | from pathlib import Path 7 | 8 | from mkosi.config import ConfigFeature 9 | from mkosi.log import die 10 | from mkosi.run import run 11 | from mkosi.types import PathString 12 | from mkosi.versioncomp import GenericVersion 13 | 14 | 15 | def statfs(path: Path) -> str: 16 | return run(["stat", "--file-system", "--format", "%T", path], stdout=subprocess.PIPE).stdout.strip() 17 | 18 | 19 | def is_subvolume(path: Path) -> bool: 20 | return path.is_dir() and statfs(path) == "btrfs" and path.stat().st_ino == 256 21 | 22 | 23 | def make_tree(path: Path, use_subvolumes: ConfigFeature = ConfigFeature.disabled) -> None: 24 | if use_subvolumes == ConfigFeature.enabled and not shutil.which("btrfs"): 25 | die("Subvolumes requested but the btrfs command was not found") 26 | 27 | if statfs(path.parent) != "btrfs": 28 | if use_subvolumes == ConfigFeature.enabled: 29 | die(f"Subvolumes requested but {path} is not located on a btrfs filesystem") 30 | 31 | path.mkdir() 32 | return 33 | 34 | if use_subvolumes != ConfigFeature.disabled and shutil.which("btrfs") is not None: 35 | result = run(["btrfs", "subvolume", "create", path], 36 | check=use_subvolumes == ConfigFeature.enabled).returncode 37 | else: 38 | result = 1 39 | 40 | if result != 0: 41 | path.mkdir() 42 | 43 | 44 | def cp_version() -> GenericVersion: 45 | return GenericVersion(run(["cp", "--version"], stdout=subprocess.PIPE).stdout.strip().splitlines()[0].split()[3]) 46 | 47 | 48 | def copy_tree( 49 | src: Path, 50 | dst: Path, 51 | *, 52 | preserve_owner: bool = True, 53 | clobber: bool = True, 54 | dereference: bool = False, 55 | use_subvolumes: ConfigFeature = ConfigFeature.disabled, 56 | ) -> None: 57 | subvolume = (use_subvolumes == ConfigFeature.enabled or 58 | use_subvolumes == ConfigFeature.auto and shutil.which("btrfs") is not None) 59 | 60 | if use_subvolumes == ConfigFeature.enabled and not shutil.which("btrfs"): 61 | die("Subvolumes requested but the btrfs command was not found") 62 | 63 | copy: list[PathString] = [ 64 | "cp", 65 | "--recursive", 66 | "--dereference" if dereference else "--no-dereference", 67 | f"--preserve=mode,timestamps,links,xattr{',ownership' if preserve_owner else ''}", 68 | "--reflink=auto", 69 | src, dst, 70 | ] 71 | 72 | # --no-clobber will make cp fail if a file already exists since coreutils v9.2. In coreutils v9.3, --update=none 73 | # was introduced to support the previous behavior of --no-clobber again. On coreutils v9.2, --no-clobber will fail 74 | # and --update=none is not available so in that case we're out of luck. There don't seem to be any distros 75 | # packaging coreutils v9.2 though so let's hope we don't trigger this edge case. 76 | if not clobber: 77 | copy += ["--update=none"] if cp_version() >= "9.3" else ["--no-clobber"] 78 | 79 | # If the source and destination are both directories, we want to merge the source directory with the 80 | # destination directory. If the source if a file and the destination is a directory, we want to copy 81 | # the source inside the directory. 82 | if src.is_dir(): 83 | copy += ["--no-target-directory"] 84 | 85 | # Subvolumes always have inode 256 so we can use that to check if a directory is a subvolume. 86 | if not subvolume or not preserve_owner or not is_subvolume(src) or (dst.exists() and any(dst.iterdir())): 87 | run(copy) 88 | return 89 | 90 | # btrfs can't snapshot to an existing directory so make sure the destination does not exist. 91 | if dst.exists(): 92 | dst.rmdir() 93 | 94 | if shutil.which("btrfs"): 95 | result = run(["btrfs", "subvolume", "snapshot", src, dst], 96 | check=use_subvolumes == ConfigFeature.enabled).returncode 97 | else: 98 | result = 1 99 | 100 | if result != 0: 101 | run(copy) 102 | 103 | 104 | def rmtree(*paths: Path) -> None: 105 | run(["rm", "-rf", "--", *paths]) 106 | 107 | 108 | def move_tree(src: Path, dst: Path, use_subvolumes: ConfigFeature = ConfigFeature.disabled) -> None: 109 | if src == dst: 110 | return 111 | 112 | if dst.is_dir(): 113 | dst = dst / src.name 114 | 115 | try: 116 | src.rename(dst) 117 | except OSError as e: 118 | if e.errno != errno.EXDEV: 119 | raise e 120 | 121 | copy_tree(src, dst, use_subvolumes=use_subvolumes) 122 | rmtree(src) 123 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mkosi — Build Bespoke OS Images 2 | 3 | A fancy wrapper around `dnf --installroot`, `apt`, `pacman` 4 | and `zypper` that generates customized disk images with a number of 5 | bells and whistles. 6 | 7 | For a longer description and available features and options, see the 8 | [man page](mkosi/resources/mkosi.md). 9 | 10 | 11 | Packaging status 12 | 13 | 14 | # Installation 15 | 16 | You can install mkosi from your distribution using its package manager 17 | or install the development version from git. If you install mkosi using 18 | your distribution's package manager, make sure it installs at least 19 | mkosi v16 or newer (Use `mkosi --version` to check). If your 20 | distribution only packages an older version of mkosi, it is recommended 21 | to install mkosi using one of the alternative installation methods 22 | listed below instead. 23 | 24 | ## Running mkosi from the repository 25 | 26 | To run mkosi straight from its git repository, you can invoke the shim 27 | `bin/mkosi`. The `MKOSI_INTERPRETER` environment variable can be set 28 | when using the `bin/mkosi` shim to configure the python interpreter used 29 | to execute mkosi. The shim can be symlinked to e.g. `/usr/local/bin` to 30 | make it accessible from the `PATH`. 31 | 32 | ```shell 33 | git clone https://github.com/systemd/mkosi 34 | ln -s $PWD/mkosi/bin/mkosi /usr/local/bin/mkosi 35 | mkosi --version 36 | ``` 37 | 38 | ## Python installation methods 39 | 40 | mkosi can also be installed straight from the git repository url using 41 | `pipx`: 42 | 43 | ```shell 44 | pipx install git+https://github.com/systemd/mkosi.git 45 | mkosi --version 46 | ``` 47 | 48 | which will transparently install mkosi into a Python virtual environment 49 | and a mkosi binary to `~/.local/bin`. This is, up to the path of the 50 | virtual environment and the mkosi binary, equivalent to 51 | 52 | ```shell 53 | python3 -m venv mkosivenv 54 | mkosivenv/bin/pip install git+https://github.com/systemd/mkosi.git 55 | mkosivenv/bin/mkosi --version 56 | ``` 57 | 58 | You can also package mkosi as a 59 | [zipapp](https://docs.python.org/3/library/zipapp.html) that you can 60 | deploy anywhere in your `PATH`. Running this will leave a `mkosi` binary 61 | in `builddir/` 62 | 63 | ```shell 64 | git clone https://github.com/systemd/mkosi 65 | cd mkosi 66 | tools/generate-zipapp.sh 67 | builddir/mkosi --version 68 | ``` 69 | 70 | Besides the mkosi binary, you can also call mkosi via 71 | 72 | ```shell 73 | python3 -m mkosi 74 | ``` 75 | 76 | when not installed as a zipapp. 77 | 78 | Please note, that the python module exists solely for the usage of the 79 | mkosi binary and is not to be considered a public API. 80 | 81 | ## kernel-install plugin 82 | 83 | mkosi can also be used as a kernel-install plugin to build initrds. To 84 | enable this feature, install `kernel-install/50-mkosi-initrd.install` 85 | into `/usr/lib/kernel/install.d` and install `mkosi-initrd/mkosi.conf` 86 | into `/usr/lib/mkosi-initrd`. Extra distro configuration for the initrd 87 | can be configured using drop-ins in `/usr/lib/mkosi-initrd`. Users can 88 | add their custom configuration in `/etc/mkosi-initrd`. 89 | 90 | Once installed, the mkosi plugin can be enabled by writing 91 | `initrd_generator=mkosi-initrd` to `/usr/lib/kernel/install.conf` or to 92 | `/etc/kernel/install.conf`. 93 | 94 | # Hacking on mkosi 95 | 96 | To hack on mkosi itself you will also need 97 | [mypy](https://github.com/python/mypy), for type checking, and 98 | [pytest](https://github.com/pytest-dev/pytest), to run tests. We check 99 | tests and typing in CI (see `.github/workflows`), but you can run the 100 | tests locally as well. 101 | 102 | # References 103 | 104 | * [Primary mkosi git repository on GitHub](https://github.com/systemd/mkosi/) 105 | * [mkosi — A Tool for Generating OS Images](http://0pointer.net/blog/mkosi-a-tool-for-generating-os-images.html) introductory blog post by Lennart Poettering (2017) 106 | * [The mkosi OS generation tool](https://lwn.net/Articles/726655/) story on LWN (2017) 107 | * [systemd-repart: Building Discoverable Disk Images](https://media.ccc.de/v/all-systems-go-2023-191-systemd-repart-building-discoverable-disk-images) and [mkosi: Building Bespoke Operating System Images](https://media.ccc.de/v/all-systems-go-2023-190-mkosi-building-bespoke-operating-system-images) talks at All Systems Go! 2023 108 | * [Building RHEL and RHEL UBI images with mkosi](https://fedoramagazine.org/create-images-directly-from-rhel-and-rhel-ubi-package-using-mkosi/) an article in Fedora Magazine (2023) 109 | 110 | ## Community 111 | 112 | Find us on Matrix at [#mkosi:matrix.org](https://matrix.to/#/#mkosi:matrix.org). 113 | -------------------------------------------------------------------------------- /mkosi/installer/dnf.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1+ 2 | import shutil 3 | import textwrap 4 | from collections.abc import Iterable 5 | 6 | from mkosi.bubblewrap import apivfs_cmd, bwrap 7 | from mkosi.installer.rpm import RpmRepository, fixup_rpmdb_location, setup_rpm 8 | from mkosi.state import MkosiState 9 | from mkosi.types import PathString 10 | from mkosi.util import sort_packages 11 | 12 | 13 | def dnf_executable(state: MkosiState) -> str: 14 | # Allow the user to override autodetection with an environment variable 15 | dnf = state.config.environment.get("MKOSI_DNF") 16 | 17 | return dnf or shutil.which("dnf5") or shutil.which("dnf") or "yum" 18 | 19 | 20 | def setup_dnf(state: MkosiState, repositories: Iterable[RpmRepository], filelists: bool = True) -> None: 21 | (state.pkgmngr / "etc/dnf/vars").mkdir(exist_ok=True, parents=True) 22 | (state.pkgmngr / "etc/yum.repos.d").mkdir(exist_ok=True, parents=True) 23 | 24 | config = state.pkgmngr / "etc/dnf/dnf.conf" 25 | 26 | if not config.exists(): 27 | config.parent.mkdir(exist_ok=True, parents=True) 28 | with config.open("w") as f: 29 | # Make sure we download filelists so all dependencies can be resolved. 30 | # See https://bugzilla.redhat.com/show_bug.cgi?id=2180842 31 | if dnf_executable(state).endswith("dnf5") and filelists: 32 | f.write("[main]\noptional_metadata_types=filelists\n") 33 | 34 | repofile = state.pkgmngr / "etc/yum.repos.d/mkosi.repo" 35 | if not repofile.exists(): 36 | repofile.parent.mkdir(exist_ok=True, parents=True) 37 | with repofile.open("w") as f: 38 | for repo in repositories: 39 | f.write( 40 | textwrap.dedent( 41 | f"""\ 42 | [{repo.id}] 43 | name={repo.id} 44 | {repo.url} 45 | gpgcheck=1 46 | enabled={int(repo.enabled)} 47 | """ 48 | ) 49 | ) 50 | 51 | if repo.sslcacert: 52 | f.write(f"sslcacert={repo.sslcacert}\n") 53 | if repo.sslclientcert: 54 | f.write(f"sslclientcert={repo.sslclientcert}\n") 55 | if repo.sslclientkey: 56 | f.write(f"sslclientkey={repo.sslclientkey}\n") 57 | 58 | for i, url in enumerate(repo.gpgurls): 59 | f.write("gpgkey=" if i == 0 else len("gpgkey=") * " ") 60 | f.write(f"{url}\n") 61 | 62 | f.write("\n") 63 | 64 | setup_rpm(state) 65 | 66 | 67 | def dnf_cmd(state: MkosiState) -> list[PathString]: 68 | dnf = dnf_executable(state) 69 | 70 | cmdline: list[PathString] = [ 71 | "env", 72 | "HOME=/", # Make sure rpm doesn't pick up ~/.rpmmacros and ~/.rpmrc. 73 | dnf, 74 | "--assumeyes", 75 | "--best", 76 | f"--releasever={state.config.release}", 77 | f"--installroot={state.root}", 78 | "--setopt=keepcache=1", 79 | f"--setopt=cachedir={state.cache_dir / 'cache' / ('libdnf5' if dnf.endswith('dnf5') else 'dnf')}", 80 | f"--setopt=persistdir={state.cache_dir / 'lib' / ('libdnf5' if dnf.endswith('dnf5') else 'dnf')}", 81 | f"--setopt=install_weak_deps={int(state.config.with_recommends)}", 82 | "--setopt=check_config_file_age=0", 83 | "--disable-plugin=*" if dnf.endswith("dnf5") else "--disableplugin=*", 84 | "--enable-plugin=builddep" if dnf.endswith("dnf5") else "--enableplugin=builddep", 85 | ] 86 | 87 | if not state.config.repository_key_check: 88 | cmdline += ["--nogpgcheck"] 89 | 90 | if state.config.repositories: 91 | opt = "--enable-repo" if dnf.endswith("dnf5") else "--enablerepo" 92 | cmdline += [f"{opt}={repo}" for repo in state.config.repositories] 93 | 94 | # TODO: this breaks with a local, offline repository created with 'createrepo' 95 | if state.config.cache_only and not state.config.local_mirror: 96 | cmdline += ["--cacheonly"] 97 | 98 | if not state.config.architecture.is_native(): 99 | cmdline += [f"--forcearch={state.config.distribution.architecture(state.config.architecture)}"] 100 | 101 | if not state.config.with_docs: 102 | cmdline += ["--no-docs" if dnf.endswith("dnf5") else "--nodocs"] 103 | 104 | if dnf.endswith("dnf5"): 105 | cmdline += ["--use-host-config"] 106 | else: 107 | cmdline += [ 108 | "--config=/etc/dnf/dnf.conf", 109 | "--setopt=reposdir=/etc/yum.repos.d", 110 | "--setopt=varsdir=/etc/dnf/vars", 111 | ] 112 | 113 | return cmdline 114 | 115 | 116 | def invoke_dnf(state: MkosiState, command: str, packages: Iterable[str], apivfs: bool = True) -> None: 117 | cmd = apivfs_cmd(state.root) if apivfs else [] 118 | bwrap(state, cmd + dnf_cmd(state) + [command, *sort_packages(packages)], 119 | network=True, env=state.config.environment) 120 | 121 | fixup_rpmdb_location(state.root) 122 | 123 | # The log directory is always interpreted relative to the install root so there's nothing we can do but 124 | # to remove the log files from the install root afterwards. 125 | for p in (state.root / "var/log").iterdir(): 126 | if any(p.name.startswith(prefix) for prefix in ("dnf", "hawkey", "yum")): 127 | p.unlink() 128 | -------------------------------------------------------------------------------- /mkosi/distributions/opensuse.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1+ 2 | 3 | import shutil 4 | import tempfile 5 | import xml.etree.ElementTree as ElementTree 6 | from collections.abc import Sequence 7 | from pathlib import Path 8 | 9 | from mkosi.config import Architecture 10 | from mkosi.distributions import Distribution, DistributionInstaller, PackageType 11 | from mkosi.installer.dnf import invoke_dnf, setup_dnf 12 | from mkosi.installer.rpm import RpmRepository 13 | from mkosi.installer.zypper import invoke_zypper, setup_zypper 14 | from mkosi.log import die 15 | from mkosi.run import run 16 | from mkosi.state import MkosiState 17 | 18 | 19 | class Installer(DistributionInstaller): 20 | @classmethod 21 | def pretty_name(cls) -> str: 22 | return "openSUSE" 23 | 24 | @classmethod 25 | def filesystem(cls) -> str: 26 | return "btrfs" 27 | 28 | @classmethod 29 | def package_type(cls) -> PackageType: 30 | return PackageType.rpm 31 | 32 | @classmethod 33 | def default_release(cls) -> str: 34 | return "tumbleweed" 35 | 36 | @classmethod 37 | def default_tools_tree_distribution(cls) -> Distribution: 38 | return Distribution.opensuse 39 | 40 | @classmethod 41 | def setup(cls, state: MkosiState) -> None: 42 | release = state.config.release 43 | if release == "leap": 44 | release = "stable" 45 | 46 | mirror = state.config.mirror or "https://download.opensuse.org" 47 | 48 | # If the release looks like a timestamp, it's Tumbleweed. 13.x is legacy 49 | # (14.x won't ever appear). For anything else, let's default to Leap. 50 | if state.config.local_mirror: 51 | release_url = f"{state.config.local_mirror}" 52 | updates_url = None 53 | if release.isdigit() or release == "tumbleweed": 54 | release_url = f"{mirror}/tumbleweed/repo/oss/" 55 | updates_url = f"{mirror}/update/tumbleweed/" 56 | elif release in ("current", "stable"): 57 | release_url = f"{mirror}/distribution/openSUSE-{release}/repo/oss/" 58 | updates_url = f"{mirror}/update/openSUSE-{release}/" 59 | else: 60 | release_url = f"{mirror}/distribution/leap/{release}/repo/oss/" 61 | updates_url = f"{mirror}/update/leap/{release}/oss/" 62 | 63 | zypper = shutil.which("zypper") 64 | 65 | # If we need to use a local mirror, create a temporary repository definition 66 | # that doesn't get in the image, as it is valid only at image build time. 67 | if state.config.local_mirror: 68 | repos = [RpmRepository("local-mirror", f"baseurl={state.config.local_mirror}", ())] 69 | else: 70 | repos = [ 71 | RpmRepository("repo-oss", f"baseurl={release_url}", fetch_gpgurls(release_url) if not zypper else ()), 72 | ] 73 | if updates_url is not None: 74 | repos += [ 75 | RpmRepository( 76 | "repo-update", 77 | f"baseurl={updates_url}", 78 | fetch_gpgurls(updates_url) if not zypper else (), 79 | ) 80 | ] 81 | 82 | if zypper: 83 | setup_zypper(state, repos) 84 | else: 85 | setup_dnf(state, repos) 86 | 87 | @classmethod 88 | def install(cls, state: MkosiState) -> None: 89 | cls.install_packages(state, ["filesystem", "distribution-release"], apivfs=False) 90 | 91 | @classmethod 92 | def install_packages(cls, state: MkosiState, packages: Sequence[str], apivfs: bool = True) -> None: 93 | if shutil.which("zypper"): 94 | options = [ 95 | "--download", "in-advance", 96 | "--recommends" if state.config.with_recommends else "--no-recommends", 97 | ] 98 | invoke_zypper(state, "install", packages, options, apivfs=apivfs) 99 | else: 100 | invoke_dnf(state, "install", packages, apivfs=apivfs) 101 | 102 | @classmethod 103 | def remove_packages(cls, state: MkosiState, packages: Sequence[str]) -> None: 104 | if shutil.which("zypper"): 105 | invoke_zypper(state, "remove", packages, ["--clean-deps"]) 106 | else: 107 | invoke_dnf(state, "remove", packages) 108 | 109 | @classmethod 110 | def architecture(cls, arch: Architecture) -> str: 111 | a = { 112 | Architecture.x86_64 : "x86_64", 113 | }.get(arch) 114 | 115 | if not a: 116 | die(f"Architecture {a} is not supported by OpenSUSE") 117 | 118 | return a 119 | 120 | 121 | def fetch_gpgurls(repourl: str) -> tuple[str, ...]: 122 | gpgurls = [f"{repourl}/repodata/repomd.xml.key"] 123 | 124 | with tempfile.TemporaryDirectory() as d: 125 | run([ 126 | "curl", 127 | "--location", 128 | "--output-dir", d, 129 | "--remote-name", 130 | "--no-progress-meter", 131 | "--fail", 132 | f"{repourl}/repodata/repomd.xml", 133 | ]) 134 | xml = (Path(d) / "repomd.xml").read_text() 135 | 136 | root = ElementTree.fromstring(xml) 137 | 138 | tags = root.find("{http://linux.duke.edu/metadata/repo}tags") 139 | if not tags: 140 | die("repomd.xml missing element") 141 | 142 | for child in tags.iter("{http://linux.duke.edu/metadata/repo}content"): 143 | if child.text and child.text.startswith("gpg-pubkey"): 144 | gpgkey = child.text.partition("?")[0] 145 | gpgurls += [f"{repourl}{gpgkey}"] 146 | 147 | return tuple(gpgurls) 148 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1+ 2 | 3 | import os 4 | import subprocess 5 | import sys 6 | import tempfile 7 | from collections.abc import Iterator, Sequence 8 | from types import TracebackType 9 | from typing import Any, NamedTuple, Optional 10 | 11 | import pytest 12 | 13 | from mkosi.distributions import Distribution 14 | from mkosi.run import run 15 | from mkosi.types import _FILE, CompletedProcess, PathString 16 | from mkosi.util import INVOKING_USER 17 | 18 | 19 | class Image: 20 | class Config(NamedTuple): 21 | distribution: Distribution 22 | release: str 23 | tools_tree_distribution: Optional[Distribution] 24 | 25 | def __init__(self, config: Config, options: Sequence[PathString] = []) -> None: 26 | self.options = options 27 | self.config = config 28 | 29 | def __enter__(self) -> "Image": 30 | self.output_dir = tempfile.TemporaryDirectory(dir="/var/tmp") 31 | os.chown(self.output_dir.name, INVOKING_USER.uid, INVOKING_USER.gid) 32 | 33 | return self 34 | 35 | def __exit__( 36 | self, 37 | type: Optional[type[BaseException]], 38 | value: Optional[BaseException], 39 | traceback: Optional[TracebackType], 40 | ) -> None: 41 | self.mkosi("clean", user=INVOKING_USER.uid, group=INVOKING_USER.gid) 42 | 43 | def mkosi( 44 | self, 45 | verb: str, 46 | options: Sequence[PathString] = (), 47 | args: Sequence[str] = (), 48 | stdin: _FILE = None, 49 | user: Optional[int] = None, 50 | group: Optional[int] = None, 51 | check: bool = True, 52 | ) -> CompletedProcess: 53 | kcl = [ 54 | "console=ttyS0", 55 | "systemd.crash_shell", 56 | "systemd.log_level=debug", 57 | "udev.log_level=info", 58 | "systemd.log_ratelimit_kmsg=0", 59 | "systemd.journald.forward_to_console", 60 | "systemd.journald.max_level_console=warning", 61 | "printk.devkmsg=on", 62 | "systemd.early_core_pattern=/core", 63 | ] 64 | 65 | return run([ 66 | "python3", "-m", "mkosi", 67 | "--distribution", str(self.config.distribution), 68 | "--release", self.config.release, 69 | *(["--tools-tree=default"] if self.config.tools_tree_distribution else []), 70 | *( 71 | ["--tools-tree-distribution", str(self.config.tools_tree_distribution)] 72 | if self.config.tools_tree_distribution 73 | else [] 74 | ), 75 | *self.options, 76 | *options, 77 | "--output-dir", self.output_dir.name, 78 | "--cache-dir", "mkosi.cache", 79 | *(f"--kernel-command-line={i}" for i in kcl), 80 | "--qemu-vsock=yes", 81 | "--qemu-mem=4G", 82 | verb, 83 | *args, 84 | ], check=check, stdin=stdin, stdout=sys.stdout, user=user, group=group) 85 | 86 | def build(self, options: Sequence[str] = (), args: Sequence[str] = ()) -> CompletedProcess: 87 | return self.mkosi( 88 | "build", 89 | [*options, "--debug", "--force"], 90 | args, 91 | stdin=sys.stdin if sys.stdin.isatty() else None, 92 | user=INVOKING_USER.uid, 93 | group=INVOKING_USER.gid, 94 | ) 95 | 96 | def boot(self, options: Sequence[str] = (), args: Sequence[str] = ()) -> CompletedProcess: 97 | result = self.mkosi( 98 | "boot", 99 | [*options, "--debug"], 100 | args, stdin=sys.stdin if sys.stdin.isatty() else None, 101 | check=False, 102 | ) 103 | 104 | if result.returncode != 123: 105 | raise subprocess.CalledProcessError(result.returncode, result.args, result.stdout, result.stderr) 106 | 107 | return result 108 | 109 | def qemu(self, options: Sequence[str] = (), args: Sequence[str] = ()) -> CompletedProcess: 110 | result = self.mkosi( 111 | "qemu", 112 | [*options, "--debug"], 113 | args, 114 | stdin=sys.stdin if sys.stdin.isatty() else None, 115 | user=INVOKING_USER.uid, 116 | group=INVOKING_USER.gid, 117 | check=False, 118 | ) 119 | 120 | if self.config.distribution == Distribution.ubuntu or self.config.distribution.is_centos_variant(): 121 | rc = 0 122 | else: 123 | rc = 123 124 | 125 | if result.returncode != rc: 126 | raise subprocess.CalledProcessError(result.returncode, result.args, result.stdout, result.stderr) 127 | 128 | return result 129 | 130 | def summary(self, options: Sequence[str] = ()) -> CompletedProcess: 131 | return self.mkosi("summary", options, user=INVOKING_USER.uid, group=INVOKING_USER.gid) 132 | 133 | def genkey(self) -> CompletedProcess: 134 | return self.mkosi("genkey", ["--force"], user=INVOKING_USER.uid, group=INVOKING_USER.gid) 135 | 136 | 137 | @pytest.fixture(scope="session", autouse=True) 138 | def suspend_capture_stdin(pytestconfig: Any) -> Iterator[None]: 139 | """ 140 | When --capture=no (or -s) is specified, pytest will still intercept stdin. Let's explicitly make it not capture 141 | stdin when --capture=no is specified so we can debug image boot failures by logging into the emergency shell. 142 | """ 143 | 144 | capmanager: Any = pytestconfig.pluginmanager.getplugin("capturemanager") 145 | 146 | if pytestconfig.getoption("capture") == "no": 147 | capmanager.suspend_global_capture(in_=True) 148 | 149 | yield 150 | 151 | if pytestconfig.getoption("capture") == "no": 152 | capmanager.resume_global_capture() 153 | -------------------------------------------------------------------------------- /kernel-install/50-mkosi.install: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # SPDX-License-Identifier: LGPL-2.1+ 3 | 4 | import argparse 5 | import logging 6 | import os 7 | import shutil 8 | import tempfile 9 | from pathlib import Path 10 | from typing import NamedTuple, Optional 11 | 12 | from mkosi.config import OutputFormat, __version__ 13 | from mkosi.log import die, log_setup 14 | from mkosi.run import run, uncaught_exception_handler 15 | from mkosi.tree import copy_tree 16 | from mkosi.types import PathString 17 | 18 | 19 | class Context(NamedTuple): 20 | command: str 21 | kernel_version: str 22 | entry_dir: Path 23 | kernel_image: Path 24 | initrds: list[Path] 25 | staging_area: Path 26 | layout: str 27 | image_type: str 28 | initrd_generator: Optional[str] 29 | uki_generator: Optional[str] 30 | verbose: bool 31 | 32 | 33 | def we_are_wanted(context: Context) -> bool: 34 | return context.uki_generator == "mkosi" or context.initrd_generator in ("mkosi", "mkosi-initrd") 35 | 36 | 37 | def mandatory_variable(name: str) -> str: 38 | try: 39 | return os.environ[name] 40 | except KeyError: 41 | die(f"${name} must be set in the environment") 42 | 43 | 44 | @uncaught_exception_handler() 45 | def main() -> None: 46 | log_setup() 47 | 48 | parser = argparse.ArgumentParser( 49 | description='kernel-install plugin to build initrds or Unified Kernel Images using mkosi', 50 | allow_abbrev=False, 51 | usage='50-mkosi.install COMMAND KERNEL_VERSION ENTRY_DIR KERNEL_IMAGE INITRD…', 52 | ) 53 | 54 | parser.add_argument("command", 55 | metavar="COMMAND", 56 | help="The action to perform. Only 'add' is supported.") 57 | parser.add_argument("kernel_version", 58 | metavar="KERNEL_VERSION", 59 | help="Kernel version string") 60 | parser.add_argument("entry_dir", 61 | metavar="ENTRY_DIR", 62 | type=Path, 63 | help="Type#1 entry directory (ignored)") 64 | parser.add_argument("kernel_image", 65 | metavar="KERNEL_IMAGE", 66 | type=Path, 67 | help="Kernel image") 68 | parser.add_argument("initrds", 69 | metavar="INITRD…", 70 | type=Path, 71 | nargs="*", 72 | help="Initrd files") 73 | parser.add_argument("--version", 74 | action="version", 75 | version=f"mkosi {__version__}") 76 | 77 | context = Context( 78 | **vars(parser.parse_args()), 79 | staging_area=Path(mandatory_variable("KERNEL_INSTALL_STAGING_AREA")), 80 | layout=mandatory_variable("KERNEL_INSTALL_LAYOUT"), 81 | image_type=mandatory_variable("KERNEL_INSTALL_IMAGE_TYPE"), 82 | initrd_generator=os.getenv("KERNEL_INSTALL_INITRD_GENERATOR"), 83 | uki_generator=os.getenv("KERNEL_INSTALL_UKI_GENERATOR"), 84 | verbose=int(os.getenv("KERNEL_INSTALL_VERBOSE", 0)) > 0, 85 | ) 86 | 87 | if context.command != "add" or not we_are_wanted(context): 88 | return 89 | 90 | # If kernel-install was passed a UKI, there's no need to build anything ourselves. 91 | if context.image_type == "uki": 92 | return 93 | 94 | # If the initrd was provided on the kernel command line, we shouldn't generate our own. 95 | if context.layout != "uki" and context.initrds: 96 | return 97 | 98 | format = OutputFormat.uki if context.layout == "uki" else OutputFormat.cpio 99 | output = "initrd" if format == OutputFormat.cpio else "uki" 100 | 101 | cmdline: list[PathString] = [ 102 | "mkosi", 103 | "--directory", "", 104 | "--format", str(format), 105 | "--output", output, 106 | "--workspace-dir=/var/tmp", 107 | "--cache-dir=/var", 108 | "--output-dir", context.staging_area, 109 | "--extra-tree", f"/usr/lib/modules/{context.kernel_version}:/usr/lib/modules/{context.kernel_version}", 110 | "--extra-tree=/usr/lib/firmware:/usr/lib/firmware", 111 | "--kernel-modules-exclude=.*", 112 | "--kernel-modules-include-host=yes", 113 | ] 114 | 115 | if context.verbose: 116 | cmdline += ["--debug"] 117 | 118 | for d in ("/usr/lib/mkosi-initrd", "/etc/mkosi-initrd"): 119 | if Path(d).exists(): 120 | cmdline += ["--include", d] 121 | 122 | with tempfile.TemporaryDirectory() as d: 123 | # Make sure we don't use any of mkosi's default repositories. 124 | for p in ( 125 | "yum.repos.d/mkosi.repo", 126 | "apt/sources.list", 127 | "zypp/repos.d/mkosi.repo", 128 | "pacman.conf", 129 | ): 130 | (Path(d) / "etc" / p).parent.mkdir(parents=True, exist_ok=True) 131 | (Path(d) / "etc" / p).touch() 132 | 133 | # Copy in the host's package manager configuration. 134 | for p in ( 135 | "dnf", 136 | "yum.repos.d/", 137 | "apt", 138 | "zypp", 139 | "pacman.conf", 140 | "pacman.d/", 141 | ): 142 | if not (Path("/etc") / p).exists(): 143 | continue 144 | 145 | (Path(d) / "etc" / p).parent.mkdir(parents=True, exist_ok=True) 146 | copy_tree(Path("/etc") / p, Path(d) / "etc" / p, dereference=True) 147 | 148 | cmdline += ["--package-manager-tree", d] 149 | 150 | logging.info(f"Building {output}") 151 | 152 | run(cmdline) 153 | 154 | (context.staging_area / output).unlink() 155 | 156 | if format == OutputFormat.cpio: 157 | shutil.move(next(context.staging_area.glob("initrd*.cpio*")), context.staging_area / "initrd") 158 | else: 159 | (context.staging_area / f"{output}.vmlinuz").unlink() 160 | (context.staging_area / f"{output}.initrd").unlink() 161 | 162 | 163 | if __name__ == '__main__': 164 | main() 165 | -------------------------------------------------------------------------------- /mkosi/util.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1+ 2 | 3 | import ast 4 | import contextlib 5 | import copy 6 | import enum 7 | import fcntl 8 | import functools 9 | import importlib 10 | import importlib.resources 11 | import itertools 12 | import logging 13 | import os 14 | import pwd 15 | import re 16 | import resource 17 | import stat 18 | import tempfile 19 | from collections.abc import Iterable, Iterator, Mapping, Sequence 20 | from pathlib import Path 21 | from types import ModuleType 22 | from typing import Any, Callable, TypeVar 23 | 24 | from mkosi.types import PathString 25 | 26 | T = TypeVar("T") 27 | V = TypeVar("V") 28 | 29 | 30 | def dictify(f: Callable[..., Iterator[tuple[T, V]]]) -> Callable[..., dict[T, V]]: 31 | def wrapper(*args: Any, **kwargs: Any) -> dict[T, V]: 32 | return dict(f(*args, **kwargs)) 33 | 34 | return functools.update_wrapper(wrapper, f) 35 | 36 | 37 | @dictify 38 | def read_env_file(path: Path) -> Iterator[tuple[str, str]]: 39 | with path.open() as f: 40 | for line_number, line in enumerate(f, start=1): 41 | line = line.rstrip() 42 | if not line or line.startswith("#"): 43 | continue 44 | if (m := re.match(r"([A-Z][A-Z_0-9]+)=(.*)", line)): 45 | name, val = m.groups() 46 | if val and val[0] in "\"'": 47 | val = ast.literal_eval(val) 48 | yield name, val 49 | else: 50 | logging.info(f"{path}:{line_number}: bad line {line!r}") 51 | 52 | 53 | def read_os_release(root: Path = Path("/")) -> dict[str, str]: 54 | filename = root / "etc/os-release" 55 | if not filename.exists(): 56 | filename = root / "usr/lib/os-release" 57 | 58 | return read_env_file(filename) 59 | 60 | 61 | def format_rlimit(rlimit: int) -> str: 62 | limits = resource.getrlimit(rlimit) 63 | soft = "infinity" if limits[0] == resource.RLIM_INFINITY else str(limits[0]) 64 | hard = "infinity" if limits[1] == resource.RLIM_INFINITY else str(limits[1]) 65 | return f"{soft}:{hard}" 66 | 67 | 68 | def sort_packages(packages: Iterable[str]) -> list[str]: 69 | """Sorts packages: normal first, paths second, conditional third""" 70 | 71 | m = {"(": 2, "/": 1} 72 | return sorted(packages, key=lambda name: (m.get(name[0], 0), name)) 73 | 74 | 75 | def flatten(lists: Iterable[Iterable[T]]) -> list[T]: 76 | """Flatten a sequence of sequences into a single list.""" 77 | return list(itertools.chain.from_iterable(lists)) 78 | 79 | 80 | class INVOKING_USER: 81 | uid = int(os.getenv("SUDO_UID") or os.getenv("PKEXEC_UID") or os.getuid()) 82 | gid = int(os.getenv("SUDO_GID") or os.getgid()) 83 | invoked_as_root = (uid == 0) 84 | 85 | @classmethod 86 | def init(cls) -> None: 87 | name = cls.name() 88 | home = cls.home() 89 | logging.debug(f"Running as user '{name}' ({cls.uid}:{cls.gid}) with home {home}.") 90 | 91 | @classmethod 92 | def is_running_user(cls) -> bool: 93 | return cls.uid == os.getuid() 94 | 95 | @classmethod 96 | @functools.lru_cache(maxsize=1) 97 | def name(cls) -> str: 98 | return pwd.getpwuid(cls.uid).pw_name 99 | 100 | @classmethod 101 | @functools.lru_cache(maxsize=1) 102 | def home(cls) -> Path: 103 | return Path(f"~{cls.name()}").expanduser() 104 | 105 | 106 | @contextlib.contextmanager 107 | def chdir(directory: PathString) -> Iterator[None]: 108 | old = Path.cwd() 109 | 110 | if old == directory: 111 | yield 112 | return 113 | 114 | try: 115 | os.chdir(directory) 116 | yield 117 | finally: 118 | os.chdir(old) 119 | 120 | 121 | def make_executable(path: Path) -> None: 122 | st = path.stat() 123 | os.chmod(path, st.st_mode | stat.S_IEXEC) 124 | 125 | 126 | def try_import(module: str) -> None: 127 | try: 128 | importlib.import_module(module) 129 | except ModuleNotFoundError: 130 | pass 131 | 132 | 133 | @contextlib.contextmanager 134 | def flock(path: Path) -> Iterator[int]: 135 | fd = os.open(path, os.O_CLOEXEC|os.O_RDONLY) 136 | try: 137 | fcntl.fcntl(fd, fcntl.FD_CLOEXEC) 138 | fcntl.flock(fd, fcntl.LOCK_EX) 139 | yield fd 140 | finally: 141 | os.close(fd) 142 | 143 | 144 | @contextlib.contextmanager 145 | def scopedenv(env: Mapping[str, Any]) -> Iterator[None]: 146 | old = copy.deepcopy(os.environ) 147 | os.environ |= env 148 | 149 | # python caches the default temporary directory so when we might modify TMPDIR we have to make sure it 150 | # gets recalculated (see https://docs.python.org/3/library/tempfile.html#tempfile.tempdir). 151 | tempfile.tempdir = None 152 | 153 | try: 154 | yield 155 | finally: 156 | os.environ = old 157 | tempfile.tempdir = None 158 | 159 | 160 | class StrEnum(enum.Enum): 161 | def __str__(self) -> str: 162 | assert isinstance(self.value, str) 163 | return self.value 164 | 165 | # Used by enum.auto() to get the next value. 166 | @staticmethod 167 | def _generate_next_value_(name: str, start: int, count: int, last_values: Sequence[str]) -> str: 168 | return name.replace("_", "-") 169 | 170 | @classmethod 171 | def values(cls) -> list[str]: 172 | return list(map(str, cls)) 173 | 174 | 175 | def one_zero(b: bool) -> str: 176 | return "1" if b else "0" 177 | 178 | 179 | @contextlib.contextmanager 180 | def umask(mask: int) -> Iterator[None]: 181 | old = os.umask(mask) 182 | try: 183 | yield 184 | finally: 185 | os.umask(old) 186 | 187 | 188 | def is_power_of_2(x: int) -> bool: 189 | return x > 0 and (x & x - 1 == 0) 190 | 191 | 192 | @contextlib.contextmanager 193 | def resource_path(mod: ModuleType) -> Iterator[Path]: 194 | t = importlib.resources.files(mod) 195 | with importlib.resources.as_file(t) as p: 196 | yield p 197 | 198 | 199 | def round_up(x: int, blocksize: int = 4096) -> int: 200 | return (x + blocksize - 1) // blocksize * blocksize 201 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | unit-test: 13 | runs-on: ubuntu-22.04 14 | concurrency: 15 | group: ${{ github.workflow }}-${{ github.ref }} 16 | cancel-in-progress: true 17 | steps: 18 | - uses: actions/checkout@v3 19 | 20 | - name: Install 21 | run: | 22 | sudo apt-get update 23 | sudo apt-get install pandoc python3-pytest 24 | python3 -m pip install --upgrade setuptools wheel pip 25 | python3 -m pip install mypy ruff 26 | npm install -g pyright 27 | 28 | - name: Run ruff 29 | run: | 30 | ruff --version 31 | ruff mkosi/ tests/ kernel-install/50-mkosi.install 32 | 33 | - name: Check that tabs are not used in code 34 | run: sh -c '! git grep -P "\\t" "*.py"' 35 | 36 | - name: Type Checking (mypy) 37 | run: | 38 | python3 -m mypy --version 39 | python3 -m mypy mkosi/ tests/ kernel-install/50-mkosi.install 40 | 41 | - name: Type Checking (pyright) 42 | run: | 43 | pyright --version 44 | pyright mkosi/ tests/ kernel-install/50-mkosi.install 45 | 46 | - name: Unit Tests 47 | run: | 48 | python3 -m pytest --version 49 | python3 -m pytest -sv tests/ 50 | 51 | - name: Test execution from current working directory 52 | run: python3 -m mkosi -h 53 | 54 | - name: Test execution from current working directory (sudo call) 55 | run: sudo python3 -m mkosi -h 56 | 57 | - name: Test venv installation 58 | run: | 59 | python3 -m venv testvenv 60 | testvenv/bin/python3 -m pip install --upgrade setuptools wheel pip 61 | testvenv/bin/python3 -m pip install . 62 | testvenv/bin/mkosi -h 63 | rm -rf testvenv 64 | 65 | - name: Test editable venv installation 66 | run: | 67 | python3 -m venv testvenv 68 | testvenv/bin/python3 -m pip install --upgrade setuptools wheel pip 69 | testvenv/bin/python3 -m pip install --editable . 70 | testvenv/bin/mkosi -h 71 | rm -rf testvenv 72 | 73 | - name: Test zipapp creation 74 | run: | 75 | ./tools/generate-zipapp.sh 76 | ./builddir/mkosi -h 77 | 78 | - name: Test shell scripts 79 | run: | 80 | sudo apt-get update && sudo apt-get install --no-install-recommends shellcheck 81 | bash -c 'shopt -s globstar; shellcheck bin/mkosi tools/*.sh' 82 | 83 | - name: Test man page generation 84 | run: pandoc -s mkosi.md -o mkosi.1 85 | 86 | integration-test: 87 | runs-on: ubuntu-22.04 88 | needs: unit-test 89 | concurrency: 90 | group: ${{ github.workflow }}-${{ matrix.distro }}-${{ matrix.tools }}-${{ github.ref }} 91 | cancel-in-progress: true 92 | strategy: 93 | fail-fast: false 94 | matrix: 95 | distro: 96 | - arch 97 | - centos 98 | - debian 99 | - fedora 100 | - opensuse 101 | - ubuntu 102 | tools: 103 | - "" 104 | - arch 105 | - debian 106 | - fedora 107 | - opensuse 108 | # TODO: Add Ubuntu and CentOS once they have systemd v254 or newer. 109 | exclude: 110 | # pacman and archlinux-keyring are not packaged in OpenSUSE. 111 | - distro: arch 112 | tools: opensuse 113 | # apt, debian-keyring and ubuntu-keyring are not packaged in OpenSUSE. 114 | - distro: debian 115 | tools: opensuse 116 | - distro: ubuntu 117 | tools: opensuse 118 | # Debian test_boot[cpio] is super flaky on these combinations until the next v255 stable release with 119 | # https://github.com/systemd/systemd/pull/30511 is available in Debian unstable. 120 | - distro: debian 121 | tools: arch 122 | - distro: debian 123 | tools: fedora 124 | - distro: debian 125 | tools: centos 126 | - distro: debian 127 | tools: debian 128 | # rpm in Debian is currently missing 129 | # https://github.com/rpm-software-management/rpm/commit/ea3187cfcf9cac87e5bc5e7db79b0338da9e355e 130 | - distro: fedora 131 | tools: debian 132 | - distro: centos 133 | tools: debian 134 | # This combination results in rpm failing because of SIGPIPE. 135 | # TODO: Try again once Arch gets a new rpm release. 136 | - distro: centos 137 | tools: arch 138 | 139 | steps: 140 | - uses: actions/checkout@v3 141 | - uses: ./ 142 | 143 | - name: Install 144 | run: | 145 | sudo apt-get update 146 | sudo apt-get install python3-pytest lvm2 cryptsetup-bin 147 | # Make sure the latest changes from the pull request are used. 148 | sudo ln -svf $PWD/bin/mkosi /usr/bin/mkosi 149 | working-directory: ./ 150 | 151 | - name: Configure 152 | run: | 153 | tee mkosi.local.conf <`. 106 | 107 | After installing non-dynamic `Requires` and `BuildRequires` 108 | dependencies, we have to install the dynamic `BuildRequires` by running 109 | `rpmbuild -bd` until it succeeds or fails with an exit code that's not 110 | `11`. After each run of `rpmbuild -bd` that exits with exit code `11`, 111 | there will be an SRPM in the `SRPMS` subdirectory of the upstream 112 | sources directory of which the `BuildRequires` have to be installed for 113 | which we use `dnf builddep`. 114 | 115 | Now we have an image and build overlay with all the necessary 116 | dependencies installed to be able to build the RPM. 117 | 118 | Next is the build script. We suffix the build script with `.chroot` so 119 | that mkosi runs it entirely inside the image. In the build script, we 120 | invoke `rpmbuild -bb --build-in-place` to have `rpmbuild` build the RPM 121 | in place from the upstream sources. Again `_topdir` and `_sourcedir` 122 | have to be configured to the upstream sources and the RPM spec sources 123 | respectively. We also have to override `_rpmdir` to point to the mkosi 124 | output directory (stored in `$OUTPUTDIR`). The build script 125 | `mkosi.build.chroot` then looks as follows: 126 | 127 | ```shell 128 | #!/bin/sh 129 | set -e 130 | 131 | env --chdir=mkosi \ 132 | rpmbuild \ 133 | -bb \ 134 | --build-in-place \ 135 | $([ "$WITH_TESTS" = "0" ] && echo --nocheck) \ 136 | --define "_topdir $PWD" \ 137 | --define "_sourcedir rpm" \ 138 | --define "_rpmdir $OUTPUTDIR" \ 139 | ${BUILDDIR:+--define} \ 140 | ${BUILDDIR:+"_vpath_builddir $BUILDDIR"} \ 141 | --define "_build_name_fmt %%{NAME}-%%{VERSION}-%%{RELEASE}.%%{ARCH}.rpm" \ 142 | rpm/mkosi.spec 143 | ``` 144 | 145 | The `_vpath_builddir` directory will be used to store out-of-tree build 146 | artifacts for build systems that support out-of-tree builds (CMake, 147 | Meson) so we set it to mkosi's out-of-tree build directory in 148 | `$BUILDDIR` if one is provided. This will make subsequent RPM builds 149 | much faster as CMake or Meson will be able to do an incremental build. 150 | 151 | After the build script finishes, the produced rpms will be located in 152 | `$OUTPUTDIR`. We can now install them from the `mkosi.postinst` 153 | post-installation script: 154 | 155 | ```shell 156 | #!/bin/sh 157 | set -e 158 | 159 | rpm --install "$OUTPUTDIR"/*mkosi*.rpm 160 | ``` 161 | -------------------------------------------------------------------------------- /mkosi/distributions/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1+ 2 | 3 | import enum 4 | import importlib 5 | import re 6 | import urllib.parse 7 | from collections.abc import Sequence 8 | from typing import TYPE_CHECKING, Optional, cast 9 | 10 | from mkosi.util import StrEnum, read_os_release 11 | 12 | if TYPE_CHECKING: 13 | from mkosi.config import Architecture 14 | from mkosi.state import MkosiState 15 | 16 | 17 | class PackageType(StrEnum): 18 | none = enum.auto() 19 | rpm = enum.auto() 20 | deb = enum.auto() 21 | pkg = enum.auto() 22 | ebuild = enum.auto() 23 | 24 | 25 | class DistributionInstaller: 26 | @classmethod 27 | def pretty_name(cls) -> str: 28 | raise NotImplementedError 29 | 30 | @classmethod 31 | def setup(cls, state: "MkosiState") -> None: 32 | raise NotImplementedError 33 | 34 | @classmethod 35 | def install(cls, state: "MkosiState") -> None: 36 | raise NotImplementedError 37 | 38 | @classmethod 39 | def install_packages(cls, state: "MkosiState", packages: Sequence[str]) -> None: 40 | raise NotImplementedError 41 | 42 | @classmethod 43 | def remove_packages(cls, state: "MkosiState", packages: Sequence[str]) -> None: 44 | raise NotImplementedError 45 | 46 | @classmethod 47 | def filesystem(cls) -> str: 48 | return "ext4" 49 | 50 | @classmethod 51 | def architecture(cls, arch: "Architecture") -> str: 52 | raise NotImplementedError 53 | 54 | @classmethod 55 | def package_type(cls) -> PackageType: 56 | return PackageType.none 57 | 58 | @classmethod 59 | def default_release(cls) -> str: 60 | return "" 61 | 62 | @classmethod 63 | def default_tools_tree_distribution(cls) -> Optional["Distribution"]: 64 | return None 65 | 66 | 67 | class Distribution(StrEnum): 68 | # Please consult docs/distribution-policy.md and contact one 69 | # of the mkosi maintainers before implementing a new distribution. 70 | fedora = enum.auto() 71 | debian = enum.auto() 72 | ubuntu = enum.auto() 73 | arch = enum.auto() 74 | opensuse = enum.auto() 75 | mageia = enum.auto() 76 | centos = enum.auto() 77 | rhel = enum.auto() 78 | rhel_ubi = enum.auto() 79 | openmandriva = enum.auto() 80 | rocky = enum.auto() 81 | alma = enum.auto() 82 | gentoo = enum.auto() 83 | custom = enum.auto() 84 | 85 | def is_centos_variant(self) -> bool: 86 | return self in ( 87 | Distribution.centos, 88 | Distribution.alma, 89 | Distribution.rocky, 90 | Distribution.rhel, 91 | Distribution.rhel_ubi, 92 | ) 93 | 94 | def is_dnf_distribution(self) -> bool: 95 | return self in ( 96 | Distribution.fedora, 97 | Distribution.mageia, 98 | Distribution.centos, 99 | Distribution.rhel, 100 | Distribution.rhel_ubi, 101 | Distribution.openmandriva, 102 | Distribution.rocky, 103 | Distribution.alma, 104 | ) 105 | 106 | def is_apt_distribution(self) -> bool: 107 | return self in (Distribution.debian, Distribution.ubuntu) 108 | 109 | def setup(self, state: "MkosiState") -> None: 110 | return self.installer().setup(state) 111 | 112 | def install(self, state: "MkosiState") -> None: 113 | return self.installer().install(state) 114 | 115 | def install_packages(self, state: "MkosiState", packages: Sequence[str]) -> None: 116 | return self.installer().install_packages(state, packages) 117 | 118 | def remove_packages(self, state: "MkosiState", packages: Sequence[str]) -> None: 119 | return self.installer().remove_packages(state, packages) 120 | 121 | def filesystem(self) -> str: 122 | return self.installer().filesystem() 123 | 124 | def architecture(self, arch: "Architecture") -> str: 125 | return self.installer().architecture(arch) 126 | 127 | def package_type(self) -> PackageType: 128 | return self.installer().package_type() 129 | 130 | def default_release(self) -> str: 131 | return self.installer().default_release() 132 | 133 | def default_tools_tree_distribution(self) -> Optional["Distribution"]: 134 | return self.installer().default_tools_tree_distribution() 135 | 136 | def installer(self) -> type[DistributionInstaller]: 137 | modname = str(self).replace('-', '_') 138 | mod = importlib.import_module(f"mkosi.distributions.{modname}") 139 | installer = getattr(mod, "Installer") 140 | assert issubclass(installer, DistributionInstaller) 141 | return cast(type[DistributionInstaller], installer) 142 | 143 | 144 | def detect_distribution() -> tuple[Optional[Distribution], Optional[str]]: 145 | try: 146 | os_release = read_os_release() 147 | except FileNotFoundError: 148 | return None, None 149 | 150 | dist_id = os_release.get("ID", "linux") 151 | dist_id_like = os_release.get("ID_LIKE", "").split() 152 | version = os_release.get("VERSION", None) 153 | version_id = os_release.get("VERSION_ID", None) 154 | version_codename = os_release.get("VERSION_CODENAME", None) 155 | extracted_codename = None 156 | 157 | if version: 158 | # extract Debian release codename 159 | m = re.search(r"\((.*?)\)", version) 160 | if m: 161 | extracted_codename = m.group(1) 162 | 163 | d: Optional[Distribution] = None 164 | for the_id in [dist_id, *dist_id_like]: 165 | d = Distribution.__members__.get(the_id, None) 166 | if d is not None: 167 | break 168 | 169 | if d in {Distribution.debian, Distribution.ubuntu} and (version_codename or extracted_codename): 170 | version_id = version_codename or extracted_codename 171 | 172 | return d, version_id 173 | 174 | 175 | def join_mirror(mirror: str, link: str) -> str: 176 | # urljoin() behaves weirdly if the base does not end with a / or the path starts with a / so fix them up as needed. 177 | if not mirror.endswith("/"): 178 | mirror = f"{mirror}/" 179 | link = link.removeprefix("/") 180 | 181 | return urllib.parse.urljoin(mirror, link) 182 | -------------------------------------------------------------------------------- /mkosi/mounts.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1+ 2 | 3 | import contextlib 4 | import os 5 | import platform 6 | import stat 7 | import tempfile 8 | from collections.abc import Iterator, Sequence 9 | from pathlib import Path 10 | from typing import Optional 11 | 12 | from mkosi.run import run 13 | from mkosi.types import PathString 14 | from mkosi.util import INVOKING_USER, umask 15 | from mkosi.versioncomp import GenericVersion 16 | 17 | 18 | def stat_is_whiteout(st: os.stat_result) -> bool: 19 | return stat.S_ISCHR(st.st_mode) and st.st_rdev == 0 20 | 21 | 22 | def delete_whiteout_files(path: Path) -> None: 23 | """Delete any char(0,0) device nodes underneath @path 24 | 25 | Overlayfs uses such files to mark "whiteouts" (files present in 26 | the lower layers, but removed in the upper one). 27 | """ 28 | for entry in path.rglob("*"): 29 | # TODO: Use Path.stat() once we depend on Python 3.10+. 30 | if stat_is_whiteout(os.stat(entry, follow_symlinks=False)): 31 | entry.unlink() 32 | 33 | 34 | @contextlib.contextmanager 35 | def mount( 36 | what: PathString, 37 | where: Path, 38 | operation: Optional[str] = None, 39 | options: Sequence[str] = (), 40 | type: Optional[str] = None, 41 | read_only: bool = False, 42 | lazy: bool = False, 43 | umount: bool = True, 44 | ) -> Iterator[Path]: 45 | if not where.exists(): 46 | with umask(~0o755): 47 | where.mkdir(parents=True) 48 | 49 | if read_only: 50 | options = ["ro", *options] 51 | 52 | cmd: list[PathString] = ["mount", "--no-mtab"] 53 | 54 | if operation: 55 | cmd += [operation] 56 | 57 | cmd += [what, where] 58 | 59 | if type: 60 | cmd += ["--types", type] 61 | 62 | if options: 63 | cmd += ["--options", ",".join(options)] 64 | 65 | try: 66 | run(cmd) 67 | yield where 68 | finally: 69 | if umount: 70 | run(["umount", "--no-mtab", *(["--lazy"] if lazy else []), where]) 71 | 72 | 73 | @contextlib.contextmanager 74 | def mount_overlay( 75 | lowerdirs: Sequence[Path], 76 | upperdir: Optional[Path] = None, 77 | where: Optional[Path] = None, 78 | lazy: bool = False, 79 | ) -> Iterator[Path]: 80 | with contextlib.ExitStack() as stack: 81 | if upperdir is None: 82 | upperdir = Path(stack.enter_context(tempfile.TemporaryDirectory(prefix="volatile-overlay"))) 83 | st = lowerdirs[-1].stat() 84 | os.chmod(upperdir, st.st_mode) 85 | os.chown(upperdir, st.st_uid, st.st_gid) 86 | 87 | workdir = Path( 88 | stack.enter_context(tempfile.TemporaryDirectory(dir=upperdir.parent, prefix=f"{upperdir.name}-workdir")) 89 | ) 90 | 91 | if where is None: 92 | where = Path( 93 | stack.enter_context( 94 | tempfile.TemporaryDirectory(dir=upperdir.parent, prefix=f"{upperdir.name}-mountpoint") 95 | ) 96 | ) 97 | 98 | options = [ 99 | f"lowerdir={':'.join(os.fspath(p) for p in reversed(lowerdirs))}", 100 | f"upperdir={upperdir}", 101 | f"workdir={workdir}", 102 | # Disable the inodes index and metacopy (only copy metadata upwards if possible) 103 | # options. If these are enabled (e.g., if the kernel enables them by default), 104 | # the mount will fail if the upper directory has been earlier used with a different 105 | # lower directory, such as with a build overlay that was generated on top of a 106 | # different temporary root. 107 | # See https://www.kernel.org/doc/html/latest/filesystems/overlayfs.html#sharing-and-copying-layers 108 | # and https://github.com/systemd/mkosi/issues/1841. 109 | "index=off", 110 | "metacopy=off" 111 | ] 112 | 113 | # userxattr is only supported on overlayfs since kernel 5.11 114 | if GenericVersion(platform.release()) >= GenericVersion("5.11"): 115 | options.append("userxattr") 116 | 117 | try: 118 | with mount("overlay", where, options=options, type="overlay", lazy=lazy): 119 | yield where 120 | finally: 121 | delete_whiteout_files(upperdir) 122 | 123 | 124 | @contextlib.contextmanager 125 | def mount_usr(tree: Optional[Path], umount: bool = True) -> Iterator[None]: 126 | if not tree: 127 | yield 128 | return 129 | 130 | # If we replace /usr, we should ignore any local modifications made to PATH as any of those binaries 131 | # might not work anymore when /usr is replaced wholesale. We also make sure that both /usr/bin and 132 | # /usr/sbin/ are searched so that e.g. if the host is Arch and the root is Debian we don't ignore the 133 | # binaries from /usr/sbin in the Debian root. 134 | old = os.environ["PATH"] 135 | os.environ["PATH"] = "/usr/bin:/usr/sbin" 136 | 137 | try: 138 | # If we mounted over /usr, trying to use umount will fail with "target is busy", because umount is 139 | # being called from /usr, which we're trying to unmount. To work around this issue, we do a lazy 140 | # unmount. 141 | with mount( 142 | what=tree / "usr", 143 | where=Path("/usr"), 144 | operation="--bind", 145 | read_only=True, 146 | lazy=True, 147 | umount=umount, 148 | ): 149 | yield 150 | finally: 151 | os.environ["PATH"] = old 152 | 153 | 154 | @contextlib.contextmanager 155 | def mount_passwd() -> Iterator[None]: 156 | with tempfile.NamedTemporaryFile(prefix="mkosi.passwd", mode="w") as passwd: 157 | passwd.write("root:x:0:0:root:/root:/bin/sh\n") 158 | if INVOKING_USER.uid != 0: 159 | name = INVOKING_USER.name() 160 | home = INVOKING_USER.home() 161 | passwd.write(f"{name}:x:{INVOKING_USER.uid}:{INVOKING_USER.gid}:{name}:{home}:/bin/sh\n") 162 | passwd.flush() 163 | os.fchown(passwd.file.fileno(), INVOKING_USER.uid, INVOKING_USER.gid) 164 | 165 | with mount(passwd.name, Path("/etc/passwd"), operation="--bind"): 166 | yield 167 | 168 | 169 | def finalize_passwd_mounts(root: Path) -> list[PathString]: 170 | """ 171 | If passwd or a related file exists in the apivfs directory, bind mount it over the host files while we 172 | run the command, to make sure that the command we run uses user/group information from the apivfs 173 | directory instead of from the host. If the file doesn't exist yet, mount over /dev/null instead. 174 | """ 175 | options: list[PathString] = [] 176 | 177 | for f in ("passwd", "group", "shadow", "gshadow"): 178 | if not (Path("/etc") / f).exists(): 179 | continue 180 | p = root / "etc" / f 181 | if p.exists(): 182 | options += ["--bind", p, f"/etc/{f}"] 183 | else: 184 | options += ["--bind", "/dev/null", f"/etc/{f}"] 185 | 186 | return options 187 | -------------------------------------------------------------------------------- /mkosi/kmod.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1+ 2 | 3 | import logging 4 | import os 5 | import re 6 | import subprocess 7 | from collections.abc import Iterator, Sequence 8 | from pathlib import Path 9 | 10 | from mkosi.log import complete_step, log_step 11 | from mkosi.run import run 12 | 13 | 14 | def loaded_modules() -> list[str]: 15 | return [line.split()[0] for line in Path("/proc/modules").read_text().splitlines()] 16 | 17 | 18 | def filter_kernel_modules( 19 | root: Path, 20 | kver: str, 21 | include: Sequence[str], 22 | exclude: Sequence[str], 23 | host: bool, 24 | ) -> list[Path]: 25 | modulesd = root / "usr/lib/modules" / kver 26 | modules = {m for m in modulesd.rglob("*.ko*")} 27 | 28 | if host: 29 | include = [*include, *loaded_modules()] 30 | 31 | keep = set() 32 | if include: 33 | regex = re.compile("|".join(include)) 34 | for m in modules: 35 | rel = os.fspath(m.relative_to(modulesd / "kernel")) 36 | if regex.search(rel): 37 | logging.debug(f"Including module {rel}") 38 | keep.add(rel) 39 | 40 | if exclude: 41 | remove = set() 42 | regex = re.compile("|".join(exclude)) 43 | for m in modules: 44 | rel = os.fspath(m.relative_to(modulesd / "kernel")) 45 | if rel not in keep and regex.search(rel): 46 | logging.debug(f"Excluding module {rel}") 47 | remove.add(m) 48 | 49 | modules -= remove 50 | 51 | return sorted(modules) 52 | 53 | 54 | def module_path_to_name(path: Path) -> str: 55 | return path.name.partition(".")[0] 56 | 57 | 58 | def resolve_module_dependencies(root: Path, kver: str, modules: Sequence[str]) -> tuple[set[Path], set[Path]]: 59 | """ 60 | Returns a tuple of lists containing the paths to the module and firmware dependencies of the given list 61 | of module names (including the given module paths themselves). The paths are returned relative to the 62 | root directory. 63 | """ 64 | modulesd = Path("usr/lib/modules") / kver 65 | builtin = set(module_path_to_name(Path(m)) for m in (root / modulesd / "modules.builtin").read_text().splitlines()) 66 | allmodules = set((root / modulesd / "kernel").glob("**/*.ko*")) 67 | nametofile = {module_path_to_name(m): m for m in allmodules} 68 | 69 | log_step("Running modinfo to fetch kernel module dependencies") 70 | 71 | # We could run modinfo once for each module but that's slow. Luckily we can pass multiple modules to 72 | # modinfo and it'll process them all in a single go. We get the modinfo for all modules to build two maps 73 | # that map the path of the module to its module dependencies and its firmware dependencies respectively. 74 | # Because there's more kernel modules than the max number of accepted CLI arguments for bwrap, we split the modules 75 | # list up into chunks. 76 | info = "" 77 | for i in range(0, len(nametofile.keys()), 8500): 78 | chunk = list(nametofile.keys())[i:i+8500] 79 | info += run(["modinfo", "--basedir", root, "--set-version", kver, "--null", *chunk], 80 | stdout=subprocess.PIPE).stdout.strip() 81 | 82 | log_step("Calculating required kernel modules and firmware") 83 | 84 | moddep = {} 85 | firmwaredep = {} 86 | 87 | depends = [] 88 | firmware = [] 89 | for line in info.split("\0"): 90 | key, sep, value = line.partition(":") 91 | if not sep: 92 | key, sep, value = line.partition("=") 93 | 94 | if key in ("depends", "softdep"): 95 | depends += [d for d in value.strip().split(",") if d] 96 | 97 | elif key == "firmware": 98 | fw = [f for f in (root / "usr/lib/firmware").glob(f"{value.strip()}*")] 99 | if not fw: 100 | logging.debug(f"Not including missing firmware /usr/lib/firmware/{value} in the initrd") 101 | 102 | firmware += fw 103 | 104 | elif key == "name": 105 | # The file names use dashes, but the module names use underscores. We track the names 106 | # in terms of the file names, since the depends use dashes and therefore filenames as 107 | # well. 108 | name = value.strip().replace("_", "-") 109 | 110 | moddep[name] = depends 111 | firmwaredep[name] = firmware 112 | 113 | depends = [] 114 | firmware = [] 115 | 116 | todo = [*builtin, *modules] 117 | mods = set() 118 | firmware = set() 119 | 120 | while todo: 121 | m = todo.pop() 122 | if m in mods: 123 | continue 124 | 125 | depends = moddep.get(m, []) 126 | for d in depends: 127 | if d not in nametofile and d not in builtin: 128 | logging.warning(f"{d} is a dependency of {m} but is not installed, ignoring ") 129 | 130 | mods.add(m) 131 | todo += depends 132 | firmware.update(firmwaredep.get(m, [])) 133 | 134 | return set(nametofile[m] for m in mods if m in nametofile), set(firmware) 135 | 136 | 137 | def gen_required_kernel_modules( 138 | root: Path, 139 | kver: str, 140 | include: Sequence[str], 141 | exclude: Sequence[str], 142 | host: bool, 143 | ) -> Iterator[Path]: 144 | modulesd = root / "usr/lib/modules" / kver 145 | modules = filter_kernel_modules(root, kver, include, exclude, host) 146 | 147 | names = [module_path_to_name(m) for m in modules] 148 | mods, firmware = resolve_module_dependencies(root, kver, names) 149 | 150 | def files() -> Iterator[Path]: 151 | yield modulesd.parent 152 | yield modulesd 153 | yield modulesd / "kernel" 154 | 155 | for d in (modulesd, root / "usr/lib/firmware"): 156 | for p in (root / d).rglob("*"): 157 | if p.is_dir(): 158 | yield p 159 | 160 | for p in sorted(mods) + sorted(firmware): 161 | yield p 162 | 163 | for p in (root / modulesd).iterdir(): 164 | if not p.name.startswith("modules"): 165 | continue 166 | 167 | yield p 168 | 169 | if (root / modulesd / "vdso").exists(): 170 | yield modulesd / "vdso" 171 | 172 | for p in (root / modulesd / "vdso").iterdir(): 173 | yield p 174 | 175 | return files() 176 | 177 | 178 | def process_kernel_modules( 179 | root: Path, 180 | kver: str, 181 | include: Sequence[str], 182 | exclude: Sequence[str], 183 | host: bool, 184 | ) -> None: 185 | if not include and not exclude: 186 | return 187 | 188 | with complete_step("Applying kernel module filters"): 189 | required = set(gen_required_kernel_modules(root, kver, include, exclude, host)) 190 | 191 | for m in (root / "usr/lib/modules" / kver).rglob("*.ko*"): 192 | if m in required: 193 | continue 194 | 195 | logging.debug(f"Removing module {m}") 196 | (root / m).unlink() 197 | 198 | for fw in (m for m in (root / "usr/lib/firmware").rglob("*") if not m.is_dir()): 199 | if fw in required: 200 | continue 201 | 202 | logging.debug(f"Removing firmware {fw}") 203 | (root / fw).unlink() 204 | -------------------------------------------------------------------------------- /mkosi/distributions/fedora.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1+ 2 | 3 | from collections.abc import Sequence 4 | 5 | from mkosi.config import Architecture 6 | from mkosi.distributions import ( 7 | Distribution, 8 | DistributionInstaller, 9 | PackageType, 10 | join_mirror, 11 | ) 12 | from mkosi.installer.dnf import invoke_dnf, setup_dnf 13 | from mkosi.installer.rpm import RpmRepository, find_rpm_gpgkey 14 | from mkosi.log import die 15 | from mkosi.state import MkosiState 16 | 17 | 18 | class Installer(DistributionInstaller): 19 | @classmethod 20 | def pretty_name(cls) -> str: 21 | return "Fedora Linux" 22 | 23 | @classmethod 24 | def filesystem(cls) -> str: 25 | return "btrfs" 26 | 27 | @classmethod 28 | def package_type(cls) -> PackageType: 29 | return PackageType.rpm 30 | 31 | @classmethod 32 | def default_release(cls) -> str: 33 | return "39" 34 | 35 | @classmethod 36 | def default_tools_tree_distribution(cls) -> Distribution: 37 | return Distribution.fedora 38 | 39 | @classmethod 40 | def setup(cls, state: MkosiState) -> None: 41 | gpgurls = ( 42 | find_rpm_gpgkey( 43 | state, 44 | key=f"RPM-GPG-KEY-fedora-{state.config.release}-primary", 45 | url="https://fedoraproject.org/fedora.gpg", 46 | ), 47 | ) 48 | 49 | repos = [] 50 | 51 | if state.config.local_mirror: 52 | repos += [RpmRepository("fedora", f"baseurl={state.config.local_mirror}", gpgurls)] 53 | elif state.config.release == "eln": 54 | mirror = state.config.mirror or "https://odcs.fedoraproject.org/composes/production/latest-Fedora-ELN/compose" 55 | for repo in ("Appstream", "BaseOS", "Extras", "CRB"): 56 | url = f"baseurl={join_mirror(mirror, repo)}" 57 | repos += [ 58 | RpmRepository(repo.lower(), f"{url}/$basearch/os", gpgurls), 59 | RpmRepository(repo.lower(), f"{url}/$basearch/debug/tree", gpgurls, enabled=False), 60 | RpmRepository(repo.lower(), f"{url}/source/tree", gpgurls, enabled=False), 61 | ] 62 | elif state.config.mirror: 63 | directory = "development" if state.config.release == "rawhide" else "releases" 64 | url = f"baseurl={join_mirror(state.config.mirror, f'{directory}/$releasever/Everything')}" 65 | repos += [ 66 | RpmRepository("fedora", f"{url}/$basearch/os", gpgurls), 67 | RpmRepository("fedora-debuginfo", f"{url}/$basearch/debug/tree", gpgurls, enabled=False), 68 | RpmRepository("fedora-source", f"{url}/source/tree", gpgurls, enabled=False), 69 | ] 70 | 71 | if state.config.release != "rawhide": 72 | url = f"baseurl={join_mirror(state.config.mirror, 'updates/$releasever/Everything')}" 73 | repos += [ 74 | RpmRepository("updates", f"{url}/$basearch", gpgurls), 75 | RpmRepository("updates-debuginfo", f"{url}/$basearch/debug", gpgurls, enabled=False), 76 | RpmRepository("updates-source", f"{url}/source/tree", gpgurls, enabled=False), 77 | ] 78 | 79 | url = f"baseurl={join_mirror(state.config.mirror, 'updates/testing/$releasever/Everything')}" 80 | repos += [ 81 | RpmRepository("updates-testing", f"{url}/$basearch", gpgurls, enabled=False), 82 | RpmRepository("updates-testing-debuginfo", f"{url}/$basearch/debug", gpgurls, enabled=False), 83 | RpmRepository("updates-testing-source", f"{url}/source/tree", gpgurls, enabled=False) 84 | ] 85 | else: 86 | url = "metalink=https://mirrors.fedoraproject.org/metalink?arch=$basearch" 87 | repos += [ 88 | RpmRepository("fedora", f"{url}&repo=fedora-$releasever", gpgurls), 89 | RpmRepository("fedora-debuginfo", f"{url}&repo=fedora-debug-$releasever", gpgurls, enabled=False), 90 | RpmRepository("fedora-source", f"{url}&repo=fedora-source-$releasever", gpgurls, enabled=False), 91 | ] 92 | 93 | if state.config.release != "rawhide": 94 | repos += [ 95 | RpmRepository("updates", f"{url}&repo=updates-released-f$releasever", gpgurls), 96 | RpmRepository( 97 | "updates-debuginfo", 98 | f"{url}&repo=updates-released-debug-f$releasever", 99 | gpgurls, 100 | enabled=False, 101 | ), 102 | RpmRepository( 103 | "updates-source", 104 | f"{url}&repo=updates-released-source-f$releasever", 105 | gpgurls, 106 | enabled=False 107 | ), 108 | RpmRepository( 109 | "updates-testing", 110 | f"{url}&repo=updates-testing-f$releasever", 111 | gpgurls, 112 | enabled=False 113 | ), 114 | RpmRepository( 115 | "updates-testing-debuginfo", 116 | f"{url}&repo=updates-testing-debug-f$releasever", 117 | gpgurls, 118 | enabled=False, 119 | ), 120 | RpmRepository( 121 | "updates-testing-source", 122 | f"{url}&repo=updates-testing-source-f$releasever", 123 | gpgurls, 124 | enabled=False, 125 | ), 126 | ] 127 | 128 | # TODO: Use `filelists=True` when F37 goes EOL. 129 | setup_dnf(state, repos, filelists=fedora_release_at_most(state.config.release, "37")) 130 | 131 | @classmethod 132 | def install(cls, state: MkosiState) -> None: 133 | cls.install_packages(state, ["filesystem"], apivfs=False) 134 | 135 | @classmethod 136 | def install_packages(cls, state: MkosiState, packages: Sequence[str], apivfs: bool = True) -> None: 137 | invoke_dnf(state, "install", packages, apivfs=apivfs) 138 | 139 | @classmethod 140 | def remove_packages(cls, state: MkosiState, packages: Sequence[str]) -> None: 141 | invoke_dnf(state, "remove", packages) 142 | 143 | @classmethod 144 | def architecture(cls, arch: Architecture) -> str: 145 | a = { 146 | Architecture.arm64 : "aarch64", 147 | Architecture.mips64_le : "mips64el", 148 | Architecture.mips_le : "mipsel", 149 | Architecture.ppc64_le : "ppc64le", 150 | Architecture.riscv64 : "riscv64", 151 | Architecture.s390x : "s390x", 152 | Architecture.x86_64 : "x86_64", 153 | }.get(arch) 154 | 155 | if not a: 156 | die(f"Architecture {a} is not supported by Fedora") 157 | 158 | return a 159 | 160 | 161 | def fedora_release_at_most(release: str, threshold: str) -> bool: 162 | if release in ("rawhide", "eln"): 163 | return False 164 | if threshold in ("rawhide", "eln"): 165 | return True 166 | # If neither is 'rawhide', both must be integers 167 | return int(release) <= int(threshold) 168 | -------------------------------------------------------------------------------- /mkosi/distributions/gentoo.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1+ 2 | 3 | import os 4 | import re 5 | import urllib.parse 6 | import urllib.request 7 | from collections.abc import Sequence 8 | from pathlib import Path 9 | 10 | from mkosi.archive import extract_tar 11 | from mkosi.bubblewrap import apivfs_cmd, bwrap, chroot_cmd 12 | from mkosi.config import Architecture 13 | from mkosi.distributions import ( 14 | Distribution, 15 | DistributionInstaller, 16 | PackageType, 17 | join_mirror, 18 | ) 19 | from mkosi.log import ARG_DEBUG, complete_step, die 20 | from mkosi.run import run 21 | from mkosi.state import MkosiState 22 | from mkosi.tree import copy_tree, rmtree 23 | from mkosi.types import PathString 24 | from mkosi.util import sort_packages 25 | 26 | 27 | def invoke_emerge(state: MkosiState, packages: Sequence[str] = (), apivfs: bool = True) -> None: 28 | bwrap( 29 | state, 30 | cmd=apivfs_cmd(state.root) + [ 31 | # We can't mount the stage 3 /usr using `options`, because bwrap isn't available in the stage 3 32 | # tarball which is required by apivfs_cmd(), so we have to mount /usr from the tarball later 33 | # using another bwrap exec. 34 | "bwrap", 35 | "--dev-bind", "/", "/", 36 | "--bind", state.cache_dir / "stage3/usr", "/usr", 37 | "emerge", 38 | "--buildpkg=y", 39 | "--usepkg=y", 40 | "--getbinpkg=y", 41 | "--binpkg-respect-use=y", 42 | "--jobs", 43 | "--load-average", 44 | "--root-deps=rdeps", 45 | "--with-bdeps=n", 46 | "--verbose-conflicts", 47 | "--noreplace", 48 | *(["--verbose", "--quiet=n", "--quiet-fail=n"] if ARG_DEBUG.get() else ["--quiet-build", "--quiet"]), 49 | f"--root={state.root}", 50 | *sort_packages(packages), 51 | ], 52 | network=True, 53 | options=[ 54 | # TODO: Get rid of as many of these as possible. 55 | "--bind", state.cache_dir / "stage3/etc", "/etc", 56 | "--bind", state.cache_dir / "stage3/var", "/var", 57 | "--ro-bind", "/etc/resolv.conf", "/etc/resolv.conf", 58 | "--bind", state.cache_dir / "repos", "/var/db/repos", 59 | ], 60 | env=dict( 61 | PKGDIR=str(state.cache_dir / "binpkgs"), 62 | DISTDIR=str(state.cache_dir / "distfiles"), 63 | ) | ({"USE": "build"} if not apivfs else {}) | state.config.environment, 64 | ) 65 | 66 | 67 | class Installer(DistributionInstaller): 68 | @classmethod 69 | def pretty_name(cls) -> str: 70 | return "Gentoo" 71 | 72 | @classmethod 73 | def filesystem(cls) -> str: 74 | return "btrfs" 75 | 76 | @classmethod 77 | def package_type(cls) -> PackageType: 78 | return PackageType.ebuild 79 | 80 | @classmethod 81 | def default_release(cls) -> str: 82 | return "17.1" 83 | 84 | @classmethod 85 | def default_tools_tree_distribution(cls) -> Distribution: 86 | return Distribution.gentoo 87 | 88 | @classmethod 89 | def setup(cls, state: MkosiState) -> None: 90 | pass 91 | 92 | @classmethod 93 | def install(cls, state: MkosiState) -> None: 94 | arch = state.config.distribution.architecture(state.config.architecture) 95 | 96 | mirror = state.config.mirror or "https://distfiles.gentoo.org" 97 | # http://distfiles.gentoo.org/releases/amd64/autobuilds/latest-stage3.txt 98 | stage3tsf_path_url = join_mirror( 99 | mirror.partition(" ")[0], 100 | f"releases/{arch}/autobuilds/latest-stage3.txt", 101 | ) 102 | 103 | with urllib.request.urlopen(stage3tsf_path_url) as r: 104 | # e.g.: 20230108T161708Z/stage3-amd64-nomultilib-systemd-mergedusr-20230108T161708Z.tar.xz 105 | regexp = rf"^[0-9]+T[0-9]+Z/stage3-{arch}-llvm-systemd-mergedusr-[0-9]+T[0-9]+Z\.tar\.xz" 106 | all_lines = r.readlines() 107 | for line in all_lines: 108 | if (m := re.match(regexp, line.decode("utf-8"))): 109 | stage3_latest = Path(m.group(0)) 110 | break 111 | else: 112 | die("profile names changed upstream?") 113 | 114 | stage3_url = join_mirror(mirror, f"releases/{arch}/autobuilds/{stage3_latest}") 115 | stage3_tar = state.cache_dir / "stage3.tar" 116 | stage3 = state.cache_dir / "stage3" 117 | 118 | with complete_step("Fetching latest stage3 snapshot"): 119 | old = stage3_tar.stat().st_mtime if stage3_tar.exists() else 0 120 | 121 | cmd: list[PathString] = ["curl", "-L", "--progress-bar", "-o", stage3_tar, stage3_url] 122 | if stage3_tar.exists(): 123 | cmd += ["--time-cond", stage3_tar] 124 | 125 | run(cmd) 126 | 127 | if stage3_tar.stat().st_mtime > old: 128 | rmtree(stage3) 129 | 130 | stage3.mkdir(exist_ok=True) 131 | 132 | if not any(stage3.iterdir()): 133 | with complete_step(f"Extracting {stage3_tar.name} to {stage3}"): 134 | extract_tar(state, stage3_tar, stage3) 135 | 136 | for d in ("binpkgs", "distfiles", "repos/gentoo"): 137 | (state.cache_dir / d).mkdir(parents=True, exist_ok=True) 138 | 139 | copy_tree(state.pkgmngr, stage3, preserve_owner=False, use_subvolumes=state.config.use_subvolumes) 140 | 141 | features = " ".join([ 142 | # Disable sandboxing in emerge because we already do it in mkosi. 143 | "-sandbox", 144 | "-pid-sandbox", 145 | "-ipc-sandbox", 146 | "-network-sandbox", 147 | "-userfetch", 148 | "-userpriv", 149 | "-usersandbox", 150 | "-usersync", 151 | "-ebuild-locks", 152 | "parallel-install", 153 | *(["noman", "nodoc", "noinfo"] if state.config.with_docs else []), 154 | ]) 155 | 156 | # Setting FEATURES via the environment variable does not seem to apply to ebuilds in portage, so we 157 | # append to /etc/portage/make.conf instead. 158 | with (stage3 / "etc/portage/make.conf").open("a") as f: 159 | f.write(f"\nFEATURES=\"${{FEATURES}} {features}\"\n") 160 | 161 | chroot = chroot_cmd( 162 | stage3, 163 | options=["--bind", state.cache_dir / "repos", "/var/db/repos"], 164 | ) 165 | 166 | bwrap(state, cmd=chroot + ["emerge-webrsync"], network=True) 167 | 168 | invoke_emerge(state, packages=["sys-apps/baselayout"], apivfs=False) 169 | 170 | @classmethod 171 | def install_packages(cls, state: MkosiState, packages: Sequence[str], apivfs: bool = True) -> None: 172 | invoke_emerge(state, packages=packages, apivfs=apivfs) 173 | 174 | for d in state.root.glob("usr/src/linux-*"): 175 | kver = d.name.removeprefix("linux-") 176 | kimg = d / { 177 | Architecture.x86_64: "arch/x86/boot/bzImage", 178 | Architecture.arm64: "arch/arm64/boot/Image.gz", 179 | Architecture.arm: "arch/arm/boot/zImage", 180 | }[state.config.architecture] 181 | vmlinuz = state.root / "usr/lib/modules" / kver / "vmlinuz" 182 | if not vmlinuz.exists() and not vmlinuz.is_symlink(): 183 | vmlinuz.symlink_to(os.path.relpath(kimg, start=vmlinuz.parent)) 184 | 185 | @classmethod 186 | def architecture(cls, arch: Architecture) -> str: 187 | a = { 188 | Architecture.x86_64 : "amd64", 189 | Architecture.arm64 : "arm64", 190 | Architecture.arm : "arm", 191 | }.get(arch) 192 | 193 | if not a: 194 | die(f"Architecture {a} is not supported by Gentoo") 195 | 196 | return a 197 | -------------------------------------------------------------------------------- /mkosi/bubblewrap.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1+ 2 | import contextlib 3 | import enum 4 | import logging 5 | import os 6 | import subprocess 7 | import sys 8 | from collections.abc import Mapping, Sequence 9 | from pathlib import Path 10 | from typing import Optional 11 | 12 | from mkosi.log import ARG_DEBUG_SHELL 13 | from mkosi.mounts import finalize_passwd_mounts, mount_overlay 14 | from mkosi.run import find_binary, log_process_failure, run 15 | from mkosi.state import MkosiState 16 | from mkosi.types import _FILE, CompletedProcess, PathString 17 | from mkosi.util import flatten, one_zero 18 | 19 | 20 | # https://github.com/torvalds/linux/blob/master/include/uapi/linux/capability.h 21 | class Capability(enum.Enum): 22 | CAP_NET_ADMIN = 12 23 | 24 | 25 | def have_effective_cap(capability: Capability) -> bool: 26 | for line in Path("/proc/self/status").read_text().splitlines(): 27 | if line.startswith("CapEff:"): 28 | hexcap = line.removeprefix("CapEff:").strip() 29 | break 30 | else: 31 | logging.warning(f"\"CapEff:\" not found in /proc/self/status, assuming we don't have {capability}") 32 | return False 33 | 34 | return (int(hexcap, 16) & (1 << capability.value)) != 0 35 | 36 | 37 | def finalize_mounts(state: MkosiState) -> list[str]: 38 | mounts = [ 39 | ((state.config.tools_tree or Path("/")) / subdir, Path("/") / subdir, True) 40 | for subdir in ( 41 | Path("etc/pki"), 42 | Path("etc/ssl"), 43 | Path("etc/crypto-policies"), 44 | Path("etc/ca-certificates"), 45 | Path("etc/pacman.d/gnupg"), 46 | Path("var/lib/ca-certificates"), 47 | ) 48 | if ((state.config.tools_tree or Path("/")) / subdir).exists() 49 | ] 50 | 51 | mounts += [ 52 | (d, d, False) 53 | for d in (state.workspace, state.config.cache_dir, state.config.output_dir, state.config.build_dir) 54 | if d 55 | ] 56 | 57 | mounts += [(d, d, True) for d in state.config.extra_search_paths] 58 | 59 | return flatten( 60 | ["--ro-bind" if readonly else "--bind", os.fspath(src), os.fspath(target)] 61 | for src, target, readonly 62 | in sorted(set(mounts), key=lambda s: s[1]) 63 | ) 64 | 65 | 66 | def bwrap( 67 | state: MkosiState, 68 | cmd: Sequence[PathString], 69 | *, 70 | network: bool = False, 71 | devices: bool = False, 72 | options: Sequence[PathString] = (), 73 | log: bool = True, 74 | scripts: Optional[Path] = None, 75 | env: Mapping[str, str] = {}, 76 | stdin: _FILE = None, 77 | stdout: _FILE = None, 78 | stderr: _FILE = None, 79 | input: Optional[str] = None, 80 | check: bool = True, 81 | ) -> CompletedProcess: 82 | cmdline: list[PathString] = [ 83 | "bwrap", 84 | "--ro-bind", "/usr", "/usr", 85 | "--ro-bind-try", "/nix/store", "/nix/store", 86 | # This mount is writable so bwrap can create extra directories or symlinks inside of it as needed. This isn't a 87 | # problem as the package manager directory is created by mkosi and thrown away when the build finishes. 88 | "--bind", state.pkgmngr / "etc", "/etc", 89 | "--bind", "/var/tmp", "/var/tmp", 90 | "--bind", "/tmp", "/tmp", 91 | "--bind", Path.cwd(), Path.cwd(), 92 | "--chdir", Path.cwd(), 93 | "--unshare-pid", 94 | "--unshare-ipc", 95 | "--unshare-cgroup", 96 | *(["--unshare-net"] if not network and have_effective_cap(Capability.CAP_NET_ADMIN) else []), 97 | "--die-with-parent", 98 | "--proc", "/proc", 99 | "--setenv", "SYSTEMD_OFFLINE", one_zero(network), 100 | ] 101 | 102 | if devices: 103 | cmdline += [ 104 | "--bind", "/sys", "/sys", 105 | "--dev-bind", "/dev", "/dev", 106 | ] 107 | else: 108 | cmdline += ["--dev", "/dev"] 109 | 110 | for p in Path("/").iterdir(): 111 | if p.is_symlink(): 112 | cmdline += ["--symlink", p.readlink(), p] 113 | 114 | if network: 115 | cmdline += ["--bind", "/etc/resolv.conf", "/etc/resolv.conf"] 116 | 117 | cmdline += finalize_mounts(state) + [ 118 | "--setenv", "PATH", f"{scripts or ''}:{os.environ['PATH']}", 119 | *options, 120 | "sh", "-c", "chmod 1777 /dev/shm && exec $0 \"$@\"", 121 | ] 122 | 123 | if setpgid := find_binary("setpgid"): 124 | cmdline += [setpgid, "--foreground", "--"] 125 | 126 | try: 127 | with ( 128 | mount_overlay([Path("/usr"), state.pkgmngr / "usr"], where=Path("/usr"), lazy=True) 129 | if (state.pkgmngr / "usr").exists() 130 | else contextlib.nullcontext() 131 | ): 132 | return run( 133 | [*cmdline, *cmd], 134 | env=env, 135 | log=False, 136 | stdin=stdin, 137 | stdout=stdout, 138 | stderr=stderr, 139 | input=input, 140 | check=check, 141 | ) 142 | except subprocess.CalledProcessError as e: 143 | if log: 144 | log_process_failure([os.fspath(s) for s in cmd], e.returncode) 145 | if ARG_DEBUG_SHELL.get(): 146 | run([*cmdline, "sh"], stdin=sys.stdin, check=False, env=env, log=False) 147 | raise e 148 | 149 | 150 | def apivfs_cmd(root: Path) -> list[PathString]: 151 | cmdline: list[PathString] = [ 152 | "bwrap", 153 | "--dev-bind", "/", "/", 154 | "--chdir", Path.cwd(), 155 | "--tmpfs", root / "run", 156 | "--tmpfs", root / "tmp", 157 | "--bind", os.getenv("TMPDIR", "/var/tmp"), root / "var/tmp", 158 | "--proc", root / "proc", 159 | "--dev", root / "dev", 160 | # APIVFS generally means chrooting is going to happen so unset TMPDIR just to be safe. 161 | "--unsetenv", "TMPDIR", 162 | ] 163 | 164 | if (root / "etc/machine-id").exists(): 165 | # Make sure /etc/machine-id is not overwritten by any package manager post install scripts. 166 | cmdline += ["--ro-bind", root / "etc/machine-id", root / "etc/machine-id"] 167 | 168 | cmdline += finalize_passwd_mounts(root) 169 | 170 | if setpgid := find_binary("setpgid"): 171 | cmdline += [setpgid, "--foreground", "--"] 172 | 173 | chmod = f"chmod 1777 {root / 'tmp'} {root / 'var/tmp'} {root / 'dev/shm'}" 174 | # Make sure anything running in the root directory thinks it's in a container. $container can't always be 175 | # accessed so we write /run/host/container-manager as well which is always accessible. 176 | container = f"mkdir {root}/run/host && echo mkosi >{root}/run/host/container-manager" 177 | 178 | cmdline += ["sh", "-c", f"{chmod} && {container} && exec $0 \"$@\""] 179 | 180 | return cmdline 181 | 182 | 183 | def chroot_cmd(root: Path, *, resolve: bool = False, options: Sequence[PathString] = ()) -> list[PathString]: 184 | cmdline: list[PathString] = [ 185 | "sh", "-c", 186 | # No exec here because we need to clean up the /work directory afterwards. 187 | f"trap 'rm -rf {root / 'work'}' EXIT && mkdir -p {root / 'work'} && chown 777 {root / 'work'} && $0 \"$@\"", 188 | "bwrap", 189 | "--dev-bind", root, "/", 190 | "--setenv", "container", "mkosi", 191 | "--setenv", "HOME", "/", 192 | "--setenv", "PATH", "/work/scripts:/usr/bin:/usr/sbin", 193 | ] 194 | 195 | if resolve: 196 | p = Path("etc/resolv.conf") 197 | if (root / p).is_symlink(): 198 | # For each component in the target path, bubblewrap will try to create it if it doesn't exist 199 | # yet. If a component in the path is a dangling symlink, bubblewrap will end up calling 200 | # mkdir(symlink) which obviously fails if multiple components of the dangling symlink path don't 201 | # exist yet. As a workaround, we resolve the symlink ourselves so that bubblewrap will correctly 202 | # create all missing components in the target path. 203 | p = p.parent / (root / p).readlink() 204 | 205 | cmdline += ["--ro-bind", "/etc/resolv.conf", Path("/") / p] 206 | 207 | cmdline += [*options] 208 | 209 | if setpgid := find_binary("setpgid", root=root): 210 | cmdline += [setpgid, "--foreground", "--"] 211 | 212 | return apivfs_cmd(root) + cmdline 213 | -------------------------------------------------------------------------------- /mkosi/versioncomp.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1+ 2 | 3 | import string 4 | from itertools import takewhile 5 | 6 | 7 | class GenericVersion: 8 | # These constants follow the convention of the return value of rpmdev-vercmp that are followe 9 | # by systemd-analyze compare-versions when called with only two arguments (without a comparison 10 | # operator), recreated in the compare_versions method. 11 | _EQUAL = 0 12 | _RIGHT_SMALLER = 1 13 | _LEFT_SMALLER = -1 14 | 15 | def __init__(self, version: str): 16 | self._version = version 17 | 18 | @classmethod 19 | def compare_versions(cls, v1: str, v2: str) -> int: 20 | """Implements comparison according to UAPI Group Version Format Specification""" 21 | def rstrip_invalid_version_chars(s: str) -> str: 22 | valid_version_chars = {*string.ascii_letters, *string.digits, "~", "-", "^", "."} 23 | for i, c in enumerate(s): 24 | if c in valid_version_chars: 25 | return s[i:] 26 | return "" 27 | 28 | def digit_prefix(s: str) -> str: 29 | return "".join(takewhile(lambda c: c in string.digits, s)) 30 | 31 | def letter_prefix(s: str) -> str: 32 | return "".join(takewhile(lambda c: c in string.ascii_letters, s)) 33 | 34 | while True: 35 | # Any characters which are outside of the set of listed above (a-z, A-Z, 0-9, -, ., ~, 36 | # ^) are skipped in both strings. In particular, this means that non-ASCII characters 37 | # that are Unicode digits or letters are skipped too. 38 | v1 = rstrip_invalid_version_chars(v1) 39 | v2 = rstrip_invalid_version_chars(v2) 40 | 41 | # If the remaining part of one of strings starts with "~": if other remaining part does 42 | # not start with ~, the string with ~ compares lower. Otherwise, both tilde characters 43 | # are skipped. 44 | 45 | if v1.startswith("~") and v2.startswith("~"): 46 | v1 = v1.removeprefix("~") 47 | v2 = v2.removeprefix("~") 48 | elif v1.startswith("~"): 49 | return cls._LEFT_SMALLER 50 | elif v2.startswith("~"): 51 | return cls._RIGHT_SMALLER 52 | 53 | # If one of the strings has ended: if the other string hasn’t, the string that has 54 | # remaining characters compares higher. Otherwise, the strings compare equal. 55 | 56 | if not v1 and not v2: 57 | return cls._EQUAL 58 | elif not v1 and v2: 59 | return cls._LEFT_SMALLER 60 | elif v1 and not v2: 61 | return cls._RIGHT_SMALLER 62 | 63 | # If the remaining part of one of strings starts with "-": if the other remaining part 64 | # does not start with -, the string with - compares lower. Otherwise, both minus 65 | # characters are skipped. 66 | 67 | if v1.startswith("-") and v2.startswith("-"): 68 | v1 = v1.removeprefix("-") 69 | v2 = v2.removeprefix("-") 70 | elif v1.startswith("-"): 71 | return cls._LEFT_SMALLER 72 | elif v2.startswith("-"): 73 | return cls._RIGHT_SMALLER 74 | 75 | # If the remaining part of one of strings starts with "^": if the other remaining part 76 | # does not start with ^, the string with ^ compares higher. Otherwise, both caret 77 | # characters are skipped. 78 | 79 | if v1.startswith("^") and v2.startswith("^"): 80 | v1 = v1.removeprefix("^") 81 | v2 = v2.removeprefix("^") 82 | elif v1.startswith("^"): 83 | # TODO: bug? 84 | return cls._LEFT_SMALLER #cls._RIGHT_SMALLER 85 | elif v2.startswith("^"): 86 | return cls._RIGHT_SMALLER #cls._LEFT_SMALLER 87 | 88 | # If the remaining part of one of strings starts with ".": if the other remaining part 89 | # does not start with ., the string with . compares lower. Otherwise, both dot 90 | # characters are skipped. 91 | 92 | if v1.startswith(".") and v2.startswith("."): 93 | v1 = v1.removeprefix(".") 94 | v2 = v2.removeprefix(".") 95 | elif v1.startswith("."): 96 | return cls._LEFT_SMALLER 97 | elif v2.startswith("."): 98 | return cls._RIGHT_SMALLER 99 | 100 | # If either of the remaining parts starts with a digit: numerical prefixes are compared 101 | # numerically. Any leading zeroes are skipped. The numerical prefixes (until the first 102 | # non-digit character) are evaluated as numbers. If one of the prefixes is empty, it 103 | # evaluates as 0. If the numbers are different, the string with the bigger number 104 | # compares higher. Otherwise, the comparison continues at the following characters at 105 | # point 1. 106 | 107 | v1_digit_prefix = digit_prefix(v1) 108 | v2_digit_prefix = digit_prefix(v2) 109 | 110 | if v1_digit_prefix or v2_digit_prefix: 111 | v1_digits = int(v1_digit_prefix) if v1_digit_prefix else 0 112 | v2_digits = int(v2_digit_prefix) if v2_digit_prefix else 0 113 | 114 | if v1_digits < v2_digits: 115 | return cls._LEFT_SMALLER 116 | elif v1_digits > v2_digits: 117 | return cls._RIGHT_SMALLER 118 | 119 | v1 = v1.removeprefix(v1_digit_prefix) 120 | v2 = v2.removeprefix(v2_digit_prefix) 121 | continue 122 | 123 | # Leading alphabetical prefixes are compared alphabetically. The substrings are 124 | # compared letter-by-letter. If both letters are the same, the comparison continues 125 | # with the next letter. Capital letters compare lower than lower-case letters (A < 126 | # a). When the end of one substring has been reached (a non-letter character or the end 127 | # of the whole string), if the other substring has remaining letters, it compares 128 | # higher. Otherwise, the comparison continues at the following characters at point 1. 129 | 130 | v1_letter_prefix = letter_prefix(v1) 131 | v2_letter_prefix = letter_prefix(v2) 132 | 133 | if v1_letter_prefix < v2_letter_prefix: 134 | return cls._LEFT_SMALLER 135 | elif v1_letter_prefix > v2_letter_prefix: 136 | return cls._RIGHT_SMALLER 137 | 138 | v1 = v1.removeprefix(v1_letter_prefix) 139 | v2 = v2.removeprefix(v2_letter_prefix) 140 | 141 | def __eq__(self, other: object) -> bool: 142 | if isinstance(other, (str, int)): 143 | other = GenericVersion(str(other)) 144 | elif not isinstance(other, GenericVersion): 145 | return False 146 | return self.compare_versions(self._version, other._version) == self._EQUAL 147 | 148 | def __ne__(self, other: object) -> bool: 149 | if isinstance(other, (str, int)): 150 | other = GenericVersion(str(other)) 151 | elif not isinstance(other, GenericVersion): 152 | return False 153 | return self.compare_versions(self._version, other._version) != self._EQUAL 154 | 155 | def __lt__(self, other: object) -> bool: 156 | if isinstance(other, (str, int)): 157 | other = GenericVersion(str(other)) 158 | elif not isinstance(other, GenericVersion): 159 | return False 160 | return self.compare_versions(self._version, other._version) == self._LEFT_SMALLER 161 | 162 | def __le__(self, other: object) -> bool: 163 | if isinstance(other, (str, int)): 164 | other = GenericVersion(str(other)) 165 | elif not isinstance(other, GenericVersion): 166 | return False 167 | return self.compare_versions(self._version, other._version) in (self._EQUAL, self._LEFT_SMALLER) 168 | 169 | def __gt__(self, other: object) -> bool: 170 | if isinstance(other, (str, int)): 171 | other = GenericVersion(str(other)) 172 | elif not isinstance(other, GenericVersion): 173 | return False 174 | return self.compare_versions(self._version, other._version) == self._RIGHT_SMALLER 175 | 176 | def __ge__(self, other: object) -> bool: 177 | if isinstance(other, (str, int)): 178 | other = GenericVersion(str(other)) 179 | elif not isinstance(other, GenericVersion): 180 | return False 181 | return self.compare_versions(self._version, other._version) in (self._EQUAL, self._RIGHT_SMALLER) 182 | 183 | def __str__(self) -> str: 184 | return self._version 185 | -------------------------------------------------------------------------------- /mkosi/distributions/debian.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: LGPL-2.1+ 2 | 3 | import shutil 4 | import tempfile 5 | from collections.abc import Sequence 6 | from pathlib import Path 7 | 8 | from mkosi.archive import extract_tar 9 | from mkosi.bubblewrap import bwrap 10 | from mkosi.config import Architecture 11 | from mkosi.distributions import Distribution, DistributionInstaller, PackageType 12 | from mkosi.installer.apt import invoke_apt, setup_apt 13 | from mkosi.log import die 14 | from mkosi.state import MkosiState 15 | from mkosi.util import umask 16 | 17 | 18 | class Installer(DistributionInstaller): 19 | @classmethod 20 | def pretty_name(cls) -> str: 21 | return "Debian" 22 | 23 | @classmethod 24 | def filesystem(cls) -> str: 25 | return "ext4" 26 | 27 | @classmethod 28 | def package_type(cls) -> PackageType: 29 | return PackageType.deb 30 | 31 | @classmethod 32 | def default_release(cls) -> str: 33 | return "testing" 34 | 35 | @classmethod 36 | def default_tools_tree_distribution(cls) -> Distribution: 37 | return Distribution.debian 38 | 39 | @staticmethod 40 | def repositories(state: MkosiState, local: bool = True) -> list[str]: 41 | archives = ("deb", "deb-src") 42 | components = ' '.join(("main", *state.config.repositories)) 43 | 44 | if state.config.local_mirror and local: 45 | return [f"deb [trusted=yes] {state.config.local_mirror} {state.config.release} {components}"] 46 | 47 | mirror = state.config.mirror or "http://deb.debian.org/debian" 48 | signedby = "[signed-by=/usr/share/keyrings/debian-archive-keyring.gpg]" 49 | 50 | repos = [ 51 | f"{archive} {signedby} {mirror} {state.config.release} {components}" 52 | for archive in archives 53 | ] 54 | 55 | # Debug repos are typically not mirrored. 56 | url = "http://deb.debian.org/debian-debug" 57 | repos += [f"deb {signedby} {url} {state.config.release}-debug {components}"] 58 | 59 | if state.config.release in ("unstable", "sid"): 60 | return repos 61 | 62 | repos += [ 63 | f"{archive} {signedby} {mirror} {state.config.release}-updates {components}" 64 | for archive in archives 65 | ] 66 | 67 | # Security updates repos are never mirrored. 68 | url = "http://security.debian.org/debian-security " 69 | repos += [ 70 | f"{archive} {signedby} {url} {state.config.release}-security {components}" 71 | for archive in archives 72 | ] 73 | 74 | return repos 75 | 76 | @classmethod 77 | def setup(cls, state: MkosiState) -> None: 78 | setup_apt(state, cls.repositories(state)) 79 | 80 | @classmethod 81 | def install(cls, state: MkosiState) -> None: 82 | # Instead of using debootstrap, we replicate its core functionality here. Because dpkg does not have 83 | # an option to delay running pre-install maintainer scripts when it installs a package, it's 84 | # impossible to use apt directly to bootstrap a Debian chroot since dpkg will try to run a maintainer 85 | # script which depends on some basic tool to be available in the chroot from a deb which hasn't been 86 | # unpacked yet, causing the script to fail. To avoid these issues, we have to extract all the 87 | # essential debs first, and only then run the maintainer scripts for them. 88 | 89 | # First, we set up merged usr. 90 | # This list is taken from https://salsa.debian.org/installer-team/debootstrap/-/blob/master/functions#L1369. 91 | subdirs = ["bin", "sbin", "lib"] + { 92 | "amd64" : ["lib32", "lib64", "libx32"], 93 | "i386" : ["lib64", "libx32"], 94 | "mips" : ["lib32", "lib64"], 95 | "mipsel" : ["lib32", "lib64"], 96 | "mips64el" : ["lib32", "lib64", "libo32"], 97 | "loongarch64" : ["lib32", "lib64"], 98 | "powerpc" : ["lib64"], 99 | "ppc64" : ["lib32", "lib64"], 100 | "ppc64el" : ["lib64"], 101 | "s390x" : ["lib32"], 102 | "sparc" : ["lib64"], 103 | "sparc64" : ["lib32", "lib64"], 104 | "x32" : ["lib32", "lib64", "libx32"], 105 | }.get(state.config.distribution.architecture(state.config.architecture), []) 106 | 107 | with umask(~0o755): 108 | for d in subdirs: 109 | (state.root / d).symlink_to(f"usr/{d}") 110 | (state.root / f"usr/{d}").mkdir(parents=True, exist_ok=True) 111 | 112 | # Next, we invoke apt-get install to download all the essential packages. With DPkg::Pre-Install-Pkgs, 113 | # we specify a shell command that will receive the list of packages that will be installed on stdin. 114 | # By configuring Debug::pkgDpkgPm=1, apt-get install will not actually execute any dpkg commands, so 115 | # all it does is download the essential debs and tell us their full in the apt cache without actually 116 | # installing them. 117 | with tempfile.NamedTemporaryFile(mode="r") as f: 118 | cls.install_packages(state, [ 119 | "-oDebug::pkgDPkgPm=1", 120 | f"-oDPkg::Pre-Install-Pkgs::=cat >{f.name}", 121 | "?essential", "?name(usr-is-merged)", 122 | ], apivfs=False) 123 | 124 | essential = f.read().strip().splitlines() 125 | 126 | # Now, extract the debs to the chroot by first extracting the sources tar file out of the deb and 127 | # then extracting the tar file into the chroot. 128 | 129 | for deb in essential: 130 | with tempfile.NamedTemporaryFile() as f: 131 | bwrap(state, ["dpkg-deb", "--fsys-tarfile", deb], stdout=f) 132 | extract_tar(state, Path(f.name), state.root, log=False) 133 | 134 | # Finally, run apt to properly install packages in the chroot without having to worry that maintainer 135 | # scripts won't find basic tools that they depend on. 136 | 137 | cls.install_packages(state, [Path(deb).name.partition("_")[0].removesuffix(".deb") for deb in essential]) 138 | 139 | @classmethod 140 | def install_packages(cls, state: MkosiState, packages: Sequence[str], apivfs: bool = True) -> None: 141 | # Debian policy is to start daemons by default. The policy-rc.d script can be used choose which ones to 142 | # start. Let's install one that denies all daemon startups. 143 | # See https://people.debian.org/~hmh/invokerc.d-policyrc.d-specification.txt for more information. 144 | # Note: despite writing in /usr/sbin, this file is not shipped by the OS and instead should be managed by 145 | # the admin. 146 | policyrcd = state.root / "usr/sbin/policy-rc.d" 147 | with umask(~0o644): 148 | policyrcd.write_text("#!/bin/sh\nexit 101\n") 149 | 150 | invoke_apt(state, "apt-get", "update", apivfs=False) 151 | invoke_apt(state, "apt-get", "install", packages, apivfs=apivfs) 152 | install_apt_sources(state, cls.repositories(state, local=False)) 153 | 154 | policyrcd.unlink() 155 | 156 | for d in state.root.glob("boot/vmlinuz-*"): 157 | kver = d.name.removeprefix("vmlinuz-") 158 | vmlinuz = state.root / "usr/lib/modules" / kver / "vmlinuz" 159 | if not vmlinuz.exists(): 160 | shutil.copy2(d, vmlinuz) 161 | 162 | 163 | @classmethod 164 | def remove_packages(cls, state: MkosiState, packages: Sequence[str]) -> None: 165 | invoke_apt(state, "apt-get", "purge", packages) 166 | 167 | @classmethod 168 | def architecture(cls, arch: Architecture) -> str: 169 | a = { 170 | Architecture.arm64 : "arm64", 171 | Architecture.arm : "armhf", 172 | Architecture.alpha : "alpha", 173 | Architecture.x86_64 : "amd64", 174 | Architecture.x86 : "i386", 175 | Architecture.ia64 : "ia64", 176 | Architecture.loongarch64 : "loongarch64", 177 | Architecture.mips64_le : "mips64el", 178 | Architecture.mips_le : "mipsel", 179 | Architecture.parisc : "hppa", 180 | Architecture.ppc64_le : "ppc64el", 181 | Architecture.ppc64 : "ppc64", 182 | Architecture.riscv64 : "riscv64", 183 | Architecture.s390x : "s390x", 184 | Architecture.s390 : "s390", 185 | }.get(arch) 186 | 187 | if not a: 188 | die(f"Architecture {arch} is not supported by Debian") 189 | 190 | return a 191 | 192 | 193 | def install_apt_sources(state: MkosiState, repos: Sequence[str]) -> None: 194 | if not (state.root / "usr/bin/apt").exists(): 195 | return 196 | 197 | sources = state.root / "etc/apt/sources.list" 198 | if not sources.exists(): 199 | with sources.open("w") as f: 200 | for repo in repos: 201 | f.write(f"{repo}\n") 202 | --------------------------------------------------------------------------------