Netpulse_SasS/netpulse
byrsapty 5c9143cd98
Some checks failed
CI / web (push) Waiting to run
CI / server (push) Waiting to run
CI / dbtest (push) Waiting to run
CI / agent (push) Waiting to run
CI / hygiene (push) Has been cancelled
Тести проти бази в CI: прогнано на раннері, success
Окрема робота dbtest у ci.yml. Базу дає services: — докер-сокет
усередину роботи не прокидається, тож кожна робота не отримує root на
хості. Запобіжник імені бази спрацював на DSN роботи server і змусив
завести окрему netpulse_probe: підлаштували конвеєр, а не запобіжник.

Сторож вимагає в логу «застосовано міграцій: N» і «усе зелене проти
бази» — «зелено, нічого не зробивши» неможливо.

Задача 110 на раннері: success.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 23:48:54 +03:00

3721 lines
192 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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;
INSERT INTO ts.samples (ts, series_id, value)
SELECT g, s.id, 30.0
FROM ts.series s,
generate_series(now() - interval '24 hours', now(), interval '5 minutes') AS g
WHERE s.tenant_id = v_tenant AND s.metric_key = 'cpu.util'
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, а не впізнавання за назвою. Причина конкретна:
-- фоновий такт цілком може або погасити ці алерти й перенести їх у
-- alr.alerts_history (звідки dedup_key не переїжджає взагалі), або
-- завести СВОЇ алерти за тим самим правилом і з тією самою назвою.
-- І перше, і друге зсунуло б лічильник, і звірка «до й після» дала б
-- хибне червоне — тобто збрехала б рівно про те, заради чого існує.
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 — усе інше в базі. Тут дозволено лише рости: система під час
# оновлення жива, локальний зонд пише телеметрію, дії лягають
# в аудит. Вимагати тут рівності означало б отримати хибне
# червоне на кожному другому прогоні.
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