Files
platform/scripts/setup/machine-setup/machine-setup.sh
T
pastilhasandClaude Opus 5 6947284f1b check the root filesystem is actually using the whole drive
New first section, before anything else that changes the machine, because the
swapfile and the ballast both size themselves from free disk.

Ubuntu Server's installer on its defaults gives the root logical volume a fixed
size and leaves the rest of the drive as unallocated extents in the volume group.
On a 2TB disk that is a ~100G root with nothing to indicate a problem: lsblk
shows the whole drive, df shows 100G, and the two are never seen side by side
until the day it fills. Growing a virtual disk at a provider leaves the same
shape one layer down, and so does resizing a partition without telling the
filesystem inside it.

Three layers, any of which can be the short one, so all three are measured and
printed together:

     drive:        76.3GB   /dev/sda
     volume:       76.1GB   /dev/sda1
     filesystem:   76.1GB   ext4, mounted at /

Seeing them in one place is most of the value. The fix is then whichever layer is
short: lvextend for free extents, growpart for a partition that stops early
(followed by pvresize and lvextend when LVM is in the way), or resize2fs alone
when only the filesystem is behind.

Only ever grows. Nothing here shrinks, creates or deletes a partition, and ext4,
xfs and btrfs all grow while mounted — so no unmount, no reboot, and a failure
part-way leaves a smaller filesystem on a larger container, which is the state it
started in.

growpart is the authority on whether a partition can move — it exits 1 with
NOCHANGE when the partition already reaches the end — but it comes from
cloud-guest-utils, which is not on every image. Installing a package purely to
ask a question is too eager, so plain arithmetic on the device sizes decides
whether it is even worth looking, and only then is growpart fetched.

A gigabyte of slack before anything is reported: a filesystem is always slightly
smaller than its container, and reporting journal and reserved-block overhead as
reclaimable space would make this section cry wolf on every machine.

Verified on this host: plain ext4 partition filling its disk, correctly reports
nothing to reclaim, and every helper returns the right device and size.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 17:59:38 +00:00

587 lines
23 KiB
Bash
Executable File

