Розгортання на живому сервері: три виправлення, знайдені першим запуском
Some checks are pending
CI / web (push) Waiting to run
CI / server (push) Waiting to run
CI / agent (push) Waiting to run

Образи зібрались із першого разу, але далі знайшлось три речі.

memlock без обмеження для Dragonfly дозволений не всюди: у контейнерній
віртуалізації ядро відмовляє, і контейнер не стартує взагалі. Швидкість
того не варта — обмеження знято.

Caddy нескінченно просив Let's Encrypt видати сертифікат на IP, чого той
не робить, і сайт лишався без TLS. Тепер порожня ACME_EMAIL означає
самопідписаний сертифікат. Плюс default_sni: SNI не містить IP-адрес,
тож без нього рукостискання обривалось «internal error».

У README команда заведення власника не працювала: у образу ENTRYPOINT, і
без --entrypoint ім'я команди йшло аргументом до netpulse-api. Заразом
netpulse-user навчився створювати перший кабінет — свіжа база не має
жодного, а користувач без кабінету нікуди не належить.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
byrsapty 2026-08-25 18:07:18 +03:00
parent 03ea70e5de
commit 6843bd3280
4 changed files with 76 additions and 13 deletions

View file

@ -4,12 +4,36 @@
# знімає з розгортання найчастішу причину нічного дзвінка — прострочений
# сертифікат на системі, яка сама має повідомляти про проблеми.
{
email {$ACME_EMAIL}
# Яким сертифікатом відповідати, коли клієнт не назвав імені.
#
# SNI не містить IP-адрес: стандарт дозволяє там лише доменні імена,
# тож браузер, який іде на https://93.171.243.247, не каже нічого — і
# Caddy не має за чим обрати сертифікат. Рукостискання обривається
# «internal error», сайт виглядає мертвим.
#
# З доменом ця настройка нічого не міняє: SNI приходить, і збіг
# знаходиться звичайним шляхом.
default_sni {$NETPULSE_DOMAIN}
}
# Одна директива на два випадки.
#
# tls <пошта> — сертифікат від Let's Encrypt на цю адресу;
# tls internal — власний самопідписаний.
#
# Значення приходить із NETPULSE_TLS, яке compose виводить із ACME_EMAIL:
# пошта задана — беремо справжній сертифікат, порожньо — самопідписаний.
#
# Порожнє значення тут не працює: Let's Encrypt не видає сертифікатів на
# публічні IP-адреси, і Caddy вічно повторював би спробу, а сайт лежав
# би без TLS. Перевірено живим розгортанням на голому IP.
# --- інтерфейс і API -------------------------------------------------
{$NETPULSE_DOMAIN} {
tls {$NETPULSE_TLS}
encode zstd gzip
# WebSocket живих оновлень. Окремим блоком, бо йому не можна
@ -43,6 +67,8 @@
# Зонди говорять gRPC поверх HTTP/2. Усередині мережі — h2c без TLS:
# сертифікат живе тут, а не в кожному примірнику колектора.
{$NETPULSE_DOMAIN}:9443 {
tls {$NETPULSE_TLS}
reverse_proxy h2c://collector:9443 {
# Потік Control двонаправлений і довгий: агент тримає його
# відкритим, доки живий. Буферизація тут ламає саме те, заради

View file

@ -102,7 +102,7 @@ docker compose exec -T db \
зашифрованими в базі, і репозиторій повністю відтворюється з неї —
```sh
docker compose run --rm api netpulse-gitsync
docker compose run --rm --entrypoint netpulse-gitsync api
```
Зворотне невірно: з репозиторію базу не відновити. Тому джерелом істини
@ -197,6 +197,17 @@ NETPULSE_DEK=np2=<новий hex>,np1=<старий hex>
яка сама має повідомляти про проблеми, — найгірший спосіб дізнатись про
проблему. Caddy оновлює його сам.
## Без домену
Домен потрібен для справжнього сертифіката: Let's Encrypt не видає їх на
IP-адреси. Поки домену немає, лишіть `ACME_EMAIL` порожнім — Caddy
випише самопідписаний, і система працюватиме одразу, з попередженням у
браузері.
Коли домен зʼявиться: замінити `NETPULSE_DOMAIN`, вписати `ACME_EMAIL` і
`docker compose restart proxy`. Caddyfile змонтований, тож перезбирати
образи не треба — але й `up -d` без `restart` його не перечитає.
## Дашборд на телевізор
В інтерфейсі: **Дашборд → На телевізор → Видати посилання**. Отриману

View file

@ -52,8 +52,10 @@ services:
cache:
image: docker.dragonflydb.io/dragonflydb/dragonfly:v1.25.5
ulimits:
memlock: -1
# memlock без обмеження прискорює Dragonfly, але дозволений не всюди:
# у контейнерній віртуалізації (LXC, частина VPS) ядро відмовляє, і
# контейнер не стартує взагалі з «error setting rlimit type 8».
# Швидкість того не варта — обмеження знято.
volumes:
- cache-data:/data
restart: unless-stopped
@ -133,10 +135,11 @@ services:
image: caddy:2.8-alpine
environment:
NETPULSE_DOMAIN: ${NETPULSE_DOMAIN:?потрібен NETPULSE_DOMAIN}
# Порожній рядок Caddy не приймає: директива email без значення —
# помилка розбору, і проксі не підніметься взагалі. Тому підстановка
# спрацьовує і на незадану змінну, і на задану порожньою.
ACME_EMAIL: ${ACME_EMAIL:-admin@${NETPULSE_DOMAIN}}
# Порожня пошта означає «домену ще немає» — тоді самопідписаний
# сертифікат. Інакше Caddy нескінченно проситиме Let's Encrypt
# видати сертифікат на IP, чого той не робить, і сайт лишиться
# без TLS зовсім.
NETPULSE_TLS: ${ACME_EMAIL:-internal}
volumes:
- ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data

View file

@ -33,6 +33,8 @@ func run() error {
var (
dsn = flag.String("dsn", os.Getenv("NETPULSE_DSN"), "DSN PostgreSQL")
tenant = flag.String("tenant", "", "slug або uuid тенанта")
newTenant = flag.String("create-tenant", "",
"створити тенанта з таким іменем, якщо його ще немає")
login = flag.String("login", "", "логін користувача")
email = flag.String("email", "", "email (необовʼязково — для сповіщень)")
fullName = flag.String("name", "", "повне ім'я")
@ -57,7 +59,7 @@ func run() error {
}
defer conn.Close(ctx)
tenantID, tenantName, err := resolveTenant(ctx, conn, *tenant)
tenantID, tenantName, err := resolveTenant(ctx, conn, *tenant, *newTenant)
if err != nil {
return err
}
@ -156,17 +158,38 @@ func run() error {
return nil
}
func resolveTenant(ctx context.Context, conn *pgx.Conn, ref string) (id, name string, err error) {
// resolveTenant знаходить кабінет або створює перший.
//
// Свіжа інсталяція не має жодного тенанта, а користувач без кабінету
// нікуди не належить. Виходить те саме коло, заради розриву якого існує
// ця команда, лише рівнем вище: щоб завести власника, потрібен кабінет,
// а щоб завести кабінет — панель, у яку ще нікому зайти.
//
// Створення за окремим прапорцем, а не мовчки: друкарська помилка в
// -tenant інакше плодила б порожні кабінети замість зрозумілої відмови.
func resolveTenant(ctx context.Context, conn *pgx.Conn, ref, createName string) (id, name string, err error) {
err = conn.QueryRow(ctx, `
SELECT id::text, name FROM core.tenants
WHERE (slug::text = $1 OR id::text = $1) AND deleted_at IS NULL
`, ref).Scan(&id, &name)
if errors.Is(err, pgx.ErrNoRows) {
return "", "", fmt.Errorf("тенанта %q не знайдено", ref)
}
if !errors.Is(err, pgx.ErrNoRows) {
return id, name, err
}
if createName == "" {
return "", "", fmt.Errorf(
"тенанта %q не знайдено; для першого запуску додайте -create-tenant \"Назва\"", ref)
}
if err := conn.QueryRow(ctx, `
INSERT INTO core.tenants (slug, name) VALUES ($1, $2)
RETURNING id::text, name
`, ref, createName).Scan(&id, &name); err != nil {
return "", "", fmt.Errorf("створення тенанта: %w", err)
}
fmt.Printf("створено кабінет %q (%s)\n", name, ref)
return id, name, nil
}
func listUsers(ctx context.Context, conn *pgx.Conn, tenantID, tenantName string) error {
rows, err := conn.Query(ctx, `
SELECT u.username::text, COALESCE(u.full_name,''), r.key::text,