#!/bin/sh
set -eu

#
# Powered by Arviol
# Ilya Vasilyev, vStack, 2026
#

usage() {
  echo "Usage: $0 [OPTIONS]"
  echo ""
  echo "Component selection (at least one required):"
  echo "  --controllers            upgrade installed non-FreeBSD-* packages from repo vstack-os"
  echo "  --base                   full pkgbase upgrade from repo vstack-os (FreeBSD-* + kernel, covers controllers too)"
  echo ""
  echo "Version / source:"
  echo "  --version X.Y.Z[-rcN]   required for tech/release; omit for dev"
  echo "  --source SOURCE          dev | tech | release | local"
  echo "  --repo-url U             override repo URL"
  echo ""
  echo "Cross-major (2.x/FreeBSD 13.2 -> 3.x/FreeBSD 14.2, 3.x/FreeBSD 14 -> 4.x/FreeBSD 15):"
  echo "  --allow-major-upgrade    permit a major transition; without it the run is refused as before"
  echo "  --resume                 continue a pkgbase transition that stopped after its transactions"
  echo "                           began: reuses that run's pkg-static copy and backups"
  echo "  --install-missing        also install every package the target release has and the node lacks,"
  echo "                           including the node metaport ( XM_METAPORT ) with its components"
  echo "  --rename-map PATH        old->new package map (default: <delivery>/pkg-rename.map next to repo)"
  echo ""
  echo "Options:"
  echo "  --abi ABI                pkg ABI override (default: pkg autodetect)"
  echo "  --log PATH               log file (default: /var/log/vstack-os-update-<ts>.log)"
  echo "  --dry-run                print the plan ( catalogue, ABI, guards, upgrade, cross-major ), no changes"
  echo "  --no-restart             skip service restarts"
  echo "  --no-esp                 do not sync the loader to the EFI system partitions"
  echo "  --no-kernel-backup       do not keep a copy of the running kernel"
  echo "  --no-fallback            do not arm one-shot boot of the new kernel"
  echo "  --no-swap-encrypt        leave the swap entries of /etc/fstab and the swap partitions as they are"
  echo "  --reboot                 reboot after base/kernel update"
  echo "  -h|--help                this message"
  echo ""
  echo "Source table:"
  echo "  dev     blade-runner.vstack.com/repos/vstack/<chan>/<dir>/repo  (no tag needed)"
  echo "  tech    repo.dev.vstack.com/<dir>/<ver>/repo                    Q7/Q999, needs --version"
  echo "  release repo.vstack.com/<dir>/<ver>/repo                        clients, needs --version"
  echo "  local   file:///local/repos/vstack/<chan>/<dir>/repo"
  echo ""
  echo "  <dir> is OS_REPO_DIR (env, default os; os-fstec for the certified"
  echo "  contour), <chan> is BUILD_CHANNEL (env, default dev)"
  echo ""
  echo "Repo:"
  echo "  single repo vstack-os serves pkgbase (FreeBSD-*) and controllers packages"
}

log() {
  _ts="$( date -u '+%Y-%m-%dT%H:%M:%SZ' )"
  printf '%s %s\n' "${_ts}" "$*" | tee -a "${LOG_FILE}"
}

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-os}"
# Product directory, the same in every contour: channel delivery is laid out as
# <root>/<channel>/<dir>/repo, tagged delivery as <dir>/<version>/repo.
OS_REPO_DIR="${OS_REPO_DIR:-os}"
PUBLISH_URL_DIR="${PUBLISH_URL_DIR:-repos/vstack}"
BUILD_CHANNEL="${BUILD_CHANNEL:-dev}"
FREEBSD_PKG_PREFIX="${FREEBSD_PKG_PREFIX:-FreeBSD-}"
BASE_TREE_PREFIX="${BASE_TREE_PREFIX:-/usr}"
PUBKEY_PATH="${PUBKEY_PATH:-/usr/share/keys/vstack/vstackos.pub}"
KEYS_URL_BASE="${KEYS_URL_BASE:-https://blade-runner.vstack.com/keys}"
ETC_DIR="${ETC_DIR:-/etc}"
LOCAL_ETC_DIR="${LOCAL_ETC_DIR:-/usr/local/etc}"
OS_RELEASE_FILE="${OS_RELEASE_FILE:-/etc/os-release}"
# ID в os-release, по которому файл опознаётся как наш, а не базовый FreeBSD.
OS_RELEASE_ID_VSTACK="${OS_RELEASE_ID_VSTACK:-vstack}"
REPOS_DIR="${REPOS_DIR:-/usr/local/etc/pkg/repos}"
BACKUP_ROOT="${BACKUP_ROOT:-/var/backups}"
LOG_DIR="${LOG_DIR:-/var/log}"
VSTACK_ENV_CONF="${VSTACK_ENV_CONF:-/usr/local/etc/vstack-env.conf}"
LOCAL_REPO_ROOT="${LOCAL_REPO_ROOT:-/local/repos/vstack}"
BACKUP_KEEP="${BACKUP_KEEP:-5}"
BOOT_DIR="${BOOT_DIR:-/boot}"
# Суффикс каталога, куда прячется прежнее ядро: с него можно загрузиться
# из loader-промпта ( boot <имя> ), не поднимая носитель.
KERNEL_PREV_SUFFIX="${KERNEL_PREV_SUFFIX:-_prev}"
# Минимальный разумный размер ядра и число модулей — грубое сито против
# обрезанной установки.
KERNEL_MIN_SIZE="${KERNEL_MIN_SIZE:-10485760}"
KERNEL_MIN_MODULES="${KERNEL_MIN_MODULES:-50}"
# Сколько загрузчик может положить в память до старта ядра без расширения
# своей staging-области: у EFI-загрузчика FreeBSD она 64 МиБ минус 8 МиБ запаса
# ( stand/efi/loader/copy.c ). Выше порога загрузчик доаллоцирует область на
# ходу, и не всякая прошивка это переживает - на VMware узел сбрасывается до
# первой строки ядра. Считаем по размерам файлов, а в памяти они занимают
# больше ( bss, выравнивание ): на q900 53 МиБ файлов дали 58 МиБ предзагрузки,
# порог 48 оставляет этот запас. 0 отключает предупреждение.
PRELOAD_WARN_MIB="${PRELOAD_WARN_MIB:-48}"
# Нижняя граница правдоподобного loader.efi: в парке он 650-750 КБ. Нужна не для
# точности, а чтобы на ESP заведомо не уехал обрезок ( см. is_efi_app ).
LOADER_MIN_SIZE="${LOADER_MIN_SIZE:-131072}"
# Где монтировать ESP, кроме штатной ${BOOT_DIR}/efi.
ESP_MNT_BASE="${ESP_MNT_BASE:-/tmp}"
# Drop-in с ДЕФОЛТНЫМ ядром на время окна обновления. Каталог читается
# загрузчиком после loader.conf ( loader_conf_dirs в /boot/defaults ),
# префикс zz- чтобы наш файл шёл последним среди drop-in.
BOOT_DROPIN="${BOOT_DROPIN:-${BOOT_DIR:-/boot}/loader.conf.d/zz-vstack-fallback.conf}"
LOCAL_RC_DIR="${LOCAL_RC_DIR:-/usr/local/etc/rc.d}"
BASE_RC_DIR="${BASE_RC_DIR:-/etc/rc.d}"
# Имя fsync(1) - переменной, чтобы ветку «fsync недоступен» можно было проверить
# тестом детерминированно: на FreeBSD fsync(1) есть в базе, на Linux его нет, и
# удаление заглушки проверяло не ветку, а состав системы, на которой гоняют набор.
FSYNC_BIN="${FSYNC_BIN:-fsync}"
BOOT_MARKER_NAME="${BOOT_MARKER_NAME:-vstack_boot_marker}"
# Смена мажора на узле с pkgbase ( 14 -> 15 ): копии библиотек прежнего мира для
# пакетов, которые остаются его сборкой, и проверка sshd нового мажора.
COMPAT_DIR="${COMPAT_DIR:-/usr/lib/compat}"
LIB_DIRS="${LIB_DIRS:-/lib /usr/lib}"
SSHD_SESSION_PATH="${SSHD_SESSION_PATH:-/usr/libexec/sshd-session}"
MIN_FREE_KB="${MIN_FREE_KB:-2097152}"

OPT_CONTROLLERS=0
OPT_BASE=0
OPT_VERSION=""
OPT_SOURCE=""
OPT_REPO_URL=""
OPT_ABI=""
OPT_LOG=""
OPT_DRY_RUN=0
OPT_NO_RESTART=0
OPT_REBOOT=0
OPT_NO_ESP=0
OPT_NO_KERNEL_BACKUP=0
OPT_NO_FALLBACK=0
OPT_ALLOW_MAJOR=0
OPT_INSTALL_MISSING=0
OPT_RENAME_MAP=""
OPT_RESUME=0
OPT_NO_SWAP_ENCRYPT=0

# Взводится САМИМИ гейтами, когда переход действительно межмажорный и разрешён
# флагом. Отличать от OPT_ALLOW_MAJOR: флаг может быть передан и на обычном
# обновлении внутри линии - тогда межмажорные шаги не нужны.
MAJOR_MODE=0
BOOT_SYNC_RC=0
XM_VSTACK_FROM=""
XM_VSTACK_TO=""
XM_INSTALLED=""
XM_NO_SUCCESSOR=""
XM_MISSING=""
XM_CTRL_MISSING=""
XM_LEFTOVERS=""
ROOT_SHELL_RC=0
# Смена мажора на узле, который уже на pkgbase ( 3.x/14 -> 4.x/15 ). Отдельно
# от XM_BASE_PHASE ( узел 2.x на дист-сетах, базу там СТАВЯТ ): здесь база
# заменяется своей же линией нового мажора, и pkg нового мажора на старом
# ядре не работает ( SIGSYS, q903 2026-10-02 ).
XM_PKGBASE=0
XM_FROM_MAJOR=""
XM_TO_MAJOR=""
XM_COMPAT_SAVED=0
XM_OLD_BASE_REMOVED=""
XM_PKG_LEFTOVERS=""
XM_MISSING_REPAIRED=""
XM_BRANDING_OFF=""
XM_PREREBOOT_RC=0
RESUME_FILE=""

# Пакеты, без которых узел после смены линии теряет управляемость. Оболочка
# root сюда НЕ вписана: её имя берётся из master.passwd в runtime ( на переходе
# 13.2 -> 14.2 это был FreeBSD-csh, на следующем может быть другое ).
#
# ВРЕМЕННО, КАК И ШИМ vmagent НИЖЕ: состав узла обязан описываться метапортом,
# а не списком в скрипте обновления. Пока метапорта нет, лучше явный список,
# чем ещё один проход вручную.
XM_REQUIRED_PKGS="${XM_REQUIRED_PKGS:-${FREEBSD_PKG_PREFIX}netmap ${FREEBSD_PKG_PREFIX}certctl ${FREEBSD_PKG_PREFIX}console-tools}"
# Метапорт узла: его зависимости и есть состав узла целевой линии. Узел прежней
# линии никогда не видел SPDK, NVMe-oF-инициатора и SAN - они не FreeBSD-* и не
# переименованы, они просто новые, и без сверки с метапортом их не хватало молча
# ( q900, 2.2 -> 3.2.0, 2026-09-30 ).
XM_METAPORT="${XM_METAPORT:-vStack-cluster-metaport}"

while [ "$#" -gt 0 ]; do
  case "$1" in
    --controllers) OPT_CONTROLLERS=1; shift ;;
    --base)        OPT_BASE=1; shift ;;
    --version)     OPT_VERSION="$2"; shift 2 ;;
    --source)      OPT_SOURCE="$2"; shift 2 ;;
    --repo-url)    OPT_REPO_URL="$2"; shift 2 ;;
    --abi)         OPT_ABI="$2"; shift 2 ;;
    --log)         OPT_LOG="$2"; shift 2 ;;
    --dry-run)     OPT_DRY_RUN=1; shift ;;
    --no-restart)  OPT_NO_RESTART=1; shift ;;
    --reboot)      OPT_REBOOT=1; shift ;;
    --no-esp)      OPT_NO_ESP=1; shift ;;
    --no-kernel-backup) OPT_NO_KERNEL_BACKUP=1; shift ;;
    --no-fallback) OPT_NO_FALLBACK=1; shift ;;
    --no-swap-encrypt)     OPT_NO_SWAP_ENCRYPT=1; shift ;;
    --allow-major-upgrade) OPT_ALLOW_MAJOR=1; shift ;;
    --install-missing)     OPT_INSTALL_MISSING=1; shift ;;
    --rename-map)          OPT_RENAME_MAP="$2"; shift 2 ;;
    --resume)              OPT_RESUME=1; shift ;;
    -h|--help)     usage; exit 0 ;;
    *) printf 'ERROR: unknown argument: %s\n' "$1" >&2; usage >&2; exit 1 ;;
  esac
done

if [ "${OPT_CONTROLLERS}" -eq 0 ] && [ "${OPT_BASE}" -eq 0 ]; then
  printf 'ERROR: at least one of --controllers or --base is required\n' >&2
  usage >&2
  exit 1
fi

ver_fail() {
  printf 'ERROR: --version must match X.Y.Z or X.Y.Z-rcN (got: %s)\n' "${OPT_VERSION}" >&2
  exit 1
}

digits_only() {
  case "$1" in
    ''|*[!0-9]*) return 1 ;;
    *) return 0 ;;
  esac
}

if [ -n "${OPT_VERSION}" ]; then
  _ver_core="${OPT_VERSION%%-*}"
  _ver_suffix="${OPT_VERSION#"${_ver_core}"}"
  case "${_ver_core}" in
    *.*.*) ;;
    *) ver_fail ;;
  esac
  _major="${_ver_core%%.*}"
  _rest="${_ver_core#*.}"
  _minor="${_rest%%.*}"
  _patch="${_rest#*.}"
  case "${_patch}" in *.*) ver_fail ;; esac
  digits_only "${_major}" || ver_fail
  digits_only "${_minor}" || ver_fail
  digits_only "${_patch}" || ver_fail
  if [ -n "${_ver_suffix}" ]; then
    case "${_ver_suffix}" in
      -rc*) digits_only "${_ver_suffix#-rc}" || ver_fail ;;
      *) ver_fail ;;
    esac
  fi
fi

if [ -z "${OPT_SOURCE}" ] && [ -z "${OPT_REPO_URL}" ]; then
  printf 'ERROR: --source or --repo-url is required\n' >&2
  usage >&2
  exit 1
fi

if [ -n "${OPT_SOURCE}" ]; then
  case "${OPT_SOURCE}" in
    dev|tech|release|local) ;;
    *) printf 'ERROR: --source must be dev|tech|release|local (got: %s)\n' "${OPT_SOURCE}" >&2; exit 1 ;;
  esac
  if [ "${OPT_SOURCE}" = "tech" ] || [ "${OPT_SOURCE}" = "release" ]; then
    if [ -z "${OPT_VERSION}" ]; then
      printf 'ERROR: --version is required for source=%s\n' "${OPT_SOURCE}" >&2
      exit 1
    fi
  fi
  if [ "${OPT_SOURCE}" = "dev" ] && [ -n "${OPT_VERSION}" ]; then
    printf 'WARNING: --version=%s is ignored for source=dev (dev repo always serves latest)\n' "${OPT_VERSION}" >&2
  fi
fi

if [ -n "${OPT_VERSION}" ]; then
  _installed_major=""
  if [ -r "${OS_RELEASE_FILE}" ]; then
    # Версию продукта берём из os-release, но только если файл не описывает
    # ЯВНО другую ОС. На узле, пришедшем переходом 2.2 -> 3.0, /etc/os-release
    # кладёт FreeBSD-runtime из прод-бандла: там ID=freebsd и VERSION_ID версии
    # БАЗЫ ( 14.2 ), а не продукта. Без этой проверки рядовое обновление
    # 3.0 -> 3.2 выглядит сменой мажора 14 -> 3 и требует
    # --allow-major-upgrade, то есть оператор приучается глушить флагом защиту,
    # которая должна срабатывать только на настоящем переходе между мажорами.
    _os_release_id="$( awk -F= '$1 == "ID" { gsub(/"/, "", $2); print $2; exit }' "${OS_RELEASE_FILE}" )"
    if [ -n "${_os_release_id}" ] && [ "${_os_release_id}" != "${OS_RELEASE_ID_VSTACK}" ]; then
      printf 'WARNING: %s describes %s, not %s - major guard has no source and is skipped\n' \
        "${OS_RELEASE_FILE}" "${_os_release_id}" "${OS_RELEASE_ID_VSTACK}" >&2
    else
      _installed_ver="$( awk -F= '$1 == "VERSION_ID" { gsub(/"/, "", $2); print $2; exit }' "${OS_RELEASE_FILE}" )"
      _installed_major="${_installed_ver%%.*}"
    fi
  fi
  if digits_only "${_installed_major}"; then
    if [ "${_major}" != "${_installed_major}" ]; then
      if [ "${OPT_ALLOW_MAJOR}" -ne 1 ]; then
        printf 'ERROR: cross-major vStack upgrade %s -> %s requires --allow-major-upgrade\n' \
          "${_installed_ver}" "${OPT_VERSION}" >&2
        exit 2
      fi
      printf 'NOTICE: cross-major vStack upgrade %s -> %s enabled explicitly (--allow-major-upgrade)\n' \
        "${_installed_ver}" "${OPT_VERSION}" >&2
      MAJOR_MODE=1
      XM_VSTACK_FROM="${_installed_ver}"
      XM_VSTACK_TO="${OPT_VERSION}"
    fi
  else
    printf 'WARNING: cannot determine installed major from %s, major guard skipped\n' "${OS_RELEASE_FILE}" >&2
  fi
fi

_TS="$( date -u '+%Y%m%dT%H%M%SZ' )"
mkdir -p "${BACKUP_ROOT}" 2>/dev/null || true
_SUMMARY_FILE="${BACKUP_ROOT}/update-summary-${_TS}.txt"
_VER_BEFORE="${BACKUP_ROOT}/versions-before-${_TS}.txt"
_VER_AFTER="${BACKUP_ROOT}/versions-after-${_TS}.txt"
# Имена пакетов ДО базовой транзакции: карта переименований сверяется
# именно с ними ( см. XM_NAMES_BEFORE ниже ).
_NAMES_BEFORE="${BACKUP_ROOT}/names-before-${_TS}.txt"
# Что загрузчик предзагрузит на следующем старте - до и после транзакции.
_PRELOAD_BEFORE="${BACKUP_ROOT}/preload-before-${_TS}.txt"
_PRELOAD_AFTER="${BACKUP_ROOT}/preload-after-${_TS}.txt"

if [ -n "${OPT_LOG}" ]; then
  LOG_FILE="${OPT_LOG}"
else
  LOG_FILE="${LOG_DIR}/vstack-os-update-${_TS}.log"
fi

mkdir -p "$( dirname "${LOG_FILE}" )"
: >> "${LOG_FILE}"

# В сухом прогоне каталог репо-конфигов подменяется временным ( PKG_REPOS_DIR ):
# каждый вызов pkg видит ту же раздачу, что и настоящий прогон, а на узле не
# появляется ни одного файла.
PKG_REPOS_DIR=""
# Чем звать pkg. При смене мажора на pkgbase - копией pkg-static, снятой ДО
# транзакций: pkg целевой линии ставится обычным пакетом, и на прежнем ядре
# он падает с SIGSYS на первом системном вызове нового ядра ( q903 ).
PKG_BIN="pkg-static"
pkg_s() {
  if [ -n "${PKG_REPOS_DIR}" ] && [ -n "${OPT_ABI}" ]; then
    "${PKG_BIN}" -o REPOS_DIR="${PKG_REPOS_DIR}" -o ABI="${OPT_ABI}" "$@"
  elif [ -n "${PKG_REPOS_DIR}" ]; then
    "${PKG_BIN}" -o REPOS_DIR="${PKG_REPOS_DIR}" "$@"
  elif [ -n "${OPT_ABI}" ]; then
    "${PKG_BIN}" -o ABI="${OPT_ABI}" "$@"
  else
    "${PKG_BIN}" "$@"
  fi
}

ASSUME_ALWAYS_YES=yes
IGNORE_OSVERSION=yes
export ASSUME_ALWAYS_YES IGNORE_OSVERSION

running_kernel_major() {
  _rkm="$( uname -r 2>/dev/null || true )"
  printf '%s\n' "${_rkm%%.*}"
}

# Прерванная смена мажора на pkgbase. После транзакций pkg-static узла уже
# нового мажора и на прежнем ядре не запускается, а /etc частично заменён:
# прогон с нуля снял бы бэкап с уже изменённого /etc и вернул бы узлу пакетный
# sshd_config. Поэтому повтор - только явный ( --resume ), на копии pkg и
# бэкапах первого прогона; маркер называет их.
XM_RESUME_MARK="${BACKUP_ROOT}/cross-major.inprogress"
XM_DONE_MARK="${BACKUP_ROOT}/cross-major.done"
resume_get() { sed -n "s/^$1=//p" "$2" 2>/dev/null | tail -1; }
if [ -f "${XM_RESUME_MARK}" ]; then
  if [ "${OPT_RESUME}" -ne 1 ]; then
    log "ERROR: a cross-major transition stopped after its package transactions began ( ${XM_RESUME_MARK} )"
    log "ERROR: rerun the same command with --resume: it reuses that run's pkg-static copy and backups"
    exit 2
  fi
  RESUME_FILE="${XM_RESUME_MARK}"
  XM_FROM_MAJOR="$( resume_get FROM_MAJOR "${RESUME_FILE}" )"
  XM_TO_MAJOR="$( resume_get TO_MAJOR "${RESUME_FILE}" )"
  PKG_BIN="$( resume_get PKG_BIN "${RESUME_FILE}" )"
  OPT_ABI="${OPT_ABI:-$( resume_get TARGET_ABI "${RESUME_FILE}" )}"
  if [ "$( running_kernel_major )" != "${XM_FROM_MAJOR}" ]; then
    log "ERROR: ${XM_RESUME_MARK} describes FreeBSD ${XM_FROM_MAJOR} -> ${XM_TO_MAJOR}, but the running kernel is $( uname -r )"
    log "ERROR: the node was rebooted in the middle of the transition - return to the snapshot"
    exit 2
  fi
  if [ ! -x "${PKG_BIN}" ] || ! "${PKG_BIN}" -v >/dev/null 2>&1; then
    log "ERROR: ${XM_RESUME_MARK}: saved pkg-static ${PKG_BIN:-?} is missing or does not run"
    exit 2
  fi
  log "INFO: resuming FreeBSD ${XM_FROM_MAJOR} -> ${XM_TO_MAJOR} transition with ${PKG_BIN}"
elif [ "${OPT_RESUME}" -eq 1 ]; then
  log "ERROR: --resume given, but there is no unfinished transition ( ${XM_RESUME_MARK} is absent )"
  exit 2