#!/bin/bash
set -e
# =============================================================================
# machine-setup — provisioning for a fresh machine
#
# Brings a blank box up to a usable state: users, SSH, networking, firewall,
# Docker, shell and editor tooling, language runtimes.
#
# Run as root: sudo scripts/setup/machine-setup/machine-setup.sh
# =============================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROGRESS_FILE="$SCRIPT_DIR/.setup-progress"
# Shared state, output helpers, the step/resume machine and OS detection. Kept in
# lib/ so a step can eventually be read — or run — on its own without dragging the
# whole script in. Definitions only; nothing in there acts.
# shellcheck source=lib/base.sh
source "$SCRIPT_DIR/lib/base.sh"
# shellcheck source=lib/packages.sh
source "$SCRIPT_DIR/lib/packages.sh"
# shellcheck source=lib/tools.sh
source "$SCRIPT_DIR/lib/tools.sh"
# shellcheck source=lib/system.sh
source "$SCRIPT_DIR/lib/system.sh"
# shellcheck source=lib/disk.sh
source "$SCRIPT_DIR/lib/disk.sh"
# Trap errors with context. Installed here rather than in lib/base.sh, because
# that file is definitions only and a trap is a side effect on whoever sources it.
trap 'echo ""; echo -e "${RED}╔══════════════════════════════════════════════════╗${NC}"; echo -e "${RED}║ SETUP FAILED${NC}"; echo -e "${RED}║ Step: ${CURRENT_STEP:-unknown}${NC}"; echo -e "${RED}║ Line: $LINENO${NC}"; echo -e "${RED}║ Command: $BASH_COMMAND${NC}"; echo -e "${RED}╚══════════════════════════════════════════════════╝${NC}"' ERR
# =============================================================================
# 1. Pre-flight
# =============================================================================
echo ""
echo -e "${BOLD}╔══════════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}║ Machine Setup ║${NC}"
echo -e "${BOLD}╚══════════════════════════════════════════════════╝${NC}"
detect_os
echo ""
info "Machine: ${OS_NAME} (${ARCH})"
info "Packages: ${PM:-none detected}"
[[ "$IS_WSL" == true ]] && warn "WSL detected — the suspend, logind and boot-hang steps do not apply here"
# Everything below this line is written against apt and systemd. Detection above
# recognises pacman, dnf and brew so the branches have somewhere to hang, but
# nothing implements them yet — and running the apt path on Arch would half-build
# a machine and stop somewhere unhelpful. Refuse clearly instead, and relax this
# list one entry at a time as each package manager grows a real path.
case "$PM" in
apt) ;;
"") fail "Could not find a package manager for '${OS_NAME}'." ;;
*) fail "${OS_NAME} uses ${PM}, which this script does not implement yet — apt-based systems only, so far." ;;
esac
ask_machine_role
info "Role: ${MACHINE_ROLE}"
if [[ -f "$PROGRESS_FILE" ]]; then
DONE_COUNT=$(wc -l < "$PROGRESS_FILE")
echo -e "${YELLOW} Resuming — $DONE_COUNT step(s) already completed${NC}"
echo -e "${YELLOW} Progress file: $PROGRESS_FILE${NC}"
echo -e "${YELLOW} To start fresh: rm $PROGRESS_FILE${NC}"
fi
if [[ "$EUID" -ne 0 ]]; then
fail "Please run as root: sudo ./machine-setup.sh"
fi
prompt_value USERNAME "New admin username (or existing)" ""
if [[ -z "$USERNAME" ]]; then
fail "Username cannot be empty"
fi
USER_HOME="/home/$USERNAME"
# Always, and outside any step: everything below reads this index — core utils,
# the fastfetch PPA, the Docker repo — and `step` skips a step whose name is
# already in the progress file. With the refresh inside one of those, a resumed
# run installed against whatever the index happened to say hours or days ago.
echo ""
info "Refreshing the package index..."
pkg_refresh >/dev/null
# =============================================================================
# 2. Disk space
# =============================================================================
#
# First of the sections that change anything, because everything after it sizes
# itself from free disk — the swapfile and the ballast both.
step "Disk space"
if ! skip; then
ROOT_DEV="$(root_device)"
ROOT_DISK="$(parent_disk "$ROOT_DEV")"
ROOT_PART="$(backing_partition "$ROOT_DEV" || true)"
DISK_B="$(dev_bytes "$ROOT_DISK")"
CONT_B="$(dev_bytes "$ROOT_DEV")"
FS_B="$(fs_bytes "$ROOT_DEV")"
VG_FREE_B="$(vg_free_bytes)"
# A filesystem is always a little smaller than the thing holding it —
# metadata, journal, reserved blocks. Only a real gap is worth reporting.
SLACK=$((1024 * 1024 * 1024))
echo ""
info "Disk space — whether the root filesystem is actually using the whole drive"
echo " Ubuntu's installer, left on its defaults, gives the root volume a"
echo " fixed size and leaves the rest of the drive unallocated. On a 2TB"
echo " disk that is a 100G root and no sign anything is wrong: lsblk shows"
echo " the whole drive, df shows 100G, and the two are never seen together"
echo " until the day it fills up. Growing a virtual disk at the provider"
echo " leaves the same shape."
echo ""
echo " drive: $(human_bytes "$DISK_B") ${ROOT_DISK}"
echo " volume: $(human_bytes "$CONT_B") ${ROOT_DEV}"
echo " filesystem: $(human_bytes "$FS_B") $(root_fstype), mounted at /"
((VG_FREE_B > SLACK)) && echo " unused in LVM: $(human_bytes "$VG_FREE_B")"
# growpart is the authority on whether a partition can move, but installing a
# package just to ask is too eager — the arithmetic decides whether it is even
# worth looking.
MIGHT_GROW=false
((DISK_B - CONT_B > SLACK)) && MIGHT_GROW=true
[[ -n "$ROOT_PART" ]] && $MIGHT_GROW && ensure_growpart
if ((VG_FREE_B > SLACK)); then
echo ""
echo " $(human_bytes "$VG_FREE_B") is sitting unallocated in the volume group"
echo " the fix is lvextend, then growing the filesystem into it — both online"
if confirm "Use it?"; then
grow_lv && grow_fs
ok "root filesystem is now $(human_bytes "$(fs_bytes "$ROOT_DEV")")"
SUMMARY+=("Disk: reclaimed $(human_bytes "$VG_FREE_B") from the volume group")
else
warn "skipped by request"
SUMMARY+=("Disk: SKIPPED — $(human_bytes "$VG_FREE_B") left unallocated")
fi
elif [[ -n "$ROOT_PART" ]] && partition_can_grow "$ROOT_PART"; then
echo ""
echo " the partition stops short of the end of the drive"
echo " this is the one step that edits the partition table — it only ever"
echo " moves the end of ${ROOT_PART} outwards, and never touches another one"
if confirm "Extend it?"; then
grow_partition "$ROOT_PART"
if root_is_lvm; then
grow_pv "$ROOT_PART"
grow_lv
fi
grow_fs
ok "root filesystem is now $(human_bytes "$(fs_bytes "$ROOT_DEV")")"
SUMMARY+=("Disk: partition extended, filesystem now $(human_bytes "$(fs_bytes "$ROOT_DEV")")")
else
warn "skipped by request"
SUMMARY+=("Disk: SKIPPED — partition left short of the drive")
fi
elif ((FS_B > 0 && CONT_B - FS_B > SLACK)); then
echo ""
echo " the filesystem is smaller than the volume holding it"
if confirm "Grow it?"; then
grow_fs
ok "root filesystem is now $(human_bytes "$(fs_bytes "$ROOT_DEV")")"
SUMMARY+=("Disk: filesystem grown to $(human_bytes "$(fs_bytes "$ROOT_DEV")")")
else
warn "skipped by request"
SUMMARY+=("Disk: SKIPPED — filesystem left short of its volume")
fi
else
echo ""
echo " the whole drive is in use, nothing to reclaim"
SUMMARY+=("Disk: already using the whole drive")
fi
step_ok
fi
# =============================================================================
# 3. System update
# =============================================================================
#
# Its own section because it is the only thing in the script that moves versions
# of software already on the machine. Everything else only ever adds what is
# absent, so this is the one that deserves to be refused on its own.
#
# The index refresh is NOT here — it runs in pre-flight, unconditionally, because
# every later step reads it and this one can be skipped.
step "System update"
if ! skip; then
mapfile -t UPGRADABLE < <(pkg_upgradable)
echo ""
info "System update — upgrades installed packages to their latest versions"
if ((${#UPGRADABLE[@]} == 0)); then
echo " to upgrade: nothing, everything is current"
SUMMARY+=("System update: already up to date")
else
echo " to upgrade: ${#UPGRADABLE[@]} package(s)"
# Capped, because a box that has not been touched in months lists hundreds
# and a wall of names is no more informative than a count.
printf ' %s\n' "${UPGRADABLE[@]:0:25}"
((${#UPGRADABLE[@]} > 25)) && echo " … and $((${#UPGRADABLE[@]} - 25)) more"
if confirm "Proceed?"; then
pkg_upgrade_all
ok "System upgraded"
SUMMARY+=("System upgraded: ${#UPGRADABLE[@]} package(s)")
else
warn "skipped by request"
SUMMARY+=("System update: SKIPPED by request — ${#UPGRADABLE[@]} package(s) left as they are")
fi
fi
step_ok
fi
# =============================================================================
# 4. Core utils
# =============================================================================
#
# What the distribution provides: the six this script would break without, and
# the command-line tools that make a machine worth sitting at.
step "Core utils"
if ! skip; then
# shellcheck disable=SC2046 # word splitting is how the list is passed
pkg_install "Core utils" $(pkgs_core)
summarise_last "Core utils"
step_ok
fi
# =============================================================================
# 5. Command-line tools
# =============================================================================
#
# A different thing from core utils, and kept apart from them: upstream binaries
# fetched from upstream, on their own release cadence, none of which the
# distribution ships. Lumping them in made a run look like it was installing
# system packages and then start pulling tarballs unannounced.
step "Command-line tools"
if ! skip; then
# shellcheck disable=SC2046 # word splitting is how the list is passed
tools_install "Command-line tools" $(tools_default)
summarise_last "Command-line tools"
step_ok
fi
# =============================================================================
# 6. Locale
# =============================================================================
#
# LOCALE in the environment overrides the default.
step "Locale"
if ! skip; then
LOCALE="${LOCALE:-en_US.UTF-8}"
CURRENT_LOCALE="$(locale_current)"
echo ""
info "Locale — the system language and character encoding"
echo " current: ${CURRENT_LOCALE:-none configured}"
echo " to set: ${LOCALE}"
if [[ "$CURRENT_LOCALE" == "$LOCALE" ]] && locale_is_generated "$LOCALE"; then
echo " already set and generated, nothing to do"
SUMMARY+=("Locale: already ${LOCALE}")
else
# Say which of the two is actually wrong, since they fail differently: a
# missing LANG means the C locale, a missing generation means every login
# prints a setlocale warning.
[[ "$CURRENT_LOCALE" != "$LOCALE" ]] && echo " LANG is not set to it"
locale_is_generated "$LOCALE" || echo " the locale has not been generated on this machine"
if confirm "Proceed?"; then
locale_set "$LOCALE"
ok "Locale set to ${LOCALE}"
SUMMARY+=("Locale: ${LOCALE}")
else
warn "skipped by request"
SUMMARY+=("Locale: SKIPPED by request — left at ${CURRENT_LOCALE:-unset}")
fi
fi
step_ok
fi
# =============================================================================
# 7. Timezone
# =============================================================================
#
# TIMEZONE in the environment answers the prompt ahead of time.
step "Timezone"
if ! skip; then
CURRENT_TZ="$(timezone_current)"
echo ""
info "Timezone — what logs, timers and every printed date are relative to"
echo " current: ${CURRENT_TZ:-unknown}"
if [[ -z "${TIMEZONE:-}" ]]; then
echo ""
for i in "${!TZ_OPTIONS[@]}"; do
printf ' [%d] %s\n' "$((i + 1))" "${TZ_OPTIONS[$i]}"
done
echo ""
while [[ -z "${TIMEZONE:-}" ]]; do
if ! read -rp " Pick a number, or type a zone name — Enter keeps ${CURRENT_TZ:-the current one}: " TZ_CHOICE; then
echo ""
fail "No answer. Set TIMEZONE=<zone> to answer this ahead of time."
fi
if [[ -z "$TZ_CHOICE" ]]; then
TIMEZONE="$CURRENT_TZ"
elif [[ "$TZ_CHOICE" =~ ^[0-9]+$ ]]; then
if ((TZ_CHOICE >= 1 && TZ_CHOICE <= ${#TZ_OPTIONS[@]})); then
TIMEZONE="${TZ_OPTIONS[$((TZ_CHOICE - 1))]}"
else
warn "There is no option ${TZ_CHOICE}."
fi
else
# Validated here rather than left to timedatectl, which fails on an
# unknown name and would take the whole run down over a typo.
if timezone_is_valid "$TZ_CHOICE"; then
TIMEZONE="$TZ_CHOICE"
else
warn "Not a zone this machine knows: '${TZ_CHOICE}' — try e.g. Europe/Berlin"
fi
fi
done
elif ! timezone_is_valid "$TIMEZONE"; then
fail "TIMEZONE='${TIMEZONE}' is not a zone this machine knows."
fi
if [[ "$TIMEZONE" == "$CURRENT_TZ" ]]; then
echo " keeping ${CURRENT_TZ}, nothing to do"
SUMMARY+=("Timezone: already ${CURRENT_TZ}")
else
echo " to set: ${TIMEZONE}"
if confirm "Proceed?"; then
timezone_set "$TIMEZONE"
ok "Timezone set to ${TIMEZONE}"
SUMMARY+=("Timezone: ${TIMEZONE}")
else
warn "skipped by request"
SUMMARY+=("Timezone: SKIPPED by request — left at ${CURRENT_TZ:-unknown}")
fi
fi
step_ok
fi
# =============================================================================
# 8. Swap
# =============================================================================
#
# Disk the kernel can park cold pages on when RAM fills, so a spike costs
# latency instead of a process. A `bun install` or a Docker build on a small
# machine is exactly the spike this is for.
step "Swap"
if ! skip; then
ACTIVE_SWAP_GB="$(swap_active_gb)"
WANT_SWAP_GB="$(swap_recommended_gb)"
SWAPPINESS="$(swappiness_for_role)"
CURRENT_SWAPPINESS="$(sysctl -n vm.swappiness 2>/dev/null || echo unknown)"
echo ""
info "Swap — overflow space so a memory spike costs speed rather than a process"
echo " RAM: $(ram_gb)G"
echo " active swap: ${ACTIVE_SWAP_GB}G"
echo " swappiness: ${CURRENT_SWAPPINESS} -> ${SWAPPINESS} (${MACHINE_ROLE})"
if [[ "$IS_WSL" == true ]]; then
# WSL2 runs its own managed swap inside the VM; a swapfile here is wasted
# disk and is not what the kernel would use anyway.
echo " WSL manages its own swap — leaving it alone"
SUMMARY+=("Swap: left to WSL")
elif ((ACTIVE_SWAP_GB > 0)); then
echo " already has ${ACTIVE_SWAP_GB}G of swap, leaving it alone"
if [[ "$CURRENT_SWAPPINESS" != "$SWAPPINESS" ]] && confirm "Set swappiness to ${SWAPPINESS}?"; then
swappiness_set "$SWAPPINESS"
ok "swappiness set to ${SWAPPINESS}"
SUMMARY+=("Swap: kept ${ACTIVE_SWAP_GB}G, swappiness ${SWAPPINESS}")
else
SUMMARY+=("Swap: kept ${ACTIVE_SWAP_GB}G")
fi
elif ((WANT_SWAP_GB == 0)); then
# Capped to nothing by the disk check rather than by choice.
warn "not enough free disk to add swap safely — $(disk_free_gb)G free"
SUMMARY+=("Swap: none added, disk too full")
else
echo " to create: ${WANT_SWAP_GB}G at ${SWAPFILE} ($(disk_free_gb)G free now)"
if confirm "Proceed?"; then
swap_create "$WANT_SWAP_GB"
swappiness_set "$SWAPPINESS"
ok "${WANT_SWAP_GB}G swap active, swappiness ${SWAPPINESS}"
SUMMARY+=("Swap: ${WANT_SWAP_GB}G created, swappiness ${SWAPPINESS}")
else
warn "skipped by request"
SUMMARY+=("Swap: SKIPPED by request")
fi
fi
step_ok
fi
# =============================================================================
# 9. Emergency disk ballast
# =============================================================================
#
# Always offered, whatever the role — the role only decides which way the
# recommendation points.
step "Emergency disk ballast"
if ! skip; then
echo ""
info "Emergency disk ballast — a reserve you can burn when the disk fills up"
echo " A junk file holding no data, sized at 10% of free disk. A root cron"
echo " checks every 10 minutes and deletes it if free space drops below"
echo " ${BALLAST_THRESHOLD}%, so you get room to log in and clean up instead of meeting a"
echo " wedged machine — Docker, journald and postgres all misbehave badly"
echo " at 100% full, and not all of them recover on their own."
echo " It is a one-shot valve: once spent, run this again to recreate it."
echo ""
if is_server; then
echo " recommended for ${MACHINE_ROLE} — a full disk on an unattended box is the bad case"
else
echo " less useful on ${MACHINE_ROLE} — you are sitting at this machine and will notice"
fi
if ballast_exists; then
echo " already present: $(ballast_size_human) at ${BALLAST_FILE}"
SUMMARY+=("Disk ballast: already present ($(ballast_size_human))")
else
BALLAST_MB="$(ballast_size_mb)"
echo " to create: ${BALLAST_MB}M at ${BALLAST_FILE}"
if confirm "Create it?"; then
ballast_create "$BALLAST_MB"
ballast_install_checker
ok "ballast $(ballast_size_human), checker at ${BALLAST_CHECKER}"
SUMMARY+=("Disk ballast: $(ballast_size_human), checked every 10 min")
else
warn "skipped by request"
SUMMARY+=("Disk ballast: SKIPPED by request")
fi
fi
step_ok
fi
# =============================================================================
# 10. earlyoom
# =============================================================================
step "earlyoom"
if ! skip; then
echo ""
info "earlyoom — keeps the machine reachable when it runs out of memory"
echo " The kernel's own OOM killer waits until an allocation actually"
echo " fails, and by then the machine has usually spent minutes thrashing:"
echo " unresponsive, ssh refusing to connect, nothing to do but reset it."
echo " earlyoom watches free memory and kills the biggest consumer while"
echo " there is still enough left to stay logged in."
echo ""
echo " This is what happens after swap runs out, so the two go together."
if earlyoom_is_active; then
echo " already installed and running"
SUMMARY+=("earlyoom: already running")
elif confirm "Install it?"; then
earlyoom_install
if earlyoom_is_active; then
ok "earlyoom running"
SUMMARY+=("earlyoom: installed and running")
else
warn "earlyoom installed but not running — check: systemctl status earlyoom"
SUMMARY+=("earlyoom: installed, not running")
fi
else
warn "skipped by request"
SUMMARY+=("earlyoom: SKIPPED by request")
fi
step_ok
fi
# =============================================================================
# 11. inotify watch limit
# =============================================================================
step "inotify watch limit"
if ! skip; then
CURRENT_WATCHES="$(inotify_current_watches)"
echo ""
info "inotify watch limit — how many files can be watched for changes at once"
echo " A single file watcher walking a project with node_modules in it can"
echo " exhaust the stock limit on its own, and every watcher on the machine"
echo " draws from the same pool. The failure is silent: nothing errors, the"
echo " watcher just stops noticing changes. Hot reload goes quiet, a build"
echo " stops rebuilding, and the reason is never on screen."
echo ""
echo " current: ${CURRENT_WATCHES}"
echo " to set: ${INOTIFY_WATCHES}"
if is_role dev; then
echo " recommended on dev — editors, bun --watch and vite are all watchers"
else
echo " less pressing on ${MACHINE_ROLE}, but anything running bun --watch or"
echo " serving a file browser is a watcher too"
fi
if ((CURRENT_WATCHES >= INOTIFY_WATCHES)); then
echo " already at or above that, nothing to do"
SUMMARY+=("inotify watches: already ${CURRENT_WATCHES}")
elif confirm "Raise it?"; then
inotify_raise
ok "inotify watches raised to $(inotify_current_watches)"
SUMMARY+=("inotify watches: raised to ${INOTIFY_WATCHES}")
else
warn "skipped by request"
SUMMARY+=("inotify watches: SKIPPED by request — left at ${CURRENT_WATCHES}")
fi
step_ok
fi
# =============================================================================
# NOT PORTED YET
# =============================================================================
#
# Sections still to move across from scripts/setup-old/setup-ubuntu.sh, in order:
#
# auto-suspend · boot-hang fix · user creation ·
# ssh keys · ssh hardening · dns · static ip · fail2ban · unattended-upgrades ·
# git config · docker · zsh + prompt · tailscale · neovim · js runtimes ·
# dev tools · ufw · zshrc
#
# Each arrives as its own commit. Delete this block when the list is empty.
# =============================================================================
# 12. Summary
# =============================================================================
echo ""
echo ""
if [[ ${#ERRORS[@]} -gt 0 ]]; then
echo -e "${YELLOW}╔══════════════════════════════════════════════════╗${NC}"
echo -e "${YELLOW}║ Setup Complete (with warnings) ║${NC}"
echo -e "${YELLOW}╚══════════════════════════════════════════════════╝${NC}"
else
echo -e "${GREEN}╔══════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ Setup Complete ║${NC}"
echo -e "${GREEN}╚══════════════════════════════════════════════════╝${NC}"
fi
echo ""
echo -e "${BOLD} What was done:${NC}"
for item in "${SUMMARY[@]}"; do
echo -e " ${GREEN}+${NC} $item"
done
if [[ ${#ERRORS[@]} -gt 0 ]]; then
echo ""
echo -e "${BOLD} Non-critical issues:${NC}"
for err in "${ERRORS[@]}"; do
echo -e " ${YELLOW}!${NC} $err"
done
fi
echo ""
echo -e "${BOLD} Machine:${NC}"
echo " System: $OS_NAME ($ARCH)"
echo " Role: $MACHINE_ROLE"
echo " User: $USERNAME"
echo " Home: $USER_HOME"
[[ -n "${TS_IP:-}" && "$TS_IP" != "unknown" ]] && echo " Tailscale: $TS_IP"
echo ""
# Clean up progress file on success
rm -f "$PROGRESS_FILE"