diff --git a/.gitignore b/.gitignore index 440d685..05f7f36 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,8 @@ netpulse-server # Фронтенд /web/node_modules/ /web/dist/ + +# Зібраний фронтенд, вшитий у бінарник API. Кладеться збіркою. +server/webui/dist/* +!server/webui/dist/index.html +!server/webui/dist/.gitignore diff --git a/db/README-migrations.md b/db/README-migrations.md new file mode 100644 index 0000000..f5210aa --- /dev/null +++ b/db/README-migrations.md @@ -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`. diff --git a/db/migrate.ps1 b/db/migrate.ps1 deleted file mode 100644 index f9f2f3f..0000000 --- a/db/migrate.ps1 +++ /dev/null @@ -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 diff --git a/db/profiles/README.md b/db/profiles/README.md index 1a70148..1720817 100644 --- a/db/profiles/README.md +++ b/db/profiles/README.md @@ -3,7 +3,7 @@ `catalog.json` — джерело істини про те, як зняти конфіг із кожної підтримуваної платформи. 148 платформ, 67 вендорів. -Міграція `db/migrations/0014_ncm_profiles.sql` **породжується** з +Міграція `server/migrations/0014_ncm_profiles.sql` **породжується** з каталогу, а не правиться руками: два описи одного й того самого розійшлися б із першою ж правкою, і невідомо було б, який справжній. diff --git a/server/cmd/netpulse-api/main.go b/server/cmd/netpulse-api/main.go index ff9be25..60d204e 100644 --- a/server/cmd/netpulse-api/main.go +++ b/server/cmd/netpulse-api/main.go @@ -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) diff --git a/server/cmd/netpulse-migrate/main.go b/server/cmd/netpulse-migrate/main.go new file mode 100644 index 0000000..c42e5eb --- /dev/null +++ b/server/cmd/netpulse-migrate/main.go @@ -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) +} diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index 3f4c22d..0af2414 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -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)) } diff --git a/server/internal/httpapi/static.go b/server/internal/httpapi/static.go new file mode 100644 index 0000000..56184c4 --- /dev/null +++ b/server/internal/httpapi/static.go @@ -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) +} diff --git a/db/migrations/0001_core.sql b/server/migrations/0001_core.sql similarity index 100% rename from db/migrations/0001_core.sql rename to server/migrations/0001_core.sql diff --git a/db/migrations/0002_inventory.sql b/server/migrations/0002_inventory.sql similarity index 100% rename from db/migrations/0002_inventory.sql rename to server/migrations/0002_inventory.sql diff --git a/db/migrations/0003_agents_plugins.sql b/server/migrations/0003_agents_plugins.sql similarity index 100% rename from db/migrations/0003_agents_plugins.sql rename to server/migrations/0003_agents_plugins.sql diff --git a/db/migrations/0004_topology.sql b/server/migrations/0004_topology.sql similarity index 100% rename from db/migrations/0004_topology.sql rename to server/migrations/0004_topology.sql diff --git a/db/migrations/0005_telemetry_timescale.sql b/server/migrations/0005_telemetry_timescale.sql similarity index 100% rename from db/migrations/0005_telemetry_timescale.sql rename to server/migrations/0005_telemetry_timescale.sql diff --git a/db/migrations/0006_ncm.sql b/server/migrations/0006_ncm.sql similarity index 100% rename from db/migrations/0006_ncm.sql rename to server/migrations/0006_ncm.sql diff --git a/db/migrations/0007_alerting.sql b/server/migrations/0007_alerting.sql similarity index 100% rename from db/migrations/0007_alerting.sql rename to server/migrations/0007_alerting.sql diff --git a/db/migrations/0008_dashboards.sql b/server/migrations/0008_dashboards.sql similarity index 100% rename from db/migrations/0008_dashboards.sql rename to server/migrations/0008_dashboards.sql diff --git a/db/migrations/0009_billing_licensing.sql b/server/migrations/0009_billing_licensing.sql similarity index 100% rename from db/migrations/0009_billing_licensing.sql rename to server/migrations/0009_billing_licensing.sql diff --git a/db/migrations/0010_seed.sql b/server/migrations/0010_seed.sql similarity index 100% rename from db/migrations/0010_seed.sql rename to server/migrations/0010_seed.sql diff --git a/db/migrations/0011_rls.sql b/server/migrations/0011_rls.sql similarity index 100% rename from db/migrations/0011_rls.sql rename to server/migrations/0011_rls.sql diff --git a/db/migrations/0012_auth.sql b/server/migrations/0012_auth.sql similarity index 100% rename from db/migrations/0012_auth.sql rename to server/migrations/0012_auth.sql diff --git a/db/migrations/0013_groups_login.sql b/server/migrations/0013_groups_login.sql similarity index 100% rename from db/migrations/0013_groups_login.sql rename to server/migrations/0013_groups_login.sql diff --git a/db/migrations/0014_ncm_profiles.sql b/server/migrations/0014_ncm_profiles.sql similarity index 100% rename from db/migrations/0014_ncm_profiles.sql rename to server/migrations/0014_ncm_profiles.sql diff --git a/db/migrations/0015_templates.sql b/server/migrations/0015_templates.sql similarity index 100% rename from db/migrations/0015_templates.sql rename to server/migrations/0015_templates.sql diff --git a/db/migrations/0016_templates_any_check.sql b/server/migrations/0016_templates_any_check.sql similarity index 100% rename from db/migrations/0016_templates_any_check.sql rename to server/migrations/0016_templates_any_check.sql diff --git a/db/migrations/0017_backup_defaults.sql b/server/migrations/0017_backup_defaults.sql similarity index 100% rename from db/migrations/0017_backup_defaults.sql rename to server/migrations/0017_backup_defaults.sql diff --git a/db/migrations/0018_rule_actions.sql b/server/migrations/0018_rule_actions.sql similarity index 100% rename from db/migrations/0018_rule_actions.sql rename to server/migrations/0018_rule_actions.sql diff --git a/db/migrations/0019_session_rotation_grace.sql b/server/migrations/0019_session_rotation_grace.sql similarity index 100% rename from db/migrations/0019_session_rotation_grace.sql rename to server/migrations/0019_session_rotation_grace.sql diff --git a/db/migrations/0020_dashboard_perms.sql b/server/migrations/0020_dashboard_perms.sql similarity index 100% rename from db/migrations/0020_dashboard_perms.sql rename to server/migrations/0020_dashboard_perms.sql diff --git a/db/migrations/0021_map_access_icons.sql b/server/migrations/0021_map_access_icons.sql similarity index 100% rename from db/migrations/0021_map_access_icons.sql rename to server/migrations/0021_map_access_icons.sql diff --git a/db/migrations/0022_template_graphs.sql b/server/migrations/0022_template_graphs.sql similarity index 100% rename from db/migrations/0022_template_graphs.sql rename to server/migrations/0022_template_graphs.sql diff --git a/db/migrations/0023_agent_enrollment.sql b/server/migrations/0023_agent_enrollment.sql similarity index 100% rename from db/migrations/0023_agent_enrollment.sql rename to server/migrations/0023_agent_enrollment.sql diff --git a/server/migrations/embed.go b/server/migrations/embed.go new file mode 100644 index 0000000..6ea50bd --- /dev/null +++ b/server/migrations/embed.go @@ -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 diff --git a/server/webui/dist/.gitignore b/server/webui/dist/.gitignore new file mode 100644 index 0000000..b2e0a0f --- /dev/null +++ b/server/webui/dist/.gitignore @@ -0,0 +1,6 @@ +# Зібраний бандл сюди кладе збірка, а не людина: коміт артефакту +# означав би тримати в git файли, які розходяться з кодом при кожній +# правці й роздувають історію. +* +!.gitignore +!index.html diff --git a/server/webui/dist/index.html b/server/webui/dist/index.html new file mode 100644 index 0000000..ce20a98 --- /dev/null +++ b/server/webui/dist/index.html @@ -0,0 +1,6 @@ + + +
Це збірка без веб-інтерфейсу: працює лише API.
+Щоб вшити інтерфейс, зберіть фронтенд і покладіть результат сюди:
+cd web && npm run build && cp -r dist/* ../server/webui/dist/