Netpulse_SasS/docker-compose.yml
byrsapty ca143a616b
Some checks failed
CI / hygiene (push) Successful in 8s
CI / web (push) Successful in 59s
CI / server (push) Failing after 3m27s
CI / agent (push) Successful in 3m3s
Білінг, SLA, вбудовані правила, пісочниця установника, тести сторінок
П'ять паралельних задач. Найцінніше в них — не можливості, а знайдене.

0069 БІЛІНГ. Аудит 0009 показав, що перевірка ліміту не спрацювала б
жодного разу: isPlanLimit шукала слово «ліміт», а тригер писав
"device limit reached" англійською. Перше ж досягнення стелі дало б
клієнту 500 замість пояснення. Плюс три діри: тригер лише на INSERT
(стеля в 15 обходилась за чотири дії через архів), max_maps/max_agents/
max_users не перевіряло ніщо — тобто рівно те, чим відрізняються плани,
і license_keys була закрита політикою tenant_isolation з 0011, хоча
tenant_id там NULLABLE навмисно: головний сценарій self-hosted був
недосяжний.

Після закінчення ліцензії не вимикається нічого — замерзає лише ріст.
Моніторинг, що перестав моніторити через несплачений рахунок, це
аварія в мережі клієнта, спричинена нами.

0070 SLA. Джерелом обрано ts.icmp_1h, а не device_status_history:
остання не вміє сказати «ми не знали» — перехід пишеться лише при
зміні стану, тож доба мовчання зонда виглядає як доба роботи. Час
розкладено на чотири частини, і «немає даних» не додається ні до чого;
замість вибору між двома брехнями звіт каже, яку частку періоду він
бачив. Закритий період тримає тригер, а не домовленість у Go.

0071 ВІДПОВІДНІСТЬ. 20 правил, кожне прив'язане до родини: об'єднаний
вираз, що покриває Cisco й не покриває MikroTik, дав би «0 порушень» і
сховав сліпу пляму. Вендор не входить у перелік, доки для нього немає
зразка конфігу в тесті. TestBuiltinRulesAreNotAlwaysGreen вимагає, щоб
у кожного правила був конфіг, де воно спрацювало, І де ні.

ПІСОЧНИЦЯ УСТАНОВНИКА — та сама установка в ізольованому проєкті
compose. Знайшла дві справжні вади з трьох спроб:
  * healthcheck бази ходив unix-сокетом, а споживачі по TCP. При
    первинній ініціалізації Postgres слухає лише сокет — compose
    вважав базу здоровою, migrate отримував connection refused. На
    створеній базі цієї фази немає, тож вада чекала на першого клієнта;
  * у білому переліку модулів API не було traps і filecfg — зонд із
    приймачем трапів неможливо було зареєструвати взагалі.

ТЕСТИ СТОРІНОК: 137 → 252. Мережевий шар, права доступу, незворотні
дії, фільтри з адресного рядка. Підмінюється лише fetch і WebSocket —
api/client.ts працює справжній.
2026-08-27 21:17:23 +03:00

351 lines
20 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters

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

