Обрано побайтову точність архіву замість маскування — без неї відновлення з архіву не працює. Наслідок прийнято свідомо: доступ до дзеркала рівносильний доступу до всіх паролів у мережі. Маскування лишається там, де конфіг читає людина: план відкату й стенограми завдань. Записано, щоб не переобговорювати. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3739 lines
194 KiB
Bash
3739 lines
194 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, і рядок, що переїхав, це не втрачений рядок.
|
||
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
|
||
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
|
||
_n=$(dc exec -T db psql -tAX -U netpulse -d netpulse -c "$_sql" 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
|