#!/usr/bin/env sh
#
# NetPulse — установка й обслуговування однією командою.
#
# Чому це існує. Розгортання складалося з вісімнадцяти змінних оточення
# й шестисот рядків інструкції. Рішень людини серед тих вісімнадцяти —
# п'ять; решта це або секрети, які не можна давати вводити руками, або
# значення, у яких є одна правильна відповідь, або PG_SHARED_BUFFERS,
# який мусить рахуватись із пам'яті машини: помилка в ньому на хості з
# 4 ГБ — це не «повільніше», це «Postgres не піднявся».
#
# Процедуру, яку клієнт не може виконати правильно з першого разу, не
# рятує докладніша інструкція. Її рятує те, що виконувати нічого.
#
#   ./netpulse install     поставити (ідемпотентно: можна повторювати)
#   ./netpulse check       лікар: ті самі твердження на живій системі
#   ./netpulse sandbox     та сама установка з нуля, але в ізоляції
#   ./netpulse backup      дамп бази + ключі, без яких дамп марний
#   ./netpulse restore -f  відновлення з рамкою TimescaleDB
#   ./netpulse upgrade     перезбирання, міграції, перевірка
#   ./netpulse logs [service]
#
# Головне про install — останній крок. Установник НЕ МАЄ ПРАВА сказати
# «готово» на підставі того, що контейнери піднялись. Рівно так уже
# було: усі служби «Started», а вхід у систему повертав 403, бо під
# новою роллю база віддавала нуль кабінетів. Тому останнє, що робить
# install, — заходить у систему справжнім паролем через HTTP і звіряє,
# що кабінет назвався, а переліки, які наливають міграції, не порожні.
# Не пройшло — не «готово з попередженням», а зупинка з назвою кроку.
#
# І те саме твердження про сам установник. «Нова інсталяція піднімається
# сама» довго було доведене міркуванням: сухий прогін проходив, окремі
# кроки перевірялись на живій базі, а повної установки з нуля не робив
# ніхто — бо ніде. Саме на цьому класі помилки проєкт уже обпікся
# (HISTORY.md, «Перехід на роль без BYPASSRLS»): перевірка була
# ретельна, зелена й дивилась повз поломку. Тому є `sandbox` — та сама
# установка, той самий compose-файл, та сама самоперевірка, але в
# окремому просторі імен, зі зсунутими портами, заданими числами
# ресурсами й гарантованим прибиранням. Не імітація: або справді
# піднімає стек, або чесно каже, що не може.
#
# POSIX sh, не bash: сервер клієнта може бути будь-яким, і dash тут
# зустрічається частіше, ніж здається.

set -u

# CDPATH= — не помилка з пробілом, а гасіння CDPATH: якщо він виставлений
# в оточенні (а на робочих машинах буває), cd мовчки піде не туди, і скрипт
# правитиме .env у чужому каталозі.
# shellcheck disable=SC1007
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
ENV_FILE="$ROOT/.env"
CONF_FILE="$ROOT/netpulse.conf"

# Порт API всередині мережі docker. Самоперевірка ходить саме сюди, а не
# крізь проксі: нас цікавить ланцюг «HTTP → автентифікація → база», і
# додавати до нього TLS і DNS означало б плутати дві різні поломки.
API_INTERNAL="http://127.0.0.1:8080"

DRY=0
ASSUME_YES=0
OWNER_PASSWORD=""
RESTORE_FILE=""

# --- пісочниця -------------------------------------------------------
#
# SANDBOX=1 міняє рівно чотири речі: ім'я проєкту compose, файл .env,
# накладку на compose-файл і те, звідки беруться цифри ресурсів. Усе
# інше — ті самі функції, у тому самому порядку. Перелік тримається
# коротким свідомо: кожна зайва розбіжність між пісочницею й установкою
# — це рядок, який у пісочниці перевірено, а на клієнті ні.
SANDBOX=0
SB_PROJECT=netpulse-sandbox
SB_OVERLAY=deploy/docker-compose.sandbox.yml
SB_ENV="$ROOT/.env.sandbox"
SB_HTTP=18080
SB_HTTPS=18081
SB_GRPC=18082
SB_KEEP=0
SB_ALONGSIDE=0
# Чи встигли ми щось запустити. Від цього залежить, чи прибирати за
# собою при обриві: до першого `up` прибирати нема чого, а після нього
# треба обов'язково.
SB_STARTED=0
SB_TORN=0
# Пароль власника пісочниці лежить у .env.sandbox відкритим. У бойовій
# установці це було б неприпустимо, тут — навпаки: пісочниця стоїть на
# 127.0.0.1, живе години й видаляється цілком, а можливість повторити
# ПОВНУ самоперевірку (`./netpulse sandbox check`) без пароля коштувала б
# половини сенсу — та частина, що перевіряє вхід, просто мовчки
# пропускалась би.
SB_PW_KEY=NETPULSE_SANDBOX_OWNER_PW
SB_OWNER_PW=""

# Заповнюється кроками; підсумок друкується один раз у кінці.
OUT_URL=""
OUT_OWNER_PW=""
OUT_ENROLL=""
OUT_OWNER_EXISTED=0

# ---------------------------------------------------------------------
# Вивід
# ---------------------------------------------------------------------
#
# Кольори лише в терміналі: у `./netpulse check > лист.txt`, який
# клієнт надсилає в підтримку, escape-послідовності перетворюють
# зрозумілий звіт на кашу.

if [ -t 1 ]; then
  C_B=$(printf '\033[1m'); C_R=$(printf '\033[31m')
  C_Y=$(printf '\033[33m'); C_G=$(printf '\033[32m'); C_0=$(printf '\033[0m')
else
  C_B=''; C_R=''; C_Y=''; C_G=''; C_0=''
fi

STEP_NAME=""
STEP_NO=0

say()  { printf '%s\n' "$*"; }
ok()   { printf '  %sok%s   %s\n' "$C_G" "$C_0" "$*"; }
warn() { printf '  %sувага%s %s\n' "$C_Y" "$C_0" "$*"; }
bad()  { printf '  %sні%s   %s\n' "$C_R" "$C_0" "$*"; }

step() {
  STEP_NO=$((STEP_NO + 1))
  STEP_NAME=$1
  printf '\n%s== крок %d · %s%s\n' "$C_B" "$STEP_NO" "$STEP_NAME" "$C_0"
}

# die зупиняє все й називає КРОК. Назва кроку тут не оздоба: людина
# читає її вголос у телефон, і від неї залежить, чи розбирається далі
# вона сама, чи надсилає вивід.
die() {
  printf '\n%sЗУПИНКА на кроці «%s»%s\n' "$C_R" "$STEP_NAME" "$C_0"
  for _l in "$@"; do printf '  %s\n' "$_l"; done
  if [ "${CMD:-}" = install ] && [ "$SANDBOX" -eq 0 ]; then
    printf '\n  Нічого незворотного не сталося: install можна запускати повторно —\n'
    printf '  наявні секрети він підхопить із .env, а не перевипустить.\n'
  fi
  # Провал у пісочниці — це теж обрив, і залишена після нього база на
  # 200 МБ та контейнер, що тримає порт, шкодять рівно так само, як
  # після Ctrl-C. Прибирання йде тут, а не в кінці cmd_install, бо до
  # кінця ми в цьому разі не дійшли.
  sandbox_teardown_on_abort "установка в пісочниці не пройшла"
  exit 1
}

# ---------------------------------------------------------------------
# docker compose
# ---------------------------------------------------------------------
#
# Дві несумісні реалізації з однаковою назвою. Обгортка визначається
# один раз, щоб решта скрипта не знала, яка з них тут.

DC_KIND=""

pick_compose() {
  if docker compose version >/dev/null 2>&1; then
    DC_KIND="plugin"
  elif command -v docker-compose >/dev/null 2>&1; then
    DC_KIND="legacy"
  else
    DC_KIND=""
  fi
}

# Глобальні прапорці пісочниці. Порядок важливий лише в одному: усі
# вони мусять стояти ПЕРЕД підкомандою, тому й зібрані в одну змінну, а
# не дописуються по місцях виклику.
#
#   -p            інше ім'я проєкту → інші контейнери, мережа й ТОМИ.
#                 Саме томи тут головні: без окремого імені `down -v`
#                 у пісочниці знищив би базу бойової інсталяції.
#   -f -f         базовий файл плюс накладка. Базовий саме той, що поїде
#                 клієнту, — інакше перевірка нічого не доводила б.
#   --env-file    свій .env. Compose при цьому НЕ читає звичайний .env,
#                 тобто бойові секрети в пісочницю не потрапляють, а
#                 бойовий файл не переписується.
# Гілки розписані повністю, а не складені з рядка прапорців. Рядок
# довелося б розбивати на слова без лапок, і шлях до .env з пробілом
# (на робочих машинах буває) розвалив би команду — причому не з
# помилкою «пробіл у шляху», а з «файл не знайдено», тобто вказавши не
# туди. Дублювання тут дешевше за цю годину.
#
# legacy-гілка лишається без прапорців пісочниці свідомо:
# docker-compose v1 не вміє того, що потрібно накладці, і
# sandbox_compose_check не пускає пісочницю далі за передпольотну
# перевірку.
dc() {
  case "$DC_KIND" in
    plugin)
      if [ "$SANDBOX" -eq 1 ]; then
        ( cd "$ROOT" && docker compose \
            -p "$SB_PROJECT" \
            -f docker-compose.yml \
            -f "$SB_OVERLAY" \
            --env-file "$SB_ENV" "$@" )
      else
        ( cd "$ROOT" && docker compose "$@" )
      fi
      ;;
    legacy) ( cd "$ROOT" && docker-compose "$@" ) ;;
    *)      return 127 ;;
  esac
}

# ---------------------------------------------------------------------
# Дрібні помічники
# ---------------------------------------------------------------------

have() { command -v "$1" >/dev/null 2>&1; }

# Версія у вигляді числа, щоб порівнювати без sort -V (його немає в
# busybox-середовищах, а установник має працювати й там).
ver_num() {
  printf '%s' "$1" | sed 's/^[vV]//; s/[^0-9.].*//' |
    awk -F. '{ printf "%d%03d%03d", $1, $2, $3 }'
}

ver_ge() { [ "$(ver_num "$1")" -ge "$(ver_num "$2")" ] 2>/dev/null; }

# Значення з .env. Береться ОСТАННЄ входження: саме так читає docker
# compose, і розходження тут означало б, що установник і compose бачать
# різні паролі.
env_get() {
  [ -f "$ENV_FILE" ] || return 1
  sed -n "s/^$1=//p" "$ENV_FILE" | tail -1
}

# Рядкове поле JSON. Ключ обов'язково після «{» або «,», інакше пошук
# "token" знаходить хвіст "access_token" і повертає не той рядок.
json_str() {
  sed -n "s/.*[{,][[:space:]]*\"$1\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" |
    head -1
}

# Чи є у відповіді хоч один елемент переліку.
#
# Рахувати елементи в sh дорого й крихко, а нам потрібне рівно одне
# твердження: «перелік не порожній». Після прибирання пробілів масив
# об'єктів завжди починається з «[{», а порожній — з «[]». Працює і для
# голого масиву, і для загорнутого в об'єкт, тобто не залежить від
# того, як саме назвали поле.
json_nonempty() {
  printf '%s' "$1" | tr -d ' \n\r\t' | grep -q '\[{'
}

# Секрети — ТІЛЬКИ hex.
#
# `openssl rand -base64 24` дає символи «/», «+» і «=». Пароль їде
# всередині DSN виду postgres://user:пароль@db, де «/» починає ім'я
# бази: пароль обривається мовчки, а помилка приходить як
# «password authentication failed» — тобто вказує не туди. Ця вада вже
# ловилась у цьому проєкті; hex не має жодного символу, який щось
# означає в URL, і коштує нам лише довжини рядка.
rand_hex() {
  if have openssl; then
    openssl rand -hex "$1"
  elif [ -r /dev/urandom ]; then
    od -An -tx1 -N "$1" /dev/urandom | tr -d ' \n'
  else
    return 1
  fi
}

# Пароль для людини: його читають з екрана й набирають у браузері.
# Алфавіт без 0/O/1/l/I — не з ввічливості, а тому що переплутана
# літера тут виглядає як «пароль не підходить», і розбирають це годину.
rand_pass() {
  if [ -r /dev/urandom ]; then
    LC_ALL=C tr -dc 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789' \
      < /dev/urandom 2>/dev/null | head -c 24
    printf '\n'
  else
    return 1
  fi
}

# ---------------------------------------------------------------------
# Стан хоста
# ---------------------------------------------------------------------

host_mem_mb() {
  if [ -r /proc/meminfo ]; then
    # END-гілка: /proc/meminfo без очікуваного рядка існує, і без неї
    # функція повернула б порожньо замість нуля — а всі викликачі
    # порівнюють результат як число.
    awk '/^MemTotal:/ { printf "%d", $2 / 1024; f = 1; exit }
         END { if (!f) printf "0" }' /proc/meminfo
  elif have sysctl && sysctl -n hw.memsize >/dev/null 2>&1; then
    sysctl -n hw.memsize | awk '{ printf "%d", $1 / 1048576 }'
  else
    printf '0'
  fi
}

# Пам'ять, яку РЕАЛЬНО можна взяти зараз, а не вся встановлена.
#
# Для пісочниці має значення рівно ця цифра. На машині, де вже працює
# бойовий стек, MemTotal каже «4096» і не має жодного стосунку до
# правди: з них зайнято три з половиною. Помилка тут — це не «пісочниця
# повільна», це «клієнтський моніторинг ліг, поки ми його перевіряли».
#
# MemAvailable, а не MemFree: ядро враховує кеш сторінок, який віддасть
# без бою, і MemFree на живій машині майже завжди виглядає катастрофою
# там, де все гаразд. Нуль означає «не знаємо» — викликач мусить
# вирішити сам, а не вдавати, що пам'яті немає.
#
# END-гілка обов'язкова, а не про всяк випадок: /proc/meminfo без рядка
# MemAvailable існує (старі ядра, емуляція /proc у Git Bash), і без неї
# функція повертає ПОРОЖНЬО, а не нуль. Порожнє значення далі
# перетворюється на «[: : integer expression expected» — тобто перевірка
# ресурсів мовчки зникає рівно там, де вона єдина.
host_mem_avail_mb() {
  if [ -r /proc/meminfo ]; then
    awk '/^MemAvailable:/ { printf "%d", $2 / 1024; f = 1; exit }
         END { if (!f) printf "0" }' /proc/meminfo
  else
    printf '0'
  fi
}

# Кількість ядер. nproc є не всюди (busybox його не має), тому далі
# /proc/cpuinfo і sysctl. Нуль означає «не знаємо» — і викликач має
# обрати обережне значення, а не вдавати, що ядро одне.
host_cpus() {
  if have nproc; then
    nproc 2>/dev/null || printf '0'
  elif [ -r /proc/cpuinfo ]; then
    awk '/^processor/ { c++ } END { printf "%d", c }' /proc/cpuinfo
  elif have sysctl && sysctl -n hw.ncpu >/dev/null 2>&1; then
    sysctl -n hw.ncpu
  else
    printf '0'
  fi
}

