├── .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 |
--------------------------------------------------------------------------------