#!/bin/sh
set -eu

#
# mgmtvm-update.sh — перевод mgmtvm центра на Center поверх pkgbase той
# раздачи vstack-center, на которую направлен ( 3.2.0 — FreeBSD 14.2, линия
# 4.0 — FreeBSD 15 ), из любого поддерживаемого состояния:
#
#   classic 13 / 14, pkgbase 14 / 15   -> pkgbase цели   одна транзакция, одна перезагрузка
#   база совпадает с раздачей          -> только поставка и провижн, без перезагрузки
#
# и любого поколения центра:
#
#   old-gen  ( vStack-control-pane, PG14, rabbit 3.13 )  БД переносится дампом
#   vstack-mgmtvm ( промежуточная линия )                пакеты меняются руками
#   vstack-center                                         только пакеты и провижн
#
# Опции и устройство — по образцу узлового vstackos.update.sh ( least-install ):
# гарды мажора, проверка источника, бэкап /etc, защита загрузки, возврат
# .pkgsave, сводка, коды выхода. Отличия — фазы с перезагрузкой ( состояние в
# STATE_DIR, повторный запуск продолжает ), фаза приложения, остановка
# сервисов центра с выключенным автостартом на всё время смены ОС.
#
# Инструкция: README.md в комплекте поставки
#

usage() {
  echo "Usage: $0 [OPTIONS]"
  echo ""
  echo "Runs the next phase of the update; without --snapshots-taken only prints the plan."
  echo ""
  echo "Version / source:"
  echo "  --version X.Y.Z[-rcN]    required for tech/release; omit for dev"
  echo "  --source SOURCE          dev | tech | release | local (default dev)"
  echo "  --repo-url U             override repo URL"
  echo "  --abi ABI                pkg ABI override (default: from the repo's FreeBSD-runtime)"
  echo ""
  echo "Cross-major (FreeBSD 13/14 -> 15):"
  echo "  --allow-major-upgrade    permit a major transition; without it the run is refused"
  echo ""
  echo "Options:"
  echo "  --snapshots-taken        disk snapshots of the VM are taken: allow changes"
  echo "                           (same as env SNAPSHOT_CONFIRMED=yes)"
  echo "  --dry-run                walk the next phase without changes: commands are printed,
                           pkg transactions are solved with -n on a copy of the pkg db"
  echo "  --reboot                 reboot by itself when a phase needs it (default: say so and stop)"
  echo "  --detach                 run in the background via daemon(8), output to --log"
  echo "  --log PATH               log file (default: /var/log/mgmtvm-update.log)"
  echo "  --no-esp                 do not sync the loader to the EFI system partition"
  echo "  --no-kernel-backup       do not keep a copy of the running kernel (implies --no-fallback)"
  echo "  --no-fallback            do not arm one-shot boot of the new kernel"
  echo "  --status                 print the saved state and exit"
  echo "  --phase NAME             restart from a phase: detect app-save world app finish"
  echo "  -h|--help                this message"
  echo ""
  echo "Old-gen (vStack-control-pane):"
  echo "  --settings-reviewed      settings.yml and nodes.yml drafts are checked: proceed"
  echo "  --cleanup-oldgen         after acceptance: remove old-gen leftovers and rollback data"
  echo ""
  echo "Source table:"
  echo "  dev     blade-runner.vstack.com/repos/vstack/<chan>/center/repo  (no tag needed)"
  echo "  tech    repo.dev.vstack.com/center/<ver>/repo                    needs --version"
  echo "  release repo.vstack.com/center/<ver>/repo                        needs --version"
  echo "  local   file:///local/repos/vstack/<chan>/center/repo"
  echo ""
  echo "Exit codes: 2 guard/source, 3 pkg/kernel, 4 /etc, 5 service, 6 root shell,"
  echo "  7 /boot, 8 application (DB, provisioning), 10 reboot or operator action needed."
}

BLADE_BASE_URL="${BLADE_BASE_URL:-https://blade-runner.vstack.com}"
REPO_BASE_URL_TECH="${REPO_BASE_URL_TECH:-https://repo.dev.vstack.com}"
REPO_BASE_URL_RELEASE="${REPO_BASE_URL_RELEASE:-https://repo.vstack.com}"
PRODUCT="${PRODUCT:-vstack-center}"
CENTER_REPO_DIR="${CENTER_REPO_DIR:-center}"
PUBLISH_URL_DIR="${PUBLISH_URL_DIR:-repos/vstack}"
BUILD_CHANNEL="${BUILD_CHANNEL:-dev}"
LOCAL_REPO_ROOT="${LOCAL_REPO_ROOT:-/local/repos/vstack}"
PUBKEY_PATH="${PUBKEY_PATH:-/usr/share/keys/vstack/poudriere.pub}"
KEYS_URL_BASE="${KEYS_URL_BASE:-https://blade-runner.vstack.com/keys}"
FB="${FREEBSD_PKG_PREFIX:-FreeBSD-}"
ETC_DIR="${ETC_DIR:-/etc}"
LOCAL_ETC_DIR="${LOCAL_ETC_DIR:-/usr/local/etc}"
REPOS_DIR="${REPOS_DIR:-/usr/local/etc/pkg/repos}"
BOOT_DIR="${BOOT_DIR:-/boot}"
STATE_DIR="${STATE_DIR:-/var/db/mgmtvm-update}"
BACKUP_ROOT="${BACKUP_ROOT:-/var/backups/mgmtvm-update}"
COMPAT_DIR="${COMPAT_DIR:-/usr/lib/compat}"
LOCAL_RC_DIR="${LOCAL_RC_DIR:-/usr/local/etc/rc.d}"
VSTACK_ETC="${VSTACK_ETC:-/usr/local/etc/vstack}"
SECRETS_DIR="${SECRETS_DIR:-/var/db/vstack/secrets}"
PG_ROOT="${PG_ROOT:-/var/db/postgres}"
RABBIT_ROOT="${RABBIT_ROOT:-/var/db/rabbitmq}"
KERNEL_PREV_SUFFIX="${KERNEL_PREV_SUFFIX:-_prev}"
KERNEL_MIN_SIZE="${KERNEL_MIN_SIZE:-10485760}"
KERNEL_MIN_MODULES="${KERNEL_MIN_MODULES:-50}"
LOADER_MIN_SIZE="${LOADER_MIN_SIZE:-131072}"
BOOT_DROPIN="${BOOT_DROPIN:-${BOOT_DIR}/loader.conf.d/zz-vstack-fallback.conf}"
BOOT_MARKER_NAME="${BOOT_MARKER_NAME:-vstack_boot_marker}"
FSYNC_BIN="${FSYNC_BIN:-fsync}"
MIN_FREE_MB="${MIN_FREE_MB:-4096}"
# Пакеты раздачи, которые не ставим при доустановке базы: отладка, разработка,
# 32-бит, man, и toolchain без clang ( clang вычеркнут бан-листом поставки ).
# toolchain тянет libprivatellvm; bhyve на mgmtvm не нужен, а в 15 он
# слинкован с libssl.so.12 и libepoll-shim ( забота нод ).
BASE_SKIP_RE="${BASE_SKIP_RE:--(dbg|dev|man|lib32|dbg-lib32|dev-lib32)\$|^${FB}(toolchain|bhyve)\$}"
CENTER_SERVICES="vstack_center_dispatcher vstack_center_endpoint vstack_center_image_distributor vector angie grafana influxd rabbitmq postgresql"
MGMTVM_LINE_SERVICES="vstack_cluster_dispatcher vstack_cluster_endpoint"
MGMTVM_LINE_PKGS="vstack-mgmtvm vstack-cluster-dispatcher vstack-cluster-endpoint vstack-web"
OLDGEN_SERVICES="vStackCPDispatcher vStackCPEndpoint vStackclusterep nginx grafana influxd rabbitmq postgresql chronyd"
OLDGEN_RCVARS="vStackCPDispatcher_enable vStackCPEndpoint_enable vStackclusterep_enable vStackCPEndpoint_args nginx_enable chronyd_enable postgresql_enable rabbitmq_enable influxd_enable grafana_enable vstack_cluster_endpoint_enable vstack_cluster_dispatcher_enable"

OPT_GO=1
OPT_DRY=0
OPT_SNAP=0
OPT_DETACH=0
OPT_REBOOT=0
OPT_ALLOW_MAJOR=0
OPT_SETTINGS_OK=0
OPT_PHASE=""
OPT_STATUS=0
OPT_SOURCE="dev"
OPT_VERSION=""
OPT_REPO_URL=""
OPT_ABI=""
OPT_NO_ESP=0
OPT_NO_FALLBACK=0
OPT_NO_KBACKUP=0
OPT_LOG=""
ORIG_ARGS="$*"

while [ "$#" -gt 0 ]; do
  case "$1" in
    --snapshots-taken)     OPT_SNAP=1; shift ;;
    --dry-run)             OPT_DRY=1; shift ;;
    --reboot)              OPT_REBOOT=1; shift ;;
    --allow-major-upgrade) OPT_ALLOW_MAJOR=1; shift ;;
    --detach)              OPT_DETACH=1; shift ;;
    --settings-reviewed)   OPT_SETTINGS_OK=1; shift ;;
    --cleanup-oldgen)      OPT_PHASE=oldgen-cleanup; shift ;;
    --phase)               OPT_PHASE="$2"; shift 2 ;;
    --status)              OPT_STATUS=1; shift ;;
    --source)              OPT_SOURCE="$2"; shift 2 ;;
    --version)             OPT_VERSION="$2"; shift 2 ;;
    --repo-url)            OPT_REPO_URL="$2"; shift 2 ;;
    --abi)                 OPT_ABI="$2"; shift 2 ;;
    --no-esp)              OPT_NO_ESP=1; shift ;;
    --no-fallback)         OPT_NO_FALLBACK=1; shift ;;
    --no-kernel-backup)    OPT_NO_KBACKUP=1; OPT_NO_FALLBACK=1; shift ;;
    --log)                 OPT_LOG="$2"; shift 2 ;;
    -h|--help)             usage; exit 0 ;;
    *) printf 'ERROR: unknown argument: %s\n' "$1" >&2; usage >&2; exit 1 ;;
  esac
done
[ "${SNAPSHOT_CONFIRMED:-}" = yes ] && OPT_SNAP=1
# Без подтверждения снимков — только план, как --dry-run.
[ "${OPT_DRY}" -eq 1 ] || [ "${OPT_SNAP}" -eq 0 ] && OPT_GO=0

LOG_FILE="${OPT_LOG:-/var/log/mgmtvm-update.log}"
TEE_LOG="${LOG_FILE}"
[ "${DETACHED:-0}" = 1 ] && TEE_LOG=/dev/null
_TS="$( date -u '+%Y%m%dT%H%M%SZ' )"

# В stderr: функции, чей stdout захватывают через $( ), тоже пишут в лог.
log() {
  if [ "${DETACHED:-0}" = 1 ]; then
    printf '%s %s\n' "$( date -u '+%Y-%m-%dT%H:%M:%SZ' )" "$*" >&2
  else
    printf '%s %s\n' "$( date -u '+%Y-%m-%dT%H:%M:%SZ' )" "$*" | tee -a "${LOG_FILE}" >&2
  fi
}
die() {
  _rc="$1"; shift
  log "ERROR: $*"
  _EXPECTED_EXIT=1
  exit "${_rc}"
}
# set -e обрывает скрипт без слов ( так терялись сбои compat_guard и черновика
# settings.yml ): любой выход не через die и не штатный — в лог.
on_exit() {
  _erc=$?
  [ -n "${OWN_PIDFILE:-}" ] && rm -f "${STATE_DIR}/run.pid"
  [ -z "${DRY_DIR:-}" ] || rm -rf "${DRY_DIR}"
  [ "${_erc}" -eq 0 ] || [ -n "${_EXPECTED_EXIT:-}" ] || log "ERROR: unexpected exit ( rc ${_erc} ) in phase ${NEXT_PHASE:-?}: a command failed under set -e, see the lines above"
}
trap on_exit EXIT
# Вывод команды — в лог, код возврата — её собственный, а не tee.
logrun() {
  _lo="$( mktemp -t mgmtvm-update )"
  _lrc=0
  eval "$@" > "${_lo}" 2>&1 || _lrc=$?
  if [ "${DETACHED:-0}" = 1 ]; then cat "${_lo}"; else tee -a "${LOG_FILE}" < "${_lo}"; fi
  rm -f "${_lo}"
  return ${_lrc}
}

