Files
owui-tools/vm/host/owuivm-maint-prepare
2026-09-15 15:24:03 -04:00

277 lines
12 KiB
Bash
Executable File

#!/bin/bash
# =====================================================================
# Everything a maintenance boot needs, done from inside the unit.
#
# ExecStartPre= owuivm-maint-prepare <name> open
# ExecStopPost= owuivm-maint-prepare <name> close
# the toggle: owuivm-maint-prepare <name> 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