# Вільне місце в МБ для каталогу. Питаємо і про репозиторій, і про
# /var/lib/docker: томи бази лежать там, і на розрізаному диску це
# різні файлові системи.
free_mb() {
  df -Pk "$1" 2>/dev/null | awk 'NR==2 { printf "%d", $4 / 1024 }'
}

# Чи слухає хтось порт. Три способи, бо ss немає на старих системах,
# netstat викинули з нових, а /proc/net є на будь-якому Linux.
port_busy() {
  _p=$1
  _proto=${2:-tcp}
  if have ss; then
    if [ "$_proto" = udp ]; then
      ss -lunH 2>/dev/null | awk '{print $5}' | grep -q "[:.]$_p\$" && return 0
    else
      ss -ltnH 2>/dev/null | awk '{print $4}' | grep -q "[:.]$_p\$" && return 0
    fi
    return 1
  fi
  if have netstat; then
    if [ "$_proto" = udp ]; then
      netstat -lun 2>/dev/null | awk '{print $4}' | grep -q "[:.]$_p\$" && return 0
    else
      netstat -ltn 2>/dev/null | awk '{print $4}' | grep -q "[:.]$_p\$" && return 0
    fi
    return 1
  fi
  _hex=$(printf ':%04X' "$_p")
  for _f in "/proc/net/$_proto" "/proc/net/${_proto}6"; do
    [ -r "$_f" ] || continue
    if awk -v p="$_hex" -v pr="$_proto" '
         NR > 1 && substr($2, length($2) - 4) == p &&
         (pr == "udp" || $4 == "0A") { found = 1 }
         END { exit !found }' "$_f"; then
      return 0
    fi
  done
  return 1
}

host_tz() {
  if [ -r /etc/timezone ]; then
    tr -d ' \n\r' < /etc/timezone
  elif [ -L /etc/localtime ]; then
    readlink /etc/localtime | sed 's|.*/zoneinfo/||'
  else
    printf ''
  fi
}

# Адреса, якою цю машину видно ззовні. Не питаємо інтернет: установник
# не має права ходити в мережу за спиною того, хто його запустив, а на
# закритому контурі такий запит ще й повисне на хвилину.
host_ip() {
  if have ip; then
    _a=$(ip route get 1.1.1.1 2>/dev/null | sed -n 's/.*src \([0-9.]*\).*/\1/p' | head -1)
    [ -n "$_a" ] && { printf '%s' "$_a"; return 0; }
  fi
  if have hostname; then
    _a=$(hostname -I 2>/dev/null | awk '{print $1}')
    [ -n "$_a" ] && { printf '%s' "$_a"; return 0; }
  fi
  if have ifconfig; then
    ifconfig 2>/dev/null |
      sed -n 's/.*inet \(addr:\)\{0,1\}\([0-9.]*\).*/\2/p' |
      grep -v '^127\.' | head -1
    return 0
  fi
  printf ''
}

# ---------------------------------------------------------------------
# КРОК 1. Передпольотна перевірка
# ---------------------------------------------------------------------
#
# Усе, що тут перевіряється, ламається пізніше і дорожче: без місця на
# диску Postgres падає посеред міграцій, без пам'яті не піднімається
# зовсім, а зайнятий 443 виявляється тоді, коли все інше вже працює й
# треба розбирати, чому не відкривається сторінка.

preflight() {
  step "передпольотна перевірка"

  _fatal=0

  if ! have docker; then
    _fatal=1
    bad "docker не знайдено"
  else
    _dv=$(docker version --format '{{.Server.Version}}' 2>/dev/null)
    if [ -z "$_dv" ]; then
      _fatal=1
      bad "docker є, але демон не відповідає (docker info падає)"
    elif ver_ge "$_dv" 20.10; then
      ok "docker $_dv"
    else
      _fatal=1
      bad "docker $_dv — потрібен 20.10 або новіший"
    fi
  fi

  pick_compose
  case "$DC_KIND" in
    plugin)
      _cv=$(docker compose version --short 2>/dev/null)
      if ver_ge "$_cv" 2.0; then
        ok "docker compose $_cv"
      else
        _fatal=1
        bad "docker compose $_cv — потрібен 2.0 або новіший"
      fi
      ;;
    legacy)
      _fatal=1
      bad "знайдено лише docker-compose v1; compose-файл проєкту використовує
       можливості v2 (profiles, depends_on.condition). Потрібен пакет
       docker-compose-plugin"
      ;;
    *)
      _fatal=1
      bad "docker compose не знайдено"
      ;;
  esac

  # У пісочниці і питання інші, і поріг інший: там нас цікавить не
  # «чи потягне ця машина NetPulse», а «чи лишилось на ній стільки, щоб
  # запустити ДРУГИЙ стек і не покласти перший». Тому окрема гілка, а не
  # ще один if усередині спільної.
  if [ "$SANDBOX" -eq 1 ]; then
    sandbox_compose_check   || _fatal=1
    sandbox_resources_check || _fatal=1
    sandbox_ports_check     || _fatal=1
    if [ "$_fatal" -ne 0 ]; then
      if [ "$DRY" -eq 1 ]; then
        warn "у сухому прогоні це не зупиняє — на справжньому запуску зупинило б"
      else
        die "Пісочницю на цій машині зараз запускати не можна." \
            "Перелічене вище — не поради, а причини, з яких запуск поклав би" \
            "або пісочницю, або те, що вже працює поруч."
      fi
    fi
    return 0
  fi

  _mem=$(host_mem_mb)
  if [ "$_mem" -eq 0 ]; then
    warn "не вдалося визначити обсяг пам'яті — розрахунок shared_buffers буде обережним"
  elif [ "$_mem" -lt 2048 ]; then
    _fatal=1
    bad "пам'яті $_mem МБ. Postgres, TimescaleDB, кеш, API і колектор на
       такій машині не вміщуються: перший запис у базу впаде на OOM.
       Мінімум — 2 ГБ, робоче значення — 4 ГБ"
  elif [ "$_mem" -lt 4096 ]; then
    warn "пам'яті $_mem МБ — вистачить на десятки хостів, не на сотні"
  else
    ok "пам'ять: $_mem МБ"
  fi

  _free=$(free_mb "$ROOT")
  [ -n "$_free" ] || _free=0
  if [ -d /var/lib/docker ]; then
    _freed=$(free_mb /var/lib/docker)
    [ -n "$_freed" ] || _freed=0
    [ "$_freed" -lt "$_free" ] && _free=$_freed
  fi
  if [ "$_free" -eq 0 ]; then
    warn "не вдалося виміряти вільне місце"
  elif [ "$_free" -lt 20480 ]; then
    _fatal=1
    bad "вільно $_free МБ. Образи займають близько 2 ГБ, і це разова
       витрата; решту з'їдає телеметрія, яка росте щодня. Нижче 20 ГБ
       установка закінчиться переповненим томом за тиждень"
  elif [ "$_free" -lt 51200 ]; then
    warn "вільно $_free МБ — стартувати вистачить, але профіль зберігання
       беріть economy й дивіться на сторінку «Сховище»"
  else
    ok "вільно на диску: $_free МБ"
  fi

  # Порти, зайняті НАШИМИ ж контейнерами, — не конфлікт, а повторний
  # запуск. Розрізняти обов'язково, інакше ідемпотентність втрачається
  # на другому ж install.
  if dc ps --services --filter status=running 2>/dev/null | grep -q '^proxy$'; then
    ok "порти 80/443/9443 тримає вже піднятий proxy цієї ж інсталяції"
  else
    _busy=0
    for _port in 80 443 9443; do
      if port_busy "$_port" tcp; then
        _fatal=1
        _busy=1
        # Причина в 80/443 і в 9443 різна, тож і підказка різна:
        # порада «пошукайте nginx» на порті колектора відправляє
        # людину не туди, а це той самий сорт впевненої неправди, з
        # якого починається загублена година.
        case "$_port" in
          9443)
            bad "порт 9443/tcp уже зайнятий. Це порт колектора зондів.
       Найімовірніше тут уже працює інший NetPulse — перевірте
       docker ps. Якщо це справді друга інсталяція, ставте її в
       окремий каталог і змініть порт у docker-compose.yml" ;;
          *)
            bad "порт $_port/tcp уже зайнятий. Це майже завжди сторонній
       веб-сервер (nginx, apache) — його треба зупинити або перенести,
       бо проксі NetPulse займає 80 і 443 цілком" ;;
        esac
      fi
    done
    if [ "$_busy" -eq 0 ]; then
      ok "порти 80, 443, 9443 вільні"
    fi
  fi

  if [ "$_fatal" -ne 0 ]; then
    if [ "$DRY" -eq 1 ]; then
      warn "у сухому прогоні це не зупиняє — на справжній установці зупинило б"
    else
      die "Перелічене вище треба виправити до установки."
    fi
  fi
}

# ---------------------------------------------------------------------
# КРОК 2. Відповіді
# ---------------------------------------------------------------------

CFG_DOMAIN=""
CFG_EMAIL=""
CFG_TRAPS=""
CFG_RETENTION=""
CFG_TZ=""

read_conf() {
  step "відповіді"

  # Пісочниця netpulse.conf НЕ читає, і це не спрощення, а запобіжник.
  # У файлі відповідей розробника цілком може стояти справжній DOMAIN
  # бойового стенду — і тоді Caddy пісочниці піде до Let's Encrypt по
  # сертифікат для чужої адреси. Видадуть його чи ні, витрачені спроби
  # спишуться з тижневої квоти домену, і платить за перевірку той, кого
  # перевіряли. Так само з TRAPS_FROM: правило в DOCKER-USER — це стан
  # хоста, а не пісочниці, і прибрати його разом із томами не вийде.
  if [ "$SANDBOX" -eq 1 ]; then
    CFG_DOMAIN=localhost
    CFG_EMAIL=""
    CFG_TRAPS=""
    CFG_RETENTION=normal
    CFG_TZ=$(host_tz)
    [ -n "$CFG_TZ" ] || CFG_TZ=Europe/Kyiv
    ok "netpulse.conf свідомо не читається — відповіді фіксовані"
    ok "адреса: localhost, сертифікат самопідписаний (Let's Encrypt не турбуємо)"
    ok "трапи: порт назовні не виставляється взагалі"
    ok "строки зберігання: normal (той самий профіль, що й типово)"
    ok "часовий пояс: $CFG_TZ"
    return 0
  fi

  if [ -f "$CONF_FILE" ]; then
    CFG_DOMAIN=$(sed -n 's/^[[:space:]]*DOMAIN=//p'      "$CONF_FILE" | tail -1 | tr -d ' \r')
    CFG_EMAIL=$(sed -n 's/^[[:space:]]*ADMIN_EMAIL=//p'  "$CONF_FILE" | tail -1 | tr -d ' \r')
    CFG_TRAPS=$(sed -n 's/^[[:space:]]*TRAPS_FROM=//p'   "$CONF_FILE" | tail -1 | tr -d ' \r')
    CFG_RETENTION=$(sed -n 's/^[[:space:]]*RETENTION=//p' "$CONF_FILE" | tail -1 | tr -d ' \r')
    CFG_TZ=$(sed -n 's/^[[:space:]]*TZ=//p'              "$CONF_FILE" | tail -1 | tr -d ' \r')
    ok "прочитано $CONF_FILE"
  else
    ok "netpulse.conf немає — беруться типові відповіді"
    say "       (зразок із поясненнями: cp netpulse.conf.example netpulse.conf)"
  fi

  case "$CFG_RETENTION" in
    "") CFG_RETENTION=normal ;;
    economy|normal|archive) ;;
    *) die "RETENTION=$CFG_RETENTION — такого профілю немає." \
           "Дозволені: economy, normal, archive." ;;
  esac

  if [ -n "$CFG_EMAIL" ] && [ -z "$CFG_DOMAIN" ]; then
    warn "ADMIN_EMAIL заданий без DOMAIN. Сертифікат буде самопідписаний,
       Let's Encrypt не задіяний, пошта нікуди не піде — прибрано"
    CFG_EMAIL=""
  fi

  if [ -n "$CFG_DOMAIN" ]; then
    case "$CFG_DOMAIN" in
      *[!a-zA-Z0-9.-]*) die "DOMAIN=$CFG_DOMAIN містить неприпустимі символи." ;;
      *.*) ok "домен: $CFG_DOMAIN" ;;
      *) die "DOMAIN=$CFG_DOMAIN не схожий на доменне ім'я (немає крапки)." \
             "Якщо домену ще немає — лишіть порожнім, буде самопідписаний сертифікат." ;;
    esac
    if [ -n "$CFG_EMAIL" ]; then
      ok "пошта для Let's Encrypt: $CFG_EMAIL"
    else
      warn "ADMIN_EMAIL порожній: про проблеми з продовженням сертифіката
       ніхто не дізнається листом"
    fi
  else
    ok "домену немає — самопідписаний сертифікат на IP"
  fi

  ok "строки зберігання: $CFG_RETENTION"
}

# ---------------------------------------------------------------------
# КРОК 3. Обчислені значення
# ---------------------------------------------------------------------

VAL_DOMAIN=""
VAL_TZ=""
VAL_SHBUF=""
VAL_DFMEM=""
VAL_PGBGW=""
VAL_TRAPS_BIND=""
VAL_TRAPS_SRC=""

