Files
platform/scripts/setup/machine-setup/lib/system.sh
T
pastilhasandClaude Opus 5 e120dfa36e full read: fix the set -e footguns a full run would have hit
Read the whole thing — 2392 lines of entry point and 2700 of libraries — looking
for what shellcheck cannot see. shellcheck itself is clean at error level; its
warnings are cross-file false positives and one deliberate tilde in a display
string. Everything below is a real defect.

── The Git section aborted on any machine where git was not already configured ──

`git config --global --get <key>` exits NON-ZERO when the key is simply unset,
and `VAR="$(git_get …)"` propagates that under `set -e`. So on a fresh machine —
the case this script exists for — the section died at its first assignment,
before printing anything, and took the remaining nine sections with it.

It passed every earlier test because those harnesses sourced the section under a
`bash -c` with no `set -e`. Verified now against a genuinely fresh account with
the real script: the section completes and writes a correct .gitconfig.

── An optional step failing aborted the whole run ──

Twelve functions ended on a command that can fail — `systemctl enable --now
earlyoom`, `systemctl restart systemd-logind`, `chsh`, `sysctl -w`, `chown -R`,
the oh-my-zsh installer, and others. Called as plain commands under `set -e`, any
one of them failing ends the script, so a masked unit or a container without
systemd would abort a 28-section run over an optional improvement.

They now return 0 explicitly and the callers verify the outcome instead — which
also fixed a lie: the sleep section printed "sleep disabled, logind reloaded"
whether or not the restart had worked. It now checks the targets and the logind
values and reports honestly.

── chown user:user assumed the primary group is named after the user ──

True on Debian and Ubuntu, which create a group per user. Not true for an account
from LDAP, or made with `useradd -g users`, or on an image with a shared group —
there `install -g <user>` fails with "invalid group" and the step aborts. Proved
it against an account whose primary group is `oddgroup`: the old form fails, the
new one gets ownership right. Eight call sites now ask `id -gn`.

── Also hardened ──

agent_path and current_editor gained `|| true` for the same reason git_get needed
it: "nothing is set" is an answer, not a failure.

Verified afterwards: shellcheck clean at error level, every section runs
standalone without aborting, and the two apparent failures in that sweep are
correct behaviour — Timezone and Git refusing an empty answer from /dev/null.

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

557 lines
22 KiB
Bash

