«журнал аудиту 0 → 0 ok» читалось як доказ роботи журналу. Насправді установник робить рівно три речі, і всі три — у переліку сліпих зон (store.AuditBlindSpots): власник, строки зберігання, локальний зонд. Дані пісочниця наливає прямим SQL, повз API. Нуль тут очікуваний. Пробу лишено (покаже, якщо колись почнуть писати), але поруч тепер сказано, що правило min на нулі не доводить нічого. І додано пробу, яка доводить: спроби входу — самоперевірка заходить у продукт, порожня таблиця означала б, що записування входів відвалилось. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3761 lines
196 KiB
Bash
3761 lines
196 KiB
Bash
#!/usr/bin/env sh
|
||
#
|
||
# NetPulse — установка й обслуговування однією командою.
|
||
#
|
||
# Чому це існує. Розгортання складалося з вісімнадцяти змінних оточення
|
||
# й шестисот рядків інструкції. Рішень людини серед тих вісімнадцяти —
|
||
# п'ять; решта це або секрети, які не можна давати вводити руками, або
|
||
# значення, у яких є одна правильна відповідь, або PG_SHARED_BUFFERS,
|
||
# який мусить рахуватись із пам'яті машини: помилка в ньому на хості з
|
||
# 4 ГБ — це не «повільніше», це «Postgres не піднявся».
|
||
#
|
||
# Процедуру, яку клієнт не може виконати правильно з першого разу, не
|
||
# рятує докладніша інструкція. Її рятує те, що виконувати нічого.
|
||
#
|
||
# ./netpulse install поставити (ідемпотентно: можна повторювати)
|
||
# ./netpulse check лікар: ті самі твердження на живій системі
|
||
# ./netpulse sandbox та сама установка з нуля, але в ізоляції
|
||
# ./netpulse sandbox upgrade те саме для шляху «стояла стара, стала нова»
|
||
# ./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)
|
||
# Незмінна копія кореня. ROOT у режимі оновлення пісочниці навмисно
|
||
# перемикається між деревом попередньої версії й цим — а git, звіт і
|
||
# шляхи прибирання мусять і далі вказувати сюди.
|
||
ROOT_MAIN=$ROOT
|
||
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"
|
||
# Початок діапазону портів. Змінна, а не число в коді, бо режимів
|
||
# пісочниці два, і два стенди з однаковою трійкою портів — це не
|
||
# «незручно», а мовчазна поразка одного з них на першому ж `up`.
|
||
SB_PORT_BASE=18080
|
||
SB_HTTP=18080
|
||
SB_HTTPS=18081
|
||
SB_GRPC=18082
|
||
SB_KEEP=0
|
||
SB_ALONGSIDE=0
|
||
|
||
# Значення для режиму «установка з нуля» запам'ятовуємо окремо: режим
|
||
# оновлення перезаписує SB_PROJECT і SB_ENV, а прибирання мусить знати
|
||
# ОБИДВА набори — інакше `sandbox down` знесе одну пісочницю й лишить
|
||
# другу, тобто зробить рівно половину того, заради чого його кличуть.
|
||
SB_CLEAN_PROJECT=$SB_PROJECT
|
||
SB_CLEAN_ENV=$SB_ENV
|
||
|
||
# --- режим оновлення -------------------------------------------------
|
||
#
|
||
# Окремий проєкт compose, окремі томи, окрема мережа й окрема трійка
|
||
# портів, свідомо винесена ЗА межі діапазону, який перебирає режим
|
||
# установки з нуля (18080…18272). Дві пісочниці на одній машині мають
|
||
# або співіснувати, або відмовитись стартувати — але ніколи не
|
||
# перетинатись мовчки.
|
||
SB_UP=0
|
||
SB_UP_PROJECT=netpulse-sandbox-upgrade
|
||
SB_UP_PORT_BASE=18300
|
||
SB_UP_ENV_NAME=.env.sandbox-upgrade
|
||
# Дерево попередньої версії. Лежить у каталозі проєкту, а не в /tmp, і
|
||
# це не байдуже: на половині машин /tmp — це tmpfs, тобто повний другий
|
||
# примірник вихідних текстів разом зі зібраним веб-інтерфейсом ліг би в
|
||
# ОПЕРАТИВНУ пам'ять — рівно ту, якої пісочниці й так ледве вистачає.
|
||
# Плюс міряти вільне місце під $ROOT і писати в іншу файлову систему
|
||
# означало б міряти не те. З контексту збірки каталог виключено
|
||
# (.dockerignore), інакше кожен `docker build` тягнув би подвійне дерево.
|
||
SB_UP_TREE="$ROOT/.sandbox-prev"
|
||
SB_UP_REF=""
|
||
SB_UP_SAME_SCHEMA=0
|
||
SB_UP_NEW_MIGRATIONS=""
|
||
# Мітка часу з БОКУ БАЗИ, знята перед перезапуском служб на новій
|
||
# версії. Годинник хоста тут не годиться: порівнюємо ми з колонкою
|
||
# timestamptz, і розбіжність у секунди дала б хибну відповідь у обидва
|
||
# боки.
|
||
SB_UP_T0=""
|
||
# Чи встигли ми щось запустити. Від цього залежить, чи прибирати за
|
||
# собою при обриві: до першого `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
|
||
# Етап — рівень крупніший за крок. Потрібен лише там, де кроки йдуть
|
||
# двома різними прогонами поспіль (режим оновлення: спершу попередня
|
||
# версія, потім нова), і без нього «ЗУПИНКА на кроці „схема й ролі“» не
|
||
# каже головного — чиї саме міграції не накотились, старі чи нові.
|
||
PHASE_NAME=""
|
||
|
||
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"
|
||
}
|
||
|
||
# Нумерація кроків починається заново на кожному етапі: наскрізний
|
||
# «крок 23» нічого не каже, а «етап В, крок 2» читається вголос.
|
||
phase() {
|
||
PHASE_NAME=$1
|
||
STEP_NO=0
|
||
printf '\n%s######## ЕТАП %s ########%s\n' "$C_B" "$PHASE_NAME" "$C_0"
|
||
shift
|
||
for _l in "$@"; do printf ' %s\n' "$_l"; done
|
||
}
|
||
|
||
# die зупиняє все й називає КРОК. Назва кроку тут не оздоба: людина
|
||
# читає її вголос у телефон, і від неї залежить, чи розбирається далі
|
||
# вона сама, чи надсилає вивід.
|
||
die() {
|
||
if [ -n "$PHASE_NAME" ]; then
|
||
printf '\n%sЗУПИНКА на етапі «%s», крок «%s»%s\n' \
|
||
"$C_R" "$PHASE_NAME" "$STEP_NAME" "$C_0"
|
||
else
|
||
printf '\n%sЗУПИНКА на кроці «%s»%s\n' "$C_R" "$STEP_NAME" "$C_0"
|
||
fi
|
||
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=""
|
||
# Тег образів. Досі був вписаний у .env числом «dev», і поки версія одна
|
||
# на весь проєкт, це нікому не заважало. Режим оновлення це змінює: у
|
||
# ньому на машині одночасно існують ДВІ збірки NetPulse, і якби обидві
|
||
# називались netpulse/server:dev, друга мовчки перетерла б першу. Гірше:
|
||
# обірваний прогін лишив би тег dev вказувати на СТАРУ збірку — а на
|
||
# нього дивиться бойова інсталяція, яка ділить із пісочницею реєстр
|
||
# образів. Тому тег став змінною.
|
||
VAL_VERSION=dev
|
||
VAL_COMMIT=none
|
||
|
||
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=$VAL_VERSION
|
||
NETPULSE_COMMIT=$VAL_COMMIT
|
||
|
||
# Локальний зонд: базовий моніторинг самої інсталяції.
|
||
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"
|
||
|
||
wait_api
|
||
}
|
||
|
||
# Винесено окремо, бо чекають на API двоє: установка й оновлення. Дві
|
||
# копії цього циклу розійшлися б у строках, і різниця вилізла б там, де
|
||
# її найважче помітити, — у хибному «не відповів» на повільній машині.
|
||
wait_api() {
|
||
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 60 ]; do
|
||
_agents=$(api_get /api/v1/agents)
|
||
json_nonempty "$_agents" && break
|
||
# Кожні півхвилини кажемо, що саме чекаємо: мовчазна пауза на три
|
||
# хвилини невідрізненна від зависання.
|
||
case "$_i" in
|
||
10|20|30|40|50) say " чекаємо на реєстрацію зонда ($((_i * 3)) с)" ;;
|
||
esac
|
||
_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 [ "$SB_UP" -eq 1 ]; then
|
||
printf '\n %sЕТАП А завершено:%s попередня версія стоїть і пройшла ту саму\n' \
|
||
"$C_G" "$C_0"
|
||
printf ' самоперевірку, що й чиста установка. Це ще не доказ оновлення —\n'
|
||
printf ' це лише те, з чого оновлюватись.\n'
|
||
return 0
|
||
fi
|
||
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:$VAL_VERSION" >/dev/null 2>&1 &&
|
||
docker image inspect "netpulse/agent:$VAL_VERSION" >/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:$VAL_VERSION і netpulse/agent:$VAL_VERSION уже є"
|
||
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
|
||
|
||
# Друга ПІСОЧНИЦЯ поруч. Причина та сама, що й із бойовим стеком, і
|
||
# цифри ті самі: два Postgres не вміщуються там, де ледве вміщується
|
||
# один. Але --alongside тут навмисно НЕ рятує: бойову інсталяцію
|
||
# людина могла свідомо вирішити потіснити на власній машині, а друга
|
||
# пісочниця — це просто забутий прогін, і правильна відповідь на неї
|
||
# рівно одна, у два слова.
|
||
if [ "$SB_PROJECT" = "$SB_UP_PROJECT" ]; then
|
||
_neigh=$SB_CLEAN_PROJECT
|
||
else
|
||
_neigh=$SB_UP_PROJECT
|
||
fi
|
||
if have docker &&
|
||
[ -n "$(docker ps -q --filter "label=com.docker.compose.project=$_neigh" 2>/dev/null | head -1)" ]; then
|
||
bad "на цій машині вже працює ІНША пісочниця NetPulse ($_neigh).
|
||
Томи, мережа й порти в них різні — зіпсувати одна одну вони не
|
||
можуть. Пам'ять і диск спільні, і саме тут усе й ламається.
|
||
Прибрати: ./netpulse sandbox down"
|
||
_bad=1
|
||
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=$((SB_PORT_BASE + _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 "не знайшлося вільної трійки портів у діапазоні $SB_PORT_BASE–$((SB_PORT_BASE + 192)).
|
||
Найімовірніше на машині вже висить кілька пісочниць:
|
||
./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
|
||
# Обидва режими, бо забути можна будь-який, а лишає по собі кожен
|
||
# свій повний Postgres. Ім'я проєкту після циклу повертаємо: ця
|
||
# функція діагностична й не має права міняти стан того, хто її кликав.
|
||
_keep=$SB_PROJECT
|
||
for SB_PROJECT in "$SB_CLEAN_PROJECT" "$SB_UP_PROJECT"; do
|
||
sandbox_leftovers || continue
|
||
warn "на цій машині лишилась пісочниця ($SB_PROJECT).
|
||
Вона не заважає цій команді — інший проєкт compose, інші томи, —
|
||
але займає пам'ять і диск. Подивитись: ./netpulse sandbox status
|
||
Прибрати: ./netpulse sandbox down"
|
||
done
|
||
SB_PROJECT=$_keep
|
||
if [ -d "$SB_UP_TREE" ]; then
|
||
warn "лишилось дерево попередньої версії: $SB_UP_TREE
|
||
Прибрати: ./netpulse sandbox down"
|
||
fi
|
||
}
|
||
|
||
# --- прибирання ------------------------------------------------------
|
||
|
||
# Заглушка .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
|
||
# Нічого не запускалось: прибирати нічого, крім файлів. Дерево
|
||
# попередньої версії сюди входить — воно розгортається ДО першого
|
||
# `up`, тобто саме в цьому вікні його й можна забути.
|
||
rm -f "$SB_ENV" "$SB_ENV.bak" "$SB_ENV.tmp"
|
||
sandbox_up_drop_leftovers
|
||
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_up_drop_leftovers
|
||
}
|
||
|
||
# Те, що лишає по собі лише режим оновлення: дерево попередньої версії
|
||
# (кількасот мегабайтів вихідних текстів і зібраного веб-інтерфейсу) і
|
||
# два власні теги образів. Лишити їх означає зробити рівно ту шкоду, від
|
||
# якої пісочниця захищає, тільки не томом, а каталогом і реєстром.
|
||
sandbox_up_drop_leftovers() {
|
||
[ "$SB_UP" -eq 1 ] || return 0
|
||
|
||
if [ -d "$SB_UP_TREE" ]; then
|
||
rm -f "$SB_UP_TREE/$SB_UP_ENV_NAME"
|
||
rm -rf "$SB_UP_TREE" ||
|
||
bad "не вдалося видалити дерево попередньої версії $SB_UP_TREE — приберіть руками"
|
||
fi
|
||
|
||
# Теги свої, тому видаляти їх безпечно: netpulse/server:dev, який
|
||
# ділить із нами бойова інсталяція, тут не згадується жодним словом —
|
||
# і не має.
|
||
have docker || return 0
|
||
for _i in netpulse/server netpulse/agent; do
|
||
for _t in sandbox-prev sandbox-new; do
|
||
docker image rm -f "$_i:$_t" >/dev/null 2>&1
|
||
done
|
||
done
|
||
return 0
|
||
}
|
||
|
||
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'
|
||
}
|
||
|
||
# ---------------------------------------------------------------------
|
||
# Пісочниця, режим другий: ОНОВЛЕННЯ З ВЕРСІЇ НА ВЕРСІЮ
|
||
# ---------------------------------------------------------------------
|
||
#
|
||
# НАВІЩО. Перший режим доводить, що інсталяція з нуля піднімається — і
|
||
# саме він знайшов дві вади, які інакше зустрів би перший клієнт. Але
|
||
# установку з нуля клієнт робить один раз, а оновлення — щоразу, і
|
||
# ламається воно частіше. Три причини названі в ROADMAP (Етап 13) і
|
||
# жодну з них не перевіряє ніщо: міграції котяться не на порожню базу, а
|
||
# на ту, де вже лежать чужі дані; конфігурація змінюється; зонд у мережі
|
||
# клієнта лишається старим, а колектор стає новим. Сьогодні цей шлях
|
||
# уперше проходить клієнт.
|
||
#
|
||
# ЩО ТАКЕ «ПОПЕРЕДНЯ ВЕРСІЯ», ЯКЩО ВЕРСІОНУВАННЯ ЩЕ НЕМАЄ
|
||
#
|
||
# Питання не риторичне, і від відповіді залежить, чого вартий увесь
|
||
# прогін. Тегів немає, реєстру немає, образ завжди netpulse/server:dev.
|
||
# Варіантів було три.
|
||
#
|
||
# ТЕГ — найправильніший і сьогодні неможливий. Перевірка оновлення є
|
||
# вхідним квитком ДО першого тегу; вимагати тег означало б вимагати
|
||
# те, заради чого вона й пишеться. Коло замикається на собі.
|
||
#
|
||
# ЗБЕРЕЖЕНИЙ ДАМП БАЗИ — найдешевший і доводить найменше. Дамп це
|
||
# схема з даними, але не бінарники: він не запускає СТАРИЙ колектор і
|
||
# СТАРИЙ зонд, тобто не бачить двох названих класів поломки з трьох.
|
||
# Гірше інше: дамп старіє мовчки. Його зробила версія, яку вже ніхто
|
||
# не збере, і коли прогін почервоніє, розрізнити «зламався код» і
|
||
# «протух дамп» буде нічим. Перевірка, яка вміє брехати про причину,
|
||
# гірша за відсутність перевірки — цей проєкт уже платив за це.
|
||
#
|
||
# КОМІТ GIT — обрано. Коміт це ПОВНЕ дерево: Go-код, міграції,
|
||
# docker-compose.yml, Caddyfile, Dockerfile. З нього збираються
|
||
# справжні старі образи, тобто попередня версія тут не описана, а
|
||
# виконується. Він відтворюваний: той самий ref дасть той самий стенд
|
||
# і за півроку. І він не тимчасове рішення — тег у git ТЕЖ ref, тому в
|
||
# день першого тегу тут не зміниться жодного рядка: `--from v0.1.0`
|
||
# запрацює сам.
|
||
#
|
||
# Типовий ref не вписаний числом, а обчислюється: найновіший коміт, у
|
||
# якого міграцій МЕНШЕ, ніж у HEAD. Причина та сама, що й у решті цього
|
||
# файлу: зелений прогін, який нічого не перевірив, гірший за відсутність
|
||
# прогону. Оновлення без жодної нової міграції доводить, що служби
|
||
# перезапустились, — і мовчки видається за доказ, що міграції котяться
|
||
# поверх даних.
|
||
#
|
||
# ЯК ЦЕ ВЛАШТОВАНО
|
||
#
|
||
# Обидва етапи веде ОДИН І ТОЙ САМИЙ установник — цей файл. Дерево
|
||
# попередньої версії підставляється через $ROOT, тобто старий
|
||
# `./netpulse` не виконується ніколи. Це свідомо: вимірювальний прилад
|
||
# має бути тим самим на обох кінцях вимірювання, інакше різниця в
|
||
# приладі читається як різниця у виробі. Під перевіркою тут не
|
||
# установник, а те, що він ставить: код, міграції, compose-файл.
|
||
# Наслідок, про який треба сказати вголос: зміни в самому УСТАНОВНИКУ
|
||
# між версіями цей режим не перевіряє.
|
||
#
|
||
# ІЗОЛЯЦІЯ. Окреме ім'я проєкту compose (netpulse-sandbox-upgrade) —
|
||
# отже окремі контейнери, окрема мережа й окремі ТОМИ. Окремий
|
||
# .env.sandbox-upgrade; бойовий .env не читається й не пишеться.
|
||
# Окрема трійка портів від 18300, свідомо винесена за межі діапазону
|
||
# першого режиму (18080…18272) — дві пісочниці не мають перетнутись
|
||
# навіть тоді, коли обидві шукають вільний порт. netpulse.conf не
|
||
# читається (див. read_conf), правило DOCKER-USER не додається
|
||
# (VAL_TRAPS_SRC порожній), стан хоста не змінюється взагалі.
|
||
#
|
||
# ЧОГО ЦЕЙ РЕЖИМ НЕ ДОВОДИТЬ — див. sandbox_up_summary. Абзац там не
|
||
# ввічливість: перевірка, яку вважають повнішою за неї саму, шкідливіша
|
||
# за її відсутність.
|
||
|
||
# --- звідки береться попередня версія --------------------------------
|
||
|
||
sandbox_up_git_ok() {
|
||
have git || return 1
|
||
git -C "$ROOT_MAIN" rev-parse --git-dir >/dev/null 2>&1
|
||
}
|
||
|
||
# Скільки файлів міграцій у дереві коміта. Рахуємо файли в git, а не
|
||
# рядки в schema_migrations: питання ставиться ДО того, як щось
|
||
# запущено, і відповідь на нього має бути ще на етапі «а чи варто».
|
||
sandbox_up_migr_count() {
|
||
git -C "$ROOT_MAIN" ls-tree -r --name-only "$1" -- server/migrations 2>/dev/null |
|
||
grep -c '\.sql$'
|
||
}
|
||
|
||
sandbox_up_resolve_ref() {
|
||
step "яку версію вважати попередньою"
|
||
|
||
sandbox_up_git_ok || die \
|
||
"Попередню версію цей режим бере з git, а тут git недоступний" \
|
||
"(немає команди або каталог не є репозиторієм)." \
|
||
"" \
|
||
"Іншого джерела попередньої версії в проєкті поки немає: тегів немає," \
|
||
"реєстру образів немає, образ завжди netpulse/server:dev. Чому саме" \
|
||
"коміт, а не збережений дамп — у коментарі до цього розділу."
|
||
|
||
_now=$(sandbox_up_migr_count HEAD)
|
||
[ "${_now:-0}" -gt 0 ] || die \
|
||
"У HEAD не знайшлося жодного файлу міграцій — це не те дерево."
|
||
|
||
if [ -z "$SB_UP_REF" ]; then
|
||
if ! git -C "$ROOT_MAIN" rev-parse --verify -q "HEAD~1" >/dev/null 2>&1; then
|
||
die "В історії лише один коміт — оновлюватись нема з чого." \
|
||
"Вкажіть версію явно: ./netpulse sandbox upgrade --from <ref>"
|
||
fi
|
||
for _c in $(git -C "$ROOT_MAIN" rev-list --max-count=300 "HEAD~1"); do
|
||
_n=$(sandbox_up_migr_count "$_c")
|
||
if [ "${_n:-0}" -lt "$_now" ]; then
|
||
SB_UP_REF=$_c
|
||
break
|
||
fi
|
||
done
|
||
[ -n "$SB_UP_REF" ] || die \
|
||
"Не знайшлося коміта з меншим набором міграцій, ніж у HEAD." \
|
||
"Тобто останні 300 комітів схеми не міняли, і автоматично обраний" \
|
||
"«попередній» доводив би лише перезапуск служб." \
|
||
"" \
|
||
"Виберіть версію самі: ./netpulse sandbox upgrade --from <ref>"
|
||
ok "ref обрано автоматично: найновіший коміт із меншим набором міграцій"
|
||
say " Автоматичний вибір — це «найближча попередня», а не «та, що"
|
||
say " справді стоїть у клієнта». Перед випуском беріть --from із"
|
||
say " тією версією, з якої оновлюватимуться насправді."
|
||
fi
|
||
|
||
git -C "$ROOT_MAIN" rev-parse --verify -q "$SB_UP_REF^{commit}" >/dev/null 2>&1 || die \
|
||
"Такого коміта немає: $SB_UP_REF"
|
||
SB_UP_REF=$(git -C "$ROOT_MAIN" rev-parse --verify -q "$SB_UP_REF^{commit}")
|
||
|
||
# Дерево має бути придатним до збірки. Без цих двох файлів прогін
|
||
# помер би на `docker compose build` із текстом про відсутній файл —
|
||
# тобто сказав би, що зламане оновлення, а зламаний насправді вибір
|
||
# версії.
|
||
for _f in docker-compose.yml deploy/Dockerfile.server; do
|
||
git -C "$ROOT_MAIN" cat-file -e "$SB_UP_REF:$_f" 2>/dev/null || die \
|
||
"У коміті $(git -C "$ROOT_MAIN" rev-parse --short "$SB_UP_REF") немає $_f." \
|
||
"Це дерево не збирається як NetPulse — візьміть новіший ref."
|
||
done
|
||
|
||
say " $(git -C "$ROOT_MAIN" log -1 --date=short --format='%h %ad %s' "$SB_UP_REF")"
|
||
|
||
_was=$(sandbox_up_migr_count "$SB_UP_REF")
|
||
SB_UP_NEW_MIGRATIONS=$(git -C "$ROOT_MAIN" diff --name-only --diff-filter=A \
|
||
"$SB_UP_REF" HEAD -- server/migrations 2>/dev/null | grep '\.sql$')
|
||
if [ "${_was:-0}" -lt "$_now" ]; then
|
||
ok "міграцій: було $_was, стане $_now — накочування поверх даних БУДЕ"
|
||
printf '%s\n' "$SB_UP_NEW_MIGRATIONS" | sed 's|.*/| нова міграція: |'
|
||
else
|
||
SB_UP_SAME_SCHEMA=1
|
||
warn "у цій парі версій НЕМАЄ жодної нової міграції ($_was проти $_now).
|
||
Прогін пройде й буде зеленим, але про головне — накочування
|
||
поверх наявних даних — він не скаже НІЧОГО. Зелене без змісту
|
||
коштує дорожче за червоне: на нього посилаються.
|
||
Якщо мета була інша, беріть давніший ref: --from <ref>"
|
||
fi
|
||
ok "нове дерево: $(git -C "$ROOT_MAIN" log -1 --format='%h %s' HEAD)"
|
||
}
|
||
|
||
# Дерево розгортається через `git archive`, а не `git worktree` і не
|
||
# `git checkout`: archive НІЧОГО не пише в репозиторій. worktree завів
|
||
# би запис у .git/worktrees, а checkout узагалі рухає робочу копію —
|
||
# обидва означають, що перевірка міняє те, що перевіряє.
|
||
sandbox_up_materialize() {
|
||
step "дерево попередньої версії"
|
||
|
||
have tar || die "Немає tar — розгорнути дерево коміта нічим."
|
||
|
||
rm -rf "$SB_UP_TREE"
|
||
mkdir -p "$SB_UP_TREE" || die "Не вдалося створити $SB_UP_TREE"
|
||
|
||
_tarf="$SB_UP_TREE.tar"
|
||
git -C "$ROOT_MAIN" archive --format=tar -o "$_tarf" "$SB_UP_REF" || die \
|
||
"git archive не віддав дерево коміта $SB_UP_REF."
|
||
tar -xf "$_tarf" -C "$SB_UP_TREE" || die \
|
||
"Не вдалося розпакувати дерево коміта в $SB_UP_TREE."
|
||
rm -f "$_tarf"
|
||
|
||
[ -f "$SB_UP_TREE/docker-compose.yml" ] || die \
|
||
"У розгорнутому дереві немає docker-compose.yml — розпакування не вдалося."
|
||
|
||
# Накладка пісочниці могла з'явитись пізніше за обраний ref. Тоді
|
||
# беремо нову — інакше цей режим просто не запускається на давніших
|
||
# версіях. Але мовчати про це не можна: у цьому разі порти, стелі
|
||
# пам'яті й restart:"no" для СТАРОГО стека задає НОВИЙ файл, і якщо
|
||
# він розійшовся зі старим compose (перейменована служба), впаде саме
|
||
# тут — а виглядатиме як вада попередньої версії.
|
||
if [ ! -f "$SB_UP_TREE/$SB_OVERLAY" ]; then
|
||
warn "у цій версії ще не було $SB_OVERLAY — беремо накладку з нового дерева.
|
||
Ізоляція від цього не страждає (ім'я проєкту, томи й порти задає
|
||
установник), але накладка й старий compose-файл тепер із різних
|
||
версій. Якщо впаде на підйомі старого стека — дивіться сюди першим"
|
||
mkdir -p "$SB_UP_TREE/$(dirname "$SB_OVERLAY")"
|
||
cp "$ROOT_MAIN/$SB_OVERLAY" "$SB_UP_TREE/$SB_OVERLAY" || die \
|
||
"Не вдалося покласти накладку пісочниці в дерево попередньої версії."
|
||
fi
|
||
|
||
ok "дерево розгорнуто: $SB_UP_TREE"
|
||
ok "репозиторій не змінено: git archive лише читає"
|
||
}
|
||
|
||
# --- дані, на яких ламається накочування ------------------------------
|
||
#
|
||
# Порожня база не доводить нічого: міграція, яка додає NOT NULL без
|
||
# DEFAULT, створює UNIQUE поверх дублів або переписує тип колонки, на
|
||
# порожній таблиці проходить завжди. Тому перед оновленням база
|
||
# наливається — і саме тими класами даних, на яких це ламається:
|
||
# інвентар (пише людина), телеметрія в гіпертаблицях Timescale
|
||
# (найбільший обсяг і найдорожча втрата), конфіги NCM, алерти.
|
||
#
|
||
# Наливання йде SQL-ом від імені власника бази, а не через HTTP API, і
|
||
# це свідомо. API попередньої версії міг мати інші поля, тобто прогін
|
||
# ламався б від зміни API там, де він має ламатись від зміни СХЕМИ.
|
||
# Ціна відома: шлях запису застосунку тут не перевіряється — його
|
||
# перевіряє самоперевірка іншим кінцем.
|
||
sandbox_up_seed() {
|
||
step "наливання даних у попередню версію"
|
||
|
||
dc exec -T db psql -v ON_ERROR_STOP=1 -U netpulse -d netpulse >/dev/null <<'SQL' || die \
|
||
"Не вдалося налити дані в базу попередньої версії." \
|
||
"Це ще НЕ вада оновлення, і плутати не можна: дані не лягли в СТАРУ" \
|
||
"схему, тобто розійшлись наливання й та версія, яку обрали попередньою." \
|
||
"Повний текст помилки від psql — вище, він називає таблицю й стовпець."
|
||
DO $$
|
||
DECLARE
|
||
v_tenant uuid;
|
||
v_site uuid;
|
||
v_repo uuid;
|
||
v_rule uuid;
|
||
BEGIN
|
||
SELECT id INTO v_tenant FROM core.tenants ORDER BY created_at LIMIT 1;
|
||
IF v_tenant IS NULL THEN
|
||
RAISE EXCEPTION 'у базі немає жодного кабінету — установка попередньої версії не дійшла до кінця';
|
||
END IF;
|
||
|
||
INSERT INTO inv.sites (tenant_id, name, code, address)
|
||
VALUES (v_tenant, 'Пісочниця · Львів', 'SB-LVIV', 'вигадана адреса')
|
||
ON CONFLICT (tenant_id, name) DO NOTHING;
|
||
SELECT id INTO v_site FROM inv.sites
|
||
WHERE tenant_id = v_tenant AND name = 'Пісочниця · Львів';
|
||
|
||
INSERT INTO ncm.repos (tenant_id, name, storage_path)
|
||
VALUES (v_tenant, 'sandbox', '/var/lib/netpulse/git/sandbox.git')
|
||
ON CONFLICT (tenant_id, name) DO NOTHING;
|
||
SELECT id INTO v_repo FROM ncm.repos
|
||
WHERE tenant_id = v_tenant AND name = 'sandbox';
|
||
|
||
INSERT INTO alr.rules (tenant_id, name, source, severity, condition)
|
||
VALUES (v_tenant, 'Пісочниця · втрата пакетів', 'icmp', 'high',
|
||
'{"op":"loss_pct >","value":20,"for":"3m"}'::jsonb)
|
||
ON CONFLICT (tenant_id, name) DO NOTHING;
|
||
SELECT id INTO v_rule FROM alr.rules
|
||
WHERE tenant_id = v_tenant AND name = 'Пісочниця · втрата пакетів';
|
||
|
||
-- 24 хости. Число не кругле заради краси: воно має бути більшим за
|
||
-- одиницю (щоб зловити міграцію, яка працює лише на першому рядку) і
|
||
-- меншим за сотні (щоб прогін лишався хвилинним).
|
||
INSERT INTO inv.devices (tenant_id, site_id, name, address, kind, vendor, model, status, notes)
|
||
SELECT v_tenant, v_site,
|
||
'sb-sw-' || lpad(i::text, 2, '0'),
|
||
('10.77.0.' || i)::inet,
|
||
'switch', 'Cisco', 'C9200', 'up',
|
||
'заведено пісочницею оновлення'
|
||
FROM generate_series(1, 24) AS i
|
||
ON CONFLICT DO NOTHING;
|
||
|
||
-- Телеметрія: доба з кроком 5 хвилин на кожен хост. Це вже кілька
|
||
-- тисяч рядків у гіпертаблиці, тобто не один чанк Timescale, — а
|
||
-- саме на межах чанків і ламається половина міграцій телеметрії.
|
||
INSERT INTO ts.icmp_samples
|
||
(ts, device_id, tenant_id, rtt_avg_ms, rtt_min_ms, rtt_max_ms,
|
||
jitter_ms, loss_pct, packets_sent, packets_recv, reachable)
|
||
SELECT g, d.id, v_tenant, 2.0, 1.4, 4.1, 0.3, 0, 5, 5, true
|
||
FROM inv.devices d,
|
||
generate_series(now() - interval '24 hours', now(), interval '5 minutes') AS g
|
||
WHERE d.tenant_id = v_tenant AND d.name LIKE 'sb-sw-%'
|
||
ON CONFLICT DO NOTHING;
|
||
|
||
INSERT INTO ts.series (tenant_id, device_id, plugin_key, metric_key, unit)
|
||
SELECT v_tenant, d.id, 'snmp', 'cpu.util', 'pct'
|
||
FROM inv.devices d
|
||
WHERE d.tenant_id = v_tenant AND d.name LIKE 'sb-sw-%'
|
||
ON CONFLICT DO NOTHING;
|
||
|
||
-- Звуження до наших рядів обов'язкове, а не для охайності: локальний
|
||
-- зонд до цієї миті вже працює й міг завести власний ряд cpu.util.
|
||
-- Писати в чужий ряд означало б підмішати наші відліки до справжніх.
|
||
INSERT INTO ts.samples (ts, series_id, value)
|
||
SELECT g, s.id, 30.0
|
||
FROM ts.series s
|
||
JOIN inv.devices d ON d.id = s.device_id,
|
||
generate_series(now() - interval '24 hours', now(), interval '5 minutes') AS g
|
||
WHERE s.tenant_id = v_tenant AND s.metric_key = 'cpu.util'
|
||
AND d.name LIKE 'sb-sw-%'
|
||
ON CONFLICT DO NOTHING;
|
||
|
||
-- Конфіги. Тіло живе в Git, тут метадані — але саме їх і чіпають
|
||
-- міграції NCM.
|
||
INSERT INTO ncm.configs
|
||
(tenant_id, device_id, repo_id, commit_sha, blob_sha, branch, path,
|
||
config_type, size_bytes, line_count, content_hash, is_change)
|
||
SELECT v_tenant, d.id, v_repo,
|
||
substr(md5(d.id::text || 'commit') || md5(d.id::text), 1, 40),
|
||
substr(md5(d.id::text || 'blob') || md5(d.id::text), 1, 40),
|
||
'main', 'sandbox/' || d.name || '/running.cfg', 'running',
|
||
2048, 64, sha256(convert_to(d.name || 'cfg', 'UTF8')), false
|
||
FROM inv.devices d
|
||
WHERE d.tenant_id = v_tenant AND d.name LIKE 'sb-sw-%'
|
||
ON CONFLICT DO NOTHING;
|
||
|
||
-- Алерти лише на частині хостів: інакше «усі рядки однакові», і
|
||
-- міграція, яка псує вибірку за станом, лишилась би непоміченою.
|
||
--
|
||
-- Мітка в context, а не впізнавання за назвою чи dedup_key. Причина
|
||
-- конкретна: фоновий такт цілком може або погасити ці алерти й
|
||
-- перенести їх у alr.alerts_history (куди dedup_key не переїжджає
|
||
-- взагалі), або завести СВОЇ алерти за тим самим правилом і з тією
|
||
-- самою назвою. І перше, і друге зсунуло б лічильник, і звірка «до й
|
||
-- після» дала б хибне червоне — тобто збрехала б рівно про те, заради
|
||
-- чого існує. context переїжджає в історію разом із рядком.
|
||
INSERT INTO alr.alerts
|
||
(tenant_id, rule_id, device_id, severity, state, title, message,
|
||
dedup_key, value, threshold, context)
|
||
SELECT v_tenant, v_rule, d.id, 'high', 'firing',
|
||
'Пісочниця · ' || d.name || ' не відповідає',
|
||
'втрата пакетів 35%', 'sb:' || d.id::text, 35, 20,
|
||
'{"netpulse_sandbox_seed":true}'::jsonb
|
||
FROM inv.devices d
|
||
WHERE d.tenant_id = v_tenant AND d.name LIKE 'sb-sw-%'
|
||
AND right(d.name, 1) IN ('1', '3', '5', '7', '9')
|
||
ON CONFLICT DO NOTHING;
|
||
END $$;
|
||
SQL
|
||
|
||
ok "налито: локації, хости, метрики, конфіги, тригер і алерти"
|
||
}
|
||
|
||
# --- що саме має пережити оновлення -----------------------------------
|
||
#
|
||
# Перелік із правилом на кожен рядок, бо правило не одне.
|
||
#
|
||
# exact — рядки, які наливала пісочниця. Їх кількість НЕ МАЄ права
|
||
# змінитись ані в який бік: зникли — оновлення їх знищило,
|
||
# побільшало — оновлення їх роздвоїло. Обидва однаково погані
|
||
# й обидва беззвучні.
|
||
# min — усе інше в базі. Тут дозволено лише рости: система під час
|
||
# оновлення жива, локальний зонд пише телеметрію, дії лягають
|
||
# в аудит. Вимагати тут рівності означало б отримати хибне
|
||
# червоне на кожному другому прогоні.
|
||
#
|
||
# Кожен exact-запит навмисно звужений до рядків, які налила саме
|
||
# пісочниця: локальний зонд весь цей час пише СВОЮ телеметрію, і без
|
||
# звуження вона потрапила б у ті самі лічильники. Алерти рахуються по
|
||
# ДВОХ таблицях одразу — фоновий такт переносить погашені в
|
||
# alr.alerts_history, і рядок, що переїхав, це не втрачений рядок.
|
||
# ЧОМУ «журнал аудиту» тут ЗАВЖДИ НУЛЬ, і це не поломка.
|
||
#
|
||
# Установник робить рівно три речі, і всі три — у переліку сліпих зон
|
||
# журналу (store.AuditBlindSpots): заводить власника (склад команди),
|
||
# ставить строки зберігання й підключає локальний зонд (дії системи).
|
||
# Дані пісочниця наливає прямим SQL, повз API. Тобто нуль — очікуваний
|
||
# результат, а не ознака, що аудит зламався.
|
||
#
|
||
# Проба лишена навмисно: якщо колись хтось почне писати аудит на
|
||
# установці, рядок це покаже. Але читати «0 → 0 ok» як доказ роботи
|
||
# журналу не можна — правило min на нулі не доводить нічого.
|
||
#
|
||
# А от «спроби входу» — доводять: самоперевірка заходить у продукт, і
|
||
# порожня таблиця тут означала б, що записування входів відвалилось.
|
||
sandbox_up_probes() {
|
||
cat <<'EOF'
|
||
хости_пісочниці|exact|SELECT count(*) FROM inv.devices WHERE name LIKE 'sb-sw-%'
|
||
локації_пісочниці|exact|SELECT count(*) FROM inv.sites WHERE name LIKE 'Пісочниця%'
|
||
проби_icmp|exact|SELECT count(*) FROM ts.icmp_samples s JOIN inv.devices d ON d.id=s.device_id WHERE d.name LIKE 'sb-sw-%'
|
||
ряди_метрик|exact|SELECT count(*) FROM ts.series s JOIN inv.devices d ON d.id=s.device_id WHERE s.metric_key='cpu.util' AND d.name LIKE 'sb-sw-%'
|
||
відліки_метрик|exact|SELECT count(*) FROM ts.samples p JOIN ts.series s ON s.id=p.series_id JOIN inv.devices d ON d.id=s.device_id WHERE s.metric_key='cpu.util' AND d.name LIKE 'sb-sw-%'
|
||
конфіги_ncm|exact|SELECT count(*) FROM ncm.configs WHERE path LIKE 'sandbox/%'
|
||
тригери|exact|SELECT count(*) FROM alr.rules WHERE name LIKE 'Пісочниця%'
|
||
алерти|exact|SELECT (SELECT count(*) FROM alr.alerts WHERE context ? 'netpulse_sandbox_seed') + (SELECT count(*) FROM alr.alerts_history WHERE context ? 'netpulse_sandbox_seed')
|
||
кабінети|min|SELECT count(*) FROM core.tenants
|
||
користувачі|min|SELECT count(*) FROM core.users
|
||
членства|min|SELECT count(*) FROM core.memberships
|
||
зонди|min|SELECT count(*) FROM core.agents
|
||
шаблони|min|SELECT count(*) FROM tpl.templates
|
||
профілі_ncm|min|SELECT count(*) FROM ncm.profiles
|
||
типи_перевірок|min|SELECT count(*) FROM core.check_types
|
||
права|min|SELECT count(*) FROM core.permissions
|
||
журнал_аудиту|min|SELECT count(*) FROM core.audit_log
|
||
спроби_входу|min|SELECT count(*) FROM core.login_attempts
|
||
EOF
|
||
}
|
||
|
||
# Знімок кількостей у файл. Через файл, а не змінну: рядків два десятки,
|
||
# і збирати їх у рядок означало б розбирати його потім розділювачем,
|
||
# який колись зустрінеться в даних.
|
||
sandbox_up_snapshot() {
|
||
_out=$1
|
||
: > "$_out" || die "Не вдалося писати в $_out"
|
||
sandbox_up_probes > "$_out.q"
|
||
while IFS='|' read -r _label _rule _sql; do
|
||
[ -n "$_label" ] || continue
|
||
# </dev/null — не косметика, а причина, з якої ця перевірка
|
||
# звітувала ОДИН рядок замість вісімнадцяти. `docker exec` читає
|
||
# stdin, а stdin тут — той самий перелік проб, яким живиться
|
||
# цикл: перша ж проба з'їдала решту, цикл завершувався після
|
||
# одного оберту, і таблиця «дані до й після» виглядала повною,
|
||
# бо в ній був рядок і слово «ok». Найгірший вид зеленого:
|
||
# перевірка, яка нічого не перевірила й нічим цього не виказала.
|
||
_n=$(dc exec -T db psql -tAX -U netpulse -d netpulse -c "$_sql" </dev/null 2>/dev/null | tr -d ' \r')
|
||
case "$_n" in
|
||
''|*[!0-9]*) _n=- ;;
|
||
esac
|
||
printf '%s|%s|%s\n' "$_label" "$_rule" "$_n" >> "$_out"
|
||
done < "$_out.q"
|
||
rm -f "$_out.q"
|
||
}
|
||
|
||
sandbox_up_compare() {
|
||
_before=$1
|
||
_after=$2
|
||
step "чи пережили дані оновлення"
|
||
|
||
_bad=""
|
||
printf '\n %-22s %10s %10s %s\n' "що саме" "до" "після" "правило"
|
||
printf ' %s\n' "------------------------------------------------------------"
|
||
while IFS='|' read -r _label _rule _a; do
|
||
[ -n "$_label" ] || continue
|
||
_b=$(sed -n "s/^$_label|[^|]*|//p" "$_before")
|
||
[ -n "$_b" ] || _b=-
|
||
_mark=" "
|
||
if [ "$_b" = - ] || [ "$_a" = - ]; then
|
||
_mark="?"
|
||
_bad="$_bad|$(printf '%s' "$_label" | tr '_' ' '): порахувати не вдалося (до=$_b, після=$_a) — таблиця зникла або запит не виконався"
|
||
elif [ "$_rule" = exact ] && [ "$_a" != "$_b" ]; then
|
||
_mark="!"
|
||
if [ "$_a" -lt "$_b" ]; then
|
||
_bad="$_bad|$(printf '%s' "$_label" | tr '_' ' '): було $_b, стало $_a — оновлення ЗНИЩИЛО $((_b - _a)) рядків"
|
||
else
|
||
_bad="$_bad|$(printf '%s' "$_label" | tr '_' ' '): було $_b, стало $_a — оновлення РОЗДВОЇЛО дані ($((_a - _b)) зайвих рядків)"
|
||
fi
|
||
elif [ "$_rule" = min ] && [ "$_a" -lt "$_b" ]; then
|
||
_mark="!"
|
||
_bad="$_bad|$(printf '%s' "$_label" | tr '_' ' '): було $_b, стало $_a — рядків поменшало там, де вони можуть лише прибувати"
|
||
fi
|
||
printf ' %s %-22s %10s %10s %s\n' \
|
||
"$_mark" "$(printf '%s' "$_label" | tr '_' ' ')" "$_b" "$_a" "$_rule"
|
||
done < "$_after"
|
||
printf '\n'
|
||
|
||
if [ -n "$_bad" ]; then
|
||
die "ДАНІ НЕ ПЕРЕЖИЛИ ОНОВЛЕННЯ." \
|
||
"Система при цьому піднялась і самоперевірку пройшла — саме тому" \
|
||
"ця звірка й існує окремо: зникла історія виглядає як працююча" \
|
||
"система, у якої «просто ще немає даних»." \
|
||
"" \
|
||
"$(printf '%s' "$_bad" | tr '|' '\n')" \
|
||
"" \
|
||
"Рядок «поменшало там, де можуть лише прибувати» має один законний" \
|
||
"привід: міграція свідомо прибрала запис із довідника (застаріле" \
|
||
"право, зайвий тип перевірки). Тоді це не поломка — але сказати про" \
|
||
"це має людина, а не мовчазний зелений підсумок." \
|
||
"" \
|
||
"Дамп бази ПЕРЕД оновленням лежить у $SB_UP_TREE/before-upgrade.dump" \
|
||
"(з --keep він переживе цей прогін)."
|
||
fi
|
||
ok "усі кількості на місці: нічого не зникло й нічого не роздвоїлось"
|
||
}
|
||
|
||
# --- саме оновлення ---------------------------------------------------
|
||
|
||
sandbox_up_dump() {
|
||
step "дамп перед оновленням"
|
||
# Те саме, що `netpulse upgrade` робить першим кроком, і з тієї ж
|
||
# причини: зворотних міграцій немає, тож дамп — єдиний шлях назад.
|
||
# Тут він ще й перевірка: pg_dump на НАПОВНЕНІЙ базі попередньої
|
||
# версії — рівно та команда, яку клієнт виконає перед оновленням, і
|
||
# дізнатись, що вона не працює, краще тут, ніж у нього.
|
||
_f="$SB_UP_TREE/before-upgrade.dump"
|
||
dc exec -T db pg_dump -U netpulse -d netpulse -Fc --no-owner > "$_f" || die \
|
||
"pg_dump не відпрацював на базі попередньої версії." \
|
||
"Клієнт на цьому місці лишився б без шляху назад — і дізнався б про це" \
|
||
"рівно тоді, коли шлях назад знадобився."
|
||
_sz=$(wc -c < "$_f" | tr -d ' ')
|
||
[ "${_sz:-0}" -gt 1024 ] || die \
|
||
"Дамп вийшов розміром $_sz байтів — це порожній або обірваний файл." \
|
||
"Такий файл виглядає як бекап і не є ним."
|
||
ok "$_f, $((_sz / 1024)) КБ"
|
||
}
|
||
|
||
sandbox_up_apply() {
|
||
step "збірка нової версії"
|
||
dc build || die \
|
||
"Образи НОВОЇ версії не зібрались. Стенд попередньої версії при цьому" \
|
||
"живий і цілий — оновлення просто не почалось." \
|
||
"Журнали збірки вище; найчастіше це нестача місця під кеш збірки."
|
||
ok "образи нової версії зібрані"
|
||
|
||
# Мітку часу знімаємо з БАЗИ й до перезапуску: за нею потім видно, чи
|
||
# дійшов від старого зонда хоч один такт ПІСЛЯ того, як колектор став
|
||
# новим. Годинник хоста тут не годиться — порівнюємо з timestamptz.
|
||
SB_UP_T0=$(dc exec -T db psql -tAX -U netpulse -d netpulse -c "SELECT now()" 2>/dev/null |
|
||
tr -d '\r' | head -1)
|
||
[ -n "$SB_UP_T0" ] || die "База не відповіла на SELECT now() перед оновленням."
|
||
|
||
step "міграції поверх наявних даних"
|
||
_out=$(dc run --rm migrate 2>&1)
|
||
_rc=$?
|
||
printf '%s\n' "$_out" | sed 's/^/ /'
|
||
[ "$_rc" -eq 0 ] || die \
|
||
"МІГРАЦІЇ НЕ НАКОТИЛИСЬ ПОВЕРХ НАЯВНИХ ДАНИХ." \
|
||
"Це головна поломка, заради якої цей режим і написаний: на порожній" \
|
||
"базі ті самі файли проходять, а на наповненій — ні." \
|
||
"Текст помилки вище називає файл, на якому зупинилось." \
|
||
"" \
|
||
"База лишилась у стані попередньої версії: кожна міграція йде окремою" \
|
||
"транзакцією, недокочена не залишається. Дамп до оновлення —" \
|
||
"$SB_UP_TREE/before-upgrade.dump"
|
||
ok "схема оновлена"
|
||
|
||
# Рядок «наявна інсталяція: паролі ролей не чіпаю» тут НЕ з'явиться, і
|
||
# це не недогляд. public.netpulse_install пишеться один раз, при
|
||
# першому в житті бази запуску мігратора, — а тоді вона була порожня.
|
||
# Тобто інсталяція, народжена чистою, лишається fresh назавжди, і
|
||
# мігратор перевидає ролям ті самі паролі з того самого .env.
|
||
# Наслідок для чесності звіту: гілку «наявна інсталяція» (fresh=false,
|
||
# перехід за deploy/RLS-EXISTING-INSTALL.md) цей режим НЕ проходить.
|
||
case "$_out" in
|
||
*"наявна інсталяція"*) ok "мігратор упізнав наявну інсталяцію" ;;
|
||
*"чиста база"*) ok "мігратор перевидав паролі ролей (база народилась чистою — так і має бути)" ;;
|
||
esac
|
||
|
||
step "перезапуск служб на новій версії"
|
||
dc up -d || die \
|
||
"Служби не піднялись на новій версії. Схема вже оновлена, тобто" \
|
||
"повернутись можна лише з дампа: $SB_UP_TREE/before-upgrade.dump"
|
||
wait_api
|
||
ok "служби працюють на новій версії"
|
||
}
|
||
|
||
# Найдорожча з трьох названих поломок і єдина, якої не видно з сервера:
|
||
# зонд стоїть у мережі клієнта й оновлюється НЕ РАЗОМ із сервером.
|
||
# Контейнер зонда тут навмисно не перезбирався — він лишився на образі
|
||
# попередньої версії (профіль agent, тому `dc up -d` його не чіпає).
|
||
# Питання одне: чи дійшов від нього хоч один такт після того, як
|
||
# колектор став новим.
|
||
sandbox_up_agent_compat() {
|
||
step "СТАРИЙ зонд проти НОВОГО колектора"
|
||
|
||
if ! dc ps --services --filter status=running 2>/dev/null | grep -q '^agent$'; then
|
||
warn "зонда в пісочниці немає — сумісність зонда з новим колектором
|
||
не перевіряється взагалі. Це діра, а не дрібниця: саме цим шляхом
|
||
ламається оновлення в клієнта, у якого зонди в чужих мережах"
|
||
return 0
|
||
fi
|
||
|
||
# Такт зонда — раз на 30 секунд, плюс перепідключення після
|
||
# перезапуску колектора. Чекаємо вчетверо довше: хибне червоне тут
|
||
# відправляє шукати неіснуючу несумісність протоколу.
|
||
_i=0
|
||
_n=0
|
||
while [ "$_i" -lt 40 ]; do
|
||
_n=$(dc exec -T db psql -tAX -U netpulse -d netpulse -c \
|
||
"SELECT count(*) FROM core.agents WHERE last_heartbeat_at > '$SB_UP_T0'::timestamptz" \
|
||
2>/dev/null | tr -d ' \r')
|
||
case "$_n" in
|
||
''|*[!0-9]*) _n=0 ;;
|
||
esac
|
||
[ "$_n" -gt 0 ] && break
|
||
case "$_i" in
|
||
10|20|30) say " чекаємо такту від старого зонда ($((_i * 3)) с)" ;;
|
||
esac
|
||
_i=$((_i + 1))
|
||
sleep 3
|
||
done
|
||
|
||
[ "$_n" -gt 0 ] || die \
|
||
"СТАРИЙ ЗОНД НЕ ДОСТУКАВСЯ ДО НОВОГО КОЛЕКТОРА за дві хвилини." \
|
||
"Жодного такту з міткою пізнішою за $SB_UP_T0." \
|
||
"" \
|
||
"Саме це й ламає оновлення в клієнта: сервер оновлюють одним рухом," \
|
||
"а зонди стоять у чужих мережах і лишаються старими. Зовні це" \
|
||
"виглядає як «після оновлення зникли всі дані»." \
|
||
"" \
|
||
"Подивитись причину: ./netpulse sandbox logs collector" \
|
||
" ./netpulse sandbox logs agent"
|
||
ok "старий зонд ($_n) продовжив слати такти новому колекторові"
|
||
|
||
step "оновлення самого зонда"
|
||
dc --profile agent up -d --build agent || die \
|
||
"Зонд не перезібрався й не піднявся на новій версії."
|
||
_t1=$(dc exec -T db psql -tAX -U netpulse -d netpulse -c "SELECT now()" 2>/dev/null |
|
||
tr -d '\r' | head -1)
|
||
_i=0
|
||
_n=0
|
||
while [ "$_i" -lt 40 ]; do
|
||
_n=$(dc exec -T db psql -tAX -U netpulse -d netpulse -c \
|
||
"SELECT count(*) FROM core.agents WHERE last_heartbeat_at > '$_t1'::timestamptz" \
|
||
2>/dev/null | tr -d ' \r')
|
||
case "$_n" in
|
||
''|*[!0-9]*) _n=0 ;;
|
||
esac
|
||
[ "$_n" -gt 0 ] && break
|
||
_i=$((_i + 1))
|
||
sleep 3
|
||
done
|
||
[ "$_n" -gt 0 ] || die \
|
||
"НОВИЙ зонд не достукався до нового колектора за дві хвилини." \
|
||
"Посвідчення зонда лежить у томі й переживає перестворення контейнера —" \
|
||
"тобто це не втрачений токен, а розходження зонда з сервером." \
|
||
"Журнали: ./netpulse sandbox logs agent"
|
||
ok "оновлений зонд працює зі своїм посвідченням із тому (нового запрошення не треба)"
|
||
}
|
||
|
||
sandbox_up_summary() {
|
||
printf '\n%s== оновлення пройшло повністю%s\n\n' "$C_G" "$C_0"
|
||
printf ' Було: %s\n' "$(git -C "$ROOT_MAIN" log -1 --date=short --format='%h %ad %s' "$SB_UP_REF" 2>/dev/null)"
|
||
printf ' Стало: %s\n' "$(git -C "$ROOT_MAIN" log -1 --date=short --format='%h %ad %s' HEAD 2>/dev/null)"
|
||
if [ -n "$SB_UP_NEW_MIGRATIONS" ]; then
|
||
printf '\n Накочено поверх наявних даних:\n'
|
||
printf '%s\n' "$SB_UP_NEW_MIGRATIONS" | sed 's|.*/| |'
|
||
fi
|
||
|
||
printf '\n %sЩо саме щойно доведено%s\n' "$C_B" "$C_0"
|
||
printf ' Попередня версія стала з нуля й пройшла самоперевірку.\n'
|
||
printf ' Міграції накотились на базу з хостами, телеметрією, конфігами\n'
|
||
printf ' й алертами — а не на порожню.\n'
|
||
printf ' Служби перезапустились на новій версії з тим самим .env і тими\n'
|
||
printf ' самими томами.\n'
|
||
printf ' СТАРИЙ зонд продовжив працювати з НОВИМ колектором.\n'
|
||
printf ' Та сама самоперевірка, що й після чистої установки, зелена.\n'
|
||
printf ' Жоден налитий рядок не зник і не роздвоївся.\n'
|
||
|
||
printf '\n %sЧого НЕ доведено%s\n' "$C_B" "$C_0"
|
||
printf ' Гілка «наявна інсталяція» в міграторі. База пісочниці народилась\n'
|
||
printf ' чистою, тому public.netpulse_install назавжди fresh=true, і\n'
|
||
printf ' мігратор іде гілкою «видати паролі ролям». Перехід стенду,\n'
|
||
printf ' зробленого до 0063 (deploy/RLS-EXISTING-INSTALL.md), лишається\n'
|
||
printf ' неперевіреним.\n'
|
||
printf ' Зміни в самому УСТАНОВНИКУ між версіями: обидва етапи веде один\n'
|
||
printf ' і той самий цей файл — інакше різниця в приладі читалась би як\n'
|
||
printf ' різниця у виробі.\n'
|
||
printf ' Оновлення через кілька версій підряд: перевіряється рівно один\n'
|
||
printf ' стрибок, з обраного ref у HEAD.\n'
|
||
printf ' Усе те, чого не доводить і перший режим: Let'\''s Encrypt і DNS,\n'
|
||
printf ' трапи на 162/udp, поведінка під навантаженням.\n'
|
||
if [ "$SB_UP_SAME_SCHEMA" -eq 1 ]; then
|
||
printf '\n %sІ головне: у цій парі версій НЕ БУЛО НОВИХ МІГРАЦІЙ.%s\n' "$C_R" "$C_0"
|
||
printf ' Прогін зелений, але про накочування поверх даних він не сказав\n'
|
||
printf ' нічого. Візьміть давніший ref: --from <ref>\n'
|
||
fi
|
||
printf '\n'
|
||
}
|
||
|
||
cmd_sandbox_upgrade() {
|
||
SANDBOX=1
|
||
SB_UP=1
|
||
SB_PROJECT=$SB_UP_PROJECT
|
||
SB_PORT_BASE=$SB_UP_PORT_BASE
|
||
|
||
trap 'sandbox_on_signal' INT TERM HUP
|
||
|
||
printf '%sNetPulse · пісочниця, режим оновлення%s\n' "$C_B" "$C_0"
|
||
say "Попередня версія з нуля → дані → оновлення → та сама самоперевірка."
|
||
say "Окремий проєкт compose ($SB_PROJECT), окремі томи, порти від"
|
||
say "$SB_UP_PORT_BASE на 127.0.0.1. Бойовий .env і netpulse.conf не читаються."
|
||
|
||
if [ "$DRY" -eq 1 ]; then
|
||
die "У режимі оновлення сухого прогону немає." \
|
||
"Він не мав би сенсу: цей режим цілком про те, що відбувається на" \
|
||
"живих даних, а сухий прогін не наливає даних і не котить міграцій." \
|
||
"Показувати «виконалося б» замість цього означало б обіцяти перевірку," \
|
||
"якої не було."
|
||
fi
|
||
|
||
STEP_NAME="підготовка"
|
||
if have docker && sandbox_leftovers; then
|
||
die "Пісочниця оновлення вже стоїть на цій машині." \
|
||
"Ставити другу поверх неї не можна: вони поділять ім'я проєкту," \
|
||
"тобто й томи, і «попередня версія з нуля» перестане бути з нуля." \
|
||
"" \
|
||
" ./netpulse sandbox status що там зараз" \
|
||
" ./netpulse sandbox down знести й почати чисто"
|
||
fi
|
||
|
||
phase "0 · підготовка" \
|
||
"Вибір попередньої версії й дерево, з якого вона збереться."
|
||
sandbox_up_resolve_ref
|
||
sandbox_up_materialize
|
||
|
||
# --- ЕТАП А --------------------------------------------------------
|
||
#
|
||
# Установка веде себе так само, як у першому режимі, з єдиною
|
||
# різницею: $ROOT вказує на дерево попередньої версії. Отже старий
|
||
# docker-compose.yml, старий Dockerfile, старі міграції — і той самий
|
||
# установник, той самий порядок кроків, та сама самоперевірка.
|
||
phase "А · попередня версія з нуля" \
|
||
"Той самий cmd_install, що й у першому режимі, але над старим деревом."
|
||
ROOT=$SB_UP_TREE
|
||
SB_ENV="$SB_UP_TREE/$SB_UP_ENV_NAME"
|
||
ENV_FILE=$SB_ENV
|
||
CONF_FILE="$SB_UP_TREE/.netpulse.conf.НЕ-ЧИТАЄТЬСЯ-У-ПІСОЧНИЦІ"
|
||
# Власний тег образів. Без нього обидві версії називались би
|
||
# netpulse/server:dev, друга перетерла б першу, а обірваний прогін
|
||
# лишив би цей тег указувати на СТАРУ збірку — на очах у бойової
|
||
# інсталяції, яка ділить із пісочницею реєстр образів.
|
||
VAL_VERSION=sandbox-prev
|
||
VAL_COMMIT=$(git -C "$ROOT_MAIN" rev-parse --short "$SB_UP_REF" 2>/dev/null)
|
||
[ -n "$VAL_COMMIT" ] || VAL_COMMIT=none
|
||
cmd_install
|
||
|
||
# --- ЕТАП Б --------------------------------------------------------
|
||
phase "Б · дані" \
|
||
"Без них накочування поверх нічого не доводить: на порожній таблиці" \
|
||
"проходить будь-яка міграція."
|
||
sandbox_up_seed
|
||
step "знімок кількостей ДО оновлення"
|
||
sandbox_up_snapshot "$SB_UP_TREE/counts-before"
|
||
sed 's/|/ /g' "$SB_UP_TREE/counts-before" | sed 's/^/ /'
|
||
ok "знімок знято"
|
||
|
||
# --- ЕТАП В --------------------------------------------------------
|
||
#
|
||
# Рівно те, що робить клієнт: дерево замінилось новим, .env лишився
|
||
# тим самим, томи ті самі. Ім'я проєкту compose не змінюється — воно й
|
||
# прив'язує новий стек до старих томів.
|
||
phase "В · оновлення до нової версії" \
|
||
"Дерево нове, .env той самий, томи ті самі — як у клієнта."
|
||
ROOT=$ROOT_MAIN
|
||
cp "$SB_UP_TREE/$SB_UP_ENV_NAME" "$ROOT_MAIN/$SB_UP_ENV_NAME" || die \
|
||
"Не вдалося перенести .env пісочниці в нове дерево."
|
||
chmod 600 "$ROOT_MAIN/$SB_UP_ENV_NAME" 2>/dev/null
|
||
SB_ENV="$ROOT_MAIN/$SB_UP_ENV_NAME"
|
||
ENV_FILE=$SB_ENV
|
||
CONF_FILE="$ROOT_MAIN/.netpulse.conf.НЕ-ЧИТАЄТЬСЯ-У-ПІСОЧНИЦІ"
|
||
ok ".env перенесено в нове дерево без змін — секрети ті самі"
|
||
|
||
# Єдині два рядки .env, які змінюються при оновленні, — тег образу й
|
||
# коміт. Саме так і виглядає справжнє оновлення: секрети, домен, строки
|
||
# зберігання й запрошення зонда лишаються, версія стає інша.
|
||
VAL_VERSION=sandbox-new
|
||
VAL_COMMIT=$(git -C "$ROOT_MAIN" rev-parse --short HEAD 2>/dev/null)
|
||
[ -n "$VAL_COMMIT" ] || VAL_COMMIT=none
|
||
sed -i.bak \
|
||
-e "s|^NETPULSE_VERSION=.*|NETPULSE_VERSION=$VAL_VERSION|" \
|
||
-e "s|^NETPULSE_COMMIT=.*|NETPULSE_COMMIT=$VAL_COMMIT|" \
|
||
"$SB_ENV" || die "Не вдалося перемкнути версію в $SB_ENV"
|
||
rm -f "$SB_ENV.bak"
|
||
ok "версія образів: sandbox-prev → sandbox-new (тег netpulse/*:dev не чіпається)"
|
||
|
||
sandbox_up_dump
|
||
sandbox_up_apply
|
||
sandbox_up_agent_compat
|
||
|
||
# --- ЕТАП Г --------------------------------------------------------
|
||
phase "Г · та сама самоперевірка, що й після чистої установки" \
|
||
"Жодного окремого набору тверджень: якщо оновлена система відрізняється" \
|
||
"від щойно встановленої, це знає рівно та перевірка, що й там."
|
||
VAL_DOMAIN=$(env_get NETPULSE_DOMAIN)
|
||
[ -n "$VAL_DOMAIN" ] || VAL_DOMAIN=localhost
|
||
OWNER_PASSWORD=$(env_get "$SB_PW_KEY")
|
||
API_TOKEN=""
|
||
selfcheck
|
||
|
||
# --- ЕТАП Д --------------------------------------------------------
|
||
phase "Д · дані до й після" \
|
||
"Самоперевірка зелена й на порожній базі — тому це окремий етап."
|
||
sandbox_up_snapshot "$SB_UP_TREE/counts-after"
|
||
sandbox_up_compare "$SB_UP_TREE/counts-before" "$SB_UP_TREE/counts-after"
|
||
|
||
PHASE_NAME=""
|
||
sandbox_up_summary
|
||
|
||
# Прибирання планове, а не аварійне: цей режим — ворота випуску, а не
|
||
# стенд для розглядання. --keep лишає все на місці для розбору.
|
||
if [ "$SB_KEEP" -eq 1 ]; then
|
||
SB_TORN=1
|
||
warn "--keep: стенд і дерево попередньої версії лишаються.
|
||
Адреса: https://localhost:$SB_HTTPS, логін admin,
|
||
пароль у $SB_ENV
|
||
Прибрати ОБОВ'ЯЗКОВО: ./netpulse sandbox down"
|
||
else
|
||
SB_TORN=1
|
||
printf '%s== прибирання%s\n' "$C_B" "$C_0"
|
||
sandbox_down || exit 1
|
||
rm -f "$ROOT_MAIN/$SB_UP_ENV_NAME"
|
||
sandbox_up_drop_leftovers
|
||
printf '\n %sОновлення пройшло повністю, слідів не лишилось.%s\n\n' "$C_G" "$C_0"
|
||
fi
|
||
trap - INT TERM HUP
|
||
}
|
||
|
||
# --- команди ---------------------------------------------------------
|
||
|
||
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 ;;
|
||
--from) shift; SB_UP_REF=${1:-} ;;
|
||
-p) shift; OWNER_PASSWORD=${1:-} ;;
|
||
*) break ;;
|
||
esac
|
||
shift
|
||
done
|
||
|
||
# Журнали й перевірка мають знайти ту пісочницю, яка справді стоїть.
|
||
# Питати про це людину означало б вимагати від неї пам'ятати, який
|
||
# режим вона запускала три години тому.
|
||
if [ "$_sub" = logs ] || [ "$_sub" = check ]; then
|
||
if [ ! -f "$SB_ENV" ] && [ -f "$ROOT_MAIN/$SB_UP_ENV_NAME" ]; then
|
||
SB_PROJECT=$SB_UP_PROJECT
|
||
SB_ENV="$ROOT_MAIN/$SB_UP_ENV_NAME"
|
||
fi
|
||
fi
|
||
|
||
case "$_sub" in
|
||
up) cmd_sandbox_up ;;
|
||
once) SB_ONCE=1; cmd_sandbox_up ;;
|
||
upgrade) cmd_sandbox_upgrade ;;
|
||
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, upgrade, 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
|
||
|
||
_rc=0
|
||
_any=0
|
||
|
||
SB_PROJECT=$SB_CLEAN_PROJECT
|
||
SB_ENV=$SB_CLEAN_ENV
|
||
if sandbox_leftovers || [ -f "$SB_ENV" ]; then
|
||
_any=1
|
||
say " режим установки з нуля ($SB_PROJECT):"
|
||
sandbox_down || _rc=1
|
||
fi
|
||
|
||
SB_PROJECT=$SB_UP_PROJECT
|
||
SB_ENV="$ROOT_MAIN/$SB_UP_ENV_NAME"
|
||
# Власні теги образів теж рахуються за слід: два гігабайти, які ніхто
|
||
# не шукатиме, бо контейнерів уже немає.
|
||
_imgs=""
|
||
for _i in netpulse/server netpulse/agent; do
|
||
for _t in sandbox-prev sandbox-new; do
|
||
docker image inspect "$_i:$_t" >/dev/null 2>&1 && _imgs=1
|
||
done
|
||
done
|
||
if sandbox_leftovers || [ -f "$SB_ENV" ] || [ -d "$SB_UP_TREE" ] || [ -n "$_imgs" ]; then
|
||
_any=1
|
||
say " режим оновлення ($SB_PROJECT):"
|
||
# Файл .env міг лишитись у дереві попередньої версії, якщо прогін
|
||
# обірвався до етапу В. Compose однаково розбере той, що тут: усе,
|
||
# що йому потрібно для `down`, — розібраний файл і мітка проєкту.
|
||
[ -f "$SB_ENV" ] || [ ! -f "$SB_UP_TREE/$SB_UP_ENV_NAME" ] ||
|
||
cp "$SB_UP_TREE/$SB_UP_ENV_NAME" "$SB_ENV"
|
||
sandbox_down || _rc=1
|
||
SB_UP=1
|
||
sandbox_up_drop_leftovers
|
||
[ -d "$SB_UP_TREE" ] || ok "дерево попередньої версії видалено"
|
||
fi
|
||
|
||
if [ "$_any" -eq 0 ]; then
|
||
ok "пісочниці немає — прибирати нічого"
|
||
return 0
|
||
fi
|
||
[ "$_rc" -eq 0 ] || exit 1
|
||
printf '\n'
|
||
}
|
||
|
||
cmd_sandbox_status() {
|
||
SANDBOX=1
|
||
pick_compose
|
||
printf '%sNetPulse · стан пісочниці%s\n\n' "$C_B" "$C_0"
|
||
if ! have docker; then
|
||
say " docker не знайдено — стан невідомий"
|
||
return 0
|
||
fi
|
||
|
||
_any=0
|
||
sandbox_status_one "$SB_CLEAN_PROJECT" "$SB_CLEAN_ENV" "установка з нуля" && _any=1
|
||
sandbox_status_one "$SB_UP_PROJECT" "$ROOT_MAIN/$SB_UP_ENV_NAME" "оновлення" && _any=1
|
||
if [ -d "$SB_UP_TREE" ]; then
|
||
_any=1
|
||
printf '\n Дерево попередньої версії: %s (%s)\n' \
|
||
"$SB_UP_TREE" "$(du -sh "$SB_UP_TREE" 2>/dev/null | cut -f1)"
|
||
fi
|
||
if [ "$_any" -eq 0 ]; then
|
||
say " пісочниці немає: ані контейнерів, ані томів, ані дерева"
|
||
return 0
|
||
fi
|
||
printf '\n Прибрати (обидва режими одразу): ./netpulse sandbox down\n\n'
|
||
}
|
||
|
||
# Один режим. Повертає 0, якщо він щось на машині займає.
|
||
sandbox_status_one() {
|
||
SB_PROJECT=$1
|
||
SB_ENV=$2
|
||
ENV_FILE=$SB_ENV
|
||
if ! sandbox_leftovers; then
|
||
[ -f "$SB_ENV" ] || return 1
|
||
warn "від режиму «$3» лишився $SB_ENV — прибрати: ./netpulse sandbox down"
|
||
return 0
|
||
fi
|
||
printf ' %s%s%s (%s)\n' "$C_B" "$3" "$C_0" "$SB_PROJECT"
|
||
[ -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
|
||
return 0
|
||
}
|
||
|
||
# Повторна перевірка живої пісочниці — тією самою 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 [служба] журнали пісочниці
|
||
|
||
Режим другий — ОНОВЛЕННЯ з версії на версію. Ставить попередню версію з
|
||
нуля, наливає в неї хости, метрики, конфіги й алерти, оновлює до цього
|
||
дерева й проганяє ту саму самоперевірку, а потім окремо звіряє, що дані
|
||
пережили. Попередня версія береться з git: тег — це теж ref, тому
|
||
--from v0.1.0 запрацює в день першого тегу без правок.
|
||
|
||
./netpulse sandbox upgrade попередня версія обереться сама
|
||
./netpulse sandbox upgrade --from REF попередня версія — цей коміт/тег
|
||
|
||
--keep не прибирати після невдачі (для розбору журналів);
|
||
у режимі оновлення лишає стенд і після успіху
|
||
--alongside дозволити запуск поруч із бойовою інсталяцією
|
||
--from REF коміт або тег, який вважати попередньою версією
|
||
|
||
Відповіді на п'ять питань, яких система не може вирішити сама, —
|
||
у 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 ;;
|
||
--from) shift; SB_UP_REF=${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
|