diff --git a/docs/architecture.md b/docs/architecture.md index 4b7f660..00a9018 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/configuration.md b/docs/configuration.md index dc0b5ca..4055e45 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 | diff --git a/scripts/10-incus.sh b/scripts/10-incus.sh index 6be9055..1b29fdd 100644 --- a/scripts/10-incus.sh +++ b/scripts/10-incus.sh @@ -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- (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- (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