fi

build_url() {
  _bu_source="$1"
  _bu_version="${2:-}"
  case "${_bu_source}" in
    dev)
      printf '%s/%s/%s/%s/repo' "${BLADE_BASE_URL}" "${PUBLISH_URL_DIR}" "${BUILD_CHANNEL}" "${OS_REPO_DIR}"
      ;;
    tech)
      printf '%s/%s/%s/repo' "${REPO_BASE_URL_TECH}" "${OS_REPO_DIR}" "${_bu_version}"
      ;;
    release)
      printf '%s/%s/%s/repo' "${REPO_BASE_URL_RELEASE}" "${OS_REPO_DIR}" "${_bu_version}"
      ;;
    local)
      printf 'file://%s/%s/%s/repo/' "${LOCAL_REPO_ROOT}" "${BUILD_CHANNEL}" "${OS_REPO_DIR}"
      ;;
  esac
}

URL=""
SIG_TYPE="pubkey"

if [ -n "${OPT_REPO_URL}" ]; then
  URL="${OPT_REPO_URL}"
else
  URL="$( build_url "${OPT_SOURCE}" "${OPT_VERSION}" )"
fi
if [ "${OPT_SOURCE}" = "local" ] && [ -z "${OPT_REPO_URL}" ]; then
  SIG_TYPE="none"
fi

# Без -s: он печатает размер файла в stdout, и в выводе прогона повисала
# одинокая строка вроде "179" ( q900, 2026-10-02 ).
check_remote_url() {
  _cr_url="$1"
  fetch -qo /dev/null "${_cr_url%/}/meta.conf" 2>/dev/null
}

check_local_path() {
  _cl_path="$1"
  test -d "${_cl_path}"
}

write_repo_conf() {
  _wr_name="$1"
  _wr_url="$2"
  _wr_sig="$3"
  _wr_dir="${4:-${REPOS_DIR}}"
  _wr_out="${_wr_dir}/${_wr_name}.conf"
  _wr_key_line=""
  if [ "${_wr_sig}" = "pubkey" ]; then
    _wr_key_line="
  pubkey: \"${PUBKEY_PATH}\","
  fi
  mkdir -p "${_wr_dir}"
  cat > "${_wr_out}" <<EOFCONF
${_wr_name}: {
  url: "${_wr_url}",
  signature_type: "${_wr_sig}",${_wr_key_line}
  enabled: yes
}
EOFCONF
  # Официальные раздачи FreeBSD: на 14 одна, FreeBSD; на 15 /etc/pkg/FreeBSD.conf
  # ( пакет FreeBSD-pkg-bootstrap ) описывает FreeBSD-ports и FreeBSD-ports-kmods
  # ( включены ) и FreeBSD-base. Выключаем все четыре: иначе после перехода на 15
  # pkg upgrade без -r видит quarterly FreeBSD рядом с поставкой ( q8, q9 ).
  # Блоки без url pkg 14 принимает молча.
  case "${_wr_name}" in
    vstack-os|vstack-os-fstec)
      for _wr_up in FreeBSD FreeBSD-ports FreeBSD-ports-kmods FreeBSD-base; do
        printf '\n%s: {\n  enabled: no\n}\n' "${_wr_up}" >> "${_wr_out}"
      done
      ;;
  esac
}

fetch_pubkey() {
  _fp_repo_url="$1"
  mkdir -p "$( dirname "${PUBKEY_PATH}" )"
  _fp_bundle="${_fp_repo_url%/}"; _fp_bundle="${_fp_bundle%/repo}"
  for _fp_url in "${_fp_repo_url%/}/vstackos.pub" "${_fp_bundle}/poudriere.pub" \
                 "${KEYS_URL_BASE}/vstackos.pub" "${KEYS_URL_BASE}/poudriere.pub"; do
    if fetch -qo "${PUBKEY_PATH}" "${_fp_url}" 2>/dev/null; then
      log "INFO: pubkey fetched from ${_fp_url}"
      return 0
    fi
  done
  return 1
}

disable_conflicting_confs() {
  _dc_keep="$1"
  if [ ! -d "${REPOS_DIR}" ]; then
    return 0
  fi
  for _f in "${REPOS_DIR}"/*.conf; do
    [ -f "${_f}" ] || continue
    _bn="$( basename "${_f}" .conf )"
    if [ "${_bn}" = "${_dc_keep}" ]; then
      continue
    fi
    mv "${_f}" "${_f}.disabled"
    log "INFO: disabled conflicting repo conf ${_f}"
  done
}

rotate_backups() {
  _rb_dir="$1"
  _rb_keep="$2"
  _rb_count=0
  _rb_list=""
  for _d in "${_rb_dir}"/etc-*; do
    [ -d "${_d}" ] || continue
    _rb_count=$(( _rb_count + 1 ))
    if [ -z "${_rb_list}" ]; then
      _rb_list="${_d}"
    else
      _rb_list="${_rb_list}
${_d}"
    fi
  done
  if [ "${_rb_count}" -le "${_rb_keep}" ]; then
    return 0
  fi
  _rb_del=$(( _rb_count - _rb_keep ))
  _rb_n=0
  printf '%s\n' "${_rb_list}" | sort | while IFS= read -r _old; do
    _rb_n=$(( _rb_n + 1 ))
    if [ "${_rb_n}" -le "${_rb_del}" ]; then
      rm -rf "${_old}"
      log "INFO: rotated old backup ${_old}"
    fi
  done
}

restore_etc_file() {
  _re_src="$1"
  _re_dst="$2"
  if [ -e "${_re_src}" ]; then
    cp -p "${_re_src}" "${_re_dst}"
  fi
}

merge_etc_db() {
  _me_old="$1"
  _me_post="$2"
  _me_dst="$3"
  _me_label="$4"
  if [ ! -e "${_me_old}" ]; then
    return 0
  fi
  if [ ! -e "${_me_post}" ]; then
    cp -p "${_me_old}" "${_me_dst}"
    return 0
  fi
  _me_tmp="${_me_dst}.merge.$$"
  _me_add="${_me_tmp}.add"
  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_add}"
  if [ -s "${_me_add}" ]; then
    cat "${_me_add}" >> "${_me_tmp}"
    while IFS=: read -r _me_name _me_rest; do
      log "INFO: ${_me_label}: preserved principal added during upgrade: ${_me_name}"
    done < "${_me_add}"
  fi
  rm -f "${_me_add}"
  mv "${_me_tmp}" "${_me_dst}"
}

# ==============================================================================
# Своп на одноразовом ключе ( DEF0001760 ).
#
# Страницы ВМ без -S уходят в своп, а обнуление памяти при уничтожении ВМ
# ( hw.vmm.safe_exit ) их не покрывает: блок свопа освобождается без
# перезаписи, данные гостя остаются на разделе. Запись <раздел>.eli в fstab
# поднимает на разделе geli onetime со случайным ключом на каждую загрузку.
# Новые узлы так ставит инсталлятор ( local.post-configure ), здесь доводятся
# стоящие.
#
# fstab переводится всегда ( кроме --no-swap-encrypt ). Сразу, без
# перезагрузки, своп снимается, раздел обнуляется и поднимается шифрованным -
# только когда работает то же ядро, что загрузится следующим, и geom_eli для
# него есть: swapon X.eli грузит geom_eli.ko из каталога ядра, и после замены
# ядра без перезагрузки это модуль чужой сборки или ENOENT ( DEF0001746 ). При
# смене мажора, новом ядре или если своп не снять ( не хватает памяти )
# шифрование включается с перезагрузки. Если шифрованный своп не поднялся,
# возвращается прежний открытый, чтобы узел не остался без свопа до ребута.
#
# Пока раздел не обнулён, на нём лежит прежнее открытое содержимое. Такие
# разделы записываются в ${BACKUP_ROOT}/swap-wipe.pending: следующий прогон
# после перезагрузки стирает их сам ( снять .eli, обнулить, поднять ), а до
# тех пор каждый прогон печатает их в журнал и в сводку с командой стирания.
# Обнуляется только раздел типа freebsd-swap по разметке; прочее - вручную.
# ==============================================================================
SWAP_CONVERTED=""
SWAP_LIVE=""
SWAP_PENDING=""
SWAP_PLAIN_NOW=""
SWAP_OFF_NOW=""
SWAP_WIPED=""
SWAP_WIPE_LEFT=""
SWAP_WHY=""
SWAP_WIPE_MARK="${BACKUP_ROOT}/swap-wipe.pending"

swap_plain_devices() {
  [ -f "${ETC_DIR}/fstab" ] || return 0
  awk '$3 == "swap" && $1 ~ /^\/dev\// && $1 !~ /\.(eli|bde)$/ { print $1 }' "${ETC_DIR}/fstab"
}

swap_active() {
  swapinfo 2>/dev/null | awk 'NR > 1 { print $1 }' | grep -qx "$1"
}

# Тип раздела по разметке диска ( freebsd-swap, freebsd-ufs, ... ); пусто -
# не раздел GPT/MBR или разметку не прочитать.
swap_part_type() {
  _spt_p="$( geom_provider "${1#/dev/}" )"
  _spt_disk="$( printf '%s\n' "${_spt_p}" | sed -E 's/p[0-9]+$//; s/s[0-9]+$//' )"
  [ "${_spt_disk}" != "${_spt_p}" ] || return 0
  gpart show -p "${_spt_disk}" 2>/dev/null | awk -v p="${_spt_p}" '$3 == p { print $4; exit }'
}

# Можно ли поднять шифрованный своп сейчас; причину отказа - в SWAP_WHY.
swap_live_ok() {
  if [ "${XM_PKGBASE}" -eq 1 ] || [ "${XM_BASE_PHASE:-0}" -eq 1 ]; then
    SWAP_WHY="cross-major: the old kernel runs the new world until the reboot"
    return 1
  fi
  if [ "${KERNEL_PENDING}" -eq 1 ]; then
    SWAP_WHY="the kernel that boots next is not the running one"
    return 1
  fi
  kldstat -q -m g_eli 2>/dev/null && return 0
  _slo_dir="$( running_kernel_dir )"
  if [ "${_kp_rc}" -eq 0 ] && [ -n "${_slo_dir}" ] && [ -f "${BOOT_DIR}/${_slo_dir}/geom_eli.ko" ]; then
    return 0
  fi
  SWAP_WHY="geom_eli is not loaded and its module for the running kernel is not confirmed"
  return 1
}

swap_wipe_mark() {
  grep -qx "$1" "${SWAP_WIPE_MARK}" 2>/dev/null && return 0
  printf '%s\n' "$1" >> "${SWAP_WIPE_MARK}"
}

swap_wipe_unmark() {
  [ -f "${SWAP_WIPE_MARK}" ] || return 0
  grep -vx "$1" "${SWAP_WIPE_MARK}" > "${SWAP_WIPE_MARK}.new" || true
  if [ -s "${SWAP_WIPE_MARK}.new" ]; then
    mv "${SWAP_WIPE_MARK}.new" "${SWAP_WIPE_MARK}"
  else
    rm -f "${SWAP_WIPE_MARK}.new" "${SWAP_WIPE_MARK}"
  fi
}

# Обнулить раздел $1 ( не в свопе ). 1 - не раздел freebsd-swap, не трогаем.
swap_zero() {
  _sz_type="$( swap_part_type "$1" )"
  if [ "${_sz_type}" != "freebsd-swap" ]; then
    log "WARNING: swap: $1 is not a freebsd-swap partition ( ${_sz_type:-type unknown} ), not zeroed"
    return 1
  fi
  log "INFO: swap: zeroing $1"
  # dd останавливается на конце раздела с ошибкой - это нормальный конец.
  dd if=/dev/zero of="$1" bs=1m >> "${LOG_FILE}" 2>&1 || true
  return 0
}

# Раздел уже шифрованный после перезагрузки, но прежнее содержимое не
# стёрто: снять .eli, обнулить, поднять снова.
swap_wipe_pending() {
  [ -f "${SWAP_WIPE_MARK}" ] || return 0
  for _swp_dev in $( cat "${SWAP_WIPE_MARK}" ); do
    if ! swap_active "${_swp_dev}.eli" || ! swap_live_ok \
       || ! swapoff "${_swp_dev}.eli" >> "${LOG_FILE}" 2>&1; then
      SWAP_WIPE_LEFT="${SWAP_WIPE_LEFT} ${_swp_dev}"
      continue
    fi
    if swap_zero "${_swp_dev}"; then
      swap_wipe_unmark "${_swp_dev}"
      SWAP_WIPED="${SWAP_WIPED} ${_swp_dev}"
    else
      SWAP_WIPE_LEFT="${SWAP_WIPE_LEFT} ${_swp_dev}"
    fi
    if swapon "${_swp_dev}.eli" >> "${LOG_FILE}" 2>&1; then
      SWAP_LIVE="${SWAP_LIVE} ${_swp_dev}.eli"
    else
      log "ERROR: swap: ${_swp_dev}.eli did not come back on line"
      SWAP_OFF_NOW="${SWAP_OFF_NOW} ${_swp_dev}"
    fi
  done
}

# 0 - всё сделано, 1 - часть перевода отложена до перезагрузки или содержимое
# не стёрто, 2 - раздел остался вне свопа до перезагрузки.
swap_encrypt() {
  if [ "${OPT_NO_SWAP_ENCRYPT}" -eq 1 ]; then
    log "INFO: swap: --no-swap-encrypt, fstab and swap partitions left as they are"
    return 0
  fi
  swap_wipe_pending
  _se_list="$( swap_plain_devices )"
  if [ -n "${_se_list}" ]; then
    cp -p "${ETC_DIR}/fstab" "${_BACKUP_DIR}/fstab" || return 1
    awk 'BEGIN { OFS = "\t" }
      $3 == "swap" && $1 ~ /^\/dev\// && $1 !~ /\.(eli|bde)$/ { $1 = $1 ".eli" }
      { print }' "${ETC_DIR}/fstab" > "${ETC_DIR}/fstab.new" || return 1
    mv "${ETC_DIR}/fstab.new" "${ETC_DIR}/fstab" || return 1
    SWAP_CONVERTED="$( printf ' %s' ${_se_list} )"
    log "INFO: swap: ${ETC_DIR}/fstab switched to geli onetime:${SWAP_CONVERTED}"
  elif [ -z "${SWAP_WIPED}${SWAP_WIPE_LEFT}" ]; then
    log "INFO: swap: no unencrypted swap partitions in ${ETC_DIR}/fstab"
  fi

  for _se_dev in ${_se_list}; do
    if ! swap_live_ok; then
      log "NOTICE: swap: ${_se_dev} is encrypted from the next boot: ${SWAP_WHY}"
      SWAP_PENDING="${SWAP_PENDING} ${_se_dev}"
      swap_wipe_mark "${_se_dev}"
      continue
    fi
    if swap_active "${_se_dev}" && ! swapoff "${_se_dev}" >> "${LOG_FILE}" 2>&1; then
      log "WARNING: swap: cannot take ${_se_dev} off line ( not enough free memory? ), encrypted from the next boot"
      SWAP_PENDING="${SWAP_PENDING} ${_se_dev}"
      swap_wipe_mark "${_se_dev}"
      continue
    fi
    swap_zero "${_se_dev}" || swap_wipe_mark "${_se_dev}"
    if swapon "${_se_dev}.eli" >> "${LOG_FILE}" 2>&1; then
      SWAP_LIVE="${SWAP_LIVE} ${_se_dev}.eli"
      log "INFO: swap: ${_se_dev}.eli on line"
    elif swapon "${_se_dev}" >> "${LOG_FILE}" 2>&1; then
      log "WARNING: swap: ${_se_dev}.eli did not come on line, ${_se_dev} is back unencrypted until the reboot"
      SWAP_PLAIN_NOW="${SWAP_PLAIN_NOW} ${_se_dev}"
      SWAP_PENDING="${SWAP_PENDING} ${_se_dev}"
      swap_wipe_mark "${_se_dev}"
    else
      log "ERROR: swap: neither ${_se_dev}.eli nor ${_se_dev} came on line, the node has no swap there until the reboot"
      SWAP_OFF_NOW="${SWAP_OFF_NOW} ${_se_dev}"
      SWAP_PENDING="${SWAP_PENDING} ${_se_dev}"
    fi
  done

  SWAP_WIPE_LEFT=""
  if [ -f "${SWAP_WIPE_MARK}" ]; then
    for _se_dev in $( cat "${SWAP_WIPE_MARK}" ); do
      SWAP_WIPE_LEFT="${SWAP_WIPE_LEFT} ${_se_dev}"
      log "NOTICE: swap: old unencrypted contents of ${_se_dev} are not wiped yet; the next update run after the reboot wipes them, or by hand:"
      log "NOTICE:   swapoff ${_se_dev}.eli && dd if=/dev/zero of=${_se_dev} bs=1m; swapon ${_se_dev}.eli"
    done
  fi
  [ -z "${SWAP_OFF_NOW}" ] || return 2
  [ -z "${SWAP_PENDING}${SWAP_WIPE_LEFT}" ] || return 1
  return 0
}

# ==============================================================================
# Смена мажора на узле с pkgbase ( 3.x/FreeBSD 14 -> 4.x/FreeBSD 15 ). Механика
# та же, что у перехода mgmtVM ( mgmtvm-update.sh, q7/q9 ) и у отдельного
# vstackos.upgrade-4.0.sh ( q903 ), теперь в одном апдейтере.
# ==============================================================================

# Библиотеки прежнего мира - в /usr/lib/compat. Пакеты, которых нет в цели,
# остаются сборкой прежнего мажора и ссылаются на них: в 15 libssl/libcrypto
# стали .so.35, а .so.30 пакет базы убирает ( q999u002: git, postfix,
# p5-Net-SSLeay ). /usr/lib/compat стоит в ldconfig_paths после /lib и
# /usr/lib, библиотеку нового мажора с тем же именем он не заслоняет. Список
# скопированного - чтобы после перезагрузки снять ненужное.
xm_compat_guard() {
  _xcg_list="${_XM_DIR}/compat-libs.list"
  if [ -f "${_xcg_list}" ]; then
    XM_COMPAT_SAVED="$( wc -l < "${_xcg_list}" | tr -d ' ' )"
    log "INFO: compat guard of the first run kept: ${XM_COMPAT_SAVED} libraries in ${COMPAT_DIR}"
    return 0
  fi
  mkdir -p "${COMPAT_DIR}" || return 1
  : > "${_xcg_list}.tmp"
  for _xcg_d in ${LIB_DIRS}; do
    for _xcg_f in "${_xcg_d}"/*.so.*; do
      [ -f "${_xcg_f}" ] && [ ! -L "${_xcg_f}" ] || continue
      [ -e "${COMPAT_DIR}/${_xcg_f##*/}" ] && continue
      cp -p "${_xcg_f}" "${COMPAT_DIR}/" || return 1
      printf '%s\n' "${COMPAT_DIR}/${_xcg_f##*/}" >> "${_xcg_list}.tmp"
    done
  done
  mv "${_xcg_list}.tmp" "${_xcg_list}"
  XM_COMPAT_SAVED="$( wc -l < "${_xcg_list}" | tr -d ' ' )"
  ldconfig -m "${COMPAT_DIR}" >> "${LOG_FILE}" 2>&1 \
    || log "WARNING: ldconfig -m ${COMPAT_DIR} failed, the libraries are picked up at the next boot"
  log "INFO: compat guard: ${XM_COMPAT_SAVED} libraries of FreeBSD ${XM_FROM_MAJOR} saved to ${COMPAT_DIR}"
  return 0
}

# Файлы, сменившие пакет-владельца между мажорами ( /lib/libedit.so.8:
# FreeBSD-clibs в 14, FreeBSD-runtime в 15 ). Если пакет прежней линии
# обновляется или снимается ПОСЛЕ того, как новый владелец уже поставлен, pkg
# стирает файл как свой - и /bin/sh остаётся без библиотеки, sshd после
# перезагрузки не стартует ( q999 ). Сверяем, что файлы установленных пакетов
# на месте, и переставляем владельцев пропавших. До возврата /etc.
xm_missing_files() {
  pkg_s check -s -a 2>&1 | sed -n 's/^\(.*\): missing file \(.*\)$/\1 \2/p' > "${_XM_DIR}/missing-files.txt" || true
  pkg_s query '%n-%v %n' 2>/dev/null > "${_XM_DIR}/pkg-name-by-pv.txt" || true
  rm -f "${_XM_DIR}/missing-files.target.txt" "${_XM_DIR}/missing-files.leftovers.txt"
  # Переставить можно только пакет из цели, и только такие переход ставил.
  # Пропавшие файлы пакетов, оставшихся сборкой прежнего мажора, пропали раньше
  # ( q9u002: 4108 файлов llvm19 из прежнего poudriere-репо ) - их называем, но
  # прогон на них не останавливаем: иначе переход падал бы на чужом старом
  # хвосте до возврата /etc и /boot.
  awk -v fix="${_XM_DIR}/missing-files.target.txt" -v old="${_XM_DIR}/missing-files.leftovers.txt" '
    FILENAME == ARGV[1] { name[$1] = $2; next }
    FILENAME == ARGV[2] { inrepo[$1] = 1; next }
    { n = name[$1]; if (n != "" && (n in inrepo)) print > fix; else print > old }
  ' "${_XM_DIR}/pkg-name-by-pv.txt" "${_xmp_repo}" "${_XM_DIR}/missing-files.txt"
  touch "${_XM_DIR}/missing-files.target.txt" "${_XM_DIR}/missing-files.leftovers.txt"
  [ -s "${_XM_DIR}/missing-files.target.txt" ]
}
xm_repair_missing_files() {
  _xmf_fix="${_XM_DIR}/missing-files.target.txt"
  if xm_missing_files; then
    log "WARNING: $( wc -l < "${_xmf_fix}" | tr -d ' ' ) files of packages from the target are missing after the transactions"
    head -20 "${_xmf_fix}" | while IFS= read -r _xmf; do log "WARNING:   missing ( package path ): ${_xmf}"; done
    _xmf_pkgs="$( awk 'FILENAME == ARGV[1] { name[$1] = $2; next } { print name[$1] }' \
      "${_XM_DIR}/pkg-name-by-pv.txt" "${_xmf_fix}" | sort -u | tr '\n' ' ' )"
    log "INFO: reinstalling the owners of the missing files: ${_xmf_pkgs}"
    # shellcheck disable=SC2086
    pkg_s install -r "${PRODUCT}" -fy ${_xmf_pkgs} >> "${LOG_FILE}" 2>&1 || return 1
    if xm_missing_files; then
      log "ERROR: files of packages from the target are still missing after reinstall ( list in ${_xmf_fix} )"
      return 1
    fi
    XM_MISSING_REPAIRED=" ${_xmf_pkgs}"
    log "INFO: missing files restored"
  else
    log "INFO: all files of packages from the target are in place"
  fi
  if [ -s "${_XM_DIR}/missing-files.leftovers.txt" ]; then
    log "WARNING: $( wc -l < "${_XM_DIR}/missing-files.leftovers.txt" | tr -d ' ' ) files are missing in packages the transition did not touch ( not in the target ) - left as is, list in ${_XM_DIR}/missing-files.leftovers.txt:"
    awk '{ print $1 }' "${_XM_DIR}/missing-files.leftovers.txt" | sort | uniq -c \
      | while read -r _xmf_c _xmf_p; do log "WARNING:   ${_xmf_p}: ${_xmf_c} files"; done
  fi
  return 0
}

