Unverified Commit 6f7b8d7b authored by Lemon-miaow's avatar Lemon-miaow
Browse files

feat(deploy): 新增 uninstall.sh(默认保留数据,--purge 全删)与 docs/operations.md 支持矩阵、容量与重装恢复手册

parent 989be55b
Loading
Loading
Loading
Loading
+1 −0
Changes for .github/workflows/ci.yml: 1 added line, 0 removed lines.
Original line number Diff line number Diff line
@@ -135,6 +135,7 @@ jobs:
          ./shellcheck-v0.11.0/shellcheck -S warning $(git ls-files '*.sh')

      - run: sh deploy/bootstrap_test.sh
      - run: sh deploy/uninstall_test.sh

  panel:
    runs-on: ubuntu-latest

deploy/uninstall.sh

0 → 100644
+428 −0
Changes for deploy/uninstall.sh: 428 added lines, 0 removed lines.
Original line number Diff line number Diff line
#!/usr/bin/env bash
# Removes what deploy/bootstrap.sh installed on this host.
#
#   sudo bash deploy/uninstall.sh                 # remove Felis, keep the data
#   sudo bash deploy/uninstall.sh --purge         # remove the data too
#   curl -fsSL <raw-url>/deploy/uninstall.sh | sudo bash -s -- --yes
#
# Options:
#   --purge       also drop the felis database and role, and delete /etc/felis and
#                 /var/lib/felis (the database bundles, and anything an earlier keep-data
#                 run set aside). Asks for the word "purge" unless --yes is given.
#   --keep-k3s    leave k3s installed and remove only Felis's namespaces and CRD.
#   --remove-k3s  run k3s's own uninstaller even when other workloads live in the cluster.
#   --no-backup   skip the final database bundle keep-data mode takes first.
#   --yes         do not ask.
#
# Keep-data mode (the default) first takes a database bundle (`felis db backup -label
# manual`) and stops if that fails. It leaves PostgreSQL's felis database, /etc/felis (the
# secrets, felis.toml, offsite.env) and /var/lib/felis in place. The world, archive,
# registry and upload volumes live under k3s's storage directory, which k3s's uninstaller
# deletes, so they are moved to /var/lib/felis/retained/k3s-storage-<UTC stamp> first; with
# --keep-k3s their PersistentVolumes are switched to Retain before the namespaces go.
# docs/operations.md walks through reinstalling on top of what is left.
#
# Either mode leaves packages alone (Docker, PostgreSQL, git and the rest), and the swap
# file a low-memory host got: other software may use them. docs/operations.md lists the
# package commands for a bare host.
set -Eeuo pipefail

STATE_DIR="${STATE_DIR:-/etc/felis}"
DATA_DIR="${DATA_DIR:-/var/lib/felis}"
RETAIN_DIR="${DATA_DIR}/retained"
HOST_BIN="${HOST_BIN:-/usr/local/bin/felis}"
OPT_DIR="${OPT_DIR:-/opt/felis}"
UNIT_DIR="${UNIT_DIR:-/etc/systemd/system}"
K3S_BIN_DIR="${K3S_BIN_DIR:-/usr/local/bin}"
K3S_STORAGE="${K3S_STORAGE:-/var/lib/rancher/k3s/storage}"
K3S_REGISTRIES="${K3S_REGISTRIES:-/etc/rancher/k3s/registries.yaml}"
export KUBECONFIG="${KUBECONFIG:-/etc/rancher/k3s/k3s.yaml}"
CLOUDFLARED_BIN="${CLOUDFLARED_BIN:-/usr/local/bin/cloudflared}"
VELOCITY_USER="felis-velocity"
DB_NAME="felis"
DB_USER="felis"
POD_CIDR="10.42.0.0/16"
SERVICE_CIDR="10.43.0.0/16"
FELIS_PANEL_NODEPORT="${FELIS_PANEL_NODEPORT:-30443}"
CONFIRM_TTY="${CONFIRM_TTY:-/dev/tty}"
FELIS_NAMESPACES=(felis minecraft felis-build)
FELIS_CRD="minecraftservers.felis.lolicon.best"
# Every unit the installer and `felis setup` write. Timers first, so none fires into a
# service that is already gone.
FELIS_UNITS=(
  felis-db-backup.timer felis-watchdog.timer felis-offsite.timer felis-build-tools.timer
  felis-db-backup.service felis-watchdog.service felis-offsite.service felis-build-tools.service
  felis-velocity.service felis-nano.service cloudflared-felis.service
  felis-postgres-firewall.service
)