# Повний стек NetPulse: БД, кеш, міграції, API з інтерфейсом, колектор
# зондів і зворотний проксі з автоматичним TLS.
#
# Швидкий старт:
# cp deploy/.env.example .env # і заповнити секрети
# docker compose up -d
# docker compose run --rm --entrypoint netpulse-user cli \
# -tenant default -login admin -role owner
#
# netpulse-user і netpulse-secret заводять кабінети, людей і секрети —
# тобто роблять рівно те, чого роль під RLS робити не має. Тому для них
# є окрема служба cli з DSN власника: під роллю застосунку вони не
# падають з помилкою, а мовчки нічого не знаходять.
#
# Тільки стенд для розробки (БД і кеш, решта — з go run):
# docker compose up -d db cache
name: netpulse
# ---------------------------------------------------------------------
# DSN трьох ролей
# ---------------------------------------------------------------------
#
# Три ролі, три різні відповіді на «що цій ролі вільно бачити» (0063).
#
# netpulse власник схеми. Ним ходять migrate і cli.
# netpulse_app API і колектор. Без BYPASSRLS — під політиками RLS.
# netpulse_worker фонові такти поверх усіх кабінетів. З BYPASSRLS.
#
# Заповнені NETPULSE_APP_PASSWORD і NETPULSE_WORKER_PASSWORD означають
# «застосунок ходить під RLS». На ЧИСТІЙ базі їх пише установник, а
# netpulse-migrate сам видає ці паролі ролям одразу після накочування
# схеми — тобто нова інсталяція працює під політиками з першої секунди,
# і слова «cutover» клієнт не бачить взагалі.
#
# Порожні означають «як до 0063»: DSN згортається до ролі власника,
# DSN воркера лишається порожнім, store.UseWorkerDSN другого пулу не
# відкриває. Саме на цьому тримається безпека оновлення наявного стенду:
# у його .env цих рядків немає, вирази нижче дають той самий DSN, що й
# раніше, а мігратор на непорожній базі паролів ролям не видає взагалі.
# Перехід для таких інсталяцій — deploy/RLS-EXISTING-INSTALL.md.
#
# Ім'я ролі більше НЕ окрема змінна, і це виправлення, а не спрощення.
# Раніше їх було дві — NETPULSE_APP_USER і NETPULSE_APP_PASSWORD, — і
# будь-яка половина без другої давала DSN, який не встановлюється: нова
# роль зі старим паролем або стара роль з новим. Виглядає це не як
# помилка конфігурації, а як «RLS усе зламав». Тепер прапорець один: є
# пароль — є й роль netpulse_app, немає пароля — немає й ролі.
#
# Вираз читається так. Перша частина додає «netpulse_app:» лише тоді,
# коли пароль заданий. Друга віддає або сам пароль (коли заданий), або
# цілу пару «netpulse:<пароль власника>» (коли ні). Обидві частини
# дивляться на ОДНУ змінну, тому проміжного стану не буває.
x-app-dsn: &app-dsn postgres://${NETPULSE_APP_PASSWORD:+netpulse_app:}${NETPULSE_APP_PASSWORD:-netpulse:$POSTGRES_PASSWORD}@db:5432/netpulse?sslmode=disable
x-worker-dsn: &worker-dsn ${NETPULSE_WORKER_PASSWORD:+postgres://netpulse_worker:${NETPULSE_WORKER_PASSWORD}@db:5432/netpulse?sslmode=disable}
x-owner-dsn: &owner-dsn postgres://netpulse:${POSTGRES_PASSWORD:?потрібен POSTGRES_PASSWORD}@db:5432/netpulse?sslmode=disable
x-server-env: &server-env
NETPULSE_DSN: *app-dsn
NETPULSE_DSN_WORKER: *worker-dsn
NETPULSE_DEK: ${NETPULSE_DEK:?потрібен NETPULSE_DEK — див. deploy/README.md}
NETPULSE_LOG_LEVEL: ${NETPULSE_LOG_LEVEL:-info}
TZ: ${TZ:-Europe/Kyiv}
services:
db:
image: timescale/timescaledb:2.17.2-pg16
environment:
POSTGRES_USER: netpulse
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?потрібен POSTGRES_PASSWORD}
POSTGRES_DB: netpulse
TIMESCALEDB_TELEMETRY: "off"
# Без цього initdb бере кодування з локалі середовища й цілком
# може створити базу як SQL_ASCII. Тексти при цьому виглядають
# цілими, а ламається інше: jsonb із екранованим не-ASCII не
# вставляється, а lower() і сортування працюють як над латиницею.
# Виправити можна лише перестворенням бази, тож задаємо одразу.
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C.UTF-8"
command:
- postgres
- -c
- shared_preload_libraries=timescaledb
- -c
- max_connections=200
- -c
- shared_buffers=${PG_SHARED_BUFFERS:-512MB}
- -c
# Вісім фонових робітників TimescaleDB — розумно на чотирьох
# ядрах і забагато на двох: вони конкурують за ті самі ядра з
# самими запитами, і стиснення чанків починає заважати опитуванню.
# Установник підставляє число за фактичною кількістю ядер.
- timescaledb.max_background_workers=${PG_BG_WORKERS:-8}
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
# -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
restart: unless-stopped
cache:
image: docker.dragonflydb.io/dragonflydb/dragonfly:v1.25.5
# memlock без обмеження прискорює Dragonfly, але дозволений не всюди:
# у контейнерній віртуалізації (LXC, частина VPS) ядро відмовляє, і
# контейнер не стартує взагалі з «error setting rlimit type 8».
# Швидкість того не варта — обмеження знято.
#
# Стеля пам'яті задається явно, бо без неї Dragonfly бере її з
# доступної на машині — і рахує ту саму пам'ять, яку вже порахував
# PG_SHARED_BUFFERS. На машині з 4 ГБ два незалежні розрахунки
# чверті одного пирога закінчуються тим, що ядро вбиває когось
# третього, найімовірніше API. Установник задає обидві цифри разом.
# Штатна команда образу — `dragonfly --logtostderr`; command її
# ЗАМІНЮЄ, а не доповнює, тому обидва слова тут обов'язкові. Без
# --logtostderr журнал іде у файл усередині контейнера, тобто
# `docker logs` мовчить — і контейнер, що падає в циклі, виглядає
# як контейнер без проблем.
#
# --proactor_threads не косметика, а умова роботи: Dragonfly вимагає
# 256 МБ на КОЖЕН потік вводу-виводу й відмовляється стартувати,
# якщо стеля менша. За замовчуванням потоків стільки ж, скільки
# ядер, тож на 4-ядерній машині стеля 512 МБ дає «потрібен 1 ГіБ,
# виходжу». Два потоки для кешу сесій — з запасом.
command:
- dragonfly
- --logtostderr
- --maxmemory=${DRAGONFLY_MAXMEMORY:-512mb}
- --proactor_threads=${DRAGONFLY_THREADS:-2}
volumes:
- cache-data:/data
restart: unless-stopped
# Міграції окремою службою, а не на старті API.
#
# API запускається в кількох примірниках, і накочування схеми зі старту
# означало б гонку між ними. Тут же — один запуск, який мусить
# завершитись успіхом, перш ніж піднімуться API й колектор.
migrate:
build: &server-build
context: .
dockerfile: deploy/Dockerfile.server
args:
VERSION: ${NETPULSE_VERSION:-dev}
COMMIT: ${NETPULSE_COMMIT:-none}
image: netpulse/server:${NETPULSE_VERSION:-dev}
entrypoint: ["netpulse-migrate"]
environment:
<<: *server-env
# Схему котить власник, а не netpulse_app: міграції створюють
# об'єкти, правлять самі політики й іноді переливають дані поверх
# кабінетів. Роль під RLS не змогла б жодного з трьох, а 0063 ще й
# перевіряє права netpulse_app — тобто мусить їх бачити.
NETPULSE_DSN: *owner-dsn
# А цими двома мігратор схему не котить — він ними ЗАХОДИТЬ.
#
# По-перше, на чистій базі саме звідси беруться паролі ролей:
# мігратор витягує пароль із самого DSN, тому в базу лягає рівно
# той рядок, який потім надішле pgx. Розійтись вони не можуть за
# побудовою — це той самий рядок і той самий розбирач.
#
# По-друге, перед тим як пустити api й collector, мігратор кожним
# з цих DSN підключається й дивиться, під якою роллю опинився.
# Саме цієї перевірки бракувало 0063: там перевіряли, що не видно
# чужого, а зламався вхід — тобто «чи видно своє».
NETPULSE_DSN_APP: *app-dsn
NETPULSE_DSN_WORKER: *worker-dsn
depends_on:
db:
condition: service_healthy
restart: "no"
# Утиліти командного рядка під роллю власника: netpulse-user (кабінети
# й люди), netpulse-secret (секрети в core.secrets).
#
# Окрема служба, а не `run --rm api`, бо DSN тут інший. Обидві утиліти
# роблять рівно те, чого роль під RLS робити не має: заводять кабінет
# (INSERT у core.tenants під політикою tenant_self неможливий за
# означенням) і додають людину в чужі за контекстом рядки. Під
# netpulse_app вони не падають, а мовчки нічого не знаходять — тобто
# на новій інсталяції перша ж команда «створити адміністратора»
# виглядала б як успіх, а адміністратора не було б.
#
# profiles: службу не піднімає ні `up`, ні `up -d`; `docker compose run`
# вмикає її профіль сам.
#
# docker compose run --rm --entrypoint netpulse-user cli \
# -tenant default -login admin -role owner
cli:
build: *server-build
image: netpulse/server:${NETPULSE_VERSION:-dev}
profiles: ["cli"]
environment:
<<: *server-env
NETPULSE_DSN: *owner-dsn
NETPULSE_DSN_WORKER: ""
volumes:
- git-data:/var/lib/netpulse
depends_on:
db:
condition: service_healthy
restart: "no"
api:
build: *server-build
image: netpulse/server:${NETPULSE_VERSION:-dev}
entrypoint: ["netpulse-api"]
command:
- -listen=:8080
- -alert-interval=${NETPULSE_ALERT_INTERVAL:-30s}
environment:
<<: *server-env
NETPULSE_JWT_SECRET: ${NETPULSE_JWT_SECRET:?потрібен NETPULSE_JWT_SECRET — не менше 32 байтів}
# Історія конфігів у Git. Спільний том для API й колектора:
# колектор туди пише під час бекапу, API звідти читає для порівняння
# версій. Різні томи означали б, що половина історії видна одному
# процесу, а половина другому.
volumes:
- git-data:/var/lib/netpulse
depends_on:
migrate:
condition: service_completed_successfully
cache:
condition: service_started
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/healthz"]
interval: 10s
timeout: 3s
retries: 6
start_period: 15s
restart: unless-stopped
# Колектор зондів. TLS знімає Caddy, всередині мережі — h2c, тому
# -insecure. Порт назовні сам не публікує: до нього ходять через
# проксі, який має справжній сертифікат.
collector:
build: *server-build
image: netpulse/server:${NETPULSE_VERSION:-dev}
entrypoint: ["netpulse-server"]
command:
- -listen=:9443
- -insecure
environment: *server-env
# Історія конфігів у Git. Спільний том для API й колектора:
# колектор туди пише під час бекапу, API звідти читає для порівняння
# версій. Різні томи означали б, що половина історії видна одному
# процесу, а половина другому.
volumes:
- git-data:/var/lib/netpulse
depends_on:
migrate:
condition: service_completed_successfully
restart: unless-stopped
proxy:
image: caddy:2.8-alpine
environment:
NETPULSE_DOMAIN: ${NETPULSE_DOMAIN:?потрібен NETPULSE_DOMAIN}
# Порожня пошта означає «домену ще немає» — тоді самопідписаний
# сертифікат. Інакше Caddy нескінченно проситиме Let's Encrypt
# видати сертифікат на IP, чого той не робить, і сайт лишиться
# без TLS зовсім.
NETPULSE_TLS: ${ACME_EMAIL:-internal}
volumes:
- ./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:
- "${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 означало б розрізняти протоколи за шляхом —
# зайва крихкість там, де порт коштує нічого.
- "${NETPULSE_BIND:-0.0.0.0}:${NETPULSE_PORT_GRPC:-9443}:9443"
depends_on:
- api
- collector
restart: unless-stopped
# Зонд на тому ж хості, що й сервер: базовий моніторинг самої
# інсталяції. Профіль, а не звичайна служба: у типовому розгортанні
# зонди стоять у мережах клієнтів, а не поруч із сервером.
agent:
build:
context: .
dockerfile: deploy/Dockerfile.agent
args:
VERSION: ${NETPULSE_VERSION:-dev}
COMMIT: ${NETPULSE_COMMIT:-none}
image: netpulse/agent:${NETPULSE_VERSION:-dev}
profiles: ["agent"]
command:
- -server=collector:9443
- -insecure
- -modules=icmp,snmp,topology,ncm,traps
- -traps-listen=${NETPULSE_TRAPS_LISTEN:-:162}
environment:
NETPULSE_ENROLL: ${NETPULSE_ENROLL:-}
NETPULSE_NAME: ${NETPULSE_AGENT_NAME:-локальний зонд}
TZ: ${TZ:-Europe/Kyiv}
# Порт трапів назовні. Прив'язка задається змінною, а не зашита:
# 162/udp без автентифікації приймає будь-кого, хто знає адресу, і
# на машині з публічним інтерфейсом «усі інтерфейси» — це рішення, а
# не типове значення. Порожній NETPULSE_TRAPS_BIND лишає службу
# всередині мережі docker, і зовні вона недосяжна.
ports:
- "${NETPULSE_TRAPS_BIND:-127.0.0.1}:162:162/udp"
cap_add:
- NET_RAW
# 162 нижче 1024, а процес усередині — не root. Без цього зонд
# порт не займе; він про це кричить у журнал, але приймати трапи
# все одно не буде.
- NET_BIND_SERVICE
volumes:
- agent-identity:/var/lib/netpulse
depends_on:
- collector
restart: unless-stopped
volumes:
db-data:
git-data:
cache-data:
caddy-data:
caddy-config:
agent-identity: