├── .github └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── README.rst ├── grub.d └── 05_zfs_linux.py ├── packaging └── arch │ ├── PKGBUILD │ └── PKGBUILD-git ├── pytest.ini ├── scripts └── travis │ ├── common │ └── create_test_root.sh │ └── ubuntu │ └── setup_zfs_requirements.sh ├── setup.cfg ├── setup.py └── zedenv_grub ├── __init__.py └── grub.py /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | 9 | 10 | ## Testing 11 | 12 | 16 | 17 | - [ ] Tested GRUB with zfsboot 18 | - [ ] Tested GRUB without zfsboot 19 | 20 | ## Code 21 | 22 | - [ ] When necessary, comments have been added in hard-to-understand areas 23 | - [ ] The changes conform to pep8 - test with `pytest --codestyle`. 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Project files 2 | !/zedenv/lib/**/*.py 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | # lib/ # Messing up ignore plugin, commented 14 | .Python 15 | env/ 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | 31 | # Installer logs 32 | pip-log.txt 33 | pip-delete-this-directory.txt 34 | 35 | # Unit test / coverage reports 36 | htmlcov/ 37 | .tox/ 38 | .coverage 39 | .coverage.* 40 | .cache 41 | .pytest_cache 42 | # Translations 43 | *.mo 44 | *.pot 45 | 46 | # Sphinx documentation 47 | doc/build 48 | 49 | # virtualenv 50 | venv/ 51 | .venv/ 52 | 53 | Pipfile 54 | Pipfile.lock 55 | 56 | # IDE files 57 | .idea/ 58 | .vscode/ 59 | 60 | /packaging/arch/* 61 | !packaging/arch/**/**/PKGBUILD 62 | !packaging/arch/PKGBUILD* 63 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | dist: xenial 3 | 4 | language: python 5 | python: 6 | - "3.6" 7 | - "3.7" 8 | - "3.8" 9 | 10 | env: 11 | global: 12 | - TEST_POOL="zpool" 13 | 14 | cache: 15 | pip: true 16 | custom_install: true 17 | 18 | before_install: 19 | - sudo scripts/travis/ubuntu/setup_zfs_requirements.sh 20 | - sudo scripts/travis/common/create_test_root.sh 21 | 22 | branches: 23 | only: 24 | - /.*/ # Build all branches 25 | 26 | install: 27 | - git clone https://github.com/johnramsden/zedenv.git 28 | - (cd zedenv && python setup.py install) 29 | - sudo env "PATH=$PATH" python setup.py install 30 | - sudo env "PATH=$PATH" pip install '.[test]' 31 | 32 | script: 33 | - modinfo zfs | grep -iw version 34 | - modinfo spl | grep -iw version 35 | - sudo env "PATH=$PATH" pytest --pycodestyle 36 | 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2017, John Ramsden 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | recursive-exclude tests * 3 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ================== 2 | zedenv GRUB Plugin 3 | ================== 4 | 5 | zedenv - ZFS boot environment manager - GRUB plugin 6 | 7 | Install 8 | ------- 9 | 10 | Install ``zedenv`` then ``zedenv-grub``. 11 | 12 | Setup 13 | ----- 14 | 15 | One of two types of setup needs to be used with grub. 16 | 17 | * Boot on ZFS - separate ``grub`` dataset needed. 18 | * Separate partition for kernels 19 | 20 | Boot on ZFS (Recommended) 21 | ######################### 22 | 23 | To use boot on ZFS: 24 | 25 | * A ``grub`` dataset is needed. It should be mounted at ``/boot/grub``. 26 | * ``org.zedenv.grub:bootonzfs`` should be set to ``yes`` 27 | * Individual boot environments should contain their kernels in ``/boot``, which should be part of the root dataset. 28 | 29 | To convert an existing grub install, set up the ``grub`` dataset, and mount it. Then install grub again. 30 | 31 | .. code-block:: shell 32 | 33 | zfs create -o canmount=off zroot/boot 34 | zfs create -o mountpoint=legacy zroot/boot/grub 35 | mount -t zfs zroot/boot/grub /boot/grub 36 | 37 | # efi 38 | mount ${esp} /boot/efi 39 | grub-install --target=x86_64-efi --efi-directory=/boot/efi --bootloader-id=GRUB 40 | 41 | # or for BIOS 42 | grub-install --target=i386-pc /dev/sdx --recheck 43 | 44 | If you get: 45 | 46 | .. code-block:: shell 47 | 48 | /dev/sda 49 | Installing for i386-pc platform. 50 | grub-install: error: failed to get canonical path of `/dev/ata-SAMSUNG_SSD_830_Series_S0VVNEAC702110-part2'. 51 | 52 | A workaround is to symlink the expected partition to the id 53 | 54 | .. code-block:: shell 55 | 56 | ln -s /dev/sda2 /dev/ata-SAMSUNG_SSD_830_Series_S0VVNEAC702110-part2 57 | 58 | Separate Partition for Kernels 59 | ############################### 60 | 61 | An example system on Arch Linux with a separate partition for kernels would be the following: 62 | 63 | * Boot partition mounted to ``/mnt/boot``. 64 | * The directory containing kernels for the active boot environment, ``/mnt/boot/env/zedenv-${boot_env}`` bind mounted to ``/boot``. 65 | * The grub directory ``/mnt/boot/grub`` bindmounted to ``/boot/grub`` 66 | * ``org.zedenv.grub:bootonzfs`` should be set to ``no`` with ``zedenv set org.zedenv.grub:bootonzfs=no`` 67 | 68 | What this would look like during an arch Linux install would be the following: 69 | 70 | .. code-block:: shell 71 | 72 | zpool import -d /dev/disk/by-id -R /mnt vault 73 | 74 | mkdir -p /mnt/mnt/boot /mnt/boot 75 | mount /dev/sda1 /mnt/mnt/boot 76 | 77 | mkdir /mnt/mnt/boot/env/zedenv-default /mnt/boot/grub 78 | mount --bind /mnt/mnt/boot/env/zedenv-default /mnt/boot 79 | mount --bind /mnt/mnt/boot/grub /mnt/boot/grub 80 | 81 | genfstab -U -p /mnt >> /mnt/etc/fstab 82 | 83 | arch-chroot /mnt /bin/bash 84 | 85 | In chroot 86 | 87 | .. code-block:: shell 88 | 89 | export ZPOOL_VDEV_NAME_PATH=1 90 | 91 | grub-install --target=x86_64-efi --efi-directory=/mnt/boot --bootloader-id=GRUB 92 | grub-mkconfig -o /boot/grub/grub.cfg 93 | 94 | An example generated grub.cfg looks like: 95 | 96 | .. code-block:: shell 97 | 98 | ### BEGIN /etc/grub.d/10_linux ### 99 | menuentry 'Arch Linux' --class arch --class gnu-linux --class gnu --class os $menuentry_id_option 'gnulinux-simple-a1b916c0819a1863' { 100 | load_video 101 | set gfxpayload=keep 102 | insmod gzio 103 | insmod part_gpt 104 | insmod fat 105 | set root='hd0,gpt1' 106 | if [ x$feature_platform_search_hint = xy ]; then 107 | search --no-floppy --fs-uuid --set=root --hint-bios=hd0,gpt1 --hint-efi=hd0,gpt1 --hint-baremetal=ahci0,gpt1 B11F-0328 108 | else 109 | search --no-floppy --fs-uuid --set=root B11F-0328 110 | fi 111 | echo 'Loading Linux linux ...' 112 | linux /env/zedenv-default/vmlinuz-linux root=ZFS=vault/sys/zedenv/ROOT/default rw quiet 113 | echo 'Loading initial ramdisk ...' 114 | initrd /env/zedenv-default/initramfs-linux.img 115 | } 116 | 117 | Converting Existing System 118 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 119 | 120 | Create a backup. 121 | 122 | .. code-block:: shell 123 | 124 | cp -a /boot /boot.bak 125 | 126 | Unmount ``/boot``, and remount it at ``/mnt/boot``. 127 | 128 | .. code-block:: shell 129 | 130 | mkdir -p /mnt/boot 131 | mount /dev/sdxY /mnt/boot 132 | 133 | Then you want to move your current kernel to ``/mnt/boot/env/zedenv-${boot_env_name}`` 134 | 135 | .. code-block:: shell 136 | 137 | mkdir /mnt/boot/env/zedenv-default 138 | mv /mnt/boot/* /mnt/boot/env/zedenv-default 139 | 140 | Move the grab directory back if it was also moved (or don't move it in the first place). 141 | 142 | .. code-block:: shell 143 | 144 | mv /mnt/boot/env/zedenv-default/grub /mnt/boot/grub 145 | 146 | Now bindmount the current kernel directory to ``/boot`` so that everything is where the system expects it. 147 | 148 | .. code-block:: shell 149 | 150 | mount --bind /mnt/boot/env/zedenv-default /boot 151 | 152 | Same thing with the grub directory 153 | 154 | .. code-block:: shell 155 | 156 | mount --bind /mnt/boot/grub /boot/grub 157 | 158 | Now everything is back to appearing how it looked originally, but things are actually stored in a different place. 159 | 160 | --- 161 | 162 | You're also probably going to want to update your fstab, if you're using Arch you can use genfstab, which requires ``arch-install-scripts``. 163 | 164 | .. code-block:: shell 165 | 166 | genfstab -U -p / 167 | 168 | You'll need to add the output to ``/etc/fstab.`` 169 | 170 | This is what an example looks like. 171 | 172 | .. code-block:: shell 173 | 174 | # /dev/sda1 175 | UUID=B11F-0328 /mnt/boot vfat rw,relatime,fmask=0022,dmask=0022,codepage=437,iocharset=iso8859-1,shortname=mixed,utf8,errors=remount-ro 0 2 176 | 177 | /mnt/boot/env/zedenv-grub-test-3 /boot none rw,fmask=0022,dmask=0022,codepage=437,iocharset=iso8859-1,shortname=mixed,utf8,errors=remount-ro,bind 0 0 178 | /mnt/boot/grub /boot/grub none rw,fmask=0022,dmask=0022,codepage=437,iocharset=iso8859-1,shortname=mixed,utf8,errors=remount-ro,bind 0 0 179 | 180 | 181 | Post Setup 182 | ------------- 183 | 184 | After install, run ``zedenv --plugins``, you should see ``grub``. 185 | 186 | Set bootloader config, options can be queried with ``zedenv get --defaults``: 187 | 188 | .. code-block:: shell 189 | 190 | $ zedenv get --defaults 191 | PROPERTY DEFAULT DESCRIPTION 192 | org.zedenv:bootloader Set a bootloader plugin. 193 | org.zedenv.systemdboot:esp /mnt/efi Set location for esp. 194 | org.zedenv.grub:boot /mnt/boot Set location for boot. 195 | org.zedenv.grub:bootonzfs yes 196 | 197 | Set the bootloader so it doesn't have to be declared on every usage with the ``-b`` flag. 198 | 199 | .. code-block:: shell 200 | 201 | # zedenv set org.zedenv:bootloader=grub 202 | 203 | ``zedenv`` will do its best to decide whether or not you are booting off of an all ZFS system, but it can also be set explicitly with ``org.zedenv.grub:bootonzfs=yes``. 204 | 205 | Any values you have set explicitly will show up with ``zedenv get``. 206 | 207 | Now create a new boot environment: 208 | 209 | .. code-block:: shell 210 | 211 | # zedenv create linux-4.18.12 212 | # zfs list 213 | NAME USED AVAIL REFER MOUNTPOINT 214 | zroot 2.43G 36.1G 29K none 215 | zroot/ROOT 2.42G 36.1G 29K none 216 | zroot/ROOT/default 2.42G 36.1G 2.42G / 217 | zroot/ROOT/linux-4.18.12 1K 36.1G 2.42G / 218 | zroot/data 9.36M 36.1G 29K none 219 | zroot/data/home 9.33M 36.1G 9.33M legacy 220 | 221 | You may want to disable all of the grub generators in ``/etc/grub.d/`` except for ``00_header`` and the zedenv generator ``05_zfs_linux.py`` by removing the executable bit. 222 | -------------------------------------------------------------------------------- /grub.d/05_zfs_linux.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | 5 | import os 6 | import platform 7 | import pyzfscmds.system.agnostic 8 | import pyzfscmds.utility 9 | import re 10 | import subprocess 11 | import functools 12 | from distutils.version import LooseVersion 13 | 14 | import zedenv.lib.be 15 | import zedenv.lib.check 16 | import zedenv.lib.configure 17 | 18 | import zedenv_grub.grub 19 | 20 | from typing import List, Optional 21 | 22 | 23 | def source(file: str): 24 | """ 25 | 'sources' a file and manipulates environment variables 26 | """ 27 | 28 | env_command = ['sh', '-c', f'set -a && . {file} && env'] 29 | 30 | try: 31 | env_output = subprocess.check_output( 32 | env_command, universal_newlines=True, stderr=subprocess.PIPE) 33 | except subprocess.CalledProcessError as e: 34 | raise RuntimeError(f"Failed to source{file}.\n{e}\n.") 35 | 36 | for line in env_output.splitlines(): 37 | (key, _, value) = line.partition("=") 38 | os.environ[key] = value 39 | 40 | 41 | def normalize_string(str_input: str): 42 | """ 43 | Given a string, remove all non alphanumerics, and replace spaces with underscores 44 | """ 45 | str_list = [] 46 | for c in str_input.split(" "): 47 | san = [lo.lower() for lo in c if lo.isalnum()] 48 | str_list.append("".join(san)) 49 | 50 | return "_".join(str_list) 51 | 52 | 53 | def grub_command(command: str, call_args: List[str] = None, stderr=subprocess.PIPE): 54 | cmd_call = [command] 55 | if call_args: 56 | cmd_call.extend(call_args) 57 | 58 | try: 59 | cmd_output = subprocess.check_output( 60 | cmd_call, universal_newlines=True, stderr=stderr) 61 | except subprocess.CalledProcessError as e: 62 | raise RuntimeError(f"Failed to run {command}.\n{e}.") 63 | 64 | return cmd_output.splitlines() 65 | 66 | 67 | class GrubLinuxEntry: 68 | 69 | def __init__(self, linux: str, 70 | grub_os: str, 71 | be_root: str, 72 | rpool: str, 73 | genkernel_arch: str, 74 | boot_environment_kernels: dict, 75 | grub_cmdline_linux: str, 76 | grub_cmdline_linux_default: str, 77 | grub_devices: Optional[List[str]], 78 | default: str, 79 | grub_boot_on_zfs: bool, 80 | grub_device_boot: str): 81 | 82 | self.grub_cmdline_linux = grub_cmdline_linux 83 | self.grub_cmdline_linux_default = grub_cmdline_linux_default 84 | self.grub_devices = grub_devices 85 | self.grub_device_boot = grub_device_boot 86 | 87 | self.grub_boot_on_zfs = grub_boot_on_zfs 88 | 89 | self.linux = linux 90 | self.grub_os = grub_os 91 | self.genkernel_arch = genkernel_arch 92 | 93 | self.basename = os.path.basename(linux) 94 | self.dirname = os.path.dirname(linux) 95 | 96 | self.boot_environment_kernels = boot_environment_kernels 97 | 98 | try: 99 | self.rel_dirname = grub_command("grub-mkrelpath", [self.dirname])[0] 100 | except RuntimeError as e: 101 | sys.exit(e) 102 | self.version = self.get_linux_version() 103 | 104 | self.rpool = rpool 105 | self.be_root = be_root 106 | self.boot_environment = self.get_boot_environment() 107 | 108 | # Root dataset will double as device ID 109 | self.linux_root_dataset = os.path.join( 110 | self.be_root, self.boot_environment) 111 | self.linux_root_device = f"ZFS={self.linux_root_dataset}" 112 | self.boot_device_id = self.linux_root_dataset 113 | 114 | self.initrd_early = self.get_initrd_early() 115 | self.initrd_real = self.get_initrd_real() 116 | 117 | self.kernel_config = self.get_kernel_config() 118 | 119 | self.initramfs = self.get_from_config(r'CONFIG_INITRAMFS_SOURCE=(.*)$') 120 | 121 | self.grub_default_entry = None 122 | if "GRUB_ACTUAL_DEFAULT" in os.environ: 123 | self.grub_default_entry = os.environ['GRUB_ACTUAL_DEFAULT'] 124 | 125 | self.grub_save_default = None 126 | if "GRUB_SAVEDEFAULT" in os.environ: 127 | self.grub_save_default = True if os.environ['GRUB_SAVEDEFAULT'] == "true" else False 128 | 129 | self.grub_gfxpayload_linux = None 130 | if "GRUB_GFXPAYLOAD_LINUX" in os.environ: 131 | self.grub_gfxpayload_linux = os.environ['GRUB_GFXPAYLOAD_LINUX'] 132 | 133 | self.grub_enable_cryptodisk = None 134 | if "GRUB_ENABLE_CRYPTODISK" in os.environ: 135 | if os.environ['GRUB_ENABLE_CRYPTODISK'] == 'y': 136 | self.grub_enable_cryptodisk = True 137 | else: 138 | self.grub_enable_cryptodisk = False 139 | 140 | self.default = default 141 | 142 | self.grub_entries = [] 143 | 144 | @staticmethod 145 | def entry_line(entry_line: str, submenu_indent: int = 0): 146 | return ("\t" * submenu_indent) + entry_line 147 | 148 | def prepare_grub_to_access_device(self) -> Optional[List[str]]: 149 | """ 150 | Get device modules to load, replicates function from grub-mkconfig_lib 151 | """ 152 | lines = [] 153 | 154 | # Below not needed since not running on net/open{bsd} 155 | """ 156 | old_ifs="$IFS" 157 | IFS=' 158 | ' 159 | partmap="`"${grub_probe}" --device $@ --target=partmap`" 160 | for module in ${partmap} ; do 161 | case "${module}" in 162 | netbsd | openbsd) 163 | echo "insmod part_bsd";; 164 | *) 165 | echo "insmod part_${module}";; 166 | esac 167 | done 168 | """ 169 | 170 | """ 171 | # Abstraction modules aren't auto-loaded. 172 | abstraction="`"${grub_probe}" --device $@ --target=abstraction`" 173 | """ 174 | 175 | if self.grub_boot_on_zfs and not zedenv.lib.be.extra_bpool(): 176 | devices = self.grub_devices 177 | else: 178 | devices = self.grub_device_boot 179 | 180 | try: 181 | abstraction = grub_command("grub-probe", 182 | ['--device', *devices, '--target=abstraction']) 183 | except RuntimeError: 184 | pass 185 | else: 186 | lines.extend([f"insmod {m}" for m in abstraction if m.strip() != '']) 187 | 188 | """ 189 | fs="`"${grub_probe}" --device $@ --target=fs`" 190 | """ 191 | try: 192 | fs = grub_command("grub-probe", 193 | ['--device', *devices, '--target=fs']) 194 | except RuntimeError: 195 | pass 196 | else: 197 | lines.extend([f"insmod {f}" for f in fs if f.strip() != '']) 198 | 199 | """ 200 | if [ x$GRUB_ENABLE_CRYPTODISK = xy ]; then 201 | for uuid in `"${grub_probe}" --device $@ --target=cryptodisk_uuid`; do 202 | echo "cryptomount -u $uuid" 203 | done 204 | fi 205 | """ 206 | if self.grub_enable_cryptodisk: 207 | try: 208 | crypt_uuids = grub_command("grub-probe", 209 | ['--device', *devices, 210 | '--target=cryptodisk_uuid']) 211 | except RuntimeError: 212 | pass 213 | else: 214 | lines.extend( 215 | [f"cryptomount -u {uuid}" for uuid in crypt_uuids if uuid.strip() != '']) 216 | 217 | """ 218 | # If there's a filesystem UUID that GRUB is capable of identifying, use it; 219 | # otherwise set root as per value in device.map. 220 | fs_hint="`"${grub_probe}" --device $@ --target=compatibility_hint`" 221 | if [ "x$fs_hint" != x ]; then 222 | echo "set root='$fs_hint'" 223 | fi 224 | """ 225 | try: 226 | fs_hint = grub_command("grub-probe", 227 | ['--device', 228 | *devices, 229 | '--target=compatibility_hint']) 230 | except RuntimeError: 231 | pass 232 | else: 233 | if fs_hint and fs_hint[0].strip() != '': 234 | hint = ''.join(fs_hint).strip() 235 | lines.append(f"set root='{hint}'") 236 | r""" 237 | if fs_uuid="`"${grub_probe}" --device $@ --target=fs_uuid 2> /dev/null`" ; then 238 | hints="`"${grub_probe}" --device $@ --target=hints_string 2> /dev/null`" || hints= 239 | echo "if [ x\$feature_platform_search_hint = xy ]; then" 240 | echo " search --no-floppy --fs-uuid --set=root ${hints} ${fs_uuid}" 241 | echo "else" 242 | echo " search --no-floppy --fs-uuid --set=root ${fs_uuid}" 243 | echo "fi" 244 | fi 245 | """ 246 | try: 247 | fs_uuid = grub_command("grub-probe", 248 | ['--device', *devices, '--target=fs_uuid'], 249 | stderr=subprocess.DEVNULL) 250 | except RuntimeError: 251 | pass 252 | else: 253 | try: 254 | hints_string = grub_command("grub-probe", 255 | ['--device', *devices, 256 | '--target=hints_string'], 257 | stderr=subprocess.DEVNULL) 258 | except RuntimeError: 259 | hints_string = None 260 | 261 | both_fs_string = fs_uuid[0] 262 | if hints_string: 263 | hints_string_joined = ''.join(hints_string).strip() 264 | if hints_string_joined != '': 265 | both_fs_string = f"{both_fs_string} {hints_string[0]}" 266 | 267 | lines.extend( 268 | [ 269 | "if [ x$feature_platform_search_hint = xy ]; then", 270 | f" search --no-floppy --fs-uuid --set=root {both_fs_string}", 271 | "else", 272 | f" search --no-floppy --fs-uuid --set=root {fs_uuid[0]}", 273 | "fi" 274 | ] 275 | ) 276 | 277 | return lines 278 | 279 | def generate_entry(self, grub_class, grub_args, entry_type, 280 | entry_indentation: int = 0) -> List[str]: 281 | 282 | entry = [] 283 | 284 | if entry_type != "simple": 285 | title_prefix = f"{self.grub_os} BE [{self.boot_environment}] with Linux {self.version}" 286 | if entry_type == "recovery": 287 | title = f"{title_prefix} (recovery mode)" 288 | else: 289 | title = title_prefix 290 | 291 | # TODO: If matches default... 292 | r""" 293 | if [ x"$title" = x"$GRUB_ACTUAL_DEFAULT" ] || \ 294 | [ x"Previous Linux versions>$title" = x"$GRUB_ACTUAL_DEFAULT" ]; then 295 | 296 | replacement_title="$(echo "Advanced options for ${OS}" | \ 297 | sed 's,>,>>,g')>$(echo "$title" | sed 's,>,>>,g')" 298 | 299 | quoted="$(echo "$GRUB_ACTUAL_DEFAULT" | grub_quote)" 300 | 301 | # NO ACTUAL NEWLINE MID COMMAND in original 302 | title_correction_code="${title_correction_code} 303 | if [ \"x\$default\" = '$quoted' ]; then 304 | default='$(echo "$replacement_title" | grub_quote)'; 305 | fi;" 306 | fi 307 | """ 308 | """ 309 | if self.grub_default_entry == (title or f"Previous Linux versions>{title}"): 310 | title_prefix = f"Advanced options for {self.grub_os}".replace('>', '>>') + ">" 311 | replacement_title = title_prefix + title.replace('>', '>>') 312 | # Not quite sure why above replacing '>' is necessary, based on above shell code 313 | """ 314 | 315 | entry.append( 316 | self.entry_line( 317 | f"menuentry '{title}' {grub_class} $menuentry_id_option " 318 | f"'gnulinux-{self.version}-{entry_type}-{self.boot_device_id}' {{", 319 | submenu_indent=entry_indentation)) 320 | else: 321 | entry.append(self.entry_line( 322 | f"menuentry '{self.grub_os} BE [{self.boot_environment}]' " 323 | f"{grub_class} $menuentry_id_option 'gnulinux-simple-{self.boot_device_id}' {{", 324 | submenu_indent=entry_indentation)) 325 | 326 | # Graphics section 327 | entry.append(self.entry_line("load_video", submenu_indent=entry_indentation + 1)) 328 | if not self.grub_gfxpayload_linux: 329 | fb_efi = self.get_from_config(r'(CONFIG_FB_EFI=y)') 330 | vt_hw_console_binding = self.get_from_config(r'(CONFIG_VT_HW_CONSOLE_BINDING=y)') 331 | 332 | if fb_efi and vt_hw_console_binding: 333 | entry.append( 334 | self.entry_line('set gfxpayload=keep', submenu_indent=entry_indentation + 1)) 335 | else: 336 | entry.append(self.entry_line(f"set gfxpayload={self.grub_gfxpayload_linux}", 337 | submenu_indent=entry_indentation + 1)) 338 | 339 | entry.append(self.entry_line(f"insmod gzio", submenu_indent=entry_indentation + 1)) 340 | 341 | for module in self.prepare_grub_to_access_device(): 342 | entry.append(self.entry_line(module, submenu_indent=entry_indentation + 1)) 343 | 344 | entry.append(self.entry_line(f"echo 'Loading Linux {self.version} ...'", 345 | submenu_indent=entry_indentation + 1)) 346 | rel_linux = os.path.join(self.rel_dirname, self.basename) 347 | entry.append( 348 | self.entry_line(f"linux {rel_linux} root={self.linux_root_device} rw {grub_args}", 349 | submenu_indent=entry_indentation + 1)) 350 | 351 | initrd = self.get_initrd() 352 | 353 | if initrd: 354 | entry.append(self.entry_line(f"echo 'Loading initial ramdisk ...'", 355 | submenu_indent=entry_indentation + 1)) 356 | entry.append(self.entry_line(f"initrd {' '.join(initrd)}", 357 | submenu_indent=entry_indentation + 1)) 358 | 359 | entry.append(self.entry_line("}", entry_indentation)) 360 | 361 | return entry 362 | 363 | def get_from_config(self, pattern) -> Optional[str]: 364 | """ 365 | Check kernel_config for initramfs setting 366 | """ 367 | config_match = None 368 | if self.kernel_config: 369 | reg = re.compile(pattern) 370 | 371 | with open(self.kernel_config) as f: 372 | config = f.read().splitlines() 373 | 374 | config_match = next((reg.match(lm).group(1) for lm in config if reg.match(lm)), None) 375 | 376 | return config_match 377 | 378 | def get_kernel_config(self) -> Optional[str]: 379 | configs = [f"{self.dirname}/config-{self.version}", 380 | f"/etc/kernels/kernel-config-{self.version}"] 381 | return next((c for c in configs if os.path.isfile(c)), None) 382 | 383 | def get_initrd(self) -> list: 384 | initrd = [] 385 | if self.initrd_real: 386 | initrd.append(os.path.join(self.rel_dirname, self.initrd_real)) 387 | 388 | if self.initrd_early: 389 | initrd.extend([os.path.join(self.rel_dirname, ie) for ie in self.initrd_early]) 390 | 391 | return initrd 392 | 393 | def get_initrd_early(self) -> list: 394 | """ 395 | Get microcode images 396 | https://www.mail-archive.com/grub-devel@gnu.org/msg26775.html 397 | See grub-mkconfig for code 398 | GRUB_EARLY_INITRD_LINUX_STOCK is distro provided microcode, ie: 399 | intel-uc.img intel-ucode.img amd-uc.img amd-ucode.img 400 | early_ucode.cpio microcode.cpio" 401 | GRUB_EARLY_INITRD_LINUX_CUSTOM is for your custom created images 402 | """ 403 | early_initrd = [] 404 | if "GRUB_EARLY_INITRD_LINUX_STOCK" in os.environ: 405 | early_initrd.extend(os.environ['GRUB_EARLY_INITRD_LINUX_STOCK'].split()) 406 | 407 | if "GRUB_EARLY_INITRD_LINUX_CUSTOM" in os.environ: 408 | early_initrd.extend(os.environ['GRUB_EARLY_INITRD_LINUX_CUSTOM'].split()) 409 | 410 | return [i for i in early_initrd if os.path.isfile(os.path.join(self.dirname, i))] 411 | 412 | def get_initrd_real(self) -> Optional[str]: 413 | initrd_list = [f"initrd.img-{self.version}", 414 | f"initrd-{self.version}.img", 415 | f"initrd-{self.version}.gz", 416 | f"initrd-{self.version}", 417 | f"initramfs-{self.version}", 418 | f"initramfs-{self.version}.img", 419 | f"initramfs-genkernel-{self.version}", 420 | f"initramfs-genkernel-{self.genkernel_arch}-{self.version}"] 421 | 422 | initrd_real = next( 423 | (i for i in initrd_list if os.path.isfile(os.path.join(self.dirname, i))), None) 424 | 425 | return initrd_real 426 | 427 | def get_boot_environment(self): 428 | """ 429 | Get name of BE from kernel directory 430 | """ 431 | if self.grub_boot_on_zfs: 432 | if not zedenv.lib.be.extra_bpool(): 433 | if self.dirname == "/boot": 434 | target = re.search( 435 | r'.*/(.*)@/boot$', grub_command("grub-mkrelpath", [self.dirname])[0]) 436 | return target.group(1) if target else None 437 | 438 | target = re.search(r'zedenv-(.*)/boot/*$', self.dirname) 439 | else: 440 | target = re.search(r'zedenv-([a-zA-Z0-9_\-\.]+)@?/*$', 441 | grub_command("grub-mkrelpath", [self.dirname])[0]) 442 | else: 443 | target = re.search(r'zedenv-(.*)/*$', self.dirname) 444 | 445 | return target.group(1) if target else None 446 | 447 | def get_linux_version(self): 448 | """ 449 | Gets the version after kernel, if there is one 450 | Example: 451 | vmlinuz-4.16.12_1 gives 4.16.12_1 452 | """ 453 | 454 | target = re.search(r'^[^0-9\-]*-(.*)$', self.basename) 455 | if target: 456 | return target.group(1) 457 | return "" 458 | 459 | 460 | class Generator: 461 | 462 | def __init__(self): 463 | 464 | self.prefix = "/usr" 465 | self.exec_prefix = "/usr" 466 | self.data_root_dir = "/usr/share" 467 | 468 | if "pkgdatadir" in os.environ: 469 | self.pkgdatadir = os.environ['pkgdatadir'] 470 | else: 471 | self.pkgdatadir = "/usr/share/grub" 472 | 473 | self.text_domain = "grub" 474 | self.text_domain_dir = f"{self.data_root_dir}/locale" 475 | 476 | self.entry_type = "advanced" 477 | 478 | # Update environment variables by sourcing grub defaults 479 | source("/etc/default/grub") 480 | 481 | grub_class = "--class gnu-linux --class gnu --class os" 482 | 483 | os.environ['ZPOOL_VDEV_NAME_PATH'] = '1' 484 | 485 | if "GRUB_DISTRIBUTOR" in os.environ: 486 | grub_distributor = os.environ['GRUB_DISTRIBUTOR'] 487 | self.grub_os = f"{grub_distributor} GNU/Linux" 488 | self.grub_class = f"--class {normalize_string(grub_distributor)} {grub_class}" 489 | else: 490 | self.grub_os = "GNU/Linux" 491 | self.grub_class = grub_class 492 | 493 | # Default to true in order to maintain compatibility with older kernels. 494 | self.grub_disable_linux_partuuid = True 495 | if "GRUB_DISABLE_LINUX_PARTUUID" in os.environ: 496 | if os.environ['GRUB_DISABLE_LINUX_PARTUUID'] in ("false", "False", "0"): 497 | self.grub_disable_linux_partuuid = False 498 | 499 | if "GRUB_CMDLINE_LINUX" in os.environ: 500 | self.grub_cmdline_linux = os.environ['GRUB_CMDLINE_LINUX'] 501 | else: 502 | self.grub_cmdline_linux = "" 503 | 504 | if "GRUB_CMDLINE_LINUX_DEFAULT" in os.environ: 505 | self.grub_cmdline_linux_default = os.environ['GRUB_CMDLINE_LINUX_DEFAULT'] 506 | else: 507 | self.grub_cmdline_linux_default = "" 508 | 509 | if "GRUB_DISABLE_SUBMENU" in os.environ and os.environ['GRUB_DISABLE_SUBMENU'] == "y": 510 | self.grub_disable_submenu = True 511 | else: 512 | self.grub_disable_submenu = False 513 | 514 | self.grub_disable_recovery = None 515 | if "GRUB_DISABLE_RECOVERY" in os.environ: 516 | if os.environ['GRUB_DISABLE_RECOVERY'] == "true": 517 | self.grub_disable_recovery = True 518 | else: 519 | self.grub_disable_recovery = False 520 | 521 | self.root_dataset = pyzfscmds.system.agnostic.mountpoint_dataset("/") 522 | self.be_root = zedenv.lib.be.root() 523 | 524 | # in GRUB terms, bootfs is everything after pool 525 | self.bootfs = "/" + self.root_dataset.split("/", 1)[1] 526 | self.rpool = self.root_dataset.split("/")[0] 527 | self.linux_root_device = f"ZFS={self.rpool}{self.bootfs}" 528 | 529 | self.active_boot_environment = zedenv.lib.be.bootfs_for_pool(self.rpool) 530 | 531 | self.machine = platform.machine() 532 | 533 | self.invalid_filenames = ["readme"] # Normalized to lowercase 534 | self.invalid_extensions = [".dpkg", ".rpmsave", ".rpmnew", ".pacsave", ".pacnew"] 535 | 536 | self.genkernel_arch = self.get_genkernel_arch() 537 | 538 | self.linux_entries = [] 539 | 540 | self.grub_boot = zedenv.lib.be.get_property(self.root_dataset, 'org.zedenv.grub:boot') 541 | if not self.grub_boot or self.grub_boot == "-": 542 | self.grub_boot = "/mnt/boot" 543 | 544 | # Get boot device 545 | try: 546 | self.grub_boot_device = grub_command("grub-probe", 547 | ['--target=device', self.grub_boot]) 548 | except RuntimeError as err1: 549 | sys.exit(f"Failed to probe boot device.\n{err1}") 550 | 551 | grub_boot_on_zfs = zedenv.lib.be.get_property( 552 | self.root_dataset, 'org.zedenv.grub:bootonzfs') 553 | if grub_boot_on_zfs.lower() in ("1", "yes"): 554 | self.grub_boot_on_zfs = True 555 | else: 556 | try: 557 | fs_type = grub_command("grub-probe", 558 | ['--device', *self.grub_boot_device, 559 | '--target=fs']) 560 | except RuntimeError: 561 | fs_type = None 562 | 563 | if fs_type and ''.join(fs_type).strip() == "zfs": 564 | self.grub_boot_on_zfs = True 565 | else: 566 | self.grub_boot_on_zfs = False 567 | 568 | simpleentries_set = zedenv.lib.be.get_property( 569 | self.root_dataset, "org.zedenv.grub:simpleentries") 570 | 571 | self.simpleentries = True 572 | if simpleentries_set and simpleentries_set.lower() in ("n", "no", "0"): 573 | self.simpleentries = False 574 | 575 | boot_env_dir = "zfsenv" if self.grub_boot_on_zfs else "env" 576 | self.boot_env_kernels = os.path.join(self.grub_boot, boot_env_dir) 577 | 578 | # /usr/bin/grub-probe --target=device / 579 | try: 580 | grub_device_temp = grub_command("grub-probe", ['--target=device', "/"]) 581 | except RuntimeError as err0: 582 | sys.exit(f"Failed to probe root device.\n{err0}") 583 | 584 | if grub_device_temp: 585 | self.grub_devices: list = grub_device_temp 586 | 587 | self.default = "" 588 | 589 | self.boot_list = self.get_boot_environments_boot_list() 590 | 591 | def file_valid(self, file_path): 592 | """ 593 | Run equivalent checks to grub_file_is_not_garbage() from grub-mkconfig_lib 594 | Check file is valid and not one of: 595 | *.dpkg - debian dpkg 596 | *.rpmsave | *.rpmnew 597 | README* | */README* - documentation 598 | """ 599 | if not os.path.isfile(file_path): 600 | return False 601 | 602 | file = os.path.basename(file_path) 603 | _, ext = os.path.splitext(file) 604 | 605 | if ext in self.invalid_extensions: 606 | return False 607 | 608 | if next((True for f in self.invalid_filenames if f.lower() in file.lower()), False): 609 | return False 610 | 611 | return True 612 | 613 | def create_entry(self, kernel_dir: str, search_regex) -> Optional[dict]: 614 | be_boot_dir = kernel_dir 615 | if self.grub_boot_on_zfs and not kernel_dir == "/boot" and not zedenv.lib.be.extra_bpool(): 616 | be_boot_dir = os.path.join(kernel_dir, "boot") 617 | 618 | boot_dir = os.path.join(self.boot_env_kernels, be_boot_dir) 619 | 620 | boot_files = os.listdir(boot_dir) 621 | kernel_matches = [i for i in boot_files 622 | if search_regex.match(i) and self.file_valid(os.path.join(boot_dir, i))] 623 | 624 | return { 625 | "directory": boot_dir, 626 | "files": boot_files, 627 | "kernels": kernel_matches 628 | } 629 | 630 | def get_boot_environments_boot_list(self) -> List[Optional[dict]]: 631 | """ 632 | Get a list of dicts containing all BE kernels 633 | """ 634 | 635 | vmlinuz = r'(vmlinuz-.*)' 636 | vmlinux = r'(vmlinux-.*)' 637 | kernel = r'(kernel-.*)' 638 | 639 | boot_search = f"{vmlinuz}|{kernel}" 640 | 641 | if re.search(r'(i[36]86)|x86_64', self.machine): 642 | boot_regex = re.compile(boot_search) 643 | else: 644 | boot_search = f"{boot_search}|{vmlinux}" 645 | boot_regex = re.compile(boot_search) 646 | 647 | boot_entries = [self.create_entry(e, boot_regex) 648 | for e in os.listdir(self.boot_env_kernels)] 649 | 650 | # Do not use `/boot` if an extra ZFS boot pool is used. 651 | if self.grub_boot_on_zfs and os.path.exists("/boot") and not zedenv.lib.be.extra_bpool(): 652 | boot_entries.append(self.create_entry("/boot", boot_regex)) 653 | 654 | return boot_entries 655 | 656 | def get_genkernel_arch(self): 657 | 658 | if re.search(r'i[36]86', self.machine): 659 | return "x86" 660 | 661 | if re.search(r'mips|mips64', self.machine): 662 | return "mips" 663 | 664 | if re.search(r'mipsel|mips64el', self.machine): 665 | return "mipsel" 666 | 667 | if re.search(r'arm.*', self.machine): 668 | return "arm" 669 | 670 | return self.machine 671 | 672 | def generate_grub_entries(self): 673 | indent = 0 674 | is_top_level = True 675 | 676 | entries = [] 677 | 678 | for i in self.boot_list: 679 | entry_position = 0 680 | kernels_sorted = sorted(i['kernels'], reverse=True, 681 | key=functools.cmp_to_key(Generator.kernel_comparator)) 682 | 683 | for j in kernels_sorted: 684 | grub_entry = GrubLinuxEntry( 685 | os.path.join(i['directory'], j), self.grub_os, self.be_root, self.rpool, 686 | self.genkernel_arch, i, self.grub_cmdline_linux, 687 | self.grub_cmdline_linux_default, self.grub_devices, self.default, 688 | self.grub_boot_on_zfs, self.grub_boot_device) 689 | 690 | ds = os.path.join(self.be_root, grub_entry.boot_environment) 691 | if ds == self.active_boot_environment: 692 | # To keep ordering, put matching entries ordered based on position 693 | self.linux_entries.insert(entry_position, grub_entry) 694 | entry_position += 1 695 | else: 696 | self.linux_entries.append(grub_entry) 697 | 698 | for boot_entry in self.linux_entries: 699 | # First few in linux_entries are active, others in submenu 700 | if is_top_level and os.path.join( 701 | self.be_root, boot_entry.boot_environment 702 | ) != self.active_boot_environment and not self.grub_disable_submenu: 703 | is_top_level = False 704 | indent = 1 705 | 706 | # Submenu title 707 | entries.append( 708 | [(f"submenu 'Boot Environments ({self.grub_os})' $menuentry_id_option " 709 | f"'gnulinux-advanced-be-{self.active_boot_environment}' {{")]) 710 | 711 | if is_top_level and self.simpleentries: 712 | # Simple entry 713 | entries.append( 714 | boot_entry.generate_entry( 715 | self.grub_class, 716 | f"{self.grub_cmdline_linux} {self.grub_cmdline_linux_default}", 717 | "simple", entry_indentation=indent)) 718 | 719 | # Advanced entry 720 | entries.append( 721 | boot_entry.generate_entry( 722 | self.grub_class, 723 | f"{self.grub_cmdline_linux} {self.grub_cmdline_linux_default}", 724 | "advanced", entry_indentation=indent)) 725 | 726 | # Recovery entry 727 | if self.grub_disable_recovery: 728 | entries.append( 729 | boot_entry.generate_entry( 730 | self.grub_class, 731 | f"single {self.grub_cmdline_linux}", 732 | "recovery", entry_indentation=indent)) 733 | 734 | if not is_top_level: 735 | entries.append("}") 736 | 737 | return entries 738 | 739 | @staticmethod 740 | def kernel_comparator(kernel0: str, kernel1: str) -> int: 741 | """ 742 | Rather than using key based compare, it is simpler to use a comparator in this situation. 743 | """ 744 | 745 | regex = re.compile(r'-([0-9]+([\.|\-][0-9]+)*)-') 746 | 747 | version0 = regex.search(kernel0) 748 | version1 = regex.search(kernel1) 749 | 750 | def ext_cmp(k0: str, k1: str) -> int: 751 | """ 752 | Check if the kernels and in an extension, 753 | if one of them does consider it less than the other 754 | """ 755 | exts = ('bak', '.old') 756 | if k0.endswith(exts) or k1.endswith(exts): 757 | if k0.endswith(exts) and k1.endswith(exts): 758 | return 0 759 | 760 | if k0.endswith(exts): 761 | return -1 762 | return 1 763 | 764 | return 0 765 | 766 | # Compare versions 767 | if version0 or version1: 768 | if version0 and version1: 769 | sv0 = -1 770 | sv1 = -1 771 | try: 772 | sv0 = LooseVersion(version0.group(1)) 773 | except ValueError: 774 | pass 775 | try: 776 | sv1 = LooseVersion(version1.group(1)) 777 | except ValueError: 778 | pass 779 | 780 | try: 781 | if sv0 < sv1: 782 | return -1 783 | if sv0 == sv1: 784 | return ext_cmp(kernel0, kernel1) 785 | return 1 786 | except AttributeError: 787 | return ext_cmp(kernel0, kernel1) 788 | 789 | if version0: 790 | return 1 791 | return -1 792 | 793 | # No version 794 | return ext_cmp(kernel0, kernel1) 795 | 796 | 797 | if __name__ == "__main__": 798 | 799 | ran_activate = False 800 | bootloader_plugin = None 801 | 802 | if pyzfscmds.system.agnostic.check_valid_system(): 803 | 804 | # Only execute if run by 'zedenv activate' 805 | if not zedenv.lib.check.Pidfile()._check(): 806 | 807 | boot_environment_root = zedenv.lib.be.root() 808 | 809 | bootloader_set = zedenv.lib.be.get_property( 810 | boot_environment_root, "org.zedenv:bootloader") 811 | 812 | if bootloader_set: 813 | bootloader = bootloader_set if bootloader_set != '-' else None 814 | else: 815 | sys.exit(0) 816 | 817 | root_dataset = pyzfscmds.system.agnostic.mountpoint_dataset("/") 818 | zpool = zedenv.lib.be.dataset_pool(root_dataset) 819 | 820 | current_be = None 821 | try: 822 | current_be = pyzfscmds.utility.dataset_child_name( 823 | zedenv.lib.be.bootfs_for_pool(zpool)) 824 | except RuntimeError: 825 | sys.exit(0) 826 | 827 | bootloader_plugin = zedenv_grub.grub.GRUB({ 828 | 'boot_environment': current_be, 829 | 'old_boot_environment': current_be, 830 | 'bootloader': "grub", 831 | 'verbose': False, 832 | 'noconfirm': False, 833 | 'noop': False, 834 | 'boot_environment_root': boot_environment_root 835 | }, skip_update=True, skip_cleanup=True) 836 | 837 | if not bootloader_plugin.bootloader == "grub": 838 | sys.exit(0) 839 | 840 | try: 841 | bootloader_plugin.post_activate() 842 | except (RuntimeWarning, RuntimeError, AttributeError) as err: 843 | sys.exit(0) 844 | else: 845 | ran_activate = True 846 | 847 | for en in Generator().generate_grub_entries(): 848 | for l in en: 849 | print(l) 850 | 851 | if ran_activate and bootloader_plugin: 852 | bootloader_plugin.teardown_boot_env_tree() 853 | -------------------------------------------------------------------------------- /packaging/arch/PKGBUILD: -------------------------------------------------------------------------------- 1 | # Maintainer: John Ramsden 2 | 3 | pkgname=zedenv-grub 4 | pkgver=0.1.6 5 | _version_suffix="alpha" 6 | pkgrel=1 7 | pkgdesc="zedenv plugin for GRUB (ALPHA)" 8 | arch=('any') 9 | url="http://github.com/johnramsden/zedenv-grub" 10 | license=('BSD' 'custom:BSD 3 clause') 11 | depends=('zfs' 'python' 'python-setuptools' 'python-click' 'python-pyzfscmds') 12 | makedepends=('git' 'python-pip') 13 | source=("${pkgname}-${pkgver}.tar.gz::https://github.com/johnramsden/${pkgname}/archive/v${pkgver}-${_version_suffix}.tar.gz") 14 | md5sums=('38ecf3d650c176a3d957151f68ec0536') 15 | conflicts=('zedenv-grub-git') 16 | 17 | build() { 18 | cd "${srcdir}/${pkgname}-${pkgver}-${_version_suffix}" 19 | python setup.py build 20 | } 21 | 22 | package() { 23 | cd "${srcdir}/${pkgname}-${pkgver}-${_version_suffix}" 24 | python setup.py install --root="${pkgdir}/" --optimize=1 --skip-build 25 | 26 | install -d "${pkgdir}/usr/share/license/${pkgname}" 27 | install -m 755 "LICENSE" "${pkgdir}/usr/share/license/${pkgname}/LICENSE" 28 | } 29 | -------------------------------------------------------------------------------- /packaging/arch/PKGBUILD-git: -------------------------------------------------------------------------------- 1 | # Maintainer: John Ramsden 2 | 3 | pkgname=zedenv-grub-git 4 | pkgver=r146.3fb25d5 5 | pkgrel=1 6 | pkgdesc="zedenv Plugin for GRUB" 7 | arch=('any') 8 | url="http://github.com/johnramsden/zedenv-grub" 9 | license=('BSD' 'custom:BSD 3 clause') 10 | depends=('zfs' 'python' 'python-setuptools' 'python-pyzfscmds' 'zedenv' 'grub') 11 | makedepends=('git' 'python-pip') 12 | source=('zedenv-grub::git+https://github.com/johnramsden/zedenv-grub#branch=master') 13 | md5sums=('SKIP') 14 | conflicts=('zedenv-grub') 15 | provides=('zedenv-grub') 16 | 17 | pkgver() { 18 | cd "${srcdir}/${pkgname%-git}" 19 | printf "r%s.%s" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)" 20 | } 21 | 22 | build() { 23 | cd "${srcdir}/${pkgname%-git}" 24 | python setup.py build 25 | } 26 | 27 | package() { 28 | cd "${srcdir}/${pkgname%-git}" 29 | python setup.py install --root="${pkgdir}/" --optimize=1 --skip-build 30 | 31 | install -d "${pkgdir}/usr/share/license/${pkgname}" 32 | install -m 755 "LICENSE" "${pkgdir}/usr/share/license/${pkgname}/LICENSE" 33 | } 34 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | max_line_length = 99 3 | -------------------------------------------------------------------------------- /scripts/travis/common/create_test_root.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Defaults if none give on cli 4 | TEST_POOL="${1:-zpool}" 5 | TEST_DATASET="${2:-${TEST_POOL}/ROOT/default}" 6 | 7 | ZEDENV_DIR="${PWD}/zfstests" 8 | TEST_DISK="${ZEDENV_DIR}/disk.img" 9 | 10 | ZPOOL_ROOT_MOUNTPOINT="${ZEDENV_DIR}/root" 11 | 12 | modprobe zfs || exit 1 13 | 14 | mkdir -p ${ZEDENV_DIR} || exit 1 15 | 16 | truncate -s 100M "${TEST_DISK}" && \ 17 | zpool create "${TEST_POOL}" "${TEST_DISK}" 18 | if [ $? -ne 0 ]; then 19 | echo "Failed to create test pool ""'""${TEST_POOL}""'"" with disk ""'""${TEST_DISK}" 20 | exit 1 21 | fi 22 | 23 | mkdir -p "${ZPOOL_ROOT_MOUNTPOINT}" && \ 24 | zfs create -p -o mountpoint="${ZPOOL_ROOT_MOUNTPOINT}" "${TEST_DATASET}" && \ 25 | mkdir -p "${ZPOOL_ROOT_MOUNTPOINT}/usr" "${ZPOOL_ROOT_MOUNTPOINT}/var" 26 | zfs create -p -o mountpoint="${ZPOOL_ROOT_MOUNTPOINT}/usr" "${TEST_DATASET}/usr" && \ 27 | zfs create -p -o mountpoint="${ZPOOL_ROOT_MOUNTPOINT}/var" "${TEST_DATASET}/var" && \ 28 | zpool set bootfs="${TEST_DATASET}" "${TEST_POOL}" 29 | 30 | if [ $? -ne 0 ]; then 31 | echo "Failed to create test dataset ""'""${TEST_DATASET}""'" 32 | exit 1 33 | fi 34 | 35 | # Allow user usage of zfs 36 | chmod u+s "$(which zfs)" "$(which zpool)" "$(which mount)" || exit 1 37 | -------------------------------------------------------------------------------- /scripts/travis/ubuntu/setup_zfs_requirements.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Travis setup script (16.04 4 | 5 | # Install zfs requirements 6 | systemctl mask zfs-import-cache zfs-share zfs-mount 7 | add-apt-repository -y ppa:jonathonf/zfs && \ 8 | apt-get -q update && \ 9 | apt-get install -y linux-headers-$(uname -r) && \ 10 | apt-get install -y spl-dkms zfs-dkms zfsutils-linux || exit 1 11 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [pycodestyle] 2 | max-line-length = 99 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | from zedenv import __version__ 4 | 5 | tests_require = [ 6 | 'coverage', 7 | 'pytest', 8 | 'pytest-runner', 9 | 'pytest-cov', 10 | 'pytest-pycodestyle', 11 | 'tox' 12 | ] 13 | 14 | dev_require = [ 15 | 'Sphinx' 16 | ] 17 | 18 | 19 | def readme(): 20 | with open('README.rst') as f: 21 | return f.read() 22 | 23 | 24 | setup( 25 | name='zedenv-grub', 26 | version=__version__, 27 | description='zedenv Plugin for GRUB', 28 | url='http://github.com/johnramsden/zedenv-zedenv', 29 | author='John Ramsden', 30 | author_email='johnramsden@riseup.net', 31 | license='BSD-3-Clause', 32 | classifiers=[ 33 | 'Development Status :: 3 - Alpha', 34 | 'License :: OSI Approved :: BSD License', 35 | 'Programming Language :: Python :: 3.6', 36 | 'Programming Language :: Python :: 3.7', 37 | 'Programming Language :: Python :: 3.8', 38 | ], 39 | keywords='cli', 40 | packages=find_packages(exclude=["*tests*", "test_*"]), 41 | install_requires=['click', 'zedenv'], 42 | setup_requires=['pytest-runner'], 43 | tests_require=tests_require, 44 | extras_require={ 45 | 'test': tests_require, 46 | 'dev': dev_require, 47 | }, 48 | entry_points=""" 49 | [zedenv.plugins] 50 | grub = zedenv_grub.grub:GRUB 51 | """, 52 | zip_safe=False, 53 | data_files=[("/etc/grub.d", ["grub.d/05_zfs_linux.py"])], 54 | ) 55 | -------------------------------------------------------------------------------- /zedenv_grub/__init__.py: -------------------------------------------------------------------------------- 1 | # __init__.py 2 | 3 | __version__ = '0.1.8' 4 | -------------------------------------------------------------------------------- /zedenv_grub/grub.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | import os 3 | import tempfile 4 | import subprocess 5 | 6 | import pyzfscmds.utility 7 | import pyzfscmds.system.agnostic 8 | 9 | import zedenv.cli.mount 10 | import zedenv.lib.system 11 | import zedenv.lib.be 12 | import zedenv.plugins.configuration as plugin_config 13 | from zedenv.lib.logger import ZELogger 14 | 15 | from typing import Tuple 16 | 17 | 18 | class GRUB(plugin_config.Plugin): 19 | systems_allowed = ["linux"] 20 | 21 | bootloader = "grub" 22 | 23 | allowed_properties: Tuple[dict] = ( 24 | { 25 | "property": "boot", 26 | "description": "Set location for boot.", 27 | "default": "/mnt/boot" 28 | }, 29 | { 30 | "property": "bootonzfs", 31 | "description": "Use ZFS for /boot.", 32 | "default": "yes" 33 | }, 34 | { 35 | "property": "grubsubdir", 36 | "description": "Set name of subdirectory under boot.", 37 | "default": "grub" 38 | }, 39 | { 40 | "property": "simpleentries", 41 | "description": "Add simple entries in GRUB.", 42 | "default": "yes" 43 | } 44 | ) 45 | 46 | def __init__(self, zedenv_data: dict, skip_update: bool = False, skip_cleanup: bool = False): 47 | 48 | super().__init__(zedenv_data) 49 | 50 | self.entry_prefix = "zedenv" 51 | 52 | self.old_entry = f"{self.entry_prefix}-{self.old_boot_environment}" 53 | self.new_entry = f"{self.entry_prefix}-{self.boot_environment}" 54 | 55 | self.boot_mountpoint = "/boot" 56 | self.env_dir = "env" 57 | self.zfs_env_dir = "zfsenv" 58 | 59 | if not os.path.isdir(self.boot_mountpoint): 60 | ZELogger.log({ 61 | "level": "EXCEPTION", 62 | "message": f"Boot mountpoint {self.boot_mountpoint} does not exist. Exiting.\n" 63 | }, exit_on_error=True) 64 | 65 | self.skip_update_grub = skip_update 66 | self.skip_cleanup = skip_cleanup 67 | 68 | # Set defaults 69 | for pr in self.allowed_properties: 70 | self.zedenv_properties[pr["property"]] = pr["default"] 71 | 72 | self.check_zedenv_properties() 73 | 74 | if self.zedenv_properties["bootonzfs"] in ("yes", "1"): 75 | self.bootonzfs = True 76 | elif self.zedenv_properties["bootonzfs"] in ("no", "0"): 77 | self.bootonzfs = False 78 | else: 79 | ZELogger.log({ 80 | "level": "EXCEPTION", 81 | "message": (f"Property 'bootonzfs' is set to invalid value " 82 | f"{self.zedenv_properties['bootonzfs']}, should be " 83 | "'yes', 'no', '0', or '1'. Exiting.\n") 84 | }, exit_on_error=True) 85 | 86 | if self.bootonzfs: 87 | if not self.noop: 88 | if not os.path.isdir(self.zedenv_properties["boot"]): 89 | try: 90 | os.makedirs(self.zedenv_properties["boot"]) 91 | except PermissionError as e: 92 | ZELogger.log({ 93 | "level": "EXCEPTION", 94 | "message": ("Require Privileges to write to " 95 | f"{self.zedenv_properties['boot']}\n{e}") 96 | }, exit_on_error=True) 97 | except OSError as os_err: 98 | ZELogger.log({"level": "EXCEPTION", "message": os_err}, 99 | exit_on_error=True) 100 | ZELogger.verbose_log({ 101 | "level": "INFO", 102 | "message": ("Created mount directory " 103 | f"{self.zedenv_properties['boot']}\n") 104 | }, self.verbose) 105 | 106 | zfs_env_dir_path = os.path.join( 107 | self.zedenv_properties["boot"], self.zfs_env_dir) 108 | if not os.path.isdir(zfs_env_dir_path): 109 | try: 110 | os.makedirs(zfs_env_dir_path) 111 | except PermissionError as e: 112 | ZELogger.log({ 113 | "level": "EXCEPTION", 114 | "message": (f"Require Privileges to write to " 115 | f"{zfs_env_dir_path}\n{e}") 116 | }, exit_on_error=True) 117 | except OSError as os_err: 118 | ZELogger.log({"level": "EXCEPTION", "message": os_err}, 119 | exit_on_error=True) 120 | else: 121 | if not os.path.isdir(self.zedenv_properties["boot"]): 122 | self.plugin_property_error("boot") 123 | 124 | self.grub_boot_dir = os.path.join( 125 | self.boot_mountpoint, self.zedenv_properties["grubsubdir"]) 126 | 127 | if not os.path.isdir(self.grub_boot_dir): 128 | ZELogger.log({"level": "EXCEPTION", 129 | "message": (f"Directory {self.grub_boot_dir} does not exist. " 130 | "Check 'grubsubdir' property is set correctly") 131 | }, exit_on_error=True) 132 | 133 | self.grub_cfg = "grub.cfg" 134 | 135 | self.grub_cfg_path = os.path.join(self.grub_boot_dir, self.grub_cfg) 136 | 137 | def grub_mkconfig(self, location: str): 138 | env = dict(os.environ, ZPOOL_VDEV_NAME_PATH='1') 139 | ZELogger.verbose_log({ 140 | "level": "INFO", 141 | "message": (f"Generating " 142 | "the GRUB configuration.\n") 143 | }, self.verbose) 144 | 145 | grub_call = ["grub-mkconfig", "-o", location] 146 | 147 | try: 148 | grub_output = subprocess.check_call(grub_call, env=env, 149 | universal_newlines=True, stderr=subprocess.PIPE) 150 | except subprocess.CalledProcessError as e: 151 | raise RuntimeError(f"Failed to generate GRUB config.\n{e}\n.") 152 | 153 | return grub_output 154 | 155 | def modify_bootloader(self, temp_boot: str): 156 | 157 | real_kernel_dir = os.path.join(self.zedenv_properties["boot"], "env") 158 | temp_kernel_dir = os.path.join(temp_boot, "env") 159 | 160 | real_old_dataset_kernel = os.path.join(real_kernel_dir, self.old_entry) 161 | temp_new_dataset_kernel = os.path.join(temp_kernel_dir, self.new_entry) 162 | 163 | if not os.path.isdir(real_old_dataset_kernel): 164 | ZELogger.log({ 165 | "level": "INFO", 166 | "message": (f"No directory for Boot environments kernels found at " 167 | f"'{real_old_dataset_kernel}', creating empty directory." 168 | f"Don't forget to add your kernel to " 169 | f"{real_kernel_dir}/zedenv-{self.boot_environment}.") 170 | }) 171 | if not self.noop: 172 | try: 173 | os.makedirs(temp_new_dataset_kernel) 174 | except PermissionError as e: 175 | ZELogger.log({ 176 | "level": "EXCEPTION", 177 | "message": f"Require Privileges to write to {temp_new_dataset_kernel}\n{e}" 178 | }, exit_on_error=True) 179 | except OSError as os_err: 180 | ZELogger.log({ 181 | "level": "EXCEPTION", 182 | "message": os_err 183 | }, exit_on_error=True) 184 | else: 185 | if not self.noop: 186 | try: 187 | shutil.copytree(real_old_dataset_kernel, temp_new_dataset_kernel) 188 | except PermissionError as e: 189 | ZELogger.log({ 190 | "level": "EXCEPTION", 191 | "message": f"Require Privileges to write to {temp_new_dataset_kernel}\n{e}" 192 | }, exit_on_error=True) 193 | except IOError as e: 194 | ZELogger.log({ 195 | "level": "EXCEPTION", 196 | "message": f"IOError writing to {temp_new_dataset_kernel}\n{e}" 197 | }, exit_on_error=True) 198 | 199 | def setup_boot_env_tree(self): 200 | mount_root = os.path.join(self.zedenv_properties["boot"], self.zfs_env_dir) 201 | 202 | if not os.path.exists(mount_root): 203 | os.mkdir(mount_root) 204 | 205 | be_list = zedenv.lib.be.list_boot_environments(self.be_root, ['name']) 206 | ZELogger.verbose_log( 207 | {"level": "INFO", "message": f"Going over list {be_list}.\n"}, self.verbose) 208 | 209 | for b in be_list: 210 | if not pyzfscmds.utility.is_snapshot(b['name']): 211 | be_name = pyzfscmds.utility.dataset_child_name(b['name'], False) 212 | 213 | if not zedenv.lib.be.extra_bpool(): 214 | # Check if 'b' is current dataset 215 | if pyzfscmds.system.agnostic.dataset_mountpoint(b['name']) == "/": 216 | ZELogger.verbose_log({ 217 | "level": "INFO", 218 | "message": f"Dataset {b['name']} is root, skipping.\n" 219 | }, self.verbose) 220 | else: 221 | be_boot_mount = os.path.join(mount_root, f"zedenv-{be_name}") 222 | ZELogger.verbose_log({ 223 | "level": "INFO", 224 | "message": f"Setting up {b['name']}.\n" 225 | }, self.verbose) 226 | 227 | if not os.path.exists(be_boot_mount): 228 | os.mkdir(be_boot_mount) 229 | 230 | if not os.listdir(be_boot_mount): 231 | zedenv.cli.mount.zedenv_mount(be_name, 232 | be_boot_mount, 233 | self.verbose, self.be_root) 234 | else: 235 | ZELogger.verbose_log({ 236 | "level": "WARNING", 237 | "message": (f"Mount directory {be_boot_mount}" 238 | " wasn't empty, skipping.\n") 239 | }, self.verbose) 240 | else: 241 | # Mount all boot datasets 242 | be_boot = zedenv.lib.be.root("/boot") 243 | 244 | be_boot_mount = os.path.join(mount_root, f"zedenv-{be_name}") 245 | ZELogger.verbose_log( 246 | {"level": "INFO", "message": f"Setting up {b['name']}.\n"}, self.verbose) 247 | 248 | if not os.path.exists(be_boot_mount): 249 | os.mkdir(be_boot_mount) 250 | 251 | if not os.listdir(be_boot_mount): 252 | zedenv.cli.mount.zedenv_mount(f"zedenv-{be_name}", be_boot_mount, 253 | self.verbose, be_boot, check_bpool=False) 254 | else: 255 | ZELogger.verbose_log({ 256 | "level": "WARNING", 257 | "message": f"Mount directory {be_boot_mount} wasn't empty, skipping.\n" 258 | }, self.verbose) 259 | 260 | def teardown_boot_env_tree(self): 261 | def ismount(path, boot): 262 | if not os.path.ismount(path): 263 | """ 264 | This is required because `os.path.ismount()` returns False if a ZFS dataset is 265 | being mounted again to a subfolder of itself. E.g. bpool/boot/env/zedenv-default 266 | is mounted to 267 | - `/boot` and 268 | - `/boot/zfsenv/zedenv-default` 269 | """ 270 | s1 = os.lstat(path) 271 | s2 = os.lstat(boot) 272 | return s1.st_ino == s2.st_ino 273 | else: 274 | return True 275 | 276 | mount_root = os.path.join(self.zedenv_properties["boot"], self.zfs_env_dir) 277 | cleanup = True 278 | 279 | if not os.path.exists(mount_root): 280 | ZELogger.verbose_log({ 281 | "level": "INFO", 282 | "message": f"Mount root: '{mount_root}' doesnt exist.\n" 283 | }, self.verbose) 284 | else: 285 | for m in os.listdir(mount_root): 286 | mount_path = os.path.join(mount_root, m) 287 | ZELogger.verbose_log({ 288 | "level": "INFO", 289 | "message": f"Unmounting {m}\n" 290 | }, self.verbose) 291 | if ismount(mount_path, self.boot_mountpoint): 292 | try: 293 | zedenv.lib.system.umount(mount_path) 294 | except RuntimeError as e: 295 | ZELogger.log({ 296 | "level": "WARNING", 297 | "message": f"Failed Un-mountingdataset from '{m}'.\n{e}" 298 | }, exit_on_error=True) 299 | cleanup = False 300 | else: 301 | ZELogger.verbose_log({ 302 | "level": "INFO", 303 | "message": f"Unmounted {m} from {mount_path}.\n" 304 | }, self.verbose) 305 | try: 306 | os.rmdir(mount_path) 307 | except OSError as ex: 308 | ZELogger.verbose_log({ 309 | "level": "WARNING", 310 | "message": f"Couldn't remove directory {mount_path}.\n{ex}\n" 311 | }, self.verbose) 312 | cleanup = False 313 | else: 314 | ZELogger.verbose_log({ 315 | "level": "INFO", 316 | "message": f"Removed directory {mount_path}.\n" 317 | }, self.verbose) 318 | 319 | if cleanup and os.path.exists(mount_root): 320 | try: 321 | os.rmdir(mount_root) 322 | except OSError as ex: 323 | ZELogger.verbose_log({ 324 | "level": "WARNING", 325 | "message": f"Couldn't remove directory {mount_root}.\n{ex}\n" 326 | }, self.verbose) 327 | 328 | def post_activate(self): 329 | ZELogger.verbose_log({ 330 | "level": "INFO", 331 | "message": (f"Creating Temporary working directory. " 332 | "No changes will be made until the end of " 333 | "the GRUB configuration.\n") 334 | }, self.verbose) 335 | 336 | if not self.bootonzfs: 337 | with tempfile.TemporaryDirectory(prefix="zedenv", suffix=self.bootloader) as t_grub: 338 | ZELogger.verbose_log({ 339 | "level": "INFO", 340 | "message": f"Created {t_grub}.\n" 341 | }, self.verbose) 342 | 343 | self.modify_bootloader(t_grub) 344 | self.recurse_move(t_grub, self.zedenv_properties["boot"], overwrite=False) 345 | 346 | if self.bootonzfs: 347 | self.setup_boot_env_tree() 348 | 349 | if not self.skip_update_grub: 350 | try: 351 | self.grub_mkconfig(self.grub_cfg_path) 352 | except RuntimeError as e: 353 | ZELogger.verbose_log({ 354 | "level": "INFO", 355 | "message": f"During 'post activate', 'grub-mkconfig' failed with:\n{e}.\n" 356 | }, self.verbose) 357 | else: 358 | ZELogger.verbose_log({ 359 | "level": "INFO", 360 | "message": f"Generated GRUB menu successfully at {self.grub_cfg_path}.\n" 361 | }, self.verbose) 362 | 363 | if self.bootonzfs and not self.skip_cleanup: 364 | self.teardown_boot_env_tree() 365 | 366 | def pre_activate(self): 367 | pass 368 | 369 | def mid_activate(self, be_mountpoint: str): 370 | ZELogger.verbose_log({ 371 | "level": "INFO", 372 | "message": f"Running {self.bootloader} mid activate.\n" 373 | }, self.verbose) 374 | 375 | replace_pattern = r'(^{real_boot}/{env}/?)(.*)(\s.*{boot}\s.*$)'.format( 376 | real_boot=self.zedenv_properties["boot"], env=self.env_dir, boot=self.boot_mountpoint) 377 | 378 | if not self.bootonzfs: 379 | self.modify_fstab(be_mountpoint, replace_pattern, self.new_entry) 380 | 381 | def post_destroy(self, target): 382 | self.post_activate() 383 | 384 | def post_create(self): 385 | self.post_activate() 386 | 387 | def post_rename(self): 388 | self.post_activate() 389 | --------------------------------------------------------------------------------