PURGE=0
K3S_MODE=auto
BACKUP=1
ASSUME_YES=0
TUNNEL_CONFIG=""

log()  { printf '\033[1;36m[felis]\033[0m %s\n' "$*"; }
ok()   { printf '\033[1;32m[ ok ]\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[warn]\033[0m %s\n' "$*" >&2; }
die()  { printf '\033[1;31m[fail]\033[0m %s\n' "$*" >&2; exit 1; }

parse_args() {
  while [ $# -gt 0 ]; do
    case "$1" in
      --purge) PURGE=1 ;;
      --keep-k3s) K3S_MODE=keep ;;
      --remove-k3s) K3S_MODE=remove ;;
      --no-backup) BACKUP=0 ;;
      --yes|-y) ASSUME_YES=1 ;;
      -h|--help) printf 'usage: uninstall.sh [--purge] [--keep-k3s|--remove-k3s] [--no-backup] [--yes]\n'; exit 0 ;;
      *) die "unknown option: $1 (see --help)" ;;
    esac
    shift
  done
}

kube() { "${K3S_BIN_DIR}/k3s" kubectl "$@"; }
k3s_present() { [ -x "${K3S_BIN_DIR}/k3s" ]; }

# foreign_namespaces prints the namespaces that are neither k3s's own nor Felis's, one per
# line. A cluster with none of them exists for Felis alone, and removing k3s takes nothing
# else with it.
foreign_namespaces() {
  kube get namespaces -o 'jsonpath={range .items[*]}{.metadata.name}{"\n"}{end}' \
    | awk '$0 != "" && $0 != "default" && $0 !~ /^kube-/ && $0 != "felis" && $0 != "minecraft" && $0 != "felis-build"'
}

# decide_k3s turns K3S_MODE=auto into keep or remove.
decide_k3s() {
  k3s_present || { K3S_MODE=absent; return 0; }
  [ "$K3S_MODE" = auto ] || return 0
  local others
  if ! others="$(foreign_namespaces)"; then
    die "k3s does not answer, so this cannot tell whether it runs anything besides Felis; start it (systemctl start k3s) or pass --keep-k3s or --remove-k3s"
  fi
  if [ -z "$others" ]; then
    K3S_MODE=remove
  else
    K3S_MODE=keep
    log "k3s also runs namespaces Felis did not create ($(printf '%s' "$others" | tr '\n' ' ')); leaving k3s installed"
  fi
}

confirm() {
  [ "$ASSUME_YES" = 1 ] && return 0
  local want="yes" answer=""
  [ "$PURGE" = 1 ] && want="purge"
  # A piped script has no stdin to read from; the terminal is asked directly.
  { exec 3<"$CONFIRM_TTY" 4>>"$CONFIRM_TTY"; } 2>/dev/null || die "no terminal to confirm on; re-run with --yes"
  printf 'Type "%s" to continue: ' "$want" >&4
  read -r answer <&3 || true
  exec 3<&- 4>&-
  [ "$answer" = "$want" ] || die "not confirmed; nothing was changed"
}

