Files
ServerKit/scripts/lib/state.sh
T
Juan DenisandClaude Opus 4.8 754bfb4a67 feat(install): Phase 1 hardening — firewall automation, py3.11/3.12, unified uninstall
Critical install/update/uninstall hotfixes from the hardening plan
(docs/plans/INSTALLER_UPDATER_UNINSTALLER_HARDENING_PLAN.md §4):

- Firewall automation (G1): new sourceable scripts/lib/firewall.sh detects
  firewalld/ufw/nftables/iptables and opens 80/443; install.sh runs
  configure_firewall, update.sh re-ensures it idempotently. Exactly what we
  open is recorded in /etc/serverkit/install-state.json (scripts/lib/state.sh)
  so uninstall undoes only our rules. Fixes the #1 "installed but unreachable"
  failure on fresh RHEL-family boxes.

- Accept Python 3.11 OR 3.12 (G2): locate_python now iterates
  python3.12/3.11/3 and provision_python installs the distro package
  (python3.11 on Debian 12, 3.12 on Ubuntu/Fedora, 3.11/3.12 on RHEL) before
  ever falling back to a source compile.

- Use $PYTHON_BIN for secret generation instead of a bare python3.

- Distro-first Node 20 (replacing unconditional NodeSource): install the
  distro nodejs+npm, fall back to NodeSource only when it's missing or < 18,
  and validate node>=18 before continuing.

- Unify uninstall (G4): new scripts/lib/uninstall.sh is the single canonical
  teardown routine shared by uninstall.sh and `serverkit uninstall` (they had
  drifted — one ran `down -v`, one didn't). Default preserves user data
  (volumes kept, DB snapshotted to backups); --purge/--delete-volumes removes
  volumes+DB+data dirs; --keep-data preserves /etc + /var/lib + backups;
  --yes/--dry-run added; honors SERVERKIT_DIR. Removes the apt lock-wait
  drop-in on uninstall.

Tests: new scripts/test/test_install.sh (11 cases, source-able install.sh)
covers python/node detection, firewall dry-run + state, and the uninstall
data-handling matrix. Existing test_update.sh still green. shellcheck clean
(error + warning) on all new/changed shell.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:24:39 -04:00

106 lines
3.0 KiB
Bash

# shellcheck shell=bash
#
# scripts/lib/state.sh — tiny install-state tracker.
#
# ServerKit makes a handful of global, host-wide changes during install
# (firewall ports, an apt lock-wait drop-in, an nginx TLS snippet). For uninstall
# to be reliable and for re-runs to be idempotent, we record exactly what we
# touched in /etc/serverkit/install-state.json and undo only those things on the
# way out — never a rule or file the operator added themselves (Goal G8).
#
# Backed by python3, which is a hard ServerKit dependency (the whole backend is
# Python), so it is present on any installed box. If python3 is somehow missing
# the helpers degrade to warn-and-no-op rather than aborting the caller.
#
# API:
# state_set <key> <value> set a scalar
# state_get <key> print a scalar ("" if absent)
# state_append <key> <value> append to an array (deduped)
# state_list <key> print array items, one per line
# state_unset <key> remove a key entirely
#
# Override the file location with SERVERKIT_STATE_FILE (used by tests).
SERVERKIT_STATE_FILE="${SERVERKIT_STATE_FILE:-/etc/serverkit/install-state.json}"
_state_have_py() { command -v python3 >/dev/null 2>&1; }
_state_py() {
# Args: <action> <key> [value]
SERVERKIT_STATE_FILE="$SERVERKIT_STATE_FILE" python3 - "$@" <<'PY'
import json, os, sys
path = os.environ["SERVERKIT_STATE_FILE"]
action = sys.argv[1] if len(sys.argv) > 1 else ""
key = sys.argv[2] if len(sys.argv) > 2 else ""
value = sys.argv[3] if len(sys.argv) > 3 else ""
try:
with open(path) as fh:
data = json.load(fh)
if not isinstance(data, dict):
data = {}
except (FileNotFoundError, ValueError):
data = {}
def save():
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
tmp = path + ".tmp"
with open(tmp, "w") as fh:
json.dump(data, fh, indent=2, sort_keys=True)
fh.write("\n")
os.replace(tmp, path)
if action == "set":
data[key] = value
save()
elif action == "get":
v = data.get(key, "")
if v is not None:
sys.stdout.write(str(v))
elif action == "append":
arr = data.get(key)
if not isinstance(arr, list):
arr = []
if value not in arr:
arr.append(value)
data[key] = arr
save()
elif action == "list":
arr = data.get(key)
if isinstance(arr, list):
for item in arr:
sys.stdout.write(str(item) + "\n")
elif action == "unset":
data.pop(key, None)
save()
else:
sys.exit(2)
PY
}
state_set() {
_state_have_py || { printf ' [state] python3 unavailable — not recording %s\n' "$1" >&2; return 0; }
_state_py set "$1" "$2"
}
state_get() {
_state_have_py || return 0
_state_py get "$1"
}
state_append() {
_state_have_py || { printf ' [state] python3 unavailable — not recording %s\n' "$1" >&2; return 0; }
_state_py append "$1" "$2"
}
state_list() {
_state_have_py || return 0
_state_py list "$1"
}
state_unset() {
_state_have_py || return 0
_state_py unset "$1"
}