From 71369509dd9c1cce9302cd226d260f892c59ae78 Mon Sep 17 00:00:00 2001 From: Frederik Lindenaar Date: Fri, 2 Jan 2026 12:45:12 +0100 Subject: [PATCH] added rpi-lvm-imager --- README.md | 39 ++++- rpi-lvm-imager.sh | 417 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 453 insertions(+), 3 deletions(-) create mode 100755 rpi-lvm-imager.sh diff --git a/README.md b/README.md index b81ac65..d766e36 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ are of any benefit to other enthusiasts. Use them freely and please let me know in case you encounter any issues or require changes. The latest versions, documentation and bugtracker available on my -[GitLab instance](https://gitlab.lindenaar.net/scripts/raspberrypi) +[Gitea instance](https://gitea.lindenaar.net/scripts/raspberrypi) -Copyright (c) 2019 Frederik Lindenaar. free for distribution under +Copyright (c) 2019 - 2025 Frederik Lindenaar. free for distribution under the GNU General Public License, see [below](#license) Contents @@ -24,6 +24,8 @@ This repository contains the following scripts: is a systemd service to disable on-board WiFi on Raspberry Pi at boot * [rpi_poweroff_button.service](#services) is a systemd service to support a power-off button using [gpio_trigger.py](#gpio_trigger) + * [rpi-lvm-imager.sh](#rpi-lvm-imager) + initialize an NVME drive with Raspberry Pi OS (Lite) on LVM gpio_trigger.py @@ -94,6 +96,37 @@ systemctl disable service <> ~~~ +rpi-lvm-imager.sh +-------------------------------------------- +This script will wipe and partition a (large) storage device with a small boot +partition, setup LVM for the rest of the disk, and then writes the latest +version of Raspbian Pi OS (Lite) or another OS image availabile via the +Raspberry Pi Imager to LVM managed partition(s) and makes the device bootable. + +The reason I worte this script was that the Raspberry Pi Imager can only write +an image to a full disk and does not allow one to partition it nor perform the +installation on an LVM managed disk. This was needed for a Raspberry Pi 5 with +NVME SSD as the device had way more diskspace than would ever be needed/useful +for the system partition. + +This script was inspired by the write-up found [here](https://raspberrypi.stackexchange.com/questions/7159/can-the-raspberry-boot-to-an-lvm-root-partition). +That was apparently superseded by [this approach](https://raspberrypi.stackexchange.com/questions/85958/easy-backups-and-snapshots-of-a-running-system-with-lvm), +hoever, I like the initial approach more. This script will write the OS to a +device after setting up LVM but before booting it so it is just quicker than +writing the OS and then moving it to an LVM managed partition. + +To write the latest version of Raspberry Pi OS (64bit Lite) to an NVME SSD +device simply run: + +~~~ +sudo rpi-lvm-imager.sh --lvm-group nvme /dev/nvme0n1 +~~~ + +Please note that this will unmound and wipe the specified device so be careful! + +to see all aailable options, run: `./rpi-lvm-imager.sh -h` + + License ----------------------------- These scripts, documentation & configration examples are free software: you can @@ -107,4 +140,4 @@ warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with -this program. If not, download it from . \ No newline at end of file +this program. If not, download it from . diff --git a/rpi-lvm-imager.sh b/rpi-lvm-imager.sh new file mode 100755 index 0000000..352b5f9 --- /dev/null +++ b/rpi-lvm-imager.sh @@ -0,0 +1,417 @@ +#! /bin/bash -e + +# +# rpi-lvm-imager.sh - initialize NVME with Raspberry Pi OS (Lite) on LVM +# +# Version 1.0, latest version, documentation and bugtracker available at: +# https://gitea.lindenaar.net/scripts/raspberrypi +# +# Copyright (c) 2025 Frederik Lindenaar +# +# This script is free software: you can redistribute and/or modify it under the +# terms of version 3 of the GNU General Public License as published by the Free +# Software Foundation, or (at your option) any later version of the license. +# +# This script is distributed in the hope that it will be useful but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# this program. If not, visit to download it. +# + +# Inspired by: https://raspberrypi.stackexchange.com/questions/7159/can-the-raspberry-boot-to-an-lvm-root-partition + +# Fetch the list of Raspberry Pi OS images, if required, and flatten it +# next return the url to download the specified image or list of images +PIOS_LIST_DOWNLOAD_URL=https://downloads.raspberrypi.com/os_list_imagingutility_v4.json +get_raspios() { + # Download the Raspberry Pi Imager catalog (if a newer version exists) + wget --quiet --timestamping --directory-prefix "${WORKDIR}" "${PIOS_LIST_DOWNLOAD_URL}" + RPI_IMAGER_CONFIG="${WORKDIR}/$(basename "${PIOS_LIST_DOWNLOAD_URL}" )" + + # Extract and flatten the list of entries, if required + PIOS_LIST_FILE="${WORKDIR}/os_list.json" + if [ "${RPI_IMAGER_CONFIG}" -nt "${PIOS_LIST_FILE}" ]; then + jq '.os_list + | map(select( .url )) + + map(select( .subitems ).subitems + | map(select( .url )) + + map(select( .subitems ).subitems + | map(select( .url )) + + map(select( .subitems ).subitems + | map(select( .url )) + + map(select( .subitems ).subitems) + | flatten) + | flatten) + | flatten) + | flatten' "${RPI_IMAGER_CONFIG}" > "${PIOS_LIST_FILE}" + if fgrep -q '"subitems"' "${PIOS_LIST_FILE}"; then + echo >&2 Warning: structure of the Raspberry Pi Imager catalog has changed + fi + fi + + if [ -z "$1" -o "$1" = list ]; then + jq --raw-output "map(.name ) | .[]" "${PIOS_LIST_FILE}" | sort -u + else + echo $(jq --raw-output "map(select( .name == \"${1}\"))[0] | .url,.extract_size,.extract_sha256" "${PIOS_LIST_FILE}") + fi +} + +# defaults for command line options +opt_raspios_lite=true +opt_raspios_bits=64 +opt_raspios_image= +opt_raspios_checksum= +opt_check_image_checksum=true +opt_raspios_checksum= +opt_lvm_group_name= +opt_lvm_size=+ +opt_boot_size= +opt_root_size=16G +opt_lvm_partitions= +opt_nvme_pcie_gen3=false + +# parse command line parameters +while [ $# -gt 0 ] +do + case "$1" in + -h|--help) cat << EOT +Raspberry Pi LVM Imager - write Raspberry Pi OS image to an LVM-enabled device + +Usage: $(baesename $0) [options] device + +Where <> can be one or more of: + + -h, --help show this help and exit + + -l, --list-images output the list of available Raspberry Pi images and exit + -L, --lite install Raspberry Pi OS Lite (default) + -R, --regular install the regular Raspberry Pi OS (with destktop) + -6, --64bit install a 64-bit version of Raspberry Pi OS + -3, --32bit install a 32-bit version of Raspberry Pi OS + -i, --image-name name of the image to install + -U, --url provide the download URL for Raspberry Pi OS + see https://www.raspberrypi.com/software/operating-systems + -C, --checksum provide sha256 checksum of image + -n, --no-checksum disable checking the sha256 checksum of the downloaded image + + -g, --lvm-group name of the LVM volume group to create (required and must not exist!) + -s, --lvm-size size of the LVM volume group to create (defaults to the rest of the disk) + -b, --boot-size size of the boot filesystem (defaults to the size in the image) + -r, --root-size size of the root filesystem (defaults to 16G) + -p, --lvm-partition comma separated /path:size list to move to separate LVMs + + -P, --nvme-pcie-gen3 enable PCIe Gen 3 mode for the NVMe + + -w, --workdir (persistent) work directory to store files, not cleaned up +EOT + exit + ;; + -l|--list-images) opt_raspios_image=list + ;; + -i|--image-name) opt_raspios_image="$2" + shift + ;; + -L|--lite) opt_raspios_lite=true + ;; + -R|--regular) opt_raspios_lite=false + ;; + -3|--32-bit) opt_raspios_bits=32 + ;; + -6|--64-bit) opt_raspios_bits=64 + ;; + -U|--url) IMAGE_DOWNLOAD_URL="$2" + shift + ;; + -C|--checksum) opt_raspios_checksum="$2" + shift + ;; + -n|--no-checksum) opt_check_image_checksum=false + ;; + -g|--lvm-group) opt_lvm_group_name="$2" + shift + ;; + -s|--lvm-size) opt_lvm_size="$2" + shift + ;; + -b|--boot-size) opt_boot_size="$2" + shift + ;; + -r|--root-size) opt_root_size="$2" + shift + ;; + -p|--lvm-partition) opt_lvm_partitions="$2" + shift + ;; + -P|--nvme-pcie-gen3) opt_nvme_pcie_gen3=true + ;; + -w|--workdir) WORKDIR="$2" + shift + ;; + -*) echo Error: unknown option $1, run $0 -h for supported options + exit 2 + ;; + *) if [ -z "$opt_target_device" ]; then + opt_target_device=$1 + else + echo Error: only one device is supported, run $0 -h for usage + exit 2 + fi + ;; + esac + shift +done + +# Ensure the script is run as root +if [ $(id -u) -gt 0 ]; then + echo "Error: $(basename $0) must be run as root" + exit 1 +fi + +# Ensure cleanup is done when we exist, irrespective how +cleanup() { + echo cleaning up + # Unmount the partitions under the rootfs in reverse order + if [ -n "${TARGET_ROOT_MOUNTPOINT}" ]; then + findmnt --raw --noheadings --output target --submounts "${TARGET_ROOT_MOUNTPOINT}" | sort -r | while read mountpoint + do + umount "${mountpoint}" + done + fi + + # Unmount the image from the loopback when it was mounted + if [ -n "$RASPIOS_DEV" ]; then + losetup --detach ${RASPIOS_DEV} + fi + + # Remove the temporary working directory (if created) + if [ -n "$TEMPDIR" ]; then + rm -rf $TEMPDIR + fi +} +trap cleanup EXIT + +# Create a temporary working directory, if required +if [ -z "$WORKDIR" ]; then + TEMPDIR=$(mktemp --directory --tmpdir $(basename $0 .sh).XXXXXXXX) + WORKDIR=${TEMPDIR} +elif [ ! -d "${WORKDIR}" ]; then + mkdir "${WORKDIR}" +fi + +# If we need to print the list of available images, print it and exit +if [ "${opt_raspios_image}" = "list" ]; then + get_raspios list + exit +fi + +# Ensure the target is a valid disk +if [ -z "${opt_target_device}" ]; then + echo Error: target device is required, run $0 -h for usage + exit 2 +elif [ "$(lsblk --nodeps --noheadings ${opt_target_device} -o type)" != "disk" ]; then + echo Error: target device ${opt_target_device} is not a disk + exit 2 +fi + +# Ensure no partitions of the target device are still mounted +if [ -n "$(findmnt -D| fgrep "${opt_target_device}")" ]; then + echo Error: partitions of target device ${opt_target_device} are still mounted: + findmnt -D | egrep "^SOURCE|${opt_target_device}" + exit 1 +fi + +# Ensure the LVM group (if one is to be setup) is provided and does not exist yet +if [ -z "${opt_lvm_group_name}" ]; then + echo Error: target LVM volume group name is required, run $0 -h for usage + exit 2 +elif [ -n "${opt_lvm_group_name}" -a -n "$(pvdisplay --columns --options pv_name,vg_name --noheading | egrep "${opt_lvm_group_name}|${opt_target_device}")" ]; then + echo Existing LVM configuration found, cleaning up conflicting setup + TTY=$(tty) + pvdisplay --columns --options pv_name,vg_name --noheading | while read dev vg + do + if [ "${vg}" = "${opt_lvm_group_name}" ]; then + wipefs --quiet --all /dev/${vg}/* + vgremove --quiet ${vg} < ${TTY} + fi + if [[ "${dev}" == ${opt_target_device}* ]]; then + if ! [ "${vg}" != "${opt_lvm_group_name}" ] || vgreduce --quiet ${vg} ${dev} < ${TTY}; then + pvremove --quiet ${dev} < ${TTY} + fi + fi + done +fi +lvm_dev= + +# Ensure the partition table of the taget device is empty +if [ "$(sudo sfdisk --dump "${opt_target_device}" 2>/dev/null )" ]; then + echo Warning: target device ${opt_target_device} may contain data: + sfdisk --quiet --list "${opt_target_device}" + + echo + read -p "erase everything and image ${opt_target_device}? (yes/no) [no]" + if [ "${REPLY}" != "yes" ]; then + echo Aborted. + exit 2 + fi + + echo + echo Erasing data on "${opt_target_device}" + wipefs --quiet --all ${opt_target_device}* + sfdisk --quiet --wipe always --wipe-partitions always --delete "${opt_target_device}" && echo OK || echo NOK + # dd if=/dev/zero "of=${opt_target_device}" bs=512 count=1 2> /dev/null +fi + +echo partitioning target disk +echo 'label: dos' | sfdisk --quiet "${opt_target_device}" + +# Ensure a Raspberry Pi OS download URL and local image name are set +if [ -z "${IMAGE_DOWNLOAD_URL}" ]; then + if [ -z "${opt_raspios_image}" ]; then + if ${opt_raspios_lite}; then + opt_raspios_image="Raspberry Pi OS Lite (${opt_raspios_bits}-bit)" + else + opt_raspios_image="Raspberry Pi OS (${opt_raspios_bits}-bit)" + fi + fi + + read IMAGE_DOWNLOAD_URL IMAGE_EXTRACT_SIZE IMAGE_EXTRACT_SHA256 <<< $(get_raspios "${opt_raspios_image}") + if [ -z "${IMAGE_DOWNLOAD_URL}" -o "${IMAGE_DOWNLOAD_URL}" = "null" ]; then + echo "Error: no URL found for image name '${opt_raspios_image}'" + exit 1 + fi +fi +RASPIOS_IMG="${WORKDIR}/$(basename "${IMAGE_DOWNLOAD_URL}" .xz)" + +# Download the image, if required, and/or verify its checksum (if possible) +if [ ! -f "${RASPIOS_IMG}" -o -n "${IMAGE_EXTRACT_SIZE}" -a "$(du -b "${RASPIOS_IMG}" 2>/dev/null | sed "s/[^0-9].*//")" != "${IMAGE_EXTRACT_SIZE}" ]; then + if $opt_check_image_checksum && [ -n "${opt_raspios_checksum:-$IMAGE_EXTRACT_SHA256}" ]; then + echo "${opt_raspios_checksum:-$IMAGE_EXTRACT_SHA256} -" > "${RASPIOS_IMG}.$$.sha256sum" + if ! wget --quiet --show-progress --output-document - "${IMAGE_DOWNLOAD_URL}" | xz --decompress | tee ${RASPIOS_IMG} | sha256sum --check --strict --quiet "${RASPIOS_IMG}.$$.sha256sum"; then + echo Error: downloading, extracting and verifying the image failed + exit 2 + fi + rm ${RASPIOS_IMG}.*.sha256sum + else + if ! wget --quiet --show-progress --output-document - "${IMAGE_DOWNLOAD_URL}" | xz --decompress > ${RASPIOS_IMG}; then + echo Error: downloading the image failed + exit 2 + fi + fi +elif $opt_check_image_checksum && [ -n "${opt_raspios_checksum:-$IMAGE_EXTRACT_SHA256}" ]; then + if ! echo "${opt_raspios_checksum:-$IMAGE_EXTRACT_SHA256} ${RASPIOS_IMG}" | sha256sum --check --strict --quiet; then + echo Error: image checksum does not match with the Raspberry Pi catalog + exit 2 + fi +fi + +# Mount the image as a loopback device and get it's device name +RASPIOS_DEV=$(losetup --find --show --partscan --read-only ${RASPIOS_IMG} ) +sleep 1 + +# Get the partition sizes of the image +TTY=$(tty) +sfdisk --dump ${RASPIOS_DEV} | egrep "^/" | sed -E "s/[:,][^=]+=/ /g; s/ +/ /g" | while read part start size type +do + label=$(lsblk "${part}" --noheadings --output label) + echo Found $label partition $part with $start $size and type $type + + if [ "${type}" = 'c' ]; then + echo ",${size},${type}" | sfdisk --quiet --append --wipe always --wipe-partition always "${opt_target_device}" + target_part=$(sfdisk --dump "${opt_target_device}" | tail -1 | cut -f1 -d\ ) + elif [ "${type}" = '83' ]; then + if [ -z "${lvm_dev}" ]; then + echo ",${opt_lvm_size},8e" | sfdisk --quiet --append --wipe always "${opt_target_device}" 2>/dev/null + lvm_dev="$(sfdisk --dump "${opt_target_device}" | tail -1 | cut -f1 -d\ )" + pvcreate --quiet "${lvm_dev}" + vgcreate --quiet "${opt_lvm_group_name}" "${lvm_dev}" + fi + + lvcreate --name "${label}" --size "${opt_root_size}" --wipesignatures y "${opt_lvm_group_name}" <$TTY + target_part="/dev/${opt_lvm_group_name}/${label}" + + else + echo Error: image contains unsupported partition ${part} with type 0x$type + exit 1 + fi + + echo Copying $label to ${target_part} + dd if=${part} of=$target_part 2>/dev/null +done + +echo Setting up the root filesystem for its initial boot + +TARGET_BOOTFS_DEV=$(lsblk --raw --noheadings --output path,label "${opt_target_device}" | fgrep bootfs | cut -d\ -f1) +TARGET_ROOTFS_DEV=$(lsblk --raw --noheadings --output path,label "${opt_target_device}" | fgrep rootfs | cut -d\ -f1) + +# Mount the new root filesystem for post-processing +TARGET_ROOT_MOUNTPOINT="${WORKDIR}/rootfs" +echo mount "${filesystems[rootfs]}" "${TARGET_ROOT_MOUNTPOINT}" +mount -o noatime "${TARGET_ROOTFS_DEV}" "${TARGET_ROOT_MOUNTPOINT}" +mount "${TARGET_BOOTFS_DEV}" "${TARGET_ROOT_MOUNTPOINT}/boot/firmware" +mount -t proc /proc "${TARGET_ROOT_MOUNTPOINT}/proc" +mount -o bind /dev "${TARGET_ROOT_MOUNTPOINT}/dev" +mount -o bind /dev/pts "${TARGET_ROOT_MOUNTPOINT}/dev/pts" +mount -t sysfs /sys "${TARGET_ROOT_MOUNTPOINT}/sys" + +# patch /boot/firmware/config.txt so tat it contains the correct root filesystem +sed -Ei "s|root=[^ \t]+|root=${TARGET_ROOTFS_DEV}|" "${TARGET_ROOT_MOUNTPOINT}/boot/firmware/cmdline.txt" + +# patch /etc/fstab so that it contains the right devices +sed -Ei "s|^[^# \t]+([ \t]+/boot/firmware[ \t])|${TARGET_BOOTFS_DEV}\1|" "${TARGET_ROOT_MOUNTPOINT}/etc/fstab" +sed -Ei "s|^[^# \t]+([ \t]+/[ \t])|${TARGET_ROOTFS_DEV}\1|" "${TARGET_ROOT_MOUNTPOINT}/etc/fstab" + +# move filesystems to separate LVMs and ensure these are automaticaly mounted +for p in ${opt_lvm_partitions//,/ } +do + IFS=: read path size <<< "${p}" + lvname=${path//*\//}fs + echo moving ${path} to a LVM ${lvmname} of ${size} + + lvcreate --name "${lvname}" --size "${size}" --wipesignatures y "${opt_lvm_group_name}" <$TTY + lvpath=$(lsblk --raw --noheadings --output path,label "${opt_target_device}" | fgrep "${lvname}" | cut -d\ -f1) + mke2fs -L "${lvname}" "${lvpath}" + + if ! [ -d "${TARGET_ROOT_MOUNTPOINT}${path}" ]; then + echo Error: direcory ${vpath} does not exist in the target environment + exit 1 + # mkdir "${TARGET_ROOT_MOUNTPOINT}/${path}" + else + mount "${lvpath}" "${TARGET_ROOT_MOUNTPOINT}/mnt" + chroot "${TARGET_ROOT_MOUNTPOINT}" rsync -aAXH "${path}/" "/mnt/" + umount "${TARGET_ROOT_MOUNTPOINT}/mnt" + chroot "${TARGET_ROOT_MOUNTPOINT}" bash -c "rm -rf ${path}/* ${path}/.*" + fi + + if egrep -Eq "^[^# \t]+[ \t]+${path}[ \t]" "${TARGET_ROOT_MOUNTPOINT}/etc/fstab" ; then + sed -Ei "s|^[^# \t]+([ \t]+${path}[ \t])|${lvpath}\1|" "${TARGET_ROOT_MOUNTPOINT}/etc/fstab" + else + echo "${lvpath} ${path} ext4 defaults,noatime 0 2" >> "${TARGET_ROOT_MOUNTPOINT}/etc/fstab" + fi + + mount "${lvpath}" "${TARGET_ROOT_MOUNTPOINT}${path}" +done + +cat "${TARGET_ROOT_MOUNTPOINT}/etc/fstab" + +# install required packages for things to work +chroot "${TARGET_ROOT_MOUNTPOINT}" apt install lvm2 + +# generate password +# openssl passwd -6 + +# setup initial (defaultt) user +echo 'jfl:$6$EyEFwTRqBv16ws7j$/5qABGo7Bm3p44mml1/LmFp9b/VdkCURRHNsGxl9YOC3IdzZ7N5LdYFOF5nZjGFQONWGgwjBBIn.JGhGNdnP70' > "${TARGET_ROOT_MOUNTPOINT}/boot/firmware/userconf" + +# enable SSH +touch "${TARGET_ROOT_MOUNTPOINT}/boot/firmware/ssh" + +# Enable PCIe Gen 3 for NVMe, if requested +if ${opt_nvme_pcie_gen3}; then + if ! egrep -q "^\s*dtparam\s*=\s*pciex1_gen\s*=" "${TARGET_ROOT_MOUNTPOINT}/boot/firmware/config.txt"; then + echo dtparam=pciex1_gen=3 >> "${TARGET_ROOT_MOUNTPOINT}/boot/firmware/config.txt" + fi +fi + +echo Done!