compute() {
  step "обчислені значення"

  # У пісочниці нічого не обчислюється з ОЗП хоста, і саме в цьому суть.
  # Розрахунок «чверть пам'яті» правильний для машини, яку віддали під
  # NetPulse цілком, і руйнівний для машини, де вже щось працює: два
  # незалежні «візьму чверть» від одного пирога дають суму більшу за
  # пиріг. Пісочниця бере фіксовані числа — достатні, щоб Postgres
  # піднявся й накотив схему, і замалі, щоб її поява щось зрушила.
  if [ "$SANDBOX" -eq 1 ]; then
    VAL_DOMAIN=$CFG_DOMAIN
    VAL_TZ=$CFG_TZ
    VAL_SHBUF=128MB
    # 256mb — не «щоб менше», а нижня межа, за якою Dragonfly не
    # стартує: він вимагає 256 МБ на кожен потік вводу-виводу. Тому
    # разом зі стелею задається й один потік (DRAGONFLY_THREADS), інакше
    # на восьмиядерній машині за замовчуванням вийде вісім потоків,
    # 2 ГіБ вимоги й відмова старту з приводу, який нічого не пояснює.
    VAL_DFMEM=256mb
    VAL_PGBGW=2
    VAL_TRAPS_BIND=127.0.0.1
    VAL_TRAPS_SRC=""
    ok "адреса системи: $VAL_DOMAIN (порти нижче — на 127.0.0.1)"
    ok "часовий пояс: $VAL_TZ"
    ok "shared_buffers=$VAL_SHBUF — задано числом, не пораховано з ОЗП хоста"
    ok "стеля кешу=$VAL_DFMEM, потоків кешу=1"
    ok "фонових робітників TimescaleDB=$VAL_PGBGW"
    ok "порти пісочниці: $SB_HTTP (HTTP), $SB_HTTPS (HTTPS), $SB_GRPC (зонди)"
    return 0
  fi

  if [ -n "$CFG_DOMAIN" ]; then
    VAL_DOMAIN=$CFG_DOMAIN
  else
    VAL_DOMAIN=$(host_ip)
    if [ -z "$VAL_DOMAIN" ]; then
      if [ "$DRY" -eq 1 ]; then
        VAL_DOMAIN="<IP-цієї-машини>"
        warn "IP визначити не вдалося; у сухому прогоні це не зупиняє"
      else
        die "Не вдалося визначити IP-адресу цієї машини," \
            "а без адреси проксі не знає, на що відповідати." \
            "Впишіть DOMAIN у netpulse.conf — можна й голий IP."
      fi
    else
      ok "адреса системи: $VAL_DOMAIN (IP цієї машини)"
    fi
  fi

  VAL_TZ=$CFG_TZ
  if [ -z "$VAL_TZ" ]; then
    VAL_TZ=$(host_tz)
    [ -n "$VAL_TZ" ] || VAL_TZ=Europe/Kyiv
    ok "часовий пояс: $VAL_TZ (з хоста)"
  else
    ok "часовий пояс: $VAL_TZ"
  fi

  # shared_buffers — чверть пам'яті, і саме тут установник заробляє
  # своє існування. Значення з .env.example (512MB) на машині з 4 ГБ
  # разом із рештою служб дає перевищення пам'яті: Postgres резервує
  # буфери одразу, ядро вбиває його першим, і виглядає це як «база
  # іноді падає», а не як помилка налаштування.
  _mem=$(host_mem_mb)
  if [ "$_mem" -eq 0 ]; then
    VAL_SHBUF=256MB
    warn "пам'ять невідома — shared_buffers=256MB (свідомо мало)"
  else
    _sb=$((_mem / 4))
    [ "$_sb" -lt 128 ] && _sb=128
    [ "$_sb" -gt 8192 ] && _sb=8192
    VAL_SHBUF="${_sb}MB"
    ok "shared_buffers=$VAL_SHBUF (чверть від $_mem МБ)"
  fi

  # Стеля кешу — з тієї самої цифри, а не окремим розрахунком.
  #
  # Dragonfly без стелі бере пам'ять із доступної на машині, тобто
  # рахує ТУ САМУ, яку щойно порахував shared_buffers. Два незалежні
  # «візьму чверть» від одного пирога дають суму більшу за пиріг, і
  # ядро вбиває когось третього — найімовірніше API, бо він найлегший.
  # Восьма частина: кеш тут допоміжний, у ньому живуть сесії й
  # короткочасні лічильники, а не дані.
  if [ "$_mem" -eq 0 ]; then
    VAL_DFMEM=256mb
  else
    _df=$((_mem / 8))
    [ "$_df" -lt 128 ] && _df=128
    [ "$_df" -gt 2048 ] && _df=2048
    VAL_DFMEM="${_df}mb"
  fi
  ok "стеля кешу=$VAL_DFMEM"

  # Фонові робітники TimescaleDB конкурують за ядра з самими запитами.
  # Вісім на двох ядрах означає, що стиснення чанків заважає опитуванню
  # хостів — а опитування тут головне.
  _cpu=$(host_cpus)
  if [ "$_cpu" -le 0 ]; then
    VAL_PGBGW=4
    warn "кількість ядер невідома — фонових робітників 4"
  else
    VAL_PGBGW=$((_cpu * 2))
    [ "$VAL_PGBGW" -lt 2 ] && VAL_PGBGW=2
    [ "$VAL_PGBGW" -gt 8 ] && VAL_PGBGW=8
    ok "фонових робітників TimescaleDB=$VAL_PGBGW (ядер: $_cpu)"
  fi

  compute_traps
}