# ОБХОД, не механика перехода. Снять, когда брендинг vStack ( logo-vStack.lua,
# brand-vStack.lua ) начнёт приезжать пакетом и попадёт в /boot/manifest
# новой линии. До тех пор загрузчик 4.0, проверяющий lua по манифесту, на
# файле вне манифеста встаёт на приглашение OK ( "Unverified
# /boot/lua/logo-vStack.lua: no entry", q903 ), и узел без консоли не
# загружается. Выключаются только строки, чьих файлов в манифесте нет.
xm_disable_unverified_branding() {
  [ -f "${BOOT_DIR}/manifest" ] || return 0
  for _xb_lc in "${BOOT_DIR}/loader.conf" "${BOOT_DIR}/loader.conf.local"; do
    [ -f "${_xb_lc}" ] || continue
    for _xb_v in loader_logo loader_brand; do
      _xb_val="$( sed -n "s/^[[:space:]]*${_xb_v}=\"\{0,1\}\([^\"]*\)\"\{0,1\}[[:space:]]*\$/\1/p" "${_xb_lc}" | tail -1 )"
      case "${_xb_val}" in ''|none) continue ;; esac
      case "${_xb_v}" in
        loader_logo)  _xb_lua="lua/logo-${_xb_val}.lua" ;;
        loader_brand) _xb_lua="lua/brand-${_xb_val}.lua" ;;
      esac
      awk -v f="${_xb_lua}" '$1 == f { found = 1 } END { exit !found }' "${BOOT_DIR}/manifest" && continue
      sed "s|^\([[:space:]]*${_xb_v}=.*\)\$|#\1   # vstackos.update.sh: ${_xb_lua} is not in ${BOOT_DIR}/manifest|" \
        "${_xb_lc}" > "${_xb_lc}.tmp.$$" && mv "${_xb_lc}.tmp.$$" "${_xb_lc}" || return 1
      XM_BRANDING_OFF="${XM_BRANDING_OFF} ${_xb_v}=${_xb_val}"
      log "NOTICE: ${_xb_lc}: ${_xb_v}=\"${_xb_val}\" disabled - ${_xb_lua} is not in ${BOOT_DIR}/manifest"
    done
  done
  return 0
}

# Проверки перед перезагрузкой: читать файлы, а не запускать новый мир на
# старом ядре ( кроме /bin/sh - без него не поднимется ни rc, ни sshd ).
xm_prereboot_checks() {
  _xpc_rc=0
  /bin/sh -c ':' 2>/dev/null || { log "ERROR: /bin/sh of the new line does not start ( missing library? )"; _xpc_rc=1; }
  if [ "${XM_TO_MAJOR}" -ge 15 ] && [ ! -e "${SSHD_SESSION_PATH}" ]; then
    log "ERROR: ${SSHD_SESSION_PATH} is missing - sshd of FreeBSD ${XM_TO_MAJOR} cannot accept logins"
    _xpc_rc=1
  fi
  _xpc_prl="$( awk 'tolower($1) == "permitrootlogin" { v = tolower($2) } END { print v }' "${ETC_DIR}/ssh/sshd_config" 2>/dev/null )"
  case "${_xpc_prl}" in
    ''|prohibit-password|without-password|yes) ;;
    *) log "ERROR: ${ETC_DIR}/ssh/sshd_config: PermitRootLogin ${_xpc_prl} - root ssh would be refused"; _xpc_rc=1 ;;
  esac
  if [ -s "${_BACKUP_DIR}/master.passwd" ]; then
    _xpc_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 }' "${_BACKUP_DIR}/master.passwd" "${ETC_DIR}/master.passwd" )"
    [ -z "${_xpc_lost}" ] || { log "ERROR: accounts lost from master.passwd: ${_xpc_lost}"; _xpc_rc=1; }
  fi
  _xpc_rt="$( pkg_s query '%v' "${FREEBSD_PKG_PREFIX}runtime" 2>/dev/null || true )"
  case "${_xpc_rt}" in
    "${XM_TO_MAJOR}".*) log "INFO: world on disk: ${FREEBSD_PKG_PREFIX}runtime ${_xpc_rt}" ;;
    *) log "ERROR: world on disk is ${FREEBSD_PKG_PREFIX}runtime ${_xpc_rt:-missing}, expected ${XM_TO_MAJOR}.x"; _xpc_rc=1 ;;
  esac
  return "${_xpc_rc}"
}

# После перезагрузки в новый мажор - первым же прогоном апдейтера: снять из
# compat то, что больше никому не нужно, и копию ядра прежнего мажора ( новый
# мир она уже не запустит, только место ).
xm_finish_after_reboot() {
  [ -f "${XM_DONE_MARK}" ] || return 0
  _xfa_to="$( resume_get TO_MAJOR "${XM_DONE_MARK}" )"
  _xfa_from="$( resume_get FROM_MAJOR "${XM_DONE_MARK}" )"
  _xfa_list="$( resume_get COMPAT_LIST "${XM_DONE_MARK}" )"
  if [ "$( running_kernel_major )" != "${_xfa_to}" ]; then
    log "WARNING: FreeBSD ${_xfa_from} -> ${_xfa_to} transition is recorded, but the node still runs $( uname -r ): reboot it"
    return 0
  fi
  if [ -s "${_xfa_list}" ]; then
    _xfa_need="$( pkg_s check -d -a 2>/dev/null | sed -n 's/.*required shared library:* *//p' | awk '{ print $1 }' | sort -u )"
    _xfa_kept=0
    _xfa_rm=0
    while IFS= read -r _xfa_l; do
      [ -e "${_xfa_l}" ] || continue
      if printf '%s\n' "${_xfa_need}" | grep -qx "${_xfa_l##*/}"; then
        _xfa_kept=$(( _xfa_kept + 1 ))
        continue
      fi
      rm -f "${_xfa_l}" && _xfa_rm=$(( _xfa_rm + 1 ))
    done < "${_xfa_list}"
    log "INFO: ${COMPAT_DIR}: removed ${_xfa_rm} libraries of FreeBSD ${_xfa_from}, kept ${_xfa_kept} still required by installed packages"
  fi
  # Проверка узла после перезагрузки ( как --verify у upgrade-4.0.sh ): только
  # отчёт, обновление дальше идёт штатно.
  _xfa_rt="$( pkg_s query '%v' "${FREEBSD_PKG_PREFIX}runtime" 2>/dev/null || true )"
  case "${_xfa_rt}" in
    "${_xfa_to}".*) log "INFO: verify: kernel $( uname -r ), ${FREEBSD_PKG_PREFIX}runtime ${_xfa_rt}, pkg $( pkg_s -v 2>/dev/null || echo ? )" ;;
    *) log "WARNING: verify: ${FREEBSD_PKG_PREFIX}runtime is ${_xfa_rt:-missing}, not the FreeBSD ${_xfa_to} line" ;;
  esac
  _xfa_old="$( pkg_s query '%n %q' 2>/dev/null | awk -v m=":${_xfa_from}:" 'index($2, m) { printf " %s", $1 }' )"
  [ -z "${_xfa_old}" ] || log "WARNING: verify: packages still built for FreeBSD ${_xfa_from} ( reinstall from the FreeBSD ${_xfa_to} ports repo if needed ):${_xfa_old}"
  _xfa_kp="${BOOT_DIR}/$( loader_kernel_dir )${KERNEL_PREV_SUFFIX}"
  if [ -d "${_xfa_kp}" ] && [ ! -f "${BOOT_DROPIN}" ]; then
    rm -rf "${_xfa_kp}"
    log "INFO: ${_xfa_kp} removed: a FreeBSD ${_xfa_from} kernel cannot run the FreeBSD ${_xfa_to} world"
  fi
  rm -f "${XM_DONE_MARK}"
  log "INFO: FreeBSD ${_xfa_from} -> ${_xfa_to} transition finished"
}

# ==============================================================================
# Переход между major-версиями ( TSK0004073 ).
#
# Поводом стал первый боевой проход 2.2.9/FreeBSD 13.2 -> 3.2.0-rc01/FreeBSD 14.2
# на паре СХД-узлов: скрипт такой переход отклонял, а со снятым вручную запретом
# оставлял узел без оболочки root ( в 14.2 csh выделен в отдельный пакет
# FreeBSD-csh, /bin/csh исчез, и sshd отбивал вход ДО проверки ключа ), без
# части базовых пакетов и с компонентами прежней линии под новыми именами.
# ==============================================================================

# Оболочка root из master.passwd ( поле 10 ). Именно из файла, а не из getent:
# проверка идёт сразу после восстановления /etc, когда базы ещё пересобираются.
# $1 - откуда читать ( по умолчанию живой master.passwd ). Посреди транзакции
# живой файл уже от pkgbase ( root -> /bin/sh ), своя копия вернётся позже, -
# тогда читать надо из бэкапа.
root_shell() {
  awk -F: '$1 == "root" { print $10; exit }' "${1:-${ETC_DIR}/master.passwd}" 2>/dev/null
}

# Возврат 0 - вход root по ssh сохранится, 1 - нет. Не чиним молча подменой
# оболочки: подмена скрыла бы, что узел недоукомплектован, а до перезагрузки
# ещё есть окно всё поставить руками.
check_root_shell() {
  _crs="$( root_shell )"
  if [ -z "${_crs}" ]; then
    log "ERROR: root has no shell field in ${ETC_DIR}/master.passwd"
    return 1
  fi
  if [ -x "${_crs}" ]; then
    log "INFO: root shell ${_crs} present and executable"
    return 0
  fi
  log "ERROR: root shell ${_crs} is missing or not executable - sshd will refuse root login"
  return 1
}

# Пакет целевой репы, дающий оболочку root. Имя выводится из самой оболочки
# ( /bin/csh -> FreeBSD-csh ), а не зашито: на следующем переходе выделят другое.
root_shell_pkg() {
  _rsp_sh="$( root_shell "${1:-}" )"
  [ -n "${_rsp_sh}" ] || return 1
  _rsp_cand="${FREEBSD_PKG_PREFIX}$( basename "${_rsp_sh}" )"
  if pkg_s rquery -r "${PRODUCT}" '%n' "${_rsp_cand}" >/dev/null 2>&1; then
    printf '%s\n' "${_rsp_cand}"
    return 0
  fi
  return 1
}

# Карта переименований: строки "old new" и "old removed", # - комментарий.
# Путь задаётся --rename-map либо берётся рядом с каталогом repo в раздаче.
# Карты может не быть ( старые раздачи ) - тогда работает только отчёт о
# пакетах без одноимённого преемника.
# Каталог ядра по loader.conf* - нужен и предзагрузке ниже, и проверкам /boot.
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
    _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}"
}

# Что загрузчик положит в память до старта ядра: ядро из loader.conf и всё,
# что включено X_load="YES" в loader.conf, loader.conf.local и loader.conf.d/*
# ( X_name - свой путь к файлу, иначе X.ko в каталоге ядра или в modules ).
# Строка: <имя> <путь> <байт>. Записи без файла не печатаются: они спят, пока
# файл не появится, - и просыпаются, как только его принесёт пакет.
loader_preload_list() {
  _lpl_kdir="$( loader_kernel_dir )"
  _lpl_kernel="${BOOT_DIR}/${_lpl_kdir}/kernel"
  if [ -f "${_lpl_kernel}" ]; then
    printf 'kernel %s %s\n' "${_lpl_kernel}" "$( wc -c < "${_lpl_kernel}" | tr -d ' ' )"
  fi
  _lpl_conf="$( for _f in "${BOOT_DIR}/loader.conf" "${BOOT_DIR}/loader.conf.local" \
                         "${BOOT_DIR}"/loader.conf.d/*; do
                  [ -f "${_f}" ] && cat "${_f}"
                done 2>/dev/null; true )"
  printf '%s\n' "${_lpl_conf}" \
    | sed -n 's/^[[:space:]]*\([A-Za-z0-9_]*\)_load="\{0,1\}[Yy][Ee][Ss]"\{0,1\}[[:space:]]*$/\1/p' \
    | awk '!seen[$0]++' | while IFS= read -r _m; do
    [ -n "${_m}" ] || continue
    # Последнее слово побеждает: NO после YES выключает модуль.
    _last="$( printf '%s\n' "${_lpl_conf}" \
      | sed -n "s/^[[:space:]]*${_m}_load=\"\{0,1\}\([A-Za-z]*\)\"\{0,1\}[[:space:]]*\$/\1/p" | tail -1 )"
    case "${_last}" in [Yy][Ee][Ss]) ;; *) continue ;; esac
    _name="$( printf '%s\n' "${_lpl_conf}" \
      | sed -n "s/^[[:space:]]*${_m}_name=\"\{0,1\}\([^\"]*\)\"\{0,1\}[[:space:]]*\$/\1/p" | tail -1 )"
    if [ -n "${_name}" ]; then
      _path="${_name}"
      case "${_path}" in /boot/*) _path="${BOOT_DIR}/${_path#/boot/}" ;; esac
    elif [ -f "${BOOT_DIR}/${_lpl_kdir}/${_m}.ko" ]; then
      _path="${BOOT_DIR}/${_lpl_kdir}/${_m}.ko"
    else
      _path="${BOOT_DIR}/modules/${_m}.ko"
    fi
    [ -f "${_path}" ] || continue
    printf '%s %s %s\n' "${_m}" "${_path}" "$( wc -c < "${_path}" | tr -d ' ' )"
  done
}

# Сравнивает предзагрузку до и после транзакции. Запись X_load="YES" без файла
# спит; пакет из этой же транзакции может принести файл - и запись оживает,
# добавляя к предзагрузке мегабайты, о которых никто не просил. Так на q900
# ( 2026-10-02 ) метапорт принёс cpu-microcode, intel-ucode.bin ( 17 МиБ ) ожил
# поверх ядра и модулей, предзагрузка перевалила staging-область EFI-загрузчика,
# и прошивка VMware сбрасывала узел до первой строки ядра. Апдейтер это не
# чинит ( loader.conf* - зона оператора ), но называет оживших и общий размер.
# Возврат 1 - порог превышен; код завершения прогона не меняется.
check_loader_preload() {
  loader_preload_list > "${_PRELOAD_AFTER}" 2>/dev/null || true
  PRELOAD_NEW=""
  PRELOAD_TOTAL=0
  PRELOAD_FILES=0
  while read -r _clp_name _clp_path _clp_size; do
    [ -n "${_clp_name}" ] || continue
    PRELOAD_TOTAL=$(( PRELOAD_TOTAL + ${_clp_size:-0} ))
    PRELOAD_FILES=$(( PRELOAD_FILES + 1 ))
    if [ -s "${_PRELOAD_BEFORE}" ] && ! grep -q "^${_clp_name} " "${_PRELOAD_BEFORE}"; then
      PRELOAD_NEW="${PRELOAD_NEW}${_clp_name} ${_clp_path} $(( ${_clp_size:-0} / 1048576 )) MiB
"
      log "NOTICE: loader preload: ${_clp_name} ( ${_clp_path}, $(( ${_clp_size:-0} / 1048576 )) MiB ) is enabled in loader.conf* and became present during this run"
    fi
  done < "${_PRELOAD_AFTER}"
  PRELOAD_TOTAL_MIB=$(( PRELOAD_TOTAL / 1048576 ))
  log "INFO: loader preload on next boot: ${PRELOAD_TOTAL_MIB} MiB in ${PRELOAD_FILES} files ( kernel + enabled modules and blobs )"
  if [ "${PRELOAD_WARN_MIB}" -gt 0 ] && [ "${PRELOAD_TOTAL}" -gt $(( PRELOAD_WARN_MIB * 1048576 )) ]; then
    log "WARNING: loader preload ${PRELOAD_TOTAL_MIB} MiB is above ${PRELOAD_WARN_MIB} MiB: the EFI loader will have to grow its staging area, and some firmware does not boot after that"
    log "WARNING: review *_load / *_name in ${BOOT_DIR}/loader.conf* before the reboot ( a blob for another CPU vendor is dead weight )"
    return 1
  fi
  return 0
}

rename_map_path() {
  if [ -n "${OPT_RENAME_MAP}" ]; then
    printf '%s\n' "${OPT_RENAME_MAP}"
    return 0
  fi
  case "${URL}" in
    file://*) printf '%s\n' "$( dirname "${URL#file://}" )/pkg-rename.map" ;;
    *)        printf '%s\n' "$( dirname "${URL}" )/pkg-rename.map" ;;
  esac
}

# Кладёт карту в _XM_MAP ( локальный путь ) либо возвращает 1, если её нет.
fetch_rename_map() {
  _frm_src="$( rename_map_path )"
  case "${_frm_src}" in
    http://*|https://*)
      if fetch -qo "${_XM_MAP}" "${_frm_src}" 2>/dev/null && [ -s "${_XM_MAP}" ]; then
        log "INFO: rename map fetched from ${_frm_src}"
        return 0
      fi
      rm -f "${_XM_MAP}"
      ;;
    *)
      if [ -s "${_frm_src}" ] && cp -p "${_frm_src}" "${_XM_MAP}"; then
        log "INFO: rename map read from ${_frm_src}"
        return 0
      fi
      ;;
  esac
  log "WARNING: no package rename map at ${_frm_src}, renamed components will only be REPORTED"
  return 1
}

log "INFO: vStack OS update starting ts=${_TS} abi=${OPT_ABI:-auto} dry_run=${OPT_DRY_RUN}"
log "INFO: controllers=${OPT_CONTROLLERS} base=${OPT_BASE} source=${OPT_SOURCE:-explicit-url} version=${OPT_VERSION:-latest}"
log "INFO: repo url=${URL}"

log "INFO: checking version/source availability"

case "${OPT_SOURCE}" in
  local)
    _local_repo_path="${LOCAL_REPO_ROOT}/${BUILD_CHANNEL}/${OS_REPO_DIR}/repo"
    if ! check_local_path "${_local_repo_path}"; then
      log "ERROR: local repo path not found: ${_local_repo_path}"
      exit 2
    fi
    ;;
  *)
    if ! check_remote_url "${URL}"; then
      log "ERROR: repo not reachable or version not found: ${URL}"
      exit 2
    fi
    ;;
esac

log "INFO: source verified"

if [ "${SIG_TYPE}" = "pubkey" ]; then
  if [ -s "${PUBKEY_PATH}" ]; then
    log "INFO: using pinned pubkey ${PUBKEY_PATH}"
  else
    log "INFO: pubkey missing, fetching"
    if ! fetch_pubkey "${URL}"; then
      log "ERROR: failed to fetch pubkey ( tried repo URL and ${KEYS_URL_BASE} )"
      exit 2
    fi
  fi
fi

# Сухой прогон идёт тем же путём, что и настоящий - каталог, подбор ABI, гварды
# мажора, - но с временным каталогом репо-конфигов и без единой записи на узле.
# Раньше он обрывался здесь же, до подбора ABI и до гвардов: на узле линии 2.x
# каталог не обновлялся ( wrong ABI ), отказ глотался, и прогон печатал
# "dry-run complete" с кодом 0 при пустом плане; ветки "would install ..." ниже
# были недостижимы ( q900, 2026-09-30 ).
#
# Настоящий прогон до конца проверок ( каталог, подбор ABI, гварды мажора ) идёт
# так же - через временный каталог: отказ любой из них оставляет узлу прежние
# репы. Раньше конфиги узла выключались и переписывались первым шагом, и отказ
# гварда ( код 2 ) оставлял узел на чужой раздаче, а при смене мажора - без
# своей вовсе. Репой узла цель становится только после проверок.
_DR_DIR=""
if [ "${OPT_DRY_RUN}" -eq 1 ]; then
  _DR_DIR="$( mktemp -d "${TMPDIR:-/tmp}/vstack_update_dryrun.XXXXXX" )"
  log "INFO: dry-run: preview via temporary repo config, system untouched"
else
  _DR_DIR="$( mktemp -d "${TMPDIR:-/tmp}/vstack_update_preflight.XXXXXX" )"
  log "INFO: preflight via temporary repo config, node repo configs untouched until the checks pass"
fi
write_repo_conf "${PRODUCT}" "${URL}" "${SIG_TYPE}" "${_DR_DIR}"
PKG_REPOS_DIR="${_DR_DIR}"

if [ "${OPT_BASE}" -eq 1 ] && [ "${OPT_DRY_RUN}" -eq 0 ]; then
  if pkg_s -N >/dev/null 2>&1; then
    log "INFO: pkg already present, skipping bootstrap"
  else
    log "INFO: bootstrapping pkg-static"
    if ! pkg_s bootstrap -f; then
      log "ERROR: pkg bootstrap failed"
      exit 3
    fi
  fi
fi

log "INFO: updating pkg catalogue"
_upd_out="$( pkg_s update -f 2>&1 )" && _upd_rc=0 || _upd_rc=$?
printf '%s\n' "${_upd_out}" >> "${LOG_FILE}" 2>/dev/null || true
if [ "${_upd_rc}" -ne 0 ]; then
  # Репа целевой линии собрана под другой мажор FreeBSD, и pkg прямо называет
  # ожидаемый ABI: "contains packages with wrong ABI: FreeBSD:14:*". При смене
  # мажора это штатная ситуация, а не ошибка оператора, поэтому подставляем
  # названный ABI сами и повторяем. Вне режима смены мажора несовпадение ABI
  # по-прежнему отказ: там оно означает, что узлу подсунули чужую репу.
  _abi_want="$( printf '%s\n' "${_upd_out}" \
    | sed -n 's/.*wrong ABI: \(FreeBSD:[0-9][0-9]*\):.*/\1/p' | head -1 )"
  # --allow-major-upgrade тоже разрешает подбор: узел dev-канала не знает своей
  # версии продукта ( VERSION_ID="manual-dev-..." ), гвард по os-release там
  # молчит, и мажор определяется только ниже, по FreeBSD-runtime раздачи.
  if { [ "${MAJOR_MODE}" -eq 1 ] || [ "${OPT_ALLOW_MAJOR}" -eq 1 ]; } \
     && [ -z "${OPT_ABI}" ] && [ -n "${_abi_want}" ]; then
    _abi_arch="$( pkg_s config ABI 2>/dev/null | awk -F: '{ print $3 }' )"
    [ -n "${_abi_arch}" ] || _abi_arch="$( uname -p )"
    OPT_ABI="${_abi_want}:${_abi_arch}"
    log "INFO: target release is built for ${_abi_want}, retrying catalogue with ABI=${OPT_ABI}"
    if ! pkg_s update -f >> "${LOG_FILE}" 2>&1; then
      log "ERROR: pkg catalogue update failed even with ABI=${OPT_ABI}"
      exit 3
    fi
  else
    printf '%s\n' "${_upd_out}" >&2
    log "ERROR: pkg catalogue update failed"
    exit 3
  fi
