Hermes is the NUC 8 that sits next to Polaris. It was the first of the two NUCs, an i5-8259U with 32 GB of RAM, and these days it is my Linux workstation: Android Studio, Gradle builds, a few Docker projects, and a headless GNOME session I reach over RDP.

When I reinstalled it at the start of October, I set it up to dual-boot. Ubuntu got half of the 250 GB NVMe, Windows with BitLocker got the other half, and the 240 GB SATA SSD was split down the middle so each OS had a data partition. It felt like keeping my options open.

Three weeks later, Windows was doing nothing except holding half of both drives.

## Why now

Three things pushed it over the line.

The Ubuntu root was full. / had 93 GB used out of 114 GB. Most of it was in my home directory: 17 GB of app data under ~/.config, 12 GB of Android emulator images, 11 GB of Gradle cache.

Windows was the half with the broken CPU. Earlier this month both operating systems were stuck at 400 MHz. The cause is the NUC's platform power sensor, which reads about 63 W at idle. That leaves the CPU no room under its platform power limit, so it sits at its minimum clock. On Ubuntu, a small service turns that power limit off at boot and the CPU gets its 3.8 GHz back. Windows never got a fix. The half of the machine I wasn't using was also the half that couldn't run at full speed.

Dual boot has a maintenance cost. My GRUB setup scripts carried special cases to keep the Windows menu entry alive whenever they regenerated the config. That's a small thing, but it's the kind of thing that breaks quietly a year later.

I priced the obvious alternative first: a 1 TB WD Green SN3000. It would fit. The NUC 8 has one M.2 2280 slot running PCIe Gen3 x4, so a Gen4 drive just runs at Gen3 speed, about 3.5 GB/s instead of the 5,000 MB/s on the box. But with one slot, a new drive means replacing the Crucial P2, not adding to it, and migrating everything anyway. Removing Windows frees 116 GiB of NVMe and 112 GB of SATA for nothing. The new drive can wait.

I set one rule before starting: the OS stays on the NVMe. The SATA SSD is the slowest component in my whole homelab, and I'm not putting a root filesystem on it.

## Part 1: deleting Windows

This was the layout:

Partition Size Contents
nvme0n1p1 200 MiB EFI system partition
nvme0n1p2 16 MiB Microsoft reserved
nvme0n1p3 115.8 GiB Windows C: (BitLocker)
nvme0n1p4 695 MiB Windows recovery
nvme0n1p5 116.2 GiB Ubuntu /
sda1 111.8 GiB Ubuntu /data
sda2 111.8 GiB NTFS DATA, Windows only

Deleting partitions is where a typo costs you a disk, so the script doesn't trust partition numbers. Each target is pinned by the disk's serial number, its PARTUUID and its partition type GUID, as I read them that afternoon:

# partition number : PARTUUID : type GUID
DELETE_NVME=(
  "2:78277a95-8c92-4744-9dd0-420d361ceb64:e3c9e316-0b5c-4db8-817d-f92df00215ae"
  "3:a7e5b93a-9650-4418-b3bf-58ab6213b111:ebd0a0a2-b9e5-4433-87c0-68b6b72699c7"
  "4:19a7abf2-7911-4c9e-8c3e-1e85ae908090:de94bba4-06d1-4d40-a16a-bfd50179d6ac"
)

Every target goes through the same check before anything else happens:

check_part() { # disk-dev "n:partuuid:type"
  local n uuid type p
  IFS=: read -r n uuid type <<<"$2"
  p=$(part "$1" "$n")
  [[ -b $p ]] || die "$p missing"
  [[ $(lsblk -dno PARTUUID "$p") == "$uuid" ]] || die "$p PARTUUID is not $uuid"
  [[ $(lsblk -dno PARTTYPE "$p") == "$type" ]] || die "$p type is not $type"
}

The type GUID is the part people skip. A PARTUUID proves it's the same partition; the type GUID proves it's the kind of partition I think it is (Microsoft reserved, basic data, recovery). The script makes the same checks the other way round for what it keeps: if /, /boot/efi or /data isn't mounted from the partition it should be, it stops before writing a single byte. When everything checks out, it prints exactly what it's about to delete and waits for me to type YES.

The boot side went first. The firmware keeps its own list of boot entries, separate from GRUB, so the Windows one goes by label, not by number:

efibootmgr | sed -nE 's/^Boot([0-9A-F]{4})\*? Windows Boot Manager.*/\1/p' | while read -r num; do
  echo "Deleting firmware entry Boot$num (Windows Boot Manager)"
  efibootmgr -q -b "$num" -B
done

There's one trap here worth checking. EFI/Boot/bootx64.efi is the fallback loader the firmware runs when every other boot entry fails. If Windows had owned that file, deleting EFI/Microsoft would leave a fallback that points at nothing. So before removing anything, the script compares the two and puts Ubuntu's shim in its place if needed:

if [[ -n $fallback && -n $mgr ]] && cmp -s "$fallback" "$mgr"; then
  cp "$ESP/ubuntu/shimx64.efi" "$fallback"
  cp "$ESP/ubuntu/grubx64.efi" "$ESP/ubuntu/mmx64.efi" "$bootdir/"
fi

On Hermes, the fallback was already Ubuntu's, so this did nothing. Then EFI/Microsoft went (the EFI partition dropped from 37 MB used to 6.3 MB), and so did the line my hang-fix script had added to keep the Windows menu entry alive. The last line refuses to carry on if GRUB still has one:

sed -i '/^GRUB_DISABLE_OS_PROBER=false$/d' "$GRUB_DROPIN"
update-grub
grep -q "Windows Boot Manager" /boot/grub/grub.cfg && die "GRUB still has a Windows entry"

Then the partitions. Each one gets its filesystem signatures wiped first, so nothing created in that space later can be mistaken for the old contents, and then it's removed from the table:

for n in 2 3 4; do wipefs -q -a "$(part "$NDEV" $n)"; done
parted -s "$NDEV" rm 4 rm 3 rm 2
partx -d --nr 2:4 "$NDEV" 2>/dev/null || true

partx -d tells the kernel the partitions are gone. The disk still has mounted partitions on it, so the kernel can't simply re-read the whole table.

On the SATA SSD, sda2 went the same way, and because the freed space comes after sda1, /data could take it while still mounted. growpart moves the partition's end to the end of the disk, and resize2fs grows the filesystem to fill it:

growpart "$SDEV" 1
resize2fs "$(part "$SDEV" 1)"

/data went from 110 GB to 220 GB with no unmount and no reboot. I rebooted anyway to see GRUB. It now lists Ubuntu, its recovery entries, memtest and the firmware settings. Nothing else.

## Part 2: the free space is on the wrong side

This is what the NVMe looked like afterwards:

nvme0n1p1    200 MiB   EFI
(free)       116 GiB
nvme0n1p5    116 GiB   /

ext4 can grow while mounted, but only at its end. The free space sat before the start of the root partition, where no filesystem tool can reach it.

The usual answer is GParted from a live USB: shift the root partition 116 GiB to the left, then grow it. I didn't like that for two reasons. First, I'd have to be at the machine with a USB stick and a keyboard. Second, the move rewrites the filesystem over itself in place, so if it's interrupted halfway, the root partition is gone. Hermes has a history of freezing hard: earlier this month it hung within minutes whenever the HDMI monitor turned off, and it froze once more after that was fixed. I wasn't going to bet my only root partition on it staying up through 116 GiB of block shuffling.

So I did it the other way around: copy and switch. Make a copy in the free space, boot it, and only delete the original once the copy has proven itself. I ran it from my Mac as a script with four phases. Each phase stops and waits, and nothing destructive happens until the last one.

### Prepare: copy the root

The new partition goes into the gap by exact sector numbers, not by "free space" or a percentage. The script checks first that the gap starts where the EFI partition ends and stops right where the old root begins:

parted -s "$DEV" unit s mkpart "$LABEL" ext4 "${FREE_START}s" "${FREE_END}s"
udevadm settle
check_new
mkfs.ext4 -q -L "$LABEL" "$NEW"

That gave nvme0n1p2, labelled hermes-root. Then the first copy, made while the system was running normally:

rsync -aHAXxS --numeric-ids --delete --info=progress2 --exclude=/swap.img / /mnt/newroot/

Each flag earns its place. -H keeps hard links, -A ACLs, and -X extended attributes, which is where file capabilities live. Lose them and some binaries quietly stop working. -x stays on the root filesystem, so /boot/efi, /data, /proc and the copy itself aren't copied. -S keeps sparse files sparse.