compute_traps() {
  VAL_TRAPS_SRC=""
  case "$CFG_TRAPS" in
    "")
      VAL_TRAPS_BIND=127.0.0.1
      ok "трапи: лише з цієї машини (порт не виставлений назовні)"
      ;;
    any|ANY|any/0|0.0.0.0/0)
      VAL_TRAPS_BIND=0.0.0.0
      warn "трапи: приймаються ВІД БУДЬ-КОГО на 162/udp."
      say "       Порт не має автентифікації: хто знає адресу, той пише вам у базу."
      say "       Якщо це не було свідомим рішенням — TRAPS_FROM=<ваша підмережа>."
      ;;
    */*)
      VAL_TRAPS_BIND=0.0.0.0
      VAL_TRAPS_SRC=$CFG_TRAPS
      ok "трапи: з підмережі $CFG_TRAPS (обмеження — правилом DOCKER-USER)"
      ;;
    *)
      # Адреса цієї машини — прив'язуємось до інтерфейсу, і жодного
      # правила у файрволі не треба. Чужа адреса — це «приймати лише
      # звідти», а прив'язкою docker такого не висловити.
      if host_has_addr "$CFG_TRAPS"; then
        VAL_TRAPS_BIND=$CFG_TRAPS
        ok "трапи: лише на інтерфейсі $CFG_TRAPS"
      else
        VAL_TRAPS_BIND=0.0.0.0
        VAL_TRAPS_SRC="$CFG_TRAPS/32"
        ok "трапи: лише від $CFG_TRAPS (обмеження — правилом DOCKER-USER)"
      fi
      ;;
  esac

  if [ -n "$VAL_TRAPS_SRC" ] && [ "$DRY" -eq 0 ]; then
    have iptables || die \
      "TRAPS_FROM=$CFG_TRAPS означає «приймати лише звідти», а зробити це" \
      "можна лише правилом у ланцюжку DOCKER-USER — iptables на цьому хості немає." \
      "" \
      "Варіанти: поставити iptables; або вказати в TRAPS_FROM адресу" \
      "внутрішнього інтерфейсу цієї машини (тоді обмеження дає сама прив'язка);" \
      "або TRAPS_FROM=any, якщо відкрити порт усім — свідоме рішення."
  fi

  if [ -n "$VAL_TRAPS_BIND" ] && [ "$VAL_TRAPS_BIND" != 127.0.0.1 ]; then
    if port_busy 162 udp; then
      warn "порт 162/udp уже хтось слухає (snmptrapd?) — зонд його не займе"
    fi
  fi
}

host_has_addr() {
  if have ip; then
    ip -o addr show 2>/dev/null | grep -q "inet6\{0,1\} $1/"
  elif have hostname; then
    hostname -I 2>/dev/null | tr ' ' '\n' | grep -qx "$1"
  else
    return 1
  fi
}

# Правило джерела для трапів. Ідемпотентне: -C перевіряє наявність,
# і повторний install не плодить копій.
apply_traps_firewall() {
  [ -n "$VAL_TRAPS_SRC" ] || return 0
  _added=0
  if ! iptables -C DOCKER-USER -p udp --dport 162 ! -s "$VAL_TRAPS_SRC" -j DROP 2>/dev/null; then
    if iptables -I DOCKER-USER 1 -p udp --dport 162 ! -s "$VAL_TRAPS_SRC" -j DROP 2>/dev/null; then
      _added=1
    else
      die "Не вдалося додати правило в ланцюжок DOCKER-USER." \
          "Найчастіша причина — запуск не від root." \
          "Правило, яке треба додати руками:" \
          "  iptables -I DOCKER-USER 1 -p udp --dport 162 ! -s $VAL_TRAPS_SRC -j DROP"
    fi
  fi
  if [ "$_added" -eq 1 ]; then
    ok "правило DOCKER-USER додано: 162/udp лише з $VAL_TRAPS_SRC"
    warn "правило живе до перезавантаження. Щоб пережило —
       iptables-save (пакет iptables-persistent) або власний unit"
  else
    ok "правило DOCKER-USER уже на місці"
  fi
}

# ---------------------------------------------------------------------
# КРОК 4. Секрети
# ---------------------------------------------------------------------
#
# Найважливіше тут — те, чого крок НЕ робить: він не перевипускає
# NETPULSE_DEK, якщо той уже є. Новий ключ шифрування означає, що всі
# збережені паролі SSH і SNMP-community перетворюються на нечитний
# шифротекст, і назад їх не дістати ніяк. Тому повторний install
# підхоплює наявні значення, а не генерує свіжі.

SEC_PG=""
SEC_APP=""
SEC_WORKER=""
SEC_DEK=""
SEC_JWT=""
SEC_REUSED=0

# Пароль їде всередині postgres://user:ПАРОЛЬ@db:5432/netpulse. «/» там
# починає ім'я бази, «@» — адресу хоста, «:» — порт: пароль обривається
# на першому такому символі МОВЧКИ, і застосунок доповідає
# «password authentication failed», тобто вказує зовсім не туди.
assert_dsn_safe() {
  case "$2" in
    *[/+=@:?\#\&]*)
      die "У значенні $1 є символ, який щось означає всередині DSN" \
          "(«/», «+», «=», «@», «:», «?», «#», «&»)." \
          "Такий пароль обірветься при розборі postgres://user:пароль@db" \
          "і дасть «password authentication failed» — помилку не про те." \
          "Виправлення: приберіть рядок $1 з .env і повторіть install," \
          "установник згенерує hex, у якому таких символів немає." ;;
  esac
}

secrets() {
  step "секрети"

  if [ -f "$ENV_FILE" ]; then
    SEC_PG=$(env_get POSTGRES_PASSWORD)
    SEC_APP=$(env_get NETPULSE_APP_PASSWORD)
    SEC_WORKER=$(env_get NETPULSE_WORKER_PASSWORD)
    SEC_DEK=$(env_get NETPULSE_DEK)
    SEC_JWT=$(env_get NETPULSE_JWT_SECRET)
    if [ -n "$SEC_DEK" ]; then
      SEC_REUSED=1
      ok "знайдено .env — наявні секрети зберігаються без змін"
      say "       (перевипуск NETPULSE_DEK знищив би всі збережені паролі)"
    fi
  fi

  if ! have openssl && [ ! -r /dev/urandom ]; then
    die "Немає ані openssl, ані /dev/urandom — випадкові значення взяти" \
        "нізвідки, а вигадувати секрети скрипт не має права."
  fi

  [ -n "$SEC_PG" ]     || SEC_PG=$(rand_hex 32)
  [ -n "$SEC_APP" ]    || SEC_APP=$(rand_hex 32)
  [ -n "$SEC_WORKER" ] || SEC_WORKER=$(rand_hex 32)
  [ -n "$SEC_JWT" ]    || SEC_JWT=$(rand_hex 32)
  [ -n "$SEC_DEK" ]    || SEC_DEK="np1=$(rand_hex 32)"

  # Перевірка, а не припущення: одного разу пароль із «/» уже обрізав
  # DSN, і поламалось воно не тут, а через три кроки, з повідомленням
  # про невірний пароль.
  assert_dsn_safe POSTGRES_PASSWORD        "$SEC_PG"
  assert_dsn_safe NETPULSE_APP_PASSWORD    "$SEC_APP"
  assert_dsn_safe NETPULSE_WORKER_PASSWORD "$SEC_WORKER"

  [ "$SEC_REUSED" -eq 1 ] || ok "згенеровано: пароль БД, паролі ролей netpulse_app і
       netpulse_worker, ключ шифрування секретів, ключ підпису сесій"
  ok "формат — hex: жоден символ не має значення всередині DSN"
}

# ---------------------------------------------------------------------
# КРОК 5. .env
# ---------------------------------------------------------------------

write_env() {
  step ".env"

  _enroll=""
  if [ -f "$ENV_FILE" ]; then
    _enroll=$(env_get NETPULSE_ENROLL)
    # Пароль власника пісочниці переживає перезапис .env з тієї ж
    # причини, що й секрети: власника вже заведено, нового пароля не
    # буде, а втративши старий, ми втратили б і перевірку входу.
    [ "$SANDBOX" -eq 1 ] && SB_OWNER_PW=$(env_get "$SB_PW_KEY")
  fi

  if [ "$DRY" -eq 1 ]; then
    ok "у сухому прогоні файл не пишеться; вміст (секрети приховані):"
    env_body "СХОВАНО" "СХОВАНО" "СХОВАНО" "np1=СХОВАНО" "СХОВАНО" "$_enroll" |
      sed 's/^/       /'
    return 0
  fi

  # umask до створення, а не chmod після: між створенням і chmod файл
  # із паролями видно всім, і це не теоретично — саме в цю щілину
  # потрапляють резервні копії каталогу.
  _old=$(umask)
  umask 077
  env_body "$SEC_PG" "$SEC_APP" "$SEC_WORKER" "$SEC_DEK" "$SEC_JWT" "$_enroll" \
    > "$ENV_FILE.tmp" || die "Не вдалося записати $ENV_FILE.tmp"
  umask "$_old"
  chmod 600 "$ENV_FILE.tmp"
  mv "$ENV_FILE.tmp" "$ENV_FILE" || die "Не вдалося замінити $ENV_FILE"
  ok "$ENV_FILE записано, права 0600"
}

env_body() {
  cat <<ENVEOF
# Створено ./netpulse install. Правити руками можна, але наступний
# install перечитає цей файл і збереже з нього секрети — тобто ваші
# зміни в секретах переживуть переустановлення, а в решті ні.

NETPULSE_DOMAIN=$VAL_DOMAIN
ACME_EMAIL=$CFG_EMAIL

POSTGRES_PASSWORD=$1

# Ролі під RLS. На свіжій установці вмикаються одразу: даних, які можна
# зіпсувати переходом, ще немає, а самоперевірка в кінці установки
# доводить, що вхід у систему під цими ролями працює. Саме її бракувало
# тоді, коли перехід на живому стенді дав 403 на вході.
NETPULSE_APP_PASSWORD=$2
NETPULSE_WORKER_PASSWORD=$3

# ВТРАТА ЦЬОГО КЛЮЧА — ВТРАТА ВСІХ ЗБЕРЕЖЕНИХ ПАРОЛІВ.
NETPULSE_DEK=$4
NETPULSE_JWT_SECRET=$5

TZ=$VAL_TZ
NETPULSE_ALERT_INTERVAL=30s
NETPULSE_LOG_LEVEL=info

# Пораховано з пам'яті хоста, а не взято з прикладу. У пісочниці —
# навпаки: задано числами, бо рахувати чверть від чужої пам'яті на
# машині, де вже щось працює, і означає покласти те, що працює.
PG_SHARED_BUFFERS=$VAL_SHBUF
DRAGONFLY_MAXMEMORY=$VAL_DFMEM
PG_BG_WORKERS=$VAL_PGBGW
$(env_body_sandbox)

NETPULSE_VERSION=dev
NETPULSE_COMMIT=none

# Локальний зонд: базовий моніторинг самої інсталяції.
NETPULSE_ENROLL=$6
NETPULSE_AGENT_NAME=локальний зонд
NETPULSE_TRAPS_BIND=$VAL_TRAPS_BIND
NETPULSE_TRAPS_LISTEN=:162
ENVEOF
}

# Додаток до .env, який існує лише в пісочниці. У бойовому .env цих
# рядків немає й бути не має: три перші читає накладка на compose-файл,
# четвертий — сам установник, і жоден із них не має сенсу поза
# одноразовим стендом.
env_body_sandbox() {
  [ "$SANDBOX" -eq 1 ] || return 0
  cat <<SBEOF

# --- пісочниця -------------------------------------------------------
# Порти перевірені на вільність перед записом. Прив'язку до 127.0.0.1
# робить deploy/docker-compose.sandbox.yml, а не ці рядки.
NETPULSE_SB_HTTP=$SB_HTTP
NETPULSE_SB_HTTPS=$SB_HTTPS
NETPULSE_SB_GRPC=$SB_GRPC
# Один потік вводу-виводу кешу. Dragonfly вимагає 256 МБ на потік і
# відмовляється стартувати, якщо стеля менша; за замовчуванням потоків
# стільки ж, скільки ядер, тож на восьмиядерній машині стеля 256mb дала
# б «потрібен 2 ГіБ, виходжу» — помилку, яка не називає причини.
DRAGONFLY_THREADS=1
# Пароль власника пісочниці у відкритому вигляді. Тут це свідомо: стенд
# стоїть на 127.0.0.1, живе години й видаляється цілком, а без пароля
# повторний «./netpulse sandbox check» мовчки пропускав би найважливішу
# частину перевірки — справжній вхід. У бойовому .env такого рядка
# немає й не буде.
$SB_PW_KEY=$SB_OWNER_PW
SBEOF
}

# Пароль власника з'являється на крок пізніше, ніж пишеться .env, тому
# він доклада́ється тим самим прийомом, що й запрошення для зонда.
#
# Друга гілка — не дрібниця. Повторний `sandbox up` без `down` застає
# власника вже заведеним, нового пароля не буде, і без цього рядка
# самоперевірка мовчки пропустила б справжній вхід — тобто саме те, що
# колись і назвали «готово».
sandbox_owner_pw_sync() {
  [ "$SANDBOX" -eq 1 ] || return 0
  [ "$DRY" -eq 1 ] && return 0
  if [ -n "$OUT_OWNER_PW" ]; then
    SB_OWNER_PW=$OUT_OWNER_PW
    sed -i.bak "s|^$SB_PW_KEY=.*|$SB_PW_KEY=$OUT_OWNER_PW|" "$ENV_FILE" \
      || die "Не вдалося записати пароль власника в $ENV_FILE"
    rm -f "$ENV_FILE.bak"
  elif [ -z "$OWNER_PASSWORD" ]; then
    OWNER_PASSWORD=$(env_get "$SB_PW_KEY")
    [ -n "$OWNER_PASSWORD" ] && ok "пароль власника взято з $ENV_FILE — вхід перевіряється повністю"
  fi
  return 0
}

# ---------------------------------------------------------------------
# КРОК 6. База й кеш
# ---------------------------------------------------------------------

bring_db() {
  step "база й кеш"

  if [ "$DRY" -eq 1 ]; then
    ok "виконалося б: docker compose up -d --build db cache"
    return 0
  fi

  # Позначку ставимо ДО команди, а не після: якщо `up` обірветься на
  # середині, контейнери й томи вже створені, і прибирати їх треба так
  # само, як після успішного старту.
  SB_STARTED=1

  dc up -d --build db cache || die "docker compose не підняв db і cache." \
    "Повний текст помилки — вище; найчастіше це нестача місця під образи."

  ok "чекаємо, поки Postgres відповість (до 120 с)"
  _i=0
  while [ "$_i" -lt 60 ]; do
    if dc exec -T db pg_isready -U netpulse -d netpulse >/dev/null 2>&1; then
      ok "Postgres відповідає"
      return 0
    fi
    _i=$((_i + 1))
    sleep 2
  done
  die "Postgres не піднявся за дві хвилини." \
      "Подивитись причину: ./netpulse logs db" \
      "Найчастіше це shared_buffers більший за пам'ять машини — тоді в журналі" \
      "буде рядок про неможливість виділити спільну пам'ять."
}

# ---------------------------------------------------------------------
# КРОК 7. Схема й ролі
# ---------------------------------------------------------------------
#
# Порядок тут не довільний: міграція 0063 СТВОРЮЄ ролі netpulse_app і
# netpulse_worker без паролів, і лише після неї їм є що видавати. А
# піднімати API до видачі паролів не можна взагалі: у DSN уже стоїть
# netpulse_app, і застосунок отримає відмову автентифікації.

migrate_schema() {
  step "схема й ролі"

  if [ "$DRY" -eq 1 ]; then
    ok "виконалося б: docker compose run --rm migrate"
    ok "далі: ALTER ROLE netpulse_app / netpulse_worker з новими паролями"
    ok "далі: звірка rolbypassrls — у netpulse_app має бути f"
    return 0
  fi

  dc run --rm migrate || die \
    "Міграції не накотились. Схема лишилась у тому стані, у якому була:" \
    "кожна міграція йде окремою транзакцією, недокочена не залишається." \
    "Текст помилки вище називає файл, на якому зупинилось."
  ok "схема накочена"

  dc exec -T db psql -v ON_ERROR_STOP=1 -U netpulse -d netpulse >/dev/null <<SQL || die \
    "Не вдалося видати паролі ролям netpulse_app і netpulse_worker."
ALTER ROLE netpulse_app    PASSWORD '$SEC_APP';
ALTER ROLE netpulse_worker PASSWORD '$SEC_WORKER';
SQL
  ok "паролі ролей видані"

  _bypass=$(dc exec -T db psql -tAX -U netpulse -d netpulse \
    -c "SELECT rolbypassrls FROM pg_roles WHERE rolname='netpulse_app'" 2>/dev/null | tr -d ' \r')
  if [ "$_bypass" != "f" ]; then
    die "У ролі netpulse_app стоїть BYPASSRLS (rolbypassrls=$_bypass)." \
        "Це означає, що політики ізоляції кабінетів не діятимуть узагалі —" \
        "усе наступне пройде й нічого не змінить."
  fi
  ok "netpulse_app без BYPASSRLS — політики RLS діють"
}

# ---------------------------------------------------------------------
# КРОК 8. Служби
# ---------------------------------------------------------------------

bring_services() {
  step "служби"

  if [ "$DRY" -eq 1 ]; then
    ok "виконалося б: docker compose up -d --build api collector proxy"
    return 0
  fi

  dc up -d --build api collector proxy || die \
    "Не вдалося підняти api, collector або proxy." \
    "Стан служб: ./netpulse logs"

  ok "чекаємо, поки API відповість на /healthz (до 120 с)"
  _i=0
  while [ "$_i" -lt 60 ]; do
    if dc exec -T api wget -q -O- "$API_INTERNAL/healthz" >/dev/null 2>&1; then
      ok "API відповідає"
      return 0
    fi
    _i=$((_i + 1))
    sleep 2
  done
  die "API не відповів за дві хвилини." \
      "Подивитись причину: ./netpulse logs api" \
      "Якщо в журналі «password authentication failed» — паролі ролей і DSN" \
      "розійшлись; повторіть install, він перевидасть паролі з .env."
}

# ---------------------------------------------------------------------
# КРОК 9. Власник
# ---------------------------------------------------------------------

create_owner() {
  step "власник"

  if [ "$DRY" -eq 1 ]; then
    ok "виконалося б: docker compose run --rm --entrypoint netpulse-user cli \\"
    say "         -tenant default -create-tenant \"NetPulse\" -login admin -role owner"
    ok "пароль — 24 символи з /dev/urandom, показується один раз"
    return 0
  fi

  # Роллю ВЛАСНИКА, а не netpulse_app: заведення кабінету — рівно те,
  # чого роль під RLS не може за побудовою (0063, розділ про
  # core.tenants). Без явного DSN утиліта мовчки нічого не знайшла б.
  _dsn="postgres://netpulse:$SEC_PG@db:5432/netpulse?sslmode=disable"

  # --entrypoint обов'язковий: у службі api вже прописаний
  # entrypoint netpulse-api, і без заміни утиліта потрапила б до нього
  # аргументом, а не запустилась. (deploy/README.md у цьому місці
  # помиляється — там команда без --entrypoint.)
  if dc run --rm -e "NETPULSE_DSN=$_dsn" --entrypoint netpulse-user cli \
       -tenant default -list 2>/dev/null | grep -q '^  admin '; then
    OUT_OWNER_EXISTED=1
    ok "власник admin уже є — пароль не змінюється"
    return 0
  fi

  OUT_OWNER_PW=$(rand_pass) || die "Не вдалося згенерувати пароль власника."

  # Пароль іде через stdin, а не прапорцем: аргументи командного рядка
  # видно в ps будь-кому на машині, і осідають вони ще й в історії
  # оболонки.
  printf '%s\n' "$OUT_OWNER_PW" | dc run --rm -T -e "NETPULSE_DSN=$_dsn" \
    --entrypoint netpulse-user cli \
    -tenant default -create-tenant "NetPulse" \
    -login admin -role owner -name "Адміністратор" \
    || die "Не вдалося завести власника." \
           "Якщо в тексті «тенанта не знайдено» — база порожня, і це означає," \
           "що міграції відпрацювали не до кінця."
  ok "заведено кабінет NetPulse і власника admin"
}

# ---------------------------------------------------------------------
# КРОК 10. Строки зберігання
# ---------------------------------------------------------------------
#
# Профіль накладається лише на першій установці. Причина та сама, що й у
# самої міграції 0064: наш «правильний» строк на чужій інсталяції — це
# чиясь втрачена історія. Якщо строки вже правили, повторний install їх
# не чіпає.

retention_rows() {
  case "$1" in
    economy)
      cat <<'EOF'
metrics_raw 7
metrics_5m 30
metrics_1h 180
icmp_raw 7
icmp_5m 30
icmp_1h 180
ifc_raw 7
ifc_5m 30
ifc_1h 180
link_status 30
device_status 30
syslog 7
traps 7
agent_health 7
alerts_history 90
notifications 30
login_attempts 90
audit_log 180
command_runs 30
ncm_jobs 30
discovery_runs 30
EOF
      ;;
    normal)
      cat <<'EOF'
metrics_raw 35
metrics_5m 180
metrics_1h 730
icmp_raw 35
icmp_5m 180
icmp_1h 730
ifc_raw 35
ifc_5m 180
ifc_1h 730
link_status 180
device_status 180
syslog 30
traps 30
agent_health 14
alerts_history 365
notifications 90
login_attempts 180
audit_log 365
command_runs 90
ncm_jobs 90
discovery_runs 90
EOF
      ;;
    archive)
      cat <<'EOF'
metrics_raw 90
metrics_5m 365
metrics_1h 1825
icmp_raw 90
icmp_5m 365
icmp_1h 1825
ifc_raw 90
ifc_5m 365
ifc_1h 1825
link_status 730
device_status 730
syslog 180
traps 180
agent_health 30
alerts_history 1825
notifications 365
login_attempts 365
audit_log 1825
command_runs 365
ncm_jobs 365
discovery_runs 365
EOF
      ;;
  esac
}

apply_retention() {
  step "строки зберігання"

  if [ "$DRY" -eq 1 ]; then
    ok "профіль $CFG_RETENTION розклався б у core.retention_settings так:"
    retention_rows "$CFG_RETENTION" | awk '{ printf "         %-16s %s діб\n", $1, $2 }'
    ok "потім SELECT core.apply_retention_policies() — накласти політики TimescaleDB"
    return 0
  fi

  if [ "$OUT_OWNER_EXISTED" -eq 1 ]; then
    ok "система вже стояла — строки зберігання не чіпаємо"
    say "       (змінити: сторінка «Сховище» в інтерфейсі)"
    return 0
  fi

  {
    printf 'BEGIN;\n'
    retention_rows "$CFG_RETENTION" | while read -r _kind _days; do
      [ -n "$_kind" ] || continue
      printf "UPDATE core.retention_settings SET keep_days = %s WHERE kind = '%s';\n" \
        "$_days" "$_kind"
    done
    printf 'COMMIT;\n'
    printf 'SELECT core.apply_retention_policies();\n'
  } | dc exec -T db psql -v ON_ERROR_STOP=1 -U netpulse -d netpulse >/dev/null \
    || die "Не вдалося накласти строки зберігання." \
           "Дані від цього не постраждали: невдала транзакція нічого не змінює." \
           "Строки можна виставити в інтерфейсі: «Сховище»."

  ok "профіль $CFG_RETENTION накладено на 21 вид даних"
}

# ---------------------------------------------------------------------
# HTTP до API зсередини контейнера
# ---------------------------------------------------------------------
#
# Ходимо wget-ом усередині контейнера api, а не curl-ом з хоста, і це
# свідомо. На чужому сервері curl може бути відсутній, а wget у образі є
# гарантовано — на ньому тримається healthcheck самого compose. Заразом
# зникає питання самопідписаного сертифіката: усередині це звичайний
# http до 127.0.0.1.

API_TOKEN=""

api_wget_ok() {
  dc exec -T api sh -s <<'INNER' >/dev/null 2>&1
wget --help 2>&1 | grep -q -- --post-data
INNER
}

api_get() {
  if [ -n "$API_TOKEN" ]; then
    dc exec -T api sh -s <<INNER 2>&1
wget -q -O- --header='Authorization: Bearer $API_TOKEN' '$API_INTERNAL$1'
INNER
  else
    dc exec -T api sh -s <<INNER 2>&1
wget -q -O- '$API_INTERNAL$1'
INNER
  fi
}

api_post() {
  if [ -n "$API_TOKEN" ]; then
    dc exec -T api sh -s <<INNER 2>&1
wget -q -O- --header='Content-Type: application/json' \
     --header='Authorization: Bearer $API_TOKEN' \
     --post-data='$2' '$API_INTERNAL$1'
INNER
  else
    dc exec -T api sh -s <<INNER 2>&1
wget -q -O- --header='Content-Type: application/json' \
     --post-data='$2' '$API_INTERNAL$1'
INNER
  fi
}

# ---------------------------------------------------------------------
# КРОК 11. Локальний зонд
# ---------------------------------------------------------------------
#
# Зонд на самому сервері ставиться не для краси. Він проходить увесь
# ланцюг, якого не бачить жодна перевірка HTTP: обмін запрошення на
# постійний токен, gRPC до колектора, реєстрація в core.agents. Якщо
# після установки в системі нуль зондів, вона показує порожні екрани, і
# людина не може відрізнити «ще нічого не налаштовано» від «колектор не
# працює».

enroll_local_agent() {
  step "локальний зонд"

  if [ "$DRY" -eq 1 ]; then
    ok "виконалося б: вхід під власником, POST /api/v1/agent-enrollments,"
    say "         docker compose --profile agent up -d agent"
    return 0
  fi

  _have=$(env_get NETPULSE_ENROLL)
  if [ -n "$_have" ] && dc ps --services --filter status=running 2>/dev/null | grep -q '^agent$'; then
    ok "локальний зонд уже працює"
    return 0
  fi

  if ! login_as_owner; then
    warn "пароля власника немає (система вже стояла) — нове запрошення не
       видається. Додати зонд можна в інтерфейсі: Зонди → Додати зонд"
    return 0
  fi

  _r=$(api_post /api/v1/agent-enrollments \
       '{"name_hint":"локальний зонд","modules":["icmp","snmp","topology","ncm","traps"]}')
  _tok=$(printf '%s' "$_r" | json_str token)
  [ -n "$_tok" ] || die "API не видав запрошення для зонда." \
    "Відповідь: $_r"

  # Запрошення лягає в .env, бо зонд обмінює його на постійний токен при
  # ПЕРШОМУ старті, а compose читає змінну на кожному up.
  sed -i.bak "s|^NETPULSE_ENROLL=.*|NETPULSE_ENROLL=$_tok|" "$ENV_FILE" \
    || die "Не вдалося записати запрошення в .env"
  rm -f "$ENV_FILE.bak"

  dc --profile agent up -d --build agent \
    || die "Не вдалося підняти локальний зонд."
  ok "локальний зонд запущено"

  apply_traps_firewall
}

login_as_owner() {
  [ -n "$API_TOKEN" ] && return 0

  _pw=$OWNER_PASSWORD
  [ -n "$_pw" ] || _pw=$OUT_OWNER_PW
  if [ -z "$_pw" ]; then
    return 1
  fi

  _r=$(api_post /api/v1/auth/login "{\"login\":\"admin\",\"password\":\"$_pw\"}")
  API_TOKEN=$(printf '%s' "$_r" | json_str access_token)
  if [ -z "$API_TOKEN" ]; then
    die "Вхід під власником не вдався — тобто зламане саме те, заради чого" \
        "ця перевірка існує." \
        "" \
        "Відповідь API: $_r" \
        "" \
        "Якщо там 403 і no_membership — база віддала нуль кабінетів під роллю" \
        "netpulse_app. Найчастіша причина: порожній NETPULSE_DSN_WORKER, тобто" \
        "не заданий NETPULSE_WORKER_PASSWORD у .env. Шлях входу читає" \
        "core.memberships JOIN core.tenants пулом воркера, і без нього" \
        "падає назад у пул застосунку, який кабінетів не бачить."
  fi
  return 0
}

# ---------------------------------------------------------------------
# КРОК 12. Самоперевірка
# ---------------------------------------------------------------------
#
# Взірець — перевірка, написана після живої поломки: перший її варіант
# питав «чи не видно чужого» і був зелений, бо RLS справді ховає чуже.
# Зламалось протилежне — не видно СВОГО. Тест на ізоляцію дивиться повз
# це місце за побудовою.
#
# Тому тут перевіряється не ізоляція, а ПРОХІДНІСТЬ: шлях, яким людина
# заходить у систему, і переліки, які після цього мають бути непорожні.
# Порожній перелік тут — привід зупинитись, а не «даних ще немає»: усе
# перелічене наливають міграції, тобто воно є на будь-якій інсталяції
# через хвилину після установки.

SELFCHECK_FAILED=""

sc_fail() { SELFCHECK_FAILED="$SELFCHECK_FAILED|$1"; bad "$1"; }

# Переліки, які МАЮТЬ бути непорожні одразу після установки. Кожен —
# із міграцій, і кожен читається через окремий шлях у store.
sc_must_lists() {
  cat <<'EOF'
/api/v1/templates шаблони
/api/v1/ncm/profiles профілі_NCM
/api/v1/check-types типи_перевірок
/api/v1/roles ролі
/api/v1/permissions права
/api/v1/team користувачі
/api/v1/storage/retention строки_зберігання
EOF
}

# Тут порожньо законно: свіжа система ще не має ані хостів, ані мап.
# Перевіряємо лише те, що ендпоїнт ВІДПОВІДАЄ, — 4xx або 5xx означав
# би, що під роллю без BYPASSRLS зламався запит, а не що даних немає.
sc_may_lists() {
  cat <<'EOF'
/api/v1/me я
/api/v1/devices хости
/api/v1/device-groups групи
/api/v1/credentials доступи
/api/v1/maps мапи
/api/v1/dashboards панелі
/api/v1/alerts алерти
/api/v1/alert-rules тригери
/api/v1/audit?limit=20 журнал_аудиту
/api/v1/queues черги
/api/v1/agent-enrollments запрошення_зондів
/api/v1/storage сховище
EOF
}

selfcheck() {
  step "самоперевірка"

  if [ "$DRY" -eq 1 ]; then
    ok "справжній вхід POST /api/v1/auth/login справжнім паролем"
    ok "звірка, що кабінет НАЗВАВСЯ (tenant_name), а не лише має id"
    ok "звірка, що вхід повернув перелік прав"
    sc_must_lists | awk '{ gsub(/_/, " ", $2); printf "         непорожньо: %s\n", $2 }'
    sc_may_lists  | awk '{ gsub(/_/, " ", $2); printf "         відповідає: %s\n", $2 }'
    ok "зонд зареєструвався: /api/v1/agents не порожній"
    ok "проксі відповідає HTTPS на публічній адресі"
    return 0
  fi

  SELFCHECK_FAILED=""

  api_wget_ok || die \
    "wget у образі api не вміє --post-data, тому справжній вхід звідси не зробити." \
    "Установник не має права оголосити систему готовою без цієї перевірки." \
    "Зробіть її з машини, де є curl:" \
    "  curl -sk -X POST https://$VAL_DOMAIN/api/v1/auth/login \\" \
    "       -H 'Content-Type: application/json' \\" \
    "       -d '{\"login\":\"admin\",\"password\":\"<пароль>\"}'"

  # --- 1. Вхід: рівно той шлях, що ламався --------------------------
  _pw=$OWNER_PASSWORD
  [ -n "$_pw" ] || _pw=$OUT_OWNER_PW
  if [ -z "$_pw" ]; then
    warn "пароля власника немає — повний вхід не перевіряється"
    say "       (система вже стояла; щоб перевірити вхід: ./netpulse check -p <пароль>)"
  else
    API_TOKEN=""
    _r=$(api_post /api/v1/auth/login "{\"login\":\"admin\",\"password\":\"$_pw\"}")
    API_TOKEN=$(printf '%s' "$_r" | json_str access_token)
    if [ -z "$API_TOKEN" ]; then
      die "ВХІД НЕ ПРОЙШОВ. Контейнери працюють, сторінка відкривається," \
          "а зайти в систему не можна — саме цей стан колись назвали «готово»." \
          "" \
          "Відповідь API: $_r" \
          "" \
          "403 no_membership означає, що база віддала нуль кабінетів під роллю" \
          "netpulse_app. Перевірити NETPULSE_WORKER_PASSWORD у .env: шлях входу" \
          "читає кабінети пулом воркера, і без пароля цей пул стає пулом" \
          "застосунку, який кабінетів не бачить за політикою RLS."
    fi
    ok "вхід: пройшов"

    # Не наявність ключа tenant, а НАЗВА. id приїжджає з членства й
    # лишився б на місці навіть тоді, коли рядок кабінету не віддався,
    # — це тиха половина тієї самої поломки.
    _tname=$(printf '%s' "$_r" | json_str tenant_name)
    if [ -n "$_tname" ]; then
      ok "кабінет назвався: $_tname"
    else
      sc_fail "вхід не повернув назви кабінету — core.tenants не віддалась"
    fi

    if printf '%s' "$_r" | tr -d ' \n' | grep -q '"permissions":\["'; then
      ok "вхід повернув права"
    else
      sc_fail "вхід не повернув прав"
    fi
  fi

  if [ -z "$API_TOKEN" ]; then
    warn "далі йдуть лише перевірки, що не потребують входу"
  else
    # --- 2. Переліки, які МАЮТЬ бути непорожні ----------------------
    #
    # Через тимчасовий файл, а не конвеєром: тіло циклу, запущене в
    # конвеєрі, працює в підоболонці, і sc_fail дописував би провали в
    # її власну змінну. Підсумок після цього виявився б зеленим при
    # червоних рядках вище — рівно та брехня, проти якої вся перевірка.
    sc_must_lists > /tmp/np_must.$$
    while read -r _path _label; do
      [ -n "$_path" ] || continue
      _b=$(api_get "$_path")
      if json_nonempty "$_b"; then
        ok "$(printf '%s' "$_label" | tr '_' ' '): непорожньо"
      else
        sc_fail "$(printf '%s' "$_label" | tr '_' ' '): ПОРОЖНЬО (наливається міграціями, тобто має бути)"
      fi
    done < /tmp/np_must.$$
    rm -f /tmp/np_must.$$

    sc_may_lists > /tmp/np_may.$$
    while read -r _path _label; do
      [ -n "$_path" ] || continue
      if api_get "$_path" >/dev/null 2>&1; then
        ok "$(printf '%s' "$_label" | tr '_' ' '): відповідає"
      else
        sc_fail "$(printf '%s' "$_label" | tr '_' ' '): не відповів 200"
      fi
    done < /tmp/np_may.$$
    rm -f /tmp/np_may.$$

    # --- 3. Зонд дійшов до колектора ---------------------------------
    # Реєстрація йде gRPC-каналом, якого HTTP-перевірки не бачать
    # зовсім. Чекаємо, бо обмін запрошення на токен займає секунди.
    _i=0
    _agents=""
    while [ "$_i" -lt 20 ]; do
      _agents=$(api_get /api/v1/agents)
      json_nonempty "$_agents" && break
      _i=$((_i + 1))
      sleep 3
    done
    if json_nonempty "$_agents"; then
      ok "зонд зареєструвався в колекторі"
    else
      sc_fail "жоден зонд не зареєструвався за хвилину — колектор або запрошення"
    fi
  fi

  # --- 4. Проксі --------------------------------------------------
  check_proxy

  if [ -n "$SELFCHECK_FAILED" ]; then
    printf '\n'
    die "Самоперевірка не пройшла. Перелічене вище — не попередження:" \
        "система в такому стані виглядає працюючою й не працює." \
        "$(printf '%s' "$SELFCHECK_FAILED" | tr '|' ' ')"
  fi
  ok "усі вхідні шляхи проходять"
}

# Проксі перевіряємо окремо й м'якше: TLS на самопідписаному
# сертифікаті поводиться по-різному в різних збірках wget, і зупиняти
# установку через версію busybox було б неправдою про стан системи.
check_proxy() {
  if ! dc ps --services --filter status=running 2>/dev/null | grep -q '^proxy$'; then
    sc_fail "контейнер proxy не працює — назовні система недоступна"
    return
  fi
  if dc exec -T api sh -s <<INNER >/dev/null 2>&1
wget -q --no-check-certificate -O- --header='Host: $VAL_DOMAIN' 'https://proxy/healthz'
INNER
  then
    ok "проксі віддає HTTPS для $VAL_DOMAIN"
  else
    warn "проксі працює, але перевірити HTTPS зсередини не вдалося.
       Перевірте з робочої машини: https://$VAL_DOMAIN/healthz"
  fi
}

# ---------------------------------------------------------------------
# Підсумок
# ---------------------------------------------------------------------

summary() {
  if [ "$SANDBOX" -eq 1 ]; then
    sandbox_summary
    return 0
  fi
  printf '\n%s== готово%s\n\n' "$C_B" "$C_0"
  printf '  Адреса:  https://%s\n' "$OUT_URL"
  if [ -z "$CFG_DOMAIN" ]; then
    printf '           сертифікат самопідписаний — браузер попередить, це очікувано\n'
  fi
  printf '  Логін:   admin\n'
  if [ -n "$OUT_OWNER_PW" ]; then
    printf '  Пароль:  %s%s%s\n' "$C_B" "$OUT_OWNER_PW" "$C_0"
  else
    printf '  Пароль:  без змін (власник уже існував)\n'
  fi
  if [ -n "$OUT_ENROLL" ]; then
    printf '\n  Запрошення для першого віддаленого зонда (дійсне добу):\n'
    printf '    %s\n' "$OUT_ENROLL"
    printf '    Наступні — в інтерфейсі: Зонди → Додати зонд.\n'
  fi
  printf '\n  %sЦе показано востаннє.%s Пароль і запрошення ніде не зберігаються\n' "$C_R" "$C_0"
  printf '  у відкритому вигляді: у базі лежать лише їхні хеші.\n'
  printf '\n  Ключі шифрування — у .env (права 0600). Втрата NETPULSE_DEK\n'
  printf '  означає втрату всіх збережених паролів SSH і SNMP: ./netpulse backup\n'
  printf '  кладе їх поруч із дампом, і зберігати їх треба ОКРЕМО від нього.\n\n'
}

# ---------------------------------------------------------------------
# install
# ---------------------------------------------------------------------

cmd_install() {
  # У пісочниці шапку вже надрукував cmd_sandbox_up — і вона там
  # докладніша. Другий заголовок поспіль читається як два різні запуски.
  if [ "$SANDBOX" -eq 0 ]; then
    printf '%sNetPulse · установка%s\n' "$C_B" "$C_0"
    sandbox_leftover_nag
    [ "$DRY" -eq 1 ] && printf 'Сухий прогін: нічого не запускається й не пишеться.\n'
  fi

  preflight
  read_conf
  compute
  secrets
  write_env
  bring_db
  migrate_schema
  bring_services
  create_owner
  sandbox_owner_pw_sync
  apply_retention
  enroll_local_agent
  selfcheck

  OUT_URL=$VAL_DOMAIN

  if [ "$DRY" -eq 1 ]; then
    printf '\n%s== сухий прогін завершено%s\n' "$C_B" "$C_0"
    printf '  Жодного контейнера не запущено, .env не змінено.\n'
    printf '  Прибрати --dry-run, щоб поставити насправді.\n\n'
    return 0
  fi

  # Друге запрошення — для першого зонда клієнта. Перше вже витрачене
  # локальним зондом, а показувати витрачений токен означає навчити
  # людину не вірити тому, що написано на екрані.
  if [ -n "$API_TOKEN" ]; then
    _r=$(api_post /api/v1/agent-enrollments \
         '{"name_hint":"перший зонд","modules":["icmp","snmp","topology","ncm"]}')
    OUT_ENROLL=$(printf '%s' "$_r" | json_str token)
  fi

  summary
}

# ---------------------------------------------------------------------
# check
# ---------------------------------------------------------------------
#
# Ті самі твердження, але на живій системі й у вигляді, придатному для
# «надішліть мені вивід цієї команди»: кожен рядок каже, ЩО саме не так,
# а не «помилка».
#
# Пароля власника тут зазвичай немає, тому перевірки поділені на дві
# групи. Ті, що не потребують пароля, б'ють у те саме місце з боку бази:
# запит, яким шлях входу дістає кабінети, виконується роллю воркера
# напряму. Якщо він порожній — вхід поверне 403, і це видно без входу.

cmd_check() {
  if [ "$SANDBOX" -eq 1 ]; then
    printf '%sNetPulse · перевірка пісочниці%s\n' "$C_B" "$C_0"
  else
    printf '%sNetPulse · перевірка%s\n' "$C_B" "$C_0"
    sandbox_leftover_nag
  fi
  SELFCHECK_FAILED=""

  step "оточення"
  pick_compose
  [ -n "$DC_KIND" ] || die "docker compose не знайдено."
  [ -f "$ENV_FILE" ] || die "Немає $ENV_FILE — систему тут не встановлювали."
  ok "docker compose на місці, .env знайдено"

  # ls, а не stat: формат stat різний у GNU, BusyBox і BSD, а нам треба
  # рівно десять символів прав — і вони однакові скрізь. Ім'я файлу тут
  # наше власне й незмінне, тобто застереження про дивні імена не про цей
  # випадок.
  # shellcheck disable=SC2012
  _perm=$(ls -l "$ENV_FILE" 2>/dev/null | cut -c1-10)
  case "$_perm" in
    -rw-------) ok ".env має права 0600" ;;
    *) sc_fail ".env має права $_perm — паролі бази й ключі шифрування читає будь-хто" ;;
  esac

  VAL_DOMAIN=$(env_get NETPULSE_DOMAIN)
  OUT_URL=$VAL_DOMAIN
  CFG_DOMAIN=$VAL_DOMAIN

  step "служби"
  _running=$(dc ps --services --filter status=running 2>/dev/null)
  for _s in db cache api collector proxy; do
    if printf '%s\n' "$_running" | grep -q "^$_s\$"; then
      ok "$_s працює"
    else
      sc_fail "$_s НЕ працює"
    fi
  done
  if printf '%s\n' "$_running" | grep -q '^agent$'; then
    ok "локальний зонд працює"
  else
    warn "локального зонда немає (не помилка, якщо зонди стоять окремо)"
  fi

  step "місце на диску"
  _free=$(free_mb "$ROOT")
  [ -n "$_free" ] || _free=0
  if [ "$_free" -lt 5120 ]; then
    sc_fail "вільно лише $_free МБ. Від переповненого тому першим падає Postgres,
       тобто весь продукт одночасно. Сторінка «Сховище» показує приріст"
  elif [ "$_free" -lt 20480 ]; then
    warn "вільно $_free МБ — час подивитись на строки зберігання"
  else
    ok "вільно $_free МБ"
  fi

  step "ролі бази"
  _app_pw=$(env_get NETPULSE_APP_PASSWORD)
  _worker=$(env_get NETPULSE_WORKER_PASSWORD)
  if [ -n "$_app_pw" ]; then
    ok "застосунок ходить роллю netpulse_app (RLS діє)"
    if [ -z "$_worker" ]; then
      sc_fail "NETPULSE_WORKER_PASSWORD порожній при заданому NETPULSE_APP_PASSWORD.
       Це рівно та комбінація, у якій вхід повертає 403: шлях входу читає
       кабінети пулом воркера, а без пароля цей пул стає пулом застосунку,
       який кабінетів не бачить за політикою RLS"
    else
      ok "NETPULSE_DSN_WORKER заповнений"
    fi
    _b=$(dc exec -T db psql -tAX -U netpulse -d netpulse \
      -c "SELECT rolbypassrls FROM pg_roles WHERE rolname='netpulse_app'" 2>/dev/null | tr -d ' \r')
    case "$_b" in
      f) ok "netpulse_app без BYPASSRLS" ;;
      t) sc_fail "netpulse_app має BYPASSRLS — політики ізоляції не діють узагалі" ;;
      *) sc_fail "не вдалося спитати pg_roles: база не відповідає" ;;
    esac
  else
    warn "застосунок ходить роллю netpulse (суперкористувач) — RLS не діє.
       Ізоляцію кабінетів тримає лише предикат tenant_id у коді"
  fi

  step "шлях входу з боку бази"
  # Той самий запит, що й у store.userMemberships. Порожній результат
  # тут — це 403 на формі входу, і побачити його можна без пароля.
  _n=$(dc exec -T db psql -tAX -U netpulse -d netpulse -c "
    SELECT count(*) FROM core.memberships m
      JOIN core.tenants t ON t.id = m.tenant_id
      JOIN core.roles r   ON r.id = m.role_id
     WHERE m.accepted_at IS NOT NULL" 2>/dev/null | tr -d ' \r')
  if [ -z "$_n" ]; then
    sc_fail "не вдалося виконати запит до бази"
  elif [ "$_n" = "0" ]; then
    sc_fail "нуль членств у кабінетах: зайти в систему не зможе ніхто.
       Завести власника: docker compose run --rm --entrypoint netpulse-user cli
       -tenant default -login admin -role owner"
  else
    ok "членств у кабінетах: $_n"
  fi

  step "API"
  if dc exec -T api wget -q -O- "$API_INTERNAL/healthz" >/dev/null 2>&1; then
    ok "/healthz відповідає"
  else
    sc_fail "/healthz не відповідає — API живий як контейнер, але не як застосунок"
  fi

  # Повний вхід — лише якщо пароль дали. Без нього перевіряємо, що
  # форма входу взагалі доходить до звірки пароля: 401 тут означає, що
  # core.users читається, а 500 — що ні.
  if [ -n "$OWNER_PASSWORD" ]; then
    step "справжній вхід"
    selfcheck_live
  else
    step "форма входу"
    _r=$(api_post /api/v1/auth/login '{"login":"admin","password":"__свідомо_невірний__"}')
    case "$_r" in
      *bad_credentials*) ok "форма входу доходить до звірки пароля" ;;
      *too_many_attempts*) warn "вхід тимчасово заблокований після невдалих спроб" ;;
      *) sc_fail "форма входу відповіла не тим: $_r" ;;
    esac
    say "       Повний вхід перевіряється лише з паролем: ./netpulse check -p <пароль>"
  fi

  step "проксі"
  check_proxy

  printf '\n%s== підсумок%s\n' "$C_B" "$C_0"
  if [ -n "$SELFCHECK_FAILED" ]; then
    printf '  %sНЕ ГАРАЗД:%s\n' "$C_R" "$C_0"
    printf '%s' "$SELFCHECK_FAILED" | tr '|' '\n' | sed '/^$/d' | sed 's/^/    - /'
    printf '\n  Цей вивід можна надіслати цілком: у ньому немає паролів.\n\n'
    exit 1
  fi
  printf '  %sУсе гаразд.%s Адреса системи: https://%s\n\n' "$C_G" "$C_0" "$OUT_URL"
}

# Повний вхід у режимі check: те саме, що робить установка, але без
# права зупиняти світ — тут це діагностика, а не установка.
selfcheck_live() {
  API_TOKEN=""
  _r=$(api_post /api/v1/auth/login "{\"login\":\"admin\",\"password\":\"$OWNER_PASSWORD\"}")
  API_TOKEN=$(printf '%s' "$_r" | json_str access_token)
  if [ -z "$API_TOKEN" ]; then
    sc_fail "вхід не пройшов: $_r"
    return
  fi
  ok "вхід: пройшов"
  _tname=$(printf '%s' "$_r" | json_str tenant_name)
  if [ -n "$_tname" ]; then
    ok "кабінет назвався: $_tname"
  else
    sc_fail "вхід не повернув назви кабінету — core.tenants не віддалась"
  fi

  sc_must_lists > /tmp/np_must.$$
  while read -r _path _label; do
    [ -n "$_path" ] || continue
    _b=$(api_get "$_path")
    if json_nonempty "$_b"; then
      ok "$(printf '%s' "$_label" | tr '_' ' '): непорожньо"
    else
      sc_fail "$(printf '%s' "$_label" | tr '_' ' '): ПОРОЖНЬО"
    fi
  done < /tmp/np_must.$$
  rm -f /tmp/np_must.$$

  sc_may_lists > /tmp/np_may.$$
  while read -r _path _label; do
    [ -n "$_path" ] || continue
    if api_get "$_path" >/dev/null 2>&1; then
      ok "$(printf '%s' "$_label" | tr '_' ' '): відповідає"
    else
      sc_fail "$(printf '%s' "$_label" | tr '_' ' '): не відповів 200"
    fi
  done < /tmp/np_may.$$
  rm -f /tmp/np_may.$$
}

# ---------------------------------------------------------------------
# Пісочниця
# ---------------------------------------------------------------------
#
# Навіщо вона є. Головне твердження установника — «нова інсталяція
# піднімається сама» — довго було доведене міркуванням: сухий прогін
# проходив, окремі кроки перевірялись на живій базі, а повної установки
# з нуля не робив ніхто. Не з недбалості: єдина доступна машина — бойовий
# стенд клієнта на 4 ГБ, а другий повний стек (Postgres із буферами в
# чверть ОЗП плюс кеш плюс API плюс колектор) поклав би робочу систему.
#
# Проєкт на цьому класі помилки вже обпікся — HISTORY.md, «Перехід на
# роль без BYPASSRLS»: перевірка була ретельна, правильна й зелена, і
# дивилась повз поломку за побудовою. Висновок звідти дослівно: зелена
# перевірка доводить тільки те, що вона перевіряє. Установка, перевірена
# лише сухим прогоном, доводить, що скрипт не має синтаксичних помилок.
#
# Тому пісочниця НЕ імітує установку. Вона викликає ту саму cmd_install,
# з тим самим docker-compose.yml і тією самою selfcheck. Різниця рівно в
# чотирьох речах, і кожна або обов'язкова для ізоляції, або обов'язкова,
# щоб не з'їсти машину:
#
#   1. ім'я проєкту compose → інші контейнери, мережа й ТОМИ;
#   2. окремий .env.sandbox → бойовий .env не читається й не пишеться;
#   3. накладка deploy/docker-compose.sandbox.yml → порти зсунуті на
#      127.0.0.1, зонд без 162/udp, стелі пам'яті, restart: "no";
#   4. shared_buffers і стеля кешу — числа, а не чверть ОЗП хоста.
#
# Не міняється: порядок кроків, міграції, ролі під RLS, заведення
# власника, реєстрація зонда і всі твердження самоперевірки — вхід
# справжнім паролем, кабінет назвався, переліки не порожні, зонд
# зареєструвався. Саме тому її «готово» щось означає.
#
# І окремо про те, чого пісочниця не доводить. Вона ставить систему на
# localhost із самопідписаним сертифікатом, тому Let's Encrypt, DNS,
# прийом трапів на 162/udp і правило DOCKER-USER лишаються неперевіреними
# — це шлях, який існує лише там, де є справжній домен і справжня
# мережа. Мовчати про це не можна: перевірка, яку вважають повнішою за
# неї саму, гірша за відсутність перевірки.

SB_ONCE=0

# --- передпольотні перевірки самої пісочниці -------------------------

# Списки портів при накладанні compose-файлів ДОДАЮТЬСЯ, а не
# замінюються. Без тега !override базові «80:80» лишились би поруч зі
# зсунутими, і пісочниця вчепилась би в порт бойового проксі — тобто
# зробила б рівно те, від чого мала захистити. Тег з'явився у 2.24.4.
#
# Мовчки взяти не ті порти тут не можна: це не «трохи гірше», це
# зупинена клієнтська система. Тому перевірка версії — фатальна.
sandbox_compose_check() {
  if [ "$DC_KIND" != plugin ]; then
    bad "пісочниці потрібен docker compose v2 як плагін"
    return 1
  fi
  _cv=$(docker compose version --short 2>/dev/null)
  if ver_ge "$_cv" 2.24.4; then
    ok "docker compose $_cv — тег !override підтримується"
    return 0
  fi
  bad "docker compose $_cv — для пісочниці потрібен 2.24.4 або новіший.
       Причина конкретна: перевизначити список портів у накладці можна
       лише тегом !override, а він з'явився у 2.24.4. На старішій версії
       пісочниця спробувала б зайняти 80, 443 і 9443 — тобто порти
       бойової інсталяції. Краще відмовитись, ніж це зробити.
       Обійти без оновлення compose можна одним рядком у
       docker-compose.yml — див. шапку deploy/docker-compose.sandbox.yml"
  return 1
}

# Ціна пісочниці, порахована ДО того, як щось запущено.
#
# Тут мірялась би MemTotal, якби нас цікавило «чи потягне ця машина
# NetPulse». Але питання інше: «чи лишилось на ній стільки, щоб підняти
# ДРУГИЙ стек і не покласти перший». Відповідь на нього дає лише
# MemAvailable, і різниця між цими двома числами — це і є та поломка,
# від якої пісочниця захищає.
sandbox_resources_check() {
  _bad=0

  # Чи доведеться збирати образи. Збірка Go в контейнері — найдорожчий
  # момент усього прогону: пік пам'яті там більший, ніж у самого стека
  # в спокої, і саме на ньому машина з 4 ГБ починає свопитись.
  SB_BUILD=1
  if have docker &&
     docker image inspect "netpulse/server:dev" >/dev/null 2>&1 &&
     docker image inspect "netpulse/agent:dev"  >/dev/null 2>&1; then
    SB_BUILD=0
  fi

  if [ "$SB_BUILD" -eq 1 ]; then
    _need_mem=4096
    _need_disk=8192
    ok "образів ще немає — їх доведеться зібрати (це найдорожчий крок)"
  else
    _need_mem=3072
    _need_disk=3072
    ok "образи netpulse/server і netpulse/agent уже є — збірка буде доважною"
  fi

  # Бойовий стек поруч. Не забороняємо назавжди, але й не робимо цього
  # мовчки: людина має сказати вголос, що згодна ділити пам'ять машини
  # між моніторингом, який зараз працює, і перевіркою.
  if have docker &&
     [ -n "$(docker ps -q --filter "label=com.docker.compose.project=netpulse" 2>/dev/null | head -1)" ]; then
    if [ "$SB_ALONGSIDE" -eq 1 ]; then
      warn "поруч працює бойова інсталяція NetPulse, і ви це підтвердили (--alongside).
       Пісочниця не чіпає ані її томів, ані портів, але пам'ять і диск
       у них спільні"
    else
      bad "на цій машині ПРАЦЮЄ бойова інсталяція NetPulse.
       Пісочниця не зіпсує їй ані даних, ані портів — вона в іншому
       проєкті compose. Але пам'ять і диск у них спільні, і саме на
       цьому все й ламається: два Postgres не вміщуються там, де ледве
       вміщується один.
       Правильне місце для пісочниці — машина розробника або окрема
       віртуалка. Якщо ви все ж знаєте, що робите: --alongside"
      _bad=1
    fi
  fi

  _avail=$(host_mem_avail_mb)
  [ -n "$_avail" ] || _avail=0
  if [ "$_avail" -eq 0 ]; then
    # Не Linux або /proc недоступний. Беремо всю пам'ять і кажемо, що
    # цифра гірша: краще завищена вимога, ніж вимкнена перевірка.
    _avail=$(host_mem_mb)
    [ -n "$_avail" ] || _avail=0
    if [ "$_avail" -eq 0 ]; then
      warn "скільки пам'яті вільно — визначити не вдалося. Пісочниці треба
       щонайменше $_need_mem МБ; якщо їх немає, впаде вона або те, що
       працює поруч"
    else
      warn "MemAvailable недоступний — рахуємо по всій пам'яті ($_avail МБ),
       тобто оптимістично"
    fi
  fi
  if [ "$_avail" -gt 0 ] && [ "$_avail" -lt "$_need_mem" ]; then
    bad "вільно пам'яті $_avail МБ, а пісочниці треба $_need_mem МБ.
       Це не запас на всякий випадок: Postgres резервує shared_buffers
       одразу, і коли пам'яті бракує, ядро вбиває не того, хто попросив
       забагато, а того, хто підвернувся. Запуск у таких умовах кладе
       машину, а не показує систему"
    _bad=1
  elif [ "$_avail" -gt 0 ]; then
    ok "вільно пам'яті: $_avail МБ (треба $_need_mem)"
  fi

  _free=$(free_mb "$ROOT")
  [ -n "$_free" ] || _free=0
  if [ -d /var/lib/docker ]; then
    _freed=$(free_mb /var/lib/docker)
    [ -n "$_freed" ] || _freed=0
    [ "$_freed" -lt "$_free" ] && _free=$_freed
  fi
  if [ "$_free" -eq 0 ]; then
    warn "вільне місце виміряти не вдалося — треба щонайменше $_need_disk МБ"
  elif [ "$_free" -lt "$_need_disk" ]; then
    bad "вільно $_free МБ, а треба $_need_disk МБ.
       Самі томи пісочниці — близько 250 МБ (порожня база з накоченою
       схемою, внутрішній сертифікат Caddy, посвідчення зонда). Решта —
       образи й кеш збірки, і от вони спільні з бойовою інсталяцією:
       переповнений диск зупинить обидві одночасно"
    _bad=1
  else
    ok "вільно на диску: $_free МБ (треба $_need_disk)"
  fi

  [ "$_bad" -eq 0 ]
}

# Порти беруться зі зсуву, а не з бойових. Перевіряються ВСІ три разом:
# зайнятий один із трьох означає, що трійку треба зсувати цілком,
# інакше наступний запуск отримає інший набір і людина шукатиме систему
# не за тією адресою.
sandbox_ports_check() {
  _try=0
  while [ "$_try" -lt 20 ]; do
    SB_HTTP=$((18080 + _try * 10))
    SB_HTTPS=$((SB_HTTP + 1))
    SB_GRPC=$((SB_HTTP + 2))
    if ! port_busy "$SB_HTTP" tcp &&
       ! port_busy "$SB_HTTPS" tcp &&
       ! port_busy "$SB_GRPC" tcp; then
      if [ "$_try" -eq 0 ]; then
        ok "порти пісочниці вільні: $SB_HTTP, $SB_HTTPS, $SB_GRPC"
      else
        ok "порти пісочниці зсунуті на вільні: $SB_HTTP, $SB_HTTPS, $SB_GRPC"
      fi
      ok "усі три піднімаються ЛИШЕ на 127.0.0.1 — з мережі пісочниця не видна"
      return 0
    fi
    _try=$((_try + 1))
  done
  bad "не знайшлося вільної трійки портів у діапазоні 18080–18272.
       Найімовірніше на машині вже висить кілька пісочниць:
       ./netpulse sandbox status"
  return 1
}

# --- що після себе лишилось ------------------------------------------
#
# Джерело правди тут — docker, а не файл-позначка. Позначку не встигне
# записати вимкнене живлення, а `kill -9` не дасть її стерти; список же
# контейнерів і томів переживає і те, і те. Саме тому запитуємо його, а
# не власний стан.

sandbox_containers() {
  have docker || return 0
  docker ps -aq --filter "label=com.docker.compose.project=$SB_PROJECT" 2>/dev/null
}

sandbox_running() {
  have docker || return 0
  docker ps -q --filter "label=com.docker.compose.project=$SB_PROJECT" 2>/dev/null
}

# Два способи знайти томи: за міткою compose і за префіксом імені.
# Мітку ставить сучасний compose, префікс є завжди — а том, який не
# знайшли, це саме те, що потім тижнями займає 200 МБ.
sandbox_volumes() {
  have docker || return 0
  {
    docker volume ls -q --filter "label=com.docker.compose.project=$SB_PROJECT" 2>/dev/null
    docker volume ls -q 2>/dev/null | grep "^${SB_PROJECT}_"
  } | sort -u
}

sandbox_leftovers() {
  _c=$(sandbox_containers | grep -c . )
  _v=$(sandbox_volumes    | grep -c . )
  [ "${_c:-0}" -gt 0 ] || [ "${_v:-0}" -gt 0 ]
}

# Нагадування в чужих командах. Пісочниця, про яку забули, тримає
# кількасот мегабайтів і порт; помітити це має не той, хто через місяць
# розбиратиме нестачу місця, а той, хто наступного разу запустить
# install чи check.
sandbox_leftover_nag() {
  have docker || return 0
  sandbox_leftovers || return 0
  warn "на цій машині лишилась пісочниця ($SB_PROJECT).
       Вона не заважає цій команді — інший проєкт compose, інші томи, —
       але займає пам'ять і диск. Подивитись: ./netpulse sandbox status
       Прибрати: ./netpulse sandbox down"
}

# --- прибирання ------------------------------------------------------

# Заглушка .env для випадку «обірвало до того, як файл записався».
#
# Без неї `compose down` не виконається взагалі: у docker-compose.yml є
# обов'язкові підстановки (POSTGRES_PASSWORD, NETPULSE_DEK і далі), і
# без значень compose падає на розборі файлу — тобто прибирання
# ламається саме тоді, коли воно потрібне. Значення тут свідомо
# безглузді: ними нічого не запускається, ними лише розбирається файл.
sandbox_stub_env() {
  _old=$(umask); umask 077
  cat > "$SB_ENV" <<'STUBEOF'
# Тимчасова заглушка, створена ./netpulse sandbox down: справжній
# .env.sandbox не знайшовся. Служить рівно одному — дати compose
# розібрати файл, щоб він міг знести контейнери й томи пісочниці.
POSTGRES_PASSWORD=stub
NETPULSE_APP_PASSWORD=
NETPULSE_WORKER_PASSWORD=
NETPULSE_DEK=np1=stub
NETPULSE_JWT_SECRET=stub
NETPULSE_DOMAIN=localhost
NETPULSE_SB_HTTP=18080
NETPULSE_SB_HTTPS=18081
NETPULSE_SB_GRPC=18082
STUBEOF
  umask "$_old"
}

# Прибирання одним рухом, включно з томами. Ідемпотентне: повторний
# виклик на порожньому місці нічого не робить і не лається.
#
# Три ешелони, бо кожен наступний ловить те, чого не бачить попередній:
#   compose down -v   штатний шлях, знає про мережі й порядок;
#   docker rm/volume  те, що лишилось від обірваного `up`, коли compose
#                     ще не встиг записати повний стан проєкту;
#   rm .env.sandbox   файл, який інакше пережив би стенд і на наступному
#                     запуску виглядав би як «пісочниця вже стоїть».
sandbox_down() {
  SANDBOX=1
  ENV_FILE=$SB_ENV
  pick_compose

  if ! have docker; then
    bad "docker не знайдено — прибрати нічого не можна, бо й перевірити нічим"
    return 1
  fi

  [ -f "$SB_ENV" ] || sandbox_stub_env

  # Код виходу `down` тут свідомо не перевіряється: успіх прибирання
  # визначається не тим, що команда не лаялась, а тим, що після неї
  # нічого не лишилось. Це й перевіряється нижче, запитом до docker.
  if [ -n "$DC_KIND" ]; then
    dc down -v --remove-orphans --timeout 15 2>&1 | sed 's/^/       /'
  fi

  # Добивання. Тихо, бо на штатному шляху тут уже порожньо, і рядок
  # «нічого не видалено» лише плутав би.
  for _c in $(sandbox_containers); do
    docker rm -f "$_c" >/dev/null 2>&1
  done
  for _v in $(sandbox_volumes); do
    docker volume rm -f "$_v" >/dev/null 2>&1
  done
  docker network rm "${SB_PROJECT}_default" >/dev/null 2>&1

  # Заглушка, якщо ми її створили, зникає разом зі справжнім файлом —
  # обидва в цьому rm.
  rm -f "$SB_ENV" "$SB_ENV.bak" "$SB_ENV.tmp"

  if sandbox_leftovers; then
    bad "прибрати вдалося не все. Лишилось:"
    sandbox_containers | sed 's/^/         контейнер /'
    sandbox_volumes    | sed 's/^/         том /'
    say "       Знести руками:"
    say "         docker rm -f \$(docker ps -aq --filter label=com.docker.compose.project=$SB_PROJECT)"
    say "         docker volume rm \$(docker volume ls -q | grep '^${SB_PROJECT}_')"
    return 1
  fi
  ok "пісочниця прибрана: контейнери, мережа, ТОМИ і .env.sandbox"
  return 0
}

# Прибирання при обриві. Викликається з die і з обробника сигналів —
# тобто з обох шляхів, якими прогін може закінчитись не дійшовши кінця.
#
# Пісочниця, яка лишила по собі том на 200 МБ і контейнер, що тримає
# порт, — це та сама шкода, від якої вона мала захистити. Тому --keep
# тут НЕ діє: він означає «лиши те, що вийшло», а при обриві не вийшло
# нічого — лишились уламки.
#
# Чого ця функція не гарантує: другий Ctrl-C посеред самого прибирання
# його обірве, а kill -9 і зникнення живлення не дадуть їй виконатись
# узагалі. Саме для цих випадків install і check при кожному запуску
# питають docker, чи не висить забута пісочниця, — позначка у файлі
# такого не переживає, а список контейнерів і томів переживає.
sandbox_teardown_on_abort() {
  [ "$SANDBOX" -eq 1 ] || return 0
  [ "$SB_TORN" -eq 0 ] || return 0
  SB_TORN=1

  if [ "$SB_STARTED" -eq 0 ]; then
    # Нічого не запускалось: прибирати нічого, крім файлу.
    rm -f "$SB_ENV" "$SB_ENV.bak" "$SB_ENV.tmp"
    return 0
  fi

  printf '\n%s== прибирання пісочниці · %s%s\n' "$C_B" "${1:-обрив}" "$C_0"
  sandbox_oom_report
  if [ "$SB_KEEP" -eq 1 ]; then
    warn "з --keep уламки лишаються для розбору. Журнали:
       ./netpulse sandbox logs
       Прибрати потім ОБОВ'ЯЗКОВО: ./netpulse sandbox down"
    return 0
  fi
  say "  Журнали зникнуть разом зі стендом. Якщо вони потрібні —"
  say "  наступного разу запускайте з --keep."
  sandbox_down
}

sandbox_on_signal() {
  printf '\n\n%sПерервано.%s\n' "$C_R" "$C_0"
  # --keep свідомо не питаємо: перерваний прогін лишає по собі не
  # стенд, а половину стенду, і зберігати її за замовчуванням означало б
  # плодити саме той сміттєвий том, проти якого все це написано.
  SB_KEEP=0
  sandbox_teardown_on_abort "перервано з клавіатури"
  exit 130
}

# --- звіт про ціну ---------------------------------------------------

# Чи не вперлась якась зі служб у стелю пам'яті з накладки. Без цього
# рядка контейнер, убитий стелею, виглядає як «застосунок упав» — і
# причину шукають у коді, якого це не стосується.
sandbox_oom_report() {
  have docker || return 0
  _hit=""
  for _c in $(sandbox_containers); do
    _line=$(docker inspect -f '{{.Name}} {{.State.OOMKilled}}' "$_c" 2>/dev/null)
    case "$_line" in
      *" true") _hit="$_hit ${_line%% *}" ;;
    esac
  done
  [ -n "$_hit" ] || return 0
  bad "стеля пам'яті вбила:$_hit
       Це обмеження пісочниці (deploy/docker-compose.sandbox.yml), а не
       поломка NetPulse: у бойовій установці таких стель немає. Якщо
       впиратись стало нормою — стелю треба піднімати, а не ігнорувати"
}

# Ціна, ВИМІРЯНА, а не оцінена. Оцінка тут нічого не варта: вона й так
# уже написана в шапці, а питання «скільки це з'їло на моїй машині»
# має рівно одну чесну відповідь — подивитись.
sandbox_cost_report() {
  have docker || return 0
  _ids=$(sandbox_running)
  printf '\n  %sЩо пісочниця займає зараз%s (виміряно, не оцінено):\n\n' "$C_B" "$C_0"
  if [ -n "$_ids" ]; then
    # shellcheck disable=SC2086
    docker stats --no-stream --format '{{.Name}}\t{{.MemUsage}}' $_ids 2>/dev/null |
      awk -F'\t' '
        { u = $2; sub(/ .*/, "", u); n = u + 0
          if (u ~ /GiB/)      n *= 1024
          else if (u ~ /KiB/) n /= 1024
          else if (u ~ /iB/)  n = n
          else                n /= 1048576
          total += n
          printf "    %-30s %s\n", $1, $2 }
        END { if (total > 0) printf "\n    РАЗОМ пам'\''яті: %.0f МБ\n", total }'
  else
    say "    жоден контейнер не працює"
  fi
  printf '\n    Томи:\n'
  if docker system df -v 2>/dev/null | grep -q "^${SB_PROJECT}_"; then
    docker system df -v 2>/dev/null | grep "^${SB_PROJECT}_" |
      awk '{ printf "      %-34s %s\n", $1, $NF }'
  else
    say "      томів пісочниці не знайдено"
  fi
  printf '\n    Образи спільні з бойовою інсталяцією — пісочниця не додає до них\n'
  printf '    жодного байта, якщо вони вже зібрані.\n'
}