run() {
  if [ "${OPT_GO}" -eq 1 ]; then
    eval "$@"
  else
    log "DRY: $*"
  fi
}

if [ "${OPT_DETACH}" -eq 1 ]; then
  _self="$( cd "$( dirname "$0" )" && pwd )/$( basename "$0" )"
  _args="$( printf '%s' "${ORIG_ARGS}" | sed 's/--detach//' )"
  daemon -f -o "${LOG_FILE}" /usr/bin/env DETACHED=1 SNAPSHOT_CONFIRMED="${SNAPSHOT_CONFIRMED:-}" MGMTVM_TEST_GENERATION="${MGMTVM_TEST_GENERATION:-}" /bin/sh "${_self}" ${_args}
  printf 'detached, log: %s\n' "${LOG_FILE}"
  exit 0
fi

mkdir -p "${STATE_DIR}"
STATE_FILE="${STATE_DIR}/state"
PLAN_FILE="${STATE_DIR}/plan.env"

state_get() { [ -f "${STATE_FILE}" ] && sed -n 's/^PHASE=//p' "${STATE_FILE}" | tail -1 || true; }
state_set() {
  [ "${OPT_GO}" -eq 1 ] || return 0
  printf 'PHASE=%s\nAT=%s\n' "$1" "$( date -u '+%Y-%m-%dT%H:%M:%SZ' )" > "${STATE_FILE}.tmp"
  mv "${STATE_FILE}.tmp" "${STATE_FILE}"
  log "STATE: next phase -> $1"
}
plan_put() {
  [ "${OPT_GO}" -eq 1 ] || { eval "$1=\"\$2\""; return 0; }
  grep -v "^$1=" "${PLAN_FILE}" 2>/dev/null > "${PLAN_FILE}.tmp" || true
  # значение в одинарных кавычках: в списках ( HELD_SERVICES ) пробелы
  printf "%s='%s'\n" "$1" "$( printf '%s' "$2" | sed "s/'/'\\\\''/g" )" >> "${PLAN_FILE}.tmp"
  mv "${PLAN_FILE}.tmp" "${PLAN_FILE}"
  eval "$1=\"\$2\""
}
[ -f "${PLAN_FILE}" ] && . "${PLAN_FILE}"

if [ "${OPT_STATUS}" -eq 1 ]; then
  printf 'phase: %s\n' "$( state_get )"
  [ -f "${PLAN_FILE}" ] && cat "${PLAN_FILE}"
  exit 0
fi

# ── раздача ─────────────────────────────────────────────────────────────────

build_url() {
  case "$1" in
    dev)     printf '%s/%s/%s/%s/repo' "${BLADE_BASE_URL}" "${PUBLISH_URL_DIR}" "${BUILD_CHANNEL}" "${CENTER_REPO_DIR}" ;;
    tech)    printf '%s/%s/%s/repo' "${REPO_BASE_URL_TECH}" "${CENTER_REPO_DIR}" "$2" ;;
    release) printf '%s/%s/%s/repo' "${REPO_BASE_URL_RELEASE}" "${CENTER_REPO_DIR}" "$2" ;;
    local)   printf 'file://%s/%s/%s/repo/' "${LOCAL_REPO_ROOT}" "${BUILD_CHANNEL}" "${CENTER_REPO_DIR}" ;;
    *) return 1 ;;
  esac
}

case "${OPT_SOURCE}" in
  tech|release) [ -n "${OPT_VERSION}" ] || [ -n "${OPT_REPO_URL}" ] || die 2 "--source ${OPT_SOURCE} needs --version" ;;
esac
URL="${OPT_REPO_URL:-$( build_url "${OPT_SOURCE}" "${OPT_VERSION}" )}"
SIG_TYPE=pubkey
[ "${OPT_SOURCE}" = local ] && [ -z "${OPT_REPO_URL}" ] && SIG_TYPE=none

write_repo_conf() {
  _wr_dir="${1:-${REPOS_DIR}}"
  mkdir -p "${_wr_dir}"
  {
    printf '%s: {\n  url: "%s",\n  signature_type: "%s",\n' "${PRODUCT}" "${URL}" "${SIG_TYPE}"
    [ "${SIG_TYPE}" = pubkey ] && printf '  pubkey: "%s",\n' "${PUBKEY_PATH}"
    printf '  enabled: yes\n}\n'
    # официальные раздачи FreeBSD: 14 — «FreeBSD», 15 — «FreeBSD-ports»,
    # «-kmods», «-base», и /etc/pkg/FreeBSD.conf 15 включает ports ( q8, 02.10 ):
    # pkg upgrade без -r потянул бы quarterly поверх поставки
    for _r in FreeBSD FreeBSD-ports FreeBSD-ports-kmods FreeBSD-base; do
      printf '\n%s: {\n  enabled: no\n}\n' "${_r}"
    done
  } > "${_wr_dir}/${PRODUCT}.conf"
}

disable_conflicting_confs() {
  [ -d "${REPOS_DIR}" ] || return 0
  for _f in "${REPOS_DIR}"/*.conf; do
    [ -f "${_f}" ] || continue
    [ "$( basename "${_f}" .conf )" = "${PRODUCT}" ] && continue
    mv "${_f}" "${_f}.disabled"
    log "INFO: disabled repo conf ${_f}"
  done
}

fetch_pubkey() {
  [ -s "${PUBKEY_PATH}" ] && return 0
  mkdir -p "$( dirname "${PUBKEY_PATH}" )"
  _fp_b="${URL%/}"; _fp_b="${_fp_b%/repo}"
  for _u in "${_fp_b}/poudriere.pub" "${KEYS_URL_BASE}/poudriere.pub"; do
    fetch -qo "${PUBKEY_PATH}" "${_u}" 2>/dev/null && { log "INFO: pubkey from ${_u}"; return 0; }
  done
  return 1
}

# pkg-static с ABI и OSVERSION цели: динамический pkg посреди смены мажора
# может не найти символы новой libc.
PKG_ABI=""
PKG_OSVER=""
# --dry-run: каталог и копия базы pkg во временном каталоге, система не меняется
PKG_DRY_OPTS=""
DRY_DIR=""
# PKGS_PATH — PATH для скриптов пакетов ( хвост со страховочными бинарями ).
pkgs() {
  if [ -n "${PKG_ABI}" ]; then
    env PATH="${PKGS_PATH:-${PATH}}" pkg-static ${PKG_DRY_OPTS} -o ABI="${PKG_ABI}" -o OSVERSION="${PKG_OSVER}" "$@"
  else
    env PATH="${PKGS_PATH:-${PATH}}" pkg-static ${PKG_DRY_OPTS} "$@"
  fi
}
ASSUME_ALWAYS_YES=yes
IGNORE_OSVERSION=yes
export ASSUME_ALWAYS_YES IGNORE_OSVERSION

# ── определение состояния ───────────────────────────────────────────────────

cur_major() { freebsd-version -u 2>/dev/null | cut -d. -f1; }
kern_major() { uname -r | cut -d. -f1; }
base_count() { pkg-static query '%n' 2>/dev/null | grep -c "^${FB}" || true; }

# vstack-center — первым: после перехода с old-gen остатки perl-центра ( если
# --cleanup-oldgen ещё не запускали ) не должны снова уводить в old-gen.
detect_generation() {
  # только для стенда: голый FreeBSD без центра ( проверка механики ОС 13 -> 15 )
  [ -n "${MGMTVM_TEST_GENERATION:-}" ] && { echo "${MGMTVM_TEST_GENERATION}"; return 0; }
  if pkg-static info -e vstack-mgmtvm 2>/dev/null; then
    echo mgmtvm-line
  elif pkg-static info -e vstack-center 2>/dev/null; then
    echo center
  elif pkg-static info -e vStack-control-pane 2>/dev/null || [ -x "${LOCAL_RC_DIR}/vStackCPDispatcher" ]; then
    echo old-gen
  else
    echo unknown
  fi
}

# Цель — мажор FreeBSD-runtime в раздаче. Каталог читается во временный
# REPOS_DIR: систему разбор не трогает.
detect_target() {
  _dt="$( mktemp -d /tmp/mgmtvm-update-probe.XXXXXX )"
  _pk_saved="${PUBKEY_PATH}"
  if [ "${SIG_TYPE}" = pubkey ] && [ ! -s "${PUBKEY_PATH}" ]; then
    PUBKEY_PATH="${_dt}/poudriere.pub"
    fetch_pubkey || { PUBKEY_PATH="${_pk_saved}"; rm -rf "${_dt}"; return 0; }
  fi
  write_repo_conf "${_dt}"
  PUBKEY_PATH="${_pk_saved}"
  mkdir -p "${_dt}/db"
  _abi_arch="$( uname -p )"
  _out=""
  # Каталог раздачи другого мажора pkg без подходящего ABI не отдаёт:
  # перебираем кандидатов от текущего вверх.
  _m="$( cur_major )"
  for _try in "${_m}" $(( _m + 1 )) $(( _m + 2 )); do
    _p="pkg-static -o REPOS_DIR=${_dt} -o PKG_DBDIR=${_dt}/db -o ABI=FreeBSD:${_try}:${_abi_arch}"
    ${_p} update -q -r "${PRODUCT}" >/dev/null 2>&1 || true
    _out="$( ${_p} rquery -r "${PRODUCT}" '%v %q' "${FB}runtime" 2>/dev/null | head -1 || true )"
    [ -n "${_out}" ] && break
  done
  rm -rf "${_dt}"
  printf '%s\n' "${_out}" | head -1
}

# ── бэкап и /etc ────────────────────────────────────────────────────────────

backup_state() {
  _bd="${BACKUP_ROOT}/$1-${_TS}"
  mkdir -p "${_bd}"
  cp -a "${ETC_DIR}/." "${_bd}/etc/" 2>/dev/null || { mkdir -p "${_bd}/etc" && cp -a "${ETC_DIR}/." "${_bd}/etc/"; } || die 4 "/etc backup failed"
  [ -d "${LOCAL_ETC_DIR}" ] && { cp -a "${LOCAL_ETC_DIR}" "${_bd}/usr-local-etc" || die 4 "${LOCAL_ETC_DIR} backup failed"; }
  mkdir -p "${_bd}/boot"
  for _f in loader.conf loader.conf.local device.hints; do
    [ -f "${BOOT_DIR}/${_f}" ] && cp -p "${BOOT_DIR}/${_f}" "${_bd}/boot/"
  done
  [ -d "${BOOT_DIR}/loader.conf.d" ] && cp -a "${BOOT_DIR}/loader.conf.d" "${_bd}/boot/" 2>/dev/null || true
  pkg-static query '%n %v' 2>/dev/null | sort > "${_bd}/pkg-before.txt" || true
  freebsd-version -kru > "${_bd}/version.txt" 2>/dev/null || true
  log "INFO: state backed up to ${_bd}"
  plan_put LAST_BACKUP "${_bd}"
}

merge_etc_db() {
  _me_old="$1"; _me_post="$2"; _me_dst="$3"
  [ -e "${_me_old}" ] || return 0
  [ -e "${_me_post}" ] || { cp -p "${_me_old}" "${_me_dst}"; return 0; }
  _me_tmp="${_me_dst}.merge.$$"
  cp -p "${_me_old}" "${_me_tmp}"
  awk -F: 'NR==FNR { if ($1 != "" && $1 !~ /^#/) seen[$1] = 1; next }
    $1 != "" && $1 !~ /^#/ && !($1 in seen) { print }' "${_me_old}" "${_me_post}" > "${_me_tmp}.add"
  if [ -s "${_me_tmp}.add" ]; then
    cat "${_me_tmp}.add" >> "${_me_tmp}"
    while IFS=: read -r _n _r; do log "INFO: $( basename "${_me_dst}" ): kept principal added by packages: ${_n}"; done < "${_me_tmp}.add"
  fi
  rm -f "${_me_tmp}.add"
  mv "${_me_tmp}" "${_me_dst}"
}

# Вернуть свои файлы /etc после установки базы. from_dist=1 — переход с
# дист-сетов: .pkgsave там — код прежней линии, а не настройки.
restore_etc() {
  _bd="$1"; _from_dist="$2"
  cp -p "${ETC_DIR}/master.passwd" "${_bd}/master.passwd.post" 2>/dev/null || true
  cp -p "${ETC_DIR}/group" "${_bd}/group.post" 2>/dev/null || true
  if [ "${_from_dist}" -eq 1 ]; then
    for _d in "${ETC_DIR}" "${LOCAL_ETC_DIR}"; do
      _n="$( find "${_d}" -name '*.pkgsave' 2>/dev/null | wc -l | tr -d ' ' )"
      find "${_d}" -name '*.pkgsave' -delete 2>/dev/null || true
      find "${_d}" -name '*.pkgnew' -newer "${_bd}/version.txt" -delete 2>/dev/null || true
      log "INFO: ${_d}: dropped ${_n} *.pkgsave of the dist-set line"
    done
  else
    find "${ETC_DIR}" "${LOCAL_ETC_DIR}" -name '*.pkgsave' 2>/dev/null | while IFS= read -r _ps; do
      _o="${_ps%.pkgsave}"
      [ -f "${_o}" ] && mv "${_o}" "${_o}.pkgnew"
      mv "${_ps}" "${_o}"
      log "INFO: restored ${_o} ( package version kept as .pkgnew )"
    done
  fi
  for _f in ssh/sshd_config ssh/ssh_config passwd pwd.db spwd.db login.conf shells sysctl.conf syslog.conf hosts ntp.conf rc.conf rc.conf.local fstab resolv.conf; do
    [ -e "${_bd}/etc/${_f}" ] && cp -p "${_bd}/etc/${_f}" "${ETC_DIR}/${_f}"
  done
  for _hk in "${_bd}/etc/ssh/ssh_host_"*; do
    [ -e "${_hk}" ] && cp -p "${_hk}" "${ETC_DIR}/ssh/"
  done
  merge_etc_db "${_bd}/etc/master.passwd" "${_bd}/master.passwd.post" "${ETC_DIR}/master.passwd"
  merge_etc_db "${_bd}/etc/group" "${_bd}/group.post" "${ETC_DIR}/group"
  chmod 600 "${ETC_DIR}/master.passwd"
  for _f in loader.conf loader.conf.local device.hints; do
    [ -f "${_bd}/boot/${_f}" ] && cp -p "${_bd}/boot/${_f}" "${BOOT_DIR}/${_f}"
  done
}

rebuild_etc_dbs() {
  pwd_mkdb -p "${ETC_DIR}/master.passwd" || die 4 "pwd_mkdb failed"
  cap_mkdb "${ETC_DIR}/login.conf" || die 4 "cap_mkdb failed"
  services_mkdb -q -o /var/db/services.db "${ETC_DIR}/services" 2>/dev/null || true
}

# ── загрузка ( из vstackos.update.sh ) ──────────────────────────────────────

loader_kernel_dir() {
  _lkd=""
  for _f in "${BOOT_DIR}/loader.conf" "${BOOT_DIR}/loader.conf.local" "${BOOT_DIR}"/loader.conf.d/*; do
    [ -f "${_f}" ] || continue
    [ "${_f}" = "${BOOT_DROPIN}" ] && continue
    _v="$( sed -n 's/^[[:space:]]*kernel="\{0,1\}\([^"]*\)"\{0,1\}[[:space:]]*$/\1/p' "${_f}" | tail -1 )"
    [ -n "${_v}" ] && _lkd="${_v}"
  done
  printf '%s\n' "${_lkd:-kernel}"
}
running_kernel_dir() {
  _r="$( sysctl -n kern.bootfile 2>/dev/null || true )"
  _r="${_r%/kernel}"; printf '%s\n' "${_r##*/}"
}
is_elf() { [ "$( dd if="$1" bs=4 count=1 2>/dev/null | od -An -tx1 | tr -d ' \n' )" = "7f454c46" ]; }
is_efi_app() {
  [ -f "$1" ] || return 1
  _s="$( wc -c < "$1" | tr -d ' ' )"
  [ "${_s}" -ge "${LOADER_MIN_SIZE}" ] || return 1
  [ "$( dd if="$1" bs=2 count=1 2>/dev/null | od -An -tx1 | tr -d ' \n' )" = "4d5a" ]
}
# идент работающего ядра — из kern.version: uname -r обрезан до 32 знаков
# ( «14.2-RELEASE-vstack-3.2.0-rc03-» ) и с идентом файла не совпадает
running_ident() { sysctl -n kern.version 2>/dev/null | head -1 | awk '{print $1" "$2}'; }
kernel_ident() { grep -a -m1 -o '@(#)FreeBSD [^ ]*' "$1" 2>/dev/null | sed 's/^@(#)//' || true; }

