#!/bin/bash # ===================================================================== # Everything a maintenance boot needs, done from inside the unit. # # ExecStartPre= owuivm-maint-prepare open # ExecStopPost= owuivm-maint-prepare close # the toggle: owuivm-maint-prepare thaw | freeze # # STARTING A MACHINE AND UNFREEZING ITS DISK ARE TWO DIFFERENT DECISIONS # # They used to be one: ExecStartPre unfroze, ExecStopPost re-froze, and # the only way to look at a base image was to make it writable first. # That is backwards for the thing the operator most wants to do - boot # the image exactly as the agent will get it, to see that it comes up, # WITHOUT the possibility of changing it by being there. # # So the write bit is now set and cleared by `thaw` and `freeze`, which # are the operator's own verbs, and `open` boots whatever mode the disk # is already in: # # frozen (0444) -> a throwaway overlay, created by owuivm-launch, # discarded by `close`. Identical to what a session # boots. Nothing the operator does can reach the base. # unfrozen (0664) -> the base disk itself, and changes are kept. # # The safety net that ExecStopPost used to provide is not gone, it has # moved to where it cannot be forgotten: vmd refuses to start a session # at all while a base image is writable, so an image left unfrozen stops # the agent rather than quietly exposing it. # # WHY THE UNIT DOES THIS AND NOT THE DESKTOP TOOL # # The first version had the GUI create the disk and write the ISO # marker, which meant the operator's own account needed write access to # /var/lib/owuivm/base - so it added them to a group. That works and it # is also a trap: a unix group takes effect at the NEXT login, so the # tool fails in a confusing way for exactly as long as the session that # installed it lasts. Doing it here needs no group at all. The desktop # tool starts and stops a unit and touches nothing. # # `close` runs even when qemu is SIGKILLed, which is the point: the # overlay of a frozen boot is removed however that boot ended. # # The checksum is re-recorded on freeze rather than checked. The operator # is MEANT to have changed the disk - that is what maintenance is - so # the manifest records what it became, and the previous value is kept # beside it so a change can be seen to have happened. # ===================================================================== set -euo pipefail # Overridable so tests/test_vm_freeze.sh can exercise the freeze logic # against a scratch directory. Defaults are the real thing; nothing but # the test ever sets them. BASE=${OWUIVM_BASE_DIR:-/var/lib/owuivm/base} ISOS=${OWUIVM_ISO_DIR:-/var/lib/owuivm/iso} # OVERRIDABLE FOR THE SAME REASON THE OTHERS ARE, AND IT COST AN INSTALL. # # A maintenance boot's session id is a constant, so this path is a # constant too - and a test running as root that used the real one left # a root:root directory behind. The next `open` then failed on # `cp: cannot remove .../OVMF_VARS.fd: Permission denied`, which reads # like a bug in the tool and is really a test's litter. RUN=${OWUIVM_RUN_DIR:-/run/owuivm/00000000000000000000000000000000} OVMF_VARS=/usr/share/OVMF/OVMF_VARS_4M.fd # Windows 11 plus whatever a session installs on top of it. qcow2 is # sparse, so this is a ceiling rather than an allocation - the 24.04 # Ubuntu image settled at 7.5 GB of a 60 GB disk. DISK_SIZE=60G name="${1:-}"; action="${2:-}" [[ "$name" =~ ^(ubuntu|windows)$ ]] || { echo "unknown image '$name'" >&2; exit 64; } disk="$BASE/$name.qcow2" vars="$BASE/$name-OVMF_VARS.fd" marker="$BASE/$name.install" # The throwaway disk of a frozen maintenance boot. Same dataset as a # session's overlay, and for the same reason: a 40 GB ZFS quota, so a # runaway guest hits ENOSPC inside its own quota instead of filling the # root filesystem and taking Docker, Ollama and Open WebUI with it. overlay=${OWUIVM_OVERLAY_DIR:-@POOL_ROOT@/vmrun}/maint-$name.qcow2 # The owner write bit, not `-w`: this script runs as owuivmadm in the # unit and as root in the tests, and root can write a read-only file - # so `-w` would give the two a different idea of the same disk. frozen() { [ -e "$disk" ] || return 1 [ $(( 8#$(stat -c %a "$disk" 2>/dev/null || echo 600) & 8#200 )) -eq 0 ] } # chmod under a running qemu is theatre: it already holds the file open, # so the mode says one thing and the machine does another. Both toggle # verbs refuse rather than lie about it. running() { systemctl is-active --quiet "owuivm-maint@$name.service" 2>/dev/null } find_iso() { # One ISO per operating system is the normal case: fetch.sh puts it # there and there is nothing else to confuse it with. If somebody has # kept two, the newest wins and the name is printed, so what booted is # never a mystery. local pattern case "$name" in ubuntu) pattern='ubuntu*.iso' ;; # NOT '*.iso'. That matched the newest ISO in the directory, which - # with an Ubuntu image sitting next to it - is how a Windows # maintenance boot would have quietly booted the Ubuntu installer. # fetch.sh refuses Windows media that does not match this shape, so # the two ends agree. windows) pattern='[Ww]in*.iso' ;; esac # shellcheck disable=SC2086 ls -t "$ISOS"/$pattern 2>/dev/null | head -1 } case "$action" in open) # One maintenance machine at a time. `Conflicts=owuivm.service` on # the unit stops a session and a maintenance boot overlapping, but a # template unit cannot conflict with its own other instances - so # ubuntu and windows could both come up, fight over the same console # socket, and share out the host's memory between them. for other in ubuntu windows; do [ "$other" = "$name" ] && continue if systemctl is-active --quiet "owuivm-maint@$other.service"; then echo "the $other image is already open for maintenance; close that first" >&2 exit 69 fi done mkdir -p "$RUN" # Say whose it is rather than letting cp report it one line later. if [ ! -w "$RUN" ]; then echo "cannot write $RUN (owned by $(stat -c %U "$RUN" 2>/dev/null)) - stale state from something else" >&2 exit 73 fi if [ ! -f "$disk" ]; then iso=$(find_iso) [ -n "$iso" ] || { echo "no installation media in $ISOS - run vm/images/fetch.sh first" >&2 exit 66 } echo "no $name image yet; creating a blank $DISK_SIZE disk and booting $(basename "$iso")" qemu-img create -f qcow2 "$disk" "$DISK_SIZE" >/dev/null # Windows needs Microsoft's keys already enrolled in the variable # store, or Secure Boot is on with nothing it trusts and its # installer refuses the machine. Ubuntu takes the plain store. case "$name" in windows) cp -f /usr/share/OVMF/OVMF_VARS_4M.ms.fd "$vars" ;; *) cp -f "$OVMF_VARS" "$vars" ;; esac printf '%s\n' "$iso" > "$marker" fi # A disk that has just been created is writable, which is what an # install needs - there is nothing to protect yet. Every other boot # takes the disk as it finds it. cp -f "$vars" "$RUN/OVMF_VARS.fd" chmod 0660 "$RUN/OVMF_VARS.fd" # Never inherit the last boot's throwaway disk. It would still have a # valid backing file, so nothing would fail - the operator would # simply be shown a machine carrying changes they thought they had # discarded. rm -f "$overlay" if frozen; then echo "frozen boot: $disk stays read-only; changes go to $overlay and are discarded" else echo "unfrozen boot: $disk is writable and changes are kept" fi ;; close) # NOTHING HERE MAY BE ABLE TO ABORT WHAT COMES AFTER IT. # # That is not a general principle, it is this file's scar. The marker # logic here once used `du -B1 --apparent-size=0`, and --apparent-size # takes no argument - so du exited non-zero, `set -euo pipefail` # killed the script at that line, and the chmod that used to live # below it never ran. Every clean shutdown left the base image # WRITABLE, silently, with only "Control process exited, # status=1/FAILURE" in the log. set +e # The throwaway disk goes whatever happened, including a SIGKILL - # this line is why `close` is ExecStopPost and not ExecStop. rm -f "$overlay" # A FROZEN BOOT LEAVES NOTHING BEHIND, AND THAT INCLUDES FIRMWARE. # # OVMF rewrites its boot order on every boot, so a look-at-it boot # would otherwise still change the image it was supposed not to # touch. `frozen` is the whole test: if the operator never took the # write bit off, nothing they did is kept. if frozen; then rm -f "$RUN/OVMF_VARS.fd" printf 'frozen boot closed: %s unchanged (mode %s)\n' \ "$disk" "$(stat -c %a "$disk" 2>/dev/null)" exit 0 fi # Copy the edited variables back, or a UEFI boot entry the installer # added is silently lost and the disk will not boot next time. Only # when the disk has something on it: carrying back the variables of a # boot that installed nothing is how a base image accumulates entries # pointing at device paths that no longer exist. allocated=$(du -B1 "$disk" 2>/dev/null | cut -f1) [ -n "$allocated" ] || allocated=0 if [ -f "$RUN/OVMF_VARS.fd" ] && [ "$allocated" -gt 536870912 ]; then chmod u+w "$vars" 2>/dev/null cp -f "$RUN/OVMF_VARS.fd" "$vars" fi rm -f "$RUN/OVMF_VARS.fd" # THE MEDIA STAYS ATTACHED, AND NOTHING HERE GUESSES. # # This used to remove the install marker when the disk had grown past # half a gigabyte, on the reasoning that a disk with contents has an # operating system on it. A partial install had written 3.45 GB, was # declared finished, lost its media, and came back in an EFI shell # with no way to resume - the disk was neither blank enough to start # over nor complete enough to boot. # # Size cannot distinguish "installed" from "interrupted halfway", and # neither can anything else available from out here. So it no longer # tries: the marker persists, and the boot order in owuivm-launch # makes that harmless - a finished install boots itself and ignores # the media, an unfinished one falls through to it. Detaching the # media is an explicit act by the operator, in the maintenance tool. # DELIBERATELY NO chmod. The image is left exactly as the operator # set it: they unfroze it on purpose and may well want to start it # again. What stops a forgotten thaw from reaching the agent is vmd # refusing to open a session on a writable base - a refusal with a # message, rather than a chmod nobody saw. printf 'unfrozen boot closed: %s is still WRITABLE (mode %s) - freeze it when done\n' \ "$disk" "$(stat -c %a "$disk" 2>/dev/null)" exit 0 ;; freeze|thaw) # The operator's own verbs. Each is one chmod, which is why the state # is legible in `ls -l` and why nothing has to be trusted about it. if running; then echo "$name is running; shut it down before changing the disk's mode" >&2 exit 69 fi [ -e "$disk" ] || { echo "there is no $name image yet" >&2; exit 66; } set +e if [ "$action" = thaw ]; then if systemctl is-active --quiet owuivm.service 2>/dev/null; then echo "an agent session is running; it must finish before an image can be unfrozen" >&2 exit 69 fi chmod 0660 "$disk" 2>/dev/null chmod 0660 "$vars" 2>/dev/null printf 'thawed: %s (mode now %s) - changes made from now on are KEPT\n' \ "$disk" "$(stat -c %a "$disk" 2>/dev/null)" exit 0 fi chmod 0444 "$disk" "$vars" 2>/dev/null # Re-recorded rather than checked: the operator is MEANT to have # changed the disk, so the manifest records what it became and the # previous value is kept beside it, where a change can be seen to # have happened. if [ -f "$BASE/$name.sha256" ]; then mv -f "$BASE/$name.sha256" "$BASE/$name.sha256.previous" fi sha256sum "$disk" "$vars" > "$BASE/$name.sha256" 2>/dev/null # World-readable on purpose: vmd compares a saved machine against # this before resuming it, and vmd is not in the image owner's group. # It says what a public file hashes to; there is nothing in it to keep. chmod 0644 "$BASE/$name.sha256" 2>/dev/null # Say what it actually is, not what was intended: a freeze that did # not take should be visible in the log rather than asserted there. printf 'frozen: %s (mode now %s)\n' "$disk" "$(stat -c %a "$disk" 2>/dev/null)" exit 0 ;; *) echo "usage: owuivm-maint-prepare {ubuntu|windows} {open|close|thaw|freeze}" >&2 exit 64 ;; esac