#!/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):"
  echo "  --allow-major-upgrade    permit a major transition; without it the run is refused as before"
  echo "  --install-missing        also install every package the target release has and the node lacks"
  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 what would be done, no changes"
  echo "  --no-restart             skip service restarts"
  echo "  --no-esp                 do not sync the loader to the EFI system partition"
  echo "  --no-kernel-backup       do not keep a copy of the running kernel"
  echo "  --no-fallback            do not arm one-shot boot of the new kernel"
  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}"
# Нижняя граница правдоподобного loader.efi: в парке он 650-750 КБ. Нужна не для
# точности, а чтобы на ESP заведомо не уехал обрезок ( см. is_efi_app ).
LOADER_MIN_SIZE="${LOADER_MIN_SIZE:-131072}"
# 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}"

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_ALLOW_MAJOR: флаг может быть передан и на обычном
# обновлении внутри линии - тогда межмажорные шаги не нужны.
MAJOR_MODE=0
BOOT_SYNC_RC=0
XM_VSTACK_FROM=""
XM_VSTACK_TO=""
XM_INSTALLED=""
XM_NO_SUCCESSOR=""
XM_MISSING=""
ROOT_SHELL_RC=0

# Пакеты, без которых узел после смены линии теряет управляемость. Оболочка
# 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}"

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 ;;
    --allow-major-upgrade) OPT_ALLOW_MAJOR=1; shift ;;
    --install-missing)     OPT_INSTALL_MISSING=1; shift ;;
    --rename-map)          OPT_RENAME_MAP="$2"; shift 2 ;;
    -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"

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_s() {
  if [ -n "${OPT_ABI}" ]; then
    pkg-static -o ABI="${OPT_ABI}" "$@"
  else
    pkg-static "$@"
  fi
}

ASSUME_ALWAYS_YES=yes
IGNORE_OSVERSION=yes
export ASSUME_ALWAYS_YES IGNORE_OSVERSION

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

check_remote_url() {
  _cr_url="$1"
  fetch -sqo /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
  case "${_wr_name}" in
    vstack-os|vstack-os-fstec)
      cat >> "${_wr_out}" <<'EOFFBSD'

FreeBSD: {
  enabled: no
}
EOFFBSD
      ;;
  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}"
}

# ==============================================================================
# Переход между 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, когда базы ещё пересобираются.
root_shell() {
  awk -F: '$1 == "root" { print $10; exit }' "${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 )"
  [ -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 в раздаче.
# Карты может не быть ( старые раздачи ) - тогда работает только отчёт о
# пакетах без одноимённого преемника.
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

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"
  write_repo_conf "${PRODUCT}" "${URL}" "${SIG_TYPE}" "${_dr_dir}"
  pkg_s -o REPOS_DIR="${_dr_dir}" update 2>&1 | tee -a "${LOG_FILE}" || true
  pkg_s -o REPOS_DIR="${_dr_dir}" upgrade -r "${PRODUCT}" -n 2>&1 | tee -a "${LOG_FILE}" || true
  rm -rf "${_dr_dir}"
  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}"

if [ "${OPT_BASE}" -eq 1 ]; 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 )"
  if [ "${MAJOR_MODE}" -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

# 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

_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}"

PKG_RC=0
KLD_RC=0

# =============================================================================
# Смена мажора: базу СТАВИМ до контроллеров.
#
# На узле линии 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
    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
    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 ).
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}"
}

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_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
  if [ -n "${_vnk_pkg_ver}" ] && command -v strings >/dev/null 2>&1; then
    _vnk_ident="$( strings -a "${_vnk_path}" 2>/dev/null | grep -m1 '^FreeBSD [0-9]' || true )"
    log "INFO: kernel ident: ${_vnk_ident:-unknown}"
    _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}"
}

# Устройство и путь загрузчика, с которого реально грузится узел.
# efibootmgr -v во второй строке варианта печатает "daNpM:/EFI/BOOT/BOOTX64.EFI".
esp_device() {
  _esp_cur="$( efibootmgr 2>/dev/null | awk '/^BootCurrent:/ {print $2}' )"
  if [ -n "${_esp_cur}" ]; then
    _esp_dev="$( 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 }' )"
    if [ -n "${_esp_dev}" ]; then
      printf '%s\n' "${_esp_dev}"
      return 0
    fi
  fi
  # Запасной путь: раздел типа efi на том же диске, где корень.
  _esp_root="$( mount | awk '$3 == "/" {print $1}' | sed 's|^/dev/||' )"
  _esp_disk="$( printf '%s\n' "${_esp_root}" | sed -E 's/p[0-9]+$//' )"
  [ -n "${_esp_disk}" ] || return 1
  gpart show -p "${_esp_disk}" 2>/dev/null | awk '$4 == "efi" {print $3; exit}'
}