save_prev_kernel() {
  _d="$( loader_kernel_dir )"
  _src="${BOOT_DIR}/${_d}"; _dst="${_src}${KERNEL_PREV_SUFFIX}"
  [ -f "${_src}/kernel" ] || { log "WARNING: ${_src}/kernel not found"; return 0; }
  [ "$( running_kernel_dir )" = "${_d}${KERNEL_PREV_SUFFIX}" ] && return 0
  # Повторный запуск после распаковки нового ядра: на месте уже не то ядро,
  # что работает, и прежняя копия ценнее.
  if [ -f "${_dst}/kernel" ] && [ "$( kernel_ident "${_src}/kernel" )" != "$( running_ident )" ]; then
    log "INFO: ${_src} is not the running kernel, ${_dst} kept as is"; return 0
  fi
  [ -d "${_dst}" ] && { chflags -R noschg "${_dst}" 2>/dev/null || true; rm -rf "${_dst}"; }
  cp -a "${_src}" "${_dst}.tmp" || return 1
  find "${_dst}.tmp" -name '*.pkgsave' -delete 2>/dev/null || true
  mv "${_dst}.tmp" "${_dst}"
  sync
  log "INFO: previous kernel kept as ${_dst} ( loader prompt: boot ${_d}${KERNEL_PREV_SUFFIX} )"
}

verify_new_kernel() {
  _k="${BOOT_DIR}/$( loader_kernel_dir )/kernel"
  [ -f "${_k}" ] || { log "ERROR: no kernel at ${_k}"; return 1; }
  is_elf "${_k}" || { log "ERROR: ${_k} is not ELF"; return 1; }
  [ "$( wc -c < "${_k}" | tr -d ' ' )" -ge "${KERNEL_MIN_SIZE}" ] || { log "ERROR: ${_k} too small"; return 1; }
  _m="$( ls "$( dirname "${_k}" )"/*.ko 2>/dev/null | wc -l | tr -d ' ' )"
  [ "${_m}" -ge "${KERNEL_MIN_MODULES}" ] || { log "ERROR: only ${_m} modules next to ${_k}"; return 1; }
  [ -s "$( dirname "${_k}" )/linker.hints" ] || { log "ERROR: no linker.hints next to ${_k}"; return 1; }
  _id="$( kernel_ident "${_k}" )"
  case "${_id}" in "FreeBSD ${TARGET_MAJOR}."*) ;; *) log "ERROR: ${_k} is '${_id}', expected FreeBSD ${TARGET_MAJOR}.x"; return 1 ;; esac
  log "INFO: kernel on disk: ${_id} ( ${_m} modules )"
}

esp_sync_loader() {
  _src="${1:-${BOOT_DIR}/loader.efi}"
  is_efi_app "${_src}" || { log "WARNING: ${_src} is not a valid EFI app, ESP untouched"; return 0; }
  _mp=""; _here=0
  if mount | grep -q " ${BOOT_DIR}/efi "; then
    _mp="${BOOT_DIR}/efi"
  else
    _dev="$( gpart show -p 2>/dev/null | awk '$4 == "efi" {print $3; exit}' )"
    [ -n "${_dev}" ] || { log "INFO: no ESP found, loader sync skipped"; return 0; }
    _mp="$( mktemp -d /tmp/mgmtvm-esp.XXXXXX )"
    mount -t msdosfs "/dev/${_dev}" "${_mp}" 2>/dev/null || { log "WARNING: cannot mount ESP ${_dev}"; rmdir "${_mp}"; return 0; }
    _here=1
  fi
  _new="$( sha256 -q "${_src}" )"
  for _t in $( find "${_mp}" -type f \( -iname 'bootx64.efi' -o -iname 'loader.efi' \) 2>/dev/null ); do
    [ "$( sha256 -q "${_t}" )" = "${_new}" ] && continue
    cp -p "${_t}" "${_t}.prev" && cp "${_src}" "${_t}.new" && mv "${_t}.new" "${_t}" \
      && log "INFO: ESP loader updated: ${_t} ( previous kept as .prev )"
  done
  sync
  [ "${_here}" -eq 1 ] && { umount "${_mp}" 2>/dev/null || true; rmdir "${_mp}" 2>/dev/null || true; }
  return 0
}

arm_fallback() {
  _d="$( loader_kernel_dir )"; _p="${_d}${KERNEL_PREV_SUFFIX}"
  [ -f "${BOOT_DIR}/${_p}/kernel" ] || { log "WARNING: no ${BOOT_DIR}/${_p}, fallback not armed"; return 0; }
  mkdir -p "$( dirname "${BOOT_DROPIN}" )"
  printf '# mgmtvm-update: default = previous kernel until %s confirms the new one\nkernel="%s"\n' \
    "${BOOT_MARKER_NAME}" "${_p}" > "${BOOT_DROPIN}"
  # паника пробного ядра — перезагрузка, а не db> на консоли: без неё fallback не наступит
  nextboot -k "${_d}" -e debug.debugger_on_panic=0 >> "${TEE_LOG}" 2>&1 || { rm -f "${BOOT_DROPIN}"; log "ERROR: nextboot failed, fallback not armed"; return 1; }
  sync
  log "INFO: one-shot boot armed for ${_d}, fallback ${_p}"
}

arm_trial_boot() {
  rm -f "${BOOT_DROPIN}" "${BOOT_DIR}/nextboot.conf"
  [ "${OPT_NO_FALLBACK}" -eq 1 ] && return 0
  install_boot_marker
  arm_fallback || true
}

