feat(install): polish install UX with banner, spinner, progress bar, summary
CI / Lint + build + test (push) Successful in 1m38s
Release / detect (push) Successful in 7s
Release / build-live-image (push) Has been skipped
Release / bundle (push) Successful in 55s

Wrap the three install scripts in a shared inline style block (TTY/UTF-8/
NO_COLOR-aware) so the one-liner install looks and feels intentional:
banner on start, timed step lines, braille spinner over silent apt/
systemctl calls with failure log dumps, single-line curl progress bars
with size-prefixed headers, and a summary box at the end with live-image
version + service state + next steps. install.sh defers banner/summary
to proxmox-install.sh when VETTING_INSTALL_WRAPPED is set so the two
scripts compose without duplication.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-20 22:29:44 -04:00
parent 48f992a451
commit 6d50f3a804
3 changed files with 684 additions and 158 deletions
+256 -70
View File
@@ -33,6 +33,172 @@
#
set -euo pipefail
# ---- style helpers -----------------------------------------------------
# Identical block across proxmox-install.sh, install.sh, pxe-setup.sh.
# Inlined (not sourced) so the curl|bash entrypoint renders without a
# prior fetch. Respects NO_COLOR, non-TTY, and non-UTF-8 locales.
_color=0; _unicode=0
if [[ -t 2 && -z "${NO_COLOR:-}" && "${TERM:-dumb}" != "dumb" ]]; then
_color=1
fi
case "${LC_ALL:-}${LANG:-}" in
*UTF-8*|*utf8*|*UTF8*) _unicode=1 ;;
esac
if (( _color )); then
_B=$'\033[1m'; _D=$'\033[2m'; _R=$'\033[0m'
_C=$'\033[1;36m'; _G=$'\033[32m'; _Y=$'\033[33m'; _E=$'\033[31m'
else
_B=""; _D=""; _R=""; _C=""; _G=""; _Y=""; _E=""
fi
if (( _unicode )); then
_S_STEP="▸"; _S_OK="✓"; _S_WARN="⚠"; _S_FAIL="✗"; _S_INFO="·"
_B_TL="╭"; _B_TR="╮"; _B_BL="╰"; _B_BR="╯"; _B_H="─"; _B_V="│"
else
_S_STEP="-->"; _S_OK="[ok]"; _S_WARN="[!]"; _S_FAIL="[x]"; _S_INFO="*"
_B_TL="+"; _B_TR="+"; _B_BL="+"; _B_BR="+"; _B_H="-"; _B_V="|"
fi
_start_epoch="$(date +%s)"; _step_epoch=""; _quiet_log=""; _spin_pid=""
_fmt_dur() {
local s=$1
if (( s < 60 )); then printf '%ds' "$s"
elif (( s < 3600 )); then printf '%dm %ds' "$((s/60))" "$((s%60))"
else printf '%dh %dm' "$((s/3600))" "$(((s%3600)/60))"
fi
}
banner() {
local inner=" $1 " w line="" i=0
w=${#inner}
while (( i < w )); do line+="${_B_H}"; i=$((i+1)); done
printf '\n %s%s%s%s%s\n' "${_C}" "${_B_TL}" "${line}" "${_B_TR}" "${_R}" >&2
printf ' %s%s%s%s%s%s%s%s%s\n' "${_C}" "${_B_V}" "${_R}" "${_B}" "${inner}" "${_R}" "${_C}" "${_B_V}" "${_R}" >&2
printf ' %s%s%s%s%s\n\n' "${_C}" "${_B_BL}" "${line}" "${_B_BR}" "${_R}" >&2
}
step() {
_step_epoch="$(date +%s)"
printf '%s%s%s %s%s%s\n' "${_C}" "${_S_STEP}" "${_R}" "${_B}" "$1" "${_R}" >&2
}
ok() {
local tail=""
if [[ -n "${_step_epoch}" ]]; then
tail=" ${_D}($(_fmt_dur $(( $(date +%s) - _step_epoch ))))${_R}"
fi
printf ' %s%s%s %s%s\n' "${_G}" "${_S_OK}" "${_R}" "$1" "${tail}" >&2
_step_epoch=""
}
info() { printf ' %s%s %s%s\n' "${_D}" "${_S_INFO}" "$1" "${_R}" >&2; }
warn() { printf ' %s%s %s%s\n' "${_Y}" "${_S_WARN}" "$1" "${_R}" >&2; }
die() {
printf '\n%s%s %s%s\n' "${_E}" "${_S_FAIL}" "$1" "${_R}" >&2
if [[ -n "${_quiet_log:-}" && -s "${_quiet_log}" ]]; then
printf ' %s── last 40 lines of output ──%s\n' "${_D}" "${_R}" >&2
tail -n 40 "${_quiet_log}" | sed 's/^/ /' >&2
printf ' %sfull log: %s%s\n' "${_D}" "${_quiet_log}" "${_R}" >&2
fi
exit 1
}
_SPIN=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏")
_start_spin() {
(( _color && _unicode )) || return 0
local label="$1"
(
local i=0
while :; do
printf '\r %s%s%s %s ' "${_C}" "${_SPIN[i]}" "${_R}" "${label}" >&2
i=$(( (i+1) % ${#_SPIN[@]} ))
sleep 0.1
done
) &
_spin_pid=$!
}
_stop_spin() {
[[ -n "${_spin_pid}" ]] || return 0
kill "${_spin_pid}" 2>/dev/null || true
wait "${_spin_pid}" 2>/dev/null || true
_spin_pid=""
printf '\r\033[2K' >&2
}
# run_quiet "<label>" -- <cmd ...>
run_quiet() {
local label="$1"; shift
[[ "${1:-}" == "--" ]] && shift
local log start rc=0
log="$(mktemp -t vetting-install.XXXXXX)"
_quiet_log="${log}"
start="$(date +%s)"
_start_spin "${label}"
"$@" >"${log}" 2>&1 || rc=$?
_stop_spin
local dt=$(( $(date +%s) - start ))
if (( rc == 0 )); then
printf ' %s%s%s %s %s(%s)%s\n' "${_G}" "${_S_OK}" "${_R}" "${label}" "${_D}" "$(_fmt_dur "$dt")" "${_R}" >&2
rm -f "${log}"; _quiet_log=""
return 0
fi
printf ' %s%s %s failed (exit %d)%s\n' "${_E}" "${_S_FAIL}" "${label}" "${rc}" "${_R}" >&2
printf ' %s── last 40 lines of output ──%s\n' "${_D}" "${_R}" >&2
tail -n 40 "${log}" | sed 's/^/ /' >&2
printf ' %sfull log: %s%s\n' "${_D}" "${log}" "${_R}" >&2
exit "${rc}"
}
# curl_pretty --label LABEL --url URL -- [extra curl args, including -o PATH]
curl_pretty() {
local label="download" url=""
local args=()
while [[ $# -gt 0 ]]; do
case "$1" in
--label) label="$2"; shift 2 ;;
--url) url="$2"; args+=("$2"); shift 2 ;;
--) shift; args+=("$@"); break ;;
*) args+=("$1"); shift ;;
esac
done
local size_str=""
if [[ -n "${url}" ]]; then
local size_bytes
size_bytes="$(curl -fsSLI --max-time 5 "${url}" 2>/dev/null \
| awk 'tolower($1) ~ /^content-length:/ {print $2}' \
| tr -d '\r' | tail -n1)"
if [[ "${size_bytes}" =~ ^[0-9]+$ ]]; then
local human
human="$(numfmt --to=iec --suffix=B --format='%.1f' "${size_bytes}" 2>/dev/null || echo "${size_bytes}B")"
size_str=" ${_D}(${human})${_R}"
fi
fi
printf '%s%s%s %sdownloading %s%s%s\n' "${_C}" "${_S_STEP}" "${_R}" "${_B}" "${label}" "${_R}" "${size_str}" >&2
local start rc=0
start="$(date +%s)"
if [[ -t 2 ]]; then
curl -fL --progress-bar "${args[@]}" || rc=$?
else
curl -fsSL "${args[@]}" || rc=$?
fi
local dt=$(( $(date +%s) - start ))
if (( rc == 0 )); then
printf ' %s%s%s %s ready %s(%s)%s\n' "${_G}" "${_S_OK}" "${_R}" "${label}" "${_D}" "$(_fmt_dur "$dt")" "${_R}" >&2
return 0
fi
printf ' %s%s %s download failed (exit %d)%s\n' "${_E}" "${_S_FAIL}" "${label}" "${rc}" "${_R}" >&2
exit "${rc}"
}
rule_open() {
local title="$1"
local dashes=$(( 46 - ${#title} - 4 ))
local tail="" i=0
(( dashes < 2 )) && dashes=2
while (( i < dashes )); do tail+="${_B_H}"; i=$((i+1)); done
printf '\n%s%s%s %s %s%s\n' "${_C}" "${_B_TL}" "${_B_H}" "${title}" "${tail}" "${_R}" >&2
}
rule_close() {
local line="" i=0
while (( i < 46 )); do line+="${_B_H}"; i=$((i+1)); done
printf '%s%s%s%s\n' "${_C}" "${_B_BL}" "${line:1}" "${_R}" >&2
}
total_elapsed() {
local dt=$(( $(date +%s) - _start_epoch ))
printf '%sinstalled in %s%s\n\n' "${_D}" "$(_fmt_dur "$dt")" "${_R}" >&2
}
# ---- end style helpers -------------------------------------------------
BINARY=""
AGENT_BINARY=""
CONFIG_DIR="/etc/vetting"
@@ -78,8 +244,21 @@ while [[ $# -gt 0 ]]; do
done
if [[ $EUID -ne 0 ]]; then
echo "install.sh must be run as root (try: sudo $0)" >&2
exit 1
die "install.sh must be run as root (try: sudo $0)"
fi
# Standalone invocation gets its own banner; wrapped runs share the one
# from proxmox-install.sh. Tracked in VETTING_INSTALL_WRAPPED so we don't
# render the banner or summary twice.
if [[ -z "${VETTING_INSTALL_WRAPPED:-}" ]]; then
banner "TheWrightServer · Vetting"
fi
# Snapshot service state so the summary can pick between fresh-install
# and upgrade-path wording even after we restart the service below.
_prev_svc_enabled=0
if systemctl is-enabled --quiet vetting.service 2>/dev/null; then
_prev_svc_enabled=1
fi
# heal_pxe_config: make sure /etc/vetting/vetting.yaml's pxe.interface
@@ -140,18 +319,17 @@ heal_pxe_config() {
fi
if [[ -z "${detected_iface}" || -z "${detected_subnet}" ]]; then
echo "WARN: pxe is enabled in ${config} but pxe.interface=${cur_iface:-<empty>} / pxe.subnet=${cur_subnet:-<empty>} is stale," >&2
echo " and no default-route NIC was found to auto-detect from. Edit the file manually before starting." >&2
warn "pxe enabled but pxe.interface=${cur_iface:-<empty>} / pxe.subnet=${cur_subnet:-<empty>} is stale, and no default-route NIC was found to auto-detect from. Edit ${config} manually before starting."
return 0
fi
local iface_to_write="${cur_iface}" subnet_to_write="${cur_subnet}"
if (( iface_ok == 0 )); then
echo "==> pxe.interface \"${cur_iface}\" is not present on this host; auto-patching to \"${detected_iface}\""
info "pxe.interface \"${cur_iface}\" is not present on this host; auto-patching to \"${detected_iface}\""
iface_to_write="${detected_iface}"
fi
if (( subnet_ok == 0 )); then
echo "==> pxe.subnet \"${cur_subnet:-<empty>}\" is missing/invalid; auto-patching to \"${detected_subnet}\""
info "pxe.subnet \"${cur_subnet:-<empty>}\" is missing/invalid; auto-patching to \"${detected_subnet}\""
subnet_to_write="${detected_subnet}"
fi
@@ -181,7 +359,7 @@ refresh_live_image() {
local bundle_ver
bundle_ver="$(tr -d '[:space:]' < "${pointer}" 2>/dev/null || true)"
if [[ -z "${bundle_ver}" ]]; then
echo "WARN: bundle's ${pointer} is empty; skipping live-image fetch" >&2
warn "bundle's ${pointer} is empty; skipping live-image fetch"
return 0
fi
@@ -191,27 +369,25 @@ refresh_live_image() {
fi
if [[ "${bundle_ver}" == "${installed_ver}" && "${FORCE_LIVE_IMAGE:-0}" != "1" ]]; then
echo "==> live-image already at ${bundle_ver}; skipping fetch (FORCE_LIVE_IMAGE=1 to redownload)"
info "live-image already at ${bundle_ver}; skipping fetch (FORCE_LIVE_IMAGE=1 to redownload)"
return 0
fi
if [[ -z "${REGISTRY_URL:-}" ]]; then
echo "WARN: REGISTRY_URL is not set; cannot fetch live-image ${bundle_ver}. Re-run via proxmox-install.sh or export REGISTRY_URL." >&2
warn "REGISTRY_URL is not set; cannot fetch live-image ${bundle_ver}. Re-run via proxmox-install.sh or export REGISTRY_URL."
return 0
fi
local owner="${PACKAGE_OWNER:-josh}"
local base="${REGISTRY_URL%/}/api/packages/${owner}/generic/live-image/${bundle_ver}"
echo "==> fetching live-image ${bundle_ver} (was '${installed_ver:-none}') from ${base}"
step "fetching live-image ${bundle_ver} (was '${installed_ver:-none}')"
local tmp
tmp="$(mktemp -d)"
# shellcheck disable=SC2064
trap "rm -rf '${tmp}'" RETURN
# Default curl meter shows rate + ETA, which matters for the ~300 MB
# initrd on slow links.
curl -fL -o "${tmp}/vmlinuz" "${base}/vmlinuz"
curl -fL -o "${tmp}/initrd.img" "${base}/initrd.img"
curl_pretty --label "kernel (vmlinuz)" --url "${base}/vmlinuz" -- -o "${tmp}/vmlinuz"
curl_pretty --label "initrd (initrd.img)" --url "${base}/initrd.img" -- -o "${tmp}/initrd.img"
install -d -m 0755 -o "${SERVICE_USER}" -g "${SERVICE_USER}" "${LIVE_DIR}"
install -m 0644 -o "${SERVICE_USER}" -g "${SERVICE_USER}" \
@@ -235,8 +411,7 @@ if [[ -z "${BINARY}" ]]; then
done
fi
if [[ -z "${BINARY}" || ! -x "${BINARY}" ]]; then
echo "could not find a vetting binary to install; pass --binary PATH or run 'make orchestrator-linux' first" >&2
exit 1
die "could not find a vetting binary to install; pass --binary PATH or run 'make orchestrator-linux' first"
fi
if [[ -z "${AGENT_BINARY}" ]]; then
@@ -248,35 +423,41 @@ if [[ -z "${AGENT_BINARY}" ]]; then
done
fi
if [[ -z "${AGENT_BINARY}" || ! -x "${AGENT_BINARY}" ]]; then
echo "could not find a vetting-agent binary; pass --agent-binary PATH or run 'make agent-linux' first" >&2
exit 1
die "could not find a vetting-agent binary; pass --agent-binary PATH or run 'make agent-linux' first"
fi
echo "==> installing runtime dependencies"
step "installing runtime dependencies"
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y --no-install-recommends \
ca-certificates dnsmasq iperf3
run_quiet "apt: dnsmasq, iperf3, ca-certificates" -- bash -c '
apt-get update -qq
apt-get install -qq -y --no-install-recommends ca-certificates dnsmasq iperf3
'
echo "==> creating ${SERVICE_USER} user"
step "creating ${SERVICE_USER} user"
if ! id -u "${SERVICE_USER}" >/dev/null 2>&1; then
useradd --system \
--home-dir "${STATE_DIR}" \
--shell /usr/sbin/nologin \
"${SERVICE_USER}"
ok "${SERVICE_USER} created"
else
info "${SERVICE_USER} user already exists"
fi
echo "==> preparing directories"
step "preparing directories"
install -d -m 0755 -o "${SERVICE_USER}" -g "${SERVICE_USER}" "${STATE_DIR}"
install -d -m 0755 -o "${SERVICE_USER}" -g "${SERVICE_USER}" "${LOG_DIR}"
install -d -m 0755 -o "${SERVICE_USER}" -g "${SERVICE_USER}" "${ASSET_DIR}"
install -d -m 0755 "${CONFIG_DIR}"
info "state: ${STATE_DIR} · logs: ${LOG_DIR} · config: ${CONFIG_DIR}"
echo "==> installing binary"
step "installing binary"
install -m 0755 "${BINARY}" /usr/local/bin/vetting
install -m 0755 "${AGENT_BINARY}" "${ASSET_DIR}/vetting-agent-linux-amd64"
info "orchestrator: /usr/local/bin/vetting"
info "agent asset: ${ASSET_DIR}/vetting-agent-linux-amd64"
echo "==> installing config and systemd unit"
step "installing config and systemd unit"
# vetting.production.yaml uses absolute /var/lib/vetting + /var/log/vetting
# paths that match the systemd unit's ReadWritePaths. vetting.example.yaml
# uses ./var/... relatives and is only correct for `make run` in a dev tree.
@@ -284,22 +465,21 @@ if [[ ! -f "${CONFIG_DIR}/vetting.yaml" ]]; then
install -m 0640 -o root -g "${SERVICE_USER}" \
"${SCRIPT_DIR}/vetting.production.yaml" \
"${CONFIG_DIR}/vetting.yaml"
echo " -> installed default config at ${CONFIG_DIR}/vetting.yaml"
info "installed default config at ${CONFIG_DIR}/vetting.yaml"
else
echo " -> preserving existing ${CONFIG_DIR}/vetting.yaml"
info "preserving existing ${CONFIG_DIR}/vetting.yaml"
fi
install -m 0644 "${SCRIPT_DIR}/vetting.service" /etc/systemd/system/vetting.service
# Install pxe-setup.sh + its pinned iPXE SHAs into a stable path so the
# operator can run `vetting-pxe-setup ...` after the one-liner install.
# The bundle's tempdir gets wiped by proxmox-install.sh on exit, so
# without this the script would be inaccessible.
# operator can run `vetting-pxe-setup` after the one-liner install.
if [[ -f "${SCRIPT_DIR}/pxe-setup.sh" && -f "${SCRIPT_DIR}/ipxe-shas.txt" ]]; then
echo "==> installing pxe-setup.sh and ipxe-shas.txt"
step "installing pxe-setup.sh and ipxe-shas.txt"
install -d -m 0755 /usr/local/share/vetting
install -m 0755 "${SCRIPT_DIR}/pxe-setup.sh" /usr/local/share/vetting/pxe-setup.sh
install -m 0644 "${SCRIPT_DIR}/ipxe-shas.txt" /usr/local/share/vetting/ipxe-shas.txt
ln -sfn /usr/local/share/vetting/pxe-setup.sh /usr/local/sbin/vetting-pxe-setup
info "vetting-pxe-setup -> /usr/local/share/vetting/pxe-setup.sh"
fi
# Stage the live image into LIVE_DIR. Preference order:
@@ -322,7 +502,7 @@ if [[ -z "${LIVE_IMAGE_SRC}" ]]; then
fi
if [[ -n "${LIVE_IMAGE_SRC}" ]]; then
echo "==> staging live image from ${LIVE_IMAGE_SRC} into ${LIVE_DIR}"
step "staging live image from ${LIVE_IMAGE_SRC}"
install -d -m 0755 -o "${SERVICE_USER}" -g "${SERVICE_USER}" "${LIVE_DIR}"
install -m 0644 -o "${SERVICE_USER}" -g "${SERVICE_USER}" \
"${LIVE_IMAGE_SRC}/vmlinuz" "${LIVE_DIR}/vmlinuz"
@@ -342,10 +522,11 @@ if [[ -n "${LIVE_IMAGE_SRC}" ]]; then
break
fi
done
ok "live image staged into ${LIVE_DIR}"
elif [[ -f "${SCRIPT_DIR}/live-image/VERSION" ]]; then
refresh_live_image
else
echo "==> no live image found (bundle/live-image or ../live-image/build); skipping live-dir staging"
info "no live image found (bundle/live-image or ../live-image/build); skipping live-dir staging"
fi
# Disable the distro's dnsmasq so only the orchestrator-supervised
@@ -353,50 +534,55 @@ fi
# something else can re-enable it after configuring a disjoint listen
# address.
if systemctl is-enabled --quiet dnsmasq 2>/dev/null; then
echo "==> disabling distro dnsmasq (orchestrator supervises its own)"
systemctl disable --now dnsmasq
step "disabling distro dnsmasq (orchestrator supervises its own)"
run_quiet "systemctl disable --now dnsmasq" -- systemctl disable --now dnsmasq
fi
echo "==> validating pxe config against this host's interfaces"
step "validating pxe config against this host's interfaces"
heal_pxe_config "${CONFIG_DIR}/vetting.yaml"
systemctl daemon-reload
run_quiet "systemctl daemon-reload" -- systemctl daemon-reload
# Upgrade path: if vetting.service is already enabled, restart it so the
# new binary + live image take effect without an explicit second
# command. First-install path (service not enabled yet) leaves the
# service alone so the operator can edit the config before starting.
if systemctl is-enabled --quiet vetting.service 2>/dev/null; then
echo "==> restarting vetting.service (upgrade path)"
if (( _prev_svc_enabled )); then
step "restarting vetting.service (upgrade path)"
systemctl reset-failed vetting.service 2>/dev/null || true
systemctl restart vetting.service
cat <<EOF
vetting upgraded and restarted. Tail logs with:
journalctl -fu vetting
EOF
else
cat <<EOF
vetting is installed but not yet enabled.
Next steps:
1. Edit ${CONFIG_DIR}/vetting.yaml and set:
- server.bind (127.0.0.1:8080 by default; switch to
0.0.0.0:8080 once you're ready to expose
it on the LAN)
- server.public_url (the URL you'll browse to)
- pxe.* if you want PXE boot support
- notifiers + routes (optional)
2. Start the service:
systemctl enable --now vetting
3. Watch the logs:
journalctl -fu vetting
The UI has no built-in auth — it trusts the LAN. If you need a
password, front the service with a reverse proxy (Caddy/nginx
basic-auth) instead.
EOF
run_quiet "systemctl restart vetting.service" -- systemctl restart vetting.service
fi
# Standalone summary (wrapped runs get the summary from proxmox-install.sh).
if [[ -z "${VETTING_INSTALL_WRAPPED:-}" ]]; then
li_ver="unknown"
[[ -f "${LIVE_DIR}/VERSION" ]] && li_ver="$(tr -d '[:space:]' < "${LIVE_DIR}/VERSION")"
svc_state="not yet enabled"
if systemctl is-enabled --quiet vetting.service 2>/dev/null; then
if systemctl is-active --quiet vetting.service 2>/dev/null; then
svc_state="enabled · running"
else
svc_state="enabled · stopped"
fi
fi
rule_open "installed"
info "live-image ${li_ver}"
info "service ${svc_state}"
info "config ${CONFIG_DIR}/vetting.yaml"
printf '\n' >&2
if (( _prev_svc_enabled )); then
info "upgraded · tail logs with:"
info " journalctl -fu vetting"
else
info "next:"
info " edit ${CONFIG_DIR}/vetting.yaml (server.bind, public_url, pxe.*)"
info " systemctl enable --now vetting"
info " journalctl -fu vetting"
printf '\n' >&2
info "pxe support:"
info " sudo vetting-pxe-setup --interface <NIC> --subnet <CIDR> \\"
info " --orchestrator-url http://<host>:8080"
fi
rule_close
total_elapsed
fi