print_plan() {
  log "this will remove from $(uname -n):"
  log "  the felis-* systemd units, cloudflared-felis.service, the ${VELOCITY_USER} user,"
  log "  ${OPT_DIR}, ${HOST_BIN}, the felis_postgres and felis_edge nftables tables and the firewalld openings"
  case "$K3S_MODE" in
    remove) log "  k3s, with everything in it (${K3S_BIN_DIR}/k3s-uninstall.sh)" ;;
    keep) log "  Felis's namespaces (${FELIS_NAMESPACES[*]}) and the ${FELIS_CRD} CRD; k3s stays" ;;
    absent) ;;
  esac
  if [ "$PURGE" = 1 ]; then
    log "  PURGE: the ${DB_NAME} database and role, ${STATE_DIR} (secrets), ${DATA_DIR} (database bundles"
    log "  and anything set aside before), every world and archive, the Felis images and Docker's build cache"
  else
    [ "$BACKUP" = 1 ] && log "  after a final database bundle into ${DATA_DIR}/db-backups"
    log "  kept: the ${DB_NAME} database, ${STATE_DIR}, ${DATA_DIR}; the volumes move to ${RETAIN_DIR}/"
  fi
}

final_backup() {
  [ "$PURGE" = 0 ] && [ "$BACKUP" = 1 ] || return 0
  [ -x "$HOST_BIN" ] && [ -r "${STATE_DIR}/felis.host.toml" ] || {
    warn "no ${HOST_BIN} or ${STATE_DIR}/felis.host.toml; skipping the final database bundle"
    return 0
  }
  log "taking a final database bundle"
  "$HOST_BIN" db backup -config "${STATE_DIR}/felis.host.toml" -label manual \
    || die "the final database bundle failed, so nothing was removed. Fix the database (sudo felis db check), or pass --no-backup to go on without one"
  ok "database bundle written to ${DATA_DIR}/db-backups"
}

# game_port reads the proxy's port from velocity.toml before /opt/felis goes.
game_port() {
  local toml="${OPT_DIR}/velocity/velocity.toml" port=""
  [ -r "$toml" ] && port="$(sed -n 's/^bind *= *"[^"]*:\([0-9][0-9]*\)".*/\1/p' "$toml" | head -n 1)"
  printf '%s\n' "${FELIS_GAME_PORT:-${port:-25565}}"
}

# tunnel_config reads the cloudflared config felis setup pointed its unit at (by default
# /etc/felis/cloudflared.yml) before the unit goes.
tunnel_config() {
  local unit="${UNIT_DIR}/cloudflared-felis.service" path=""
  [ -r "$unit" ] && path="$(sed -n 's/^ExecStart=.* --config \([^ ]*\) tunnel run$/\1/p' "$unit" | head -n 1)"
  printf '%s\n' "${path:-${STATE_DIR}/cloudflared.yml}"
}

# nano_port reads felis-nano's port from its unit before the unit goes.
nano_port() {
  local unit="${UNIT_DIR}/felis-nano.service"
  [ -r "$unit" ] || return 0
  sed -n 's/^ExecStart=.* -listen [^ ]*:\([0-9][0-9]*\).*$/\1/p' "$unit" | head -n 1
}

remove_units() {
  local unit removed=0
  for unit in "${FELIS_UNITS[@]}"; do
    [ -f "${UNIT_DIR}/${unit}" ] || continue
    systemctl disable --now "$unit" >/dev/null 2>&1 || systemctl stop "$unit" >/dev/null 2>&1 || true
    rm -f "${UNIT_DIR}/${unit}"
    removed=$((removed + 1))
  done
  systemctl daemon-reload
  systemctl reset-failed >/dev/null 2>&1 || true
  ok "${removed} systemd unit(s) removed"
}

remove_nft_tables() {
  command -v nft >/dev/null 2>&1 || return 0
  local t
  for t in felis_postgres felis_edge; do
    if nft list table inet "$t" >/dev/null 2>&1; then
      nft delete table inet "$t"
      ok "nftables table inet ${t} removed"
    fi
  done
}

# remove_firewalld_rules takes back what configure_k3s_firewall, configure_velocity_firewall
# and the nano setup opened. The k3s ones stay when k3s does.
remove_firewalld_rules() { # game-port nano-port
  command -v firewall-cmd >/dev/null 2>&1 || return 0
  systemctl is-active --quiet firewalld || return 0
  local game="$1" nano="$2" port rule changed=0
  local ports=("${game}/tcp" "${FELIS_PANEL_NODEPORT}/tcp")
  [ -n "$nano" ] && ports+=("${nano}/tcp")
  [ "$K3S_MODE" = remove ] && ports+=("6443/tcp")
  for port in "${ports[@]}"; do
    if firewall-cmd --permanent --query-port="$port" >/dev/null 2>&1; then
      firewall-cmd --permanent --remove-port="$port" >/dev/null
      changed=1
    fi
  done
  if [ -n "$nano" ]; then
    while IFS= read -r rule; do
      case "$rule" in
        *"port=\"${nano}\""*) firewall-cmd --permanent --remove-rich-rule="$rule" >/dev/null; changed=1 ;;
      esac
    done < <(firewall-cmd --permanent --list-rich-rules 2>/dev/null)
  fi
  if [ "$K3S_MODE" = remove ]; then
    local cidr
    for cidr in "$POD_CIDR" "$SERVICE_CIDR"; do
      if firewall-cmd --permanent --zone=trusted --query-source="$cidr" >/dev/null 2>&1; then
        firewall-cmd --permanent --zone=trusted --remove-source="$cidr" >/dev/null
        changed=1
      fi
    done
  fi
  if [ "$changed" = 1 ]; then
    firewall-cmd --reload >/dev/null
    ok "firewalld openings removed"
  fi
}