fi

if [ "${OPT_BASE}" -eq 1 ]; then
  # Обе подстановки обязаны быть терпимы к отсутствию пакета: на узле линии 2.x
  # pkgbase нет вовсе, pkg query возвращает 1, и при set -e присваивание роняло
  # весь прогон БЕЗ единой строки в логе - последним, что видел оператор, было
  # "updating pkg catalogue".
  _inst_fbsd="$( pkg_s query '%v' FreeBSD-runtime 2>/dev/null || true )"
  _cand_fbsd="$( pkg_s rquery -r "${PRODUCT}" '%v' FreeBSD-runtime 2>/dev/null || true )"
  _inst_fbsd_major="${_inst_fbsd%%.*}"
  _cand_fbsd_major="${_cand_fbsd%%.*}"
  if digits_only "${_inst_fbsd_major}" && digits_only "${_cand_fbsd_major}"; then
    if [ "${_inst_fbsd_major}" != "${_cand_fbsd_major}" ]; then
      if [ "${OPT_ALLOW_MAJOR}" -ne 1 ]; then
        log "ERROR: cross-major base upgrade ${_inst_fbsd_major} -> ${_cand_fbsd_major} requires --allow-major-upgrade ( repo ${URL} )"
        exit 2
      fi
      log "NOTICE: cross-major base upgrade ${_inst_fbsd_major} -> ${_cand_fbsd_major} enabled explicitly (--allow-major-upgrade)"
      MAJOR_MODE=1
    else
      log "INFO: FreeBSD major match (${_inst_fbsd_major}), base upgrade allowed"
    fi
  elif [ -z "${_inst_fbsd}" ] && [ -n "${_cand_fbsd}" ]; then
    # Узел живёт на дист-сетах ( линия 2.x ): обновлять в pkgbase нечего, его
    # надо СТАВИТЬ. Само по себе это делает --install-missing в разборе смены
    # мажора ниже; без него --base тихо ничего не сделает, поэтому говорим прямо.
    if [ "${MAJOR_MODE}" -eq 1 ] && [ "${OPT_INSTALL_MISSING}" -eq 1 ]; then
      log "INFO: node has no pkgbase, base set will be INSTALLED from the target release"
    else
      log "WARNING: node has no pkgbase ( no ${FREEBSD_PKG_PREFIX}runtime ), --base has nothing to upgrade"
      log "WARNING: to move such a node onto the target release add --install-missing"
    fi
  else
    log "WARNING: cannot determine FreeBSD major (installed=${_inst_fbsd:-?} candidate=${_cand_fbsd:-?}), cross-major check skipped"
  fi
fi

# Смена мажора на узле, который уже на pkgbase. Базу и пакеты узла переносит
# одна процедура: порознь ( только --base или только --controllers ) узел
# остаётся с портами одного мажора на базе другого.
if [ -n "${RESUME_FILE}" ]; then
  MAJOR_MODE=1
  XM_PKGBASE=1
elif [ "${MAJOR_MODE}" -eq 1 ] && [ "${OPT_BASE}" -eq 1 ] \
     && digits_only "${_inst_fbsd_major:-}" && digits_only "${_cand_fbsd_major:-}" \
     && [ "${_cand_fbsd_major}" -gt "${_inst_fbsd_major}" ]; then
  XM_PKGBASE=1
  XM_FROM_MAJOR="${_inst_fbsd_major}"
  XM_TO_MAJOR="${_cand_fbsd_major}"
fi
if [ "${XM_PKGBASE}" -eq 1 ]; then
  if [ "${OPT_CONTROLLERS}" -ne 1 ]; then
    log "ERROR: FreeBSD ${XM_FROM_MAJOR} -> ${XM_TO_MAJOR} moves the base and the node packages together: add --controllers"
    exit 2
  fi
  if [ "$( running_kernel_major )" != "${XM_FROM_MAJOR}" ]; then
    log "ERROR: running kernel $( uname -r ) is not FreeBSD ${XM_FROM_MAJOR} - this transition starts on the FreeBSD ${XM_FROM_MAJOR} kernel"
    exit 2
  fi
  # ВМ при смене userland не живут: bhyve нового мажора на старом ядре не
  # стартует, работающие убьёт перезагрузка. Перенос ВМ и пулов - шаг
  # процедуры до запуска.
  _xm_vms="$( ps -axo comm= 2>/dev/null | grep -c '^bhyve$' || true )"
  if [ "${_xm_vms:-0}" -gt 0 ]; then
    if [ "${OPT_DRY_RUN}" -eq 1 ]; then
      log "WARNING: ${_xm_vms} bhyve VM(s) are running - the real run refuses until they are stopped or moved"
    else
      log "ERROR: ${_xm_vms} bhyve VM(s) are running on this node - stop or move them ( and the pools ) before the transition"
      exit 2
    fi
  fi
  _xm_pools="$( zpool list -H -o name 2>/dev/null | tr '\n' ' ' || true )"
  [ -z "${_xm_pools}" ] || log "WARNING: pools still imported on this node: ${_xm_pools}- they go down with the reboot"
  _xm_free="$( df -k "${BASE_TREE_PREFIX}" 2>/dev/null | awk 'NR == 2 { print $4 }' )"
  case "${_xm_free}" in
    ''|*[!0-9]*) log "WARNING: cannot determine free space on ${BASE_TREE_PREFIX}" ;;
    *)
      if [ "${_xm_free}" -lt "${MIN_FREE_KB}" ]; then
        log "ERROR: only ${_xm_free}K free on ${BASE_TREE_PREFIX}, the base set of FreeBSD ${XM_TO_MAJOR} needs ${MIN_FREE_KB}K"
        [ "${OPT_DRY_RUN}" -eq 1 ] || exit 2
      fi ;;
  esac
  log "NOTICE: pkgbase FreeBSD ${XM_FROM_MAJOR} -> ${XM_TO_MAJOR}: base, then node packages, one reboot, no kernel fallback"
fi

# ------------------------------------------------------------------------------
# План согласования состава при смене линии. Читает ${_xm_repo} ( имена в
# целевой раздаче ), ${_xm_inst} ( установлено сейчас ), ${XM_NAMES_BEFORE}
# ( установлено до базовой транзакции ) и заполняет XM_MISSING, XM_NO_SUCCESSOR,
# XM_CTRL_MISSING, XM_LEFTOVERS и _xm_want - что ставить. Сам ничего не ставит:
# в сухом прогоне это и есть весь вывод, в настоящем за ним идёт pkg install.
# $1 = preview | apply
# ------------------------------------------------------------------------------
xm_plan() {
  _xp_mode="$1"
  # Отладочные пакеты базы ( -dbg ) недостающими не считаются: с
  # --install-missing они ставились бы сотнями на каждый узел.
  XM_MISSING="$( comm -23 "${_xm_repo}" "${_xm_inst}" | grep "^${FREEBSD_PKG_PREFIX}" | grep -v -- '-dbg$' || true )"
  XM_NO_SUCCESSOR="$( comm -13 "${_xm_repo}" "${_xm_inst}" || true )"
  XM_CTRL_MISSING=""
  XM_LEFTOVERS=""
  _xm_want=""

  # На узле без pkgbase базовый набор идёт отдельной фазой; перечислять его в
  # плане как "недостающее" значит утопить план в полутора сотнях строк FreeBSD-*.
  # То же при смене мажора на pkgbase: базу нового мажора ставит первая
  # транзакция целиком, её новые пакеты ( cron, bsdconfig, xz ... ) - не
  # недостающие ( q999u002, rc06: 51 строка ложного "missing" ).
  _xp_base_pending=0
  if [ "${_xp_mode}" = "preview" ] && [ "${OPT_BASE}" -eq 1 ] \
     && { [ "${XM_PKGBASE}" -eq 1 ] || ! grep -q "^${FREEBSD_PKG_PREFIX}runtime\$" "${_xm_inst}"; }; then
    _xp_base_pending=1
  fi

  for _xm_req in ${XM_REQUIRED_PKGS}; do
    if printf '%s\n' "${XM_MISSING}" | grep -qx "${_xm_req}"; then
      _xm_want="${_xm_want} ${_xm_req}"
    fi
  done

  # Оболочка root - отдельным шагом: без неё узел теряется по ssh, и это
  # ровно тот пакет, который на переходе 13.2 -> 14.2 не приехал. В настоящем
  # прогоне живой master.passwd в этот момент уже перезаписан базовым набором
  # ( root -> /bin/sh ), и оболочка читается из копии /etc, снятой до транзакции;
  # иначе проверка пакета csh молча пропускалась с ложным WARNING ( q900, 2026-10-02 ).
  _xm_passwd="${ETC_DIR}/master.passwd"
  if [ "${_xp_mode}" = "apply" ] && [ -s "${_BACKUP_DIR:-}/master.passwd" ]; then
    _xm_passwd="${_BACKUP_DIR}/master.passwd"
  fi
  if _xm_shell_pkg="$( root_shell_pkg "${_xm_passwd}" )"; then
    if printf '%s\n' "${XM_MISSING}" | grep -qx "${_xm_shell_pkg}"; then
      log "INFO: root shell package ${_xm_shell_pkg} is missing on the node, adding it"
      _xm_want="${_xm_want} ${_xm_shell_pkg}"
    fi
  else
    log "WARNING: cannot resolve a target-repo package for root shell $( root_shell "${_xm_passwd}" )"
  fi

  # Переименования из карты раздачи: old -> new ставим, old -> removed только
  # называем ( снос компонента прежней линии - решение оператора, не скрипта ),
  # но называем и в сводке: оставшийся пакет держит библиотеки прежней линии и
  # всплывает в pkg check битыми зависимостями, а строка NOTICE в логе теряется.
  if fetch_rename_map; then
    while IFS=' ' read -r _xm_old _xm_new _xm_junk; do
      case "${_xm_old}" in ''|\#*) continue ;; esac
      [ -n "${_xm_new}" ] || continue
      grep -qx "${_xm_old}" "${XM_NAMES_BEFORE}" || continue
      if [ "${_xm_new}" = "removed" ]; then
        log "NOTICE: ${_xm_old} has no successor in the target release and stays as a leftover"
        if grep -qx "${_xm_old}" "${_xm_inst}"; then
          XM_LEFTOVERS="${XM_LEFTOVERS} ${_xm_old}"
        fi
        continue
      fi
      if grep -qx "${_xm_new}" "${_xm_inst}"; then
        log "INFO: ${_xm_old} -> ${_xm_new}: successor already installed"
        continue
      fi
      log "NOTICE: ${_xm_old} -> ${_xm_new}: installing the renamed component"
      _xm_want="${_xm_want} ${_xm_new}"
    done < "${_XM_MAP}"
  fi

  # Состав узла задаёт метапорт целевой линии. С --install-missing ставится сам
  # метапорт: он приводит всё, что должен, и дальше pkg upgrade держит состав.
  if grep -qx "${XM_METAPORT}" "${_xm_repo}"; then
    _xm_deps="$( pkg_s rquery -r "${PRODUCT}" '%dn' "${XM_METAPORT}" 2>/dev/null | sort -u || true )"
    for _xm_dep in ${_xm_deps}; do
      grep -qx "${_xm_dep}" "${_xm_inst}" || XM_CTRL_MISSING="${XM_CTRL_MISSING} ${_xm_dep}"
    done
    if [ -n "${XM_CTRL_MISSING}" ]; then
      log "NOTICE: components of ${XM_METAPORT} absent on the node:${XM_CTRL_MISSING}"
      if [ "${OPT_INSTALL_MISSING}" -eq 1 ]; then
        if grep -qx "${XM_METAPORT}" "${_xm_inst}"; then
          _xm_want="${_xm_want}${XM_CTRL_MISSING}"
        else
          _xm_want="${_xm_want} ${XM_METAPORT}"
        fi
      else
        log "NOTICE: add --install-missing to bring the node to the ${XM_METAPORT} composition"
      fi
    fi
  else
    log "INFO: ${XM_METAPORT} is not in the target release, node composition is not checked against it"
  fi

  if [ "${OPT_INSTALL_MISSING}" -eq 1 ] && [ -n "${XM_MISSING}" ] && [ "${_xp_base_pending}" -eq 0 ]; then
    log "NOTICE: --install-missing given, adding every package the target release has and the node lacks"
    _xm_want="${_xm_want} $( printf '%s\n' "${XM_MISSING}" | tr '\n' ' ' )"
  fi

  if [ "${_xp_mode}" = "preview" ]; then
    if [ -n "${_xm_want}" ]; then
      log "INFO: dry-run, would install:${_xm_want}"
    else
      log "INFO: dry-run, nothing to add: node composition matches the target release"
    fi
  fi

  if [ -n "${XM_MISSING}" ] && [ "${_xp_base_pending}" -eq 0 ]; then
    log "INFO: packages present in the target release and absent on the node:"
    printf '%s\n' "${XM_MISSING}" | while IFS= read -r _xm_m; do
      [ -n "${_xm_m}" ] && log "INFO:   missing ${_xm_m}"
    done
  fi
  if [ -n "${XM_NO_SUCCESSOR}" ]; then
    log "NOTICE: installed packages with no same-name candidate in the target release:"
    printf '%s\n' "${XM_NO_SUCCESSOR}" | while IFS= read -r _xm_n; do
      [ -n "${_xm_n}" ] && log "NOTICE:   no successor ${_xm_n}"
    done
  fi
  if [ -n "${XM_LEFTOVERS}" ]; then
    log "NOTICE: leftovers of the previous line named removed in the rename map:${XM_LEFTOVERS}"
  fi
}

# Сухой прогон: план обычного обновления и, при смене линии, план согласования
# состава - тот же xm_plan, что и в настоящем прогоне, по тем же спискам.
dry_run_preview() {
  log "INFO: dry-run, pkg upgrade plan for ${PRODUCT}:"
  pkg_s upgrade -r "${PRODUCT}" -n 2>&1 | tee -a "${LOG_FILE}" || true
  if [ "${MAJOR_MODE}" -eq 1 ]; then
    _xm_repo="${_DR_DIR}/repo.txt"
    _xm_inst="${_DR_DIR}/installed.txt"
    _XM_MAP="${_DR_DIR}/pkg-rename.map"
    _dr_rc=0
    pkg_s rquery -r "${PRODUCT}" '%n' 2>/dev/null | sort -u > "${_xm_repo}" || _dr_rc=$?
    pkg_s query '%n' 2>/dev/null | sort -u > "${_xm_inst}" || _dr_rc=$?
    if [ "${_dr_rc}" -ne 0 ] || [ ! -s "${_xm_repo}" ]; then
      log "ERROR: cannot list target repo contents, cross-major plan impossible"
      return 3
    fi
    XM_NAMES_BEFORE="${_xm_inst}"
    if [ "${OPT_BASE}" -eq 1 ] && ! grep -q "^${FREEBSD_PKG_PREFIX}runtime\$" "${_xm_inst}"; then
      _dr_cnt="$( grep "^${FREEBSD_PKG_PREFIX}" "${_xm_repo}" | grep -vc -- '-dbg$' || true )"
      log "INFO: dry-run, would install the base set of the target release ( ${_dr_cnt} packages ) as a separate phase"
    fi
    if [ "${XM_PKGBASE}" -eq 1 ]; then
      _dr_cnt="$( grep "^${FREEBSD_PKG_PREFIX}" "${_xm_repo}" | grep -vc -- '-dbg$' || true )"
      _dr_ctrl="$( grep -v "^${FREEBSD_PKG_PREFIX}" "${_xm_inst}" | comm -12 - "${_xm_repo}" | tr '\n' ' ' )"
      _dr_left="$( grep -v "^${FREEBSD_PKG_PREFIX}" "${_xm_inst}" | comm -23 - "${_xm_repo}" | tr '\n' ' ' )"
      log "INFO: dry-run, pkgbase FreeBSD ${XM_FROM_MAJOR} -> ${XM_TO_MAJOR} with a saved copy of pkg-static:"
      log "INFO: dry-run,   1. libraries of FreeBSD ${XM_FROM_MAJOR} copied to ${COMPAT_DIR}"
      log "INFO: dry-run,   2. base set of the target release ( ${_dr_cnt} packages ), forced"
      log "INFO: dry-run,   3. node packages reinstalled from the target: ${_dr_ctrl:-none}"
      [ -z "${_dr_left}" ] || log "NOTICE: dry-run, stay FreeBSD ${XM_FROM_MAJOR} builds ( served by ${COMPAT_DIR} ): ${_dr_left}"
    fi
    log "INFO: dry-run, cross-major reconciliation plan against ${URL}:"
    xm_plan preview
  fi
  _dr_swap="$( swap_plain_devices | tr '\n' ' ' )"
  if [ "${OPT_NO_SWAP_ENCRYPT}" -eq 1 ]; then
    log "INFO: dry-run, swap left as it is ( --no-swap-encrypt )"
  elif [ -n "${_dr_swap}" ]; then
    log "INFO: dry-run, swap would switch to geli onetime ( DEF0001760 ): ${_dr_swap}"
    log "INFO: dry-run, swap goes encrypted on line only if the running kernel is the one that boots next and has geom_eli, otherwise from the next boot"
  fi
  if [ "${OPT_NO_SWAP_ENCRYPT}" -eq 0 ] && [ -s "${BACKUP_ROOT}/swap-wipe.pending" ]; then
    log "INFO: dry-run, old unencrypted swap contents would be wiped: $( tr '\n' ' ' < "${BACKUP_ROOT}/swap-wipe.pending" )"
  fi
  return 0
}

if [ "${OPT_DRY_RUN}" -eq 1 ]; then
  _dr_rc=0
  dry_run_preview || _dr_rc=$?
  rm -rf "${_DR_DIR}"
  if [ "${_dr_rc}" -ne 0 ]; then
    log "ERROR: dry-run could not build the plan"
    exit 3
  fi
  log "INFO: dry-run complete, no changes made"
  exit 0
fi

# Проверки пройдены: раздача цели становится репой узла.
log "INFO: writing repo config"
write_repo_conf "${PRODUCT}" "${URL}" "${SIG_TYPE}"
log "INFO: wrote repo conf for ${PRODUCT}"
disable_conflicting_confs "${PRODUCT}"
PKG_REPOS_DIR=""
rm -rf "${_DR_DIR}"

xm_finish_after_reboot

if [ -n "${RESUME_FILE}" ]; then
  # Продолжение: эталоны - первого прогона. Новый бэкап снимался бы с уже
  # изменённого /etc, а снимки "до" описывали бы полуобновлённый узел.
  _BACKUP_DIR="$( resume_get BACKUP_DIR "${RESUME_FILE}" )"
  _BOOT_BACKUP="${_BACKUP_DIR}/boot"
  _XM_DIR="$( resume_get XM_DIR "${RESUME_FILE}" )"
  _XM_MAP="${_XM_DIR}/pkg-rename.map"
  _VSTACK_ENV_BACKUP="$( resume_get VSTACK_ENV_BACKUP "${RESUME_FILE}" )"
  _VER_BEFORE="$( resume_get VER_BEFORE "${RESUME_FILE}" )"
  _NAMES_BEFORE="$( resume_get NAMES_BEFORE "${RESUME_FILE}" )"
  _PRELOAD_BEFORE="$( resume_get PRELOAD_BEFORE "${RESUME_FILE}" )"
  if [ ! -d "${_BACKUP_DIR}" ] || [ ! -d "${_XM_DIR}" ]; then
    log "ERROR: ${RESUME_FILE}: backup ${_BACKUP_DIR:-?} or work dir ${_XM_DIR:-?} is missing"
    exit 2
  fi
  log "INFO: backups of the first run are reused: ${_BACKUP_DIR}"
else
  # grep без -i раньше терял vStack-* ( пакеты названы vStack-ccrd, vStack-storage-san ),
  # то есть в лог попадала только часть компонентов.
  log "INFO: recording installed versions before upgrade"
  pkg_s query '%n %v' 2>/dev/null | grep -Ei "^(vstack|${FREEBSD_PKG_PREFIX})" | sort > "${_VER_BEFORE}" || true
  cat "${_VER_BEFORE}" >> "${LOG_FILE}" 2>&1 || true

  # Без фильтра по префиксу: карта может вести и на пакет за пределами
  # vstack/FreeBSD-*, а отличить нужное от ненужного здесь нечем.
  pkg_s query '%n' 2>/dev/null | sort -u > "${_NAMES_BEFORE}" || true
  loader_preload_list > "${_PRELOAD_BEFORE}" 2>/dev/null || true

  _BACKUP_TS="$( date -u '+%Y%m%dT%H%M%SZ' )"
  _BACKUP_DIR="${BACKUP_ROOT}/etc-${_BACKUP_TS}"
  # Имя каталога с точностью до секунды, поэтому второй прогон в ту же секунду
  # попадал в уже существующий каталог: `cp -a /etc dst` при существующем dst
  # кладёт копию ВНУТРЬ него ( dst/etc ), каталог остаётся с прошлого прогона, и
  # восстановление возвращает ЧУЖОЙ /etc. Поэтому каталог создаём сами и копируем
  # содержимое, а на коллизию имени разводимся суффиксом.
  if [ -e "${_BACKUP_DIR}" ]; then
    _BACKUP_DIR="${_BACKUP_DIR}-$$"
    log "WARNING: backup directory already exists, using ${_BACKUP_DIR}"
  fi
  log "INFO: backing up /etc to ${_BACKUP_DIR}"
  if ! mkdir -p "${_BACKUP_DIR}" || ! cp -a "${ETC_DIR}/." "${_BACKUP_DIR}/"; then
    log "ERROR: /etc backup failed"
    exit 4
  fi

  if [ -d "${LOCAL_ETC_DIR}" ]; then
    log "INFO: backing up ${LOCAL_ETC_DIR} to ${_BACKUP_DIR}/usr-local-etc"
    if ! cp -a "${LOCAL_ETC_DIR}" "${_BACKUP_DIR}/usr-local-etc"; then
      log "ERROR: ${LOCAL_ETC_DIR} backup failed"
      exit 4
    fi
  fi

  # Профиль тюнинга узла ( очереди vdev, ARC ) живёт в loader.conf.local, а часть
  # узлов держит hw.contigmem.* прямо в loader.conf - файле установщика, который
  # обновление базы перезаписывает. Без этой копии узел откатывается к дефолту
  # молча, и замечают это по деградации, а не по ошибке ( TSK0004073 ).
  _BOOT_BACKUP="${_BACKUP_DIR}/boot"
  mkdir -p "${_BOOT_BACKUP}"
  for _bf in loader.conf loader.conf.local device.hints; do
    if [ -f "${BOOT_DIR}/${_bf}" ]; then
      if ! cp -p "${BOOT_DIR}/${_bf}" "${_BOOT_BACKUP}/${_bf}"; then
        log "ERROR: ${BOOT_DIR}/${_bf} backup failed"
        exit 4
      fi
      log "INFO: backed up ${BOOT_DIR}/${_bf}"
    fi
  done
  # Каталог drop-in'ов копируем ТОЛЬКО для разбора постфактум: восстанавливать его
  # нельзя, там живёт одноразовый zz-vstack-fallback.conf, и возврат старой копии
  # воскресил бы откат на ядро, которого уже нет ( DEF0001553 ).
  if [ -d "${BOOT_DIR}/loader.conf.d" ]; then
    cp -a "${BOOT_DIR}/loader.conf.d" "${_BOOT_BACKUP}/loader.conf.d" 2>/dev/null || true
  fi

  _XM_DIR="${BACKUP_ROOT}/cross-major-${_BACKUP_TS}"
  _XM_MAP="${_XM_DIR}/pkg-rename.map"
  _VSTACK_ENV_BACKUP="${BACKUP_ROOT}/vstack-env-${_BACKUP_TS}.conf"
  if [ -f "${VSTACK_ENV_CONF}" ]; then
    if ! cp -p "${VSTACK_ENV_CONF}" "${_VSTACK_ENV_BACKUP}"; then
      log "ERROR: vstack-env.conf backup failed"
      exit 4
    fi
  fi

  rotate_backups "${BACKUP_ROOT}" "${BACKUP_KEEP}"