The copy came out at 862,631 files against 862,697 on the live root. The difference is the swap file plus files that changed during the copy, which the second pass would catch. On disk it was 75 GB against 93 GB. Eight of those gigabytes are the swap file, which I excluded on purpose. Most of the rest is probably sparse files that rsync didn't inflate.

### Switch: boot the copy exactly once

For the final pass, I stopped the GNOME session and Docker so nothing would change underneath the copy, and ran the same rsync again. Then the copy had to stop pretending to be the old root. Its /etc/fstab still mounted the old UUID as /:

sed -i "s/$OLD_UUID/$U/" "$MNT/etc/fstab"
findmnt --verify --tab-file "$MNT/etc/fstab" || die "new fstab does not verify"
rm -f "$MNT/swap.img"
fallocate -l 8G "$MNT/swap.img"; chmod 600 "$MNT/swap.img"; mkswap -q "$MNT/swap.img"

findmnt --verify catches a broken fstab now, while I can still fix it, rather than at boot, where a bad line drops systemd into emergency mode on a machine nobody is sitting at. The swap file is made fresh instead of copied, because copying a swap file that's in use gives you a file full of whatever was in memory at the time.

Then the safety net. GRUB has a command called grub-reboot, which picks a menu entry for the next boot only. The script added an entry for the new root to the old root's GRUB menu, using the same kernel and the same kernel arguments the running system booted with (read from /proc/cmdline), plus one more:

menuentry 'Ubuntu (new root hermes-root)' --id hermes-newroot {
	search --no-floppy --fs-uuid --set=root 7a150327-2439-4fb0-9ead-1c574b0ebede
	linux /boot/vmlinuz-7.0.0-38-generic root=UUID=7a150327-… ro quiet splash … panic=10
	initrd /boot/initrd.img-7.0.0-38-generic
}

and then:

grub-reboot hermes-newroot
systemctl reboot

The panic=10 matters. If the initramfs can't find the root filesystem, Ubuntu drops to a recovery shell and waits for someone to type, and nobody is sitting at Hermes. With panic= set, it reboots after ten seconds instead, and because the selection was one-time, the next boot lands on the old root, untouched. The worst case was a couple of minutes offline, not a trip to the machine.

Note

Hermes boots with GRUB's menu hidden and a zero-second timeout. Even with a monitor attached there's no moment to pick an entry, so a one-time selection from the running system is the only practical way to test a new root.

It came up on the first try. findmnt / said /dev/nvme0n1p2, and every service came back: Tailscale, the CPU power-limit fix, Docker, the remote desktop, and Beszel monitoring.

### Finalize: make it permanent, with a way back

At this point the new root could boot, but GRUB itself still lived on the old one. On a UEFI Ubuntu install, the EFI partition holds a three-line grub.cfg that only says where the real configuration is:

search.fs_uuid 17cecf48-08aa-4c81-9a50-305d4a7f4927 root
set prefix=($root)'/boot/grub'
configfile $prefix/grub.cfg

The script regenerated GRUB on the new root, then rewrote that file from a list of UUIDs, new root first and old root second:

write_stub() { # uuid... — the EFI stub tries each root's /boot/grub/grub.cfg in turn
  local tmp; tmp=$(mktemp)
  for u in "$@"; do
    printf "search.fs_uuid %s root\nset prefix=(\$root)'/boot/grub'\nconfigfile \$prefix/grub.cfg\n" "$u"
  done > "$tmp"
  cp "$tmp" "$STUB"
}
write_stub "$U" "$OLD_UUID"

GRUB scripts don't stop when a command fails, so if the first configfile can't load, it falls through to the second block and boots the old root. Then one more reboot, this time a normal one. Hermes came back on the new root, and panic=10 was gone from the kernel command line. That's how I knew it had booted through the regular path and not the test entry.

### Cleanup: delete the original

Only now did anything get deleted. The script checks that it's running from the new root, that the old one isn't mounted and that the EFI file already points at the new UUID, then asks for YES one last time:

write_stub "$U"
wipefs -q -a "$OLD"
parted -s "$DEV" rm 5
growpart "$DEV" 2
resize2fs "$NEW"

write_stub "$U" drops the fallback, since there's about to be nothing to fall back to. With the old root gone, the free space sits after the new one, which is the side ext4 can grow into.

