277 lines
12 KiB
Bash
Executable File
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
|