fi

PKG_RC=0
KLD_RC=0

# ==============================================================================
# Смена мажора на pkgbase: копия pkg, библиотеки прежнего мира, база.
#
# Весь прогон до перезагрузки идёт копией pkg-static, снятой здесь: pkg целевой
# линии ставится обычным пакетом и на прежнем ядре падает. База - первой
# транзакцией и принудительно: пакеты узла лягут уже на новую базу.
# ==============================================================================
if [ "${XM_PKGBASE}" -eq 1 ]; then
  if [ -z "${RESUME_FILE}" ]; then
    mkdir -p "${_XM_DIR}"
    _xm_pkg_src="$( command -v pkg-static 2>/dev/null || true )"
    PKG_BIN="${_XM_DIR}/pkg-static.${XM_FROM_MAJOR}"
    if [ -z "${_xm_pkg_src}" ] || ! cp -p "${_xm_pkg_src}" "${PKG_BIN}" || ! "${PKG_BIN}" -v >/dev/null 2>&1; then
      log "ERROR: cannot keep a working copy of pkg-static ( ${_xm_pkg_src:-not found} ) for the transition"
      exit 3
    fi
    log "INFO: this run uses ${PKG_BIN} ( copy of ${_xm_pkg_src}, $( "${PKG_BIN}" -v ) ) - pkg of FreeBSD ${XM_TO_MAJOR} starts working after the reboot"
    {
      printf 'FROM_MAJOR=%s\n' "${XM_FROM_MAJOR}"
      printf 'TO_MAJOR=%s\n' "${XM_TO_MAJOR}"
      printf 'TARGET_ABI=%s\n' "${OPT_ABI}"
      printf 'PKG_BIN=%s\n' "${PKG_BIN}"
      printf 'XM_DIR=%s\n' "${_XM_DIR}"
      printf 'BACKUP_DIR=%s\n' "${_BACKUP_DIR}"
      printf 'VSTACK_ENV_BACKUP=%s\n' "${_VSTACK_ENV_BACKUP}"
      printf 'VER_BEFORE=%s\n' "${_VER_BEFORE}"
      printf 'NAMES_BEFORE=%s\n' "${_NAMES_BEFORE}"
      printf 'PRELOAD_BEFORE=%s\n' "${_PRELOAD_BEFORE}"
    } > "${XM_RESUME_MARK}" || { log "ERROR: cannot write ${XM_RESUME_MARK}"; exit 4; }
  fi

  if ! xm_compat_guard; then
    log "ERROR: cannot copy the libraries of FreeBSD ${XM_FROM_MAJOR} to ${COMPAT_DIR}"
    exit 4
  fi

  _xmp_repo="${_XM_DIR}/target-names.txt"
  pkg_s rquery -r "${PRODUCT}" '%n' 2>/dev/null | sort -u > "${_xmp_repo}" || true
  _xmp_list="$( grep "^${FREEBSD_PKG_PREFIX}" "${_xmp_repo}" | grep -v -- '-dbg$' | tr '\n' ' ' )"
  # Отладочные пакеты базы - только если они уже стояли и есть в цели: иначе
  # остались бы сборкой прежнего мажора рядом с новой базой.
  for _xmp_d in $( pkg_s query '%n' 2>/dev/null | grep "^${FREEBSD_PKG_PREFIX}.*-dbg\$" ); do
    grep -qx -- "${_xmp_d}" "${_xmp_repo}" && _xmp_list="${_xmp_list} ${_xmp_d}"
  done
  if [ -z "${_xmp_list}" ]; then
    log "ERROR: target release offers no ${FREEBSD_PKG_PREFIX}* packages"
    exit 3
  fi
  rm -rf "${BASE_TREE_PREFIX}/include/c++/v1/__string" \
         "${BASE_TREE_PREFIX}/include/c++/v1/__tuple" \
         "${BASE_TREE_PREFIX}/share/nls/POSIX" 2>/dev/null || true
  # Сначала оболочка и её библиотеки - отдельной транзакцией и без скриптов
  # пакетов. Файлы базы переезжают между пакетами 14 и 15 ( libedit.so.8:
  # FreeBSD-clibs -> FreeBSD-runtime ). На узлах, где у -dev/-man пакетов 14
  # есть конфликты с 15 ( q9u004 ), pkg собирает базу в одну транзакцию с
  # удалениями, ставит clibs 15 раньше runtime 15 - /bin/sh теряет libedit
  # посреди транзакции, и первый же pre-install скрипт пакета узла ( nrpe )
  # обрывает её. Скрипты этих пакетов отработают в полной транзакции ниже.
  _xmp_sh=""
  for _xmp_s in "${FREEBSD_PKG_PREFIX}runtime" "${FREEBSD_PKG_PREFIX}utilities"; do
    grep -qx -- "${_xmp_s}" "${_xmp_repo}" && _xmp_sh="${_xmp_sh} ${_xmp_s}"
  done
  if [ -n "${_xmp_sh}" ]; then
    log "INFO: installing the shell runtime first:${_xmp_sh} ( package scripts run with the base set )"
    # shellcheck disable=SC2086
    if ! pkg_s install -I -r "${PRODUCT}" -fy ${_xmp_sh} >> "${LOG_FILE}" 2>&1; then
      log "ERROR: shell runtime install failed - do NOT reboot"
      log "HINT: fix the cause and rerun the same command with --resume"
      exit 3
    fi
    if ! /bin/sh -c ':' 2>/dev/null; then
      log "ERROR: /bin/sh does not start after the shell runtime transaction - do NOT reboot"
      exit 3
    fi
  fi
  log "INFO: installing the base set of FreeBSD ${XM_TO_MAJOR} ( $( printf '%s\n' ${_xmp_list} | grep -c . ) packages )"
  _xmp_rc=0
  # shellcheck disable=SC2086
  pkg_s install -r "${PRODUCT}" -fy ${_xmp_list} >> "${LOG_FILE}" 2>&1 || _xmp_rc=$?
  _xmp_rt="$( pkg_s query '%v' "${FREEBSD_PKG_PREFIX}runtime" 2>/dev/null || true )"
  if [ "${_xmp_rc}" -ne 0 ] || [ "${_xmp_rt%%.*}" != "${XM_TO_MAJOR}" ]; then
    log "ERROR: base set install failed rc=${_xmp_rc}, ${FREEBSD_PKG_PREFIX}runtime ${_xmp_rt:-missing} - do NOT reboot"
    log "HINT: fix the cause and rerun the same command with --resume"
    exit 3
  fi
  log "INFO: base set installed, ${FREEBSD_PKG_PREFIX}runtime ${_xmp_rt}"

  # Пакеты базы прежнего мажора, которых в цели нет ( в 15 часть слита или
  # убрана: libbsm, liblzma, elftoolchain ... ). Они остаются сборкой прежнего
  # мажора со ссылками на его библиотеки. Пакеты ядра не трогаем никогда:
  # сирота ядра без замены оставил бы /boot без ядра.
  _xmp_old=""
  for _xmp_p in $( pkg_s query '%n %v' 2>/dev/null \
      | awk -v fb="^${FREEBSD_PKG_PREFIX}" -v m="^${XM_FROM_MAJOR}\\." '$1 ~ fb && $1 !~ /kernel/ && $2 ~ m { print $1 }' ); do
    grep -qx -- "${_xmp_p}" "${_xmp_repo}" || _xmp_old="${_xmp_old} ${_xmp_p}"
  done
  if [ -n "${_xmp_old}" ]; then
    # shellcheck disable=SC2086
    if pkg_s delete -y ${_xmp_old} >> "${LOG_FILE}" 2>&1; then
      XM_OLD_BASE_REMOVED="${_xmp_old}"
      log "INFO: base packages of FreeBSD ${XM_FROM_MAJOR} absent in the target removed:${_xmp_old}"
    else
      log "WARNING: could not remove base packages of FreeBSD ${XM_FROM_MAJOR}:${_xmp_old}"
    fi
  fi

  # Страховка «загрузиться прежним ядром» здесь не работает: прежнее ядро новый
  # мир не запустит ( SIGSYS у login и sshd ). Точка возврата - снапшот.
  log "NOTICE: cross-major: no kernel fallback is armed - the previous kernel cannot run the new userland"
  log "NOTICE: the return point for this transition is a snapshot or a backup of the whole node"
  OPT_NO_KERNEL_BACKUP=1
  OPT_NO_FALLBACK=1
fi

# =============================================================================
# Смена мажора: базу СТАВИМ до контроллеров.
#
# На узле линии 2.x pkgbase нет вовсе, а порты целевой линии собраны под другой
# мажор FreeBSD. Если сначала обновить порты, пакеты новой линии лягут на старую
# базу и утащат за собой базовые библиотеки: узел остаётся с неработающими
# base-утилитами ЕЩЁ ДО того, как дело дойдёт до установки базы, и дальше
# ломается даже разбор loader.conf ( он читается через tail ). Порядок здесь
# тот же, что у прод-процедуры перехода: сначала база и ядро, следом порты,
# и только потом один ребут.
# =============================================================================
XM_BASE_PHASE=0
if [ "${MAJOR_MODE}" -eq 1 ] && [ "${OPT_BASE}" -eq 1 ] && [ -z "${_inst_fbsd}" ]; then
  XM_BASE_PHASE=1
  _xmb_list="$( pkg_s rquery -r "${PRODUCT}" '%n' 2>/dev/null \
    | grep "^${FREEBSD_PKG_PREFIX}" | grep -v -- '-dbg$' | sort -u | tr '\n' ' ' )"
  if [ -z "${_xmb_list}" ]; then
    log "ERROR: target release offers no ${FREEBSD_PKG_PREFIX}* packages, base cannot be materialised"
    exit 3
  fi
  _xmb_cnt="$( printf '%s\n' ${_xmb_list} | grep -c . )"
  if [ "${OPT_DRY_RUN}" -eq 1 ]; then
    log "INFO: dry-run, would install the base set of the target release ( ${_xmb_cnt} packages )"
  else
    # Свой pkg ставим ОТДЕЛЬНЫМ шагом и до базы. Иначе он подтянется сам
    # посреди установки набора: узел линии 2.x живёт на pkg 1.x, целевая линия
    # приносит 2.x, и смена формата базы данных посреди большой транзакции - не
    # то, что стоит отдавать на волю случая. Прод-процедура делает это тем же
    # первым шагом ( pkg-static bootstrap -f ).
    log "INFO: cross-major: bringing pkg itself to the target release first"
    pkg_s install -r "${PRODUCT}" -fy pkg >> "${LOG_FILE}" 2>&1 \
      || log "WARNING: could not update pkg separately, continuing with the installed one"

    # Дист-сеты прежней линии оставили на этих путях ФАЙЛЫ там, где пакеты базы
    # целевой ждут КАТАЛОГИ. Для pkg это конфликт, он отказывает целиком. Ниже,
    # в обновлении pkgbase, те же пути чистятся перед upgrade - здесь они нужны
    # раньше, до первой установки набора.
    log "INFO: clearing dist-set leftovers that conflict with the base packages"
    rm -rf "${BASE_TREE_PREFIX}/include/c++/v1/__string" \
           "${BASE_TREE_PREFIX}/include/c++/v1/__tuple" \
           "${BASE_TREE_PREFIX}/share/nls/POSIX" 2>/dev/null || true

    log "INFO: cross-major: installing the base set of the target release ( ${_xmb_cnt} packages )"
    _xmb_rc=0
    # shellcheck disable=SC2086
    pkg_s install -r "${PRODUCT}" -fy ${_xmb_list} >> "${LOG_FILE}" 2>&1 || _xmb_rc=$?
    if [ "${_xmb_rc}" -ne 0 ]; then
      log "ERROR: base set install failed rc=${_xmb_rc}"
      exit 3
    fi
    # Набор считается поставленным только если на узле появился runtime: без
    # него база не заменена, а дальше пойдут порты новой линии на старой базе.
    if [ -z "$( pkg_s query '%v' "${FREEBSD_PKG_PREFIX}runtime" 2>/dev/null || true )" ]; then
      log "ERROR: ${FREEBSD_PKG_PREFIX}runtime is still not installed, base set did not materialise"
      exit 3
    fi
    log "INFO: base set installed, node now carries pkgbase of the target release"

    # Страховка «загрузиться прежним ядром» здесь не работает и потому не
    # взводится: прежнее ядро - другого мажора, и новый userland на нём не
    # живёт ( сервисы падают на несуществующих системных вызовах ). Копия
    # прежнего ядра снималась бы уже ПОСЛЕ установки базы, то есть копировала
    # бы новое ядро само в себя. Точка возврата при смене мажора - снапшот или
    # бэкап узла целиком, ровно как записано в прод-процедуре перехода.
    if [ "${OPT_NO_KERNEL_BACKUP}" -eq 0 ] || [ "${OPT_NO_FALLBACK}" -eq 0 ]; then
      log "NOTICE: cross-major: no kernel fallback is armed - the previous kernel cannot run the new userland"
      log "NOTICE: the return point for this transition is a snapshot or a backup of the whole node"
      OPT_NO_KERNEL_BACKUP=1
      OPT_NO_FALLBACK=1
    fi
  fi
fi

if [ "${OPT_CONTROLLERS}" -eq 1 ]; then
  log "INFO: building controllers package list (excluding ${FREEBSD_PKG_PREFIX}*)"
  _ctrl_query_rc=0
  _ctrl_all_pkgs="$( pkg_s query '%n' )" || _ctrl_query_rc=$?
  if [ "${_ctrl_query_rc}" -ne 0 ]; then
    log "ERROR: pkg query failed rc=${_ctrl_query_rc}"
    exit 3
  fi
  _ctrl_pkg_list="$( printf '%s\n' "${_ctrl_all_pkgs}" | grep -v "^${FREEBSD_PKG_PREFIX}" || true )"
  if [ -z "${_ctrl_pkg_list}" ]; then
    log "INFO: no non-${FREEBSD_PKG_PREFIX}* packages installed, skipping controllers upgrade"
  else
    set --
    while IFS= read -r _ctrl_pkg; do
      [ -n "${_ctrl_pkg}" ] || continue
      set -- "$@" "${_ctrl_pkg}"
    done <<EOF_CTRL_PKGS
${_ctrl_pkg_list}
EOF_CTRL_PKGS
    if [ "${XM_PKGBASE}" -eq 1 ]; then
      # Принудительно и по имени: пакеты собраны под прежний мажор и должны
      # быть заменены сборкой цели даже при той же версии. Пакеты, которых в
      # цели нет, остаются сборкой прежнего мажора на библиотеках из compat.
      #
      # Список - по составу узла ДО прогона, а не по текущему: транзакция базы
      # с удалениями сносит пакеты узла ( q9u004: vStack-nodeside,
      # -storage-ctrl, -storage-san ), и без этого снесённое назад не встало
      # бы. При --resume это состав до первого прогона.
      _xm_gone_by_base="$( grep -v "^${FREEBSD_PKG_PREFIX}" "${_NAMES_BEFORE}" 2>/dev/null \
        | grep -vxF "${_ctrl_pkg_list}" | tr '\n' ' ' || true )"
      if [ -n "${_xm_gone_by_base}" ]; then
        log "NOTICE: removed by the base transaction, installed back from the target where it has them: ${_xm_gone_by_base}"
        _ctrl_pkg_list="$( printf '%s\n%s\n' "${_ctrl_pkg_list}" "$( printf '%s\n' ${_xm_gone_by_base} )" | grep . | sort -u )"
      fi
      set --
      while IFS= read -r _ctrl_pkg; do
        [ -n "${_ctrl_pkg}" ] || continue
        if grep -qx -- "${_ctrl_pkg}" "${_xmp_repo}"; then
          set -- "$@" "${_ctrl_pkg}"
        else
          XM_PKG_LEFTOVERS="${XM_PKG_LEFTOVERS} ${_ctrl_pkg}"
        fi
      done <<EOF_XM_CTRL
${_ctrl_pkg_list}
EOF_XM_CTRL
      [ -z "${XM_PKG_LEFTOVERS}" ] \
        || log "NOTICE: absent in the target, stay FreeBSD ${XM_FROM_MAJOR} builds ( served by ${COMPAT_DIR} ):${XM_PKG_LEFTOVERS}"
      if [ "$#" -gt 0 ]; then
        log "INFO: reinstalling node packages from the target ( $# packages )"
        pkg_s install -r "${PRODUCT}" -fy "$@" >> "${LOG_FILE}" 2>&1 || PKG_RC=$?
      fi
      if [ "${PKG_RC}" -ne 0 ]; then
        log "ERROR: reinstall of node packages failed rc=${PKG_RC} - do NOT reboot"
        log "HINT: fix the cause and rerun the same command with --resume"
        exit 3
      fi
    else
      log "INFO: controllers upgrade preview"
      pkg_s upgrade -r "${PRODUCT}" -n "$@" 2>&1 | tee -a "${LOG_FILE}" || true
      log "INFO: upgrading controllers packages"
      pkg_s upgrade -r "${PRODUCT}" "$@" || PKG_RC=$?
      if [ "${PKG_RC}" -ne 0 ]; then
        log "ERROR: pkg upgrade controllers failed rc=${PKG_RC}"
        exit 3
      fi
    fi
    log "INFO: controllers upgrade done"
  fi

  # ВРЕМЕННЫЙ КОСТЫЛЬ ( TSK0003968 ). ПЕРЕДЕЛАТЬ НА НОРМАЛЬНЫЕ МЕТАПОРТЫ.
  #
  # Выше идёт pkg upgrade по списку, построенному из УЖЕ УСТАНОВЛЕННОГО
  # ( pkg query '%n' ). Пакета, которого на узле ещё нет, в этом списке быть
  # не может — значит новые пакеты релиза на существующие узлы не приезжают
  # НИКОГДА, сколько ни обновляй. vmagent ( FTR0000473 / TSK0003692 ) просто
  # первый случай, который это вскрыл.
  #
  # Как надо: состав узла описывается МЕТАПОРТОМ, и новое приезжает
  # зависимостью, а не перечислением в скрипте обновления. Сейчас состав
  # размазан по трём местам — ansible-роль vstack_node в vstack-center/dist,
  # манифесты ci/manifests в devops/ports и вот этот скрипт. Эту хрень надо
  # разобрать целиком, а не дописывать сюда пакеты по одному: следующий
  # такой пакет опять никто не заметит, пока он не понадобится на проде.
  #
  # || true намеренно: в аттестуемом контуре пакета vmagent нет по бан-листу
  # ( !vmagent* в ci/manifests/ban-fstec.conf ), и там эта строка обязана
  # тихо не сделать ничего, а не завалить обновление узла.
  log "INFO: ensuring vmagent is installed ( TSK0003968, temporary shim )"
  pkg_s install -r "${PRODUCT}" vmagent >> "${LOG_FILE}" 2>&1 \
    || log "INFO: vmagent not installed ( absent in repo or install failed ) - continuing"
fi

# ==============================================================================
# Защита загрузки: прежнее ядро, проверка нового, загрузчик на ESP.
#
# Зачем. pkgbase перезаписывает ядро и /boot/loader.efi, но грузится узел
# НЕ из /boot: прошивка читает копию загрузчика с ESP ( FAT ), а её не
# обновляет ни pkg, ни этот скрипт. Известный пробел pkgbase, в инструкциях
# FreeBSD копирование прописано руками ( PR 279829 ). Плюс до появления
# этих проверок скрипт ребутил узел, никак не убедившись, что новое ядро
# вообще на месте, и не оставляя, с чего загрузиться при неудаче.
# ==============================================================================

# ==============================================================================
# Гарантия, что /boot лежит на диске ДО перезагрузки ( DEF0001553, критерий 1 ).
#
# sync(8) записи только ПЛАНИРУЕТ и возвращает успех, не дожидаясь их: на корне
# с UFS и soft-updates ( SU+J выключен ) это ровно тот зазор, в котором терялись
# свежие файлы и узел вставал на loader-промпте. fsync(2), в отличие от него,
# возвращается только после фиксации и сообщает об ошибке - то есть даёт не
# просто сброс, а ПОДТВЕРЖДЕНИЕ, которого не хватало.
#
# Синхронизируем и файлы, и каталоги: без fsync каталога запись может уцелеть, а
# ссылка на неё - нет.
# ==============================================================================
sync_boot_durable() {
  if ! command -v "${FSYNC_BIN}" >/dev/null 2>&1; then
    log "WARNING: fsync(1) not available, falling back to sync(8) - ${BOOT_DIR} durability is NOT confirmed"
    sync
    sync
    return 0
  fi

  _sbd_list="$( mktemp /tmp/vstack-fsync.XXXXXX )" || return 1
  _sbd_skip="$( mktemp /tmp/vstack-fsync-skip.XXXXXX )" || return 1
  # Точки монтирования вложенных ФС из сверки исключаем: fsync такого каталога
  # уходит на КОРЕНЬ вложенной ФС, а msdosfs ( ESP штатно смонтирован в
  # ${BOOT_DIR}/efi ) VOP_FSYNC на каталоге не реализует и отвечает EINVAL -
  # подтверждать там нечего, и отказ этот ни о чём не говорит. Файлы на такой ФС
  # fsync принимают, а загрузчик на ESP - именно файл, поэтому каталоги берём
  # только с файловой системы самого ${BOOT_DIR} ( find -x ), а файлы - по всему
  # дереву вторым проходом. Сам ${BOOT_DIR} тоже: запись каталога верхнего уровня.
  mount | awk -v b="${BOOT_DIR}/" '$2 == "on" && index( $3, b ) == 1 { print $3 }' \
    > "${_sbd_skip}"
  {
    find -x "${BOOT_DIR}"
    find "${BOOT_DIR}" -type f
    printf '%s\n' "${BOOT_DIR}"
  } 2>/dev/null | sort -u | grep -v -x -F -f "${_sbd_skip}" > "${_sbd_list}"
  rm -f "${_sbd_skip}"

  _sbd_rc=0
  _sbd_ok=0
  _sbd_bad=0
  while IFS= read -r _sbd_p; do
    [ -n "${_sbd_p}" ] || continue
    [ -e "${_sbd_p}" ] || continue
    if "${FSYNC_BIN}" "${_sbd_p}" >/dev/null 2>&1; then
      _sbd_ok=$(( _sbd_ok + 1 ))
    else
      _sbd_bad=$(( _sbd_bad + 1 ))
      _sbd_rc=1
      log "ERROR: fsync failed for ${_sbd_p}"
    fi
  done < "${_sbd_list}"
  rm -f "${_sbd_list}"

  # Осаживаем ещё и глобально: /boot мог быть не единственным затронутым ФС
  # ( ESP размонтирован выше, /etc восстановлен ниже ).
  sync

  if [ "${_sbd_rc}" -ne 0 ]; then
    log "ERROR: ${BOOT_DIR} durability NOT confirmed: ${_sbd_bad} of $(( _sbd_ok + _sbd_bad )) paths failed to fsync"
    return 1
  fi
  log "INFO: ${BOOT_DIR} durability confirmed: ${_sbd_ok} paths fsynced"
  return 0
}