It grew online with no reboot. From first reading the partition table to the final resize, including deleting Windows, took a little over an hour.

Before After
/ 114 GB, 86% used 229 GB, 38% used
/data 110 GB 220 GB
Windows 116 GiB NVMe + 112 GB SATA gone
NVMe partitions 5 2

## What got in the way

### Hermes was "down", and it wasn't

The very first thing I did was check whether the SN3000 would fit. I tried to read the NVMe slot's link speed from Hermes, and SSH timed out, first over Tailscale and then over the LAN. A later try got No route to host. Given Hermes's history, I assumed it had frozen again.

When it "came back", uptime said 8 days. It had never gone anywhere: the problem was on the Mac's end of the network. My first plan was to go read the crash logs on Polaris. Now uptime is the first thing I check, before assuming any crash.

### Everything ran from the Mac

I never sat at Hermes for any of this. Every phase that needed root ran as ssh -t hermes sudo bash grow-root.sh <phase>: the -t gives sudo a terminal to ask for my password, and then the script prints exactly what it's about to do and waits for YES. I didn't set up passwordless sudo to make it smoother. Typing the password and then YES meant every destructive step took two deliberate inputs.

Once, I walked away at the password prompt. sudo gives up after five minutes, nothing ran, and I started that phase again.

### The small stuff

  • GNOME had auto-mounted the Windows data partition at /run/media/angad/DATA, so it had to be unmounted before it could be deleted.
  • wipefs wouldn't fully clear that partition, because an NTFS boot sector looks enough like an old-style partition table that it wanted --force. That didn't matter here: the partition was deleted and the space went to /data anyway.
  • My own git guard, which blocks commits on weekdays between 9 and 5, kept me from committing the scripts. They're staged and waiting for the evening.

## Loose ends

The first boot from the new root logged one message I hadn't seen before: thermald saying "No Zones present". It's probably harmless, but it's written down.

The bigger open question is still the drive. Hermes still boots with NVMe power saving and PCIe link power management turned off, because of the freezes, and the Crucial P2 was present for every one of them. It's now the only disk the OS lives on. If the freezes come back, the SN3000 stops being optional, and the same copy-and-switch approach carries straight over to it.

## Appendix: the full scripts

The snippets above are the parts that matter. Here are both scripts whole, exactly as I ran them. They are written for Hermes and nothing else: the constants at the top are its disk serial numbers, PARTUUIDs and sector offsets. On any other machine the safety checks stop them before they write a single byte, so to use them you have to read them, swap in your own values from lsblk and /sys/class/block, and have a backup first.

remove-windows.sh: delete the Windows partitions and grow /data
#!/usr/bin/env bash
#
# remove-windows.sh — remove the Windows half of Hermes's dual boot:
#   NVMe (Crucial P2): delete p2 (Microsoft reserved), p3 (BitLocker C:), p4 (recovery).
#                      The freed ~116 GiB before p5 is left UNALLOCATED; / is not touched.
#   SATA (WD Green):   delete sda2 (NTFS "DATA") and grow sda1 (/data, ext4) into it, online.
#   Boot:              delete the "Windows Boot Manager" firmware entries, EFI/Microsoft, and
#                      the os-prober opt-in that display-hang-fix.sh adds for dual boot.
#
# Kept: nvme0n1p1 (EFI), nvme0n1p5 (Ubuntu /), sda1 (/data).
#
# Safety: every partition is identified by disk serial + PARTUUID + type GUID as read on
# 2026-10-23; anything unexpected (or anything kept not mounted where it should be) aborts
# before a single write. Asks for YES before writing.
#
# USAGE (on Hermes):
#   sudo bash remove-windows.sh
#
# ROLLBACK: none. Windows and its partitions are gone (none of its data was needed).
set -euo pipefail

NVME=/dev/disk/by-id/nvme-CT250P2SSD8_2214E62461FB
SATA=/dev/disk/by-id/ata-WDC_WDS240G2G0A-00JH30_21014E806870
GRUB_DROPIN=/etc/default/grub.d/90-hermes-display-hang.cfg