# Копия свежего загрузчика на ESP. Идемпотентно: сверяет sha256 и трогает
# файл только при расхождении, прежний оставляет рядом как .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_mp=""
  _esl_mounted_here=0
  if mount | grep -q " ${BOOT_DIR}/efi "; then
    _esl_mp="${BOOT_DIR}/efi"
    log "INFO: ESP already mounted at ${_esl_mp}"
  else
    _esl_dev="$( esp_device || true )"
    if [ -z "${_esl_dev}" ]; then
      log "INFO: no EFI system partition found ( not a UEFI install? ), ESP sync skipped"
      return 0
    fi
    _esl_mp="$( mktemp -d /tmp/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 ESP stays old"
      rmdir "${_esl_mp}" 2>/dev/null || true
      return 0
    fi
    _esl_mounted_here=1
    log "INFO: ESP /dev/${_esl_dev} mounted at ${_esl_mp}"
  fi

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

  sync
  if [ "${_esl_mounted_here}" -eq 1 ]; then
    umount "${_esl_mp}" 2>/dev/null || log "WARNING: cannot unmount ${_esl_mp}"
    rmdir "${_esl_mp}" 2>/dev/null || true
  fi
  return 0
}

# Каталог ядра, на котором узел работает ПРЯМО СЕЙЧАС: kern.bootfile отдаёт
# полный путь вида /boot/kernel.GENERIC_PROD/kernel.
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
}

# Уборка состояния прошлого обновления. Залипший 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 '# Создан vStack.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 остаётся и об этом шумим.
#
# ВРЕМЕННЫЙ КОСТЫЛЬ: файл кладёт vStack.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

  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

  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 ! 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
    # Установить - только то, что названо явно. Полный список репы содержит
    # src/tests/debug-наборы, и тянуть его целиком без спроса нельзя.
    XM_MISSING="$( comm -23 "${_xm_repo}" "${_xm_inst}" | grep "^${FREEBSD_PKG_PREFIX}" || true )"
    XM_NO_SUCCESSOR="$( comm -13 "${_xm_repo}" "${_xm_inst}" || true )"

    _xm_want=""
    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 не приехал.
    if _xm_shell_pkg="$( root_shell_pkg )"; 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 )"
    fi

    # Переименования из карты раздачи: old -> new ставим, old -> removed только
    # называем ( снос компонента прежней линии - решение оператора, не скрипта ).
    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"
          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

    if [ "${OPT_INSTALL_MISSING}" -eq 1 ] && [ -n "${XM_MISSING}" ]; 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 [ -n "${_xm_want}" ]; then
      if [ "${OPT_DRY_RUN}" -eq 1 ]; then
        log "INFO: dry-run, would install:${_xm_want}"
      else
        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
      fi
    else
      log "INFO: nothing to add, node composition matches the target release"
    fi

    if [ -n "${XM_MISSING}" ]; 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
  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

_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 ]; then
  # При смене мажора возвращать «свои» файлы поголовно нельзя и в ${ETC_DIR}.
  # На узле прежней линии /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 ]; 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 [ "${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

log "INFO: recording installed versions after upgrade"
pkg-static 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 и ПРЕЖНЕМ ядре:
# бинари новой линии зовут системные вызовы, которых старое ядро не знает, и
# перезапуск контроллеров в этот момент - лотерея ( на стенде обошлось, но это
# не гарантия ). Ребут всё равно обязателен, после него сервисы поднимутся сами.
if [ "${OPT_NO_RESTART}" -eq 0 ] && [ "${OPT_CONTROLLERS}" -eq 1 ] && [ "${XM_BASE_PHASE:-0}" -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 ]; 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
    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-- pkg check -d -a --\n'
    pkg-static 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

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 ]; 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_BASE_PHASE:-0}" -eq 1 ]; then
  log "NOTICE: cross-major upgrade: the node runs the new userland on the OLD kernel until it reboots"
  log "NOTICE: do not restart services before the reboot - binaries of the new line need the new kernel"
fi
log "INFO: vStack OS update complete"
exit 0
