Пакування: runner міграцій і вшитий у бінарник фронтенд

netpulse-migrate замість PowerShell-скрипта: у контейнері немає ані
psql, ані PowerShell, а тягнути клієнт Postgres в образ заради одного
запуску — це половина дистрибутива на порожньому місці.

Міграції вшиті через embed і переїхали в server/migrations: embed не
бачить нічого за межами кореня свого модуля, а міграції поруч із
бінарником, який їх накочує, не можуть розійтися версіями.

Накочування під advisory-блокуванням: два інстанси при rolling update
інакше застосували б ту саму міграцію двічі. Кожен файл в одній
транзакції разом із записом у schema_migrations; виняток — continuous
aggregates, які TimescaleDB забороняє в транзакції. Змінена вже
застосована міграція зупиняє запуск: у різних інсталяціях інакше
опиниться різна схема під одним номером.

Перевірено на чистій базі: 23 міграції, 101 таблиця, повторний запуск
каже «схема актуальна».

Веб віддає сам API через embed: на self-hosted це прибирає з інструкції
встановлення цілий компонент. Три політики кешування — назавжди для
assets із хешем у імені, ніколи для index.html, коротко для решти.

Знайдено живим прогоном: невідомий шлях під /api/ віддавав 200 з
index.html, і клієнт падав на розборі HTML як JSON замість чесного 404.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
byrsapty 2026-08-25 01:01:55 +03:00
parent ddaae60fa0
commit 205dd5e079
35 changed files with 451 additions and 64 deletions

5
.gitignore vendored
View file

@ -31,3 +31,8 @@ netpulse-server
# Фронтенд
/web/node_modules/
/web/dist/
# Зібраний фронтенд, вшитий у бінарник API. Кладеться збіркою.
server/webui/dist/*
!server/webui/dist/index.html
!server/webui/dist/.gitignore

22
db/README-migrations.md Normal file
View file

@ -0,0 +1,22 @@
# Міграції переїхали
Файли схеми тепер лежать у `server/migrations/`.
Причина технічна: `netpulse-migrate` вшиває їх у бінарник через
`//go:embed`, а `embed` не бачить нічого за межами кореня свого модуля.
Модуль сервера починається в `server/`, тож `db/migrations` для нього
недосяжні.
Це не лише про компіляцію. Міграції поруч із бінарником, який їх
накочує, не можуть розійтися версіями: у контейнері немає способу
підсунути схему з іншого релізу.
Накочування:
```
netpulse-migrate -dsn postgres://user:pass@host:5432/netpulse
netpulse-migrate -dsn … -dry-run # лише показати, що буде застосовано
```
Генератор профілів NCM (`db/profiles/build.py`) пише свій результат
туди ж — у `server/migrations/0014_ncm_profiles.sql`.

View file

@ -1,63 +0,0 @@
<#
Накочує міграції по порядку номерів у транзакції (кожен файл окремо).
Використання:
.\migrate.ps1 # локальний стенд з docker-compose
.\migrate.ps1 -DbUrl "postgres://user:pw@host:5432/db"
.\migrate.ps1 -DryRun # лише показати порядок
#>
param(
[string]$DbUrl = "postgres://netpulse:netpulse@localhost:5432/netpulse",
[switch]$DryRun
)
$ErrorActionPreference = "Stop"
$dir = Join-Path $PSScriptRoot "migrations"
$files = Get-ChildItem -Path $dir -Filter "*.sql" | Sort-Object Name
if ($DryRun) {
$files | ForEach-Object { $_.Name }
return
}
if (-not (Get-Command psql -ErrorAction SilentlyContinue)) {
throw "psql не знайдено в PATH. Встанови PostgreSQL client tools або запусти через контейнер: docker compose exec -T db psql ..."
}
# Таблиця обліку застосованих міграцій
$bootstrap = @'
CREATE TABLE IF NOT EXISTS public.schema_migrations (
version text PRIMARY KEY,
checksum text NOT NULL,
applied_at timestamptz NOT NULL DEFAULT now()
);
'@
$bootstrap | psql $DbUrl -v ON_ERROR_STOP=1 -q
foreach ($f in $files) {
$version = $f.BaseName
$applied = (psql $DbUrl -tA -c "SELECT 1 FROM public.schema_migrations WHERE version = '$version'").Trim()
if ($applied -eq "1") {
Write-Host "skip $version" -ForegroundColor DarkGray
continue
}
$sum = (Get-FileHash $f.FullName -Algorithm SHA256).Hash
Write-Host "apply $version" -ForegroundColor Cyan
# Зазвичай — одна транзакція на файл, щоб не було часткових міграцій.
# Виняток: TimescaleDB забороняє CREATE MATERIALIZED VIEW WITH
# (timescaledb.continuous) всередині транзакційного блоку.
$needsAutocommit = (Select-String -Path $f.FullName -Pattern "timescaledb\.continuous" -Quiet)
if ($needsAutocommit) {
Write-Host " (autocommit: continuous aggregates)" -ForegroundColor DarkYellow
psql $DbUrl -v ON_ERROR_STOP=1 -q -f $f.FullName
} else {
psql $DbUrl -v ON_ERROR_STOP=1 --single-transaction -q -f $f.FullName
}
if ($LASTEXITCODE -ne 0) { throw "Міграція $version впала" }
psql $DbUrl -v ON_ERROR_STOP=1 -q -c `
"INSERT INTO public.schema_migrations (version, checksum) VALUES ('$version','$sum')"
}
Write-Host "OK: усі міграції застосовано" -ForegroundColor Green

View file

@ -3,7 +3,7 @@
`catalog.json` — джерело істини про те, як зняти конфіг із кожної
підтримуваної платформи. 148 платформ, 67 вендорів.
Міграція `db/migrations/0014_ncm_profiles.sql` **породжується** з
Міграція `server/migrations/0014_ncm_profiles.sql` **породжується** з
каталогу, а не правиться руками: два описи одного й того самого
розійшлися б із першою ж правкою, і невідомо було б, який справжній.

View file

@ -21,6 +21,7 @@ import (
"github.com/netpulse/netpulse/server/internal/auth"
"github.com/netpulse/netpulse/server/internal/crypto"
"github.com/netpulse/netpulse/server/internal/httpapi"
"github.com/netpulse/netpulse/server/webui"
"github.com/netpulse/netpulse/server/internal/store"
)
@ -88,6 +89,13 @@ func run() error {
api := httpapi.New(st, signer, log)
// Зібраний інтерфейс, якщо він є в цій збірці. Порожній dist —
// робочий стан: розробка йде проти vite, а API просто віддає API.
if fsys, ok := webui.FS(); ok {
httpapi.SetStatic(fsys)
log.Info("веб-інтерфейс вшито в бінарник")
}
go api.Hub().Run(ctx)
go pruneLoop(ctx, st, log, *pruneAge)

View file

@ -0,0 +1,238 @@
// Команда netpulse-migrate — накочування схеми.
//
// Бінарник, а не скрипт: у контейнері немає ані PowerShell, ані psql, а
// вимагати клієнт Postgres поруч із застосунком означає тягнути в образ
// половину дистрибутива заради одного запуску.
//
// Міграції вшиті в бінарник через embed: файл, який лежить поруч,
// рано чи пізно виявиться версією з іншого релізу.
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"flag"
"fmt"
"io/fs"
"os"
"os/signal"
"regexp"
"sort"
"strings"
"syscall"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
schema "github.com/netpulse/netpulse/server/migrations"
)
// Шлях відносний до кореня модуля не працює: embed бачить лише те, що
// лежить у каталозі пакета або нижче. Тому міграції тягнуться через
// окремий пакет, який стоїть поруч із ними.
var migrations = schema.Files
// migrateLockKey — advisory-блокування на час накочування.
//
// Два інстанси, що стартують одночасно (rolling update, docker compose
// зі скейлом), інакше накотили б ту саму міграцію двічі: перевірка
// «чи застосовано» і сам запис — різні моменти часу.
const migrateLockKey = 0x6e70_6d67 // "npmg"
// continuousRe ловить те, що не можна виконати в транзакції.
//
// TimescaleDB відмовляє: CREATE MATERIALIZED VIEW WITH
// (timescaledb.continuous) поза транзакційним блоком. Такі файли
// накочуються без обгортки — ціна в тому, що збій посеред файлу лишає
// половину змін, тому їх свідомо тримають короткими.
var continuousRe = regexp.MustCompile(`timescaledb\.continuous`)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "netpulse-migrate:", err)
os.Exit(1)
}
}
func run() error {
dsn := flag.String("dsn", os.Getenv("NETPULSE_DSN"), "postgres://user:pass@host:5432/db")
dryRun := flag.Bool("dry-run", false, "лише показати, що буде застосовано")
timeout := flag.Duration("timeout", 10*time.Minute, "стеля на всі міграції")
flag.Parse()
if *dsn == "" {
return errors.New("не вказано -dsn (або NETPULSE_DSN)")
}
files, err := listMigrations()
if err != nil {
return err
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
ctx, cancel := context.WithTimeout(ctx, *timeout)
defer cancel()
pool, err := pgxpool.New(ctx, *dsn)
if err != nil {
return fmt.Errorf("підключення: %w", err)
}
defer pool.Close()
// Одне з'єднання на весь запуск: advisory-блокування живе рівно
// стільки, скільки з'єднання, яке його взяло.
conn, err := pool.Acquire(ctx)
if err != nil {
return fmt.Errorf("підключення: %w", err)
}
defer conn.Release()
if err := bootstrap(ctx, conn.Conn()); err != nil {
return err
}
if _, err := conn.Exec(ctx, `SELECT pg_advisory_lock($1)`, int64(migrateLockKey)); err != nil {
return fmt.Errorf("блокування: %w", err)
}
defer func() {
_, _ = conn.Exec(context.WithoutCancel(ctx),
`SELECT pg_advisory_unlock($1)`, int64(migrateLockKey))
}()
applied, err := appliedVersions(ctx, conn.Conn())
if err != nil {
return err
}
pending := 0
for _, f := range files {
version := strings.TrimSuffix(f, ".sql")
body, err := migrations.ReadFile(f)
if err != nil {
return err
}
sum := sha256.Sum256(body)
checksum := hex.EncodeToString(sum[:])
if old, ok := applied[version]; ok {
// Змінена вже застосована міграція — це майже завжди
// помилка: у різних інсталяціях опиниться різна схема під
// одним номером. Кажемо про це й зупиняємось.
if old != checksum {
return fmt.Errorf(
"міграцію %s змінено після застосування (було %s…, стало %s…).\n"+
"Не правте застосовані міграції — додайте нову",
version, old[:8], checksum[:8])
}
continue
}
pending++
if *dryRun {
fmt.Printf("буде застосовано %s\n", version)
continue
}
fmt.Printf("застосовую %s\n", version)
if err := apply(ctx, conn.Conn(), version, checksum, string(body)); err != nil {
return fmt.Errorf("міграція %s: %w", version, err)
}
}
switch {
case pending == 0:
fmt.Println("схема актуальна")
case *dryRun:
fmt.Printf("непримінених міграцій: %d\n", pending)
default:
fmt.Printf("застосовано міграцій: %d\n", pending)
}
return nil
}
func listMigrations() ([]string, error) {
entries, err := fs.ReadDir(migrations, ".")
if err != nil {
return nil, err
}
var out []string
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") {
out = append(out, e.Name())
}
}
// Порядок — за іменем: номер на початку файлу і є версією.
sort.Strings(out)
if len(out) == 0 {
return nil, errors.New("у бінарнику немає жодної міграції")
}
return out, nil
}
func bootstrap(ctx context.Context, conn *pgx.Conn) error {
_, err := conn.Exec(ctx, `
CREATE TABLE IF NOT EXISTS public.schema_migrations (
version text PRIMARY KEY,
checksum text NOT NULL,
applied_at timestamptz NOT NULL DEFAULT now()
)
`)
return err
}
func appliedVersions(ctx context.Context, conn *pgx.Conn) (map[string]string, error) {
rows, err := conn.Query(ctx, `SELECT version, checksum FROM public.schema_migrations`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]string{}
for rows.Next() {
var v, c string
if err := rows.Scan(&v, &c); err != nil {
return nil, err
}
out[v] = c
}
return out, rows.Err()
}
// apply накочує один файл.
//
// Зазвичай в одній транзакції разом із записом у schema_migrations: без
// цього збій посеред файлу лишає схему в стані, який ніхто не описував,
// а наступний запуск вважає міграцію незастосованою й повторює її.
func apply(ctx context.Context, conn *pgx.Conn, version, checksum, body string) error {
if continuousRe.MatchString(body) {
// TimescaleDB забороняє continuous aggregates у транзакції.
// Позначку ставимо після успіху: інакше збій лишив би міграцію
// «застосованою» без застосування.
if _, err := conn.Exec(ctx, body); err != nil {
return err
}
_, err := conn.Exec(ctx,
`INSERT INTO public.schema_migrations (version, checksum) VALUES ($1, $2)`,
version, checksum)
return err
}
tx, err := conn.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, body); err != nil {
return err
}
if _, err := tx.Exec(ctx,
`INSERT INTO public.schema_migrations (version, checksum) VALUES ($1, $2)`,
version, checksum); err != nil {
return err
}
return tx.Commit(ctx)
}

View file

@ -176,6 +176,12 @@ func (s *Server) Handler() http.Handler {
// заголовку через підпротокол — див. ws.go.
mux.Handle("GET /api/v1/ws", s.authenticated(s.handleWS))
// Статика останньою: "/" у ServeMux ловить усе, що не збіглося з
// конкретнішими маршрутами, тож API лишається головним, а фронтенд
// отримує решту. Без цього /api/v1/невідомий-шлях віддавав би
// index.html замість 404, і клієнт падав би на розборі HTML як JSON.
mux.HandleFunc("/", s.serveStatic)
return s.withRecovery(s.withLogging(mux))
}

View file

@ -0,0 +1,114 @@
package httpapi
import (
"io"
"io/fs"
"net/http"
"path"
"strings"
"time"
)
// StaticFS — зібраний веб-інтерфейс.
//
// Порожній за замовчуванням: API збирається й запускається без нього
// (розробка йде проти vite з його гарячим перезавантаженням). Реліз
// підкладає сюди справжню файлову систему через SetStatic.
var staticFS fs.FS
// SetStatic підключає зібраний фронтенд.
//
// Віддавати статику самим API, а не Nginx поруч: на self-hosted це
// прибирає з інструкції встановлення цілий компонент, який доведеться
// налаштовувати, оновлювати й лагодити тому, хто прийшов моніторити
// мережу, а не адмініструвати веб-сервер.
func SetStatic(f fs.FS) { staticFS = f }
// buildTime — момент запуску процесу.
//
// Слугує міткою для Last-Modified: точний час збірки файлу в embed.FS
// недоступний (усе нульове), а без будь-якої мітки браузер перепитує
// кожен ресурс щоразу.
var buildTime = time.Now()
// serveStatic віддає SPA.
//
// Три різні політики кешування, бо це три різні речі:
//
// - /assets/* — імена з хешем вмісту, тож кешуються назавжди;
// - index.html — ніколи, інакше після оновлення браузер тягне старий
// html зі старими іменами ассетів, яких на сервері вже немає;
// - решта (favicon, manifest) — недовго.
func (s *Server) serveStatic(w http.ResponseWriter, r *http.Request) {
if staticFS == nil {
http.NotFound(w, r)
return
}
// Невідомий шлях під /api/ — це помилка виклику, а не маршрут SPA.
//
// Знайдено живим прогоном: /api/v1/невідомий-шлях віддавав 200 з
// index.html, і клієнт падав на розборі HTML як JSON — замість
// зрозумілого 404. Роутер у браузері про /api/ нічого не знає, тож
// віддавати йому ці шляхи немає жодних підстав.
if strings.HasPrefix(r.URL.Path, "/api/") {
writeError(w, http.StatusNotFound, "not_found", "невідомий шлях API")
return
}
upath := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
if upath == "" {
upath = "index.html"
}
f, err := staticFS.Open(upath)
if err != nil {
// Будь-який невідомий шлях — це маршрут SPA (/map, /devices…),
// а не відсутній файл: роутер живе в браузері, і сервер про
// його маршрути не знає й не має знати.
s.serveIndex(w, r)
return
}
defer func() { _ = f.Close() }()
st, err := f.Stat()
if err != nil || st.IsDir() {
s.serveIndex(w, r)
return
}
if strings.HasPrefix(upath, "assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "public, max-age=300")
}
rs, ok := f.(io.ReadSeeker)
if !ok {
s.serveIndex(w, r)
return
}
http.ServeContent(w, r, upath, buildTime, rs)
}
func (s *Server) serveIndex(w http.ResponseWriter, r *http.Request) {
f, err := staticFS.Open("index.html")
if err != nil {
http.NotFound(w, r)
return
}
defer func() { _ = f.Close() }()
rs, ok := f.(io.ReadSeeker)
if !ok {
http.NotFound(w, r)
return
}
// index.html не кешується ніколи: він містить імена ассетів із
// хешами, і закешована копія після оновлення шле браузер по файли,
// яких уже немає.
w.Header().Set("Cache-Control", "no-cache, must-revalidate")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
http.ServeContent(w, r, "index.html", buildTime, rs)
}

View file

@ -0,0 +1,11 @@
// Package schema тримає SQL-міграції, вшиті в бінарник.
//
// Окремий пакет поруч із самими файлами, бо //go:embed бачить лише
// каталог свого пакета й нижче: із server/cmd/netpulse-migrate до
// server/migrations дотягнутись не можна.
package schema
import "embed"
//go:embed *.sql
var Files embed.FS

6
server/webui/dist/.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
# Зібраний бандл сюди кладе збірка, а не людина: коміт артефакту
# означав би тримати в git файли, які розходяться з кодом при кожній
# правці й роздувають історію.
*
!.gitignore
!index.html

6
server/webui/dist/index.html vendored Normal file
View file

@ -0,0 +1,6 @@
<!doctype html>
<meta charset="utf-8">
<title>NetPulse API</title>
<p>Це збірка без веб-інтерфейсу: працює лише API.</p>
<p>Щоб вшити інтерфейс, зберіть фронтенд і покладіть результат сюди:
<code>cd web &amp;&amp; npm run build &amp;&amp; cp -r dist/* ../server/webui/dist/</code></p>

34
server/webui/embed.go Normal file
View file

@ -0,0 +1,34 @@
// Package webui тримає зібраний фронтенд, вшитий у бінарник API.
//
// Каталог dist/ порожній у репозиторії й наповнюється при збірці:
// коміт зібраного бандла означав би тримати в git артефакт, який
// розходиться з кодом при кожній правці й роздуває історію.
//
// Порожній dist — робочий стан: API стартує без інтерфейсу, а розробка
// йде проти vite з гарячим перезавантаженням.
package webui
import (
"embed"
"io/fs"
)
//go:embed all:dist
var dist embed.FS
// FS повертає корінь зібраного інтерфейсу.
//
// Другий результат — чи є що віддавати. У збірці без фронтенду в dist
// лежить лише заглушка з поясненням; ознака справжнього бандла —
// каталог assets/, який робить vite. Перевіряти наявність index.html
// марно: він там завжди, інакше embed не скомпілюється.
func FS() (fs.FS, bool) {
sub, err := fs.Sub(dist, "dist")
if err != nil {
return nil, false
}
if _, err := fs.Stat(sub, "assets"); err != nil {
return nil, false
}
return sub, true
}