#!/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="" 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 </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 </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 <&1 wget -q -O- --header='Authorization: Bearer $API_TOKEN' '$API_INTERNAL$1' INNER else dc exec -T api sh -s <&1 wget -q -O- '$API_INTERNAL$1' INNER fi } api_post() { if [ -n "$API_TOKEN" ]; then dc exec -T api sh -s <&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 <&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 </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 " 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 " 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 " fi ok "нове дерево: $(git -C "$ROOT_MAIN" log -1 --format='%h %s' HEAD)" } # Дерево розгортається через `git archive`, а не `git worktree` і не # `git checkout`: archive НІЧОГО не пише в репозиторій. worktree завів # би запис у .git/worktrees, а checkout узагалі рухає робочу копію — # обидва означають, що перевірка міняє те, що перевіряє. sandbox_up_materialize() { step "дерево попередньої версії" have tar || die "Немає tar — розгорнути дерево коміта нічим." rm -rf "$SB_UP_TREE" mkdir -p "$SB_UP_TREE" || die "Не вдалося створити $SB_UP_TREE" _tarf="$SB_UP_TREE.tar" git -C "$ROOT_MAIN" archive --format=tar -o "$_tarf" "$SB_UP_REF" || die \ "git archive не віддав дерево коміта $SB_UP_REF." tar -xf "$_tarf" -C "$SB_UP_TREE" || die \ "Не вдалося розпакувати дерево коміта в $SB_UP_TREE." rm -f "$_tarf" [ -f "$SB_UP_TREE/docker-compose.yml" ] || die \ "У розгорнутому дереві немає docker-compose.yml — розпакування не вдалося." # Накладка пісочниці могла з'явитись пізніше за обраний ref. Тоді # беремо нову — інакше цей режим просто не запускається на давніших # версіях. Але мовчати про це не можна: у цьому разі порти, стелі # пам'яті й restart:"no" для СТАРОГО стека задає НОВИЙ файл, і якщо # він розійшовся зі старим compose (перейменована служба), впаде саме # тут — а виглядатиме як вада попередньої версії. if [ ! -f "$SB_UP_TREE/$SB_OVERLAY" ]; then warn "у цій версії ще не було $SB_OVERLAY — беремо накладку з нового дерева. Ізоляція від цього не страждає (ім'я проєкту, томи й порти задає установник), але накладка й старий compose-файл тепер із різних версій. Якщо впаде на підйомі старого стека — дивіться сюди першим" mkdir -p "$SB_UP_TREE/$(dirname "$SB_OVERLAY")" cp "$ROOT_MAIN/$SB_OVERLAY" "$SB_UP_TREE/$SB_OVERLAY" || die \ "Не вдалося покласти накладку пісочниці в дерево попередньої версії." fi ok "дерево розгорнуто: $SB_UP_TREE" ok "репозиторій не змінено: git archive лише читає" } # --- дані, на яких ламається накочування ------------------------------ # # Порожня база не доводить нічого: міграція, яка додає NOT NULL без # DEFAULT, створює UNIQUE поверх дублів або переписує тип колонки, на # порожній таблиці проходить завжди. Тому перед оновленням база # наливається — і саме тими класами даних, на яких це ламається: # інвентар (пише людина), телеметрія в гіпертаблицях Timescale # (найбільший обсяг і найдорожча втрата), конфіги NCM, алерти. # # Наливання йде SQL-ом від імені власника бази, а не через HTTP API, і # це свідомо. API попередньої версії міг мати інші поля, тобто прогін # ламався б від зміни API там, де він має ламатись від зміни СХЕМИ. # Ціна відома: шлях запису застосунку тут не перевіряється — його # перевіряє самоперевірка іншим кінцем. sandbox_up_seed() { step "наливання даних у попередню версію" dc exec -T db psql -v ON_ERROR_STOP=1 -U netpulse -d netpulse >/dev/null <<'SQL' || die \ "Не вдалося налити дані в базу попередньої версії." \ "Це ще НЕ вада оновлення, і плутати не можна: дані не лягли в СТАРУ" \ "схему, тобто розійшлись наливання й та версія, яку обрали попередньою." \ "Повний текст помилки від psql — вище, він називає таблицю й стовпець." DO $$ DECLARE v_tenant uuid; v_site uuid; v_repo uuid; v_rule uuid; BEGIN SELECT id INTO v_tenant FROM core.tenants ORDER BY created_at LIMIT 1; IF v_tenant IS NULL THEN RAISE EXCEPTION 'у базі немає жодного кабінету — установка попередньої версії не дійшла до кінця'; END IF; INSERT INTO inv.sites (tenant_id, name, code, address) VALUES (v_tenant, 'Пісочниця · Львів', 'SB-LVIV', 'вигадана адреса') ON CONFLICT (tenant_id, name) DO NOTHING; SELECT id INTO v_site FROM inv.sites WHERE tenant_id = v_tenant AND name = 'Пісочниця · Львів'; INSERT INTO ncm.repos (tenant_id, name, storage_path) VALUES (v_tenant, 'sandbox', '/var/lib/netpulse/git/sandbox.git') ON CONFLICT (tenant_id, name) DO NOTHING; SELECT id INTO v_repo FROM ncm.repos WHERE tenant_id = v_tenant AND name = 'sandbox'; INSERT INTO alr.rules (tenant_id, name, source, severity, condition) VALUES (v_tenant, 'Пісочниця · втрата пакетів', 'icmp', 'high', '{"op":"loss_pct >","value":20,"for":"3m"}'::jsonb) ON CONFLICT (tenant_id, name) DO NOTHING; SELECT id INTO v_rule FROM alr.rules WHERE tenant_id = v_tenant AND name = 'Пісочниця · втрата пакетів'; -- 24 хости. Число не кругле заради краси: воно має бути більшим за -- одиницю (щоб зловити міграцію, яка працює лише на першому рядку) і -- меншим за сотні (щоб прогін лишався хвилинним). INSERT INTO inv.devices (tenant_id, site_id, name, address, kind, vendor, model, status, notes) SELECT v_tenant, v_site, 'sb-sw-' || lpad(i::text, 2, '0'), ('10.77.0.' || i)::inet, 'switch', 'Cisco', 'C9200', 'up', 'заведено пісочницею оновлення' FROM generate_series(1, 24) AS i ON CONFLICT DO NOTHING; -- Телеметрія: доба з кроком 5 хвилин на кожен хост. Це вже кілька -- тисяч рядків у гіпертаблиці, тобто не один чанк Timescale, — а -- саме на межах чанків і ламається половина міграцій телеметрії. INSERT INTO ts.icmp_samples (ts, device_id, tenant_id, rtt_avg_ms, rtt_min_ms, rtt_max_ms, jitter_ms, loss_pct, packets_sent, packets_recv, reachable) SELECT g, d.id, v_tenant, 2.0, 1.4, 4.1, 0.3, 0, 5, 5, true FROM inv.devices d, generate_series(now() - interval '24 hours', now(), interval '5 minutes') AS g WHERE d.tenant_id = v_tenant AND d.name LIKE 'sb-sw-%' ON CONFLICT DO NOTHING; INSERT INTO ts.series (tenant_id, device_id, plugin_key, metric_key, unit) SELECT v_tenant, d.id, 'snmp', 'cpu.util', 'pct' FROM inv.devices d WHERE d.tenant_id = v_tenant AND d.name LIKE 'sb-sw-%' ON CONFLICT DO NOTHING; -- Звуження до наших рядів обов'язкове, а не для охайності: локальний -- зонд до цієї миті вже працює й міг завести власний ряд cpu.util. -- Писати в чужий ряд означало б підмішати наші відліки до справжніх. INSERT INTO ts.samples (ts, series_id, value) SELECT g, s.id, 30.0 FROM ts.series s JOIN inv.devices d ON d.id = s.device_id, generate_series(now() - interval '24 hours', now(), interval '5 minutes') AS g WHERE s.tenant_id = v_tenant AND s.metric_key = 'cpu.util' AND d.name LIKE 'sb-sw-%' ON CONFLICT DO NOTHING; -- Конфіги. Тіло живе в Git, тут метадані — але саме їх і чіпають -- міграції NCM. INSERT INTO ncm.configs (tenant_id, device_id, repo_id, commit_sha, blob_sha, branch, path, config_type, size_bytes, line_count, content_hash, is_change) SELECT v_tenant, d.id, v_repo, substr(md5(d.id::text || 'commit') || md5(d.id::text), 1, 40), substr(md5(d.id::text || 'blob') || md5(d.id::text), 1, 40), 'main', 'sandbox/' || d.name || '/running.cfg', 'running', 2048, 64, sha256(convert_to(d.name || 'cfg', 'UTF8')), false FROM inv.devices d WHERE d.tenant_id = v_tenant AND d.name LIKE 'sb-sw-%' ON CONFLICT DO NOTHING; -- Алерти лише на частині хостів: інакше «усі рядки однакові», і -- міграція, яка псує вибірку за станом, лишилась би непоміченою. -- -- Мітка в context, а не впізнавання за назвою чи dedup_key. Причина -- конкретна: фоновий такт цілком може або погасити ці алерти й -- перенести їх у alr.alerts_history (куди dedup_key не переїжджає -- взагалі), або завести СВОЇ алерти за тим самим правилом і з тією -- самою назвою. І перше, і друге зсунуло б лічильник, і звірка «до й -- після» дала б хибне червоне — тобто збрехала б рівно про те, заради -- чого існує. context переїжджає в історію разом із рядком. INSERT INTO alr.alerts (tenant_id, rule_id, device_id, severity, state, title, message, dedup_key, value, threshold, context) SELECT v_tenant, v_rule, d.id, 'high', 'firing', 'Пісочниця · ' || d.name || ' не відповідає', 'втрата пакетів 35%', 'sb:' || d.id::text, 35, 20, '{"netpulse_sandbox_seed":true}'::jsonb FROM inv.devices d WHERE d.tenant_id = v_tenant AND d.name LIKE 'sb-sw-%' AND right(d.name, 1) IN ('1', '3', '5', '7', '9') ON CONFLICT DO NOTHING; END $$; SQL ok "налито: локації, хости, метрики, конфіги, тригер і алерти" } # --- що саме має пережити оновлення ----------------------------------- # # Перелік із правилом на кожен рядок, бо правило не одне. # # exact — рядки, які наливала пісочниця. Їх кількість НЕ МАЄ права # змінитись ані в який бік: зникли — оновлення їх знищило, # побільшало — оновлення їх роздвоїло. Обидва однаково погані # й обидва беззвучні. # min — усе інше в базі. Тут дозволено лише рости: система під час # оновлення жива, локальний зонд пише телеметрію, дії лягають # в аудит. Вимагати тут рівності означало б отримати хибне # червоне на кожному другому прогоні. # # Кожен exact-запит навмисно звужений до рядків, які налила саме # пісочниця: локальний зонд весь цей час пише СВОЮ телеметрію, і без # звуження вона потрапила б у ті самі лічильники. Алерти рахуються по # ДВОХ таблицях одразу — фоновий такт переносить погашені в # alr.alerts_history, і рядок, що переїхав, це не втрачений рядок. # ЧОМУ «журнал аудиту» тут ЗАВЖДИ НУЛЬ, і це не поломка. # # Установник робить рівно три речі, і всі три — у переліку сліпих зон # журналу (store.AuditBlindSpots): заводить власника (склад команди), # ставить строки зберігання й підключає локальний зонд (дії системи). # Дані пісочниця наливає прямим SQL, повз API. Тобто нуль — очікуваний # результат, а не ознака, що аудит зламався. # # Проба лишена навмисно: якщо колись хтось почне писати аудит на # установці, рядок це покаже. Але читати «0 → 0 ok» як доказ роботи # журналу не можна — правило min на нулі не доводить нічого. # # А от «спроби входу» — доводять: самоперевірка заходить у продукт, і # порожня таблиця тут означала б, що записування входів відвалилось. sandbox_up_probes() { cat <<'EOF' хости_пісочниці|exact|SELECT count(*) FROM inv.devices WHERE name LIKE 'sb-sw-%' локації_пісочниці|exact|SELECT count(*) FROM inv.sites WHERE name LIKE 'Пісочниця%' проби_icmp|exact|SELECT count(*) FROM ts.icmp_samples s JOIN inv.devices d ON d.id=s.device_id WHERE d.name LIKE 'sb-sw-%' ряди_метрик|exact|SELECT count(*) FROM ts.series s JOIN inv.devices d ON d.id=s.device_id WHERE s.metric_key='cpu.util' AND d.name LIKE 'sb-sw-%' відліки_метрик|exact|SELECT count(*) FROM ts.samples p JOIN ts.series s ON s.id=p.series_id JOIN inv.devices d ON d.id=s.device_id WHERE s.metric_key='cpu.util' AND d.name LIKE 'sb-sw-%' конфіги_ncm|exact|SELECT count(*) FROM ncm.configs WHERE path LIKE 'sandbox/%' тригери|exact|SELECT count(*) FROM alr.rules WHERE name LIKE 'Пісочниця%' алерти|exact|SELECT (SELECT count(*) FROM alr.alerts WHERE context ? 'netpulse_sandbox_seed') + (SELECT count(*) FROM alr.alerts_history WHERE context ? 'netpulse_sandbox_seed') кабінети|min|SELECT count(*) FROM core.tenants користувачі|min|SELECT count(*) FROM core.users членства|min|SELECT count(*) FROM core.memberships зонди|min|SELECT count(*) FROM core.agents шаблони|min|SELECT count(*) FROM tpl.templates профілі_ncm|min|SELECT count(*) FROM ncm.profiles типи_перевірок|min|SELECT count(*) FROM core.check_types права|min|SELECT count(*) FROM core.permissions журнал_аудиту|min|SELECT count(*) FROM core.audit_log спроби_входу|min|SELECT count(*) FROM core.login_attempts EOF } # Знімок кількостей у файл. Через файл, а не змінну: рядків два десятки, # і збирати їх у рядок означало б розбирати його потім розділювачем, # який колись зустрінеться в даних. sandbox_up_snapshot() { _out=$1 : > "$_out" || die "Не вдалося писати в $_out" sandbox_up_probes > "$_out.q" while IFS='|' read -r _label _rule _sql; do [ -n "$_label" ] || continue # /dev/null | 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 \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