# partition number : PARTUUID : type GUID
DELETE_NVME=(
  "2:78277a95-8c92-4744-9dd0-420d361ceb64:e3c9e316-0b5c-4db8-817d-f92df00215ae"
  "3:a7e5b93a-9650-4418-b3bf-58ab6213b111:ebd0a0a2-b9e5-4433-87c0-68b6b72699c7"
  "4:19a7abf2-7911-4c9e-8c3e-1e85ae908090:de94bba4-06d1-4d40-a16a-bfd50179d6ac"
)
DELETE_SATA="2:443e7721-99c3-45b5-a593-4c858a373de7:ebd0a0a2-b9e5-4433-87c0-68b6b72699c7"
KEEP_ROOT_PARTUUID=548d5a84-399f-488d-a1f4-525d2eeceb00
KEEP_EFI_PARTUUID=0a70e817-6b31-44a3-9c9d-11ea419dd068
KEEP_DATA_PARTUUID=e3898e7a-0c46-4ae6-8499-b816303d5251

[[ $EUID -eq 0 ]] || { echo "run with sudo" >&2; exit 1; }
[[ -b $NVME && -b $SATA ]] || { echo "NVMe or SATA disk not found — nothing done" >&2; exit 1; }
NDEV=$(readlink -f "$NVME"); SDEV=$(readlink -f "$SATA")

die() { echo "ABORT: $* — nothing done" >&2; exit 1; }
part() { # disk-dev number → partition dev
  [[ $1 == *nvme* ]] && echo "${1}p$2" || echo "${1}$2"
}
check_part() { # disk-dev "n:partuuid:type"
  local n uuid type p
  IFS=: read -r n uuid type <<<"$2"
  p=$(part "$1" "$n")
  [[ -b $p ]] || die "$p missing"
  [[ $(lsblk -dno PARTUUID "$p") == "$uuid" ]] || die "$p PARTUUID is not $uuid"
  [[ $(lsblk -dno PARTTYPE "$p") == "$type" ]] || die "$p type is not $type"
}

# --- safety checks ----------------------------------------------------------
for spec in "${DELETE_NVME[@]}"; do check_part "$NDEV" "$spec"; done
check_part "$SDEV" "$DELETE_SATA"
[[ $(findmnt -no SOURCE /) == "$(part "$NDEV" 5)" ]] || die "/ is not on $(part "$NDEV" 5)"
[[ $(lsblk -dno PARTUUID "$(part "$NDEV" 5)") == "$KEEP_ROOT_PARTUUID" ]] || die "root PARTUUID changed"
[[ $(findmnt -no SOURCE /boot/efi) == "$(part "$NDEV" 1)" ]] || die "/boot/efi is not on $(part "$NDEV" 1)"
[[ $(lsblk -dno PARTUUID "$(part "$NDEV" 1)") == "$KEEP_EFI_PARTUUID" ]] || die "EFI PARTUUID changed"
[[ $(findmnt -no SOURCE /data) == "$(part "$SDEV" 1)" ]] || die "/data is not on $(part "$SDEV" 1)"
[[ $(lsblk -dno PARTUUID "$(part "$SDEV" 1)") == "$KEEP_DATA_PARTUUID" ]] || die "/data PARTUUID changed"
for n in 2 3 4; do findmnt -rn -S "$(part "$NDEV" $n)" >/dev/null && die "$(part "$NDEV" $n) is mounted"; done
grep -qE '(^|[[:space:]])/dev/(nvme0n1p[234]|sda2)|04C8852B4DA7A02F|DATA' /etc/fstab \
  && die "/etc/fstab references a partition being deleted"

echo "Firmware boot entries:"; efibootmgr
echo; echo "EFI partition:"; find /boot/efi/EFI -maxdepth 2 | sort
echo; echo "Will DELETE (permanently):"
lsblk -o NAME,SIZE,FSTYPE,PARTTYPENAME,LABEL,MOUNTPOINTS \
  "$(part "$NDEV" 2)" "$(part "$NDEV" 3)" "$(part "$NDEV" 4)" "$(part "$SDEV" 2)"
echo; echo "Will KEEP: $(part "$NDEV" 1) (/boot/efi), $(part "$NDEV" 5) (/), $(part "$SDEV" 1) (/data, grown to the whole SATA SSD)."
echo "The ~116 GiB freed on the NVMe is left unallocated."
read -r -p "Type YES to continue: " answer
[[ $answer == YES ]] || { echo "Cancelled."; exit 0; }