# Стандартная раскладка ядра ( TSK0004248 ). Боевое ядро GENERIC_PROD лежало
# вторым, в /boot/kernel.GENERIC_PROD, и выбиралось kernel= в loader.conf;
# теперь оно одно и в /boot/kernel. После транзакции прежний каталог пуст
# ( pkgbase ) или снесён ( classic, kernel_from_pkgs ), а ядро — в
# /boot/kernel. Загрузчик с kernel= на каталог без ядра встаёт: «Failed to
# load kernel» без автозагрузки ( bhyve, 06.10.2026 ). Поэтому: снять все
# такие kernel= — в живом loader.conf* и в копии бэкапа ( restore_etc вернул
# бы строку ), прежнее ядро <каталог>_prev переименовать в kernel_prev и
# перевзвести пробную загрузку на новые имена. Возвращает 0 и ничего не
# делает, если раскладка прежняя.
KLM_DONE=0
kernel_layout_migrate() {
  _kl="$( loader_kernel_dir )"
  [ "${_kl}" != "kernel" ] || return 0
  [ ! -f "${BOOT_DIR}/${_kl}/kernel" ] && [ -f "${BOOT_DIR}/kernel/kernel" ] || return 0
  log "NOTICE: kernel layout: the kernel now lives in ${BOOT_DIR}/kernel, ${BOOT_DIR}/${_kl} has none — dropping kernel=\"${_kl}\""
  _kre="^[[:space:]]*kernel=\"\{0,1\}$( printf '%s' "${_kl}" | sed 's/[.]/\\./g' )\"\{0,1\}[[:space:]]*\$"
  for _kf in "${BOOT_DIR}/loader.conf" "${BOOT_DIR}/loader.conf.local" \
             ${_bd:+"${_bd}/boot/loader.conf" "${_bd}/boot/loader.conf.local"}; do
    [ -f "${_kf}" ] && grep -q "${_kre}" "${_kf}" || continue
    sed "/${_kre}/d" "${_kf}" > "${_kf}.tmp.$$" && mv "${_kf}.tmp.$$" "${_kf}" || return 1
    log "INFO: ${_kf}: kernel=\"${_kl}\" removed"
  done
  [ "$( loader_kernel_dir )" = "kernel" ] || { log "ERROR: loader still selects kernel=\"$( loader_kernel_dir )\""; return 1; }
  if [ -d "${BOOT_DIR}/${_kl}${KERNEL_PREV_SUFFIX}" ]; then
    [ -d "${BOOT_DIR}/kernel${KERNEL_PREV_SUFFIX}" ] && { chflags -R noschg "${BOOT_DIR}/kernel${KERNEL_PREV_SUFFIX}" 2>/dev/null || true; rm -rf "${BOOT_DIR}/kernel${KERNEL_PREV_SUFFIX}"; }
    mv "${BOOT_DIR}/${_kl}${KERNEL_PREV_SUFFIX}" "${BOOT_DIR}/kernel${KERNEL_PREV_SUFFIX}" || return 1
    log "INFO: previous kernel ${_kl}${KERNEL_PREV_SUFFIX} is now kernel${KERNEL_PREV_SUFFIX}"
  fi
  rm -f "${BOOT_DIR}/${_kl}/linker.hints"
  rmdir "${BOOT_DIR}/${_kl}" 2>/dev/null && log "INFO: empty ${BOOT_DIR}/${_kl} removed"
  KLM_DONE=1
  return 0
}

install_boot_marker() {
  mkdir -p "${LOCAL_RC_DIR}"
  cat > "${LOCAL_RC_DIR}/${BOOT_MARKER_NAME}" <<'MARKER'
#!/bin/sh
# PROVIDE: vstack_boot_marker
# REQUIRE: LOGIN
# KEYWORD: nojail
. /etc/rc.subr
name="vstack_boot_marker"
rcvar="vstack_boot_marker_enable"
start_cmd="vstack_boot_marker_start"
stop_cmd=":"
load_rc_config "${name}"
: "${vstack_boot_marker_enable:=YES}"
: "${vstack_boot_marker_dropin:=/boot/loader.conf.d/zz-vstack-fallback.conf}"
vstack_boot_marker_start()
{
	[ -f "${vstack_boot_marker_dropin}" ] || return 0
	_def="$( sed -n 's/^kernel="\(.*\)"$/\1/p' "${vstack_boot_marker_dropin}" | tail -1 )"
	_run="$( sysctl -n kern.bootfile )"; _run="${_run%/kernel}"; _run="${_run##*/}"
	if [ "${_run}" = "${_def}" ]; then
		logger -t "${name}" -p daemon.err "RUNNING THE FALLBACK KERNEL ${_run}: the new kernel did not boot"
		return 0
	fi
	rm -f "${vstack_boot_marker_dropin}" /boot/nextboot.conf
	logger -t "${name}" "boot on ${_run} confirmed, fallback default removed"
}
run_rc_command "$1"
MARKER
  chmod 0555 "${LOCAL_RC_DIR}/${BOOT_MARKER_NAME}"
}

sync_boot_durable() {
  if command -v "${FSYNC_BIN}" >/dev/null 2>&1; then
    find "${BOOT_DIR}" -type f 2>/dev/null | grep -v "^${BOOT_DIR}/efi/" | while IFS= read -r _f; do "${FSYNC_BIN}" "${_f}" 2>/dev/null || true; done
  fi
  sync; sync
}

# ── сервисы ─────────────────────────────────────────────────────────────────

svc_exists() { [ -x "/etc/rc.d/$1" ] || [ -x "${LOCAL_RC_DIR}/$1" ]; }
stop_services() {
  for _s in $1; do
    svc_exists "${_s}" || continue
    service "${_s}" status >/dev/null 2>&1 || continue
    log "INFO: stop ${_s}"
    run "service '${_s}' stop >/dev/null 2>&1 || service '${_s}' onestop >/dev/null 2>&1 || true"
  done
}
check_services() {
  _bad=""
  for _s in ${CENTER_SERVICES}; do
    svc_exists "${_s}" || continue
    [ "${_s}" = vector ] && continue
    service "${_s}" status >/dev/null 2>&1 || _bad="${_bad} ${_s}"
  done
  [ -z "${_bad}" ] || { log "ERROR: services not running:${_bad}"; return 1; }
  log "INFO: all center services running"
}

# Метка «нужна перезагрузка»: время загрузки, в которую фаза её потребовала.
# Пока загрузка та же, следующую фазу не начинать — ни в окне shutdown -r
# ( стенд 02.10: повтор команды через 14 с начал app и был оборван ), ни без
# перезагрузки вообще ( app на прежнем ядре того же мажора ).
boot_sec() { sysctl -n kern.boottime 2>/dev/null | sed -n 's/^{ sec = \([0-9]*\),.*/\1/p'; }
reboot_mark() { [ "${OPT_GO}" -eq 1 ] && boot_sec > "${STATE_DIR}/reboot.pending"; return 0; }
reboot_guard() {
  [ -f "${STATE_DIR}/reboot.pending" ] || return 0
  if [ "$( cat "${STATE_DIR}/reboot.pending" )" = "$( boot_sec )" ]; then
    die 10 "a reboot is pending since the last phase ( no reboot yet in this boot ): shutdown -r now, or wait for the one already issued"
  fi
  rm -f "${STATE_DIR}/reboot.pending"
}

do_reboot() {
  reboot_mark
  if [ "${OPT_GO}" -eq 1 ] && [ "${OPT_REBOOT}" -eq 1 ]; then
    log "INFO: rebooting ( $1 ); after boot run the same command again to continue"
    sync_boot_durable
    shutdown -r +10s "mgmtvm-update: $1" >/dev/null 2>&1 || shutdown -r now
    exit 0
  fi
  sync_boot_durable
  # мир новее ядра: новые входы ( ssh, login на консоли ) падают с SIGSYS
  # ( T1 02.10 ) — перезагружать из этой же сессии
  if [ "$( kern_major )" -lt "${TARGET_MAJOR}" ]; then
    log "WARNING: FreeBSD ${TARGET_MAJOR} world on the FreeBSD $( kern_major ) kernel: NEW ssh and console logins fail until the reboot — reboot from THIS session"
  fi
  log "NOTICE: reboot required ( $1 ): shutdown -r now, then run the same command again ( or pass --reboot )"
  _EXPECTED_EXIT=1
  exit 10
}

# ── проверки перед перезагрузкой: читать файлы, не запускать мир ────────────

prereboot_checks() {
  _bd="$1"; _rc=0
  for _f in /etc/rc /usr/sbin/service /lib/libedit.so.8 /usr/lib/pam_xdg.so /usr/sbin/sshd; do
    [ -e "${_f}" ] || { log "ERROR: missing ${_f}"; _rc=1; }
  done
  # sshd-session — с OpenSSH 9.8 ( FreeBSD 15 ); в 14.x его нет
  if [ "${TARGET_MAJOR}" -ge 15 ] && [ ! -e /usr/libexec/sshd-session ]; then log "ERROR: missing /usr/libexec/sshd-session"; _rc=1; fi
  # sysrc в 15 — в новом пакете FreeBSD-bsdconfig; для загрузки не нужен, его
  # ставит фаза app до провижна ( ansible-модуль sysrc )
  [ -e /usr/sbin/sysrc ] || log "WARNING: /usr/sbin/sysrc missing until the app phase installs ${FB}bsdconfig"
  _lost="$( awk -F: 'NR==FNR { if ($1 !~ /^#/ && $1 != "") o[$1]=1; next } { n[$1]=1 }
    END { for (u in o) if (!(u in n)) printf "%s ", u }' "${_bd}/etc/master.passwd" "${ETC_DIR}/master.passwd" )"
  [ -z "${_lost}" ] || { log "ERROR: accounts lost from master.passwd: ${_lost}"; _rc=1; }
  for _u in $( cat "${STATE_DIR}/pkg-users.txt" 2>/dev/null ); do
    awk -F: -v u="${_u}" '$1==u {f=1} END {exit !f}' "${ETC_DIR}/master.passwd" || { log "ERROR: no account for package user ${_u}"; _rc=1; }
  done
  _sh="$( awk -F: '$1=="root" {print $10}' "${ETC_DIR}/master.passwd" )"
  [ -x "${_sh}" ] || { log "ERROR: root shell ${_sh} missing"; _rc=1; }
  _prl="$( awk 'tolower($1)=="permitrootlogin" {v=tolower($2)} END {print v}' "${ETC_DIR}/ssh/sshd_config" )"
  case "${_prl}" in ""|prohibit-password|without-password|yes) ;; *) log "ERROR: PermitRootLogin ${_prl}"; _rc=1 ;; esac
  cmp -s "${_bd}/etc/rc.conf" "${ETC_DIR}/rc.conf" || { log "ERROR: /etc/rc.conf changed"; _rc=1; }
  _w="$( freebsd-version -u 2>/dev/null || true )"
  case "${_w}" in "${TARGET_MAJOR}."*) log "INFO: world on disk ${_w}" ;; *) log "ERROR: world on disk is '${_w}'"; _rc=1 ;; esac
  return "${_rc}"
}

# ── фазы ────────────────────────────────────────────────────────────────────