#!/bin/bash
# =============================================================================
# machine-setup — system configuration
# =============================================================================
#
# Definitions only, like the other lib/ files. Locale, and the system-level
# settings that follow it.
[[ -n "${MACHINE_SETUP_SYSTEM_LOADED:-}" ]] && return 0
MACHINE_SETUP_SYSTEM_LOADED=1
# -----------------------------------------------------------------------------
# Locale
# -----------------------------------------------------------------------------
#
# Two separate facts, and the original only handled one of them:
#
# what a new login shell is told to use — LANG in /etc/default/locale
# whether that locale actually exists — whether it has been generated
#
# Setting LANG to a locale that was never generated is the state that produces
# "setlocale: LC_ALL: cannot change locale" on every ssh login and every perl
# invocation. Both are checked, so the step can say which one is missing.
# What a new login shell will be handed, or empty if nothing is configured.
locale_current() {
if [[ -r /etc/default/locale ]]; then
awk -F= '/^LANG=/ { gsub(/"/, "", $2); print $2 }' /etc/default/locale
elif [[ -r /etc/locale.conf ]]; then
awk -F= '/^LANG=/ { gsub(/"/, "", $2); print $2 }' /etc/locale.conf
fi
}
# Has this locale actually been built?
#
# `locale -a` prints en_US.utf8 where the configuration spells it en_US.UTF-8,
# so both sides are folded to lower case with the dashes removed before
# comparing. A literal match here would report a perfectly good locale missing.
locale_is_generated() {
local want="${1,,}"
want="${want//-/}"
locale -a 2>/dev/null | tr '[:upper:]' '[:lower:]' | tr -d '-' | grep -qx "$want"
}
locale_set() {
local want="$1"
local escaped="${want//./\\.}"
case "$PM" in
apt)
# locale-gen comes from the `locales` package, which minimal images and
# most cloud base images do not ship. Without this the step fails with
# "locale-gen: command not found" halfway through.
if ! pkg_is_installed locales; then
info " installing locales, which provides locale-gen"
pkg_install_now locales
fi
# Uncomment it if it is there commented out, add it if it is absent.
# Editing the file rather than passing the name to locale-gen is what makes
# it survive: a locale generated by argument alone is lost the next time
# anything regenerates from /etc/locale.gen.
if grep -qE "^#[[:space:]]*${escaped}[[:space:]]" /etc/locale.gen 2>/dev/null; then
sed -i "s/^#[[:space:]]*\(${escaped}[[:space:]]\)/\1/" /etc/locale.gen
elif ! grep -qE "^${escaped}[[:space:]]" /etc/locale.gen 2>/dev/null; then
# The charset is the part after the dot: en_US.UTF-8 -> UTF-8
echo "${want} ${want##*.}" >>/etc/locale.gen
fi
locale-gen
update-locale LANG="$want"
;;
pacman)
if grep -qE "^#[[:space:]]*${escaped}[[:space:]]" /etc/locale.gen 2>/dev/null; then
sed -i "s/^#[[:space:]]*\(${escaped}[[:space:]]\)/\1/" /etc/locale.gen
fi
locale-gen
echo "LANG=${want}" >/etc/locale.conf
;;
dnf)
# No locale.gen here — the locales come prebuilt in langpack packages.
pkg_install_now "glibc-langpack-${want%%_*}"
localectl set-locale "LANG=${want}"
;;
brew)
warn "macOS has no system locale to set — it is per-user, from the terminal's settings"
return 1
;;
esac
}
# -----------------------------------------------------------------------------
# Swap
# -----------------------------------------------------------------------------
SWAPFILE=/swapfile
# Rounded to nearest, not floored: a 4 GiB swapfile is 4194300 kB, which floors
# to 3 and reads as though a gigabyte went missing. Same for RAM, where 3.7 GiB
# reporting as "3G" makes the sizing tiers look wrong.
kb_to_gb_rounded() { echo $((($1 + 524288) / 1048576)); }
# Total active swap in GiB, 0 if there is none.
#
# From /proc/meminfo rather than by grepping swapon's output for a slash, which
# is what the original did to spot a swap FILE — that test reports no swap at all
# on a machine using zram or a swap partition, and the step would then add a
# swapfile beside perfectly good swap.
swap_active_gb() { kb_to_gb_rounded "$(awk '/^SwapTotal:/ { print $2 }' /proc/meminfo)"; }
ram_gb() { kb_to_gb_rounded "$(awk '/^MemTotal:/ { print $2 }' /proc/meminfo)"; }
# Free space on the filesystem that would hold the swapfile, in GiB. Floored
# rather than rounded, deliberately: this one decides how much to allocate, and
# rounding up invents space that is not there.
disk_free_gb() { echo $(($(df -Pk "$(dirname "$SWAPFILE")" | awk 'NR == 2 { print $4 }') / 1024 / 1024)); }
# How much swap this machine should have.
#
# The tiers are the original's. What is new is that the answer is capped by what
# is actually on the disk — the original would try to fallocate 8G on a VPS with
# 4G free, fail, and take the run down with it.
swap_recommended_gb() {
local ram size
ram="$(ram_gb)"
if ((ram <= 2)); then
size=2
elif ((ram <= 8)); then
size=4
else
size=8
fi
# Leave a few gigabytes behind. A swapfile that fills the disk is a worse
# problem than no swapfile.
local room=$(($(disk_free_gb) - 5))
((room < size)) && size="$room"
((size < 1)) && size=0
echo "$size"
}
# How eagerly the kernel swaps, by role.
#
# 10 on a server: swapping is the emergency valve, not a routine, and the cost of
# a page fault on a request path is latency somebody is waiting for. A desktop is
# the opposite case — swapping out an application nobody has touched in an hour
# is exactly what you want — so dev keeps the kernel default of 60.
swappiness_for_role() { if is_server; then echo 10; else echo 60; fi; }
swap_create() {
local gb="$1"
# fallocate is instant but produces a file some filesystems refuse to swap on
# (btrfs without the right attributes, zfs at all). dd is slow and always
# works, so it is the fallback rather than the default.
if ! fallocate -l "${gb}G" "$SWAPFILE" 2>/dev/null; then
info " fallocate is not usable here — writing the file with dd, which is slower"
dd if=/dev/zero of="$SWAPFILE" bs=1M count=$((gb * 1024)) status=none
fi
chmod 600 "$SWAPFILE"
mkswap "$SWAPFILE" >/dev/null
swapon "$SWAPFILE"
grep -qs "^${SWAPFILE}[[:space:]]" /etc/fstab || echo "${SWAPFILE} none swap sw 0 0" >>/etc/fstab
}
# Written as a drop-in rather than by rewriting /etc/sysctl.conf in place. The
# original sed'd that file, which means the setting is tangled up with whatever
# else lives there and is invisible to anyone looking for what this script did.
swappiness_set() {
echo "vm.swappiness=$1" >/etc/sysctl.d/99-machine-setup-swappiness.conf
sysctl -q -w "vm.swappiness=$1"
# Returns 0 whatever happens. This is an optional improvement, and a
# function that ends on a failing command is fatal under `set -e` when it
# is called as a plain command — which would abort the remaining sections
# over something the run could simply report. The caller checks the outcome.
return 0
}
# -----------------------------------------------------------------------------
# Emergency disk ballast
# -----------------------------------------------------------------------------
#
# The same idea as swap, one layer down. Swap is the valve for memory pressure;
# this is the valve for disk pressure.
#
# A junk file holding no data, sized at 10% of free disk. Its only job is to be
# deleted when the filesystem is about to fill, buying enough headroom to log in
# and clean up properly instead of meeting a wedged box — Docker, journald and
# postgres all misbehave badly at 100% full, and some of them do not recover on
# their own.
#
# A one-shot valve: once spent, it has to be recreated.
#
# ── Moved out of the user's home ──
#
# The original put the checker in $USER_HOME/.local/bin and ran it from a root
# cron. A root cron executing a script inside a directory its owner can write is
# a privilege escalation waiting to be noticed — moot on a box where that user
# already has passwordless sudo, but wrong, and not something to carry forward.
# Both the script and the file now live in root-owned system paths.
# Where it goes is asked rather than decided. A ballast only protects the
# filesystem it is ON — the checker measures its own directory — so the choice is
# also a choice of which mount is being protected. Defaults to the user's home,
# which on most machines is the same filesystem as / and is the easiest place to
# find it again months later.
BALLAST_FILE=""
BALLAST_CHECKER=/usr/local/sbin/emergency-disk-check
BALLAST_CRON=/etc/cron.d/emergency-disk-check
BALLAST_THRESHOLD=10
BALLAST_NAME=emergency-disk-ballast.bin
ballast_exists() { [[ -n "$BALLAST_FILE" && -f "$BALLAST_FILE" ]]; }
ballast_size_human() { du -h "$BALLAST_FILE" 2>/dev/null | cut -f1; }
# The nearest directory that exists, walking up. A path being chosen for the
# ballast does not mean anything has created it yet, and df cannot measure a
# directory that is not there.
existing_ancestor() {
local dir="$1"
while [[ ! -d "$dir" && "$dir" != "/" ]]; do dir="$(dirname "$dir")"; done
echo "$dir"
}
# Free space in KiB on whichever filesystem would hold this path.
ballast_free_kb() { df -Pk "$(existing_ancestor "$1")" | awk 'NR == 2 { print $4 }'; }
ballast_create() {
local mb="$1"
mkdir -p "$(dirname "$BALLAST_FILE")"
# fallocate reserves real blocks. A sparse file made with truncate would
# reserve nothing and free nothing when deleted, which is the entire point.
if ! fallocate -l "${mb}M" "$BALLAST_FILE" 2>/dev/null; then
info " fallocate is not usable here — writing with dd, which is slower"
dd if=/dev/zero of="$BALLAST_FILE" bs=1M count="$mb" status=none
fi
chmod 600 "$BALLAST_FILE"
}
ballast_install_checker() {
mkdir -p "$(dirname "$BALLAST_FILE")"
cat >"$BALLAST_CHECKER" <<CHECKER
#!/usr/bin/env bash
#
# Emergency disk ballast checker. Installed by machine-setup.
#
# Deletes the pre-allocated ballast file when free space falls below the
# threshold, buying headroom to log in and clean up. Run with --status to see
# where things stand without changing anything.
set -euo pipefail
BALLAST="${BALLAST_FILE}"
THRESHOLD=${BALLAST_THRESHOLD}
TAG="emergency-disk"
# Walk up to a directory that exists. The ballast's own directory is gone if
# somebody cleaned up after the valve was spent, and df failing under
# \`set -e\` would make cron mail an error every ten minutes.
MOUNT_DIR="\$(dirname "\$BALLAST")"
while [[ ! -d "\$MOUNT_DIR" && "\$MOUNT_DIR" != "/" ]]; do MOUNT_DIR="\$(dirname "\$MOUNT_DIR")"; done
USE_PCT="\$(df -P "\$MOUNT_DIR" | awk 'NR == 2 { gsub(/%/, "", \$5); print \$5 }')"
FREE_PCT=\$((100 - USE_PCT))
if [[ "\${1:-}" == "--status" ]]; then
echo "Mount: \$(df -P "\$MOUNT_DIR" | awk 'NR == 2 { print \$6 }')"
echo "Free: \${FREE_PCT}% (threshold: \${THRESHOLD}%)"
if [[ -f "\$BALLAST" ]]; then
echo "Ballast: present, \$(du -h "\$BALLAST" | cut -f1) — \$BALLAST"
else
echo "Ballast: ABSENT (already spent) — \$BALLAST"
fi
exit 0
fi
# Everything urgent goes through here, so there is one place to add a second
# channel later. Today it is syslog only, which means the message is in the
# journal and nowhere else — nobody finds out until they go looking, which is
# exactly the wrong moment. Push, mail or Officer's own notify sidecar hook in
# here.
notify() {
logger -t "\$TAG" -p user.crit "\$1"
# A copy on stderr as well, so a human running this by hand sees it.
echo "\$1" >&2
}
((FREE_PCT < THRESHOLD)) || exit 0
if [[ -f "\$BALLAST" ]]; then
FREED="\$(du -h "\$BALLAST" | cut -f1)"
rm -f "\$BALLAST"
notify "Free space \${FREE_PCT}% below \${THRESHOLD}% — deleted ballast, reclaimed \${FREED}. CLEAN UP NOW: this valve is spent."
else
notify "Free space \${FREE_PCT}% below \${THRESHOLD}% — ballast already spent, no headroom left to reclaim."
fi
CHECKER
chown root:root "$BALLAST_CHECKER"
chmod 755 "$BALLAST_CHECKER"
cat >"$BALLAST_CRON" <<CRON
# Emergency disk ballast — deletes the ballast file if free space drops below ${BALLAST_THRESHOLD}%.
# Installed by machine-setup. Check status: ${BALLAST_CHECKER} --status
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
*/10 * * * * root ${BALLAST_CHECKER}
CRON
chmod 644 "$BALLAST_CRON"
}
# -----------------------------------------------------------------------------
# earlyoom
# -----------------------------------------------------------------------------
#
# What happens when swap runs out too.
#
# The kernel's own OOM killer waits until allocation genuinely fails, and by then
# the machine has usually spent minutes thrashing — unresponsive, ssh refusing to
# connect, nothing to do but reset it. earlyoom watches free memory and kills the
# largest consumer while there is still enough left to stay reachable.
earlyoom_is_active() { systemctl is-active --quiet earlyoom 2>/dev/null; }
earlyoom_install() {
pkg_is_installed earlyoom || pkg_install_now earlyoom
systemctl enable --now earlyoom >/dev/null 2>&1
# Returns 0 whatever happens. This is an optional improvement, and a
# function that ends on a failing command is fatal under `set -e` when it
# is called as a plain command — which would abort the remaining sections
# over something the run could simply report. The caller checks the outcome.
return 0
}
# -----------------------------------------------------------------------------
# Resource limits
# -----------------------------------------------------------------------------
#
# inotify watches: how many files one user can have the kernel watching. The
# stock limit is small enough that one file watcher walking
# node_modules. Every watcher on the machine draws from the same pool.
#
# The failure is silent, which is what makes it worth setting in advance: nothing
# errors, the watcher simply stops noticing changes. Hot reload goes quiet, a
# build stops rebuilding, and the reason is never on screen.
#
# Mostly a development concern, but not exclusively — anything running `bun
# --watch` or serving a file browser is a watcher too.
INOTIFY_WATCHES=524288
INOTIFY_INSTANCES=1024
inotify_current_watches() { sysctl -n fs.inotify.max_user_watches 2>/dev/null || echo 0; }
inotify_raise() {
cat >/etc/sysctl.d/99-machine-setup-inotify.conf <<EOF
# Raised by machine-setup: the 8192 default is exhausted by file watchers, and
# the failure is silent — the watcher stops noticing changes without an error.
fs.inotify.max_user_watches=${INOTIFY_WATCHES}
fs.inotify.max_user_instances=${INOTIFY_INSTANCES}
EOF
sysctl -q -w "fs.inotify.max_user_watches=${INOTIFY_WATCHES}"
sysctl -q -w "fs.inotify.max_user_instances=${INOTIFY_INSTANCES}"
# Returns 0 whatever happens. This is an optional improvement, and a
# function that ends on a failing command is fatal under `set -e` when it
# is called as a plain command — which would abort the remaining sections
# over something the run could simply report. The caller checks the outcome.
return 0
}
# -----------------------------------------------------------------------------
# Sleep and suspend
# -----------------------------------------------------------------------------
#
# A server that suspends is a server that is off. The machine stops answering,
# and on a box with no keyboard attached there is nothing to wake it — which is
# the whole failure: it looks like a crash, and the only fix is physical.
#
# Two independent mechanisms, and both have to be dealt with:
#
# the sleep targets what suspend/hibernate hang off. Masking them means
# nothing can trigger a sleep, including a stray
# `systemctl suspend`
# logind's handlers what closing a lid, pressing the power button or going
# idle DO. These are what a desktop image sets, and they
# act before anything reaches a target
#
# Written as a drop-in rather than by editing logind.conf in place, so what this
# script set is one file that can be read or deleted on its own.
SLEEP_TARGETS=(sleep.target suspend.target hibernate.target hybrid-sleep.target)
LOGIND_DROPIN=/etc/systemd/logind.conf.d/99-machine-setup.conf
# The value actually in force for a logind setting, or empty for the default.
# Drop-ins override the main file and later ones override earlier, so the last
# match wins — reading only logind.conf would miss a setting made by a drop-in
# and report the machine as unconfigured when it is not.
logind_effective() {
local key="$1"
{
[[ -r /etc/systemd/logind.conf ]] && grep -hE "^${key}=" /etc/systemd/logind.conf
for f in /etc/systemd/logind.conf.d/*.conf; do
[[ -r "$f" ]] && grep -hE "^${key}=" "$f"
done
} 2>/dev/null | tail -1 | cut -d= -f2-
}
sleep_targets_masked() {
local t
for t in "${SLEEP_TARGETS[@]}"; do
[[ "$(systemctl is-enabled "$t" 2>/dev/null)" == "masked" ]] || return 1
done
}
# What the machine should be set to. RuntimeDirectorySize is deliberately NOT
# here: the original set it to 10% alongside these, which is both unrelated to
# sleeping — it is the size of /run — and systemd's own default, so the line
# never did anything.
logind_wanted() {
cat <<'EOF'
HandleLidSwitch=ignore
HandleLidSwitchExternalPower=ignore
HandleLidSwitchDocked=ignore
HandlePowerKey=ignore
IdleAction=none
EOF
}
# Is every wanted setting already in force?
logind_is_configured() {
local line key value
while IFS= read -r line; do
key="${line%%=*}"
value="${line#*=}"
[[ "$(logind_effective "$key")" == "$value" ]] || return 1
done < <(logind_wanted)
}
disable_sleep() {
systemctl mask "${SLEEP_TARGETS[@]}" >/dev/null 2>&1
mkdir -p "$(dirname "$LOGIND_DROPIN")"
{
echo "# Written by machine-setup: this machine is a server and must not sleep."
echo "[Login]"
logind_wanted
} >"$LOGIND_DROPIN"
# Only restart when something actually changed — a needless restart of logind
# disturbs live sessions, and this step runs on every pass.
systemctl restart systemd-logind
# Returns 0 whatever happens. This is an optional improvement, and a
# function that ends on a failing command is fatal under `set -e` when it
# is called as a plain command — which would abort the remaining sections
# over something the run could simply report. The caller checks the outcome.
return 0
}
# -----------------------------------------------------------------------------
# Boot hang
# -----------------------------------------------------------------------------
#
# systemd-networkd-wait-online blocks boot until the network is up. Where
# systemd-networkd actually manages the network — a server or cloud image, via
# cloud-init and netplan — it does that in milliseconds and is load-bearing.
#
# Where NetworkManager owns the network instead, systemd-networkd runs nothing,
# but the wait-online unit is still enabled and waits for a link that will never
# be configured. It gives up after its full timeout, on every boot.
#
# So the fix is masking one unit, and only on the second stack. Do NOT
# `systemctl disable --now systemd-networkd` to achieve the same thing: on a
# networkd-managed box that brings it up with no network on the next boot, no
# ssh, and nothing but the provider's rescue console.
WAIT_ONLINE_UNIT=systemd-networkd-wait-online.service
network_manager_name() {
if systemctl is-active --quiet NetworkManager.service 2>/dev/null; then
echo "NetworkManager"
elif systemctl is-active --quiet systemd-networkd.service 2>/dev/null; then
echo "systemd-networkd"
else
echo "neither — unclear"
fi
}
# Is the wait actually pointless here? NetworkManager in charge and networkd not.
# Anything else, including "cannot tell", is left alone.
wait_online_is_spurious() {
systemctl is-active --quiet NetworkManager.service 2>/dev/null &&
! systemctl is-active --quiet systemd-networkd.service 2>/dev/null
}
# What that unit actually cost on this boot, straight from systemd's own
# accounting. Worth printing rather than asking somebody whether boot "feels
# slow": the answer is either 14ms or two minutes, and there is no arguing with
# it. Empty when the unit did not run.
wait_online_boot_time() {
systemd-analyze blame 2>/dev/null | awk -v u="$WAIT_ONLINE_UNIT" '$NF == u { $NF = ""; sub(/[[:space:]]+$/, ""); print; exit }'
}
# Returns 0 whatever happens — see swappiness_set for why an optional step must
# not be able to abort the run.
mask_wait_online() {
systemctl mask --now "$WAIT_ONLINE_UNIT" >/dev/null 2>&1
return 0
}
# -----------------------------------------------------------------------------
# Timezone
# -----------------------------------------------------------------------------
# The shortlist offered at the prompt. Any zone name can be typed instead, so
# this is a convenience rather than a limit.
TZ_OPTIONS=(UTC Europe/Lisbon Europe/London Europe/Berlin Europe/Stockholm US/Eastern US/Pacific Asia/Tokyo)
# What the machine is set to now.
#
# Three sources because they disagree about availability rather than about the
# answer: timedatectl is absent without systemd (containers, WSL), /etc/timezone
# is Debian-specific, and the /etc/localtime symlink is the one thing that is
# always true when any of them are.
timezone_current() {
if command -v timedatectl &>/dev/null && timedatectl show -p Timezone --value 2>/dev/null | grep -q .; then
timedatectl show -p Timezone --value 2>/dev/null
elif [[ -r /etc/timezone ]]; then
tr -d '[:space:]' </etc/timezone
elif [[ -L /etc/localtime ]]; then
readlink -f /etc/localtime | sed 's|.*/zoneinfo/||'
fi
}
# Checked against the zoneinfo database before it is used. `timedatectl
# set-timezone` on a name that does not exist fails, and under `set -e` that
# takes the whole run down over a typo.
timezone_is_valid() { [[ -f "/usr/share/zoneinfo/$1" ]]; }
timezone_set() {
local tz="$1"
case "$PM" in
brew) systemsetup -settimezone "$tz" >/dev/null ;;
*)
# timedatectl where there is a systemd to talk to; the files directly
# otherwise, which is the same thing it would have written.
if command -v timedatectl &>/dev/null && [[ "$IS_WSL" != true ]]; then
timedatectl set-timezone "$tz"
else
ln -sf "/usr/share/zoneinfo/${tz}" /etc/localtime
echo "$tz" >/etc/timezone
fi
;;
esac
}