# --- boot: firmware entries, EFI/Microsoft, GRUB ------------------------------
efibootmgr | sed -nE 's/^Boot([0-9A-F]{4})\*? Windows Boot Manager.*/\1/p' | while read -r num; do
  echo "Deleting firmware entry Boot$num (Windows Boot Manager)"
  efibootmgr -q -b "$num" -B
done
# If the removable-media fallback is Windows' boot manager, point it at Ubuntu's shim
# instead, so the firmware fallback still boots something.
ESP=/boot/efi/EFI
fallback=$(find "$ESP" -maxdepth 2 -ipath "$ESP/boot/bootx64.efi" | head -1)
mgr=$(find "$ESP" -maxdepth 3 -ipath "$ESP/microsoft/boot/bootmgfw.efi" | head -1)
if [[ -n $fallback && -n $mgr ]] && cmp -s "$fallback" "$mgr"; then
  echo "EFI fallback $fallback is Windows' boot manager; replacing it with Ubuntu's shim"
  bootdir=$(dirname "$fallback")
  cp "$ESP/ubuntu/shimx64.efi" "$fallback"
  cp "$ESP/ubuntu/grubx64.efi" "$ESP/ubuntu/mmx64.efi" "$bootdir/"
fi
msdir=$(find "$ESP" -maxdepth 1 -iname microsoft | head -1)
[[ -n $msdir ]] && { echo "Removing $msdir"; rm -rf "$msdir"; }
sed -i '/^GRUB_DISABLE_OS_PROBER=false$/d' "$GRUB_DROPIN"
update-grub 2>&1 | tail -3
grep -q "Windows Boot Manager" /boot/grub/grub.cfg && die "GRUB still has a Windows entry"

# --- NVMe: delete the Windows partitions ----------------------------------------
for n in 2 3 4; do wipefs -q -a "$(part "$NDEV" $n)"; done
parted -s "$NDEV" rm 4 rm 3 rm 2
partx -d --nr 2:4 "$NDEV" 2>/dev/null || true
udevadm settle

# --- SATA: delete DATA, grow /data -----------------------------------------------
P2=$(part "$SDEV" 2)
findmnt -rn -S "$P2" >/dev/null && umount "$P2"
wipefs -q -a "$P2"
parted -s "$SDEV" rm 2
partx -d --nr 2 "$SDEV" 2>/dev/null || true
udevadm settle
growpart "$SDEV" 1
resize2fs "$(part "$SDEV" 1)"

echo; echo "Done:"
lsblk -o NAME,SIZE,FSTYPE,LABEL,MOUNTPOINTS "$NDEV" "$SDEV"
parted -s "$NDEV" unit GiB print free | tail -5
df -h / /data /boot/efi
efibootmgr
grow-root.sh: copy-and-switch the root, then grow it
#!/usr/bin/env bash
#
# grow-root.sh — move Hermes's Ubuntu root to the front of the NVMe and grow it to the whole
# disk, by copy-and-switch rather than an in-place move (the old root stays a complete fallback
# until the last phase):
#
#   prepare   create nvme0n1p2 "hermes-root" (ext4) in the free space between the EFI partition
#             and the old root (nvme0n1p5, left behind by remove-windows.sh), first rsync of /.
#   switch    stop the desktop session and Docker, final rsync, point the copy's fstab at itself,
#             add a GRUB entry for the new root to the OLD root's menu and select it ONCE
#             (grub-reboot), then reboot. If the new root fails to boot, the next boot falls back
#             to the old root (panic=10 makes initramfs failures reboot instead of waiting).
#   finalize  (running from the new root) regenerate its grub.cfg and point the EFI stub at it,
#             keeping the old root as a fallback in the stub, then reboot to prove that path.
#   cleanup   (running from the new root, booted through the stub) delete the old root, grow the
#             new root online to the end of the disk, and drop the fallback from the stub.
#
# Safety: partitions are matched by disk serial + PARTUUID / sector offsets read on 2026-10-23;
# every phase checks it is running from the root it expects and asks for YES before writing.
#
# USAGE (on Hermes):  sudo bash grow-root.sh prepare|switch|finalize|cleanup
set -euo pipefail