# Имя каталога ядра, которое реально грузится: loader.conf может указывать
# не на /boot/kernel ( у нас это kernel.GENERIC_PROD ).
file_sha256() {
  if command -v sha256 >/dev/null 2>&1; then
    sha256 -q "$1"
  elif command -v sha256sum >/dev/null 2>&1; then
    sha256sum "$1" | awk '{print $1}'
  else
    printf 'unknown\n'
  fi
}

is_elf() {
  [ -f "$1" ] || return 1
  _magic="$( dd if="$1" bs=4 count=1 2>/dev/null | od -An -tx1 | tr -d ' \n' )"
  [ "${_magic}" = "7f454c46" ]
}

# Загрузчик EFI - это PE32+, то есть файл начинается с "MZ". Проверка ровно та же
# по смыслу, что is_elf для ядра: на ESP пишется файл, который прошивка исполнит
# ДО всего остального, и если он мусор, узел встанет на первой же перезагрузке, а
# сказать об этом будет уже некому.
is_efi_app() {
  [ -f "$1" ] || return 1
  _iea_size="$( wc -c < "$1" 2>/dev/null | tr -d ' ' )"
  case "${_iea_size}" in ''|*[!0-9]*) return 1 ;; esac
  [ "${_iea_size}" -ge "${LOADER_MIN_SIZE}" ] || return 1
  _iea_magic="$( dd if="$1" bs=2 count=1 2>/dev/null | od -An -tx1 | tr -d ' \n' )"
  [ "${_iea_magic}" = "4d5a" ]
}

# Копия работающего ядра рядом, ДО апгрейда. Без неё «откатиться на прежнее
# ядро» невозможно: pkg перезаписывает каталог на месте, а kernel.old от
# pkgbase не появляется.
save_prev_kernel() {
  _spk_dir="$( loader_kernel_dir )"
  _spk_src="${BOOT_DIR}/${_spk_dir}"
  _spk_dst="${BOOT_DIR}/${_spk_dir}${KERNEL_PREV_SUFFIX}"

  if [ ! -f "${_spk_src}/kernel" ]; then
    log "WARNING: ${_spk_src}/kernel not found, nothing to preserve"
    return 0
  fi

  # Узел уже работает на сохранённой копии ( сработал автовозврат ): в
  # каноническом каталоге лежит ядро, которое НЕ загрузилось, и копировать
  # его поверх заведомо рабочего нельзя.
  if [ "$( running_kernel_dir )" = "${_spk_dir}${KERNEL_PREV_SUFFIX}" ]; then
    log "INFO: running on ${_spk_dir}${KERNEL_PREV_SUFFIX} already, keeping it as the fallback"
    return 0
  fi

  # Узел не перезагружали после прошлого обновления: в каноническом каталоге
  # ядро, которое здесь ещё ни разу не грузилось. Если работающее уже лежит
  # копией - запасным остаётся оно, а не непроверенное.
  _spk_run=0
  kernel_dir_is_running "${_spk_dir}" || _spk_run=$?
  if [ "${_spk_run}" -eq 1 ]; then
    _spk_prev=0
    kernel_dir_is_running "${_spk_dir}${KERNEL_PREV_SUFFIX}" || _spk_prev=$?
    if [ "${_spk_prev}" -eq 0 ]; then
      log "INFO: ${_spk_src} never ran here ( no reboot since the last update ), ${_spk_dst} is the running kernel - keeping it as the fallback"
      return 0
    fi
    log "WARNING: ${_spk_src} never ran here ( no reboot since the last update ) - the fallback ${_spk_dst} is an unproven kernel"
  fi

  # Места нужно столько же, сколько занимает каталог ядра.
  _spk_need="$( du -sk "${_spk_src}" 2>/dev/null | awk '{print $1}' )"
  _spk_free="$( df -k "${BOOT_DIR}" 2>/dev/null | awk 'NR==2 {print $4}' )"
  if [ -n "${_spk_need}" ] && [ -n "${_spk_free}" ] \
     && [ "${_spk_free}" -lt "$(( _spk_need + 65536 ))" ]; then
    log "ERROR: not enough space in ${BOOT_DIR} to preserve the running kernel (need ${_spk_need}K, free ${_spk_free}K)"
    log "HINT: free some space or rerun with --no-kernel-backup ( then a failed boot needs the console )"
    return 1
  fi

  rm -rf "${_spk_dst}.tmp"
  if ! cp -a "${_spk_src}" "${_spk_dst}.tmp"; then
    log "ERROR: failed to copy ${_spk_src} to ${_spk_dst}.tmp"
    return 1
  fi
  rm -rf "${_spk_dst}"
  mv "${_spk_dst}.tmp" "${_spk_dst}"
  sync
  log "INFO: previous kernel preserved as ${_spk_dst} ( loader prompt: boot ${_spk_dir}${KERNEL_PREV_SUFFIX} )"
  return 0
}

# Проверка ТОГО ядра, которое будет грузиться. Полной «грузибельности»
# офлайн не проверить, поэтому сито из проверяемых признаков: файл на месте
# по пути из loader.conf, это ELF, размер разумный, модули и linker.hints
# на месте, версия совпадает с установленным пакетом ядра.
verify_new_kernel() {
  _vnk_dir="$( loader_kernel_dir )"
  _vnk_path="${BOOT_DIR}/${_vnk_dir}/kernel"
  _vnk_rc=0

  log "INFO: verifying kernel ${_vnk_path} ( loader.conf kernel=\"${_vnk_dir}\" )"

  if [ ! -f "${_vnk_path}" ]; then
    log "ERROR: kernel not found at ${_vnk_path}"
    return 1
  fi
  if ! is_elf "${_vnk_path}"; then
    log "ERROR: ${_vnk_path} is not an ELF image"
    _vnk_rc=1
  fi
  _vnk_size="$( wc -c < "${_vnk_path}" 2>/dev/null | tr -d ' ' || echo 0 )"
  if [ "${_vnk_size}" -lt "${KERNEL_MIN_SIZE}" ]; then
    log "ERROR: ${_vnk_path} is too small (${_vnk_size} bytes)"
    _vnk_rc=1
  fi
  _vnk_mods="$( ls "${BOOT_DIR}/${_vnk_dir}"/*.ko 2>/dev/null | wc -l | tr -d ' ' )"
  if [ "${_vnk_mods}" -lt "${KERNEL_MIN_MODULES}" ]; then
    log "ERROR: only ${_vnk_mods} modules in ${BOOT_DIR}/${_vnk_dir}, expected at least ${KERNEL_MIN_MODULES}"
    _vnk_rc=1
  fi
  if [ ! -s "${BOOT_DIR}/${_vnk_dir}/linker.hints" ]; then
    log "ERROR: ${BOOT_DIR}/${_vnk_dir}/linker.hints is missing or empty"
    _vnk_rc=1
  fi

  # Сверка с пакетом: ядро в /boot должно быть из той версии, которую pkg
  # считает установленной. Ловит случай «pkg отчитался, файлы не доехали».
  _vnk_pkg_ver="$( pkg_s query '%v' "${FREEBSD_PKG_PREFIX}kernel-generic_prod" 2>/dev/null || true )"
  if [ -z "${_vnk_pkg_ver}" ]; then
    _vnk_pkg_ver="$( pkg_s query '%v' "${FREEBSD_PKG_PREFIX}kernel-generic" 2>/dev/null || true )"
  fi
  # Идент - grep по "@(#)FreeBSD", а не strings(1): FreeBSD-elftoolchain в базе 15
  # нет, и после смены мажора strings на узле пропадает - сверка молча
  # пропускалась.
  _vnk_ident="$( grep -a -m1 -o '@(#)FreeBSD [^ ]*' "${_vnk_path}" 2>/dev/null | head -1 | sed 's/^@(#)//' || true )"
  log "INFO: kernel ident: ${_vnk_ident:-unknown}"
  if [ "${XM_PKGBASE}" -eq 1 ]; then
    case "${_vnk_ident}" in
      "FreeBSD ${XM_TO_MAJOR}."*) ;;
      *) log "ERROR: ${_vnk_path} is '${_vnk_ident:-no ident}', expected a FreeBSD ${XM_TO_MAJOR} kernel"; _vnk_rc=1 ;;
    esac
  elif [ -n "${_vnk_pkg_ver}" ]; then
    _vnk_base="${_vnk_pkg_ver%%.s*}"
    case "${_vnk_ident}" in
      *"${_vnk_base}"*) ;;
      *) log "WARNING: kernel ident does not mention base version ${_vnk_base} from package ${_vnk_pkg_ver}" ;;
    esac
  fi

  if [ "${_vnk_rc}" -eq 0 ]; then
    log "INFO: kernel checks passed (${_vnk_size} bytes, ${_vnk_mods} modules)"
  fi
  return "${_vnk_rc}"
}

# Провайдер под меткой GEOM: gpt/efiboot0 -> ada0p1. Без glabel или для имени
# без метки возвращает имя как есть. Нужен, чтобы одну и ту же ESP, увиденную
# под меткой ( mount ) и под провайдером ( efibootmgr, gpart ), не обойти дважды.
geom_provider() {
  case "$1" in
    gpt/*|gptid/*|label/*|diskid/*|msdosfs/*|ufs/*|ufsid/*)
      _gp_p="$( glabel status -s 2>/dev/null | awk -v n="$1" '$1 == n { print $3; exit }' )"
      printf '%s\n' "${_gp_p:-$1}"
      ;;
    *) printf '%s\n' "$1" ;;
  esac
}

# Диски, на которых лежит корень. У загрузочного зеркала их несколько, и ESP
# своя на каждом ( DEF0001747 ): обновить загрузчик только на одной значит
# оставить резервный путь загрузки со старым загрузчиком под новое ядро.
#   * UFS на разделе:          ada0p2           -> ada0;
#   * gmirror из разделов:     mirror/gm0       -> ada0, ada1 ( потребители );
#   * раздел на зеркале дисков: mirror/gm0p2    -> mirror/gm0 ( ESP тоже на нём );
#   * ZFS:                     zroot/ROOT/...   -> диски vdev'ов пула.
root_disks() {
  _rd_src="$( mount | awk '$3 == "/" { print $1; exit }' )"
  case "${_rd_src}" in
    '') return 0 ;;
    /dev/*) _rd_provs="${_rd_src#/dev/}" ;;
    *)
      _rd_provs="$( zpool list -vHP "${_rd_src%%/*}" 2>/dev/null \
        | awk '$1 ~ /^\/dev\// { sub( "^/dev/", "", $1 ); print $1 }' )"
      ;;
  esac
  for _rd_p in ${_rd_provs}; do
    _rd_p="$( geom_provider "${_rd_p}" )"
    case "${_rd_p}" in
      mirror/*)
        # Сам GEOM зеркала ( потребители - разделы дисков ) или раздел на
        # зеркале целых дисков: различаем ответом gmirror, а не по имени.
        _rd_cons="$( gmirror list "${_rd_p#mirror/}" 2>/dev/null \
          | awk '/^Consumers:/ { c = 1; next } c && $2 == "Name:" { print $3 }' )"
        if [ -n "${_rd_cons}" ]; then
          for _rd_c in ${_rd_cons}; do
            geom_provider "${_rd_c}"
          done
          continue
        fi
        ;;
    esac
    printf '%s\n' "${_rd_p}"
  done | sed -E 's/p[0-9]+$//; s/s[0-9]+[a-h]?$//' | awk 'NF && !seen[$0]++'
}

# Все ESP, на которых надо держать свежий загрузчик: та, с которой узел
# загрузился ( efibootmgr -v во второй строке варианта печатает
# "daNpM:/EFI/BOOT/BOOTX64.EFI" ), и разделы типа efi на дисках корня.
# Чужие диски ( данные, вставленный носитель ) не трогаем: на них может лежать
# свой загрузчик, и перезаписать его - испортить, а не починить.
esp_devices() {
  {
    _esp_cur="$( efibootmgr 2>/dev/null | awk '/^BootCurrent:/ {print $2}' )"
    if [ -n "${_esp_cur}" ]; then
      efibootmgr -v 2>/dev/null | awk -v want="Boot${_esp_cur}" '
          index($0, want) { seen = 1; next }
          seen && match($0, /[a-z]+[0-9]+p[0-9]+:/) { print substr($0, RSTART, RLENGTH - 1); exit }'
    fi
    for _esp_disk in $( root_disks ); do
      gpart show -p "${_esp_disk}" 2>/dev/null | awk '$4 == "efi" {print $3}'
    done
  } | while IFS= read -r _esp_d; do
    [ -n "${_esp_d}" ] && geom_provider "${_esp_d}"
  done | awk 'NF && !seen[$0]++'
}

# Свежий загрузчик в одно уже смонтированное дерево ESP. Идемпотентно: сверяет
# sha256 и трогает файл только при расхождении, прежний оставляет рядом как .prev.
# Имена и регистр в парке разные ( BOOTX64.EFI и bootx64.efi, есть и
# EFI/freebsd/loader.efi ), поэтому ищем файлы, а не строим путь. Слэш после
# точки монтирования - чтобы find прошёл и по символической ссылке.
esp_sync_tree() {
  _est_mp="$1"
  _est_dev="$2"
  _est_targets="$( find "${_est_mp}/" -type f \( -iname 'bootx64.efi' -o -iname 'loader.efi' \) 2>/dev/null )"
  if [ -z "${_est_targets}" ]; then
    log "WARNING: no loader found on ESP ${_est_dev}, nothing to sync"
    return 0
  fi
  for _est_t in ${_est_targets}; do
    _est_old="$( file_sha256 "${_est_t}" 2>/dev/null || echo none )"
    if [ "${_est_old}" = "${_esl_new}" ]; then
      log "INFO: ESP loader ${_est_t} ( ${_est_dev} ) already up to date"
      continue
    fi
    log "INFO: updating ESP loader ${_est_t} ( ${_est_dev} ) (${_est_old} -> ${_esl_new})"
    if cp -p "${_est_t}" "${_est_t}.prev" 2>/dev/null; then
      log "INFO: previous ESP loader kept as ${_est_t}.prev"
    else
      log "WARNING: cannot back up ${_est_t}, skipping this target"
      continue
    fi
    if cp "${_esl_src}" "${_est_t}.new" && mv "${_est_t}.new" "${_est_t}"; then
      log "INFO: ESP loader updated: ${_est_t} ( ${_est_dev} )"
    else
      log "WARNING: failed to write ${_est_t}, previous loader is still in place"
      rm -f "${_est_t}.new" 2>/dev/null || true
    fi
  done
}

# Загрузчик на все ESP узла ( esp_devices ). Каждая обрабатывается отдельно:
# своя сверка, своя копия .prev, и сбой одной не мешает остальным.
esp_sync_loader() {
  _esl_src="${BOOT_DIR}/loader.efi"
  if [ ! -f "${_esl_src}" ]; then
    log "WARNING: ${_esl_src} not found, ESP left untouched"
    return 0
  fi
  # Источник проверяем ДО того, как что-то монтировать и тем более писать:
  # перезапись загрузчика на ESP необратима в пределах прогона ( копия рядом
  # остаётся, но восстановить её придётся руками ), поэтому лучше не начинать,
  # чем начать с негодным файлом.
  if ! is_efi_app "${_esl_src}"; then
    log "WARNING: ${_esl_src} is not a valid EFI application ( expected PE32+ >= ${LOADER_MIN_SIZE} bytes ), ESP left untouched"
    return 0
  fi
  _esl_new="$( file_sha256 "${_esl_src}" )"

  _esl_done=" "
  _esl_count=0
  # Штатно смонтированную ESP ( ${BOOT_DIR}/efi ) не перемонтируем: берём как есть.
  _esl_mdev="$( mount | awk -v m="${BOOT_DIR}/efi" '$3 == m { print $1; exit }' )"
  if [ -n "${_esl_mdev}" ]; then
    _esl_mdev="$( geom_provider "${_esl_mdev#/dev/}" )"
    log "INFO: ESP already mounted at ${BOOT_DIR}/efi ( ${_esl_mdev} )"
    esp_sync_tree "${BOOT_DIR}/efi" "${_esl_mdev}"
    _esl_done="${_esl_done}${_esl_mdev} "
    _esl_count=$(( _esl_count + 1 ))
  fi

  for _esl_dev in $( esp_devices || true ); do
    case "${_esl_done}" in *" ${_esl_dev} "*) continue ;; esac
    _esl_done="${_esl_done}${_esl_dev} "
    _esl_count=$(( _esl_count + 1 ))
    _esl_mp="$( mktemp -d "${ESP_MNT_BASE}/vstack-esp.XXXXXX" )"
    if ! mount -t msdosfs "/dev/${_esl_dev}" "${_esl_mp}" 2>/dev/null; then
      log "WARNING: cannot mount ESP /dev/${_esl_dev}, loader on it stays old"
      rmdir "${_esl_mp}" 2>/dev/null || true
      continue
    fi
    log "INFO: ESP /dev/${_esl_dev} mounted at ${_esl_mp}"
    esp_sync_tree "${_esl_mp}" "${_esl_dev}"
    sync
    umount "${_esl_mp}" 2>/dev/null || log "WARNING: cannot unmount ${_esl_mp}"
    rmdir "${_esl_mp}" 2>/dev/null || true
  done

  if [ "${_esl_count}" -eq 0 ]; then
    log "INFO: no EFI system partition found ( not a UEFI install? ), ESP sync skipped"
    return 0
  fi
  log "INFO: ESP sync done on ${_esl_count} partition(s):${_esl_done% }"
  sync
  return 0
}

# Каталог ядра, на котором узел работает ПРЯМО СЕЙЧАС: kern.bootfile отдаёт
# полный путь вида /boot/kernel.GENERIC_PROD/kernel.
# Переход на стандартную раскладку ядра ( TSK0004248 ). Боевое ядро GENERIC_PROD
# лежало вторым, в /boot/kernel.GENERIC_PROD, и выбиралось kernel= в loader.conf:
# первым в /boot/kernel шёл запасной GENERIC. Теперь ядро GENERIC_PROD одно и
# в /boot/kernel. Пакет FreeBSD-kernel-generic_prod при обновлении переезжает
# туда сам, FreeBSD-kernel-generic pkg убирает по конфликту файлов, а прежний
# каталог остаётся пустым ( при смене мажора - с ядром прежней линии от
# дист-сетов, ничьим ).
#
# Загрузчик с kernel= на каталог без ядра встаёт: "Failed to load kernel" и
# без автозагрузки ( bhyve, 06.10.2026 ). Поэтому строку снимаем ДО
# перезагрузки - и в живом loader.conf, и в копии узла: восстановление /boot
# ниже вернуло бы её поверх. Сохранённое прежнее ядро переименовываем под
# новый каталог: на <каталог>_prev смотрят автовозврат и маркер загрузки.
#
# Признак перехода - владелец, а не наличие файлов: каталог из loader.conf
# больше не принадлежит ни одному пакету, а /boot/kernel/kernel - пакету ядра.
kernel_layout_migrate() {
  _klm_dir="$( loader_kernel_dir )"
  [ "${_klm_dir}" != "kernel" ] || return 0
  _klm_old="$( pkg_s which -q "${BOOT_DIR}/${_klm_dir}/kernel" 2>/dev/null || true )"
  [ -z "${_klm_old}" ] || return 0
  _klm_new="$( pkg_s which -q "${BOOT_DIR}/kernel/kernel" 2>/dev/null || true )"
  case "${_klm_new}" in
    "${FREEBSD_PKG_PREFIX}kernel-"*) ;;
    *) return 0 ;;
  esac
  log "NOTICE: kernel layout: ${_klm_new} now lives in ${BOOT_DIR}/kernel, ${BOOT_DIR}/${_klm_dir} is not a kernel any more - the loader goes back to the default"

  _klm_re="$( printf '%s' "${_klm_dir}" | sed 's/[.]/\\./g' )"
  _klm_re="^[[:space:]]*kernel=\"\{0,1\}${_klm_re}\"\{0,1\}[[:space:]]*\$"
  _klm_files="${BOOT_DIR}/loader.conf ${BOOT_DIR}/loader.conf.local"
  [ -z "${_BOOT_BACKUP:-}" ] || _klm_files="${_klm_files} ${_BOOT_BACKUP}/loader.conf ${_BOOT_BACKUP}/loader.conf.local"
  for _klm_f in ${_klm_files}; do
    [ -f "${_klm_f}" ] || continue
    grep -q "${_klm_re}" "${_klm_f}" || continue
    if ! { sed "/${_klm_re}/d" "${_klm_f}" > "${_klm_f}.tmp.$$" && mv "${_klm_f}.tmp.$$" "${_klm_f}"; }; then
      log "ERROR: cannot remove kernel=\"${_klm_dir}\" from ${_klm_f}"
      return 1
    fi
    log "INFO: ${_klm_f}: kernel=\"${_klm_dir}\" removed"
  done
  _klm_now="$( loader_kernel_dir )"
  if [ "${_klm_now}" != "kernel" ]; then
    log "ERROR: the loader still selects kernel=\"${_klm_now}\" ( ${BOOT_DIR}/loader.conf.d? ) - it has to go before the reboot"
    return 1
  fi

  _klm_prev="${BOOT_DIR}/${_klm_dir}${KERNEL_PREV_SUFFIX}"
  if [ -d "${_klm_prev}" ]; then
    rm -rf "${BOOT_DIR}/kernel${KERNEL_PREV_SUFFIX}"
    if ! mv "${_klm_prev}" "${BOOT_DIR}/kernel${KERNEL_PREV_SUFFIX}"; then
      log "ERROR: cannot move the preserved kernel ${_klm_prev} to ${BOOT_DIR}/kernel${KERNEL_PREV_SUFFIX}"
      return 1
    fi
    log "INFO: preserved kernel ${_klm_prev} is now ${BOOT_DIR}/kernel${KERNEL_PREV_SUFFIX}"
  fi

  rm -f "${BOOT_DIR}/${_klm_dir}/linker.hints"
  if rmdir "${BOOT_DIR}/${_klm_dir}" 2>/dev/null; then
    log "INFO: empty ${BOOT_DIR}/${_klm_dir} removed"
  elif [ "${XM_PKGBASE}" -eq 1 ]; then
    rm -rf "${BOOT_DIR}/${_klm_dir}"
    log "INFO: ${BOOT_DIR}/${_klm_dir} removed: a FreeBSD ${XM_FROM_MAJOR} kernel cannot run the FreeBSD ${XM_TO_MAJOR} world"
  else
    log "NOTICE: ${BOOT_DIR}/${_klm_dir} is left as is: no package owns it, review and remove by hand"
  fi
  return 0
}

running_kernel_dir() {
  _rkd="$( sysctl -n kern.bootfile 2>/dev/null || true )"
  case "${_rkd}" in
    */*/kernel)
      _rkd="${_rkd%/kernel}"
      printf '%s\n' "${_rkd##*/}"
      ;;
    *) printf '\n' ;;
  esac
}

