refactor(install): always use root loop-file storage

Remove secondary-disk discovery and whole-disk ZFS creation from the
installer. New installations now always create a sparse ZFS loop-file
pool from free space on /, while existing vpsmgr pools are still adopted.
The installer no longer scans, selects, formats, or modifies devices such
as /dev/vdb, including virtual ISO/CD devices.

Update storage documentation and keep the existing reclaim, pool sizing,
and ZFS quota behavior.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
bluebluesodaandCommandCodeBot committed 2026-08-15 15:24:18 +00:00
1 parent 3d524eef05
commit 64349de0c0
3 files changed
+68 -98

No files matched your search

+5 -5
View File
@@ -154,11 +154,11 @@ The panel daemon runs as the dedicated unprivileged `vps` system user
## Storage
- The pool is ZFS. On first install, `10-incus.sh` either adopts an existing
pool, uses a spare whole-disk block device, or (no spare disk) creates a
**sparse loop-file pool** sized to a share of the free space on `/`:
80% by default, 90% when ≥ 20 GiB free. The loop file only allocates blocks
as the pool actually fills. On very small hosts, cap the ZFS ARC
- The pool is ZFS. On first install, `10-incus.sh` adopts an existing pool or
creates a **sparse loop-file pool** sized to a share of the free space on
`/`: 80% by default, 90% when ≥ 20 GiB free. New installations never scan,
select, format, or modify secondary disks. The loop file only allocates
blocks as the pool actually fills. On very small hosts, cap the ZFS ARC
(`zfs.arc_max`) so container memory keeps priority over the pool's cache.
- Containers are ZFS clones of the image: the image's blocks are shared
(copy-on-write), so a well-provisioned image costs one copy no matter how
+1 -1
View File
@@ -34,7 +34,7 @@ same table; `vps config list` shows the live values with this annotation.
| `net.ext_if` | operator | re-run `vps install` | external NIC (auto-detected from default route) |
| `net.ipv6_subnet` | operator | re-run `vps install` | global IPv6 prefix for pass-through, e.g. `2602:fada:6::/64`; empty = disabled (does not remove IPv6 state already applied, see note below) |
| `net.ipv6_mode` | **fixed at install** | — | IPv6 allocation mode: `none` / `prefix` (/112 blocks) / `pool` (per-container address) |
| `net.ipv6_pool` | operator | next `vps install` | pool-mode address list (bare global addresses or `/128`); editable via the admin panel's IPv6 Pool page |
| `net.ipv6_pool` | operator | applied immediately | pool-mode address list (bare global addresses or `/128`); editable via the admin panel's IPv6 Pool page |
| `incus.image` | operator | next `vps add` / reinstall | container image alias |
| `incus.image_fallback` | operator | next `vps add` / reinstall | fallback remote image |
| `incus.pool` | **fixed at install** | — | storage pool |
+62 -92
View File
@@ -59,11 +59,11 @@ done
[[ -S /var/lib/incus/unix.socket ]] || die "incus daemon socket not ready"
# --- storage pool ---
# ZFS only: Incus quotas, snapshots and clone-on-create (the whole disk model)
# depend on it. Incus does NOT bundle the ZFS userspace tools, so
# zfsutils-linux must be installed (00-check.sh ensures it). The dir backend is
# deliberately not offered — it has no quotas, which silently breaks the disk
# limits every container is supposed to have.
# ZFS only: Incus quotas, snapshots and clone-on-create depend on it. New
# installations use a sparse loop-file pool on /. Incus does NOT bundle the
# ZFS userspace tools, so zfsutils-linux must be installed (00-check.sh ensures
# it). The dir backend is deliberately not offered — it has no quotas, which
# silently breaks the disk limits every container is supposed to have.
command -v zpool >/dev/null 2>&1 || die "zfsutils-linux is not installed (zpool missing) — 00-check.sh should have installed it; run it or: apt-get install -y zfsutils-linux"
POOL=vpsmgr
POOL_EXISTS=0
@@ -76,96 +76,71 @@ if incus storage show "$POOL" >/dev/null 2>&1; then
log "storage pool '$POOL' exists ($DRIVER)"
fi
# find a spare whole-disk block device (no partitions, not the root disk, unmounted)
find_spare_disk(){
ROOT_DISK=$(lsblk -rno NAME,MOUNTPOINTS | awk '$2=="/"{print $1; exit}' | sed 's/[0-9]*$//')
for d in $(lsblk -rno NAME,TYPE | awk '$2=="disk"{print $1}'); do
[[ "$d" == "$ROOT_DISK" ]] && continue
[[ -b "/dev/$d" ]] || continue
NCHILD=$(lsblk -rno NAME "$d" | tail -n +2 | wc -l)
[[ "$NCHILD" -gt 0 ]] && continue
grep -q "/dev/$d" /proc/mounts && continue
echo "/dev/$d"; return 0
done
return 1
}
# Decide how to configure the pool in preseed.
# New installations always use a sparse loop-file ZFS pool on /. The installer
# deliberately never scans, selects, formats, or modifies secondary block
# devices: virtual CD/ISO devices and provider-specific disk layouts are left
# completely untouched. Existing vpsmgr pools are adopted unchanged.
DRIVER=zfs
SPARE=""
POOL_SIZE_MB=""
if [[ $POOL_EXISTS -eq 1 ]]; then
# adopt existing pool
# Adopt existing pool.
DRIVER=$(incus storage show "$POOL" | awk -F': ' '/driver:/{print $2}')
SRC_LINE=" source: \"$POOL\""
SIZE_LINE=""
else
SPARE=$(find_spare_disk || true)
if [[ -n "$SPARE" ]]; then
log "zfs pool '$POOL' will be created on spare block device $SPARE"
SRC_LINE=" source: \"$SPARE\""
SIZE_LINE=""
else
# Loop-file pool: the pool is a sparse file that grows on demand, so the
# ceiling is a share of the host's FREE space. Before sizing it, make sure
# the host actually has room: when free space is tight, reclaim caches and
# build artifacts FIRST (apt cache, autoremove, journal, stale kernels,
# /tmp, pip/go caches) so the pool + Incus image store + system still fit.
# Without this, a host close to full would carve a pool that then starves
# the root filesystem.
# Loop-file pool: the pool is sparse and grows on demand inside /. Before
# sizing it, reclaim caches and build artifacts when free space is tight so
# the pool, Incus image store, and host remain usable.
FREE_KB=$(df -k --output=avail / | tail -1 | tr -d ' ')
if (( FREE_KB < 25 * 1024 * 1024 )); then
log "free space on / is $(( FREE_KB / 1024 / 1024 )) GiB (< 25 GiB) — reclaiming caches before sizing the pool..."
# Cleanest wins first; each is idempotent and never touches user data.
DEBIAN_FRONTEND=noninteractive apt-get clean 2>/dev/null || true
rm -rf /var/lib/apt/lists/* 2>/dev/null || true
DEBIAN_FRONTEND=noninteractive apt-get autoremove -y -qq 2>/dev/null || true
journalctl --vacuum-time=3d >/dev/null 2>&1 || true
rm -rf /tmp/* /var/tmp/* 2>/dev/null || true
# Stale kernels: purge ALL linux-image-* except the RUNNING kernel — but
# if a NEWER kernel than the running one is already installed (upgraded,
# not yet rebooted), keep that newest one too: deleting it would make the
# next reboot BOOT AN OLDER KERNEL than the one currently running. The
# meta package linux-image-<arch> (no version, e.g. linux-image-amd64)
# is excluded — it holds no image payload but keeps apt pulling future
# kernels; purging it would silently stop kernel upgrades.
RUNNING_KERNEL=$(uname -r)
mapfile -t ALL_KERNELS < <(dpkg -l 'linux-image-*' 2>/dev/null | awk '/^ii/{print $2}' | grep -v -- "-dbg" | grep -E -- "-[0-9]+" | sort -V)
KEEP=("linux-image-$RUNNING_KERNEL")
NEWEST=${ALL_KERNELS[-1]}
if [[ -n "$NEWEST" && "$NEWEST" != "linux-image-$RUNNING_KERNEL" ]]; then
KEEP+=("$NEWEST")
log " keeping $NEWEST (newer than running kernel — reboot pending)"
fi
OLD_KERNELS=()
for k in "${ALL_KERNELS[@]}"; do
keep=0
for kk in "${KEEP[@]}"; do [[ "$k" == "$kk" ]] && keep=1; done
(( keep )) || OLD_KERNELS+=("$k")
done
if (( ${#OLD_KERNELS[@]} > 0 )); then
log " purging ${#OLD_KERNELS[@]} old kernel(s): ${OLD_KERNELS[*]}"
DEBIAN_FRONTEND=noninteractive apt-get purge -y -qq "${OLD_KERNELS[@]}" >/dev/null 2>&1 || true
fi
rm -rf /root/.cache/pip /root/.cache/go-build /root/go/pkg/mod/cache/download 2>/dev/null || true
FREE_KB=$(df -k --output=avail / | tail -1 | tr -d ' ')
if (( FREE_KB < 25 * 1024 * 1024 )); then
log "free space on / is $(( FREE_KB / 1024 / 1024 )) GiB (< 25 GiB) — reclaiming caches before sizing the pool..."
# Cleanest wins first; each is idempotent and never touches user data.
DEBIAN_FRONTEND=noninteractive apt-get clean 2>/dev/null || true
rm -rf /var/lib/apt/lists/* 2>/dev/null || true
DEBIAN_FRONTEND=noninteractive apt-get autoremove -y -qq 2>/dev/null || true
journalctl --vacuum-time=3d >/dev/null 2>&1 || true
rm -rf /tmp/* /var/tmp/* 2>/dev/null || true
# Stale kernels: purge ALL linux-image-* except the RUNNING kernel — but
# if a NEWER kernel than the running one is already installed (upgraded,
# not yet rebooted), keep that newest one too: deleting it would make the
# next reboot BOOT AN OLDER KERNEL than the one currently running. The
# meta package linux-image-<arch> (no version, e.g. linux-image-amd64) is
# excluded — it holds no image payload but keeps apt pulling future
# kernels; purging it would silently stop kernel upgrades.
RUNNING_KERNEL=$(uname -r)
mapfile -t ALL_KERNELS < <(dpkg -l 'linux-image-*' 2>/dev/null | awk '/^ii/{print $2}' | grep -v -- "-dbg" | grep -E -- "-[0-9]+" | sort -V)
KEEP=("linux-image-$RUNNING_KERNEL")
NEWEST=${ALL_KERNELS[-1]}
if [[ -n "$NEWEST" && "$NEWEST" != "linux-image-$RUNNING_KERNEL" ]]; then
KEEP+=("$NEWEST")
log " keeping $NEWEST (newer than running kernel — reboot pending)"
fi
OLD_KERNELS=()
for k in "${ALL_KERNELS[@]}"; do
keep=0
for kk in "${KEEP[@]}"; do [[ "$k" == "$kk" ]] && keep=1; done
(( keep )) || OLD_KERNELS+=("$k")
done
if (( ${#OLD_KERNELS[@]} > 0 )); then
log " purging ${#OLD_KERNELS[@]} old kernel(s): ${OLD_KERNELS[*]}"
DEBIAN_FRONTEND=noninteractive apt-get purge -y -qq "${OLD_KERNELS[@]}" >/dev/null 2>&1 || true
fi
rm -rf /root/.cache/pip /root/.cache/go-build /root/go/pkg/mod/cache/download 2>/dev/null || true
FREE_KB=$(df -k --output=avail / | tail -1 | tr -d ' ')
log "after reclaim: $(( FREE_KB / 1024 / 1024 )) GiB free on /"
fi
# Pool ceiling = a share of the free space, as a sparse loop file that
# grows on demand. Small disks keep 80% so the host / Incus image store
# keep enough headroom; big disks (>= 20 GiB free) can afford 90%.
# No reserve check: the loop file is sparse, so creating the pool costs
# nothing — only actual container data grows it, and the percentage
# ceiling already keeps the host from being fully starved.
PCT=80
if (( FREE_KB > 20 * 1024 * 1024 )); then
PCT=90
fi
POOL_SIZE_MB=$(( FREE_KB * PCT / 100 / 1024 ))
log "loop-file zfs pool '$POOL' (~${POOL_SIZE_MB} MiB = ${PCT}% of free, created by Incus)"
SRC_LINE=""
SIZE_LINE=" size: \"${POOL_SIZE_MB}MiB\""
log "after reclaim: $(( FREE_KB / 1024 / 1024 )) GiB free on /"
fi
# Small disks keep 80% so the host and Incus image store retain headroom;
# larger free roots can afford 90%. The loop file is sparse, so creation
# itself consumes no space; actual container data grows it on demand.
PCT=80
if (( FREE_KB > 20 * 1024 * 1024 )); then
PCT=90
fi
POOL_SIZE_MB=$(( FREE_KB * PCT / 100 / 1024 ))
log "loop-file zfs pool '$POOL' on / (~${POOL_SIZE_MB} MiB = ${PCT}% of free)"
SRC_LINE=""
SIZE_LINE=" size: \"${POOL_SIZE_MB}MiB\""
fi
# --- incus admin init (preseed) ---
@@ -234,13 +209,8 @@ EOF
incus network show incusbr0 >/dev/null 2>&1 || incus network create incusbr0 ipv4.address=$V4_GW ipv4.nat=true ipv6.address=none dns.mode=none
fi
if ! incus storage show "$POOL" >/dev/null 2>&1; then
if [[ -n "$SPARE" ]]; then
incus storage create "$POOL" zfs source="$SPARE" || die "zfs pool creation on $SPARE failed"
elif [[ -n "$POOL_SIZE_MB" ]]; then
incus storage create "$POOL" zfs size="${POOL_SIZE_MB}MiB" || die "zfs pool creation (loop file) failed"
else
die "no disk for the zfs pool and no loop-file size decided"
fi
[[ -n "$POOL_SIZE_MB" ]] || die "no loop-file size decided for the ZFS pool"
incus storage create "$POOL" zfs size="${POOL_SIZE_MB}MiB" || die "zfs pool creation (loop file) failed"
fi
fi
else