# retain_volumes_in_cluster keeps every volume Felis's claims are bound to when the
# namespaces go: local-path deletes a Delete-policy volume's directory with its claim.
retain_volumes_in_cluster() {
  local pv
  while IFS= read -r pv; do
    [ -n "$pv" ] || continue
    kube patch pv "$pv" -p '{"spec":{"persistentVolumeReclaimPolicy":"Retain"}}' >/dev/null
  done < <(kube get pv -o 'jsonpath={range .items[*]}{.metadata.name} {.spec.claimRef.namespace}{"\n"}{end}' \
    | awk '$2 == "felis" || $2 == "minecraft" || $2 == "felis-build" { print $1 }')
  ok "Felis's volumes set to Retain; their directories stay under ${K3S_STORAGE}"
}

remove_from_cluster() {
  [ "$PURGE" = 1 ] || retain_volumes_in_cluster
  log "deleting Felis's namespaces and CRD"
  # The operator is part of what goes, so nothing would clear a MinecraftServer finalizer
  # and the minecraft namespace would stay Terminating. Drop them first.
  local s
  while IFS= read -r s; do
    [ -n "$s" ] || continue
    kube -n minecraft patch minecraftserver "$s" --type=merge -p '{"metadata":{"finalizers":null}}' >/dev/null 2>&1 || true
  done < <(kube -n minecraft get minecraftservers -o 'jsonpath={range .items[*]}{.metadata.name}{"\n"}{end}' 2>/dev/null || true)
  kube delete namespace "${FELIS_NAMESPACES[@]}" --ignore-not-found --wait=true --timeout=300s >/dev/null \
    || warn "a namespace is still terminating; check: k3s kubectl get namespaces"
  kube delete crd "$FELIS_CRD" --ignore-not-found >/dev/null || true
  if [ "$PURGE" = 1 ]; then
    local pv
    while IFS= read -r pv; do
      [ -n "$pv" ] && kube delete pv "$pv" --ignore-not-found >/dev/null
    done < <(kube get pv -o 'jsonpath={range .items[*]}{.metadata.name} {.spec.claimRef.namespace}{"\n"}{end}' \
      | awk '$2 == "felis" || $2 == "minecraft" || $2 == "felis-build" { print $1 }')
  fi
  if [ -f "$K3S_REGISTRIES" ] && grep -q 'registry\.felis\.svc' "$K3S_REGISTRIES"; then
    rm -f "$K3S_REGISTRIES"
    systemctl restart k3s
  fi
  ok "Felis removed from the cluster; k3s stays"
}

