diff --git a/deploy/README.md b/deploy/README.md index 784972d..3806543 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -50,6 +50,138 @@ docker compose run --rm --entrypoint netpulse-user cli \ Інтерфейс — на `https://`. +## Пісочниця: повна установка, яку можна перевірити + +Твердження «нова інсталяція піднімається сама» довго було доведене +міркуванням, а не запуском. Сухий прогін проходив, окремі кроки +перевірялись на живій базі — а повної установки з нуля не робив ніхто, +бо ніде: єдина доступна машина була бойовим стендом на 4 ГБ, і другий +повний стек поклав би робочу систему. + +`./netpulse sandbox` ставить NetPulse **по-справжньому**, з тим самим +`docker-compose.yml`, тими самими міграціями й тією самою +самоперевіркою, але в окремому проєкті `compose` з власними томами й +портами на `127.0.0.1`. Це не імітація: вона або справді піднімає стек і +заходить у нього справжнім паролем, або зупиняється й каже, на чому. + +```sh +./netpulse sandbox # підняти й лишити, щоб подивитись +./netpulse sandbox once # підняти, перевірити, знести все +./netpulse sandbox check # та сама перевірка ще раз +./netpulse sandbox status # що вона зараз займає +./netpulse sandbox down # знести все, включно з томами +./netpulse sandbox logs [служба] # журнали +``` + +**Розробникові перед випуском** — `./netpulse sandbox once`. Він робить +повну установку з нуля, проганяє всі твердження самоперевірки (вхід +справжнім паролем, кабінет назвався, сім переліків із міграцій +непорожні, дванадцять ендпоїнтів відповідають, зонд зареєструвався в +колекторі) і прибирає за собою до останнього тому. Ненульовий код +виходу означає, що цю збірку клієнтові віддавати не можна. + +**Клієнтові — щоб подивитись до того, як ставити** — `./netpulse +sandbox` без підкоманди. Після установки вона друкує адресу +`https://localhost:<порт>`, логін і пароль; система жива, у ній є +кабінет, локальний зонд і всі довідники. Подивились — `./netpulse +sandbox down`, і на машині не лишається нічого. + +### Скільки це коштує + +| Що | Скільки | +| -------------- | ------------------------------------------------------------ | +| Пам'ять у роботі | ~550–900 МБ на всі шість служб | +| Стеля пам'яті | ~2.05 ГБ — жорстке обмеження з накладки, вище не підніметься | +| Пік при збірці | ще ~1–2 ГБ, поки збираються образи Go | +| Диск: томи | ~250 МБ (порожня база зі схемою, внутрішній CA Caddy, посвідчення зонда) | +| Диск: образи | ~2 ГБ, **спільні** з бойовою інсталяцією — якщо вони вже зібрані, пісочниця не додає нічого | +| Порти | 18080, 18081, 18082 (або наступна вільна трійка), усі на `127.0.0.1` | + +Точні цифри після запуску показує `./netpulse sandbox status` — вони +виміряні, а не оцінені. + +### Коли її запускати НЕ можна + +Установник перевіряє це сам і **відмовляється**, а не пробує: + +- **вільно менше 3 ГБ пам'яті** (4 ГБ, якщо образи ще треба зібрати). + Міряється `MemAvailable`, а не вся пам'ять: на машині, де вже працює + бойовий стек, «4 ГБ встановлено» не має жодного стосунку до того, + скільки з них можна взяти; +- **вільно менше 3 ГБ диска** (8 ГБ без готових образів); +- **на машині працює бойова інсталяція NetPulse.** Пісочниця не зіпсує + їй ані даних, ані портів — вона в іншому проєкті `compose`. Але + пам'ять і диск у них спільні, і два Postgres не вміщуються там, де + ледве вміщується один. Обійти можна прапорцем `--alongside`, і це + свідоме рішення, а не формальність; +- **docker compose старший за 2.24.4** — див. нижче; +- **пісочниця вже стоїть.** Друга поверх першої поділила б із нею томи, + і прогін «з нуля» перестав би бути прогоном з нуля. + +Правильне місце для пісочниці — машина розробника або окрема віртуалка. + +### Що буде при обриві + +Пісочниця, яка лишила по собі том на 250 МБ і контейнер, що тримає +порт, — це та сама шкода, від якої вона мала захистити. Тому: + +- `Ctrl-C`, `SIGTERM`, `SIGHUP` перехоплюються і прибирають усе; +- будь-яка зупинка установки (`ЗУПИНКА на кроці …`) теж прибирає все; +- у накладці стоїть `restart: "no"` — забута пісочниця **не воскресає** + після перезавантаження хоста; +- прибирання йде трьома ешелонами: `compose down -v`, потім пряме + `docker rm`/`docker volume rm` за міткою проєкту (ловить те, що + лишилось від обірваного `up`), потім видалення `.env.sandbox`; +- `--keep` лишає уламки для розбору журналів — але тільки при **невдачі + установки**, не при сигналі: перерваний прогін лишає по собі не стенд, + а половину стенду. + +Чого перехопити неможливо: `kill -9` і зникнення живлення. Саме тому +джерелом правди про залишки є не файл-позначка, а сам docker — і +`./netpulse install` та `./netpulse check` при кожному запуску кажуть, +якщо на машині висить забута пісочниця. + +### Чого пісочниця НЕ доводить + +Це найважливіший абзац розділу. Зелена перевірка доводить тільки те, що +вона перевіряє — цей проєкт уже платив за протилежне припущення. + +- **Let's Encrypt і DNS.** Пісочниця стоїть на `localhost` із + самопідписаним сертифікатом і навмисно не читає `netpulse.conf`: + справжній `DOMAIN` звідти відправив би її по сертифікат для адреси, + яка веде на бойовий стенд, і витрачені спроби списались би з тижневої + квоти домену. +- **Прийом SNMP-трапів і правило `DOCKER-USER`.** Порт 162/udp назовні + не виставляється взагалі: він не має автентифікації, і відкривати + його заради перевірки означало б купити перевірку ціною дірки. +- **Розрахунок `shared_buffers` із пам'яті хоста.** У пісочниці він + заданий числом — це умова того, щоб вона нічого не поклала. +- **Поведінка під навантаженням.** База порожня, хостів на моніторингу + немає, історії немає. + +### Чому потрібен compose 2.24.4 + +Списки `ports` при накладанні compose-файлів **додаються**, а не +замінюються. Щоб зсунуті порти пісочниці не стали *додатком* до базових +80/443/9443, накладка перевизначає їх тегом `!override`, а він +з'явився у docker compose 2.24.4. На старішій версії пісочниця +відмовляється працювати — бо мовчки зайняти порти бойового проксі +гірше, ніж не запуститись. + +Вимога знімається одним рядком у `docker-compose.yml`: якщо зробити +базові порти змінними — + +```yaml + ports: + - "${NETPULSE_BIND:-0.0.0.0}:${NETPULSE_PORT_HTTP:-80}:80" + - "${NETPULSE_BIND:-0.0.0.0}:${NETPULSE_PORT_HTTPS:-443}:443" + - "${NETPULSE_BIND:-0.0.0.0}:${NETPULSE_PORT_GRPC:-9443}:9443" +``` + +— то пісочниці вистачить власного `.env.sandbox`, накладка портів стане +непотрібною, а мінімальна версія compose лишиться 2.0, як у решті +установника. + ## Підключення зонда В інтерфейсі: **Зонди → Додати зонд**. Видане запрошення (`np_enr_…`) diff --git a/deploy/docker-compose.sandbox.yml b/deploy/docker-compose.sandbox.yml new file mode 100644 index 0000000..acd25ef --- /dev/null +++ b/deploy/docker-compose.sandbox.yml @@ -0,0 +1,118 @@ +# Пісочниця NetPulse — накладка на docker-compose.yml. +# +# Це НЕ окремий стек і не спрощена копія. Це той самий docker-compose.yml, +# накритий рівно тими правками, без яких повна установка на робочій +# машині або конфліктує з наявною, або з'їдає машину. Усе інше — +# порядок служб, міграції, ролі під RLS, зонд — лишається тим самим, бо +# інакше пісочниця перевіряла б не установку, а власну вигадку. +# +# Запускати руками не треба: `./netpulse sandbox` підставляє і цей файл, +# і ім'я проєкту, і окремий .env.sandbox. +# +# docker compose -p netpulse-sandbox \ +# -f docker-compose.yml -f deploy/docker-compose.sandbox.yml \ +# --env-file .env.sandbox <команда> +# +# --------------------------------------------------------------------- +# ЩО САМЕ ТУТ МІНЯЄТЬСЯ І ЧОМУ +# +# 1. Порти. Базовий файл публікує 80, 443, 9443 і 162/udp числами. Друга +# інсталяція на тій самій машині впирається в них першою ж командою +# `up`, і виглядає це як «пісочниця поклала бойову систему» — рівно та +# шкода, від якої вона мала захистити. Тому проксі публікується на +# зсунуті порти І ЛИШЕ на 127.0.0.1: пісочниця не має бути видною з +# мережі навіть випадково, у ній стоїть відомий пароль і самопідписаний +# сертифікат. +# +# 2. Трапи. Зонд у пісочниці піднімається (без нього не перевірити +# реєстрацію в колекторі — а це єдиний шлях, якого HTTP-перевірки не +# бачать зовсім), але 162/udp назовні не виставляє взагалі. Порт без +# автентифікації, відкритий заради перевірки, — це не перевірка. +# +# 3. Стелі пам'яті. Без них пісочниця конкурує з рештою машини на рівних +# і виграє: Postgres резервує буфери одразу, а ядро вбиває не того, +# хто попросив забагато, а того, хто підвернувся. Стеля перетворює +# «машина стала» на «контейнер пісочниці впав», і друге видно одразу. +# Цифри взяті з запасом: стеля тут — запобіжник, а не режим економії, +# і впертись у неї на порожній базі не має ніщо. Якщо все ж уперлось, +# `./netpulse sandbox status` це називає прямо, щоб причину не шукали +# в коді. +# +# 4. restart: "no". У бойовому стеку `unless-stopped` правильний: служба +# має пережити перезавантаження хоста. Для пісочниці це протилежність +# потрібного — забута пісочниця не має воскресати після ребуту й +# мовчки тримати порт і том. Побічний наслідок корисний: контейнер, +# який падає в циклі, тут одразу видно як мертвий, а не як «щось +# довго стартує». +# +# --------------------------------------------------------------------- +# ЧОМУ ПОТРІБЕН COMPOSE 2.24.4 +# +# Списки `ports` при накладанні файлів compose ДОДАЄ, а не замінює: без +# явного тега `!override` базові «80:80» лишилися б на місці поруч зі +# зсунутими, і пісочниця все одно вчепилась би в порт бойового проксі. +# Тег `!override` з'явився у docker compose 2.24.4. +# +# Ця вимога зникає одним рядком у docker-compose.yml — якщо базові порти +# зробити змінними зі значенням за замовчуванням: +# +# ports: +# - "${NETPULSE_BIND:-0.0.0.0}:${NETPULSE_PORT_HTTP:-80}:80" +# - "${NETPULSE_BIND:-0.0.0.0}:${NETPULSE_PORT_HTTPS:-443}:443" +# - "${NETPULSE_BIND:-0.0.0.0}:${NETPULSE_PORT_GRPC:-9443}:9443" +# +# Тоді пісочниці вистачить .env.sandbox, накладка портів стає не +# потрібна, а мінімальна версія compose лишається 2.0 — як у решті +# установника. Поки цього немає, `./netpulse sandbox` перевіряє версію і +# відмовляється працювати на старішій, а не мовчки бере не ті порти. + +# Ім'я проєкту продубльовано тут навмисно. `./netpulse sandbox` і так +# передає -p, але якщо цей файл колись запустять руками без -p, ізоляція +# має лишитись: інше ім'я проєкту — це інші контейнери, інша мережа й, +# найголовніше, ІНШІ ТОМИ. Без нього `down -v` у пісочниці знищив би +# базу бойової інсталяції. +name: netpulse-sandbox + +services: + db: + mem_limit: 768m + restart: "no" + + cache: + mem_limit: 320m + restart: "no" + + migrate: + mem_limit: 256m + + cli: + mem_limit: 256m + + api: + mem_limit: 384m + restart: "no" + + collector: + mem_limit: 256m + restart: "no" + + proxy: + mem_limit: 128m + restart: "no" + # Порти пише установник у .env.sandbox, попередньо переконавшись, що + # вони вільні. Значення за замовчуванням тут — щоб файл не був + # непрацездатним сам по собі, а не щоб на них покладатись. + ports: !override + - "127.0.0.1:${NETPULSE_SB_HTTP:-18080}:80" + - "127.0.0.1:${NETPULSE_SB_HTTPS:-18081}:443" + - "127.0.0.1:${NETPULSE_SB_GRPC:-18082}:9443" + + agent: + mem_limit: 192m + restart: "no" + # Порожньо, а не інший порт: 162/udp не має автентифікації, і + # відкривати його заради перевірки означало б купити перевірку ціною + # дірки. Усе, що перевіряє зонд — обмін запрошення на токен, gRPC до + # колектора, поява рядка в core.agents, — відбувається вихідними + # з'єднаннями й публікації порту не потребує. + ports: !override [] diff --git a/docker-compose.yml b/docker-compose.yml index 5621e8e..302332c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -95,7 +95,21 @@ services: volumes: - db-data:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U netpulse -d netpulse"] + # -h 127.0.0.1 обов'язкове: без нього pg_isready йде unix-сокетом, + # а споживачі — по TCP. + # + # Різниця видна рівно один раз у житті інсталяції, на ПЕРШОМУ + # запуску. Образ Postgres під час ініціалізації піднімає тимчасовий + # сервер БЕЗ TCP (listen_addresses=''), щоб виконати initdb і + # скрипти. Сокетна перевірка в цю мить каже «готовий», compose + # позначає службу здоровою, migrate стартує — і отримує + # «connection refused», хоча база нібито здорова. + # + # На вже створеній базі цієї фази немає, тому вада не показувалась + # роками: вона чекала не на нас, а на першого клієнта. Знайдена + # пісочницею установника з першої ж спроби — це рівно те, заради + # чого вона й писалась. + test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U netpulse -d netpulse"] interval: 5s timeout: 5s retries: 20 @@ -267,13 +281,22 @@ services: - ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro - caddy-data:/data - caddy-config:/config + # Порти й адреса прив'язки — змінними, з типовими значеннями, що + # дають рівно попередню поведінку. + # + # Це не гнучкість заради гнучкості. Списки `ports` при накладанні + # compose-файлів ДОДАЮТЬСЯ, а не замінюються: пісочниця, яка + # оголосила б свої 18080/18443, отримала б їх НА ДОДАЧУ до 80 і 443 + # бойового проксі — тобто перехопила б робочий трафік. Обійти це + # можна тегом `!override`, але він вимагає compose 2.24.4, а решта + # установника працює на 2.0. Змінна знімає і те, і те. ports: - - "80:80" - - "443:443" + - "${NETPULSE_BIND:-0.0.0.0}:${NETPULSE_PORT_HTTP:-80}:80" + - "${NETPULSE_BIND:-0.0.0.0}:${NETPULSE_PORT_HTTPS:-443}:443" # Окремий порт для зондів: вони говорять gRPC, а не HTTP, і # ділити з ним 443 означало б розрізняти протоколи за шляхом — # зайва крихкість там, де порт коштує нічого. - - "9443:9443" + - "${NETPULSE_BIND:-0.0.0.0}:${NETPULSE_PORT_GRPC:-9443}:9443" depends_on: - api - collector diff --git a/netpulse b/netpulse index 94d977d..b3b3a71 100644 --- a/netpulse +++ b/netpulse @@ -14,6 +14,7 @@ # # ./netpulse install поставити (ідемпотентно: можна повторювати) # ./netpulse check лікар: ті самі твердження на живій системі +# ./netpulse sandbox та сама установка з нуля, але в ізоляції # ./netpulse backup дамп бази + ключі, без яких дамп марний # ./netpulse restore -f відновлення з рамкою TimescaleDB # ./netpulse upgrade перезбирання, міграції, перевірка @@ -27,6 +28,17 @@ # що кабінет назвався, а переліки, які наливають міграції, не порожні. # Не пройшло — не «готово з попередженням», а зупинка з назвою кроку. # +# І те саме твердження про сам установник. «Нова інсталяція піднімається +# сама» довго було доведене міркуванням: сухий прогін проходив, окремі +# кроки перевірялись на живій базі, а повної установки з нуля не робив +# ніхто — бо ніде. Саме на цьому класі помилки проєкт уже обпікся +# (HISTORY.md, «Перехід на роль без BYPASSRLS»): перевірка була +# ретельна, зелена й дивилась повз поломку. Тому є `sandbox` — та сама +# установка, той самий compose-файл, та сама самоперевірка, але в +# окремому просторі імен, зі зсунутими портами, заданими числами +# ресурсами й гарантованим прибиранням. Не імітація: або справді +# піднімає стек, або чесно каже, що не може. +# # POSIX sh, не bash: сервер клієнта може бути будь-яким, і dash тут # зустрічається частіше, ніж здається. @@ -50,6 +62,36 @@ 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" +SB_HTTP=18080 +SB_HTTPS=18081 +SB_GRPC=18082 +SB_KEEP=0 +SB_ALONGSIDE=0 +# Чи встигли ми щось запустити. Від цього залежить, чи прибирати за +# собою при обриві: до першого `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="" @@ -91,10 +133,15 @@ step() { die() { printf '\n%sЗУПИНКА на кроці «%s»%s\n' "$C_R" "$STEP_NAME" "$C_0" for _l in "$@"; do printf ' %s\n' "$_l"; done - if [ "${CMD:-}" = install ]; then + 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 } @@ -117,9 +164,41 @@ pick_compose() { 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) ( cd "$ROOT" && docker compose "$@" ) ;; + 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 @@ -203,7 +282,11 @@ rand_pass() { host_mem_mb() { if [ -r /proc/meminfo ]; then - awk '/^MemTotal:/ { printf "%d", $2 / 1024; exit }' /proc/meminfo + # 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 @@ -211,6 +294,32 @@ host_mem_mb() { 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. Нуль означає «не знаємо» — і викликач має # обрати обережне значення, а не вдавати, що ядро одне. @@ -351,6 +460,26 @@ preflight() { ;; 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 буде обережним" @@ -441,6 +570,28 @@ 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') @@ -501,6 +652,34 @@ VAL_TRAPS_SRC="" 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 @@ -744,7 +923,13 @@ write_env() { step ".env" _enroll="" - [ -f "$ENV_FILE" ] && _enroll=$(env_get NETPULSE_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 "у сухому прогоні файл не пишеться; вміст (секрети приховані):" @@ -792,10 +977,13 @@ TZ=$VAL_TZ NETPULSE_ALERT_INTERVAL=30s NETPULSE_LOG_LEVEL=info -# Пораховано з пам'яті хоста, а не взято з прикладу. +# Пораховано з пам'яті хоста, а не взято з прикладу. У пісочниці — +# навпаки: задано числами, бо рахувати чверть від чужої пам'яті на +# машині, де вже щось працює, і означає покласти те, що працює. PG_SHARED_BUFFERS=$VAL_SHBUF DRAGONFLY_MAXMEMORY=$VAL_DFMEM PG_BG_WORKERS=$VAL_PGBGW +$(env_body_sandbox) NETPULSE_VERSION=dev NETPULSE_COMMIT=none @@ -808,6 +996,56 @@ NETPULSE_TRAPS_LISTEN=:162 ENVEOF } +# Додаток до .env, який існує лише в пісочниці. У бойовому .env цих +# рядків немає й бути не має: три перші читає накладка на compose-файл, +# четвертий — сам установник, і жоден із них не має сенсу поза +# одноразовим стендом. +env_body_sandbox() { + [ "$SANDBOX" -eq 1 ] || return 0 + cat </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:dev" >/dev/null 2>&1 && + docker image inspect "netpulse/agent:dev" >/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 і netpulse/agent уже є — збірка буде доважною" + 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 + + _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=$((18080 + _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 "не знайшлося вільної трійки портів у діапазоні 18080–18272. + Найімовірніше на машині вже висить кілька пісочниць: + ./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 + sandbox_leftovers || return 0 + warn "на цій машині лишилась пісочниця ($SB_PROJECT). + Вона не заважає цій команді — інший проєкт compose, інші томи, — + але займає пам'ять і диск. Подивитись: ./netpulse sandbox status + Прибрати: ./netpulse sandbox down" +} + +# --- прибирання ------------------------------------------------------ + +# Заглушка .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 + # Нічого не запускалось: прибирати нічого, крім файлу. + rm -f "$SB_ENV" "$SB_ENV.bak" "$SB_ENV.tmp" + 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_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' +} + +# --- команди --------------------------------------------------------- + +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 ;; + -p) shift; OWNER_PASSWORD=${1:-} ;; + *) break ;; + esac + shift + done + + 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 ;; + 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 + if ! sandbox_leftovers && [ ! -f "$SB_ENV" ]; then + ok "пісочниці немає — прибирати нічого" + return 0 + fi + sandbox_down || 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" + return 0 + fi + [ -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 + printf '\n Прибрати: ./netpulse sandbox down\n\n' +} + +# Повторна перевірка живої пісочниці — тією самою 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 # --------------------------------------------------------------------- @@ -1799,9 +2669,25 @@ NetPulse — установка й обслуговування. ./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 [служба] журнали пісочниці + + --keep не прибирати після невдачі (для розбору журналів) + --alongside дозволити запуск поруч із бойовою інсталяцією + Відповіді на п'ять питань, яких система не може вирішити сама, — у netpulse.conf. Зразок із поясненнями: netpulse.conf.example. Без цього файлу install ставить робочу систему на типових відповідях. +Пісочниця netpulse.conf НЕ читає навмисно: справжній DOMAIN звідти +відправив би її по сертифікат для чужої адреси. USAGE } @@ -1815,6 +2701,8 @@ while [ $# -gt 0 ]; do -p) shift; OWNER_PASSWORD=${1:-} ;; -f) shift; RESTORE_FILE=${1:-} ;; -c) shift; CONF_FILE=${1:-} ;; + --keep) SB_KEEP=1 ;; + --alongside) SB_ALONGSIDE=1 ;; -h|--help) usage; exit 0 ;; *) break ;; esac @@ -1824,6 +2712,7 @@ done case "$CMD" in install) cmd_install ;; check) cmd_check ;; + sandbox) cmd_sandbox "$@" ;; backup) cmd_backup ;; restore) cmd_restore ;; upgrade) cmd_upgrade ;; diff --git a/netpulse.conf.example b/netpulse.conf.example index 86dc296..a1ecf11 100644 --- a/netpulse.conf.example +++ b/netpulse.conf.example @@ -15,6 +15,14 @@ # ./netpulse install # # Синтаксис: КЛЮЧ=значення, без лапок, без пробілів навколо «=». +# +# ПІСОЧНИЦЯ (`./netpulse sandbox`) цей файл НЕ читає, і це навмисно. +# Вона ставить систему на localhost із самопідписаним сертифікатом, +# а справжній DOMAIN звідси відправив би її по сертифікат для адреси, +# яка веде на бойовий стенд: витрачені спроби списались би з тижневої +# квоти домену, і платив би за перевірку той, кого перевіряли. З +# TRAPS_FROM те саме — правило в DOCKER-USER це стан хоста, і прибрати +# його разом із томами пісочниці не вийшло б. # --------------------------------------------------------------------- diff --git a/scripts/check-eol.sh b/scripts/check-eol.sh index 8988439..c54cee6 100644 --- a/scripts/check-eol.sh +++ b/scripts/check-eol.sh @@ -43,6 +43,9 @@ set -u +# CDPATH= — не помилка з пробілом, а гасіння CDPATH: якщо він виставлений +# в оточенні, cd мовчки піде не туди, і скрипт перевірятиме чужий каталог. +# shellcheck disable=SC1007 ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) cd "$ROOT" || exit 1 diff --git a/scripts/check.sh b/scripts/check.sh index 6bfbf66..7b2415c 100644 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -23,6 +23,9 @@ set -u +# CDPATH= — не помилка з пробілом, а гасіння CDPATH: якщо він виставлений +# в оточенні, cd мовчки піде не туди, і скрипт перевірятиме чужий каталог. +# shellcheck disable=SC1007 ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) cd "$ROOT" || exit 1 diff --git a/server/API.md b/server/API.md index b147279..9262ccc 100644 --- a/server/API.md +++ b/server/API.md @@ -122,8 +122,55 @@ JWT — ні. | `GET` | `/api/v1/storage/retention` | строки зберігання за видами даних | | `POST` | `/api/v1/storage/retention/preview` | **що зникне** від запропонованих строків (`settings:write`) | | `PUT` | `/api/v1/storage/retention` | зберегти строки й накласти політики (`settings:write`) | +| `GET` | `/api/v1/sla/targets` | цілі SLA (`devices:read`) | +| `POST` | `/api/v1/sla/targets` | створити ціль (`settings:write`) | +| `PUT` | `/api/v1/sla/targets/{id}` | замінити ціль (`settings:write`) | +| `DELETE` | `/api/v1/sla/targets/{id}` | видалити ціль **разом із закритими звітами** (`settings:write`) | +| `GET` | `/api/v1/sla/targets/{id}/report` | звіт за період, у який потрапляє `?date=YYYY-MM-DD` (типово — попередній) | +| `GET` | `/api/v1/sla/targets/{id}/report.csv` | те саме вивантаженням | +| `POST` | `/api/v1/sla/targets/{id}/close` | **закрити період**: порахувати раз і зберегти як факт (`settings:write`) | | `GET` | `/api/v1/ws` | WebSocket: події та завантаження каналів | +### Звіти SLA + +Доступність рахується з `ts.icmp_1h` — годинних згорток ICMP. Не з сирих +вимірів: 0005 дає їм 35 діб, тобто звіт за квартал, порахований по них, +через два місяці мовчки дав би інше число. У годинних згорток строку +немає взагалі, і саме їм `retention_policy.go` ставить нижню межу +30 діб зі словами «місячні звіти читають саме звідси». + +Період, який уже скінчився й устоявся (6 годин після кінця — стільки +TimescaleDB рахує згортки), **закривається**: рахується один раз і лягає +в `core.sla_periods` разом зі знімками умов. Далі його читають, а не +рахують. Незакритий період позначено `closed: false` — це прикидка, яка +змінюється щогодини. + +Час періоду розкладено на чотири взаємно виключні частини, які в сумі +дають `clock_sec`: + +```jsonc +{ + "clock_sec": 2592000, // період у межах життя хоста + "maintenance_sec": 7200, // вікна обслуговування: годинник зупинено + "up_sec": 2577600, // виміряно, відповідав + "downtime_sec": 900, // виміряно, не відповідав + "unknown_sec": 6300, // НЕ виміряно нічим + "uptime_pct": 99.965, // up / (up + down) — мовчання не в знаменнику + "coverage_pct": 99.756, // (up + down) / (clock - maintenance) + "insufficient": false, // покриття нижче за поріг цілі → вердикту немає + "breached": false +} +``` + +`unknown_sec` ніколи не додається ні до `up_sec`, ні до `downtime_sec`. +Поки `coverage_pct` нижче за `min_coverage_pct` цілі, `insufficient: true` +і вердикт не виноситься: це **не** «виконано». + +`warnings` — чого розрахунок не врахував: `rrule_ignored` (повторювані +вікна обслуговування), `business_hours_ignored`, `beyond_horizon` +(початок періоду старший за збережену історію), `unknown_tz`, +`device_purged`. + ### `POST /api/v1/auth/login` — вхід ```jsonc diff --git a/server/cmd/netpulse-api/main.go b/server/cmd/netpulse-api/main.go index 7dc3f16..d3fa184 100644 --- a/server/cmd/netpulse-api/main.go +++ b/server/cmd/netpulse-api/main.go @@ -56,6 +56,10 @@ func run() error { "скільки тримати закриті алерти до переносу в історію") gitRoot = flag.String("git-root", envOr("NETPULSE_GIT_ROOT", "/var/lib/netpulse/git"), "корінь сховища версій конфігів; порожньо — без Git") + licensePub = flag.String("license-pubkey", os.Getenv("NETPULSE_LICENSE_PUBKEY"), + "відкриті ключі перевірки ліцензій: kid=[,...]; порожньо — ліцензії не перевіряються") + licenseEvery = flag.Duration("license-interval", time.Hour, + "як часто перевіряти стан ліцензії; 0 — лише при старті") privateHooks = flag.Bool("allow-private-webhooks", os.Getenv("NETPULSE_ALLOW_PRIVATE_WEBHOOKS") == "1", "дозволити вебхуки на внутрішні адреси — для self-hosted інсталяцій") @@ -130,8 +134,18 @@ func run() error { log.Info("веб-інтерфейс вшито в бінарник") } + // Ліцензія. Порожній ключ — робочий стан: збірка без вшитого + // відкритого ключа працює як працювала, ліміти беруться з тарифу, а + // сторінка тарифу чесно каже, що перевірити ключ нічим. + verifier, err := store.ParseLicenseVerifier(*licensePub) + if err != nil { + return fmt.Errorf("-license-pubkey: %w", err) + } + api = api.WithLicense(verifier) + go api.Hub().Run(ctx) go pruneLoop(ctx, st, log, *pruneAge) + go licenseLoop(ctx, st, verifier, log, *licenseEvery) // Движок алертів живе тут, а не в netpulse-server, бо саме цей // процес уже читає БД для UI і має ключі для каналів доставки. @@ -226,6 +240,67 @@ func pruneLoop(ctx context.Context, st *store.Store, log *slog.Logger, age time. } } +// ЧОМУ ПЕРЕВІРКА ЛІЦЕНЗІЇ — ЦИКЛ, А НЕ ОДИН КРОК ПРИ СТАРТІ +// +// Бо стан ліцензії міняється без жодної дії — просто від того, що минув +// час. Інсталяцію, яку не перезавантажували чотири місяці (а це норма +// для коробки в клієнта), перевірка при старті лишила б в active аж до +// наступного оновлення: попередження «лишилось п'ять діб» людина +// побачила б через півроку після того, як вони скінчились. +// +// ЧОМУ ПОМИЛКА ТУТ НЕ ЗУПИНЯЄ ПРОЦЕС +// +// Це головне рішення, і воно те саме, що й у решті ліцензійної частини. +// Моніторинг, який не піднявся через ліцензію, — аварія в мережі +// клієнта, спричинена нами: він не побачить падіння магістралі й +// дізнається про нього від абонентів. Тому будь-яка невдача перевірки — +// це рядок у журналі, а не код виходу. Найгірше, що з неї виходить, — +// стелі лишаються такими, якими були. + +// licenseLoop перевіряє ліцензію при старті й далі за тактом. +func licenseLoop(ctx context.Context, st *store.Store, v *store.LicenseVerifier, + log *slog.Logger, every time.Duration) { + + check := func() { + state, err := st.RefreshLicense(ctx, v) + if err != nil { + log.Warn("перевірка ліцензії", "err", err) + return + } + // Рівень залежить від стану, і це не косметика: рядок про + // пільговий період має бути помітним у потоці журналу, бо це + // єдине попередження, яке отримає той, хто на сторінку тарифу + // не заходить. + switch state.State { + case store.LicenseGrace: + log.Warn("ліцензія: пільговий період", "діб", state.DaysLeft, + "до", state.GraceUntil) + case store.LicenseExpired: + log.Warn("ліцензія протермінована — збір і сповіщення працюють, "+ + "нові хости й зонди не заводяться", "кому", state.IssuedTo) + case store.LicenseInvalid: + log.Error("ліцензія не перевіряється", "причина", state.Reason) + default: + log.Info("ліцензія", "стан", state.State, "інсталяція", state.InstallID) + } + } + + check() + if every <= 0 { + return + } + t := time.NewTicker(every) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + check() + } + } +} + func envOr(key, def string) string { if v := os.Getenv(key); v != "" { return v diff --git a/server/cmd/netpulse-server/main.go b/server/cmd/netpulse-server/main.go index 73da4f0..53d2a7c 100644 --- a/server/cmd/netpulse-server/main.go +++ b/server/cmd/netpulse-server/main.go @@ -172,6 +172,11 @@ func run() error { // й попередній: поруч із тим, хто ці дані створює, і подалі від // шляху запитів людини — див. storage_retention.go. go svc.SweepDataRetention(ctx) + // Закриття періодів SLA. Тут із тієї ж причини, що й обидва + // прибиральники, і ще з однієї: закривати період треба ВЧАСНО, поки + // дані під ним ще є, а не тоді, коли хтось відкриє сторінку — див. + // sla_close.go. + go svc.CloseSLAPeriods(ctx) // Дзеркалення архіву конфігів на зовнішній Git. Окремий такт, а не // push після коміту: недоступний Forgejo не має коштувати жодного // бекапу — див. ncm_mirror.go. diff --git a/server/internal/grpcapi/sla_close.go b/server/internal/grpcapi/sla_close.go new file mode 100644 index 0000000..32e5fe7 --- /dev/null +++ b/server/internal/grpcapi/sla_close.go @@ -0,0 +1,100 @@ +package grpcapi + +import ( + "context" + "time" +) + +// slaCloseLockKey — окреме advisory-блокування під закриття періодів SLA. +// +// Своє, а не спільне з прибиральником даних: обидва такти тікають +// незалежно, і спільний ключ означав би, що довге закриття кварталу на +// п'ятистах хостах заодно відкладає звільнення місця на диску. +const slaCloseLockKey = 0x6e70_736c // "npsl" + +// SLACloseInterval — як часто дивитись, чи не пора закривати період. +// +// Година. Найкоротший період — доба, а закривати його можна не раніше +// ніж через SLASettleLag після кінця; частіше шукати нема чого. Рідше — +// означало б, що звіт за минулий місяць стає остаточним «десь протягом +// дня», а саме першого числа по нього й приходять. +const SLACloseInterval = time.Hour + +// ЧОМУ ЦЕ ФОНОВИЙ ТАКТ, А НЕ РОЗРАХУНОК ПРИ ВІДКРИТТІ СТОРІНКИ +// +// Спокуса зробити «закриємо, коли вперше відкриють» велика: коду менше, +// такту немає. І вона тихо руйнує всю задачу. +// +// Період, який ніхто не відкрив вчасно, чекати не вміє — дані під ним +// зникають за строками зберігання. Квартал, уперше відкритий через +// півроку, закрився б назавжди по тому, що від нього лишилось: кілька +// останніх тижнів замість трьох місяців, з мовчазними 100% на решті. +// Тобто найгірший звіт вийшов би саме там, де його найдовше не дивились, +// — а не дивляться найдовше рівно на ті мережі, де все спокійно. +// +// Тому закриття не залежить від уваги людини взагалі. +// +// Живе в колекторі, а не в REST-процесі, з тих самих міркувань, що й +// прибиральник даних: поруч із тим, хто ці дані створює, і подалі від +// шляху запитів людини. Кілька екземплярів колектора безпечні — тік бере +// advisory-блокування, тож працює рівно один. + +// CloseSLAPeriods закриває періоди, які вже можна закрити. +func (s *Service) CloseSLAPeriods(ctx context.Context) { + t := time.NewTicker(SLACloseInterval) + defer t.Stop() + + s.log.Info("закриття періодів SLA запущено", "інтервал", SLACloseInterval) + + // Перший прохід одразу: після простою сервера є що добрати, і + // чекати на нього годину означає віддати цю годину даним, які тим + // часом можуть зникнути за строком зберігання. + s.slaCloseTick(ctx) + + for { + select { + case <-ctx.Done(): + return + case <-t.C: + s.slaCloseTick(ctx) + } + } +} + +func (s *Service) slaCloseTick(ctx context.Context) { + conn, err := s.store.Pool().Acquire(ctx) + if err != nil { + s.log.Error("закриття SLA: з'єднання", "err", err) + return + } + defer conn.Release() + + var got bool + if err := conn.QueryRow(ctx, `SELECT pg_try_advisory_lock($1)`, + int64(slaCloseLockKey)).Scan(&got); err != nil { + s.log.Error("закриття SLA: блокування", "err", err) + return + } + if !got { + // Закриває інший інстанс — штатний стан. + return + } + defer func() { + _, _ = conn.Exec(context.WithoutCancel(ctx), + `SELECT pg_advisory_unlock($1)`, int64(slaCloseLockKey)) + }() + + closed, err := s.store.CloseDueSLAPeriods(ctx) + if err != nil { + // Помилка не скасовує вже закритого: кожен період закривається + // своєю транзакцією, і наступний тік продовжить з того ж місця. + s.log.Error("закриття SLA: прохід", "err", err, "закрито_до_збою", len(closed)) + } + for _, c := range closed { + // Кожен закритий період — окремим рядком, а не лічильником. + // Число «закрито 7» не відповідає на єдине питання, яке про + // закриття взагалі ставлять: «чому в цьому звіті таке число». + s.log.Info("період SLA закрито", + "ціль", c.Name, "період", c.Period, "кабінет", c.TenantID) + } +} diff --git a/server/internal/httpapi/agents_manage.go b/server/internal/httpapi/agents_manage.go index 52dafed..c9c8b0d 100644 --- a/server/internal/httpapi/agents_manage.go +++ b/server/internal/httpapi/agents_manage.go @@ -11,9 +11,23 @@ import ( // Модулі, які вміє зонд. Дублює перелік у бінарнику навмисно: увімкнути // в UI те, чого агент не вміє, означає мовчки не отримати даних. +// validAgentModules — те, що зонд справді вміє. +// +// Перелік дублює реальність зонда, і тому легко відстає від неї. Саме +// це й сталося з `traps`: модуль написали, у compose увімкнули, на +// живому залізі перевірили — а сюди не дописали. Наслідок був не +// «трапи не працюють», а «зонд неможливо зареєструвати взагалі»: +// установник просив traps і отримував 400 на останньому кроці. +// Знайшла пісочниця установника з другої спроби. +// +// Джерело правди — Key() модулів у agent/internal/modules/* плюс двоє, +// які не є модулями реєстру (syslog і traps — вони лише слухають порт, +// не мають ані задач, ані розкладу) і `ncm`, який живе в сесії зонда. +// Дописуючи модуль на зонді, допишіть його тут. var validAgentModules = map[string]bool{ "icmp": true, "snmp": true, "topology": true, "ncm": true, "syslog": true, "http": true, + "traps": true, "filecfg": true, } func (s *Server) handleListEnrollments(w http.ResponseWriter, r *http.Request, p *Principal) { diff --git a/server/internal/httpapi/billing.go b/server/internal/httpapi/billing.go new file mode 100644 index 0000000..1c2171f --- /dev/null +++ b/server/internal/httpapi/billing.go @@ -0,0 +1,243 @@ +package httpapi + +import ( + "errors" + "net/http" + + "github.com/netpulse/netpulse/server/internal/store" +) + +// Тариф, стелі й ліцензія. +// +// ПРО ПРАВА +// +// Дивитись — billing:read. Це право інженера, а не бухгалтера, і межа +// проведена не там, де здається природним. Питання «чому не заводиться +// шістнадцятий хост» ставить той, хто заводить хости, і відповідь на +// нього мусить бути в нього перед очима ДО того, як він упреться. Тому +// сторінка показує стелю разом із використаним, а не рахунок. +// +// Міняти — billing:manage. Воно є лише у власника: 0010 навмисно не +// дала його навіть адміну. Це стосується й ліцензійного ключа — +// застосування ключа міняє стелі всієї інсталяції, тобто це та сама дія +// за наслідками, що й зміна тарифу. +// +// Обидва права були заведені 0010 і обидва досі значились у +// store/roles.go серед dormantPerms: ключ у базі є, коду, який його +// питає, немає. Цей файл — той код. +// +// ЧОГО ТУТ НЕМАЄ +// +// Вебхуків платіжки, інвойсів і черги usage-звітів. Не «поки що»: між +// ними й цим файлом навмисно нема зв'язку. Усе, що читає перевірка +// лімітів, — bill.entitlements; хто її заповнив, видно в колонці source +// й нікого більше не обходить. Тому платіжку можна буде дописати +// окремим файлом, не торкаючись ані цих обробників, ані тригерів у БД. + +// handleBillingOverview — стан тарифу цілком. +// +// Одна відповідь на всю сторінку, а не чотири ручки, з тієї ж причини, +// що й у черг: стелі, використане й стан ліцензії — це одне питання +// («на чому я зараз і скільки лишилось»), і зібрана з трьох запитів у +// різні секунди відповідь суперечила б сама собі — «14 з 15» поруч із +// «15 з 15» на сусідній картці. +func (s *Server) handleBillingOverview(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "billing:read") { + return + } + + ent, err := s.store.TenantEntitlement(r.Context(), p.TenantID) + if err != nil { + s.writeStoreError(w, "стелі тарифу", err) + return + } + usage, err := s.store.TenantUsage(r.Context(), p.TenantID) + if err != nil { + s.writeStoreError(w, "використання тарифу", err) + return + } + plans, err := s.store.ListPlans(r.Context()) + if err != nil { + s.writeStoreError(w, "перелік тарифів", err) + return + } + features, err := s.store.ListFeatures(r.Context()) + if err != nil { + s.writeStoreError(w, "каталог можливостей", err) + return + } + + // Стан ліцензії їде разом зі стелями, хоч він і рівня інсталяції. + // Розділити їх означало б показати «дозволено 15 хостів» без + // причини, чому саме 15, — а причина тут майже завжди в ліцензії. + lic, err := s.store.LicenseStatus(r.Context(), s.license) + if err != nil && !errors.Is(err, store.ErrNotFound) { + s.writeStoreError(w, "стан ліцензії", err) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "entitlement": ent, + "usage": usage, + "plans": plans, + "features": features, + "license": lic, + // Чи вільно цій людині міняти тариф. Клієнт вивів би це й сам зі + // свого переліку прав, але тоді правило жило б у двох місцях і + // розійшлося б на першій же зміні. Те саме рішення, що на + // сторінці сховища. + "can_manage": p.Can("billing:manage"), + }) +} + +// handleSetBillingPlan переводить кабінет на тариф. +func (s *Server) handleSetBillingPlan(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "billing:manage") { + return + } + var in struct { + Plan string `json:"plan"` + } + if !decodeBody(w, r, &in) { + return + } + + // Знімок ДО зміни: у журнал має поїхати «з free на pro», а не + // «змінили тариф». Читаємо до, бо після цієї відповіді вже немає. + before, err := s.store.TenantEntitlement(r.Context(), p.TenantID) + if err != nil { + s.writeStoreError(w, "стелі тарифу", err) + return + } + + if err := s.store.SetTenantPlan(r.Context(), p.TenantID, in.Plan, p.UserID); err != nil { + s.writeStoreError(w, "зміна тарифу", err) + return + } + + s.auditBilling(r, p, store.AuditActionBillingPlan, store.AuditObjectBillingPlan, + map[string]any{"from": before.PlanKey, "to": in.Plan}) + + s.handleBillingOverview(w, r, p) +} + +// handleGetLicense — стан ліцензії й ідентифікатор інсталяції. +// +// Окрема ручка попри те, що те саме їде в загальній відповіді: +// install_id потрібен у мить замовлення ключа, а туди приходять із +// порожньої інсталяції, де перелік тарифів ще нічого не означає. +func (s *Server) handleGetLicense(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "billing:read") { + return + } + st, err := s.store.LicenseStatus(r.Context(), s.license) + if err != nil { + s.writeStoreError(w, "стан ліцензії", err) + return + } + writeJSON(w, http.StatusOK, st) +} + +// handleApplyLicense застосовує введений ключ. +// +// Відмова тут — 400 із ПРИЧИНОЮ, а не загальне «ключ недійсний». +// Причин рівно три, вони не взаємозамінні, і кожна веде до різних дій +// людини: формат (переплутали поле, обрізали при копіюванні), підпис +// (ключ не наш або збірка не має відкритого ключа), інсталяція (ключ +// правильний, але від сусіднього стенду). Звести їх до одного рядка +// означало б перетворити кожне звернення в підтримку на вгадування. +func (s *Server) handleApplyLicense(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "billing:manage") { + return + } + var in struct { + Key string `json:"key"` + } + if !decodeBody(w, r, &in) { + return + } + + st, err := s.store.ApplyLicense(r.Context(), s.license, in.Key, p.UserID) + switch { + case errors.Is(err, store.ErrNoVerifier): + // 501, а не 400: справа не в тому, що прислали, а в тому, що ця + // збірка ліцензії перевіряти не вміє. Просити людину виправити + // ключ у такій ситуації означало б послати її по колу. + writeError(w, http.StatusNotImplemented, "no_verifier", + "ця збірка не має відкритого ключа для перевірки ліцензій — "+ + "задайте NETPULSE_LICENSE_PUBKEY") + return + case errors.Is(err, store.ErrLicenseFormat): + writeError(w, http.StatusBadRequest, "license_format", + "ключ не розібрався: "+err.Error()) + return + case errors.Is(err, store.ErrLicenseSignature): + writeError(w, http.StatusBadRequest, "license_signature", + "підпис ключа не сходиться: "+err.Error()) + return + case errors.Is(err, store.ErrLicenseInstall): + writeError(w, http.StatusBadRequest, "license_install", err.Error()) + return + case err != nil: + s.writeStoreError(w, "застосування ліцензії", err) + return + } + + // У журнал іде ідентифікатор ліцензії й кому вона видана, але НЕ сам + // ключ. Ключ — це секрет: той, хто його бачить, може підняти з ним + // іншу інсталяцію. Журнал аудиту читає ширше коло людей, ніж те, що + // має право керувати тарифом, і секретам там не місце — те саме + // правило, що для паролів дзеркала Git. + s.auditBilling(r, p, store.AuditActionLicenseApply, store.AuditObjectLicense, + map[string]any{ + "license_id": st.LicenseID, + "issued_to": st.IssuedTo, + "plan": st.Plan, + "state": st.State, + "expires_at": st.ExpiresAt, + }) + + writeJSON(w, http.StatusOK, st) +} + +// handleClearLicense знімає ключ із інсталяції. +func (s *Server) handleClearLicense(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "billing:manage") { + return + } + before, _ := s.store.LicenseStatus(r.Context(), s.license) + + if err := s.store.ClearLicense(r.Context(), p.UserID); err != nil { + s.writeStoreError(w, "зняття ліцензії", err) + return + } + s.auditBilling(r, p, store.AuditActionLicenseClear, store.AuditObjectLicense, + map[string]any{"license_id": before.LicenseID, "issued_to": before.IssuedTo}) + + st, err := s.store.LicenseStatus(r.Context(), s.license) + if err != nil { + s.writeStoreError(w, "стан ліцензії", err) + return + } + writeJSON(w, http.StatusOK, st) +} + +// auditBilling — спільний хвіст трьох обробників. +// +// Аудит пишеться ПІСЛЯ успішної дії й не скасовує її при збої: тариф +// уже змінено, і мовчазне «не вдалось» тут гірше за рядок, якого немає +// в журналі. Той самий порядок, що й у строків зберігання. +func (s *Server) auditBilling(r *http.Request, p *Principal, + action, object string, meta map[string]any) { + + if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{ + ActorUserID: p.UserID, + ActorTokenID: p.TokenID, + ActorIP: clientIP(r), + Action: action, + ObjectType: object, + Meta: meta, + }); err != nil { + s.log.Error("аудит білінгу", "дія", action, "err", err) + } +} diff --git a/server/internal/httpapi/groups.go b/server/internal/httpapi/groups.go index 90c23b4..6872811 100644 --- a/server/internal/httpapi/groups.go +++ b/server/internal/httpapi/groups.go @@ -91,9 +91,22 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request, p *P } id, err := s.store.CreateDevice(r.Context(), p.TenantID, in) if err != nil { - if isPlanLimit(err) { - writeError(w, http.StatusPaymentRequired, "plan_limit", - "вичерпано ліміт пристроїв тарифу") + if lim, ok := store.AsPlanLimit(err); ok { + // Текст складає БД — там, де перевірка й спрацювала, і з + // тими самими числами: «у тарифі Free дозволено 15 хостів, + // зараз 15». Числа їдуть іще й окремими полями, щоб форма + // могла показати їх поруч із кнопкою, а не лише в смужці + // помилки. + writeJSON(w, http.StatusPaymentRequired, map[string]any{ + "error": map[string]any{ + "code": "plan_limit", + "message": lim.Error(), + "limit": lim.Kind, + "plan": lim.Plan, + "allowed": lim.Allowed, + "used": lim.Used, + }, + }) return } s.writeStoreError(w, "створення хоста", err) @@ -264,9 +277,22 @@ func decodeDevice(w http.ResponseWriter, r *http.Request) (store.DeviceInput, de }, in, true } -func isPlanLimit(err error) bool { - return err != nil && strings.Contains(err.Error(), "ліміт") -} +// isPlanLimit прибрано навмисно, і це варто пояснити, бо функція +// виглядала робочою півроку. +// +// Вона перевіряла strings.Contains(err.Error(), "ліміт") — а тригер +// 0009 підіймав 'device limit reached for tenant % (limit %)', тобто +// англійською. Збігу не було ніколи. Тобто в мить, коли стеля вперше +// спрацювала б, людина отримала б 500 «внутрішня помилка» замість 402 з +// поясненням — рівно те, чого перевірка в БД мала не допустити. +// +// Помітити це неможливо було ніяк: bill.entitlements порожня на всіх +// інсталяціях, стеля не спрацьовувала жодного разу, і зелений код нічого +// про це не казав. +// +// Заміна (store.AsPlanLimit) впізнає відмову за HINT = 'upgrade_plan' — +// за полем, яке ставить сама перевірка, а не за текстом, який +// переписують, коли він погано читається. // --------------------------------------------------------------------- // Групи користувачів diff --git a/server/internal/httpapi/maps_write.go b/server/internal/httpapi/maps_write.go index 6ad9013..01c33cb 100644 --- a/server/internal/httpapi/maps_write.go +++ b/server/internal/httpapi/maps_write.go @@ -194,6 +194,12 @@ func decodeBody(w http.ResponseWriter, r *http.Request, v any) bool { return true } +// isPlanLimitPg — коротка форма для switch вище. +func isPlanLimitPg(err error) bool { + _, ok := store.AsPlanLimit(err) + return ok +} + func (s *Server) writeStoreError(w http.ResponseWriter, what string, err error) { switch { case errors.Is(err, store.ErrNotFound): @@ -208,6 +214,30 @@ func (s *Server) writeStoreError(w http.ResponseWriter, what string, err error) // 402 — саме те, що сталося: потрібен інший тариф. writeError(w, http.StatusPaymentRequired, "plan_limit", err.Error()) + case isPlanLimitPg(err): + // Та сама відмова, але сирою помилкою бази — так вона приходить + // з усіх шляхів, крім правки мапи (єдиного, який її перекладав). + // + // Рядок стоїть саме тут, у СПІЛЬНОМУ обробнику помилок, а не в + // кожному місці окремо, і це головне про нього. Стеля тепер + // стоїть не лише на створенні хоста: вона спрацьовує на + // поверненні з архіву, на масовому ввімкненні, на реєстрації + // зонда, на додаванні людини в кабінет. Кожен із цих шляхів + // віддав би 500 «внутрішня помилка» — і кожен довелося б + // згадати окремо. Забути один із них нічого не коштує й нічим + // не проявляється, доки хтось не впреться саме в нього. + lim, _ := store.AsPlanLimit(err) + writeJSON(w, http.StatusPaymentRequired, map[string]any{ + "error": map[string]any{ + "code": "plan_limit", + "message": lim.Error(), + "limit": lim.Kind, + "plan": lim.Plan, + "allowed": lim.Allowed, + "used": lim.Used, + }, + }) + case errors.Is(err, store.ErrInvalid): writeError(w, http.StatusBadRequest, "invalid", err.Error()) diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index 8a0c6a5..b6cd20e 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -35,6 +35,12 @@ type Server struct { // коли порушення стає подією. nil означає, що движок алертів на // цій інсталяції вимкнено. events *alerting.EventSink + + // Відкриті ключі, якими перевіряють ліцензії. nil — робочий стан: + // збірка без вшитого ключа працює як була, а сторінка тарифу чесно + // каже, що перевірити ключ нічим. Мовчазна відмова тут була б + // гіршою за відсутність можливості. + license *store.LicenseVerifier } // New створює сервер. signer може бути nil лише в тестах, які не @@ -79,6 +85,17 @@ func (s *Server) WithKeyring(ring *crypto.Keyring) *Server { return s } +// WithLicense дає серверу відкриті ключі перевірки ліцензій. +// +// Окремим методом, а не аргументом New, з тієї ж причини, що й ключі +// шифрування: інсталяція без ліцензії — це не поламана інсталяція, а +// звичайна. Вимагати ключ від тесту, від кіоску й від клієнта, який +// щойно розгорнув систему й ще нічого не купив, було б безпідставно. +func (s *Server) WithLicense(v *store.LicenseVerifier) *Server { + s.license = v + return s +} + // Hub — доступ до трансляції для зовнішнього коду (тести, метрики). func (s *Server) Hub() *Hub { return s.hub } @@ -330,6 +347,31 @@ func (s *Server) Handler() http.Handler { mux.Handle("POST /api/v1/storage/retention/preview", s.authenticated(s.handleRetentionPreview)) mux.Handle("PUT /api/v1/storage/retention", s.authenticated(s.handleSetRetentionSettings)) + // Тариф, стелі й ліцензія. Дивитись — billing:read, і це право + // інженера: «чому не заводиться шістнадцятий хост» питає той, хто + // заводить хости. Міняти — billing:manage, і воно є лише у власника. + // Ліцензійний ключ під тим самим правом, що й тариф: за наслідками + // це та сама дія — вона задає стелі всієї інсталяції. + mux.Handle("GET /api/v1/billing", s.authenticated(s.handleBillingOverview)) + mux.Handle("PUT /api/v1/billing/plan", s.authenticated(s.handleSetBillingPlan)) + mux.Handle("GET /api/v1/billing/license", s.authenticated(s.handleGetLicense)) + mux.Handle("POST /api/v1/billing/license", s.authenticated(s.handleApplyLicense)) + mux.Handle("DELETE /api/v1/billing/license", s.authenticated(s.handleClearLicense)) + + // Звіти SLA. Дивитись — devices:read (доступність своєї мережі бачить + // кожен, хто бачить моніторинг), заводити цілі й закривати періоди — + // settings:write. Див. sla.go. + // + // Закриття — POST, а не PUT, і це не формальність: воно не «зберігає + // стан», а виконує незворотну дію — робить пораховане число фактом. + mux.Handle("GET /api/v1/sla/targets", s.authenticated(s.handleListSLATargets)) + mux.Handle("POST /api/v1/sla/targets", s.authenticated(s.handleSaveSLATarget)) + mux.Handle("PUT /api/v1/sla/targets/{id}", s.authenticated(s.handleSaveSLATarget)) + mux.Handle("DELETE /api/v1/sla/targets/{id}", s.authenticated(s.handleDeleteSLATarget)) + mux.Handle("GET /api/v1/sla/targets/{id}/report", s.authenticated(s.handleSLAReport)) + mux.Handle("GET /api/v1/sla/targets/{id}/report.csv", s.authenticated(s.handleSLAReportCSV)) + mux.Handle("POST /api/v1/sla/targets/{id}/close", s.authenticated(s.handleCloseSLAPeriod)) + mux.Handle("GET /api/v1/audit", s.authenticated(s.handleListAudit)) mux.Handle("GET /api/v1/audit/meta", s.authenticated(s.handleAuditMeta)) mux.Handle("GET /api/v1/audit/{id}", s.authenticated(s.handleGetAuditEvent)) diff --git a/server/internal/httpapi/sla.go b/server/internal/httpapi/sla.go new file mode 100644 index 0000000..a04642e --- /dev/null +++ b/server/internal/httpapi/sla.go @@ -0,0 +1,296 @@ +package httpapi + +import ( + "errors" + "net/http" + "time" + + "github.com/netpulse/netpulse/server/internal/store" +) + +// Звіти SLA: доступність за період і вивантаження. +// +// ПРО ПРАВА +// +// Дивитись — devices:read. Доступність своєї мережі бачить кожен, хто +// взагалі бачить моніторинг: ховати її немає від кого, а не побачити +// наближення до порога вчасно коштує грошей за договором. Те саме +// рішення, що й для сховища (0064) та дзеркала конфігів (0054). +// +// Заводити цілі й закривати періоди — settings:write. Ціль SLA — це +// зобов'язання ОРГАНІЗАЦІЇ перед клієнтом, того самого класу, що й +// строки зберігання: вона описує не один хост, а те, під чим +// підписались. Закриття періоду ще й незворотне за наслідками: після +// нього число стає фактом, який показують назовні. +// +// Окремих ключів sla:read / sla:write свідомо не заводимо. Кожен новий +// ключ треба роздати ролям, показати на екрані прав і пояснити — тобто +// взяти з людини плату за розрізнення, якого вона не просила. Завести +// їх пізніше можна; забрати роздане право назад — уже ні. + +// Ключі прав, які питають обробники цього файла. +const ( + slaReadPerm = "devices:read" + slaWritePerm = "settings:write" +) + +// slaAnchor — мить усередині періоду, який просять. +// +// Клієнт шле дату (?date=2026-04-15), а не межі періоду. Це навмисно: +// межі кварталу — властивість цілі (її типу періоду й поясу), і давати +// клієнту рахувати їх самому означає завести другу реалізацію +// календаря, яка розійдеться з нашою на переході часу або на межі року. +// Порожнє поле означає «попередній період»: саме його питають на аудиті, +// і саме він єдиний, який уже можна закрити. +func slaAnchor(r *http.Request) (time.Time, bool, error) { + raw := r.URL.Query().Get("date") + if raw == "" { + return time.Time{}, false, nil + } + t, err := time.Parse("2006-01-02", raw) + if err != nil { + return time.Time{}, false, err + } + // Полудень, а не опівніч. Дата приходить без поясу, тобто читається + // як опівніч UTC; у поясі цілі це вже сусідня доба, а на межі місяця + // — сусідній період. Полудень лишається тією самою добою в будь-якому + // поясі Землі, тож звіт за 1 липня не виявиться звітом за червень. + return t.Add(12 * time.Hour), true, nil +} + +// resolveSLAAnchor доводить якір до конкретної миті всередині періоду. +func (s *Server) resolveSLAAnchor(r *http.Request, t store.SLATarget) (time.Time, error) { + anchor, explicit, err := slaAnchor(r) + if err != nil { + return time.Time{}, err + } + if explicit { + return anchor, nil + } + loc, _ := t.Location() + prev, err := store.SLAPrevPeriod(t.PeriodKind, time.Now(), loc) + if err != nil { + return time.Time{}, err + } + return prev.From, nil +} + +// handleListSLATargets — перелік цілей. +func (s *Server) handleListSLATargets(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, slaReadPerm) { + return + } + targets, err := s.store.SLATargets(r.Context(), p.TenantID) + if err != nil { + s.writeStoreError(w, "цілі SLA", err) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "targets": targets, + // Правило «кому вільно міняти» живе на сервері в одному місці. + // Клієнт міг би вивести його зі свого переліку прав, але тоді + // воно жило б у двох і розійшлося б на першій же зміні. + "can_edit": p.Can(slaWritePerm), + // Скільки чекати після кінця періоду до закриття. Клієнт показує + // це поруч із кнопкою: інакше «закрити» неактивна без пояснення. + "settle_lag_sec": int(store.SLASettleLag.Seconds()), + }) +} + +// handleSaveSLATarget заводить або оновлює ціль. +func (s *Server) handleSaveSLATarget(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, slaWritePerm) { + return + } + var in store.SLATargetInput + if !decodeBody(w, r, &in) { + return + } + if id := r.PathValue("id"); id != "" { + in.ID = id + } + + out, err := s.store.SaveSLATarget(r.Context(), p.TenantID, in) + if err != nil { + s.writeStoreError(w, "збереження цілі SLA", err) + return + } + if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{ + ActorUserID: p.UserID, + ActorTokenID: p.TokenID, + ActorIP: clientIP(r), + Action: store.AuditActionSLATargetSave, + ObjectType: store.AuditObjectSLATarget, + ObjectID: out.ID, + Meta: map[string]any{ + "name": out.Name, + "target_pct": out.TargetPct, + "period_kind": out.PeriodKind, + "tz": out.TZ, + "enabled": out.Enabled, + }, + }); err != nil { + s.log.Error("аудит цілі SLA", "err", err) + } + writeJSON(w, http.StatusOK, out) +} + +// handleDeleteSLATarget видаляє ціль разом із її закритими періодами. +func (s *Server) handleDeleteSLATarget(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, slaWritePerm) { + return + } + id := r.PathValue("id") + n, err := s.store.DeleteSLATarget(r.Context(), p.TenantID, id) + if err != nil { + s.writeStoreError(w, "видалення цілі SLA", err) + return + } + if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{ + ActorUserID: p.UserID, + ActorTokenID: p.TokenID, + ActorIP: clientIP(r), + Action: store.AuditActionSLATargetDelete, + ObjectType: store.AuditObjectSLATarget, + ObjectID: id, + // Скільки закритих звітів зникло разом із ціллю. Саме це число + // шукатимуть у журналі, коли спитають «а де торішній звіт». + Meta: map[string]any{"periods_deleted": n}, + }); err != nil { + s.log.Error("аудит видалення цілі SLA", "err", err) + } + writeJSON(w, http.StatusOK, map[string]any{"periods_deleted": n}) +} + +// handleSLAReport — звіт цілі за період. +func (s *Server) handleSLAReport(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, slaReadPerm) { + return + } + rep, err := s.slaReport(r, p) + if err != nil { + s.writeSLAError(w, err) + return + } + writeJSON(w, http.StatusOK, rep) +} + +// handleSLAReportCSV — те саме вивантаженням. +// +// Окремий обробник, а не параметр формату в попередньому: відповідь тут +// не JSON, і плутати два типи вмісту в одному шляху означає, що клієнт +// має вгадувати, як її читати. Квиток завантаження (0038) тут не +// потрібен: звіт — це десятки кілобайтів, а не десятки мегабайтів, і +// вкладка забере його вмить. +func (s *Server) handleSLAReportCSV(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, slaReadPerm) { + return + } + rep, err := s.slaReport(r, p) + if err != nil { + s.writeSLAError(w, err) + return + } + w.Header().Set("Content-Type", "text/csv; charset=utf-8") + w.Header().Set("Content-Disposition", + `attachment; filename="`+store.SLAReportFileName(rep)+`"`) + // Звіт за закритий період незмінний, але кешувати його однаково не + // можна: попередній розрахунок за тим самим шляхом змінюється + // щогодини, а розрізняти їх кешу нема чим. + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + if err := store.SLAReportCSV(w, rep); err != nil { + // Відповідь уже пішла — статус не змінити. Лишається журнал. + s.log.Error("вивантаження звіту SLA", "err", err) + } +} + +// handleCloseSLAPeriod закриває період: рахує раз і зберігає як факт. +func (s *Server) handleCloseSLAPeriod(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, slaWritePerm) { + return + } + targets, err := s.store.SLATargets(r.Context(), p.TenantID) + if err != nil { + s.writeStoreError(w, "цілі SLA", err) + return + } + t, ok := slaFind(targets, r.PathValue("id")) + if !ok { + writeError(w, http.StatusNotFound, "not_found", "цілі не знайдено") + return + } + anchor, err := s.resolveSLAAnchor(r, t) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid", "дата має вигляд 2006-01-02") + return + } + + // force — свідомий перерахунок уже закритого періоду. Окремим + // параметром, а не мовчазним переписуванням: без нього повторний + // виклик нічого не змінює, і фоновий такт може ходити сюди щогодини. + force := r.URL.Query().Get("force") == "1" + + rep, err := s.store.CloseSLAPeriod(r.Context(), p.TenantID, t.ID, anchor, force) + if err != nil { + s.writeSLAError(w, err) + return + } + if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{ + ActorUserID: p.UserID, + ActorTokenID: p.TokenID, + ActorIP: clientIP(r), + Action: store.AuditActionSLAPeriodClose, + ObjectType: store.AuditObjectSLATarget, + ObjectID: t.ID, + Meta: map[string]any{ + "name": t.Name, + "period": rep.PeriodFrom + "/" + rep.PeriodTo, + "revision": rep.Revision, + "forced": force, + "uptime_pct": rep.Totals.UptimePct, + "hosts": len(rep.Rows), + }, + }); err != nil { + s.log.Error("аудит закриття періоду SLA", "err", err) + } + writeJSON(w, http.StatusOK, rep) +} + +func (s *Server) slaReport(r *http.Request, p *Principal) (store.SLAReport, error) { + targets, err := s.store.SLATargets(r.Context(), p.TenantID) + if err != nil { + return store.SLAReport{}, err + } + t, ok := slaFind(targets, r.PathValue("id")) + if !ok { + return store.SLAReport{}, store.ErrNotFound + } + anchor, err := s.resolveSLAAnchor(r, t) + if err != nil { + return store.SLAReport{}, store.ErrInvalid + } + return s.store.SLAReportFor(r.Context(), p.TenantID, t.ID, anchor) +} + +func slaFind(targets []store.SLATarget, id string) (store.SLATarget, bool) { + for _, t := range targets { + if t.ID == id { + return t, true + } + } + return store.SLATarget{}, false +} + +// writeSLAError відрізняє «ще рано» від решти. +// +// Окремий код, бо це не помилка клієнта й не поломка: період справді +// існує, просто згортки під ним ще рахуються. Клієнт показує цей текст +// біля кнопки замість загальної смужки помилки нагорі. +func (s *Server) writeSLAError(w http.ResponseWriter, err error) { + if errors.Is(err, store.ErrSLANotSettled) { + writeError(w, http.StatusConflict, "sla_not_settled", err.Error()) + return + } + s.writeStoreError(w, "звіт SLA", err) +} diff --git a/server/internal/store/audit_actions.go b/server/internal/store/audit_actions.go index acdeb48..810ce5b 100644 --- a/server/internal/store/audit_actions.go +++ b/server/internal/store/audit_actions.go @@ -71,6 +71,14 @@ const ( AuditActionTelegramUnlink = "core.telegram.unlink" AuditActionRetentionSettings = "core.retention.update" + + AuditActionSLATargetSave = "core.sla_target.save" + AuditActionSLATargetDelete = "core.sla_target.delete" + AuditActionSLAPeriodClose = "core.sla_period.close" + + AuditActionBillingPlan = "bill.plan.update" + AuditActionLicenseApply = "bill.license.apply" + AuditActionLicenseClear = "bill.license.clear" ) // Ключі типів об'єктів — те, НАД ЧИМ зроблено дію. @@ -85,6 +93,9 @@ const ( AuditObjectRole = "core.role" AuditObjectTelegram = "core.telegram_account" AuditObjectRetention = "core.retention_settings" + AuditObjectSLATarget = "core.sla_target" + AuditObjectBillingPlan = "bill.plan" + AuditObjectLicense = "bill.license" ) // AuditActionInfo — те, що словник знає про дію. @@ -236,6 +247,53 @@ var auditActions = []AuditActionInfo{ // саме за цим полем шукатимуть того, після кого зникла історія. Label: "Зміна строків зберігання даних", }, + { + Key: AuditActionSLATargetSave, Group: "Адміністрування", + Label: "Зміна цілі SLA", + }, + { + Key: AuditActionSLATargetDelete, Group: "Адміністрування", + // Тут Destructive стоїть, на відміну від строків зберігання, і + // різниця саме в миті: видалення цілі каскадом зносить УСІ її + // закриті періоди негайно, у цій же транзакції. Скільки саме — + // у meta.periods_deleted, бо через рік це буде єдине місце, де + // видно, що торішній звіт колись існував. + Label: "Видалення цілі SLA", Destructive: true, + }, + { + Key: AuditActionSLAPeriodClose, Group: "Адміністрування", + // Закриття не руйнівне: воно, навпаки, робить число незмінним. + // А от meta.forced і meta.revision — те, за чим шукатимуть + // відповідь на «чому в мене два роздруки з різними числами». + Label: "Закриття періоду SLA", + }, + { + Key: AuditActionBillingPlan, Group: "Тариф і ліцензія", + // Зміна тарифу нічого не стирає в мить збереження, але саме + // вона задає стелі — тобто з неї починається кожне «а чому в + // нас перестали заводитись хости». Питання ставлять за тиждень + // після події, і без цього рядка відповідь на нього не має де + // взятись: bill.entitlements переписується цілком, попереднього + // стану там не лишається. Тому в meta їдуть обидва тарифи, «з» + // і «на». + Label: "Зміна тарифу", + }, + { + Key: AuditActionLicenseApply, Group: "Тариф і ліцензія", + // Найважливіший рядок розділу. Ключ задає стелі ВСІЄЇ + // інсталяції, включно з чужими кабінетами на спільному + // хостингу, і робить це одним натисканням. Самого ключа в meta + // немає й не буде — це секрет; там лише його ідентифікатор, + // кому виданий і до якої дати. + Label: "Застосування ліцензійного ключа", + }, + { + Key: AuditActionLicenseClear, Group: "Тариф і ліцензія", + // Destructive не ставимо: рядок у bill.license_keys лишається, + // зникає лише те, що діє. Але наслідок відчутний — стелі + // повертаються до «не задані», — і саме тому дія в журналі. + Label: "Зняття ліцензійного ключа", + }, } var auditActionByKey = func() map[string]AuditActionInfo { @@ -270,6 +328,9 @@ var auditObjectTypes = []AuditActionInfo{ {Key: AuditObjectRole, Label: "Роль"}, {Key: AuditObjectTelegram, Label: "Прив'язка Telegram"}, {Key: AuditObjectRetention, Label: "Строки зберігання даних"}, + {Key: AuditObjectSLATarget, Label: "Ціль SLA"}, + {Key: AuditObjectBillingPlan, Label: "Тариф"}, + {Key: AuditObjectLicense, Label: "Ліцензія інсталяції"}, } var auditObjectTypeByKey = func() map[string]string { @@ -318,6 +379,8 @@ func auditActionGroupFromKey(key string) string { return "Сповіщення" case "core": return "Адміністрування" + case "bill": + return "Тариф і ліцензія" default: return "" } diff --git a/server/internal/store/billing.go b/server/internal/store/billing.go new file mode 100644 index 0000000..98d9688 --- /dev/null +++ b/server/internal/store/billing.go @@ -0,0 +1,843 @@ +package store + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +// Тарифи, стелі й підписка: те, що показують людині, і те, що читає +// перевірка лімітів. +// +// ЩО ТУТ Є ДЖЕРЕЛОМ ПРАВДИ +// +// Рівно одна таблиця — bill.entitlements. Її читають тригери в БД, її +// читає сторінка, з неї беруться числа у відмові «дозволено 15, зараз +// 15». Усе інше (тариф, ліцензійний ключ, майбутня підписка Stripe) — +// це джерела, які в неї ПИШУТЬ, і жодне з них не має права бути +// прочитаним у мить перевірки. +// +// Це не стилістика. Перевірка ліміту виконується на кожній вставці +// хоста, а вставок буває тисяча за прогін автовиявлення; JOIN по +// чотирьох таблицях із розбором JSON у кожній із них там неприпустимий. +// І друге, важливіше: перевірка, яка знає про Stripe, ламається разом зі +// Stripe. Той, хто дописуватиме платіжку, не повинен мати способу +// зачепити відповідь на питання «чи можна завести хост». +// +// ЩО РОБИТЬ ЦЕЙ ФАЙЛ +// +// Читає стан для сторінки й перекладає відмову БД у щось, що можна +// показати людині. Перерахунок стель із ліцензії живе поруч, у +// billing_license.go — там, де й решта ліцензійної логіки. + +// --------------------------------------------------------------------- +// Відмова через ліміт +// --------------------------------------------------------------------- + +// ЧОМУ ВІДМОВА ЧЕРЕЗ СТЕЛЮ — ТИП, А НЕ РЯДОК +// +// Тому що клієнт має отримати числа, а не фразу. «Вичерпано ліміт +// пристроїв тарифу» — це те, що людина вже й так зрозуміла з того, що +// нічого не створилось; корисне починається з «дозволено 15, зараз 15», +// бо саме воно каже, що робити далі, і саме воно доводить, що система +// не зламалась, а відмовила свідомо. +// +// Числа приходять із DETAIL відмови (див. bill.deny_limit у 0069) саме +// тому, що вони мають прийти З ТІЄЇ Ж транзакції, яка відмовила. +// Порахувати їх другим запитом означало б показати інше число, ніж те, +// на якому спрацювала перевірка. + +// PlanLimitError — стеля тарифу вичерпана. +type PlanLimitError struct { + // Kind — що саме скінчилось: devices | maps | map_nodes | agents | users. + Kind string `json:"limit"` + Plan string `json:"plan"` + Allowed int `json:"allowed"` + Used int `json:"used"` + + // message — фраза, яку склала сама БД, українською й з числами. + // Складати її вдруге в Go означало б мати два формулювання однієї + // відмови: одне в логах Postgres, інше на екрані. + message string +} + +func (e *PlanLimitError) Error() string { + if e.message != "" { + return e.message + } + return "досягнуто ліміт тарифу" +} + +// Is прив'язує тип до наявного вартового ErrPlanLimit. +// +// Щоб код, який уже питає errors.Is(err, ErrPlanLimit) (шлях правки +// мапи, де відмову перекладає mapPgError), продовжував працювати й тоді, +// коли до нього дійде вже типізована помилка. +// +// Чого це НЕ робить, і про це варто сказати вголос: сира помилка бази +// вартовому не відповідає — у її ланцюжку лежить *pgconn.PgError, а не +// цей тип. Тому спільний обробник помилок питає ще й AsPlanLimit +// окремим рядком (httpapi/maps_write.go). Спокуса покластись на одну +// перевірку тут коштувала б 500 на всіх шляхах, крім мап. +func (e *PlanLimitError) Is(target error) bool { return target == ErrPlanLimit } + +// AsPlanLimit впізнає відмову через стелю в будь-якій обгортці. +// +// Впізнавання йде за HINT = 'upgrade_plan', а НЕ за текстом помилки. +// Так було в maps_write.go з самого початку, і так правильно: текст +// відмови — це те, що переписують, коли він погано читається. Саме на +// тексті й зламалась попередня перевірка (httpapi/groups.go шукав слово +// «ліміт» у повідомленні, яке 0009 писала англійською), і зламалась +// мовчки — до першого спрацювання стелі ніхто б і не дізнався. +func AsPlanLimit(err error) (*PlanLimitError, bool) { + if err == nil { + return nil, false + } + var already *PlanLimitError + if errors.As(err, &already) { + return already, true + } + var pgErr *pgconn.PgError + if !errors.As(err, &pgErr) || pgErr.Hint != "upgrade_plan" { + return nil, false + } + out := &PlanLimitError{message: pgErr.Message} + // Подробиця може не розібратись — наприклад, на інсталяції, де + // 0069 ще не накотилась і тригер лишився з 0009. Це не привід + // втрачати саму відмову: без чисел вона гірша, але вона є, і 402 + // має поїхати замість 500 у будь-якому разі. + if pgErr.Detail != "" { + _ = json.Unmarshal([]byte(pgErr.Detail), out) + } + return out, true +} + +// --------------------------------------------------------------------- +// Тарифи +// --------------------------------------------------------------------- + +// Plan — тариф так, як його бачить сторінка. +type Plan struct { + Key string `json:"key"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + BasePriceCents int `json:"base_price_cents"` + PerDeviceCents int `json:"per_device_cents"` + Currency string `json:"currency"` + BillingPeriod string `json:"billing_period"` + PlanLimits + Features []string `json:"features"` + IsPublic bool `json:"is_public"` +} + +// Feature — можливість із каталогу bill.features. +type Feature struct { + Key string `json:"key"` + Name string `json:"name"` + Description string `json:"description,omitempty"` +} + +// ListPlans — усі тарифи, разом із непублічними. +// +// Непублічні теж, бо один із них (self_hosted) майже напевно й буде +// поточним: сторінка мусить уміти назвати те, що зараз діє. Ховає їх зі +// СПИСКУ ПРОПОЗИЦІЙ уже інтерфейс — за прапорцем is_public, а не за +// відсутністю рядка. +func (s *Store) ListPlans(ctx context.Context) ([]Plan, error) { + rows, err := s.pool.Query(ctx, ` + SELECT key, name, COALESCE(description,''), base_price_cents, per_device_cents, + currency, billing_period, + max_devices, max_maps, max_map_nodes, max_agents, max_users, + metric_retention_days, features, is_public + FROM bill.plans ORDER BY sort_order, key + `) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []Plan{} + for rows.Next() { + var p Plan + if err := rows.Scan(&p.Key, &p.Name, &p.Description, &p.BasePriceCents, + &p.PerDeviceCents, &p.Currency, &p.BillingPeriod, + &p.MaxDevices, &p.MaxMaps, &p.MaxMapNodes, &p.MaxAgents, &p.MaxUsers, + &p.RetentionDays, &p.Features, &p.IsPublic); err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} + +// ListFeatures — каталог можливостей. +func (s *Store) ListFeatures(ctx context.Context) ([]Feature, error) { + rows, err := s.pool.Query(ctx, + `SELECT key, name, COALESCE(description,'') FROM bill.features ORDER BY key`) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []Feature{} + for rows.Next() { + var f Feature + if err := rows.Scan(&f.Key, &f.Name, &f.Description); err != nil { + return nil, err + } + out = append(out, f) + } + return out, rows.Err() +} + +// --------------------------------------------------------------------- +// Стелі кабінету +// --------------------------------------------------------------------- + +// Entitlement — чинні стелі кабінету. +type Entitlement struct { + PlanKey string `json:"plan_key"` + PlanName string `json:"plan_name"` + PlanLimits + Features []string `json:"features"` + Source string `json:"source"` + Reason string `json:"reason"` + ValidUntil *time.Time `json:"valid_until,omitempty"` + GraceUntil *time.Time `json:"grace_until,omitempty"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Has — чи входить можливість у стелі кабінету. +func (e *Entitlement) Has(feature string) bool { + for _, f := range e.Features { + if f == feature { + return true + } + } + return false +} + +// ЧОМУ СТЕЛІ ЗАВОДЯТЬСЯ ЛІНИВО, А НЕ ТРИГЕРОМ НА core.tenants +// +// 0069 завела рядок кожному кабінету, який існував на мить накочування. +// Кабінети, створені ПІСЛЯ (netpulse-user, майбутня реєстрація), +// лишаються без рядка — і це нормальний стан, а не поломка: стелі там +// немає, тригери пропускають усе, продукт працює. +// +// Але сторінка тарифу в такому кабінеті мусить щось показати, і +// показати вона мусить правду: «стелі не задані». Заводити рядок звідси +// дешевше й чесніше, ніж вішати ще один тригер на створення кабінету: +// тригер спрацював би в мить, коли про тариф ще ніхто не питав, і його +// довелося б тримати в синхроні з переліком колонок ще в одному місці. + +// TenantEntitlement читає стелі, заводячи їх за потреби. +func (s *Store) TenantEntitlement(ctx context.Context, tenantID string) (*Entitlement, error) { + e, err := s.readEntitlement(ctx, tenantID) + if err == nil || !errors.Is(err, ErrNotFound) { + return e, err + } + if err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + INSERT INTO bill.entitlements + (tenant_id, plan_key, max_devices, max_maps, max_map_nodes, max_agents, + max_users, metric_retention_days, features, source, reason) + SELECT $1, p.key, p.max_devices, p.max_maps, p.max_map_nodes, p.max_agents, + p.max_users, p.metric_retention_days, p.features, 'license_key', 'migration' + FROM bill.plans p WHERE p.key = 'self_hosted' + ON CONFLICT (tenant_id) DO NOTHING + `, tenantID) + return err + }); err != nil { + return nil, err + } + return s.readEntitlement(ctx, tenantID) +} + +func (s *Store) readEntitlement(ctx context.Context, tenantID string) (*Entitlement, error) { + var e Entitlement + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + return tx.QueryRow(ctx, ` + SELECT e.plan_key, COALESCE(p.name, e.plan_key), + e.max_devices, e.max_maps, e.max_map_nodes, e.max_agents, e.max_users, + e.metric_retention_days, e.features, e.source::text, e.reason, + e.valid_until, e.grace_until, e.updated_at + FROM bill.entitlements e + LEFT JOIN bill.plans p ON p.key = e.plan_key + WHERE e.tenant_id = $1 + `, tenantID).Scan(&e.PlanKey, &e.PlanName, + &e.MaxDevices, &e.MaxMaps, &e.MaxMapNodes, &e.MaxAgents, &e.MaxUsers, + &e.RetentionDays, &e.Features, &e.Source, &e.Reason, + &e.ValidUntil, &e.GraceUntil, &e.UpdatedAt) + }) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + if e.Features == nil { + e.Features = []string{} + } + return &e, nil +} + +// TenantUsage — скільки слотів зайнято. +// +// Питається в БД тією самою функцією, яку викликають тригери +// (bill.usage_now), а не власним count(*). Друге визначення «зайнятого +// хоста» неминуче розійшлося б із першим — і розійшлося б саме там, де +// це найдорожче: сторінка казала б «14 з 15», а вставка відмовляла б. +func (s *Store) TenantUsage(ctx context.Context, tenantID string) (Usage, error) { + var u Usage + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + return tx.QueryRow(ctx, + `SELECT devices, maps, agents, users FROM bill.usage_now($1)`, tenantID). + Scan(&u.Devices, &u.Maps, &u.Agents, &u.Users) + }) + return u, err +} + +// SetTenantPlan переводить кабінет на тариф. +// +// Ручна дія власника — те, чим користуються, поки платіжки немає, і те, +// чим користуватимуться після неї для enterprise-домовленостей. Пише в +// bill.entitlements напряму, з source = 'manual': видно, що стелі +// поставила людина, а не ключ і не провайдер. +// +// Стеля нижча за зайняте не забороняється, і це свідомо. Власник має +// право перевести кабінет на менший тариф, маючи 40 хостів при стелі +// 15: наявні 40 лишаються під наглядом (тригер стоїть на появі нового, +// не на існуванні), нові не заводяться, а на сторінці стоїть «40 з 15» +// — тобто рівно та правда, за якою вирішують, що робити далі. +// Заборонити перехід означало б вимагати спершу видалити 25 хостів, +// щоб дізнатись, чи влаштує дешевший тариф. +func (s *Store) SetTenantPlan(ctx context.Context, tenantID, planKey, userID string) error { + return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + tag, err := tx.Exec(ctx, ` + UPDATE bill.entitlements e SET + plan_key = p.key, + max_devices = p.max_devices, max_maps = p.max_maps, + max_map_nodes = p.max_map_nodes, max_agents = p.max_agents, + max_users = p.max_users, + metric_retention_days = p.metric_retention_days, + features = p.features, + source = 'manual', reason = 'plan', license_id = NULL, + valid_until = NULL, grace_until = NULL, + updated_at = now() + FROM bill.plans p + WHERE e.tenant_id = $1 AND p.key = $2 + `, tenantID, planKey) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("%w: тариф %q не існує або кабінету ще не заведено стель", + ErrInvalid, planKey) + } + return nil + }) +} + +// --------------------------------------------------------------------- +// Ліцензія інсталяції +// --------------------------------------------------------------------- + +// instanceRow — сирий рядок bill.instance. +type instanceRow struct { + InstallID string + LicenseKey string + Payload []byte + State string + InvalidReason string + ClockMaxSeen time.Time + ClockWarpedAt *time.Time + CheckedAt *time.Time + AppliedAt *time.Time +} + +// readInstance читає рядок інсталяції. +// +// Пулом воркера, як і core.storage_config: таблиця рівня інсталяції, +// tenant_id у неї немає за побудовою, і RLS до неї не застосовна. +func (s *Store) readInstance(ctx context.Context) (*instanceRow, error) { + var r instanceRow + var key, reason *string + err := s.bg.QueryRow(ctx, ` + SELECT install_id::text, license_key, payload, state, invalid_reason, + clock_max_seen, clock_warped_at, checked_at, applied_at + FROM bill.instance WHERE id + `).Scan(&r.InstallID, &key, &r.Payload, &r.State, &reason, + &r.ClockMaxSeen, &r.ClockWarpedAt, &r.CheckedAt, &r.AppliedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + if key != nil { + r.LicenseKey = *key + } + if reason != nil { + r.InvalidReason = *reason + } + return &r, nil +} + +// InstallID — ідентифікатор цієї інсталяції. +// +// Потрібен людині: саме його вона надсилає, замовляючи ключ. +func (s *Store) InstallID(ctx context.Context) (string, error) { + r, err := s.readInstance(ctx) + if err != nil { + return "", err + } + return r.InstallID, nil +} + +// LicenseStatus — поточний стан без перерахунку. +// +// Дешеве читання для сторінки. Стан перераховує такт (RefreshLicense); +// сторінка показує те, що він порахував, і поруч — коли саме. Рахувати +// на кожному відкритті означало б, що стан залежить від того, чи хтось +// дивиться. +func (s *Store) LicenseStatus(ctx context.Context, v *LicenseVerifier) (LicenseState, error) { + r, err := s.readInstance(ctx) + if err != nil { + return LicenseState{}, err + } + st := LicenseState{ + State: r.State, + Reason: r.InvalidReason, + InstallID: r.InstallID, + CheckedAt: r.CheckedAt, + AppliedAt: r.AppliedAt, + ClockWarped: r.ClockWarpedAt != nil, + ClockMaxSeen: &r.ClockMaxSeen, + VerifierEmpty: v.Empty(), + } + if len(r.Payload) == 0 { + return st, nil + } + var p LicensePayload + if err := json.Unmarshal(r.Payload, &p); err != nil { + return st, nil + } + live := EvaluateLicense(&p, nowFor(r)) + // Стан беремо з перерахованого, а не з колонки: колонка відстає + // рівно на час до наступного такту, і в добу переходу active→grace + // сторінка показувала б учорашню правду. + // + // Виняток — invalid: він означає, що payload не пройшов перевірку + // підпису, і будувати з нього строк не можна взагалі. + if r.State != LicenseInvalid { + live.InstallID = st.InstallID + live.CheckedAt, live.AppliedAt = st.CheckedAt, st.AppliedAt + live.ClockWarped, live.ClockMaxSeen = st.ClockWarped, st.ClockMaxSeen + live.VerifierEmpty = st.VerifierEmpty + return live, nil + } + st.LicenseID, st.IssuedTo, st.Plan = p.LicenseID, p.IssuedTo, p.Plan + return st, nil +} + +// nowFor — час, за яким рахувати строк для цього рядка інсталяції. +func nowFor(r *instanceRow) time.Time { + t, _ := effectiveNow(time.Now().UTC(), r.ClockMaxSeen) + return t +} + +// ApplyLicense застосовує введений ключ. +// +// Порядок: перевірити підпис → перевірити прив'язку до інсталяції → +// записати → перерахувати стелі. Перший крок, що не вдався, лишає все +// як було: інсталяція з чинним ключем не має втратити його від того, +// що хтось вставив у поле сторонній рядок. +func (s *Store) ApplyLicense(ctx context.Context, v *LicenseVerifier, key, userID string) (LicenseState, error) { + inst, err := s.readInstance(ctx) + if err != nil { + return LicenseState{}, err + } + + p, perr := v.ParseLicense(key) + if perr != nil { + return LicenseState{}, perr + } + // Прив'язка. Порожній InstallID у ключі — ключ «на пред'явника»: + // його випустили до розгортання, і забирає його той, хто ввів + // першим. Непорожній мусить збігтися дослівно. + if p.InstallID != "" && p.InstallID != inst.InstallID { + return LicenseState{}, fmt.Errorf("%w: ключ виданий для %s, ця інсталяція — %s", + ErrLicenseInstall, p.InstallID, inst.InstallID) + } + + body, err := json.Marshal(p) + if err != nil { + return LicenseState{}, err + } + now := time.Now().UTC() + st := EvaluateLicense(p, now) + + if _, err := s.bg.Exec(ctx, ` + UPDATE bill.instance SET + license_key = $1, payload = $2::jsonb, + license_id = NULLIF($3,'')::uuid, issued_to = NULLIF($4,''), + state = $5, invalid_reason = NULL, + expires_at = $6, grace_until = $7, + applied_at = now(), applied_by = $8, checked_at = now(), + clock_max_seen = GREATEST(clock_max_seen, now()) + WHERE id + `, key, string(body), licenseUUID(p.LicenseID), p.IssuedTo, st.State, + st.ExpiresAt, st.GraceUntil, nullUUID(userID)); err != nil { + return LicenseState{}, err + } + + // Слід у bill.license_keys — не дублювання, а відповідь на питання + // «які ключі тут бували». Ключ, який замінили місяць тому, з + // bill.instance зникає безслідно, і з'ясувати, чи був він взагалі, + // стає ніяк. tenant_id лишається порожнім навмисно: ліцензія + // належить інсталяції, а не кабінету (див. 0069). + if err := s.recordLicenseKey(ctx, p, key, inst.InstallID); err != nil { + return LicenseState{}, err + } + + if err := s.applyEntitlements(ctx, p, st.State); err != nil { + return LicenseState{}, err + } + return s.LicenseStatus(ctx, v) +} + +// ClearLicense знімає ключ. +// +// Не «видаляє ліцензію»: рядок у bill.license_keys лишається, зникає +// лише те, що діє. Кабінети повертаються до self_hosted, тобто до +// стану «стелі не задані», а не до найменшого тарифу — зняття ключа не +// має бути дією, після якої система перестає приймати хости. +func (s *Store) ClearLicense(ctx context.Context, userID string) error { + if _, err := s.bg.Exec(ctx, ` + UPDATE bill.instance SET + license_key = NULL, payload = NULL, license_id = NULL, issued_to = NULL, + state = 'unlicensed', invalid_reason = NULL, + expires_at = NULL, grace_until = NULL, + applied_at = now(), applied_by = $1, checked_at = now() + WHERE id + `, nullUUID(userID)); err != nil { + return err + } + return s.applyEntitlements(ctx, nil, LicenseUnlicensed) +} + +// ЧОМУ СТАН ЛІЦЕНЗІЇ ПЕРЕРАХОВУЄТЬСЯ ТАКТОМ, А НЕ РАЗ ПРИ СТАРТІ +// +// Бо стан ліцензії міняється БЕЗ ЖОДНОЇ ДІЇ — просто від того, що минув +// час. Інсталяція, яку не перезавантажували чотири місяці (а це норма), +// перейшла б із active в expired і не помітила б цього до наступного +// перезапуску. Тобто попередження «лишилось 5 діб» людина побачила б +// через півроку після того, як вони скінчились. +// +// Такт же робить три речі: рухає монотонний годинник, перечитує підпис +// (ключ міг стати невалідним через ротацію ключів підпису у збірці) і +// переписує стелі, якщо стан змінився. + +// RefreshLicense — такт перевірки в рантаймі. +func (s *Store) RefreshLicense(ctx context.Context, v *LicenseVerifier) (LicenseState, error) { + inst, err := s.readInstance(ctx) + if err != nil { + return LicenseState{}, err + } + + now := time.Now().UTC() + eff, warped := effectiveNow(now, inst.ClockMaxSeen) + + if inst.LicenseKey == "" { + if _, err := s.bg.Exec(ctx, ` + UPDATE bill.instance SET checked_at = now(), + clock_max_seen = GREATEST(clock_max_seen, now()) WHERE id`); err != nil { + return LicenseState{}, err + } + return s.LicenseStatus(ctx, v) + } + + p, perr := v.ParseLicense(inst.LicenseKey) + if perr != nil { + // Ключ був прийнятий раніше, а тепер не перевіряється. Найчастіша + // причина — збірку оновили, і ключ підпису, яким його видали, + // з неї прибрали. + // + // Стан invalid, стелі НЕ чіпаємо. Це принципово: ми не знаємо, + // чи справді ключ підроблений, чи це ми загубили відкритий ключ, + // — і в другому випадку відібрати в клієнта стелі означало б + // покарати його за нашу помилку розгортання. Видно це на + // сторінці, і полагодити має людина, а не тиха дія коду. + if _, err := s.bg.Exec(ctx, ` + UPDATE bill.instance SET state = 'invalid', invalid_reason = $1, + checked_at = now(), clock_max_seen = GREATEST(clock_max_seen, now()) + WHERE id`, perr.Error()); err != nil { + return LicenseState{}, err + } + return s.LicenseStatus(ctx, v) + } + + st := EvaluateLicense(p, eff) + + if _, err := s.bg.Exec(ctx, ` + UPDATE bill.instance SET + state = $1, invalid_reason = NULL, + expires_at = $2, grace_until = $3, + checked_at = now(), + clock_max_seen = GREATEST(clock_max_seen, now()), + clock_warped_at = CASE WHEN $4 THEN now() ELSE clock_warped_at END + WHERE id + `, st.State, st.ExpiresAt, st.GraceUntil, warped); err != nil { + return LicenseState{}, err + } + + // Стелі переписуються щотакту, а не лише на зміні стану. + // + // Дорожче на один UPDATE на кабінет за годину — і рівно це закриває + // найтихішу з поломок: рядок entitlements, зіпсований чужою правкою + // руками в базі, інакше лишався б чинним нескінченно, бо «стан не + // змінився». + if err := s.applyEntitlements(ctx, p, st.State); err != nil { + return LicenseState{}, err + } + return s.LicenseStatus(ctx, v) +} + +// recordLicenseKey лишає слід про застосований ключ. +func (s *Store) recordLicenseKey(ctx context.Context, p *LicensePayload, key, installID string) error { + body, err := json.Marshal(p) + if err != nil { + return err + } + prefix := key + if len(prefix) > 16 { + prefix = prefix[:16] + } + // Підпис лягає окремою колонкою, хоч він і є всередині key. + // + // Не заради місця, а заради відповіді на питання, яке ставлять раз + // на кілька років і завжди в поганий день: «яким саме ключем підпису + // це підписано і чи сходиться воно досі». Дістати підпис регулярним + // виразом із текстового поля посеред розслідування — не те, чим + // хочеться займатись; колонка bytea для цього в 0009 і заведена. + // Первинний ключ рядка генерує база, а не lid із payload. + // + // Спокуса взяти lid очевидна — «нехай ідентифікатор ліцензії буде + // ідентифікатором рядка». Але lid пише той, хто випускає ключ, і + // продовження ліцензії з тим самим lid (звичайна практика) уперлось + // би в первинний ключ. Наслідок був би найгіршим із можливих: + // дійсний оплачений ключ не застосовується, а людина бачить + // «внутрішню помилку». Тотожність рядка тримає хеш самого ключа — + // саме він і є тим, що не повторюється. + _, err = s.bg.Exec(ctx, ` + INSERT INTO bill.license_keys + (plan_key, key_hash, key_prefix, payload, signature, signing_key_id, + status, max_devices, max_map_nodes, features, bound_install_id, issued_to, + activated_at, expires_at) + VALUES (COALESCE((SELECT key FROM bill.plans WHERE key = $1), 'self_hosted'), + digest($2, 'sha256'), $3, $4::jsonb, $5, $6, + 'active', $7, $8, $9, $10::uuid, NULLIF($11,''), + now(), $12) + ON CONFLICT (key_hash) DO UPDATE SET + status = 'active', activated_at = now(), + bound_install_id = EXCLUDED.bound_install_id + `, p.Plan, key, prefix, string(body), + licenseSignatureBytes(key), p.KeyID, + p.MaxDevices, p.MaxMapNodes, featuresOrEmpty(p.Features), installID, + p.IssuedTo, timeOrNil(p.ExpiresAt)) + return err +} + +// licenseSignatureBytes дістає підпис із уже перевіреного ключа. +// +// Порожній зріз замість помилки: сюди потрапляє лише ключ, який щойно +// пройшов ParseLicense, тобто розкодувався напевно. Але падати на +// збереженні СЛІДУ через несподіванку в розборі не можна — ліцензія при +// цьому вже застосована, і відкотити її частково гірше, ніж лишити +// колонку порожньою. +func licenseSignatureBytes(key string) []byte { + parts := strings.Split(strings.TrimSpace(key), ".") + if len(parts) != 3 { + return []byte{} + } + sig, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + return []byte{} + } + return sig +} + +// licenseUUID пропускає далі лише те, що справді схоже на uuid. +// +// lid у payload пише той, хто випускає ключ, і поле це вільне. Рядок, +// який не є uuid, доїхав би до `::uuid` у запиті й перетворився б на +// 500 «внутрішня помилка» — тобто дійсний за підписом ключ не +// застосувався б через косметичне поле. Порожній рядок означає «нехай +// база згенерує свій», і це нормальний стан. +func licenseUUID(v string) string { + if len(v) != 36 { + return "" + } + for i, c := range v { + switch i { + case 8, 13, 18, 23: + if c != '-' { + return "" + } + default: + isHex := (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') + if !isHex { + return "" + } + } + } + return v +} + +func featuresOrEmpty(f []string) []string { + if f == nil { + return []string{} + } + return f +} + +// timeOrNil — вказівник, а не any з nil усередині. +// +// Порожній інтерфейс із nil доїжджає до драйвера як «значення +// невідомого типу», і колонка timestamptz на нього реагує по-різному +// залежно від версії. Типізований nil-вказівник однозначний завжди. +func timeOrNil(unix int64) *time.Time { + if unix == 0 { + return nil + } + t := time.Unix(unix, 0).UTC() + return &t +} + +// applyEntitlements переписує стелі всім кабінетам, які живуть із +// ліцензії. +// +// САМЕ «ЯКІ ЖИВУТЬ ІЗ ЛІЦЕНЗІЇ» — це та межа, за яку не протікає +// платіжка. Кабінет, стелі якого прийшли з підписки (source = 'stripe') +// або поставлені руками (source = 'manual'), ключ інсталяції не чіпає. +// Без цієї умови перший же ключ, застосований на спільному хостингу, +// переписав би чужі оплачені стелі своїми. +func (s *Store) applyEntitlements(ctx context.Context, p *LicensePayload, state string) error { + planKey := "self_hosted" + if p != nil && p.Plan != "" { + planKey = p.Plan + } + + base, features, err := s.planLimits(ctx, planKey) + if err != nil { + return err + } + limits := LimitsFromPayload(base, p) + features = FeaturesFromPayload(features, p) + + reason := "license" + switch state { + case LicenseUnlicensed, LicenseInvalid: + reason = "migration" + case LicenseExpired: + reason = "license_expired" + } + + ids, err := s.licensedTenants(ctx) + if err != nil { + return err + } + for _, id := range ids { + // Стелі рахуються ОКРЕМО для кожного кабінету, бо «не нижче + // зайнятого» — це про конкретні числа конкретного кабінету, а + // не про інсталяцію загалом. + used, err := s.TenantUsage(ctx, id) + if err != nil { + return err + } + eff := EffectiveLimits(limits, state, used) + + var validUntil, graceUntil *time.Time + if p != nil { + v, g := p.Expires(), p.GraceUntil() + validUntil, graceUntil = &v, &g + } + if err := s.InTenantTx(ctx, id, func(tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + UPDATE bill.entitlements SET + plan_key = $2, + max_devices = $3, max_maps = $4, max_map_nodes = $5, + max_agents = $6, max_users = $7, + metric_retention_days = $8, features = $9, + source = 'license_key', reason = $10, + valid_until = $11, grace_until = $12, + updated_at = now() + WHERE tenant_id = $1 AND source = 'license_key' + `, id, planKey, eff.MaxDevices, eff.MaxMaps, eff.MaxMapNodes, + eff.MaxAgents, eff.MaxUsers, eff.RetentionDays, features, + reason, validUntil, graceUntil) + return err + }); err != nil { + return err + } + } + return nil +} + +// planLimits — стелі й фічі тарифу. +func (s *Store) planLimits(ctx context.Context, key string) (PlanLimits, []string, error) { + var l PlanLimits + var f []string + err := s.bg.QueryRow(ctx, ` + SELECT max_devices, max_maps, max_map_nodes, max_agents, max_users, + metric_retention_days, features + FROM bill.plans WHERE key = $1 + `, key).Scan(&l.MaxDevices, &l.MaxMaps, &l.MaxMapNodes, &l.MaxAgents, + &l.MaxUsers, &l.RetentionDays, &f) + if errors.Is(err, pgx.ErrNoRows) { + // Ключ називає тариф, якого в цій збірці немає. Не помилка + // застосування: ключ дійсний, підпис сходиться, просто план + // новіший за інсталяцію. Стелі беремо з самого ключа, а базою + // стає «без обмежень» — бо клієнт заплатив, і відмовляти йому + // через незнайоме слово в полі plan ми не маємо права. + return PlanLimits{RetentionDays: 400}, []string{}, nil + } + if err != nil { + return l, nil, err + } + if f == nil { + f = []string{} + } + return l, f, nil +} + +// licensedTenants — кабінети, стелі яких задає ліцензія інсталяції. +func (s *Store) licensedTenants(ctx context.Context) ([]string, error) { + rows, err := s.bg.Query(ctx, ` + SELECT e.tenant_id::text + FROM bill.entitlements e + JOIN core.tenants t ON t.id = e.tenant_id AND t.deleted_at IS NULL + WHERE e.source = 'license_key' + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + out = append(out, id) + } + return out, rows.Err() +} diff --git a/server/internal/store/billing_license.go b/server/internal/store/billing_license.go new file mode 100644 index 0000000..fee35c3 --- /dev/null +++ b/server/internal/store/billing_license.go @@ -0,0 +1,517 @@ +package store + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "time" +) + +// Ліцензійні ключі для self-hosted. +// +// ЧОМУ ЦЕ ВАЖЛИВІШЕ ЗА STRIPE +// +// Бо продукт ставлять клієнту НА ЙОГО ЗАЛІЗО, і найчастіше — у сегмент +// без виходу назовні. Це не рідкість і не окремий випадок: мережа, за +// якою треба стежити, зазвичай і є та мережа, з якої нікуди не +// достукатись. Ліцензія, яка вимагає зателефонувати додому, у такій +// інсталяції не працює взагалі — не «працює гірше», а не працює. +// +// Звідси головна вимога до формату: ключ мусить нести всю правду про +// себе САМ. Строк, стелі, набір фіч, кому виданий, до якої інсталяції +// прив'язаний — усе всередині, під підписом. Перевірка — це перевірка +// підпису над байтами, які вже лежать перед нами, і більше нічого. +// +// ФОРМАТ +// +// NP1.. +// +// Три частини, крапка як роздільник — та сама форма, що в JWT, і це +// свідомо: її впізнають, її не ламають поштові клієнти, вона копіюється +// подвійним кліком. Але це НЕ JWT: там алгоритм оголошено в самому +// токені, і найвідоміша вразливість формату — саме через це («alg: +// none»). Тут алгоритм один, він у префіксі NP1 і не узгоджується ні з +// ким. +// +// Підпис Ed25519. Обґрунтування вибору проти RSA-4096 з 0009 — +// у міграції 0069; коротко: 64 байти проти 512 у ключі, який людина +// вводить руками, і жодного параметра, який можна виставити неправильно. +// +// ПІДПИСУЄМО САМЕ ТІ БАЙТИ, ЯКІ ВЕЗЕМО +// +// Підпис рахується над base64-розкодованим payload дослівно, а не над +// «канонічним виглядом структури». Це прибирає цілий клас поломок: +// будь-яка канонізація (порядок полів, пропуск порожніх, формат чисел) +// — це домовленість між тим, хто підписав, і тим, хто перевіряє, і +// розходиться вона мовчки, через рік, після нейтральної на вигляд зміни +// структури. Байти не розходяться ніколи. +// +// Наслідок, з яким треба жити: невідомі поля в payload переживають +// перевірку. Це правильно — ключ, виданий новішою збіркою генератора, +// має лишатись дійсним на старішій інсталяції, а не ставати +// «недійсним» через поле, про яке вона не знає. + +// --------------------------------------------------------------------- +// Формат ключа +// --------------------------------------------------------------------- + +const licensePrefix = "NP1" + +var ( + // ErrLicenseFormat — рядок не схожий на ключ узагалі. + ErrLicenseFormat = errors.New("це не схоже на ліцензійний ключ") + // ErrLicenseSignature — підпис не сходиться або ключ підпису невідомий. + ErrLicenseSignature = errors.New("підпис ліцензії не сходиться") + // ErrLicenseInstall — ключ виданий іншій інсталяції. + ErrLicenseInstall = errors.New("ключ виданий іншій інсталяції") + // ErrNoVerifier — на цій збірці немає жодного відкритого ключа + // підпису, тобто перевірити ліцензію нічим. + ErrNoVerifier = errors.New("відкритий ключ підпису ліцензій не налаштовано") +) + +// LicensePayload — те, що лежить під підписом. +// +// Стелі — вказівники, і саме тому. Відсутнє поле означає «без +// обмеження», нуль — «нуль штук». Для ліцензії ця різниця вирішальна: +// ключ, у якому забули max_devices, має відкривати необмежену +// інсталяцію, а не блокувати перший же хост. Помилка в цей бік +// коштувала б клієнту робочого дня, а нам — звернення о шостій ранку. +type LicensePayload struct { + V int `json:"v"` + LicenseID string `json:"lid"` + KeyID string `json:"kid"` + + // InstallID порожній — ключ не прив'язаний до інсталяції. + // + // Так виглядає ключ, виданий ДО розгортання: install_id заводить + // сама інсталяція при першому старті, і на момент продажу його ще + // не існує. Прив'язка відбувається при застосуванні (bound_install_id + // у bill.license_keys) — тобто перший, хто ввів ключ, і забирає його + // собі. + InstallID string `json:"install,omitempty"` + + Plan string `json:"plan"` + IssuedTo string `json:"to,omitempty"` + + IssuedAt int64 `json:"iat"` + ExpiresAt int64 `json:"exp"` + + // GraceDays — скільки діб після строку все лишається як було. + // + // Не «скільки терпіти неплатника»: пільговий період існує проти + // НАШОЇ ж помилки — лист із рахунком у спамі, відпустка людини, яка + // підписує, оплата, що йшла три дні. Нуль тут означав би, що + // продукт починає обмежувати клієнта в день, коли той іще навіть не + // знає про проблему. + GraceDays int `json:"grace,omitempty"` + + MaxDevices *int `json:"max_devices,omitempty"` + MaxMaps *int `json:"max_maps,omitempty"` + MaxMapNodes *int `json:"max_map_nodes,omitempty"` + MaxAgents *int `json:"max_agents,omitempty"` + MaxUsers *int `json:"max_users,omitempty"` + RetentionDays *int `json:"retention_days,omitempty"` + Features []string `json:"features,omitempty"` +} + +// Expires — строк дії як час. +func (p *LicensePayload) Expires() time.Time { return time.Unix(p.ExpiresAt, 0).UTC() } + +// GraceUntil — до якої миті все лишається як було. +func (p *LicensePayload) GraceUntil() time.Time { + return p.Expires().AddDate(0, 0, p.GraceDays) +} + +// --------------------------------------------------------------------- +// Перевірка підпису +// --------------------------------------------------------------------- + +// LicenseVerifier — відкриті ключі, якими перевіряють ліцензії. +// +// Кілька ключів одночасно, як у crypto.Keyring, і з тієї ж причини: +// ключ підпису колись доведеться змінити, а вже видані ліцензії мають +// лишитись дійсними до кінця свого строку. Тому payload несе kid, а не +// покладається на «ключ у нас один». +type LicenseVerifier struct { + keys map[string]ed25519.PublicKey +} + +// ParseLicenseVerifier читає ключі з рядка `kid=[,kid2=…]`. +// +// Той самий синтаксис, що й у -dek: два різні формати для двох наборів +// ключів в одному продукті — це два способи помилитись. +// +// Порожній рядок дає порожній набір, а не помилку. Це робочий стан: +// збірка без вшитого ключа підпису — це збірка для розробки й для +// клієнта, який ліцензію не купував. Вона працює; просто ключ у ній +// перевірити нічим, і будь-який введений ключ отримає стан invalid із +// причиною, названою вголос. +func ParseLicenseVerifier(spec string) (*LicenseVerifier, error) { + v := &LicenseVerifier{keys: map[string]ed25519.PublicKey{}} + for _, part := range strings.Split(spec, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + kid, raw, ok := strings.Cut(part, "=") + if !ok { + return nil, fmt.Errorf("очікується kid=ключ, отримано %q", part) + } + key, err := decodeKeyBytes(raw) + if err != nil { + return nil, fmt.Errorf("ключ %s: %w", kid, err) + } + if len(key) != ed25519.PublicKeySize { + return nil, fmt.Errorf("ключ %s: очікується %d байтів, отримано %d", + kid, ed25519.PublicKeySize, len(key)) + } + v.keys[strings.TrimSpace(kid)] = ed25519.PublicKey(key) + } + return v, nil +} + +// NewLicenseVerifier — набір із готових ключів (для тестів і генератора). +func NewLicenseVerifier(keys map[string]ed25519.PublicKey) *LicenseVerifier { + cp := make(map[string]ed25519.PublicKey, len(keys)) + for k, v := range keys { + cp[k] = v + } + return &LicenseVerifier{keys: cp} +} + +// Empty — чи є чим перевіряти. +func (v *LicenseVerifier) Empty() bool { return v == nil || len(v.keys) == 0 } + +func decodeKeyBytes(raw string) ([]byte, error) { + if b, err := hex.DecodeString(raw); err == nil { + return b, nil + } + if b, err := base64.StdEncoding.DecodeString(raw); err == nil { + return b, nil + } + if b, err := base64.RawURLEncoding.DecodeString(raw); err == nil { + return b, nil + } + return nil, errors.New("не hex і не base64") +} + +// ParseLicense розбирає ключ і перевіряє підпис. +// +// Порядок кроків не косметичний: спершу форма, потім підпис, і лише +// потім будь-що про зміст. Розбирати payload до перевірки підпису +// означало б довіряти числам, які написав той, кого вони обмежують. +func (v *LicenseVerifier) ParseLicense(key string) (*LicensePayload, error) { + key = strings.TrimSpace(key) + // Ключ їздить у листах і в чатах, звідки повертається з переносами + // рядків і пробілами всередині. Прибрати їх тут дешевше, ніж + // пояснювати людині, що саме в її ключі не так. + key = strings.NewReplacer("\n", "", "\r", "", " ", "", "\t", "").Replace(key) + + parts := strings.Split(key, ".") + if len(parts) != 3 || parts[0] != licensePrefix { + return nil, ErrLicenseFormat + } + body, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, ErrLicenseFormat + } + sig, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + return nil, ErrLicenseFormat + } + if len(sig) != ed25519.SignatureSize { + return nil, ErrLicenseFormat + } + + // kid лежить у payload, тобто читати його доводиться ДО перевірки + // підпису — інакше невідомо, чим перевіряти. Це не дірка: підроблений + // kid не дає нічого, бо вказує лише на те, ЯКИМ відкритим ключем + // пробувати, а ключі всі наші. Найгірше, що дає брехня тут, — + // «невідомий ключ підпису». + var head LicensePayload + if err := json.Unmarshal(body, &head); err != nil { + return nil, ErrLicenseFormat + } + if v.Empty() { + return nil, ErrNoVerifier + } + pub, ok := v.keys[head.KeyID] + if !ok { + return nil, fmt.Errorf("%w: невідомий ключ підпису %q", ErrLicenseSignature, head.KeyID) + } + if !ed25519.Verify(pub, body, sig) { + return nil, ErrLicenseSignature + } + // Версія перевіряється ПІСЛЯ підпису: чужому числу до перевірки віри + // немає, а після — це вже наше власне число. + if head.V != 1 { + return nil, fmt.Errorf("%w: версія ключа %d не підтримується цією збіркою", + ErrLicenseFormat, head.V) + } + return &head, nil +} + +// SignLicense збирає ключ. Живе поруч із перевіркою навмисно. +// +// Генератор ліцензій — окремий інструмент і в цьому бінарнику не +// потрібен. Але формат, у якому підписують, і формат, у якому +// перевіряють, — це одна річ, і рознесені по різних репозиторіях вони +// розійдуться. Тут же на цій функції стоять тести: перевірити, що +// підпис сходиться, інакше нічим. +func SignLicense(priv ed25519.PrivateKey, p LicensePayload) (string, error) { + if p.V == 0 { + p.V = 1 + } + body, err := json.Marshal(p) + if err != nil { + return "", err + } + sig := ed25519.Sign(priv, body) + return licensePrefix + "." + + base64.RawURLEncoding.EncodeToString(body) + "." + + base64.RawURLEncoding.EncodeToString(sig), nil +} + +// --------------------------------------------------------------------- +// Стан ліцензії +// --------------------------------------------------------------------- + +// Стани. Рядки збігаються з CHECK у bill.instance. +const ( + LicenseUnlicensed = "unlicensed" + LicenseActive = "active" + LicenseGrace = "grace" + LicenseExpired = "expired" + LicenseInvalid = "invalid" +) + +// LicenseState — усе, що інтерфейс і такт знають про ліцензію. +type LicenseState struct { + State string `json:"state"` + Reason string `json:"reason,omitempty"` + + InstallID string `json:"install_id"` + LicenseID string `json:"license_id,omitempty"` + IssuedTo string `json:"issued_to,omitempty"` + Plan string `json:"plan,omitempty"` + + ExpiresAt *time.Time `json:"expires_at,omitempty"` + GraceUntil *time.Time `json:"grace_until,omitempty"` + // DaysLeft — скільки діб лишилось до кінця пільгового періоду. + // Від'ємне після нього; для unlicensed не заповнюється. + DaysLeft *int `json:"days_left,omitempty"` + + // ClockWarped — системний час пішов назад проти побаченого раніше. + // + // Показується, але нічого не вимикає: годинник з'їжджає й сам. + ClockWarped bool `json:"clock_warped,omitempty"` + ClockMaxSeen *time.Time `json:"clock_max_seen,omitempty"` + CheckedAt *time.Time `json:"checked_at,omitempty"` + AppliedAt *time.Time `json:"applied_at,omitempty"` + VerifierEmpty bool `json:"verifier_empty,omitempty"` +} + +// EvaluateLicense — стан ключа на заданий момент. +// +// Чиста функція: жодної бази, жодного time.Now(). Саме тому її можна +// перевірити тестом на всіх межах, включно з тими, які в житті настають +// раз на рік і на які ніхто не дивиться. +// +// now сюди подають НЕ як time.Now(), а як max(time.Now(), останній +// побачений час) — див. RefreshLicense. Годинник належить тому, кого +// ліцензія обмежує, і рахувати строк за ним просто так не можна. +func EvaluateLicense(p *LicensePayload, now time.Time) LicenseState { + if p == nil { + return LicenseState{State: LicenseUnlicensed} + } + st := LicenseState{ + State: LicenseActive, + LicenseID: p.LicenseID, + IssuedTo: p.IssuedTo, + Plan: p.Plan, + } + exp, grace := p.Expires(), p.GraceUntil() + st.ExpiresAt, st.GraceUntil = &exp, &grace + + // Округлення вгору навмисно: «лишилось 0 діб» людина читає як «уже + // все», хоча насправді ще є частина доби. Показувати 1 доти, доки + // строк справді не минув, чесніше за арифметично точний нуль. + days := int(grace.Sub(now).Hours() / 24) + if grace.After(now) { + days++ + } + st.DaysLeft = &days + + switch { + case now.Before(exp): + st.State = LicenseActive + case now.Before(grace): + st.State = LicenseGrace + st.Reason = "строк дії минув, триває пільговий період" + default: + st.State = LicenseExpired + st.Reason = "строк дії та пільговий період минули" + } + return st +} + +// --------------------------------------------------------------------- +// Стелі, які дає ліцензія +// --------------------------------------------------------------------- + +// PlanLimits — набір стель. Вказівник = «без обмеження». +type PlanLimits struct { + MaxDevices *int `json:"max_devices"` + MaxMaps *int `json:"max_maps"` + MaxMapNodes *int `json:"max_map_nodes"` + MaxAgents *int `json:"max_agents"` + MaxUsers *int `json:"max_users"` + RetentionDays int `json:"metric_retention_days"` +} + +// Usage — скільки слотів зайнято. Дзеркало bill.usage_now. +type Usage struct { + Devices int `json:"devices"` + Maps int `json:"maps"` + Agents int `json:"agents"` + Users int `json:"users"` +} + +// ЩО РОБИТЬ ПРОСТРОЧЕНА ЛІЦЕНЗІЯ І ЧОМУ САМЕ ЦЕ +// +// Не вимикає нічого. Ні збору, ні алертів, ні сповіщень, ні доступу до +// зібраного. Замерзає рівно одне — РІСТ: не з'являється новий хост, +// зонд, користувач, мапа. +// +// Це не поблажливість, це єдина відповідь, яка витримує перевірку +// наслідками. Моніторинг, що перестав моніторити через несплачений +// рахунок, — аварія в мережі клієнта, спричинена нами. Він не побачить +// падіння магістралі й дізнається про нього від абонентів; ми при цьому +// грошей не отримаємо, а отримаємо звернення й репутацію продукту, який +// тихо перестав працювати саме тоді, коли був потрібен. Продавати +// прилад, який сам себе вимикає, можна рівно один раз. +// +// Друга частина того самого рішення, і без неї перша нічого не варта: +// стеля ніколи не опускається нижче ФАКТИЧНО зайнятого. Інсталяція з +// п'ятьмастами хостами на протермінованому ключі лишається +// п'ятисотхостовою, а не падає до п'ятнадцяти. Інакше «нічого не +// вимикаємо» було б брехнею: стеля нижча за використане означає, що +// перший же перезапис entitlements зробив би 485 хостів такими, які не +// можна повернути з архіву, а тригер на UPDATE — тими, які не можна +// ввімкнути назад. +// +// Так само поводиться стан unlicensed: інсталяція, яку щойно розгорнули +// й ще не купили, має показувати продукт, а не стіну. + +// EffectiveLimits — стелі, які насправді лягають у bill.entitlements. +func EffectiveLimits(plan PlanLimits, state string, used Usage) PlanLimits { + if state != LicenseExpired { + return plan + } + out := plan + out.MaxDevices = notBelow(plan.MaxDevices, used.Devices) + out.MaxMaps = notBelow(plan.MaxMaps, used.Maps) + out.MaxAgents = notBelow(plan.MaxAgents, used.Agents) + out.MaxUsers = notBelow(plan.MaxUsers, used.Users) + // MaxMapNodes свідомо лишається як є: він рахується НА МАПУ, а не + // на кабінет, і «скільки вузлів на найбільшій мапі» — не те число, + // яке тут відоме. Наслідок м'який і у правильний бік: наявні вузли + // не зникають (тригер стоїть на вставці), а домалювати новий на вже + // переповненій мапі не вийде — що після прострочення й правильно. + return out +} + +// notBelow не дає стелі опуститись нижче вже зайнятого. +// +// nil (без обмеження) лишається nil: «без стелі» вже не нижче за будь-що. +func notBelow(limit *int, used int) *int { + if limit == nil { + return nil + } + if *limit >= used { + return limit + } + v := used + return &v +} + +// LimitsFromPayload — стелі, записані в самому ключі. +// +// Ключ перекриває тариф, а не навпаки, і це не дрібниця: домовленість +// («вам 250 хостів, хоча в тарифі 100») живе саме тут, у підписаному +// рядку, а не в чиємусь листі. Поле, якого в ключі немає, лишається +// таким, яким його задає тариф. +func LimitsFromPayload(base PlanLimits, p *LicensePayload) PlanLimits { + if p == nil { + return base + } + out := base + if p.MaxDevices != nil { + out.MaxDevices = p.MaxDevices + } + if p.MaxMaps != nil { + out.MaxMaps = p.MaxMaps + } + if p.MaxMapNodes != nil { + out.MaxMapNodes = p.MaxMapNodes + } + if p.MaxAgents != nil { + out.MaxAgents = p.MaxAgents + } + if p.MaxUsers != nil { + out.MaxUsers = p.MaxUsers + } + if p.RetentionDays != nil { + out.RetentionDays = *p.RetentionDays + } + return out +} + +// FeaturesFromPayload — набір можливостей. +// +// Порожній перелік у ключі означає «як у тарифі», а не «жодної». +// Різниця та сама, що між nil і нулем у стелях, і ціна помилки та сама: +// ключ без переліку фіч закрив би клієнтові все, за що він заплатив. +func FeaturesFromPayload(base []string, p *LicensePayload) []string { + if p == nil || len(p.Features) == 0 { + return base + } + out := append([]string(nil), p.Features...) + sort.Strings(out) + return out +} + +// --------------------------------------------------------------------- +// Монотонний годинник +// --------------------------------------------------------------------- + +// clockSkewTolerance — наскільки час має піти назад, щоб це вважалось +// зсувом, а не звичайним життям. +// +// Година, а не хвилина. Дрібні стрибки назад — норма: поправка NTP +// після довгого простою, віртуалка, яку зняли з паузи, перехід на +// зимовий час у системі, де хтось тримає локальний час замість UTC. +// Позначати їх зсувом означало б показувати попередження щотижня, а +// попередження, яке блимає щотижня, перестають читати. +const clockSkewTolerance = time.Hour + +// effectiveNow — час, за яким рахують строк ліцензії. +// +// Максимум із поточного й найпізнішого побаченого. Годинник назад не +// йде; якщо now менший за seen, його рухали — і рахувати строк за ним +// означало б віддати керування строком тому, кого строк обмежує. +// +// Це не криптографія й не заважає рішучому обходу: досить стерти рядок +// у базі. Воно ловить дешеву спробу, коштує одного UPDATE на годину й +// нічого не ламає, коли годинник з'їхав чесно. +func effectiveNow(now, seen time.Time) (time.Time, bool) { + if seen.IsZero() || !now.Before(seen) { + return now, false + } + return seen, now.Before(seen.Add(-clockSkewTolerance)) +} diff --git a/server/internal/store/billing_license_test.go b/server/internal/store/billing_license_test.go new file mode 100644 index 0000000..9a97562 --- /dev/null +++ b/server/internal/store/billing_license_test.go @@ -0,0 +1,464 @@ +package store + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "strconv" + "strings" + "testing" + "time" +) + +// Перевірки ліцензійного механізму без бази. +// +// ЩО САМЕ ТУТ ПЕРЕВІРЯЄТЬСЯ І ЧОМУ САМЕ ЦЕ +// +// Урок цього проєкту, дослівно: зелена перевірка доводить рівно те, що +// вона перевіряє. Тест ізоляції RLS був зелений і пропустив зламаний +// вхід, бо перевіряв «чи не видно чужого», коли зламалось «чи видно +// своє». +// +// У ліцензії симетрична пастка, і вона тут головна. Легко написати +// набір «ключ підроблено — відмовили», «строк минув — стан expired», і +// весь він буде зелений на реалізації, яка після прострочення вимикає +// збір даних. Тобто перевірка доводила б, що механізм СПРАЦЬОВУЄ, і +// мовчала б про те, що він спрацьовує руйнівно. +// +// Тому половина перевірок нижче — про те, що НЕ має статись: +// протермінована ліцензія не опускає стель нижче зайнятого, порожні +// поля в ключі не закривають того, що дає тариф, зсув годинника нічого +// не вимикає, а невідоме поле в payload не робить чужий ключ недійсним. + +// --------------------------------------------------------------------- +// Допоміжне +// --------------------------------------------------------------------- + +func testKeypair(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) { + t.Helper() + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("генерація ключа: %v", err) + } + return pub, priv +} + +func intp(v int) *int { return &v } + +// --------------------------------------------------------------------- +// Підпис +// --------------------------------------------------------------------- + +func TestLicenseRoundTrip(t *testing.T) { + pub, priv := testKeypair(t) + v := NewLicenseVerifier(map[string]ed25519.PublicKey{"k1": pub}) + + exp := time.Now().AddDate(1, 0, 0).Unix() + key, err := SignLicense(priv, LicensePayload{ + LicenseID: "11111111-1111-1111-1111-111111111111", + KeyID: "k1", + Plan: "enterprise", + IssuedTo: "ТОВ «Мережа»", + ExpiresAt: exp, + GraceDays: 30, + // Кирилиця в issued_to тут не для краси: назва клієнта + // українською — звичайний випадок, і base64url над UTF-8 має + // пережити подорож ключа туди й назад без втрат. + MaxDevices: intp(250), + }) + if err != nil { + t.Fatalf("підпис: %v", err) + } + + got, err := v.ParseLicense(key) + if err != nil { + t.Fatalf("перевірка щойно підписаного ключа: %v", err) + } + if got.IssuedTo != "ТОВ «Мережа»" { + t.Errorf("issued_to не пережив кодування: %q", got.IssuedTo) + } + if got.MaxDevices == nil || *got.MaxDevices != 250 { + t.Errorf("max_devices не пережив кодування: %v", got.MaxDevices) + } +} + +// Ключ їздить у листах і повертається з переносами рядків. +// +// Це не педантизм: половина звернень «ключ не приймається» — саме про +// це. Перевірка стоїть тут, бо мовчазна відмова на пробілі виглядає +// точно так само, як відмова на підробці, і людина шукатиме не там. +func TestLicenseSurvivesCopyPasteDamage(t *testing.T) { + pub, priv := testKeypair(t) + v := NewLicenseVerifier(map[string]ed25519.PublicKey{"k1": pub}) + key, _ := SignLicense(priv, LicensePayload{ + KeyID: "k1", Plan: "pro", ExpiresAt: time.Now().AddDate(1, 0, 0).Unix(), + }) + + damaged := " " + key[:20] + "\n" + key[20:40] + " \r\n" + key[40:] + "\t" + if _, err := v.ParseLicense(damaged); err != nil { + t.Fatalf("ключ із листа не прийнявся: %v", err) + } +} + +func TestLicenseRejectsTampering(t *testing.T) { + pub, priv := testKeypair(t) + v := NewLicenseVerifier(map[string]ed25519.PublicKey{"k1": pub}) + + key, _ := SignLicense(priv, LicensePayload{ + KeyID: "k1", Plan: "free", MaxDevices: intp(15), + ExpiresAt: time.Now().AddDate(1, 0, 0).Unix(), + }) + parts := strings.Split(key, ".") + + // Підмінений payload зі своїм, більш щедрим, вмістом і старим + // підписом — це і є та атака, проти якої весь механізм. + forged, _ := SignLicense(priv, LicensePayload{ + KeyID: "k1", Plan: "enterprise", MaxDevices: intp(100000), + ExpiresAt: time.Now().AddDate(10, 0, 0).Unix(), + }) + forgedParts := strings.Split(forged, ".") + + cases := []struct { + name string + key string + }{ + {"чужий payload зі старим підписом", parts[0] + "." + forgedParts[1] + "." + parts[2]}, + {"порожній підпис", parts[0] + "." + parts[1] + "."}, + {"не той префікс", "NP2." + parts[1] + "." + parts[2]}, + {"дві частини замість трьох", parts[0] + "." + parts[1]}, + {"сміття", "просто рядок"}, + {"порожньо", ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if _, err := v.ParseLicense(c.key); err == nil { + t.Fatal("ключ прийнято, хоча він недійсний") + } + }) + } +} + +func TestLicenseRejectsForeignSigner(t *testing.T) { + ourPub, _ := testKeypair(t) + _, theirPriv := testKeypair(t) + + v := NewLicenseVerifier(map[string]ed25519.PublicKey{"k1": ourPub}) + // Ключ, підписаний чужим приватним ключем, але з НАШИМ kid: саме так + // виглядала б спроба видати собі ліцензію самотужки. + key, _ := SignLicense(theirPriv, LicensePayload{ + KeyID: "k1", Plan: "enterprise", + ExpiresAt: time.Now().AddDate(1, 0, 0).Unix(), + }) + if _, err := v.ParseLicense(key); err == nil { + t.Fatal("прийнято ключ, підписаний чужим ключем") + } +} + +// Ключ, виданий новішим генератором, має лишатись дійсним. +// +// Перевірка про те, чого не має статись: невідоме поле в payload не +// робить ключ недійсним. Інакше перше ж розширення формату +// перетворило б усі видані ключі на «підпис не сходиться» — у клієнтів, +// які нічого не робили. +func TestLicenseKeepsUnknownFields(t *testing.T) { + pub, priv := testKeypair(t) + v := NewLicenseVerifier(map[string]ed25519.PublicKey{"k1": pub}) + + body := []byte(`{"v":1,"kid":"k1","plan":"pro","exp":` + + strconv.FormatInt(time.Now().AddDate(1, 0, 0).Unix(), 10) + + `,"майбутнє_поле":{"a":[1,2,3]}}`) + enc := base64.RawURLEncoding + key := licensePrefix + "." + + enc.EncodeToString(body) + "." + enc.EncodeToString(ed25519.Sign(priv, body)) + + got, err := v.ParseLicense(key) + if err != nil { + t.Fatalf("ключ із невідомим полем відхилено: %v", err) + } + if got.Plan != "pro" { + t.Errorf("plan = %q, очікувалось pro", got.Plan) + } +} + +// Збірка без відкритого ключа не вдає, що перевірила. +// +// Найгірший із можливих варіантів тут — мовчазне «ключ прийнято»: тоді +// будь-який рядок відкривав би будь-які стелі, і виявилось би це ніколи. +func TestLicenseWithoutVerifierRefuses(t *testing.T) { + v, err := ParseLicenseVerifier("") + if err != nil { + t.Fatalf("порожній набір ключів має бути робочим станом: %v", err) + } + if !v.Empty() { + t.Fatal("порожній рядок дав непорожній набір") + } + _, priv := testKeypair(t) + key, _ := SignLicense(priv, LicensePayload{KeyID: "k1", Plan: "enterprise"}) + if _, err := v.ParseLicense(key); err != ErrNoVerifier { + t.Fatalf("очікувалось ErrNoVerifier, отримано %v", err) + } +} + +// --------------------------------------------------------------------- +// Стан за часом +// --------------------------------------------------------------------- + +func TestEvaluateLicenseStates(t *testing.T) { + exp := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC) + p := &LicensePayload{ExpiresAt: exp.Unix(), GraceDays: 30} + + cases := []struct { + name string + now time.Time + want string + }{ + {"задовго до строку", exp.AddDate(0, -6, 0), LicenseActive}, + {"за секунду до строку", exp.Add(-time.Second), LicenseActive}, + {"за секунду після строку", exp.Add(time.Second), LicenseGrace}, + {"в середині пільгового", exp.AddDate(0, 0, 15), LicenseGrace}, + {"за секунду до кінця пільгового", exp.AddDate(0, 0, 30).Add(-time.Second), LicenseGrace}, + {"одразу після пільгового", exp.AddDate(0, 0, 30).Add(time.Second), LicenseExpired}, + {"через рік після", exp.AddDate(1, 0, 0), LicenseExpired}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := EvaluateLicense(p, c.now); got.State != c.want { + t.Fatalf("стан = %q, очікувалось %q", got.State, c.want) + } + }) + } + + // Ключ без пільгового періоду переходить у expired одразу. Окремо, + // бо нуль тут — це справді нуль, а не «не задано»: інакше + // GraceDays=0 мовчки давав би місяць понад строк. + zero := &LicensePayload{ExpiresAt: exp.Unix()} + if got := EvaluateLicense(zero, exp.Add(time.Second)); got.State != LicenseExpired { + t.Fatalf("без пільгового: стан = %q, очікувалось %q", got.State, LicenseExpired) + } +} + +func TestEvaluateLicenseNil(t *testing.T) { + if got := EvaluateLicense(nil, time.Now()); got.State != LicenseUnlicensed { + t.Fatalf("без ключа стан = %q, очікувалось %q", got.State, LicenseUnlicensed) + } +} + +// --------------------------------------------------------------------- +// Монотонний годинник +// --------------------------------------------------------------------- + +func TestClockRollbackDoesNotExtendLicense(t *testing.T) { + seen := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + + // Годинник перевели на рік назад — строк має рахуватись за + // побаченим, а не за тим, що показує машина. + back := seen.AddDate(-1, 0, 0) + eff, warped := effectiveNow(back, seen) + if !eff.Equal(seen) { + t.Fatalf("час рахується за переведеним годинником: %v", eff) + } + if !warped { + t.Fatal("зсув на рік назад не позначено") + } + + // Дрібний стрибок назад — норма: поправка NTP, віртуалка з паузи. + // Позначати його зсувом означало б блимати попередженням щотижня. + small := seen.Add(-10 * time.Minute) + eff, warped = effectiveNow(small, seen) + if !eff.Equal(seen) { + t.Fatalf("дрібний стрибок має все одно рахуватись за побаченим: %v", eff) + } + if warped { + t.Fatal("десять хвилин назад позначено як зсув — попередження блиматиме на рівному місці") + } + + // Час іде вперед — звичайне життя, нічого не позначаємо. + fwd := seen.Add(time.Hour) + eff, warped = effectiveNow(fwd, seen) + if !eff.Equal(fwd) || warped { + t.Fatalf("нормальний хід часу: eff=%v warped=%v", eff, warped) + } +} + +// Зсув годинника нічого не вимикає — він лише позначається. +// +// Перевірка про те, чого не має статись. Спокуса «побачили обман — +// заблокували» тут особливо сильна, і вона хибна: годинник з'їжджає й +// сам (сів CMOS, зник NTP після переїзду в ізольований сегмент), а +// покарати за несправність означає вимкнути моніторинг тому, хто нічого +// не порушував. +func TestClockWarpDoesNotChangeLimits(t *testing.T) { + plan := PlanLimits{MaxDevices: intp(100), RetentionDays: 90} + used := Usage{Devices: 40} + + // Стан лишається тим, який дала EvaluateLicense; годинник впливає + // лише на те, ЯКИЙ момент їй передали, а не на самі стелі. + got := EffectiveLimits(plan, LicenseActive, used) + if got.MaxDevices == nil || *got.MaxDevices != 100 { + t.Fatalf("стелю змінено без причини: %v", got.MaxDevices) + } + if got.RetentionDays != 90 { + t.Fatalf("строк зберігання змінено без причини: %d", got.RetentionDays) + } +} + +// --------------------------------------------------------------------- +// Стелі після прострочення — головна перевірка файлу +// --------------------------------------------------------------------- + +// Протермінована ліцензія не опускає стелю нижче зайнятого. +// +// Це і є те «не спрацювало як слід», яке найдорожче: якби стеля впала +// до тарифної, 485 із 500 хостів стали б такими, яких не можна повернути +// з архіву й не можна ввімкнути назад — тобто продукт відібрав би в +// клієнта те, що вже працює, за несплачений рахунок. +func TestExpiredLicenseFreezesCeilingAtUsage(t *testing.T) { + plan := PlanLimits{ + MaxDevices: intp(15), MaxMaps: intp(1), + MaxAgents: intp(1), MaxUsers: intp(3), + RetentionDays: 7, + } + used := Usage{Devices: 500, Maps: 12, Agents: 4, Users: 9} + + got := EffectiveLimits(plan, LicenseExpired, used) + + check := func(name string, got *int, want int) { + t.Helper() + if got == nil { + t.Fatalf("%s: стеля зникла зовсім", name) + } + if *got != want { + t.Fatalf("%s: стеля = %d, очікувалось %d", name, *got, want) + } + } + check("хости", got.MaxDevices, 500) + check("мапи", got.MaxMaps, 12) + check("зонди", got.MaxAgents, 4) + check("користувачі", got.MaxUsers, 9) + + // Строк зберігання прострочення НЕ чіпає. Скоротити його означало б + // знищити вже зібрану історію — тобто вчинити з даними клієнта те, + // чого не робить навіть свідоме зменшення тарифу. + if got.RetentionDays != 7 { + t.Fatalf("строк зберігання = %d, ліцензія не має його чіпати", got.RetentionDays) + } +} + +// А от РОСТИ протермінована ліцензія не дає: стеля дорівнює зайнятому, +// тобто наступний хост уже не влізе. Без цієї перевірки попередня +// доводила б лише те, що ми нічого не обмежуємо взагалі. +func TestExpiredLicenseStopsGrowth(t *testing.T) { + plan := PlanLimits{MaxDevices: intp(15)} + got := EffectiveLimits(plan, LicenseExpired, Usage{Devices: 500}) + if *got.MaxDevices != 500 { + t.Fatalf("стеля = %d, очікувалось рівно зайняте (500)", *got.MaxDevices) + } + // Стеля 500 при 500 зайнятих — це саме «нічого нового»: тригер у БД + // відмовляє на cnt >= lim. + if *got.MaxDevices > 500 { + t.Fatal("стеля вища за зайняте — прострочена ліцензія дозволяє рости") + } +} + +// Ліцензія без обмеження лишається без обмеження й після прострочення. +func TestExpiredUnlimitedStaysUnlimited(t *testing.T) { + got := EffectiveLimits(PlanLimits{}, LicenseExpired, Usage{Devices: 500}) + if got.MaxDevices != nil { + t.Fatalf("необмежена стеля стала %d", *got.MaxDevices) + } +} + +// До прострочення стеля дорівнює купленій, а не зайнятому. +// +// Симетрична перевірка до попередніх: якби «не нижче зайнятого» діяло +// завжди, клієнт, який якось завів 500 хостів на тарифі в 15, назавжди +// лишався б із 500 — тобто стеля не діяла б узагалі. +func TestActiveLicenseKeepsPlanCeiling(t *testing.T) { + plan := PlanLimits{MaxDevices: intp(15)} + for _, state := range []string{LicenseActive, LicenseGrace, LicenseUnlicensed} { + got := EffectiveLimits(plan, state, Usage{Devices: 500}) + if got.MaxDevices == nil || *got.MaxDevices != 15 { + t.Fatalf("стан %s: стеля = %v, очікувалось 15", state, got.MaxDevices) + } + } +} + +// --------------------------------------------------------------------- +// Стелі з самого ключа +// --------------------------------------------------------------------- + +// Порожні поля ключа не забирають того, що дає тариф. +// +// Ще одна перевірка «чого не має статись», і ціна помилки тут — робочий +// день клієнта: ключ, у якому не заповнили max_devices, мусить лишити +// тарифну стелю, а не заблокувати перший же хост нулем. +func TestLicenseEmptyFieldsFallBackToPlan(t *testing.T) { + base := PlanLimits{ + MaxDevices: intp(100), MaxMaps: intp(10), MaxMapNodes: intp(50), + MaxAgents: intp(5), MaxUsers: intp(25), RetentionDays: 90, + } + got := LimitsFromPayload(base, &LicensePayload{Plan: "pro"}) + + if *got.MaxDevices != 100 || *got.MaxMaps != 10 || *got.MaxMapNodes != 50 || + *got.MaxAgents != 5 || *got.MaxUsers != 25 || got.RetentionDays != 90 { + t.Fatalf("ключ без стель перекрив тариф: %+v", got) + } + + // А заповнене — перекриває: саме тут живе домовленість «вам 250, + // хоча в тарифі 100». + got = LimitsFromPayload(base, &LicensePayload{MaxDevices: intp(250)}) + if *got.MaxDevices != 250 { + t.Fatalf("стеля з ключа не застосувалась: %v", got.MaxDevices) + } + if *got.MaxUsers != 25 { + t.Fatalf("незадане поле ключа зачепило сусіднє: %v", got.MaxUsers) + } + + // Нуль у ключі — це справді нуль, а не «не задано». Різницю тримає + // вказівник, і саме її ця перевірка й стереже. + got = LimitsFromPayload(base, &LicensePayload{MaxAgents: intp(0)}) + if got.MaxAgents == nil || *got.MaxAgents != 0 { + t.Fatalf("нуль у ключі прочитано як «не задано»: %v", got.MaxAgents) + } +} + +func TestLicenseFeaturesFallBackToPlan(t *testing.T) { + base := []string{"icmp", "snmp", "ncm_git"} + + if got := FeaturesFromPayload(base, &LicensePayload{}); len(got) != 3 { + t.Fatalf("порожній перелік у ключі закрив фічі тарифу: %v", got) + } + got := FeaturesFromPayload(base, &LicensePayload{Features: []string{"snmp", "icmp"}}) + if len(got) != 2 || got[0] != "icmp" || got[1] != "snmp" { + t.Fatalf("перелік із ключа не застосувався або не впорядкований: %v", got) + } +} + +// --------------------------------------------------------------------- +// Набір ключів підпису +// --------------------------------------------------------------------- + +func TestParseLicenseVerifier(t *testing.T) { + pub, _ := testKeypair(t) + spec := "k1=" + hex.EncodeToString(pub) + + v, err := ParseLicenseVerifier(spec) + if err != nil { + t.Fatalf("розбір набору: %v", err) + } + if v.Empty() { + t.Fatal("набір порожній після розбору непорожнього рядка") + } + + bad := []string{ + "безЗнакаРівності", + "k1=не-hex-і-не-base64", + "k1=" + hex.EncodeToString([]byte{1, 2, 3}), // коротший за ed25519.PublicKeySize + } + for _, s := range bad { + if _, err := ParseLicenseVerifier(s); err == nil { + t.Errorf("прийнято некоректний набір %q", s) + } + } +} diff --git a/server/internal/store/billing_limits_db_test.go b/server/internal/store/billing_limits_db_test.go new file mode 100644 index 0000000..77eb272 --- /dev/null +++ b/server/internal/store/billing_limits_db_test.go @@ -0,0 +1,325 @@ +package store + +import ( + "context" + "fmt" + "os" + "testing" + "time" +) + +// Стеля тарифу проти СПРАВЖНЬОЇ бази. +// +// ЧОМУ ТУТ НЕ ОБІЙТИСЬ ЧИТАННЯМ SQL ОЧИМА +// +// Бо перевірка стелі — це тригер, і вся її суть у тому, на яких саме +// операціях він стоїть. 0009 повісила його лише на INSERT, і читанням +// коду це виглядало вичерпним: хост з'являється вставкою, стеля рахує +// хости, все сходиться. Не сходилось: хост з'являється ще й UPDATE-ом — +// поверненням з архіву й простим увімкненням. Стелю в 15 обходили за +// чотири дії, і жодна перевірка про це не знала. +// +// ГОЛОВНЕ ПРО ЦЕЙ ФАЙЛ +// +// Половина перевірок нижче — про те, що стеля НЕ спрацювала там, де не +// має. Це не симетрія заради симетрії: тест, який доводить лише +// «шістнадцятий не створився», лишається зеленим і на реалізації, яка +// не дає створити ЖОДНОГО хоста, перейменувати наявний або повернути +// вимкнений. Помилка в цей бік не проявляється помилкою — вона +// проявляється тим, що продукт перестає працювати, і скаржиться на неї +// клієнт, а не перевірка. +// +// Пропускається мовчки без NETPULSE_TEST_DSN. Запускати на ОДНОРАЗОВІЙ +// базі з накоченими міграціями: +// +// NETPULSE_TEST_DSN=postgres://postgres:x@localhost/np \ +// go test ./internal/store/ -run PlanLimit + +func TestPlanLimitDevices(t *testing.T) { + s, tenantID := billingTestTenant(t) + ctx := context.Background() + + setDeviceCeiling(t, s, tenantID, 2) + + mk := func(name string, enabled bool) (string, error) { + return s.CreateDevice(ctx, tenantID, DeviceInput{ + Name: name, Address: nextAddr(), Kind: "switch", Enabled: enabled, + }) + } + + // --- стеля не заважає працювати до неї ----------------------------- + first, err := mk("host-1", true) + if err != nil { + t.Fatalf("перший хост при стелі 2 не створився: %v", err) + } + if _, err := mk("host-2", true); err != nil { + t.Fatalf("другий хост при стелі 2 не створився: %v", err) + } + + // --- стеля спрацьовує --------------------------------------------- + _, err = mk("host-3", true) + lim, ok := AsPlanLimit(err) + if !ok { + t.Fatalf("третій хост при стелі 2 створився або відмова не впізнана: %v", err) + } + if lim.Kind != "devices" || lim.Allowed != 2 || lim.Used != 2 { + t.Fatalf("відмова без правильних чисел: %+v (%s)", lim, lim.Error()) + } + // Найважливіше в самій відмові — щоб її можна було показати людині. + // Порожній або англійський текст тут означає, що на екран поїде або + // нічого, або мова розробника. + if lim.Error() == "" || !containsCyrillic(lim.Error()) { + t.Fatalf("текст відмови не для людини: %q", lim.Error()) + } + + // --- ДІРА 0009 №1: повернення з архіву ----------------------------- + // + // Заархівований хост звільняє слот — це правильно й це перевіряється + // нижче. Але повернути його, коли слот уже зайняв інший, не можна: + // інакше стеля обходиться парою «заархівував — завів новий — + // повернув старий». + if err := s.DeleteDevice(ctx, tenantID, first); err != nil { + t.Fatalf("архівування: %v", err) + } + if _, err := mk("host-4", true); err != nil { + t.Fatalf("слот не звільнився після архівування: %v", err) + } + _, rerr := s.RestoreDevices(ctx, tenantID, Scope{Unrestricted: true}, []string{first}) + if _, ok := AsPlanLimit(rerr); !ok { + t.Fatalf("повернення з архіву обійшло стелю (діра 0009): %v", rerr) + } + + // --- ДІРА 0009 №2: увімкнення вимкненого --------------------------- + // + // Вимкнений хост слота не займає — його МОЖНА завести навіть під + // стелею (так заводять хост «про запас» перед переїздом). Але + // ввімкнути його, коли слотів немає, не можна. + off, err := mk("host-off", false) + if err != nil { + t.Fatalf("вимкнений хост під стелею не створився — "+ + "стеля спрацювала там, де не має: %v", err) + } + uerr := s.UpdateDevice(ctx, tenantID, off, DeviceInput{ + Name: "host-off", Address: nextAddr(), Kind: "switch", Enabled: true, + }) + if _, ok := AsPlanLimit(uerr); !ok { + t.Fatalf("увімкнення вимкненого обійшло стелю (діра 0009): %v", uerr) + } +} + +// Стеля не має заважати правити те, що вже є. +// +// Перевірка «чого не має статись», і без неї попередня доводила б +// лише, що ми навчились відмовляти. Тригер на UPDATE — саме та зміна, у +// якій легко заборонити перейменування хоста на переповненому кабінеті, +// і виявилось би це не тут, а в клієнта, який не може виправити +// друкарську помилку в назві. +func TestPlanLimitDoesNotBlockEditing(t *testing.T) { + s, tenantID := billingTestTenant(t) + ctx := context.Background() + + setDeviceCeiling(t, s, tenantID, 1) + + id, err := s.CreateDevice(ctx, tenantID, DeviceInput{ + Name: "єдиний", Address: nextAddr(), Kind: "switch", Enabled: true, + }) + if err != nil { + t.Fatalf("хост при стелі 1: %v", err) + } + + // Кабінет рівно на стелі. Кожна з правок нижче лишає кількість + // увімкнених хостів такою ж — отже, жодна не має впертись. + steps := []struct { + name string + in DeviceInput + }{ + {"перейменування", DeviceInput{Name: "єдиний-2", Address: "10.77.0.1", Kind: "switch", Enabled: true}}, + {"зміна адреси", DeviceInput{Name: "єдиний-2", Address: "10.77.0.2", Kind: "switch", Enabled: true}}, + {"зміна типу", DeviceInput{Name: "єдиний-2", Address: "10.77.0.2", Kind: "router", Enabled: true}}, + {"вимкнення", DeviceInput{Name: "єдиний-2", Address: "10.77.0.2", Kind: "router", Enabled: false}}, + {"повторне ввімкнення", DeviceInput{Name: "єдиний-2", Address: "10.77.0.2", Kind: "router", Enabled: true}}, + } + for _, st := range steps { + if err := s.UpdateDevice(ctx, tenantID, id, st.in); err != nil { + t.Fatalf("%s під стелею впало: %v", st.name, err) + } + } +} + +// Стеля, якої немає, не має вигадуватись. +// +// Це стан КОЖНОЇ інсталяції, яка накотила 0069: план self_hosted, усі +// стелі порожні. Якби тут щось спрацьовувало, оновлення відібрало б у +// клієнта можливість заводити хости — і саме проти цього написана +// перша половина 0069. +func TestNoCeilingNoRefusal(t *testing.T) { + s, tenantID := billingTestTenant(t) + ctx := context.Background() + + if _, err := s.pool.Exec(ctx, ` + UPDATE bill.entitlements SET max_devices = NULL, max_maps = NULL, + max_agents = NULL, max_users = NULL WHERE tenant_id = $1`, tenantID); err != nil { + t.Fatalf("зняття стель: %v", err) + } + + for i := 0; i < 25; i++ { + if _, err := s.CreateDevice(ctx, tenantID, DeviceInput{ + Name: fmt.Sprintf("вільний-%d", i), Address: nextAddr(), + Kind: "switch", Enabled: true, + }); err != nil { + t.Fatalf("хост %d при знятих стелях: %v", i, err) + } + } +} + +// Кабінет без рядка в bill.entitlements працює й показує стан. +// +// Так виглядає кабінет, створений ПІСЛЯ 0069 (netpulse-user його +// заводить, стель не заводить). Перевірка на два боки: стеля не +// вигадується, а сторінка тарифу все одно має що показати. +func TestEntitlementCreatedLazily(t *testing.T) { + s, tenantID := billingTestTenant(t) + ctx := context.Background() + + if _, err := s.pool.Exec(ctx, + `DELETE FROM bill.entitlements WHERE tenant_id = $1`, tenantID); err != nil { + t.Fatalf("прибирання стель: %v", err) + } + + if _, err := s.CreateDevice(ctx, tenantID, DeviceInput{ + Name: "без-стель", Address: nextAddr(), Kind: "switch", Enabled: true, + }); err != nil { + t.Fatalf("хост у кабінеті без стель: %v", err) + } + + ent, err := s.TenantEntitlement(ctx, tenantID) + if err != nil { + t.Fatalf("стелі не завелись ліниво: %v", err) + } + if ent.PlanKey != "self_hosted" { + t.Fatalf("тариф = %q, очікувався self_hosted", ent.PlanKey) + } + if ent.MaxDevices != nil { + t.Fatalf("у щойно заведених стелях узялась стеля хостів: %d", *ent.MaxDevices) + } +} + +// Використане, яке показує сторінка, і використане, на якому спрацьовує +// тригер, — одне й те саме число. +// +// Розходження тут не ламає нічого технічно й тому не помічається: обидва +// шляхи «працюють». Помічає його людина, у якої на екрані «14 з 15», а +// шістнадцятий хост не заводиться. +func TestUsageMatchesTrigger(t *testing.T) { + s, tenantID := billingTestTenant(t) + ctx := context.Background() + + setDeviceCeiling(t, s, tenantID, 3) + + var ids []string + for i := 0; i < 3; i++ { + id, err := s.CreateDevice(ctx, tenantID, DeviceInput{ + Name: fmt.Sprintf("рахунок-%d", i), Address: nextAddr(), + Kind: "switch", Enabled: i < 2, // третій вимкнений + }) + if err != nil { + t.Fatalf("хост %d: %v", i, err) + } + ids = append(ids, id) + } + + u, err := s.TenantUsage(ctx, tenantID) + if err != nil { + t.Fatalf("використання: %v", err) + } + // Вимкнений хост слота не займає — так каже bill.usage_now, і так + // само має рахувати тригер. + if u.Devices != 2 { + t.Fatalf("сторінка рахує %d хостів, очікувалось 2 (вимкнений не рахується)", u.Devices) + } + + // Доводимо, що тригер рахує так само: при стелі 3 і двох увімкнених + // третій увімкнений має пройти, а четвертий — ні. + if _, err := s.CreateDevice(ctx, tenantID, DeviceInput{ + Name: "рахунок-3", Address: nextAddr(), Kind: "switch", Enabled: true, + }); err != nil { + t.Fatalf("тригер порахував вимкнений хост як зайнятий слот: %v", err) + } + _, err = s.CreateDevice(ctx, tenantID, DeviceInput{ + Name: "рахунок-4", Address: nextAddr(), Kind: "switch", Enabled: true, + }) + if _, ok := AsPlanLimit(err); !ok { + t.Fatalf("стеля не спрацювала на четвертому увімкненому: %v", err) + } + _ = ids +} + +// --------------------------------------------------------------------- +// Оснастка +// --------------------------------------------------------------------- + +func billingTestTenant(t *testing.T) (*Store, string) { + t.Helper() + dsn := os.Getenv("NETPULSE_TEST_DSN") + if dsn == "" { + t.Skip("NETPULSE_TEST_DSN не задано — перевірка проти бази пропускається") + } + ctx := context.Background() + + s, err := New(ctx, dsn) + if err != nil { + t.Fatalf("підключення: %v", err) + } + t.Cleanup(s.Close) + + var tenantID string + slug := fmt.Sprintf("bill-test-%d", time.Now().UnixNano()) + if err := s.pool.QueryRow(ctx, ` + INSERT INTO core.tenants (slug, name) VALUES ($1, 'Тест білінгу') RETURNING id::text + `, slug).Scan(&tenantID); err != nil { + t.Fatalf("кабінет: %v", err) + } + t.Cleanup(func() { + _, _ = s.pool.Exec(context.Background(), + `DELETE FROM core.tenants WHERE id = $1`, tenantID) + }) + + if _, err := s.pool.Exec(ctx, ` + INSERT INTO bill.entitlements + (tenant_id, plan_key, metric_retention_days, features, source) + VALUES ($1, 'self_hosted', 400, '{}', 'license_key') + ON CONFLICT (tenant_id) DO NOTHING`, tenantID); err != nil { + t.Fatalf("стелі: %v", err) + } + return s, tenantID +} + +func setDeviceCeiling(t *testing.T, s *Store, tenantID string, n int) { + t.Helper() + if _, err := s.pool.Exec(context.Background(), + `UPDATE bill.entitlements SET plan_key = 'free', max_devices = $2 + WHERE tenant_id = $1`, tenantID, n); err != nil { + t.Fatalf("стеля хостів: %v", err) + } +} + +// nextAddr дає неповторну адресу. +// +// Адреси мусять різнитись не через стелю, а через unique-індекс на +// (tenant_id, address): падіння на ньому виглядало б як падіння стелі, +// і півгодини пішло б не туди. +var addrN = 0 + +func nextAddr() string { + addrN++ + return fmt.Sprintf("10.66.%d.%d", addrN/250, addrN%250+1) +} + +func containsCyrillic(s string) bool { + for _, r := range s { + if r >= 'А' && r <= 'я' { + return true + } + } + return false +} diff --git a/server/internal/store/billing_limits_test.go b/server/internal/store/billing_limits_test.go new file mode 100644 index 0000000..919e8b8 --- /dev/null +++ b/server/internal/store/billing_limits_test.go @@ -0,0 +1,146 @@ +package store + +import ( + "errors" + "fmt" + "testing" + + "github.com/jackc/pgx/v5/pgconn" +) + +// Розпізнавання відмови через стелю — без бази. +// +// ЧОМУ ЦЕ ОКРЕМА ПЕРЕВІРКА, А НЕ ЧАСТИНА ТЕСТУ ПРОТИ БАЗИ +// +// Тому що зламана тут ланка була саме такою: перевірка в БД працювала +// бездоганно, а Go не впізнавав її відмову. httpapi/groups.go шукав у +// тексті помилки слово «ліміт», а тригер 0009 писав англійською — і +// збігу не було ніколи. Тобто в мить, коли стеля вперше спрацювала б, +// людина отримала б 500 «внутрішня помилка» замість пояснення. +// +// Тест проти бази цього НЕ ловить: там відмова справді сталася, рядок +// не створився, перевірка зелена. Ловить лише окрема перевірка того, що +// саме код зробив із цією відмовою далі. +// +// Тому нижче — не «стеля спрацювала», а «відмову впізнано, і з неї +// дістали числа, які можна показати людині». + +// planLimitPgError збирає відмову рівно такою, якою її підіймає +// bill.deny_limit із 0069. +func planLimitPgError(msg, detail string) *pgconn.PgError { + return &pgconn.PgError{ + Severity: "ERROR", + Code: "23514", // check_violation + Message: msg, + Detail: detail, + Hint: "upgrade_plan", + } +} + +func TestAsPlanLimitReadsNumbers(t *testing.T) { + raw := planLimitPgError( + "у тарифі Free дозволено 15 хостів, зараз 15", + `{"limit":"devices","plan":"free","allowed":15,"used":15}`) + + // Обгортка навмисно подвійна: у житті помилка проходить через + // fmt.Errorf у CreateDevice і ще раз через шар транзакції. + err := fmt.Errorf("створення хоста: %w", fmt.Errorf("tx: %w", raw)) + + lim, ok := AsPlanLimit(err) + if !ok { + t.Fatal("відмову через стелю не впізнано в обгортках") + } + if lim.Kind != "devices" || lim.Plan != "free" || lim.Allowed != 15 || lim.Used != 15 { + t.Fatalf("числа не дістались: %+v", lim) + } + if lim.Error() != "у тарифі Free дозволено 15 хостів, зараз 15" { + t.Fatalf("текст відмови загублено: %q", lim.Error()) + } + + // Типізована помилка відповідає наявному вартовому — на це + // спирається шлях правки мапи, де відмову перекладає mapPgError. + if !errors.Is(lim, ErrPlanLimit) { + t.Fatal("errors.Is(PlanLimitError, ErrPlanLimit) не спрацював") + } + + // А СИРА помилка бази вартовому не відповідає, і це не недогляд, а + // факт, який мусить бути записаний перевіркою: у її ланцюжку лежить + // *pgconn.PgError, і жоден errors.Is його з ErrPlanLimit не зведе. + // Саме тому спільний обробник помилок питає AsPlanLimit окремим + // рядком. Якби хтось «спростив» його назад до одного errors.Is, + // відмова через стелю знову перетворилась би на 500 — на всіх + // шляхах, крім мап, і мовчки. + if errors.Is(err, ErrPlanLimit) { + t.Fatal("сира помилка бази раптом відповідає ErrPlanLimit — " + + "перевірте, чи не зайвий тепер AsPlanLimit у writeStoreError") + } +} + +// Регресія на ту саму поломку, з якої почалась ця робота. +// +// Стара перевірка шукала слово «ліміт» у тексті. Відмова англійською — +// саме те, що підіймала 0009, і саме те, що вона пропускала. Тепер +// впізнавання йде за HINT, тобто текст може бути будь-яким, включно з +// таким, який ще не переклали. +func TestAsPlanLimitDoesNotDependOnWording(t *testing.T) { + raw := planLimitPgError("device limit reached for tenant abc (limit 15)", "") + lim, ok := AsPlanLimit(fmt.Errorf("створення хоста: %w", raw)) + if !ok { + t.Fatal("англійська відмова 0009 не впізнана — та сама поломка, що й була") + } + // Чисел немає — стара міграція їх не передавала. Відмова від цього + // не перестає бути відмовою: 402 має поїхати в будь-якому разі. + if lim.Allowed != 0 || lim.Used != 0 { + t.Fatalf("узялись числа, яких у відмові не було: %+v", lim) + } + if lim.Error() == "" { + t.Fatal("текст відмови порожній — людині нічого показати") + } +} + +// Перевірка протилежного боку: сторонні помилки бази не мають +// перетворюватись на «вичерпано тариф». +// +// Це те, що ламається тихо: обробник, який на будь-яку помилку віддає +// 402 «змініть тариф», сховає справжню поломку за пропозицією заплатити, +// і розбиратись у ній ніхто не піде. +func TestAsPlanLimitIgnoresOtherErrors(t *testing.T) { + cases := []struct { + name string + err error + }{ + {"звичайна помилка", errors.New("щось пішло не так")}, + {"nil", nil}, + {"порушення унікальності", fmt.Errorf("x: %w", &pgconn.PgError{ + Code: "23505", Message: "duplicate key value violates unique constraint", + })}, + {"check без підказки", fmt.Errorf("x: %w", &pgconn.PgError{ + Code: "23514", Message: "new row violates check constraint", + })}, + {"чужа підказка", fmt.Errorf("x: %w", &pgconn.PgError{ + Code: "23514", Message: "щось", Hint: "GRANT SELECT ...", + })}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if _, ok := AsPlanLimit(c.err); ok { + t.Fatal("сторонню помилку прийнято за вичерпаний тариф") + } + }) + } +} + +// Зіпсована подробиця не має губити саму відмову. +// +// Інсталяція зі свіжим Go й старою схемою (0069 ще не накотилась) — +// звичайний стан під час оновлення, і в ньому DETAIL порожній або +// чужого формату. Втратити через це 402 означало б показати «внутрішню +// помилку» саме тоді, коли причина відома точно. +func TestAsPlanLimitSurvivesBrokenDetail(t *testing.T) { + for _, detail := range []string{"", "не json", `{"allowed":"багато"}`, `[1,2,3]`} { + err := fmt.Errorf("x: %w", planLimitPgError("стеля", detail)) + if _, ok := AsPlanLimit(err); !ok { + t.Fatalf("відмову втрачено через подробицю %q", detail) + } + } +} diff --git a/server/internal/store/ncm_compliance_builtin_test.go b/server/internal/store/ncm_compliance_builtin_test.go new file mode 100644 index 0000000..93e40e3 --- /dev/null +++ b/server/internal/store/ncm_compliance_builtin_test.go @@ -0,0 +1,729 @@ +package store + +import ( + "regexp" + "sort" + "strings" + "testing" + + schema "github.com/netpulse/netpulse/server/migrations" +) + +// Перевірка вбудованих правил відповідності з міграції 0071. +// +// НАВІЩО ЦЕЙ ФАЙЛ ІСНУЄ. Правило «не має бути», чий зразок не збігається +// НІКОЛИ, повідомляє «порушень немає» — тобто виглядає ідеально й +// нічого не перевіряє. Відрізнити його від справного правила за +// результатом прогону неможливо в принципі: обидва дають нуль +// порушень. Єдиний спосіб — показати конфіг, на якому зразок мусить +// спрацювати, і конфіг, на якому не мусить. +// +// ЗВІДКИ БЕРУТЬСЯ ЗРАЗКИ. З самої міграції, через schema.Files. Копії +// виразів у тесті немає навмисно: два описи одного й того самого +// розійдуться на першій же правці, і тест почав би підтверджувати +// власну копію замість того, що поїде клієнтові. Та сама причина, з +// якої db/profiles/build.py породжує SQL, а не дублює його. +// +// ЩО ТЕСТ ГАРАНТУЄ, а що ні. Гарантує: кожен зразок хоч на одному +// справжньому конфізі спрацював і хоч на одному не спрацював, і для +// КОЖНОГО вендора з переліку правила є конфіг цього вендора з +// очікуваним висновком. Не гарантує: що зразок покриває всі прошивки +// цієї родини — конфіги збирались із документації й типових +// вивантажень, а не з усього парку. + +// --------------------------------------------------------------------- +// Читання правил із міграції +// --------------------------------------------------------------------- + +const builtinMigration = "0071_builtin_compliance_prototypes.sql" + +// Рядок довідника має жорстку форму саме заради цього розбору: +// +// ('ключ', 'серйозність', 'вид', 'тип конфігу', NULL | string_to_array('Вендор,Вендор', ','), +// $rx$зразок$rx$, +// +// Доларові лапки навколо зразка — не оздоба: у виразі живуть зворотні +// скісні риски й апострофи, і саме вони роблять межі зразка +// однозначними для цього виразу. +var builtinRuleRe = regexp.MustCompile( + `(?s)\('([a-z0-9-]+)',\s*'([a-z]+)',\s*'([a-z_]+)',\s*'([a-z]+)',\s*` + + `(?:NULL|string_to_array\('([^']*)', ','\)),\s*\$rx\$(.*?)\$rx\$`) + +type builtinRule struct { + Key string + Severity string + Kind string + ConfigType string + Vendors []string + Pattern string +} + +func loadBuiltinRules(t *testing.T) map[string]builtinRule { + t.Helper() + + body, err := schema.Files.ReadFile(builtinMigration) + if err != nil { + t.Fatalf("міграція %s не читається: %v", builtinMigration, err) + } + + out := map[string]builtinRule{} + for _, m := range builtinRuleRe.FindAllStringSubmatch(string(body), -1) { + r := builtinRule{ + Key: m[1], Severity: m[2], Kind: m[3], ConfigType: m[4], Pattern: m[6], + } + if m[5] != "" { + r.Vendors = strings.Split(m[5], ",") + } + if _, dup := out[r.Key]; dup { + t.Fatalf("ключ %q у довіднику двічі", r.Key) + } + out[r.Key] = r + } + if len(out) == 0 { + t.Fatalf("з %s не вийнято жодного правила — розбір розійшовся з форматом файлу", + builtinMigration) + } + return out +} + +// --------------------------------------------------------------------- +// Зразки конфігів +// --------------------------------------------------------------------- + +// Конфіг і вендор, під яким цей хост стоїть в inv.devices. +// +// Вендор тут не прикраса: селектор вбудованого правила — це +// {"vendors":[...]}, який selectorSQL перетворює на `d.vendor = ANY(...)`, +// тобто рівно на порівняння з цим рядком. Пари «правило + вендор», для +// якої немає конфігу, тест не пропускає — інакше вендора можна було б +// дописати в правило, не перевіривши на ньому нічого. +type cfgSample struct { + Vendor string + Body string +} + +var samples = map[string]cfgSample{ + + // --- Cisco IOS --------------------------------------------------- + + "cisco-bad": {"Cisco", `Building configuration... + +Current configuration : 4021 bytes +! +version 15.2 +service timestamps debug datetime msec +no service password-encryption +! +hostname kyiv-acc-sw01 +! +enable password cisco123 +! +username admin privilege 15 password 0 admin123 +! +interface Vlan1 + ip address 10.10.0.11 255.255.255.0 +! +snmp-server community public RO +snmp-server community private RW +! +line con 0 + password cisco +line vty 0 4 + transport input telnet ssh + login local +line vty 5 15 + transport input all +! +end +`}, + + "cisco-good": {"Cisco", `Building configuration... +! +version 15.2 +service password-encryption +! +hostname kyiv-core-sw01 +! +enable secret 5 $1$mERr$X1s0Kk9yq2mA3cCf1uJvS/ +! +username noc privilege 15 secret 5 $1$hM3z$8QeJm0Wl2sN6tR4vB7xYc. +! +aaa new-model +! +ip access-list standard MGMT-ACL + permit 10.20.30.0 0.0.0.255 +! +snmp-server community NP-ro-2024 RO MGMT-ACL +! +logging host 10.20.30.15 +logging trap informational +! +ntp server 10.20.30.10 prefer +ntp server 10.20.30.11 +! +line con 0 + exec-timeout 5 0 +line vty 0 15 + access-class MGMT-ACL in + transport input ssh + login local +! +end +`}, + + // --- Eltex MES --------------------------------------------------- + // + // Свідомо змішаний: telnet відкритий, решта в порядку. Саме такий + // конфіг і ловить помилку «зразок написаний під Cisco, а вендор у + // переліку є» — на бездоганному конфізі її не видно. + + "eltex-mixed": {"Eltex", `! +version 4.0.14 +! +hostname odesa-acc-sw03 +! +username admin password encrypted 5f4dcc3b5aa765d61d8327deb882cf99 privilege 15 +! +ip telnet server +! +snmp-server community NP-ro-2024 ro +! +logging host 10.20.30.15 +! +sntp unicast client enable +sntp server 10.20.30.10 +! +line telnet + exec-timeout 10 +line ssh + exec-timeout 10 +! +management access-list MGMT + permit ip-source 10.20.30.0 mask 255.255.255.0 +management access-class MGMT +! +end +`}, + + // --- ZTE ZXR10 --------------------------------------------------- + + "zte-mixed": {"ZTE", `! +! +! +hostname kharkiv-agg-zte01 +! +username zteadmin password 0 zte123 privilege 15 +! +snmp-server community public view AllView ro +snmp-server enable +! +ntp server 10.20.30.10 +! +logging server 10.20.30.15 +! +line telnet idle-timeout 10 +! +line vty 0 4 + transport input telnet ssh +! +end +`}, + + // --- Huawei VRP -------------------------------------------------- + + "huawei-bad": {"Huawei", `!Last configuration was updated at 2026-03-11 09:12:44+02:00 +# +sysname lviv-agg-sw02 +# +telnet server enable +# +snmp-agent +snmp-agent community read public +snmp-agent community write private +snmp-agent sys-info version v2c +# +aaa + local-user admin password simple Huawei@123 + local-user admin service-type telnet ssh +# +user-interface vty 0 4 + authentication-mode password + set authentication password simple Admin@123 + protocol inbound all +# +return +`}, + + "huawei-good": {"Huawei", `# +sysname lviv-core-sw01 +# +undo telnet server enable +undo telnet ipv6 server enable +# +snmp-agent +snmp-agent community read cipher %^%#NP2024ro%^%# +snmp-agent sys-info version v3 +# +info-center enable +info-center loghost 10.20.30.15 +# +ntp-service unicast-server 10.20.30.10 +ntp-service unicast-server 10.20.30.11 +# +aaa + local-user noc password irreversible-cipher $1c$KJHGfdsa + local-user noc service-type ssh +# +user-interface vty 0 4 + acl 2000 inbound + authentication-mode aaa + protocol inbound ssh +# +return +`}, + + // --- H3C Comware ------------------------------------------------- + + "h3c-bad": {"H3C", `# + version 7.1.070, Release 6127P02 +# + sysname dnipro-acc-h3c01 +# + telnet server enable +# + snmp-agent + snmp-agent community read public +# + local-user admin class manage + password simple H3C@1234 + service-type telnet ssh +# + line vty 0 15 + authentication-mode scheme + protocol inbound all +# +return +`}, + + "h3c-good": {"H3C", `# + sysname dnipro-core-h3c01 +# + undo telnet server enable +# + info-center loghost 10.20.30.15 +# + ntp-service unicast-server 10.20.30.10 +# + snmp-agent community read cipher $c$3$Nq8kL2Rt +# + local-user noc class manage + password hash $h$6$OqRsTuVw + service-type ssh +# + line vty 0 15 + acl 2000 inbound + protocol inbound ssh +# +return +`}, + + // --- D-Link ------------------------------------------------------ + + "dlink-bad": {"D-Link", `#------------------------------------------------------------------- +# DGS-1210-28 Gigabit Ethernet Switch +# Configuration +#------------------------------------------------------------------- +# BASIC +config serial_port baud_rate 115200 auto_logout 10_minutes +enable telnet 23 +enable web 80 +# ACCOUNT +create account admin admin +# SNMP +enable snmp +create snmp community public view CommunityView read_only +create snmp community private view CommunityView read_write +# TIME AND SNTP +disable sntp +# SYSLOG +disable syslog +`}, + + "dlink-good": {"D-Link", `#------------------------------------------------------------------- +# DGS-3420-28SC Gigabit Ethernet Switch +# Configuration +#------------------------------------------------------------------- +# BASIC +disable telnet +enable ssh +config ssh authmode password enable +# SNMP +enable snmp +create snmp community NP-ro-2024 view CommunityView read_only +# TIME AND SNTP +config sntp primary 10.20.30.10 secondary 10.20.30.11 poll-interval 720 +enable sntp +# SYSLOG +create syslog host 1 ipaddress 10.20.30.15 severity informational facility local0 udp_port 514 state enable +enable syslog +# ACCESS +create trusted_host network 10.20.30.0/24 +`}, + + // --- MikroTik RouterOS ------------------------------------------- + + "mikrotik-bad": {"MikroTik", `# mar/11/2026 09:12:44 by RouterOS 7.14.3 +# software id = ABCD-1234 +# +/interface bridge +add name=bridge1 +/ip service +set telnet disabled=no port=23 +set ftp disabled=no +set www disabled=no +set ssh disabled=no +set winbox disabled=no +/snmp community +set [ find default=yes ] name=public addresses=0.0.0.0/0 +/snmp +set enabled=yes +/system clock +set time-zone-name=Europe/Kyiv +/system logging +add topics=info +/user +add name=noc password=Str0ngPass group=full +`}, + + "mikrotik-good": {"MikroTik", `# mar/11/2026 09:12:44 by RouterOS 7.14.3 +# +/interface bridge +add name=bridge1 +/ip service +set telnet disabled=yes +set ftp disabled=yes +set www disabled=yes +set ssh address=10.20.30.0/24 disabled=no +set winbox address=10.20.30.0/24 disabled=no +/snmp community +set [ find default=yes ] name=NP-ro-2024 addresses=10.20.30.0/24 +/snmp +set enabled=yes trap-version=2 +/system ntp client +set enabled=yes servers=10.20.30.10,10.20.30.11 +/system logging action +add name=remote target=remote remote=10.20.30.15 +/system logging +add action=remote topics=info,error,warning +/system clock +set time-zone-name=Europe/Kyiv +`}, +} + +// --------------------------------------------------------------------- +// Очікування +// --------------------------------------------------------------------- + +// true — правило пройдено (порушення немає), false — знайдено порушення. +// +// Заповнено для КОЖНОЇ пари «правило + зразок вендора з його переліку». +// Пропуск — це не «нам байдуже», а помилка тесту: саме через пропущену +// пару вендор потрапляє в правило неперевіреним. +var expect = map[string]map[string]bool{ + + // Єдине правило без прив'язки до вендора — тому перелічені всі + // зразки набору. + "snmp-default-community": { + "cisco-bad": false, "cisco-good": true, + "eltex-mixed": true, "zte-mixed": false, + "huawei-bad": false, "huawei-good": true, + "h3c-bad": false, "h3c-good": true, + "dlink-bad": false, "dlink-good": true, + "mikrotik-bad": false, "mikrotik-good": true, + }, + + "no-telnet-ios": { + // transport input telnet / transport input all + "cisco-bad": false, "cisco-good": true, + // ip telnet server — форма Eltex, якої в синтаксисі Cisco немає + "eltex-mixed": false, + "zte-mixed": false, + }, + "no-telnet-vrp": { + "huawei-bad": false, "huawei-good": true, + "h3c-bad": false, "h3c-good": true, + }, + "no-telnet-dlink": { + "dlink-bad": false, "dlink-good": true, + }, + "no-telnet-routeros": { + "mikrotik-bad": false, "mikrotik-good": true, + }, + + "plaintext-password-ios": { + // no service password-encryption + enable password + password 0 + "cisco-bad": false, "cisco-good": true, + // password encrypted — незворотна схема, не порушення + "eltex-mixed": true, + // password 0 <відкрито> + "zte-mixed": false, + }, + "plaintext-password-vrp": { + "huawei-bad": false, "huawei-good": true, + "h3c-bad": false, "h3c-good": true, + }, + "plaintext-password-routeros": { + "mikrotik-bad": false, "mikrotik-good": true, + }, + + "syslog-target-ios": { + "cisco-bad": false, "cisco-good": true, + "eltex-mixed": true, + // logging server <адреса> — форма ZTE + "zte-mixed": true, + }, + "syslog-target-vrp": { + "huawei-bad": false, "huawei-good": true, + "h3c-bad": false, "h3c-good": true, + }, + "syslog-target-dlink": { + "dlink-bad": false, "dlink-good": true, + }, + "syslog-target-routeros": { + // /system logging без action — це журнал у пам'ять + "mikrotik-bad": false, "mikrotik-good": true, + }, + + "ntp-source-ios": { + "cisco-bad": false, "cisco-good": true, + // sntp server — форма Eltex + "eltex-mixed": true, + "zte-mixed": true, + }, + "ntp-source-vrp": { + "huawei-bad": false, "huawei-good": true, + "h3c-bad": false, "h3c-good": true, + }, + "ntp-source-dlink": { + // disable sntp + "dlink-bad": false, "dlink-good": true, + }, + "ntp-source-routeros": { + "mikrotik-bad": false, "mikrotik-good": true, + }, + + "mgmt-acl-ios": { + "cisco-bad": false, "cisco-good": true, + // management access-class — форма Eltex + "eltex-mixed": true, + // на ZTE список доступу до vty не заведений + "zte-mixed": false, + }, + "mgmt-acl-vrp": { + "huawei-bad": false, "huawei-good": true, + "h3c-bad": false, "h3c-good": true, + }, + "mgmt-acl-dlink": { + "dlink-bad": false, "dlink-good": true, + }, + "mgmt-acl-routeros": { + "mikrotik-bad": false, "mikrotik-good": true, + }, +} + +// --------------------------------------------------------------------- +// Самі перевірки +// --------------------------------------------------------------------- + +// Склад набору звіряється поіменно: правило, яке з'явилось у міграції й +// не має очікувань, інакше проїхало б повз тест непоміченим — тобто +// найдорожчим способом. +func TestBuiltinRulesAllCovered(t *testing.T) { + rules := loadBuiltinRules(t) + + var missing []string + for key := range rules { + if _, ok := expect[key]; !ok { + missing = append(missing, key) + } + } + sort.Strings(missing) + if len(missing) > 0 { + t.Errorf("правила без очікувань у тесті: %s", strings.Join(missing, ", ")) + } + + var stale []string + for key := range expect { + if _, ok := rules[key]; !ok { + stale = append(stale, key) + } + } + sort.Strings(stale) + if len(stale) > 0 { + t.Errorf("очікування для правил, яких у міграції немає: %s", strings.Join(stale, ", ")) + } +} + +// Кожне правило звіряється зі зразками конфігів усіх своїх вендорів. +func TestBuiltinRulesAgainstRealConfigs(t *testing.T) { + rules := loadBuiltinRules(t) + + for key, br := range rules { + want, ok := expect[key] + if !ok { + continue // про це вже сказав TestBuiltinRulesAllCovered + } + + re, err := regexp.Compile(br.Pattern) + if err != nil { + t.Errorf("%s: зразок не компілюється: %v", key, err) + continue + } + rule := ComplianceRule{Kind: br.Kind, Pattern: br.Pattern} + + for name, s := range samples { + // Правило застосовне до цього конфігу, якщо вендорів у + // нього немає взагалі або цей вендор у переліку. + if !applies(br.Vendors, s.Vendor) { + continue + } + exp, has := want[name] + if !has { + t.Errorf("%s: для вендора %s (зразок %s) немає очікування — "+ + "вендор у правилі є, а перевіреного конфігу немає", + key, s.Vendor, name) + continue + } + + got, line, num := checkCompliance(rule, re, s.Body) + if got != exp { + t.Errorf("%s на %s: очікували %s, отримали %s (рядок %d: %q)", + key, name, verdict(exp), verdict(got), num, line) + } + } + } +} + +// Найважливіша перевірка файлу. +// +// Зразок, який не збігається НІКОЛИ, дає «порушень немає» на всьому +// парку — тобто ідеальний вигляд і нуль користі. Зразок, який +// збігається ЗАВЖДИ, дає суцільне червоне, після якого на розділ +// перестають дивитись. Обидва стани перевіряються однаково: у наборі +// зразків має бути хоч один конфіг, де правило спрацювало, і хоч один, +// де ні. +func TestBuiltinRulesAreNotAlwaysGreen(t *testing.T) { + rules := loadBuiltinRules(t) + + for key, br := range rules { + re, err := regexp.Compile(br.Pattern) + if err != nil { + continue // про це вже сказав попередній тест + } + rule := ComplianceRule{Kind: br.Kind, Pattern: br.Pattern} + + var passed, failed int + for _, s := range samples { + if !applies(br.Vendors, s.Vendor) { + continue + } + if ok, _, _ := checkCompliance(rule, re, s.Body); ok { + passed++ + } else { + failed++ + } + } + if failed == 0 { + t.Errorf("%s: зразок не спрацював ЖОДНОГО разу на %d конфігах своїх вендорів — "+ + "таке правило показує «порушень немає» завжди", key, passed) + } + if passed == 0 { + t.Errorf("%s: зразок спрацював на ВСІХ %d конфігах своїх вендорів — "+ + "правило, яке ніколи не буває зеленим, перестають читати", key, failed) + } + } +} + +// Кожен вендор, названий у правилах, мусить мати конфіг у наборі. +// +// Без цієї перевірки вендора можна дописати в перелік правила, не +// показавши жодного його конфігу, — і правило мовчки поширилось би на +// синтаксис, якого ніхто не бачив. +func TestBuiltinRuleVendorsHaveSamples(t *testing.T) { + rules := loadBuiltinRules(t) + + have := map[string]bool{} + for _, s := range samples { + have[s.Vendor] = true + } + + seen := map[string]bool{} + for key, br := range rules { + for _, v := range br.Vendors { + if !have[v] && !seen[key+v] { + seen[key+v] = true + t.Errorf("%s: вендор %q названий у правилі, а конфігу цього вендора в наборі немає", + key, v) + } + } + } +} + +// Тип конфігу заданий явно в кожному правилі (0056), і це саме +// 'running': жодне з правил набору не описує конфіг-файли сервера. +func TestBuiltinRulesDeclareConfigType(t *testing.T) { + for key, br := range loadBuiltinRules(t) { + if br.ConfigType != "running" { + t.Errorf("%s: тип конфігу %q — набір писався під конфіги заліза", key, br.ConfigType) + } + if !complianceKinds[br.Kind] { + t.Errorf("%s: вид правила %q рушій не знає", key, br.Kind) + } + } +} + +// Прототипи з цієї ж міграції мають бути вимкнені. +// +// Правило проєкту: оновлення не вмикає нічого, що заводить чеки. +// Ввімкнений прототип у вбудованому шаблоні заводить snmp.walk і +// snmp.get на кожному хості, якому шаблон причеплений, — мовчки, під +// час накату. Перевіряти це очима на рев'ю вже двічі не вийшло. +func TestBuiltinPrototypesShipDisabled(t *testing.T) { + body, err := schema.Files.ReadFile(builtinMigration) + if err != nil { + t.Fatalf("міграція %s не читається: %v", builtinMigration, err) + } + text := string(body) + + // Хвіст рядка INSERT у tpl.item_prototypes: discovery_sec, + // interval_sec, max_rows, enabled. + tail := regexp.MustCompile(`(?m)^\s*\d+,\s*\d+,\s*\d+,\s*(true|false)\)`) + hits := tail.FindAllStringSubmatch(text, -1) + if len(hits) != 2 { + t.Fatalf("очікували 2 прототипи в міграції, знайшли %d", len(hits)) + } + for _, h := range hits { + if h[1] != "false" { + t.Errorf("прототип заведено ввімкненим: %q", strings.TrimSpace(h[0])) + } + } +} + +func applies(vendors []string, vendor string) bool { + if len(vendors) == 0 { + return true + } + for _, v := range vendors { + if v == vendor { + return true + } + } + return false +} + +func verdict(passed bool) string { + if passed { + return "«порушень немає»" + } + return "«порушення»" +} diff --git a/server/internal/store/roles.go b/server/internal/store/roles.go index 06cba12..119c491 100644 --- a/server/internal/store/roles.go +++ b/server/internal/store/roles.go @@ -144,8 +144,8 @@ var dormantPerms = map[string]bool{ "devices:control": true, // ручний запуск перевірок іде під devices:write "maps:publish": true, // публічні посилання є лише в дашбордів "ncm:rollback": true, // відкат на пристрій ще не реалізовано - "billing:read": true, // сторінки тарифу ще немає - "billing:manage": true, + // billing:read і billing:manage звідси прибрано: сторінка тарифу + // з'явилась, і обидва права тепер питає httpapi/billing.go. } // PermissionCatalog — усі права, згруповані, з описом із бази. diff --git a/server/internal/store/sla.go b/server/internal/store/sla.go new file mode 100644 index 0000000..9968715 --- /dev/null +++ b/server/internal/store/sla.go @@ -0,0 +1,487 @@ +package store + +import ( + "fmt" + "math" + "sort" + "time" +) + +// Розрахунок доступності за період: арифметика без бази. +// +// ЧОМУ ЦЕ ОКРЕМИЙ ФАЙЛ БЕЗ ЖОДНОГО ЗАПИТУ +// +// Усе, що робить звіт SLA спірним, — це не SQL, а домовленості: що таке +// «недоступний», куди подіти вікно обслуговування, як рахувати хост, +// заведений посеред кварталу. Перевіряти домовленості на живій базі +// означає перевіряти їх утричі дорожче й на порядок рідше — тобто не +// перевіряти. Тому весь спір винесено в чисті функції, а база лишається +// тим, чим має бути: постачальником рядків. +// +// ЩО ТАКЕ «НЕДОСТУПНИЙ» — ЧОТИРИ РІШЕННЯ, ЯКІ ТУТ ПРИЙНЯТО +// +// 1. Час, про який немає ЖОДНОГО виміру, не є ані доступністю, ані +// недоступністю. Він рахується окремо (UnknownSec) і не додається +// нікуди. Це головне рішення файлу. +// +// Обидві альтернативи гірші, і кожна по-своєму. Порахувати мовчання +// як «працювало» означає, що місяць із мертвим зондом дає 100% — а +// саме такий звіт і показують, не перевіряючи, бо він гарний. +// Порахувати мовчання як «лежало» означає, що годинна перерва в +// роботі САМОГО МОНІТОРИНГУ стає аварією в мережі клієнта, за яку +// хтось платить. +// +// Тому третій варіант: сказати вголос, скільки саме ми знаємо. +// CoveragePct — це частка періоду, підперта виміром, і поки вона +// нижча за поріг цілі, вердикт не виноситься взагалі: період +// позначається як «недостатньо даних». Зелений висновок із діри в +// даних тут неможливий за побудовою. +// +// 2. Вікно обслуговування з exclude_from_sla зупиняє годинник. Не +// «зараховується як доступність» — саме зупиняє: ці секунди виходять +// і з чисельника, і зі знаменника. Різниця видна на межі: двогодинні +// роботи, зараховані як uptime, ПОКРАЩИЛИ б місяць, у якому була +// аварія. Робота, про яку домовились заздалегідь, не має права +// покращувати звіт — лише не псувати його. +// +// 3. Зонд offline — це випадок 1, і він вирішується сам. Вимірів немає, +// годинних відер немає, час падає в UnknownSec. Окремої обробки не +// потрібно, і саме тому джерелом узято ts.icmp_1h, а не історію +// станів: історія станів пише рядок лише при ЗМІНІ, тож мовчання +// зонда вона зображує як незмінне «up». +// +// 4. Хост, вимкнений людиною (inv.devices.enabled = false), теж падає в +// UnknownSec — і це компроміс, який треба назвати вголос. Правильно +// було б зупинити годинник, як на обслуговуванні. Зробити цього не +// можна: історії поля enabled у продукті немає, тобто відповіді на +// питання «коли саме його вимкнули» не існує ніде. Вигадати її +// означало б підмішати здогад у документ для аудитора. Тому вимкнений +// хост псує ПОКРИТТЯ, а не доступність, і це видно в звіті числом. + +// SLAPeriodKind — типи періодів, які вміє різати цей файл. +// +// Місяць і квартал — те, що просять на аудиті. Доба й тиждень коштували +// рівно один рядок у switch і потрібні для іншого: на них видно, що +// розрахунок узагалі працює, не чекаючи місяця. +const ( + SLAPeriodDaily = "daily" + SLAPeriodWeekly = "weekly" + SLAPeriodMonthly = "monthly" + SLAPeriodQuarterly = "quarterly" +) + +// slaBucketSec — довжина одного відра ts.icmp_1h. +// +// Година, і це стеля точності всього звіту: аварія на п'ять хвилин +// всередині години видна як частка втрачених спроб, а не як п'ять +// хвилин. Для «99.9% за квартал» (це 2 години 12 хвилин допустимого +// простою) такої точності досить; для «99.99%» — уже ні, і про це +// сказано людині на самій сторінці. +const slaBucketSec = 3600 + +// SLASpan — напіввідкритий відрізок часу [From, To). +// +// Напіввідкритий навмисно: сусідні періоди мають стикатися без щілини й +// без нахлесту, інакше секунда опівночі 1 липня потрапила б і в другий +// квартал, і в третій. +type SLASpan struct { + From time.Time + To time.Time +} + +// SLAHourBucket — один рядок ts.icmp_1h, зведений до того, що потрібно +// доступності. +// +// Samples — скільки спроб зробили за годину, Down — скільки з них не +// відповіли. Саме ця пара й відрізняє «не відповідав» від «не питали»: +// відра, якого немає, немає, а не «нуль втрат». +type SLAHourBucket struct { + Start time.Time + Samples int64 + Down int64 +} + +// SLAInput — усе, що потрібно, щоб порахувати один хост за один період. +type SLAInput struct { + // Межі періоду в абсолютному часі. Ріже їх SLAPeriodBounds у поясі + // цілі; далі пояс не потрібен — уся арифметика йде в секундах + // абсолютного часу. Наслідок, який варто розуміти: доба переходу на + // літній час має 23 або 25 годин, і місяць із нею довший чи коротший + // сам собою, без жодної окремої гілки. + From time.Time + To time.Time + + // Коли хост з'явився і, якщо вже архівований, коли зник. Період + // звужується до їх перетину: хост, заведений 20 травня, не має + // «недоступності» за 1–19 травня — його не було. + CreatedAt time.Time + DeletedAt *time.Time + + // Вікна обслуговування, які вже відібрано селектором. Можуть + // перетинатися — тут вони зводяться в непересічні. + Maintenance []SLASpan + + // Годинні відра, що потрапляють у період. Порядок не важливий. + Buckets []SLAHourBucket + + // Проти чого міряємо. Обидва числа беруться з цілі й кладуться в + // закритий період знімком: ціль живе далі й може змінитись. + TargetPct float64 + MinCoveragePct float64 +} + +// SLAOutcome — розкладений період. +// +// Чотири часові величини взаємно виключні й у сумі дають ClockSec рівно. +// Це не педантизм: звіт, у якому частини не сходяться з цілим, читач +// перевіряє один раз, не сходить — і далі не вірить жодному числу. +type SLAOutcome struct { + ClockSec int64 `json:"clock_sec"` + MaintenanceSec int64 `json:"maintenance_sec"` + UpSec int64 `json:"up_sec"` + DownSec int64 `json:"downtime_sec"` + UnknownSec int64 `json:"unknown_sec"` + + // UptimePct — з ВИМІРЯНОГО часу, а не з періоду. Знаменник — + // UpSec+DownSec; мовчання в нього не входить (див. рішення 1). + UptimePct float64 `json:"uptime_pct"` + // CoveragePct — яку частку годинника ми взагалі бачили. + CoveragePct float64 `json:"coverage_pct"` + + // Incidents — скільки окремих смуг недоступності. Точність — година: + // це кількість непересічних серій годин, у яких була хоч одна + // невдала спроба. Дві аварії всередині однієї години рахуються як + // одна; годину без даних посеред аварії серія переживає, бо + // відсутність виміру не є доказом відновлення. + Incidents int `json:"incidents"` + + // Insufficient — даних замало для вердикту. Тоді Breached завжди + // false, і це НЕ означає «виконано»: означає «не знаємо». + Insufficient bool `json:"insufficient"` + Breached bool `json:"breached"` +} + +// ComputeSLA розкладає період одного хоста. +func ComputeSLA(in SLAInput) SLAOutcome { + var out SLAOutcome + + // 1. Годинник: період, звужений життям хоста. + live, ok := slaClip(SLASpan{From: in.From, To: in.To}, slaLifetime(in)) + if !ok { + // Хоста в цьому періоді не існувало. Нулі скрізь, вердикту + // немає. Показати тут «0% доступності» означало б винайти + // аварію з нічого. + out.Insufficient = true + return out + } + clock := slaSeconds(live) + out.ClockSec = int64(math.Round(clock)) + + // 2. Обслуговування: об'єднуємо перед відніманням. + // + // Об'єднання — не оптимізація. Два вікна 02:00–04:00 і 03:00–05:00 + // дають три години зупиненого годинника, а сума тривалостей дала б + // чотири — і доступність вийшла б кращою, ніж є, тихо й на кожному + // звіті. + maint := slaMerge(slaClipAll(in.Maintenance, live)) + billable := slaSubtract(live, maint) + billableF := slaSecondsAll(billable) + + // 3. Відра, накладені на оплачуваний час. + var upF, downF, observedF float64 + buckets := append([]SLAHourBucket(nil), in.Buckets...) + sort.Slice(buckets, func(i, j int) bool { return buckets[i].Start.Before(buckets[j].Start) }) + + for _, b := range buckets { + if b.Samples <= 0 { + // Відро без жодної спроби — це те саме мовчання, лише + // записане рядком. Рахувати його як «втрат не було» — + // найтихіший спосіб зіпсувати звіт. + continue + } + ov := slaOverlapAll(SLASpan{From: b.Start, To: b.Start.Add(slaBucketSec * time.Second)}, billable) + if ov <= 0 { + continue + } + observedF += ov + // Втрати всередині години розподіляються рівномірно. Це + // припущення, і воно єдине можливе: у відрі немає позначки, коли + // саме впало. Похибка обмежена годиною й не накопичується — + // сума часток дає ту саму суму спроб. + d := ov * float64(b.Down) / float64(b.Samples) + downF += d + upF += ov - d + } + if observedF > billableF { + // Може статись на межі округлення чи якщо відро частково + // накрило вікно обслуговування. Виміряного часу не буває більше + // за оплачуваний. + observedF = billableF + } + + // 4. Округлення так, щоб частини сходились із цілим. + // + // Спершу дві виміряні величини, далі мовчання добирається різницею, + // а обслуговування — залишком годинника. Порядок саме такий, бо + // підганяти під ціле треба найменш точну величину, а не ту, на яку + // дивиться клієнт. + out.UpSec = int64(math.Round(upF)) + out.DownSec = int64(math.Round(downF)) + billableSec := int64(math.Round(billableF)) + if out.UpSec+out.DownSec > billableSec { + out.DownSec = billableSec - out.UpSec + if out.DownSec < 0 { + out.DownSec = 0 + out.UpSec = billableSec + } + } + out.UnknownSec = billableSec - out.UpSec - out.DownSec + if out.UnknownSec < 0 { + out.UnknownSec = 0 + } + out.MaintenanceSec = out.ClockSec - out.UpSec - out.DownSec - out.UnknownSec + if out.MaintenanceSec < 0 { + out.MaintenanceSec = 0 + } + + // 5. Висновки. + measured := out.UpSec + out.DownSec + if measured > 0 { + out.UptimePct = slaRound3(100 * float64(out.UpSec) / float64(measured)) + } + if billableSec > 0 { + out.CoveragePct = slaRound3(100 * float64(measured) / float64(billableSec)) + } + out.Incidents = slaIncidents(buckets, billable) + + // Вердикт виноситься лише при достатньому покритті. Три способи + // його не мати: хоста не було, весь період — обслуговування, або + // вимірів менше за поріг цілі. У всіх трьох випадках єдина чесна + // відповідь — «не знаємо», і вона НЕ дорівнює «виконано». + out.Insufficient = billableSec == 0 || measured == 0 || + out.CoveragePct < in.MinCoveragePct + if !out.Insufficient { + out.Breached = out.UptimePct < in.TargetPct + } + return out +} + +// slaIncidents рахує смуги недоступності. +// +// Смуга починається на годині з невдалими спробами й закінчується на +// першій ПОВНІСТЮ вдалій годині. Година без даних смугу не закриває: +// відсутність виміру — не доказ того, що хост піднявся, і закривати нею +// аварію означало б розбивати одну добову аварію на десяток «інцидентів» +// щоразу, коли мовчав зонд. +func slaIncidents(buckets []SLAHourBucket, billable []SLASpan) int { + n := 0 + inRun := false + for _, b := range buckets { + if b.Samples <= 0 { + continue + } + if slaOverlapAll(SLASpan{From: b.Start, To: b.Start.Add(slaBucketSec * time.Second)}, billable) <= 0 { + continue + } + if b.Down > 0 { + if !inRun { + n++ + inRun = true + } + continue + } + inRun = false + } + return n +} + +// slaLifetime — відрізок, у якому хост існував. +func slaLifetime(in SLAInput) SLASpan { + s := SLASpan{From: in.CreatedAt, To: in.To} + if in.DeletedAt != nil && in.DeletedAt.Before(in.To) { + s.To = *in.DeletedAt + } + // Хост без дати створення (теоретично неможливо, практично — + // відновлення з чужого дампа) не має звужувати період: краще + // порахувати весь, ніж мовчки нічого. + if s.From.IsZero() { + s.From = in.From + } + return s +} + +// --------------------------------------------------------------------- +// Арифметика відрізків +// --------------------------------------------------------------------- + +func slaSeconds(s SLASpan) float64 { + d := s.To.Sub(s.From).Seconds() + if d < 0 { + return 0 + } + return d +} + +func slaSecondsAll(list []SLASpan) float64 { + var t float64 + for _, s := range list { + t += slaSeconds(s) + } + return t +} + +// slaClip — перетин двох відрізків. ok=false, якщо перетину немає. +func slaClip(a, b SLASpan) (SLASpan, bool) { + out := SLASpan{From: a.From, To: a.To} + if b.From.After(out.From) { + out.From = b.From + } + if b.To.Before(out.To) { + out.To = b.To + } + if !out.From.Before(out.To) { + return SLASpan{}, false + } + return out, true +} + +func slaClipAll(list []SLASpan, within SLASpan) []SLASpan { + out := make([]SLASpan, 0, len(list)) + for _, s := range list { + if c, ok := slaClip(s, within); ok { + out = append(out, c) + } + } + return out +} + +// slaMerge зводить перекриття в непересічні відрізки. +func slaMerge(list []SLASpan) []SLASpan { + if len(list) == 0 { + return nil + } + cp := append([]SLASpan(nil), list...) + sort.Slice(cp, func(i, j int) bool { return cp[i].From.Before(cp[j].From) }) + + out := []SLASpan{cp[0]} + for _, s := range cp[1:] { + last := &out[len(out)-1] + // Стик (s.From == last.To) теж об'єднуємо: два суміжні вікна — + // одна перерва, і показувати їх окремо немає кому. + if !s.From.After(last.To) { + if s.To.After(last.To) { + last.To = s.To + } + continue + } + out = append(out, s) + } + return out +} + +// slaSubtract віднімає непересічні cut від base. +func slaSubtract(base SLASpan, cut []SLASpan) []SLASpan { + out := []SLASpan{base} + for _, c := range cut { + next := make([]SLASpan, 0, len(out)+1) + for _, s := range out { + ovl, ok := slaClip(s, c) + if !ok { + next = append(next, s) + continue + } + if s.From.Before(ovl.From) { + next = append(next, SLASpan{From: s.From, To: ovl.From}) + } + if ovl.To.Before(s.To) { + next = append(next, SLASpan{From: ovl.To, To: s.To}) + } + } + out = next + } + return out +} + +func slaOverlapAll(s SLASpan, list []SLASpan) float64 { + var t float64 + for _, b := range list { + if c, ok := slaClip(s, b); ok { + t += slaSeconds(c) + } + } + return t +} + +func slaRound3(v float64) float64 { + return math.Round(v*1000) / 1000 +} + +// --------------------------------------------------------------------- +// Календарні межі +// --------------------------------------------------------------------- + +// SLAPeriodBounds — межі періоду, у який потрапляє anchor. +// +// Ріже в поясі loc, а не в UTC. «Квартал» для клієнта в Києві — це три +// календарні місяці за київським календарем; зсунуті на дві-три години +// межі здаються дрібницею рівно доти, доки в цих годинах не станеться +// аварія, яка потрапить не в той квартал. +func SLAPeriodBounds(kind string, anchor time.Time, loc *time.Location) (SLASpan, error) { + if loc == nil { + loc = time.UTC + } + t := anchor.In(loc) + y, m, d := t.Date() + midnight := func(yy int, mm time.Month, dd int) time.Time { + return time.Date(yy, mm, dd, 0, 0, 0, 0, loc) + } + + switch kind { + case SLAPeriodDaily: + f := midnight(y, m, d) + return SLASpan{From: f, To: f.AddDate(0, 0, 1)}, nil + case SLAPeriodWeekly: + // Понеділок — перший день тижня. time.Weekday рахує від неділі, + // тож зсув, а не віднімання: інакше кожна неділя опинялась би в + // наступному тижні. + shift := (int(t.Weekday()) + 6) % 7 + f := midnight(y, m, d).AddDate(0, 0, -shift) + return SLASpan{From: f, To: f.AddDate(0, 0, 7)}, nil + case SLAPeriodMonthly: + f := midnight(y, m, 1) + return SLASpan{From: f, To: f.AddDate(0, 1, 0)}, nil + case SLAPeriodQuarterly: + f := midnight(y, time.Month((int(m)-1)/3*3+1), 1) + return SLASpan{From: f, To: f.AddDate(0, 3, 0)}, nil + } + return SLASpan{}, fmt.Errorf("%w: невідомий тип періоду %q", ErrInvalid, kind) +} + +// SLAPrevPeriod — межі періоду, що передує тому, у якому now. +// +// Потрібне рівно для закриття: закривати можна лише те, що вже +// скінчилось. +func SLAPrevPeriod(kind string, now time.Time, loc *time.Location) (SLASpan, error) { + cur, err := SLAPeriodBounds(kind, now, loc) + if err != nil { + return SLASpan{}, err + } + // Відступ усередину попереднього періоду, а не віднімання довжини: + // довжина місяця й кварталу різна, а секунда перед початком поточного + // гарантовано належить попередньому. + return SLAPeriodBounds(kind, cur.From.Add(-time.Second), loc) +} + +// SLASettleLag — скільки чекати після кінця періоду перед закриттям. +// +// Шість годин. Причина в 0005: ts.icmp_1h рахується з ts.icmp_5m +// політикою з end_offset 1 година й розкладом раз на годину, а сама +// ts.icmp_5m — з відставанням у 5 хвилин. Тобто останні години періоду +// матеріалізуються не раніше ніж за дві години після його кінця. +// +// Шість замість двох — це запас на зупинений на ніч сервер і на +// відкладені фонові задачі TimescaleDB. Ціна запасу — звіт за минулий +// місяць стає остаточним об 06:00 першого числа, а не опівночі. Ціна +// його відсутності — закритий назавжди період із недорахованим хвостом, +// і виправити його можна лише руками. +const SLASettleLag = 6 * time.Hour diff --git a/server/internal/store/sla_compute.go b/server/internal/store/sla_compute.go new file mode 100644 index 0000000..6ec5574 --- /dev/null +++ b/server/internal/store/sla_compute.go @@ -0,0 +1,538 @@ +package store + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +// Живий розрахунок і закриття періоду. +// +// ЧОМУ ЗАКРИТИЙ ПЕРІОД НЕ ПЕРЕРАХОВУЮТЬ +// +// Звіт про доступність — це документ, який показують назовні: клієнту за +// договором, аудитору на перевірці. У такого документа є одна властивість, +// без якої він не документ: два роздруки того самого кварталу, зроблені в +// різні дні, мають збігатися. +// +// Розрахунок «на льоту» цієї властивості не має й мати не може. Під ним +// лежать дані зі строком зберігання, вікна обслуговування, які хтось +// може дозаповнити заднім числом, перелік хостів, який змінюється щодня, +// і сама ціль, яку можна відредагувати. Кожна з цих речей рухається +// незалежно від нас, і жодна не питає дозволу. +// +// Тому щойно період скінчився й згортки під ним устоялись (SLASettleLag), +// він рахується РАЗ і лягає в core.sla_periods разом зі знімками умов: +// обіцяний відсоток, поріг покриття, пояс, ім'я хоста. Далі це вже не +// дані, а висновок, і читається він як висновок. Перерахувати можна — +// але лише свідомо, і кожен перерахунок видно в revision самого рядка. + +// slaBackfillPeriods — скільки закритих періодів назад добирає фоновий +// такт. +// +// Три. Не «усі»: інсталяція, яку підняли після року простою, інакше +// порахувала б дванадцять місяців по даних, яких за той рік уже немає, і +// закрила б їх назавжди порожніми. Три періоди назад покривають звичайну +// причину пропуску — сервер стояв вихідні — і не дотягуються туди, де +// закривати вже нема чого. +const slaBackfillPeriods = 3 + +// slaDevice — хост і межі його життя. +type slaDevice struct { + ID string + Name string + CreatedAt time.Time + DeletedAt *time.Time +} + +// slaSelectDevices — хости цілі, які ІСНУВАЛИ хоч частину періоду. +// +// М'яко видалені сюди входять навмисно. Хост, який прибрали з переліку +// 20 травня, у травні працював, і звіт за травень без нього показав би +// кращу картину, ніж була, — просто тому, що найгірший учасник зник зі +// списку разом із причиною, через яку його прибрали. +func (s *Store) slaSelectDevices(ctx context.Context, tx pgx.Tx, tenantID string, + sel Selector, span SLASpan) ([]slaDevice, error) { + + a := &args{} + pTenant := a.add(tenantID) + pFrom := a.add(span.From) + pTo := a.add(span.To) + where, err := s.selectorSQL(sel, a, "d.id") + if err != nil { + return nil, err + } + q := fmt.Sprintf(` + SELECT d.id::text, d.name, d.created_at, d.deleted_at + FROM inv.devices d + WHERE d.tenant_id = %s + AND d.created_at < %s + AND (d.deleted_at IS NULL OR d.deleted_at > %s) + %s + ORDER BY d.name + `, pTenant, pTo, pFrom, where) + + rows, err := tx.Query(ctx, q, a.vals...) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []slaDevice + for rows.Next() { + var d slaDevice + if err := rows.Scan(&d.ID, &d.Name, &d.CreatedAt, &d.DeletedAt); err != nil { + return nil, err + } + out = append(out, d) + } + return out, rows.Err() +} + +// slaBuckets — годинні відра всіх хостів за період, одним запитом. +// +// Один запит, а не по хосту: квартал на 500 хостів — це близько мільйона +// відер, і мільйон запитів по одному ряду перетворив би звіт на +// півгодинну операцію. Предикат tenant_id тут ОБОВ'ЯЗКОВИЙ і не +// дублюється RLS: 0011 не вмикає політик на гіпертаблицях і безперервних +// агрегатах, тобто другого рубежу для ts.icmp_1h не існує. +func (s *Store) slaBuckets(ctx context.Context, tx pgx.Tx, tenantID string, + deviceIDs []string, span SLASpan) (map[string][]SLAHourBucket, error) { + + out := map[string][]SLAHourBucket{} + if len(deviceIDs) == 0 { + return out, nil + } + rows, err := tx.Query(ctx, ` + SELECT device_id::text, bucket, samples, down_samples + FROM ts.icmp_1h + WHERE tenant_id = $1 AND device_id = ANY($2::uuid[]) + AND bucket >= $3 AND bucket < $4 + ORDER BY device_id, bucket + `, tenantID, deviceIDs, span.From, span.To) + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var id string + var b SLAHourBucket + if err := rows.Scan(&id, &b.Start, &b.Samples, &b.Down); err != nil { + return nil, err + } + out[id] = append(out[id], b) + } + return out, rows.Err() +} + +// slaMaintenance — вікна обслуговування, розкладені по хостах. +// +// Селектор вікна розбирається тим самим selectorSQL, що й придушення +// алертів. Розійтись їм не можна: вікно, під яким алерт придушено, а SLA +// зіпсовано, — це найгірший із можливих станів, бо про нього дізнаються +// не тоді, коли він стався, а через квартал, з чужої претензії. +func (s *Store) slaMaintenance(ctx context.Context, tx pgx.Tx, tenantID string, + devices []slaDevice, span SLASpan) (map[string][]SLASpan, bool, error) { + + out := map[string][]SLASpan{} + rows, err := tx.Query(ctx, ` + SELECT COALESCE(selector::text, '{}'), lower(period), upper(period), + (rrule IS NOT NULL) + FROM alr.maintenance_windows + WHERE tenant_id = $1 AND exclude_from_sla + AND period && tstzrange($2, $3, '[)') + `, tenantID, span.From, span.To) + if err != nil { + return nil, false, err + } + type window struct { + sel Selector + empty bool + from, to time.Time + } + var windows []window + hasRRule := false + for rows.Next() { + var raw string + var from, to *time.Time + var rr bool + if err := rows.Scan(&raw, &from, &to, &rr); err != nil { + rows.Close() + return nil, false, err + } + var w window + if err := json.Unmarshal([]byte(raw), &w.sel); err != nil { + // Вікно з нерозбірливим селектором ігнорувати не можна: воно + // б мовчки перетворилось на простій. Трактуємо як «весь + // кабінет» — бік помилки, що не псує звіт клієнту. + w.sel = Selector{} + } + // Вікно без селектора накриває весь кабінет: так виглядає + // «вимикаємо все на 30 хвилин». Та сама умова, що й у + // LoadSuppression. + w.empty = len(w.sel.DeviceIDs) == 0 && len(w.sel.GroupIDs) == 0 && + len(w.sel.SiteIDs) == 0 && len(w.sel.Tags) == 0 && + len(w.sel.Kinds) == 0 && len(w.sel.Vendors) == 0 && + len(w.sel.TemplateIDs) == 0 + // Незамкнений діапазон ('[2026-01-01,)') означає «і далі»; + // обрізаємо періодом, а не відкидаємо вікно. + w.from, w.to = span.From, span.To + if from != nil && from.After(w.from) { + w.from = *from + } + if to != nil && to.Before(w.to) { + w.to = *to + } + if rr { + hasRRule = true + } + windows = append(windows, w) + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, false, err + } + if len(windows) == 0 { + return out, false, nil + } + + // Порожній селектор накриває всіх — резолвити нічого. + inSet := map[string]bool{} + for _, d := range devices { + inSet[d.ID] = true + } + for _, w := range windows { + if w.empty { + for _, d := range devices { + out[d.ID] = append(out[d.ID], SLASpan{From: w.from, To: w.to}) + } + continue + } + ids, err := s.slaSelectDevices(ctx, tx, tenantID, w.sel, span) + if err != nil { + return nil, false, err + } + for _, d := range ids { + if !inSet[d.ID] { + continue + } + out[d.ID] = append(out[d.ID], SLASpan{From: w.from, To: w.to}) + } + } + return out, hasRRule, nil +} + +// slaComputeRows — живий розрахунок цілі за період. +func (s *Store) slaComputeRows(ctx context.Context, tx pgx.Tx, tenantID string, + t SLATarget, span SLASpan) ([]SLARow, []string, error) { + + devices, err := s.slaSelectDevices(ctx, tx, tenantID, t.Selector, span) + if err != nil { + return nil, nil, err + } + ids := make([]string, 0, len(devices)) + for _, d := range devices { + ids = append(ids, d.ID) + } + buckets, err := s.slaBuckets(ctx, tx, tenantID, ids, span) + if err != nil { + return nil, nil, err + } + maint, hasRRule, err := s.slaMaintenance(ctx, tx, tenantID, devices, span) + if err != nil { + return nil, nil, err + } + + var warn []string + if hasRRule { + warn = append(warn, SLAWarnRRule) + } + + rows := make([]SLARow, 0, len(devices)) + for _, d := range devices { + r := SLARow{DeviceID: d.ID, DeviceName: d.Name, Warnings: []string{}} + r.SLAOutcome = ComputeSLA(SLAInput{ + From: span.From, + To: span.To, + CreatedAt: d.CreatedAt, + DeletedAt: d.DeletedAt, + Maintenance: maint[d.ID], + Buckets: buckets[d.ID], + TargetPct: t.TargetPct, + MinCoveragePct: t.MinCoveragePct, + }) + if hasRRule && len(maint[d.ID]) > 0 { + r.Warnings = append(r.Warnings, SLAWarnRRule) + } + rows = append(rows, r) + } + return rows, warn, nil +} + +// --------------------------------------------------------------------- +// Закриття +// --------------------------------------------------------------------- + +// ErrSLANotSettled — період ще не можна закривати. +// +// Окрема помилка, а не загальний ErrInvalid: клієнт має показати не +// «некоректний запит», а причину — «зачекайте, згортки під періодом ще +// рахуються». Ці два тексти ведуть людину в різні боки. +var ErrSLANotSettled = fmt.Errorf("%w: період ще не завершився або згортки під ним не встоялись", ErrInvalid) + +// CloseSLAPeriod рахує період один раз і зберігає як факт. +// +// Ідемпотентний: повторний виклик на вже закритому періоді нічого не +// переписує й повертає збережене. Саме через це фоновий такт може +// сміливо ходити по тих самих періодах щогодини. +// +// force переписує закрите — і це єдиний шлях це зробити. Він піднімає +// revision, тобто лишає слід у самому звіті: два роздруки того самого +// кварталу з різними числами інакше неможливо розрізнити. +func (s *Store) CloseSLAPeriod(ctx context.Context, tenantID, targetID string, + anchor time.Time, force bool) (SLAReport, error) { + + var rep SLAReport + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + t, err := s.slaTarget(ctx, tx, tenantID, targetID) + if err != nil { + return err + } + rep, err = s.slaCloseTx(ctx, tx, tenantID, t, anchor, force) + return err + }) + return rep, err +} + +func (s *Store) slaCloseTx(ctx context.Context, tx pgx.Tx, tenantID string, + t SLATarget, anchor time.Time, force bool) (SLAReport, error) { + + loc, tzOK := t.Location() + span, err := SLAPeriodBounds(t.PeriodKind, anchor, loc) + if err != nil { + return SLAReport{}, err + } + // Раніше строку не закриваємо НІКОЛИ, навіть на вимогу. + // + // force дозволяє переписати вже пораховане, а не порахувати + // недорахованим. Різниця вирішальна: закритий на добу раніше + // квартал недорахував би останні години назавжди, і жодна кнопка + // цього вже не помітила б. + if time.Now().Before(span.To.Add(SLASettleLag)) { + return SLAReport{}, ErrSLANotSettled + } + + stored, err := s.slaStoredRows(ctx, tx, tenantID, t.ID, span, loc) + if err != nil { + return SLAReport{}, err + } + if stored.Closed && !force { + rep, err := s.slaReportTx(ctx, tx, tenantID, t, anchor) + return rep, err + } + + rows, warn, err := s.slaComputeRows(ctx, tx, tenantID, t, span) + if err != nil { + return SLAReport{}, err + } + if !tzOK { + warn = slaAppendUnique(warn, SLAWarnUnknownTZ) + } + if len(t.BusinessHours) > 0 { + warn = slaAppendUnique(warn, SLAWarnBusinessHours) + } + horizon, err := s.slaHorizon(ctx, tx, tenantID) + if err != nil { + return SLAReport{}, err + } + if slaBeyondHorizon(horizon, span.From) { + // Найважливіше попередження з усіх: частина періоду не має даних + // не тому, що їх не збирали, а тому, що їх уже видалили. Воно + // вмерзає в рядок разом із числом — інакше через рік звіт + // виглядав би як звичайна недостатність покриття. + warn = slaAppendUnique(warn, SLAWarnBeyondHorizon) + } + + revision := 1 + if stored.Closed { + revision = stored.Revision + 1 + // Дозвіл на правку закритого — рівно на цю транзакцію. + if _, err := tx.Exec(ctx, `SELECT set_config('app.sla_reopen', 'on', true)`); err != nil { + return SLAReport{}, err + } + } + + from := span.From.In(loc).Format("2006-01-02") + to := span.To.In(loc).Format("2006-01-02") + + // Повне перезаписування, а не UPSERT по хостах: за час між + // розрахунками хост міг випасти з селектора, і залишений рядок + // показував би в звіті когось, кого ціль уже не стосується. + if _, err := tx.Exec(ctx, ` + DELETE FROM core.sla_periods + WHERE tenant_id = $1 AND sla_target_id = $2 + AND period = daterange($3::date, $4::date) + `, tenantID, t.ID, from, to); err != nil { + return SLAReport{}, err + } + + for _, r := range rows { + w := slaAppendUnique(append([]string(nil), r.Warnings...), warn...) + raw, _ := json.Marshal(w) + if _, err := tx.Exec(ctx, ` + INSERT INTO core.sla_periods + (tenant_id, sla_target_id, device_id, device_name, period, + uptime_pct, downtime_sec, maintenance_sec, up_sec, unknown_sec, + clock_sec, coverage_pct, incidents, breached, + target_pct, min_coverage_pct, tz, source, closed, revision, + warnings, computed_at) + VALUES ($1, $2, $3, $4, daterange($5::date, $6::date), + $7, $8, $9, $10, $11, $12, $13, $14, $15, + $16, $17, $18, 'icmp_1h', true, $19, $20::jsonb, now()) + `, tenantID, t.ID, r.DeviceID, r.DeviceName, from, to, + r.UptimePct, r.DownSec, r.MaintenanceSec, r.UpSec, r.UnknownSec, + r.ClockSec, r.CoveragePct, r.Incidents, r.Breached, + t.TargetPct, t.MinCoveragePct, t.TZ, revision, string(raw)); err != nil { + return SLAReport{}, err + } + } + + now := time.Now() + return SLAReport{ + Target: t, + From: span.From, + To: span.To, + PeriodFrom: from, + PeriodTo: to, + Closed: true, + Closable: true, + ComputedAt: &now, + Revision: revision, + Rows: rows, + Horizon: horizon, + Warnings: warn, + Totals: slaTotals(rows, t.TargetPct, t.MinCoveragePct), + }, nil +} + +// SLAClosed — скільки періодів закрив прохід і за якими цілями. +type SLAClosed struct { + TenantID string + TargetID string + Name string + Period string +} + +// CloseDueSLAPeriods закриває все, що вже можна закрити. +// +// Фоновий такт, а не «порахуємо, коли відкриють сторінку». Причина +// проста: періоду, який ніхто не відкрив вчасно, дані під ним не +// дочекаються. Квартал, уперше відкритий через півроку, закрився б +// назавжди по тому, що від нього лишилось, — і саме цей рядок відрізняє +// звіт, який можна показати, від звіту, який можна лише пояснити. +func (s *Store) CloseDueSLAPeriods(ctx context.Context) ([]SLAClosed, error) { + tenants, err := s.TenantIDs(ctx) + if err != nil { + return nil, err + } + var done []SLAClosed + for _, tenantID := range tenants { + targets, err := s.slaEnabledTargets(ctx, tenantID) + if err != nil { + return done, err + } + for _, t := range targets { + loc, _ := t.Location() + // Від найстарішого до найсвіжішого: якщо на середині щось + // упаде, уже закрите лишиться закритим, а наступний тік + // продовжить з того ж місця. + for back := slaBackfillPeriods; back >= 1; back-- { + span, err := slaPeriodBack(t.PeriodKind, time.Now(), loc, back) + if err != nil { + break + } + if time.Now().Before(span.To.Add(SLASettleLag)) { + continue + } + closed, err := s.slaCloseIfMissing(ctx, tenantID, t, span) + if err != nil { + return done, err + } + if closed { + done = append(done, SLAClosed{ + TenantID: tenantID, TargetID: t.ID, Name: t.Name, + Period: span.From.In(loc).Format("2006-01-02"), + }) + } + } + } + } + return done, nil +} + +// slaCloseIfMissing — окрема транзакція на кожен період. +// +// Не одна на все: закриття кварталу на 500 хостів — це 500 вставок, і +// тримати їх в одній транзакції з усіма іншими цілями означало б, що +// збій на останній скасовує роботу за всі попередні. +func (s *Store) slaCloseIfMissing(ctx context.Context, tenantID string, t SLATarget, span SLASpan) (bool, error) { + loc, _ := t.Location() + closed := false + err := s.InTenantTxBG(ctx, tenantID, func(tx pgx.Tx) error { + var n int + if err := tx.QueryRow(ctx, ` + SELECT count(*)::int FROM core.sla_periods + WHERE tenant_id = $1 AND sla_target_id = $2 + AND period = daterange($3::date, $4::date) AND closed + `, tenantID, t.ID, span.From.In(loc).Format("2006-01-02"), + span.To.In(loc).Format("2006-01-02")).Scan(&n); err != nil { + return err + } + if n > 0 { + return nil + } + // anchor — будь-яка мить усередині періоду; беремо початок. + if _, err := s.slaCloseTx(ctx, tx, tenantID, t, span.From, false); err != nil { + return err + } + closed = true + return nil + }) + return closed, err +} + +func (s *Store) slaEnabledTargets(ctx context.Context, tenantID string) ([]SLATarget, error) { + all, err := s.SLATargets(ctx, tenantID) + if err != nil { + return nil, err + } + out := all[:0] + for _, t := range all { + if t.Enabled { + out = append(out, t) + } + } + return out, nil +} + +// slaPeriodBack — межі періоду, що передує поточному на back кроків. +func slaPeriodBack(kind string, now time.Time, loc *time.Location, back int) (SLASpan, error) { + span, err := SLAPeriodBounds(kind, now, loc) + if err != nil { + return SLASpan{}, err + } + for i := 0; i < back; i++ { + // Секунда перед початком періоду завжди належить попередньому — + // незалежно від того, скільки в ньому діб. Віднімання фіксованої + // довжини тут дало б збій на лютому й на переході часу. + span, err = SLAPeriodBounds(kind, span.From.Add(-time.Second), loc) + if err != nil { + return SLASpan{}, err + } + } + return span, nil +} diff --git a/server/internal/store/sla_csv.go b/server/internal/store/sla_csv.go new file mode 100644 index 0000000..ba97e3e --- /dev/null +++ b/server/internal/store/sla_csv.go @@ -0,0 +1,248 @@ +package store + +import ( + "encoding/csv" + "fmt" + "io" + "strconv" + "strings" + "time" +) + +// Вивантаження звіту SLA. +// +// ЧОМУ CSV ЗБИРАЄ СЕРВЕР, А НЕ БРАУЗЕР +// +// Спокуса зібрати його в TypeScript велика: рядки вже на екрані, +// лишається склеїти. Але саме цей файл кладуть у папку до договору, і +// саме його потім звіряють із тим, що показує сторінка. Дві реалізації +// одного округлення розійдуться на третьому знаку — і розбіжність +// побачить не той, хто її зробив, а клієнт. +// +// ЧОМУ КРАПКА, А НЕ КОМА, У ДРОБАХ +// +// Український Excel читає крапку як текст, і 99.912 стає рядком. +// Спокусливо писати кому — і тоді той самий файл ламається в усьому +// іншому: у csv кома вже є роздільником полів, у Python, jq, Grafana й +// будь-якому імпортері кома в числі — це помилка розбору. +// +// Тому крапка, а різницю бере на себе перший рядок файла: заголовок +// sep=; змушує Excel читати роздільником крапку з комою, і тоді числа з +// крапкою вже нікуди не з'їжджають. Це не наша вигадка, а домовленість +// самого Excel; решта світу цей рядок пропускає як коментар. + +// SLAReportCSV пише звіт у потік. +// +// Один рядок на хост плюс підсумковий. Підсумок саме в тому ж файлі, а +// не окремим: людина, яка відкрила вивантаження, шукає в ньому головне +// число, і змушувати її додавати стовпчик руками означає віддати їй +// шанс помилитись у тому, заради чого файл і робився. +func SLAReportCSV(w io.Writer, rep SLAReport) error { + // BOM: без нього український Excel читає UTF-8 як cp1251, і всі + // назви хостів перетворюються на кракозябри. Решта інструментів BOM + // пропускає. + if _, err := io.WriteString(w, "\ufeff"); err != nil { + return err + } + if _, err := io.WriteString(w, "sep=;\r\n"); err != nil { + return err + } + + cw := csv.NewWriter(w) + cw.Comma = ';' + // CRLF — те, чого чекає Excel. Для решти світу різниці немає. + cw.UseCRLF = true + + // Шапка з умовами звіту. Вона й є те, що робить файл документом: + // без неї «99.912» не відповідає на питання «за що і проти чого». + head := [][]string{ + {"Ціль", rep.Target.Name}, + {"Період", rep.PeriodFrom + " — " + rep.PeriodTo}, + {"Тип періоду", slaPeriodLabel(rep.Target.PeriodKind)}, + {"Часовий пояс", rep.Target.TZ}, + {"Ціль доступності, %", slaNum(rep.Target.TargetPct)}, + {"Поріг покриття, %", slaNum(rep.Target.MinCoveragePct)}, + {"Джерело даних", rep.Horizon.Relation}, + {"Стан звіту", slaStateLabel(rep)}, + } + if rep.ComputedAt != nil { + head = append(head, []string{"Розраховано", rep.ComputedAt.Format(time.RFC3339)}) + } + if rep.Revision > 1 { + head = append(head, []string{"Перерахунків", strconv.Itoa(rep.Revision)}) + } + // Строк зберігання джерела — у шапці, а не в примітці. Це та + // властивість, через яку звіт узагалі може змінитись, і читач має + // побачити її поруч із числами. + head = append(head, []string{"Строк зберігання джерела", slaKeepLabel(rep.Horizon.KeepDays)}) + for _, code := range rep.Warnings { + head = append(head, []string{"Застереження", SLAWarningLabel(code)}) + } + for _, row := range head { + if err := cw.Write(row); err != nil { + return err + } + } + if err := cw.Write(nil); err != nil { + return err + } + + if err := cw.Write([]string{ + "Хост", + "Доступність, %", + "Покриття, %", + "Вердикт", + "Простій, с", + "Доступно, с", + "Немає даних, с", + "Обслуговування, с", + "Тривалість періоду, с", + "Смуг недоступності", + "Застереження", + }); err != nil { + return err + } + + for _, r := range rep.Rows { + if err := cw.Write(slaCSVRow(r.DeviceName, r.SLAOutcome, r.Warnings)); err != nil { + return err + } + } + if err := cw.Write(slaCSVRow("РАЗОМ", rep.Totals, nil)); err != nil { + return err + } + + cw.Flush() + return cw.Error() +} + +func slaCSVRow(name string, o SLAOutcome, warnings []string) []string { + // Доступність порожня, а не «0», коли її не виміряли. Нуль у цій + // клітинці читається як «лежало весь період» — рівно навпаки до + // того, що сталось насправді. + uptime := "" + if !o.Insufficient { + uptime = slaNum(o.UptimePct) + } + labels := make([]string, 0, len(warnings)) + for _, c := range warnings { + labels = append(labels, SLAWarningLabel(c)) + } + return []string{ + name, + uptime, + slaNum(o.CoveragePct), + slaVerdict(o), + strconv.FormatInt(o.DownSec, 10), + strconv.FormatInt(o.UpSec, 10), + strconv.FormatInt(o.UnknownSec, 10), + strconv.FormatInt(o.MaintenanceSec, 10), + strconv.FormatInt(o.ClockSec, 10), + strconv.Itoa(o.Incidents), + strings.Join(labels, "; "), + } +} + +// slaVerdict — три стани, а не два. +// +// «Недостатньо даних» мусить бути окремим словом. Звести його до +// «виконано» означає збрехати, до «порушено» — звинуватити мережу в +// тому, що зламався моніторинг. +func slaVerdict(o SLAOutcome) string { + switch { + case o.Insufficient: + return "недостатньо даних" + case o.Breached: + return "порушено" + default: + return "виконано" + } +} + +// SLAVerdict — той самий висновок для інших форматів. +func SLAVerdict(o SLAOutcome) string { return slaVerdict(o) } + +func slaStateLabel(rep SLAReport) string { + if rep.Closed { + return "закритий період (перерахунку не підлягає)" + } + if rep.Closable { + return "попередній розрахунок; період можна закрити" + } + return "попередній розрахунок; період ще триває" +} + +func slaKeepLabel(days *int) string { + if days == nil { + return "без строку" + } + return strconv.Itoa(*days) + " діб" +} + +func slaPeriodLabel(kind string) string { + switch kind { + case SLAPeriodDaily: + return "доба" + case SLAPeriodWeekly: + return "тиждень" + case SLAPeriodMonthly: + return "місяць" + case SLAPeriodQuarterly: + return "квартал" + } + return kind +} + +// SLAPeriodLabel — назва типу періоду для інтерфейсу й журналу. +func SLAPeriodLabel(kind string) string { return slaPeriodLabel(kind) } + +// slaNum — число з трьома знаками й крапкою, без хвостових нулів. +// +// Без хвостових нулів навмисно: «100» і «100.000» в одному стовпчику +// читаються як різні за точністю виміри, хоч це те саме число. +func slaNum(v float64) string { + s := strconv.FormatFloat(v, 'f', 3, 64) + s = strings.TrimRight(s, "0") + s = strings.TrimSuffix(s, ".") + if s == "" || s == "-" { + return "0" + } + return s +} + +// SLAReportFileName — ім'я файла вивантаження. +// +// Назва цілі в імені навмисно: у папці «Договори» лежатиме десяток таких +// файлів, і «sla.csv» серед них не означає нічого. +func SLAReportFileName(rep SLAReport) string { + name := slaSafeName(rep.Target.Name) + return fmt.Sprintf("sla-%s-%s.csv", name, rep.PeriodFrom) +} + +func slaSafeName(s string) string { + var b strings.Builder + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': + b.WriteRune(r) + case r == '-' || r == '_': + b.WriteRune(r) + default: + // Кирилиця й пробіли зводяться до дефіса: ім'я файла їде в + // заголовок Content-Disposition, а він у частині проксі й + // старих браузерів не переживає ані не-ASCII, ані пробілу. + b.WriteRune('-') + } + } + out := strings.Trim(b.String(), "-") + for strings.Contains(out, "--") { + out = strings.ReplaceAll(out, "--", "-") + } + if out == "" { + return "report" + } + if len(out) > 40 { + out = strings.Trim(out[:40], "-") + } + return out +} diff --git a/server/internal/store/sla_db_test.go b/server/internal/store/sla_db_test.go new file mode 100644 index 0000000..d819d5f --- /dev/null +++ b/server/internal/store/sla_db_test.go @@ -0,0 +1,242 @@ +package store + +import ( + "context" + "os" + "strings" + "testing" + "time" +) + +// Перевірка SLA ПРОТИ БАЗИ. +// +// Чиста арифметика покрита в sla_test.go, і це головна половина. Друга +// половина арифметикою не перевіряється взагалі, бо вона не в коді: +// +// - закритий період справді не переписується — це властивість тригера +// на таблиці, а не гілки в Go, і тест без бази про неї нічого не +// каже, скільки б зеленого не показував; +// - закритий період переживає ПОВНЕ видалення хоста (0057) — це +// властивість знятого зовнішнього ключа; +// - повторне закриття нічого не міняє — це властивість трьох запитів +// разом, а не функції. +// +// Мовчки пропускається без NETPULSE_TEST_DSN: `go test ./...` не має +// вимагати бази. Запускати ЛИШЕ на одноразовій базі — тест створює +// кабінет і видаляє його з усім вмістом: +// +// docker run --rm -d --name np-test -e POSTGRES_PASSWORD=x \ +// -e POSTGRES_DB=np timescale/timescaledb:2.17.2-pg16 +// NETPULSE_DSN=postgres://postgres:x@localhost/np go run ./cmd/netpulse-migrate +// NETPULSE_TEST_DSN=postgres://postgres:x@localhost/np \ +// go test ./internal/store/ -run SLAAgainstDB -v +func TestSLAAgainstDB(t *testing.T) { + dsn := os.Getenv("NETPULSE_TEST_DSN") + if dsn == "" { + t.Skip("NETPULSE_TEST_DSN не задано — перевірка проти бази пропускається") + } + ctx := context.Background() + + st, err := New(ctx, dsn) + if err != nil { + t.Fatalf("підключення: %v", err) + } + t.Cleanup(st.Close) + + slug := "sla-" + strings.ReplaceAll(time.Now().Format("150405.000"), ".", "") + var tenantID string + if err := st.pool.QueryRow(ctx, ` + INSERT INTO core.tenants (slug, name, timezone) VALUES ($1, $2, 'UTC') + RETURNING id::text + `, slug, "Перевірка SLA").Scan(&tenantID); err != nil { + t.Fatalf("кабінет: %v", err) + } + t.Cleanup(func() { + bg := context.Background() + // Тригер незмінності стоїть і на DELETE: без дозволу каскад від + // кабінету не змів би закритих періодів, і прибирання тесту + // лишило б за собою кабінет. + _, _ = st.pool.Exec(bg, `SET app.sla_reopen = 'on'`) + _, _ = st.pool.Exec(bg, `DELETE FROM core.tenants WHERE id = $1`, tenantID) + _, _ = st.pool.Exec(bg, `RESET app.sla_reopen`) + }) + + // Період — позаминула доба: вона вже скінчилась і давно «встоялась». + day := time.Now().UTC().AddDate(0, 0, -2).Truncate(24 * time.Hour) + + var deviceID string + if err := st.pool.QueryRow(ctx, ` + INSERT INTO inv.devices (tenant_id, name, address, kind, created_at) + VALUES ($1, $2, '10.88.0.1', 'switch', $3) RETURNING id::text + `, tenantID, slug+"-sw", day.AddDate(0, 0, -10)).Scan(&deviceID); err != nil { + t.Fatalf("хост: %v", err) + } + + // Дані кладемо ПРЯМО в годинну згортку, а не в сирі виміри. + // + // Не з лінощів: у бойовій базі ts.icmp_1h наповнює політика + // TimescaleDB, і чекати на неї в тесті довелось би реальну годину. + // А перевіряємо тут не роботу згорток (вона перевірена 0005), а + // поведінку звіту над ними. + mat := st.slaMaterializedHypertable(ctx, t, "ts.icmp_1h") + for i := 0; i < 24; i++ { + down := int64(0) + if i == 5 { + down = 30 // чверть години простою + } + if _, err := st.pool.Exec(ctx, ` + INSERT INTO `+mat+` (bucket, device_id, tenant_id, samples, down_samples) + VALUES ($1, $2, $3, 120, $4) + `, day.Add(time.Duration(i)*time.Hour), deviceID, tenantID, down); err != nil { + t.Skipf("не вдалось покласти відро напряму в матеріалізовану таблицю (%v) — "+ + "перевірка потребує доступу до внутрішньої схеми TimescaleDB", err) + } + } + + target, err := st.SaveSLATarget(ctx, tenantID, SLATargetInput{ + Name: "Ядро", + PeriodKind: SLAPeriodDaily, + TargetPct: 99.9, + TZ: "UTC", + MinCoveragePct: 95, + Enabled: true, + }) + if err != nil { + t.Fatalf("ціль: %v", err) + } + + anchor := day.Add(12 * time.Hour) + + // --- 1. Живий розрахунок бачить дані. + rep, err := st.SLAReportFor(ctx, tenantID, target.ID, anchor) + if err != nil { + t.Fatalf("звіт: %v", err) + } + if rep.Closed { + t.Fatal("незакритий період не має видавати себе за закритий") + } + if len(rep.Rows) != 1 { + t.Fatalf("рядків у звіті: %d, очікували 1", len(rep.Rows)) + } + if rep.Rows[0].DownSec != 900 { + t.Fatalf("простій: %d с, очікували 900", rep.Rows[0].DownSec) + } + + // --- 2. Закриття робить число фактом. + closed, err := st.CloseSLAPeriod(ctx, tenantID, target.ID, anchor, false) + if err != nil { + t.Fatalf("закриття: %v", err) + } + if !closed.Closed || closed.Revision != 1 { + t.Fatalf("закритий період: closed=%v revision=%d", closed.Closed, closed.Revision) + } + + // --- 3. Повторне закриття нічого не міняє. + again, err := st.CloseSLAPeriod(ctx, tenantID, target.ID, anchor, false) + if err != nil { + t.Fatalf("повторне закриття: %v", err) + } + if again.Revision != 1 { + t.Fatalf("повторне закриття підняло ревізію до %d", again.Revision) + } + + // --- 4. ГОЛОВНЕ: дані зникли, а звіт лишився тим самим. + // + // Саме це й моделює строк зберігання: за два місяці відер під + // кварталом не буде. Наївний розрахунок після цього дав би інше + // число; закритий період має віддати те саме. + if _, err := st.pool.Exec(ctx, + `DELETE FROM `+mat+` WHERE tenant_id = $1`, tenantID); err != nil { + t.Fatalf("видалення відер: %v", err) + } + after, err := st.SLAReportFor(ctx, tenantID, target.ID, anchor) + if err != nil { + t.Fatalf("звіт після втрати даних: %v", err) + } + if !after.Closed { + t.Fatal("після втрати даних звіт перестав бути закритим") + } + if after.Rows[0].DownSec != closed.Rows[0].DownSec || + after.Totals.UptimePct != closed.Totals.UptimePct { + t.Fatalf("закритий звіт змінився після зникнення даних: було %v%%, стало %v%%", + closed.Totals.UptimePct, after.Totals.UptimePct) + } + + // --- 5. Тригер не дає переписати закритий рядок повз наш код. + if _, err := st.pool.Exec(ctx, ` + UPDATE core.sla_periods SET uptime_pct = 100 + WHERE tenant_id = $1 AND sla_target_id = $2 + `, tenantID, target.ID); err == nil { + t.Fatal("закритий період переписався звичайним UPDATE — тригер незмінності не працює") + } + + // --- 6. Свідомий перерахунок піднімає ревізію. + forced, err := st.CloseSLAPeriod(ctx, tenantID, target.ID, anchor, true) + if err != nil { + t.Fatalf("свідомий перерахунок: %v", err) + } + if forced.Revision != 2 { + t.Fatalf("ревізія після перерахунку: %d, очікували 2", forced.Revision) + } + // Дані ми щойно знесли, тож перерахунок ЧЕСНО дає невідомість, а не + // ті самі числа. Це і є ціна свідомого перерахунку, і саме тому він + // лишає слід. + if !forced.Totals.Insufficient { + t.Fatal("перерахунок по зниклих даних мусить дати «недостатньо даних»") + } + + // --- 7. Закритий період переживає ПОВНЕ видалення хоста. + if _, err := st.pool.Exec(ctx, + `DELETE FROM inv.devices WHERE id = $1`, deviceID); err != nil { + t.Fatalf("видалення хоста: %v", err) + } + var left int + if err := st.pool.QueryRow(ctx, ` + SELECT count(*)::int FROM core.sla_periods WHERE tenant_id = $1 + `, tenantID).Scan(&left); err != nil { + t.Fatalf("перелік періодів: %v", err) + } + if left == 0 { + t.Fatal("повне видалення хоста забрало з собою закритий звіт — " + + "зовнішній ключ із CASCADE повернувся") + } + survived, err := st.SLAReportFor(ctx, tenantID, target.ID, anchor) + if err != nil { + t.Fatalf("звіт після видалення хоста: %v", err) + } + if len(survived.Rows) == 0 || survived.Rows[0].DeviceName == "" { + t.Fatal("у звіті лишився рядок без імені хоста — знімок імені не працює") + } + found := false + for _, w := range survived.Rows[0].Warnings { + if w == SLAWarnDevicePurged { + found = true + } + } + if !found { + t.Fatal("рядок видаленого хоста не позначено попередженням") + } + + // --- 8. Незавершений період закрити не можна навіть на вимогу. + if _, err := st.CloseSLAPeriod(ctx, tenantID, target.ID, time.Now(), true); err == nil { + t.Fatal("поточну добу вдалось закрити — недорахований звіт став би фактом") + } +} + +// slaMaterializedHypertable — фізична таблиця під безперервним агрегатом. +// +// Писати у вигляд не можна, а політика наповнить його не раніше ніж за +// годину. Та сама асиметрія, що описана в 0064: політики й розміри +// живуть на матеріалізованій таблиці, а не на вигляді. +func (s *Store) slaMaterializedHypertable(ctx context.Context, t *testing.T, view string) string { + t.Helper() + var schema, name string + if err := s.pool.QueryRow(ctx, ` + SELECT materialization_hypertable_schema, materialization_hypertable_name + FROM timescaledb_information.continuous_aggregates + WHERE view_schema = split_part($1, '.', 1) AND view_name = split_part($1, '.', 2) + `, view).Scan(&schema, &name); err != nil { + t.Skipf("не знайдено матеріалізованої таблиці для %s: %v", view, err) + } + return schema + "." + name +} diff --git a/server/internal/store/sla_store.go b/server/internal/store/sla_store.go new file mode 100644 index 0000000..8b48cc1 --- /dev/null +++ b/server/internal/store/sla_store.go @@ -0,0 +1,600 @@ +package store + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v5" +) + +// Цілі SLA і закриті періоди: усе, що ходить у базу. +// +// ЧОМУ ДЖЕРЕЛО — ts.icmp_1h, А НЕ СИРІ ВИМІРИ +// +// Це не питання швидкості, а питання того, чи буде звіт тим самим через +// два місяці. Строки з 0005, як їх бачить 0064: +// +// ts.icmp_samples 35 діб +// ts.icmp_5m 400 діб +// ts.icmp_1h строку немає +// +// Квартал — 90–92 доби. Порахований по сирих вимірах, він СЬОГОДНІ +// правильний, а через два місяці той самий запит на той самий квартал +// дасть інше число: рядків під ним просто не буде, а «немає рядка» й +// «втрат не було» запит не розрізняє. Звіт мовчки поїде до 100%. +// +// ts.icmp_5m покриває квартал, але 400 діб — це політика, яку 0064 дала +// міняти з веб-форми. ts.icmp_1h — єдиний рівень, якому продукт уже +// пообіцяв жити: 0005 не заводить йому політики зовсім, а +// retention_policy.go ставить нижню межу 30 діб зі словами «місячні +// звіти читають саме звідси». +// +// І головне — навіть цього замало. Тому закритий період не читає дані +// взагалі: він читає сам себе. Див. CloseSLAPeriod. + +// SLATarget — ціль доступності. +type SLATarget struct { + ID string `json:"id"` + Name string `json:"name"` + Selector Selector `json:"selector"` + // TargetPct — обіцяний відсоток. 99.9 за квартал — це 2 год 12 хв + // допустимого простою. + TargetPct float64 `json:"target_pct"` + PeriodKind string `json:"period_kind"` + TZ string `json:"tz"` + MinCoveragePct float64 `json:"min_coverage_pct"` + Enabled bool `json:"enabled"` + // BusinessHours лежить у схемі з 0008 і НЕ реалізований. Поле + // віддається як є, щоб форма могла показати його заповненим і + // поруч — попередження: розрахунок його не звужує. + BusinessHours json.RawMessage `json:"business_hours,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// Location — пояс цілі, розібраний. +// +// Невідоме ім'я поясу не є приводом упасти: у контейнері без tzdata +// LoadLocation не знайде нічого взагалі, і тоді звіт має вийти в UTC із +// попередженням, а не не вийти зовсім. +func (t SLATarget) Location() (*time.Location, bool) { + if t.TZ == "" { + return time.UTC, true + } + loc, err := time.LoadLocation(t.TZ) + if err != nil { + return time.UTC, false + } + return loc, true +} + +// Коди попереджень. Короткі ключі, бо лежать у jsonb закритого періоду й +// читаються через рік; людські формулювання — у slaWarningLabels. +const ( + // Серед вікон обслуговування трапилось повторюване (rrule). RFC 5545 + // у продукті не розгортає ніхто — ані придушення алертів, ані цей + // розрахунок. Враховано лише базове входження вікна. + SLAWarnRRule = "rrule_ignored" + // У цілі заповнені робочі години. Розрахунок їх не звужує. + SLAWarnBusinessHours = "business_hours_ignored" + // Період починається раніше, ніж сягає збережена історія годинних + // згорток. Частина періоду не має даних НЕ тому, що їх не збирали. + SLAWarnBeyondHorizon = "beyond_horizon" + // Пояс цілі не впізнано, рахували в UTC. + SLAWarnUnknownTZ = "unknown_tz" + // Хоста вже немає: рядок пережив повне видалення (0057). Ім'я + // показується зі знімка. + SLAWarnDevicePurged = "device_purged" +) + +var slaWarningLabels = map[string]string{ + SLAWarnRRule: "повторювані вікна обслуговування враховано лише першим входженням", + SLAWarnBusinessHours: "робочі години задано, але розрахунок їх не звужує", + SLAWarnBeyondHorizon: "початок періоду старший за збережену історію годинних згорток", + SLAWarnUnknownTZ: "часовий пояс цілі не впізнано, рахували в UTC", + SLAWarnDevicePurged: "хост видалено назавжди; показано ім'я на момент розрахунку", +} + +// SLAWarningLabel — людське формулювання коду попередження. +func SLAWarningLabel(code string) string { + if s, ok := slaWarningLabels[code]; ok { + return s + } + return code +} + +// SLARow — один хост за один період. +type SLARow struct { + DeviceID string `json:"device_id"` + DeviceName string `json:"device_name"` + SLAOutcome + Warnings []string `json:"warnings"` +} + +// SLAHorizon — скільки історії РЕАЛЬНО є під цим звітом. +// +// Їде в кожній відповіді, а не лежить у довідці. Питання «чому за +// березень порожньо» має відповідь у тому самому вікні, де видно +// порожнечу, — інакше на нього відповідає підтримка, і щоразу заново. +type SLAHorizon struct { + // Relation — з чого рахували. + Relation string `json:"relation"` + // KeepDays — строк на це відношення; nil означає «не видаляється». + KeepDays *int `json:"keep_days"` + // Oldest — найстаріше годинне відро, яке зараз є в базі для цього + // кабінету. Це і є фактичний горизонт, на відміну від наміру. + Oldest *time.Time `json:"oldest_bucket,omitempty"` +} + +// slaBeyondHorizon — чи починається період раніше, ніж сягають дані. +// +// Окрема функція, а не умова по місцю, рівно з однієї причини: це +// найважче помітна з усіх помилок звіту. Період, половина якого просто +// видалена за строком зберігання, виглядає точнісінько як період, у +// якому півмережі не опитувалось, — і обидва дають те саме низьке +// покриття. Різницю знає лише ця умова, і перевіряти її треба тестом, а +// не читанням. +func slaBeyondHorizon(h SLAHorizon, from time.Time) bool { + if h.Oldest == nil { + // Даних немає взагалі. Це не «за межею горизонту», а порожня + // база: горизонту, який можна перетнути, ще не існує. + return false + } + return h.Oldest.After(from) +} + +// SLAReport — звіт цілі за один період. +type SLAReport struct { + Target SLATarget `json:"target"` + + // Межі періоду. From/To — абсолютний час, PeriodFrom/PeriodTo — + // календарні дати в поясі цілі (те, що читає людина й що лежить у + // daterange). + From time.Time `json:"from"` + To time.Time `json:"to"` + PeriodFrom string `json:"period_from"` + PeriodTo string `json:"period_to"` + + // Closed — рядки прочитано з core.sla_periods, а не пораховано. + // Саме це відрізняє звіт від прикидки, і саме це має бачити той, + // хто збирається його роздрукувати. + Closed bool `json:"closed"` + ComputedAt *time.Time `json:"computed_at,omitempty"` + Revision int `json:"revision,omitempty"` + + // Closable — період уже скінчився й згортки під ним устоялись. + Closable bool `json:"closable"` + + Rows []SLARow `json:"rows"` + Horizon SLAHorizon `json:"horizon"` + Warnings []string `json:"warnings"` + Totals SLAOutcome `json:"totals"` +} + +// --------------------------------------------------------------------- +// Цілі +// --------------------------------------------------------------------- + +// SLATargets — усі цілі кабінету. +func (s *Store) SLATargets(ctx context.Context, tenantID string) ([]SLATarget, error) { + var out []SLATarget + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, ` + SELECT id::text, name, selector::text, target_pct, period_kind, tz, + min_coverage_pct, enabled, + COALESCE(business_hours::text, ''), created_at + FROM core.sla_targets + WHERE tenant_id = $1 + ORDER BY name + `, tenantID) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var t SLATarget + var sel, bh string + if err := rows.Scan(&t.ID, &t.Name, &sel, &t.TargetPct, &t.PeriodKind, + &t.TZ, &t.MinCoveragePct, &t.Enabled, &bh, &t.CreatedAt); err != nil { + return err + } + // Зіпсований селектор не має ховати всю ціль: порожній + // селектор означає «весь кабінет», і це видно на екрані. + _ = json.Unmarshal([]byte(sel), &t.Selector) + if bh != "" && bh != "null" { + t.BusinessHours = json.RawMessage(bh) + } + out = append(out, t) + } + return rows.Err() + }) + return out, err +} + +func (s *Store) slaTarget(ctx context.Context, tx pgx.Tx, tenantID, id string) (SLATarget, error) { + var t SLATarget + var sel, bh string + err := tx.QueryRow(ctx, ` + SELECT id::text, name, selector::text, target_pct, period_kind, tz, + min_coverage_pct, enabled, COALESCE(business_hours::text, ''), created_at + FROM core.sla_targets WHERE tenant_id = $1 AND id = $2 + `, tenantID, id).Scan(&t.ID, &t.Name, &sel, &t.TargetPct, &t.PeriodKind, &t.TZ, + &t.MinCoveragePct, &t.Enabled, &bh, &t.CreatedAt) + if err == pgx.ErrNoRows { + return t, ErrNotFound + } + if err != nil { + return t, err + } + _ = json.Unmarshal([]byte(sel), &t.Selector) + if bh != "" && bh != "null" { + t.BusinessHours = json.RawMessage(bh) + } + return t, nil +} + +// SLATargetInput — те, що приходить із форми. +type SLATargetInput struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + Selector Selector `json:"selector"` + TargetPct float64 `json:"target_pct"` + PeriodKind string `json:"period_kind"` + TZ string `json:"tz"` + MinCoveragePct float64 `json:"min_coverage_pct"` + Enabled bool `json:"enabled"` +} + +// ValidateSLATarget перевіряє ціль до того, як вона щось порахує. +// +// Окрема функція, а не перевірки по місцю: рівно ті самі умови треба +// прикласти і в REST, і у фоновому такті, а розійшовшись, вони дали б +// ціль, яку форма приймає, а розрахунок не розуміє. +func ValidateSLATarget(in SLATargetInput) error { + if strings.TrimSpace(in.Name) == "" { + return fmt.Errorf("%w: ціль без назви", ErrInvalid) + } + switch in.PeriodKind { + case SLAPeriodDaily, SLAPeriodWeekly, SLAPeriodMonthly, SLAPeriodQuarterly: + default: + return fmt.Errorf("%w: невідомий тип періоду %q", ErrInvalid, in.PeriodKind) + } + // Нижня межа не нуль: ціль «0% доступності» не означає нічого, а + // набирається однією помилкою в порожньому полі. + if in.TargetPct <= 0 || in.TargetPct > 100 { + return fmt.Errorf("%w: ціль доступності має бути в межах (0; 100]", ErrInvalid) + } + if in.MinCoveragePct < 0 || in.MinCoveragePct > 100 { + return fmt.Errorf("%w: поріг покриття має бути в межах [0; 100]", ErrInvalid) + } + if in.TZ != "" { + if _, err := time.LoadLocation(in.TZ); err != nil { + return fmt.Errorf("%w: невідомий часовий пояс %q", ErrInvalid, in.TZ) + } + } + return nil +} + +// SaveSLATarget заводить або оновлює ціль. +// +// Пояс і тип періоду ЗМІНЮВАТИ можна, і це навмисно: помилку в них +// інакше довелось би виправляти видаленням цілі разом з усіма закритими +// періодами. Наслідок, який знімається знімками в самому періоді: старі +// закриті рядки лишаються порахованими за старим поясом і старою ціллю, +// і в них це записано. +func (s *Store) SaveSLATarget(ctx context.Context, tenantID string, in SLATargetInput) (SLATarget, error) { + if err := ValidateSLATarget(in); err != nil { + return SLATarget{}, err + } + sel, err := json.Marshal(in.Selector) + if err != nil { + return SLATarget{}, err + } + tz := in.TZ + if tz == "" { + tz = "UTC" + } + + var out SLATarget + err = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + var id string + if in.ID != "" { + err := tx.QueryRow(ctx, ` + UPDATE core.sla_targets + SET name = $3, selector = $4::jsonb, target_pct = $5, + period_kind = $6, tz = $7, min_coverage_pct = $8, enabled = $9 + WHERE tenant_id = $1 AND id = $2 + RETURNING id::text + `, tenantID, in.ID, in.Name, string(sel), in.TargetPct, + in.PeriodKind, tz, in.MinCoveragePct, in.Enabled).Scan(&id) + if err == pgx.ErrNoRows { + return ErrNotFound + } + if err != nil { + return err + } + } else { + if err := tx.QueryRow(ctx, ` + INSERT INTO core.sla_targets + (tenant_id, name, selector, target_pct, period_kind, tz, + min_coverage_pct, enabled) + VALUES ($1, $2, $3::jsonb, $4, $5, $6, $7, $8) + RETURNING id::text + `, tenantID, in.Name, string(sel), in.TargetPct, in.PeriodKind, tz, + in.MinCoveragePct, in.Enabled).Scan(&id); err != nil { + return err + } + } + out, err = s.slaTarget(ctx, tx, tenantID, id) + return err + }) + return out, err +} + +// DeleteSLATarget видаляє ціль РАЗОМ із закритими періодами. +// +// Каскад тут лишається навмисно, на відміну від хоста: період без цілі +// не має сенсу — у ньому немає ані обіцяного відсотка, ані переліку +// хостів. А от кнопка мусить казати це вголос, тому кількість періодів, +// які зникнуть, повертається викликачеві до підтвердження. +func (s *Store) DeleteSLATarget(ctx context.Context, tenantID, id string) (int, error) { + var n int + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + if err := tx.QueryRow(ctx, ` + SELECT count(*)::int FROM core.sla_periods + WHERE tenant_id = $1 AND sla_target_id = $2 + `, tenantID, id).Scan(&n); err != nil { + return err + } + // Тригер незмінності стоїть і на DELETE: без явного дозволу + // каскад від цілі не зніс би жодного закритого періоду, і + // видалення падало б із незрозумілою помилкою. + if _, err := tx.Exec(ctx, `SELECT set_config('app.sla_reopen', 'on', true)`); err != nil { + return err + } + tag, err := tx.Exec(ctx, ` + DELETE FROM core.sla_targets WHERE tenant_id = $1 AND id = $2 + `, tenantID, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil + }) + return n, err +} + +// --------------------------------------------------------------------- +// Звіт +// --------------------------------------------------------------------- + +// SLAReportFor — звіт цілі за період, у який потрапляє anchor. +// +// Порядок дій тут і є все рішення задачі: +// +// 1. Якщо період ЗАКРИТО — читаємо збережені рядки й більше нічого не +// рахуємо. Дані під ними могли вже зникнути; висновок лишився. +// 2. Інакше рахуємо по ts.icmp_1h і віддаємо з позначкою «попередній». +func (s *Store) SLAReportFor(ctx context.Context, tenantID, targetID string, anchor time.Time) (SLAReport, error) { + var rep SLAReport + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + t, err := s.slaTarget(ctx, tx, tenantID, targetID) + if err != nil { + return err + } + rep, err = s.slaReportTx(ctx, tx, tenantID, t, anchor) + return err + }) + return rep, err +} + +func (s *Store) slaReportTx(ctx context.Context, tx pgx.Tx, tenantID string, + t SLATarget, anchor time.Time) (SLAReport, error) { + + loc, tzOK := t.Location() + span, err := SLAPeriodBounds(t.PeriodKind, anchor, loc) + if err != nil { + return SLAReport{}, err + } + + rep := SLAReport{ + Target: t, + From: span.From, + To: span.To, + PeriodFrom: span.From.In(loc).Format("2006-01-02"), + PeriodTo: span.To.In(loc).Format("2006-01-02"), + Closable: !time.Now().Before(span.To.Add(SLASettleLag)), + } + if !tzOK { + rep.Warnings = append(rep.Warnings, SLAWarnUnknownTZ) + } + if len(t.BusinessHours) > 0 { + rep.Warnings = append(rep.Warnings, SLAWarnBusinessHours) + } + if rep.Horizon, err = s.slaHorizon(ctx, tx, tenantID); err != nil { + return rep, err + } + if slaBeyondHorizon(rep.Horizon, span.From) { + rep.Warnings = append(rep.Warnings, SLAWarnBeyondHorizon) + } + + // 1. Уже закритий період читається, а не рахується. + stored, err := s.slaStoredRows(ctx, tx, tenantID, t.ID, span, loc) + if err != nil { + return rep, err + } + if len(stored.Rows) > 0 && stored.Closed { + rep.Closed = true + rep.Rows = stored.Rows + rep.ComputedAt = stored.ComputedAt + rep.Revision = stored.Revision + // Знімки з рядка перекривають поточну ціль: звіт має показувати + // умови, за якими його виносили, а не сьогоднішні. + if stored.TargetPct > 0 { + rep.Target.TargetPct = stored.TargetPct + } + if stored.TZ != "" { + rep.Target.TZ = stored.TZ + } + // Підсумок теж рахується проти ЗНІМКІВ, а не проти сьогоднішньої + // цілі: інакше піднятий сьогодні поріг покриття заднім числом + // зробив би торішній вердикт «невідомим». + rep.Totals = slaTotals(rep.Rows, stored.TargetPct, stored.MinCoverage) + return rep, nil + } + + // 2. Живий розрахунок. + rows, warn, err := s.slaComputeRows(ctx, tx, tenantID, t, span) + if err != nil { + return rep, err + } + rep.Rows = rows + rep.Warnings = slaAppendUnique(rep.Warnings, warn...) + rep.Totals = slaTotals(rows, t.TargetPct, t.MinCoveragePct) + return rep, nil +} + +// slaHorizon — фактичний горизонт даних під звітом. +func (s *Store) slaHorizon(ctx context.Context, tx pgx.Tx, tenantID string) (SLAHorizon, error) { + h := SLAHorizon{Relation: "ts.icmp_1h"} + // Строк читаємо з core.retention_settings, а не з політики + // TimescaleDB: саме він показує НАМІР людини, і саме його вона + // щойно могла змінити. NULL тут — «не видаляється», і це поточний + // стан продукту. + if err := tx.QueryRow(ctx, ` + SELECT keep_days FROM core.retention_settings WHERE relation = 'ts.icmp_1h' + `).Scan(&h.KeepDays); err != nil && err != pgx.ErrNoRows { + return h, err + } + // А це — факт: найстаріше відро, яке справді лежить. Розходження + // між ним і строком означає, що дані молодші за політику (нова + // інсталяція) — і саме воно пояснює порожній звіт за минулий рік. + if err := tx.QueryRow(ctx, ` + SELECT min(bucket) FROM ts.icmp_1h WHERE tenant_id = $1 + `, tenantID).Scan(&h.Oldest); err != nil && err != pgx.ErrNoRows { + return h, err + } + return h, nil +} + +type slaStored struct { + Closed bool + Rows []SLARow + ComputedAt *time.Time + Revision int + // Знімки умов, за яких виносився вердикт. Читаються з рядка, а не з + // цілі: ціль живе далі й могла змінитись, а торішній «виконано» не + // має ставати «порушено» без жодної події в мережі. + TargetPct float64 + MinCoverage float64 + TZ string +} + +func (s *Store) slaStoredRows(ctx context.Context, tx pgx.Tx, tenantID, targetID string, + span SLASpan, loc *time.Location) (slaStored, error) { + + var out slaStored + rows, err := tx.Query(ctx, ` + SELECT COALESCE(p.device_id::text, ''), p.device_name, + p.clock_sec, p.maintenance_sec, p.up_sec, p.downtime_sec, p.unknown_sec, + p.uptime_pct, p.coverage_pct, p.incidents, p.breached, + p.warnings::text, p.closed, p.revision, p.computed_at, + COALESCE(p.target_pct, 0), COALESCE(p.min_coverage_pct, 0), p.tz, + (d.id IS NULL) AS purged + FROM core.sla_periods p + LEFT JOIN inv.devices d ON d.id = p.device_id + WHERE p.tenant_id = $1 AND p.sla_target_id = $2 + AND p.period = daterange($3::date, $4::date) + ORDER BY p.device_name + `, tenantID, targetID, span.From.In(loc).Format("2006-01-02"), span.To.In(loc).Format("2006-01-02")) + if err != nil { + return out, err + } + defer rows.Close() + + for rows.Next() { + var r SLARow + var warn string + var closed bool + var rev int + var at time.Time + var tgt, cov float64 + var tz string + var purged bool + if err := rows.Scan(&r.DeviceID, &r.DeviceName, + &r.ClockSec, &r.MaintenanceSec, &r.UpSec, &r.DownSec, &r.UnknownSec, + &r.UptimePct, &r.CoveragePct, &r.Incidents, &r.Breached, + &warn, &closed, &rev, &at, &tgt, &cov, &tz, &purged); err != nil { + return out, err + } + _ = json.Unmarshal([]byte(warn), &r.Warnings) + if purged && r.DeviceID != "" { + r.Warnings = slaAppendUnique(r.Warnings, SLAWarnDevicePurged) + } + // Недостатність відновлюється зі знімка порога, а не з поточної + // цілі: інакше піднятий сьогодні поріг заднім числом зробив би + // торішній вердикт «невідомим». + r.Insufficient = r.ClockSec-r.MaintenanceSec == 0 || + r.UpSec+r.DownSec == 0 || r.CoveragePct < cov + out.Rows = append(out.Rows, r) + out.Closed = closed + out.Revision = rev + out.ComputedAt = &at + out.TargetPct = tgt + out.MinCoverage = cov + out.TZ = tz + } + return out, rows.Err() +} + +// slaTotals — підсумок по цілі. +// +// Підсумкова доступність рахується з СУМИ секунд, а не як середнє +// відсотків по хостах. Різниця не косметична: середнє відсотків дає +// однакову вагу хосту, що прожив у періоді добу, і хосту, що прожив +// квартал, — тобто новий хост, заведений 30 числа й одразу впалий, +// зіпсував би місяць усій групі. +func slaTotals(rows []SLARow, targetPct, minCoverage float64) SLAOutcome { + var t SLAOutcome + for _, r := range rows { + t.ClockSec += r.ClockSec + t.MaintenanceSec += r.MaintenanceSec + t.UpSec += r.UpSec + t.DownSec += r.DownSec + t.UnknownSec += r.UnknownSec + t.Incidents += r.Incidents + } + measured := t.UpSec + t.DownSec + billable := t.ClockSec - t.MaintenanceSec + if measured > 0 { + t.UptimePct = slaRound3(100 * float64(t.UpSec) / float64(measured)) + } + if billable > 0 { + t.CoveragePct = slaRound3(100 * float64(measured) / float64(billable)) + } + t.Insufficient = billable == 0 || measured == 0 || t.CoveragePct < minCoverage + if !t.Insufficient { + t.Breached = t.UptimePct < targetPct + } + return t +} + +func slaAppendUnique(dst []string, add ...string) []string { + for _, a := range add { + found := false + for _, d := range dst { + if d == a { + found = true + break + } + } + if !found { + dst = append(dst, a) + } + } + return dst +} diff --git a/server/internal/store/sla_test.go b/server/internal/store/sla_test.go new file mode 100644 index 0000000..eb90c1a --- /dev/null +++ b/server/internal/store/sla_test.go @@ -0,0 +1,640 @@ +package store + +import ( + "strings" + "testing" + "time" +) + +// Перевірки розрахунку доступності. +// +// ЩО САМЕ ТУТ ПЕРЕВІРЯЄТЬСЯ, І ЧОМУ САМЕ ЦЕ +// +// Не «формула ділить правильно» — вона ділить правильно з першого разу. +// Перевіряються МЕЖІ, тобто ті випадки, у яких звіт мовчки бреше замість +// того, щоб упасти: +// +// - період, у якому взагалі немає даних; +// - період, частина якого лежить за строком зберігання; +// - хост, заведений посеред періоду; +// - хост, видалений посеред періоду; +// - вікно обслуговування на межі доби. +// +// Спільна риса всіх п'яти: жоден із них не дає помилки. Кожен дає число, +// і саме тому їх треба перевіряти числом, а не оком. + +func slaTime(s string) time.Time { + t, err := time.Parse(time.RFC3339, s) + if err != nil { + panic(err) + } + return t +} + +// slaHours робить рівний ряд відер: n годин від start, по samples спроб, +// з яких down невдалих. +func slaHours(start time.Time, n int, samples, down int64) []SLAHourBucket { + out := make([]SLAHourBucket, 0, n) + for i := 0; i < n; i++ { + out = append(out, SLAHourBucket{ + Start: start.Add(time.Duration(i) * time.Hour), + Samples: samples, + Down: down, + }) + } + return out +} + +// Базовий випадок, від якого рахуються всі інші: доба, повний ряд відер, +// жодної втрати. +func TestSLAFullDayNoLoss(t *testing.T) { + from := slaTime("2026-04-01T00:00:00Z") + out := ComputeSLA(SLAInput{ + From: from, + To: from.AddDate(0, 0, 1), + CreatedAt: slaTime("2026-01-01T00:00:00Z"), + Buckets: slaHours(from, 24, 120, 0), + TargetPct: 99.9, + MinCoveragePct: 95, + }) + if out.ClockSec != 86400 { + t.Fatalf("годинник: %d, очікували 86400", out.ClockSec) + } + if out.UpSec != 86400 || out.DownSec != 0 || out.UnknownSec != 0 { + t.Fatalf("розклад часу: up=%d down=%d unknown=%d", out.UpSec, out.DownSec, out.UnknownSec) + } + if out.UptimePct != 100 || out.CoveragePct != 100 { + t.Fatalf("доступність %v при покритті %v", out.UptimePct, out.CoveragePct) + } + if out.Insufficient || out.Breached { + t.Fatal("повна доба без втрат не може бути ні порушенням, ні невідомістю") + } +} + +// МЕЖА 1: даних немає ЖОДНИХ. +// +// Найдорожчий випадок у всій задачі. Наївний розрахунок дає тут 100% — +// «серед вимірів не було невдалих», — і саме цей звіт роздрукують, бо +// він гарний. Правильна відповідь — «не знаємо», і вона не має жодного +// шансу зійти за «виконано». +func TestSLAEmptyPeriodIsNotHundredPercent(t *testing.T) { + from := slaTime("2026-04-01T00:00:00Z") + out := ComputeSLA(SLAInput{ + From: from, + To: from.AddDate(0, 0, 1), + CreatedAt: slaTime("2026-01-01T00:00:00Z"), + TargetPct: 99.9, + MinCoveragePct: 95, + }) + if out.UptimePct != 0 { + t.Fatalf("доступність без жодного виміру має лишитись нулем, а не %v", out.UptimePct) + } + if out.CoveragePct != 0 { + t.Fatalf("покриття: %v, очікували 0", out.CoveragePct) + } + if out.UnknownSec != 86400 { + t.Fatalf("невідомий час: %d, очікували цілу добу", out.UnknownSec) + } + if out.UpSec != 0 { + t.Fatalf("порожній період дав %d секунд доступності — саме та помилка, від якої все це написано", out.UpSec) + } + if !out.Insufficient { + t.Fatal("період без даних мусить бути позначений як недостатній") + } + if out.Breached { + t.Fatal("недостатність даних — це не порушення SLA") + } + if v := SLAVerdict(out); v != "недостатньо даних" { + t.Fatalf("вердикт %q замість «недостатньо даних»", v) + } +} + +// Половина доби без даних: покриття 50%, і вердикту немає. +// +// Тут і видно, навіщо покриття окремим числом. Доступність із виміряної +// половини — усі 100%, і без порога вона поїхала б у звіт як «виконано». +func TestSLAHalfSilenceBlocksVerdict(t *testing.T) { + from := slaTime("2026-04-01T00:00:00Z") + out := ComputeSLA(SLAInput{ + From: from, + To: from.AddDate(0, 0, 1), + CreatedAt: slaTime("2026-01-01T00:00:00Z"), + Buckets: slaHours(from, 12, 120, 0), + TargetPct: 99.9, + MinCoveragePct: 95, + }) + if out.CoveragePct != 50 { + t.Fatalf("покриття: %v, очікували 50", out.CoveragePct) + } + if out.UptimePct != 100 { + t.Fatalf("доступність із виміряного: %v, очікували 100", out.UptimePct) + } + if !out.Insufficient { + t.Fatal("покриття 50% нижче за поріг 95% — вердикту бути не має") + } + if out.UnknownSec != 43200 { + t.Fatalf("невідомий час: %d, очікували півдоби", out.UnknownSec) + } +} + +// Втрати всередині години розкладаються часткою спроб. +func TestSLAPartialLossInsideHour(t *testing.T) { + from := slaTime("2026-04-01T00:00:00Z") + buckets := slaHours(from, 24, 120, 0) + // Одна година: чверть спроб не відповіла — це 15 хвилин простою. + buckets[3].Down = 30 + + out := ComputeSLA(SLAInput{ + From: from, + To: from.AddDate(0, 0, 1), + CreatedAt: slaTime("2026-01-01T00:00:00Z"), + Buckets: buckets, + TargetPct: 99.9, + MinCoveragePct: 95, + }) + if out.DownSec != 900 { + t.Fatalf("простій: %d с, очікували 900", out.DownSec) + } + if out.UpSec+out.DownSec+out.UnknownSec+out.MaintenanceSec != out.ClockSec { + t.Fatalf("частини не сходяться з цілим: %d+%d+%d+%d != %d", + out.UpSec, out.DownSec, out.UnknownSec, out.MaintenanceSec, out.ClockSec) + } + if !out.Breached { + t.Fatalf("15 хвилин простою за добу — це %v%%, нижче за ціль 99.9", out.UptimePct) + } +} + +// МЕЖА 2: хост заведено посеред періоду. +// +// Без цієї межі новий хост, який з'явився 20 числа, отримав би 19 діб +// «недоступності» — і зіпсував би місяць усій групі рівно тим, що його +// завели. +func TestSLADeviceCreatedMidPeriod(t *testing.T) { + from := slaTime("2026-04-01T00:00:00Z") + to := from.AddDate(0, 1, 0) + created := slaTime("2026-04-21T00:00:00Z") + + out := ComputeSLA(SLAInput{ + From: from, + To: to, + CreatedAt: created, + // Дані є рівно з дня заведення й до кінця місяця: 10 діб. + Buckets: slaHours(created, 10*24, 120, 0), + TargetPct: 99.9, + MinCoveragePct: 95, + }) + if want := int64(10 * 86400); out.ClockSec != want { + t.Fatalf("годинник: %d, очікували %d (лише з дня заведення)", out.ClockSec, want) + } + if out.UnknownSec != 0 { + t.Fatalf("до заведення хоста часу бути не має, а є %d с невідомого", out.UnknownSec) + } + if out.CoveragePct != 100 || out.UptimePct != 100 { + t.Fatalf("покриття %v, доступність %v — очікували по 100", out.CoveragePct, out.UptimePct) + } + if out.Insufficient { + t.Fatal("десять повністю виміряних діб — це достатньо для вердикту") + } +} + +// МЕЖА 3: хост видалено посеред періоду. +// +// Дзеркальна помилка: без звуження годинника архівований 10 числа хост +// показав би 20 діб мовчання й потягнув би покриття групи вниз — при +// тому що після 10 числа його просто не існувало. +func TestSLADeviceDeletedMidPeriod(t *testing.T) { + from := slaTime("2026-04-01T00:00:00Z") + to := from.AddDate(0, 1, 0) + deleted := slaTime("2026-04-11T00:00:00Z") + + out := ComputeSLA(SLAInput{ + From: from, + To: to, + CreatedAt: slaTime("2026-01-01T00:00:00Z"), + DeletedAt: &deleted, + Buckets: slaHours(from, 10*24, 120, 0), + TargetPct: 99.9, + MinCoveragePct: 95, + }) + if want := int64(10 * 86400); out.ClockSec != want { + t.Fatalf("годинник: %d, очікували %d (лише до видалення)", out.ClockSec, want) + } + if out.CoveragePct != 100 { + t.Fatalf("покриття: %v, очікували 100", out.CoveragePct) + } +} + +// Хоста в періоді не існувало зовсім — рядок є, чисел немає. +func TestSLADeviceOutsidePeriodEntirely(t *testing.T) { + from := slaTime("2026-04-01T00:00:00Z") + out := ComputeSLA(SLAInput{ + From: from, + To: from.AddDate(0, 1, 0), + CreatedAt: slaTime("2026-06-01T00:00:00Z"), + TargetPct: 99.9, + MinCoveragePct: 95, + }) + if out.ClockSec != 0 || out.UnknownSec != 0 { + t.Fatalf("хоста не було: годинник %d, невідомо %d — очікували нулі", + out.ClockSec, out.UnknownSec) + } + if !out.Insufficient || out.Breached { + t.Fatal("неіснуючий хост не порушує SLA й не виконує його") + } +} + +// МЕЖА 4: вікно обслуговування на межі доби. +// +// Вікно 23:00–01:00 не належить жодній добі цілком. Дві типові помилки: +// приписати його одній добі повністю (тоді друга доба псується) або +// відкинути як «не вміщається» (тоді псуються обидві). +func TestSLAMaintenanceAcrossMidnight(t *testing.T) { + from := slaTime("2026-04-02T00:00:00Z") + to := from.AddDate(0, 0, 1) + + buckets := slaHours(from, 24, 120, 0) + // Перша година доби — суцільна недоступність, але вона накрита + // вікном обслуговування, що почалось учора о 23:00. + buckets[0].Down = 120 + + out := ComputeSLA(SLAInput{ + From: from, + To: to, + CreatedAt: slaTime("2026-01-01T00:00:00Z"), + Maintenance: []SLASpan{{ + From: slaTime("2026-04-01T23:00:00Z"), + To: slaTime("2026-04-02T01:00:00Z"), + }}, + Buckets: buckets, + TargetPct: 99.9, + MinCoveragePct: 95, + }) + if out.MaintenanceSec != 3600 { + t.Fatalf("обслуговування: %d с, очікували 3600 — у цю добу потрапила лише його половина", + out.MaintenanceSec) + } + if out.DownSec != 0 { + t.Fatalf("простій під вікном обслуговування не рахується, а нарахувало %d с", out.DownSec) + } + if out.UptimePct != 100 { + t.Fatalf("доступність: %v, очікували 100", out.UptimePct) + } + // Годинник зупинявся, тому знаменник покриття — 23 години, а не 24. + if out.CoveragePct != 100 { + t.Fatalf("покриття: %v — вікно має виходити й зі знаменника теж", out.CoveragePct) + } + if out.UpSec+out.DownSec+out.UnknownSec+out.MaintenanceSec != out.ClockSec { + t.Fatal("частини не сходяться з цілим") + } +} + +// Вікна, що перетинаються, не рахуються двічі. +func TestSLAOverlappingMaintenanceCountedOnce(t *testing.T) { + from := slaTime("2026-04-02T00:00:00Z") + out := ComputeSLA(SLAInput{ + From: from, + To: from.AddDate(0, 0, 1), + CreatedAt: slaTime("2026-01-01T00:00:00Z"), + Maintenance: []SLASpan{ + {From: slaTime("2026-04-02T02:00:00Z"), To: slaTime("2026-04-02T04:00:00Z")}, + {From: slaTime("2026-04-02T03:00:00Z"), To: slaTime("2026-04-02T05:00:00Z")}, + }, + Buckets: slaHours(from, 24, 120, 0), + TargetPct: 99.9, + MinCoveragePct: 95, + }) + if out.MaintenanceSec != 3*3600 { + t.Fatalf("обслуговування: %d с, очікували 10800 (02:00–05:00 без подвійного рахунку)", + out.MaintenanceSec) + } +} + +// Обслуговування не має ПОКРАЩУВАТИ звіт. +// +// Спокуслива помилка: зарахувати вікно як доступність. Тоді місяць, у +// якому була година аварії й десять годин планових робіт, вийшов би +// кращим за місяць без робіт узагалі. +func TestSLAMaintenanceDoesNotImproveUptime(t *testing.T) { + from := slaTime("2026-04-02T00:00:00Z") + buckets := slaHours(from, 24, 120, 0) + buckets[10].Down = 120 // година справжньої аварії + + base := SLAInput{ + From: from, + To: from.AddDate(0, 0, 1), + CreatedAt: slaTime("2026-01-01T00:00:00Z"), + Buckets: buckets, + TargetPct: 99, + MinCoveragePct: 95, + } + without := ComputeSLA(base) + + base.Maintenance = []SLASpan{{ + From: slaTime("2026-04-02T20:00:00Z"), + To: slaTime("2026-04-02T22:00:00Z"), + }} + with := ComputeSLA(base) + + if with.UptimePct > without.UptimePct { + t.Fatalf("планові роботи покращили звіт: %v проти %v", with.UptimePct, without.UptimePct) + } + if with.DownSec != without.DownSec { + t.Fatalf("аварія поза вікном змінилась через вікно: %d проти %d", + with.DownSec, without.DownSec) + } +} + +// Смуги недоступності: година мовчання посеред аварії не ділить її надвоє. +func TestSLAIncidentsSurviveDataGap(t *testing.T) { + from := slaTime("2026-04-02T00:00:00Z") + buckets := slaHours(from, 24, 120, 0) + buckets[5].Down = 120 + buckets[7].Down = 120 + // Шоста година — без даних узагалі: відро прибрано. + buckets = append(buckets[:6], buckets[7:]...) + + out := ComputeSLA(SLAInput{ + From: from, + To: from.AddDate(0, 0, 1), + CreatedAt: slaTime("2026-01-01T00:00:00Z"), + Buckets: buckets, + TargetPct: 99.9, + MinCoveragePct: 50, + }) + if out.Incidents != 1 { + t.Fatalf("смуг недоступності: %d, очікували 1 — діра в даних не є відновленням", + out.Incidents) + } +} + +// Дві аварії, розділені справді робочою годиною, — це дві смуги. +func TestSLAIncidentsSplitByGoodHour(t *testing.T) { + from := slaTime("2026-04-02T00:00:00Z") + buckets := slaHours(from, 24, 120, 0) + buckets[5].Down = 120 + buckets[7].Down = 120 + + out := ComputeSLA(SLAInput{ + From: from, + To: from.AddDate(0, 0, 1), + CreatedAt: slaTime("2026-01-01T00:00:00Z"), + Buckets: buckets, + TargetPct: 99.9, + MinCoveragePct: 50, + }) + if out.Incidents != 2 { + t.Fatalf("смуг недоступності: %d, очікували 2", out.Incidents) + } +} + +// Відро з нулем спроб — це мовчання, а не «втрат не було». +func TestSLAZeroSampleBucketIsSilence(t *testing.T) { + from := slaTime("2026-04-02T00:00:00Z") + out := ComputeSLA(SLAInput{ + From: from, + To: from.Add(2 * time.Hour), + CreatedAt: slaTime("2026-01-01T00:00:00Z"), + Buckets: []SLAHourBucket{{Start: from, Samples: 0}}, + TargetPct: 99.9, + MinCoveragePct: 95, + }) + if out.UpSec != 0 { + t.Fatalf("порожнє відро дало %d с доступності", out.UpSec) + } + if out.UnknownSec != 7200 { + t.Fatalf("невідомий час: %d, очікували 7200", out.UnknownSec) + } +} + +// --------------------------------------------------------------------- +// Календар +// --------------------------------------------------------------------- + +// Квартал ріжеться в поясі цілі, а не в UTC. +// +// Різниця — три години на кожній межі. Аварія о 01:00 1 липня за +// київським часом належить третьому кварталу; порахована в UTC, вона +// потрапила б у другий, тобто у ВЖЕ ЗАКРИТИЙ звіт. +func TestSLAQuarterBoundsUseTargetTimezone(t *testing.T) { + kyiv, err := time.LoadLocation("Europe/Kyiv") + if err != nil { + t.Skip("у системі немає бази часових поясів") + } + span, err := SLAPeriodBounds(SLAPeriodQuarterly, slaTime("2026-05-15T10:00:00Z"), kyiv) + if err != nil { + t.Fatal(err) + } + if got := span.From.In(kyiv).Format(time.RFC3339); got != "2026-04-01T00:00:00+03:00" { + t.Fatalf("початок кварталу: %s", got) + } + if got := span.To.In(kyiv).Format(time.RFC3339); got != "2026-07-01T00:00:00+03:00" { + t.Fatalf("кінець кварталу: %s", got) + } + // Межі напіввідкриті: 1 липня о 00:00 належить уже наступному + // кварталу, а не обом одразу. + next, err := SLAPeriodBounds(SLAPeriodQuarterly, span.To, kyiv) + if err != nil { + t.Fatal(err) + } + if !next.From.Equal(span.To) { + t.Fatalf("між кварталами щілина або нахлест: %s проти %s", next.From, span.To) + } +} + +// Місяць рахується календарем, а не «тридцятьма добами». +func TestSLAMonthBoundsFollowCalendar(t *testing.T) { + span, err := SLAPeriodBounds(SLAPeriodMonthly, slaTime("2026-02-14T12:00:00Z"), time.UTC) + if err != nil { + t.Fatal(err) + } + if got := span.To.Sub(span.From).Hours() / 24; got != 28 { + t.Fatalf("лютий 2026 вийшов %v діб", got) + } +} + +// Попередній період — це попередній календарний, а не «мінус довжина». +// +// Різниця видно на січні: віднявши 31 добу від 1 березня, отримаєш +// 29 січня, тобто взагалі не той місяць. +func TestSLAPrevPeriodCrossesYear(t *testing.T) { + span, err := SLAPrevPeriod(SLAPeriodMonthly, slaTime("2026-01-10T00:00:00Z"), time.UTC) + if err != nil { + t.Fatal(err) + } + if got := span.From.Format("2006-01-02"); got != "2025-12-01" { + t.Fatalf("попередній місяць до січня 2026: %s", got) + } +} + +// Тиждень починається понеділком, і неділя належить попередньому. +func TestSLAWeekStartsOnMonday(t *testing.T) { + // 2026-04-05 — неділя. + span, err := SLAPeriodBounds(SLAPeriodWeekly, slaTime("2026-04-05T12:00:00Z"), time.UTC) + if err != nil { + t.Fatal(err) + } + if got := span.From.Format("2006-01-02"); got != "2026-03-30" { + t.Fatalf("початок тижня для неділі 5 квітня: %s, очікували 2026-03-30", got) + } +} + +func TestSLAUnknownPeriodKindIsRefused(t *testing.T) { + if _, err := SLAPeriodBounds("yearly", time.Now(), time.UTC); err == nil { + t.Fatal("невідомий тип періоду мусить бути помилкою, а не порожнім звітом") + } +} + +// --------------------------------------------------------------------- +// Горизонт зберігання +// --------------------------------------------------------------------- + +// МЕЖА 5: період частково лежить за строком зберігання. +// +// Найтихіша з усіх помилок. Такий період виглядає точнісінько як період, +// у якому півмережі не опитувалось: те саме низьке покриття, ті самі +// невідомі секунди. Різницю знає лише ця умова. +func TestSLABeyondHorizonDetected(t *testing.T) { + oldest := slaTime("2026-05-10T00:00:00Z") + h := SLAHorizon{Relation: "ts.icmp_1h", Oldest: &oldest} + + if !slaBeyondHorizon(h, slaTime("2026-04-01T00:00:00Z")) { + t.Fatal("квартал, що починається до найстарішого відра, має підняти попередження") + } + if slaBeyondHorizon(h, slaTime("2026-06-01T00:00:00Z")) { + t.Fatal("період усередині горизонту попередження піднімати не має") + } +} + +// Порожня база — це не «за межею горизонту». +// +// Різниця важлива: у першому випадку дані видалили, у другому їх ще не +// збирали. Порада людині в цих двох випадках протилежна. +func TestSLAEmptyDatabaseIsNotBeyondHorizon(t *testing.T) { + if slaBeyondHorizon(SLAHorizon{Relation: "ts.icmp_1h"}, time.Now()) { + t.Fatal("база без жодного відра не має скаржитись на строк зберігання") + } +} + +// --------------------------------------------------------------------- +// Підсумок і вивантаження +// --------------------------------------------------------------------- + +// Підсумок групи рахується з секунд, а не як середнє відсотків. +// +// Різниця видна саме там, де вона найдорожча: новий хост, що прожив у +// періоді годину й ту пролежав, у середньому відсотків важить стільки ж, +// скільки хост, що пропрацював цілий місяць. +func TestSLATotalsWeighByTime(t *testing.T) { + rows := []SLARow{ + {DeviceName: "старий", SLAOutcome: SLAOutcome{ + ClockSec: 30 * 86400, UpSec: 30 * 86400, CoveragePct: 100, UptimePct: 100, + }}, + {DeviceName: "новий", SLAOutcome: SLAOutcome{ + ClockSec: 3600, DownSec: 3600, CoveragePct: 100, + }}, + } + tot := slaTotals(rows, 99.9, 95) + + // Середнє відсотків дало б 50%. Правильна відповідь — 99.86%. + if tot.UptimePct < 99.8 { + t.Fatalf("підсумкова доступність: %v — схоже на середнє відсотків, а не на суму секунд", + tot.UptimePct) + } + if tot.ClockSec != 30*86400+3600 { + t.Fatalf("сумарний годинник: %d", tot.ClockSec) + } +} + +// Підсумок групи, у якій нікого не виміряли, теж не є «виконано». +func TestSLATotalsInsufficientWhenNothingMeasured(t *testing.T) { + rows := []SLARow{ + {DeviceName: "мовчун", SLAOutcome: SLAOutcome{ClockSec: 86400, UnknownSec: 86400}}, + } + tot := slaTotals(rows, 99.9, 95) + if !tot.Insufficient || tot.Breached { + t.Fatalf("група без вимірів: insufficient=%v breached=%v", tot.Insufficient, tot.Breached) + } +} + +// У вивантаженні клітинка доступності порожня, а не нульова, коли її не +// виміряли: нуль читається як «лежало весь період». +func TestSLACSVLeavesUptimeBlankWhenUnknown(t *testing.T) { + var b strings.Builder + rep := SLAReport{ + Target: SLATarget{Name: "Ядро", TargetPct: 99.9, PeriodKind: SLAPeriodMonthly, TZ: "UTC"}, + PeriodFrom: "2026-04-01", PeriodTo: "2026-05-01", + Rows: []SLARow{{DeviceName: "sw-1", SLAOutcome: SLAOutcome{ + ClockSec: 86400, UnknownSec: 86400, Insufficient: true, + }}}, + Horizon: SLAHorizon{Relation: "ts.icmp_1h"}, + } + if err := SLAReportCSV(&b, rep); err != nil { + t.Fatal(err) + } + out := b.String() + if !strings.Contains(out, "sw-1;;0;недостатньо даних") { + t.Fatalf("рядок хоста без вимірів виглядає не так:\n%s", out) + } + if !strings.Contains(out, "без строку") { + t.Fatal("у шапці має стояти строк зберігання джерела") + } + if !strings.Contains(out, "sep=;") { + t.Fatal("без sep=; український Excel розкладе файл в один стовпчик") + } +} + +// Ім'я файла не тягне кирилицю в заголовок Content-Disposition. +func TestSLAReportFileNameIsASCII(t *testing.T) { + name := SLAReportFileName(SLAReport{ + Target: SLATarget{Name: "Ядро мережі / Київ"}, + PeriodFrom: "2026-04-01", + }) + for _, r := range name { + if r > 127 { + t.Fatalf("в імені файла лишилась не-ASCII: %q", name) + } + } + if !strings.HasSuffix(name, "-2026-04-01.csv") { + t.Fatalf("ім'я файла без періоду: %q", name) + } +} + +// --------------------------------------------------------------------- +// Перевірка цілі +// --------------------------------------------------------------------- + +func TestValidateSLATargetRejectsNonsense(t *testing.T) { + base := SLATargetInput{ + Name: "Ядро", PeriodKind: SLAPeriodMonthly, TargetPct: 99.9, MinCoveragePct: 95, + } + if err := ValidateSLATarget(base); err != nil { + t.Fatalf("нормальна ціль відхилена: %v", err) + } + + bad := base + bad.Name = " " + if err := ValidateSLATarget(bad); err == nil { + t.Error("ціль без назви приймається") + } + + bad = base + bad.PeriodKind = "yearly" + if err := ValidateSLATarget(bad); err == nil { + t.Error("невідомий тип періоду приймається") + } + + // Нуль — не «без цілі», а «0% доступності». Набирається однією + // помилкою в порожньому полі, після чого жоден звіт ніколи не + // покаже порушення. + bad = base + bad.TargetPct = 0 + if err := ValidateSLATarget(bad); err == nil { + t.Error("ціль 0% приймається") + } + + bad = base + bad.TZ = "Middle/Earth" + if err := ValidateSLATarget(bad); err == nil { + t.Error("вигаданий часовий пояс приймається") + } +} diff --git a/server/migrations/0069_billing.sql b/server/migrations/0069_billing.sql new file mode 100644 index 0000000..4830b24 --- /dev/null +++ b/server/migrations/0069_billing.sql @@ -0,0 +1,641 @@ +-- ===================================================================== +-- NetPulse :: 0069_billing.sql +-- Тарифи, ліміти й ліцензії: те, що 0009 описала, але чим ніхто ніколи +-- не скористався. +-- +-- ЩО З 0009 ЖИВЕ, А ЩО ЛЕЖИТЬ МЕРТВИМ +-- +-- Це перше, що треба знати, бо будувати поверх 0009 без цієї звірки +-- означає добудовувати те, чого немає. Звірка зроблена grep-ом по +-- всьому дереву: `bill.` згадується поза самою 0009 рівно в семи +-- місцях, і жодне з них не є кодом застосунку. +-- +-- ЖИВЕ рівно три речі: +-- +-- 1. bill.plans і bill.features — заповнені 0010 (три плани, +-- дев'ятнадцять фіч), читаються політикою read_all з 0011. Тобто +-- дані є й видимі. Жоден рядок Go їх не читає. +-- +-- 2. Тригери bill.assert_device_limit і bill.assert_map_node_limit — +-- справді висять на INSERT і справді виконуються на КОЖНІЙ вставці +-- хоста й вузла мапи. Але перший їхній рядок — SELECT max_devices +-- FROM bill.entitlements, а bill.entitlements порожня на кожній +-- інсталяції, що існує: заповнює її лише db/tests/smoke.sql. +-- lim IS NULL → RETURN NEW. Тобто механізм працює, а ефекту не має +-- ніде, крім смоук-тесту. +-- +-- 3. store/maps_write.go::mapPgError перекладає HINT='upgrade_plan' у +-- ErrPlanLimit, а httpapi віддає 402. Це єдина справді робоча +-- ланка — і вона є лише на шляху правки мапи. +-- +-- МЕРТВЕ — усе інше. bill.subscriptions, bill.entitlements (як місце, +-- куди хтось пише), bill.usage_daily, bill.usage_reports, bill.invoices, +-- bill.invoice_lines, bill.payment_events, bill.license_keys, +-- bill.license_checkins, усі чотири ENUM-и. Жодного INSERT, жодного +-- SELECT з коду. Права billing:read і billing:manage заведені 0010 і +-- перелічені в store/roles.go у dormantPerms як «сторінки тарифу ще +-- немає». +-- +-- І одна ланка ЗЛАМАНА, що гірше за мертву. httpapi/groups.go на +-- створенні хоста робить isPlanLimit(err) — а та перевіряє +-- strings.Contains(err.Error(), "ліміт"). Тригер 0009 підіймає +-- 'device limit reached for tenant % (limit %)', тобто англійською. +-- Збігу немає ніколи. Отже в мить, коли ліміт УПЕРШЕ спрацював би, +-- людина отримала б не «вичерпано ліміт тарифу», а 500 «внутрішня +-- помилка» — рівно те, чого перевірка в БД мала не допустити. +-- +-- ДЕ ЛІМІТ НЕ СПРАЦЬОВУЄ, ХОЧА МАВ БИ +-- +-- Це небезпечніший бік, ніж хибне спрацювання: хибне видно одразу й +-- скаржаться на нього того ж дня, а пропущене не проявляється ніяк. +-- +-- Тригер стоїть лише на INSERT. Хост, повернутий з архіву +-- (RestoreDevices — UPDATE deleted_at = NULL), і хост, який просто +-- ввімкнули (UPDATE enabled = true), проходять повз перевірку цілком. +-- Тобто стелю в 15 хостів обходить будь-хто: завести 15, заархівувати +-- десять, завести ще десять, повернути з архіву. Отримуємо 25 під +-- наглядом і план, який каже «до 15». +-- +-- max_maps, max_agents, max_users і metric_retention_days з +-- bill.plans не перевіряє НІЩО й ніде. Це не колонки про запас — це +-- те, чим три плани в 0010 відрізняються один від одного. +-- +-- ЩО РОБИТЬ ЦЯ МІГРАЦІЯ +-- +-- 1. Заводить bill.entitlements КОЖНОМУ наявному кабінету — але з +-- планом, у якого всі стелі порожні. Пояснення нижче; коротко: +-- оновлення не має права нічого відібрати. +-- 2. Переписує перевірку лімітів: одна функція замість двох, робота +-- на INSERT і на UPDATE, повідомлення українською й машиночитна +-- подробиця, з якої застосунок збирає фразу «у тарифі Х дозволено +-- N хостів, зараз N». +-- 3. Заводить стан ліцензії на рівні інсталяції (bill.instance): +-- install_id, сам ключ, перевірений payload, строк, пільговий +-- період і монотонний годинник. +-- 4. Відкриває bill.license_keys для ключів, не прив'язаних до +-- кабінету, — під RLS з 0011 такий рядок не видно нікому взагалі. +-- +-- ЧОГО ЦЯ МІГРАЦІЯ НЕ РОБИТЬ +-- +-- Нічого зі Stripe. Таблиці 0009 (subscriptions, invoices, +-- payment_events, usage_reports) лишаються як є й лишаються порожніми. +-- Це свідомо: платіжка не має права протікати в перевірку лімітів. +-- Єдине, що читають тригери й застосунок, — bill.entitlements; хто саме +-- її заповнив (ліцензійний ключ, Stripe, домовленість руками), видно в +-- колонці source й нікого більше не обходить. Тому інтеграцію з +-- платіжкою можна дописати пізніше, не торкаючись жодного рядка нижче. +-- ===================================================================== + +-- --------------------------------------------------------------------- +-- 1. План, з якого нічого не ламається +-- --------------------------------------------------------------------- + +-- Найнебезпечніший рядок усієї задачі — той, яким наявним кабінетам +-- уперше видають entitlements. Досі стеля не діяла НІДЕ; будь-яке +-- значення, крім «немає стелі», означає, що після накочування цієї +-- міграції інсталяція з двомастами хостами перестає приймати +-- двісті перший. Тобто оновлення, яке нічого не питало, відібрало б +-- у клієнта продукт — і виявилось би це не тут, а вночі, коли черговий +-- заводить хост після аварійної заміни. +-- +-- Тому план self_hosted: усі стелі NULL, усі фічі. Він не «безкоштовний +-- enterprise» — він СТАН «ліміти ще ніхто не задавав». Стелі з'являються +-- рівно тоді, коли їх задає свідома дія: застосований ліцензійний ключ +-- або обраний тариф. Це та сама логіка, що й у 0064 зі строками +-- зберігання: оновлення вмикає механізм, але не вмикає його наслідків. +-- +-- is_public = false: у переліку тарифів на сторінці його немає, бо +-- купити його не можна. Він показується лише як поточний стан. +INSERT INTO bill.plans + (key, name, description, base_price_cents, per_device_cents, + max_devices, max_maps, max_map_nodes, max_agents, max_users, + metric_retention_days, features, sort_order, is_public) +SELECT 'self_hosted', 'Self-hosted без ліцензії', + 'Стелі не задані. Так виглядає інсталяція, якій ще не застосували ключ і не обрали тариф', + 0, 0, NULL, NULL, NULL, NULL, NULL, 400, + -- Набір фіч береться з enterprise, а не переписується списком: + -- список розійшовся б із 0010 на першій же новій фічі, і + -- розбіжність побачив би лише той, хто відкриє обидва файли. + (SELECT features FROM bill.plans WHERE key = 'enterprise'), + 0, false +ON CONFLICT (key) DO UPDATE SET + features = EXCLUDED.features, + is_public = false; + +COMMENT ON COLUMN bill.plans.is_public IS + 'Чи показувати в переліку тарифів. false — стан, а не пропозиція (self_hosted)'; + +-- --------------------------------------------------------------------- +-- 2. Entitlements кожному кабінету +-- --------------------------------------------------------------------- + +-- Матеріалізована таблиця, а не VIEW поверх plans і subscriptions, — і +-- це рішення 0009, яке варто підтвердити вголос, бо на нього спирається +-- усе решта. Причина в тому, ЗВІДКИ стелі можуть узятись: із тарифу, з +-- персональних домовленостей (subscriptions.overrides), з ліцензійного +-- ключа, з пільгового періоду після прострочення. Вигляд, який зводить +-- чотири джерела, довелось би обчислювати в тригері на кожній вставці +-- хоста — тобто платити JOIN-ом по чотирьох таблицях за кожен рядок +-- автовиявлення. +-- +-- Ціна матеріалізації — розсинхрон: рядок може відстати від того, що +-- насправді дає ліцензія. Тому перерахунок робить рівно одне місце +-- (store/billing_license.go, ApplyLicense і годинний такт), а не кожен, +-- кому знадобилось. +-- Вставка йде по одному кабінету з виставленим app.tenant_id, а не +-- одним INSERT ... SELECT, і це не стилістика. 0011 повісила на +-- bill.entitlements політику tenant_isolation разом із FORCE ROW LEVEL +-- SECURITY — тобто WITH CHECK (tenant_id = core.current_tenant()) +-- перевіряється й для власника таблиці. Без контексту +-- core.current_tenant() дає NULL, порівняння дає NULL, і жоден рядок не +-- проходить. Один INSERT спрацював би лише під суперкористувачем; те, +-- що міграції сьогодні котять саме ним, — властивість розгортання, а не +-- гарантія (0063 якраз забирає BYPASSRLS у робочих ролей). Цикл працює +-- під будь-якою роллю, що має право писати в таблицю. +DO $$ +DECLARE + t record; +BEGIN + FOR t IN SELECT id FROM core.tenants WHERE deleted_at IS NULL LOOP + PERFORM set_config('app.tenant_id', t.id::text, true); + INSERT INTO bill.entitlements + (tenant_id, plan_key, max_devices, max_maps, max_map_nodes, max_agents, + max_users, metric_retention_days, features, source) + SELECT t.id, p.key, p.max_devices, p.max_maps, p.max_map_nodes, p.max_agents, + p.max_users, p.metric_retention_days, p.features, 'license_key' + FROM bill.plans p + WHERE p.key = 'self_hosted' + ON CONFLICT (tenant_id) DO NOTHING; + END LOOP; + PERFORM set_config('app.tenant_id', '', true); +END $$; + +-- Причина останнього перерахунку — колонка про людей, а не про машину. +-- +-- «Чому в мене раптом стеля 15 хостів» — питання, на яке без цього +-- рядка немає відповіді взагалі: entitlements переписується цілком, і +-- попереднього стану ніде не лишається. Значення тут коротке й +-- перелічуване, бо його читає інтерфейс: license (застосували ключ), +-- license_expired (ключ протермінувався), plan (обрали тариф), +-- migration (заведено оновленням), manual (руками в базі). +ALTER TABLE bill.entitlements + ADD COLUMN IF NOT EXISTS reason text NOT NULL DEFAULT 'migration', + ADD COLUMN IF NOT EXISTS license_id uuid; + +COMMENT ON COLUMN bill.entitlements.reason IS + 'Чому стелі саме такі: license | license_expired | plan | migration | manual'; +COMMENT ON COLUMN bill.entitlements.grace_until IS + 'Кінець пільгового періоду. Після нього стеля замерзає на досягнутому, а не падає'; + +-- --------------------------------------------------------------------- +-- 3. Перевірка лімітів у БД +-- --------------------------------------------------------------------- + +-- ЧОМУ ПЕРЕВІРКА ЛИШАЄТЬСЯ В БАЗІ, А НЕ ПЕРЕЇЖДЖАЄ В GO +-- +-- Спокуса саме така: «ліміт має відмовляти зрозуміло, отже хай його +-- рахує застосунок і сам пише фразу». Це помилка, і вона коштує рівно +-- того, заради чого ліміт існує. +-- +-- Порахувати в Go означає SELECT count(*), потім INSERT — тобто вікно +-- між ними. Два браузери, два запити автовиявлення, масова вставка з +-- імпорту — і обидва бачать «14 з 15», обидва вставляють. Стеля з +-- гонкою — це не стеля. +-- +-- Тому перевірка лишається там, де вона й має бути: у тій самій +-- транзакції, що й вставка, під тим самим рядковим замком. А зрозумілу +-- фразу дає не місце перевірки, а те, ЩО саме вона підіймає нагору. +-- Досі вона підіймала англійський рядок без жодних чисел — звідси й +-- 500 замість 402. + +-- bill.usage_now — скільки чого зайнято ЗАРАЗ. +-- +-- Одна функція, а не count(*) по місцях виклику, з тієї ж причини, з +-- якої строки зберігання зведені в одну таблицю: «що вважається +-- зайнятим хостом» — це рішення, і воно має бути записане один раз. +-- Тут воно таке: хост займає слот, якщо він не в архіві І ввімкнений. +-- Вимкнений хост не опитується, не породжує метрик і не коштує нам +-- нічого — брати за нього гроші означало б брати за рядок у таблиці. +-- +-- STABLE, а не VOLATILE: у межах одного запиту відповідь не міняється, +-- і планувальник має право не викликати її двічі. +-- Імена вихідних колонок із суфіксом, а не devices/maps/agents/users. +-- У функції на SQL імена вихідних параметрів підставляються в тіло як +-- ідентифікатори, і колонка з іменем `devices` поруч із таблицею +-- inv.devices — це рівно та неоднозначність, яку неприємно ловити на +-- накочуванні. Суфікс коштує нічого й прибирає питання цілком. +CREATE OR REPLACE FUNCTION bill.usage_now(p_tenant uuid) + RETURNS TABLE (devices_used int, maps_used int, agents_used int, users_used int) + LANGUAGE sql STABLE AS $$ + SELECT + (SELECT count(*)::int FROM inv.devices + WHERE tenant_id = p_tenant AND deleted_at IS NULL AND enabled), + (SELECT count(*)::int FROM topo.maps + WHERE tenant_id = p_tenant AND deleted_at IS NULL), + -- Зонди без deleted_at: у core.agents архіву немає, видалення там + -- одразу справжнє (store/agents.go). Тому й умови «не в архіві» тут + -- немає — не забули, а нема чого писати. + (SELECT count(*)::int FROM core.agents + WHERE tenant_id = p_tenant), + (SELECT count(*)::int FROM core.memberships + WHERE tenant_id = p_tenant) +$$; + +COMMENT ON FUNCTION bill.usage_now(uuid) IS + 'Скільки слотів тарифу зайнято зараз. Одне визначення «зайнятого» на весь продукт'; + +-- bill.deny_limit — єдине місце, де перевірка перетворюється на відмову. +-- +-- Дві частини повідомлення роблять різну роботу, і плутати їх не можна. +-- +-- MESSAGE — фраза для людини, українською, з числами: «у тарифі +-- Free дозволено 15 хостів, зараз 15». Вона потрапляє в лог +-- Postgres, у psql, у будь-яку утиліту — тобто в усі місця, куди +-- застосунок не дотягнеться. Англійський рядок 0009 у цих місцях +-- читав лише розробник. +-- +-- DETAIL — той самий факт у JSON, для застосунку. Розбирати MESSAGE +-- регулярками не можна: фразу колись перепишуть, і перевірка тихо +-- перестане впізнавати власну помилку — рівно те, що вже сталося з +-- isPlanLimit і словом «ліміт». +-- +-- HINT лишається 'upgrade_plan' незмінним: за ним уже впізнає ліміт +-- store/maps_write.go::mapPgError, і ламати робочу ланку заради +-- однаковості нема причин. +CREATE OR REPLACE FUNCTION bill.deny_limit( + p_kind text, p_plan text, p_allowed int, p_used int) + RETURNS void LANGUAGE plpgsql AS $$ +DECLARE + plan_name text; + noun text; +BEGIN + SELECT name INTO plan_name FROM bill.plans WHERE key = p_plan; + plan_name := COALESCE(plan_name, p_plan); + + noun := CASE p_kind + WHEN 'devices' THEN 'хостів' + WHEN 'maps' THEN 'мап' + WHEN 'map_nodes' THEN 'вузлів на мапі' + WHEN 'agents' THEN 'зондів' + WHEN 'users' THEN 'користувачів' + ELSE p_kind + END; + + RAISE EXCEPTION 'у тарифі % дозволено % %, зараз %', plan_name, p_allowed, noun, p_used + USING ERRCODE = 'check_violation', + HINT = 'upgrade_plan', + DETAIL = json_build_object( + 'limit', p_kind, + 'plan', p_plan, + 'allowed', p_allowed, + 'used', p_used)::text; +END $$; + +-- Хости. Тепер і на UPDATE — саме там була дірка. +-- +-- Умова спрацювання на UPDATE вужча за «будь-яка правка»: слот +-- займається лише переходом у стан «під наглядом». Перейменування +-- хоста, зміна адреси, прив'язка до зонда стелі не торкаються, і +-- перевіряти їх означало б рахувати count(*) на кожному такті збору, +-- який пише status. +-- +-- Порахований count(*) не включає сам рядок, що правиться: BEFORE +-- UPDATE бачить таблицю зі СТАРИМИ значеннями, а старі — це «вимкнений» +-- або «в архіві», тобто під умову підрахунку рядок не підпадає. Тому +-- порівняння cnt >= lim правильне для обох операцій без окремої гілки. +CREATE OR REPLACE FUNCTION bill.assert_device_limit() RETURNS trigger + LANGUAGE plpgsql AS $$ +DECLARE + lim int; + pkey text; + cnt int; +BEGIN + -- Вимкнений або одразу заархівований хост слота не займає. Вихід тут, + -- а не в кінці: інакше вимкнений хост не можна було б завести на + -- інсталяції під стелею — а саме так заводять хост «про запас» перед + -- переїздом, і саме це має лишатись можливим. + IF NOT NEW.enabled OR NEW.deleted_at IS NOT NULL THEN + RETURN NEW; + END IF; + + -- Вкладений IF, а не один вираз через AND, і це не стиль. plpgsql + -- обчислює умову цілим виразом; `TG_OP = 'UPDATE' AND OLD.enabled` + -- на INSERT упало б на другій половині — «record old is not assigned + -- yet», — тобто перша ж вставка хоста поламала б продукт. + IF TG_OP = 'UPDATE' THEN + IF OLD.enabled AND OLD.deleted_at IS NULL THEN + RETURN NEW; -- слот уже був зайнятий цим самим рядком + END IF; + END IF; + + SELECT max_devices, plan_key INTO lim, pkey + FROM bill.entitlements WHERE tenant_id = NEW.tenant_id; + IF lim IS NULL THEN + RETURN NEW; -- стелі немає або entitlements ще не заведено + END IF; + + SELECT devices_used INTO cnt FROM bill.usage_now(NEW.tenant_id); + IF cnt >= lim THEN + PERFORM bill.deny_limit('devices', pkey, lim, cnt); + END IF; + RETURN NEW; +END $$; + +DROP TRIGGER IF EXISTS trg_devices_limit ON inv.devices; +CREATE TRIGGER trg_devices_limit BEFORE INSERT OR UPDATE OF enabled, deleted_at + ON inv.devices + FOR EACH ROW EXECUTE FUNCTION bill.assert_device_limit(); + +-- Вузли мапи. Функція з 0009 лишається за змістом (стеля на ОДНУ мапу, +-- як і описано в 0010: «Free — 1 мапа, до 15 вузлів»), міняється лише +-- те, що вона підіймає нагору. +CREATE OR REPLACE FUNCTION bill.assert_map_node_limit() RETURNS trigger + LANGUAGE plpgsql AS $$ +DECLARE + lim int; + pkey text; + cnt int; +BEGIN + SELECT max_map_nodes, plan_key INTO lim, pkey + FROM bill.entitlements WHERE tenant_id = NEW.tenant_id; + IF lim IS NULL THEN + RETURN NEW; + END IF; + SELECT count(*)::int INTO cnt FROM topo.map_nodes WHERE map_id = NEW.map_id; + IF cnt >= lim THEN + PERFORM bill.deny_limit('map_nodes', pkey, lim, cnt); + END IF; + RETURN NEW; +END $$; + +-- Мапи, зонди й користувачі: колонки в bill.plans були з 0009, стелі не +-- було ніде. Тарифи, що відрізняються лише невиконуваними числами, — +-- це не тарифи, а таблиця. +-- +-- Одна функція на три таблиці, а не три однакові: різниця між ними +-- вміщається в аргумент тригера, а три копії розійшлись би на першій же +-- правці підрахунку. +CREATE OR REPLACE FUNCTION bill.assert_tenant_limit() RETURNS trigger + LANGUAGE plpgsql AS $$ +DECLARE + kind text := TG_ARGV[0]; + lim int; + pkey text; + u record; + cnt int; +BEGIN + SELECT plan_key, + CASE kind + WHEN 'maps' THEN max_maps + WHEN 'agents' THEN max_agents + WHEN 'users' THEN max_users + END + INTO pkey, lim + FROM bill.entitlements WHERE tenant_id = NEW.tenant_id; + IF lim IS NULL THEN + RETURN NEW; + END IF; + + SELECT * INTO u FROM bill.usage_now(NEW.tenant_id); + cnt := CASE kind + WHEN 'maps' THEN u.maps_used + WHEN 'agents' THEN u.agents_used + WHEN 'users' THEN u.users_used + END; + IF cnt >= lim THEN + PERFORM bill.deny_limit(kind, pkey, lim, cnt); + END IF; + RETURN NEW; +END $$; + +DROP TRIGGER IF EXISTS trg_maps_limit ON topo.maps; +CREATE TRIGGER trg_maps_limit BEFORE INSERT ON topo.maps + FOR EACH ROW EXECUTE FUNCTION bill.assert_tenant_limit('maps'); + +DROP TRIGGER IF EXISTS trg_agents_limit ON core.agents; +CREATE TRIGGER trg_agents_limit BEFORE INSERT ON core.agents + FOR EACH ROW EXECUTE FUNCTION bill.assert_tenant_limit('agents'); + +DROP TRIGGER IF EXISTS trg_memberships_limit ON core.memberships; +CREATE TRIGGER trg_memberships_limit BEFORE INSERT ON core.memberships + FOR EACH ROW EXECUTE FUNCTION bill.assert_tenant_limit('users'); + +-- --------------------------------------------------------------------- +-- 4. Стан ліцензії інсталяції +-- --------------------------------------------------------------------- + +-- ЧОМУ РІВЕНЬ ІНСТАЛЯЦІЇ, А НЕ КАБІНЕТУ +-- +-- Бо ключ ставлять у продукт, який клієнт розгорнув У СЕБЕ. Кабінет там +-- один, і питання «яка ліцензія в кабінету Б» не виникає. Той самий +-- висновок, що й у 0064 про строки зберігання, і те саме обмеження, яке +-- треба знати заздалегідь: на спільному хостингу кількох клієнтів +-- ліцензія інсталяції накриє їх усіх. +-- +-- Тому застосування ключа переписує entitlements лише тим кабінетам, у +-- яких source = 'license_key'. Кабінет, стелі якого прийшли з підписки +-- (source = 'stripe'), ключ інсталяції не чіпає — це і є та межа, за +-- якою платіжка не протікає в ліцензії, а ліцензії в платіжку. +CREATE TABLE IF NOT EXISTS bill.instance ( + id boolean PRIMARY KEY DEFAULT true CHECK (id), + + -- Ідентифікатор ЦІЄЇ інсталяції. Заводиться один раз і не міняється: + -- ключ, виданий на install_id, більше нікуди не підійде, і саме це + -- відрізняє ліцензію від пароля, який перешлють колезі. + -- + -- Живе в базі, а не у файлі поруч із бінарником: контейнер + -- перезбирають, том із базою — ні. + install_id uuid NOT NULL DEFAULT core.new_id(), + + -- Ключ як його ввела людина — цілком, разом із підписом. + -- + -- Зберігається саме текстом, а не розібраним: перевірити підпис можна + -- лише над тими самими байтами, які підписували. Реконструкція + -- payload з колонок дала б інший канонічний вигляд і, отже, іншу + -- контрольну суму — тобто власна ліцензія перестала б проходити + -- перевірку після першої ж зміни схеми. + license_key text, + + -- Розібраний і ПЕРЕВІРЕНИЙ payload. Дублює license_key навмисно: + -- запити на кшталт «чиї стелі зараз діють» не мають розбирати base64. + payload jsonb, + + license_id uuid, + issued_to text, + + -- unlicensed — ключа немає зовсім. Це робочий стан, а не поломка: + -- так виглядає щойно розгорнута інсталяція до покупки. + -- active — ключ дійсний. + -- grace — строк минув, пільговий період триває. + -- expired — минув і пільговий. + -- invalid — ключ є, але підпис/прив'язка не сходяться. + state text NOT NULL DEFAULT 'unlicensed' + CHECK (state IN ('unlicensed','active','grace','expired','invalid')), + -- Чому саме invalid — ФРАЗОЮ, а не кодом причини. + -- + -- Сюди лягає текст помилки перевірки як є («невідомий ключ підпису + -- k2», «підпис ліцензії не сходиться»), і показується він людині + -- дослівно. Код причини довелося б перекладати назад у фразу ще в + -- одному місці, а перелік причин тут не є чимось, за чим фільтрують. + -- «Ключ недійсний» без причини перетворює звернення в підтримку на + -- вгадування — це і є те, чого колонка не допускає. + invalid_reason text, + + expires_at timestamptz, + grace_until timestamptz, + + -- МОНОТОННИЙ ГОДИННИК + -- + -- Ліцензія без інтернету перевіряється за системним часом машини, а + -- машина належить тому, кого ліцензія обмежує. Перевести годинник на + -- рік назад — дія на одну команду. + -- + -- Ловиться це найдешевшим способом, який взагалі є: пам'ятати + -- найпізніший час, який ця інсталяція БАЧИЛА. Час назад не йде; якщо + -- now() виявився суттєво меншим за побачене, годинник рухали. + -- + -- Наслідок навмисно м'який: строк рахується за clock_max_seen, а не + -- за now(), і факт зсуву показується на сторінці. Вимикати щось за + -- це не можна — годинник з'їжджає й сам (сів CMOS, зник NTP після + -- переїзду в ізольований сегмент), і покарати за це означало б + -- покарати за несправність, а не за обхід. + clock_max_seen timestamptz NOT NULL DEFAULT now(), + clock_warped_at timestamptz, + + -- Коли востаннє перераховували стан. Порожнє поле при непорожньому + -- ключі означає, що такт перевірки не працює, — і це видно на + -- сторінці, а не лише в логах. + checked_at timestamptz, + applied_at timestamptz, + applied_by uuid REFERENCES core.users(id) ON DELETE SET NULL +); + +INSERT INTO bill.instance (id) VALUES (true) ON CONFLICT DO NOTHING; + +COMMENT ON TABLE bill.instance IS + 'Ліцензія цієї інсталяції: install_id, ключ, стан і монотонний годинник'; +COMMENT ON COLUMN bill.instance.clock_max_seen IS + 'Найпізніший побачений час. Строк рахується за ним, а не за now(): годинник належить клієнту'; + +-- RLS тут немає, і це не пропуск: політика 0011 накладається на таблиці +-- з колонкою tenant_id, а в цієї її немає за побудовою — рівно як у +-- core.storage_config з 0064. Читання відкрите: ховати «ліцензія діє до +-- 1 березня» немає від кого, а НЕ бачити цього означає дізнатись про +-- прострочення від колеги. Право на зміну перевіряє застосунок +-- (billing:manage). +GRANT SELECT, INSERT, UPDATE ON bill.instance TO netpulse_app, netpulse_worker; + +-- --------------------------------------------------------------------- +-- 5. Ключі, не прив'язані до кабінету +-- --------------------------------------------------------------------- + +-- bill.license_keys.tenant_id оголошена в 0009 як NULLABLE — і це +-- правильно: ключ для self-hosted випускають ДО того, як у клієнта +-- з'явиться кабінет, а часто й на іншій інсталяції (у нас, а не в +-- нього). Але 0011 автоматом повісила на таблицю tenant_isolation з +-- USING (tenant_id = core.current_tenant()), а NULL = будь-що дає NULL, +-- тобто не TRUE. Наслідок: рядок із tenant_id IS NULL не видно НІКОМУ й +-- ніколи, включно з тим, хто його щойно вставив. +-- +-- Тобто головний сценарій продукту («клієнт ставить систему в себе») +-- був закритий політикою, написаною для іншого випадку. Помітити це +-- читанням 0009 неможливо — політики там немає, вона з'являється через +-- дві міграції й циклом по всіх таблицях одразу. +DROP POLICY IF EXISTS tenant_isolation ON bill.license_keys; +CREATE POLICY license_keys_visible ON bill.license_keys + USING (tenant_id IS NULL OR tenant_id = core.current_tenant()) + WITH CHECK (tenant_id IS NULL OR tenant_id = core.current_tenant()); + +-- Ключ підписують Ed25519, а не RSA-4096 PSS, як планувала 0009. +-- +-- Жодного ключа ще не видано (таблиця порожня на всіх інсталяціях), +-- тому ламати сумісність нема з чим, а різниця істотна саме для +-- ліцензії, яку ЛЮДИНА ВВОДИТЬ РУКАМИ: підпис RSA-4096 — це 512 байтів, +-- тобто близько 700 символів base64 на самий лише підпис. Ключ, який не +-- вміщається в поле й не переживає копіювання з листа, повертається до +-- нас зверненням у підтримку. Ed25519 дає 64 байти. +-- +-- Друга причина важливіша за довжину. У RSA-PSS є що налаштувати +-- неправильно — хеш, MGF, довжина солі; у Ed25519 налаштувань немає +-- взагалі, і перевірка або сходиться, або ні. Для механізму, який +-- працює без інтернету й без можливості відкликати ключ на льоту, це +-- вирішальна властивість. +-- +-- Колонки 0009 (signature bytea, signing_key_id text, payload jsonb) +-- підходять без змін — вони не називають алгоритму. +COMMENT ON COLUMN bill.license_keys.signature IS + 'Ed25519 над канонічним payload (не RSA-PSS, як планувала 0009 — див. коментар 0069)'; +COMMENT ON COLUMN bill.license_keys.signing_key_id IS + 'Ідентифікатор ключа підпису для ротації; входить у сам ключ, щоб перевірка знала, чим перевіряти'; + +-- --------------------------------------------------------------------- +-- 6. Права +-- --------------------------------------------------------------------- + +-- Обидва права заведено ще 0010 і обидва досі значились у +-- store/roles.go серед dormantPerms: ключ у базі є, коду, який його +-- питає, немає. Тепер він є, і рядки звідти прибираються тією ж +-- правкою — інакше екран ролей і далі попереджав би про право, яке вже +-- працює. +-- +-- Розподіл між ними такий самий, як у сховища й дзеркала: ДИВИТИСЬ +-- вільно широко, МІНЯТИ — вузько. Але межа проходить не там, де +-- зазвичай. +-- +-- billing:read — стан підписки, стелі й скільки зайнято. Це право +-- інженера, а не бухгалтера: «чому не заводиться шістнадцятий хост» +-- — питання того, хто заводить хости, і відповідь на нього має +-- бути в нього перед очима ДО того, як він упреться. Саме тому +-- сторінка показує стелю разом із використаним, а не лише рахунок. +-- +-- billing:manage — застосувати ліцензійний ключ, змінити тариф. +-- Лише власник: 0010 навмисно не дала цього права навіть адміну. +UPDATE core.permissions +SET description = 'Перегляд тарифу, стель, використаного та стану ліцензії' +WHERE key = 'billing:read'; + +UPDATE core.permissions +SET description = 'Зміна тарифу, застосування ліцензійного ключа, платіжні дані' +WHERE key = 'billing:manage'; + +-- --------------------------------------------------------------------- +-- 7. Що лишається поза цією міграцією й чому +-- --------------------------------------------------------------------- + +-- ЖОДНОГО «ВИМКНУТИ ВСЕ» ПІСЛЯ ПРОСТРОЧЕННЯ. +-- +-- У схемі немає ні прапорця «заблоковано», ні тригера, який зупиняв би +-- збір. Це не забуто — це головне рішення задачі, і схема мусить його +-- витримувати, бо схема переживе будь-який застосунок. +-- +-- Моніторинг, який перестав моніторити через несплачений рахунок, — це +-- аварія, яку спричинили ми, у мережі, за яку відповідає клієнт. Він не +-- побачить падіння магістралі й дізнається про нього від абонентів; ми +-- при цьому не отримаємо грошей, а отримаємо звернення й репутацію +-- продукту, який тихо перестав працювати. Жодна ліцензійна угода такої +-- відповідальності не покриває. +-- +-- Тому після прострочення замерзає лише РІСТ: не з'являється новий +-- хост, зонд, користувач, мапа. Усе, що вже під наглядом, лишається під +-- наглядом безстроково — метрики збираються, алерти підіймаються, +-- сповіщення йдуть. Стеля при цьому ніколи не опускається нижче +-- фактично зайнятого: 500 хостів на протермінованій ліцензії лишаються +-- п'ятьмастами, а не падають до 15. Прострочена ліцензія перетворює +-- продукт із того, що росте, на те, що працює, — і це найсильніший +-- аргумент заплатити з усіх, які в нас є, бо клієнт продовжує бачити +-- цінність, а не її відсутність. +-- +-- Реалізує це застосунок (store/billing_license.go): стелі +-- перераховуються в bill.entitlements як max(стеля_плану, +-- фактично_зайнято). Тригери нижче про ліцензію не знають нічого — вони +-- бачать лише число в entitlements, і це навмисно: правило «не +-- опускати нижче зайнятого» має жити в одному місці, а не в кожному +-- тригері окремо. + +-- Stripe: див. шапку. Таблиці 0009 лишаються порожніми. + +-- bill.usage_daily не заповнюється й цією міграцією. Щоденний зріз +-- потрібен для per-device тарифікації (скільки виставити за місяць), а +-- виставляти рахунки поки нема чим. Заводити такт, який щоночі пише +-- рядок, який ніхто не читає, означало б зробити ще одну таблицю, що +-- росте без причини, — рівно те, проти чого написана 0064. diff --git a/server/migrations/0070_sla.sql b/server/migrations/0070_sla.sql new file mode 100644 index 0000000..8913a6a --- /dev/null +++ b/server/migrations/0070_sla.sql @@ -0,0 +1,354 @@ +-- ===================================================================== +-- NetPulse :: 0070_sla.sql +-- Звіти SLA: доступність за період, порахована РАЗ і збережена як факт. +-- +-- ЩО ВЖЕ БУЛО +-- +-- 0008 завела core.sla_targets і core.sla_periods. За півтора року в них +-- не з'явилось жодного рядка, бо коду, який їх пише, немає. Тобто це не +-- «доробити наявне», а «вирішити, як воно взагалі рахується», — і +-- рішення тут дорожчі за код. +-- +-- ГОЛОВНА ПАСТКА, І ЧОМУ ВОНА НЕ ВИДНА +-- +-- Наївний звіт про доступність рахують по сирих вимірах: узяли +-- ts.icmp_samples за квартал, поділили відповіді на спроби, показали +-- 99.94%. Число виглядає правильним і сьогодні воно правильне. +-- +-- Через два місяці той самий запит на той самий квартал дасть інше +-- число. Причина — 0005: ts.icmp_samples живе 35 діб, і 0064 зробила цей +-- строк ще й РЕДАГОВАНИМ з веб-форми. Тобто дані під звітом зникають +-- хвостом уперед, а запит цього не помічає: він не бачить різниці між +-- «за 1 квітня втрат не було» й «за 1 квітня рядків уже немає». Звіт +-- мовчки їде вгору до 100%. +-- +-- Наслідок називається просто: документ, який показали клієнту або +-- аудитору, через два місяці не відтворюється. Це найгірша можлива +-- властивість звіту — гірша за відверто неправильне число, бо +-- неправильне помітно одразу. +-- +-- ЗВІДСИ ДВА РІШЕННЯ, І ВОНИ Й Є ЦЯ МІГРАЦІЯ +-- +-- Перше: рахувати по годинних згортках ts.icmp_1h, а не по сирих даних. +-- З усіх рівнів деталізації рівно в цього немає політики видалення +-- (0005: «1h-роллапи не видаляємо: це база для SLA-звітів»), і рівно +-- йому retention_policy.go ставить нижню межу 30 діб із поясненням +-- «місячні звіти читають саме звідси». Реальні горизонти такі: +-- +-- ts.icmp_samples 35 діб квартал не покриває взагалі +-- ts.icmp_5m 400 діб квартал покриває, але це політика, +-- яку 0064 дала міняти з форми +-- ts.icmp_1h без строку єдине, що продукт уже пообіцяв тримати +-- +-- Друге, важливіше: ЗАКРИТИЙ період не перераховується ніколи. Щойно +-- період скінчився й згортки під ним устоялись, рядок у core.sla_periods +-- пишеться один раз і стає фактом. Далі його читають, а не рахують. +-- Тому навіть якщо завтра прибрати ts.icmp_1h цілком, звіт за минулий +-- квартал лишиться тим самим числом — у ньому вже не дані, а висновок. +-- +-- ЧОМУ НЕ ts.device_status_history, ХОЧА ВОНА Й ЗВЕТЬСЯ «ІСТОРІЄЮ СТАНІВ» +-- +-- Спокуса очевидна: там лежать переходи «вгору/вниз» із точністю до +-- секунди, а не відра по годині. Три причини проти, і третя вирішальна. +-- +-- 1. Це журнал ЗМІН, а не станів. Щоб знати стан хоста о 00:00 1 квітня, +-- треба знайти останній рядок ПЕРЕД періодом — а він може бути +-- як завгодно старим. 0064 завела цій таблиці редагований строк +-- зберігання, тобто саме той рядок і зникне першим. +-- 2. Її пише applyDeviceStatus, тобто вона показує стан, який вирішив +-- конвеєр алертів. У ньому вже враховані придушення й антифлап — +-- дві політики, змішані в одному числі, яке потім показують +-- аудитору як вимір. +-- 3. І головне: вона не вміє сказати «ми не знали». Перехід пишеться +-- ЛИШЕ при зміні стану. Коли зонд відвалився, вимірів не надходить, +-- переходу немає — і таблиця стверджує, що хост був «up» усю добу +-- мовчання. Тобто джерело, яке за побудовою рахує «даних немає» як +-- «працювало», — саме та помилка, від якої цей файл захищає. +-- +-- ts.icmp_1h цього не робить: у ній є samples і down_samples. Година без +-- рядка — це година без даних, і сплутати її з робочою нічим. +-- ===================================================================== + +-- --------------------------------------------------------------------- +-- 1. Цілі SLA +-- --------------------------------------------------------------------- + +-- Часовий пояс цілі, і це не косметика. +-- +-- «Доступність за квартал» — це календарний квартал у поясі організації, +-- а не 92 доби від UTC-опівночі. Різниця для Києва — від двох до трьох +-- годин на кожній межі періоду, і саме в них найчастіше й ставлять +-- планові роботи. Пояс лежить на цілі, а не береться з core.tenants при +-- кожному розрахунку: тенант може переїхати між поясами, і тоді старий +-- звіт мовчки почав би описувати інші 92 доби. +ALTER TABLE core.sla_targets + ADD COLUMN IF NOT EXISTS tz text NOT NULL DEFAULT 'UTC'; + +-- Ціль можна вимкнути, не видаляючи. Видалення забирає за собою всі +-- закриті періоди (ON DELETE CASCADE на sla_periods.sla_target_id), тобто +-- «більше не рахуємо цю ціль» і «зітерти торішні звіти» — це різні +-- наміри, і в них мають бути різні кнопки. +ALTER TABLE core.sla_targets + ADD COLUMN IF NOT EXISTS enabled boolean NOT NULL DEFAULT true; + +-- Скільки періоду треба ЗНАТИ, щоб узагалі виносити вердикт. +-- +-- Це найважливіше поле в таблиці. Без нього період, у якому зонд +-- пролежав три тижні, дав би «100% доступності» — бо серед тих вимірів, +-- що дійшли, справді не було жодної втрати. Формально правда, по суті +-- брехня. +-- +-- Тому вердикт («виконано» / «порушено») виноситься лише коли покриття +-- не нижче за цей поріг. Нижче — період позначається як «недостатньо +-- даних» і не зараховується НІ в який бік. Мовчазне «зелено» тут +-- заборонено за побудовою. +ALTER TABLE core.sla_targets + ADD COLUMN IF NOT EXISTS min_coverage_pct numeric(5,2) NOT NULL DEFAULT 95.00 + CHECK (min_coverage_pct >= 0 AND min_coverage_pct <= 100); + +-- Тип періоду обмежується явно. Досі це був вільний text із коментарем +-- «daily | weekly | monthly | quarterly» — тобто домовленість, яку не +-- перевіряє ніхто, а розрахунок за незнайомим значенням мовчки дав би +-- порожній звіт. +ALTER TABLE core.sla_targets + DROP CONSTRAINT IF EXISTS sla_targets_period_kind_chk; +ALTER TABLE core.sla_targets + ADD CONSTRAINT sla_targets_period_kind_chk + CHECK (period_kind IN ('daily','weekly','monthly','quarterly')); + +COMMENT ON COLUMN core.sla_targets.tz IS + 'Пояс, у якому ріжуться календарні межі періоду'; +COMMENT ON COLUMN core.sla_targets.min_coverage_pct IS + 'Нижче цього покриття вердикт не виноситься: період позначається як «недостатньо даних»'; +COMMENT ON COLUMN core.sla_targets.business_hours IS + 'НЕ РЕАЛІЗОВАНО (0070). Заповнене поле не звужує розрахунок, а лише додає попередження в період'; + +-- Пояс за замовчуванням береться з тенанта — один раз, при накаті. +-- Далі вони живуть окремо; див. міркування про переїзд вище. +UPDATE core.sla_targets t +SET tz = COALESCE(NULLIF(n.timezone, ''), 'UTC') +FROM core.tenants n +WHERE n.id = t.tenant_id AND t.tz = 'UTC'; + +-- --------------------------------------------------------------------- +-- 2. Закриті періоди +-- --------------------------------------------------------------------- + +-- Найважливіша зміна файлу: рядок періоду перестає залежати від того, +-- чи живий ще хост. +-- +-- Було: sla_periods.device_id REFERENCES inv.devices(id) ON DELETE CASCADE. +-- Тобто повне видалення хоста (0057) заднім числом стирало його звіти — +-- і докладний коментар у devices_purge.go чесно перелічує «періоди SLA» +-- серед того, що зникає каскадом. +-- +-- Для будь-якої іншої таблиці це правильно. Для цієї — ні, і причина та +-- сама, з якої переживає видалення журнал аудиту: звіт, показаний +-- клієнту, не може перестати існувати тому, що хтось прибрав хост із +-- переліку. Квартал не «розраховується заново без цього хоста» — він уже +-- відбувся. +-- +-- Тому ключ знімається, а id лишається звичайною колонкою. Наслідок, +-- який треба знати: після повного видалення хоста в періодах лишається +-- uuid, за яким уже нікого немає. Саме заради цього поруч з'являється +-- знімок імені — інакше звіт показував би стовпчик із голими uuid. +ALTER TABLE core.sla_periods + DROP CONSTRAINT IF EXISTS sla_periods_device_id_fkey; + +ALTER TABLE core.sla_periods + ADD COLUMN IF NOT EXISTS device_name text NOT NULL DEFAULT ''; + +-- Час, розкладений на чотири взаємно виключні частини. Разом вони дають +-- clock_sec, і саме тому їх чотири, а не два: +-- +-- maintenance_sec вікно обслуговування — годинник зупинено +-- up_sec виміряно, хост відповідав +-- downtime_sec виміряно, хост не відповідав +-- unknown_sec не виміряно нічим: зонд мовчав, хост був вимкнений, +-- даних просто немає +-- +-- Остання й є вся суть. Тримати її окремою колонкою означає, що +-- «система не знала» фізично неможливо сплутати ані з «працювало», ані +-- з «лежало»: щоб збрехати, довелось би свідомо додати unknown_sec до +-- up_sec, а це видно в коді, а не ховається в SQL. +ALTER TABLE core.sla_periods + ADD COLUMN IF NOT EXISTS up_sec bigint NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS unknown_sec bigint NOT NULL DEFAULT 0, + -- Повна тривалість періоду В МЕЖАХ ЖИТТЯ ХОСТА. Хост, заведений + -- 20 травня, не має «недоступності» за 1–19 травня: його не було. + -- Без цієї колонки різницю між «нема даних» і «нема хоста» довелось би + -- відновлювати з inv.devices, якого після видалення теж уже немає. + ADD COLUMN IF NOT EXISTS clock_sec bigint NOT NULL DEFAULT 0, + -- Частка періоду, про яку взагалі є вимір. Число, за яким читач + -- вирішує, чи вірити uptime_pct. + ADD COLUMN IF NOT EXISTS coverage_pct numeric(6,3) NOT NULL DEFAULT 0; + +-- Знімки того, ПРОТИ ЧОГО міряли. Ціль живе далі й може змінитись — +-- 99.5% підняли до 99.9%, поріг покриття зсунули. Закритий звіт мусить +-- пам'ятати умови, що діяли тоді, інакше торішній «виконано» одного дня +-- стане «порушено» без жодної події в мережі. +ALTER TABLE core.sla_periods + ADD COLUMN IF NOT EXISTS target_pct numeric(5,3), + ADD COLUMN IF NOT EXISTS min_coverage_pct numeric(5,2), + ADD COLUMN IF NOT EXISTS tz text NOT NULL DEFAULT 'UTC'; + +-- З якого відношення взято числа. Сьогодні завжди 'icmp_1h'; колонка +-- потрібна на той день, коли з'явиться інше джерело: без неї старі рядки +-- й нові виглядали б однаково, а порівнювати їх було б не можна. +ALTER TABLE core.sla_periods + ADD COLUMN IF NOT EXISTS source text NOT NULL DEFAULT 'icmp_1h'; + +-- closed — межа між «прикидкою» й «фактом». +-- +-- false: період ще триває або згортки під ним не встоялись; число +-- показують із позначкою «попередній розрахунок» і перераховують +-- скільки завгодно разів. +-- true: період закрито. Далі його читають. Перерахунок можливий лише +-- через явну дію людини, і кожен такий перерахунок видно — +-- див. revision. +ALTER TABLE core.sla_periods + ADD COLUMN IF NOT EXISTS closed boolean NOT NULL DEFAULT false; + +-- Скільки разів це число вже переписували. +-- +-- Не лічильник заради лічильника. Перерахунок закритого періоду — +-- законна дія (виправили пояс, дозаповнили вікно обслуговування), але +-- вона МАЄ лишати слід у самому звіті. Інакше два роздруки того самого +-- кварталу з різними числами неможливо розрізнити, і правий завжди той, +-- у кого папірець свіжіший. +ALTER TABLE core.sla_periods + ADD COLUMN IF NOT EXISTS revision int NOT NULL DEFAULT 1; + +-- Чого цей розрахунок НЕ врахував. +-- +-- Перелік коротких ключів: 'rrule_ignored', 'business_hours_ignored', +-- 'device_purged'. Живе в самому рядку, а не в логах, бо читати його +-- має той, хто дивиться на звіт через рік, — а логів за той день уже +-- немає. Мовчазна відмова врахувати щось є брехнею; названа вголос — +-- ні. +ALTER TABLE core.sla_periods + ADD COLUMN IF NOT EXISTS warnings jsonb NOT NULL DEFAULT '[]'::jsonb; + +COMMENT ON COLUMN core.sla_periods.unknown_sec IS + 'Час, про який немає жодного виміру. Ніколи не додається ні до up_sec, ні до downtime_sec'; +COMMENT ON COLUMN core.sla_periods.clock_sec IS + 'Тривалість періоду в межах життя хоста: створений чи видалений посеред періоду рахується частково'; +COMMENT ON COLUMN core.sla_periods.closed IS + 'true — факт, не перераховується; false — попередній розрахунок'; +COMMENT ON COLUMN core.sla_periods.revision IS + 'Скільки разів закритий період перераховували руками'; + +-- Перелік періодів однієї цілі — головний запит сторінки. +CREATE INDEX IF NOT EXISTS sla_periods_target_idx + ON core.sla_periods (sla_target_id, period); + +-- --------------------------------------------------------------------- +-- 3. Незмінність закритого періоду — на рівні бази +-- --------------------------------------------------------------------- + +-- Правило «закрите не переписують» тримається тригером, а не домовленістю +-- в Go. +-- +-- ПРИЧИНА: обіцянка «звіт за минулий квартал не змінюється» коштує рівно +-- стільки, скільки коштує найслабший шлях запису. Шляхів уже два (REST +-- і фоновий такт), третій з'явиться разом із наступною задачею, і саме +-- він забуде перевірку. Перевірка ж, що стоїть на таблиці, не має +-- обхідного шляху взагалі. +-- +-- НАСЛІДОК: перерахувати закритий період можна лише свідомо — виставивши +-- app.sla_reopen у 'on' у СВОЇЙ транзакції. Це не захист від адміністратора +-- бази (він зніме тригер), це захист від власного коду, написаного через +-- півроку іншою людиною. +CREATE OR REPLACE FUNCTION core.sla_period_guard() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + IF OLD.closed + AND COALESCE(current_setting('app.sla_reopen', true), '') <> 'on' THEN + RAISE EXCEPTION 'закритий період SLA не переписують' + USING ERRCODE = 'restrict_violation', + -- Підказка тут не з ввічливості: без неї перше зіткнення з + -- цим тригером виглядає як поломка бази, а не як + -- спрацювання правила. + HINT = 'для свідомого перерахунку: SET LOCAL app.sla_reopen = ''on'''; + END IF; + + -- NEW у тригері DELETE не існує, тож повертати його не можна: інакше + -- перше ж видалення НЕЗАКРИТОГО періоду впало б на порожньому записі. + IF TG_OP = 'DELETE' THEN + RETURN OLD; + END IF; + RETURN NEW; +END $$; + +COMMENT ON FUNCTION core.sla_period_guard() IS + 'Забороняє правку й видалення закритих періодів SLA без явного app.sla_reopen'; + +DROP TRIGGER IF EXISTS trg_sla_period_guard ON core.sla_periods; +CREATE TRIGGER trg_sla_period_guard + BEFORE UPDATE OR DELETE ON core.sla_periods + FOR EACH ROW EXECUTE FUNCTION core.sla_period_guard(); + +-- ПОБІЧНИЙ НАСЛІДОК, ЯКИЙ ТРЕБА ЗНАТИ НАПЕРЕД +-- +-- Тригер стоїть і на DELETE, тобто його бачать КАСКАДИ. Два місця, де це +-- проявиться: +-- +-- * видалення цілі SLA — оброблено в DeleteSLATarget: воно виставляє +-- app.sla_reopen у своїй транзакції, бо «видалити ціль» справді +-- означає «разом із її звітами»; +-- * жорстке видалення кабінету (DELETE FROM core.tenants) — упаде. +-- У продукті такого шляху немає, кабінет прибирається м'яко +-- (deleted_at), але тести й ручне прибирання бази роблять саме це. +-- Ліки — той самий SET app.sla_reopen = 'on' перед видаленням. +-- +-- Це навмисно не пом'якшено «дозволити каскади»: каскад, який мовчки +-- зносить закриті звіти, — рівно те, від чого написаний цей тригер. + +-- --------------------------------------------------------------------- +-- 4. Вікна обслуговування +-- --------------------------------------------------------------------- + +-- Функції в базі під це НЕ заводиться, і про причину варто сказати тут, +-- бо перше бажання — саме її й написати. +-- +-- Вікна треба (а) відібрати за селектором і (б) ОБ'ЄДНАТИ, бо вони +-- перетинаються: 02:00–04:00 плюс 03:00–05:00 — це три години зупиненого +-- годинника, а не чотири. Проста сума дала б завищення, до того ж +-- непомітне: доступність просто виходила б трохи кращою, ніж є. +-- +-- Обидві половини вже мають місце в коді. Селектор розбирає +-- selectorSQL — той самий, яким придушуються алерти; розійтись їм не +-- можна, бо інакше алерт придушено, а SLA зіпсовано. А об'єднання +-- відрізків — це та сама арифметика інтервалів, якою й так ріжеться +-- життя хоста в періоді, і в Go вона перевіряється тестом без бази, +-- тоді як у SQL — лише на живому Postgres. +-- +-- Тому тут лишається одне: індекс під вибірку вікон уже є +-- (mw_active_idx, gist(tenant_id, period) з 0007), і додавати нема чого. + +-- --------------------------------------------------------------------- +-- 5. Права +-- --------------------------------------------------------------------- + +-- Нових ключів прав не заводимо, і це рішення, а не лінощі. +-- +-- Дивитись звіт — devices:read: доступність своєї мережі бачить кожен, +-- хто взагалі бачить моніторинг. Ховати її немає від кого, а от не +-- побачити наближення до порога вчасно коштує грошей за договором. +-- +-- Заводити цілі й закривати періоди — settings:write. Ціль SLA — це +-- зобов'язання організації перед клієнтом, того самого класу, що й +-- строки зберігання: вона не про один хост, а про те, під чим +-- підписались. А закриття періоду ще й незворотне за наслідками. +-- +-- Окремі ключі sla:read/sla:write виглядали б охайніше, але кожен новий +-- ключ треба роздати ролям, показати на екрані прав і пояснити — тобто +-- заплатити людині за розрізнення, якого вона не просила. Завести їх +-- пізніше можна; забрати роздане право назад — уже ні. +GRANT SELECT, INSERT, UPDATE, DELETE ON core.sla_targets, core.sla_periods + TO netpulse_app, netpulse_worker; + +-- RLS тут окремо не вмикається: обидві таблиці мають tenant_id і були +-- захоплені циклом 0011 ще при створенні. Перевірено переліком політик, +-- а не припущенням, — саме на такому припущенні 0063 і знайшла шість +-- зв'язкових таблиць без жодної політики. diff --git a/server/migrations/0071_builtin_compliance_prototypes.sql b/server/migrations/0071_builtin_compliance_prototypes.sql new file mode 100644 index 0000000..0f33ebc --- /dev/null +++ b/server/migrations/0071_builtin_compliance_prototypes.sql @@ -0,0 +1,995 @@ +-- ===================================================================== +-- NetPulse :: 0071_builtin_compliance_prototypes.sql +-- Вбудовані правила відповідності й ще два прототипи — щоб розділи +-- перестали бути порожніми. +-- +-- ЩО ЗМІРЯНО НА СТЕНДІ +-- +-- правил відповідності 0 +-- прототипів увімкнено 0 +-- подієвих тригерів 0 +-- +-- Механіка вся є й перевірена: 0006 завела правила, 0056 навчила їх +-- знати, ЯКИЙ конфіг вони читають, 0058 зробила порушення подією, +-- 0059 дала прототипи. Немає рівно одного — вмісту. Клієнт, який +-- поставив систему, відкриває «Відповідність» і бачить порожню +-- таблицю з кнопкою «Додати правило» — тобто пропозицію самому +-- придумати, що таке безпечний конфіг. Порожній продукт виглядає +-- недоробленим навіть тоді, коли він повний, і найдорожче тут не +-- враження: людина, яка не знає, з чого почати, не починає взагалі. +-- +-- ЩО РОБИТЬ ЦЯ МІГРАЦІЯ +-- +-- 1. Заводить довідник вбудованих правил (20 штук) і розкладає його +-- по кабінетах — наявних і майбутніх. +-- 2. Дає вигляд, який показує, ЯКІ хости жодне вбудоване правило не +-- покриває: зелена таблиця в кабінеті, де половина заліза не +-- підпадає під жодне правило, — найдорожчий різновид брехні. +-- 3. Додає два прототипи сенсорів (ENTITY-SENSOR-MIB), обидва +-- вимкнені, в окремому шаблоні. +-- 4. Дає два вигляди, які відповідають на питання «скільки чеків +-- з'явиться і на яких хостах», ДО того, як прапорець увімкнули. +-- +-- ЧОГО ВОНА НЕ РОБИТЬ +-- +-- Не вмикає жодного прототипу. Не заводить жодного чека. Не змінює +-- поведінки збору. Після накату на наявний стенд у мережу не піде +-- жодного зайвого пакета — з'явиться лише вміст у двох таблицях і +-- чотири вигляди. +-- ===================================================================== + + +-- ===================================================================== +-- ЧАСТИНА 1. ВБУДОВАНІ ПРАВИЛА ВІДПОВІДНОСТІ +-- ===================================================================== + +-- --------------------------------------------------------------------- +-- 1.1. Чому правило не може бути «спільним для всіх кабінетів» +-- --------------------------------------------------------------------- + +-- Профілі збору (ncm.profiles), шаблони (tpl.templates) і ролі +-- (core.roles) мають вбудовані рядки з tenant_id IS NULL: один рядок на +-- інсталяцію, видимий усім. Для правил відповідності цей шлях закритий, +-- і не через смак: +-- +-- * ncm.compliance_rules.tenant_id оголошено NOT NULL ще в 0006; +-- * увесь репозиторний шар читає їх як `WHERE r.tenant_id = $1` +-- (store/ncm_compliance.go, ListComplianceRules), а RunCompliance +-- ходить рівно через цей перелік. +-- +-- Зробити колонку необов'язковою означало б, що вбудовані правила +-- перестануть потрапляти у вибірку — тобто будуть у базі й не будуть +-- працювати. Це та сама поломка, від якої застерігає 0058: сутність, +-- яка виглядає ввімкненою й не робить нічого. Виправляти її довелося б +-- у Go, а Go тут не змінюється жодним рядком — навмисно: міграція, +-- яка вимагає одночасного релізу сервера, не має кроку, на якому можна +-- зупинитись (та сама причина, що в 0063). +-- +-- Тому кожен кабінет отримує ВЛАСНУ копію правила. Це не обхідний шлях, +-- а правильна модель: правило відповідності — не довідник, а політика. +-- Один клієнт вимагає SSH-only скрізь, інший тримає telnet у технологічній +-- мережі й свідомо цього правила не хоче. Спільний рядок не дав би йому +-- ані вимкнути правило, ані звузити його селектор, не зачепивши сусідів. +-- +-- Ціна копії — потрібен ключ, за яким копію можна впізнати. + +ALTER TABLE ncm.compliance_rules + ADD COLUMN builtin_key text; + +COMMENT ON COLUMN ncm.compliance_rules.builtin_key IS + 'Ключ вбудованого правила, з якого зроблено цю копію; NULL — правило написала людина'; + +-- Один вбудований ключ на кабінет. Частковий індекс, бо в написаних +-- руками правил ключа немає, і NULL-ів тут буде більшість. +CREATE UNIQUE INDEX ncm_compliance_builtin_uniq + ON ncm.compliance_rules (tenant_id, builtin_key) + WHERE builtin_key IS NOT NULL; + +-- Правку копії ключ переживає навмисно: SaveComplianceRule перелічує +-- колонки в UPDATE поіменно й builtin_key не чіпає. Тобто людина може +-- звузити селектор, підправити зразок чи знизити серйозність — і +-- правило лишиться впізнаваним як вбудоване. Скинути ключ означало б +-- перетворити «наше правило, підправлене під нас» на «якесь правило». + + +-- --------------------------------------------------------------------- +-- 1.2. Довідник +-- --------------------------------------------------------------------- + +-- Окрема таблиця, а не INSERT просто в ncm.compliance_rules по колу +-- кабінетів. Причина одна й практична: кабінети створюються й після +-- накату міграції. Без довідника новий кабінет отримав би порожній +-- розділ — тобто рівно те, заради чого ця міграція існує, повернулось +-- би через тиждень для наступного клієнта. +-- +-- Без tenant_id, як core.check_types: це код, а не дані клієнта. Той +-- самий довід, що в db/profiles/README.md про каталог профілів. +CREATE TABLE ncm.builtin_compliance ( + key core.slug PRIMARY KEY, + name text NOT NULL, + description text NOT NULL, + kind ncm.rule_kind NOT NULL, + pattern text NOT NULL, + severity ncm.rule_severity NOT NULL, + + -- Тип конфігу задається ЯВНО в кожному рядку, хоч у колонки + -- ncm.compliance_rules.config_type і є DEFAULT 'running' із 0056. + -- + -- Значення за замовчуванням означало б, що предмет перевірки правила + -- вирішує клауза DEFAULT, а не той, хто правило написав. Для правила + -- про `transport input` це нешкідливо рівно доти, доки колись хтось + -- не вирішить змінити умовчання, — і тоді двадцять правил мовчки + -- почнуть читати інший документ. 0056 з'явилась саме через мовчазну + -- підміну документа; повторювати її на рівні DEFAULT смішно. + config_type text NOT NULL, + + -- Вендори, до яких правило застосовне. NULL — до всіх. + -- + -- Перетворюється на selector = {"vendors":[...]} — рівно ту форму, + -- яку читає store.Selector і компілює selectorSQL у предикат + -- `d.vendor = ANY(...)`. Інших полів селектора у вбудованих правил + -- немає навмисно (див. 1.5). + vendors text[], + + remediation text NOT NULL, + enabled boolean NOT NULL, + sort_order int NOT NULL DEFAULT 100 +); + +COMMENT ON TABLE ncm.builtin_compliance IS + 'Довідник вбудованих правил відповідності. Кожен кабінет отримує власні копії — див. ncm.builtin_compliance_apply().'; + +ALTER TABLE ncm.builtin_compliance ENABLE ROW LEVEL SECURITY; + +-- Довідник без tenant_id читають усі — так само, як core.check_types і +-- core.permissions у 0011. Політика лише на SELECT: писати в довідник +-- має право міграція, а не кабінет. +CREATE POLICY read_all ON ncm.builtin_compliance FOR SELECT USING (true); + +GRANT SELECT ON ncm.builtin_compliance TO netpulse_app, netpulse_worker; + + +-- --------------------------------------------------------------------- +-- 1.3. Головне рішення: вендорна специфіка +-- --------------------------------------------------------------------- + +-- «Немає telnet» — це не одне правило. На Cisco це `transport input`, +-- на Huawei `telnet server enable`, на D-Link `enable telnet`, на +-- MikroTik `set telnet disabled=no`. Питання, на яке треба відповісти +-- один раз і не переглядати: один зразок на всі родини — чи по правилу +-- на родину? +-- +-- ВІДПОВІДЬ: по правилу на родину, прив'язаному через selector.vendors. +-- Один виняток — про нього нижче. +-- +-- Три доводи, кожного з яких вистачило б. +-- +-- 1. ЗЕЛЕНЕ ВІД ОДНОЇ РОДИНИ ХОВАЄ СЛІПУ ПЛЯМУ В ІНШІЙ. Правило з +-- об'єднаним зразком, яке покриває Cisco й НЕ покриває MikroTik, +-- показує «порушень немає» — і воно каже правду про сорок Cisco та +-- мовчить про десять MikroTik. Один рядок у таблиці, одне число «0 +-- порушень», і жодного способу побачити, що десята частина парку +-- ніколи не перевірялась. Розбите на родини, воно дає окремий +-- рядок на кожну, і родина без правила видима як родина без +-- правила. Це та сама думка, що в шапці цього файлу про порожній +-- розділ: відсутність, яку видно, дешевша за відсутність, якої не +-- видно. +-- +-- 2. ПІДКАЗКА «ЯК ВИПРАВИТИ» В ПРАВИЛА ОДНА. Колонка remediation — +-- текст, який людина читає, стоячи перед знахідкою. Об'єднане +-- правило змусило б написати туди або чотири команди підряд («якщо +-- у вас Cisco, то...»), або одну — правильну для однієї родини й +-- хибну для трьох. Порада, правильна на чверть, гірша за її +-- відсутність: за відсутньою підуть у документацію, а за хибною +-- підуть на залізо. +-- +-- 3. ЗРАЗОК, ЯКИЙ НЕ ЗБІГАЄТЬСЯ НІКОЛИ, НЕ ВІДРІЗНИТИ ВІД СПРАВНОГО. +-- Це урок усього проєкту, і тут він має ціну: правило «не має +-- бути», чий зразок не підходить до синтаксису, дає «порушень +-- немає» — тобто виглядає ідеально. Прив'язка до вендора не +-- рятує сама собою, але робить перевірку можливою: набір вендорів +-- правила скінченний, і на кожен із них можна показати справжній +-- конфіг, де правило спрацювало, і справжній, де не спрацювало. +-- Саме це й зроблено — див. server/internal/store/ncm_compliance_builtin_test.go. +-- +-- ЗВІДСИ ЖОРСТКЕ ПРАВИЛО ЦЬОГО ФАЙЛУ: вендор не потрапляє в перелік +-- правила, поки для нього немає перевіреного зразка конфігу в тесті. +-- Тому Arista, Brocade, Juniper, Extreme, HP і Alcatel, чиї профілі в +-- каталозі є, у вбудовані правила НЕ ввійшли: їхній синтаксис я не +-- перевіряв, а вписати вендора «бо схоже на Cisco» означає завести +-- рівно ту мовчазну зелень, від якої все це й будується. Побачити +-- таких у своєму парку можна виглядом +-- ncm.builtin_compliance_coverage (1.6). +-- +-- ВИНЯТОК: правило про типові community. Воно єдине не прив'язане до +-- вендора, і не з ліні. Його зразок не спирається на синтаксис узагалі: +-- `public` і `private` — це не ключові слова вендора, а два рядки, які +-- однакові скрізь, бо це типові значення самого SNMP. Слово `community` +-- поруч із ними теж є в усіх діалектах (`snmp-server community`, +-- `snmp-agent community`, `create snmp community`), а MikroTik, який +-- пише `name=public` окремим рядком секції, покритий другою гілкою +-- зразка. Прив'язати таке правило до переліку вендорів означало б +-- зробити його сліпим до вендора, якого в переліку немає, — тобто +-- втратити єдине правило, яке працює на незнайомому залізі з коробки. +-- +-- І ще одне спостереження, яке визначило склад набору. Дві напрямки +-- правил помиляються по-різному: +-- +-- «НЕ МАЄ БУТИ» (regex_absent) при неповному зразку МОВЧИТЬ. Помилка +-- безкоштовна на вигляд і дорога по суті. Тому такі зразки написані +-- широко: кілька гілок на родину, включно з формами, яких у моїх +-- зразках немає (`set [ find name=telnet ]`). Зайва гілка тут не +-- коштує нічого: вона може лише знайти більше. +-- +-- «МАЄ БУТИ» (regex_match) при неповному зразку КРИЧИТЬ: кожен хост +-- родини стає порушником. Помилка гучна, але дорога інакше — з неї +-- починається звичка не дивитись на червоне. Тому такі зразки +-- написані вузько й лише для родин, де я бачив, як виглядає ПРАВИЛЬНО +-- налаштований конфіг. +-- +-- Саме тому переліки вендорів у правил «немає telnet» ширші, ніж у +-- правил «є сервер журналювання», хоч ідея в них однакова. + + +-- --------------------------------------------------------------------- +-- 1.4. Сам набір +-- --------------------------------------------------------------------- + +-- Зразки в доларових лапках ($rx$...$rx$), а не в звичайних. Причина +-- механічна: у регулярних виразах живуть зворотні скісні риски й +-- апострофи, і подвоєння лапок перетворило б їх на нечитний рядок, у +-- якому помилку не видно очима. Друга причина важливіша: у такому +-- вигляді зразок однозначно виймається з файлу — тест +-- ncm_compliance_builtin_test.go читає САМЕ ЦЕЙ файл через +-- schema.Files і ганяє звідси зразки по конфігах. Другого примірника +-- цих виразів у репозиторії немає, і розійтись їм нема з чим. + +INSERT INTO ncm.builtin_compliance + (key, severity, kind, config_type, vendors, pattern, name, description, remediation, enabled, sort_order) +VALUES + +-- === SNMP ============================================================= + +-- Типові community — єдине правило набору без прив'язки до вендора +-- (обґрунтування в 1.3). +-- +-- Ловить: `snmp-server community public RO` (Cisco/Eltex/ZTE), +-- `snmp-agent community read public` (Huawei/H3C), +-- `create snmp community private view CommunityView read_write` (D-Link), +-- `set [ find default=yes ] name=public` (MikroTik — окрема гілка, бо +-- слова `community` в цьому рядку немає, воно в заголовку секції). +-- +-- Не ловить (і не має): `community NP-ro-2024`, `community read cipher`, +-- `name=NP-ro-2024`. Межа слова навколо public/private навмисна: без +-- неї правило спрацьовувало б на community з назвою `MyPublicKey`. + ('snmp-default-community', 'critical', 'regex_absent', 'running', NULL, + $rx$(?i)(communit(y|ies).*\b(public|private)\b|\bname=(public|private)\b)$rx$, + 'SNMP: типові community public/private', + 'Community public і private знає напам''ять кожен сканер. Через RO-community читається таблиця маршрутизації, перелік портів і сусіди; через RW-community конфіг ще й переписується. Це не теоретичний ризик, а перше, що пробує будь-хто, хто дістався до мережі.', + 'Замініть community на власне значення й обмежте його списком доступу. Cisco: no snmp-server community public / snmp-server community <нове> RO . Huawei: undo snmp-agent community public. D-Link: delete snmp community public. MikroTik: /snmp community set [ find default=yes ] name=<нове> addresses=<мережа керування>. Найкраще — перейти на SNMPv3.', + true, 10), + +-- === Telnet =========================================================== + +-- Cisco-подібні. Три форми, бо родина одна лише за консоллю: +-- * `transport input telnet` і `transport input all` на лініях vty; +-- * `ip telnet server` — Eltex MES; +-- * `telnet server enable` — трапляється на ZTE. +-- Форми вимкнення (`no ip telnet server`, `transport input ssh`) під +-- зразок не підпадають, бо кожна гілка прив'язана до початку рядка. +-- +-- ЧОГО ЦЕ ПРАВИЛО НЕ ЛОВИТЬ, і це треба знати. Лінія vty БЕЗ жодного +-- рядка `transport input` на старих IOS означає «дозволено все», тобто +-- telnet увімкнено — і в конфізі про це немає ані слова. Правило +-- порядкове (checkCompliance читає рядки, а не блоки), тому відсутність +-- рядка всередині блока воно побачити не може в принципі. Це чесна межа +-- рушія, а не недогляд зразка. + ('no-telnet-ios', 'high', 'regex_absent', 'running', + string_to_array('Cisco,Eltex,ZTE', ','), + $rx$(?i)^\s*(transport input\b.*\b(telnet|all)\b|ip telnet server\b|telnet server enable\b)$rx$, + 'Telnet на керуванні (Cisco, Eltex, ZTE)', + 'Telnet передає пароль відкритим текстом. У мережі оператора це означає, що пароль до комутатора видно кожному, хто слухає транзитний сегмент, — включно з абонентом, який зняв дзеркало порту.', + 'Cisco/ZTE: line vty 0 15 / transport input ssh. Eltex: no ip telnet server. Перед вимкненням переконайтесь, що SSH піднято й ключ згенеровано (crypto key generate rsa), інакше доступ до вузла втрачається разом із telnet.', + true, 20), + +-- Huawei VRP і H3C Comware. Три форми: глобальний сервер, `protocol +-- inbound` на vty й тип сервісу користувача. Форми з `undo` не +-- підпадають — гілки прив'язані до початку рядка. + ('no-telnet-vrp', 'high', 'regex_absent', 'running', + string_to_array('Huawei,H3C', ','), + $rx$(?i)^\s*telnet\s+(ipv6\s+)?server\s+enable\b|^\s*protocol\s+inbound\s+(all|telnet)\b|\bservice-type\b[^\n]*\btelnet\b$rx$, + 'Telnet на керуванні (Huawei, H3C)', + 'Те саме, що для Cisco, плюс особливість VRP: telnet тут вмикається у трьох незалежних місцях, і вимкнення глобального сервера не забирає telnet у користувача, якому він виданий через service-type.', + 'undo telnet server enable; на лінії — protocol inbound ssh; у користувача — local-user <ім''я> service-type ssh. Перевірити всі три: залишений service-type telnet лишає вхід відкритим.', + true, 21), + +-- D-Link. `enable telnet [порт]` на Smart/xStack і форма +-- `config telnet ... state enable` на частині прошивок. + ('no-telnet-dlink', 'high', 'regex_absent', 'running', + string_to_array('D-Link', ','), + $rx$(?i)^\s*(enable\s+telnet\b|config\s+telnet\b[^\n]*\bstate\s+enable\b)$rx$, + 'Telnet на керуванні (D-Link)', + 'На D-Link telnet увімкнений із заводу, і найчастіше він так і лишається: SSH на цих серіях треба вмикати окремою командою й генерувати ключ.', + 'disable telnet, попередньо: enable ssh та config ssh authmode password enable. Без піднятого SSH вимкнення telnet лишає лише консольний кабель.', + true, 22), + +-- MikroTik RouterOS. Одна гілка покриває обидві форми запису — і +-- `set telnet disabled=no`, і `set [ find name=telnet ] disabled=no`, +-- бо між `set` і `disabled=no` в обох випадках стоїть слово telnet. + ('no-telnet-routeros', 'high', 'regex_absent', 'running', + string_to_array('MikroTik', ','), + $rx$(?i)^\s*set\s+[^\n]*\btelnet\b[^\n]*\bdisabled=no\b$rx$, + 'Telnet на керуванні (MikroTik)', + 'У RouterOS telnet — один із сервісів /ip service, увімкнений типово. Поруч із ним типово увімкнені ftp і www, які так само передають пароль відкритим текстом.', + '/ip service set telnet disabled=yes; заразом варто вимкнути ftp і www та обмежити ssh і winbox параметром address=<мережа керування>.', + true, 23), + +-- === Паролі =========================================================== + +-- Cisco-подібні. Чотири ознаки зворотного (тобто відновлюваного) +-- зберігання: +-- * `no service password-encryption` — паролі лежать відкрито; +-- * `enable password` у будь-якій формі — на відміну від +-- `enable secret`, це або відкритий текст, або тип 7; +-- * `password 0 ...` (відкрито) і `password 7 ...` (тип 7, +-- розшифровується онлайн-декодером за секунду); +-- * одинокий `password <слово>` у блоці лінії — форма без типу. +-- +-- Навмисно НЕ ловить `password encrypted <хеш>` (Eltex) і `secret 5|8|9` +-- (Cisco): це незворотні схеми, і позначати їх порушенням означало б +-- зробити правило червоним на правильно налаштованому вузлі. + ('plaintext-password-ios', 'high', 'regex_absent', 'running', + string_to_array('Cisco,Eltex,ZTE', ','), + $rx$(?i)^\s*no service password-encryption\s*$|^\s*enable password\b|\bpassword\s+[07]\s+\S|^\s*password\s+\S+\s*$$rx$, + 'Пароль у зворотному вигляді (Cisco, Eltex, ZTE)', + 'Конфіг комутатора бачить кожен, хто має доступ на читання: підрядник, стажер, система резервного копіювання, репозиторій Git. Пароль типу 7 і пароль відкритим текстом — це той самий пароль, різниця лише в секунді на розшифрування.', + 'enable secret замість enable password; username <ім''я> secret <пароль> замість password; service password-encryption як мінімум. Зміна не розшифровує вже збережені паролі — їх треба задати наново.', + true, 30), + +-- Huawei VRP і H3C. Ключове слово `simple` в обох діалектах означає +-- рівно «зберігати відкритим текстом». + ('plaintext-password-vrp', 'high', 'regex_absent', 'running', + string_to_array('Huawei,H3C', ','), + $rx$(?i)\bpassword\s+simple\b$rx$, + 'Пароль відкритим текстом (Huawei, H3C)', + 'На VRP і Comware форма `password simple` кладе пароль у конфіг як є. Поруч у тому ж конфізі є форми cipher та irreversible-cipher, тобто вибір зроблено свідомо або скопійовано з чужого прикладу.', + 'Huawei: local-user <ім''я> password irreversible-cipher <пароль>. H3C: local-user <ім''я> / password hash <...> або password cipher. Стару форму треба перезадати: перемикання режиму не перешифровує наявний рядок.', + true, 31), + +-- MikroTik. Порожній `password=""` під зразок не підпадає навмисно: +-- це не пароль у конфізі, а його відсутність у вивантаженні. + ('plaintext-password-routeros', 'high', 'regex_absent', 'running', + string_to_array('MikroTik', ','), + $rx$(?i)\bpassword=("[^"]+"|[^\s"]+)$rx$, + 'Пароль відкритим текстом (MikroTik)', + 'У вивантаженні RouterOS пароль з''являється там, де його задали параметром: /ppp secret, /interface pppoe-client, /tool e-mail. Такий конфіг не можна ані показати підряднику, ані покласти в Git.', + 'Виносьте секрети з конфігу: для PPP — на RADIUS, для решти — окремі облікові записи з мінімальними правами. Пам''ятайте, що резервна копія /export уже поїхала в сховище — змініть паролі, а не лише конфіг.', + true, 32), + +-- === Журналювання ===================================================== + +-- Cisco-подібні. Зразок вимагає саме АДРЕСУ: `logging trap +-- informational` описує рівень, а не одержувача, і зарахувати його як +-- «сервер журналювання є» означало б дати зелене вузлу, який пише +-- журнал у власну пам'ять і губить його при перезавантаженні. + ('syslog-target-ios', 'medium', 'regex_match', 'running', + string_to_array('Cisco,Eltex,ZTE', ','), + $rx$(?i)^\s*logging\s+(host\s+|server\s+)?(\d{1,3}\.){3}\d{1,3}\b$rx$, + 'Журнал іде на сервер (Cisco, Eltex, ZTE)', + 'Локальний буфер журналу гине разом із вузлом: перезавантаження, збій живлення й підміна пристрою забирають із собою рівно ті записи, заради яких журнал і читають. Розбір аварії починається з питання «що було за хвилину до», і відповідь має лежати не на аварійному вузлі.', + 'logging host <адреса> (Cisco/Eltex) або logging server <адреса> (ZTE), плюс logging trap informational. Перевірте, що адреса досяжна з vrf керування.', + true, 40), + + ('syslog-target-vrp', 'medium', 'regex_match', 'running', + string_to_array('Huawei,H3C', ','), + $rx$(?i)^\s*info-center\s+loghost\b$rx$, + 'Журнал іде на сервер (Huawei, H3C)', + 'Те саме, що для Cisco. На VRP і Comware за відправлення журналу відповідає info-center, і він може бути ввімкнений без жодного одержувача — тобто працювати «в нікуди».', + 'info-center enable, потім info-center loghost <адреса> [channel ]. Без другої команди перша нічого не надсилає.', + true, 41), + + ('syslog-target-dlink', 'medium', 'regex_match', 'running', + string_to_array('D-Link', ','), + $rx$(?i)^\s*create\s+syslog\s+host\b$rx$, + 'Журнал іде на сервер (D-Link)', + 'На D-Link одержувач журналу — окремий об''єкт, який треба створити. Команда enable syslog без нього вмикає підсистему, у якої немає куди писати.', + 'create syslog host 1 ipaddress <адреса> severity informational facility local0 udp_port 514 state enable, потім enable syslog.', + true, 42), + +-- MikroTik. Дві гілки: заголовок секції дій журналювання й параметр +-- `remote=<адреса>` у самій дії. Секція /system logging без дій — +-- це локальний журнал у пам'яті, тобто саме те, що правило шукає й +-- не має зараховувати. + ('syslog-target-routeros', 'medium', 'regex_match', 'running', + string_to_array('MikroTik', ','), + $rx$(?i)^\s*/system\s+logging\s+action\b|\bremote=(\d{1,3}\.){3}\d{1,3}\b$rx$, + 'Журнал іде на сервер (MikroTik)', + 'Типовий RouterOS пише журнал у пам''ять, і перезавантаження стирає його повністю. На платах із малим обсягом пам''яті буфер до того ж короткий — кілька хвилин під навантаженням.', + '/system logging action add name=remote target=remote remote=<адреса> та /system logging add action=remote topics=info,error,warning,critical.', + true, 43), + +-- === Час ============================================================== + +-- Cisco-подібні. Одна гілка на `ntp server` і `sntp server`: Eltex +-- використовує другу форму, решта — першу. + ('ntp-source-ios', 'medium', 'regex_match', 'running', + string_to_array('Cisco,Eltex,ZTE', ','), + $rx$(?i)^\s*s?ntp\s+server\b$rx$, + 'Джерело часу задано (Cisco, Eltex, ZTE)', + 'Час без синхронізації — це журнал, який неможливо звести з журналом сусіднього вузла. Розбір аварії, у якому мітки двох пристроїв розходяться на години, перетворюється на здогади; сертифікати при цьому починають «протухати» або, навпаки, ставати чинними завчасно.', + 'ntp server <адреса> (Cisco/ZTE) або sntp server <адреса> + sntp unicast client enable (Eltex). Задавайте два джерела: одне джерело — це не резервування, а точка відмови з мітками часу.', + true, 50), + + ('ntp-source-vrp', 'medium', 'regex_match', 'running', + string_to_array('Huawei,H3C', ','), + $rx$(?i)^\s*ntp(-service)?\s+unicast-server\b$rx$, + 'Джерело часу задано (Huawei, H3C)', + 'Те саме, що для Cisco. VRP називає команду ntp-service, Comware 7 — ntp-service або ntp; обидві форми покриті.', + 'ntp-service unicast-server <адреса>. Другий сервер — окремою командою.', + true, 51), + + ('ntp-source-dlink', 'medium', 'regex_match', 'running', + string_to_array('D-Link', ','), + $rx$(?i)^\s*(config\s+sntp\b|enable\s+sntp\b)$rx$, + 'Джерело часу задано (D-Link)', + 'На D-Link SNTP типово вимкнений, і рядок disable sntp у конфізі — звичайна річ. Годинник при цьому йде від внутрішнього кварцу й розходиться на хвилини за тиждень.', + 'config sntp primary <адреса> secondary <адреса> poll-interval 720, потім enable sntp.', + true, 52), + +-- MikroTik. Заголовок секції `/system ntp client` з'являється у +-- вивантаженні лише тоді, коли клієнт налаштований, тому його +-- достатньо; друга гілка ловить форму зі старих версій, де адреса +-- задається параметром primary-ntp. + ('ntp-source-routeros', 'medium', 'regex_match', 'running', + string_to_array('MikroTik', ','), + $rx$(?i)^\s*/system\s+ntp\s+client\b|\b(primary-ntp|ntp-server)=\S$rx$, + 'Джерело часу задано (MikroTik)', + 'RouterOS без NTP-клієнта тримає час від запуску: після перезавантаження без батарейки годинник починається з дати прошивки. Мітки в журналі й правила з розкладом стають безглуздими.', + '/system ntp client set enabled=yes servers=<адреса1>,<адреса2>. Заразом задайте /system clock set time-zone-name=Europe/Kyiv.', + true, 53), + +-- === Обмеження доступу до керування =================================== + +-- Cisco-подібні. Дві форми: access-class на лінії vty (Cisco, ZTE) і +-- management access-class (Eltex). + ('mgmt-acl-ios', 'medium', 'regex_match', 'running', + string_to_array('Cisco,Eltex,ZTE', ','), + $rx$(?i)^\s*(access-class\s+\S+\s+in\b|management\s+access-class\b)$rx$, + 'Доступ до керування обмежено списком (Cisco, Eltex, ZTE)', + 'Вузол, у якого SSH відкритий з усіх адрес, доступний для підбору пароля з будь-якого абонентського порту. Список доступу на лініях керування — єдиний рубіж, який працює до перевірки пароля, тобто до того, як підбір узагалі щось коштує.', + 'Cisco/ZTE: ip access-list standard MGMT / permit <мережа керування>, потім line vty 0 15 / access-class MGMT in. Eltex: management access-list MGMT / permit ip-source <мережа> та management access-class MGMT.', + true, 60), + + ('mgmt-acl-vrp', 'medium', 'regex_match', 'running', + string_to_array('Huawei,H3C', ','), + $rx$(?i)^\s*acl\s+\d+\s+inbound\b$rx$, + 'Доступ до керування обмежено списком (Huawei, H3C)', + 'Те саме, що для Cisco. На VRP і Comware список чіпляється до user-interface vty командою acl <номер> inbound.', + 'acl number 2000 / rule permit source <мережа> <вілдкард>, потім user-interface vty 0 4 / acl 2000 inbound.', + true, 61), + + ('mgmt-acl-dlink', 'medium', 'regex_match', 'running', + string_to_array('D-Link', ','), + $rx$(?i)^\s*create\s+trusted_host\b$rx$, + 'Доступ до керування обмежено списком (D-Link)', + 'На D-Link роль списку доступу для керування виконує trusted_host. Поки не створено жодного, керування відкрите з усієї мережі.', + 'create trusted_host network <мережа>/<маска> — і одразу перевірте, що ваша власна адреса до неї входить.', + true, 62), + +-- MikroTik. Параметр address= на сервісі — рівно той самий рубіж, що +-- access-class на vty. + ('mgmt-acl-routeros', 'medium', 'regex_match', 'running', + string_to_array('MikroTik', ','), + $rx$(?i)^\s*set\s+(ssh|winbox|www-ssl|api|api-ssl)\b[^\n]*\baddress=\S$rx$, + 'Доступ до керування обмежено списком (MikroTik)', + 'Winbox і API RouterOS — постійні цілі масових сканів, і кілька гучних кампаній масового зламу починались саме з відкритих у світ портів керування.', + '/ip service set ssh address=<мережа керування>; те саме для winbox і api. Параметр address приймає кілька мереж через кому.', + true, 63); + + +-- --------------------------------------------------------------------- +-- 1.5. Чого у вбудованих правил немає в селекторі +-- --------------------------------------------------------------------- + +-- Лише vendors. Ані груп, ані сайтів, ані міток — і це не спрощення. +-- +-- Групи, сайти й мітки — це те, як КЛІЄНТ поділив свою мережу. Ми про +-- цей поділ не знаємо нічого: у одного «core» означає ядро мережі, у +-- другого — назву майданчика. Вбудоване правило, яке посилається на +-- групу, або не знайде її (і мовчки не застосується ні до кого), або +-- знайде однойменну чужу (і застосується не туди). Вендор натомість — +-- це властивість самого заліза, яку система визначила сама +-- (inv.ident_rules, 0035), а не назва, яку хтось придумав. +-- +-- Цей же факт робить можливим вигляд нижче: селектор вбудованого +-- правила має рівно одну форму, і її можна чесно порахувати в SQL. + + +-- --------------------------------------------------------------------- +-- 1.6. Хто лишився непокритим +-- --------------------------------------------------------------------- + +-- Правило, прив'язане до вендора, мовчазне двічі. Перший раз — коли +-- зразок не підходить (від цього рятує тест). Другий — коли хост просто +-- не підпадає під селектор: він не рахується ані як пройдений, ані як +-- проваленений, ані як пропущений. У ComplianceStat його немає взагалі. +-- +-- Для одного правила це правильно (правило про D-Link не має що сказати +-- про Cisco). Для НАБОРУ — небезпечно: хост вендора, якого немає в +-- жодному правилі, дає порожній результат, який зовні не відрізнити від +-- бездоганного. Саме це й треба зробити видимим. +-- +-- Вигляд рахує лише ВБУДОВАНІ правила навмисно. Селектор правил, +-- написаних руками, тлумачить Go (store.selectorSQL: групи, сайти, +-- мітки, шаблони), і повторити цю логіку в SQL означало б завести +-- другий примірник, який розійдеться з першим на першій же правці — +-- рівно те, від чого застерігає db/profiles/README.md. Вбудовані ж +-- правила мають селектор однієї відомої форми (1.5), тож число тут +-- точне, а не приблизне. +-- +-- security_invoker обов'язковий: без нього вигляд читав би таблиці +-- правами ВЛАСНИКА й показував би хости всіх кабінетів. Це не здогад — +-- 0063 знайшла рівно таку діру в topo.link_live і назвала її єдиним +-- місцем у схемі, де перехід на роль без BYPASSRLS сам собою нічого не +-- змінює. Другого такого місця не буде. +CREATE VIEW ncm.builtin_compliance_coverage +WITH (security_invoker = true) AS +SELECT + d.tenant_id, + d.id AS device_id, + d.name AS device_name, + COALESCE(d.vendor, '') AS vendor, + count(r.id) AS builtin_rules, + count(r.id) FILTER (WHERE r.enabled) AS builtin_rules_enabled +FROM inv.devices d +LEFT JOIN ncm.compliance_rules r + ON r.tenant_id = d.tenant_id + AND r.builtin_key IS NOT NULL + -- Три випадки, і всі три треба назвати явно: + -- * ключа vendors немає — правило застосовне до всіх; + -- * перелік порожній — те саме, але записане інакше; + -- * вендор хоста є в переліку. + -- COALESCE навколо jsonb_typeof не косметика: для селектора {} + -- вираз `jsonb_typeof(NULL) <> 'array'` дає NULL, а не TRUE, і + -- універсальне правило випало б із підрахунку — тобто вигляд + -- недорахував би покриття саме там, де воно повне. + AND ( COALESCE(jsonb_typeof(r.selector -> 'vendors'), 'null') <> 'array' + OR jsonb_array_length(r.selector -> 'vendors') = 0 + OR jsonb_exists(r.selector -> 'vendors', COALESCE(d.vendor, '')) ) +WHERE d.deleted_at IS NULL +GROUP BY d.tenant_id, d.id, d.name, d.vendor; + +COMMENT ON VIEW ncm.builtin_compliance_coverage IS + 'Скільки вбудованих правил накриває кожен хост. Нуль означає, що перевірка відповідності про цей хост не знає нічого — і це не те саме, що «порушень немає».'; + +GRANT SELECT ON ncm.builtin_compliance_coverage TO netpulse_app, netpulse_worker; + + +-- --------------------------------------------------------------------- +-- 1.7. Розкладання по кабінетах +-- --------------------------------------------------------------------- + +-- SECURITY DEFINER, і це не зручність. Політика tenant_isolation на +-- ncm.compliance_rules має WITH CHECK (tenant_id = core.current_tenant()), +-- тобто вставити рядок для кабінету, який ЩОЙНО створюють і який +-- поточним ще не став, під роллю netpulse_app неможливо за побудовою. +-- Тригер без прав власника мовчки нічого б не вставив — і новий кабінет +-- отримав би той самий порожній розділ. +-- +-- search_path прибитий цвяхами з тієї ж причини, що в core.apply_retention_policies +-- (0064): функція правами власника не має читати таблицю, підмінену +-- схемою в search_path того, хто викликає. +-- +-- EXECUTE відкликано в PUBLIC. Функція правами власника, яка приймає +-- чужий tenant_id, — це готовий обхід ізоляції: будь-хто з доступом до +-- бази міг би писати рядки в чужий кабінет. Тригеру дозвіл не потрібен: +-- права на тригерну функцію перевіряються при CREATE TRIGGER, а не при +-- спрацюванні. +CREATE FUNCTION ncm.builtin_compliance_apply(p_tenant uuid) RETURNS int +LANGUAGE plpgsql SECURITY DEFINER +SET search_path = pg_catalog, ncm, core, public AS $fn$ +DECLARE + added int; +BEGIN + INSERT INTO ncm.compliance_rules + (tenant_id, name, description, kind, pattern, severity, selector, + remediation, enabled, config_type, builtin_key) + SELECT p_tenant, b.name, b.description, b.kind, b.pattern, b.severity, + CASE WHEN b.vendors IS NULL OR cardinality(b.vendors) = 0 + THEN '{}'::jsonb + ELSE jsonb_build_object('vendors', to_jsonb(b.vendors)) + END, + b.remediation, b.enabled, b.config_type, b.key + FROM ncm.builtin_compliance b + WHERE NOT EXISTS ( + SELECT 1 FROM ncm.compliance_rules r + WHERE r.tenant_id = p_tenant AND r.builtin_key = b.key + ) + ORDER BY b.sort_order, b.key; + + GET DIAGNOSTICS added = ROW_COUNT; + RETURN added; +END +$fn$; + +COMMENT ON FUNCTION ncm.builtin_compliance_apply(uuid) IS + 'Довкладає в кабінет ті вбудовані правила, яких у ньому ще немає. Наявних не чіпає — правку копії людина робить назавжди.'; + +REVOKE EXECUTE ON FUNCTION ncm.builtin_compliance_apply(uuid) FROM PUBLIC; + +-- Наявні кабінети. +-- +-- Видалені пропускаємо: рядки в них нікому не видно, а FK на +-- core.tenants однаково тримає їх до фізичного видалення. +DO $$ +DECLARE + t record; +BEGIN + FOR t IN SELECT id FROM core.tenants WHERE deleted_at IS NULL LOOP + PERFORM ncm.builtin_compliance_apply(t.id); + END LOOP; +END $$; + +-- Майбутні кабінети. +-- +-- Тригером, а не кроком у коді створення кабінету, бо такого коду +-- немає: кабінети заводяться SQL-ом при встановленні (див. +-- deploy/ і тести httpapi). Обов'язок, покладений на процедуру, яку +-- виконують руками, виконується рівно доти, доки про нього пам'ятає +-- той, хто її виконує. +-- +-- AFTER INSERT і RETURN NULL: рядок кабінету вже вставлений, і +-- повертати з нього нічого не треба. +CREATE FUNCTION ncm.builtin_compliance_on_tenant() RETURNS trigger +LANGUAGE plpgsql SECURITY DEFINER +SET search_path = pg_catalog, ncm, core, public AS $fn$ +BEGIN + PERFORM ncm.builtin_compliance_apply(NEW.id); + RETURN NULL; +END +$fn$; + +REVOKE EXECUTE ON FUNCTION ncm.builtin_compliance_on_tenant() FROM PUBLIC; + +CREATE TRIGGER trg_tenants_builtin_compliance + AFTER INSERT ON core.tenants + FOR EACH ROW EXECUTE FUNCTION ncm.builtin_compliance_on_tenant(); + + +-- --------------------------------------------------------------------- +-- 1.8. Чому правила УВІМКНЕНІ типово — при жорсткому правилі «нічого не +-- вмикати само» +-- --------------------------------------------------------------------- + +-- Правило проєкту звучить так: оновлення не має ввімкнути нічого, що +-- заводить чеки або підіймає алерти. Його вже двічі порушували ціною +-- поламаних хостів, і 0059 свідомо лишила hrStorage вимкненим саме +-- через нього. +-- +-- Перевіримо вбудовані правила відповідності проти обох половин. +-- +-- ЧЕКИ. Правило не заводить жодного. RunCompliance +-- (store/ncm_compliance.go) читає ncm.configs — те, що ВЖЕ зібрано, — і +-- не створює ані сесії до заліза, ані рядка в core.checks. Приріст +-- трафіку в мережу після цієї міграції рівно нульовий, і це можна +-- перевірити не міркуванням, а grep-ом: у RunCompliance немає жодного +-- запису в core.checks. +-- +-- АЛЕРТИ. Складніше й цікавіше. Джерело `compliance` в alr.rules +-- працює з 0058, і порушення справді може стати алертом. Але подивимось, +-- ХТО запускає прогін: RunCompliance викликається рівно з одного місця — +-- handleRunCompliance, тобто POST /api/v1/ncm/compliance/run. Ані такту +-- воркера, ані розкладу, ані виклику після збору конфігу немає (перевірено +-- пошуком по всьому дереву: три згадки, усі в цьому ланцюжку). +-- +-- Тобто ввімкнене правило не робить НІЧОГО, доки людина не натисне +-- «Перевірити». Оновлення не вмикає нічого само — принцип не порушено +-- ані буквою, ані духом. Вимкнути правила «про всяк випадок» означало б +-- дати клієнтові двадцять сірих рядків і кнопку «увімкнути» біля +-- кожного, тобто повернути ту саму порожнечу, лише багатослівнішу. +-- +-- ЧЕСНО ПРО РИЗИК. Перше натискання «Перевірити» на стенді, де вже є +-- правило алертів із джерелом `compliance`, підніме стільки алертів, +-- скільки знайдеться порушень. Це не «мовчазний наслідок оновлення» — +-- це наслідок свідомого натискання, і саме тому він прийнятний. Пом'якшує +-- його наявна механіка, а не нова: один алерт на пару «правило + хост» +-- (0058), min_interval_seconds, min_severity у самому правилі алертів і +-- auto_close_seconds = 86400, який 0058 виставила подієвим правилам +-- джерела compliance. +-- +-- І окремо про серйозність. Жодне вбудоване правило не має рівня +-- `critical`, крім типових community, — а це рівно той випадок, коли +-- розбудити вночі правильно. Решта — high і medium: вони мають +-- потрапити у звіт, а не в телефон. + + +-- ===================================================================== +-- ЧАСТИНА 2. ПРОТОТИПИ +-- ===================================================================== + +-- --------------------------------------------------------------------- +-- 2.1. Що лишилось після 0059 +-- --------------------------------------------------------------------- + +-- 0059 лишила рівно один вбудований прототип — hrStorage — і вимкнула +-- його. Рішення було правильним і лишається правильним: шаблон +-- «snmp-host-resources» вже причеплений до хостів на робочих стендах, і +-- ввімкнений прототип завів би їм по два чеки мовчки, під час міграції. +-- +-- Але «правильно вимкнено» — це половина відповіді. Друга половина: +-- людина, яка хоче його ввімкнути, зараз не може дізнатись, що станеться. +-- Прапорець стоїть у редакторі шаблону поруч зі словом «Увімкнено», і +-- натиснути його — це рішення, ухвалене наосліп: скільки чеків +-- з'явиться? на яких хостах? скільки рядків знайдеться? Відповідь є в +-- базі, але немає жодного запиту, який її ставить. +-- +-- Вимкнений прапорець без цифри поруч перетворює обережність на +-- параліч: не ввімкне ніхто й ніколи, бо ніхто не знає ціни. Тому цифри +-- нижче — не звіт, а умова, за якої вимкнене типово взагалі має сенс. + + +-- --------------------------------------------------------------------- +-- 2.2. Скільки це коштуватиме +-- --------------------------------------------------------------------- + +-- Що саме заводить увімкнений прототип (reconcilePrototypes, +-- store/prototypes.go), по кроках: +-- +-- 1. Один чек snmp.walk на пару (хост, шаблон) для кожного РІЗНОГО +-- discovery_sec. Прототипи одного шаблону з однаковою частотою +-- обходу злипаються в один чек — тому «ще один прототип» не +-- обов'язково означає «ще один обхід». +-- 2. Після першого обходу — по одному snmp.get на прототип на хост, у +-- якому стільки OID, скільки знайдено рядків × метрик у прототипі. +-- +-- Другий крок відкладений, і це важливо для чесності цифр: скільки +-- буде рядків, до обходу не знає ніхто. Тому вигляд каже те, що знає: +-- скільки хостів, скільки обходів з'явиться НЕГАЙНО, скільки чеків +-- значень буде щонайбільше й скільки рядків уже відомо. +-- +-- Спершу — вигляд «хост за хостом», а зведення будується поверх нього. +-- +-- Порядок не косметичний. Зведення мусить порахувати, у скількох +-- хостів обходу ЩЕ НЕМА, тобто відфільтрувати рядки за умовою «чека не +-- існує». Написати це як count(*) FILTER (WHERE NOT EXISTS (...)) +-- означало б покласти підзапит усередину фільтра агрегата — місце, де +-- планувальник хоч і не бореться, але перевірити це на живій базі під +-- час написання не було де. Обчислити ознаку один раз у нижньому +-- вигляді й фільтрувати за звичайною булевою колонкою — те саме число +-- без жодної тонкості. Заразом «на яких саме хостах» стає окремим +-- питанням із окремою відповіддю, а не масивом усередині зведення. +-- +-- security_invoker — з тієї ж причини, що в 1.6. +CREATE VIEW tpl.prototype_impact_devices +WITH (security_invoker = true) AS +SELECT + d.tenant_id, + p.id AS prototype_id, + t.key AS template_key, + p.key AS prototype_key, + p.enabled, + d.id AS device_id, + d.name AS device_name, + COALESCE(d.vendor, '') AS vendor, + EXISTS ( + SELECT 1 FROM core.checks c + WHERE c.device_id = d.id + AND c.template_id = p.template_id + AND c.check_type = 'snmp.walk' + AND c.interval_sec = p.discovery_sec + ) AS walk_check_exists, + ( + SELECT count(*) FROM tpl.discovered_rows dr + WHERE dr.device_id = d.id + AND dr.template_id = p.template_id + AND dr.prototype_key = p.key + AND dr.gone_at IS NULL + ) AS rows_known +FROM tpl.item_prototypes p +JOIN tpl.templates t ON t.id = p.template_id +JOIN tpl.device_templates dt ON dt.template_id = p.template_id +JOIN inv.devices d ON d.id = dt.device_id +WHERE d.deleted_at IS NULL AND d.enabled; + +COMMENT ON VIEW tpl.prototype_impact_devices IS + 'Хост за хостом: кому саме поїде новий чек, якщо прототип увімкнути, і чи є в нього обхід уже зараз.'; + +-- Зведення. Те саме, згорнуте до чисел, якими ухвалюють рішення. +-- +-- Що саме заводить увімкнений прототип (reconcilePrototypes, +-- store/prototypes.go), по кроках: +-- +-- 1. Один чек snmp.walk на пару (хост, шаблон) для кожного РІЗНОГО +-- discovery_sec. Прототипи одного шаблону з однаковою частотою +-- обходу злипаються в один чек — тому «ще один прототип» не +-- обов'язково означає «ще один обхід», і саме це показує +-- new_walk_checks. +-- 2. Після першого обходу — по одному snmp.get на прототип на хост, у +-- якому стільки OID, скільки знайдено рядків × метрик у прототипі. +-- +-- Другий крок відкладений, і це важливо для чесності цифр: скільки буде +-- рядків, до обходу не знає ніхто. Тому max_value_checks зветься +-- стелею, а не прогнозом: на хості, де таблиці немає, не з'явиться +-- жодного (reconcilePrototypes не пише порожній snmp.get). +CREATE VIEW tpl.prototype_impact +WITH (security_invoker = true) AS +SELECT + x.tenant_id, + p.id AS prototype_id, + t.key AS template_key, + t.name AS template_name, + p.key AS prototype_key, + p.name AS prototype_name, + p.enabled, + p.discovery_sec, + p.interval_sec, + p.max_rows, + jsonb_array_length(p.metrics) AS metrics_per_row, + + -- Хости, до яких шаблон уже причеплений. Вимкнені й видалені сюди не + -- потрапили ще в нижньому вигляді: чек їм не поїде, і показувати їх у + -- ціні означало б лякати числом, якого не буде. + count(*) AS devices, + count(*) FILTER (WHERE NOT x.walk_check_exists) AS new_walk_checks, + count(*) AS max_value_checks, + COALESCE(sum(x.rows_known), 0) AS rows_known +FROM tpl.prototype_impact_devices x +JOIN tpl.item_prototypes p ON p.id = x.prototype_id +JOIN tpl.templates t ON t.id = p.template_id +GROUP BY x.tenant_id, p.id, t.key, t.name; + +COMMENT ON VIEW tpl.prototype_impact IS + 'Ціна прототипу до натискання прапорця: скільки хостів, скільки обходів з''явиться негайно, скільки чеків значень буде щонайбільше.'; + +GRANT SELECT ON tpl.prototype_impact TO netpulse_app, netpulse_worker; +GRANT SELECT ON tpl.prototype_impact_devices TO netpulse_app, netpulse_worker; + + +-- --------------------------------------------------------------------- +-- 2.3. Ще два прототипи: сенсори шасі +-- --------------------------------------------------------------------- + +-- ENTITY-SENSOR-MIB (RFC 3433) — те саме, чим hrStorage є для дисків: +-- стандартна таблиця, яку віддає залізо різних вендорів без вендорних +-- MIB. Температура й живлення — рівно ті два виміри, заради яких на +-- комутатор дивляться до того, як він помер, а не після. +-- +-- ОКРЕМИЙ ШАБЛОН, а не прототипи в наявному snmp-generic. Це головне +-- рішення блоку, і воно про ту саму свідомість вибору. +-- +-- snmp-generic причеплений до кожного SNMP-хоста. Прототип у ньому +-- означав би, що натискання одного прапорця вмикає обхід на ВСЬОМУ +-- парку одночасно — рішення масштабу «вся мережа», замасковане під +-- рішення масштабу «один перемикач». Окремий шаблон розкладає його на +-- два свідомі кроки: спершу людина обирає, яким хостам це потрібно +-- (причепити шаблон), потім бачить у tpl.prototype_impact реальне +-- число й вмикає. Причому число до першого кроку дорівнює нулю — тобто +-- помилитись нічим. +-- +-- Ціна рішення чесна: шаблон, поки його нікому не причепили, виглядає +-- у переліку порожнім. Це прийнятно — порожній шаблон нічого не +-- ламає, на відміну від обходу, що поїхав на тисячу хостів. +INSERT INTO tpl.templates (id, tenant_id, key, name, description, vendor, is_builtin) VALUES + ('00000000-0000-0000-0000-0000000000c6'::uuid, NULL, 'snmp-entity-sensors', + 'SNMP: сенсори шасі (ENTITY-SENSOR-MIB)', + 'Температура й живлення з RFC 3433 — стандартної таблиці, яку віддає більшість керованого заліза без вендорних MIB. Обидва прототипи типово вимкнені: перед увімкненням подивіться на ціну у tpl.prototype_impact.', + NULL, true) +ON CONFLICT DO NOTHING; + +-- Перелік рядків беремо з entPhysicalName (ENTITY-MIB), а не з самої +-- таблиці сенсорів, і це вибір із двох поганих варіантів. +-- +-- entPhySensorType (.1.3.6.1.2.1.99.1.1.1.1) перелічує РІВНО сенсори — +-- жодного зайвого рядка. Але назви в нього немає: підписом стане +-- entPhysicalIndex, тобто число. Графік «сенсор 1007» не читає ніхто, а +-- відрізнити на ньому вхідну температуру від температури живлення +-- неможливо. +-- +-- entPhysicalName (.1.3.6.1.2.1.47.1.1.1.1.7) перелічує ВСІ фізичні +-- сутності: шасі, слоти, порти, трансивери — на стеку це сотні рядків. +-- Зате в кожного є людська назва, а отже, за нею можна відфільтрувати +-- потрібні. Саме для цього row_filter із 0059 і існує. +-- +-- Обрано друге. Рядки, що не є сенсорами, відсіює фільтр; ті кілька, +-- що просочаться, повернуть noSuchInstance й не дадуть метрики — +-- неприємно, але тихо й дешево. Зворотний вибір дав би графіки, яких +-- не можна прочитати, а це не дешевше, а безглуздіше. +-- +-- Обидва прототипи ходять по ОДНОМУ піддереву з ОДНАКОВОЮ частотою — +-- і це не збіг, а розрахунок: reconcilePrototypes злипає такі обходи в +-- один чек snmp.walk. Тобто другий прототип додає чек значень, але не +-- додає обходу. Число у new_walk_checks це показує. +-- +-- 900 секунд на обхід замість типової години: перелік сутності +-- змінюється тоді, коли в шасі вставили модуль або витягли блок +-- живлення, і чверть години — прийнятний час, щоб побачити це на +-- графіку. 300 секунд на значення: температура шасі не стрибає за +-- хвилину, а кожен такт коштує стільки OID, скільки сенсорів × метрик. +-- +-- max_rows = 32: сенсорів у шасі одиниці, десятки на стеку. Стеля тут +-- не про пам'ять, а про PDU (див. 0059): 32 рядки × 4 метрики — це вже +-- 6 запитів на такт. +-- +-- ТИПОВО ВИМКНЕНІ. З тієї ж причини, що hrStorage у 0059, і без жодного +-- послаблення: увімкнений прототип у вбудованому шаблоні завів би чеки +-- мовчки, під час накату. Те, що шаблон новий і поки нікому не +-- причеплений, робить ризик нульовим СЬОГОДНІ — але шаблон причеплять +-- завтра, і тоді ввімкнений прапорець спрацював би заднім числом, без +-- жодного рішення людини. Вимкнене типово має бути властивістю +-- прототипу, а не наслідком того, що його ще нікуди не поставили. +INSERT INTO tpl.item_prototypes + (template_id, key, name, discover_oid, name_from, row_filter, metrics, + discovery_sec, interval_sec, max_rows, enabled) +VALUES + -- Температура. + -- + -- Фільтр за назвою, бо тип сенсора лежить в іншій колонці, а + -- row_filter із 0059 знає про рядок рівно два поля — індекс і назву + -- (і це не тимчасове звуження, див. rowFields у store/prototypes.go). + -- Перелік слів узятий із того, як вендори називають ці сутності: + -- Temp, Temperature, Inlet, Outlet, Hotspot. + ('00000000-0000-0000-0000-0000000000c6'::uuid, 'sensor-temp', 'Температура', + '.1.3.6.1.2.1.47.1.1.1.1.7', 'value', + '{"match":"all","conditions":[ + {"field":"name","op":"matches","value":"(?i)(temp|inlet|outlet|hotspot)"} + ]}'::jsonb, + -- Три числа замість одного, рівно як у hrStorage 0059: RFC 3433 + -- віддає показ, ступінь десятки й кількість знаків після коми + -- ОКРЕМИМИ об'єктами, і перемножити їх ані на зонді, ані на сервері + -- нема де — ts.samples зберігає те, що виміряли. Четверте число — + -- стан сенсора: значення справного й значення збоєного сенсора + -- виглядають однаково, і без цієї метрики «раптом 0 °C» не + -- відрізнити від «сенсор відвалився». + '[{"oid":".1.3.6.1.2.1.99.1.1.1.4","metric_key":"sensor.temp_raw","unit":"","scale":1}, + {"oid":".1.3.6.1.2.1.99.1.1.1.2","metric_key":"sensor.temp_scale_code","unit":"","scale":1}, + {"oid":".1.3.6.1.2.1.99.1.1.1.3","metric_key":"sensor.temp_precision","unit":"","scale":1}, + {"oid":".1.3.6.1.2.1.99.1.1.1.5","metric_key":"sensor.temp_status","unit":"","scale":1}]'::jsonb, + 900, 300, 32, false), + + -- Живлення. + -- + -- Окремий прототип, а не ще кілька метрик у першому: ключі метрик + -- мають бути різні. Вольти й градуси під одним metric_key дали б один + -- ряд, у якому половина точок — температура, і жоден поріг на такому + -- ряді не має сенсу. + ('00000000-0000-0000-0000-0000000000c6'::uuid, 'sensor-power', 'Живлення', + '.1.3.6.1.2.1.47.1.1.1.1.7', 'value', + '{"match":"all","conditions":[ + {"field":"name","op":"matches","value":"(?i)(power|psu|supply|volt|amp)"} + ]}'::jsonb, + '[{"oid":".1.3.6.1.2.1.99.1.1.1.4","metric_key":"sensor.power_raw","unit":"","scale":1}, + {"oid":".1.3.6.1.2.1.99.1.1.1.2","metric_key":"sensor.power_scale_code","unit":"","scale":1}, + {"oid":".1.3.6.1.2.1.99.1.1.1.3","metric_key":"sensor.power_precision","unit":"","scale":1}, + {"oid":".1.3.6.1.2.1.99.1.1.1.5","metric_key":"sensor.power_status","unit":"","scale":1}]'::jsonb, + 900, 300, 32, false) +ON CONFLICT DO NOTHING; + + +-- ===================================================================== +-- ЧОГО ТУТ НЕМАЄ +-- ===================================================================== + +-- Немає жодного тригера шаблону (tpl.template_triggers, 0024). Третій +-- нуль зі зміряного стенду — «подієвих тригерів 0» — лишається нулем +-- навмисно. Тригер шаблону породжує ПРАВИЛО АЛЕРТІВ на кожному хості, +-- якому шаблон причеплений (0024/0025), тобто вмикається саме те, що +-- вмикати не можна: оновлення підняло б алерти на наявних стендах без +-- жодного рішення людини. Це рівно та поломка, яку проєкт уже двічі +-- оплатив. Вбудовані тригери мають з'явитись у міграції, яка +-- одночасно дасть спосіб побачити їхню ціну — так само, як ця дає його +-- прототипам. +-- +-- Немає правил під config_type = 'files'. Конфіг-файли сервера (0055) +-- — це /etc, і осмислені правила для нього («PermitRootLogin no», +-- «PasswordAuthentication no») вимагають знати, ЯКИЙ файл читається; +-- зараз під типом `files` їде набір, а правило порядкове й читає його +-- суцільно. Правило «немає рядка PermitRootLogin yes» дало б зелене на +-- сервері, де sshd_config у набір узагалі не входить, — тобто мовчазну +-- зелень, від якої весь цей файл і будується. +-- +-- Немає правил для Juniper, Arista, HP, Extreme, Brocade, Alcatel. +-- Причина одна: перевіреного зразка конфігу цих платформ у мене не +-- було, а вписати вендора «бо синтаксис схожий» — це і є те саме +-- правило, яке завжди зелене. Скільки таких хостів у парку, показує +-- ncm.builtin_compliance_coverage; додавати їх треба разом зі зразком +-- у ncm_compliance_builtin_test.go, а не окремо. diff --git a/web/TESTING.md b/web/TESTING.md index 2b5ac5a..b90eb75 100644 --- a/web/TESTING.md +++ b/web/TESTING.md @@ -69,18 +69,67 @@ sh ../scripts/check.sh web # те саме, але тим самим шлях ### Мережа й живі дані -- **`api/client.ts`, `api/ws.ts`, `hooks/*`** — не покриті взагалі. Ні - реконект WebSocket, ні повторна підписка, ні обробка `ApiError` - (`isConflict`, `isPlanLimit`), ні токен із `localStorage`. -- **Жодного тесту зі справжнім сервером.** Усі перевірки — на чистих функціях - і на компонентах із підставленими даними. Розбіжність між `types.ts` і - реальною відповіддю API тут не ловиться в принципі. +Мережевий шар тепер покритий (див. «Що покрито»), але покритий він **проти +підставного `fetch`**, а не проти сервера. Тому лишається непокритим: + +- **Жодного тесту зі справжнім сервером.** Підставні відповіді написані за + `server/API.md` і за обробниками `server/internal/httpapi/`, і звірка ця + зроблена **очима, один раз**. Ніщо не тримає її актуальною: якщо сервер + перейменує поле, тести лишаться зеленими на старій формі. Розбіжність між + `types.ts` і реальною відповіддю API тут не ловиться в принципі — так само, + як і раніше. +- **Справжній `WebSocket` не бере участі.** Перевірено логіку + `LiveConnection` проти підставного сокета: рукостискання, підпротоколи, + поведінка проксі, `bufferedAmount`, порядок подій у реальному браузері — ні. +- **Черга подій під навантаженням.** Що буде, коли сервер надішле тисячі + повідомлень за секунду, тест не знає: усі перевірки — на одиницях подій. +- **Ротація refresh-токена в часі.** Перевірено, що обмін ОДИН на сплеск + запитів; що буде, коли access-токен протухне рівно між `fetch` і читанням + тіла, — не перевірено. +- **Режим кіоска (`VITE_API_TOKEN`).** `staticTokenPresent` читається з + `import.meta.env` під час імпорту модуля, тому в тестах він завжди + порожній: жодна перевірка цієї гілки не виконується (див. «знайдене» у + звіті — там про неї є що сказати). ### Сторінки -Жодна сторінка з `src/pages/` не покрита. Не перевірено: маршрутизація, -фільтри в адресному рядку (`AuditPage.parseFilter/writeFilter`), права доступу, -форми хостів, шаблонів, правил, ролей. Це найбільша діра за обсягом коду. +Покрито п’ять сторінок із двадцяти семи (`AuditPage`, `DevicesPage`, +`TeamPage`, `RolesPage`, `AlertsPage`) і два вікна незворотних дій. +**Решта двадцять дві не покриті нічим:** `MapPage`, `DevicePage`, +`ConfigsPage`, `CommandsPage`, `RulesPage`, `TemplatesPage`, `TrapsPage`, +`StoragePage`, `QueuesPage`, `MetricsPage`, `DashboardPage`, `GroupsPage`, +`CredentialsPage`, `ProfilesPage`, `CompliancePage`, `MirrorPage`, +`ChannelsPage`, `EscalationsPage`, `AgentsPage`, `ServerFilesPage`, +`ProfilePage`, `TvPage`. Для них не перевірено ні права, ні форми, ні фільтри. + +Окремо про те, чого немає навіть на покритих сторінках: + +- **Фільтри в адресі перевірені лише в `AuditPage`.** `DevicesPage` тримає + свої фільтри в стані компонента, а не в адресі, — тобто відфільтрований + перелік хостів колезі не перешлеш. Це не поломка тесту, це властивість + продукту, і тест її не покриває, бо покривати нічого. +- **`DeviceFilterPanel` і фільтри метрик** — перевірено лише чисті функції + (`filters.test.ts`), не зв’язку «панель → запит». +- **Створення хоста, шаблона, правила, каналу** — форми не покриті. + Перевірено лише те, що кнопка створення з’являється за правом. +- **Порядок і кількість запитів при монтуванні сторінки** перевірено лише + там, де це саме предмет тесту (зонди на `DevicesPage`). Зайвий запит на + решті сторінок пройде зеленим. + +### Права доступу + +Перевірено пари «є право / немає права» для хостів, користувачів, ролей і +алертів. **Не перевірено:** + +- **звуження групами доступу** (`writable`, `access: read|write` на мапі): + тести працюють із хостами, у яких `writable: true`; +- **розбіжність між тим, що ховає клієнт, і тим, що відхиляє сервер.** + Клієнтський `session.can()` і серверний `requirePerm()` — два різні + переліки, і ніщо не звіряє їх між собою. Право, яке сервер уже вимагає, а + клієнт ще ні (або навпаки), пройде зеленим по обидва боки; +- **право, що змінилось під час роботи вкладки.** Сервер перечитує членство + при кожній ротації токена, клієнт — при `session.set`. Що бачить людина в + проміжку, не перевірено. ### Доступність і клавіатура @@ -101,6 +150,38 @@ sh ../scripts/check.sh web # те саме, але тим самим шлях --- +## Знайдене, але не закріплене тестом + +Правило 3 нижче забороняє закріплювати ваду зеленим `expect`. Це знайдено під +час написання тестів мережевого шару й сторінок; тестів на це навмисно немає — +або вони були б червоні, або зафіксували б неправильну поведінку. + +1. **«Будь-який машинний токен» у журналі аудиту їде не тим параметром.** + `AuditPage` кладе значення `-` у `filter.tokens`, звідки `api.audit()` + надсилає `token=-`. Сервер шукає `-` серед `actor` + (`httpapi/audit.go`, гілка `f.AnyToken`), а `token` кладе в + `ActorTokenIDs` — і той іде в SQL як `actor_token_id = ANY(...)` по + стовпцю `uuid` (`migrations/0001_core.sql:215`). Тобто вибір цієї + позначки дає не «рядки без людини», а помилку розбору UUID. + Показово: коментар у `types.ts` над `AuditFilter.actors` описує + ПРАВИЛЬНУ поведінку — `-` мав лежати серед `actors`. + +2. **Режим кіоска нікому нічого не показує.** При заданому + `VITE_API_TOKEN` `App` пропускає відновлення сесії, тож + `session.me()` лишається `null` назавжди — а `session.can()` без `me` + повертає `false` на будь-яке право. Наслідок: бічне меню порожнє, а + кожен маршрут показує «Розділ недоступний». `api.me()` у клієнті є, але + не викликається звідки-небудь жодного разу. + +3. **`api.logout()` кидає помилку в порожнечу.** `AppShell` кличе його як + `void api.logout()`; сесія чиститься в `finally`, але відмова сервера + стає необробленим відхиленням промісу. + +Тестів на п.1 і п.2 немає навмисно: вони були б червоні, а червоний тест у +`check.sh` зупиняє роботу всім. + +--- + ## Що покрито ### Чиста логіка @@ -130,6 +211,34 @@ sh ../scripts/check.sh web # те саме, але тим самим шлях | `confirm.test.tsx` | подвійний клік не шле другий запит, помилка сервера НЕ закриває вікно, Esc = скасувати, підпис кнопки | | `datatable.test.tsx` | порожній стан, подвійна відмальовка рядків, `hideOnMobile`, `Toggle` | +### Мережевий шар + +| Файл тесту | Що саме | +|---|---| +| `apiclient.test.ts` | заголовок `Bearer`, `credentials: same-origin`, 204 без тіла, розбір `{"error":{code,message}}`, `isConflict`/`isPlanLimit`/`isForbidden` **і навпаки**, тихий обмін на `401 token_expired`, відсутність обміну на `no_session` і `403`, один обмін на десять паралельних запитів, `restore()` один на завантаження, `logout` чистить сесію навіть при відмові сервера, `session.can`, `meFromLogin` | +| `ws.test.ts` | адреса й токен у підпротоколі, читання токена **заново** після реконекту, `connecting/online/offline`, затримка 1→2→4→8→15 с зі стелею й скиданням, `stop()` зупиняє реконект і забуває підписку, повторна підписка на мапу після кожного розриву, пошкоджене повідомлення, слухач, що впав | +| `hooks.test.tsx` | злиття сплеску подій в один перечит, перечит після паузи, чужі повідомлення ігноруються, розмонтування скасовує заплановане, `useAlerts` без `alerts:read` не питає нічого | + +Найважливіше тут — **негативні половини**. «Обмін відбувся» нічого не варте +без «на `no_session` не відбувся»; «реконект стався» — без «після `stop()` не +стався». Саме асиметрична перевірка й пропустила зламаний вхід у тесті +ізоляції RLS. + +### Сторінки + +| Файл тесту | Що саме | +|---|---| +| `routing.test.tsx` | неавторизований на закритій адресі бачить вхід і сторінка **даних не питає**; після входу відкривається та сама адреса; `Guard` без права не малює сторінку й не робить запиту; домівка залежить від ролі; пункт меню без права відсутній; вихід повертає до входу й **розриває сокет** | +| `pagepermissions.test.tsx` | пари «є право / немає права» для хостів, користувачів, ролей, алертів; себе й власника прибрати не можна; право, якого немає в тебе, не можна віддати ролі; зміна ролі шле **лише те, що змінилось**; порожня правка запиту не робить; подвійний клік не шле двох PATCH; `409 shared_user` лишає вікно з набраним | +| `auditfilter.test.tsx` | фільтр із посилання й той самий фільтр, обраний руками, дають **однаковий** запит і однакову адресу; типовий період в адресу не пишеться; скидання чистить і адресу, і запит; гортання йде у вікні, яке зібрав сервер | +| `destructive.test.tsx` | видалення хоста: набір звіряється з сервером, повне видалення заблоковане до підтвердження втрати, без зібраного галочки немає, типово — повне, підпис кнопки називає режим, подвійний клік не дублює запит, помилка не закриває вікно; відкат конфігу: план, непідтримуваний профіль, ручні рядки, `plan_hash`, збережена причина після відмови | + +Підставні відповіді написані не «схоже», а за формою сервера: помилка — з +`writeError()` (`httpapi/server.go`), вхід і `refresh` — з `API.md`, масові +дії над хостами — з `devices_bulk.go` і `store.BulkDeviceTarget`, журнал — з +`handleListAudit`. Спільні підпори лежать у `src/test/support.ts` — щоб +розбіжність із сервером правилась в одному місці й ламала всі тести одразу. + Обрано саме ці два вікна не випадково: `Modal` — єдине місце, де самовільне закриття зʼїдає набране в довгій формі, а `ConfirmDialog` — останній екран перед незворотною дією, і його дві тихі поломки (другий запит на видалення; закриття diff --git a/web/src/App.tsx b/web/src/App.tsx index 7c62b34..14f6183 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -28,8 +28,10 @@ import { TeamPage } from './pages/TeamPage' import { RolesPage } from './pages/RolesPage' import { AuditPage } from './pages/AuditPage' import { TrapsPage } from './pages/TrapsPage' +import { SLAPage } from './pages/SLAPage' import { StoragePage } from './pages/StoragePage' import { ProfilePage } from './pages/ProfilePage' +import { BillingPage } from './pages/BillingPage' import { TvPage } from './pages/TvPage' import type { Permission } from './types' @@ -136,11 +138,22 @@ function Workspace() { систему. Право потрібне лише на ЗМІНУ строків, і його перевіряє сервер. Те саме рішення, що й для дзеркала. */} } />} /> + {/* Звіти SLA під devices:read з тих самих міркувань, що й + сховище: доступність своєї мережі бачить кожен, хто взагалі + бачить моніторинг. Заводити цілі й закривати періоди — + settings:write, і його перевіряє сервер. */} + } />} /> {/* Трапи під devices:read, а не під alerts:read: це подія з мережі, і бачити її має той, хто щодня дивиться на мережу. Правити словник назв — окреме право (settings:write), і його перевіряє сервер. */} } />} /> + {/* Тариф під billing:read, а не під billing:manage: побачити + стелю має той, хто в неї впреться, тобто інженер, який + заводить хости. Змінити тариф чи застосувати ключ — окреме + право, і його перевіряє сервер. Те саме рішення, що для + сховища й дзеркала. */} + } />} /> } />} /> } /> } /> diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 9b6e417..ffd6181 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -90,6 +90,11 @@ import type { TrapPage, TrapsMeta, TrapFilter, + SLATarget, + SLATargetInput, + SLAReport, + BillingOverview, + LicenseState, } from '../types' /** Те, що приймає POST/PATCH /api/v1/devices. */ @@ -1556,4 +1561,116 @@ export const api = { method: 'PUT', body: JSON.stringify({ keep }), }), + + // --- звіти SLA --- + + slaTargets: () => + request<{ targets: SLATarget[] | null; can_edit: boolean; settle_lag_sec: number }>( + '/api/v1/sla/targets', + ).then((r) => ({ ...r, targets: r.targets ?? [] })), + + saveSLATarget: (in_: SLATargetInput) => + request( + in_.id ? `/api/v1/sla/targets/${in_.id}` : '/api/v1/sla/targets', + { method: in_.id ? 'PUT' : 'POST', body: JSON.stringify(in_) }, + ), + + deleteSLATarget: (id: string) => + request<{ periods_deleted: number }>(`/api/v1/sla/targets/${id}`, { method: 'DELETE' }), + + /** + * Звіт за період, у який потрапляє date. + * + * Дата, а не межі періоду: межі кварталу — властивість цілі (її типу + * й поясу), і рахувати їх у браузері означало б завести другу + * реалізацію календаря, яка розійдеться з серверною на переході часу + * або на межі року. Порожня дата означає «попередній період» — саме + * той, який питають на аудиті. + */ + slaReport: (id: string, date?: string) => + request(`/api/v1/sla/targets/${id}/report${date ? `?date=${date}` : ''}`), + + closeSLAPeriod: (id: string, date?: string, force = false) => { + const p = new URLSearchParams() + if (date) p.set('date', date) + if (force) p.set('force', '1') + const q = p.toString() + return request(`/api/v1/sla/targets/${id}/close${q ? `?${q}` : ''}`, { + method: 'POST', + }) + }, + + /** + * Вивантаження звіту. + * + * CSV збирає сервер, а не браузер: саме цей файл кладуть у папку до + * договору й потім звіряють із тим, що показує сторінка. Дві + * реалізації одного округлення розійдуться на третьому знаку, і + * побачить це не той, хто помилився, а клієнт. + * + * Тягнемо через fetch, а не звичайним посиланням: посилання не вміє + * додати заголовок Authorization. Квиток завантаження (0038) тут + * зайвий — звіт це десятки кілобайтів, а не десятки мегабайтів. + */ + downloadSLAReport: async (id: string, date?: string) => { + const path = `/api/v1/sla/targets/${id}/report.csv${date ? `?date=${date}` : ''}` + const headers: Record = {} + const t = authToken() + if (t) headers.Authorization = `Bearer ${t}` + + const res = await fetch(path, { headers, credentials: 'same-origin' }) + if (!res.ok) { + // Помилка тут приходить у JSON, а успіх — у CSV. Розбирати тіло + // однаково не можна: на помилці воно вже не той тип. + let message = res.statusText + try { + const body = await res.json() + message = body?.error?.message ?? message + } catch { + // Тіло не JSON — лишається код відповіді. + } + throw new ApiError(res.status, 'download_failed', message) + } + const blob = await res.blob() + const name = + /filename="([^"]+)"/.exec(res.headers.get('Content-Disposition') ?? '')?.[1] ?? 'sla.csv' + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = name + a.click() + URL.revokeObjectURL(url) + }, + + // ------------------------------------------------------------------- + // Тариф, стелі й ліцензія + // + // Стан їде однією ручкою навмисно: стелі, використане й ліцензія — це + // одне питання («на чому я зараз і скільки лишилось»), і зібрана з + // трьох запитів у різні секунди відповідь суперечила б сама собі — + // «14 з 15» поруч із «15 з 15» на сусідній картці. + // + // Ліцензія має ще й власну ручку: install_id потрібен у мить + // замовлення ключа, а туди приходять із порожньої інсталяції, де + // перелік тарифів іще нічого не означає. + // ------------------------------------------------------------------- + + billing: () => request('/api/v1/billing'), + + setPlan: (plan: string) => + request('/api/v1/billing/plan', { + method: 'PUT', + body: JSON.stringify({ plan }), + }), + + license: () => request('/api/v1/billing/license'), + + applyLicense: (key: string) => + request('/api/v1/billing/license', { + method: 'POST', + body: JSON.stringify({ key }), + }), + + clearLicense: () => + request('/api/v1/billing/license', { method: 'DELETE' }), } diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx index d01b4ff..661384d 100644 --- a/web/src/components/AppShell.tsx +++ b/web/src/components/AppShell.tsx @@ -58,6 +58,12 @@ const navGroups: NavGroup[] = [ // немає взагалі. { to: '/traps', label: 'Трапи', icon: '📥', perm: 'devices:read' }, { to: '/metrics', label: 'Метрики', icon: '📈', perm: 'devices:read' }, + // SLA в «Моніторингу», а не в «Адмініструванні»: це підсумок того, + // що показують решта пунктів цієї групи, і питають його ті самі + // люди. Поруч із метриками навмисно — там дивляться, як мережа + // почувається зараз, тут — як вона почувалась за квартал, і другий + // погляд без першого не має сенсу. + { to: '/sla', label: 'Звіти SLA', icon: '📋', perm: 'devices:read' }, ], }, { @@ -153,6 +159,11 @@ const navGroups: NavGroup[] = [ // пункт не з'явиться — а разом із ним і група, якщо в ній більше // нічого не лишилось. { to: '/audit', label: 'Журнал аудиту', icon: '🧾', perm: 'audit:read' }, + // Тариф останнім в «Адмініструванні», а не окремою групою: питання + // «скільки в мене лишилось слотів» ставлять поруч із питанням + // «кого ще завести», і обидва — тут. Окрема група з одного пункту + // додала б заголовок, який нічого не групує. + { to: '/billing', label: 'Тариф і ліцензія', icon: '💳', perm: 'billing:read' }, ], }, ] diff --git a/web/src/pages/BillingPage.tsx b/web/src/pages/BillingPage.tsx new file mode 100644 index 0000000..0e8cded --- /dev/null +++ b/web/src/pages/BillingPage.tsx @@ -0,0 +1,649 @@ +import { useEffect, useMemo, useState } from 'react' +import { api } from '../api/client' +import { + Button, + Card, + ErrorNote, + Modal, + PageBody, + PageHeader, + Spinner, + inputClass, + plural, +} from '../components/ui' +import type { + BillingOverview, + BillingUsage, + LicenseState, + Plan, + PlanLimits, +} from '../types' + +/** + * Тариф: що включено, скільки зайнято, коли платити. + * + * ГОЛОВНЕ ПРО ЦЮ СТОРІНКУ + * + * Вона існує, щоб людина побачила стелю ДО того, як у неї впреться. + * + * Це не риторика. Стеля тарифу — єдине обмеження в продукті, яке + * проявляється відмовою в мить дії: диск закінчується поступово й про + * це попереджає окрема сторінка, черга росте й це видно на графіку, а + * стеля мовчить рівно до кроку, на якому вже пізно. Мережевий інженер, + * який о другій ночі міняє згорілий комутатор і не може завести новий + * хост, дізнається про тариф найгіршим із можливих способів. + * + * Тому перше, що на сторінці, — не ціни й не рахунок, а числа «зайнято + * зі стелі». Ціни нижче й дрібніше: питання «скільки це коштує» ставлять + * раз на рік, а «скільки в мене лишилось» — щотижня. + * + * ЧОМУ ЦЕ БАЧИТЬ ІНЖЕНЕР, А НЕ ЛИШЕ ВЛАСНИК + * + * Право billing:read має кожна роль, якій його дали, і сторінка + * відкрита під ним, а не під billing:manage. Ховати від того, хто + * заводить хости, число «15 з 15» означає лишити його з питанням «чому + * не працює» й без способу на нього відповісти. Змінити тариф чи + * застосувати ключ він при цьому не зможе — це окреме право, і його + * перевіряє сервер. + */ +export function BillingPage() { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [err, setErr] = useState(null) + const [licenseOpen, setLicenseOpen] = useState(false) + const [planOpen, setPlanOpen] = useState(false) + + const reload = () => { + setLoading(true) + api + .billing() + .then((d) => { + setData(d) + setErr(null) + }) + .catch((e) => setErr(e instanceof Error ? e.message : String(e))) + .finally(() => setLoading(false)) + } + + useEffect(reload, []) + + const canManage = data?.can_manage ?? false + + return ( + <> + + + {canManage && } + {canManage && ( + + )} + + } + /> + + + {err} + + {loading && !data ? ( + + ) : data ? ( + <> + setLicenseOpen(true) : undefined} + /> + + + + + ) : null} + + + {licenseOpen && data && ( + setLicenseOpen(false)} + onDone={() => { + setLicenseOpen(false) + reload() + }} + /> + )} + {planOpen && data && ( + setPlanOpen(false)} + onDone={() => { + setPlanOpen(false) + reload() + }} + /> + )} + + ) +} + +// --------------------------------------------------------------------- +// Стан ліцензії +// --------------------------------------------------------------------- + +/** + * Смужка стану ліцензії. + * + * Формулювання тут важливіші за оформлення, і найважливіше з них — те, + * що стоїть на стані `expired`. Людина, яка бачить слово «протерміновано» + * у системі моніторингу, першою думкою має «отже, воно вже нічого не + * стежить» — і саме цю думку рядок мусить зняти в тому ж реченні, у + * якому повідомляє погану новину. Інакше о цій же хвилині почнеться + * позапланова перевірка «а чи бачимо ми взагалі мережу». + */ +function LicenseBanner({ + license, + onManage, +}: { + license: LicenseState + onManage?: () => void +}) { + const tone: Record = { + active: 'border-slate-800 bg-slate-900', + grace: 'border-amber-900/60 bg-amber-950/20', + expired: 'border-amber-900/60 bg-amber-950/20', + invalid: 'border-rose-900/60 bg-rose-950/20', + unlicensed: 'border-slate-800 bg-slate-900', + } + + const text: Record = { + unlicensed: ( + <> + Ліцензійного ключа немає — стелі не задані, працює все. Так виглядає інсталяція, якій + ще не застосували ключ і не обрали тариф. + + ), + active: ( + <> + Ліцензія діє {license.issued_to ? <>для «{license.issued_to}» : null}до{' '} + {fmtDate(license.expires_at)} + {typeof license.days_left === 'number' && license.days_left <= 45 ? ( + <> — лишилось {license.days_left} {plural(license.days_left, ['доба', 'доби', 'діб'])} + ) : null} + . + + ), + grace: ( + <> + Строк ліцензії минув {fmtDate(license.expires_at)}, триває пільговий період до{' '} + {fmtDate(license.grace_until)}. Зараз не + змінюється нічого. Після цієї дати збір, алерти й сповіщення так само працюватимуть, але + нові хости, зонди й користувачі перестануть заводитись. + + ), + expired: ( + <> + Строк ліцензії та пільговий період минули.{' '} + + Збір даних, алерти й сповіщення працюють далі й не зупиняться. + {' '} + Замерз лише ріст: усе, що вже під наглядом, лишається під наглядом, а нові хости, зонди, + користувачі й мапи не заводяться до продовження ліцензії. + + ), + invalid: ( + <> + Ключ є, але перевірити його не вдалось: {license.reason || 'причина невідома'}. Стелі + лишились такими, якими були — ми не знаємо напевно, чи справа в ключі, чи в тому, що + збірка втратила відкритий ключ підпису, і відбирати доступ через власну невизначеність + не станемо. + + ), + } + + return ( + +
+

+ {text[license.state] ?? text.unlicensed} +

+ {onManage && ( + + )} +
+ +
+
+
Інсталяція:
+ {/* Саме це число людина надсилає, замовляючи ключ. Воно має + бути на видноті, а не в налаштуваннях: по нього приходять + рівно раз і завжди поспіхом. */} +
{license.install_id}
+
+ {license.checked_at && ( +
+
Перевірено:
+
{fmtDate(license.checked_at)}
+
+ )} +
+ + {license.verifier_empty && ( +

+ У цій збірці немає жодного відкритого ключа перевірки ліцензій + (NETPULSE_LICENSE_PUBKEY порожній). Ключ ввести не вийде — і це сказано прямо, щоб не + виглядало, ніби ключ неправильний. +

+ )} + + {license.clock_warped && ( +

+ Системний час на цій машині йшов назад проти раніше побаченого. Строк ліцензії + рахується за пізнішою з двох міток. Нічого не вимкнено: годинник з'їжджає й сам — + сів CMOS, зник NTP після переїзду в ізольований сегмент. +

+ )} +
+ ) +} + +// --------------------------------------------------------------------- +// Скільки зайнято +// --------------------------------------------------------------------- + +/** + * Головний блок сторінки: зайняте проти стелі. + * + * Смужка заповнення, а не лише два числа. «12 з 15» і «120 з 150» — + * різні за терміновістю, хоч частка та сама, і саме довжина смужки + * повідомляє це швидше за арифметику в голові. + */ +function UsageGrid({ limits, usage }: { limits: PlanLimits; usage: BillingUsage }) { + const cells: { label: string; used: number; limit: number | null; hint: string }[] = [ + { + label: 'Хости', + used: usage.devices, + limit: limits.max_devices, + // Правило підрахунку названо вголос саме тут: воно неочевидне й + // саме через нього виникає розбіжність «у списку 40, а тариф каже 28». + hint: 'рахуються лише ввімкнені; архівні й вимкнені слота не займають', + }, + { label: 'Мапи', used: usage.maps, limit: limits.max_maps, hint: 'без архівних' }, + { label: 'Зонди', used: usage.agents, limit: limits.max_agents, hint: 'усі зареєстровані' }, + { label: 'Користувачі', used: usage.users, limit: limits.max_users, hint: 'учасники кабінету' }, + ] + + // Підпис розділу — не окрема назва, а те, чим цей блок відрізняється + // від таблиці тарифів нижче: там ті самі слова («Хости», «Мапи») + // означають пропозицію, тут — поточний стан. Без підпису два блоки + // читаються як один, і це помітно навіть у тесті, який не може + // відрізнити «Хости» від «Хости». + return ( +
+ {cells.map((c) => ( + + ))} +
+ ) +} + +function UsageCell({ + label, + used, + limit, + hint, +}: { + label: string + used: number + limit: number | null + hint: string +}) { + // Стеля, нижча за зайняте, — не помилка й не рідкість: так виглядає + // кабінет, який свідомо перевели на менший тариф, і кабінет із + // протермінованою ліцензією. Показуємо як є; ховати це означало б + // приховати саме те, через що нічого не заводиться. + const over = limit !== null && used >= limit + const pct = limit && limit > 0 ? Math.min(100, Math.round((used / limit) * 100)) : 0 + + return ( + +
+ {label} + + {used} + / {limit === null ? '∞' : limit} + +
+ +
+
+
+ +

+ {limit === null ? 'без обмеження' : over ? 'стелю досягнуто — нове не заведеться' : hint} +

+ + ) +} + +// --------------------------------------------------------------------- +// Поточний тариф +// --------------------------------------------------------------------- + +const reasonText: Record = { + license: 'стелі задає ліцензійний ключ', + license_expired: 'ліцензія протермінована — стелі заморожені на досягнутому', + plan: 'тариф обрав власник кабінету', + migration: 'стелі не задавались', + manual: 'стелі виставлені руками', +} + +function CurrentPlan({ data }: { data: BillingOverview }) { + const e = data.entitlement + const catalog = useMemo( + () => new Map(data.features.map((f) => [f.key, f])), + [data.features], + ) + const included = new Set(e.features) + + return ( + +
+

{e.plan_name}

+ + {reasonText[e.reason] ?? e.reason} · метрики живуть {e.metric_retention_days}{' '} + {plural(e.metric_retention_days, ['добу', 'доби', 'діб'])} + +
+ + {/* Перелік показує ВСІ можливості каталогу, а не лише включені. + Список без «чого немає» відповідає на питання «що я маю» й + мовчить про «що я отримаю, якщо заплачу» — тобто про єдине + питання, заради якого на цю сторінку приходять двічі. */} +
    + {data.features.map((f) => { + const on = included.has(f.key) + return ( +
  • + {on ? '✓' : '·'} + {f.name} +
  • + ) + })} +
+
+ ) +} + +// --------------------------------------------------------------------- +// Тарифи +// --------------------------------------------------------------------- + +function money(cents: number, currency: string): string { + if (cents === 0) return '0' + return `${(cents / 100).toFixed(2)} ${currency}` +} + +function limitCell(v: number | null): string { + return v === null ? '∞' : String(v) +} + +function PlanTable({ data }: { data: BillingOverview }) { + const plans = data.plans.filter((p) => p.is_public || p.key === data.entitlement.plan_key) + if (plans.length === 0) return null + + return ( + +

Тарифи

+ {/* Таблиця в контейнері з власним прокрученням: на телефоні + сторінка не має їхати вбік цілком через один широкий блок. */} +
+ + + + + + + + + + + + + + + {plans.map((p) => { + const current = p.key === data.entitlement.plan_key + return ( + + + + + + + + + + + ) + })} + +
ТарифАбонплатаЗа хостХостиМапиЗондиЛюдиМетрики
+ {p.name} + {current && поточний} + {money(p.base_price_cents, p.currency)}{money(p.per_device_cents, p.currency)}{limitCell(p.max_devices)}{limitCell(p.max_maps)}{limitCell(p.max_agents)}{limitCell(p.max_users)}{p.metric_retention_days} діб
+
+
+ ) +} + +// --------------------------------------------------------------------- +// Діалоги +// --------------------------------------------------------------------- + +/** + * Зміна тарифу. + * + * Попередження про звуження стелі показується ДО збереження й з + * числами. Це той самий принцип, що в масовому видаленні хостів і в + * попередньому перегляді очистки: підтвердження без точного переліку + * наслідків підтвердженням не вважається. + * + * Але перехід НЕ забороняється. Власник має право перевести кабінет на + * менший тариф, маючи 40 хостів при стелі 15: наявні 40 лишаються під + * наглядом, нові не заводяться. Вимагати спершу видалити 25 хостів, + * щоб дізнатись, чи влаштує дешевший тариф, — це не обережність, а + * пастка. + */ +function PlanDialog({ + data, + onClose, + onDone, +}: { + data: BillingOverview + onClose: () => void + onDone: () => void +}) { + const [plan, setPlan] = useState(data.entitlement.plan_key) + const [busy, setBusy] = useState(false) + const [err, setErr] = useState(null) + + const chosen = data.plans.find((p) => p.key === plan) + const shrink = chosen ? shrinkWarnings(chosen, data.usage) : [] + + const save = () => { + setBusy(true) + api + .setPlan(plan) + .then(onDone) + .catch((e) => setErr(e instanceof Error ? e.message : String(e))) + .finally(() => setBusy(false)) + } + + return ( + +
+ {err} + + + + {shrink.length > 0 && ( +
+

+ Після переходу стеля стане нижчою за вже зайняте: +

+
    + {shrink.map((s) => ( +
  • · {s}
  • + ))} +
+

+ Нічого не зникне й не вимкнеться: усе, що вже під наглядом, лишається під наглядом. + Перестане заводитись НОВЕ — доти, доки зайняте не опуститься нижче стелі. +

+
+ )} + +
+ + +
+
+
+ ) +} + +function shrinkWarnings(p: Plan, u: BillingUsage): string[] { + const rows: [string, number | null, number][] = [ + ['хостів', p.max_devices, u.devices], + ['мап', p.max_maps, u.maps], + ['зондів', p.max_agents, u.agents], + ['користувачів', p.max_users, u.users], + ] + return rows + .filter(([, limit, used]) => limit !== null && used > limit) + .map(([name, limit, used]) => `${name}: зайнято ${used}, у тарифі ${limit}`) +} + +/** + * Ліцензійний ключ. + * + * Поле — textarea, а не input, і це не дрібниця: ключ довгий, приходить + * листом і повертається з переносами рядків. Однорядкове поле показало б + * людині його хвіст і сховало початок — тобто рівно те, що вона хоче + * перевірити очима перед натисканням. + */ +function LicenseDialog({ + license, + onClose, + onDone, +}: { + license: LicenseState + onClose: () => void + onDone: () => void +}) { + const [key, setKey] = useState('') + const [busy, setBusy] = useState(false) + const [err, setErr] = useState(null) + const [confirmClear, setConfirmClear] = useState(false) + + const run = (p: Promise) => { + setBusy(true) + p.then(onDone) + .catch((e) => setErr(e instanceof Error ? e.message : String(e))) + .finally(() => setBusy(false)) + } + + return ( + +
+ {err} + +
+

Ідентифікатор цієї інсталяції

+

+ {license.install_id} +

+

+ Надішліть його, замовляючи ключ. Ключ, виданий на цей ідентифікатор, більше нікуди не + підійде — саме це відрізняє ліцензію від пароля, який перешлють колезі. +

+
+ +