phase_detect() {
  log "INFO: mgmtvm-update ${_TS} go=${OPT_GO} repo=${URL}"
  [ "$( id -u )" = 0 ] || die 2 "root required"
  command -v pkg-static >/dev/null || die 2 "pkg-static not found"
  # новое обновление — новый план: LAST_BACKUP прошлого раза фаза world
  # приняла бы за свой ( «повтор фазы» ) и сверяла бы /etc по старому бэкапу
  if [ "${OPT_GO}" -eq 1 ]; then
    rm -f "${PLAN_FILE}" "${STATE_DIR}/packages-lost.txt"
  fi
  LAST_BACKUP=""; HELD_SERVICES=""
  _from="$( cur_major )"; _kern="$( kern_major )"; _nb="$( base_count )"
  _mode=pkgbase; [ "${_nb}" -eq 0 ] && _mode=classic
  _gen="$( detect_generation )"
  _tgt="$( detect_target )"
  [ -n "${_tgt}" ] || die 2 "cannot read ${FB}runtime from ${URL}"
  _tver="${_tgt%% *}"; _tabi="${_tgt##* }"
  [ -n "${OPT_ABI}" ] && _tabi="${OPT_ABI}"
  _tmaj="${_tver%%.*}"
  _sl="$( sysctl -n kern.securelevel )"
  [ "${_sl}" -lt 1 ] || die 2 "kern.securelevel=${_sl}: schg flags cannot be cleared"
  _free="$( df -m / | awk 'NR==2 {print $4}' )"
  [ "${_free}" -ge "${MIN_FREE_MB}" ] || die 2 "only ${_free} MB free on /, need ${MIN_FREE_MB}"
  [ "${_gen}" != unknown ] || die 2 "cannot recognise the center generation ( no control-pane, vstack-mgmtvm or vstack-center )"
  [ "${_tmaj}" -ge "${_from}" ] || die 2 "target ${_tmaj} is older than installed ${_from}"
  # смена мажора — только явно, как у узлового скрипта
  if [ "${_tmaj}" -gt "${_from}" ] && [ "${OPT_ALLOW_MAJOR}" -eq 0 ]; then
    [ "${OPT_GO}" -eq 1 ] && die 2 "cross-major upgrade FreeBSD ${_from} -> ${_tmaj} requires --allow-major-upgrade"
    log "WARNING: cross-major upgrade FreeBSD ${_from} -> ${_tmaj}: the run will need --allow-major-upgrade"
  fi
  if [ "${_tmaj}" -gt "${_from}" ] && [ "${OPT_REBOOT}" -eq 0 ]; then
    log "WARNING: without --reboot keep this ssh session: after the world phase new logins fail until the reboot"
  fi
  _others="$( who | awk -v me="$( who -m 2>/dev/null | awk '{print $2}' )" '$2 != me' | wc -l | tr -d ' ' )"
  [ "${_others}" -eq 0 ] || log "WARNING: ${_others} other login session(s) — nobody else must run pkg during the update"
  pgrep -q -x pkg && die 2 "another pkg process is running"
  # провижн Center без settings.yml не идёт ( run-playbook: Settings file not
  # found ) — узнать это после смены ОС поздно
  case "${_gen}" in center|mgmtvm-line)
    [ -n "${MGMTVM_TEST_GENERATION:-}" ] || [ -f "${LOCAL_ETC_DIR}/vstack/settings.yml" ] \
      || die 2 "no ${LOCAL_ETC_DIR}/vstack/settings.yml: Center provisioning would fail after the OS change" ;;
  esac
  _osver="$( printf '%s' "${_tmaj}" )"
  case "${_tmaj}" in 15) _osver=1500068 ;; *) _osver="${_tmaj}00000" ;; esac
  plan_put FROM_MAJOR "${_from}"
  plan_put FROM_MODE "${_mode}"
  plan_put GENERATION "${_gen}"
  plan_put TARGET_MAJOR "${_tmaj}"
  plan_put TARGET_VERSION "${_tver}"
  plan_put TARGET_ABI "${_tabi}"
  plan_put TARGET_OSVER "${_osver}"
  plan_put REPO_URL "${URL}"
  log "INFO: installed FreeBSD ${_from} ( kernel ${_kern} ), ${_mode} ( ${_nb} base pkgs ), generation ${_gen}"
  log "INFO: target FreeBSD ${_tver} ( ${_tabi} ) from ${URL}"
  # тот же мажор, но база раздачи новее ( MgmtOS 14.1 -> 3.2.0 c 14.2, или
  # пересобранная база 15 ): обновлять базу фазой world с перезагрузкой, а не
  # заодно с поставкой в app — там нет ни страховки, ни замены ядра
  _behind=0
  if [ "${_mode}" = pkgbase ] && [ "${_from}" -eq "${_tmaj}" ]; then
    _cur="$( pkg-static query '%v' "${FB}runtime" 2>/dev/null )" || true
    [ -n "${_cur}" ] && [ "$( pkg-static version -t "${_cur}" "${_tver}" )" = "<" ] && _behind=1
    [ "${_behind}" -eq 1 ] && log "INFO: base ${_cur} is older than ${_tver}: world phase"
  fi
  plan_put BASE_BEHIND "${_behind}"
  # ОС меняется одной фазой world ( одна транзакция базы и ядра, одна
  # перезагрузка ) и для classic, и для pkgbase, со сменой мажора и без
  _os=0
  { [ "${_from}" -lt "${_tmaj}" ] || [ "${_mode}" = classic ] || [ "${_behind}" -eq 1 ]; } && _os=1
  _path=""
  [ "${_gen}" = old-gen ] && _path="app-save "
  [ "${_os}" -eq 1 ] && _path="${_path}world(reboot) "
  _path="${_path}app finish"
  log "INFO: plan: ${_path}"
  if [ "${_gen}" = old-gen ]; then
    _next=app-save
  elif [ "${_os}" -eq 1 ]; then
    _next=world
  else
    _next=app
  fi
  state_set "${_next}"
  NEXT_PHASE="${_next}"
}

# Конфигурация репозитория и pkg под цель. До этой точки система не менялась.
# Каталог читается один раз здесь; дальше все install/upgrade с -U, чтобы
# перепубликация раздачи посреди фазы ( 404 на meta.txz ) не рвала её.
# Чужой pkg ( firstboot, оператор ) держит блокировку базы — подождать.
wait_pkg_lock() {
  _w=0
  while pgrep -q -x pkg || pgrep -q -x pkg-static; do
    [ "${_w}" -ge 300 ] && die 3 "another pkg process holds the database for 5 minutes: $( pgrep -lf '^pkg' | tr '\n' ' ' )"
    [ "${_w}" -eq 0 ] && log "INFO: waiting for another pkg process to finish"
    sleep 10; _w=$(( _w + 10 ))
  done
}

prepare_repo() {
  wait_pkg_lock
  PKG_ABI="${TARGET_ABI}"; PKG_OSVER="${TARGET_OSVER}"
  [ "${OPT_GO}" -eq 1 ] || { dry_repo; return 0; }
  [ "${SIG_TYPE}" = pubkey ] && { fetch_pubkey || die 2 "cannot fetch pubkey"; }
  run write_repo_conf
  run disable_conflicting_confs
  # раздачу перепубликуют ( dev — несколько раз в день ): каталога на минуту нет
  _try=1
  until run "pkgs update -f -r '${PRODUCT}' >> \"${TEE_LOG}\" 2>&1"; do
    [ "${_try}" -lt 5 ] || die 3 "pkg catalogue update failed ( ${_try} attempts )"
    log "WARNING: pkg catalogue update failed ( attempt ${_try} ), repo may be republishing: retry in 60s"
    sleep 60; _try=$(( _try + 1 ))
  done
}

# --dry-run: раздача и копия базы pkg во временном каталоге — транзакции
# считаются с -n по-настоящему, а /etc/pkg, /var/db/pkg и кэш не меняются.
dry_repo() {
  DRY_DIR="$( mktemp -d /tmp/mgmtvm-update-dry.XXXXXX )"
  mkdir -p "${DRY_DIR}/db" "${DRY_DIR}/cache"
  _pk_saved="${PUBKEY_PATH}"
  [ "${SIG_TYPE}" = pubkey ] && [ ! -s "${PUBKEY_PATH}" ] && PUBKEY_PATH="${DRY_DIR}/poudriere.pub"
  [ "${SIG_TYPE}" = pubkey ] && { fetch_pubkey || die 2 "cannot fetch pubkey"; }
  write_repo_conf "${DRY_DIR}"
  PUBKEY_PATH="${_pk_saved}"
  cp -p "$( pkg-static config PKG_DBDIR 2>/dev/null || echo /var/db/pkg )/local.sqlite" "${DRY_DIR}/db/" 2>/dev/null || true
  PKG_DRY_OPTS="-o REPOS_DIR=${DRY_DIR} -o PKG_DBDIR=${DRY_DIR}/db -o PKG_CACHEDIR=${DRY_DIR}/cache"
  pkgs update -f -r "${PRODUCT}" >> "${TEE_LOG}" 2>&1 || die 3 "pkg catalogue update failed"
}

# Что сделала бы транзакция: счётчики и поимённо — что будет удалено.
dry_txn() {
  _dt_sub="$1"; shift
  _dt_out="$( pkgs "${_dt_sub}" -n "$@" 2>&1 )" || { printf '%s\n' "${_dt_out}" | tail -5 | while IFS= read -r _l; do log "DRY: ${_l}"; done; log "WARNING: dry transaction 'pkg ${_dt_sub} -n' failed"; return 0; }
  printf '%s\n' "${_dt_out}" >> "${TEE_LOG}"
  printf '%s\n' "${_dt_out}" | grep -E '^Number of packages' | while IFS= read -r _l; do log "DRY: ${_l}"; done
  _rm="$( printf '%s\n' "${_dt_out}" | awk '/to be REMOVED:/ {f=1; next} /^[^ \t]/ || /^$/ {f=0} f {sub(/^[ \t]+/, ""); print}' | tr '\n' ' ' )"
  [ -z "${_rm}" ] || log "DRY: would be REMOVED: ${_rm}"
}

# Транзакция базы с повтором. pkg сначала качает всё, потом ставит; если упало
# на скачивании ( раздачу перепубликовали: каталог есть, файлов уже нет — dev,
# 01.10 08:23 ), FreeBSD-runtime не изменился — подождать, обновить каталог,
# повторить. Изменилось — не повторять: дальше разбирается человек.
pkg_txn() {
  _try=1
  while :; do
    _rt="$( pkg-static query '%v' "${FB}runtime" 2>/dev/null )" || true
    logrun "$@" && return 0
    [ "$( pkg-static query '%v' "${FB}runtime" 2>/dev/null )" = "${_rt}" ] || return 1
    [ "${_try}" -lt 3 ] || return 1
    log "WARNING: transaction failed before applying anything ( attempt ${_try} ), repo may be republishing: retry in 120s"
    sleep 120
    logrun pkgs update -f -r "${PRODUCT}" || true
    _try=$(( _try + 1 ))
  done
}

new_base_pkgs() {
  _all="$( pkgs rquery -U -r "${PRODUCT}" '%n' 2>/dev/null | grep "^${FB}" | grep -v -E -- "${BASE_SKIP_RE}" | sort -u )"
  _have="$( pkg-static query '%n' | grep "^${FB}" | sort -u )"
  printf '%s\n' "${_all}" | grep -vxF "${_have:-@@none@@}" | tr '\n' ' '
}

# Пакеты ядра ( и загрузчика ) нового мажора — только скачать прежним pkg и
# распаковать в /boot руками: pkg-static нового мажора на старом ядре падает с
# SIGSYS, а pkg install из раздачи другого ABI сначала переустанавливает сам
# pkg и тянет runtime. В базу pkg пакеты внесёт следующая фаза, на новом ядре.
#   kernel_from_pkgs 'kernel-generic(_prod)?|bootloader'
kernel_from_pkgs() {
  _kd="${STATE_DIR}/kernel-pkgs"
  _kp="$( pkgs rquery -U -r "${PRODUCT}" '%n' | grep -E "^${FB}($1)\$" | tr '\n' ' ' )"
  [ -n "${_kp}" ] || die 3 "no kernel packages ( $1 ) in ${URL}"
  run "rm -rf '${_kd}'; pkgs fetch -U -y -d -o '${_kd}' -r '${PRODUCT}' ${_kp} >> \"${TEE_LOG}\" 2>&1" || die 3 "cannot fetch ${_kp}"
  [ "${OPT_GO}" -eq 1 ] || return 0
  _files="$( find "${_kd}" -name '*.pkg' | grep -E "/${FB}($1)-[0-9]" )"
  [ -n "${_files}" ] || die 3 "fetched nothing into ${_kd}"
  _d="$( loader_kernel_dir )"
  for _k in "${BOOT_DIR}/${_d}" "${BOOT_DIR}/kernel"; do
    [ -d "${_k}" ] && { chflags -R noschg "${_k}" 2>/dev/null || true; rm -rf "${_k}"; }
  done
  for _f in ${_files}; do
    log "INFO: extracting $( basename "${_f}" )"
    tar -xpf "${_f}" -C / --exclude '+*' --exclude 'boot/efi' 2>&1 | grep -v 'Removing leading' || true
    _miss="$( tar -tf "${_f}" --exclude '+*' --exclude 'boot/efi' 2>/dev/null | sed 's|^/*|/|' | while IFS= read -r _m; do
      [ -e "${_m}" ] || [ -L "${_m}" ] || { echo "${_m}"; break; }
    done )"
    if [ -n "${_miss}" ]; then die 3 "extract of ${_f} failed ( ${_miss} missing ) — boot from ${_d}${KERNEL_PREV_SUFFIX} is still possible"; fi
  done
  kldxref -R "${BOOT_DIR}" 2>/dev/null || true
  kernel_layout_migrate || die 3 "kernel layout switch failed, NOT rebooting ( previous: ${_d}${KERNEL_PREV_SUFFIX} )"
  verify_new_kernel || die 3 "new kernel failed verification, NOT rebooting ( previous: $( loader_kernel_dir )${KERNEL_PREV_SUFFIX} )"
}

# pkgbase: пакет ядра, которого нет в раздаче ( q8: FreeBSD-kernel-mini из
# MgmtOS ), pkg upgrade не трогает — /boot/kernel остаётся прежним. До
# транзакции он снимается прежним pkg, ядро раздачи распаковывается руками.
orphan_kernel_swap() {
  _have="$( pkg-static query '%n' | grep -E "^${FB}kernel-" | grep -v -E -- '-dbg$' )"
  [ -n "${_have}" ] || return 0
  _repo="$( pkgs rquery -U -r "${PRODUCT}" '%n' | grep -E "^${FB}kernel-" )"
  _orph="$( printf '%s\n' "${_have}" | grep -vxF "${_repo:-@@none@@}" | tr '\n' ' ' )"
  [ -n "${_orph}" ] || return 0
  log "INFO: kernel package(s) not in ${URL}: ${_orph}— replaced by kernel-generic before the transaction"
  # копия прежнего ядра уже снята в phase_world ( save_prev_kernel )
  run "logrun pkg-static delete -fy ${_orph}" || die 3 "cannot remove ${_orph}"
  kernel_from_pkgs 'kernel-generic(_prod)?'
}

# Страховка от переезда файлов между пакетами ( pkgbase -> pkgbase ): копии
# .so прежнего мира в /usr/lib/compat — он в стандартных путях ldconfig после
# /lib и /usr/lib и переживает пересборку подсказок посреди транзакции;
# копии исполняемых — в хвост PATH для скриптов пакетов.
compat_guard() {
  mkdir -p "${COMPAT_DIR}" "${STATE_DIR}/bin"
  touch "${STATE_DIR}/compat-libs.list"
  for _f in /lib/*.so.* /usr/lib/*.so.*; do
    [ -f "${_f}" ] && [ ! -L "${_f}" ] || continue
    _t="${COMPAT_DIR}/${_f##*/}"; [ -e "${_t}" ] && continue
    cp -p "${_f}" "${_t}" 2>/dev/null && echo "${_t}" >> "${STATE_DIR}/compat-libs.list" || log "WARNING: compat guard: cannot copy ${_f}"
  done
  for _d in /bin /sbin /usr/bin /usr/sbin; do
    # повтор фазы: копии уже есть, и у части флаг schg ( cp -p его переносит )
    for _f in "${_d}"/*; do
      [ -f "${_f}" ] && [ -x "${_f}" ] && [ ! -e "${STATE_DIR}/bin/${_f##*/}" ] || continue
      cp -p "${_f}" "${STATE_DIR}/bin/" 2>/dev/null || log "WARNING: compat guard: cannot copy ${_f}"
    done
  done
  /etc/rc.d/ldconfig forcestart >/dev/null 2>&1 || ldconfig -m "${COMPAT_DIR}"
  log "INFO: compat guard: $( wc -l < "${STATE_DIR}/compat-libs.list" | tr -d ' ' ) libs, $( ls "${STATE_DIR}/bin" | wc -l | tr -d ' ' ) binaries"
}

# Сервисы центра на всё время смены ОС: остановить и выключить автостарт —
# иначе после перезагрузки PostgreSQL и RabbitMQ поднимутся на полуобновлённой
# системе ( новая база, бинари портов прежней ). NO пишется в rc.conf.local:
# он читается последним, и туда же роль провижна вернёт YES.
services_hold() {
  stop_services "${CENTER_SERVICES}"
  _held=""
  for _s in ${CENTER_SERVICES}; do
    svc_exists "${_s}" || continue
    case "$( sysrc -n "${_s}_enable" 2>/dev/null || true )" in [Yy][Ee][Ss]) ;; *) continue ;; esac
    run "sysrc -f '${ETC_DIR}/rc.conf.local' '${_s}_enable=NO' >/dev/null"
    _held="${_held} ${_s}"
  done
  plan_put HELD_SERVICES "${_held# }"
  [ -z "${_held}" ] || log "NOTICE: autostart held off until the app phase ( the provisioning turns it back on ):${_held}"
}

# Загрузчик на ESP — из скачанного пакета прежними бинарями, до транзакции.
esp_from_pkg() {
  _bv="$( pkgs rquery -U -r "${PRODUCT}" '%v' "${FB}bootloader" 2>/dev/null )" || true
  _cd="$( pkg-static config PKG_CACHEDIR 2>/dev/null )" || true
  _bp="$( ls "${_cd:-/var/cache/pkg}/${FB}bootloader-${_bv}"*.pkg 2>/dev/null | head -1 )"
  _et="$( mktemp -d /tmp/mgmtvm-esp-src.XXXXXX )"
  # раздача file:// ( зеркало ) в кэш не копируется — забрать пакет явно
  if [ -n "${_bv}" ] && [ -z "${_bp}" ]; then
    pkgs fetch -U -y -o "${_et}/pkg" -r "${PRODUCT}" "${FB}bootloader" >> "${TEE_LOG}" 2>&1 || true
    _bp="$( find "${_et}/pkg" -name "${FB}bootloader-${_bv}*.pkg" 2>/dev/null | head -1 )"
  fi
  [ -n "${_bv}" ] && [ -n "${_bp}" ] || { rm -rf "${_et}"; log "WARNING: ${FB}bootloader package not found, ESP untouched"; return 0; }
  tar -xf "${_bp}" -C "${_et}" --include '*boot/loader.efi' 2>/dev/null || true
  esp_sync_loader "${_et}/boot/loader.efi"
  rm -rf "${_et}"
}

phase_world() {
  log "INFO: phase world: ${FROM_MODE} ${FROM_MAJOR} -> pkgbase ${TARGET_VERSION}: one transaction, one reboot"
  if [ "${FROM_MAJOR}" -lt "${TARGET_MAJOR}" ] && [ "${OPT_ALLOW_MAJOR}" -eq 0 ]; then
    die 2 "cross-major upgrade FreeBSD ${FROM_MAJOR} -> ${TARGET_MAJOR} requires --allow-major-upgrade"
  fi
  prepare_repo
  db_owner_align
  services_hold
  mgmtvm_line_retire
  # Повтор фазы после сбоя: /etc уже мог поменяться, эталон — первый бэкап.
  case "${LAST_BACKUP:-}" in
    */world-*) log "INFO: rerun of the world phase, reference backup ${LAST_BACKUP}" ;;
    *) run backup_state world
       run "pkg-static query -a '%U' 2>/dev/null | sort -u > '${STATE_DIR}/pkg-users.txt'" ;;
  esac
  _bd="${LAST_BACKUP:-${BACKUP_ROOT}/world-${_TS}}"
  PKGS_PATH="/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin:${STATE_DIR}/bin"
  # Копии .so и бинарей прежнего мира: скрипты пакетов посреди транзакции и
  # внепоставочные пакеты ( curl, perl5, lnav ... ) после неё. Лишнее снимет finish.
  run compat_guard
  [ "${OPT_NO_KBACKUP}" -eq 1 ] || run save_prev_kernel || die 3 "cannot preserve the running kernel"
  _newb=""; _set=""
  if [ "${FROM_MODE}" = pkgbase ]; then
    orphan_kernel_swap
    # Всё скачивает прежний pkg: сама транзакция идёт из кэша. pkg нового
    # мажора на старом ядре сеть не осиливает ( резолвер, SIGSYS — q9, 30.09 ).
    # Пакеты базы, новые в цели ( bsdconfig с sysrc, ncurses, xz ... ), pkg
    # upgrade не ставит — их ставит отдельная установка после транзакции.
    _newb="$( new_base_pkgs )"
    [ -z "${_newb}" ] || log "INFO: base packages new in the target ( installed after the transaction ): ${_newb}"
    run "logrun pkgs upgrade -U -F -y -r '${PRODUCT}'" || die 3 "cannot download the base upgrade"
    [ -z "${_newb}" ] || run "logrun pkgs fetch -U -y -d -r '${PRODUCT}' ${_newb}" || log "WARNING: cannot prefetch ${_newb}"
  else
    run "rm -rf /usr/include/c++/v1/__string /usr/include/c++/v1/__tuple /usr/share/nls/POSIX 2>/dev/null || true"
    _set="$( pkgs rquery -U -r "${PRODUCT}" '%n' 2>/dev/null | grep "^${FB}" | grep -v -E -- "${BASE_SKIP_RE}" | sort -u | tr '\n' ' ' )"
    run "logrun pkgs fetch -U -y -d -r '${PRODUCT}' pkg ${_set}" || die 3 "cannot download the base packages"
  fi
  [ "${OPT_NO_ESP}" -eq 1 ] || run esp_from_pkg
  # Пробная загрузка — до транзакции, прежними бинарями: по умолчанию грузится
  # <kdir>_prev, новое ядро — один раз. Упадёт — следующая загрузка на прежнем.
  [ "${OPT_NO_FALLBACK}" -eq 1 ] || run arm_trial_boot
  if [ "${FROM_MODE}" = classic ]; then
    # Каталоги ядра classic пакетам не принадлежат: pkg положит ядро цели
    # поверх и оставит модули прежнего мажора рядом. Убрать — после взвода
    # пробной загрузки, чтобы обрыв здесь оставлял загрузку на <kdir>_prev.
    for _k in "${BOOT_DIR}/kernel" "${BOOT_DIR}/kernel.GENERIC_PROD"; do
      [ -d "${_k}" ] && run "chflags -R noschg '${_k}' 2>/dev/null; rm -rf '${_k}'"
    done
    run "pkg_txn pkgs install -U -r '${PRODUCT}' -fy pkg ${_set}" || die 3 "base install failed — do NOT reboot, restore the snapshot"
    _from_dist=1
  else
    run "pkg_txn pkgs upgrade -U -y -r '${PRODUCT}'" || die 3 "base upgrade transaction failed — do NOT reboot, compat guard is in place"
    _from_dist=0
  fi
  if [ "${OPT_GO}" -eq 0 ]; then
    if [ "${FROM_MODE}" = classic ]; then dry_txn install -U -r "${PRODUCT}" -f pkg ${_set}; else dry_txn upgrade -U -r "${PRODUCT}"; fi
    log "DRY: then new base packages, /etc restore, pre-reboot checks, reboot; after it the app phase"
    return 0
  fi
  # pkg цели на старом ядре: из кэша ставит ( q7, q999 ). Не вышло — доставит app.
  if [ -n "${_newb}" ]; then
    logrun pkgs install -U -y -r "${PRODUCT}" ${_newb} || log "NOTICE: new base packages deferred to the app phase: ${_newb}"
  fi
  certctl rehash >/dev/null 2>&1 || log "WARNING: certctl rehash failed"
  kldxref -R "${BOOT_DIR}" 2>/dev/null || true
  restore_etc "${_bd}" "${_from_dist}"
  # pwd_mkdb цели на старом ядре может упасть — базы пересоберёт фаза app,
  # до перезагрузки хватает текстовых файлов.
  pwd_mkdb -p "${ETC_DIR}/master.passwd" 2>/dev/null || log "NOTICE: pwd_mkdb deferred to the app phase"
  # restore_etc вернул loader.conf узла — переход раскладки проверяется после
  # него. Пробная загрузка взведена до транзакции на прежние имена каталогов.
  kernel_layout_migrate || die 3 "kernel layout switch failed — do NOT reboot"
  if [ "${KLM_DONE}" -eq 1 ] && [ "${OPT_NO_FALLBACK}" -eq 0 ]; then
    arm_trial_boot
  fi
  verify_new_kernel || die 3 "kernel on disk failed verification — do NOT reboot"
  prereboot_checks "${_bd}" || die 3 "pre-reboot checks failed — do NOT reboot, fix or restore the snapshot"
  state_set app
  do_reboot "FreeBSD ${TARGET_VERSION} installed"
}