remove_k3s() {
  local stamp
  if [ "$PURGE" = 0 ] && [ -d "$K3S_STORAGE" ]; then
    # k3s-killall.sh stops every pod and unmounts their volumes, so nothing is writing a
    # world while it moves.
    "${K3S_BIN_DIR}/k3s-killall.sh" >/dev/null 2>&1 || systemctl stop k3s
    stamp="$(date -u +%Y%m%dT%H%M%SZ)"
    install -d -m 0700 "$RETAIN_DIR"
    mv "$K3S_STORAGE" "${RETAIN_DIR}/k3s-storage-${stamp}"
    ok "volumes moved to ${RETAIN_DIR}/k3s-storage-${stamp}"
  fi
  if [ -x "${K3S_BIN_DIR}/k3s-uninstall.sh" ]; then
    log "running k3s-uninstall.sh"
    "${K3S_BIN_DIR}/k3s-uninstall.sh" >/dev/null 2>&1 || warn "k3s-uninstall.sh reported an error; check /var/lib/rancher and /etc/rancher"
    ok "k3s removed"
  else
    warn "k3s is at ${K3S_BIN_DIR}/k3s but ${K3S_BIN_DIR}/k3s-uninstall.sh is missing; remove k3s by hand"
  fi
}

# remove_cloudflared_binary deletes the binary the installer put in /usr/local/bin, unless
# a unit other than Felis's still runs it.
remove_cloudflared_binary() {
  [ -x "$CLOUDFLARED_BIN" ] || return 0
  local others
  others="$(grep -ls "$CLOUDFLARED_BIN" "${UNIT_DIR}"/*.service /lib/systemd/system/*.service /usr/lib/systemd/system/*.service 2>/dev/null || true)"
  if [ -n "$others" ]; then
    log "leaving ${CLOUDFLARED_BIN}: $(printf '%s' "$others" | tr '\n' ' ')uses it"
    return 0
  fi
  rm -f "$CLOUDFLARED_BIN"
  ok "${CLOUDFLARED_BIN} removed"
}

remove_host_files() {
  if id "$VELOCITY_USER" >/dev/null 2>&1; then
    userdel "$VELOCITY_USER" >/dev/null 2>&1 || warn "could not remove the ${VELOCITY_USER} user"
  fi
  rm -rf "$OPT_DIR"
  rm -f "$HOST_BIN" "${HOST_BIN}.new"
  remove_cloudflared_binary
  if [ "$PURGE" = 0 ]; then
    # What describes the removed install goes; what a reinstall reuses stays. Without
    # bootstrap.done the next run takes the first-install path.
    rm -f "${STATE_DIR}/bootstrap.done" "${STATE_DIR}/system-server-images" \
      "${STATE_DIR}/velocity.fingerprint" "${STATE_DIR}/previous-felis-image"
  fi
  ok "${OPT_DIR} and ${HOST_BIN} removed"
}

as_postgres() { (cd / && runuser -u postgres -- "$@"); }

# remove_hba_block drops the block write_pg_hba_block maintains, and nothing else.
remove_hba_block() { # file
  local tmp
  tmp="$(mktemp)"
  awk '
    $0 == "# BEGIN FELIS MANAGED HBA" { skip = 1; next }
    $0 == "# END FELIS MANAGED HBA" { skip = 0; blank = 1; next }
    blank && $0 == "" { blank = 0; next }
    { blank = 0 }
    !skip { print }
  ' "$1" > "$tmp"
  cat "$tmp" > "$1"
  rm -f "$tmp"
}

