mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 17:11:30 +00:00
384 lines
17 KiB
Bash
Executable File
384 lines
17 KiB
Bash
Executable File
#!/bin/bash
|
|
# node-reset.sh — return one Spinifex node to a freshly-installed state.
|
|
#
|
|
# Teardown only: it stops services, removes state, and exits. It installs
|
|
# nothing, starts nothing and decides nothing. Callers do the rebuilding —
|
|
# reset-dev-env.sh locally, install-node.sh --wipe over SSH.
|
|
#
|
|
# Usage:
|
|
# sudo scripts/node-reset.sh [--keep-data] [--yes] [--dry-run]
|
|
#
|
|
# Options:
|
|
# --keep-data Leave /var/lib/spinifex alone: volumes, S3 objects and
|
|
# JetStream survive. For when only the control plane is suspect.
|
|
# --yes Skip the confirmation prompt
|
|
# --dry-run Print what would be removed and touch nothing
|
|
#
|
|
# WHAT THIS DESTROYS
|
|
#
|
|
# Every instance on this node and every volume backing them, the node's CA and
|
|
# master key, all OVN logical network state, and the S3 objects held here. None
|
|
# of it is recoverable. Data sealed under the master key cannot be read back
|
|
# even if the bytes are restored from elsewhere.
|
|
set -euo pipefail
|
|
|
|
ETC_DIR=/etc/spinifex
|
|
DATA_DIR=/var/lib/spinifex
|
|
LOG_DIR=/var/log/spinifex
|
|
RUN_DIR=/run/spinifex
|
|
|
|
KEEP_DATA=false
|
|
ASSUME_YES=false
|
|
DRY_RUN=false
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--keep-data) KEEP_DATA=true ;;
|
|
--yes | -y) ASSUME_YES=true ;;
|
|
--dry-run) DRY_RUN=true ;;
|
|
-h | --help)
|
|
sed -n '2,23p' "$0" | sed 's/^# \?//'
|
|
exit 0
|
|
;;
|
|
*)
|
|
echo "ERROR: unknown option: $1" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
shift
|
|
done
|
|
|
|
log() { echo "[node-reset] $*"; }
|
|
run() {
|
|
if $DRY_RUN; then
|
|
echo " would run: $*"
|
|
return 0
|
|
fi
|
|
"$@"
|
|
}
|
|
|
|
WIPE_DIRS=("$ETC_DIR" "$LOG_DIR" "$RUN_DIR")
|
|
$KEEP_DATA || WIPE_DIRS+=("$DATA_DIR")
|
|
|
|
# Directories and symlinks are structure, not state: setup.sh builds both and
|
|
# nothing in a cluster is stored as either. Two of those symlinks are load
|
|
# bearing and easy to miss — /var/lib/spinifex/config and awsgw/config both
|
|
# point at /etc/spinifex, the second nested a level down, and awsgw reads its
|
|
# IAM master key through it. Keeping every symlink covers them without this
|
|
# script having to know where setup.sh put them.
|
|
#
|
|
# keep_args adds the regular files worth keeping: the service helper scripts
|
|
# and the env files the units load, at the top level of $1 only. A *.sh deeper
|
|
# in the tree is cluster state, not an installed helper.
|
|
#
|
|
# firewall/custom.nft is kept too, one level down: it is an operator's SSH
|
|
# allowlist, and losing it silently returns the node to accepting ssh from
|
|
# every source. Parenthesised so the -o groups against the whole top-level arm.
|
|
KEEP_TOP=()
|
|
keep_args() {
|
|
KEEP_TOP=('(' -path "$1/*" ! -path "$1/*/*" '(' -name '*.sh' -o -name '*.env' ')' ')' \
|
|
-o -path "$1/firewall/custom.nft")
|
|
}
|
|
|
|
# Report what is at stake in figures rather than adjectives. An operator who
|
|
# reads "3 instances, 12 volumes, 840G" makes a better decision than one who
|
|
# reads a warning banner.
|
|
# `|| true` on each: under pipefail a find over a directory that was never
|
|
# created fails the whole pipeline, and a node with nothing to report is the
|
|
# most ordinary case there is.
|
|
if [ -d "$DATA_DIR" ]; then
|
|
instances=$(sudo find "$DATA_DIR/instances" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | wc -l || true)
|
|
volumes=$(sudo find "$DATA_DIR/volumes" -maxdepth 1 -mindepth 1 2>/dev/null | wc -l || true)
|
|
size=$(sudo du -sh "$DATA_DIR" 2>/dev/null | cut -f1 || true)
|
|
log "on $(hostname): $instances instance(s), $volumes volume(s), ${size:-unknown} under $DATA_DIR"
|
|
$KEEP_DATA && log " --keep-data: $DATA_DIR will be preserved"
|
|
fi
|
|
log "removing: ${WIPE_DIRS[*]}"
|
|
|
|
if ! $ASSUME_YES && ! $DRY_RUN; then
|
|
read -r -p "Destroy this node's state? Type 'destroy' to continue: " reply
|
|
[ "$reply" = "destroy" ] || { log "aborted"; exit 1; }
|
|
fi
|
|
|
|
log "stopping services"
|
|
run sudo systemctl stop spinifex.target 2>/dev/null || true
|
|
run sudo systemctl reset-failed 'spinifex-*' 2>/dev/null || true
|
|
|
|
# Stopping the target is not the same as its services being stopped.
|
|
# spinifex-shutdown.service is ordered After= the storage services so its drain
|
|
# runs while they are still up, which queues their stop jobs behind its
|
|
# ExecStop, and `systemctl stop` returns as soon as the target itself is
|
|
# inactive. NATS surviving that is what lets JetStream rewrite its index after
|
|
# the wipe and before the verification, failing the reset on a file the script
|
|
# had already deleted. update-nodes.sh carries the same wait.
|
|
if ! $DRY_RUN; then
|
|
for unit in spinifex-predastore spinifex-viperblock spinifex-nats; do
|
|
if systemctl is-active --quiet "$unit"; then
|
|
sudo systemctl stop "$unit" || true
|
|
fi
|
|
done
|
|
elapsed=0
|
|
while [ -n "$(pgrep -x spx || true)" ]; do
|
|
if [ "$elapsed" -ge 120 ]; then
|
|
echo "ERROR: spx still running after 120s:" >&2
|
|
pgrep -ax spx >&2 || true
|
|
echo " Wiping under a live service races JetStream, which rewrites" >&2
|
|
echo " its index after the rm. Stop them manually and re-run." >&2
|
|
exit 1
|
|
fi
|
|
sleep 2
|
|
elapsed=$((elapsed + 2))
|
|
done
|
|
fi
|
|
|
|
# `pgrep -x` and `pkill -x` match against comm, which the kernel truncates to 15
|
|
# characters, so `qemu-system-x86_64` is stored as `qemu-system-x86` and an -x
|
|
# match on the full name can never fire. Match the prefix instead: it is 11
|
|
# characters, well under the limit, and covers both x86_64 and aarch64. This is
|
|
# the same rule qemuProcessPrefix applies in spinifex/runtime/compute/vm/orphan_scan.go.
|
|
#
|
|
# Deliberately not -f. That matches the whole command line, where argv[0] may
|
|
# carry a path, so an anchored pattern misses the process it was written for.
|
|
#
|
|
# Scoped by owner, because a host carrying libvirt guests alongside Spinifex
|
|
# ones has both under the same comm and an unscoped kill would take the wrong
|
|
# set down. The daemon runs guests as its own service user on an installed node;
|
|
# a dev install runs them as whoever invoked spx.
|
|
QEMU_PGREP=('^qemu-system')
|
|
for qemu_user in spinifex-daemon spinifex "${SUDO_USER:-$(id -un)}"; do
|
|
if id -u "$qemu_user" >/dev/null 2>&1; then
|
|
QEMU_PGREP=(-u "$qemu_user" "${QEMU_PGREP[@]}")
|
|
break
|
|
fi
|
|
done
|
|
qemu_pids() { sudo pgrep "${QEMU_PGREP[@]}" 2>/dev/null || true; }
|
|
|
|
if [ -n "$(qemu_pids)" ]; then
|
|
log "signalling QEMU guests owned by $qemu_user"
|
|
run sudo pkill "${QEMU_PGREP[@]}" 2>/dev/null || true
|
|
fi
|
|
|
|
# Viperblock state must not be torn out from under a live guest, so wait for
|
|
# QEMU to actually exit rather than assuming the signal was enough.
|
|
if ! $DRY_RUN; then
|
|
elapsed=0
|
|
while [ -n "$(qemu_pids)" ]; do
|
|
if [ "$elapsed" -ge 30 ]; then
|
|
echo "ERROR: QEMU still running after 30s:" >&2
|
|
sudo pgrep -a "${QEMU_PGREP[@]}" >&2 || true
|
|
echo " Kill them manually and re-run." >&2
|
|
exit 1
|
|
fi
|
|
sleep 1
|
|
elapsed=$((elapsed + 1))
|
|
done
|
|
fi
|
|
|
|
# Clearing external_ids drops system-id along with it. That is the point: a
|
|
# node keeping its old system-id across a reset re-registers under the old
|
|
# chassis name and collides with the new one over the encap IP.
|
|
log "removing OVS bridges and identity"
|
|
if command -v ovs-vsctl >/dev/null 2>&1; then
|
|
run sudo systemctl start openvswitch-switch 2>/dev/null || true
|
|
$DRY_RUN || sleep 1
|
|
# Listed even under --dry-run: "which bridges" is the question an operator
|
|
# actually has here, and br-wan is a Linux bridge so it is never in this set.
|
|
for br in $(sudo ovs-vsctl list-br 2>/dev/null || true); do
|
|
log " deleting bridge: $br"
|
|
run sudo ovs-vsctl --if-exists del-br "$br"
|
|
done
|
|
run sudo ovs-vsctl --if-exists clear Open_vSwitch . external_ids 2>/dev/null || true
|
|
run sudo systemctl stop openvswitch-switch 2>/dev/null || true
|
|
fi
|
|
run sudo rm -f /etc/openvswitch/system-id.conf
|
|
|
|
# Delete the OVN DBs outright — a caller re-running setup-ovn.sh gets fresh
|
|
# empty ones. This is what clears stale chassis rows and port bindings, which
|
|
# otherwise accumulate across resets and wedge ovn-controller in a commit loop.
|
|
# The firewall policy under $ETC_DIR/firewall is installed state, not cluster
|
|
# state, but the wipe below cannot tell them apart and takes the lot. Losing it
|
|
# is silent and permanent: `spinifex-firewall-apply set-peers` fails on every
|
|
# attempt with "no firewall policy", so the daemon never re-arms and the node
|
|
# stays open forever. Read the arming mode now and regenerate the stage after
|
|
# the wipe. peers.nft is the one file here that really is cluster state, and
|
|
# regenerating does not bring it back.
|
|
#
|
|
# Reading it from $ETC_DIR alone is not enough, because a run that fails between
|
|
# the wipe and the restore has already deleted the file. The retry then reads
|
|
# the absence as "this node was never armed", skips the restore, and
|
|
# install-node.sh honours that and forms the cluster with an open node on a
|
|
# public address. Stash the mode outside every wiped tree so the retry can still
|
|
# find it, and keep it there until the policy is back.
|
|
FIREWALL_STASH=/var/lib/spinifex-reset/firewall-mode
|
|
|
|
FIREWALL_MODE=""
|
|
if [ -r "$ETC_DIR/firewall/mode" ]; then
|
|
FIREWALL_MODE=$(sudo cat "$ETC_DIR/firewall/mode" 2>/dev/null | tr -d '[:space:]')
|
|
elif [ -r "$FIREWALL_STASH" ]; then
|
|
FIREWALL_MODE=$(sudo cat "$FIREWALL_STASH" 2>/dev/null | tr -d '[:space:]')
|
|
log "recovered the firewall mode from an interrupted reset: $FIREWALL_MODE"
|
|
fi
|
|
|
|
# Absent from both means this node genuinely has no policy — a fresh install or
|
|
# a dev tree — and arming one here would be inventing a decision nobody made.
|
|
if [ -n "$FIREWALL_MODE" ] && ! $DRY_RUN; then
|
|
sudo mkdir -p "$(dirname "$FIREWALL_STASH")"
|
|
printf '%s\n' "$FIREWALL_MODE" | sudo tee "$FIREWALL_STASH" >/dev/null
|
|
fi
|
|
|
|
# Named in the transcript because the risk runs the other way too: an allowlist
|
|
# naming the old cluster's management network locks an operator out of the new one.
|
|
if [ -e "$ETC_DIR/firewall/custom.nft" ]; then
|
|
log "keeping the operator ssh allowlist in $ETC_DIR/firewall/custom.nft"
|
|
fi
|
|
|
|
log "removing OVN databases"
|
|
run sudo systemctl stop ovn-central 2>/dev/null || true
|
|
run sudo systemctl stop ovn-controller 2>/dev/null || true
|
|
run sudo rm -f /var/lib/ovn/ovnnb_db.db /var/lib/ovn/ovnsb_db.db
|
|
|
|
# The RAFT membership flags live in this file, not in the databases just
|
|
# deleted. Left behind, ovn-central recreates the DB as a cluster member and
|
|
# blocks dialling a peer that has itself been reset.
|
|
run sudo rm -f /etc/default/ovn-central
|
|
|
|
if ip link show veth-wan-br >/dev/null 2>&1; then
|
|
log " deleting veth pair: veth-wan-br <-> veth-wan-ovs"
|
|
run sudo ip link del veth-wan-br 2>/dev/null || true
|
|
fi
|
|
|
|
# Without this, systemd-networkd recreates the veth on the next reboot even
|
|
# after a full reset.
|
|
if [ -e /etc/systemd/network/15-spinifex-veth-wan.netdev ] ||
|
|
[ -e /etc/systemd/network/15-spinifex-veth-wan.network ] ||
|
|
[ -e /etc/systemd/network/16-spinifex-veth-wan-ovs.network ]; then
|
|
log " deleting veth persistence units"
|
|
run sudo rm -f /etc/systemd/network/15-spinifex-veth-wan.netdev \
|
|
/etc/systemd/network/15-spinifex-veth-wan.network \
|
|
/etc/systemd/network/16-spinifex-veth-wan-ovs.network
|
|
run sudo networkctl reload 2>/dev/null || true
|
|
fi
|
|
|
|
# `rm -rf` is wrong here on two counts. On an ISO-installed node several of
|
|
# these are separate ZFS datasets — rpool/log and rpool/data/{nats,viperblock,
|
|
# predastore,predastore-db,predastore-nodes} — and a mountpoint cannot be
|
|
# removed at all. And the directory tree is itself install state worth keeping:
|
|
# it carries the ownership setup.sh assigned per service.
|
|
log "wiping ${WIPE_DIRS[*]}"
|
|
for dir in "${WIPE_DIRS[@]}"; do
|
|
[ -d "$dir" ] || continue
|
|
if $DRY_RUN; then
|
|
echo " would empty: $dir"
|
|
continue
|
|
fi
|
|
keep_args "$dir"
|
|
# Regular files only, so directories and symlinks survive along with the
|
|
# per-service ownership setup.sh assigned. Nothing then has to be
|
|
# reinstalled afterwards, which is what lets this script install nothing
|
|
# and leave a node on the exact build it was already running.
|
|
sudo find "$dir" -mindepth 1 \( -type f -o -type s -o -type p \) \
|
|
! \( "${KEEP_TOP[@]}" \) -delete 2>/dev/null || true
|
|
done
|
|
|
|
# The wipe above keeps directories, so the pre-cutover cluster/host-N/node-M
|
|
# skeleton would survive beside the node-<id> dirs written now. predastore owns
|
|
# this tree, not setup.sh, so removing it outright loses no installed ownership.
|
|
if ! $KEEP_DATA && [ -d "$DATA_DIR/predastore/cluster" ]; then
|
|
log "removing the predastore cluster tree"
|
|
run sudo rm -rf "$DATA_DIR/predastore/cluster"
|
|
fi
|
|
|
|
# JetStream needs the same treatment for the same reason, and the consequence of
|
|
# skipping it is worse. Emptying the files leaves a $G/streams/KV_<name> directory
|
|
# per stream the old cluster held; NATS enumerates those on start and reports them
|
|
# as HA assets it must restore, so a four-node meta group sits at size=4 with no
|
|
# leader, elected over hundreds of assets that have no data behind them. Every
|
|
# service then fails to reach JetStream and the daemon aborts its bootstrap.
|
|
# The directory is recreated on start, so its ownership is not installed state.
|
|
if ! $KEEP_DATA && [ -d "$DATA_DIR/nats/jetstream" ]; then
|
|
log "removing the JetStream store"
|
|
run sudo rm -rf "$DATA_DIR/nats/jetstream"
|
|
fi
|
|
|
|
# Directories may survive — a mountpoint cannot be removed, and neither can the
|
|
# path leading down to one. Files may not: a surviving file is state that would
|
|
# carry into the new cluster, which is the exact failure this script exists to
|
|
# prevent, so refuse rather than hand back a node that looks clean.
|
|
if ! $DRY_RUN; then
|
|
for dir in "${WIPE_DIRS[@]}"; do
|
|
[ -d "$dir" ] || continue
|
|
keep_args "$dir"
|
|
left=$(sudo find "$dir" -mindepth 1 ! -type d ! -type l \
|
|
! \( "${KEEP_TOP[@]}" \) 2>/dev/null | head -5)
|
|
if [ -n "$left" ]; then
|
|
echo "ERROR: $dir still holds files after the wipe:" >&2
|
|
echo "$left" | sed 's/^/ /' >&2
|
|
exit 1
|
|
fi
|
|
done
|
|
fi
|
|
|
|
# Put the firewall policy back. Only this stage runs, so the node stays on the
|
|
# build it was already running — nothing is downloaded or reinstalled.
|
|
#
|
|
# A node that arrived armed and leaves unarmed is a failure, not a warning. It
|
|
# sits on a public address with nothing in front of it, and the only signal used
|
|
# to be a log line that reads as cosmetic. Exit non-zero so the caller stops
|
|
# rather than forming a cluster around it, and leave the stash in place so the
|
|
# retry still knows what mode to restore.
|
|
SETUP_SH=/usr/local/share/spinifex/setup.sh
|
|
if [ -n "$FIREWALL_MODE" ]; then
|
|
if [ ! -x "$SETUP_SH" ]; then
|
|
echo "ERROR: $SETUP_SH is missing, cannot restore the firewall policy." >&2
|
|
echo " This node was armed ($FIREWALL_MODE) and is now open. Reinstall it" >&2
|
|
echo " or arm it by hand before forming a cluster." >&2
|
|
$DRY_RUN || exit 1
|
|
fi
|
|
log "restoring the host firewall policy (mode: $FIREWALL_MODE)"
|
|
if run sudo env SETUP_STAGES=firewall "$SETUP_SH" --firewall "$FIREWALL_MODE"; then
|
|
run sudo rm -f "$FIREWALL_STASH"
|
|
else
|
|
echo "ERROR: could not restore the firewall policy." >&2
|
|
echo " This node was armed ($FIREWALL_MODE) and is now open on its public" >&2
|
|
echo " addresses. Repair with:" >&2
|
|
echo " sudo env SETUP_STAGES=firewall $SETUP_SH --firewall $FIREWALL_MODE" >&2
|
|
echo " sudo systemctl restart spinifex-daemon" >&2
|
|
$DRY_RUN || exit 1
|
|
fi
|
|
fi
|
|
|
|
if [ ! -d /etc/spinifex ]; then
|
|
echo "ERROR: /etc/spinifex is missing and could not be restored." >&2
|
|
echo " spx would treat this node as a dev install and build it under ~/spinifex," >&2
|
|
echo " forming a cluster whose services can never start. Re-run the installer." >&2
|
|
$DRY_RUN || exit 1
|
|
fi
|
|
|
|
# A node that has already been through that fallback carries a stray dev-layout
|
|
# tree holding its own keys and configs. It is not user data — it is wreckage
|
|
# from a misdetected layout — and leaving it invites a later dev-mode run to
|
|
# adopt it.
|
|
#
|
|
# `|| true` is load bearing. setup.sh creates the *group* spinifex and the eight
|
|
# spinifex-* service users, never a user named spinifex — that account comes from
|
|
# the ISO's interactive login. So on any curl|bash-installed node `getent passwd
|
|
# spinifex` exits 2, and under `set -euo pipefail` that took the whole script
|
|
# down at the last step, after the wipe had already happened. Prod and dev-prod
|
|
# are ISO-installed, which is why it stayed hidden.
|
|
DEV_HOME="$(getent passwd spinifex | cut -d: -f6 || true)"
|
|
DEV_ROOT="${DEV_HOME}/spinifex"
|
|
if [ -n "$DEV_HOME" ] && [ -d "$DEV_ROOT" ]; then
|
|
log "removing the dev-layout fallback tree at $DEV_ROOT"
|
|
run sudo rm -rf "$DEV_ROOT"
|
|
fi
|
|
|
|
# The next init writes a fresh CA. Leaving the old one trusted means the host
|
|
# trusts a CA nobody holds the key for.
|
|
if [ -f /usr/local/share/ca-certificates/spinifex-ca.crt ]; then
|
|
log "removing the stale CA from the trust store"
|
|
run sudo rm -f /usr/local/share/ca-certificates/spinifex-ca.crt
|
|
run sudo update-ca-certificates
|
|
fi
|
|
|
|
log "done — node is installed but uninitialized, ready to init or join"
|