sandbox_summary() {
  printf '\n%s== пісочниця піднялась і пройшла ту саму самоперевірку%s\n\n' "$C_G" "$C_0"

  # У режимі once стенд знесеться за кілька секунд, і друкувати адресу з
  # паролем означало б дати людині те, що перестане працювати, поки вона
  # це читає.
  if [ "$SB_ONCE" -eq 1 ] && [ "$SB_KEEP" -eq 0 ]; then
    printf '  Режим once: стенд зараз буде знесено разом із томами.\n'
    printf '  Щоб подивитись на систему — ./netpulse sandbox без «once».\n'
  else
    printf '  Адреса:  https://localhost:%s\n' "$SB_HTTPS"
    printf '           сертифікат самопідписаний — браузер попередить, це очікувано\n'
    printf '  Логін:   admin\n'
    printf '  Пароль:  %s%s%s\n' "$C_B" "${OUT_OWNER_PW:-$SB_OWNER_PW}" "$C_0"
    printf '           (лежить у %s — пісочниця одноразова,\n' "$SB_ENV"
    printf '           у бойовій установці пароль ніде не зберігається)\n'
    printf '  Зонди:   порт %s, назовні не виставлений\n' "$SB_GRPC"
  fi

  sandbox_cost_report

  printf '\n  %sЩо саме щойно доведено%s\n' "$C_B" "$C_0"
  printf '    Той самий docker-compose.yml, ті самі міграції, ті самі ролі\n'
  printf '    під RLS, той самий вхід справжнім паролем через HTTP, ті самі\n'
  printf '    переліки й та сама реєстрація зонда в колекторі.\n'
  printf '\n  %sЧого НЕ доведено%s\n' "$C_B" "$C_0"
  printf '    Let'\''s Encrypt і DNS: тут localhost і самопідписаний сертифікат.\n'
  printf '    Прийом трапів на 162/udp і правило DOCKER-USER: порт свідомо не\n'
  printf '    виставлявся. Поведінка під навантаженням: база порожня.\n'
  printf '    Розрахунок shared_buffers з ОЗП: у пісочниці він заданий числом.\n'
  if [ "$SB_ONCE" -eq 0 ] || [ "$SB_KEEP" -eq 1 ]; then
    printf '\n  %sПрибрати одним рухом:%s ./netpulse sandbox down\n' "$C_R" "$C_0"
    printf '  Знести її забувши — значить лишити контейнери, порт і томи.\n'
  fi
  printf '\n'
}

