c4b2278e7d
Everything but the reboot. Two rerunnable elway playbooks; the host is
still running from the ext4 root and its boot path is byte-identical to
the last 140 days, because grub-install is deliberately held back to the
cutover window.
Phase 1 (esh-pve-nas-stage-zfs-root.yaml): carve a 512 MB /boot LV out
of the 768 MB swap LV, populate it, rsync the 4.3 GB ext4 root into
nvme/ROOT/pve-1, write the copy's fstab.
Phase 2 (esh-pve-nas-stage-bootloader.yaml): ZFS initramfs, grub.cfg,
explicit pve-zfs-root and pve-ext4-rollback entries with stable ids,
grubenv pinned to the rollback so cutover's grub-reboot is a one-shot.
Three landmines the plan did not predict, all caught by verify steps
asserting effective state rather than by reading the plan:
- The /boot LV had nowhere to live. VG pve had 4 MB free and mounted
ext4 cannot shrink; freeing space from root needs a rescue boot, which
costs the one-reboot property. Space came from swap (768M -> 256M).
- The runbook's `zpool set cachefile=... nvme` would have broken the
NAS. Populating a cachefile flips the host from import-by-scan to
import-by-cache, so a one-pool cache leaves ssd and tank unimported --
and CT 103 esh-nas has twelve bind mounts spanning all three pools.
Set on all three instead, verified in the resulting cache.
- update-grub silently emitted a pool-less root=ZFS=/ROOT/pve-1, which
boots to an initramfs prompt. Debian's 10_linux builds ${rpool}${bootfs}
and rpool comes from grub-probe --target=fs_label, which returns empty
because GRUB's ZFS reader cannot open a pool with encryption,
large_dnode and zstd_compress -- the same feature set that forced /boot
to stay ext4. The probe failure is swallowed by `2>/dev/null || true`.
Fixed with a /etc/default/grub.d drop-in plus explicit menu entries.
The transferable lesson: the original verify grepped for the correct
root= string appearing somewhere in grub.cfg, which passes while every
menu entry is still broken. Assert the effective value, not the presence
of a substring.
179 lines
7.2 KiB
YAML
179 lines
7.2 KiB
YAML
# esh-pve-nas — STAGE the PVE root migration off the USB DOM onto ZFS.
|
|
#
|
|
# Runbook: docs/runbooks/esh-pve-nas-boot-migration.md
|
|
# Design: boot chain stays ext4 on the DOM; root moves to nvme/ROOT/pve-1.
|
|
#
|
|
# THIS PLAYBOOK DOES NOT CUT OVER. It leaves the host still running from the
|
|
# ext4 root on the DOM. Nothing here changes what the next reboot does — the
|
|
# bootloader phase is deliberately a separate playbook.
|
|
#
|
|
# What it does, all live, no downtime:
|
|
# 1. Reclaims 512 MB from the 768 MB swap LV for a dedicated /boot LV
|
|
# (operator's call 2026-08-17: shrink swap to 256 MB rather than drop it).
|
|
# 2. Populates that LV from the current /boot.
|
|
# 3. rsyncs the live ext4 root into the ZFS dataset nvme/ROOT/pve-1.
|
|
# 4. Writes the ZFS copy's /etc/fstab for the post-cutover layout.
|
|
#
|
|
# The ext4 root LV is never modified — it stays byte-intact as the rollback,
|
|
# including its own /boot contents, which the new mount only shadows.
|
|
#
|
|
# Preconditions (verified 2026-08-17, re-asserted as guard steps below):
|
|
# - nvme/ROOT/pve-1 exists, canmount=noauto, encryption off
|
|
# - /etc/zfs/zpool.cache populated with ALL THREE pools (nvme, ssd, tank).
|
|
# ⚠ A cache holding only `nvme` flips the host from import-by-scan to
|
|
# import-by-cache and leaves ssd+tank unimported at boot — which breaks
|
|
# CT 103 `esh-nas`, whose 12 bind mounts span all three pools.
|
|
#
|
|
# Rerunnable: every step is guarded, so a second run reports ok/skipped.
|
|
|
|
vars:
|
|
newroot: /mnt/newroot
|
|
bootstage: /mnt/boot-new
|
|
boot_lv_size: 512M
|
|
swap_lv_size: 256M
|
|
root_dataset: nvme/ROOT/pve-1
|
|
|
|
steps:
|
|
# ---------- guards: refuse to run against an already-migrated or unprepared host ----------
|
|
|
|
- name: GUARD — host must still be running from the ext4 root on the DOM
|
|
shell: |
|
|
test "$(findmnt -no FSTYPE /)" = "ext4" || {
|
|
echo "root is not ext4 — host already cut over; refusing"; exit 1; }
|
|
changed_when: "false"
|
|
|
|
- name: GUARD — ZFS root dataset must exist with canmount=noauto
|
|
shell: |
|
|
test "$(zfs get -H -o value canmount {{ root_dataset }})" = "noauto" || {
|
|
echo "{{ root_dataset }} missing or canmount!=noauto; refusing"; exit 1; }
|
|
changed_when: "false"
|
|
|
|
- name: GUARD — zpool.cache must list all three pools
|
|
shell: |
|
|
for p in nvme ssd tank; do
|
|
zdb -C -U /etc/zfs/zpool.cache 2>/dev/null | grep -q "name: '$p'" || {
|
|
echo "pool $p missing from zpool.cache — would not import at boot"; exit 1; }
|
|
done
|
|
changed_when: "false"
|
|
|
|
# ---------- phase 1: carve a /boot LV out of swap ----------
|
|
|
|
# Gated on the ORIGINAL 768M size, not on "is swap on" — otherwise a rerun
|
|
# swaps off the new 256M device and never turns it back on.
|
|
- name: Disable swap so its LV can be resized
|
|
shell: swapoff /dev/pve/swap
|
|
when: "lvs --noheadings -o lv_size --units m pve/swap 2>/dev/null | grep -q '768'"
|
|
|
|
- name: Remove the oversized swap LV
|
|
shell: lvremove -y pve/swap
|
|
when: "lvs --noheadings -o lv_size --units m pve/swap 2>/dev/null | grep -q '768'"
|
|
|
|
- name: Create the dedicated /boot LV
|
|
shell: lvcreate -y -L {{ boot_lv_size }} -n boot pve
|
|
when: "! lvs pve/boot >/dev/null 2>&1"
|
|
|
|
- name: Recreate swap at the reduced size
|
|
shell: lvcreate -y -L {{ swap_lv_size }} -n swap pve
|
|
when: "! lvs pve/swap >/dev/null 2>&1"
|
|
|
|
- name: Make the /boot filesystem
|
|
shell: mkfs.ext4 -q -L pveboot /dev/pve/boot
|
|
when: "! blkid -s TYPE -o value /dev/pve/boot 2>/dev/null | grep -q ext4"
|
|
|
|
- name: Make and enable the new swap
|
|
shell: |
|
|
blkid -s TYPE -o value /dev/pve/swap 2>/dev/null | grep -q swap || mkswap -L pveswap /dev/pve/swap
|
|
swapon /dev/pve/swap
|
|
when: "! swapon --show=NAME --noheadings | grep -q dm-"
|
|
|
|
# ---------- phase 2: populate the /boot LV ----------
|
|
|
|
- name: Stage-mount the new /boot LV
|
|
shell: mkdir -p {{ bootstage }} && mount /dev/pve/boot {{ bootstage }}
|
|
when: "! mountpoint -q {{ bootstage }}"
|
|
|
|
- name: Copy the current /boot into it (ESP contents excluded — separate vfat mount)
|
|
shell: |
|
|
rsync -aHAX --numeric-ids --one-file-system --delete \
|
|
--exclude='/lost+found' \
|
|
/boot/ {{ bootstage }}/
|
|
mkdir -p {{ bootstage }}/efi
|
|
changed_when: "true"
|
|
|
|
- name: Verify the kernel and initrd landed
|
|
shell: |
|
|
ls {{ bootstage }}/vmlinuz-* {{ bootstage }}/initrd.img-* >/dev/null
|
|
test -f {{ bootstage }}/grub/grub.cfg
|
|
changed_when: "false"
|
|
|
|
# ---------- phase 3: rsync the live root into the ZFS dataset ----------
|
|
|
|
- name: Point the ZFS root dataset at a staging mountpoint
|
|
shell: zfs set mountpoint={{ newroot }} {{ root_dataset }}
|
|
when: "test \"$(zfs get -H -o value mountpoint {{ root_dataset }})\" != '{{ newroot }}'"
|
|
|
|
- name: Mount the ZFS root dataset for staging
|
|
shell: zfs mount {{ root_dataset }}
|
|
when: "! mountpoint -q {{ newroot }}"
|
|
|
|
- name: rsync the ext4 root into ZFS (one-file-system — every other mount is excluded)
|
|
shell: |
|
|
rsync -aHAX --numeric-ids --one-file-system --delete \
|
|
--exclude='/proc/*' --exclude='/sys/*' --exclude='/dev/*' \
|
|
--exclude='/run/*' --exclude='/tmp/*' --exclude='/mnt/*' \
|
|
--exclude='/media/*' \
|
|
/ {{ newroot }}/
|
|
# mountpoints that --one-file-system skipped still need to exist
|
|
mkdir -p {{ newroot }}/proc {{ newroot }}/sys {{ newroot }}/dev \
|
|
{{ newroot }}/run {{ newroot }}/tmp {{ newroot }}/mnt \
|
|
{{ newroot }}/boot {{ newroot }}/boot/efi \
|
|
{{ newroot }}/nvme {{ newroot }}/ssd {{ newroot }}/tank \
|
|
{{ newroot }}/var/log/journal
|
|
chmod 1777 {{ newroot }}/tmp
|
|
changed_when: "true"
|
|
|
|
# ---------- phase 4: fstab for the post-cutover layout ----------
|
|
|
|
- name: Write the ZFS copy's /etc/fstab
|
|
shell: |
|
|
cat > {{ newroot }}/etc/fstab <<'FSTAB'
|
|
# <file system> <mount point> <type> <options> <dump> <pass>
|
|
# root is {{ root_dataset }} (ZFS) — mounted by the initramfs, no entry here.
|
|
/dev/pve/boot /boot ext4 defaults 0 2
|
|
UUID=1D32-43A5 /boot/efi vfat defaults 0 2
|
|
/dev/pve/swap none swap sw 0 0
|
|
proc /proc proc defaults 0 0
|
|
FSTAB
|
|
changed_when: "true"
|
|
|
|
verify:
|
|
- name: LVM layout is root + boot + swap
|
|
shell: lvs --noheadings -o lv_name pve | tr -d ' ' | sort | tr '\n' ',' | grep -qx 'boot,root,swap,'
|
|
changed_when: "false"
|
|
|
|
- name: Live root is still the untouched ext4 LV
|
|
shell: test "$(findmnt -no SOURCE /)" = "/dev/mapper/pve-root"
|
|
changed_when: "false"
|
|
|
|
- name: Swap is active at the reduced size
|
|
shell: swapon --show=NAME --noheadings | grep -q dm-
|
|
changed_when: "false"
|
|
|
|
- name: New /boot LV carries a bootable kernel set
|
|
shell: ls {{ bootstage }}/vmlinuz-* {{ bootstage }}/initrd.img-* >/dev/null
|
|
changed_when: "false"
|
|
|
|
- name: ZFS root copy has a populated /usr and /etc
|
|
shell: test -x {{ newroot }}/usr/bin/pveversion && test -f {{ newroot }}/etc/fstab
|
|
changed_when: "false"
|
|
|
|
- name: ZFS root copy's fstab has no root line and does have the boot line
|
|
shell: |
|
|
! grep -qE '^\S+\s+/\s+' {{ newroot }}/etc/fstab
|
|
grep -q '/dev/pve/boot /boot ext4' {{ newroot }}/etc/fstab
|
|
changed_when: "false"
|
|
|
|
- name: PVE cluster config copied (guest configs present)
|
|
shell: test -d {{ newroot }}/var/lib/pve-cluster
|
|
changed_when: "false"
|