purge_database() {
  [ "$PURGE" = 1 ] || return 0
  if ! systemctl is-active --quiet postgresql 2>/dev/null; then
    warn "PostgreSQL is not running; the ${DB_NAME} database and role are left in it"
    return 0
  fi
  local hba
  hba="$(as_postgres psql -tAc 'SHOW hba_file;' 2>/dev/null || true)"
  as_postgres psql -v ON_ERROR_STOP=1 -q <<SQL
SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${DB_NAME}' AND pid <> pg_backend_pid();
DROP DATABASE IF EXISTS ${DB_NAME};
DROP ROLE IF EXISTS ${DB_USER};
ALTER SYSTEM RESET listen_addresses;
SQL
  [ -n "$hba" ] && [ -f "$hba" ] && remove_hba_block "$hba"
  systemctl restart postgresql
  ok "database and role '${DB_NAME}' dropped; PostgreSQL listens on its default address again"
}

purge_images() {
  [ "$PURGE" = 1 ] || return 0
  command -v docker >/dev/null 2>&1 || return 0
  # The installer stops Docker after its builds; start it just long enough to clean up.
  local was_active=1 refs
  systemctl is-active --quiet docker || { was_active=0; systemctl start docker >/dev/null 2>&1 || return 0; }
  refs="$(docker image ls --format '{{.Repository}}:{{.Tag}}' | grep -E '^(registry\.felis\.svc:5000/felis/|felis/)' || true)"
  if [ -n "$refs" ]; then
    # shellcheck disable=SC2086 # one ref per word
    docker image rm -f $refs >/dev/null 2>&1 || true
  fi
  docker builder prune -af >/dev/null 2>&1 || true
  [ "$was_active" = 1 ] || systemctl stop docker docker.socket >/dev/null 2>&1 || true
  ok "Felis images and Docker's build cache removed"
}

purge_state() {
  [ "$PURGE" = 1 ] || return 0
  # felis setup's tunnel credentials sit beside cloudflared's login (cert.pem, which stays:
  # it is the Cloudflare account's, not Felis's). The tunnel itself lives on in the account
  # until it is deleted there (docs/operations.md).
  local cred=""
  [ -r "$TUNNEL_CONFIG" ] \
    && cred="$(sed -n 's/^credentials-file: *"\{0,1\}\([^"]*\)"\{0,1\} *$/\1/p' "$TUNNEL_CONFIG" | head -n 1)"
  if [ -n "$cred" ]; then rm -f "$cred"; fi
  rm -f "$TUNNEL_CONFIG"
  rm -rf "$STATE_DIR" "$DATA_DIR"
  ok "${STATE_DIR} and ${DATA_DIR} removed"
}

main() {
  parse_args "$@"
  [ "$(id -u)" = 0 ] || die "run as root: sudo bash $0"
  [ -e "$STATE_DIR" ] || [ -e "$HOST_BIN" ] || [ -e "$OPT_DIR" ] \
    || die "no Felis install here (${STATE_DIR}, ${HOST_BIN} and ${OPT_DIR} are all absent)"
  decide_k3s
  print_plan
  confirm
  final_backup

  local game nano
  game="$(game_port)"
  nano="$(nano_port)"
  TUNNEL_CONFIG="$(tunnel_config)"
  remove_units
  case "$K3S_MODE" in
    remove) remove_k3s ;;
    keep) remove_from_cluster ;;
  esac
  remove_nft_tables
  remove_firewalld_rules "$game" "$nano"
  remove_host_files
  purge_database
  purge_images
  purge_state

  if [ "$PURGE" = 1 ]; then
    ok "Felis is gone from this host"
  else
    ok "Felis is removed; the data stays in the ${DB_NAME} database, ${STATE_DIR} and ${DATA_DIR}"
    log "reinstalling reuses it: see docs/operations.md, \"Reinstall on top of kept data\""
  fi
}

if [ "${FELIS_UNINSTALL_SOURCED:-0}" != 1 ]; then
  main "$@"
fi
+214 −0

File added.

Preview size limit exceeded, changes collapsed.

docs/operations.md

0 → 100644
+250 −0

File added.

Preview size limit exceeded, changes collapsed.