# --- команди ---------------------------------------------------------

cmd_sandbox() {
  _sub=${1:-up}
  [ $# -gt 0 ] && shift

  # Прапорці ПІСЛЯ підкоманди. Загальний розбирач їх не бачить: він
  # зупиняється на першому не-прапорці, а це і є підкоманда. Проковтнути
  # їх мовчки не можна: людина, яка написала `sandbox once --dry-run` і
  # отримала справжній запуск, має рацію, і помилка тут наша.
  while [ $# -gt 0 ]; do
    case "$1" in
      --dry-run|-n) DRY=1 ;;
      --keep)       SB_KEEP=1 ;;
      --alongside)  SB_ALONGSIDE=1 ;;
      -p)           shift; OWNER_PASSWORD=${1:-} ;;
      *)            break ;;
    esac
    shift
  done

  case "$_sub" in
    up)     cmd_sandbox_up ;;
    once)   SB_ONCE=1; cmd_sandbox_up ;;
    down)   cmd_sandbox_down ;;
    status) cmd_sandbox_status ;;
    check)  cmd_sandbox_check ;;
    logs)   SANDBOX=1; ENV_FILE=$SB_ENV; pick_compose
            [ -f "$SB_ENV" ] || die "Пісочниці немає: $SB_ENV не знайдено."
            dc logs -f --tail=200 "$@" ;;
    *)      printf 'невідома підкоманда пісочниці: %s\n' "$_sub"
            printf 'є: up, once, down, status, check, logs\n'
            exit 2 ;;
  esac
}