phase_app() {
  log "INFO: phase app ( generation ${GENERATION} )"
  [ "$( kern_major )" = "${TARGET_MAJOR}" ] || die 10 "running kernel $( uname -r ) is not ${TARGET_MAJOR}"
  # пробная загрузка нового ядра не удалась, а мажор тот же ( 14.1 -> 14.2 ):
  # по мажору это не видно, видно по тому, с какого каталога загрузились
  _kd="$( loader_kernel_dir )"
  if [ "$( running_kernel_dir )" = "${_kd}${KERNEL_PREV_SUFFIX}" ]; then
    die 10 "running the fallback kernel ${_kd}${KERNEL_PREV_SUFFIX}: the new kernel did not boot, see logger vstack_boot_marker; after the fix: --phase world"
  fi
  prepare_repo
  run rebuild_etc_dbs
  run "certctl rehash >/dev/null 2>&1 || true"
  case "${GENERATION}" in
    old-gen)     app_oldgen ;;
    mgmtvm-line) app_mgmtvm_line; app_center ;;
    *)           app_center ;;
  esac
  # автостарт, выключенный на время смены ОС, возвращает роль провижна
  for _s in ${HELD_SERVICES:-}; do
    case "$( sysrc -n "${_s}_enable" 2>/dev/null || true )" in
      [Yy][Ee][Ss]) ;;
      *) log "WARNING: ${_s}_enable is still off after the provisioning ( it was on before the update )" ;;
    esac
  done
  state_set finish
  NEXT_PHASE=finish
}

# Поставка переустанавливается целиком: пакеты портов прежнего мажора ( и той
# же версии из другой сборки ) заменяются сборками раздачи.
reinstall_delivery() {
  _before="$( pkg-static query '%n' | sort )"
  [ "${OPT_GO}" -eq 1 ] || dry_txn upgrade -U -f -r "${PRODUCT}"
  run "logrun pkgs upgrade -U -fy -r '${PRODUCT}'" || die 3 "reinstall of the delivery failed"
  run "logrun pkgs install -U -y -r '${PRODUCT}' ${PRODUCT}" || die 3 "cannot install ${PRODUCT}"
  _add="$( new_base_pkgs )"
  [ -z "${_add}" ] || run "logrun pkgs install -U -y -r '${PRODUCT}' ${_add}" || die 3 "cannot install new base packages: ${_add}"
  if [ "${OPT_GO}" -eq 1 ]; then
    # пакеты базы прежнего мажора, которых в новой нет ( 14 -> 15: *-dev, dpv,
    # ftpd ): pkg upgrade их не трогает, а зависимости у них уже сломаны
    # только после смены мажора и никогда не ядро: при 14 -> 14 ( 3.2.0 ) пакет
    # ядра MgmtOS ( kernel-mini ) отсутствует в раздаче, и его удаление оставило
    # бы /boot/kernel пустым
    _orph=""
    [ "${FROM_MAJOR}" -lt "${TARGET_MAJOR}" ] && \
    for _p in $( pkg-static query '%n %v' | awk -v fb="^${FB}" -v m="^${FROM_MAJOR}\\." '$1 ~ fb && $1 !~ /kernel/ && $2 ~ m {print $1}' ); do
      pkgs rquery -U -r "${PRODUCT}" '%n' "${_p}" >/dev/null 2>&1 || _orph="${_orph} ${_p}"
    done
    if [ -n "${_orph}" ]; then
      logrun pkg-static delete -y ${_orph} || log "WARNING: cannot delete old base packages:${_orph}"
      log "INFO: base packages of ${FROM_MAJOR} absent in ${TARGET_MAJOR} removed:${_orph}"
    fi
    # что пропало за весь переход ( транзакция мира удаляет пакеты, чьи
    # зависимости ушли из базы: q7 — curl, lnav ), сверка с бэкапом до мира
    _ref=""
    { [ "${FROM_MAJOR}" -lt "${TARGET_MAJOR}" ] || [ "${FROM_MODE}" = classic ] || [ "${BASE_BEHIND:-0}" = 1 ]; } && _ref="$( ls -d "${BACKUP_ROOT}"/world-* 2>/dev/null | tail -1 )"
    [ -n "${_ref}" ] && [ -s "${_ref}/pkg-before.txt" ] && _before="$( awk '{print $1}' "${_ref}/pkg-before.txt" | sort )"
    _gone="$( printf '%s\n' "${_before}" | grep -v "^${FB}" | grep -vxF "$( pkg-static query '%n' | sort )" | tr '\n' ' ' )"
    [ -z "${_gone}" ] || log "NOTICE: packages removed by the update and absent in ${URL} ( reinstall from the FreeBSD ports repo if needed ): ${_gone}"
    [ -z "${_gone}" ] || printf '%s\n' ${_gone} > "${STATE_DIR}/packages-lost.txt"
  fi
}

app_center() {
  db_owner_align
  stop_services "${CENTER_SERVICES}"
  reinstall_delivery
  run "service vstack_center onestart mgmtvm >> \"${TEE_LOG}\" 2>&1" || die 8 "provisioning ( service vstack_center onestart mgmtvm ) failed, see /var/log/vstack_center.log"
  for _s in ${CENTER_SERVICES}; do
    svc_exists "${_s}" || continue
    [ "${_s}" = vector ] && continue
    run "service '${_s}' restart >/dev/null 2>&1 || service '${_s}' start >/dev/null 2>&1 || true"
  done
  [ "${OPT_GO}" -eq 1 ] && { check_services || die 5 "center services are not all running"; }
  return 0
}

# Линия vstack-mgmtvm: сервисы и пакеты под прежними именами. В раздаче их нет
# ( vstack-cluster-* -> vstack-center-* ), pkg upgrade их не трогает, и без
# уборки прежний диспетчер поднялся бы рядом с новым на той же очереди AMQP,
# а vstack-web помешал бы поставить веб новой поставки ( ранбук 3.0 -> 3.2, §4.5, q26 ).
mgmtvm_line_retire() {
  [ "${GENERATION}" = mgmtvm-line ] || return 0
  stop_services "${MGMTVM_LINE_SERVICES}"
  for _s in ${MGMTVM_LINE_SERVICES}; do
    case "$( sysrc -n "${_s}_enable" 2>/dev/null || true )" in [Yy][Ee][Ss]) ;; *) continue ;; esac
    run "sysrc -f '${ETC_DIR}/rc.conf.local' '${_s}_enable=NO' >/dev/null"
    log "INFO: ${_s} retired ( autostart off for good, the package goes in the app phase )"
  done
}

app_mgmtvm_line() {
  # vstack-mgmtvm и vstack-center делят run-playbook.sh — апгрейдом не
  # переезжают, меняются руками ( ранбук 3.0 -> 3.2, §4.5 ).
  mgmtvm_line_retire
  _old=""
  for _p in ${MGMTVM_LINE_PKGS}; do pkg-static info -e "${_p}" 2>/dev/null && _old="${_old} ${_p}"; done
  [ -z "${_old}" ] || run "pkgs delete -y${_old} >> \"${TEE_LOG}\" 2>&1" || die 3 "cannot delete the mgmtvm line packages:${_old}"
  [ -z "${_old}" ] || log "INFO: mgmtvm line packages removed:${_old}"
  for _s in ${MGMTVM_LINE_SERVICES}; do
    for _f in "${ETC_DIR}/rc.conf" "${ETC_DIR}/rc.conf.local"; do
      [ -f "${_f}" ] && grep -q "^${_s}_" "${_f}" && run "sed -i '' '/^${_s}_/d' '${_f}'"
    done
  done
  run "pkgs install -U -y -r '${PRODUCT}' ${PRODUCT} >> \"${TEE_LOG}\" 2>&1" || die 3 "cannot install ${PRODUCT}"
}

# Владельцы БД. Часть схем и таблиц Center и vstack-mgmtvm принадлежит
# postgres ( q26, q28: 23 таблицы; q25: почти все ), а диспетчер новой поставки
# мигрирует под mgmt и на чужой таблице падает посреди провижна, когда центр
# уже остановлен ( ранбук 3.0 -> 3.2, §5 ). Передаётся всё mgmt, пока
# PostgreSQL ещё работает на прежней системе. old-gen это делает восстановление
# дампа ( --no-owner --role=mgmt ). PostgreSQL не запущен — ничего не делает:
# в фазе app после смены ОС передача уже прошла до неё.
DB_NOT_MGMT_SQL="select count(*) from pg_tables where schemaname not in ('pg_catalog','information_schema') and tableowner <> 'mgmt'"
db_psql() { printf '%s\n' "$1" | ( cd /tmp && su -m postgres -c "psql -X -q -At -v ON_ERROR_STOP=1 -d vstack" ); }
db_owner_align() {
  case "${GENERATION}" in center|mgmtvm-line) ;; *) return 0 ;; esac
  service postgresql status >/dev/null 2>&1 || { log "NOTICE: postgresql is not running, db owners left as they are"; return 0; }
  db_psql "select 1 from pg_roles where rolname = 'mgmt'" | grep -q 1 || { log "WARNING: no role mgmt in postgresql, db owners left as they are"; return 0; }
  _dn="$( db_psql "${DB_NOT_MGMT_SQL}" )" || die 8 "cannot read db owners"
  [ "${_dn}" -gt 0 ] || { log "INFO: db owners: all tables belong to mgmt"; return 0; }
  log "NOTICE: db owners: ${_dn} tables do not belong to mgmt, handing schemas and tables to mgmt"
  [ "${OPT_GO}" -eq 1 ] || { log "DRY: ALTER SCHEMA/TABLE ... OWNER TO mgmt ( ${_dn} tables )"; return 0; }
  db_psql "DO \$\$ DECLARE r record; BEGIN
    FOR r IN SELECT nspname FROM pg_namespace WHERE nspname IN
      ('management','network','node','san','storage','vm','auth_creds','security') AND pg_get_userbyid(nspowner) <> 'mgmt'
    LOOP EXECUTE format('ALTER SCHEMA %I OWNER TO mgmt', r.nspname); END LOOP;
    FOR r IN SELECT schemaname s, tablename t FROM pg_tables WHERE schemaname NOT IN ('pg_catalog','information_schema')
      AND tableowner <> 'mgmt'
    LOOP EXECUTE format('ALTER TABLE %I.%I OWNER TO mgmt', r.s, r.t); END LOOP;
  END \$\$;" >> "${TEE_LOG}" 2>&1 || die 8 "cannot hand db owners to mgmt"
  _dn="$( db_psql "${DB_NOT_MGMT_SQL}" )" || die 8 "cannot read db owners"
  [ "${_dn}" -eq 0 ] || die 8 "db owners: ${_dn} tables still do not belong to mgmt"
  log "INFO: db owners: all tables handed to mgmt"
}

phase_finish() {
  log "INFO: phase finish"
  if [ -d "${STATE_DIR}/bin" ]; then
    run "chflags -R noschg '${STATE_DIR}/bin'; rm -rf '${STATE_DIR}/bin'"
  fi
  # маркер загрузки нужен до первой подтверждённой загрузки нового ядра
  if [ ! -f "${BOOT_DROPIN}" ] && [ -f "${LOCAL_RC_DIR}/${BOOT_MARKER_NAME}" ]; then
    run "rm -f '${LOCAL_RC_DIR}/${BOOT_MARKER_NAME}'"
  fi
  # свои временные: скачанные пакеты ядра, кэш pkg, отключённые конфиги раздач
  run "rm -rf '${STATE_DIR}/kernel-pkgs' '${STATE_DIR}/pkg-users.txt'"
  # pkg clean -a после смены мажора отказывает ( No package database
  # installed, q9 и q7 02.10 ) — кэш только кэш, чистится целиком
  _cd="$( pkg-static config PKG_CACHEDIR 2>/dev/null || true )"
  [ -n "${_cd}" ] && [ -d "${_cd}" ] && run "find '${_cd}' -mindepth 1 -delete"
  # .pkgsave в /boot — прежние бинари загрузчика и модули ядра ( classic:
  # пакет загрузчика лёг поверх дист-файлов; ручной перевод на pkgbase ):
  # правимые руками файлы ( device.hints, loader.conf ) — не трогать, только назвать.
  _keep=""; _n=0
  find "${BOOT_DIR}" -name '*.pkgsave' > "${STATE_DIR}/boot-pkgsave.list" 2>/dev/null || true
  while IFS= read -r _f; do
    case "${_f}" in
      */defaults/*) ;;
      */device.hints.pkgsave|*/loader.conf*.pkgsave)
        if cmp -s "${_f}" "${_f%.pkgsave}"; then
          [ "${OPT_GO}" -eq 1 ] && rm -f "${_f}"
        else
          _keep="${_keep} ${_f}"
        fi
        continue ;;
    esac
    _n=$(( _n + 1 ))
    [ "${OPT_GO}" -eq 1 ] && { chflags noschg "${_f}" 2>/dev/null || true; rm -f "${_f}"; }
  done < "${STATE_DIR}/boot-pkgsave.list"
  rm -f "${STATE_DIR}/boot-pkgsave.list"
  [ "${_n}" -eq 0 ] || log "INFO: ${BOOT_DIR}: ${_n} *.pkgsave removed ( previous loader binaries and kernel modules )"
  [ -z "${_keep}" ] || log "NOTICE: compare with the current file and remove: ${_keep}"
  # копия прежнего ядра после смены мажора загрузить новый мир не может
  # ( T1 02.10: ядро 13 + мир 15 — login и sshd падают с SIGSYS ), только место
  _kp="${BOOT_DIR}/$( loader_kernel_dir )${KERNEL_PREV_SUFFIX}"
  if [ -d "${_kp}" ] && [ ! -f "${BOOT_DROPIN}" ] && [ "${FROM_MAJOR}" -lt "${TARGET_MAJOR}" ]; then
    run "chflags -R noschg '${_kp}' 2>/dev/null; rm -rf '${_kp}'"
    log "INFO: ${_kp} ( FreeBSD ${FROM_MAJOR} ) removed: it cannot run the FreeBSD ${TARGET_MAJOR} world"
  fi
  if ls "${REPOS_DIR}"/*.disabled >/dev/null 2>&1; then
    run "mkdir -p '${BACKUP_ROOT}/repos-disabled' && mv '${REPOS_DIR}'/*.disabled '${BACKUP_ROOT}/repos-disabled/'"
  fi
  # сироты: bhyve ( на mgmtvm не нужен, в 15 со сломанными зависимостями ) и
  # пакеты-зависимости, оставшиеся без хозяина ( q7: heimdal, libssh2 после
  # удаления curl ) — только то, чего нет в поставке: её состав не трогаем
  if [ "${OPT_GO}" -eq 1 ]; then
    _rm=""
    pkg-static info -e "${FB}bhyve" && [ -z "$( pkg-static query '%rn' "${FB}bhyve" )" ] && _rm="${FB}bhyve"
    for _p in $( pkg-static autoremove -n 2>/dev/null | sed -n '/to be REMOVED/,/^$/p' | sed -n 's/^[[:space:]]*\([^:[:space:]]*\):.*/\1/p' ); do
      pkgs rquery -U -r "${PRODUCT}" '%n' "${_p}" >/dev/null 2>&1 || _rm="${_rm} ${_p}"
    done
    if [ -n "${_rm}" ]; then
      logrun pkg-static delete -y ${_rm} || log "WARNING: cannot delete orphans: ${_rm}"
      log "INFO: orphan packages removed: ${_rm}"
    fi
  fi
  # пакеты вне поставки, собранные под прежний мажор ( q9: curl, lnav ), живут
  # на библиотеках из compat — назвать, чтобы их пересобрали или переставили
  if [ "${OPT_GO}" -eq 1 ]; then
    _old="$( pkg-static check -d -a 2>/dev/null | sed -n 's/^\([^ ]*\) is missing a required shared library.*/\1/p' | sort -u | tr '\n' ' ' )"
    [ -z "${_old}" ] || log "NOTICE: packages outside ${PRODUCT} built for an older FreeBSD, kept working by ${COMPAT_DIR}: ${_old}— reinstall them from the FreeBSD ${TARGET_MAJOR} ports repo"
  fi
  # /usr/lib/compat: остаётся только то, чем владеет пакет ( compat14x и т. п. ) или
  # что по pkg check -d ещё требуется. Всё прочее — копии страховки и чужие
  # остатки прежних переходов ( .pkgsave ручного перевода q7 на pkgbase ).
  if [ -d "${COMPAT_DIR}" ] && [ "${OPT_GO}" -eq 1 ]; then
    _need="$( pkg-static check -d -a 2>/dev/null | sed -n 's/.*required shared library: //p' | sort -u )"
    for _l in "${COMPAT_DIR}"/*; do
      [ -f "${_l}" ] || [ -L "${_l}" ] || continue
      pkg-static which -q "${_l}" >/dev/null 2>&1 && continue
      case "${_l}" in *.pkgsave) ;; *) printf '%s\n' "${_need}" | grep -qx "${_l##*/}" && continue ;; esac
      chflags noschg "${_l}" 2>/dev/null || true; rm -f "${_l}"
    done
    rm -f "${STATE_DIR}/compat-libs.list"
    _left="$( find "${COMPAT_DIR}" -maxdepth 1 ! -type d 2>/dev/null | sed 's|.*/||' | tr '\n' ' ' )"
    log "INFO: ${COMPAT_DIR} keeps only what installed packages still need: ${_left:-nothing}"
    /etc/rc.d/ldconfig restart >/dev/null 2>&1 || true
  fi
  _sum="${BACKUP_ROOT}/summary-${_TS}.txt"
  if [ "${OPT_GO}" -eq 1 ]; then
    mkdir -p "${BACKUP_ROOT}"
    {
      echo "mgmtvm-update summary ${_TS}"
      echo "from: FreeBSD ${FROM_MAJOR} ${FROM_MODE}, ${GENERATION}"
      echo "now:  $( freebsd-version -kru | tr '\n' ' ' )"
      echo "packages: $( pkg-static query '%n' | wc -l | tr -d ' ' ), base: $( base_count )"
      echo "pkg check -d:"; pkg-static check -d -a 2>&1 | grep -v '^Checking' || true
      [ -s "${STATE_DIR}/packages-lost.txt" ] && { echo "packages removed by the update ( not in the delivery ):"; sed 's/^/  /' "${STATE_DIR}/packages-lost.txt"; }
      echo "autoremove candidates:"; pkg-static autoremove -n 2>/dev/null | sed -n '/to be REMOVED/,/^$/p' || true
      echo "services:"; for _s in ${CENTER_SERVICES}; do svc_exists "${_s}" && printf '  %s: %s\n' "${_s}" "$( service "${_s}" status >/dev/null 2>&1 && echo running || echo down )"; done
    } > "${_sum}"
    log "INFO: summary: ${_sum}"
    cat "${_sum}" >> "${LOG_FILE}"
  fi
  state_set "done"
  NEXT_PHASE="done"
}

OLDGEN_LIB="$( dirname "$0" )/mgmtvm-update-oldgen.sh"
if [ -f "${OLDGEN_LIB}" ]; then
  . "${OLDGEN_LIB}"
else
  app_save_oldgen() { die 8 "old-gen support file ${OLDGEN_LIB} not found"; }
  app_oldgen() { die 8 "old-gen support file ${OLDGEN_LIB} not found"; }
  oldgen_cleanup() { die 8 "old-gen support file ${OLDGEN_LIB} not found"; }
fi

# ── главный цикл ────────────────────────────────────────────────────────────

# Один экземпляр на машину: два параллельных прогона одной фазы ( 01.10, стенд )
# гоняются за /boot/<kdir>_prev и взвод fallback.
if [ "${OPT_GO}" -eq 1 ]; then
  mkdir -p "${STATE_DIR}"
  _pid="$( cat "${STATE_DIR}/run.pid" 2>/dev/null || true )"
  if [ -n "${_pid}" ] && [ "${_pid}" != "$$" ] && kill -0 "${_pid}" 2>/dev/null; then
    die 2 "another mgmtvm-update is running ( pid ${_pid} ), see ${LOG_FILE}"
  fi
  echo $$ > "${STATE_DIR}/run.pid"
  OWN_PIDFILE=1
  [ "${OPT_PHASE}" = world ] || [ "${OPT_PHASE}" = oldgen-cleanup ] || reboot_guard
fi

if [ "${OPT_GO}" -eq 0 ] && [ "${OPT_DRY}" -eq 0 ]; then
  log "NOTICE: plan only: take snapshots of the VM disks on the owning node, then run again with --snapshots-taken"
fi

NEXT_PHASE="${OPT_PHASE:-$( state_get )}"
[ -n "${NEXT_PHASE}" ] && [ "${NEXT_PHASE}" != "done" ] || NEXT_PHASE=detect
[ "${OPT_GO}" -eq 1 ] || [ "${OPT_PHASE}" = oldgen-cleanup ] || NEXT_PHASE=detect

while :; do
  case "${NEXT_PHASE}" in
    detect)   phase_detect ;;
    app-save) app_save_oldgen ;;
    world|kernel) phase_world ;;
    app)      phase_app ;;
    finish)   phase_finish ;;
    oldgen-cleanup) oldgen_cleanup ;;
    done)     log "INFO: nothing to do, update is complete ( state ${STATE_FILE} )"; exit 0 ;;
    *)        die 2 "unknown phase ${NEXT_PHASE}" ;;
  esac
  if [ "${OPT_GO}" -eq 0 ]; then
    [ "${NEXT_PHASE}" = detect ] || [ "${NEXT_PHASE}" = "done" ] && exit 0
    # --dry-run проходит следующую фазу вхолостую ( план без него — нет )
    if [ "${OPT_DRY}" -eq 1 ] && [ -z "${_dry_done:-}" ] && [ "${NEXT_PHASE}" != "done" ]; then _dry_done=1; continue; fi
    log "DRY: next phase would be ${NEXT_PHASE}; to run: --snapshots-taken"
    exit 0
  fi
done