NVME=/dev/disk/by-id/nvme-CT250P2SSD8_2214E62461FB
OLD_UUID=17cecf48-08aa-4c81-9a50-305d4a7f4927
OLD_PARTUUID=548d5a84-399f-488d-a1f4-525d2eeceb00
EFI_PARTUUID=0a70e817-6b31-44a3-9c9d-11ea419dd068
FREE_START=411648      # first sector after nvme0n1p1
FREE_END=244639743     # last sector before nvme0n1p5
LABEL=hermes-root
MNT=/mnt/newroot
STUB=/boot/efi/EFI/ubuntu/grub.cfg
GRUB_ENTRY=/etc/grub.d/39_hermes_newroot

die() { echo "ABORT: $* — nothing more done" >&2; exit 1; }
confirm() { read -r -p "$1 Type YES to continue: " a; [[ $a == YES ]] || { echo "Cancelled."; exit 0; }; }

[[ $EUID -eq 0 ]] || die "run with sudo"
[[ -b $NVME ]] || die "NVMe not found"
DEV=$(readlink -f "$NVME")
OLD=${DEV}p5
NEW=${DEV}p2
[[ $(lsblk -dno PARTUUID "${DEV}p1") == "$EFI_PARTUUID" ]] || die "EFI partition is not ${DEV}p1"
ROOT_SRC=$(findmnt -no SOURCE /)

new_uuid() { blkid -s UUID -o value "$NEW"; }
check_new() {
  [[ -b $NEW && $(lsblk -dno PARTLABEL "$NEW") == "$LABEL" ]] || die "$NEW is not the $LABEL partition"
  [[ $(cat "/sys/class/block/$(basename "$NEW")/start") == "$FREE_START" ]] || die "$NEW does not start at sector $FREE_START"
}
check_old() {
  [[ -b $OLD && $(lsblk -dno PARTUUID "$OLD") == "$OLD_PARTUUID" ]] || die "$OLD is not the old root"
}
copy_root() {
  mkdir -p "$MNT"
  findmnt -rn "$MNT" >/dev/null || mount "$NEW" "$MNT"
  [[ $(findmnt -no SOURCE "$MNT") == "$NEW" ]] || die "$MNT is not $NEW"
  # -x stays on / (skips /boot/efi, /data, /tmp, /proc, ... and $MNT itself); the swap file is
  # recreated rather than copied.
  rsync -aHAXxS --numeric-ids --delete --info=progress2 --exclude=/swap.img / "$MNT"/
}
write_stub() { # uuid... — the EFI stub tries each root's /boot/grub/grub.cfg in turn
  local tmp; tmp=$(mktemp)
  for u in "$@"; do
    printf "search.fs_uuid %s root\nset prefix=(\$root)'/boot/grub'\nconfigfile \$prefix/grub.cfg\n" "$u"
  done > "$tmp"
  cp "$tmp" "$STUB"; rm -f "$tmp"
  echo "EFI stub $STUB:"; cat "$STUB"
}

case ${1:-} in
prepare)
  [[ $ROOT_SRC == "$OLD" ]] || die "/ is not on $OLD"
  check_old
  [[ -b ${DEV}p2 ]] && die "${DEV}p2 already exists"
  p1_end=$(( $(cat "/sys/class/block/$(basename "$DEV")p1/start") + $(cat "/sys/class/block/$(basename "$DEV")p1/size") ))
  [[ $p1_end == "$FREE_START" && $(cat "/sys/class/block/$(basename "$OLD")/start") == $(( FREE_END + 1 )) ]] \
    || die "free space is not where expected"
  grep -q 'set default="${next_entry}"' /boot/grub/grub.cfg || die "grub.cfg has no next_entry (grub-reboot) support"
  echo "Will create ${DEV}p2 ($(( (FREE_END - FREE_START + 1) / 2097152 )) GiB, ext4 '$LABEL') in the free space and copy / into it."
  echo "/ ($OLD) is only read."
  confirm ""
  parted -s "$DEV" unit s mkpart "$LABEL" ext4 "${FREE_START}s" "${FREE_END}s"
  udevadm settle
  check_new
  wipefs -q -a "$NEW"
  mkfs.ext4 -q -L "$LABEL" "$NEW"
  copy_root
  df -h / "$MNT"
  echo "Prepared. New root UUID: $(new_uuid)"
  ;;

