diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8eeeda3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +# Що НЕ потрапляє в контекст збірки. +# +# Файл з'явився через пісочницю оновлення: вона розгортає повне дерево +# попередньої версії в .sandbox-prev/, і без цього рядка кожен +# `docker compose build` тягнув би подвійні вихідні тексти разом зі +# зібраним веб-інтерфейсом. Помітно це не як помилка, а як «збірка чомусь +# стала вдвічі довшою». +.sandbox-prev/ + +# Секрети. У Dockerfile їх не копіює ніхто, але контекст іде демонові +# цілком — тобто файл із паролями бази й ключем шифрування подорожує +# туди без потреби. Ціна виключення нульова, ціна недогляду — ні. +.env +.env.* +!.env.example diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 131f6e0..49fa533 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -1,9 +1,9 @@ # Складання і перевірки NetPulse на Forgejo Actions. # -# Проганяється рівно те, що досі проганяли руками (`sh scripts/check.sh`), -# плюс три речі, яких скрипт зробити не може: інтеграційні тести проти -# живої бази, звірка каталогу профілів зі згенерованою міграцією і -# перевірка закінчень рядків. +# Проганяється рівно те, що досі проганяли руками (`sh scripts/check.sh` +# і `sh scripts/dbtest.sh`), плюс три речі, яких перший скрипт зробити не +# може: інтеграційні тести проти живої бази, звірка каталогу профілів зі +# згенерованою міграцією і перевірка закінчень рядків. # # ЧОМУ ЦЕ ВАЖЛИВІШЕ ЗА САМІ ТЕСТИ. Інтеграційні тести grpcapi мовчки # пропускались півтора року: без NETPULSE_TEST_DSN вони роблять t.Skip, @@ -12,7 +12,18 @@ # розпізнавання пристроїв. Тест, який не виконується, не є перевіркою; # він є її виглядом. Тому в роботі server стоїть окремий крок, який # ПАДАЄ, якщо інтеграційні тести пропустились: без нього цей workflow -# повторив би ту саму помилку — зелений на тестах, яких не було. +# повторив би ту саму помилку — зелений на тестах, яких не було. У +# роботі dbtest сторож свій і перевіряє інше: що міграції накотились з +# нуля, тобто база була справжня й одноразова. +# +# ЧОМУ РОБІТ З БАЗОЮ ДВІ (server і dbtest), І ЦЕ НЕ ЗАБУТИЙ ДУБЛІКАТ. +# server перевіряє КОД: збірку, vet, увесь `go test ./...`. dbtest +# перевіряє ІНСТРУМЕНТ: `scripts/dbtest.sh` — те, що людина запускає +# руками перед розгортанням. Якщо CI повторює його кроки в yaml замість +# викликати, скрипт може зогнити при зеленому конвеєрі, і виявиться це +# рівно в момент розгортання. Ціна дубля невелика: роботи йдуть по черзі +# (місткість раннера 1), кеші збірки спільні, тож повторно компілюється +# небагато — платимо часом виконання тестів, а не збіркою. # # ЧОМУ ТУТ НЕМАЄ ЖОДНОЇ JS-ДІЇ (actions/checkout тощо). Раннер виконує # JS-дії тим node, який знайде В ОБРАЗІ роботи. У golang:1.25-alpine @@ -376,6 +387,193 @@ jobs: if [ "$bc" -gt 1200 ]; then echo "чищу кеш збірки"; go clean -cache; fi if [ "$mc" -gt 900 ]; then echo "чищу кеш модулів"; go clean -modcache; fi + # ------------------------------------------------------------------ + # Тести проти бази. Рівно те, що досі запускалося руками + # (`sh scripts/dbtest.sh`), — тепер на кожен пуш. + # + # ЧОМУ ОКРЕМА РОБОТА, А НЕ КРОК У server. Вона повільна (свій + # Postgres, накат усіх міграцій з нуля) і червоніє з іншої причини: + # не «код не збирається», а «схема й код розійшлись». Змішані в одну + # роботу, ці причини читаються як одна пляма, і швидкий сигнал про + # поламану збірку чекав би на базу. 28 серпня цей скрипт за ОДИН + # прогін знайшов чотири справжні вади — зокрема nil-зріз, що їхав + # явним NULL повз DEFAULT '{}', і міграцію, якої не бачив старий + # образ. Ручний запуск найдорожчої перевірки — найгірше місце для + # ручної дії: її пропускають рівно тоді, коли поспішають. + # + # ЧОМУ ТУТ ВИКЛИКАЄТЬСЯ САМЕ СКРИПТ, А НЕ ПОВТОРЕНІ В YAML КРОКИ. + # Робота server має власну послідовність «накат + go test», і тут вона + # не дублюється заради краси: дублюється ПЕРЕВІРКА САМОГО СКРИПТА. + # `scripts/dbtest.sh` — це те, що людина запускає перед розгортанням. + # Якщо він зогниє (зміниться прапорець netpulse-migrate, поламається + # запобіжник імені, поїде тег образу), переписані в yaml кроки цього + # не помітять — і зелений CI мирно співіснуватиме зі зламаним + # скриптом. Тому виклик як є, одним рядком. + # + # ЧОМУ БАЗУ ДАЄ services:, А НЕ САМ СКРИПТ. `scripts/dbtest.sh` уміє + # підняти собі Postgres САМ — але для цього йому потрібен docker + # УСЕРЕДИНІ контейнера роботи, тобто прокинутий сокет докера хоста. + # Це рівно той дозвіл, який перетворює будь-який workflow із будь-якої + # гілки на root на цій машині, включно з даними бойової бази. Ціна вже + # заплачена один раз — для самого раннера (deploy/docker-compose.ci.yml + # називає це вголос), і роздавати її ще й КОЖНІЙ роботі не треба. + # Уникнути можна: скрипт свого контейнера не піднімає, якщо + # NETPULSE_TEST_DSN уже заданий. Тому контейнер бази створює демон + # докера ЗОВНІ роботи, а робота бачить лише хост `db` у своїй мережі. + # Docker у контейнері роботи відсутній — і це не недогляд, а те, чого + # ми домагались. + # + # ІМʼЯ БАЗИ — netpulse_probe, А НЕ netpulse_ci, ЯК У server. У скрипті + # стоїть невідмикний запобіжник: він відмовляється працювати, якщо в + # імені бази немає «probe» або «test». Перевірено фактично: з + # `netpulse_ci` він падає ще до підключення. Це не примха скрипта, а + # його головна риса — саме вона стоїть між міграціями з гілки й + # бойовою базою. Тому підлаштовується конвеєр, а не запобіжник. + # ------------------------------------------------------------------ + dbtest: + runs-on: docker + timeout-minutes: 40 + container: + image: golang:1.25-alpine + # Ті самі кеші, що й у server: місткість раннера — 1, тобто роботи + # йдуть по черзі й за том не бʼються. Нових томів навмисно не + # заводимо: кожне нове імʼя довелось би додавати ще й у + # container.valid_volumes (deploy/act-runner.config.yml), інакше + # робота впала б на монтуванні. + volumes: + - netpulse-ci-gomod:/go/pkg/mod + - netpulse-ci-gobuild:/root/.cache/go-build + services: + # Окремий контейнер бази, а не спільний із роботою server: роботи + # ізольовані одна від одної, і сервіс однієї для іншої не існує. + # Образ — той самий, що в docker-compose.yml і в самому скрипті. + db: + image: timescale/timescaledb:2.17.2-pg16 + env: + # Користувач і пароль повторюють ті, які скрипт задає СВОЄМУ + # контейнеру. Різниця між прогоном у CI і прогоном на ноутбуці + # тоді зводиться рівно до одного: хто підняв базу. + POSTGRES_USER: netpulse + POSTGRES_PASSWORD: probe + POSTGRES_DB: netpulse_probe + TIMESCALEDB_TELEMETRY: "off" + # Без цього initdb бере кодування з локалі й цілком може + # створити базу як SQL_ASCII. netpulse-migrate це помічає і + # відмовляється працювати — задаємо одразу, як у compose. + POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C.UTF-8" + # Автотюнер образу інакше візьме чверть ПАМʼЯТІ МАШИНИ під + # shared_buffers. На машині з 4 ГБ це майже гарантований OOM у + # парі з go build. + TS_TUNE_MEMORY: 512MB + TS_TUNE_NUM_CPUS: "2" + env: + # Ті самі міркування, що в роботі server: -buildvcs=false прибирає + # цілий клас відмов на правах на .git, -p=2 тримає пік памʼяті. + GOFLAGS: -buildvcs=false -p=2 + GOTOOLCHAIN: local + # ЄДИНА змінна, яку читає скрипт, — і саме тому тут не може + # повторитись історія з мовчазним пропуском. Заданий DSN означає + # три речі ОДРАЗУ: скрипт не піднімає свого контейнера; міграції + # котяться на ЦЮ базу; `go test` бачить той самий DSN і не робить + # t.Skip. Розійтись цим трьом нема з чого — джерело одне. У роботі + # server джерел два (NETPULSE_DSN для міграцій, NETPULSE_TEST_DSN + # для тестів), і саме тому там потрібен окремий сторож пропуску. + NETPULSE_TEST_DSN: postgres://netpulse:probe@db:5432/netpulse_probe?sslmode=disable + steps: + - name: Інструменти + run: apk add --no-cache git + + - name: Викачування + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # set -e явно, а не в надії на прапорці оболонки раннера: + # якщо git тихо не викачає дерево, наступні кроки побачать + # порожній каталог, gofmt не знайде жодного файлу, а робота + # позеленіє. Порожня перевірка виглядає так само, як успішна. + set -e + git config --global --add safe.directory '*' + git init -q . + git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git" + auth=$(printf 'x-access-token:%s' "$TOKEN" | base64 | tr -d '\n') + git config http.extraheader "Authorization: basic $auth" + git fetch --depth=1 origin "$GITHUB_SHA" 2>/dev/null \ + || git fetch --depth=1 origin "$GITHUB_REF" + git checkout -q FETCH_HEAD + # Доказ, що дерево справді на місці, а не «команди не впали». + [ -f scripts/dbtest.sh ] || { echo "робоче дерево порожнє"; exit 1; } + + # Скрипт розрахований на ГОТОВУ базу і не чекає на неї: свою він + # опитує через pg_isready, а задану ззовні вважає піднятою. Тому + # чекаємо тут. Без цього кроку перші секунди initdb давали б + # червоне «connection refused», тобто справжня вада виглядала б + # так само, як звичайний старт контейнера, — і CI навчили б + # перезапускати замість читати. + # + # Через TCP, а не pg_isready: клієнта Postgres в образі немає. + # Образ під час initdb слухає лише unix-сокет, тож відкритий 5432 + # і означає «готова». + - name: Очікування бази + run: | + for i in $(seq 1 90); do + if nc -z db 5432; then echo "база слухає (${i}с)"; break; fi + if [ "$i" = "90" ]; then + echo "база не піднялась за 90 с: сервіс db мовчить на 5432." + echo "тестів проти бази НЕ БУЛО — зеленити тут нема чого." + exit 1 + fi + sleep 1 + done + + # Вивід у файл, бо його читає наступний крок; код виходу + # зберігається окремо, інакше `cat` затер би його своїм. + - name: Тести проти бази + run: | + if sh scripts/dbtest.sh > /tmp/dbtest.log 2>&1; then + rc=0 + else + rc=$? + fi + cat /tmp/dbtest.log + exit "$rc" + + # Сторож. Той самий, що й у server, і з тієї ж причини: робота, яка + # існує заради тестів, мусить довести, що тести БУЛИ. + # + # Ненульовий код виходу скрипта ловить крок вище. Цей ловить + # протилежне — прогін, який «вдався», нічого не зробивши. Число + # міграцій рахується з каталогу, а не зашите, і його збіг доводить + # найбільше: база була справжня, чиста й одноразова. «схема + # актуальна» замість накату означала б базу, що пережила минулий + # прогін, тобто накат з нуля не перевірявся взагалі. + - name: Прогін проти бази справді відбувся + run: | + want=$(ls server/migrations/*.sql | wc -l | tr -d ' ') + if ! grep -q "застосовано міграцій: $want$" /tmp/dbtest.log; then + echo "не бачу накату $want міграцій на ЧИСТУ базу." + echo "порожньо — скрипт до бази не дійшов; «схема актуальна» —" + echo "база не одноразова. В обох випадках прогін нічого не довів." + grep -n 'міграц\|схема актуальна' /tmp/dbtest.log | head + exit 1 + fi + if ! grep -q "усе зелене проти бази" /tmp/dbtest.log; then + echo "скрипт не дійшов до свого підсумкового рядка — обірвався" + exit 1 + fi + echo "накат з нуля ($want міграцій) і тести проти живої бази виконались" + + # Кеші Go ростуть необмежено. Стеля тут, у кінці роботи, а не в + # сторонньому прибиральнику: у цей момент точно ніхто не компілює, + # і чистка не може вирвати файл з-під go build. + - name: Кеші Go у межах + if: always() + run: | + bc=$(du -sm "$(go env GOCACHE)" 2>/dev/null | cut -f1); bc=${bc:-0} + mc=$(du -sm "$(go env GOMODCACHE)" 2>/dev/null | cut -f1); mc=${mc:-0} + echo "кеш збірки: ${bc} МБ (стеля 1200), модулі: ${mc} МБ (стеля 900)" + if [ "$bc" -gt 1200 ]; then echo "чищу кеш збірки"; go clean -cache; fi + if [ "$mc" -gt 900 ]; then echo "чищу кеш модулів"; go clean -modcache; fi + # ------------------------------------------------------------------ # Зонд. Без бази: він до неї не ходить. # ------------------------------------------------------------------ diff --git a/.gitignore b/.gitignore index 0e65f56..b99a431 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,11 @@ /data/ /var/ +# Дерево попередньої версії, яке розгортає `./netpulse sandbox upgrade`. +# Це повний другий примірник вихідних текстів із git archive — комітити +# його означало б покласти історію проєкту всередину самої історії. +/.sandbox-prev/ + # Фронтенд /web/node_modules/ /web/dist/ diff --git a/HISTORY.md b/HISTORY.md index 1105ca4..9e7c638 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -7683,3 +7683,136 @@ NULL і валить запис. Правило без власних канал однієї сходинки за такт. Прогнано проти справжньої бази: **65 міграцій, усі пакети зелені**. + +--- + +## 2026-08-28 — Тести проти бази більше не запускає людина + +`scripts/dbtest.sh` існував рівно один день і за цей день виявився +найрезультативнішою перевіркою проєкту: чотири справжні вади за один +прогін, включно з `nil`-зрізом, що їхав явним `NULL` повз `DEFAULT '{}'`, +і міграцією, якої не бачив старий образ. І весь цей день його запускав я +руками. Найдорожча перевірка на ручному приводі — це та сама вада, від +якої скрипт і написано, тільки на поверх вище: її пропускають рівно тоді, +коли поспішають, тобто перед розгортанням. + +Тепер у `.forgejo/workflows/ci.yml` є окрема робота `dbtest`. + +**Чому окрема, а не крок у `server`.** Вона повільна й червоніє з іншої +причини: не «код не збирається», а «схема й код розійшлись». Змішані в +одну роботу, ці причини читаються як одна пляма, а швидкий сигнал про +поламану збірку чекав би на базу. + +**Чому вона викликає скрипт, а не повторює його кроки в yaml.** Робота +`server` свою послідовність «накат + `go test`» уже має, і дубль тут +свідомий: дублюється не перевірка коду, а перевірка ІНСТРУМЕНТА. +`dbtest.sh` — це те, що запускає людина перед розгортанням; переписані в +yaml кроки не помітять, як він зогнив, і зелений конвеєр мирно +співіснуватиме зі зламаним скриптом. Дубль дешевий: роботи йдуть по +черзі (місткість раннера 1), кеш збірки спільний. + +**Головне, що довелось вирішити чесно: звідки береться Postgres.** +Скрипт уміє підняти його сам — але для цього йому потрібен docker +УСЕРЕДИНІ контейнера роботи, тобто прокинутий сокет докера хоста. Це +рівно той дозвіл, який перетворює будь-який workflow із будь-якої гілки +на root на цій машині, включно з даними бойової бази. Один раз ця ціна +вже заплачена — за сам раннер, і `deploy/docker-compose.ci.yml` називає +її вголос. Роздавати її ще й кожній роботі не треба, і не довелось: у +скрипта є друга гілка, «готова база», і саме вона тут використана. Базу +дає `services:`, контейнер створює демон докера ЗОВНІ роботи, а робота +бачить лише хост `db` у своїй мережі. Докера в ній немає. + +**Запобіжник імені виявився не формальністю.** Скрипт відмовляється +працювати, якщо в імені бази немає `probe` або `test`. У роботі `server` +база зветься `netpulse_ci` — перевірено фактично: з нею скрипт падає ще +до підключення. Тому в `dbtest` своя база, `netpulse_probe`. Підлаштовано +конвеєр, а не запобіжник: він стоїть між міграціями з гілки й бойовою +базою, і послаблювати його заради зручності CI означало б міняти єдину +справжню гарантію на косметику. + +**Що станеться, якщо база не піднялась.** Це найважливіше питання до +цілої роботи, бо саме тут повторити початкову ваду найлегше. Відповідь: +робота ПАДАЄ, і падає тричі поспіль на різних рубежах. Крок «Очікування +бази» опитує 5432 дев'яносто секунд і виходить із текстом «тестів проти +бази НЕ БУЛО». Далі сам скрипт: DSN заданий, отже свого контейнера він не +піднімає й одразу котить міграції — а `netpulse-migrate` без бази +повертає ненульовий код (перевірено локально: `rc=1`). І нарешті сторож +«Прогін проти бази справді відбувся» вимагає в логу накат саме тієї +кількості міграцій, що лежить у каталозі: порожній лог означає, що скрипт +до бази не дійшов, а «схема актуальна» — що база пережила минулий прогін +і накат з нуля не перевірявся. + +Мовчазного пропуску, як у `grpcapi`, тут не може бути з іншої причини, +структурної: `NETPULSE_TEST_DSN` — ЄДИНА змінна, яку читає скрипт. Той +самий рядок котить міграції й доїжджає до `go test`. У роботі `server` +джерел два (`NETPULSE_DSN` і `NETPULSE_TEST_DSN`), і рівно тому там +потрібен окремий сторож пропуску — розійтись двом джерелам є з чого. + +**Чого не перевірено.** Того, що цей раннер узагалі виконує `services:`. +Робота `server` покладається на них теж, але `ci.yml` не запускався +жодного разу — раннер стоїть, конвеєр на ньому не бігав. Локально +перевірено все, що можна без раннера: YAML розбирається, кожен `run` +проходить `sh -n`, скрипт із бойовим іменем бази відмовляється працювати, +з `netpulse_ci` — теж, з `netpulse_probe` і недосяжною базою повертає 1, +а тіло сторожа, витягнуте прямо з `ci.yml`, дає правильний вердикт на +чотирьох варіантах логу. Зеленого наскрізного прогону не було: докера на +цій машині немає. + +--- + +## 2026-08-28 — Відкат уперше дійшов до живого заліза. І показав пароль + +Механізм відкату конфігурації існував із 0035 і НІКОЛИ не виконувався на +справжньому обладнанні. Сьогодні його прогнали до самої межі — плану, +заявки й погодження — і на залізо свідомо НЕ писали. + +### Три відмови, і кожна правильна + +* **ZTE OLT, усі 4 наявні версії:** «різниця завелика для автоматичного + відкату: версії розійшлися майже повністю». Для цього пристрою архів + для порівняння практично непридатний — щозбору конфіг інший цілком. +* **D-Link ПОЛЬОВЕ-СВ і ПОЛЬОВЕ.7:** план пройшов, заявка відмовилась — + «312 рядків неможливо прибрати автоматично» (на другому 671). Профіль + чесно пояснює причину: D-Link не має універсального заперечення рядка. +* **Погодити відкат у цьому кабінеті неможливо в принципі:** + `require_approval: true`, `allow_self_approve: false`, користувач один. + Це не вада коду — це зіткнення правила безпеки з реальністю, і воно + стосується КОЖНОГО нового клієнта: усі починають самі. + +### Вада, яку відмова випадково прикрила + +Перший рядок збереженого конфігу — `Command: show config current_config`, +тобто відлуння команди, а не конфігурація. Планувальник порахував його +рядком конфігу й поклав у команди до заливки `Command: show config`. Якби +312 рядків не зупинили заявку, на живий комутатор пішла б безглузда +команда. Врятувала чужа перевірка, не своя. + +### І головне: пароль адміністратора лежав відкритим + +У конфігу ПОЛЬОВЕ-СВ: + +``` +create account admin btadmin +<пароль> +<пароль ще раз> +disable password encryption +``` + +Комутатор питає пароль ІНТЕРАКТИВНО, і `show config` віддає відповіді +окремими рядками — без жодного ключового слова поруч. `redactLines` +маскує порядково за зразками (`password`, `community`, `secret`…), тож +такий рядок для нього — просто слово. Пароль опинявся в плані відкату, у +`ncm.rollbacks` і — оскільки маскування перед записом у git НЕМАЄ ВЗАГАЛІ +— у git-дзеркалі, тобто за межами сервера. + +**Виправлено:** `redactLines` отримав стан. Після рядка, що заводить +обліковий запис, наступні до двох односкладових рядків маскуються як +відлуння пароля. Ім'я облікового запису лишається читабельним — людина +при погодженні мусить бачити, ЩО змінюється. Односкладовий рядок сам по +собі не маскується (`enable`, `end`, `exit` не постраждали) — тест на це є. + +**Що НЕ виправлено й потребує рішення власника:** тіла конфігів ідуть у +git-дзеркало ДОСЛІВНО. Маскувати їх означає зламати відновлення з архіву +(NCM цінний саме побайтовою точністю); не маскувати — означає, що +кожен секрет із конфігу виїжджає на зовнішній git-сервер. Це вибір, а не +вада, і зробити його має власник. diff --git a/ROADMAP.md b/ROADMAP.md index f1831b2..e70b043 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -520,10 +520,14 @@ CI-раннер ми запустили 2026-08-27. Бракує лише тог ### Друге: перевірки, які є, але не бігають самі -4. **`scripts/dbtest.sh` не в CI.** Раннер уже стоїть (контейнер - `netpulse-ci-runner`), `check.sh` у конвеєрі є, `build.py --check` - теж. Бракує саме тестів проти бази — тобто тих, що 28 серпня знайшли - чотири справжні вади за один прогін. Зараз їх запускаю руками я. +4. **`scripts/dbtest.sh` заведено в CI, але на раннері ще не прогнано.** + У `.forgejo/workflows/ci.yml` зʼявилась окрема робота `dbtest`: базу + дає `services:` (докер-сокет усередину роботи не прокидається), DSN + вказує на `netpulse_probe`, сторож вимагає накату всіх міграцій з + нуля. Перевірено локально — YAML, синтаксис кроків і поведінка + скрипта з заданим DSN; не перевірено головного: що цей раннер + виконує `services:`. Робота `server` користується ним теж, але + workflow не запускався жодного разу. 5. **Токен у `origin` замість ключа розгортання.** ### Третє: діри, названі рецензіями й не закриті diff --git a/deploy/act-runner.config.yml b/deploy/act-runner.config.yml index a6cafa6..b0f6ffa 100644 --- a/deploy/act-runner.config.yml +++ b/deploy/act-runner.config.yml @@ -25,7 +25,9 @@ runner: # Стеля на роботу. Довший прогін означає, що щось зависло (мережа до # реєстру образів, база, яка не піднялась), а не що збірка велика: - # найдовша робота тут — server, і вона вкладається в десяток хвилин. + # найдовші роботи тут — server і dbtest, і кожна вкладається в десяток + # хвилин. Свої стелі вони мають ще й у самому workflow + # (timeout-minutes: 40) — ця лишається запасною. timeout: 1h # Пауза між опитуваннями Forgejo. Дві секунди — замовчування; частіше diff --git a/deploy/docker-compose.ci.yml b/deploy/docker-compose.ci.yml index 509d1f3..f32a840 100644 --- a/deploy/docker-compose.ci.yml +++ b/deploy/docker-compose.ci.yml @@ -49,8 +49,9 @@ # Памʼять у спокої: раннер ~40 МБ + прибиральник ~5 МБ. # Памʼять під час прогону: одна робота за раз (capacity: 1), стеля # контейнера роботи — 1600 МБ (deploy/act-runner.config.yml), плюс -# контейнер Postgres у роботі server (~400 МБ із TS_TUNE_MEMORY). -# Тобто пік ~2 ГБ понад те, що вже їсть бойовий стек. +# контейнер Postgres у роботах server і dbtest (~400 МБ із +# TS_TUNE_MEMORY). Роботи з базою дві, але йдуть вони по черзі, тож +# пік від цього не росте: ~2 ГБ понад те, що вже їсть бойовий стек. # Диск: образи ~1.1 ГБ (alpine 8 МБ, node:22-alpine ~180 МБ, # golang:1.25-alpine ~350 МБ, timescaledb ~450 МБ, сам раннер # ~120 МБ) + кеші зі стелями 1200/900/400 МБ, які тримають самі diff --git a/netpulse b/netpulse index 6d24f5a..486025c 100644 --- a/netpulse +++ b/netpulse @@ -15,6 +15,7 @@ # ./netpulse install поставити (ідемпотентно: можна повторювати) # ./netpulse check лікар: ті самі твердження на живій системі # ./netpulse sandbox та сама установка з нуля, але в ізоляції +# ./netpulse sandbox upgrade те саме для шляху «стояла стара, стала нова» # ./netpulse backup дамп бази + ключі, без яких дамп марний # ./netpulse restore -f відновлення з рамкою TimescaleDB # ./netpulse upgrade перезбирання, міграції, перевірка @@ -49,6 +50,10 @@ set -u # правитиме .env у чужому каталозі. # shellcheck disable=SC1007 ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +# Незмінна копія кореня. ROOT у режимі оновлення пісочниці навмисно +# перемикається між деревом попередньої версії й цим — а git, звіт і +# шляхи прибирання мусять і далі вказувати сюди. +ROOT_MAIN=$ROOT ENV_FILE="$ROOT/.env" CONF_FILE="$ROOT/netpulse.conf" @@ -73,11 +78,50 @@ 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` прибирати нема чого, а після нього # треба обов'язково. @@ -115,6 +159,11 @@ fi STEP_NAME="" STEP_NO=0 +# Етап — рівень крупніший за крок. Потрібен лише там, де кроки йдуть +# двома різними прогонами поспіль (режим оновлення: спершу попередня +# версія, потім нова), і без нього «ЗУПИНКА на кроці „схема й ролі“» не +# каже головного — чиї саме міграції не накотились, старі чи нові. +PHASE_NAME="" say() { printf '%s\n' "$*"; } ok() { printf ' %sok%s %s\n' "$C_G" "$C_0" "$*"; } @@ -127,11 +176,26 @@ step() { 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() { - printf '\n%sЗУПИНКА на кроці «%s»%s\n' "$C_R" "$STEP_NAME" "$C_0" + 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' @@ -1140,6 +1204,13 @@ bring_services() { "Не вдалося підняти api, collector або proxy." \ "Стан служб: ./netpulse logs" + wait_api +} + +# Винесено окремо, бо чекають на API двоє: установка й оновлення. Дві +# копії цього циклу розійшлися б у строках, і різниця вилізла б там, де +# її найважче помітити, — у хибному «не відповів» на повільній машині. +wait_api() { ok "чекаємо, поки API відповість на /healthz (до 120 с)" _i=0 while [ "$_i" -lt 60 ]; do @@ -1662,6 +1733,17 @@ INNER # --------------------------------------------------------------------- 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 @@ -2051,6 +2133,26 @@ sandbox_resources_check() { 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 @@ -2108,7 +2210,7 @@ sandbox_resources_check() { sandbox_ports_check() { _try=0 while [ "$_try" -lt 20 ]; do - SB_HTTP=$((18080 + _try * 10)) + SB_HTTP=$((SB_PORT_BASE + _try * 10)) SB_HTTPS=$((SB_HTTP + 1)) SB_GRPC=$((SB_HTTP + 2)) if ! port_busy "$SB_HTTP" tcp && @@ -2124,7 +2226,7 @@ sandbox_ports_check() { fi _try=$((_try + 1)) done - bad "не знайшлося вільної трійки портів у діапазоні 18080–18272. + bad "не знайшлося вільної трійки портів у діапазоні $SB_PORT_BASE–$((SB_PORT_BASE + 192)). Найімовірніше на машині вже висить кілька пісочниць: ./netpulse sandbox status" return 1 @@ -2170,11 +2272,22 @@ sandbox_leftovers() { # install чи check. sandbox_leftover_nag() { have docker || return 0 - sandbox_leftovers || return 0 - warn "на цій машині лишилась пісочниця ($SB_PROJECT). + # Обидва режими, бо забути можна будь-який, а лишає по собі кожен + # свій повний 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 } # --- прибирання ------------------------------------------------------ @@ -2279,8 +2392,11 @@ sandbox_teardown_on_abort() { SB_TORN=1 if [ "$SB_STARTED" -eq 0 ]; then - # Нічого не запускалось: прибирати нічого, крім файлу. + # Нічого не запускалось: прибирати нічого, крім файлів. Дерево + # попередньої версії сюди входить — воно розгортається ДО першого + # `up`, тобто саме в цьому вікні його й можна забути. rm -f "$SB_ENV" "$SB_ENV.bak" "$SB_ENV.tmp" + sandbox_up_drop_tree return 0 fi @@ -2295,6 +2411,19 @@ sandbox_teardown_on_abort() { say " Журнали зникнуть разом зі стендом. Якщо вони потрібні —" say " наступного разу запускайте з --keep." sandbox_down + sandbox_up_drop_tree +} + +# Дерево попередньої версії — це кількасот мегабайтів вихідних текстів +# і зібраного веб-інтерфейсу. Лишити його означає зробити рівно ту +# шкоду, від якої пісочниця захищає, тільки не томом, а каталогом. +sandbox_up_drop_tree() { + [ "$SB_UP" -eq 1 ] || return 0 + [ -d "$SB_UP_TREE" ] || return 0 + rm -f "$SB_UP_TREE/$SB_UP_ENV_NAME" + rm -rf "$SB_UP_TREE" || + bad "не вдалося видалити дерево попередньої версії $SB_UP_TREE — приберіть руками" + return 0 } sandbox_on_signal() { @@ -2398,6 +2527,726 @@ sandbox_summary() { 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 обрано автоматично: найновіший коміт із меншим набором міграцій" + 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 \ + "Не вдалося налити дані в базу попередньої версії." \ + "Це ще не вада оновлення: дані не лягли в СТАРУ схему, тобто" \ + "розійшлись наливання й та версія, яку обрали попередньою." \ + "Подивитись повний текст помилки: повторіть цей крок руками —" \ + " ./netpulse sandbox logs db" +DO $$ +DECLARE + v_tenant uuid; + v_site uuid; + v_repo uuid; + v_rule uuid; +BEGIN + SELECT id INTO v_tenant FROM core.tenants ORDER BY created_at LIMIT 1; + IF v_tenant IS NULL THEN + RAISE EXCEPTION 'у базі немає жодного кабінету — установка попередньої версії не дійшла до кінця'; + END IF; + + INSERT INTO inv.sites (tenant_id, name, code, address) + VALUES (v_tenant, 'Пісочниця · Львів', 'SB-LVIV', 'вигадана адреса') + ON CONFLICT (tenant_id, name) DO NOTHING; + SELECT id INTO v_site FROM inv.sites + WHERE tenant_id = v_tenant AND name = 'Пісочниця · Львів'; + + INSERT INTO ncm.repos (tenant_id, name, storage_path) + VALUES (v_tenant, 'sandbox', '/var/lib/netpulse/git/sandbox.git') + ON CONFLICT (tenant_id, name) DO NOTHING; + SELECT id INTO v_repo FROM ncm.repos + WHERE tenant_id = v_tenant AND name = 'sandbox'; + + INSERT INTO alr.rules (tenant_id, name, source, severity, condition) + VALUES (v_tenant, 'Пісочниця · втрата пакетів', 'icmp', 'high', + '{"op":"loss_pct >","value":20,"for":"3m"}'::jsonb) + ON CONFLICT (tenant_id, name) DO NOTHING; + SELECT id INTO v_rule FROM alr.rules + WHERE tenant_id = v_tenant AND name = 'Пісочниця · втрата пакетів'; + + -- 24 хости. Число не кругле заради краси: воно має бути більшим за + -- одиницю (щоб зловити міграцію, яка працює лише на першому рядку) і + -- меншим за сотні (щоб прогін лишався хвилинним). + INSERT INTO inv.devices (tenant_id, site_id, name, address, kind, vendor, model, status, notes) + SELECT v_tenant, v_site, + 'sb-sw-' || lpad(i::text, 2, '0'), + ('10.77.0.' || i)::inet, + 'switch', 'Cisco', 'C9200', 'up', + 'заведено пісочницею оновлення' + FROM generate_series(1, 24) AS i + ON CONFLICT DO NOTHING; + + -- Телеметрія: доба з кроком 5 хвилин на кожен хост. Це вже кілька + -- тисяч рядків у гіпертаблиці, тобто не один чанк Timescale, — а + -- саме на межах чанків і ламається половина міграцій телеметрії. + INSERT INTO ts.icmp_samples + (ts, device_id, tenant_id, rtt_avg_ms, rtt_min_ms, rtt_max_ms, + jitter_ms, loss_pct, packets_sent, packets_recv, reachable) + SELECT g, d.id, v_tenant, 2.0, 1.4, 4.1, 0.3, 0, 5, 5, true + FROM inv.devices d, + generate_series(now() - interval '24 hours', now(), interval '5 minutes') AS g + WHERE d.tenant_id = v_tenant AND d.name LIKE 'sb-sw-%' + ON CONFLICT DO NOTHING; + + INSERT INTO ts.series (tenant_id, device_id, plugin_key, metric_key, unit) + SELECT v_tenant, d.id, 'snmp', 'cpu.util', 'pct' + FROM inv.devices d + WHERE d.tenant_id = v_tenant AND d.name LIKE 'sb-sw-%' + ON CONFLICT DO NOTHING; + + INSERT INTO ts.samples (ts, series_id, value) + SELECT g, s.id, 30.0 + FROM ts.series s, + generate_series(now() - interval '24 hours', now(), interval '5 minutes') AS g + WHERE s.tenant_id = v_tenant AND s.metric_key = 'cpu.util' + ON CONFLICT DO NOTHING; + + -- Конфіги. Тіло живе в Git, тут метадані — але саме їх і чіпають + -- міграції NCM. + INSERT INTO ncm.configs + (tenant_id, device_id, repo_id, commit_sha, blob_sha, branch, path, + config_type, size_bytes, line_count, content_hash, is_change) + SELECT v_tenant, d.id, v_repo, + substr(md5(d.id::text || 'commit') || md5(d.id::text), 1, 40), + substr(md5(d.id::text || 'blob') || md5(d.id::text), 1, 40), + 'main', 'sandbox/' || d.name || '/running.cfg', 'running', + 2048, 64, sha256(convert_to(d.name || 'cfg', 'UTF8')), false + FROM inv.devices d + WHERE d.tenant_id = v_tenant AND d.name LIKE 'sb-sw-%' + ON CONFLICT DO NOTHING; + + -- Алерти лише на частині хостів: інакше «усі рядки однакові», і + -- міграція, яка псує вибірку за станом, лишилась би непоміченою. + INSERT INTO alr.alerts + (tenant_id, rule_id, device_id, severity, state, title, message, + dedup_key, value, threshold) + SELECT v_tenant, v_rule, d.id, 'high', 'firing', + 'Пісочниця · ' || d.name || ' не відповідає', + 'втрата пакетів 35%', 'sb:' || d.id::text, 35, 20 + FROM inv.devices d + WHERE d.tenant_id = v_tenant AND d.name LIKE 'sb-sw-%' + AND right(d.name, 1) IN ('1', '3', '5', '7', '9') + ON CONFLICT DO NOTHING; +END $$; +SQL + + ok "налито: локації, хости, метрики, конфіги, тригер і алерти" +} + +# --- що саме має пережити оновлення ----------------------------------- +# +# Перелік із правилом на кожен рядок, бо правило не одне. +# +# exact — рядки, які наливала пісочниця. Їх кількість НЕ МАЄ права +# змінитись ані в який бік: зникли — оновлення їх знищило, +# побільшало — оновлення їх роздвоїло. Обидва однаково погані +# й обидва беззвучні. +# min — усе інше в базі. Тут дозволено лише рости: система під час +# оновлення жива, локальний зонд пише телеметрію, дії лягають +# в аудит. Вимагати тут рівності означало б отримати хибне +# червоне на кожному другому прогоні. +sandbox_up_probes() { + cat <<'EOF' +хости_пісочниці|exact|SELECT count(*) FROM inv.devices WHERE name LIKE 'sb-sw-%' +локації_пісочниці|exact|SELECT count(*) FROM inv.sites WHERE name LIKE 'Пісочниця%' +проби_icmp|exact|SELECT count(*) FROM ts.icmp_samples s JOIN inv.devices d ON d.id=s.device_id WHERE d.name LIKE 'sb-sw-%' +ряди_метрик|exact|SELECT count(*) FROM ts.series WHERE metric_key='cpu.util' +відліки_метрик|exact|SELECT count(*) FROM ts.samples p JOIN ts.series s ON s.id=p.series_id WHERE s.metric_key='cpu.util' +конфіги_ncm|exact|SELECT count(*) FROM ncm.configs WHERE path LIKE 'sandbox/%' +тригери|exact|SELECT count(*) FROM alr.rules WHERE name LIKE 'Пісочниця%' +алерти|exact|SELECT count(*) FROM alr.alerts WHERE dedup_key LIKE 'sb:%' +кабінети|min|SELECT count(*) FROM core.tenants +користувачі|min|SELECT count(*) FROM core.users +членства|min|SELECT count(*) FROM core.memberships +зонди|min|SELECT count(*) FROM core.agents +шаблони|min|SELECT count(*) FROM tpl.templates +профілі_ncm|min|SELECT count(*) FROM ncm.profiles +типи_перевірок|min|SELECT count(*) FROM core.check_types +права|min|SELECT count(*) FROM core.permissions +журнал_аудиту|min|SELECT count(*) FROM core.audit_log +EOF +} + +# Знімок кількостей у файл. Через файл, а не змінну: рядків два десятки, +# і збирати їх у рядок означало б розбирати його потім розділювачем, +# який колись зустрінеться в даних. +sandbox_up_snapshot() { + _out=$1 + : > "$_out" || die "Не вдалося писати в $_out" + sandbox_up_probes > "$_out.q" + while IFS='|' read -r _label _rule _sql; do + [ -n "$_label" ] || continue + _n=$(dc exec -T db psql -tAX -U netpulse -d netpulse -c "$_sql" 2>/dev/null | tr -d ' \r') + case "$_n" in + ''|*[!0-9]*) _n=- ;; + esac + printf '%s|%s|%s\n' "$_label" "$_rule" "$_n" >> "$_out" + done < "$_out.q" + rm -f "$_out.q" +} + +sandbox_up_compare() { + _before=$1 + _after=$2 + step "чи пережили дані оновлення" + + _bad="" + printf '\n %-22s %10s %10s %s\n' "що саме" "до" "після" "правило" + printf ' %s\n' "------------------------------------------------------------" + while IFS='|' read -r _label _rule _a; do + [ -n "$_label" ] || continue + _b=$(sed -n "s/^$_label|[^|]*|//p" "$_before") + [ -n "$_b" ] || _b=- + _mark=" " + if [ "$_b" = - ] || [ "$_a" = - ]; then + _mark="?" + _bad="$_bad|$(printf '%s' "$_label" | tr '_' ' '): порахувати не вдалося (до=$_b, після=$_a) — таблиця зникла або запит не виконався" + elif [ "$_rule" = exact ] && [ "$_a" != "$_b" ]; then + _mark="!" + if [ "$_a" -lt "$_b" ]; then + _bad="$_bad|$(printf '%s' "$_label" | tr '_' ' '): було $_b, стало $_a — оновлення ЗНИЩИЛО $((_b - _a)) рядків" + else + _bad="$_bad|$(printf '%s' "$_label" | tr '_' ' '): було $_b, стало $_a — оновлення РОЗДВОЇЛО дані ($((_a - _b)) зайвих рядків)" + fi + elif [ "$_rule" = min ] && [ "$_a" -lt "$_b" ]; then + _mark="!" + _bad="$_bad|$(printf '%s' "$_label" | tr '_' ' '): було $_b, стало $_a — рядків поменшало там, де вони можуть лише прибувати" + fi + printf ' %s %-22s %10s %10s %s\n' \ + "$_mark" "$(printf '%s' "$_label" | tr '_' ' ')" "$_b" "$_a" "$_rule" + done < "$_after" + printf '\n' + + if [ -n "$_bad" ]; then + die "ДАНІ НЕ ПЕРЕЖИЛИ ОНОВЛЕННЯ." \ + "Система при цьому піднялась і самоперевірку пройшла — саме тому" \ + "ця звірка й існує окремо: зникла історія виглядає як працююча" \ + "система, у якої «просто ще немає даних»." \ + "" \ + "$(printf '%s' "$_bad" | tr '|' '\n')" \ + "" \ + "Дамп бази ПЕРЕД оновленням лежить у $SB_UP_TREE/before-upgrade.dump" \ + "(з --keep він переживе цей прогін)." + fi + ok "усі кількості на місці: нічого не зникло й нічого не роздвоїлось" +} + +# --- саме оновлення --------------------------------------------------- + +sandbox_up_dump() { + step "дамп перед оновленням" + # Те саме, що `netpulse upgrade` робить першим кроком, і з тієї ж + # причини: зворотних міграцій немає, тож дамп — єдиний шлях назад. + # Тут він ще й перевірка: pg_dump на НАПОВНЕНІЙ базі попередньої + # версії — рівно та команда, яку клієнт виконає перед оновленням, і + # дізнатись, що вона не працює, краще тут, ніж у нього. + _f="$SB_UP_TREE/before-upgrade.dump" + dc exec -T db pg_dump -U netpulse -d netpulse -Fc --no-owner > "$_f" || die \ + "pg_dump не відпрацював на базі попередньої версії." \ + "Клієнт на цьому місці лишився б без шляху назад — і дізнався б про це" \ + "рівно тоді, коли шлях назад знадобився." + _sz=$(wc -c < "$_f" | tr -d ' ') + [ "${_sz:-0}" -gt 1024 ] || die \ + "Дамп вийшов розміром $_sz байтів — це порожній або обірваний файл." \ + "Такий файл виглядає як бекап і не є ним." + ok "$_f, $((_sz / 1024)) КБ" +} + +sandbox_up_apply() { + step "збірка нової версії" + dc build || die \ + "Образи НОВОЇ версії не зібрались. Стенд попередньої версії при цьому" \ + "живий і цілий — оновлення просто не почалось." \ + "Журнали збірки вище; найчастіше це нестача місця під кеш збірки." + ok "образи нової версії зібрані" + + # Мітку часу знімаємо з БАЗИ й до перезапуску: за нею потім видно, чи + # дійшов від старого зонда хоч один такт ПІСЛЯ того, як колектор став + # новим. Годинник хоста тут не годиться — порівнюємо з timestamptz. + SB_UP_T0=$(dc exec -T db psql -tAX -U netpulse -d netpulse -c "SELECT now()" 2>/dev/null | + tr -d '\r' | head -1) + [ -n "$SB_UP_T0" ] || die "База не відповіла на SELECT now() перед оновленням." + + step "міграції поверх наявних даних" + _out=$(dc run --rm migrate 2>&1) + _rc=$? + printf '%s\n' "$_out" | sed 's/^/ /' + [ "$_rc" -eq 0 ] || die \ + "МІГРАЦІЇ НЕ НАКОТИЛИСЬ ПОВЕРХ НАЯВНИХ ДАНИХ." \ + "Це головна поломка, заради якої цей режим і написаний: на порожній" \ + "базі ті самі файли проходять, а на наповненій — ні." \ + "Текст помилки вище називає файл, на якому зупинилось." \ + "" \ + "База лишилась у стані попередньої версії: кожна міграція йде окремою" \ + "транзакцією, недокочена не залишається. Дамп до оновлення —" \ + "$SB_UP_TREE/before-upgrade.dump" + ok "схема оновлена" + + # Рядок «наявна інсталяція: паролі ролей не чіпаю» тут НЕ з'явиться, і + # це не недогляд. public.netpulse_install пишеться один раз, при + # першому в житті бази запуску мігратора, — а тоді вона була порожня. + # Тобто інсталяція, народжена чистою, лишається fresh назавжди, і + # мігратор перевидає ролям ті самі паролі з того самого .env. + # Наслідок для чесності звіту: гілку «наявна інсталяція» (fresh=false, + # перехід за deploy/RLS-EXISTING-INSTALL.md) цей режим НЕ проходить. + case "$_out" in + *"наявна інсталяція"*) ok "мігратор упізнав наявну інсталяцію" ;; + *"чиста база"*) ok "мігратор перевидав паролі ролей (база народилась чистою — так і має бути)" ;; + esac + + step "перезапуск служб на новій версії" + dc up -d || die \ + "Служби не піднялись на новій версії. Схема вже оновлена, тобто" \ + "повернутись можна лише з дампа: $SB_UP_TREE/before-upgrade.dump" + wait_api + ok "служби працюють на новій версії" +} + +# Найдорожча з трьох названих поломок і єдина, якої не видно з сервера: +# зонд стоїть у мережі клієнта й оновлюється НЕ РАЗОМ із сервером. +# Контейнер зонда тут навмисно не перезбирався — він лишився на образі +# попередньої версії (профіль agent, тому `dc up -d` його не чіпає). +# Питання одне: чи дійшов від нього хоч один такт після того, як +# колектор став новим. +sandbox_up_agent_compat() { + step "СТАРИЙ зонд проти НОВОГО колектора" + + if ! dc ps --services --filter status=running 2>/dev/null | grep -q '^agent$'; then + warn "зонда в пісочниці немає — сумісність зонда з новим колектором + не перевіряється взагалі. Це діра, а не дрібниця: саме цим шляхом + ламається оновлення в клієнта, у якого зонди в чужих мережах" + return 0 + fi + + # Такт зонда — раз на 30 секунд, плюс перепідключення після + # перезапуску колектора. Чекаємо вчетверо довше: хибне червоне тут + # відправляє шукати неіснуючу несумісність протоколу. + _i=0 + _n=0 + while [ "$_i" -lt 40 ]; do + _n=$(dc exec -T db psql -tAX -U netpulse -d netpulse -c \ + "SELECT count(*) FROM core.agents WHERE last_heartbeat_at > '$SB_UP_T0'::timestamptz" \ + 2>/dev/null | tr -d ' \r') + case "$_n" in + ''|*[!0-9]*) _n=0 ;; + esac + [ "$_n" -gt 0 ] && break + case "$_i" in + 10|20|30) say " чекаємо такту від старого зонда ($((_i * 3)) с)" ;; + esac + _i=$((_i + 1)) + sleep 3 + done + + [ "$_n" -gt 0 ] || die \ + "СТАРИЙ ЗОНД НЕ ДОСТУКАВСЯ ДО НОВОГО КОЛЕКТОРА за дві хвилини." \ + "Жодного такту з міткою пізнішою за $SB_UP_T0." \ + "" \ + "Саме це й ламає оновлення в клієнта: сервер оновлюють одним рухом," \ + "а зонди стоять у чужих мережах і лишаються старими. Зовні це" \ + "виглядає як «після оновлення зникли всі дані»." \ + "" \ + "Подивитись причину: ./netpulse sandbox logs collector" \ + " ./netpulse sandbox logs agent" + ok "старий зонд ($_n) продовжив слати такти новому колекторові" + + step "оновлення самого зонда" + dc --profile agent up -d --build agent || die \ + "Зонд не перезібрався й не піднявся на новій версії." + _t1=$(dc exec -T db psql -tAX -U netpulse -d netpulse -c "SELECT now()" 2>/dev/null | + tr -d '\r' | head -1) + _i=0 + _n=0 + while [ "$_i" -lt 40 ]; do + _n=$(dc exec -T db psql -tAX -U netpulse -d netpulse -c \ + "SELECT count(*) FROM core.agents WHERE last_heartbeat_at > '$_t1'::timestamptz" \ + 2>/dev/null | tr -d ' \r') + case "$_n" in + ''|*[!0-9]*) _n=0 ;; + esac + [ "$_n" -gt 0 ] && break + _i=$((_i + 1)) + sleep 3 + done + [ "$_n" -gt 0 ] || die \ + "НОВИЙ зонд не достукався до нового колектора за дві хвилини." \ + "Посвідчення зонда лежить у томі й переживає перестворення контейнера —" \ + "тобто це не втрачений токен, а розходження зонда з сервером." \ + "Журнали: ./netpulse sandbox logs agent" + ok "оновлений зонд працює зі своїм посвідченням із тому (нового запрошення не треба)" +} + +sandbox_up_summary() { + printf '\n%s== оновлення пройшло повністю%s\n\n' "$C_G" "$C_0" + printf ' Було: %s\n' "$(git -C "$ROOT_MAIN" log -1 --date=short --format='%h %ad %s' "$SB_UP_REF" 2>/dev/null)" + printf ' Стало: %s\n' "$(git -C "$ROOT_MAIN" log -1 --date=short --format='%h %ad %s' HEAD 2>/dev/null)" + if [ -n "$SB_UP_NEW_MIGRATIONS" ]; then + printf '\n Накочено поверх наявних даних:\n' + printf '%s\n' "$SB_UP_NEW_MIGRATIONS" | sed 's|.*/| |' + fi + + printf '\n %sЩо саме щойно доведено%s\n' "$C_B" "$C_0" + printf ' Попередня версія стала з нуля й пройшла самоперевірку.\n' + printf ' Міграції накотились на базу з хостами, телеметрією, конфігами\n' + printf ' й алертами — а не на порожню.\n' + printf ' Служби перезапустились на новій версії з тим самим .env і тими\n' + printf ' самими томами.\n' + printf ' СТАРИЙ зонд продовжив працювати з НОВИМ колектором.\n' + printf ' Та сама самоперевірка, що й після чистої установки, зелена.\n' + printf ' Жоден налитий рядок не зник і не роздвоївся.\n' + + printf '\n %sЧого НЕ доведено%s\n' "$C_B" "$C_0" + printf ' Гілка «наявна інсталяція» в міграторі. База пісочниці народилась\n' + printf ' чистою, тому public.netpulse_install назавжди fresh=true, і\n' + printf ' мігратор іде гілкою «видати паролі ролям». Перехід стенду,\n' + printf ' зробленого до 0063 (deploy/RLS-EXISTING-INSTALL.md), лишається\n' + printf ' неперевіреним.\n' + printf ' Зміни в самому УСТАНОВНИКУ між версіями: обидва етапи веде один\n' + printf ' і той самий цей файл — інакше різниця в приладі читалась би як\n' + printf ' різниця у виробі.\n' + printf ' Оновлення через кілька версій підряд: перевіряється рівно один\n' + printf ' стрибок, з обраного ref у HEAD.\n' + printf ' Усе те, чого не доводить і перший режим: Let'\''s Encrypt і DNS,\n' + printf ' трапи на 162/udp, поведінка під навантаженням.\n' + if [ "$SB_UP_SAME_SCHEMA" -eq 1 ]; then + printf '\n %sІ головне: у цій парі версій НЕ БУЛО НОВИХ МІГРАЦІЙ.%s\n' "$C_R" "$C_0" + printf ' Прогін зелений, але про накочування поверх даних він не сказав\n' + printf ' нічого. Візьміть давніший ref: --from \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.НЕ-ЧИТАЄТЬСЯ-У-ПІСОЧНИЦІ" + 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 перенесено в нове дерево без змін — секрети ті самі" + + 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_tree + printf '\n %sОновлення пройшло повністю, слідів не лишилось.%s\n\n' "$C_G" "$C_0" + fi + trap - INT TERM HUP +} + # --- команди --------------------------------------------------------- cmd_sandbox() { @@ -2413,24 +3262,36 @@ cmd_sandbox() { --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 ;; - down) cmd_sandbox_down ;; - status) cmd_sandbox_status ;; - check) cmd_sandbox_check ;; - logs) SANDBOX=1; ENV_FILE=$SB_ENV; pick_compose - [ -f "$SB_ENV" ] || die "Пісочниці немає: $SB_ENV не знайдено." - dc logs -f --tail=200 "$@" ;; - *) printf 'невідома підкоманда пісочниці: %s\n' "$_sub" - printf 'є: up, once, down, status, check, logs\n' - exit 2 ;; + 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 } @@ -2487,34 +3348,87 @@ cmd_sandbox_up() { trap - INT TERM HUP } +# Прибирає ОБИДВА режими одним рухом. Розділяти їх прапорцем було б +# помилкою того самого роду, що й забути другий том: людина, яка кличе +# «прибрати пісочницю», хоче, щоб на машині не лишилось нічого, — і не +# зобов'язана пам'ятати, у якому режимі вона її колись підняла. cmd_sandbox_down() { printf '%sNetPulse · прибирання пісочниці%s\n\n' "$C_B" "$C_0" STEP_NAME="прибирання" if ! have docker; then die "docker не знайдено." fi - if ! sandbox_leftovers && [ ! -f "$SB_ENV" ]; then + + _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" + if sandbox_leftovers || [ -f "$SB_ENV" ] || [ -d "$SB_UP_TREE" ]; 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_tree + [ -d "$SB_UP_TREE" ] || ok "дерево попередньої версії видалено" + fi + + if [ "$_any" -eq 0 ]; then ok "пісочниці немає — прибирати нічого" return 0 fi - sandbox_down || exit 1 + [ "$_rc" -eq 0 ] || exit 1 printf '\n' } cmd_sandbox_status() { SANDBOX=1 - ENV_FILE=$SB_ENV pick_compose printf '%sNetPulse · стан пісочниці%s\n\n' "$C_B" "$C_0" if ! have docker; then say " docker не знайдено — стан невідомий" return 0 fi - if ! sandbox_leftovers; then - say " пісочниці немає: ані контейнерів, ані томів" - [ -f "$SB_ENV" ] && warn "але лишився $SB_ENV — прибрати: ./netpulse sandbox down" + + _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/^/ /' @@ -2525,7 +3439,7 @@ cmd_sandbox_status() { _p=$(sed -n 's/^NETPULSE_SB_HTTPS=//p' "$SB_ENV" | tail -1) [ -n "$_p" ] && printf '\n Адреса: https://localhost:%s (логін admin)\n' "$_p" fi - printf '\n Прибрати: ./netpulse sandbox down\n\n' + return 0 } # Повторна перевірка живої пісочниці — тією самою cmd_check, що й на @@ -2694,8 +3608,19 @@ compose, окремі томи, порти на 127.0.0.1, задані числ ./netpulse sandbox down знести все, включно з томами ./netpulse sandbox logs [служба] журнали пісочниці - --keep не прибирати після невдачі (для розбору журналів) +Режим другий — ОНОВЛЕННЯ з версії на версію. Ставить попередню версію з +нуля, наливає в неї хости, метрики, конфіги й алерти, оновлює до цього +дерева й проганяє ту саму самоперевірку, а потім окремо звіряє, що дані +пережили. Попередня версія береться з git: тег — це теж ref, тому +--from v0.1.0 запрацює в день першого тегу без правок. + + ./netpulse sandbox upgrade попередня версія обереться сама + ./netpulse sandbox upgrade --from REF попередня версія — цей коміт/тег + + --keep не прибирати після невдачі (для розбору журналів); + у режимі оновлення лишає стенд і після успіху --alongside дозволити запуск поруч із бойовою інсталяцією + --from REF коміт або тег, який вважати попередньою версією Відповіді на п'ять питань, яких система не може вирішити сама, — у netpulse.conf. Зразок із поясненнями: netpulse.conf.example. @@ -2717,6 +3642,7 @@ while [ $# -gt 0 ]; do -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 diff --git a/scripts/dbtest.sh b/scripts/dbtest.sh index c181690..7bc6bb8 100644 --- a/scripts/dbtest.sh +++ b/scripts/dbtest.sh @@ -26,6 +26,14 @@ # NETPULSE_TEST_DSN=... scripts/dbtest.sh # проти готової бази, # # ім'я якої містить probe/test # +# У CI ЙДЕ ДРУГИЙ ШЛЯХ. Робота dbtest у .forgejo/workflows/ci.yml задає +# NETPULSE_TEST_DSN на базу netpulse_probe, яку раннер піднімає сервісом. +# Саме тому там і потрібна гілка «готова база»: підняти контейнер собі +# скрипт зміг би лише з докер-сокетом хоста всередині роботи, а це +# root-доступ до всієї машини для будь-якого workflow із будь-якої гілки. +# Другий наслідок цієї гілки — ім'я бази в CI мусить містити probe/test, +# інакше запобіжник нижче спрацює на власному конвеєрі. +# set -u SRC="$(cd "$(dirname "$0")/.." && pwd)" diff --git a/server/internal/store/ncm_redact_account_test.go b/server/internal/store/ncm_redact_account_test.go new file mode 100644 index 0000000..40ecbf9 --- /dev/null +++ b/server/internal/store/ncm_redact_account_test.go @@ -0,0 +1,73 @@ +package store + +import ( + "strings" + "testing" +) + +// Точний уривок із конфігу живого комутатора D-Link DGS-1100-06/ME, +// через який вада й знайшлась: пароль адміністратора лежав відкритим у +// плані відкату, у базі й у git-дзеркалі. +// +// Пароль тут вигаданий; форма — справжня. +func TestAccountPasswordEchoIsMasked(t *testing.T) { + in := []string{ + "# User Account", + "create account admin btadmin ", + "s3cr3tpass", + "s3cr3tpass", + "disable password encryption", + } + got := redactLines(nil, in) + + for i, line := range got { + if strings.Contains(line, "s3cr3tpass") { + t.Errorf("рядок %d лишив пароль відкритим: %q", i, line) + } + } + if got[2] != "***" || got[3] != "***" { + t.Errorf("відлуння пароля не замасковане: %q, %q", got[2], got[3]) + } + // Рядок про сам обліковий запис має лишитись читабельним: людина при + // погодженні мусить бачити, ЩО саме змінюється. + if !strings.Contains(got[1], "btadmin") { + t.Errorf("з'їдено ім'я облікового запису: %q", got[1]) + } +} + +// Маскування не має пожирати справжні рядки конфігу. +func TestAccountEchoDoesNotEatConfig(t *testing.T) { + in := []string{ + "create account user guest ", + "pass1", + "pass1", + "enable", // односкладова команда одразу після відлуння + "disable clipaging", // звичайний рядок + } + got := redactLines(nil, in) + if got[3] != "enable" { + t.Errorf("з'їдено команду після двох рядків відлуння: %q", got[3]) + } + if got[4] != "disable clipaging" { + t.Errorf("з'їдено звичайний рядок: %q", got[4]) + } +} + +// Односкладовий рядок САМ ПО СОБІ не секрет: маскується лише той, що +// стоїть одразу після заведення облікового запису. +func TestBareTokenAloneIsNotMasked(t *testing.T) { + got := redactLines(nil, []string{"enable", "end", "exit"}) + for i, line := range got { + if line == "***" { + t.Errorf("рядок %d замасковано без причини: був %q", i, []string{"enable", "end", "exit"}[i]) + } + } +} + +// Стара поведінка не зламана: секрет в одному рядку з ключовим словом. +func TestInlineSecretStillMasked(t *testing.T) { + got := redactLines(nil, []string{"snmp-server community public RO"}) + if strings.Contains(got[0], "public") { + t.Errorf("community лишилась відкритою: %q", got[0]) + } +} diff --git a/server/internal/store/ncm_rollback_plan.go b/server/internal/store/ncm_rollback_plan.go index a2546ae..9ab33b6 100644 --- a/server/internal/store/ncm_rollback_plan.go +++ b/server/internal/store/ncm_rollback_plan.go @@ -461,15 +461,62 @@ func redactLines(patterns []string, in []string) []string { res = append(res, defaultRedact...) out := make([]string, len(in)) + // echo — скільки наступних рядків є відлунням запиту пароля. + // + // Порядкове маскування ловить лише те, де секрет стоїть в одному + // рядку з ключовим словом. У D-Link це не так: комутатор питає + // пароль інтерактивно, і `show config` віддає ВІДПОВІДІ окремими + // рядками, без жодного слова поруч: + // + // create account admin btadmin + // <пароль> + // <пароль ще раз> + // + // Такий рядок — просто слово. Жоден зразок його не впізнає, і саме + // так пароль адміністратора живого комутатора опинився відкритим у + // плані відкату. Тому стан: після рядка, що заводить обліковий + // запис, наступні односкладові рядки — це секрет, поки не почнеться + // щось, схоже на команду. + echo := 0 for i, s := range in { + if echo > 0 && isBareToken(s) { + out[i] = "***" + echo-- + continue + } + echo = 0 for _, re := range res { s = re.ReplaceAllString(s, "***") } + if accountOpener.MatchString(s) { + // Двох вистачає: пароль і його підтвердження. Більше — + // означало б з'їдати справжні рядки конфігу. + echo = 2 + } out[i] = s } return out } +// accountOpener — рядок, після якого пристрій питає пароль окремо. +// +// Свідомо ширший за D-Link: та сама звичка є в кількох клонів, а ціна +// зайвого маскування — «***» замість слова в плані, тоді як ціна +// пропуску — пароль адміністратора в базі, у git-дзеркалі й на екрані. +var accountOpener = regexp.MustCompile(`(?i)^[ ]*(create|config)[ ]+account($|[^a-zA-Z0-9_-])`) + +// isBareToken — чи схожий рядок на відлуння введеного пароля. +// +// Одне слово без пробілів і без початкового `#`: команди конфігурації +// односкладовими не бувають, а коментар починається з решітки. +func isBareToken(s string) bool { + t := strings.TrimSpace(s) + if t == "" || strings.HasPrefix(t, "#") || strings.HasPrefix(t, "!") { + return false + } + return !strings.ContainsAny(t, " \t") +} + // redactText — те саме для суцільного тексту (стенограма сесії). func redactText(patterns []string, in string) string { if in == "" {