# Работает ли сейчас ядро из каталога $1 ( имя под BOOT_DIR ). Сверка по строке
# версии: первая строка kern.version лежит в файле ядра дословно ( version[]
# из vers.c ), а по имени каталога не понять - обновление без перезагрузки
# кладёт новое ядро на место работающего. 0 - работает, 1 - нет, 2 - не
# определить ( нет файла ядра или kern.version ).
kernel_dir_is_running() {
  _kdr_file="${BOOT_DIR}/$1/kernel"
  _kdr_ver="$( sysctl -n kern.version 2>/dev/null | head -1 || true )"
  if [ -z "${_kdr_ver}" ] || [ ! -f "${_kdr_file}" ]; then
    return 2
  fi
  grep -qaF -e "${_kdr_ver}" "${_kdr_file}" && return 0
  return 1
}

# Уборка состояния прошлого обновления. Залипший drop-in опаснее его
# отсутствия: следующий плановый ребут уехал бы на прежнее ядро.
normalize_boot_state() {
  [ -f "${BOOT_DROPIN}" ] || return 0
  _nbs_dir="$( loader_kernel_dir )"
  _nbs_run="$( running_kernel_dir )"
  if [ "${_nbs_run}" = "${_nbs_dir}${KERNEL_PREV_SUFFIX}" ]; then
    log "WARNING: node is running the fallback kernel ${_nbs_run} - previous update did not boot"
    log "WARNING: keeping ${BOOT_DROPIN} until the new kernel boots successfully"
    return 0
  fi
  log "INFO: removing stale ${BOOT_DROPIN} ( running kernel: ${_nbs_run:-unknown} )"
  rm -f "${BOOT_DROPIN}"
  rm -f "${BOOT_DIR}/nextboot.conf"
  return 0
}

# Одноразовая загрузка нового ядра с автовозвратом.
#
# Дефолт на время окна обновления уводится на сохранённую копию, а новое ядро
# выбирается через nextboot: загрузчик гасит nextboot ДО загрузки ядра, поэтому
# неудачная попытка на следующем ребуте сама уезжает на дефолт, то есть на
# прежнее ядро. Проверено на стенде: drop-in перекрывает loader.conf, а после
# провалившейся попытки nextboot_enable уже "NO" и следующая загрузка идёт
# с дефолта.
#
# Узел после неудачи стоит на loader-промпте и сам не перезагрузится —
# перезагрузку даёт оператор ( ресет из VMS ).
arm_fallback() {
  _arf_dir="$( loader_kernel_dir )"
  _arf_prev="${_arf_dir}${KERNEL_PREV_SUFFIX}"

  if [ ! -f "${BOOT_DIR}/${_arf_prev}/kernel" ]; then
    log "WARNING: no fallback kernel at ${BOOT_DIR}/${_arf_prev}, one-shot boot not armed"
    return 0
  fi

  mkdir -p "$( dirname "${BOOT_DROPIN}" )"
  {
    printf '# Создан vstackos.update.sh на время окна обновления.\n'
    printf '# Дефолт уведён на прежнее ядро; новое выбирается nextboot одноразово.\n'
    printf '# Снимает %s после успешной загрузки.\n' "${BOOT_MARKER_NAME}"
    printf 'kernel="%s"\n' "${_arf_prev}"
  } > "${BOOT_DROPIN}"
  log "INFO: default kernel temporarily set to ${_arf_prev} via ${BOOT_DROPIN}"

  if command -v nextboot >/dev/null 2>&1; then
    if nextboot -k "${_arf_dir}" >> "${LOG_FILE}" 2>&1; then
      log "INFO: one-shot boot armed for ${_arf_dir} ( fallback on failure: ${_arf_prev} )"
    else
      log "ERROR: nextboot failed for ${_arf_dir}, removing ${BOOT_DROPIN} to keep boot predictable"
      rm -f "${BOOT_DROPIN}"
      return 1
    fi
  else
    log "ERROR: nextboot not found, removing ${BOOT_DROPIN} to keep boot predictable"
    rm -f "${BOOT_DROPIN}"
    return 1
  fi
  sync
  return 0
}

# ВРЕМЕННЫЙ КОСТЫЛЬ. ПЕРЕДЕЛАТЬ НА МЕТАПОРТ УЗЛА, КОГДА ОН ПОЯВИТСЯ.
#
# Маркер «загрузка удалась» — это rc-скрипт, а rc-скрипты приезжают пакетами.
# Пакета-метапорта узла сейчас нет ( тот же долг, что у vmagent выше ), а
# install_overlay работает только на установке, поэтому скрипт кладёт сюда
# сам апдейтер. Запись идемпотентная, содержимое перезаписывается при каждом
# прогоне — то есть обновляется вместе со скриптом обновления.
#
# Как надо: файл принадлежит метапорту состава узла, апдейтер про него не
# знает вовсе, а здесь остаётся только вызов из arm_fallback.
install_boot_marker() {
  _ibm_path="${LOCAL_RC_DIR}/${BOOT_MARKER_NAME}"
  mkdir -p "${LOCAL_RC_DIR}"
  cat > "${_ibm_path}" <<'MARKER'
#!/bin/sh
#
# PROVIDE: vstack_boot_marker
# REQUIRE: LOGIN
# KEYWORD: nojail
#
# Снимает временный drop-in с дефолтным ядром после того, как узел
# действительно загрузился на новом ядре. Если узел работает на прежнем
# ядре ( сработал автовозврат ), drop-in остаётся и об этом шумим.
#
# ВРЕМЕННЫЙ КОСТЫЛЬ: файл кладёт vstackos.update.sh, потому что метапорта
# состава узла ещё нет. Переделать на метапорт.
#

. /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_boot_dir:=/boot}"
: "${vstack_boot_marker_prev_suffix:=_prev}"
# Пусто по умолчанию: «успех» это дошедший до этой точки rc на ожидаемом
# ядре. Если захочется строже — перечислить сервисы через пробел, тогда
# drop-in снимается только при их работающем статусе.
: "${vstack_boot_marker_services:=}"
# Только для тестов: подменить путь работающего ядра.
: "${vstack_boot_marker_bootfile:=}"

vstack_boot_marker_start()
{
	_bm_dropin="${vstack_boot_marker_dropin}"
	[ -f "${_bm_dropin}" ] || return 0

	_bm_default="$( sed -n 's/^[[:space:]]*kernel="\{0,1\}\([^"]*\)"\{0,1\}[[:space:]]*$/\1/p' \
	    "${_bm_dropin}" | tail -1 )"
	case "${_bm_default}" in
	*"${vstack_boot_marker_prev_suffix}") ;;
	*)
		logger -t "${name}" -p daemon.warn \
		    "unexpected default kernel '${_bm_default}' in ${_bm_dropin}, leaving as is"
		return 0
		;;
	esac
	_bm_canon="${_bm_default%${vstack_boot_marker_prev_suffix}}"

	_bm_bootfile="${vstack_boot_marker_bootfile}"
	[ -n "${_bm_bootfile}" ] || _bm_bootfile="$( sysctl -n kern.bootfile 2>/dev/null )"
	_bm_run="${_bm_bootfile%/kernel}"
	_bm_run="${_bm_run##*/}"

	if [ "${_bm_run}" = "${_bm_default}" ]; then
		logger -t "${name}" -p daemon.err \
		    "NODE IS RUNNING THE FALLBACK KERNEL ${_bm_run}: the new kernel did not boot"
		logger -t "${name}" -p daemon.err \
		    "keeping ${_bm_dropin}; check /var/log/vstack-os-update-*.log and report"
		return 0
	fi

	if [ "${_bm_run}" != "${_bm_canon}" ]; then
		logger -t "${name}" -p daemon.warn \
		    "running kernel ${_bm_run} is neither ${_bm_canon} nor ${_bm_default}, leaving ${_bm_dropin}"
		return 0
	fi

	for _bm_svc in ${vstack_boot_marker_services}; do
		if ! service "${_bm_svc}" status > /dev/null 2>&1; then
			logger -t "${name}" -p daemon.err \
			    "service ${_bm_svc} is not running, keeping ${_bm_dropin}"
			return 0
		fi
	done

	rm -f "${_bm_dropin}" "${vstack_boot_marker_boot_dir}/nextboot.conf"
	logger -t "${name}" -p daemon.notice \
	    "boot on ${_bm_run} confirmed, temporary fallback default removed"
	return 0
}

run_rc_command "$1"
MARKER
  chmod 0555 "${_ibm_path}"
  log "INFO: boot marker installed as ${_ibm_path} ( temporary, belongs in the node metaport )"
  return 0
}

if [ "${OPT_BASE}" -eq 1 ]; then
  normalize_boot_state

  if [ "${OPT_NO_KERNEL_BACKUP}" -eq 0 ]; then
    save_prev_kernel || exit 3
  else
    log "INFO: --no-kernel-backup given, the running kernel is not preserved"
  fi

  log "INFO: preparing pkgbase upgrade"
  rm -rf "${BASE_TREE_PREFIX}/include/c++/v1/__string" \
         "${BASE_TREE_PREFIX}/include/c++/v1/__tuple" \
         "${BASE_TREE_PREFIX}/share/nls/POSIX" 2>/dev/null || true

  if [ "${XM_PKGBASE}" -eq 1 ]; then
    log "INFO: base of FreeBSD ${XM_TO_MAJOR} is already in place ( cross-major phase above )"
  else
    log "INFO: base upgrade preview"
    pkg_s upgrade -r "${PRODUCT}" -n 2>&1 | tee -a "${LOG_FILE}" || true
    log "INFO: upgrading pkgbase packages"
    PKG_RC=0
    pkg_s upgrade -r "${PRODUCT}" || PKG_RC=$?
    if [ "${PKG_RC}" -ne 0 ]; then
      log "ERROR: pkg upgrade base failed rc=${PKG_RC}"
      exit 3
    fi
  fi

  log "INFO: rebuilding kld hints"
  rm -f /boot/kernel/linker.hints /boot/kernel.old/linker.hints 2>/dev/null || true
  KLD_RC=0
  kldxref -R /boot/ || KLD_RC=$?
  if [ "${KLD_RC}" -ne 0 ]; then
    log "ERROR: kldxref failed rc=${KLD_RC}, continuing with config restore"
  fi
  log "INFO: pkgbase upgrade done"

  if ! kernel_layout_migrate; then
    log "ERROR: kernel layout switch is incomplete, NOT rebooting"
    exit 3
  fi

  # Проверки до перезагрузки: если новое ядро не на месте, ребут превращает
  # узел в задачу для консоли, поэтому дальше не идём.
  if ! verify_new_kernel; then
    log "ERROR: kernel verification failed, NOT rebooting"
    log "HINT: previous kernel is still available at ${BOOT_DIR}/$( loader_kernel_dir )${KERNEL_PREV_SUFFIX}"
    exit 3
  fi

  if [ "${OPT_NO_ESP}" -eq 0 ]; then
    esp_sync_loader
  else
    log "INFO: --no-esp given, loader on the EFI system partition left as is"
  fi

  if [ "${OPT_NO_FALLBACK}" -eq 0 ]; then
    install_boot_marker
    arm_fallback || log "WARNING: one-shot boot not armed, a failed boot will need the console"
  else
    log "INFO: --no-fallback given, one-shot boot not armed"
  fi
fi

# ==============================================================================
# Сверка состава после смены линии ( TSK0004073 ).
#
# Идёт ПОСЛЕ обеих транзакций и ДО восстановления /etc: пакеты доставляют свои
# конфиги, и восстановление должно идти поверх уже полного состава.
# ==============================================================================
XM_PKG_INSTALLED=0
XM_LOST=""

if [ "${MAJOR_MODE}" -eq 1 ]; then
  log "INFO: cross-major reconciliation: comparing node against ${URL}"
  mkdir -p "${_XM_DIR}"

  _xm_repo="${_XM_DIR}/repo.txt"
  _xm_inst="${_XM_DIR}/installed.txt"
  _xm_rc=0
  pkg_s rquery -r "${PRODUCT}" '%n' 2>/dev/null | sort -u > "${_xm_repo}" || _xm_rc=$?
  pkg_s query '%n' 2>/dev/null | sort -u > "${_xm_inst}" || _xm_rc=$?

  # Для карты переименований нужен состав узла ДО базовой транзакции. Она
  # удаляет пакет прежней линии сама ( одноимённого преемника в целевой
  # репе нет ), и к этому месту в ${_xm_inst} его уже нет - строка карты молча
  # пропускалась, а компонент оставался неустановленным. Наблюдалось на боевой паре
  # СХД 2026-09-28: vStack-ccrd-hapair -> vStack-ccrd не отработало дважды из двух,
  # узел вернулся без кластерного демона.
  XM_NAMES_BEFORE="${_xm_inst}"
  if [ -s "${_NAMES_BEFORE}" ]; then
    XM_NAMES_BEFORE="${_NAMES_BEFORE}"
  else
    log "WARNING: no pre-upgrade package list, rename map falls back to the post-upgrade one"
  fi
  if [ "${_xm_rc}" -ne 0 ] || [ ! -s "${_xm_repo}" ]; then
    log "ERROR: cannot list target repo contents, cross-major reconciliation skipped"
  else
    xm_plan apply

    if [ -n "${_xm_want}" ]; then
      log "INFO: installing:${_xm_want}"
      # Согласование состава ДОБАВЛЯЕТ компоненты и не должно ничего уносить.
      # pkg этого не обещает: пакет целевой линии может конфликтовать с
      # установленным по общему файлу, и тогда вытеснит его. На стенде запись
      # карты `vStack-clucli -> vStack-cluster-cli` увела вместе с собой
      # vStack-ccrd ( общий /usr/local/bin/ccrd_cli ), и узел вернулся из перехода
      # без кластерного демона - без бинаря и без rc-скрипта, молча.
      # Сухим прогоном это не поймать: `pkg install -n` печатает план солвера, а
      # вытеснение по конфликту файлов вскрывается позже, на проверке целостности.
      # Поэтому сверяем состав узла до и после и называем пропавшее вслух.
      _xm_pkg_before="${_XM_DIR}/pkg-before.txt"
      _xm_pkg_after="${_XM_DIR}/pkg-after.txt"
      pkg_s query '%n' 2>/dev/null | sort > "${_xm_pkg_before}" || true
      _xm_inst_rc=0
      # shellcheck disable=SC2086
      pkg_s install -r "${PRODUCT}" ${_xm_want} >> "${LOG_FILE}" 2>&1 || _xm_inst_rc=$?
      if [ "${_xm_inst_rc}" -ne 0 ]; then
        log "ERROR: installing cross-major packages failed rc=${_xm_inst_rc}"
        exit 3
      fi
      pkg_s query '%n' 2>/dev/null | sort > "${_xm_pkg_after}" || true
      _xm_gone="$( comm -23 "${_xm_pkg_before}" "${_xm_pkg_after}" 2>/dev/null | tr '\n' ' ' )"
      if [ -n "${_xm_gone}" ]; then
        log "ERROR: composition reconciliation REMOVED installed packages:${_xm_gone}"
        log "ERROR: adding components must not take existing ones away - the node lost them"
        log "HINT: a rename-map successor conflicting with an installed package displaces it"
        log "HINT: restore what matters before the reboot ( pkg install -r ${PRODUCT} <name> )"
        XM_LOST="${_xm_gone}"
      fi
      XM_INSTALLED="${_xm_want}"
      XM_PKG_INSTALLED=1
      # Компоненты метапорта шли в ту же транзакцию: раз она прошла, они на узле.
      if [ "${OPT_INSTALL_MISSING}" -eq 1 ]; then
        XM_CTRL_MISSING=""
      fi
    else
      log "INFO: nothing to add, node composition matches the target release"
    fi
  fi

  # Доустановка могла принести модули ядра - подсказки пересобираем ещё раз.
  if [ "${XM_PKG_INSTALLED}" -eq 1 ] && [ "${OPT_BASE}" -eq 1 ]; then
    log "INFO: rebuilding kld hints after cross-major installs"
    kldxref -R /boot/ || KLD_RC=$?
  fi
fi

if [ "${XM_PKGBASE}" -eq 1 ]; then
  # Хранилище сертификатов и база сервисов - от новой базы ( как у перехода
  # mgmtVM ): иначе TLS к центру и брокеру проверяется прежним набором корней.
  certctl rehash >> "${LOG_FILE}" 2>&1 || log "WARNING: certctl rehash failed, the trust store is rebuilt after the reboot by hand"
  services_mkdb -q -o /var/db/services.db "${ETC_DIR}/services" >> "${LOG_FILE}" 2>&1 \
    || log "WARNING: services_mkdb failed, /var/db/services.db stays from the previous line"
  if ! xm_repair_missing_files; then
    log "ERROR: files of installed packages are missing - do NOT reboot"
    log "HINT: fix the cause and rerun the same command with --resume"
    exit 3
  fi
fi

_POST_PASSWD="${_BACKUP_DIR}/master.passwd.post-upgrade"
_POST_GROUP="${_BACKUP_DIR}/group.post-upgrade"
cp -p "${ETC_DIR}/master.passwd" "${_POST_PASSWD}" 2>/dev/null || true
cp -p "${ETC_DIR}/group" "${_POST_GROUP}" 2>/dev/null || true

# Возврат «своих» файлов из .pkgsave касается ТОЛЬКО ${ETC_DIR} и
# ${LOCAL_ETC_DIR}. В ${BOOT_DIR} .pkgsave появляется не у конфигов, а у КОДА:
# файлы /boot узла прежней линии не принадлежат ни одному пакету, и установка
# базы откладывает туда ядро, модули и загрузчик целиком. Возврат означал бы
# откат всего /boot на прежний мажор ( на прогоне 2.2 -> 3.2 это 2488 файлов:
# новый userland и ПРЕЖНЕЕ ядро ). Настройки /boot возвращаются ниже поимённо.
if [ "${XM_BASE_PHASE:-0}" -eq 1 ] || [ "${XM_PKGBASE}" -eq 1 ]; then
  # При смене мажора возвращать «свои» файлы поголовно нельзя и в ${ETC_DIR}.
  # То же на узле с pkgbase: копии узла, отложенные транзакцией нового мажора,
  # - это rc.d и прочий код прежней линии ( q903, 3.x -> 4.0 ).
  # На узле прежней линии /etc пришёл дист-сетами и пакетам не принадлежит,
  # поэтому pkg откладывает в .pkgsave ВЕСЬ /etc, включая rc.d - то есть код,
  # а не настройки. Возврат оставлял бы узлу rc-скрипты прежнего мажора рядом
  # с .pkgnew новой линии: rcorder видит и те и другие, а os-release печатает
  # версию прежней линии, потому что номер в нём зашит в сам rc-скрипт.
  # Настройки узла возвращаются ниже поимённо - тем же списком, что и в
  # прод-процедуре перехода, она эти хвосты просто удаляет.
  log "INFO: cross-major: node-owned files are restored by name, pkg leftovers are dropped"
  # Поимённо возвращается только короткий список ( ниже ). Всё прочее, что узел
  # держал в файлах базы ( /etc/ttys, crontab, newsyslog.conf, pam.d ... ),
  # уходит вместе с хвостами - поэтому называем каждый файл, чья копия узла
  # отличалась от новой, и говорим, где лежит полная копия до обновления.
  log "INFO: cross-major: full copy of ${ETC_DIR} and ${LOCAL_ETC_DIR} before the upgrade: ${_BACKUP_DIR}"
  for _xm_leftover_dir in "${ETC_DIR}" "${LOCAL_ETC_DIR}" "${BOOT_DIR}"; do
    [ -d "${_xm_leftover_dir}" ] || continue
    # В ${BOOT_DIR} хвосты - это прежнее ядро и модули ( тысячи файлов ), а не
    # настройки: их поимённо не печатаем, tunables возвращаются отдельно.
    if [ "${_xm_leftover_dir}" != "${BOOT_DIR}" ]; then
      find "${_xm_leftover_dir}" -name '*.pkgsave' 2>/dev/null | sort | while IFS= read -r _xm_ps; do
        cmp -s "${_xm_ps}" "${_xm_ps%.pkgsave}" 2>/dev/null \
          || log "INFO: dropped node copy that differs from the new line: ${_xm_ps%.pkgsave}"
      done
    fi
    _xm_ps_cnt="$( find "${_xm_leftover_dir}" -name '*.pkgsave' 2>/dev/null | wc -l | tr -d ' ' )"
    _xm_pn_cnt="$( find "${_xm_leftover_dir}" -name '*.pkgnew' 2>/dev/null | wc -l | tr -d ' ' )"
    find "${_xm_leftover_dir}" -name '*.pkgsave' -delete 2>/dev/null || true
    find "${_xm_leftover_dir}" -name '*.pkgnew' -delete 2>/dev/null || true
    log "INFO: ${_xm_leftover_dir}: dropped ${_xm_ps_cnt} *.pkgsave and ${_xm_pn_cnt} *.pkgnew of the previous line"
  done
else
  log "INFO: restoring pkg-saved configs in ${ETC_DIR} and ${LOCAL_ETC_DIR}"
  find "${ETC_DIR}" "${LOCAL_ETC_DIR}" -name '*.pkgsave' 2>/dev/null | while IFS= read -r _ps; do
    _orig="${_ps%.pkgsave}"
    if [ -f "${_orig}" ]; then
      mv "${_orig}" "${_orig}.pkgnew"
    fi
    mv "${_ps}" "${_orig}"
    log "INFO: restored ${_orig} (new version kept as ${_orig}.pkgnew)"
  done