switch)
  [[ $ROOT_SRC == "$OLD" ]] || die "/ is not on $OLD"
  check_old; check_new
  U=$(new_uuid); [[ -n $U ]] || die "no filesystem on $NEW"
  KVER=$(uname -r)
  ARGS=$(tr ' ' '\n' </proc/cmdline | grep -v -E '^(BOOT_IMAGE|root|panic)=' | grep . | tr '\n' ' ')
  echo "Will stop gnome-headless-session@angad and Docker, re-sync / to $NEW, and reboot ONCE"
  echo "into it (kernel $KVER, root=UUID=$U). If that boot fails, Hermes falls back to the old root."
  confirm ""
  systemctl stop gnome-headless-session@angad docker.socket docker containerd || true
  sync
  copy_root
  # The copy's own fstab, swap file, and no GRUB entry of its own
  sed -i "s/$OLD_UUID/$U/" "$MNT/etc/fstab"
  grep -q "$U" "$MNT/etc/fstab" || die "new fstab does not mention $U"
  findmnt --verify --tab-file "$MNT/etc/fstab" || die "new fstab does not verify"
  rm -f "$MNT/swap.img"
  fallocate -l 8G "$MNT/swap.img"; chmod 600 "$MNT/swap.img"; mkswap -q "$MNT/swap.img"
  rm -f "$MNT$GRUB_ENTRY"
  # One-shot entry in the old root's menu
  cat >"$GRUB_ENTRY" <<EOF
#!/bin/sh
# Temporary (grow-root.sh): boot the copied root on $NEW once, via grub-reboot.
cat <<'ENTRY'
menuentry 'Ubuntu (new root $LABEL)' --id hermes-newroot {
	insmod gzio
	insmod part_gpt
	insmod ext2
	search --no-floppy --fs-uuid --set=root $U
	linux /boot/vmlinuz-$KVER root=UUID=$U ${ARGS}panic=10
	initrd /boot/initrd.img-$KVER
}
ENTRY
EOF
  chmod 755 "$GRUB_ENTRY"
  update-grub 2>&1 | tail -2
  grep -q "hermes-newroot" /boot/grub/grub.cfg || die "GRUB entry not generated"
  grep -q 'set default="${next_entry}"' /boot/grub/grub.cfg || die "grub.cfg has no next_entry support"
  grub-reboot hermes-newroot
  grub-editenv list
  sed -n "/hermes-newroot/,/^}/p" /boot/grub/grub.cfg
  umount "$MNT"
  echo "Rebooting into the new root once..."
  systemctl reboot
  ;;

finalize)
  check_old; check_new
  U=$(new_uuid)
  [[ $ROOT_SRC == "$NEW" ]] || die "/ is not on $NEW (still on the old root? then the one-shot boot failed)"
  findmnt -rn -S "$OLD" >/dev/null && die "$OLD is mounted"
  echo "Will regenerate this root's grub.cfg and point the EFI stub at it (old root kept as the"
  echo "stub's fallback), then reboot to prove the permanent boot path."
  confirm ""
  update-grub 2>&1 | tail -2
  grep -q "root=UUID=$U" /boot/grub/grub.cfg || die "grub.cfg does not boot $U"
  cp "$STUB" "$STUB.bak-grow-root"
  write_stub "$U" "$OLD_UUID"
  echo "Rebooting through the EFI stub..."
  systemctl reboot
  ;;

cleanup)
  check_old; check_new
  U=$(new_uuid)
  [[ $ROOT_SRC == "$NEW" ]] || die "/ is not on $NEW"
  findmnt -rn -S "$OLD" >/dev/null && die "$OLD is mounted"
  grep -q "$U" "$STUB" || die "EFI stub does not boot $U; run finalize first"
  echo "Will PERMANENTLY DELETE the old root:"
  lsblk -o NAME,SIZE,FSTYPE,UUID,PARTUUID "$OLD"
  echo "and grow / ($NEW) to the end of the disk."
  confirm ""
  write_stub "$U"
  rm -f "$STUB.bak-grow-root"
  wipefs -q -a "$OLD"
  parted -s "$DEV" rm 5
  partx -d --nr 5 "$DEV" 2>/dev/null || true
  udevadm settle
  growpart "$DEV" 2
  resize2fs "$NEW"
  echo; lsblk -o NAME,SIZE,FSTYPE,LABEL,MOUNTPOINTS "$DEV"
  df -h /
  ;;

*)
  echo "usage: sudo bash $0 prepare|switch|finalize|cleanup" >&2; exit 2 ;;
esac
support

Found this useful? You can buy me a coffee.

☕ buy me a coffee