cmd_sandbox_up() {
  SANDBOX=1
  ENV_FILE=$SB_ENV
  # netpulse.conf не читається взагалі — див. read_conf. Шлях підміняємо
  # на явно неіснуючий, щоб випадкове звернення до нього не взяло чужих
  # відповідей.
  CONF_FILE="$ROOT/.netpulse.conf.НЕ-ЧИТАЄТЬСЯ-У-ПІСОЧНИЦІ"

  # Пастка ставиться ДО першої дії. Ctrl-C між `up` і самоперевіркою —
  # найімовірніший спосіб отримати покинутий стенд, бо саме там прогін
  # найдовший.
  trap 'sandbox_on_signal' INT TERM HUP

  printf '%sNetPulse · пісочниця%s\n' "$C_B" "$C_0"
  say "Та сама установка, що поїде клієнту, в окремому проєкті compose"
  say "($SB_PROJECT), з окремими томами й портами на 127.0.0.1."
  [ "$DRY" -eq 1 ] && say "Сухий прогін: нічого не запускається й не пишеться."

  if [ "$DRY" -eq 0 ] && have docker && sandbox_leftovers; then
    die "Пісочниця вже стоїть на цій машині." \
        "Ставити другу поверх неї не можна: вони поділять ім'я проєкту," \
        "тобто й томи, і повний прогін з нуля перестане бути прогоном з нуля." \
        "" \
        "  ./netpulse sandbox status   що там зараз" \
        "  ./netpulse sandbox down     знести й почати чисто"
  fi

  cmd_install

  if [ "$DRY" -eq 1 ]; then
    trap - INT TERM HUP
    return 0
  fi

  if [ "$SB_ONCE" -eq 1 ]; then
    # Режим «перевірити й не лишати слідів»: саме він потрібен перед
    # випуском. Прибирання тут не аварійне, а планове, тому --keep його
    # скасовує — на відміну від обриву.
    if [ "$SB_KEEP" -eq 1 ]; then
      warn "--once і --keep разом: стенд лишається, як просили --keep"
    else
      SB_TORN=1
      printf '\n%s== прибирання (режим once)%s\n' "$C_B" "$C_0"
      sandbox_down || exit 1
      printf '\n  %sУстановка з нуля пройшла повністю, слідів не лишилось.%s\n\n' "$C_G" "$C_0"
    fi
  fi

  # Успіх: далі Ctrl-C не має права знести те, що щойно піднялось.
  SB_TORN=1
  trap - INT TERM HUP
}