fi

if [ "${XM_BASE_PHASE:-0}" -eq 0 ] && [ "${XM_PKGBASE}" -eq 0 ]; then
  _boot_pkgsave_cnt="$( find "${BOOT_DIR}" -name '*.pkgsave' 2>/dev/null | wc -l | tr -d ' ' )"
  if [ "${_boot_pkgsave_cnt:-0}" -gt 0 ]; then
    log "INFO: ${BOOT_DIR}: ${_boot_pkgsave_cnt} previous-line files kept as *.pkgsave ( not restored, see above )"
  fi
fi

log "INFO: restoring /etc protected files from ${_BACKUP_DIR}"
ETC_RC=0

restore_etc_file "${_BACKUP_DIR}/ssh/sshd_config"  "${ETC_DIR}/ssh/sshd_config"  || ETC_RC=1
restore_etc_file "${_BACKUP_DIR}/ssh/ssh_config"   "${ETC_DIR}/ssh/ssh_config"   || ETC_RC=1
for _hk in "${_BACKUP_DIR}/ssh/ssh_host_"*; do
  [ -e "${_hk}" ] || continue
  _hk_base="$( basename "${_hk}" )"
  restore_etc_file "${_hk}" "${ETC_DIR}/ssh/${_hk_base}" || ETC_RC=1
done
merge_etc_db "${_BACKUP_DIR}/master.passwd" "${_POST_PASSWD}" "${ETC_DIR}/master.passwd" "master.passwd" || ETC_RC=1
restore_etc_file "${_BACKUP_DIR}/passwd"        "${ETC_DIR}/passwd"        || ETC_RC=1
merge_etc_db "${_BACKUP_DIR}/group" "${_POST_GROUP}" "${ETC_DIR}/group" "group" || ETC_RC=1
restore_etc_file "${_BACKUP_DIR}/pwd.db"        "${ETC_DIR}/pwd.db"        || ETC_RC=1
restore_etc_file "${_BACKUP_DIR}/spwd.db"       "${ETC_DIR}/spwd.db"       || ETC_RC=1
restore_etc_file "${_BACKUP_DIR}/login.conf"    "${ETC_DIR}/login.conf"    || ETC_RC=1
restore_etc_file "${_BACKUP_DIR}/shells"        "${ETC_DIR}/shells"        || ETC_RC=1
restore_etc_file "${_BACKUP_DIR}/sysctl.conf"   "${ETC_DIR}/sysctl.conf"   || ETC_RC=1
restore_etc_file "${_BACKUP_DIR}/syslog.conf"   "${ETC_DIR}/syslog.conf"   || ETC_RC=1
restore_etc_file "${_BACKUP_DIR}/hosts"         "${ETC_DIR}/hosts"         || ETC_RC=1
restore_etc_file "${_BACKUP_DIR}/ntp.conf"      "${ETC_DIR}/ntp.conf"      || ETC_RC=1

if [ -f "${_VSTACK_ENV_BACKUP}" ]; then
  mkdir -p "$( dirname "${VSTACK_ENV_CONF}" )"
  restore_etc_file "${_VSTACK_ENV_BACKUP}" "${VSTACK_ENV_CONF}" || ETC_RC=1
fi

# Тюнинг /boot возвращаем наравне с /etc. Только перечисленные файлы:
# loader.conf.d сюда НЕ входит ( см. комментарий у бэкапа ).
log "INFO: restoring ${BOOT_DIR} tunables from ${_BOOT_BACKUP}"
for _bf in loader.conf loader.conf.local device.hints; do
  if [ -f "${_BOOT_BACKUP}/${_bf}" ]; then
    if ! cmp -s "${_BOOT_BACKUP}/${_bf}" "${BOOT_DIR}/${_bf}" 2>/dev/null; then
      log "INFO: ${BOOT_DIR}/${_bf} changed during upgrade, restoring node copy ( new version kept as ${_bf}.pkgnew )"
      cp -p "${BOOT_DIR}/${_bf}" "${BOOT_DIR}/${_bf}.pkgnew" 2>/dev/null || true
    fi
    restore_etc_file "${_BOOT_BACKUP}/${_bf}" "${BOOT_DIR}/${_bf}" || ETC_RC=1
  fi
done

if [ "${XM_PKGBASE}" -eq 1 ] && ! xm_disable_unverified_branding; then
  log "ERROR: cannot disable loader branding missing from ${BOOT_DIR}/manifest - the node would stop at the loader prompt"
  ETC_RC=1
fi

if [ "${ETC_RC}" -ne 0 ]; then
  log "ERROR: /etc restore step had failures"
  exit 4
fi

log "INFO: running pwd_mkdb"
if ! pwd_mkdb -p "${ETC_DIR}/master.passwd"; then
  log "ERROR: pwd_mkdb failed"
  exit 4
fi

log "INFO: running cap_mkdb"
if ! cap_mkdb "${ETC_DIR}/login.conf"; then
  log "ERROR: cap_mkdb failed"
  exit 4
fi

# Вход root по ssh ломается тише всего: sshd отбивает его ДО проверки ключа
# ( "User root not allowed because shell ... does not exist" ), узел при этом
# пингуется и внешне здоров. Поэтому проверяем сами и ДО конца работы, пока
# окно доустановить пакет ещё открыто ( TSK0004073 ).
ROOT_SHELL_RC=0
check_root_shell || ROOT_SHELL_RC=1
if [ "${ROOT_SHELL_RC}" -ne 0 ]; then
  log "ERROR: fix the root shell BEFORE rebooting, e.g."
  log "ERROR:   pkg-static install -r ${PRODUCT} ${FREEBSD_PKG_PREFIX}$( basename "$( root_shell )" 2>/dev/null || echo '<shell>' )"
  log "ERROR: or point root at an existing shell with chsh(1)"
fi

if [ "${XM_PKGBASE}" -eq 1 ]; then
  xm_prereboot_checks || XM_PREREBOOT_RC=1
fi

PRELOAD_RC=0
check_loader_preload || PRELOAD_RC=1

# Работает ли то ядро, что загрузится следующим: от этого зависят перевод
# свопа ( geom_eli.ko из каталога ядра ) и перезапуск служб ниже. Каталог -
# без суффикса запасного: взведённый автовозврат подставляет его в
# loader.conf.d. _kp_rc: 0 - работает, 1 - нет ( KERNEL_PENDING ), 2 - не
# определить.
KERNEL_PENDING=0
_kp_dir="$( loader_kernel_dir )"
_kp_rc=0
kernel_dir_is_running "${_kp_dir%${KERNEL_PREV_SUFFIX}}" || _kp_rc=$?
[ "${_kp_rc}" -ne 1 ] || KERNEL_PENDING=1

SWAP_RC=0
swap_encrypt || SWAP_RC=$?

log "INFO: recording installed versions after upgrade"
pkg_s query '%n %v' 2>/dev/null | grep -Ei "^(vstack|${FREEBSD_PKG_PREFIX})" | sort > "${_VER_AFTER}" || true
cat "${_VER_AFTER}" >> "${LOG_FILE}" 2>&1 || true

SVC_RC=0

# Список общий для всех типов узлов, а состав узла - нет: на СХД нет vm-подсистемы,
# на HCP нет san-демона. Раньше любой отсутствующий сервис завершал прогон кодом 5,
# и переход гоняли с --no-restart, то есть вообще без перезапусков ( TSK0004073 ).
svc_installed() {
  [ -x "${BASE_RC_DIR}/$1" ] || [ -x "${LOCAL_RC_DIR}/$1" ]
}

# При смене мажора узел до ребута живёт на НОВОМ userland и ПРЕЖНЕМ ядре:
# бинари новой линии зовут системные вызовы, которых старое ядро не знает, и
# перезапуск контроллеров в этот момент - лотерея ( на стенде обошлось, но это
# не гарантия ). Ребут всё равно обязателен, после него сервисы поднимутся сами.
#
# То же при новом ядре в пределах линии ( DEF0001746 ): пакет ядра заменил
# каталог работающего, его модулей больше нет, и первый же kldload службы
# ( vStackVMctrl грузит nmdm ) падает с ENOENT. KERNEL_PENDING посчитан выше,
# перед переводом свопа.
if [ "${OPT_NO_RESTART}" -eq 0 ] && [ "${OPT_CONTROLLERS}" -eq 1 ] \
   && { [ "${XM_BASE_PHASE:-0}" -eq 1 ] || [ "${XM_PKGBASE}" -eq 1 ]; }; then
  log "NOTICE: cross-major: service restarts skipped - the old kernel cannot run the new userland, services come up after the reboot"
elif [ "${OPT_NO_RESTART}" -eq 0 ] && [ "${OPT_CONTROLLERS}" -eq 1 ] && [ "${KERNEL_PENDING}" -eq 1 ]; then
  log "NOTICE: the kernel in ${BOOT_DIR}/${_kp_dir%${KERNEL_PREV_SUFFIX}} is not the running one - service restarts skipped: modules of the running kernel are gone, services come up after the reboot"
elif [ "${OPT_NO_RESTART}" -eq 0 ] && [ "${OPT_CONTROLLERS}" -eq 1 ]; then
  for _svc in spdk_tgt vstack_san_daemon vstack_nvmf_initiator vstack_net_daemon \
               vStackVMprio vStackVMctrl vStackVMperfdataCPUfeeder \
               vStackStorageCtrl vStackNodeside vStackVMperfdatafeeder; do
    if ! svc_installed "${_svc}"; then
      log "INFO: ${_svc} is not installed on this node, skipping"
      continue
    fi
    log "INFO: restarting ${_svc}"
    SVC_RC=0
    service "${_svc}" restart >> "${LOG_FILE}" 2>&1 || SVC_RC=$?
    if [ "${SVC_RC}" -ne 0 ]; then
      log "ERROR: ${_svc} restart failed rc=${SVC_RC}"
      exit 5
    fi
    service "${_svc}" status >> "${LOG_FILE}" 2>&1 || true
  done
fi

# ==============================================================================
# Сводка ( TSK0004073 ). Раньше её собирал инженер руками по логу; теперь она
# печатается и остаётся файлом рядом с бэкапами, чтобы приложить к прогону.
# ==============================================================================
write_summary() {
  {
    printf 'vStack OS update summary\n'
    printf '  timestamp   : %s\n' "${_TS}"
    printf '  node        : %s\n' "$( hostname 2>/dev/null || echo unknown )"
    printf '  repo        : %s\n' "${URL}"
    printf '  mode        : controllers=%s base=%s cross-major=%s\n' \
      "${OPT_CONTROLLERS}" "${OPT_BASE}" "${MAJOR_MODE}"
    if [ "${MAJOR_MODE}" -eq 1 ] && [ -n "${XM_VSTACK_FROM}" ]; then
      printf '  direction   : vStack %s -> %s\n' "${XM_VSTACK_FROM}" "${XM_VSTACK_TO}"
    fi
    printf '  log         : %s\n' "${LOG_FILE}"
    printf '  backup      : %s\n' "${_BACKUP_DIR}"

    printf '\n-- versions changed --\n'
    if [ -s "${_VER_BEFORE}" ] && [ -s "${_VER_AFTER}" ]; then
      diff -u "${_VER_BEFORE}" "${_VER_AFTER}" | grep -E '^[-+][^-+]' || printf '  (no changes)\n'
    else
      printf '  (version snapshots unavailable)\n'
    fi

    if [ "${MAJOR_MODE}" -eq 1 ]; then
      printf '\n-- packages added by cross-major reconciliation --\n'
      if [ -n "${XM_INSTALLED}" ]; then
        printf '%s\n' "${XM_INSTALLED}" | tr ' ' '\n' | grep -v '^$' | sed 's/^/  /'
      else
        printf '  (none)\n'
      fi
      printf '\n-- installed here, no same-name package in the target release --\n'
      if [ -n "${XM_NO_SUCCESSOR}" ]; then
        printf '%s\n' "${XM_NO_SUCCESSOR}" | grep -v '^$' | sed 's/^/  /'
      else
        printf '  (none)\n'
      fi
      printf '\n-- in the target release, still absent on the node --\n'
      if [ -n "${XM_MISSING}" ]; then
        printf '%s\n' "${XM_MISSING}" | grep -v '^$' | sed 's/^/  /'
      else
        printf '  (none)\n'
      fi
      printf '\n-- components of %s absent on the node --\n' "${XM_METAPORT}"
      if [ -n "${XM_CTRL_MISSING}" ]; then
        printf '%s\n' ${XM_CTRL_MISSING} | sed 's/^/  /'
      else
        printf '  (none)\n'
      fi
      printf '\n-- leftovers of the previous line ( named removed in the rename map ) --\n'
      if [ -n "${XM_LEFTOVERS}" ]; then
        printf '%s\n' ${XM_LEFTOVERS} | sed 's/^/  /'
        printf '  ( their broken dependencies show up in pkg check below; pkg delete after acceptance )\n'
      else
        printf '  (none)\n'
      fi
    fi

    if [ "${XM_PKGBASE}" -eq 1 ]; then
      printf '\n-- pkgbase FreeBSD %s -> %s --\n' "${XM_FROM_MAJOR}" "${XM_TO_MAJOR}"
      printf '  pkg used      : %s\n' "${PKG_BIN}"
      printf '  compat        : %s libraries of FreeBSD %s in %s\n' "${XM_COMPAT_SAVED}" "${XM_FROM_MAJOR}" "${COMPAT_DIR}"
      printf '  old base gone :%s\n' "${XM_OLD_BASE_REMOVED:- none}"
      printf '  stay %s builds:%s\n' "${XM_FROM_MAJOR}" "${XM_PKG_LEFTOVERS:- none}"
      printf '  files repaired:%s\n' "${XM_MISSING_REPAIRED:- none}"
      printf '  branding off  :%s\n' "${XM_BRANDING_OFF:- none}"
      if [ "${XM_PREREBOOT_RC}" -eq 0 ]; then
        printf '  pre-reboot    : OK\n'
      else
        printf '  pre-reboot    : FAILED - see ERROR lines above, do NOT reboot\n'
      fi
    fi

    printf '\n-- config divergences (.pkgnew kept next to the restored file) --\n'
    find "${ETC_DIR}" "${LOCAL_ETC_DIR}" "${BOOT_DIR}" -name '*.pkgnew' 2>/dev/null \
      | sort | sed 's/^/  /' | grep . || printf '  (none)\n'

    if [ -n "${XM_LOST}" ]; then
      printf '\n-- REMOVED by reconciliation ( restore before the reboot ) --\n'
      printf '%s\n' ${XM_LOST} | sed 's/^/  /'
    fi

    printf '\n-- root shell --\n'
    if [ "${ROOT_SHELL_RC}" -eq 0 ]; then
      printf '  OK: %s\n' "$( root_shell )"
    else
      printf '  BROKEN: %s is missing - ssh login as root will be refused\n' "$( root_shell )"
    fi

    printf '\n-- loader preload ( next boot ) --\n'
    printf '  total: %s MiB in %s files, warning above %s MiB\n' \
      "${PRELOAD_TOTAL_MIB:-?}" "${PRELOAD_FILES:-?}" "${PRELOAD_WARN_MIB}"
    printf '  became present during this run:\n'
    if [ -n "${PRELOAD_NEW:-}" ]; then
      printf '%s' "${PRELOAD_NEW}" | sed 's/^/    /'
    else
      printf '    (none)\n'
    fi
    if [ "${PRELOAD_RC:-0}" -ne 0 ]; then
      printf '  WARNING: above the threshold - the loader grows its staging area, some firmware\n'
      printf '  fails to boot; check *_load / *_name in loader.conf* before the reboot\n'
    fi

    printf '\n-- swap ( geli onetime, DEF0001760 ) --\n'
    if [ "${OPT_NO_SWAP_ENCRYPT}" -eq 1 ]; then
      printf '  skipped: --no-swap-encrypt\n'
    elif [ -n "${SWAP_CONVERTED}${SWAP_WIPED}${SWAP_WIPE_LEFT}" ]; then
      printf '  fstab switched :%s\n' "${SWAP_CONVERTED:- none}"
      printf '  on line now    :%s\n' "${SWAP_LIVE:- none}"
      printf '  from next boot :%s\n' "${SWAP_PENDING:- none}"
      if [ -n "${SWAP_PENDING}" ] && [ -n "${SWAP_WHY}" ]; then
        printf '  ( %s )\n' "${SWAP_WHY}"
      fi
      if [ -n "${SWAP_PLAIN_NOW}" ]; then
        printf '  WARNING: unencrypted until the reboot:%s\n' "${SWAP_PLAIN_NOW}"
      fi
      if [ -n "${SWAP_OFF_NOW}" ]; then
        printf '  ERROR: off line until the reboot:%s\n' "${SWAP_OFF_NOW}"
      fi
      if [ -n "${SWAP_WIPED}" ]; then
        printf '  old contents wiped:%s\n' "${SWAP_WIPED}"
      fi
      if [ -n "${SWAP_WIPE_LEFT}" ]; then
        printf '  old contents NOT wiped yet:%s\n' "${SWAP_WIPE_LEFT}"
        printf '  the next update run after the reboot wipes them, or by hand:\n'
        for _ws_dev in ${SWAP_WIPE_LEFT}; do
          printf '    swapoff %s.eli && dd if=/dev/zero of=%s bs=1m; swapon %s.eli\n' \
            "${_ws_dev}" "${_ws_dev}" "${_ws_dev}"
        done
      fi
    else
      printf '  nothing to switch\n'
    fi

    printf '\n-- pkg check -d -a --\n'
    pkg_s check -d -a 2>&1 | sed 's/^/  /' | tail -40 || true

    if [ "${MAJOR_MODE}" -eq 1 ]; then
      printf '\n-- reminders --\n'
      printf '  do NOT run `zpool upgrade`: after the base change ZFS offers new feature\n'
      printf '  flags, enabling them is irreversible and closes the rollback to the old OS.\n'
    fi
  } > "${_SUMMARY_FILE}" 2>&1
  cat "${_SUMMARY_FILE}" | tee -a "${LOG_FILE}"
  log "INFO: summary saved to ${_SUMMARY_FILE}"
}

write_summary

if [ "${KLD_RC}" -ne 0 ]; then
  log "ERROR: update finished with kldxref failure rc=${KLD_RC}, check /boot before reboot"
  exit 3
fi

# Переход доведён: маркер незавершённого перехода сменяется записью для уборки
# после перезагрузки ( xm_finish_after_reboot ). До записи ребута, иначе
# --reboot унёс бы узел с маркером "прервано".
if [ "${XM_PKGBASE}" -eq 1 ] && [ "${XM_PREREBOOT_RC}" -eq 0 ] && [ "${KLD_RC}" -eq 0 ]; then
  {
    printf 'FROM_MAJOR=%s\n' "${XM_FROM_MAJOR}"
    printf 'TO_MAJOR=%s\n' "${XM_TO_MAJOR}"
    printf 'COMPAT_LIST=%s\n' "${_XM_DIR}/compat-libs.list"
  } > "${XM_DONE_MARK}"
  rm -f "${XM_RESUME_MARK}"
fi

if [ "${OPT_BASE}" -eq 1 ]; then
  # reboot(8) и shutdown(8) кэш сбрасывают сами, но ребут может прийти и
  # снаружи ( ansible, оператор ), поэтому фиксируем записи здесь и убеждаемся,
  # что они действительно на диске.
  BOOT_SYNC_RC=0
  sync_boot_durable || BOOT_SYNC_RC=1
  if [ -f "${BOOT_DROPIN}" ]; then
    log "INFO: boot summary: one-shot $( loader_kernel_dir ), default (fallback) $( loader_kernel_dir )${KERNEL_PREV_SUFFIX}"
    log "INFO: if the node does not come back, reset it ( VMS / IPMI ) - it will boot the fallback kernel"
  else
    log "INFO: boot summary: kernel=$( loader_kernel_dir ), no one-shot fallback armed"
  fi
  if [ "${OPT_REBOOT}" -eq 1 ] && [ "${BOOT_SYNC_RC}" -ne 0 ]; then
    log "ERROR: --reboot ignored: ${BOOT_DIR} is not confirmed on disk, a reboot now is exactly the DEF0001553 scenario"
  elif [ "${OPT_REBOOT}" -eq 1 ] && [ "${ROOT_SHELL_RC}" -ne 0 ]; then
    log "ERROR: --reboot ignored: root shell is broken, the node would come back unreachable over ssh"
  elif [ "${OPT_REBOOT}" -eq 1 ] && [ "${XM_PREREBOOT_RC}" -ne 0 ]; then
    log "ERROR: --reboot ignored: pre-reboot checks of the FreeBSD ${XM_TO_MAJOR} world failed"
  elif [ "${OPT_REBOOT}" -eq 1 ]; then
    log "INFO: rebooting system"
    reboot
  else
    log "INFO: reboot required to complete base/kernel update"
    printf 'NOTICE: reboot required\n'
  fi
fi

if [ "${BOOT_SYNC_RC}" -ne 0 ]; then
  log "ERROR: vStack OS update finished, but ${BOOT_DIR} durability is not confirmed - do NOT reboot until checked"
  exit 7
fi

if [ "${ROOT_SHELL_RC}" -ne 0 ]; then
  log "ERROR: vStack OS update finished, but the root shell is broken - fix it before rebooting"
  exit 6
fi

if [ "${XM_PREREBOOT_RC}" -ne 0 ]; then
  log "ERROR: vStack OS update finished, but the FreeBSD ${XM_TO_MAJOR} world failed pre-reboot checks - do NOT reboot"
  exit 3
fi

# Своп не роняет обновление: с перезагрузки он шифрованный в любом случае.
# Код 8 - только когда раздел остался вне свопа до перезагрузки.
if [ "${SWAP_RC}" -eq 2 ]; then
  log "ERROR: vStack OS update finished, but swap is off line until the reboot:${SWAP_OFF_NOW} - see the summary"
  exit 8
elif [ "${SWAP_RC}" -ne 0 ]; then
  log "WARNING: swap: not every swap partition is encrypted and wiped yet - see the summary"
fi

if [ "${XM_BASE_PHASE:-0}" -eq 1 ] || [ "${XM_PKGBASE}" -eq 1 ]; then
  log "NOTICE: cross-major upgrade: the node runs the new userland on the OLD kernel until it reboots"
  log "NOTICE: new ssh and console logins fail until then - reboot from THIS session"
  log "NOTICE: do not restart services before the reboot - binaries of the new line need the new kernel"
fi
if [ "${KERNEL_PENDING}" -eq 1 ] && [ "${OPT_BASE}" -eq 0 ]; then
  log "NOTICE: the node still runs the previous kernel - reboot it"
fi
log "INFO: vStack OS update complete"
exit 0
