├── .gitignore ├── .vscode └── settings.json ├── Jenkinsfile ├── MANIFEST.in ├── README.md ├── TODO ├── activate-zfs-in-qubes-vm ├── bootstrap-chroot ├── deploy-packages-in-chroot ├── deploy-zfs ├── doc └── bitcoin.png ├── grub-zfs-fixer └── README.md ├── install-fedora-on-zfs ├── mypy.ini ├── pyproject.toml ├── run-in-fs-context ├── src └── installfedoraonzfs │ ├── __init__.py │ ├── breakingbefore.py │ ├── cmd.py │ ├── git.py │ ├── log.py │ ├── pm.py │ ├── retry.py │ ├── test_base.py │ └── vm.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .tox 3 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true 3 | } -------------------------------------------------------------------------------- /Jenkinsfile: -------------------------------------------------------------------------------- 1 | // https://github.com/Rudd-O/shared-jenkins-libraries 2 | @Library('shared-jenkins-libraries@master') _ 3 | 4 | def buildCmdline(pname, myBuildFrom, mySourceBranch, myLuks, mySeparateBoot, myRelease) { 5 | if (mySeparateBoot == "yes") { 6 | mySeparateBoot = "--separate-boot=boot-${pname}.img" 7 | } else { 8 | mySeparateBoot = "" 9 | } 10 | if (myBuildFrom == "RPMs") { 11 | myBuildFrom = "--use-prebuilt-rpms=out/fc${myRelease}/" 12 | } else { 13 | myBuildFrom = "" 14 | } 15 | if (myLuks == "yes") { 16 | myLuks = "--luks-password=seed" 17 | } else { 18 | myLuks = "" 19 | } 20 | if (mySourceBranch != "") { 21 | mySourceBranch = "--use-branch=${env.SOURCE_BRANCH}" 22 | } 23 | 24 | def program = """ 25 | yumcache="/var/cache/zfs-fedora-installer" 26 | mntdir="\$PWD/mnt/${pname}" 27 | mkdir -p "\$mntdir" 28 | volsize=10000 29 | cmd=src/install-fedora-on-zfs 30 | set -x 31 | set +e 32 | ret=0 33 | ls -l 34 | sudo \\ 35 | python3 -u "\$cmd" \\ 36 | ${myBuildFrom} \\ 37 | ${mySourceBranch} \\ 38 | ${myLuks} \\ 39 | ${mySeparateBoot} \\ 40 | --releasever=${myRelease} \\ 41 | --trace-file=/dev/stderr \\ 42 | --workdir="\$mntdir" \\ 43 | --yum-cachedir="\$yumcache" \\ 44 | --host-name="\$HOST_NAME" \\ 45 | --pool-name="${pname}" \\ 46 | --vol-size=\$volsize \\ 47 | --swap-size=256 \\ 48 | --root-password=seed \\ 49 | --chown="\$USER" \\ 50 | --chgrp=`groups | cut -d " " -f 1` \\ 51 | root-${pname}.img >&2 52 | ret="\$?" 53 | >&2 echo ==============Diagnostics================== 54 | >&2 sudo zpool list || true 55 | >&2 sudo blkid || true 56 | >&2 sudo lsblk || true 57 | >&2 sudo losetup -la || true 58 | >&2 sudo mount || true 59 | >&2 echo Return value of program: "\$ret" 60 | >&2 echo =========== End Diagnostics =============== 61 | if [ "\$ret" == "120" ] ; then ret=0 ; fi 62 | exit "\$ret" 63 | """.stripIndent().trim() 64 | return program 65 | } 66 | 67 | pipeline { 68 | 69 | agent none 70 | 71 | options { 72 | checkoutToSubdirectory 'src' 73 | } 74 | 75 | parameters { 76 | string defaultValue: "zfs/${currentBuild.projectName}", description: '', name: 'UPSTREAM_PROJECT', trim: true 77 | string defaultValue: "", description: 'Use a specific ZFS build number for this test run', name: 'UPSTREAM_PROJECT_BUILD_NUMBER', trim: true 78 | string defaultValue: 'master', description: '', name: 'SOURCE_BRANCH', trim: true 79 | string defaultValue: "grub-zfs-fixer/master", description: '', name: 'GRUB_UPSTREAM_PROJECT', trim: true 80 | string defaultValue: 'no', description: '', name: 'BUILD_FROM_SOURCE', trim: true 81 | string defaultValue: 'yes', description: '', name: 'BUILD_FROM_RPMS', trim: true 82 | string defaultValue: 'seed', description: '', name: 'POOL_NAME', trim: true 83 | string defaultValue: 'seed.dragonfear', description: '', name: 'HOST_NAME', trim: true 84 | string defaultValue: 'yes', description: '', name: 'SEPARATE_BOOT', trim: true 85 | // Having trouble with LUKS being yes on Fedora 25. 86 | string defaultValue: 'no', description: '', name: 'LUKS', trim: true 87 | string defaultValue: '', description: "Which Fedora releases to build for (empty means the job's default).", name: 'RELEASE', trim: true 88 | } 89 | 90 | stages { 91 | stage('Preparation') { 92 | agent { label 'master' } 93 | steps { 94 | announceBeginning() 95 | script{ 96 | if (params.RELEASE == '') { 97 | env.RELEASE = funcs.loadParameter('RELEASE', '30') 98 | } else { 99 | env.RELEASE = params.RELEASE 100 | } 101 | } 102 | script { 103 | env.GIT_HASH = sh ( 104 | script: "cd src && git rev-parse --short HEAD", 105 | returnStdout: true 106 | ).trim() 107 | println "Git hash is reported as ${env.GIT_HASH}" 108 | } 109 | } 110 | } 111 | stage('Setup environment') { 112 | agent { label 'master' } 113 | steps { 114 | script { 115 | env.GRUB_UPSTREAM_PROJECT = params.GRUB_UPSTREAM_PROJECT 116 | if (funcs.isUpstreamCause(currentBuild)) { 117 | def upstreamProject = funcs.getUpstreamProject(currentBuild) 118 | env.UPSTREAM_PROJECT = upstreamProject 119 | env.SOURCE_BRANCH = "" 120 | env.BUILD_FROM_SOURCE = "no" 121 | env.BUILD_FROM_RPMS = "yes" 122 | } else { 123 | env.UPSTREAM_PROJECT = params.UPSTREAM_PROJECT 124 | env.SOURCE_BRANCH = params.SOURCE_BRANCH 125 | env.BUILD_FROM_SOURCE = params.BUILD_FROM_SOURCE 126 | env.BUILD_FROM_RPMS = params.BUILD_FROM_RPMS 127 | } 128 | if (env.UPSTREAM_PROJECT == "") { 129 | currentBuild.result = 'ABORTED' 130 | error("UPSTREAM_PROJECT must be set to a project containing built ZFS RPMs.") 131 | } 132 | if (env.BUILD_FROM_SOURCE == "yes" && env.BUILD_FROM_RPMS == "yes") { 133 | env.BUILD_FROM = "source RPMs" 134 | } else if (env.BUILD_FROM_SOURCE == "yes" && env.BUILD_FROM_RPMS == "no") { 135 | env.BUILD_FROM = "source" 136 | } else if (env.BUILD_FROM_SOURCE == "no" && env.BUILD_FROM_RPMS == "yes") { 137 | env.BUILD_FROM = "RPMs" 138 | } else { 139 | currentBuild.result = 'ABORTED' 140 | error("At least one of BUILD_FROM_SOURCE and BUILD_FROM_RPMS must be set to yes.") 141 | } 142 | if (env.BUILD_FROM_SOURCE == "yes" && env.SOURCE_BRANCH == "") { 143 | currentBuild.result = 'ABORTED' 144 | error("SOURCE_BRANCH must be set when BUILD_FROM_SOURCE is set to yes.") 145 | } 146 | env.BUILD_TRIGGER = funcs.describeCause(currentBuild) 147 | currentBuild.description = "Test of ${env.BUILD_FROM} from source branch ${env.SOURCE_BRANCH} and RPMs from ${env.UPSTREAM_PROJECT}. ${env.BUILD_TRIGGER}." 148 | } 149 | } 150 | } 151 | stage('Copy from master') { 152 | agent { label 'master' } 153 | when { allOf { not { equals expected: 'NOT_BUILT', actual: currentBuild.result }; equals expected: "", actual: "" } } 154 | steps { 155 | dir("out") { 156 | deleteDir() 157 | } 158 | script { 159 | if (params.UPSTREAM_PROJECT_BUILD_NUMBER == '') { 160 | copyArtifacts( 161 | projectName: env.UPSTREAM_PROJECT, 162 | fingerprintArtifacts: true, 163 | selector: upstream(fallbackToLastSuccessful: true) 164 | ) 165 | } else { 166 | copyArtifacts( 167 | projectName: env.UPSTREAM_PROJECT, 168 | fingerprintArtifacts: true, 169 | selector: specific(params.UPSTREAM_PROJECT_BUILD_NUMBER) 170 | ) 171 | } 172 | } 173 | copyArtifacts( 174 | projectName: env.GRUB_UPSTREAM_PROJECT, 175 | fingerprintArtifacts: true, 176 | selector: upstream(fallbackToLastSuccessful: true) 177 | ) 178 | sh '{ set +x ; } >/dev/null 2>&1 ; find out/*/*.rpm -type f | sort | grep -v debuginfo | grep -v debugsource | grep -v python | xargs sha256sum > rpmsums' 179 | stash includes: 'out/*/*.rpm', name: 'rpms', excludes: '**/*debuginfo*,**/*debugsource*,**/*python*' 180 | stash includes: 'rpmsums', name: 'rpmsums' 181 | stash includes: 'src/**', name: 'zfs-fedora-installer' 182 | script { 183 | env.DETECTED_RELEASES = sh ( 184 | script: "cd out && ls -1 */zfs-dracut*noarch.rpm | sed 's|/.*||' | sed 's|fc||'", 185 | returnStdout: true 186 | ).trim().replace("\n", ' ') 187 | println "The detected releases are ${env.DETECTED_RELEASES}" 188 | if (params.RELEASE == '') { 189 | println "Overriding releases ${env.RELEASE} with detected releases ${env.DETECTED_RELEASES}" 190 | env.RELEASE = env.DETECTED_RELEASES 191 | } 192 | } 193 | } 194 | } 195 | stage('Parallelize') { 196 | agent { label 'fedorazfs' } 197 | options { skipDefaultCheckout() } 198 | when { not { equals expected: 'NOT_BUILT', actual: currentBuild.result } } 199 | steps { 200 | script { 201 | stage("Check agent") { 202 | sh( 203 | script: """#!/bin/sh 204 | /bin/true 205 | """, 206 | label: "Agent is OK" 207 | ) 208 | } 209 | stage("Unstash RPMs") { 210 | script { 211 | timeout(time: 10, unit: 'MINUTES') { 212 | sh '{ set +x ; } >/dev/null 2>&1 ; find out/*/*.rpm -type f | sort | grep -v debuginfo | grep -v debugsource | grep -v python | xargs sha256sum > local-rpmsums' 213 | unstash "rpmsums" 214 | def needsunstash = sh ( 215 | script: ''' 216 | set +e ; set -x 217 | output=$(diff -Naur local-rpmsums rpmsums 2>&1) 218 | if [ "$?" = "0" ] 219 | then 220 | echo MATCH 221 | else 222 | echo "$output" >&2 223 | fi 224 | ''', 225 | returnStdout: true 226 | ).trim() 227 | if (needsunstash != "MATCH") { 228 | println "Need to unstash RPMs from master" 229 | dir("out") { 230 | deleteDir() 231 | } 232 | unstash "rpms" 233 | } 234 | } 235 | } 236 | } 237 | stage("Unstash zfs-fedora-installer") { 238 | unstash "zfs-fedora-installer" 239 | } 240 | stage("Activate ZFS") { 241 | script { 242 | lock("activatezfs") { 243 | if (!sh(script: "lsmod", returnStdout: true).contains("zfs")) { 244 | timeout(time: 20, unit: 'MINUTES') { 245 | sh 'if test -f /usr/sbin/setenforce ; then sudo setenforce 0 || exit $? ; fi' 246 | def program = ''' 247 | deps="rsync rpm-build e2fsprogs dosfstools cryptsetup qemu gdisk python3" 248 | rpm -q \$deps || sudo dnf install -qy \$deps 249 | '''.stripIndent().trim() 250 | sh program 251 | sh ''' 252 | sudo modprobe zfs || { 253 | eval $(cat /etc/os-release) 254 | if test -d out/fc$VERSION_ID/ ; then 255 | sudo src/deploy-zfs --use-prebuilt-rpms out/fc$VERSION_ID/ 256 | else 257 | sudo src/deploy-zfs 258 | fi 259 | sudo modprobe zfs 260 | sudo service systemd-udevd restart 261 | } 262 | ''' 263 | } 264 | } 265 | } 266 | } 267 | } 268 | stage("Test") { 269 | script { 270 | def axisList = [ 271 | env.RELEASE.split(' '), 272 | env.BUILD_FROM.split(' '), 273 | params.LUKS.split(' '), 274 | params.SEPARATE_BOOT.split(' '), 275 | ] 276 | def parallelized = funcs.combo( 277 | { 278 | return { 279 | stage("${it[0]} ${it[1]} ${it[2]} ${it[3]}") { 280 | script { 281 | println "Stage ${it[0]} ${it[1]} ${it[2]} ${it[3]}" 282 | def myRelease = it[0] 283 | def myBuildFrom = it[1] 284 | def myLuks = it[2] 285 | def mySeparateBoot = it[3] 286 | def pname = "${env.POOL_NAME}_${env.BRANCH_NAME}_${env.BUILD_NUMBER}_${env.GIT_HASH}_${myRelease}_${myBuildFrom}_${myLuks}_${mySeparateBoot}" 287 | def mySourceBranch = "" 288 | if (env.SOURCE_BRANCH != "") { 289 | mySourceBranch = env.SOURCE_BRANCH 290 | } 291 | script { 292 | timeout(60) { 293 | def program = buildCmdline(pname, myBuildFrom, mySourceBranch, myLuks, mySeparateBoot, myRelease) 294 | def desc = "============= REPORT ==============\nPool name: ${pname}\nBranch name: ${env.BRANCH_NAME}\nGit hash: ${env.GIT_HASH}\nRelease: ${myRelease}\nBuild from: ${myBuildFrom}\nLUKS: ${myLuks}\nSeparate boot: ${mySeparateBoot}\nSource branch: ${env.SOURCE_BRANCH}\n============= END REPORT ==============" 295 | println "${desc}\n\n" + "Program that will be executed:\n${program}" 296 | sh( 297 | script: program, 298 | label: "Command run" 299 | ) 300 | } 301 | } 302 | } 303 | } 304 | } 305 | }, 306 | axisList 307 | ) 308 | parallelized.failFast = true 309 | parallel parallelized 310 | } 311 | } 312 | } 313 | } 314 | } 315 | } 316 | post { 317 | always { 318 | node('master') { 319 | announceEnd(currentBuild.currentResult) 320 | } 321 | } 322 | } 323 | } 324 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include Jenkinsfile 2 | include build.parameters 3 | include Makefile 4 | include tox.ini 5 | include mypy.ini 6 | include README.md 7 | include TODO 8 | include activate-zfs-in-qubes-vm 9 | include bootstrap-chroot 10 | include deploy-packages-in-chroot 11 | include deploy-zfs 12 | include install-fedora-on-zfs 13 | inclde doc/* 14 | include grub-zfs-fixer/* 15 | include run-in-fs-context 16 | global-exclude *.py[cod] 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Fedora on ZFS root installer 2 | ============================ 3 | 4 | | Donate to support this free software | 5 | |:------------------------------------:| 6 | | | 7 | | [1NhK4UBCyBx4bDgxLVHtAK6EZjSxc8441V](bitcoin:1NhK4UBCyBx4bDgxLVHtAK6EZjSxc8441V) | 8 | 9 | This project contains two programs. 10 | 11 | *Program number one* is `install-fedora-on-zfs`. This script will create an image file containing a fresh, minimal Fedora 19+ installation on a ZFS pool. This pool can: 12 | 13 | * be booted on a QEMU / virt-manager virtual machine, or 14 | * be written to a block device, after which you can grow the last partition to make ZFS use the extra space. 15 | 16 | If you specify a path to a device instead of a path to an image file, then the device will be used instead. The resulting image is obviously bootable and fully portable between computers, until the next time dracut regenerates the initial RAM disks, after which the initial RAM disks will be tailored to the specific hardware of the machine where it ran. Make sure that the device is large enough to contain the whole install. 17 | 18 | `install-fedora-on-zfs` requires a working ZFS install on the machine you are running it. See below for instructions. 19 | 20 | *Program number two* is `deploy-zfs`. This script will deploy ZFS, ZFS-Dracut and `grub-zfs-fixer` via DKMS RPMs to a running Fedora system, and most recently to a Qubes OS 4.2 system. That system can then be converted to a full ZFS on root system if you so desire. Here are guides for various OSes: 21 | 22 | * [Fedora](https://rudd-o.com/linux-and-free-software/installing-fedora-on-top-of-zfs) 23 | * [Qubes OS](https://rudd-o.com/linux-and-free-software/how-to-install-zfs-on-qubes-os) 24 | 25 | To keep the ZFS packages within the deployed system up-to-date, it's recommended that you use the excellent [ZFS updates](https://github.com/Rudd-O/ansible-samples/tree/master/zfsupdates) Ansible playbook, built specifically for this purpose. 26 | 27 | See below for setup instructions. 28 | 29 | Usage of `install-fedora-on-zfs` 30 | -------------------------------- 31 | 32 | ./install-fedora-on-zfs --help 33 | usage: install-fedora-on-zfs [-h] [--vol-size VOLSIZE] 34 | [--separate-boot BOOTDEV] [--boot-size BOOTSIZE] 35 | [--pool-name POOLNAME] [--host-name HOSTNAME] 36 | [--root-password ROOTPASSWORD] 37 | [--swap-size SWAPSIZE] [--releasever VER] 38 | [--luks-password LUKSPASSWORD] 39 | [--break-before STAGE] 40 | [--shell-before STAGE] 41 | [--use-prebuilt-rpms DIR] VOLDEV 42 | 43 | Install a minimal Fedora system inside a ZFS pool within a disk image or 44 | device 45 | 46 | positional arguments: 47 | VOLDEV path to volume (device to use or regular file to 48 | create) 49 | 50 | optional arguments: 51 | -h, --help show this help message and exit 52 | --vol-size VOLSIZE volume size in MiB (default 11000) 53 | --separate-boot BOOTDEV 54 | place /boot in a separate volume 55 | --boot-size BOOTSIZE boot partition size in MiB, or boot volume size in 56 | MiB, when --separate-boot is specified (default 256) 57 | --pool-name POOLNAME pool name (default tank) 58 | --host-name HOSTNAME host name (default localhost.localdomain) 59 | --root-password ROOTPASSWORD 60 | root password (default password) 61 | --swap-size SWAPSIZE swap volume size in MiB (default 1024) 62 | --releasever VER Fedora release version (default the same as the 63 | computer you are installing on) 64 | --luks-password LUKSPASSWORD 65 | LUKS password to encrypt the ZFS volume with (default 66 | no encryption) 67 | --no-cleanup if an error occurs, do not clean up working volumes 68 | --use-prebuilt-rpms DIR 69 | use the pre-built (DKMS/tools) ZFS RPMs in 70 | this directory (default: build ZFS RPMs 71 | within the chroot) 72 | 73 | After setup is done, you can use `dd` to transfer the image(s) to the appropriate media (perhaps an USB drive) for booting. See below for examples and more information. 74 | 75 | Usage of `deploy-zfs` 76 | --------------------- 77 | 78 | usage: deploy-zfs [-h] [--use-prebuilt-rpms DIR] [--no-cleanup] 79 | 80 | Install ZFS on a running system 81 | 82 | optional arguments: 83 | -h, --help show this help message and exit 84 | --use-prebuilt-rpms DIR 85 | also install pre-built ZFS, GRUB and other RPMs 86 | in this directory, except for debuginfo packages 87 | within the directory (default: build ZFS and GRUB 88 | RPMs, within the system) 89 | --no-cleanup if an error occurs, do not clean up temporary mounts 90 | and files 91 | 92 | Details about the `install-fedora-on-zfs` installation process 93 | -------------------------------------------------------------- 94 | 95 | 1. The specified device(s) / file(s) will be prepared for the ZFS installation. If you specified a separate boot device, then it will be partitioned with a `/boot` partition, and the main device will be entirely used for a ZFS pool. Otherwise, the main device will be partitioned between a `/boot` partition (which will use about 256 MB of the space on the device / file) and a ZFS pool (which will use the rest of the space available on the device / file, minus 16 MB at the end). 96 | 2. If you requested encryption, the device containing the ZFS pool is encrypted using LUKS, and the soon-to-be-done system is set up to use LUKS encryption on boot. Be ware that you will be prompted for this password interactively at a later point. 97 | 3. Essential core packages (`yum`, `bash`, `basesystem`, `vim-minimal`, `nano`, `kernel`, `grub2`) will be installed on the system. 98 | 4. Within the freshly installed OS, my git repositories for ZFS will be cloned and built as RPMs. 99 | 5. The RPMs built will be installed in the OS root file system. 100 | 6. `grub2-mkconfig` will be patched so it works with ZFS on root. Yum will be configured to ignore grub updates. 101 | 7. QEMU will be executed, booting the newly-created image with specific instructions to install the bootloader and perform other janitorial tasks. At this point, if you requested LUKS encryption, you will be prompted for the LUKS password. 102 | 8. Everything the script did will be cleaned up, leaving the file / block device ready to be booted off a QEMU virtual machine, or whatever device you write the image to. 103 | 104 | Requirements for `install-fedora-on-zfs` 105 | ---------------------------------------- 106 | 107 | These are the programs you need to execute `install-fedora-on-zfs`: 108 | 109 | * a working ZFS install (see below) 110 | * python 111 | * qemu-kvm 112 | * losetup 113 | * mkfs.ext4 114 | * grub2 115 | * rsync 116 | * yum 117 | * dracut 118 | * mkswap 119 | * cryptsetup 120 | 121 | Getting ZFS installed on your machine for `install-fedora-on-zfs` to use 122 | ------------------------------------------------------------------------ 123 | 124 | Before using this program in your computer, you need to have a functioning copy of ZFS in it. Run the `deploy-zfs` program to get it. 125 | 126 | After doing so, run: 127 | 128 | sudo udevadm control --reload-rules 129 | 130 | Now you can verify that the `zfs` command works. If it does, then you are ready to run the `install-fedora-on-zfs` program. 131 | 132 | Transferring the `install-fedora-on-zfs` images to media 133 | -------------------------------------------------------- 134 | 135 | You can transfer the resulting disk images to larger media afterward. The usual `dd if=/path/to/root/image of=/dev/path/to/disk/device` advice works fine. Here is an example showing how to write an image file that was just created to `/dev/sde`: 136 | 137 | dd if=/path/to/image/file of=/dev/sde 138 | 139 | Of course, if you chose to have a separate boot image (`--separate-boot`), then you can write the boot image and the volume image `VOLDEV` to separate devices. 140 | 141 | **Security warning**: disk images created this way should not be reused across hosts, because several unique identifiers (and the LUKS master key, in case of encrypted images) will then be shared across those hosts. You should create distinct images for distinct systems instead. Changes are in the pipeline to uniquify installs and strip them of identifying informatin, precisely to prevent this problem. 142 | 143 | Taking advantage of increased disk space in the target media 144 | ------------------------------------------------------------ 145 | 146 | You can also tell ZFS (and LUKS) to use the newly available space, if the target media is larger than the images. This is usually the case, because the destination device tends to be significantly larger than the partitions created when the installation process ran. 147 | 148 | If you used the default single volume mode: 149 | 150 | 1. Alter the partition table so the last partition ends 16 MB before the last sector. 151 | 2. Reread the partition table (might require a reboot). 152 | 3. (If you used encryption) tell LUKS to resize the volume via `cryptsetup resize /dev/mapper/luks-`. 153 | 4. `zpool online -e ` to have ZFS recognize the full partition size. In some circumstances you may have to `zpool set autoexpand=on ` to inform the pool that its underlying device has grown. 154 | 155 | If you used the boot-on-separate-device mode: 156 | 157 | 3. (If you used encryption) tell LUKS to resize the volume via `cryptsetup resize /dev/mapper/luks-`. 158 | 4. `zpool online -e ` to have ZFS recognize the full partition size. See above for instructions on how to use `zpool set autoexpand=on` if this does not work. 159 | 160 | Of course, you can also extend the pool to other disk devices. If you do so, make sure to regenerate the initial RAM disks with `dracut -fv` (which will regenerate the RAM disk for the currently booted kernel). 161 | 162 | Notes / known issues 163 | -------------------- 164 | 165 | This script only works on Fedora hosts. I accept patches to make it work on CentOS. 166 | 167 | License 168 | ------- 169 | 170 | GNU GPL v3. 171 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | Create program that implements this guide. 2 | 3 | https://openzfs.github.io/openzfs-docs/Getting%20Started/Fedora/Root%20on%20ZFS/2-system-installation.html 4 | -------------------------------------------------------------------------------- /activate-zfs-in-qubes-vm: -------------------------------------------------------------------------------- 1 | # #!/bin/bash 2 | 3 | set -ex 4 | 5 | [ "${FLOCKER}" == "" ] && { 6 | flocker=~/.activate-zfs-in-qubes-vm 7 | exec env FLOCKER=$flocker flock -x "$flocker" "$0" "$@" 8 | } || : 9 | 10 | function cleanup() { 11 | if [ -d "$tmprpmdir" ] ; then 12 | rm -rf -- "$tmprpmdir" 13 | fi 14 | if [ -f "/etc/yum.repos.d/zfs-temp.repo" ] ; then 15 | rm -f "/etc/yum.repos.d/zfs-temp.repo" 16 | fi 17 | } 18 | 19 | trap cleanup EXIT 20 | 21 | function deployzfshere() { 22 | 23 | local cmd 24 | local opts 25 | local release 26 | local kernel 27 | 28 | pathtorpms="$1" 29 | kernel="$2" 30 | arch="$3" 31 | 32 | # FIXME: ultimately we must install the kernel-devel package using this mechanism because the kernel upgrade cycle happens outside the VM lifecycle, in the VM host to be exact. VMs themselves have no kernel within them. 33 | command -v dnf && { cmd=dnf ; opts="--best --allowerasing" ; } || { cmd=yum ; opts= ; } 34 | version=$(echo "$kernel" | awk -F - ' { print $1 } ') 35 | release=$(echo "$kernel" | awk -F - ' { print $2 } ' | sed -r 's/[.]([a-z0-9_]+)$//' ) 36 | if uname -r | grep -q pvops.qubes 37 | then 38 | rpm -q kernel-devel-"$kernel" || { 39 | url=https://yum.qubes-os.org/r3.2/current/dom0/fc23/rpm 40 | $cmd install $opts -qy $url/kernel-devel-"$kernel".rpm 41 | } 42 | else 43 | rpm -q kernel-"$kernel" kernel-devel-"$kernel" kernel-modules-"$kernel" || { 44 | $cmd install $opts -y kernel-"$kernel" kernel-devel-"$kernel" kernel-modules-"$kernel" || { 45 | url=https://kojipkgs.fedoraproject.org/packages/kernel/$version/$release/$arch 46 | $cmd install $opts -y $url/kernel-"$kernel".rpm $url/kernel-devel-"$kernel".rpm $url/kernel-modules-"$kernel".rpm 47 | } 48 | } 49 | fi 50 | rpm -q createrepo_c || $cmd install $opts -y createrepo_c 51 | rpm -q elfutils-devel || $cmd install $opts -y elfutils-devel 52 | rpm -q rsync || $cmd install $opts -y rsync 53 | 54 | release=`rpm -q fedora-release fedora-release-cloud grub2 --queryformat '%{version}\n' | grep -v ' is not installed' | tail -1` 55 | if [ "$release" == "" ] ; then echo "No release detected, aborting" >&2 ; exit 8 ; fi 56 | 57 | tmprpmdir=`mktemp -d` 58 | rsync -av "$pathtorpms"/ "$tmprpmdir"/ 59 | pushd "$tmprpmdir" 60 | find -type f | grep '[.]'fc"$release" | tee /dev/stderr > pkglist 61 | createrepo_c -v -i pkglist . 62 | popd 63 | 64 | # Now we create the temporary yum / dnf repository. 65 | cat > /etc/yum.repos.d/zfs-temp.repo << EOF 66 | [zfs-temp] 67 | name=ZFS temporary deployer 68 | baseurl=file://$tmprpmdir 69 | enabled=0 70 | gpgcheck=0 71 | metadata_expire=1 72 | EOF 73 | 74 | # Now we install the RPMs proper. 75 | # FIXME: installation of arbitrary RPMs here really should not happen. The following RPMs are being supplied by Jenkins itself. 76 | # This is dangerous for several reasons. What really ought to happen is a deploy using yum from a known, prekeyed, and signed 77 | # yum repository, but I don't yet have a file server with trusted packages, and I am working on that. 78 | $cmd $opts -y --enablerepo=zfs-temp install \ 79 | zfs \ 80 | zfs-dkms \ 81 | 82 | cleanup 83 | 84 | dkms autoinstall 85 | echo =============== DKMS BUILD LOGS ================= >&2 86 | for a in /var/lib/dkms/zfs/*/build/make.log /var/lib/dkms/zfs/*/log/make.log ; do 87 | test -f "$a" || continue 88 | echo === "$a" === >&2 89 | cat "$a" >&2 90 | done 91 | 92 | } 93 | 94 | if [ "$1" == "" ] ; then 95 | echo usage: sudo "$0" "" >&2 96 | exit 64 97 | fi 98 | 99 | fullpathtorpms=$( realpath "$1" ) 100 | arch=`uname -m` 101 | kernel=`uname -r` 102 | 103 | if [ ! -f "/var/lib/dkms/zfs/kernel-$kernel-$arch/module/zfs.ko" -a ! -f "/var/lib/dkms/zfs/kernel-$kernel-$arch/module/zfs.ko.xz" ] ; then 104 | deployzfshere "$fullpathtorpms" "$kernel" "$arch" 105 | fi 106 | 107 | lsmod | grep -q zlib || { 108 | if find /lib/modules/"$kernel" -name 'zlib.ko*' | grep -q zlib ; then modprobe zlib ; fi 109 | } 110 | for x in `seq 20` ; do 111 | if lsmod | grep zfs ; then break ; fi 112 | for mod in /var/lib/dkms/zfs/kernel-$kernel-$arch/module/*.ko /var/lib/dkms/zfs/kernel-$kernel-$arch/module/*.ko.xz ; do 113 | test -f "$mod" || continue 114 | insmod $mod || true 115 | done 116 | done 117 | lsmod | grep zfs 118 | -------------------------------------------------------------------------------- /bootstrap-chroot: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import os 4 | import sys 5 | 6 | sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src")) 7 | import installfedoraonzfs # noqa: E402, I001 8 | 9 | 10 | if __name__ == "__main__": 11 | sys.exit(installfedoraonzfs.bootstrap_chroot()) 12 | -------------------------------------------------------------------------------- /deploy-packages-in-chroot: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import os 4 | import sys 5 | 6 | sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src")) 7 | import installfedoraonzfs.pm 8 | 9 | 10 | if __name__ == "__main__": 11 | sys.exit(installfedoraonzfs.pm.deploypackagesinchroot()) 12 | -------------------------------------------------------------------------------- /deploy-zfs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import os 4 | import sys 5 | 6 | sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src")) 7 | import installfedoraonzfs 8 | 9 | 10 | if __name__ == "__main__": 11 | sys.exit(installfedoraonzfs.deploy_zfs()) 12 | -------------------------------------------------------------------------------- /doc/bitcoin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rudd-O/zfs-fedora-installer/00ba9121cd4367cc4406ab1614e0f90a1a8b1981/doc/bitcoin.png -------------------------------------------------------------------------------- /grub-zfs-fixer/README.md: -------------------------------------------------------------------------------- 1 | GRUB compatibility for booting off of ZFS root file systems 2 | =========================================================== 3 | 4 | This project has moved to https://github.com/Rudd-O/grub-zfs-fixer . 5 | -------------------------------------------------------------------------------- /install-fedora-on-zfs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import os 4 | import sys 5 | 6 | sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src")) 7 | import installfedoraonzfs 8 | 9 | 10 | if __name__ == "__main__": 11 | sys.exit(installfedoraonzfs.install_fedora_on_zfs()) 12 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | strict = True 3 | mypy_path = $MYPY_CONFIG_FILE_DIR/src 4 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.ruff] 2 | line-length = 88 3 | select = [ 4 | "B002", # Python does not support the unary prefix increment 5 | "B007", # Loop control variable {name} not used within loop body 6 | "B014", # Exception handler with duplicate exception 7 | "B023", # Function definition does not bind loop variable {name} 8 | "B026", # Star-arg unpacking after a keyword argument is strongly discouraged 9 | "C", # complexity 10 | "COM818", # Trailing comma on bare tuple prohibited 11 | "D", # docstrings 12 | "DTZ003", # Use datetime.now(tz=) instead of datetime.utcnow() 13 | "DTZ004", # Use datetime.fromtimestamp(ts, tz=) instead of datetime.utcfromtimestamp(ts) 14 | "E", # pycodestyle 15 | "F", # pyflakes/autoflake 16 | "G", # flake8-logging-format 17 | "I", # isort 18 | "ICN001", # import concentions; {name} should be imported as {asname} 19 | "ISC001", # Implicitly concatenated string literals on one line 20 | "N804", # First argument of a class method should be named cls 21 | "N805", # First argument of a method should be named self 22 | "N815", # Variable {name} in class scope should not be mixedCase 23 | "PGH001", # No builtin eval() allowed 24 | "PGH004", # Use specific rule codes when using noqa 25 | "PLC0414", # Useless import alias. Import alias does not rename original package. 26 | "PLC", # pylint 27 | "PLE", # pylint 28 | "PLR", # pylint 29 | "PLW", # pylint 30 | "Q000", # Double quotes found but single quotes preferred 31 | "RUF006", # Store a reference to the return value of asyncio.create_task 32 | "S102", # Use of exec detected 33 | "S103", # bad-file-permissions 34 | "S108", # hardcoded-temp-file 35 | "S306", # suspicious-mktemp-usage 36 | "S307", # suspicious-eval-usage 37 | "S313", # suspicious-xmlc-element-tree-usage 38 | "S314", # suspicious-xml-element-tree-usage 39 | "S315", # suspicious-xml-expat-reader-usage 40 | "S316", # suspicious-xml-expat-builder-usage 41 | "S317", # suspicious-xml-sax-usage 42 | "S318", # suspicious-xml-mini-dom-usage 43 | "S319", # suspicious-xml-pull-dom-usage 44 | "S320", # suspicious-xmle-tree-usage 45 | "S601", # paramiko-call 46 | "S602", # subprocess-popen-with-shell-equals-true 47 | "S604", # call-with-shell-equals-true 48 | "S608", # hardcoded-sql-expression 49 | "S609", # unix-command-wildcard-injection 50 | "SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass 51 | "SIM117", # Merge with-statements that use the same scope 52 | "SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys() 53 | "SIM201", # Use {left} != {right} instead of not {left} == {right} 54 | "SIM208", # Use {expr} instead of not (not {expr}) 55 | "SIM212", # Use {a} if {a} else {b} instead of {b} if not {a} else {a} 56 | "SIM300", # Yoda conditions. Use 'age == 42' instead of '42 == age'. 57 | "SIM401", # Use get from dict with default instead of an if block 58 | "T100", # Trace found: {name} used 59 | "T20", # flake8-print 60 | "TID251", # Banned imports 61 | "TRY004", # Prefer TypeError exception for invalid type 62 | "TRY200", # Use raise from to specify exception cause 63 | "TRY302", # Remove exception handler; error is immediately re-raised 64 | "UP", # pyupgrade 65 | "W", # pycodestyle 66 | ] 67 | ignore = [ 68 | "D202", # No blank lines allowed after function docstring 69 | "D203", # 1 blank line required before class docstring 70 | "D213", # Multi-line docstring summary should start at the second line 71 | "D406", # Section name should end with a newline 72 | "D407", # Section name underlining 73 | ] 74 | 75 | [tool.ruff.isort] 76 | force-sort-within-sections = true 77 | combine-as-imports = true 78 | split-on-trailing-comma = false -------------------------------------------------------------------------------- /run-in-fs-context: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import os 4 | import sys 5 | 6 | sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src")) 7 | import installfedoraonzfs 8 | 9 | 10 | if __name__ == "__main__": 11 | sys.exit(installfedoraonzfs.run_command_in_filesystem_context()) 12 | -------------------------------------------------------------------------------- /src/installfedoraonzfs/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import argparse 4 | import contextlib 5 | import glob 6 | import logging 7 | import multiprocessing 8 | import os 9 | from os.path import join as j 10 | from pathlib import Path 11 | import platform 12 | import shlex 13 | import shutil 14 | import signal # noqa: F401 15 | import subprocess 16 | import tempfile 17 | import time 18 | from typing import Any, Callable, Generator, Sequence 19 | 20 | from installfedoraonzfs import pm 21 | from installfedoraonzfs.breakingbefore import BreakingBefore, break_stages, shell_stages 22 | from installfedoraonzfs.cmd import ( 23 | Popen, 24 | bindmount, 25 | check_call, 26 | check_call_silent, 27 | check_call_silent_stdout, 28 | check_output, 29 | create_file, 30 | delete_contents, 31 | filetype, 32 | get_associated_lodev, 33 | get_output_exitcode, 34 | ismount, 35 | losetup, 36 | mount, 37 | readlines, 38 | readtext, 39 | umount, 40 | writetext, 41 | ) 42 | from installfedoraonzfs.git import Gitter, gitter_factory 43 | from installfedoraonzfs.log import log_config 44 | import installfedoraonzfs.retry as retrymod 45 | from installfedoraonzfs.vm import BootDriver, boot_image_in_qemu, test_qemu 46 | 47 | _LOGGER = logging.getLogger() 48 | 49 | 50 | qemu_timeout = 360 51 | 52 | 53 | def add_volume_arguments(parser: argparse.ArgumentParser) -> None: 54 | """Add arguments common to volume mounting.""" 55 | parser.add_argument( 56 | "voldev", 57 | metavar="VOLDEV", 58 | type=str, 59 | help="path to volume (device to use or regular file to create)", 60 | ) 61 | parser.add_argument( 62 | "--separate-boot", 63 | dest="bootdev", 64 | metavar="BOOTDEV", 65 | type=str, 66 | action="store", 67 | default=None, 68 | help="place /boot in a separate volume", 69 | ) 70 | parser.add_argument( 71 | "--pool-name", 72 | dest="poolname", 73 | metavar="POOLNAME", 74 | type=str, 75 | action="store", 76 | default="tank", 77 | help="pool name (default tank)", 78 | ) 79 | 80 | 81 | class UsesRepo(argparse.ArgumentParser): 82 | """Validates prebuilt RPMs.""" 83 | 84 | def __init__(self, *args, **kwargs) -> None: # type: ignore 85 | """Initialize the parser.""" 86 | argparse.ArgumentParser.__init__(self, *args, **kwargs) 87 | add_common_arguments(self) 88 | self.add_repo_arguments() 89 | 90 | def parse_args(self, args=None, namespace=None) -> Any: # type:ignore 91 | """Parse arguments.""" 92 | args = argparse.ArgumentParser.parse_args(self) 93 | if args.prebuiltrpms and not os.path.isdir(args.prebuiltrpms): 94 | _LOGGER.error( 95 | "error: --prebuilt-rpms-path %r does not exist", 96 | args.prebuiltrpms, 97 | ) 98 | return args 99 | 100 | def add_repo_arguments(self) -> None: 101 | """Add arguments pertaining to source and binary ZFS repo selection.""" 102 | self.add_argument( 103 | "--use-prebuilt-rpms", 104 | dest="prebuiltrpms", 105 | metavar="DIR", 106 | type=str, 107 | action="store", 108 | default=None, 109 | help="also install pre-built ZFS, GRUB and other RPMs in this directory," 110 | " except for debuginfo packages within the directory (default: build ZFS and" 111 | " GRUB RPMs, within the system)", 112 | ) 113 | self.add_argument( 114 | "--zfs-repo", 115 | dest="zfs_repo", 116 | action="store", 117 | default="https://github.com/Rudd-O/zfs", 118 | help="when building ZFS from source, use this repository instead of master", 119 | ) 120 | self.add_argument( 121 | "--use-branch", 122 | dest="branch", 123 | action="store", 124 | default="master", 125 | help="when building ZFS from source, check out this commit, tag or branch from" 126 | " the repository instead of master", 127 | ) 128 | 129 | 130 | def add_pm_arguments(parser: argparse.ArgumentParser) -> None: 131 | """Add arguments for package manager.""" 132 | parser.add_argument( 133 | "--yum-cachedir", 134 | dest="yum_cachedir", 135 | action="store", 136 | type=Path, 137 | default=None, 138 | help="directory to use for a yum cache that persists across executions", 139 | ) 140 | 141 | 142 | def add_common_arguments(parser: argparse.ArgumentParser) -> None: 143 | """Add arguments common to all commands.""" 144 | parser.add_argument( 145 | "--trace-file", 146 | dest="trace_file", 147 | action="store", 148 | default=None, 149 | help="file name for a detailed trace file of program activity (default no" 150 | " trace file)", 151 | ) 152 | 153 | 154 | def add_env_arguments(parser: argparse.ArgumentParser) -> None: 155 | """Add arguments common to commands that use the pool.""" 156 | parser.add_argument( 157 | "--workdir", 158 | dest="workdir", 159 | action="store", 160 | default=f"/run/user/{os.getuid()}/zfs-fedora-installer", 161 | help="use this directory as a working (scratch) space for the mount points of" 162 | " the created pool", 163 | ) 164 | 165 | 166 | def add_distro_arguments(parser: argparse.ArgumentParser) -> None: 167 | """Add arguments pertaining to target distro selection.""" 168 | parser.add_argument( 169 | "--releasever", 170 | dest="releasever", 171 | metavar="VER", 172 | type=str, 173 | action="store", 174 | default=None, 175 | help="Release version to install (default the same as the computer" 176 | " you are installing on)", 177 | ) 178 | 179 | 180 | def get_install_fedora_on_zfs_parser() -> UsesRepo: 181 | """Get a parser to configure install-fedora-on-zfs.""" 182 | parser = UsesRepo( 183 | description="Install a minimal Fedora system inside a ZFS pool within" 184 | " a disk image or device", 185 | formatter_class=argparse.RawDescriptionHelpFormatter, 186 | ) 187 | add_volume_arguments(parser) 188 | parser.add_argument( 189 | "--vol-size", 190 | dest="volsize", 191 | metavar="VOLSIZE", 192 | type=str, 193 | action="store", 194 | default="11000", 195 | help="volume size in MiB (default 11000), or bytes if postfixed with a B", 196 | ) 197 | parser.add_argument( 198 | "--boot-size", 199 | dest="bootsize", 200 | metavar="BOOTSIZE", 201 | type=int, 202 | action="store", 203 | default=1024, 204 | help="boot partition size in MiB, or boot volume size in MiB, when" 205 | " --separate-boot is specified (default 1024)", 206 | ) 207 | parser.add_argument( 208 | "--host-name", 209 | dest="hostname", 210 | metavar="HOSTNAME", 211 | type=str, 212 | action="store", 213 | default="localhost.localdomain", 214 | help="host name (default localhost.localdomain)", 215 | ) 216 | parser.add_argument( 217 | "--root-password", 218 | dest="rootpassword", 219 | metavar="ROOTPASSWORD", 220 | type=str, 221 | action="store", 222 | default="password", 223 | help="root password (default password)", 224 | ) 225 | parser.add_argument( 226 | "--swap-size", 227 | dest="swapsize", 228 | metavar="SWAPSIZE", 229 | type=int, 230 | action="store", 231 | default=1024, 232 | help="swap volume size in MiB (default 1024)", 233 | ) 234 | add_distro_arguments(parser) 235 | parser.add_argument( 236 | "--luks-password", 237 | dest="lukspassword", 238 | metavar="LUKSPASSWORD", 239 | type=str, 240 | action="store", 241 | default=None, 242 | help="LUKS password to encrypt the ZFS volume with (default no encryption);" 243 | " unprintable glyphs whose ASCII value lies below 32 (the space character)" 244 | " will be rejected", 245 | ) 246 | parser.add_argument( 247 | "--luks-options", 248 | dest="luksoptions", 249 | metavar="LUKSOPTIONS", 250 | type=str, 251 | action="store", 252 | default=None, 253 | help="space-separated list of options to pass to cryptsetup luksFormat (default" 254 | " no options)", 255 | ) 256 | parser.add_argument( 257 | "--interactive-qemu", 258 | dest="interactive_qemu", 259 | action="store_true", 260 | default=False, 261 | help="QEMU will run interactively, with the console of your Linux system" 262 | " connected to your terminal; the normal timeout of %s seconds will not" 263 | " apply, and Ctrl+C will interrupt the emulation; this is useful to" 264 | " manually debug problems installing the bootloader; in this mode you are" 265 | " responsible for typing the password to any LUKS devices you have requested" 266 | " to be created" % qemu_timeout, 267 | ) 268 | add_pm_arguments(parser) 269 | parser.add_argument( 270 | "--force-kvm", 271 | dest="force_kvm", 272 | action="store_true", 273 | default=None, 274 | help="force KVM use for the boot sector installation (default autodetect)", 275 | ) 276 | parser.add_argument( 277 | "--chown", 278 | dest="chown", 279 | action="store", 280 | default=None, 281 | help="change the owner of the image files upon creation to this user", 282 | ) 283 | parser.add_argument( 284 | "--chgrp", 285 | dest="chgrp", 286 | action="store", 287 | default=None, 288 | help="change the group of the image files upon creation to this group", 289 | ) 290 | parser.add_argument( 291 | "--break-before", 292 | dest="break_before", 293 | choices=break_stages, 294 | action="store", 295 | default=None, 296 | help="break before the specified stage (see below); useful to stop" 297 | " the process at a particular stage for debugging", 298 | ) 299 | parser.add_argument( 300 | "--shell-before", 301 | dest="shell_before", 302 | choices=shell_stages, 303 | action="store", 304 | default=None, 305 | help="open a shell inside the chroot before running a specific stage" 306 | "; useful to debug issues within the chroot; process continues after" 307 | " exiting the shell", 308 | ) 309 | parser.add_argument( 310 | "--short-circuit", 311 | dest="short_circuit", 312 | choices=break_stages, 313 | action="store", 314 | default=None, 315 | help="short-circuit to the specified stage (see below); useful to jump " 316 | "ahead and execute from a particular stage thereon; it can be " 317 | "combined with --break-before to stop at a later stage", 318 | ) 319 | add_env_arguments(parser) 320 | parser.epilog = ( 321 | "Stages for the --break-before and --short-circuit arguments:\n%s" 322 | % ( 323 | "".join( 324 | "\n* %s:%s%s" 325 | % (k, " " * (max(len(x) for x in break_stages) - len(k) + 1), v) 326 | for k, v in list(break_stages.items()) 327 | ), 328 | ) 329 | + "\n\n" 330 | + "Stages for the --shell-before argument:\n%s" 331 | % ( 332 | "".join( 333 | "\n* %s:%s%s" 334 | % (k, " " * (max(len(x) for x in shell_stages) - len(k) + 1), v) 335 | for k, v in list(shell_stages.items()) 336 | ), 337 | ) 338 | ) 339 | return parser 340 | 341 | 342 | def get_deploy_parser() -> UsesRepo: 343 | """Add arguments for deploy.""" 344 | parser = UsesRepo(description="Install ZFS on a running system") 345 | parser.add_argument( 346 | "--no-update-sources", 347 | dest="update_sources", 348 | action="store_false", 349 | help="Update Git repositories if building from sources", 350 | ) 351 | parser.add_argument( 352 | "--dispvm-template", 353 | dest="dispvm_template", 354 | default=None, 355 | help="Disposable qube template for checking out code (e.g. fedora-39-dvm)" 356 | " (only applicable to deployment of ZFS in a Qubes OS environment, defaults" 357 | " to whatever your system's default disposable qube template is, and must" 358 | " currently be a Fedora-based or dnf-managed disposable template); see" 359 | " https://www.qubes-os.org/doc/how-to-use-disposables/ for more information", 360 | ) 361 | return parser 362 | 363 | 364 | def get_bootstrap_chroot_parser() -> argparse.ArgumentParser: 365 | """Add arguments for chroot bootstrap.""" 366 | parser = argparse.ArgumentParser(description="Bootstrap a chroot on a directory") 367 | parser.add_argument("chroot", type=Path, help="chroot directory to operate on") 368 | add_distro_arguments(parser) 369 | add_pm_arguments(parser) 370 | add_common_arguments(parser) 371 | return parser 372 | 373 | 374 | def get_run_command_parser() -> argparse.ArgumentParser: 375 | """Add arguments for run command in chroot.""" 376 | parser = argparse.ArgumentParser( 377 | description="Run a command in a Fedora system inside a ZFS pool within a disk" 378 | " image or device", 379 | formatter_class=argparse.RawDescriptionHelpFormatter, 380 | ) 381 | add_volume_arguments(parser) 382 | add_env_arguments(parser) 383 | add_common_arguments(parser) 384 | parser.add_argument("args", nargs=argparse.REMAINDER) 385 | return parser 386 | 387 | 388 | def import_pool(poolname: str, rootmountpoint: Path) -> None: 389 | """Import a pool by name, and mount it to an alt mount point.""" 390 | check_call(["zpool", "import"]) 391 | check_call(["zpool", "import", "-f", "-R", str(rootmountpoint), poolname]) 392 | 393 | 394 | def list_pools() -> list[str]: 395 | """List pools active in the system.""" 396 | d = check_output(["zpool", "list", "-H", "-o", "name"], logall=True) 397 | return [x for x in d.splitlines() if x] 398 | 399 | 400 | # We try the import of the pool 3 times, with a 5-second timeout in between tries. 401 | import_pool_retryable = retrymod.retry( 402 | 2, timeout=5, retryable_exception=subprocess.CalledProcessError 403 | )(import_pool) # type: ignore 404 | 405 | 406 | def partition_boot(bootdev: Path, bootsize: int, rootvol: bool) -> None: 407 | """Partitions device into four partitions. 408 | 409 | 1. a 2MB biosboot partition 410 | 2. an EFI partition, sized bootsize/2 411 | 3. a /boot partition, sized bootsize/2 412 | 4. if rootvol evals to True: a root volume partition 413 | 414 | Caller is responsible for waiting until the devices appear. 415 | """ 416 | cmd = ["gdisk", str(bootdev)] 417 | pr = Popen(cmd, stdin=subprocess.PIPE) 418 | if rootvol: 419 | _LOGGER.info( 420 | "Creating 2M BIOS boot, %sM EFI system, %sM boot, rest root partition", 421 | int(bootsize / 2), 422 | int(bootsize / 2), 423 | ) 424 | pr.communicate( 425 | f"""o 426 | y 427 | 428 | n 429 | 1 430 | 431 | +2M 432 | 21686148-6449-6E6F-744E-656564454649 433 | 434 | 435 | n 436 | 2 437 | 438 | +{int(bootsize / 2)}M 439 | C12A7328-F81F-11D2-BA4B-00A0C93EC93B 440 | 441 | 442 | n 443 | 3 444 | 445 | +{int(bootsize / 2)}M 446 | 447 | 448 | 449 | n 450 | 4 451 | 452 | 453 | 454 | 455 | 456 | p 457 | w 458 | y 459 | """ 460 | ) 461 | else: 462 | _LOGGER.info( 463 | "Creating 2M BIOS boot, %sM EFI system, the rest boot partition", 464 | int(bootsize / 2), 465 | ) 466 | pr.communicate( 467 | f"""o 468 | y 469 | 470 | n 471 | 1 472 | 473 | +2M 474 | 21686148-6449-6E6F-744E-656564454649 475 | 476 | 477 | n 478 | 2 479 | 480 | +{int(bootsize / 2)}M 481 | C12A7328-F81F-11D2-BA4B-00A0C93EC93B 482 | 483 | 484 | n 485 | 3 486 | 487 | 488 | 489 | p 490 | w 491 | y 492 | """ 493 | ) 494 | retcode = pr.wait() 495 | if retcode != 0: 496 | raise subprocess.CalledProcessError(retcode, cmd) 497 | 498 | 499 | @contextlib.contextmanager 500 | def blockdev_context( 501 | voldev: Path, 502 | bootdev: None | Path, 503 | volsize: int, 504 | bootsize: int, 505 | chown: str | int | None, 506 | chgrp: str | int | None, 507 | create: bool, 508 | ) -> Generator[tuple[Path, Path, Path], None, None]: 509 | """Create a block device context. 510 | 511 | Takes a volume device path, and possible a boot device path, 512 | and yields a properly partitioned set of volumes which can 513 | then be used to format and create pools on. 514 | 515 | volsize is in bytes. bootsize is in mebibytes. 516 | """ 517 | undoer = Undoer() 518 | 519 | with undoer: 520 | _LOGGER.info("Entering blockdev context. Create=%s.", create) 521 | 522 | def get_rootpart(rdev: Path) -> Path | None: 523 | parts = ( 524 | [str(rdev) + "p4"] 525 | if str(rdev).startswith("/dev/loop") 526 | else [ 527 | str(rdev) + "-part4", 528 | str(rdev) + "4", 529 | ] 530 | ) 531 | for rootpart in parts: 532 | if os.path.exists(rootpart): 533 | return Path(rootpart) 534 | return None 535 | 536 | def get_efipart_bootpart(bdev: Path) -> tuple[Path, Path] | tuple[None, None]: 537 | parts = ( 538 | [ 539 | (str(bdev) + "p2", str(bdev) + "p3"), 540 | ] 541 | if str(bdev).startswith("/dev/loop") 542 | else [ 543 | (str(bdev) + "-part2", str(bdev) + "-part3"), 544 | (str(bdev) + "2", str(bdev) + "3"), 545 | ] 546 | ) 547 | for efipart, bootpart in parts: 548 | _LOGGER.info( 549 | "About to check for the existence of %s and %s.", efipart, bootpart 550 | ) 551 | if os.path.exists(efipart) and os.path.exists(bootpart): 552 | _LOGGER.info("Both %s and %s exist.", efipart, bootpart) 553 | return Path(efipart), Path(bootpart) 554 | return None, None 555 | 556 | voltype = filetype(voldev) 557 | 558 | if ( 559 | voltype == "doesntexist" 560 | ): # FIXME use truncate directly with python. no need to dick around. 561 | if not create: 562 | raise Exception( 563 | f"Wanted to create boot device {voldev} but create=False" 564 | ) 565 | create_file(voldev, volsize, owner=chown, group=chgrp) 566 | voltype = "file" 567 | 568 | if voltype == "file": 569 | new_voldev = get_associated_lodev(voldev) 570 | if not new_voldev: 571 | new_voldev = losetup(voldev) 572 | 573 | assert new_voldev is not None, (new_voldev, voldev) 574 | undoer.to_un_losetup.append(new_voldev) 575 | voldev = new_voldev 576 | voltype = "blockdev" 577 | 578 | if bootdev: 579 | boottype = filetype(bootdev) 580 | 581 | if boottype == "doesntexist": 582 | if not create: 583 | raise Exception( 584 | f"Wanted to create boot device {bootdev} but create=False" 585 | ) 586 | create_file(bootdev, bootsize * 1024 * 1024, owner=chown, group=chgrp) 587 | boottype = "file" 588 | 589 | if boottype == "file": 590 | new_bootdev = get_associated_lodev(bootdev) 591 | if not new_bootdev: 592 | new_bootdev = losetup(bootdev) 593 | 594 | assert new_bootdev is not None, (new_bootdev, bootdev) 595 | undoer.to_un_losetup.append(new_bootdev) 596 | bootdev = new_bootdev 597 | boottype = "blockdev" 598 | 599 | for i in reversed(range(2)): 600 | efipart, bootpart = get_efipart_bootpart(bootdev if bootdev else voldev) 601 | if None in (bootpart, efipart): 602 | if i > 0: 603 | time.sleep(2) 604 | continue 605 | if create: 606 | partition_boot(bootdev or voldev, bootsize, not bootdev) 607 | else: 608 | raise Exception( 609 | f"Wanted to partition boot device {bootdev or voldev} but" 610 | f" create=False ({(efipart, bootpart)})" 611 | ) 612 | break 613 | 614 | for i in reversed(range(2)): 615 | efipart, bootpart = get_efipart_bootpart(bootdev if bootdev else voldev) 616 | if None in (efipart, bootpart): 617 | if i > 0: 618 | time.sleep(2) 619 | continue 620 | raise Exception( 621 | f"partitions 2 or 3 in device" 622 | f" {bootdev if bootdev else voldev} failed to be created" 623 | ) 624 | break 625 | 626 | rootpart = voldev if bootdev else get_rootpart(voldev) 627 | 628 | assert rootpart, "root partition in device %r failed to be created" % voldev 629 | assert bootpart, "boot partition in device %r failed to be created" % ( 630 | bootdev or voldev 631 | ) 632 | assert efipart, "EFI partition in device %r failed to be created" % ( 633 | bootdev or voldev 634 | ) 635 | 636 | _LOGGER.info("Blockdev context complete.") 637 | 638 | yield rootpart, bootpart, efipart 639 | 640 | 641 | def setup_boot_filesystems( 642 | bootpart: Path, 643 | efipart: Path, 644 | label_postfix: str, 645 | create: bool, 646 | ) -> tuple[str, str]: 647 | """Set up boot and EFI file systems. 648 | 649 | This function is a noop if file systems already exist. 650 | """ 651 | try: 652 | output = check_output(["blkid", "-c", "/dev/null", str(bootpart)]) 653 | except subprocess.CalledProcessError: 654 | output = "" 655 | 656 | if 'TYPE="ext4"' not in output: 657 | if not create: 658 | raise Exception( 659 | f"Wanted to create boot file system on {bootpart}" 660 | f" but create=False (output: {output})" 661 | ) 662 | # Conservative set of features so older distributions can be 663 | # tested when built in in newer distributions. 664 | ext4_opts = ( 665 | "none,has_journal,ext_attr,resize_inode,dir_index,filetype" 666 | ",extent,64bit,flex_bg,sparse_super,large_file,huge_file,dir_nlink" 667 | ) 668 | check_call( 669 | [ 670 | "mkfs.ext4", 671 | ] 672 | + ["-O", ext4_opts] 673 | + ["-L", "boot_" + label_postfix, str(bootpart)] 674 | ) 675 | bootpartuuid = check_output( 676 | ["blkid", "-c", "/dev/null", str(bootpart), "-o", "value", "-s", "UUID"] 677 | ).strip() 678 | 679 | try: 680 | output = check_output(["blkid", "-c", "/dev/null", str(efipart)]) 681 | except subprocess.CalledProcessError: 682 | output = "" 683 | if 'TYPE="vfat"' not in output: 684 | if not create: 685 | raise Exception( 686 | f"Wanted to create EFI file system on {efipart} but create=False" 687 | ) 688 | check_call( 689 | ["mkfs.vfat", "-F", "32", "-n", "efi_" + label_postfix[:7], str(efipart)] 690 | ) 691 | efipartuuid = check_output( 692 | ["blkid", "-c", "/dev/null", str(efipart), "-o", "value", "-s", "UUID"] 693 | ).strip() 694 | 695 | return bootpartuuid, efipartuuid 696 | 697 | 698 | @contextlib.contextmanager 699 | def filesystem_context( 700 | poolname: str, 701 | rootpart: Path, 702 | bootpart: Path, 703 | efipart: Path, 704 | workdir: Path, 705 | swapsize: int, 706 | lukspassword: str | None, 707 | luksoptions: str | None, 708 | create: bool, 709 | ) -> Generator[ 710 | tuple[ 711 | Path, 712 | Callable[[str], str], 713 | Callable[[str], str], 714 | Callable[[list[str]], list[str]], 715 | str | None, 716 | str | None, 717 | str, 718 | str, 719 | ], 720 | None, 721 | None, 722 | ]: 723 | """Provide a filesystem context to the installer.""" 724 | undoer = Undoer() 725 | _LOGGER.info("Entering filesystem context. Create=%s.", create) 726 | bootpartuuid, efipartuuid = setup_boot_filesystems( 727 | bootpart, efipart, poolname, create 728 | ) 729 | 730 | if lukspassword: 731 | _LOGGER.info("Setting up LUKS.") 732 | needsdoing = False 733 | try: 734 | rootuuid = check_output( 735 | ["blkid", "-c", "/dev/null", str(rootpart), "-o", "value", "-s", "UUID"] 736 | ).strip() 737 | if not rootuuid: 738 | raise IndexError("no UUID for %s" % rootpart) 739 | luksuuid = "luks-" + rootuuid 740 | except IndexError: 741 | needsdoing = True 742 | except subprocess.CalledProcessError as e: 743 | if e.returncode != 2: 744 | raise 745 | needsdoing = True 746 | if needsdoing: 747 | if not create: 748 | raise Exception( 749 | f"Wanted to create LUKS volume on {rootpart} but create=False" 750 | ) 751 | luksopts = shlex.split(luksoptions) if luksoptions else [] 752 | cmd = ( 753 | ["cryptsetup", "-y", "-v", "luksFormat"] 754 | + luksopts 755 | + [str(rootpart), "-"] 756 | ) 757 | proc = Popen(cmd, stdin=subprocess.PIPE) 758 | proc.communicate(lukspassword) 759 | retcode = proc.wait() 760 | if retcode != 0: 761 | raise subprocess.CalledProcessError(retcode, cmd) 762 | rootuuid = check_output( 763 | ["blkid", "-c", "/dev/null", str(rootpart), "-o", "value", "-s", "UUID"] 764 | ).strip() 765 | if not rootuuid: 766 | raise IndexError("still no UUID for %s" % rootpart) 767 | luksuuid = "luks-" + rootuuid 768 | if not os.path.exists(j("/dev", "mapper", luksuuid)): 769 | cmd = ["cryptsetup", "-y", "-v", "luksOpen", str(rootpart), luksuuid] 770 | proc = Popen(cmd, stdin=subprocess.PIPE) 771 | proc.communicate(lukspassword) 772 | retcode = proc.wait() 773 | if retcode != 0: 774 | raise subprocess.CalledProcessError(retcode, cmd) 775 | undoer.to_luks_close.append(luksuuid) 776 | rootpart = Path(j("/dev", "mapper", luksuuid)) 777 | else: 778 | rootuuid = None 779 | luksuuid = None 780 | 781 | rootmountpoint = Path(j(workdir, poolname)) 782 | if poolname not in list_pools(): 783 | func = import_pool if create else import_pool_retryable 784 | try: 785 | _LOGGER.info("Trying to import pool %s.", poolname) 786 | func(poolname, rootmountpoint) 787 | except subprocess.CalledProcessError as exc: 788 | if not create: 789 | raise Exception( 790 | f"Wanted to create ZFS pool {poolname} on {rootpart}" 791 | " but create=False" 792 | ) from exc 793 | _LOGGER.info("Creating pool %s.", poolname) 794 | check_call( 795 | [ 796 | "zpool", 797 | "create", 798 | "-m", 799 | "none", 800 | "-o", 801 | "ashift=12", 802 | "-O", 803 | "compression=on", 804 | "-O", 805 | "atime=off", 806 | "-O", 807 | "com.sun:auto-snapshot=false", 808 | "-R", 809 | str(rootmountpoint), 810 | poolname, 811 | str(rootpart), 812 | ] 813 | ) 814 | check_call(["zfs", "set", "xattr=sa", poolname]) 815 | undoer.to_export.append(poolname) 816 | 817 | _LOGGER.info("Checking / creating datasets." if create else "Checking datasets") 818 | try: 819 | check_call_silent_stdout( 820 | ["zfs", "list", "-H", "-o", "name", j(poolname, "ROOT")], 821 | ) 822 | except subprocess.CalledProcessError as exc: 823 | if not create: 824 | raise Exception( 825 | f"Wanted to create ZFS file system ROOT on {poolname} but create=False" 826 | ) from exc 827 | check_call(["zfs", "create", j(poolname, "ROOT")]) 828 | 829 | try: 830 | check_call_silent_stdout( 831 | ["zfs", "list", "-H", "-o", "name", j(poolname, "ROOT", "os")], 832 | ) 833 | if not os.path.ismount(rootmountpoint): 834 | check_call(["zfs", "mount", j(poolname, "ROOT", "os")]) 835 | except subprocess.CalledProcessError as exc: 836 | if not create: 837 | raise Exception( 838 | f"Wanted to create ZFS file system ROOT/os on {poolname}" 839 | " but create=False" 840 | ) from exc 841 | check_call(["zfs", "create", "-o", "mountpoint=/", j(poolname, "ROOT", "os")]) 842 | undoer.to_unmount.append(rootmountpoint) 843 | 844 | _LOGGER.info("Checking / creating swap zvol." if create else "Checking swap zvol.") 845 | try: 846 | check_call_silent_stdout( 847 | ["zfs", "list", "-H", "-o", "name", j(poolname, "swap")], 848 | ) 849 | except subprocess.CalledProcessError as exc: 850 | if not create: 851 | raise Exception( 852 | f"Wanted to create ZFS file system swap on {poolname} but create=False" 853 | ) from exc 854 | check_call( 855 | ["zfs", "create", "-V", "%dM" % swapsize, "-b", "4K", j(poolname, "swap")] 856 | ) 857 | check_call(["zfs", "set", "compression=gzip-9", j(poolname, "swap")]) 858 | check_call(["zfs", "set", "com.sun:auto-snapshot=false", j(poolname, "swap")]) 859 | swappart = os.path.join("/dev/zvol", poolname, "swap") 860 | 861 | for _ in range(5): 862 | if not os.path.exists(swappart): 863 | time.sleep(5) 864 | if not os.path.exists(swappart): 865 | raise ZFSMalfunction( 866 | "ZFS does not appear to create the device nodes for zvols. If you" 867 | " installed ZFS from source recently, pay attention that the --with-udevdir=" 868 | " configure parameter is correct, and ensure udev has reloaded." 869 | ) 870 | 871 | _LOGGER.info("Checking / formatting swap." if create else "Checking swap.") 872 | try: 873 | output = check_output(["blkid", "-c", "/dev/null", swappart]) 874 | except subprocess.CalledProcessError: 875 | output = "" 876 | if 'TYPE="swap"' not in output: 877 | if not create: 878 | raise Exception( 879 | f"Wanted to create swap volume on {poolname}/swap but create=False" 880 | ) 881 | check_call(["mkswap", "-f", swappart]) 882 | 883 | def p(withinchroot: str) -> str: 884 | return str(j(rootmountpoint, withinchroot.lstrip(os.path.sep))) 885 | 886 | def q(outsidechroot: str) -> str: 887 | return outsidechroot[len(str(rootmountpoint)) :] 888 | 889 | _LOGGER.info("Mounting virtual and physical file systems.") 890 | # mount virtual file systems, creating their mount points as necessary 891 | for m in "boot sys proc".split(): 892 | if not os.path.isdir(p(m)): 893 | os.mkdir(p(m)) 894 | 895 | if not os.path.ismount(p("boot")): 896 | mount(bootpart, Path(p("boot"))) 897 | undoer.to_unmount.append(p("boot")) 898 | 899 | for m in "boot/efi".split(): 900 | if not os.path.isdir(p(m)): 901 | os.mkdir(p(m)) 902 | 903 | if not os.path.ismount(p("boot/efi")): 904 | mount(efipart, Path(p("boot/efi"))) 905 | undoer.to_unmount.append(p("boot/efi")) 906 | 907 | for srcmount, bindmounted in [ 908 | ("/proc", p("proc")), 909 | ("/sys", p("sys")), 910 | # ("/sys/fs/selinux", p("sys/fs/selinux")), 911 | ]: 912 | if not os.path.ismount(bindmounted) and os.path.ismount(srcmount): 913 | bindmount(Path(srcmount), Path(bindmounted)) 914 | undoer.to_unmount.append(bindmounted) 915 | 916 | # create needed directories to succeed in chrooting as per #22 917 | for m in "etc var var/lib var/lib/dbus var/log var/log/audit".split(): 918 | if not os.path.isdir(p(m)): 919 | os.mkdir(p(m)) 920 | if m == "var/log/audit": 921 | os.chmod(p(m), 0o700) 922 | 923 | def in_chroot(lst: list[str]) -> list[str]: 924 | return ["chroot", str(rootmountpoint)] + lst 925 | 926 | _LOGGER.info("Filesystem context complete.") 927 | try: 928 | yield ( 929 | rootmountpoint, 930 | p, 931 | q, 932 | in_chroot, 933 | rootuuid, 934 | luksuuid, 935 | bootpartuuid, 936 | efipartuuid, 937 | ) 938 | finally: 939 | undoer.undo() 940 | 941 | 942 | class ZFSMalfunction(Exception): 943 | """ZFS has malfunctioned.""" 944 | 945 | 946 | class ZFSBuildFailure(Exception): 947 | """ZFS build failure.""" 948 | 949 | 950 | class ImpossiblePassphrase(Exception): 951 | """Bad passphrase to use.""" 952 | 953 | 954 | class Undoer: 955 | """Helps stack undo actions in a LIFO manner.""" 956 | 957 | def __init__(self) -> None: 958 | """Initialize an empty undoer.""" 959 | self.actions: list[Any] = [] 960 | 961 | class Tracker: 962 | def __init__(self, typ: str) -> None: 963 | self.typ = typ 964 | 965 | def append(me: "Tracker", o: Any) -> None: # noqa:N805 966 | assert o is not None 967 | self.actions.append([me.typ, o]) 968 | 969 | def remove(me: "Tracker", o: Any) -> None: # noqa:N805 970 | for n, (typ, origo) in reversed(list(enumerate(self.actions[:]))): 971 | if typ == me.typ and o == origo: 972 | self.actions.pop(n) 973 | break 974 | 975 | self.to_un_losetup = Tracker("un_losetup") 976 | self.to_luks_close = Tracker("luks_close") 977 | self.to_export = Tracker("export") 978 | self.to_rmdir = Tracker("rmdir") 979 | self.to_unmount = Tracker("unmount") 980 | self.to_rmrf = Tracker("rmrf") 981 | 982 | def __enter__(self) -> None: 983 | """Enter the undoer context.""" 984 | pass 985 | 986 | def __exit__(self, *unused_args: Any) -> None: 987 | """Exit the undoer context.""" 988 | self.undo() 989 | 990 | def undo(self) -> None: 991 | """Execute the undo action list LIFO style.""" 992 | logger = logging.getLogger("Undoer") 993 | logger.info("Rewinding stack of actions.") 994 | for n, (typ, o) in reversed(list(enumerate(self.actions[:]))): 995 | if typ == "unmount": 996 | umount(o) 997 | if typ == "rmrf": 998 | shutil.rmtree(o) 999 | if typ == "rmdir": 1000 | os.rmdir(o) 1001 | if typ == "export": 1002 | # check_call(["sync"]) 1003 | check_call(["zpool", "export", o]) 1004 | if typ == "luks_close": 1005 | # check_call(["sync"]) 1006 | check_call(["cryptsetup", "luksClose", str(o)]) 1007 | if typ == "un_losetup": 1008 | # check_call(["sync"]) 1009 | cmd = ["losetup", "-d", str(o)] 1010 | env = dict(os.environ) 1011 | env["LANG"] = "C.UTF-8" 1012 | env["LC_ALL"] = "C.UTF-8" 1013 | output, exitcode = get_output_exitcode(cmd, env=env) 1014 | if exitcode != 0: 1015 | if "No such device or address" in output: 1016 | logger.warning("Ignorable failure while detaching %s", o) 1017 | else: 1018 | raise subprocess.CalledProcessError( 1019 | exitcode, ["losetup", "-d", str(o)] 1020 | ) 1021 | time.sleep(1) 1022 | self.actions.pop(n) 1023 | logger.info("Rewind complete.") 1024 | 1025 | 1026 | def chroot_shell( 1027 | in_chroot: Callable[[list[str]], list[str]], 1028 | phase_to_stop_at: str | None, 1029 | current_phase: str, 1030 | ) -> None: 1031 | """Drop user into a shell.""" 1032 | if phase_to_stop_at == current_phase: 1033 | _LOGGER.info( 1034 | "=== Dropping you into a shell before phase {current_phase}. ===", 1035 | ) 1036 | _LOGGER.info( 1037 | "=== Exit the shell to continue, or exit 1 to abort. ===", 1038 | ) 1039 | subprocess.check_call(in_chroot(["/bin/bash"])) 1040 | 1041 | 1042 | def install_fedora( 1043 | workdir: Path, 1044 | voldev: Path, 1045 | volsize: int, 1046 | zfs_repo: str, 1047 | bootdev: Path | None = None, 1048 | bootsize: int = 256, 1049 | poolname: str = "tank", 1050 | hostname: str = "localhost.localdomain", 1051 | rootpassword: str = "password", 1052 | swapsize: int = 1024, 1053 | releasever: str | None = None, 1054 | lukspassword: str | None = None, 1055 | interactive_qemu: bool = False, 1056 | luksoptions: Any = None, # FIXME 1057 | prebuilt_rpms_path: Path | None = None, 1058 | yum_cachedir: Path | None = None, 1059 | force_kvm: bool | None = None, 1060 | chown: str | int | None = None, 1061 | chgrp: str | int | None = None, 1062 | break_before: str | None = None, 1063 | shell_before: str | None = None, 1064 | short_circuit: str | None = None, 1065 | branch: str = "master", 1066 | ) -> None: 1067 | """Install a bootable Fedora in an image or disk backed by ZFS.""" 1068 | if lukspassword and not BootDriver.is_typeable(lukspassword): 1069 | raise ImpossiblePassphrase( 1070 | "LUKS passphrase %r cannot be typed during boot" % lukspassword 1071 | ) 1072 | 1073 | if rootpassword and not BootDriver.is_typeable(rootpassword): 1074 | raise ImpossiblePassphrase( 1075 | "root password %r cannot be typed during boot" % rootpassword 1076 | ) 1077 | 1078 | original_voldev = voldev 1079 | original_bootdev = bootdev 1080 | 1081 | def beginning() -> None: 1082 | _LOGGER.info("Program has begun.") 1083 | 1084 | with blockdev_context( 1085 | voldev, bootdev, volsize, bootsize, chown, chgrp, create=True 1086 | ) as (rootpart, bootpart, efipart), filesystem_context( 1087 | poolname, 1088 | rootpart, 1089 | bootpart, 1090 | efipart, 1091 | workdir, 1092 | swapsize, 1093 | lukspassword, 1094 | luksoptions, 1095 | create=True, 1096 | ) as ( 1097 | rootmountpoint, 1098 | p, 1099 | _, 1100 | in_chroot, 1101 | rootuuid, 1102 | luksuuid, 1103 | bootpartuuid, 1104 | efipartuuid, 1105 | ): 1106 | _LOGGER.info("Adding basic files.") 1107 | # sync device files 1108 | # FIXME: this could be racy when building multiple images 1109 | # in parallel. 1110 | # We really should only synchronize the absolute minimum of 1111 | # device files (in/out/err/null, and the disks that belong 1112 | # to this build MAYBE.) 1113 | check_call( 1114 | [ 1115 | "rsync", 1116 | "-ax", 1117 | "--numeric-ids", 1118 | "--exclude=zvol", 1119 | "--exclude=sd*", 1120 | "--delete", 1121 | "--delete-excluded", 1122 | "/dev/", 1123 | p("dev/"), 1124 | ] 1125 | ) 1126 | 1127 | # make up a nice locale.conf file. neutral. international 1128 | localeconf = """LANG="en_US.UTF-8" 1129 | """ 1130 | writetext(Path(p(j("etc", "locale.conf"))), localeconf) 1131 | 1132 | # make up a nice vconsole.conf file. neutral. international 1133 | vconsoleconf = """KEYMAP="us" 1134 | """ 1135 | writetext(Path(p(j("etc", "vconsole.conf"))), vconsoleconf) 1136 | 1137 | # make up a nice fstab file 1138 | fstab = f"""{poolname}/ROOT/os / zfs defaults,x-systemd-device-timeout=0 0 0 1139 | UUID={bootpartuuid} /boot ext4 noatime 0 1 1140 | UUID={efipartuuid} /boot/efi vfat noatime 0 1 1141 | /dev/zvol/{poolname}/swap swap swap discard 0 0 1142 | """ 1143 | writetext(Path(p(j("etc", "fstab"))), fstab) 1144 | 1145 | orig_resolv = p(j("etc", "resolv.conf.orig")) 1146 | final_resolv = p(j("etc", "resolv.conf")) 1147 | 1148 | if os.path.lexists(final_resolv): 1149 | if not os.path.lexists(orig_resolv): 1150 | _LOGGER.info("Backing up original resolv.conf") 1151 | os.rename(final_resolv, orig_resolv) 1152 | 1153 | if os.path.islink(final_resolv): 1154 | os.unlink(final_resolv) 1155 | _LOGGER.info("Writing temporary resolv.conf") 1156 | writetext(Path(final_resolv), readtext(Path(j("/etc", "resolv.conf")))) 1157 | 1158 | if not os.path.exists(p(j("etc", "hostname"))): 1159 | writetext(Path(p(j("etc", "hostname"))), hostname) 1160 | if not os.path.exists(p(j("etc", "hostid"))): 1161 | with open("/dev/urandom", "rb") as rnd: 1162 | randomness = rnd.read(4) 1163 | with open(p(j("etc", "hostid")), "wb") as hostidf: 1164 | hostidf.write(randomness) 1165 | with open(p(j("etc", "hostid")), "rb") as hostidfile: 1166 | hostid = hostidfile.read().hex() 1167 | hostid = f"{hostid[6:8]}{hostid[4:6]}{hostid[2:4]}{hostid[0:2]}" 1168 | _LOGGER.info("Host ID is %s", hostid) 1169 | 1170 | if luksuuid: 1171 | crypttab = f"""{luksuuid} UUID={rootuuid} none discard 1172 | """ 1173 | writetext(Path(p(j("etc", "crypttab"))), crypttab) 1174 | os.chmod(p(j("etc", "crypttab")), 0o600) 1175 | 1176 | # install base packages 1177 | pkgmgr = pm.chroot_bootstrapper_factory( 1178 | rootmountpoint, yum_cachedir, releasever, None 1179 | ) 1180 | pkgmgr.bootstrap_packages() 1181 | 1182 | # omit zfs modules when dracutting 1183 | if not os.path.exists(p("usr/bin/dracut.real")): 1184 | check_call(in_chroot(["mv", "/usr/bin/dracut", "/usr/bin/dracut.real"])) 1185 | writetext( 1186 | p("usr/bin/dracut"), 1187 | """#!/bin/bash 1188 | 1189 | echo This is a fake dracut. 1190 | """, 1191 | ) 1192 | os.chmod(p("usr/bin/dracut"), 0o755) 1193 | 1194 | if luksuuid: 1195 | luksstuff = f" rd.luks.uuid={rootuuid} rd.luks.allow-discards" 1196 | else: 1197 | luksstuff = "" 1198 | 1199 | # write grub config 1200 | grubconfig = f"""GRUB_TIMEOUT=0 1201 | GRUB_HIDDEN_TIMEOUT=3 1202 | GRUB_HIDDEN_TIMEOUT_QUIET=true 1203 | GRUB_DISTRIBUTOR="$(sed 's, release .*$,,g' /etc/system-release)" 1204 | GRUB_DEFAULT=saved 1205 | GRUB_CMDLINE_LINUX="rd.md=0 rd.lvm=0 rd.dm=0 $([ -x /usr/sbin/rhcrashkernel-param ] && /usr/sbin/rhcrashkernel-param || :) quiet systemd.show_status=true{luksstuff}" 1206 | GRUB_DISABLE_RECOVERY="true" 1207 | GRUB_GFXPAYLOAD_LINUX="keep" 1208 | GRUB_TERMINAL_OUTPUT="vga_text" 1209 | GRUB_DISABLE_LINUX_UUID=true 1210 | GRUB_PRELOAD_MODULES='part_msdos ext2' 1211 | """ 1212 | writetext(Path(p(j("etc", "default", "grub"))), grubconfig) 1213 | 1214 | # write kernel command line 1215 | if not os.path.isdir(p(j("etc", "kernel"))): 1216 | os.mkdir(p(j("etc", "kernel"))) 1217 | kernelcmd = ( 1218 | f"root=ZFS={poolname}/ROOT/os rd.md=0 rd.lvm=0 rd.dm=0 quiet" 1219 | + f""" systemd.show_status=true{luksstuff} 1220 | """ 1221 | ) 1222 | writetext(p(j("etc", "kernel", "cmdline")), kernelcmd) 1223 | 1224 | chroot_shell(in_chroot, shell_before, "install_kernel") 1225 | 1226 | # install kernel packages 1227 | pkgmgr.setup_kernel_bootloader() 1228 | 1229 | # set password 1230 | shadow = Path(p(j("etc", "shadow"))) 1231 | pwfile = readlines(shadow) 1232 | pwnotset = bool( 1233 | [ 1234 | line 1235 | for line in pwfile 1236 | if line.startswith("root:*:") or line.startswith("root::") 1237 | ] 1238 | ) 1239 | if pwnotset: 1240 | _LOGGER.info("Setting root password") 1241 | cmd = ["mkpasswd", "--method=SHA-512", "--stdin"] 1242 | pwproc = subprocess.run( 1243 | cmd, input=rootpassword, capture_output=True, text=True, check=True 1244 | ) 1245 | pw = pwproc.stdout[:-1] 1246 | for n, line in enumerate(pwfile): 1247 | if line.startswith("root:"): 1248 | fields = line.split(":") 1249 | fields[1] = pw 1250 | pwfile[n] = ":".join(fields) 1251 | writetext(shadow, "".join(pwfile)) 1252 | else: 1253 | _LOGGER.info( 1254 | "Not setting root password -- first line of /etc/shadow: %s", 1255 | pwfile[0].strip(), 1256 | ) 1257 | 1258 | def deploy_zfs() -> None: 1259 | _LOGGER.info("Deploying ZFS and dependencies") 1260 | 1261 | gitter = gitter_factory() 1262 | 1263 | with blockdev_context( 1264 | voldev, bootdev, volsize, bootsize, chown, chgrp, create=True 1265 | ) as (rootpart, bootpart, efipart), filesystem_context( 1266 | poolname, 1267 | rootpart, 1268 | bootpart, 1269 | efipart, 1270 | workdir, 1271 | swapsize, 1272 | lukspassword, 1273 | luksoptions, 1274 | create=True, 1275 | ) as ( 1276 | rootmountpoint, 1277 | p, 1278 | _, 1279 | in_chroot, 1280 | _rootuuid, 1281 | _luksuuid, 1282 | _bootpartuuid, 1283 | _efipartuuid, 1284 | ): 1285 | pkgmgr = pm.chroot_package_manager_factory( 1286 | rootmountpoint, yum_cachedir, releasever, None 1287 | ) 1288 | deploy_zfs_in_machine( 1289 | p=p, 1290 | in_chroot=in_chroot, 1291 | pkgmgr=pkgmgr, 1292 | gitter=gitter, 1293 | prebuilt_rpms_path=prebuilt_rpms_path, 1294 | zfs_repo=zfs_repo, 1295 | branch=branch, 1296 | break_before=break_before, 1297 | shell_before=shell_before, 1298 | install_current_kernel_devel=False, 1299 | ) 1300 | 1301 | def get_kernel_initrd_kver(p: Callable[[str], str]) -> tuple[Path, Path, Path, str]: 1302 | try: 1303 | _LOGGER.debug( 1304 | "Fishing out kernel and initial RAM disk from %s", 1305 | p(j("boot", "loader", "*", "linux")), 1306 | ) 1307 | kernel = glob.glob(p(j("boot", "loader", "*", "linux")))[0] 1308 | kver = os.path.basename(os.path.dirname(kernel)) 1309 | initrd = p(j("boot", "loader", kver, "initrd")) 1310 | hostonly = p(j("boot", "loader", kver, "initrd-hostonly")) 1311 | return Path(kernel), Path(initrd), Path(hostonly), kver 1312 | except IndexError: 1313 | _LOGGER.debug( 1314 | "Fishing out kernel and initial RAM disk from %s", 1315 | p(j("boot", "vmlinuz-*")), 1316 | ) 1317 | kernel = glob.glob(p(j("boot", "vmlinuz-*")))[0] 1318 | kver = os.path.basename(kernel)[len("vmlinuz-") :] 1319 | initrd = p(j("boot", "initramfs-%s.img" % kver)) 1320 | hostonly = p(j("boot", "initramfs-hostonly-%s.img" % kver)) 1321 | return Path(kernel), Path(initrd), Path(hostonly), kver 1322 | except Exception: 1323 | check_call(["ls", "-lRa", p("boot")]) 1324 | raise 1325 | 1326 | def reload_chroot() -> None: # FIXME should be named finalize_chroot 1327 | # The following reload in a different context scope is a workaround 1328 | # for blkid failing without the reload happening first. 1329 | _LOGGER.info("Finalizing chroot for image") 1330 | 1331 | with blockdev_context( 1332 | voldev, bootdev, volsize, bootsize, chown, chgrp, create=False 1333 | ) as (rootpart, bootpart, efipart), filesystem_context( 1334 | poolname, 1335 | rootpart, 1336 | bootpart, 1337 | efipart, 1338 | workdir, 1339 | swapsize, 1340 | lukspassword, 1341 | luksoptions, 1342 | create=False, 1343 | ) as (_, p, q, in_chroot, rootuuid, _, bootpartuuid, _): 1344 | chroot_shell(in_chroot, shell_before, "reload_chroot") 1345 | 1346 | # FIXME: package manager should do this. 1347 | # but only if cachedir is off. 1348 | if not yum_cachedir: 1349 | # OS owns the cache directory. 1350 | # Release disk space now that installation is done. 1351 | for pkgm in ("dnf", "yum"): 1352 | for directory in ("cache", "lib"): 1353 | delete_contents(Path(p(j("var", directory, pkgm)))) 1354 | 1355 | if os.path.exists(p("usr/bin/dracut.real")): 1356 | check_call(in_chroot(["mv", "/usr/bin/dracut.real", "/usr/bin/dracut"])) 1357 | kernel, initrd, hostonly_initrd, kver = get_kernel_initrd_kver(p) 1358 | if os.path.isfile(initrd): 1359 | mayhapszfsko = check_output(["lsinitrd", str(initrd)]) 1360 | else: 1361 | mayhapszfsko = "" 1362 | # At this point, we regenerate the initrd, if it does not have zfs.ko. 1363 | if "zfs.ko" not in mayhapszfsko: 1364 | check_call(in_chroot(["dracut", "-Nf", q(str(initrd)), kver])) 1365 | after_recreation = check_output(["lsinitrd", str(initrd)]) 1366 | for line in after_recreation.splitlines(False): 1367 | if "zfs" in line: 1368 | _LOGGER.debug("initramfs: %s", line) 1369 | if "zfs.ko" not in after_recreation: 1370 | assert 0, ( 1371 | "ZFS kernel module was not found in the initramfs %s --" 1372 | " perhaps it failed to build." % initrd 1373 | ) 1374 | 1375 | # Kill the resolv.conf file written only to install packages. 1376 | orig_resolv = p(j("etc", "resolv.conf.orig")) 1377 | final_resolv = p(j("etc", "resolv.conf")) 1378 | if os.path.lexists(orig_resolv): 1379 | _LOGGER.info("Restoring original resolv.conf") 1380 | if os.path.lexists(final_resolv): 1381 | os.unlink(final_resolv) 1382 | os.rename(orig_resolv, final_resolv) 1383 | 1384 | # Remove host device files 1385 | shutil.rmtree(p("dev")) 1386 | # sync /dev but only itself and /dev/zfs 1387 | check_call(["rsync", "-ptlgoD", "--numeric-ids", "/dev/", p("dev/")]) 1388 | check_call(["rsync", "-ptlgoD", "--numeric-ids", "/dev/zfs", p("dev/zfs")]) 1389 | 1390 | # Snapshot the system as it is, now that it is fully done. 1391 | try: 1392 | check_call_silent_stdout( 1393 | [ 1394 | "zfs", 1395 | "list", 1396 | "-t", 1397 | "snapshot", 1398 | "-H", 1399 | "-o", 1400 | "name", 1401 | j(poolname, "ROOT", "os@initial"), 1402 | ] 1403 | ) 1404 | except subprocess.CalledProcessError: 1405 | # check_call(["sync"]) 1406 | check_call(["zfs", "snapshot", j(poolname, "ROOT", "os@initial")]) 1407 | 1408 | def biiq( 1409 | init: str, hostonly: bool, enforcing: bool, timeout_factor: float = 1.0 1410 | ) -> None: 1411 | def fish_kernel_initrd() -> ( 1412 | tuple[str | None, str | None, Path, Path, Path, Path] 1413 | ): 1414 | with blockdev_context( 1415 | voldev, bootdev, volsize, bootsize, chown, chgrp, create=False 1416 | ) as (rootpart, bootpart, efipart), filesystem_context( 1417 | poolname, 1418 | rootpart, 1419 | bootpart, 1420 | efipart, 1421 | workdir, 1422 | swapsize, 1423 | lukspassword, 1424 | luksoptions, 1425 | create=False, 1426 | ) as (_, p, _, _, rootuuid, luksuuid, _, _): 1427 | kerneltempdir = tempfile.mkdtemp( 1428 | prefix="install-fedora-on-zfs-bootbits-" 1429 | ) 1430 | try: 1431 | kernel, initrd, hostonly_initrd, _ = get_kernel_initrd_kver(p) 1432 | shutil.copy2(kernel, kerneltempdir) 1433 | shutil.copy2(initrd, kerneltempdir) 1434 | if os.path.isfile(hostonly_initrd): 1435 | shutil.copy2(hostonly_initrd, kerneltempdir) 1436 | except (KeyboardInterrupt, Exception): 1437 | shutil.rmtree(kerneltempdir) 1438 | raise 1439 | return ( 1440 | rootuuid, 1441 | luksuuid, 1442 | Path(kerneltempdir), 1443 | kernel, 1444 | initrd, 1445 | hostonly_initrd, 1446 | ) 1447 | 1448 | undoer = Undoer() 1449 | ( 1450 | rootuuid, 1451 | luksuuid, 1452 | kerneltempdir, 1453 | kernel, 1454 | initrd, 1455 | hostonly_initrd, 1456 | ) = fish_kernel_initrd() 1457 | undoer.to_rmrf.append(kerneltempdir) 1458 | with undoer: 1459 | return boot_image_in_qemu( 1460 | hostname, 1461 | init, 1462 | poolname, 1463 | original_voldev, 1464 | original_bootdev, 1465 | Path(os.path.join(kerneltempdir, os.path.basename(kernel))), 1466 | Path( 1467 | os.path.join( 1468 | kerneltempdir, 1469 | os.path.basename(initrd if not hostonly else hostonly_initrd), 1470 | ) 1471 | ), 1472 | force_kvm, 1473 | interactive_qemu, 1474 | lukspassword, 1475 | rootpassword, 1476 | rootuuid, 1477 | luksuuid, 1478 | int(qemu_timeout * timeout_factor), 1479 | enforcing, 1480 | ) 1481 | 1482 | def bootloader_install() -> None: 1483 | _LOGGER.info("Installing bootloader.") 1484 | with blockdev_context( 1485 | voldev, bootdev, volsize, bootsize, chown, chgrp, create=False 1486 | ) as (rootpart, bootpart, efipart), filesystem_context( 1487 | poolname, 1488 | rootpart, 1489 | bootpart, 1490 | efipart, 1491 | workdir, 1492 | swapsize, 1493 | lukspassword, 1494 | luksoptions, 1495 | create=False, 1496 | ) as (_, p, q, _, _, _, _, _): 1497 | _, initrd, hostonly_initrd, kver = get_kernel_initrd_kver(p) 1498 | # create bootloader installer 1499 | bootloadertext = """#!/bin/bash -e 1500 | error() {{ 1501 | retval=$? 1502 | echo There was an unrecoverable error finishing setup >&2 1503 | exit $retval 1504 | }} 1505 | trap error ERR 1506 | export PATH=/sbin:/usr/sbin:/bin:/usr/bin 1507 | mount / -o remount,rw 1508 | mount /boot 1509 | mount /boot/efi 1510 | mount -t tmpfs tmpfs /tmp 1511 | mount -t tmpfs tmpfs /var/tmp 1512 | mount --bind /dev/stderr /dev/log 1513 | 1514 | if ! test -f /.autorelabel ; then 1515 | # We have already passed to the fixfiles stage, 1516 | # so let's not redo the work. 1517 | # Useful to save time when iterating on this stage 1518 | # with short-circuiting. 1519 | echo Setting up GRUB environment block 1520 | rm -f /boot/grub2/grubenv /boot/efi/EFI/fedora/grubenv 1521 | echo "# GRUB Environment Block" > /boot/grub2/grubenv 1522 | for x in `seq 999` 1523 | do 1524 | echo -n "#" >> /boot/grub2/grubenv 1525 | done 1526 | chmod 644 /boot/grub2/grubenv 1527 | 1528 | echo Installing BIOS GRUB 1529 | grub2-install --target=i386-pc /dev/sda 1530 | grub2-mkconfig -o /boot/grub2/grub.cfg 1531 | 1532 | echo Adjusting ZFS cache file and settings 1533 | rm -f /etc/zfs/zpool.cache 1534 | zpool set cachefile=/etc/zfs/zpool.cache "{poolname}" 1535 | ls -la /etc/zfs/zpool.cache 1536 | zfs inherit com.sun:auto-snapshot "{poolname}" 1537 | 1538 | echo Generating initial RAM disks 1539 | dracut -Nf {initrd} `uname -r` 1540 | lsinitrd {initrd} | grep zfs 1541 | dracut -Hf {hostonly_initrd} `uname -r` 1542 | lsinitrd {hostonly_initrd} | grep zfs 1543 | fi 1544 | 1545 | echo Setting up SELinux autorelabeling 1546 | fixfiles -F onboot 1547 | 1548 | umount /var/tmp 1549 | umount /dev/log 1550 | 1551 | echo Starting autorelabel boot 1552 | # systemd will now start and relabel, then reboot. 1553 | exec /sbin/init "$@" 1554 | """.format( 1555 | **{ 1556 | "poolname": poolname, 1557 | "kver": kver, 1558 | "hostonly_initrd": q(str(hostonly_initrd)), 1559 | "initrd": q(str(initrd)), 1560 | } 1561 | ) 1562 | bootloaderpath = Path(p("installbootloader")) 1563 | writetext(Path(bootloaderpath), bootloadertext) 1564 | os.chmod(bootloaderpath, 0o755) 1565 | 1566 | _LOGGER.info( 1567 | "Entering sub-phase preparation of bootloader and SELinux relabeling in VM." 1568 | ) 1569 | return biiq("init=/installbootloader", False, True, 2.0) 1570 | 1571 | def boot_to_test_x_hostonly(hostonly: bool) -> None: 1572 | _LOGGER.info("Entering test of hostonly=%s initial RAM disk in VM.", hostonly) 1573 | biiq("systemd.unit=multi-user.target", hostonly, True) 1574 | 1575 | def boot_to_test_non_hostonly() -> None: 1576 | boot_to_test_x_hostonly(False) 1577 | 1578 | def boot_to_test_hostonly() -> None: 1579 | boot_to_test_x_hostonly(True) 1580 | 1581 | try: 1582 | # start main program 1583 | for stage in [ 1584 | "beginning", 1585 | "deploy_zfs", 1586 | "reload_chroot", 1587 | "bootloader_install", 1588 | "boot_to_test_non_hostonly", 1589 | "boot_to_test_hostonly", 1590 | ]: 1591 | if break_before == stage: 1592 | raise BreakingBefore(stage) 1593 | if short_circuit in (stage, None): 1594 | locals()[stage]() 1595 | short_circuit = None 1596 | 1597 | # tell the user we broke 1598 | except BreakingBefore as e: 1599 | _LOGGER.info("------------------------------------------------") 1600 | _LOGGER.info("Breaking before %s", break_stages[e.args[0]]) 1601 | raise 1602 | 1603 | # end operating with the devices 1604 | except BaseException: 1605 | _LOGGER.exception("Unexpected error") 1606 | raise 1607 | 1608 | 1609 | def _test_cmd(cmdname: str, expected_ret: int) -> bool: 1610 | try: 1611 | with open(os.devnull) as devnull_r, open(os.devnull, "w") as devnull_w: 1612 | subprocess.check_call( 1613 | shlex.split(cmdname), 1614 | stdin=devnull_r, 1615 | stdout=devnull_w, 1616 | stderr=devnull_w, 1617 | ) 1618 | except subprocess.CalledProcessError as e: 1619 | if e.returncode == expected_ret: 1620 | return True 1621 | return False 1622 | except FileNotFoundError: 1623 | return False 1624 | return True 1625 | 1626 | 1627 | def _test_mkfs_ext4() -> bool: 1628 | return _test_cmd("mkfs.ext4", 1) 1629 | 1630 | 1631 | def _test_mkfs_vfat() -> bool: 1632 | return _test_cmd("mkfs.vfat", 1) 1633 | 1634 | 1635 | def _test_zfs() -> bool: 1636 | return _test_cmd("zfs", 2) and os.path.exists("/dev/zfs") 1637 | 1638 | 1639 | def _test_rsync() -> bool: 1640 | return _test_cmd("rsync", 1) 1641 | 1642 | 1643 | def _test_gdisk() -> bool: 1644 | return _test_cmd("gdisk", 5) 1645 | 1646 | 1647 | def _test_cryptsetup() -> bool: 1648 | return _test_cmd("cryptsetup", 1) 1649 | 1650 | 1651 | def _test_mkpasswd() -> bool: 1652 | return _test_cmd("mkpasswd --help", 0) 1653 | 1654 | 1655 | def _test_dnf() -> bool: 1656 | try: 1657 | check_call_silent_stdout(["dnf", "--help"]) 1658 | except subprocess.CalledProcessError as e: 1659 | if e.returncode != 1: 1660 | return False 1661 | except OSError as e: 1662 | if e.errno == 2: 1663 | return False 1664 | raise 1665 | return True 1666 | 1667 | 1668 | def install_fedora_on_zfs() -> int: 1669 | """Install Fedora on a ZFS root pool.""" 1670 | args = get_install_fedora_on_zfs_parser().parse_args() 1671 | log_config(args.trace_file) 1672 | if not _test_rsync(): 1673 | _LOGGER.error( 1674 | "error: rsync is not available. Please use your package manager to install" 1675 | " rsync." 1676 | ) 1677 | return 5 1678 | if not _test_zfs(): 1679 | _LOGGER.error( 1680 | "error: ZFS is not installed properly. Please install ZFS with `deploy-zfs`" 1681 | " and then modprobe zfs. If installing from source, pay attention to the" 1682 | " --with-udevdir= configure parameter and don't forget to run ldconfig" 1683 | " after the install." 1684 | ) 1685 | return 5 1686 | if not _test_mkfs_ext4(): 1687 | _LOGGER.error( 1688 | "error: mkfs.ext4 is not installed properly. Please install e2fsprogs." 1689 | ) 1690 | return 5 1691 | if not _test_mkfs_vfat(): 1692 | _LOGGER.error( 1693 | "error: mkfs.vfat is not installed properly. Please install dosfstools." 1694 | ) 1695 | return 5 1696 | if not _test_cryptsetup(): 1697 | _LOGGER.error( 1698 | "error: cryptsetup is not installed properly. Please install cryptsetup." 1699 | ) 1700 | return 5 1701 | if not _test_mkpasswd(): 1702 | _LOGGER.error( 1703 | "error: mkpasswd is not installed properly. Please install mkpasswd." 1704 | ) 1705 | return 5 1706 | if not _test_gdisk(): 1707 | _LOGGER.error("error: gdisk is not installed properly. Please install gdisk.") 1708 | return 5 1709 | if not _test_dnf(): 1710 | _LOGGER.error("error: DNF is not installed properly. Please install DNF.") 1711 | return 5 1712 | if not args.break_before and not test_qemu(): 1713 | _LOGGER.error( 1714 | "error: QEMU is not installed properly. Please use your package manager" 1715 | " to install QEMU (in Fedora, qemu-system-x86-core or qemu-kvm), or" 1716 | " use --break-before=bootloader_install to create the image but not" 1717 | " boot it in a VM (it is likely that the image will not be bootable" 1718 | " since the bootloader will not be present)." 1719 | ) 1720 | return 5 1721 | 1722 | if not args.volsize: 1723 | _LOGGER.error("error: --vol-size must be a number.") 1724 | return os.EX_USAGE 1725 | try: 1726 | if args.volsize[-1] == "B": 1727 | volsize = int(args.volsize[:-1]) 1728 | else: 1729 | volsize = int(args.volsize) * 1024 * 1024 1730 | except Exception as exc: 1731 | _LOGGER.error( 1732 | "error: %s; --vol-size must be a valid number of megabytes," 1733 | " or bytes with a B postfix.", 1734 | exc, 1735 | ) 1736 | return os.EX_USAGE 1737 | 1738 | try: 1739 | install_fedora( 1740 | Path(args.workdir), 1741 | Path(args.voldev), 1742 | volsize, 1743 | args.zfs_repo, 1744 | Path(args.bootdev) if args.bootdev else None, 1745 | args.bootsize, 1746 | args.poolname, 1747 | args.hostname, 1748 | args.rootpassword, 1749 | args.swapsize, 1750 | args.releasever, 1751 | args.lukspassword, 1752 | args.interactive_qemu, 1753 | args.luksoptions, 1754 | args.prebuiltrpms, 1755 | Path(args.yum_cachedir) if args.yum_cachedir else None, 1756 | args.force_kvm, 1757 | branch=args.branch, 1758 | chown=args.chown, 1759 | chgrp=args.chgrp, 1760 | break_before=args.break_before, 1761 | shell_before=args.shell_before, 1762 | short_circuit=args.short_circuit, 1763 | ) 1764 | except ImpossiblePassphrase as e: 1765 | _LOGGER.error("error: %s", e) 1766 | return os.EX_USAGE 1767 | except (ZFSMalfunction, ZFSBuildFailure) as e: 1768 | _LOGGER.error("error: %s", e) 1769 | return 9 1770 | except BreakingBefore: 1771 | return 120 1772 | return 0 1773 | 1774 | 1775 | def deploy_zfs_in_machine( 1776 | p: Callable[[str], str], 1777 | in_chroot: Callable[[list[str]], list[str]], 1778 | pkgmgr: pm.PackageManager, 1779 | gitter: Gitter, 1780 | zfs_repo: str, 1781 | branch: str, 1782 | prebuilt_rpms_path: Path | None, 1783 | break_before: str | None, 1784 | shell_before: str | None, 1785 | install_current_kernel_devel: bool, 1786 | update_sources: bool = True, 1787 | ) -> None: 1788 | """Deploy ZFS in the local machine.""" 1789 | arch = platform.machine() 1790 | stringtoexclude = "debuginfo" 1791 | stringtoexclude2 = "debugsource" 1792 | 1793 | # check for shell 1794 | chroot_shell(in_chroot, shell_before, "install_prebuilt_rpms") 1795 | 1796 | undoer = Undoer() 1797 | 1798 | with undoer: 1799 | if prebuilt_rpms_path: 1800 | target_rpms_path = Path( 1801 | p(j("tmp", "zfs-fedora-installer-prebuilt-rpms")) 1802 | ) # FIXME hardcoded! Use workdir instead. 1803 | if not os.path.isdir(target_rpms_path): 1804 | os.mkdir(target_rpms_path) 1805 | if ismount(target_rpms_path): 1806 | if ( 1807 | os.stat(prebuilt_rpms_path).st_ino 1808 | != os.stat(target_rpms_path).st_ino 1809 | ): 1810 | umount(target_rpms_path) 1811 | bindmount( 1812 | Path(os.path.abspath(prebuilt_rpms_path)), target_rpms_path 1813 | ) 1814 | else: 1815 | bindmount(Path(os.path.abspath(prebuilt_rpms_path)), target_rpms_path) 1816 | if os.path.isdir(target_rpms_path): 1817 | undoer.to_rmdir.append(target_rpms_path) 1818 | if ismount(target_rpms_path): 1819 | undoer.to_unmount.append(target_rpms_path) 1820 | prebuilt_rpms_to_install = { 1821 | os.path.basename(s) 1822 | for s in ( 1823 | glob.glob(j(prebuilt_rpms_path, f"*{arch}.rpm")) 1824 | + glob.glob(j(prebuilt_rpms_path, "*noarch.rpm")) 1825 | ) 1826 | if stringtoexclude not in os.path.basename(s) 1827 | and stringtoexclude2 not in os.path.basename(s) 1828 | } 1829 | else: 1830 | target_rpms_path = None 1831 | prebuilt_rpms_to_install = set() 1832 | 1833 | if prebuilt_rpms_to_install and target_rpms_path: 1834 | _LOGGER.info( 1835 | "Installing available prebuilt RPMs: %s", prebuilt_rpms_to_install 1836 | ) 1837 | files_to_install = [ 1838 | Path(j(target_rpms_path, s)) for s in prebuilt_rpms_to_install 1839 | ] 1840 | pkgmgr.install_local_packages(files_to_install) 1841 | 1842 | if target_rpms_path: 1843 | umount(target_rpms_path) 1844 | undoer.to_unmount.remove(target_rpms_path) 1845 | os.rmdir(target_rpms_path) 1846 | undoer.to_rmdir.remove(target_rpms_path) 1847 | 1848 | # kernel devel 1849 | if install_current_kernel_devel: 1850 | uname_r = check_output(in_chroot("uname -r".split())).strip() 1851 | if "pvops.qubes" in uname_r: 1852 | assert 0, ( 1853 | "Installation on non-HVM Qubes AppVMs is unsupported due to the" 1854 | " unavailability of kernel-devel packages in-VM (kernel version" 1855 | f" {uname_r}).\n" 1856 | "If you want to boot an AppVM as HVM, follow the instructions here:" 1857 | " https://www.qubes-os.org/doc/managing-vm-kernel/#using-kernel-installed-in-the-vm" 1858 | ) 1859 | pkgs = ["kernel-%s" % uname_r, "kernel-devel-%s" % uname_r] 1860 | pkgmgr.ensure_packages_installed(pkgs) 1861 | 1862 | for project, patterns, keystonepkgs, mindeps, buildcmd in ( 1863 | ( 1864 | "grub-zfs-fixer", 1865 | ("grub-zfs-fixer-*.noarch.rpm",), 1866 | ("grub-zfs-fixer",), 1867 | [ 1868 | "make", 1869 | "rpm-build", 1870 | ], 1871 | "cd /usr/src/grub-zfs-fixer && make rpm", 1872 | ), 1873 | ( 1874 | "zfs", 1875 | ( 1876 | "zfs-dkms-*.noarch.rpm", 1877 | "libnvpair*.%s.rpm" % arch, 1878 | "libuutil*.%s.rpm" % arch, 1879 | "libzfs?-[0123456789]*.%s.rpm" % arch, 1880 | "libzfs?-devel-[0123456789]*.%s.rpm" % arch, 1881 | "libzpool*.%s.rpm" % arch, 1882 | "zfs-[0123456789]*.%s.rpm" % arch, 1883 | "zfs-dracut-*.noarch.rpm", 1884 | ), 1885 | ("zfs", "zfs-dkms", "zfs-dracut"), 1886 | [ 1887 | "zlib-devel", 1888 | "libuuid-devel", 1889 | "bc", 1890 | "libblkid-devel", 1891 | "libattr-devel", 1892 | "lsscsi", 1893 | "mdadm", 1894 | "parted", 1895 | "libudev-devel", 1896 | "libtool", 1897 | "openssl-devel", 1898 | "make", 1899 | "automake", 1900 | "libtirpc-devel", 1901 | "libffi-devel", 1902 | "python3-devel", 1903 | "python3-cffi", 1904 | "libaio-devel", 1905 | "rpm-build", 1906 | "ncompress", 1907 | "python3-setuptools", 1908 | ], 1909 | ( 1910 | "cd /usr/src/zfs && " 1911 | "./autogen.sh && " 1912 | "./configure --with-config=user && " 1913 | f"make -j{multiprocessing.cpu_count()} rpm-utils && " 1914 | f"make -j{multiprocessing.cpu_count()} rpm-dkms" 1915 | ), 1916 | ), 1917 | ): 1918 | # check for shell 1919 | project_under = f"deploy_{project.replace('-', '_')}" 1920 | chroot_shell(in_chroot, shell_before, project_under) 1921 | 1922 | try: 1923 | _LOGGER.info( 1924 | "Checking if keystone packages %s are installed", 1925 | ", ".join(keystonepkgs), 1926 | ) 1927 | check_call_silent( 1928 | in_chroot(["rpm", "-q"] + list(keystonepkgs)), 1929 | ) 1930 | except subprocess.CalledProcessError: 1931 | _LOGGER.info("Packages %s are not installed, building", keystonepkgs) 1932 | project_dir = Path(p(j("usr", "src", project))) 1933 | 1934 | def getrpms(pats: Sequence[str], directory: Path) -> list[Path]: 1935 | therpms = [ 1936 | Path(rpmfile) 1937 | for pat in pats 1938 | for rpmfile in glob.glob(j(directory, pat)) 1939 | if stringtoexclude not in os.path.basename(rpmfile) 1940 | ] 1941 | return therpms 1942 | 1943 | files_to_install = getrpms(patterns, project_dir) 1944 | if not files_to_install: 1945 | repo = ( 1946 | zfs_repo 1947 | if project == "zfs" 1948 | else ("https://github.com/Rudd-O/%s" % project) 1949 | ) 1950 | repo_branch = branch if project == "zfs" else "master" 1951 | 1952 | gitter.checkout_repo_at( 1953 | repo, project_dir, repo_branch, update=update_sources 1954 | ) 1955 | 1956 | if mindeps: 1957 | pkgmgr.ensure_packages_installed(mindeps) 1958 | 1959 | _LOGGER.info("Building project: %s", project) 1960 | cmd = in_chroot(["bash", "-c", buildcmd]) 1961 | check_call(cmd) 1962 | files_to_install = getrpms(patterns, project_dir) 1963 | 1964 | _LOGGER.info("Installing built RPMs: %s", files_to_install) 1965 | pkgmgr.install_local_packages(files_to_install) 1966 | 1967 | # Check we have a patched grub2-mkconfig. 1968 | _LOGGER.info("Checking if grub2-mkconfig has been patched") 1969 | mkconfig_file = Path(p(j("usr", "sbin", "grub2-mkconfig"))) 1970 | mkconfig_text = readtext(mkconfig_file) 1971 | if "This program was patched by fix-grub-mkconfig" not in mkconfig_text: 1972 | raise ZFSBuildFailure( 1973 | f"expected to find patched {mkconfig_file} but could not find it." 1974 | " Perhaps the grub-zfs-fixer RPM was never installed?" 1975 | ) 1976 | 1977 | # Build ZFS for all kernels that have a kernel-devel. 1978 | _LOGGER.info("Running DKMS install for all kernel-devel packages") 1979 | check_call( 1980 | in_chroot( 1981 | [ 1982 | "bash", 1983 | "-xc", 1984 | "ver= ;" 1985 | "for f in /var/lib/dkms/zfs/* ; do" 1986 | ' test -L "$f" && continue ;' 1987 | ' test -d "$f" || continue ;' 1988 | ' ver=$(basename "$f") ; ' 1989 | "done ;" 1990 | "for kver in $(rpm -q kernel-devel" 1991 | " --queryformat='%{version}-%{release}.%{arch} ')" 1992 | ' ; do dkms install -m zfs -v "$ver" -k "$kver" || exit $? ; ' 1993 | "done", 1994 | ] 1995 | ) 1996 | ) 1997 | 1998 | # Check we have a ZFS.ko for at least one kernel. 1999 | modules_dir = p(j("usr", "lib", "modules", "*", "*", "zfs.ko*")) 2000 | modules_files = glob.glob(modules_dir) 2001 | if not modules_files: 2002 | raise ZFSBuildFailure( 2003 | f"expected to find but could not find module zfs.ko in {modules_dir}." 2004 | " Perhaps the ZFS source you used is too old to work with the kernel" 2005 | " this program installed?" 2006 | ) 2007 | 2008 | 2009 | def deploy_zfs() -> int: 2010 | """Deploy ZFS locally.""" 2011 | args = get_deploy_parser().parse_args() 2012 | log_config(args.trace_file) 2013 | 2014 | def p(withinchroot: str) -> str: 2015 | return j("/", withinchroot.lstrip(os.path.sep)) 2016 | 2017 | def in_chroot(x: list[str]) -> list[str]: 2018 | return x 2019 | 2020 | package_manager = pm.os_package_manager_factory() 2021 | try: 2022 | gitter = gitter_factory(dispvm_template=args.dispvm_template) 2023 | except ValueError as e: 2024 | _LOGGER.error("error: %s.", e) 2025 | return os.EX_USAGE 2026 | 2027 | try: 2028 | deploy_zfs_in_machine( 2029 | p=p, 2030 | in_chroot=in_chroot, 2031 | pkgmgr=package_manager, 2032 | gitter=gitter, 2033 | prebuilt_rpms_path=args.prebuiltrpms, 2034 | zfs_repo=args.zfs_repo, 2035 | branch=args.branch, 2036 | break_before=None, 2037 | shell_before=None, 2038 | install_current_kernel_devel=True, 2039 | update_sources=args.update_sources, 2040 | ) 2041 | except BaseException: 2042 | _LOGGER.exception("Unexpected error") 2043 | raise 2044 | 2045 | return 0 2046 | 2047 | 2048 | def bootstrap_chroot() -> int: 2049 | """Bootstrap a chroot.""" 2050 | args = get_bootstrap_chroot_parser().parse_args() 2051 | log_config(args.trace_file) 2052 | 2053 | pkgmgr = pm.chroot_bootstrapper_factory( 2054 | args.chroot, args.yum_cachedir, args.releasever, None 2055 | ) 2056 | 2057 | try: 2058 | pkgmgr.bootstrap_packages() 2059 | except BaseException: 2060 | _LOGGER.exception("Unexpected error") 2061 | raise 2062 | 2063 | return 0 2064 | 2065 | 2066 | def run_command_in_filesystem_context() -> int: 2067 | """Run a command in the context of the created image.""" 2068 | args = get_run_command_parser().parse_args() 2069 | log_config(args.trace_file) 2070 | with blockdev_context( 2071 | Path(args.voldev), 2072 | Path(args.bootdev) if args.bootdev else None, 2073 | 0, 2074 | 0, 2075 | None, 2076 | None, 2077 | create=False, 2078 | ) as (rootpart, bootpart, efipart), filesystem_context( 2079 | args.poolname, 2080 | rootpart, 2081 | bootpart, 2082 | efipart, 2083 | Path(args.workdir), 2084 | 0, 2085 | "", 2086 | "", 2087 | create=False, 2088 | ) as (_, _, _, in_chroot, _, _, _, _): 2089 | return subprocess.call(in_chroot(args.args)) 2090 | -------------------------------------------------------------------------------- /src/installfedoraonzfs/breakingbefore.py: -------------------------------------------------------------------------------- 1 | """Constants used for breaking/shell before functionality.""" 2 | 3 | import collections 4 | 5 | 6 | class BreakingBefore(Exception): 7 | """Utility exception used to break at a particular stage.""" 8 | 9 | pass 10 | 11 | 12 | break_stages = collections.OrderedDict() 13 | break_stages["beginning"] = "doing anything" 14 | break_stages["deploy_zfs"] = "deploying ZFS" 15 | break_stages["reload_chroot"] = "reloading the final chroot" 16 | break_stages["bootloader_install"] = "installation of the bootloader" 17 | break_stages[ 18 | "boot_to_test_non_hostonly" 19 | ] = "booting with the generic initramfs to test it works" 20 | break_stages[ 21 | "boot_to_test_hostonly" 22 | ] = "booting with the host-only initramfs to test it works" 23 | 24 | 25 | shell_stages = collections.OrderedDict() 26 | shell_stages[ 27 | "install_kernel" 28 | ] = "installing kernel, DKMS, and GRUB packages in the chroot" 29 | shell_stages["install_prebuilt_rpms"] = "deploying ZFS to the chroot" 30 | shell_stages["deploy_grub_zfs_fixer"] = "deploying ZFS to the chroot" 31 | shell_stages["deploy_zfs"] = "deploying ZFS to the chroot" 32 | shell_stages["reload_chroot"] = "preparing the chroot to boot" 33 | -------------------------------------------------------------------------------- /src/installfedoraonzfs/cmd.py: -------------------------------------------------------------------------------- 1 | """Commands and utilities.""" 2 | 3 | import contextlib 4 | import errno 5 | import fcntl 6 | import glob 7 | import logging 8 | import os 9 | from pathlib import Path 10 | import select 11 | import shlex 12 | import signal 13 | import stat 14 | import subprocess 15 | import sys 16 | import tempfile 17 | import threading 18 | import time 19 | from typing import IO, Any, BinaryIO, Literal, Sequence, TextIO, TypeVar, cast 20 | 21 | logger = logging.getLogger("cmd") 22 | 23 | 24 | def readtext(fn: Path) -> str: 25 | """Read a text file.""" 26 | with open(fn) as f: 27 | return f.read() 28 | 29 | 30 | def writetext(fn: Path, text: str) -> None: 31 | """Write text to a file. 32 | 33 | The write is not transactional. Incomplete writes can appear after a crash 34 | """ 35 | with open(fn, "w") as f: 36 | f.write(text) 37 | 38 | 39 | def readlines(fn: Path) -> list[str]: 40 | """Read lines from a file. 41 | 42 | Lines returned do not get their newlines stripped. 43 | """ 44 | with open(fn) as f: 45 | return f.readlines() 46 | 47 | 48 | def format_cmdline(lst: Sequence[str]) -> str: 49 | """Format a command line for print().""" 50 | return " ".join(shlex.quote(x) for x in lst) 51 | 52 | 53 | def check_call(cmd: list[str], *args: Any, **kwargs: Any) -> None: 54 | """subprocess.check_call with logging. 55 | 56 | Standard input will be closed and all I/O will proceed with text. 57 | 58 | Arguments: 59 | cmd: command and arguments to run 60 | cwd: current working directory 61 | *args: positional arguments for check_call 62 | **kwargs: keyword arguments for check_call 63 | """ 64 | cwd = kwargs.get("cwd", os.getcwd()) 65 | kwargs["close_fds"] = True 66 | kwargs["stdin"] = open(os.devnull) 67 | kwargs["universal_newlines"] = True 68 | logger.debug("Check calling %s in cwd %r", format_cmdline(cmd), cwd) 69 | subprocess.check_call(cmd, *args, **kwargs) 70 | 71 | 72 | def check_call_silent_stdout(cmd: list[str]) -> None: 73 | """subprocess.check_call with no standard output.""" 74 | with open(os.devnull, "w") as devnull: 75 | check_call(cmd, stdout=devnull) 76 | 77 | 78 | def check_call_silent(cmd: list[str]) -> None: 79 | """subprocess.check_call with no standard output or error.""" 80 | with open(os.devnull, "w") as devnull: 81 | check_call(cmd, stdout=devnull, stderr=devnull) 82 | 83 | 84 | def check_output(cmd: list[str], *args: Any, **kwargs: Any) -> str: 85 | """Obtain the standard output of a command. 86 | 87 | Arguments: 88 | cmd: command and arguments to run 89 | cwd: current working directory 90 | *args: positional arguments for check_call 91 | **kwargs: keyword arguments for check_call 92 | """ 93 | logall = kwargs.get("logall", False) 94 | if "logall" in kwargs: 95 | del kwargs["logall"] 96 | cwd = kwargs.get("cwd", os.getcwd()) 97 | kwargs["universal_newlines"] = True 98 | kwargs["close_fds"] = True 99 | logger.debug("Check outputting %s in cwd %r", format_cmdline(cmd), cwd) 100 | output = cast(str, subprocess.check_output(cmd, *args, **kwargs)) 101 | if output: 102 | if logall: 103 | logger.debug("Output from command: %r", output) 104 | else: 105 | firstline = output.splitlines()[0].strip() 106 | logger.debug("First line of output from command: %s", firstline) 107 | else: 108 | logger.debug("No output from command") 109 | return output 110 | 111 | 112 | def get_associated_lodev(path: Path) -> Path | None: 113 | """Return loopback devices associated with path.""" 114 | output = ":".join( 115 | check_output(["losetup", "-j", str(path)]).rstrip().split(":")[:-2] 116 | ) 117 | if output: 118 | return Path(output) 119 | return None 120 | 121 | 122 | def filetype( 123 | dev: Path 124 | ) -> Literal["file"] | Literal["blockdev"] | Literal["doesntexist"]: 125 | """Return 'file' or 'blockdev' or 'doesntexist' for dev.""" 126 | try: 127 | s = os.stat(dev) 128 | except OSError as e: 129 | if e.errno == errno.ENOENT: 130 | return "doesntexist" 131 | raise 132 | if stat.S_ISBLK(s.st_mode): 133 | return "blockdev" 134 | if stat.S_ISREG(s.st_mode): 135 | return "file" 136 | assert 0, "specified path %r is not a block device or a file" 137 | 138 | 139 | def losetup(path: Path) -> Path: 140 | """Set up a local loop device for a file.""" 141 | dev = check_output(["losetup", "-P", "--find", "--show", str(path)])[:-1] 142 | check_output(["blockdev", "--rereadpt", dev]) 143 | return Path(dev) 144 | 145 | 146 | class Tee(threading.Thread): 147 | """Tees output from filesets to filesets. 148 | 149 | Each fileset is a tuple with the first (read) file, and second/third write files. 150 | """ 151 | 152 | def __init__(self, *filesets: tuple[TextIO, TextIO, TextIO]): 153 | """Initialize the tee.""" 154 | threading.Thread.__init__(self) 155 | self.setDaemon(True) 156 | self.filesets = filesets 157 | self.err: BaseException | None = None 158 | 159 | def run(self) -> None: 160 | """Begin copying from readables to writables. 161 | 162 | The copying in the thread will continue until all readables 163 | have been closed. Writables will not be closed by this 164 | algorithm. 165 | """ 166 | pollables = {f[0]: f[1:] for f in self.filesets} 167 | for inf in list(pollables.keys()): 168 | flag = fcntl.fcntl(inf.fileno(), fcntl.F_GETFL) 169 | fcntl.fcntl(inf.fileno(), fcntl.F_SETFL, flag | os.O_NONBLOCK) 170 | while pollables: 171 | readables, _, _ = select.select(list(pollables.keys()), [], []) 172 | data = readables[0].read() 173 | try: 174 | if not data: 175 | # Other side of file descriptor closed / EOF. 176 | readables[0].close() 177 | # We will not be polling it again 178 | del pollables[readables[0]] 179 | continue 180 | for w in pollables[readables[0]]: 181 | w.write(data) 182 | except Exception as exc: 183 | readables[0].close() 184 | del pollables[readables[0]] 185 | if not self.err: 186 | self.err = exc 187 | break 188 | for f in self.filesets: 189 | for w in f[1:]: 190 | with contextlib.suppress(ValueError): 191 | w.flush() 192 | 193 | def join(self, timeout: float | None = None) -> None: 194 | """Join the thread.""" 195 | threading.Thread.join(self, timeout) 196 | if self.err: 197 | raise self.err 198 | 199 | 200 | def get_output_exitcode(cmd: list[str], **kwargs: Any) -> tuple[str, int]: 201 | """Get the output (stdout / stderr) of a command and its exit code. 202 | 203 | The stdout/stderr stream will be printed to standard output / standard error. 204 | 205 | stdout and stderr will be mixed in the returned output. 206 | """ 207 | cwd = kwargs.get("cwd", os.getcwd()) 208 | kwargs["universal_newlines"] = True 209 | stdin = kwargs.get("stdin") 210 | if "stdin" in kwargs: 211 | del kwargs["stdin"] 212 | stdout = kwargs.get("stdout", sys.stdout) 213 | stderr = kwargs.get("stderr", sys.stderr) 214 | if stderr == subprocess.STDOUT: 215 | assert 0, "you cannot specify subprocess.STDOUT on this function" 216 | 217 | f = tempfile.TemporaryFile(mode="w+") 218 | try: 219 | logger.debug("Get output exitcode %s in cwd %r", format_cmdline(cmd), cwd) 220 | p = subprocess.Popen( 221 | cmd, stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kwargs 222 | ) 223 | t = Tee( 224 | (cast(TextIO, p.stdout), f, stdout), 225 | (cast(TextIO, p.stderr), f, stderr), 226 | ) 227 | t.start() 228 | t.join() 229 | retval = p.wait() 230 | f.seek(0) 231 | output = f.read() 232 | finally: 233 | f.close() 234 | return output, retval 235 | 236 | 237 | def Popen(cmd: list[str], *args: Any, **kwargs: Any) -> subprocess.Popen: 238 | """subprocess.Popen with logging.""" 239 | cwd = kwargs.get("cwd", os.getcwd()) 240 | kwargs["universal_newlines"] = True 241 | logger.debug("Popening %s in cwd %r", format_cmdline(cmd), cwd) 242 | return subprocess.Popen(cmd, *args, **kwargs) 243 | 244 | 245 | def mount(source: Path, target: Path, *opts: str) -> Path: 246 | """Mount a file system. 247 | 248 | Returns the mountpoint. 249 | """ 250 | cmd = ["mount"] 251 | cmd.extend(opts) 252 | cmd.extend(["--", str(source), str(target)]) 253 | check_call(cmd) 254 | return target 255 | 256 | 257 | def bindmount(source: Path, target: Path) -> Path: 258 | """Bind mounts a path onto another path. 259 | 260 | Returns the mountpoint. 261 | """ 262 | return mount(source, target, "--bind") 263 | 264 | 265 | def get_file_size(filename: Path) -> int: 266 | """Get the file size by seeking at end.""" 267 | fd = os.open(filename, os.O_RDONLY) 268 | try: 269 | return os.lseek(fd, 0, os.SEEK_END) 270 | finally: 271 | os.close(fd) 272 | 273 | 274 | Lockable = TypeVar("Lockable", bound="IO[Any]") 275 | 276 | 277 | def _lockf(f: Lockable) -> Lockable: 278 | fcntl.flock(f.fileno(), fcntl.LOCK_EX) 279 | return f 280 | 281 | 282 | def _unlockf(f: Lockable) -> Lockable: 283 | fcntl.flock(f.fileno(), fcntl.LOCK_UN) 284 | return f 285 | 286 | 287 | def isbindmount(target: Path) -> bool: 288 | """Is path a bind mountpoint.""" 289 | with open("/proc/self/mounts", "rb") as f: 290 | mountpoints = [ 291 | x.split()[1].decode("unicode-escape") for x in f.read().splitlines() 292 | ] 293 | return str(target) in mountpoints 294 | 295 | 296 | def ismount(target: Path) -> bool: 297 | """Is path a mountpoint.""" 298 | return os.path.ismount(target) or isbindmount(target) 299 | 300 | 301 | def check_for_open_files(prefix: Path) -> dict[str, list[tuple[str, str]]]: # noqa: C901 302 | """Check that there are open files or mounted file systems within the prefix. 303 | 304 | Returns a dictionary where the keys are the files, and the values are lists 305 | that contain tuples (pid, command line) representing the processes that are 306 | keeping those files open, or tuples ("", description) representing 307 | the file systems mounted there. 308 | """ 309 | MAXWIDTH = 60 310 | results: dict[str, list[tuple[str, str]]] = {} 311 | files = glob.glob("/proc/*/fd/*") + glob.glob("/proc/*/cwd") 312 | for f in files: 313 | try: 314 | d = os.readlink(f) 315 | except Exception: 316 | continue 317 | if d.startswith(str(prefix) + os.path.sep) or d == str(prefix): 318 | pid = f.split(os.path.sep)[2] 319 | if pid == "self": 320 | continue 321 | c = os.path.join("/", *(f.split(os.path.sep)[1:3] + ["cmdline"])) 322 | try: 323 | with open(c) as ff: 324 | cmd = format_cmdline(ff.read().split("\0")) 325 | except Exception: 326 | continue 327 | if len(cmd) > MAXWIDTH: 328 | cmd = cmd[:57] + "..." 329 | if d not in results: 330 | results[d] = [] 331 | results[d].append((pid, cmd)) 332 | with open("/proc/self/mounts", "rb") as mounts: 333 | for line in mounts.read().splitlines(): 334 | fields = line.split() 335 | dev = fields[0].decode("unicode-escape") 336 | mp = fields[1].decode("unicode-escape") 337 | if mp.startswith(str(prefix) + os.path.sep): 338 | if mp not in results: 339 | results[mp] = [] 340 | results[mp].append(("", dev)) 341 | return results 342 | 343 | 344 | def _killpids(pidlist: Sequence[int]) -> None: 345 | for p in pidlist: 346 | if int(p) == os.getpid(): 347 | continue 348 | os.kill(p, signal.SIGKILL) 349 | 350 | 351 | def _printfiles(openfiles: dict[str, list[tuple[str, str]]]) -> Sequence[int]: 352 | pids: set[int] = set() 353 | for of, procs in list(openfiles.items()): 354 | logger.warning("%r:", of) 355 | for pid, cmd in procs: 356 | logger.warning(" %8s %s", pid, cmd) 357 | with contextlib.suppress(ValueError): 358 | pids.add(int(pid)) 359 | return list(pids) 360 | 361 | 362 | def umount(mountpoint: Path, tries: int = 5) -> None: 363 | """Unmount a file system, trying `tries` times.""" 364 | 365 | sleep = 1 366 | while True: 367 | if not ismount(mountpoint): 368 | return None 369 | try: 370 | check_call(["umount", str(mountpoint)]) 371 | break 372 | except subprocess.CalledProcessError: 373 | openfiles = check_for_open_files(mountpoint) 374 | if openfiles: 375 | logger.warning("There are open files in %r:", mountpoint) 376 | pids = _printfiles(openfiles) 377 | if tries <= 1 and pids: 378 | logger.warning("Killing processes with open files: %s:", pids) 379 | _killpids(pids) 380 | if tries <= 0: 381 | raise 382 | logger.warning("Syncing and sleeping %d seconds", sleep) 383 | # check_call(["sync"]) 384 | time.sleep(sleep) 385 | tries -= 1 386 | sleep = sleep * 2 387 | 388 | 389 | def create_file( 390 | filename: Path, 391 | sizebytes: int, 392 | owner: str | int | None = None, 393 | group: str | int | None = None, 394 | ) -> None: 395 | """Create a file of a certain size.""" 396 | with open(filename, "wb") as f: 397 | f.seek(sizebytes - 1) 398 | f.write(b"\0") 399 | if owner is not None: 400 | check_call(["chown", str(owner), "--", str(filename)]) 401 | if group is not None: 402 | check_call(["chgrp", str(group), "--", str(filename)]) 403 | 404 | 405 | def delete_contents(directory: Path) -> None: 406 | """Remove a directory completely.""" 407 | if not os.path.exists(directory): 408 | return 409 | ps = [str(os.path.join(directory, p)) for p in os.listdir(directory)] 410 | if ps: 411 | check_call(["rm", "-rf"] + ps) 412 | 413 | 414 | def makedirs(ds: list[Path]) -> list[Path]: 415 | """Recursively create list of directories.""" 416 | for subdir in ds: 417 | while not os.path.isdir(subdir): 418 | os.makedirs(subdir, exist_ok=True) 419 | return ds 420 | 421 | 422 | class lockfile: 423 | """Create a lockfile to be used as a context manager.""" 424 | 425 | def __init__(self, path: Path): 426 | """Initialize the lockfile object.""" 427 | self.path = path 428 | self.f: BinaryIO | None = None 429 | 430 | def __enter__(self) -> None: 431 | """Grab the lock and permit execution of contexted code.""" 432 | logger.debug("Grabbing lock %s", self.path) 433 | self.f = open(self.path, "wb") 434 | _lockf(self.f) 435 | logger.debug("Grabbed lock %s", self.path) 436 | 437 | def __exit__(self, *unused_args: Any) -> None: 438 | """Unlock and close lockfile.""" 439 | logger.debug("Releasing lock %s", self.path) 440 | assert self.f 441 | _unlockf(self.f) 442 | self.f.close() 443 | self.f = None 444 | logger.debug("Released lock %s", self.path) 445 | 446 | 447 | def cpuinfo() -> str: 448 | """Return the CPU info.""" 449 | return open("/proc/cpuinfo").read() 450 | 451 | 452 | class UnsupportedDistribution(Exception): 453 | """Distribution is not supported.""" 454 | 455 | 456 | class UnsupportedDistributionVersion(Exception): 457 | """Distribution version is not supported.""" 458 | 459 | 460 | def get_distro_release_info() -> dict[str, str]: 461 | """Obtain the distribution's release info as a dictionary.""" 462 | vars: dict[str, str] = {} 463 | try: 464 | with open("/etc/os-release") as f: 465 | data = f.read() 466 | for line in data.splitlines(): 467 | if not line.strip(): 468 | continue 469 | k, _, v = line.strip().partition("=") 470 | v = shlex.split(v)[0] 471 | vars[k] = v 472 | except FileNotFoundError as e: 473 | raise UnsupportedDistribution("unknown") from e 474 | return vars 475 | -------------------------------------------------------------------------------- /src/installfedoraonzfs/git.py: -------------------------------------------------------------------------------- 1 | """Git-related utilities.""" 2 | 3 | import logging 4 | import os 5 | import shlex 6 | import shutil 7 | import tempfile 8 | 9 | from installfedoraonzfs.cmd import check_call, check_output, get_distro_release_info 10 | from pathlib import Path 11 | from typing import Protocol 12 | 13 | 14 | _LOGGER = logging.getLogger("git") 15 | 16 | 17 | class Gitter(Protocol): 18 | """Protocol for a class that can check out a repo.""" 19 | 20 | def checkout_repo_at( 21 | self, repo: str, project_dir: Path, branch: str, update: bool = True 22 | ) -> None: 23 | """Check out a repository URL to `project_dir`.""" 24 | ... 25 | 26 | 27 | class NetworkedGitter: 28 | """A Gitter that requires use of the network.""" 29 | 30 | def checkout_repo_at( 31 | self, repo: str, project_dir: Path, branch: str, update: bool = True 32 | ) -> None: 33 | """Check out a repository URL to `project_dir`.""" 34 | qbranch = shlex.quote(branch) 35 | if os.path.isdir(project_dir): 36 | if update: 37 | _LOGGER.info("Updating and checking out git repository: %s", repo) 38 | check_call("git fetch".split(), cwd=project_dir) 39 | check_call( 40 | [ 41 | "bash", 42 | "-c", 43 | f"git reset --hard origin/{qbranch}" 44 | f" || git reset --hard {qbranch}", 45 | ], 46 | cwd=project_dir, 47 | ) 48 | else: 49 | _LOGGER.info("Cloning git repository: %s", repo) 50 | check_call(["git", "clone", repo, str(project_dir)]) 51 | check_call( 52 | [ 53 | "bash", 54 | "-c", 55 | f"git reset --hard origin/{qbranch} || git reset --hard {qbranch}", 56 | ], 57 | cwd=project_dir, 58 | ) 59 | check_call(["git", "--no-pager", "show"], cwd=project_dir) 60 | 61 | 62 | class QubesGitter: 63 | """A Gitter that requires use of the network.""" 64 | 65 | def __init__(self, dispvm_template: str): 66 | """Initialize the gitter. 67 | 68 | Args: 69 | dispvm_template: mandatory name of disposable VM to use. 70 | """ 71 | self.dispvm_template = dispvm_template 72 | 73 | def checkout_repo_at( 74 | self, repo: str, project_dir: Path, branch: str, update: bool = True 75 | ) -> None: 76 | """Check out a repository URL to `project_dir`.""" 77 | from installfedoraonzfs.pm import LocalQubesOSPackageManager 78 | 79 | local_pkgmgr = LocalQubesOSPackageManager() 80 | local_pkgmgr.ensure_packages_installed(["git-core"]) 81 | 82 | qbranch = shlex.quote(branch) 83 | abs_project_dir = os.path.abspath(project_dir) 84 | if os.path.isdir(project_dir) and update: 85 | _LOGGER.info("Update requested — removing existing repo: %s", project_dir) 86 | shutil.rmtree(project_dir) 87 | if not os.path.isdir(project_dir): 88 | _LOGGER.info("Cloning git repository: %s", repo) 89 | with tempfile.TemporaryDirectory() as tempdir: 90 | installgit = "(which git || dnf install -y git-core)" 91 | gitclone = shlex.join( 92 | ["git", "clone", "--", repo, os.path.basename(tempdir)] 93 | ) 94 | tar = f"cd {shlex.quote(os.path.basename(tempdir))} && tar c ." 95 | gitcloneandtar = f"{installgit} >&2 && {gitclone} >&2 && {tar}" 96 | # default_dvm = check_output(["qubes-prefs", "default_dispvm"]) 97 | indvm = shlex.join( 98 | [ 99 | "qvm-run", 100 | "-a", 101 | "-p", 102 | "--no-filter-escape-chars", 103 | "--no-color-output", 104 | f"--dispvm={self.dispvm_template}", 105 | "bash", 106 | "-c", 107 | gitcloneandtar, 108 | ] 109 | ) 110 | extract = "tar x" 111 | chdirandextract = ( 112 | "(" 113 | f"cd {shlex.quote(tempdir)} && mkdir -p incoming" 114 | f" && mkdir -p {shlex.quote(os.path.dirname(abs_project_dir))}" 115 | f" && cd incoming && {extract}" 116 | f" && cd .." 117 | f" && mv incoming {shlex.quote(abs_project_dir)}" 118 | ")" 119 | ) 120 | fullcommand = [ 121 | "bash", 122 | "-c", 123 | f"set -o pipefail ; {indvm} | {chdirandextract}", 124 | ] 125 | check_call(fullcommand) 126 | check_call( 127 | [ 128 | "bash", 129 | "-c", 130 | f"git reset --hard origin/{qbranch} || git reset --hard {qbranch}", 131 | ], 132 | cwd=project_dir, 133 | ) 134 | check_call(["git", "--no-pager", "show"], cwd=project_dir) 135 | 136 | 137 | def gitter_factory(dispvm_template: str | None = None) -> Gitter: 138 | """Return a Gitter that is compatible with the system.""" 139 | info = get_distro_release_info() 140 | if info.get("ID") == "qubes": 141 | if not dispvm_template: 142 | dispvm_template = check_output(["qubes-prefs", "default_dispvm"]).rstrip() 143 | if not dispvm_template: 144 | raise ValueError( 145 | "there is no default disposable qube template on this system;" 146 | " you must specify a disposable qube template with --dispvm-template" 147 | " when using this program on this system" 148 | ) 149 | return QubesGitter(dispvm_template) 150 | if dispvm_template: 151 | raise ValueError( 152 | "disposable qube may not be specified when using this" 153 | f" program on a {info.get('NAME')} system" 154 | ) 155 | return NetworkedGitter() 156 | -------------------------------------------------------------------------------- /src/installfedoraonzfs/log.py: -------------------------------------------------------------------------------- 1 | """Logging functionality.""" 2 | 3 | import logging 4 | from pathlib import Path 5 | import time 6 | from typing import Any 7 | 8 | BASIC_FORMAT = "%(asctime)8s %(levelname)2s %(message)s" 9 | TRACE_FORMAT = ( 10 | "%(asctime)8s %(levelname)2s:%(name)16s:%(funcName)32s@%(lineno)4d\t%(message)s" 11 | ) 12 | 13 | 14 | def log_config(trace_file: Path | None = None) -> None: 15 | """Set up logging formats.""" 16 | logging.addLevelName(logging.DEBUG, "TT") 17 | logging.addLevelName(logging.INFO, "II") 18 | logging.addLevelName(logging.WARNING, "WW") 19 | logging.addLevelName(logging.ERROR, "EE") 20 | logging.addLevelName(logging.CRITICAL, "XX") 21 | 22 | class TimeFormatter(logging.Formatter): 23 | def __init__(self, *a: Any, **kw: Any) -> None: 24 | logging.Formatter.__init__(self, *a, **kw) 25 | self.start = time.time() 26 | 27 | def formatTime( 28 | self, record: logging.LogRecord, datefmt: str | None = None 29 | ) -> str: 30 | t = time.time() - self.start 31 | m = int(t / 60) 32 | s = t % 60 33 | return "%dm%.2f" % (m, s) 34 | 35 | rl = logging.getLogger() 36 | rl.setLevel(logging.DEBUG) 37 | ch = logging.StreamHandler() 38 | cfm = TimeFormatter(BASIC_FORMAT) 39 | ch.setLevel(logging.INFO) 40 | ch.setFormatter(cfm) 41 | rl.addHandler(ch) 42 | if trace_file: 43 | th = logging.FileHandler(trace_file, mode="w") 44 | tfm = TimeFormatter(TRACE_FORMAT) 45 | th.setLevel(logging.DEBUG) 46 | th.setFormatter(tfm) 47 | rl.addHandler(th) 48 | -------------------------------------------------------------------------------- /src/installfedoraonzfs/pm.py: -------------------------------------------------------------------------------- 1 | """Package manager utilities.""" 2 | 3 | import contextlib 4 | import errno 5 | import logging 6 | import os 7 | from pathlib import Path 8 | import platform 9 | import re 10 | import subprocess 11 | import tempfile 12 | from typing import Any, Generator, Literal, Protocol, cast 13 | 14 | from installfedoraonzfs import cmd as cmdmod 15 | import installfedoraonzfs.retry as retrymod 16 | 17 | _LOGGER = logging.getLogger(__name__) 18 | 19 | DNF_DOWNLOAD_THEN_INSTALL: tuple[list[str], list[str]] = ( 20 | ["--downloadonly"], 21 | [], 22 | ) 23 | 24 | 25 | def _run_with_retries(cmd: list[str]) -> tuple[str, int]: 26 | r = retrymod.retry(2) 27 | return r(lambda: _check_call_detect_retryable_errors(cmd))() # type: ignore 28 | 29 | 30 | class ChrootBootstrapper(Protocol): 31 | """Protocol for a package manager that supports bootstrap of a chroot.""" 32 | 33 | def bootstrap_packages(self) -> None: 34 | """Install a minimal set of packages for a shell.""" 35 | ... 36 | 37 | def setup_kernel_bootloader(self) -> None: 38 | """Set up a kernel and a bootloader.""" 39 | ... 40 | 41 | 42 | class SupportsDownloadablePackageInstall(Protocol): 43 | """Protocol for a package manager that supports distro package installation.""" 44 | 45 | 46 | class PackageManager(Protocol): 47 | """Protocol for a package manager that supports universal package installation.""" 48 | 49 | def ensure_packages_installed(self, package_specs: list[str]) -> None: 50 | """Install a list of packages from the distro. Download them first.""" 51 | ... 52 | 53 | def install_local_packages(self, package_files: list[Path]) -> None: 54 | """Install a list of local packages. Download dependencies first.""" 55 | ... 56 | 57 | 58 | class ChrootPackageManager(PackageManager, Protocol): 59 | """Protocol for a package manager that can manage a chroot.""" 60 | 61 | chroot: Path 62 | 63 | 64 | class OSPackageManager(PackageManager, Protocol): 65 | """Protocol for a package manager that can manage an OS.""" 66 | 67 | chroot: None 68 | 69 | 70 | class DownloadFailed(retrymod.Retryable, subprocess.CalledProcessError): 71 | """Package download failed.""" 72 | 73 | def __str__(self) -> str: 74 | """Stringify the exception.""" 75 | return f"DNF download {self.cmd} failed with {self.returncode}: {self.output}" 76 | 77 | 78 | class PluginSelinuxRetryable(retrymod.Retryable, subprocess.CalledProcessError): 79 | """Retryable SELinux plugin failure.""" 80 | 81 | 82 | def _check_call_detect_retryable_errors(cmd: list[str]) -> tuple[str, int]: 83 | out, ret = cmdmod.get_output_exitcode(cmd) 84 | if ret != 0: 85 | if "--downloadonly" in cmd: 86 | raise DownloadFailed(ret, cmd, output=out) 87 | if "error: Plugin selinux" in out: 88 | raise PluginSelinuxRetryable(ret, cmd, output=out) 89 | _LOGGER.error("This is not a retryable error, it should not be retried.") 90 | raise subprocess.CalledProcessError(ret, cmd, output=out) 91 | return out, ret 92 | 93 | 94 | class LocalFedoraPackageManager: 95 | """Package manager that can install packages locally on Fedora systems.""" 96 | 97 | chroot = None 98 | 99 | def __init__(self) -> None: 100 | """Initialize the package manager.""" 101 | self._logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}") 102 | 103 | def install_local_packages(self, package_files: list[Path]) -> None: 104 | """Install a list of local packages on a Fedora system. Download them first.""" 105 | 106 | packages = [os.path.abspath(p) for p in package_files] 107 | for package in packages: 108 | if not os.path.isfile(package): 109 | raise FileNotFoundError( 110 | errno.ENOENT, os.strerror(errno.ENOENT), package 111 | ) 112 | return self.ensure_packages_installed(packages) 113 | 114 | def ensure_packages_installed(self, package_names: list[str]) -> None: 115 | """Install a list of packages on a Fedora system. Download them first.""" 116 | 117 | for option in DNF_DOWNLOAD_THEN_INSTALL: 118 | self._logger.info( 119 | "Installing packages %s: %s", option, ", ".join(package_names) 120 | ) 121 | cmd = (["dnf", "install"]) + ["-y"] + package_names 122 | _run_with_retries(cmd) 123 | 124 | 125 | class LocalQubesOSPackageManager: 126 | """Package manager that can install packages locally on Qubes OS systems.""" 127 | 128 | chroot = None 129 | 130 | def __init__(self) -> None: 131 | """Initialize the package manager.""" 132 | self._logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}") 133 | 134 | def install_local_packages(self, package_files: list[Path]) -> None: 135 | """Install a list of local packages on a Qubes OS system. 136 | 137 | Installation is two-phase. First, take all dependencies the package 138 | files need, and install these. Then install the packages themselves. 139 | """ 140 | packages = [os.path.abspath(p) for p in package_files] 141 | for package in packages: 142 | if not os.path.isfile(package): 143 | raise FileNotFoundError( 144 | errno.ENOENT, os.strerror(errno.ENOENT), package 145 | ) 146 | 147 | deps: set[str] = set() 148 | cmd = ["rpm", "-q", "--requires"] 149 | for d in cmdmod.check_output(cmd + packages).splitlines(): 150 | if d and not d.startswith("rpmlib("): 151 | deps.add(d) 152 | if deps: 153 | self._logger.info("First phase: installing dependencies: %s", deps) 154 | self.ensure_packages_installed(list(deps)) 155 | 156 | self._logger.info( 157 | "Second phase: installing package files: %s", ", ".join(packages) 158 | ) 159 | cmd = (["dnf", "install"]) + ["-y"] + packages 160 | _run_with_retries(cmd) 161 | 162 | def ensure_packages_installed(self, package_names: list[str]) -> None: 163 | """Install a list of packages on a Qubes OS system.""" 164 | 165 | self._logger.info("Installing packages: %s", ", ".join(package_names)) 166 | cmd = [ 167 | "qubes-dom0-update", 168 | "--action=install", 169 | "--console", 170 | "-y", 171 | ] + package_names 172 | _run_with_retries(cmd) 173 | 174 | 175 | class ChrootFedoraPackageManagerAndBootstrapper: 176 | """Package manager that can bootstrap and install packages on a Fedora chroot.""" 177 | 178 | def __init__(self, releasever: str, chroot: Path, cachedir: Path | None): 179 | """Initialize the chroot package manager.""" 180 | if chroot.absolute() == chroot.absolute().parent: 181 | assert 0, f"cannot use the root directory ({chroot}) as chroot" 182 | if cachedir and (cachedir.absolute() == cachedir.absolute().parent): 183 | assert 0, f"cannot use the root directory ({chroot}) as cache directory" 184 | 185 | self.chroot = chroot.absolute() 186 | self.releasever = releasever 187 | self._logger = logging.getLogger(f"{__name__}.{{self.__class__.__name__}}") 188 | self._cachedir = cachedir.absolute() if cachedir else None 189 | 190 | def bootstrap_packages(self) -> None: 191 | """Bootstrap the chroot.""" 192 | 193 | def get_base_packages() -> list[str]: 194 | """Get packages to be installed from outside the chroot.""" 195 | packages = ( 196 | "filesystem basesystem setup rootfiles bash rpm passwd pam" 197 | " util-linux rpm dnf" 198 | ).split() 199 | return packages 200 | 201 | def get_in_chroot_packages() -> list[str]: 202 | """Get packages to be installed in the chroot phase.""" 203 | pkgs = ( 204 | "e2fsprogs nano binutils rsync coreutils" 205 | " vim-minimal net-tools" 206 | " cryptsetup kbd-misc kbd policycoreutils selinux-policy-targeted" 207 | " libseccomp sed pciutils kmod dracut" 208 | " grub2 grub2-tools grubby efibootmgr" 209 | ).split() 210 | e = pkgs.extend 211 | e("shim-x64 grub2-efi-x64 grub2-efi-x64-modules".split()) 212 | e(["sssd-client"]) 213 | e(["systemd-networkd"]) 214 | return pkgs 215 | 216 | self._logger.info("Installing basic packages into chroot.") 217 | packages = get_base_packages() 218 | self._ensure_packages_installed(packages, method="out_of_chroot") 219 | self._logger.info("Installing more packages within chroot.") 220 | chroot_packages = get_in_chroot_packages() 221 | self._ensure_packages_installed(chroot_packages, method="out_of_chroot") 222 | 223 | def setup_kernel_bootloader(self) -> None: 224 | """Install the kernel and the bootloader into the chroot.""" 225 | p = ( 226 | "kernel kernel-headers kernel-modules " 227 | "kernel-devel dkms grub2 grub2-efi".split() 228 | ) 229 | self._ensure_packages_installed( 230 | p, 231 | method="out_of_chroot", 232 | extra_args=["--setopt=install_weak_deps=true"], 233 | ) 234 | 235 | def ensure_packages_installed(self, package_names: list[str]) -> None: 236 | """Install packages by name within the chroot.""" 237 | self._ensure_packages_installed(package_names, method="out_of_chroot") 238 | 239 | def install_local_packages(self, package_files: list[Path]) -> None: 240 | """Install a list of local packages on a Fedora system. Download them first.""" 241 | 242 | packages = [os.path.abspath(p) for p in package_files] 243 | for package in packages: 244 | if not os.path.isfile(package): 245 | raise FileNotFoundError( 246 | errno.ENOENT, os.strerror(errno.ENOENT), package 247 | ) 248 | return self._ensure_packages_installed( 249 | [str(s) for s in packages], method="out_of_chroot" 250 | ) 251 | 252 | @contextlib.contextmanager 253 | def _method( 254 | self, method: Literal["in_chroot"] | Literal["out_of_chroot"] 255 | ) -> Generator[Path, None, None]: 256 | pkgmgr = "dnf" 257 | guestver = self.releasever 258 | if method == "in_chroot": 259 | dirforconfig = self.chroot 260 | sourceconf = ( 261 | (self.chroot / "/etc/dnf/dnf.conf") 262 | if os.path.isfile(self.chroot / "/etc/dnf/dnf.conf") 263 | else Path("/etc/dnf/dnf/conf") 264 | ) 265 | else: 266 | dirforconfig = Path(os.getenv("TMPDIR") or "/tmp") # noqa: S108 267 | sourceconf = Path("/etc/dnf/dnf.conf") 268 | 269 | parms = { 270 | "logfile": "/dev/null", 271 | "debuglevel": 2, 272 | "reposdir": "/nonexistent", 273 | "include": None, 274 | "max_parallel_downloads": 10, 275 | "keepcache": True, 276 | "install_weak_deps": False, 277 | } 278 | 279 | # /yumcache 280 | if not self._cachedir: 281 | n = self._make_temp_yum_config(sourceconf, dirforconfig, **parms) 282 | try: 283 | yield n.name 284 | finally: 285 | n.close() 286 | del n 287 | 288 | else: 289 | cmdmod.makedirs([self._cachedir]) 290 | with cmdmod.lockfile(self._cachedir / "ifz-lockfile"): 291 | hostarch = platform.machine() 292 | guestarch = ( 293 | hostarch # FIXME: at some point will we support other arches? 294 | ) 295 | hostver = cmdmod.get_distro_release_info()["VERSION_ID"] 296 | # /yumcache/dnf/host-hostver-hostarch/chroot-guestver-guestarch 297 | # Must be this way because the data in the cachedir follows 298 | # specific formats that vary from hostver to hostver. 299 | cachedir = ( 300 | self._cachedir 301 | / pkgmgr 302 | / f"host-{hostver}-{hostarch}" 303 | / f"chroot-{guestver}-{guestarch}" 304 | / "cache" 305 | ) 306 | cmdmod.makedirs([cachedir]) 307 | # /yumcache/.../lock 308 | # /chroot/var/cache/dnf 309 | 310 | with cmdmod.lockfile(cachedir / "lock"): 311 | cachedir_in_chroot = cmdmod.makedirs( 312 | [self.chroot / f"tmp-{pkgmgr}-cache"] 313 | )[0] 314 | parms["cachedir"] = str(cachedir_in_chroot)[len(str(self.chroot)) :] 315 | parms["keepcache"] = "true" 316 | while cmdmod.ismount(cachedir_in_chroot): 317 | self._logger.debug("Preemptively unmounting %s", cachedir_in_chroot) 318 | cmdmod.umount(cachedir_in_chroot) 319 | n = None 320 | cachemount = None 321 | try: 322 | self._logger.debug( 323 | "Mounting %s to %s", cachedir, cachedir_in_chroot 324 | ) 325 | cachemount = cmdmod.bindmount(cachedir, cachedir_in_chroot) 326 | n = self._make_temp_yum_config(sourceconf, dirforconfig, **parms) 327 | self._logger.debug("Created custom dnf configuration %s", n.name) 328 | yield n.name 329 | finally: 330 | if n: 331 | n.close() 332 | del n 333 | if cachemount: 334 | cmdmod.umount(cachedir_in_chroot) 335 | try: 336 | with contextlib.suppress(FileNotFoundError): 337 | os.rmdir(cachedir_in_chroot) 338 | except Exception as e: 339 | self._logger.debug( 340 | "Ignoring inability to remove %s (%s)", 341 | cachedir_in_chroot, 342 | e, 343 | ) 344 | 345 | def _make_temp_yum_config( 346 | self, source: Path, directory: Path, **kwargs: Any 347 | ) -> Any: 348 | fedora_repos_template = """ 349 | [fedora] 350 | name=Fedora $releasever - $basearch 351 | failovermethod=priority 352 | #baseurl=http://download.fedoraproject.org/pub/fedora/linux/releases/$releasever/Everything/$basearch/os/ 353 | metalink=https://mirrors.fedoraproject.org/metalink?repo=fedora-$releasever&arch=$basearch 354 | enabled=1 355 | metadata_expire=7d 356 | gpgcheck=1 357 | gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-fedora-$releasever-$basearch 358 | skip_if_unavailable=False 359 | 360 | [updates] 361 | name=Fedora $releasever - $basearch - Updates 362 | failovermethod=priority 363 | #baseurl=http://download.fedoraproject.org/pub/fedora/linux/updates/$releasever/$basearch/ 364 | metalink=https://mirrors.fedoraproject.org/metalink?repo=updates-released-f$releasever&arch=$basearch 365 | enabled=1 366 | gpgcheck=1 367 | gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-fedora-$releasever-$basearch 368 | skip_if_unavailable=False 369 | """ 370 | tempyumconfig = tempfile.NamedTemporaryFile(dir=directory) 371 | yumconfigtext = cmdmod.readtext(source) 372 | for optname, optval in list(kwargs.items()): 373 | if optval is None: 374 | yumconfigtext, repls = re.subn( 375 | f"^ *{optname} *=.*$", "", yumconfigtext, flags=re.M 376 | ) 377 | else: 378 | yumconfigtext, repls = re.subn( 379 | f"^ *{optname} *=.*$", 380 | f"{optname}={optval}", 381 | yumconfigtext, 382 | flags=re.M, 383 | ) 384 | if not repls: 385 | yumconfigtext, repls = re.subn( 386 | "\\[main]", f"[main]\n{optname}={optval}", yumconfigtext 387 | ) 388 | assert repls, ( 389 | "Could not substitute yum.conf main config section" 390 | f" with the {optname} stanza. Text: {yumconfigtext}" 391 | ) 392 | tempyumconfig.write(yumconfigtext.encode("utf-8")) 393 | tempyumconfig.write(fedora_repos_template.encode("utf-8")) 394 | tempyumconfig.flush() 395 | tempyumconfig.seek(0) 396 | return tempyumconfig 397 | 398 | def _ensure_packages_installed( 399 | self, 400 | packages: list[str], 401 | method: Literal["in_chroot"] | Literal["out_of_chroot"], 402 | extra_args: Any = None, 403 | ) -> None: 404 | more_args = extra_args if extra_args else [] 405 | 406 | def in_chroot(lst: list[str]) -> list[str]: 407 | return ["chroot", str(self.chroot)] + lst 408 | 409 | with self._method(method) as config: 410 | try: 411 | cmdmod.check_call_silent(in_chroot(["rpm", "-q"] + packages)) 412 | self._logger.info("All required packages are available") 413 | return 414 | except subprocess.CalledProcessError: 415 | pass 416 | 417 | for option in DNF_DOWNLOAD_THEN_INSTALL: 418 | self._logger.info( 419 | "Installing packages %s %s (extra args: %s): %s", 420 | option, 421 | method, 422 | extra_args, 423 | ", ".join(packages), 424 | ) 425 | cmd = ( 426 | (["dnf"] if method == "out_of_chroot" else in_chroot(["dnf"])) 427 | + ["install", "-y", "--disableplugin=*qubes*"] 428 | + more_args 429 | + ( 430 | [ 431 | "-c", 432 | str(config) 433 | if method == "out_of_chroot" 434 | else str(config)[len(str(self.chroot)) :], 435 | ] 436 | ) 437 | + option 438 | + ( 439 | [ 440 | "--installroot=%s" % self.chroot, 441 | "--releasever=%s" % self.releasever, 442 | ] 443 | if method == "out_of_chroot" 444 | else [ 445 | "--releasever=%s" % self.releasever, 446 | ] 447 | ) 448 | + packages 449 | ) 450 | _run_with_retries(cmd) 451 | 452 | 453 | def os_package_manager_factory() -> OSPackageManager: 454 | """Create a package manager.""" 455 | info = cmdmod.get_distro_release_info() 456 | distro = info.get("ID") 457 | if distro == "fedora": 458 | return LocalFedoraPackageManager() 459 | elif distro == "qubes": 460 | releasever = info.get("VERSION_ID") 461 | if releasever and releasever.zfill(5) < "4.2".zfill(5): 462 | raise cmdmod.UnsupportedDistributionVersion(info.get("NAME"), releasever) 463 | return LocalQubesOSPackageManager() 464 | raise cmdmod.UnsupportedDistribution(info.get("NAME")) 465 | 466 | 467 | def _chroot_manager_factory( 468 | chroot: Path, 469 | cachedir: Path | None, 470 | releasever: str | None, 471 | distro: str | None, 472 | kind: Literal["bootstrapper"] | Literal["packagemanager"], 473 | ) -> ChrootBootstrapper | ChrootPackageManager: 474 | """Create a bootstrapper for a chroot.""" 475 | info = cmdmod.get_distro_release_info() 476 | if distro is None: 477 | distro = info.get("ID") 478 | if releasever is None: 479 | if distro != info.get("ID"): 480 | assert ( 481 | 0 482 | ), "Cannot specify a different distro without specifying a releasever" 483 | releasever = info.get("VERSION_ID") 484 | assert releasever, f"Your releasever is invalid: {releasever}" 485 | 486 | if distro != "fedora": 487 | raise cmdmod.UnsupportedDistribution(info.get("NAME")) 488 | if releasever and releasever.zfill(5) < "37".zfill(5): 489 | raise cmdmod.UnsupportedDistributionVersion(info.get("NAME"), releasever) 490 | 491 | if kind == "bootstrapper": 492 | t: ChrootBootstrapper = ChrootFedoraPackageManagerAndBootstrapper( 493 | releasever, chroot, cachedir 494 | ) 495 | return t 496 | 497 | elif kind == "packagemanager": 498 | u: ChrootPackageManager = ChrootFedoraPackageManagerAndBootstrapper( 499 | releasever, chroot, cachedir 500 | ) 501 | return u 502 | 503 | assert 0, "not reached" 504 | 505 | 506 | def chroot_package_manager_factory( 507 | chroot: Path, 508 | cachedir: Path | None, 509 | releasever: str | None, 510 | distro: str | None, 511 | ) -> ChrootPackageManager: 512 | """Create a bootstrapper for a chroot.""" 513 | return cast( 514 | ChrootPackageManager, 515 | _chroot_manager_factory(chroot, cachedir, releasever, distro, "packagemanager"), 516 | ) 517 | 518 | 519 | def chroot_bootstrapper_factory( 520 | chroot: Path, 521 | cachedir: Path | None, 522 | releasever: str | None, 523 | distro: str | None, 524 | ) -> ChrootBootstrapper: 525 | """Create a bootstrapper for a chroot.""" 526 | return cast( 527 | ChrootBootstrapper, 528 | _chroot_manager_factory(chroot, cachedir, releasever, distro, "bootstrapper"), 529 | ) 530 | -------------------------------------------------------------------------------- /src/installfedoraonzfs/retry.py: -------------------------------------------------------------------------------- 1 | """Retry functionality.""" 2 | 3 | import logging 4 | import time 5 | from typing import Any, Callable 6 | 7 | 8 | class Retryable(BaseException): 9 | """Type of exception that can be retried.""" 10 | 11 | pass 12 | 13 | 14 | class retry: 15 | """Retry a particular callable. 16 | 17 | Returns a callable that will retry the callee up to N times, 18 | if the callee raises an exception of type Retryable. 19 | To be clear: if N == 0, then the function will not retry. 20 | So, to get three tries, you must pass N == 2. 21 | """ 22 | 23 | def __init__( 24 | self, 25 | N: int, 26 | timeout: int | float = 0, 27 | retryable_exception: type[BaseException] = Retryable, 28 | ) -> None: 29 | """Initialize the retrier. 30 | 31 | Args: 32 | N: number of retries (0 = no retry) 33 | timeout: time to sleep between retries 34 | retryable_exception: type of exception to retry 35 | """ 36 | self.N = N 37 | self.timeout = timeout 38 | self.retryable_exception = retryable_exception 39 | 40 | def __call__(self, kallable: Callable[[...], Any]) -> Callable[[...], Any]: 41 | """Return a function that will retry the callable.""" 42 | 43 | def retryer(*a: Any, **kw: Any) -> Any: 44 | logger = logging.getLogger("retry") 45 | while True: 46 | try: 47 | return kallable(*a, **kw) 48 | except self.retryable_exception as e: 49 | if self.N >= 1: 50 | logger.error( 51 | "Received retryable error %s running %s, " 52 | "trying %s more times", 53 | e, 54 | kallable, 55 | self.N, 56 | ) 57 | time.sleep(self.timeout) 58 | else: 59 | raise 60 | self.N -= 1 61 | 62 | return retryer 63 | -------------------------------------------------------------------------------- /src/installfedoraonzfs/test_base.py: -------------------------------------------------------------------------------- 1 | """ 2 | Created on Dec 19, 2018 3 | 4 | @author: user 5 | """ 6 | import unittest 7 | 8 | import contextlib 9 | import installfedoraonzfs 10 | import os 11 | import shutil 12 | import subprocess 13 | import tempfile 14 | import re 15 | 16 | 17 | @contextlib.contextmanager 18 | def preprootboot(directory=None): 19 | u = installfedoraonzfs.Undoer() 20 | d = tempfile.mkdtemp(dir=directory) 21 | root = os.path.join(d, "root") 22 | boot = os.path.join(d, "boot") 23 | try: 24 | yield d, root, boot, u 25 | finally: 26 | u.undo() 27 | shutil.rmtree(d) 28 | 29 | 30 | def lodevs(): 31 | devs = subprocess.check_output(["losetup", "-la"]) 32 | return [x for x in devs.splitlines() if x] 33 | 34 | 35 | class TestBlockdevContext(unittest.TestCase): 36 | @unittest.skipIf(os.getuid() != 0, "not root") 37 | def testSeparateBoot(self): 38 | firstlo = lodevs() 39 | with preprootboot() as (_, root, boot, undoer): 40 | with installfedoraonzfs.blockdev_context( 41 | root, boot, undoer, 32, 8, None, None, True 42 | ) as (rootpart, bootpart, efipart): 43 | assert bootpart.endswith("p3"), (rootpart, bootpart, efipart) 44 | assert efipart.endswith("p2"), (rootpart, bootpart, efipart) 45 | assert re.match("^/dev/loop[0-9]+$", rootpart), ( 46 | rootpart, 47 | bootpart, 48 | efipart, 49 | ) 50 | nowlo = lodevs() 51 | self.assertListEqual(firstlo, nowlo) 52 | 53 | @unittest.skipIf(os.getuid() != 0, "not root") 54 | def testSeparateBootTwice(self): 55 | firstlo = lodevs() 56 | with preprootboot() as (_, root, boot, undoer): 57 | with installfedoraonzfs.blockdev_context( 58 | root, boot, undoer, 32, 8, None, None, True 59 | ) as (rootpart, bootpart, efipart): 60 | assert bootpart.endswith("p3"), (rootpart, bootpart, efipart) 61 | assert efipart.endswith("p2"), (rootpart, bootpart, efipart) 62 | assert re.match("^/dev/loop[0-9]+$", rootpart), ( 63 | rootpart, 64 | bootpart, 65 | efipart, 66 | ) 67 | undoer.undo() 68 | nowlo = lodevs() 69 | self.assertListEqual(firstlo, nowlo) 70 | with installfedoraonzfs.blockdev_context( 71 | root, boot, undoer, 32, 8, None, None, False 72 | ) as (rootpart2, bootpart2, efipart2): 73 | assert rootpart == rootpart2, (rootpart, rootpart2) 74 | assert bootpart == bootpart2, (bootpart, bootpart2) 75 | assert efipart == efipart2, (efipart, efipart2) 76 | nowlo = lodevs() 77 | self.assertListEqual(firstlo, nowlo) 78 | 79 | 80 | class TestSetupFilesystems(unittest.TestCase): 81 | @unittest.skipIf(os.getuid() != 0, "not root") 82 | def testBasic(self): 83 | firstlo = lodevs() 84 | wdir = os.path.dirname(__file__) 85 | with preprootboot(wdir) as (_, root, boot, undoer): 86 | with installfedoraonzfs.blockdev_context( 87 | root, boot, undoer, 256, 128, None, None, True 88 | ) as (_, bootpart, efipart): 89 | bootuuid, efiuuid = installfedoraonzfs.setup_boot_filesystems( 90 | bootpart, efipart, "postfix", True 91 | ) 92 | assert bootuuid 93 | assert efiuuid 94 | nowlo = lodevs() 95 | self.assertListEqual(firstlo, nowlo) 96 | 97 | @unittest.skipIf(os.getuid() != 0, "not root") 98 | def testTwice(self): 99 | wdir = os.path.dirname(__file__) 100 | with preprootboot(wdir) as (_, root, boot, undoer): 101 | with installfedoraonzfs.blockdev_context( 102 | root, boot, undoer, 256, 128, None, None, True 103 | ) as (_, bootpart, efipart): 104 | bootuuid, efiuuid = installfedoraonzfs.setup_boot_filesystems( 105 | bootpart, efipart, "postfix", True 106 | ) 107 | assert bootuuid 108 | assert efiuuid 109 | undoer.undo() 110 | with installfedoraonzfs.blockdev_context( 111 | root, boot, undoer, 256, 128, None, None, False 112 | ) as (_, bootpart, efipart): 113 | bootuuid2, efiuuid2 = installfedoraonzfs.setup_boot_filesystems( 114 | bootpart, efipart, "postfix", False 115 | ) 116 | assert bootuuid == bootuuid2, (bootuuid, bootuuid2) 117 | assert efiuuid == efiuuid, (efiuuid, efiuuid2) 118 | 119 | 120 | if __name__ == "__main__": 121 | # import sys;sys.argv = ['', 'Test.testName'] 122 | unittest.main() 123 | -------------------------------------------------------------------------------- /src/installfedoraonzfs/vm.py: -------------------------------------------------------------------------------- 1 | """Virtual machine tech used to set up a bootable system.""" 2 | 3 | import errno 4 | import logging 5 | import os 6 | from pathlib import Path 7 | import pty 8 | import subprocess 9 | import threading 10 | import time 11 | from typing import BinaryIO 12 | import uuid 13 | 14 | from installfedoraonzfs.cmd import ( 15 | Popen, 16 | check_call_silent, 17 | cpuinfo, 18 | format_cmdline, 19 | get_associated_lodev, 20 | ) 21 | from installfedoraonzfs.retry import Retryable 22 | 23 | logger = logging.getLogger("VM") 24 | qemu_full_emulation_factor = 5 25 | 26 | 27 | class VMSetupException(Exception): 28 | """Base class for VM exceptions.""" 29 | 30 | 31 | class OOMed(VMSetupException): 32 | """Out of memory.""" 33 | 34 | 35 | class Panicked(VMSetupException): 36 | """Kernel panic.""" 37 | 38 | 39 | class SystemdSegfault(Retryable, VMSetupException): 40 | """Segfault in systemd.""" 41 | 42 | 43 | class MachineNeverShutoff(VMSetupException): 44 | """Machine timed out waiting to shut off.""" 45 | 46 | 47 | class QEMUDied(subprocess.CalledProcessError, VMSetupException): 48 | """QEMU died.""" 49 | 50 | 51 | class BadPW(VMSetupException): 52 | """Bad password.""" 53 | 54 | 55 | class Emergency(VMSetupException): 56 | """System dropped into emergency.""" 57 | 58 | 59 | QemuOpts = tuple[str, list[str]] 60 | 61 | 62 | def detect_qemu(force_kvm: bool | None = None) -> QemuOpts: 63 | """Detect QEMU and what options to use.""" 64 | emucmd = "qemu-system-x86_64" 65 | emuopts = [] 66 | if force_kvm is False: 67 | pass 68 | elif force_kvm is True: 69 | emucmd = "qemu-kvm" 70 | emuopts = ["-enable-kvm"] 71 | elif "vmx" in cpuinfo() or "svm" in cpuinfo(): 72 | emucmd = "qemu-kvm" 73 | emuopts = ["-enable-kvm"] 74 | return emucmd, emuopts 75 | 76 | 77 | def test_qemu() -> bool: 78 | """Test for the presence of QEMU.""" 79 | try: 80 | check_call_silent([detect_qemu()[0], "--help"]) 81 | except subprocess.CalledProcessError as e: 82 | if e.returncode == 0: 83 | return True 84 | raise 85 | except OSError as e: 86 | if e.errno == errno.ENOENT: 87 | return False 88 | raise 89 | return True 90 | 91 | 92 | def boot_image_in_qemu( 93 | hostname: str, 94 | initparm: str, 95 | poolname: str, 96 | voldev: Path, 97 | bootdev: Path | None, 98 | kernelfile: Path, 99 | initrdfile: Path, 100 | force_kvm: bool | None, 101 | interactive_qemu: bool, 102 | lukspassword: str | None, 103 | rootpassword: str, 104 | rootuuid: str | None, 105 | luksuuid: str | None, 106 | qemu_timeout: int, 107 | enforcing: bool, 108 | ) -> None: 109 | """Fully boot the Linux image inside QEMU.""" 110 | if voldev: 111 | lodev = get_associated_lodev(voldev) 112 | assert not lodev, f"{voldev} still has a loopback device: {lodev}" 113 | if bootdev: 114 | lodev = get_associated_lodev(bootdev) 115 | assert not lodev, f"{bootdev} still has a loopback device: {lodev}" 116 | 117 | vmuuid = str(uuid.uuid1()) 118 | emucmd, emuopts = detect_qemu(force_kvm) 119 | if "-enable-kvm" in emuopts: 120 | proper_timeout = qemu_timeout 121 | else: 122 | proper_timeout = qemu_timeout * qemu_full_emulation_factor 123 | logger.warning( 124 | "No hardware (KVM) emulation available. The next step is going to take a while." 125 | ) 126 | screenmode = [ 127 | "-nographic", 128 | "-monitor", 129 | "none", 130 | "-chardev", 131 | "stdio,id=char0", 132 | "-serial", 133 | "chardev:char0", 134 | ] + ( 135 | [] 136 | if interactive_qemu 137 | else [ 138 | "-chardev", 139 | "file,id=char1,path=/dev/stderr", 140 | "-mon", 141 | "char1,mode=control", 142 | ] 143 | ) 144 | dracut_cmdline = ( 145 | "rd.info rd.shell systemd.show_status=1 " 146 | "systemd.journald.forward_to_console=1 systemd.log_level=info " 147 | "systemd.log_target=console" 148 | ) 149 | luks_cmdline = f"rd.luks.uuid={rootuuid} " if luksuuid else "" 150 | enforcingparm = "enforcing=1" if enforcing else "enforcing=0" 151 | cmdline = ( 152 | f"{dracut_cmdline} {luks_cmdline} console=ttyS0" 153 | f" root=ZFS={poolname}/ROOT/os ro" 154 | f" {initparm} {enforcingparm} systemd.log_color=0" 155 | ) 156 | cmd: list[str] = ( 157 | [ 158 | emucmd, 159 | ] 160 | + screenmode 161 | + ["-name", hostname, "-M", "pc", "-no-reboot", "-m", "1536"] 162 | + ["-uuid", vmuuid, "-kernel", str(kernelfile), "-initrd", str(initrdfile)] 163 | + ["-append", cmdline, "-net", "none"] 164 | + emuopts 165 | + ( 166 | [ 167 | "-drive", 168 | f"file={bootdev},if=none,id=drive-ide0-0-0,format=raw", 169 | "-device", 170 | "ide-hd,bus=ide.0,unit=0,drive=drive-ide0-0-0,id=ide0-0-0,bootindex=1", 171 | ] 172 | if bootdev 173 | else [] 174 | ) 175 | + [ 176 | "-drive", 177 | f"file={voldev},if=none,id=drive-ide0-0-1,format=raw", 178 | "-device", 179 | "ide-hd,bus=ide.0,unit=1,drive=drive-ide0-0-1,id=ide0-0-1,bootindex=2", 180 | ] 181 | ) 182 | 183 | logger.info("QEMU command: %s", format_cmdline(cmd)) 184 | 185 | if interactive_qemu: 186 | vmiomaster, vmioslave = None, None 187 | stdin, stdout, stderr = None, None, None 188 | driver = None 189 | else: 190 | vmiomaster_i, vmioslave_i = pty.openpty() 191 | vmiomaster, vmioslave = ( 192 | os.fdopen(vmiomaster_i, "a+b", buffering=0), 193 | os.fdopen(vmioslave_i, "a+b", buffering=0), 194 | ) 195 | stdin, stdout, stderr = vmioslave, vmioslave, vmioslave 196 | logger.info( 197 | "Creating BootDriver to supervise boot and input passphrases if needed" 198 | ) 199 | driver = BootDriver( 200 | "root", rootpassword, lukspassword if lukspassword else "", vmiomaster 201 | ) 202 | 203 | try: 204 | qemu_process = Popen( 205 | cmd, stdin=stdin, stdout=stdout, stderr=stderr, close_fds=True 206 | ) 207 | if vmioslave: 208 | vmioslave.close() 209 | if driver: 210 | driver.start() 211 | logger.info("Waiting for QEMU to finish or be killed.") 212 | while proper_timeout > 0: 213 | t = min([proper_timeout, 5]) 214 | proper_timeout -= t 215 | try: 216 | retcode = qemu_process.wait(t) 217 | except subprocess.TimeoutExpired: 218 | if proper_timeout > 0: 219 | if driver.error: 220 | break 221 | if proper_timeout % 60 == 0: 222 | logger.info( 223 | "Waiting for QEMU. %s more seconds to go.", 224 | proper_timeout, 225 | ) 226 | else: 227 | logger.error( 228 | "QEMU did not exit within the timeout. Killing it." 229 | ) 230 | qemu_process.kill() 231 | retcode = qemu_process.wait() 232 | exception = None 233 | if driver: 234 | try: 235 | driver.join() 236 | except Exception as e: 237 | exception = e 238 | 239 | retcode = qemu_process.wait() 240 | if isinstance(exception, MachineNeverShutoff) and retcode != 0: 241 | raise QEMUDied(retcode, cmd) 242 | elif exception: 243 | raise exception 244 | elif retcode != 0: 245 | raise subprocess.CalledProcessError(retcode, cmd) 246 | finally: 247 | if vmioslave: 248 | vmioslave.close() 249 | if vmiomaster: 250 | vmiomaster.close() 251 | 252 | 253 | class BootDriver(threading.Thread): 254 | """Boot driver that runs Linux inside a QEMU process.""" 255 | 256 | @staticmethod 257 | def is_typeable(string: str) -> bool: 258 | """Can a string be typed to the console.""" 259 | ASCII_SPACE = 32 260 | for p in string: 261 | if ord(p) < ASCII_SPACE: 262 | return False 263 | return True 264 | 265 | def __init__( 266 | self, 267 | login: str, 268 | password: str, 269 | luks_passphrase: str, 270 | pty: BinaryIO, 271 | ) -> None: 272 | """Initialize the boot driver.""" 273 | threading.Thread.__init__(self) 274 | self.setDaemon(True) 275 | assert self.is_typeable(luks_passphrase), ( 276 | "Cannot handle passphrase %r" % luks_passphrase 277 | ) 278 | self.luks_passphrase = luks_passphrase 279 | assert self.is_typeable(luks_passphrase), ( 280 | "Cannot handle passphrase %r" % luks_passphrase 281 | ) 282 | assert self.is_typeable(login), "Cannot handle user name %r" % login 283 | assert self.is_typeable(password), "Cannot handle password %r" % password 284 | self.login = login 285 | self.password = password 286 | self.pty = pty 287 | self.output: list[bytes] = [] 288 | self.error: Exception | None = None 289 | 290 | def run(self) -> None: 291 | """Thread of execution of the boot driver.""" 292 | logger.info("Boot driver started") 293 | consolelogger = logging.getLogger("VM.console") 294 | if self.luks_passphrase: 295 | logger.info("Expecting LUKS passphrase prompt") 296 | lastline: list[bytes] = [] 297 | 298 | unseen = "unseen" 299 | waiting_for_escape_sequence = "waiting_for_escape_sequence" 300 | pending_write = "pending_write" 301 | written = "written" 302 | 303 | login_prompt_seen = "login_prompt_seen" 304 | login_written = "login_written" 305 | password_prompt_seen = "password_prompt_seen" 306 | password_written = "password_written" 307 | shell_prompt_seen = "shell_prompt_seen" 308 | poweroff_written = "poweroff_written" 309 | 310 | luks_passphrase_prompt_state = unseen 311 | login_prompt_state = unseen 312 | 313 | try: 314 | while True: 315 | try: 316 | c = self.pty.read(1) 317 | except OSError as e: 318 | if e.errno == errno.EIO: 319 | c = b"" 320 | else: 321 | raise 322 | if c == b"": 323 | logger.info("QEMU slave PTY gone") 324 | break 325 | self.output.append(c) 326 | if c == b"\n": 327 | s = b"".join(lastline) 328 | consolelogger.debug(s.decode("utf-8")) 329 | 330 | if ( 331 | b"traps: systemd[1] general protection" in s 332 | or b"memory corruption" in s 333 | or b"Freezing execution." in s 334 | ): 335 | # systemd or udevd exploded. Raise retryable SystemdSegfault. 336 | self.error = SystemdSegfault( 337 | "systemd appears to have segfaulted." 338 | ) 339 | elif b" authentication failure." in s: 340 | self.error = BadPW("authentication failed") 341 | elif b" Not enough available memory to open a keyslot." in s: 342 | # OOM. Raise non-retryable OOMed. 343 | self.error = OOMed("a process appears to have been OOMed.") 344 | elif b" Killed" in s: 345 | # OOM. Raise non-retryable OOMed. 346 | self.error = OOMed("a process appears to have been OOMed.") 347 | elif b"end Kernel panic" in s: 348 | # OOM. Raise non-retryable kernel panic. 349 | self.error = Panicked("kernel has panicked.") 350 | elif b"Kernel panic - not syncing" in s: 351 | # OOM. Raise non-retryable kernel panic. 352 | self.error = Panicked("kernel has panicked.") 353 | elif b"root password for maintenance" in s: 354 | # System did not boot. 355 | self.error = Emergency("system entered emergency mode") 356 | 357 | lastline = [] 358 | elif c == b"\r": 359 | pass 360 | else: 361 | lastline.append(c) 362 | s = b"".join(lastline) 363 | 364 | if self.luks_passphrase: 365 | if luks_passphrase_prompt_state == unseen: 366 | if b"nter passphrase for" in s: 367 | # Please enter passphrase for disk QEMU... 368 | # Enter passphrase for /dev/... 369 | # LUKS passphrase prompt appeared. Enter it later. 370 | logger.info("Passphrase prompt begun appearing.") 371 | luks_passphrase_prompt_state = waiting_for_escape_sequence 372 | if luks_passphrase_prompt_state == waiting_for_escape_sequence: 373 | if b"[0m" in s or b")!" in s: 374 | logger.info("Passphrase prompt done appearing.") 375 | luks_passphrase_prompt_state = pending_write 376 | if luks_passphrase_prompt_state == pending_write: 377 | logger.info("Writing passphrase.") 378 | self.write_luks_passphrase() 379 | luks_passphrase_prompt_state = written 380 | 381 | if self.login and self.password: 382 | if login_prompt_state == unseen: 383 | if b" login: " in s: 384 | # Please enter passphrase for disk QEMU... 385 | # Enter passphrase for /dev/... 386 | # LUKS passphrase prompt appeared. Enter it later. 387 | logger.info("Login prompt begun appearing.") 388 | login_prompt_state = login_prompt_seen 389 | if login_prompt_state == login_prompt_seen: 390 | logger.info("Writing login.") 391 | self.write_login() 392 | login_prompt_state = login_written 393 | if login_prompt_state == login_written: 394 | if b"Password: " in s: 395 | logger.info("Password prompt begun appearing.") 396 | login_prompt_state = password_prompt_seen 397 | if login_prompt_state == password_prompt_seen: 398 | logger.info("Writing password.") 399 | self.write_password() 400 | login_prompt_state = password_written 401 | if login_prompt_state == password_written: 402 | if b" ~]# " in s: 403 | logger.info("Shell prompt begun appearing.") 404 | login_prompt_state = shell_prompt_seen 405 | if login_prompt_state == shell_prompt_seen: 406 | logger.info("Writing poweroff.") 407 | self.write_poweroff() 408 | login_prompt_state = poweroff_written 409 | 410 | logger.info("Boot driver ended") 411 | if not self.error: 412 | if ( 413 | b"reboot: Power down" not in self.get_output() 414 | and b"reboot: Restarting system" not in self.get_output() 415 | ): 416 | self.error = MachineNeverShutoff( 417 | "The bootable image never shut off." 418 | ) 419 | 420 | except Exception as exc: 421 | self.error = exc 422 | 423 | def get_output(self) -> bytes: 424 | """Get the total sum of output from the Linux console.""" 425 | return b"".join(self.output) 426 | 427 | def join(self, timeout: float | None = None) -> None: 428 | """Join the VM execuion thread.""" 429 | threading.Thread.join(self, timeout) 430 | if self.error: 431 | raise self.error 432 | 433 | def _write_stuff(self, stuff: str) -> None: 434 | """Write text followed by a newline to the console.""" 435 | time.sleep(0.25) 436 | for char in stuff: 437 | self.pty.write(char.encode("utf-8")) 438 | self.pty.flush() 439 | self.pty.write(b"\n") 440 | self.pty.flush() 441 | 442 | def write_luks_passphrase(self) -> None: 443 | """Write the LUKS passphrase to the console.""" 444 | return self._write_stuff(self.luks_passphrase) 445 | 446 | def write_login(self) -> None: 447 | """Write the login username to the console.""" 448 | return self._write_stuff(self.login) 449 | 450 | def write_password(self) -> None: 451 | """Write the login password to the console.""" 452 | return self._write_stuff(self.password) 453 | 454 | def write_poweroff(self) -> None: 455 | """Write `poweroff` to the console.""" 456 | return self._write_stuff("poweroff") 457 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = basepython 3 | 4 | [testenv] 5 | deps = 6 | pytest 7 | mypy 8 | pskca>=0.1.11 9 | commands = 10 | pytest 11 | mypy -p installfedoraonzfs 12 | --------------------------------------------------------------------------------