cmd_sandbox_down() {
  printf '%sNetPulse · прибирання пісочниці%s\n\n' "$C_B" "$C_0"
  STEP_NAME="прибирання"
  if ! have docker; then
    die "docker не знайдено."
  fi
  if ! sandbox_leftovers && [ ! -f "$SB_ENV" ]; then
    ok "пісочниці немає — прибирати нічого"
    return 0
  fi
  sandbox_down || exit 1
  printf '\n'
}

cmd_sandbox_status() {
  SANDBOX=1
  ENV_FILE=$SB_ENV
  pick_compose
  printf '%sNetPulse · стан пісочниці%s\n\n' "$C_B" "$C_0"
  if ! have docker; then
    say "  docker не знайдено — стан невідомий"
    return 0
  fi
  if ! sandbox_leftovers; then
    say "  пісочниці немає: ані контейнерів, ані томів"
    [ -f "$SB_ENV" ] && warn "але лишився $SB_ENV — прибрати: ./netpulse sandbox down"
    return 0
  fi
  [ -f "$SB_ENV" ] || sandbox_stub_env
  if [ -n "$DC_KIND" ]; then
    dc ps 2>&1 | sed 's/^/  /'
  fi
  sandbox_oom_report
  sandbox_cost_report
  if [ -f "$SB_ENV" ]; then
    _p=$(sed -n 's/^NETPULSE_SB_HTTPS=//p' "$SB_ENV" | tail -1)
    [ -n "$_p" ] && printf '\n  Адреса: https://localhost:%s (логін admin)\n' "$_p"
  fi
  printf '\n  Прибрати: ./netpulse sandbox down\n\n'
}

# Повторна перевірка живої пісочниці — тією самою cmd_check, що й на
# бойовій системі, і з паролем, тобто ПОВНА. Саме заради цього пароль і
# лежить у .env.sandbox: без нього check мовчки пропустив би справжній
# вхід — рівно ту частину, яка колись і виявилась зламаною.
cmd_sandbox_check() {
  SANDBOX=1
  ENV_FILE=$SB_ENV
  [ -f "$SB_ENV" ] || die "Пісочниці немає: $SB_ENV не знайдено." \
    "Підняти: ./netpulse sandbox"
  [ -n "$OWNER_PASSWORD" ] || OWNER_PASSWORD=$(env_get "$SB_PW_KEY")
  cmd_check
}

# ---------------------------------------------------------------------
# backup / restore / upgrade / logs
# ---------------------------------------------------------------------

cmd_backup() {
  printf '%sNetPulse · бекап%s\n' "$C_B" "$C_0"
  pick_compose
  [ -f "$ENV_FILE" ] || die "Немає $ENV_FILE."

  _dir="$ROOT/backups"
  mkdir -p "$_dir" || die "Не вдалося створити $_dir"
  _stamp=$(date +%Y-%m-%d-%H%M)
  _dump="$_dir/netpulse-$_stamp.dump"
  _keys="$_dir/netpulse-$_stamp.keys"

  step "дамп бази"
  # Формат custom, а не простий SQL: стискається і дозволяє відновлювати
  # вибірково. --no-owner — бо ролі на цільовій машині можуть бути інші.
  dc exec -T db pg_dump -U netpulse -d netpulse -Fc --no-owner > "$_dump" \
    || die "pg_dump не відпрацював; неповний файл лишився як $_dump"
  _size=$(wc -c < "$_dump" | tr -d ' ')
  [ "${_size:-0}" -gt 1024 ] || die \
    "Дамп вийшов розміром $_size байтів — це порожній або обірваний файл." \
    "Такий файл виглядає як бекап і не є ним."
  ok "$_dump, $((_size / 1024)) КБ"

  step "ключі"
  # Без цих двох рядків дамп не відновлюється в робочу систему: у базі
  # лежить лише шифротекст секретів.
  _old=$(umask); umask 077
  {
    printf '# Ключі до дампа netpulse-%s.dump\n' "$_stamp"
    printf '# ЗБЕРІГАТИ ОКРЕМО ВІД ДАМПА: разом вони — готовий доступ до всіх\n'
    printf '# паролів SSH і SNMP-community з інвентарю.\n'
    printf 'NETPULSE_DEK=%s\n' "$(env_get NETPULSE_DEK)"
    printf 'NETPULSE_JWT_SECRET=%s\n' "$(env_get NETPULSE_JWT_SECRET)"
  } > "$_keys"
  umask "$_old"
  chmod 600 "$_keys"
  ok "$_keys (права 0600)"

  printf '\n  Дамп без ключів не відновлюється, ключі поруч із дампом\n'
  printf '  скасовують шифрування. Переносьте їх різними шляхами.\n\n'
}

cmd_restore() {
  printf '%sNetPulse · відновлення%s\n' "$C_B" "$C_0"
  pick_compose
  [ -n "$RESTORE_FILE" ] || die "Не вказано файл: ./netpulse restore -f <дамп>"
  [ -f "$RESTORE_FILE" ] || die "Файл $RESTORE_FILE не знайдено."

  if [ "$ASSUME_YES" -eq 0 ]; then
    printf '\n  %sПоточна база буде знищена%s і замінена вмістом\n' "$C_R" "$C_0"
    printf '  %s\n' "$RESTORE_FILE"
    printf '  Повторіть із -y, якщо це саме те, що потрібно.\n\n'
    exit 1
  fi

  step "зупинка застосунку"
  dc stop api collector || die "Не вдалося зупинити api і collector."
  ok "api і collector зупинені"

  step "наливання"
  # Рамка timescaledb_pre_restore/post_restore обов'язкова: без неї
  # фонові процеси агрегації втручаються в наливання, і дамп лягає
  # пошкодженим — мовчки.
  dc exec -T db psql -v ON_ERROR_STOP=1 -U netpulse -d postgres \
    -c 'DROP DATABASE IF EXISTS netpulse; CREATE DATABASE netpulse;' >/dev/null \
    || die "Не вдалося перестворити базу."
  dc exec -T db psql -v ON_ERROR_STOP=1 -U netpulse -d netpulse \
    -c 'CREATE EXTENSION IF NOT EXISTS timescaledb; SELECT timescaledb_pre_restore();' >/dev/null \
    || die "Не вдалося перевести TimescaleDB у режим відновлення."
  dc exec -T db pg_restore -U netpulse -d netpulse --no-owner < "$RESTORE_FILE" \
    || warn "pg_restore повернув помилки — перегляньте їх перед тим, як користуватись"
  dc exec -T db psql -v ON_ERROR_STOP=1 -U netpulse -d netpulse \
    -c 'SELECT timescaledb_post_restore();' >/dev/null \
    || die "Не вдалося вивести TimescaleDB із режиму відновлення."
  ok "дані налиті"

  step "запуск"
  dc up -d api collector || die "Не вдалося підняти api і collector."
  ok "api і collector підняті"

  printf '\n  У .env має лежати ТОЙ САМИЙ NETPULSE_DEK, що й на момент дампа.\n'
  printf '  Інакше система підніметься, а кожна спроба скористатись збереженим\n'
  printf '  паролем поверне помилку розшифрування — і виглядатиме це як\n'
  printf '  зламані креденшели, а не як втрачений ключ.\n\n'
  printf '  Перевірити стан: ./netpulse check\n\n'
}

cmd_upgrade() {
  printf '%sNetPulse · оновлення%s\n' "$C_B" "$C_0"
  pick_compose
  [ -f "$ENV_FILE" ] || die "Немає $ENV_FILE."

  # Відкат схеми не передбачений: зворотні міграції на телеметрії
  # коштують дорожче, ніж відновлення з дампа. Тому дамп тут не
  # ввічливість, а єдиний шлях назад.
  cmd_backup

  step "перезбирання"
  dc build || die "Збірка образів не вдалася."
  ok "образи зібрані"

  step "міграції"
  dc run --rm migrate || die \
    "Міграції не накотились — API навмисно не піднімається зі старою схемою." \
    "Уже застосований файл зі зміненою контрольною сумою зупиняє запуск:" \
    "це захист від мовчазного розходження схеми з кодом."
  ok "схема накочена"

  step "перезапуск"
  dc up -d || die "Не вдалося перезапустити служби."
  ok "служби перезапущені"

  printf '\n  Перевірити: ./netpulse check\n\n'
}

cmd_logs() {
  pick_compose
  if [ $# -gt 0 ]; then
    dc logs -f --tail=200 "$@"
  else
    dc logs -f --tail=200
  fi
}

# ---------------------------------------------------------------------
# Розбір командного рядка
# ---------------------------------------------------------------------

usage() {
  cat <<'USAGE'
NetPulse — установка й обслуговування.

  ./netpulse install [--dry-run]   поставити; повторний запуск безпечний
  ./netpulse check   [-p ПАРОЛЬ]   перевірити живу систему
  ./netpulse backup                дамп бази + ключі до нього
  ./netpulse restore -f ДАМП -y    відновити з дампа
  ./netpulse upgrade               перезібрати, накотити міграції
  ./netpulse logs [служба]         журнали

Пісочниця — та сама установка з нуля, але в ізоляції: окремий проєкт
compose, окремі томи, порти на 127.0.0.1, задані числами ресурси.
Потрібна, щоб перевіряти повну установку, не маючи чистої машини.

  ./netpulse sandbox               підняти й лишити, щоб подивитись
  ./netpulse sandbox once          підняти, перевірити, знести все
  ./netpulse sandbox check         та сама перевірка ще раз, з паролем
  ./netpulse sandbox status        що вона зараз займає
  ./netpulse sandbox down          знести все, включно з томами
  ./netpulse sandbox logs [служба] журнали пісочниці

  --keep        не прибирати після невдачі (для розбору журналів)
  --alongside   дозволити запуск поруч із бойовою інсталяцією

Відповіді на п'ять питань, яких система не може вирішити сама, —
у netpulse.conf. Зразок із поясненнями: netpulse.conf.example.
Без цього файлу install ставить робочу систему на типових відповідях.
Пісочниця netpulse.conf НЕ читає навмисно: справжній DOMAIN звідти
відправив би її по сертифікат для чужої адреси.
USAGE
}

CMD=${1:-}
[ $# -gt 0 ] && shift

while [ $# -gt 0 ]; do
  case "$1" in
    --dry-run|-n) DRY=1 ;;
    -y|--yes)     ASSUME_YES=1 ;;
    -p)           shift; OWNER_PASSWORD=${1:-} ;;
    -f)           shift; RESTORE_FILE=${1:-} ;;
    -c)           shift; CONF_FILE=${1:-} ;;
    --keep)       SB_KEEP=1 ;;
    --alongside)  SB_ALONGSIDE=1 ;;
    -h|--help)    usage; exit 0 ;;
    *)            break ;;
  esac
  shift
done

case "$CMD" in
  install) cmd_install ;;
  check)   cmd_check ;;
  sandbox) cmd_sandbox "$@" ;;
  backup)  cmd_backup ;;
  restore) cmd_restore ;;
  upgrade) cmd_upgrade ;;
  logs)    cmd_logs "$@" ;;
  ""|-h|--help|help) usage ;;
  *) printf 'невідома команда: %s\n\n' "$CMD"; usage; exit 2 ;;
esac
