From 0e557c2694c6412bf089654c19a90471ed285edb Mon Sep 17 00:00:00 2001 From: Mirotin Artem Date: Tue, 7 Jul 2026 13:17:50 +0300 Subject: [PATCH 1/7] storage/migrateguard: empty the destructive registry ahead of the migration squash --- .../storage/migrateguard/migrateguard.go | 33 ++-- .../storage/migrateguard/migrateguard_test.go | 153 ++++++++---------- .../controlplane/storage/postgres/migrate.go | 6 +- 3 files changed, 92 insertions(+), 100 deletions(-) diff --git a/internal/controlplane/storage/migrateguard/migrateguard.go b/internal/controlplane/storage/migrateguard/migrateguard.go index b31af00c..e252ad42 100644 --- a/internal/controlplane/storage/migrateguard/migrateguard.go +++ b/internal/controlplane/storage/migrateguard/migrateguard.go @@ -1,13 +1,12 @@ // Package migrateguard refuses to apply migrations that would silently // destroy production data unless the operator has explicitly opted in. // -// Some early-life migrations (notably 0014_fleet_groups_redesign) ship -// with `DELETE FROM` against tables that hold real production rows. -// On a fresh DB those statements are no-ops; on a populated DB they -// erase fleet, agent and client state that cannot be reconstructed -// without restore-from-backup. The check below sits between the goose -// runner and `UpContext` so the unsafe path can never be taken without -// PANVEX_ALLOW_DESTRUCTIVE_MIGRATION=1 in the environment. +// Historically the registry held 0014_fleet_groups_redesign (DELETE FROM +// against fleet/agent/client tables); after the P9 squash the registry +// ships empty and exists for future destructive migrations. The check +// below sits between the goose runner and `UpContext` so an unsafe path +// can never be taken without PANVEX_ALLOW_DESTRUCTIVE_MIGRATION=1 in the +// environment. package migrateguard import ( @@ -43,8 +42,7 @@ const ( // another DROP/TRUNCATE/DELETE step. // // Per-dialect differences are handled at the CALL SITE — a benign -// dialect (e.g. SQLite 0014, which uses ADD COLUMN + UPDATE) simply -// does not call CheckAll. Keep the call-side wiring in +// dialect simply does not call CheckAll. Keep the call-side wiring in // internal/controlplane/storage/{postgres,sqlite}/migrate.go in sync // with this list when adding a new entry. type DestructiveMigration struct { @@ -53,13 +51,16 @@ type DestructiveMigration struct { } // DestructiveMigrations is the canonical registry of destructive -// migrations. -var DestructiveMigrations = []DestructiveMigration{ - { - Version: 14, - Tables: []string{"fleet_groups", "agents", "clients"}, - }, -} +// migrations. Empty since the P9 migration squash: 0001_init.sql +// contains only CREATE statements, and the one historical destructive +// entry (0014_fleet_groups_redesign) no longer ships. Keeping a stale +// entry would be worse than useless — a post-squash fresh install +// never records version 14 in goose_db_version, so checkOne would +// treat the migration as "pending" forever and block every migration +// run the moment the DB has data. Append a real entry (with its NEW +// goose version, > squashedHistoryCeiling) whenever a future migration +// destroys rows. +var DestructiveMigrations = []DestructiveMigration{} // CheckAll runs the guard for every entry in DestructiveMigrations and // returns the first ErrBlocked it produces (or nil if none apply). diff --git a/internal/controlplane/storage/migrateguard/migrateguard_test.go b/internal/controlplane/storage/migrateguard/migrateguard_test.go index 44efd7e5..98ddd248 100644 --- a/internal/controlplane/storage/migrateguard/migrateguard_test.go +++ b/internal/controlplane/storage/migrateguard/migrateguard_test.go @@ -14,128 +14,117 @@ import ( func openSQLite(t *testing.T) *sql.DB { t.Helper() - dsn := "file:" + filepath.Join(t.TempDir(), "guard.db") - db, err := sql.Open("sqlite", dsn) + db, err := sql.Open("sqlite", "file:"+filepath.Join(t.TempDir(), "guard.db")) if err != nil { t.Fatalf("sql.Open: %v", err) } + db.SetMaxOpenConns(1) t.Cleanup(func() { _ = db.Close() }) return db } -// schema bootstraps just enough of the production layout for the guard -// to make a meaningful decision: the goose bookkeeping table plus the -// three tables that DestructiveMigrations[0] guards. -func schema(t *testing.T, db *sql.DB) { +func mustExec(t *testing.T, db *sql.DB, query string, args ...any) { t.Helper() - stmts := []string{ - `CREATE TABLE goose_db_version ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - version_id BIGINT NOT NULL, - is_applied INTEGER NOT NULL, - tstamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP - )`, - `CREATE TABLE fleet_groups (id TEXT PRIMARY KEY, name TEXT)`, - `CREATE TABLE agents (id TEXT PRIMARY KEY)`, - `CREATE TABLE clients (id TEXT PRIMARY KEY)`, - } - for _, s := range stmts { - if _, err := db.ExecContext(t.Context(), s); err != nil { - t.Fatalf("schema %q: %v", s, err) - } + if _, err := db.ExecContext(context.Background(), query, args...); err != nil { + t.Fatalf("exec %q: %v", query, err) } } -func TestCheckAll_FreshDB_NoGooseTable(t *testing.T) { - t.Setenv(EnvAllowDestructiveMigration, "") - db := openSQLite(t) - if err := CheckAll(context.Background(), db, DialectSQLite, slog.Default()); err != nil { - t.Fatalf("fresh DB should pass, got %v", err) +// seedGooseTable creates the goose ledger and marks the given versions +// applied — the minimum a guard probe needs to see a "not fresh" DB. +func seedGooseTable(t *testing.T, db *sql.DB, versions ...int64) { + t.Helper() + mustExec(t, db, `CREATE TABLE goose_db_version ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + version_id INTEGER NOT NULL, + is_applied INTEGER NOT NULL, + tstamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP)`) + for _, v := range versions { + mustExec(t, db, `INSERT INTO goose_db_version (version_id, is_applied) VALUES (?, 1)`, v) } } -func TestCheckAll_VersionAlreadyApplied(t *testing.T) { - t.Setenv(EnvAllowDestructiveMigration, "") - db := openSQLite(t) - schema(t, db) - // Mark version 14 as applied AND populate the tables: the migration - // already ran, so the data here is post-erase state and must not - // trigger the guard. - if _, err := db.ExecContext(t.Context(), `INSERT INTO goose_db_version (version_id, is_applied) VALUES (14, 1)`); err != nil { - t.Fatalf("seed goose_db_version: %v", err) - } - if _, err := db.ExecContext(t.Context(), `INSERT INTO fleet_groups (id, name) VALUES ('g1', 'x')`); err != nil { - t.Fatalf("seed fleet_groups: %v", err) +// syntheticEntry is a registry entry that no real migration ships — +// the registry itself is empty since the P9 squash, so every behaviour +// of checkOne is exercised through this synthetic value. +var syntheticEntry = DestructiveMigration{ + Version: 9999, + Tables: []string{"fleet_groups"}, +} + +// TestCheckAllEmptyRegistryIsNoOp pins the post-squash contract: with an +// empty DestructiveMigrations registry, CheckAll passes on a populated +// DB without needing PANVEX_ALLOW_DESTRUCTIVE_MIGRATION. A stale entry +// referencing a squashed-away version would permanently block migrations +// on any fresh-install DB that has data — this test is the regression +// canary for that trap. +func TestCheckAllEmptyRegistryIsNoOp(t *testing.T) { + if len(DestructiveMigrations) != 0 { + t.Fatalf("DestructiveMigrations must be empty after the P9 squash; got %d entries", len(DestructiveMigrations)) } + db := openSQLite(t) + seedGooseTable(t, db, 1) + mustExec(t, db, `CREATE TABLE fleet_groups (id TEXT PRIMARY KEY)`) + mustExec(t, db, `INSERT INTO fleet_groups (id) VALUES ('fg-1')`) if err := CheckAll(context.Background(), db, DialectSQLite, slog.Default()); err != nil { - t.Fatalf("already-applied should pass, got %v", err) + t.Fatalf("CheckAll with empty registry must pass, got: %v", err) } } -func TestCheckAll_PendingButTablesEmpty(t *testing.T) { +func TestCheckOneBlocksPopulatedUnapplied(t *testing.T) { t.Setenv(EnvAllowDestructiveMigration, "") db := openSQLite(t) - schema(t, db) - // goose table exists but version 14 is not in it; tables empty. - if err := CheckAll(context.Background(), db, DialectSQLite, slog.Default()); err != nil { - t.Fatalf("empty tables should pass, got %v", err) + seedGooseTable(t, db, 1) + mustExec(t, db, `CREATE TABLE fleet_groups (id TEXT PRIMARY KEY)`) + mustExec(t, db, `INSERT INTO fleet_groups (id) VALUES ('fg-1')`) + + err := checkOne(context.Background(), db, DialectSQLite, syntheticEntry, slog.Default()) + if !errors.Is(err, ErrBlocked) { + t.Fatalf("expected ErrBlocked, got: %v", err) } } -func TestCheckAll_PendingAndPopulated_BlocksByDefault(t *testing.T) { +func TestCheckOneFreshDBPasses(t *testing.T) { t.Setenv(EnvAllowDestructiveMigration, "") - db := openSQLite(t) - schema(t, db) - if _, err := db.ExecContext(t.Context(), `INSERT INTO fleet_groups (id, name) VALUES ('g1', 'production')`); err != nil { - t.Fatalf("seed: %v", err) - } + db := openSQLite(t) // no goose_db_version table at all - err := CheckAll(context.Background(), db, DialectSQLite, slog.Default()) - if !errors.Is(err, ErrBlocked) { - t.Fatalf("want ErrBlocked, got %v", err) + if err := checkOne(context.Background(), db, DialectSQLite, syntheticEntry, slog.Default()); err != nil { + t.Fatalf("fresh DB must pass, got: %v", err) } } -func TestCheckAll_PendingAndPopulated_AllowedByEnv(t *testing.T) { - t.Setenv(EnvAllowDestructiveMigration, "1") +func TestCheckOneAppliedVersionPasses(t *testing.T) { + t.Setenv(EnvAllowDestructiveMigration, "") db := openSQLite(t) - schema(t, db) - if _, err := db.ExecContext(t.Context(), `INSERT INTO agents (id) VALUES ('a1')`); err != nil { - t.Fatalf("seed: %v", err) - } + seedGooseTable(t, db, syntheticEntry.Version) + mustExec(t, db, `CREATE TABLE fleet_groups (id TEXT PRIMARY KEY)`) + mustExec(t, db, `INSERT INTO fleet_groups (id) VALUES ('fg-1')`) - if err := CheckAll(context.Background(), db, DialectSQLite, slog.Default()); err != nil { - t.Fatalf("opt-in should pass, got %v", err) + if err := checkOne(context.Background(), db, DialectSQLite, syntheticEntry, slog.Default()); err != nil { + t.Fatalf("already-applied version must pass, got: %v", err) } } -func TestCheckAll_TableMissing_DoesNotPanic(t *testing.T) { +func TestCheckOneEmptyTablesPass(t *testing.T) { t.Setenv(EnvAllowDestructiveMigration, "") db := openSQLite(t) - // goose table exists but the data tables do not — earliest schema state. - if _, err := db.ExecContext(t.Context(), `CREATE TABLE goose_db_version ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - version_id BIGINT NOT NULL, - is_applied INTEGER NOT NULL, - tstamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP - )`); err != nil { - t.Fatal(err) - } - if err := CheckAll(context.Background(), db, DialectSQLite, slog.Default()); err != nil { - t.Fatalf("missing data tables should pass, got %v", err) + seedGooseTable(t, db, 1) + mustExec(t, db, `CREATE TABLE fleet_groups (id TEXT PRIMARY KEY)`) + + if err := checkOne(context.Background(), db, DialectSQLite, syntheticEntry, slog.Default()); err != nil { + t.Fatalf("empty destructive-target tables must pass, got: %v", err) } } -func TestCheckAll_BlankEnvDoesNotCountAsOptIn(t *testing.T) { - // Explicitly set to whitespace; should be treated as unset. - t.Setenv(EnvAllowDestructiveMigration, " ") +func TestCheckOneOptInAllows(t *testing.T) { + t.Setenv(EnvAllowDestructiveMigration, "1") db := openSQLite(t) - schema(t, db) - if _, err := db.ExecContext(t.Context(), `INSERT INTO clients (id) VALUES ('c1')`); err != nil { - t.Fatal(err) - } - if err := CheckAll(context.Background(), db, DialectSQLite, slog.Default()); !errors.Is(err, ErrBlocked) { - t.Fatalf("want ErrBlocked despite whitespace-only env, got %v", err) + seedGooseTable(t, db, 1) + mustExec(t, db, `CREATE TABLE fleet_groups (id TEXT PRIMARY KEY)`) + mustExec(t, db, `INSERT INTO fleet_groups (id) VALUES ('fg-1')`) + + if err := checkOne(context.Background(), db, DialectSQLite, syntheticEntry, slog.Default()); err != nil { + t.Fatalf("opt-in env must allow, got: %v", err) } } diff --git a/internal/controlplane/storage/postgres/migrate.go b/internal/controlplane/storage/postgres/migrate.go index 76e6f650..113607eb 100644 --- a/internal/controlplane/storage/postgres/migrate.go +++ b/internal/controlplane/storage/postgres/migrate.go @@ -39,8 +39,10 @@ func MigrateContext(ctx context.Context, db *sql.DB) error { return err } // Guard runs BEFORE goose.UpContext so a destructive migration - // (e.g., 0014_fleet_groups_redesign) cannot delete production rows - // without an explicit operator opt-in. See migrateguard package. + // cannot delete production rows without an explicit operator + // opt-in. The registry is empty since the P9 squash (no-op), but + // the wiring stays so a future destructive migration only needs a + // registry entry. See migrateguard package. if err := migrateguard.CheckAll(ctx, db, migrateguard.DialectPostgres, nil); err != nil { return fmt.Errorf("postgres: %w", err) } From ef911b29d8505d369a38619eda397f5f8a5ac82b Mon Sep 17 00:00:00 2001 From: Mirotin Artem Date: Tue, 7 Jul 2026 13:21:13 +0300 Subject: [PATCH 2/7] db/sqlite: squash migrations 0001..0058 into 0001_init, drop the baseline machinery --- db/migrations/sqlite/0001_init.sql | 755 +++++++++++++----- .../sqlite/0002_discovered_clients.sql | 23 - .../sqlite/0003_agent_cert_dates.sql | 15 - db/migrations/sqlite/0004_sessions.sql | 14 - .../sqlite/0005_agent_revocations.sql | 15 - .../0006_timeseries_and_update_config.sql | 85 -- db/migrations/sqlite/0007_indexes.sql | 31 - db/migrations/sqlite/0008_missing_indexes.sql | 11 - .../sqlite/0009_retention_settings.sql | 35 - ...0010_discovered_clients_pending_unique.sql | 13 - db/migrations/sqlite/0011_column_drift.sql | 21 - db/migrations/sqlite/0012_cascade_fk.sql | 147 ---- db/migrations/sqlite/0013_login_lockout.sql | 19 - .../sqlite/0014_fleet_groups_redesign.sql | 65 -- db/migrations/sqlite/0015_client_usage.sql | 24 - .../sqlite/0016_sessions_last_seen.sql | 18 - db/migrations/sqlite/0017_consumed_totp.sql | 12 - db/migrations/sqlite/0018_cp_secrets.sql | 9 - .../sqlite/0019_jobs_actor_id_index.sql | 5 - .../sqlite/0020_agents_cert_serial.sql | 10 - .../sqlite/0021_enrollment_token_hash.sql | 20 - .../0022_cascade_fk_telemt_instances.sql | 9 - .../sqlite/0023_check_constraints.sql | 11 - .../sqlite/0024_unify_real_to_double.sql | 8 - .../sqlite/0025_user_fleet_group_scopes.sql | 19 - .../sqlite/0026_enum_check_constraints.sql | 128 --- .../0027_fix_job_targets_check_enum.sql | 9 - ...cascade_fk_grants_discovered_instances.sql | 124 --- .../sqlite/0029_connection_links_array.sql | 37 - .../sqlite/0030_node_transport_mode.sql | 27 - .../sqlite/0031_agent_fallback_state.sql | 13 - db/migrations/sqlite/0032_password_policy.sql | 10 - db/migrations/sqlite/0033_agent_cert_pin.sql | 15 - db/migrations/sqlite/0034_geoip_settings.sql | 7 - .../sqlite/0035_telemt_unreachable.sql | 9 - ..._bootstrap_columns_from_panel_settings.sql | 30 - .../sqlite/0037_runtime_settings.sql | 12 - .../sqlite/0038_audit_hash_chain.sql | 22 - db/migrations/sqlite/0039_webhook_outbox.sql | 37 - .../0040_invert_telemt_reachability.sql | 16 - .../sqlite/0041_enrollment_attempts.sql | 38 - .../0042_client_deployment_last_reset.sql | 12 - .../sqlite/0043_client_usage_quota.sql | 13 - .../0044_drop_enrollment_token_value_hash.sql | 24 - ...0045_client_deployment_link_diagnostic.sql | 11 - ..._rename_connected_users_to_connections.sql | 8 - .../sqlite/0047_drop_telemt_detail_boosts.sql | 14 - .../sqlite/0048_jobs_status_partial.sql | 52 -- .../sqlite/0049_agent_config_targets.sql | 18 - db/migrations/sqlite/0050_check_parity.sql | 160 ---- .../sqlite/0051_client_subscription_token.sql | 10 - .../sqlite/0052_json_valid_checks.sql | 346 -------- .../sqlite/0053_config_apply_batches.sql | 37 - ...0054_config_apply_batch_target_message.sql | 13 - .../sqlite/0055_runtime_current_json.sql | 18 - ...0056_config_apply_batch_group_nullable.sql | 31 - .../sqlite/0057_client_usage_watermark.sql | 16 - .../0058_client_usage_drop_last_seq.sql | 8 - db/migrations/sqlite/baseline/baseline_v1.sql | 701 ---------------- db/migrations/sqlite/baseline/embed.go | 21 - .../migrate/baseline_equivalence_test.go | 117 --- .../storage/migrate/baseline_regen_test.go | 212 ----- .../controlplane/storage/sqlite/migrate.go | 93 +-- .../storage/sqlite/migrate_stress_test.go | 380 --------- .../storage/sqlite/migrate_test.go | 30 +- 65 files changed, 571 insertions(+), 3702 deletions(-) delete mode 100644 db/migrations/sqlite/0002_discovered_clients.sql delete mode 100644 db/migrations/sqlite/0003_agent_cert_dates.sql delete mode 100644 db/migrations/sqlite/0004_sessions.sql delete mode 100644 db/migrations/sqlite/0005_agent_revocations.sql delete mode 100644 db/migrations/sqlite/0006_timeseries_and_update_config.sql delete mode 100644 db/migrations/sqlite/0007_indexes.sql delete mode 100644 db/migrations/sqlite/0008_missing_indexes.sql delete mode 100644 db/migrations/sqlite/0009_retention_settings.sql delete mode 100644 db/migrations/sqlite/0010_discovered_clients_pending_unique.sql delete mode 100644 db/migrations/sqlite/0011_column_drift.sql delete mode 100644 db/migrations/sqlite/0012_cascade_fk.sql delete mode 100644 db/migrations/sqlite/0013_login_lockout.sql delete mode 100644 db/migrations/sqlite/0014_fleet_groups_redesign.sql delete mode 100644 db/migrations/sqlite/0015_client_usage.sql delete mode 100644 db/migrations/sqlite/0016_sessions_last_seen.sql delete mode 100644 db/migrations/sqlite/0017_consumed_totp.sql delete mode 100644 db/migrations/sqlite/0018_cp_secrets.sql delete mode 100644 db/migrations/sqlite/0019_jobs_actor_id_index.sql delete mode 100644 db/migrations/sqlite/0020_agents_cert_serial.sql delete mode 100644 db/migrations/sqlite/0021_enrollment_token_hash.sql delete mode 100644 db/migrations/sqlite/0022_cascade_fk_telemt_instances.sql delete mode 100644 db/migrations/sqlite/0023_check_constraints.sql delete mode 100644 db/migrations/sqlite/0024_unify_real_to_double.sql delete mode 100644 db/migrations/sqlite/0025_user_fleet_group_scopes.sql delete mode 100644 db/migrations/sqlite/0026_enum_check_constraints.sql delete mode 100644 db/migrations/sqlite/0027_fix_job_targets_check_enum.sql delete mode 100644 db/migrations/sqlite/0028_cascade_fk_grants_discovered_instances.sql delete mode 100644 db/migrations/sqlite/0029_connection_links_array.sql delete mode 100644 db/migrations/sqlite/0030_node_transport_mode.sql delete mode 100644 db/migrations/sqlite/0031_agent_fallback_state.sql delete mode 100644 db/migrations/sqlite/0032_password_policy.sql delete mode 100644 db/migrations/sqlite/0033_agent_cert_pin.sql delete mode 100644 db/migrations/sqlite/0034_geoip_settings.sql delete mode 100644 db/migrations/sqlite/0035_telemt_unreachable.sql delete mode 100644 db/migrations/sqlite/0036_drop_bootstrap_columns_from_panel_settings.sql delete mode 100644 db/migrations/sqlite/0037_runtime_settings.sql delete mode 100644 db/migrations/sqlite/0038_audit_hash_chain.sql delete mode 100644 db/migrations/sqlite/0039_webhook_outbox.sql delete mode 100644 db/migrations/sqlite/0040_invert_telemt_reachability.sql delete mode 100644 db/migrations/sqlite/0041_enrollment_attempts.sql delete mode 100644 db/migrations/sqlite/0042_client_deployment_last_reset.sql delete mode 100644 db/migrations/sqlite/0043_client_usage_quota.sql delete mode 100644 db/migrations/sqlite/0044_drop_enrollment_token_value_hash.sql delete mode 100644 db/migrations/sqlite/0045_client_deployment_link_diagnostic.sql delete mode 100644 db/migrations/sqlite/0046_rename_connected_users_to_connections.sql delete mode 100644 db/migrations/sqlite/0047_drop_telemt_detail_boosts.sql delete mode 100644 db/migrations/sqlite/0048_jobs_status_partial.sql delete mode 100644 db/migrations/sqlite/0049_agent_config_targets.sql delete mode 100644 db/migrations/sqlite/0050_check_parity.sql delete mode 100644 db/migrations/sqlite/0051_client_subscription_token.sql delete mode 100644 db/migrations/sqlite/0052_json_valid_checks.sql delete mode 100644 db/migrations/sqlite/0053_config_apply_batches.sql delete mode 100644 db/migrations/sqlite/0054_config_apply_batch_target_message.sql delete mode 100644 db/migrations/sqlite/0055_runtime_current_json.sql delete mode 100644 db/migrations/sqlite/0056_config_apply_batch_group_nullable.sql delete mode 100644 db/migrations/sqlite/0057_client_usage_watermark.sql delete mode 100644 db/migrations/sqlite/0058_client_usage_drop_last_seq.sql delete mode 100644 db/migrations/sqlite/baseline/baseline_v1.sql delete mode 100644 db/migrations/sqlite/baseline/embed.go delete mode 100644 internal/controlplane/storage/migrate/baseline_equivalence_test.go delete mode 100644 internal/controlplane/storage/migrate/baseline_regen_test.go delete mode 100644 internal/controlplane/storage/sqlite/migrate_stress_test.go diff --git a/db/migrations/sqlite/0001_init.sql b/db/migrations/sqlite/0001_init.sql index 5dc3d6b2..c39b47ba 100644 --- a/db/migrations/sqlite/0001_init.sql +++ b/db/migrations/sqlite/0001_init.sql @@ -1,30 +1,42 @@ -- +goose Up -CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL UNIQUE, - password_hash TEXT NOT NULL, - role TEXT NOT NULL, - totp_enabled INTEGER NOT NULL DEFAULT 0, - totp_secret TEXT NOT NULL DEFAULT '', - created_at_unix INTEGER NOT NULL +-- P9 squash (2026-07): консолидация миграций 0001..0058 в один init. +-- Файл сгенерирован дампом sqlite_master свежей БД, смигрированной +-- полным историческим деревом (tools/squashtool, задача P9-3). +-- НЕ редактировать вручную задним числом: изменения схемы = новая +-- миграция с номером >= 0059 (см. db/migrations/README.md). + +CREATE TABLE "agent_certificate_recovery_grants" ( + agent_id TEXT PRIMARY KEY, + issued_by TEXT NOT NULL, + issued_at_unix INTEGER NOT NULL, + expires_at_unix INTEGER NOT NULL, + used_at_unix INTEGER, + revoked_at_unix INTEGER, + FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE ); -CREATE TABLE IF NOT EXISTS user_appearance ( - user_id TEXT PRIMARY KEY, - theme TEXT NOT NULL DEFAULT 'system', - density TEXT NOT NULL DEFAULT 'comfortable', - help_mode TEXT NOT NULL DEFAULT 'basic', - updated_at_unix INTEGER NOT NULL DEFAULT 0, - FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +CREATE TABLE agent_config_targets ( + scope_type TEXT NOT NULL, + scope_id TEXT NOT NULL, + sections_json TEXT NOT NULL DEFAULT '{}', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + PRIMARY KEY (scope_type, scope_id) ); -CREATE TABLE IF NOT EXISTS fleet_groups ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - created_at_unix INTEGER NOT NULL +CREATE TABLE agent_fallback_state ( + agent_id TEXT PRIMARY KEY, + entered_at_unix INTEGER NOT NULL, + FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE +); + +CREATE TABLE agent_revocations ( + agent_id TEXT PRIMARY KEY, + revoked_at_unix INTEGER NOT NULL, + cert_expires_at_unix INTEGER NOT NULL ); -CREATE TABLE IF NOT EXISTS agents ( +CREATE TABLE "agents" ( id TEXT PRIMARY KEY, node_name TEXT NOT NULL, fleet_group_id TEXT, @@ -32,55 +44,322 @@ CREATE TABLE IF NOT EXISTS agents ( read_only INTEGER NOT NULL DEFAULT 0, last_seen_at_unix INTEGER NOT NULL, created_at_unix INTEGER NOT NULL DEFAULT 0, + cert_issued_at_unix INTEGER, + cert_expires_at_unix INTEGER, + cert_serial TEXT NOT NULL DEFAULT '', + transport_mode TEXT NOT NULL DEFAULT 'inbound' + CHECK (transport_mode IN ('inbound', 'outbound')), + dial_address TEXT, + bootstrap_state TEXT NOT NULL DEFAULT 'active' + CHECK (bootstrap_state IN ('pending', 'active', 'expired', 'revoked')), + bootstrap_token_hash BLOB, + bootstrap_expires_at INTEGER, + cert_spki_sha256 BLOB NOT NULL DEFAULT x'' + CHECK (length(cert_spki_sha256) IN (0, 32)), FOREIGN KEY (fleet_group_id) REFERENCES fleet_groups (id) ); -CREATE TABLE IF NOT EXISTS telemt_instances ( +CREATE TABLE "audit_events" ( id TEXT PRIMARY KEY, + actor_id TEXT NOT NULL, + action TEXT NOT NULL, + target_id TEXT NOT NULL, + created_at_unix INTEGER NOT NULL, + details TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(details)), + prev_hash TEXT NOT NULL DEFAULT '', + event_hash TEXT NOT NULL DEFAULT '' +); + +CREATE TABLE certificate_authority ( + scope TEXT PRIMARY KEY, + ca_pem TEXT NOT NULL, + private_key_pem TEXT NOT NULL, + updated_at_unix INTEGER NOT NULL +); + +CREATE TABLE "client_assignments" ( + id TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + target_type TEXT NOT NULL, + fleet_group_id TEXT, + agent_id TEXT, + created_at_unix INTEGER NOT NULL, + FOREIGN KEY (client_id) REFERENCES clients (id) ON DELETE CASCADE, + FOREIGN KEY (fleet_group_id) REFERENCES fleet_groups (id) ON DELETE SET NULL, + FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE SET NULL +); + +CREATE TABLE "client_deployments" ( + client_id TEXT NOT NULL, agent_id TEXT NOT NULL, + desired_operation TEXT NOT NULL, + status TEXT NOT NULL, + last_error TEXT NOT NULL DEFAULT '', + last_applied_at_unix INTEGER, + updated_at_unix INTEGER NOT NULL, + connection_links TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(connection_links)), + last_reset_epoch_secs INTEGER NOT NULL DEFAULT 0, + link_diagnostic TEXT NOT NULL DEFAULT '', + PRIMARY KEY (client_id, agent_id), + FOREIGN KEY (client_id) REFERENCES clients (id) ON DELETE CASCADE, + FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE +); + +CREATE TABLE client_ip_history ( + agent_id TEXT NOT NULL, + client_id TEXT NOT NULL, + ip_address TEXT NOT NULL, + first_seen_unix INTEGER NOT NULL, + last_seen_unix INTEGER NOT NULL, + PRIMARY KEY (agent_id, client_id, ip_address) +); + +CREATE TABLE client_usage ( + client_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + traffic_used_bytes INTEGER NOT NULL DEFAULT 0, + unique_ips_used INTEGER NOT NULL DEFAULT 0, + active_tcp_conns INTEGER NOT NULL DEFAULT 0, + active_unique_ips INTEGER NOT NULL DEFAULT 0, + observed_at_unix INTEGER NOT NULL, quota_used_bytes INTEGER NOT NULL DEFAULT 0, quota_last_reset_unix INTEGER NOT NULL DEFAULT 0, agent_boot_id TEXT NOT NULL DEFAULT '', last_total_bytes INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (client_id, agent_id), + FOREIGN KEY (client_id) REFERENCES clients (id) ON DELETE CASCADE, + FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE +); + +CREATE TABLE clients ( + id TEXT PRIMARY KEY, name TEXT NOT NULL, - version TEXT NOT NULL DEFAULT '', - config_fingerprint TEXT NOT NULL DEFAULT '', - connected_users INTEGER NOT NULL DEFAULT 0, - read_only INTEGER NOT NULL DEFAULT 0, + secret_ciphertext TEXT NOT NULL, + user_ad_tag TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + max_tcp_conns INTEGER NOT NULL DEFAULT 0, + max_unique_ips INTEGER NOT NULL DEFAULT 0, + data_quota_bytes INTEGER NOT NULL DEFAULT 0, + expiration_rfc3339 TEXT NOT NULL DEFAULT '', + created_at_unix INTEGER NOT NULL, + updated_at_unix INTEGER NOT NULL, + deleted_at_unix INTEGER +, subscription_token TEXT); + +CREATE TABLE config_apply_batch_targets ( + batch_id TEXT NOT NULL REFERENCES config_apply_batches (id) ON DELETE CASCADE, + agent_id TEXT NOT NULL, + wave_index INTEGER NOT NULL, + job_id TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL CHECK (status IN ('pending', 'running', 'succeeded', 'failed', 'skipped')), message TEXT NOT NULL DEFAULT '', + PRIMARY KEY (batch_id, agent_id) +); + +CREATE TABLE "config_apply_batches" ( + id TEXT PRIMARY KEY, + fleet_group_id TEXT REFERENCES fleet_groups (id) ON DELETE CASCADE, + mode TEXT NOT NULL CHECK (mode IN ('all_at_once', 'rolling')), + wave_size INTEGER NOT NULL DEFAULT 1, + expected_revision TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL CHECK (status IN ('running', 'succeeded', 'failed', 'halted')), + created_at_unix INTEGER NOT NULL, + updated_at_unix INTEGER NOT NULL +); + +CREATE TABLE consumed_totp ( + user_id TEXT NOT NULL, + code TEXT NOT NULL, + used_at_unix INTEGER NOT NULL, + PRIMARY KEY (user_id, code) +); + +CREATE TABLE cp_secrets ( + key TEXT PRIMARY KEY, + value BLOB NOT NULL, + updated_at_unix INTEGER NOT NULL +); + +CREATE TABLE "discovered_clients" ( + id TEXT PRIMARY KEY, + agent_id TEXT NOT NULL, + client_name TEXT NOT NULL, + secret TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending_review' CHECK (status IN ('pending_review','adopted','ignored')), + total_octets INTEGER NOT NULL DEFAULT 0, + current_connections INTEGER NOT NULL DEFAULT 0, + active_unique_ips INTEGER NOT NULL DEFAULT 0, + max_tcp_conns INTEGER NOT NULL DEFAULT 0, + max_unique_ips INTEGER NOT NULL DEFAULT 0, + data_quota_bytes INTEGER NOT NULL DEFAULT 0, + expiration TEXT NOT NULL DEFAULT '', + discovered_at_unix INTEGER NOT NULL, + updated_at_unix INTEGER NOT NULL, + connection_links TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(connection_links)), + UNIQUE (agent_id, client_name), + FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE +); + +CREATE TABLE enrollment_attempts ( + id TEXT PRIMARY KEY, + token_id TEXT, + agent_id TEXT, + mode TEXT NOT NULL CHECK (mode IN ('inbound', 'outbound')), + client_addr TEXT, + request_id TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('in_progress', 'success', 'failed')), + error_code TEXT, + error_message TEXT, + started_at TIMESTAMP NOT NULL, + finished_at TIMESTAMP +); + +CREATE TABLE "enrollment_events" ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + attempt_id TEXT NOT NULL REFERENCES enrollment_attempts (id) ON DELETE CASCADE, + ts TIMESTAMP NOT NULL, + step TEXT NOT NULL, + level TEXT NOT NULL CHECK (level IN ('info', 'warn', 'error')), + message TEXT, + fields_json TEXT CHECK (fields_json IS NULL OR json_valid(fields_json)) +); + +CREATE TABLE "enrollment_tokens" ( + value TEXT PRIMARY KEY, + fleet_group_id TEXT, + issued_at_unix INTEGER NOT NULL, + expires_at_unix INTEGER NOT NULL, + consumed_at_unix INTEGER, + revoked_at_unix INTEGER, + FOREIGN KEY (fleet_group_id) REFERENCES fleet_groups (id) ON DELETE SET NULL +); + +CREATE TABLE "fleet_group_integrations" ( + id TEXT PRIMARY KEY, + fleet_group_id TEXT NOT NULL, + kind TEXT NOT NULL, + provider_id TEXT, + config TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(config)), + enabled INTEGER NOT NULL DEFAULT 0, + created_at_unix INTEGER NOT NULL, updated_at_unix INTEGER NOT NULL, - FOREIGN KEY (agent_id) REFERENCES agents (id) + FOREIGN KEY (fleet_group_id) REFERENCES fleet_groups (id) ON DELETE CASCADE, + FOREIGN KEY (provider_id) REFERENCES integration_providers (id) ON DELETE SET NULL, + UNIQUE (fleet_group_id, kind) +); + +CREATE TABLE fleet_groups ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + created_at_unix INTEGER NOT NULL +, label TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '', updated_at_unix INTEGER NOT NULL DEFAULT 0); + +CREATE TABLE "integration_providers" ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + -- Permissive: config is either plain JSON or a vault-sealed + -- "PVS1:"/"PVS2:"/"PVS3:" ciphertext string (see file header note). + config TEXT NOT NULL DEFAULT '{}' + CHECK (json_valid(config) OR config LIKE 'PVS_:%'), + created_at_unix INTEGER NOT NULL, + updated_at_unix INTEGER NOT NULL +); + +CREATE TABLE "job_targets" ( + job_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('queued','sent','acknowledged','succeeded','failed','expired')), + result_text TEXT NOT NULL DEFAULT '', + result_json TEXT NOT NULL DEFAULT '', + updated_at_unix INTEGER NOT NULL, + PRIMARY KEY (job_id, agent_id), + FOREIGN KEY (job_id) REFERENCES jobs (id) +); + +CREATE TABLE "jobs" ( + id TEXT PRIMARY KEY, + action TEXT NOT NULL, + actor_id TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('queued','running','succeeded','failed','expired','partial')), + created_at_unix INTEGER NOT NULL, + ttl_nanos INTEGER NOT NULL, + idempotency_key TEXT NOT NULL UNIQUE, + payload_json TEXT NOT NULL DEFAULT '' + CHECK (payload_json = '' OR json_valid(payload_json)) +); + +CREATE TABLE login_lockouts ( + username TEXT PRIMARY KEY, + failures INTEGER NOT NULL DEFAULT 0, + locked_at_unix INTEGER, + updated_at_unix INTEGER NOT NULL +); + +CREATE TABLE "metric_snapshots" ( + id TEXT PRIMARY KEY, + agent_id TEXT NOT NULL, + instance_id TEXT NOT NULL DEFAULT '', + captured_at_unix INTEGER NOT NULL, + "values" TEXT NOT NULL CHECK (json_valid("values")), + FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE +); + +CREATE TABLE "panel_settings" ( + scope TEXT PRIMARY KEY, + http_public_url TEXT NOT NULL DEFAULT '', + grpc_public_endpoint TEXT NOT NULL DEFAULT '', + password_min_length INTEGER NOT NULL DEFAULT 10 + CHECK (password_min_length >= 8 AND password_min_length <= 128), + retention_json TEXT NOT NULL DEFAULT '', + geoip_json TEXT NOT NULL DEFAULT '', + geoip_state_json TEXT NOT NULL DEFAULT '', + updated_at_unix INTEGER NOT NULL ); -CREATE TABLE IF NOT EXISTS telemt_runtime_current ( +CREATE TABLE runtime_settings ( + name TEXT PRIMARY KEY, + value_json TEXT NOT NULL, + updated_at INTEGER NOT NULL, + updated_by TEXT NOT NULL DEFAULT '' +); + +CREATE TABLE "sessions" ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + created_at_unix INTEGER NOT NULL, last_seen_at_unix INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); + +CREATE TABLE telemt_diagnostics_current ( agent_id TEXT PRIMARY KEY, observed_at_unix INTEGER NOT NULL, state TEXT NOT NULL DEFAULT '', state_reason TEXT NOT NULL DEFAULT '', + system_info_json TEXT NOT NULL DEFAULT '{}', + effective_limits_json TEXT NOT NULL DEFAULT '{}', + security_posture_json TEXT NOT NULL DEFAULT '{}', + minimal_all_json TEXT NOT NULL DEFAULT '{}', + me_pool_json TEXT NOT NULL DEFAULT '{}', + dcs_json TEXT NOT NULL DEFAULT '{}', + FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE +); + +CREATE TABLE "telemt_instances" ( + id TEXT PRIMARY KEY, + agent_id TEXT NOT NULL, + name TEXT NOT NULL, + version TEXT NOT NULL DEFAULT '', + config_fingerprint TEXT NOT NULL DEFAULT '', + connections INTEGER NOT NULL DEFAULT 0, read_only INTEGER NOT NULL DEFAULT 0, - accepting_new_connections INTEGER NOT NULL DEFAULT 0, - me_runtime_ready INTEGER NOT NULL DEFAULT 0, - me2dc_fallback_enabled INTEGER NOT NULL DEFAULT 0, - use_middle_proxy INTEGER NOT NULL DEFAULT 0, - startup_status TEXT NOT NULL DEFAULT '', - startup_stage TEXT NOT NULL DEFAULT '', - startup_progress_pct REAL NOT NULL DEFAULT 0, - initialization_status TEXT NOT NULL DEFAULT '', - degraded INTEGER NOT NULL DEFAULT 0, - initialization_stage TEXT NOT NULL DEFAULT '', - initialization_progress_pct REAL NOT NULL DEFAULT 0, - transport_mode TEXT NOT NULL DEFAULT '', - current_connections INTEGER NOT NULL DEFAULT 0, - current_connections_me INTEGER NOT NULL DEFAULT 0, - current_connections_direct INTEGER NOT NULL DEFAULT 0, - active_users INTEGER NOT NULL DEFAULT 0, - uptime_seconds REAL NOT NULL DEFAULT 0, - connections_total INTEGER NOT NULL DEFAULT 0, - connections_bad_total INTEGER NOT NULL DEFAULT 0, - handshake_timeouts_total INTEGER NOT NULL DEFAULT 0, - configured_users INTEGER NOT NULL DEFAULT 0, - dc_coverage_pct REAL NOT NULL DEFAULT 0, - healthy_upstreams INTEGER NOT NULL DEFAULT 0, - total_upstreams INTEGER NOT NULL DEFAULT 0, + updated_at_unix INTEGER NOT NULL, FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE ); -CREATE TABLE IF NOT EXISTS telemt_runtime_dcs_current ( +CREATE TABLE telemt_runtime_current ( + agent_id TEXT PRIMARY KEY, + observed_at_unix INTEGER NOT NULL, + runtime_json TEXT NOT NULL DEFAULT '', + FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE +); + +CREATE TABLE telemt_runtime_dcs_current ( agent_id TEXT NOT NULL, dc INTEGER NOT NULL, observed_at_unix INTEGER NOT NULL, @@ -95,23 +374,7 @@ CREATE TABLE IF NOT EXISTS telemt_runtime_dcs_current ( FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE ); -CREATE TABLE IF NOT EXISTS telemt_runtime_upstreams_current ( - agent_id TEXT NOT NULL, - upstream_id INTEGER NOT NULL, - observed_at_unix INTEGER NOT NULL, - route_kind TEXT NOT NULL DEFAULT '', - address TEXT NOT NULL DEFAULT '', - healthy INTEGER NOT NULL DEFAULT 0, - fails INTEGER NOT NULL DEFAULT 0, - effective_latency_ms REAL NOT NULL DEFAULT 0, - PRIMARY KEY (agent_id, upstream_id), - FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE -); - --- Column name differs between drivers: SQLite uses timestamp_unix (INTEGER) --- while Postgres uses timestamp_at (TIMESTAMPTZ). Query implementations in --- each driver's telemetry.go must use the correct column name. -CREATE TABLE IF NOT EXISTS telemt_runtime_events ( +CREATE TABLE telemt_runtime_events ( agent_id TEXT NOT NULL, sequence INTEGER NOT NULL, observed_at_unix INTEGER NOT NULL, @@ -123,21 +386,20 @@ CREATE TABLE IF NOT EXISTS telemt_runtime_events ( FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE ); -CREATE TABLE IF NOT EXISTS telemt_diagnostics_current ( - agent_id TEXT PRIMARY KEY, +CREATE TABLE telemt_runtime_upstreams_current ( + agent_id TEXT NOT NULL, + upstream_id INTEGER NOT NULL, observed_at_unix INTEGER NOT NULL, - state TEXT NOT NULL DEFAULT '', - state_reason TEXT NOT NULL DEFAULT '', - system_info_json TEXT NOT NULL DEFAULT '{}', - effective_limits_json TEXT NOT NULL DEFAULT '{}', - security_posture_json TEXT NOT NULL DEFAULT '{}', - minimal_all_json TEXT NOT NULL DEFAULT '{}', - me_pool_json TEXT NOT NULL DEFAULT '{}', - dcs_json TEXT NOT NULL DEFAULT '{}', + route_kind TEXT NOT NULL DEFAULT '', + address TEXT NOT NULL DEFAULT '', + healthy INTEGER NOT NULL DEFAULT 0, + fails INTEGER NOT NULL DEFAULT 0, + effective_latency_ms REAL NOT NULL DEFAULT 0, + PRIMARY KEY (agent_id, upstream_id), FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE ); -CREATE TABLE IF NOT EXISTS telemt_security_inventory_current ( +CREATE TABLE telemt_security_inventory_current ( agent_id TEXT PRIMARY KEY, observed_at_unix INTEGER NOT NULL, state TEXT NOT NULL DEFAULT '', @@ -148,153 +410,230 @@ CREATE TABLE IF NOT EXISTS telemt_security_inventory_current ( FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE ); -CREATE TABLE IF NOT EXISTS telemt_detail_boosts ( - agent_id TEXT PRIMARY KEY, - expires_at_unix INTEGER NOT NULL, - updated_at_unix INTEGER NOT NULL, - FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE -); - -CREATE TABLE IF NOT EXISTS jobs ( - id TEXT PRIMARY KEY, - action TEXT NOT NULL, - actor_id TEXT NOT NULL, - status TEXT NOT NULL, - created_at_unix INTEGER NOT NULL, - ttl_nanos INTEGER NOT NULL, - idempotency_key TEXT NOT NULL UNIQUE, - payload_json TEXT NOT NULL DEFAULT '' +CREATE TABLE ts_dc_health ( + agent_id TEXT NOT NULL, + captured_at_unix INTEGER NOT NULL, + dc INTEGER NOT NULL, + coverage_pct_avg REAL NOT NULL DEFAULT 0, + coverage_pct_min REAL NOT NULL DEFAULT 0, + rtt_ms_avg REAL NOT NULL DEFAULT 0, + rtt_ms_max REAL NOT NULL DEFAULT 0, + alive_writers_min INTEGER NOT NULL DEFAULT 0, + required_writers INTEGER NOT NULL DEFAULT 0, + load_max INTEGER NOT NULL DEFAULT 0, + sample_count INTEGER NOT NULL DEFAULT 1, + PRIMARY KEY (agent_id, dc, captured_at_unix) ); -CREATE TABLE IF NOT EXISTS job_targets ( - job_id TEXT NOT NULL, - agent_id TEXT NOT NULL, - status TEXT NOT NULL, - result_text TEXT NOT NULL DEFAULT '', - result_json TEXT NOT NULL DEFAULT '', - updated_at_unix INTEGER NOT NULL, - PRIMARY KEY (job_id, agent_id), - FOREIGN KEY (job_id) REFERENCES jobs (id) +CREATE TABLE ts_server_load ( + agent_id TEXT NOT NULL, + captured_at_unix INTEGER NOT NULL, + cpu_pct_avg REAL NOT NULL DEFAULT 0, + cpu_pct_max REAL NOT NULL DEFAULT 0, + mem_pct_avg REAL NOT NULL DEFAULT 0, + mem_pct_max REAL NOT NULL DEFAULT 0, + disk_pct_avg REAL NOT NULL DEFAULT 0, + disk_pct_max REAL NOT NULL DEFAULT 0, + load_1m REAL NOT NULL DEFAULT 0, + load_5m REAL NOT NULL DEFAULT 0, + load_15m REAL NOT NULL DEFAULT 0, + connections_avg INTEGER NOT NULL DEFAULT 0, + connections_max INTEGER NOT NULL DEFAULT 0, + connections_me_avg INTEGER NOT NULL DEFAULT 0, + connections_direct_avg INTEGER NOT NULL DEFAULT 0, + active_users_avg INTEGER NOT NULL DEFAULT 0, + active_users_max INTEGER NOT NULL DEFAULT 0, + connections_total INTEGER NOT NULL DEFAULT 0, + connections_bad_total INTEGER NOT NULL DEFAULT 0, + handshake_timeouts_total INTEGER NOT NULL DEFAULT 0, + dc_coverage_min_pct REAL NOT NULL DEFAULT 0, + dc_coverage_avg_pct REAL NOT NULL DEFAULT 0, + healthy_upstreams INTEGER NOT NULL DEFAULT 0, + total_upstreams INTEGER NOT NULL DEFAULT 0, + net_bytes_sent INTEGER NOT NULL DEFAULT 0, + net_bytes_recv INTEGER NOT NULL DEFAULT 0, + sample_count INTEGER NOT NULL DEFAULT 1, + PRIMARY KEY (agent_id, captured_at_unix) ); -CREATE TABLE IF NOT EXISTS audit_events ( - id TEXT PRIMARY KEY, - actor_id TEXT NOT NULL, - action TEXT NOT NULL, - target_id TEXT NOT NULL, - created_at_unix INTEGER NOT NULL, - details_json TEXT NOT NULL DEFAULT '{}' +CREATE TABLE ts_server_load_hourly ( + agent_id TEXT NOT NULL, + bucket_hour_unix INTEGER NOT NULL, + cpu_pct_avg REAL, + cpu_pct_max REAL, + mem_pct_avg REAL, + mem_pct_max REAL, + connections_avg REAL, + connections_max INTEGER, + active_users_avg REAL, + active_users_max INTEGER, + dc_coverage_min REAL, + dc_coverage_avg REAL, + sample_count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (agent_id, bucket_hour_unix) ); -CREATE TABLE IF NOT EXISTS metric_snapshots ( - id TEXT PRIMARY KEY, - agent_id TEXT NOT NULL, - instance_id TEXT NOT NULL DEFAULT '', - captured_at_unix INTEGER NOT NULL, - values_json TEXT NOT NULL +CREATE TABLE update_config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL ); -CREATE TABLE IF NOT EXISTS enrollment_tokens ( - value TEXT PRIMARY KEY, - fleet_group_id TEXT, - issued_at_unix INTEGER NOT NULL, - expires_at_unix INTEGER NOT NULL, - consumed_at_unix INTEGER, - revoked_at_unix INTEGER +CREATE TABLE user_appearance ( + user_id TEXT PRIMARY KEY, + theme TEXT NOT NULL DEFAULT 'system', + density TEXT NOT NULL DEFAULT 'comfortable', + help_mode TEXT NOT NULL DEFAULT 'basic', + updated_at_unix INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); -CREATE TABLE IF NOT EXISTS agent_certificate_recovery_grants ( - agent_id TEXT PRIMARY KEY, - issued_by TEXT NOT NULL, - issued_at_unix INTEGER NOT NULL, - expires_at_unix INTEGER NOT NULL, - used_at_unix INTEGER, - revoked_at_unix INTEGER, - FOREIGN KEY (agent_id) REFERENCES agents (id) +CREATE TABLE user_fleet_group_scopes ( + user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + fleet_group_id TEXT NOT NULL REFERENCES fleet_groups (id) ON DELETE CASCADE, + granted_at_unix INTEGER NOT NULL DEFAULT 0, + granted_by TEXT NOT NULL DEFAULT '', + PRIMARY KEY (user_id, fleet_group_id) ); -CREATE TABLE IF NOT EXISTS clients ( +CREATE TABLE users ( id TEXT PRIMARY KEY, - name TEXT NOT NULL, - secret_ciphertext TEXT NOT NULL, - user_ad_tag TEXT NOT NULL, - enabled INTEGER NOT NULL DEFAULT 1, - max_tcp_conns INTEGER NOT NULL DEFAULT 0, - max_unique_ips INTEGER NOT NULL DEFAULT 0, - data_quota_bytes INTEGER NOT NULL DEFAULT 0, - expiration_rfc3339 TEXT NOT NULL DEFAULT '', - created_at_unix INTEGER NOT NULL, - updated_at_unix INTEGER NOT NULL, - deleted_at_unix INTEGER + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL, + totp_enabled INTEGER NOT NULL DEFAULT 0, + totp_secret TEXT NOT NULL DEFAULT '', + created_at_unix INTEGER NOT NULL ); -CREATE TABLE IF NOT EXISTS client_assignments ( - id TEXT PRIMARY KEY, - client_id TEXT NOT NULL, - target_type TEXT NOT NULL, - fleet_group_id TEXT, - agent_id TEXT, - created_at_unix INTEGER NOT NULL, - FOREIGN KEY (client_id) REFERENCES clients (id), - FOREIGN KEY (fleet_group_id) REFERENCES fleet_groups (id), - FOREIGN KEY (agent_id) REFERENCES agents (id) +CREATE TABLE webhook_endpoints ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + url TEXT NOT NULL, + secret_ciphertext TEXT NOT NULL, + event_filter TEXT NOT NULL DEFAULT '', + allow_private INTEGER NOT NULL DEFAULT 0 CHECK (allow_private IN (0, 1)), + enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); -CREATE TABLE IF NOT EXISTS client_deployments ( - client_id TEXT NOT NULL, - agent_id TEXT NOT NULL, - desired_operation TEXT NOT NULL, - status TEXT NOT NULL, - last_error TEXT NOT NULL DEFAULT '', - connection_link TEXT NOT NULL DEFAULT '', - last_applied_at_unix INTEGER, - updated_at_unix INTEGER NOT NULL, - PRIMARY KEY (client_id, agent_id), - FOREIGN KEY (client_id) REFERENCES clients (id), - FOREIGN KEY (agent_id) REFERENCES agents (id) +CREATE TABLE "webhook_outbox" ( + id TEXT PRIMARY KEY, + endpoint_id TEXT NOT NULL REFERENCES webhook_endpoints (id) ON DELETE CASCADE, + event_action TEXT NOT NULL, + payload TEXT NOT NULL CHECK (json_valid(payload)), + attempt INTEGER NOT NULL DEFAULT 0, + next_attempt_at TIMESTAMP NOT NULL, + last_error TEXT NOT NULL DEFAULT '', + dead INTEGER NOT NULL DEFAULT 0 CHECK (dead IN (0, 1)), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + delivered_at TIMESTAMP ); -CREATE TABLE IF NOT EXISTS panel_settings ( - scope TEXT PRIMARY KEY, - http_public_url TEXT NOT NULL DEFAULT '', - http_root_path TEXT NOT NULL DEFAULT '', - grpc_public_endpoint TEXT NOT NULL DEFAULT '', - http_listen_address TEXT NOT NULL DEFAULT '', - grpc_listen_address TEXT NOT NULL DEFAULT '', - tls_mode TEXT NOT NULL DEFAULT '', - tls_cert_file TEXT NOT NULL DEFAULT '', - tls_key_file TEXT NOT NULL DEFAULT '', - updated_at_unix INTEGER NOT NULL -); +CREATE UNIQUE INDEX clients_subscription_token_key + ON clients (subscription_token); -CREATE TABLE IF NOT EXISTS certificate_authority ( - scope TEXT PRIMARY KEY, - ca_pem TEXT NOT NULL, - private_key_pem TEXT NOT NULL, - updated_at_unix INTEGER NOT NULL -); +CREATE UNIQUE INDEX fleet_groups_name_unique + ON fleet_groups (name); + +CREATE INDEX idx_agent_fallback_state_entered_at + ON agent_fallback_state (entered_at_unix); + +CREATE INDEX idx_agent_revocations_cert_expires_at_unix + ON agent_revocations(cert_expires_at_unix); + +CREATE INDEX idx_agents_cert_spki_sha256 + ON agents (cert_spki_sha256) + WHERE length(cert_spki_sha256) > 0; + +CREATE INDEX idx_agents_fleet_group_id ON agents (fleet_group_id); + +CREATE INDEX idx_agents_last_seen_at ON agents (last_seen_at_unix); + +CREATE INDEX idx_agents_transport_mode ON agents(transport_mode); + +CREATE INDEX idx_audit_events_chain_walk ON audit_events (created_at_unix, id); + +CREATE INDEX idx_audit_events_created_at ON audit_events (created_at_unix); + +CREATE INDEX idx_client_assignments_client_id ON client_assignments (client_id); + +CREATE INDEX idx_client_deployments_client_id ON client_deployments (client_id); + +CREATE INDEX idx_client_ip_client ON client_ip_history (client_id, last_seen_unix DESC); + +CREATE INDEX idx_client_ip_client_addr ON client_ip_history (client_id, ip_address); + +CREATE INDEX idx_client_ip_last_seen ON client_ip_history (last_seen_unix); + +CREATE INDEX idx_client_usage_agent_id + ON client_usage (agent_id); + +CREATE INDEX idx_config_apply_batch_targets_batch_wave + ON config_apply_batch_targets (batch_id, wave_index); + +CREATE INDEX idx_config_apply_batches_status + ON config_apply_batches (status); + +CREATE INDEX idx_consumed_totp_used_at ON consumed_totp(used_at_unix); + +CREATE INDEX idx_discovered_clients_agent_id ON discovered_clients (agent_id); + +CREATE UNIQUE INDEX idx_discovered_clients_pending_unique + ON discovered_clients (agent_id, client_name) + WHERE status = 'pending_review'; + +CREATE INDEX idx_enrollment_attempts_agent ON enrollment_attempts(agent_id); + +CREATE INDEX idx_enrollment_attempts_started ON enrollment_attempts(started_at); + +CREATE INDEX idx_enrollment_attempts_token ON enrollment_attempts(token_id); + +CREATE INDEX idx_enrollment_events_attempt ON enrollment_events (attempt_id, ts); + +CREATE INDEX idx_enrollment_tokens_fleet_group_id ON enrollment_tokens (fleet_group_id); + +CREATE INDEX idx_fleet_group_integrations_fleet_group_id ON fleet_group_integrations (fleet_group_id); + +CREATE INDEX idx_fleet_group_integrations_kind ON fleet_group_integrations (kind); + +CREATE INDEX idx_integration_providers_kind ON integration_providers (kind); + +CREATE INDEX idx_job_targets_agent_id ON job_targets (agent_id); + +CREATE INDEX idx_jobs_actor_id ON jobs (actor_id); + +CREATE INDEX idx_jobs_created_at ON jobs (created_at_unix); + +CREATE INDEX idx_jobs_status ON jobs (status); + +CREATE INDEX idx_login_lockouts_locked_at_unix + ON login_lockouts(locked_at_unix); + +CREATE INDEX idx_metric_snapshots_agent_captured ON metric_snapshots (agent_id, captured_at_unix); + +CREATE INDEX idx_metric_snapshots_captured_at ON metric_snapshots (captured_at_unix); + +CREATE INDEX idx_sessions_created_at_unix ON sessions (created_at_unix); + +CREATE INDEX idx_sessions_user_id ON sessions (user_id); + +CREATE INDEX idx_telemt_instances_agent_id ON telemt_instances (agent_id); + +CREATE INDEX idx_ts_dc_health_time ON ts_dc_health (agent_id, captured_at_unix DESC); + +CREATE INDEX idx_ts_server_load_time ON ts_server_load (agent_id, captured_at_unix DESC); + +CREATE INDEX idx_user_fleet_group_scopes_fleet_group_id + ON user_fleet_group_scopes (fleet_group_id); + +CREATE INDEX idx_user_fleet_group_scopes_user_id + ON user_fleet_group_scopes (user_id); + +CREATE INDEX idx_webhook_outbox_ready + ON webhook_outbox (next_attempt_at) + WHERE dead = 0 AND delivered_at IS NULL; -- +goose Down -DROP TABLE IF EXISTS certificate_authority; -DROP TABLE IF EXISTS panel_settings; -DROP TABLE IF EXISTS client_deployments; -DROP TABLE IF EXISTS client_assignments; -DROP TABLE IF EXISTS clients; -DROP TABLE IF EXISTS agent_certificate_recovery_grants; -DROP TABLE IF EXISTS enrollment_tokens; -DROP TABLE IF EXISTS metric_snapshots; -DROP TABLE IF EXISTS audit_events; -DROP TABLE IF EXISTS job_targets; -DROP TABLE IF EXISTS jobs; -DROP TABLE IF EXISTS telemt_detail_boosts; -DROP TABLE IF EXISTS telemt_security_inventory_current; -DROP TABLE IF EXISTS telemt_diagnostics_current; -DROP TABLE IF EXISTS telemt_runtime_events; -DROP TABLE IF EXISTS telemt_runtime_upstreams_current; -DROP TABLE IF EXISTS telemt_runtime_dcs_current; -DROP TABLE IF EXISTS telemt_runtime_current; -DROP TABLE IF EXISTS telemt_instances; -DROP TABLE IF EXISTS agents; -DROP TABLE IF EXISTS user_appearance; -DROP TABLE IF EXISTS users; -DROP TABLE IF EXISTS fleet_groups; +-- Squash-init: даунгрейда некуда — no-op. +SELECT 1; diff --git a/db/migrations/sqlite/0002_discovered_clients.sql b/db/migrations/sqlite/0002_discovered_clients.sql deleted file mode 100644 index da44e33e..00000000 --- a/db/migrations/sqlite/0002_discovered_clients.sql +++ /dev/null @@ -1,23 +0,0 @@ --- +goose Up -CREATE TABLE IF NOT EXISTS discovered_clients ( - id TEXT PRIMARY KEY, - agent_id TEXT NOT NULL, - client_name TEXT NOT NULL, - secret TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'pending_review', - total_octets INTEGER NOT NULL DEFAULT 0, - current_connections INTEGER NOT NULL DEFAULT 0, - active_unique_ips INTEGER NOT NULL DEFAULT 0, - connection_link TEXT NOT NULL DEFAULT '', - max_tcp_conns INTEGER NOT NULL DEFAULT 0, - max_unique_ips INTEGER NOT NULL DEFAULT 0, - data_quota_bytes INTEGER NOT NULL DEFAULT 0, - expiration TEXT NOT NULL DEFAULT '', - discovered_at_unix INTEGER NOT NULL, - updated_at_unix INTEGER NOT NULL, - UNIQUE (agent_id, client_name), - FOREIGN KEY (agent_id) REFERENCES agents (id) -); - --- +goose Down -DROP TABLE IF EXISTS discovered_clients; diff --git a/db/migrations/sqlite/0003_agent_cert_dates.sql b/db/migrations/sqlite/0003_agent_cert_dates.sql deleted file mode 100644 index 9fd5b040..00000000 --- a/db/migrations/sqlite/0003_agent_cert_dates.sql +++ /dev/null @@ -1,15 +0,0 @@ --- +goose Up --- NOTE: SQLite's ALTER TABLE ADD COLUMN has no "IF NOT EXISTS" clause. --- Goose's version tracking ensures this migration only runs once per database, --- so an unconditional ADD COLUMN is safe here — the previous hand-rolled --- ensureAgentsCertIssuedAtColumn helpers became necessary only because the --- ad-hoc migrator had no way to know whether the column was already applied. -ALTER TABLE agents ADD COLUMN cert_issued_at_unix INTEGER; -ALTER TABLE agents ADD COLUMN cert_expires_at_unix INTEGER; - --- +goose Down --- SQLite supports DROP COLUMN since 3.35 (modernc.org/sqlite ships 3.47+), --- but the operation rewrites the table; for a pre-3.35 runtime this Down --- would need a table-rename approach. Best-effort on modern SQLite. -ALTER TABLE agents DROP COLUMN cert_expires_at_unix; -ALTER TABLE agents DROP COLUMN cert_issued_at_unix; diff --git a/db/migrations/sqlite/0004_sessions.sql b/db/migrations/sqlite/0004_sessions.sql deleted file mode 100644 index 1d46a365..00000000 --- a/db/migrations/sqlite/0004_sessions.sql +++ /dev/null @@ -1,14 +0,0 @@ --- +goose Up -CREATE TABLE IF NOT EXISTS sessions ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - created_at_unix INTEGER NOT NULL -); - -CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); -CREATE INDEX IF NOT EXISTS idx_sessions_created_at_unix ON sessions(created_at_unix); - --- +goose Down -DROP INDEX IF EXISTS idx_sessions_created_at_unix; -DROP INDEX IF EXISTS idx_sessions_user_id; -DROP TABLE IF EXISTS sessions; diff --git a/db/migrations/sqlite/0005_agent_revocations.sql b/db/migrations/sqlite/0005_agent_revocations.sql deleted file mode 100644 index 67e91a26..00000000 --- a/db/migrations/sqlite/0005_agent_revocations.sql +++ /dev/null @@ -1,15 +0,0 @@ --- +goose Up --- See postgres/0005_agent_revocations.sql for rationale. SQLite uses INTEGER --- unix timestamps to match the existing convention. -CREATE TABLE IF NOT EXISTS agent_revocations ( - agent_id TEXT PRIMARY KEY, - revoked_at_unix INTEGER NOT NULL, - cert_expires_at_unix INTEGER NOT NULL -); - -CREATE INDEX IF NOT EXISTS idx_agent_revocations_cert_expires_at_unix - ON agent_revocations(cert_expires_at_unix); - --- +goose Down -DROP INDEX IF EXISTS idx_agent_revocations_cert_expires_at_unix; -DROP TABLE IF EXISTS agent_revocations; diff --git a/db/migrations/sqlite/0006_timeseries_and_update_config.sql b/db/migrations/sqlite/0006_timeseries_and_update_config.sql deleted file mode 100644 index 0135af09..00000000 --- a/db/migrations/sqlite/0006_timeseries_and_update_config.sql +++ /dev/null @@ -1,85 +0,0 @@ --- +goose Up --- update_config stores internal feature-flag/kv state for the updater subsystem. -CREATE TABLE IF NOT EXISTS update_config ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS ts_server_load ( - agent_id TEXT NOT NULL, - captured_at_unix INTEGER NOT NULL, - cpu_pct_avg REAL NOT NULL DEFAULT 0, - cpu_pct_max REAL NOT NULL DEFAULT 0, - mem_pct_avg REAL NOT NULL DEFAULT 0, - mem_pct_max REAL NOT NULL DEFAULT 0, - disk_pct_avg REAL NOT NULL DEFAULT 0, - disk_pct_max REAL NOT NULL DEFAULT 0, - load_1m REAL NOT NULL DEFAULT 0, - load_5m REAL NOT NULL DEFAULT 0, - load_15m REAL NOT NULL DEFAULT 0, - connections_avg INTEGER NOT NULL DEFAULT 0, - connections_max INTEGER NOT NULL DEFAULT 0, - connections_me_avg INTEGER NOT NULL DEFAULT 0, - connections_direct_avg INTEGER NOT NULL DEFAULT 0, - active_users_avg INTEGER NOT NULL DEFAULT 0, - active_users_max INTEGER NOT NULL DEFAULT 0, - connections_total INTEGER NOT NULL DEFAULT 0, - connections_bad_total INTEGER NOT NULL DEFAULT 0, - handshake_timeouts_total INTEGER NOT NULL DEFAULT 0, - dc_coverage_min_pct REAL NOT NULL DEFAULT 0, - dc_coverage_avg_pct REAL NOT NULL DEFAULT 0, - healthy_upstreams INTEGER NOT NULL DEFAULT 0, - total_upstreams INTEGER NOT NULL DEFAULT 0, - net_bytes_sent INTEGER NOT NULL DEFAULT 0, - net_bytes_recv INTEGER NOT NULL DEFAULT 0, - sample_count INTEGER NOT NULL DEFAULT 1, - PRIMARY KEY (agent_id, captured_at_unix) -); - -CREATE TABLE IF NOT EXISTS ts_dc_health ( - agent_id TEXT NOT NULL, - captured_at_unix INTEGER NOT NULL, - dc INTEGER NOT NULL, - coverage_pct_avg REAL NOT NULL DEFAULT 0, - coverage_pct_min REAL NOT NULL DEFAULT 0, - rtt_ms_avg REAL NOT NULL DEFAULT 0, - rtt_ms_max REAL NOT NULL DEFAULT 0, - alive_writers_min INTEGER NOT NULL DEFAULT 0, - required_writers INTEGER NOT NULL DEFAULT 0, - load_max INTEGER NOT NULL DEFAULT 0, - sample_count INTEGER NOT NULL DEFAULT 1, - PRIMARY KEY (agent_id, dc, captured_at_unix) -); - -CREATE TABLE IF NOT EXISTS ts_server_load_hourly ( - agent_id TEXT NOT NULL, - bucket_hour_unix INTEGER NOT NULL, - cpu_pct_avg REAL, - cpu_pct_max REAL, - mem_pct_avg REAL, - mem_pct_max REAL, - connections_avg REAL, - connections_max INTEGER, - active_users_avg REAL, - active_users_max INTEGER, - dc_coverage_min REAL, - dc_coverage_avg REAL, - sample_count INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (agent_id, bucket_hour_unix) -); - -CREATE TABLE IF NOT EXISTS client_ip_history ( - agent_id TEXT NOT NULL, - client_id TEXT NOT NULL, - ip_address TEXT NOT NULL, - first_seen_unix INTEGER NOT NULL, - last_seen_unix INTEGER NOT NULL, - PRIMARY KEY (agent_id, client_id, ip_address) -); - --- +goose Down -DROP TABLE IF EXISTS client_ip_history; -DROP TABLE IF EXISTS ts_server_load_hourly; -DROP TABLE IF EXISTS ts_dc_health; -DROP TABLE IF EXISTS ts_server_load; -DROP TABLE IF EXISTS update_config; diff --git a/db/migrations/sqlite/0007_indexes.sql b/db/migrations/sqlite/0007_indexes.sql deleted file mode 100644 index 4e02b762..00000000 --- a/db/migrations/sqlite/0007_indexes.sql +++ /dev/null @@ -1,31 +0,0 @@ --- +goose Up -CREATE INDEX IF NOT EXISTS idx_agents_last_seen_at ON agents (last_seen_at_unix); -CREATE INDEX IF NOT EXISTS idx_agents_fleet_group_id ON agents (fleet_group_id); -CREATE INDEX IF NOT EXISTS idx_telemt_instances_agent_id ON telemt_instances (agent_id); -CREATE INDEX IF NOT EXISTS idx_client_assignments_client_id ON client_assignments (client_id); -CREATE INDEX IF NOT EXISTS idx_client_deployments_client_id ON client_deployments (client_id); -CREATE INDEX IF NOT EXISTS idx_jobs_created_at ON jobs (created_at_unix); -CREATE INDEX IF NOT EXISTS idx_audit_events_created_at ON audit_events (created_at_unix); -CREATE INDEX IF NOT EXISTS idx_metric_snapshots_agent_captured ON metric_snapshots (agent_id, captured_at_unix); -CREATE INDEX IF NOT EXISTS idx_discovered_clients_agent_id ON discovered_clients (agent_id); -CREATE INDEX IF NOT EXISTS idx_ts_server_load_time ON ts_server_load (agent_id, captured_at_unix DESC); -CREATE INDEX IF NOT EXISTS idx_ts_dc_health_time ON ts_dc_health (agent_id, captured_at_unix DESC); -CREATE INDEX IF NOT EXISTS idx_client_ip_last_seen ON client_ip_history (last_seen_unix); -CREATE INDEX IF NOT EXISTS idx_client_ip_client ON client_ip_history (client_id, last_seen_unix DESC); -CREATE INDEX IF NOT EXISTS idx_client_ip_client_addr ON client_ip_history (client_id, ip_address); - --- +goose Down -DROP INDEX IF EXISTS idx_client_ip_client_addr; -DROP INDEX IF EXISTS idx_client_ip_client; -DROP INDEX IF EXISTS idx_client_ip_last_seen; -DROP INDEX IF EXISTS idx_ts_dc_health_time; -DROP INDEX IF EXISTS idx_ts_server_load_time; -DROP INDEX IF EXISTS idx_discovered_clients_agent_id; -DROP INDEX IF EXISTS idx_metric_snapshots_agent_captured; -DROP INDEX IF EXISTS idx_audit_events_created_at; -DROP INDEX IF EXISTS idx_jobs_created_at; -DROP INDEX IF EXISTS idx_client_deployments_client_id; -DROP INDEX IF EXISTS idx_client_assignments_client_id; -DROP INDEX IF EXISTS idx_telemt_instances_agent_id; -DROP INDEX IF EXISTS idx_agents_fleet_group_id; -DROP INDEX IF EXISTS idx_agents_last_seen_at; diff --git a/db/migrations/sqlite/0008_missing_indexes.sql b/db/migrations/sqlite/0008_missing_indexes.sql deleted file mode 100644 index 1921eb0c..00000000 --- a/db/migrations/sqlite/0008_missing_indexes.sql +++ /dev/null @@ -1,11 +0,0 @@ --- +goose Up -CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs (status); -CREATE INDEX IF NOT EXISTS idx_job_targets_agent_id ON job_targets (agent_id); -CREATE INDEX IF NOT EXISTS idx_metric_snapshots_captured_at ON metric_snapshots (captured_at_unix); -CREATE INDEX IF NOT EXISTS idx_enrollment_tokens_fleet_group_id ON enrollment_tokens (fleet_group_id); - --- +goose Down -DROP INDEX IF EXISTS idx_enrollment_tokens_fleet_group_id; -DROP INDEX IF EXISTS idx_metric_snapshots_captured_at; -DROP INDEX IF EXISTS idx_job_targets_agent_id; -DROP INDEX IF EXISTS idx_jobs_status; diff --git a/db/migrations/sqlite/0009_retention_settings.sql b/db/migrations/sqlite/0009_retention_settings.sql deleted file mode 100644 index b89f6385..00000000 --- a/db/migrations/sqlite/0009_retention_settings.sql +++ /dev/null @@ -1,35 +0,0 @@ --- +goose Up --- P2-REL-03: persist retention settings across restarts. --- Adds a retention_json column to panel_settings so the singleton --- scope='panel' row also carries the operator-managed retention --- configuration as an opaque JSON blob. Empty string means "not --- yet set" and causes the control-plane to fall back to defaults. --- SQLite's ALTER TABLE ADD COLUMN lacks IF NOT EXISTS; goose records --- this version so it never runs twice on the same database. -ALTER TABLE panel_settings - ADD COLUMN retention_json TEXT NOT NULL DEFAULT ''; - --- +goose Down --- SQLite cannot DROP COLUMN on older versions; recreate the table --- without retention_json to roll back. This Down path is used only --- by tests / operator-initiated downgrades — production drops table --- contents via 0001's Down. -CREATE TABLE panel_settings_backup ( - scope TEXT PRIMARY KEY, - http_public_url TEXT NOT NULL DEFAULT '', - http_root_path TEXT NOT NULL DEFAULT '', - grpc_public_endpoint TEXT NOT NULL DEFAULT '', - http_listen_address TEXT NOT NULL DEFAULT '', - grpc_listen_address TEXT NOT NULL DEFAULT '', - tls_mode TEXT NOT NULL DEFAULT '', - tls_cert_file TEXT NOT NULL DEFAULT '', - tls_key_file TEXT NOT NULL DEFAULT '', - updated_at_unix INTEGER NOT NULL -); -INSERT INTO panel_settings_backup - SELECT scope, http_public_url, http_root_path, grpc_public_endpoint, - http_listen_address, grpc_listen_address, tls_mode, tls_cert_file, - tls_key_file, updated_at_unix - FROM panel_settings; -DROP TABLE panel_settings; -ALTER TABLE panel_settings_backup RENAME TO panel_settings; diff --git a/db/migrations/sqlite/0010_discovered_clients_pending_unique.sql b/db/migrations/sqlite/0010_discovered_clients_pending_unique.sql deleted file mode 100644 index 6a01f63d..00000000 --- a/db/migrations/sqlite/0010_discovered_clients_pending_unique.sql +++ /dev/null @@ -1,13 +0,0 @@ --- +goose Up --- P2-LOG-02 (L-10 / M-C4): belt-and-suspenders guard against duplicate --- pending_review rows. The table already has UNIQUE (agent_id, client_name) --- from 0002, but adding this partial UNIQUE index makes the dedupe intent --- explicit and keeps the invariant intact even if the broader constraint --- is ever loosened (e.g. to allow historical "ignored"/"adopted" copies). --- SQLite supports partial indexes since 3.8.0 (2013). -CREATE UNIQUE INDEX IF NOT EXISTS idx_discovered_clients_pending_unique - ON discovered_clients (agent_id, client_name) - WHERE status = 'pending_review'; - --- +goose Down -DROP INDEX IF EXISTS idx_discovered_clients_pending_unique; diff --git a/db/migrations/sqlite/0011_column_drift.sql b/db/migrations/sqlite/0011_column_drift.sql deleted file mode 100644 index f5f58dfd..00000000 --- a/db/migrations/sqlite/0011_column_drift.sql +++ /dev/null @@ -1,21 +0,0 @@ --- +goose Up --- P2-DB-05 (DF-25 / M-F13): rename SQLite columns to match the PostgreSQL --- schema. Historically SQLite used `details_json` and `values_json` (TEXT) --- while Postgres used the unsuffixed `details` and `values` (JSONB) names. --- That divergence forced every Store method to keep two SQL variants — one --- per backend — and any edit had to touch both. Converging on the Postgres --- names lets the storage layer share statements. --- --- SQLite supports ALTER TABLE ... RENAME COLUMN since 3.25 (2018). The --- modernc.org/sqlite driver we embed is based on 3.45+, so direct RENAME --- COLUMN works — no table-rebuild fallback needed. --- --- `values` is a reserved keyword in SQLite, so any subsequent query that --- refers to it must double-quote the identifier. That change lives in --- internal/controlplane/storage/sqlite/store.go alongside this migration. -ALTER TABLE audit_events RENAME COLUMN details_json TO details; -ALTER TABLE metric_snapshots RENAME COLUMN values_json TO "values"; - --- +goose Down -ALTER TABLE audit_events RENAME COLUMN details TO details_json; -ALTER TABLE metric_snapshots RENAME COLUMN "values" TO values_json; diff --git a/db/migrations/sqlite/0012_cascade_fk.sql b/db/migrations/sqlite/0012_cascade_fk.sql deleted file mode 100644 index df6b008e..00000000 --- a/db/migrations/sqlite/0012_cascade_fk.sql +++ /dev/null @@ -1,147 +0,0 @@ --- +goose Up --- +goose NO TRANSACTION --- P2-DB-03 (DF-24 / M-F11): SQLite does not support ALTER TABLE ADD --- CONSTRAINT, so we rebuild each affected table with the correct FK set and --- copy rows across. --- --- Dev-stage policy (plan v4): operate on dev databases only. Drop-and- --- recreate is acceptable, orphan rows are purged before the rebuild so the --- new FK constraints do not abort the copy. No down.sql. --- --- Goose runs the migration on a single connection, so PRAGMA foreign_keys --- toggling is scoped to this rebuild and does not leak to other connections. - -PRAGMA foreign_keys = OFF; - --- sessions: current columns per 0004_sessions.sql: (id, user_id, created_at_unix). --- Each table rebuild below is wrapped in its own explicit transaction so a --- crash between DROP and RENAME can never leave a table dropped-but-not- --- renamed. PRAGMA foreign_keys stays outside every BEGIN/COMMIT — SQLite --- forbids toggling it inside a transaction. -DELETE FROM sessions WHERE user_id NOT IN (SELECT id FROM users); - -BEGIN; - -CREATE TABLE sessions_new ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - created_at_unix INTEGER NOT NULL, - FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE -); - -INSERT INTO sessions_new (id, user_id, created_at_unix) -SELECT id, user_id, created_at_unix FROM sessions; - -DROP TABLE sessions; -ALTER TABLE sessions_new RENAME TO sessions; - -CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions (user_id); -CREATE INDEX IF NOT EXISTS idx_sessions_created_at_unix ON sessions (created_at_unix); - -COMMIT; - --- enrollment_tokens: deliberately NOT linked to fleet_groups by a FK — the --- control-plane issues tokens against a fleet group id that may not yet --- exist (agent_flow.consumeEnrollmentToken creates the group on first --- enrollment). Adding a FK here would break the issue-then-create flow. - --- metric_snapshots: current columns per 0001_init.sql + 0011_column_drift.sql: --- (id, agent_id, instance_id, captured_at_unix, "values"). The `values` --- column was renamed from `values_json` in 0011, and since `values` is a --- reserved keyword in SQLite it must be double-quoted. -DELETE FROM metric_snapshots WHERE agent_id NOT IN (SELECT id FROM agents); - -BEGIN; - -CREATE TABLE metric_snapshots_new ( - id TEXT PRIMARY KEY, - agent_id TEXT NOT NULL, - instance_id TEXT NOT NULL DEFAULT '', - captured_at_unix INTEGER NOT NULL, - "values" TEXT NOT NULL, - FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE -); - -INSERT INTO metric_snapshots_new (id, agent_id, instance_id, captured_at_unix, "values") -SELECT id, agent_id, instance_id, captured_at_unix, "values" FROM metric_snapshots; - -DROP TABLE metric_snapshots; -ALTER TABLE metric_snapshots_new RENAME TO metric_snapshots; - -CREATE INDEX IF NOT EXISTS idx_metric_snapshots_captured_at ON metric_snapshots (captured_at_unix); -CREATE INDEX IF NOT EXISTS idx_metric_snapshots_agent_captured ON metric_snapshots (agent_id, captured_at_unix); - -COMMIT; - --- client_assignments: current columns per 0001_init.sql: --- (id, client_id, target_type, fleet_group_id, agent_id, created_at_unix). -DELETE FROM client_assignments WHERE client_id NOT IN (SELECT id FROM clients); -DELETE FROM client_assignments -WHERE fleet_group_id IS NOT NULL - AND fleet_group_id NOT IN (SELECT id FROM fleet_groups); -DELETE FROM client_assignments -WHERE agent_id IS NOT NULL - AND agent_id NOT IN (SELECT id FROM agents); - -BEGIN; - -CREATE TABLE client_assignments_new ( - id TEXT PRIMARY KEY, - client_id TEXT NOT NULL, - target_type TEXT NOT NULL, - fleet_group_id TEXT, - agent_id TEXT, - created_at_unix INTEGER NOT NULL, - FOREIGN KEY (client_id) REFERENCES clients (id) ON DELETE CASCADE, - FOREIGN KEY (fleet_group_id) REFERENCES fleet_groups (id) ON DELETE SET NULL, - FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE SET NULL -); - -INSERT INTO client_assignments_new (id, client_id, target_type, fleet_group_id, agent_id, created_at_unix) -SELECT id, client_id, target_type, fleet_group_id, agent_id, created_at_unix FROM client_assignments; - -DROP TABLE client_assignments; -ALTER TABLE client_assignments_new RENAME TO client_assignments; - -CREATE INDEX IF NOT EXISTS idx_client_assignments_client_id ON client_assignments (client_id); - -COMMIT; - --- client_deployments: current columns per 0001_init.sql: --- (client_id, agent_id, desired_operation, status, last_error, --- connection_link, last_applied_at_unix, updated_at_unix). -DELETE FROM client_deployments WHERE client_id NOT IN (SELECT id FROM clients); -DELETE FROM client_deployments WHERE agent_id NOT IN (SELECT id FROM agents); - -BEGIN; - -CREATE TABLE client_deployments_new ( - client_id TEXT NOT NULL, - agent_id TEXT NOT NULL, - desired_operation TEXT NOT NULL, - status TEXT NOT NULL, - last_error TEXT NOT NULL DEFAULT '', - connection_link TEXT NOT NULL DEFAULT '', - last_applied_at_unix INTEGER, - updated_at_unix INTEGER NOT NULL, - PRIMARY KEY (client_id, agent_id), - FOREIGN KEY (client_id) REFERENCES clients (id) ON DELETE CASCADE, - FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE -); - -INSERT INTO client_deployments_new (client_id, agent_id, desired_operation, status, last_error, connection_link, last_applied_at_unix, updated_at_unix) -SELECT client_id, agent_id, desired_operation, status, last_error, connection_link, last_applied_at_unix, updated_at_unix FROM client_deployments; - -DROP TABLE client_deployments; -ALTER TABLE client_deployments_new RENAME TO client_deployments; - -CREATE INDEX IF NOT EXISTS idx_client_deployments_client_id ON client_deployments (client_id); - -COMMIT; - -PRAGMA foreign_keys = ON; - --- +goose Down --- +goose NO TRANSACTION --- dev-stage: drop+recreate acceptable, no rollback. -SELECT 1; diff --git a/db/migrations/sqlite/0013_login_lockout.sql b/db/migrations/sqlite/0013_login_lockout.sql deleted file mode 100644 index f66c3f2d..00000000 --- a/db/migrations/sqlite/0013_login_lockout.sql +++ /dev/null @@ -1,19 +0,0 @@ --- +goose Up --- S7: persist login-lockout state so a control-plane restart or --- fail-over cannot be used to reset the failed-attempt counter for --- an account. Key is the raw username. SQLite stores timestamps as --- INTEGER unix seconds to match the rest of the schema (see sessions, --- metric_snapshots, etc.). -CREATE TABLE IF NOT EXISTS login_lockouts ( - username TEXT PRIMARY KEY, - failures INTEGER NOT NULL DEFAULT 0, - locked_at_unix INTEGER, - updated_at_unix INTEGER NOT NULL -); - -CREATE INDEX IF NOT EXISTS idx_login_lockouts_locked_at_unix - ON login_lockouts(locked_at_unix); - --- +goose Down -DROP INDEX IF EXISTS idx_login_lockouts_locked_at_unix; -DROP TABLE IF EXISTS login_lockouts; diff --git a/db/migrations/sqlite/0014_fleet_groups_redesign.sql b/db/migrations/sqlite/0014_fleet_groups_redesign.sql deleted file mode 100644 index cce81d79..00000000 --- a/db/migrations/sqlite/0014_fleet_groups_redesign.sql +++ /dev/null @@ -1,65 +0,0 @@ --- +goose Up --- Fleet groups redesign: see postgres/0014 for the full rationale. --- SQLite supports ADD COLUMN but not CHANGE COLUMN, so we add the --- new fields in place and backfill. -ALTER TABLE fleet_groups ADD COLUMN label TEXT NOT NULL DEFAULT ''; -ALTER TABLE fleet_groups ADD COLUMN description TEXT NOT NULL DEFAULT ''; -ALTER TABLE fleet_groups ADD COLUMN updated_at_unix INTEGER NOT NULL DEFAULT 0; - --- Backfill label from the existing name and seed updated_at from --- created_at so pre-migration rows have plausible values. The columns --- are NOT NULL DEFAULT '' / 0 (added above), so every row landed with --- the sentinel values we filter on; a follow-up migration that runs --- after operators have set custom labels must skip these branches. -UPDATE fleet_groups SET label = name WHERE length(label) = 0; -UPDATE fleet_groups SET updated_at_unix = created_at_unix WHERE updated_at_unix = 0; - --- SQLite pre-3.35 cannot ADD CONSTRAINT, but a UNIQUE index does the --- same job and is lighter than rebuilding the table. -CREATE UNIQUE INDEX IF NOT EXISTS fleet_groups_name_unique - ON fleet_groups (name); - -CREATE TABLE IF NOT EXISTS integration_providers ( - id TEXT PRIMARY KEY, - kind TEXT NOT NULL, - label TEXT NOT NULL DEFAULT '', - config TEXT NOT NULL DEFAULT '{}', - created_at_unix INTEGER NOT NULL, - updated_at_unix INTEGER NOT NULL -); - -CREATE INDEX IF NOT EXISTS idx_integration_providers_kind - ON integration_providers (kind); - -CREATE TABLE IF NOT EXISTS fleet_group_integrations ( - id TEXT PRIMARY KEY, - fleet_group_id TEXT NOT NULL, - kind TEXT NOT NULL, - provider_id TEXT, - config TEXT NOT NULL DEFAULT '{}', - enabled INTEGER NOT NULL DEFAULT 0, - created_at_unix INTEGER NOT NULL, - updated_at_unix INTEGER NOT NULL, - FOREIGN KEY (fleet_group_id) REFERENCES fleet_groups (id) ON DELETE CASCADE, - FOREIGN KEY (provider_id) REFERENCES integration_providers (id) ON DELETE SET NULL, - UNIQUE (fleet_group_id, kind) -); - -CREATE INDEX IF NOT EXISTS idx_fleet_group_integrations_fleet_group_id - ON fleet_group_integrations (fleet_group_id); -CREATE INDEX IF NOT EXISTS idx_fleet_group_integrations_kind - ON fleet_group_integrations (kind); - --- +goose Down -DROP INDEX IF EXISTS idx_fleet_group_integrations_kind; -DROP INDEX IF EXISTS idx_fleet_group_integrations_fleet_group_id; -DROP TABLE IF EXISTS fleet_group_integrations; -DROP INDEX IF EXISTS idx_integration_providers_kind; -DROP TABLE IF EXISTS integration_providers; - --- SQLite supports DROP COLUMN from 3.35 (modernc.org/sqlite pins a --- recent enough version). Index drop is idempotent. -DROP INDEX IF EXISTS fleet_groups_name_unique; -ALTER TABLE fleet_groups DROP COLUMN updated_at_unix; -ALTER TABLE fleet_groups DROP COLUMN description; -ALTER TABLE fleet_groups DROP COLUMN label; diff --git a/db/migrations/sqlite/0015_client_usage.sql b/db/migrations/sqlite/0015_client_usage.sql deleted file mode 100644 index 62f1581c..00000000 --- a/db/migrations/sqlite/0015_client_usage.sql +++ /dev/null @@ -1,24 +0,0 @@ --- +goose Up --- SQLite mirror of 0015 (Postgres). Schema-sync parity is enforced by --- TestSchemaSyncPostgresMatchesSQLite. Timestamps use TEXT (ISO-8601) --- like the rest of the SQLite bundle; BIGINT collapses to INTEGER. -CREATE TABLE IF NOT EXISTS client_usage ( - client_id TEXT NOT NULL, - agent_id TEXT NOT NULL, - traffic_used_bytes INTEGER NOT NULL DEFAULT 0, - unique_ips_used INTEGER NOT NULL DEFAULT 0, - active_tcp_conns INTEGER NOT NULL DEFAULT 0, - active_unique_ips INTEGER NOT NULL DEFAULT 0, - last_seq INTEGER NOT NULL DEFAULT 0, - observed_at_unix INTEGER NOT NULL, - PRIMARY KEY (client_id, agent_id), - FOREIGN KEY (client_id) REFERENCES clients (id) ON DELETE CASCADE, - FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE -); - -CREATE INDEX IF NOT EXISTS idx_client_usage_agent_id - ON client_usage (agent_id); - --- +goose Down -DROP INDEX IF EXISTS idx_client_usage_agent_id; -DROP TABLE IF EXISTS client_usage; diff --git a/db/migrations/sqlite/0016_sessions_last_seen.sql b/db/migrations/sqlite/0016_sessions_last_seen.sql deleted file mode 100644 index 48acffa9..00000000 --- a/db/migrations/sqlite/0016_sessions_last_seen.sql +++ /dev/null @@ -1,18 +0,0 @@ --- +goose Up -ALTER TABLE sessions ADD COLUMN last_seen_at_unix INTEGER NOT NULL DEFAULT 0; -UPDATE sessions SET last_seen_at_unix = created_at_unix WHERE last_seen_at_unix = 0; - --- +goose Down --- SQLite cannot DROP COLUMN inline (older versions); rebuild table. -CREATE TABLE sessions_old ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - created_at_unix INTEGER NOT NULL, - FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE -); -INSERT INTO sessions_old (id, user_id, created_at_unix) -SELECT id, user_id, created_at_unix FROM sessions; -DROP TABLE sessions; -ALTER TABLE sessions_old RENAME TO sessions; -CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); -CREATE INDEX IF NOT EXISTS idx_sessions_created_at_unix ON sessions(created_at_unix); diff --git a/db/migrations/sqlite/0017_consumed_totp.sql b/db/migrations/sqlite/0017_consumed_totp.sql deleted file mode 100644 index c0a28af1..00000000 --- a/db/migrations/sqlite/0017_consumed_totp.sql +++ /dev/null @@ -1,12 +0,0 @@ --- +goose Up -CREATE TABLE IF NOT EXISTS consumed_totp ( - user_id TEXT NOT NULL, - code TEXT NOT NULL, - used_at_unix INTEGER NOT NULL, - PRIMARY KEY (user_id, code) -); -CREATE INDEX IF NOT EXISTS idx_consumed_totp_used_at ON consumed_totp(used_at_unix); - --- +goose Down -DROP INDEX IF EXISTS idx_consumed_totp_used_at; -DROP TABLE IF EXISTS consumed_totp; diff --git a/db/migrations/sqlite/0018_cp_secrets.sql b/db/migrations/sqlite/0018_cp_secrets.sql deleted file mode 100644 index d85a988a..00000000 --- a/db/migrations/sqlite/0018_cp_secrets.sql +++ /dev/null @@ -1,9 +0,0 @@ --- +goose Up -CREATE TABLE IF NOT EXISTS cp_secrets ( - key TEXT PRIMARY KEY, - value BLOB NOT NULL, - updated_at_unix INTEGER NOT NULL -); - --- +goose Down -DROP TABLE IF EXISTS cp_secrets; diff --git a/db/migrations/sqlite/0019_jobs_actor_id_index.sql b/db/migrations/sqlite/0019_jobs_actor_id_index.sql deleted file mode 100644 index bb9f788e..00000000 --- a/db/migrations/sqlite/0019_jobs_actor_id_index.sql +++ /dev/null @@ -1,5 +0,0 @@ --- +goose Up -CREATE INDEX IF NOT EXISTS idx_jobs_actor_id ON jobs(actor_id); - --- +goose Down -DROP INDEX IF EXISTS idx_jobs_actor_id; diff --git a/db/migrations/sqlite/0020_agents_cert_serial.sql b/db/migrations/sqlite/0020_agents_cert_serial.sql deleted file mode 100644 index 66f78046..00000000 --- a/db/migrations/sqlite/0020_agents_cert_serial.sql +++ /dev/null @@ -1,10 +0,0 @@ --- +goose Up -ALTER TABLE agents ADD COLUMN cert_serial TEXT NOT NULL DEFAULT ''; - --- +goose Down --- SQLite cannot DROP COLUMN inline on older versions; rebuild table. -CREATE TABLE agents_old AS SELECT id, node_name, fleet_group_id, version, read_only, - last_seen_at_unix, cert_issued_at_unix, cert_expires_at_unix - FROM agents; -DROP TABLE agents; -ALTER TABLE agents_old RENAME TO agents; diff --git a/db/migrations/sqlite/0021_enrollment_token_hash.sql b/db/migrations/sqlite/0021_enrollment_token_hash.sql deleted file mode 100644 index 8c32caeb..00000000 --- a/db/migrations/sqlite/0021_enrollment_token_hash.sql +++ /dev/null @@ -1,20 +0,0 @@ --- +goose Up -ALTER TABLE enrollment_tokens ADD COLUMN value_hash TEXT NOT NULL DEFAULT ''; -CREATE INDEX IF NOT EXISTS idx_enrollment_tokens_value_hash ON enrollment_tokens(value_hash); - --- +goose Down -DROP INDEX IF EXISTS idx_enrollment_tokens_value_hash; --- SQLite < 3.35 cannot DROP COLUMN inline; rebuild a copy without value_hash. -CREATE TABLE enrollment_tokens_old ( - value TEXT PRIMARY KEY, - fleet_group_id TEXT, - issued_at_unix INTEGER NOT NULL, - expires_at_unix INTEGER NOT NULL, - consumed_at_unix INTEGER, - revoked_at_unix INTEGER -); -INSERT INTO enrollment_tokens_old (value, fleet_group_id, issued_at_unix, expires_at_unix, consumed_at_unix, revoked_at_unix) - SELECT value, fleet_group_id, issued_at_unix, expires_at_unix, consumed_at_unix, revoked_at_unix - FROM enrollment_tokens; -DROP TABLE enrollment_tokens; -ALTER TABLE enrollment_tokens_old RENAME TO enrollment_tokens; diff --git a/db/migrations/sqlite/0022_cascade_fk_telemt_instances.sql b/db/migrations/sqlite/0022_cascade_fk_telemt_instances.sql deleted file mode 100644 index 73f85283..00000000 --- a/db/migrations/sqlite/0022_cascade_fk_telemt_instances.sql +++ /dev/null @@ -1,9 +0,0 @@ --- +goose Up --- Q4.U-D-03: no-op for sqlite — telemt_instances already carries --- ON DELETE CASCADE via 0012_cascade_fk.sql. Migration files are kept --- in lockstep across drivers so the postgres-side change shares the --- same number. -SELECT 1; - --- +goose Down -SELECT 1; diff --git a/db/migrations/sqlite/0023_check_constraints.sql b/db/migrations/sqlite/0023_check_constraints.sql deleted file mode 100644 index 6c46d053..00000000 --- a/db/migrations/sqlite/0023_check_constraints.sql +++ /dev/null @@ -1,11 +0,0 @@ --- +goose Up --- Q4.U-D-02: SQLite cannot ADD CONSTRAINT on an existing table; the --- equivalent guard is a CHECK in CREATE TABLE. Adding it inline would --- require rebuilding three tables under SQLite, which is invasive --- enough that we defer to schema-rebuild work in the typed-config --- sweep. Postgres carries the canonical guard; SQLite relies on the --- application layer (jobs.IsValidStatus etc.) until then. -SELECT 1; - --- +goose Down -SELECT 1; diff --git a/db/migrations/sqlite/0024_unify_real_to_double.sql b/db/migrations/sqlite/0024_unify_real_to_double.sql deleted file mode 100644 index 04b01513..00000000 --- a/db/migrations/sqlite/0024_unify_real_to_double.sql +++ /dev/null @@ -1,8 +0,0 @@ --- +goose Up --- Q5.U-D-01: no-op for sqlite — REAL is already 8-byte IEEE-754 there. --- The companion postgres migration promotes its REAL columns to --- DOUBLE PRECISION. Migration numbers stay in lockstep across drivers. -SELECT 1; - --- +goose Down -SELECT 1; diff --git a/db/migrations/sqlite/0025_user_fleet_group_scopes.sql b/db/migrations/sqlite/0025_user_fleet_group_scopes.sql deleted file mode 100644 index d1d86bf0..00000000 --- a/db/migrations/sqlite/0025_user_fleet_group_scopes.sql +++ /dev/null @@ -1,19 +0,0 @@ --- +goose Up --- R-S-14: SQLite mirror of postgres migration 0025. fleet_groups.id is --- TEXT (not UUID) on the SQLite side — schema 0014 keeps that legacy --- shape. ON DELETE CASCADE matches the postgres semantics. -CREATE TABLE IF NOT EXISTS user_fleet_group_scopes ( - user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, - fleet_group_id TEXT NOT NULL REFERENCES fleet_groups (id) ON DELETE CASCADE, - granted_at_unix INTEGER NOT NULL DEFAULT 0, - granted_by TEXT NOT NULL DEFAULT '', - PRIMARY KEY (user_id, fleet_group_id) -); - -CREATE INDEX IF NOT EXISTS idx_user_fleet_group_scopes_user_id - ON user_fleet_group_scopes (user_id); -CREATE INDEX IF NOT EXISTS idx_user_fleet_group_scopes_fleet_group_id - ON user_fleet_group_scopes (fleet_group_id); - --- +goose Down -DROP TABLE IF EXISTS user_fleet_group_scopes; diff --git a/db/migrations/sqlite/0026_enum_check_constraints.sql b/db/migrations/sqlite/0026_enum_check_constraints.sql deleted file mode 100644 index 47c58f5c..00000000 --- a/db/migrations/sqlite/0026_enum_check_constraints.sql +++ /dev/null @@ -1,128 +0,0 @@ --- +goose Up --- +goose NO TRANSACTION --- R-D-02-sqlite: bring SQLite into parity with postgres migration 0023 --- by rebuilding jobs / job_targets / discovered_clients with explicit --- CHECK constraints on their enum-shaped TEXT columns. SQLite does not --- support ALTER TABLE ADD CONSTRAINT, so the rebuild pattern from --- 0012_cascade_fk is reused: rename → recreate → copy → drop → restore. --- --- Dev-stage policy: drop-and-recreate is acceptable; rows that fail the --- new CHECK are purged before the copy so the new constraint does not --- abort the migration on dirty data. - -PRAGMA foreign_keys = OFF; - --- ─── jobs ──────────────────────────────────────────────────────────── --- Status enum mirrors jobs.IsValidStatus in the Go layer. --- Each table rebuild below is wrapped in its own explicit transaction so a --- crash between DROP and RENAME can never leave a table dropped-but-not- --- renamed. PRAGMA foreign_keys stays outside every BEGIN/COMMIT — SQLite --- forbids toggling it inside a transaction. -DELETE FROM jobs WHERE status NOT IN ('queued','running','succeeded','failed','expired'); - -BEGIN; - -CREATE TABLE jobs_new ( - id TEXT PRIMARY KEY, - action TEXT NOT NULL, - actor_id TEXT NOT NULL, - status TEXT NOT NULL CHECK (status IN ('queued','running','succeeded','failed','expired')), - created_at_unix INTEGER NOT NULL, - ttl_nanos INTEGER NOT NULL, - idempotency_key TEXT NOT NULL UNIQUE, - payload_json TEXT NOT NULL DEFAULT '' -); - -INSERT INTO jobs_new (id, action, actor_id, status, created_at_unix, ttl_nanos, idempotency_key, payload_json) -SELECT id, action, actor_id, status, created_at_unix, ttl_nanos, idempotency_key, payload_json FROM jobs; - -DROP TABLE jobs; -ALTER TABLE jobs_new RENAME TO jobs; - -CREATE INDEX IF NOT EXISTS idx_jobs_created_at ON jobs (created_at_unix); -CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs (status); -CREATE INDEX IF NOT EXISTS idx_jobs_actor_id ON jobs (actor_id); - -COMMIT; - --- ─── job_targets ───────────────────────────────────────────────────── --- The application enum (jobs.TargetStatus) is queued / sent / --- acknowledged / succeeded / failed / expired. The earlier postgres --- migration 0023 typo'd `sent` as `dispatched` — postgres writes to --- target.status="sent" trip the constraint. The follow-up migration --- 0027_fix_job_targets_check_enum corrects postgres; the SQLite --- constraint below is born correct. -DELETE FROM job_targets WHERE status NOT IN ('queued','sent','acknowledged','succeeded','failed','expired'); - -BEGIN; - -CREATE TABLE job_targets_new ( - job_id TEXT NOT NULL, - agent_id TEXT NOT NULL, - status TEXT NOT NULL CHECK (status IN ('queued','sent','acknowledged','succeeded','failed','expired')), - result_text TEXT NOT NULL DEFAULT '', - result_json TEXT NOT NULL DEFAULT '', - updated_at_unix INTEGER NOT NULL, - PRIMARY KEY (job_id, agent_id), - FOREIGN KEY (job_id) REFERENCES jobs (id) -); - -INSERT INTO job_targets_new (job_id, agent_id, status, result_text, result_json, updated_at_unix) -SELECT job_id, agent_id, status, result_text, result_json, updated_at_unix FROM job_targets; - -DROP TABLE job_targets; -ALTER TABLE job_targets_new RENAME TO job_targets; - -CREATE INDEX IF NOT EXISTS idx_job_targets_agent_id ON job_targets (agent_id); - -COMMIT; - --- ─── discovered_clients ────────────────────────────────────────────── -DELETE FROM discovered_clients WHERE status NOT IN ('pending_review','adopted','ignored'); - -BEGIN; - -CREATE TABLE discovered_clients_new ( - id TEXT PRIMARY KEY, - agent_id TEXT NOT NULL, - client_name TEXT NOT NULL, - secret TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'pending_review' CHECK (status IN ('pending_review','adopted','ignored')), - total_octets INTEGER NOT NULL DEFAULT 0, - current_connections INTEGER NOT NULL DEFAULT 0, - active_unique_ips INTEGER NOT NULL DEFAULT 0, - connection_link TEXT NOT NULL DEFAULT '', - max_tcp_conns INTEGER NOT NULL DEFAULT 0, - max_unique_ips INTEGER NOT NULL DEFAULT 0, - data_quota_bytes INTEGER NOT NULL DEFAULT 0, - expiration TEXT NOT NULL DEFAULT '', - discovered_at_unix INTEGER NOT NULL, - updated_at_unix INTEGER NOT NULL, - UNIQUE (agent_id, client_name), - FOREIGN KEY (agent_id) REFERENCES agents (id) -); - -INSERT INTO discovered_clients_new ( - id, agent_id, client_name, secret, status, total_octets, current_connections, - active_unique_ips, connection_link, max_tcp_conns, max_unique_ips, - data_quota_bytes, expiration, discovered_at_unix, updated_at_unix -) -SELECT id, agent_id, client_name, secret, status, total_octets, current_connections, - active_unique_ips, connection_link, max_tcp_conns, max_unique_ips, - data_quota_bytes, expiration, discovered_at_unix, updated_at_unix -FROM discovered_clients; - -DROP TABLE discovered_clients; -ALTER TABLE discovered_clients_new RENAME TO discovered_clients; - -CREATE INDEX IF NOT EXISTS idx_discovered_clients_agent_id ON discovered_clients (agent_id); -CREATE UNIQUE INDEX IF NOT EXISTS idx_discovered_clients_pending_unique - ON discovered_clients (agent_id, client_name) - WHERE status = 'pending_review'; - -COMMIT; - -PRAGMA foreign_keys = ON; - --- +goose Down -SELECT 1; diff --git a/db/migrations/sqlite/0027_fix_job_targets_check_enum.sql b/db/migrations/sqlite/0027_fix_job_targets_check_enum.sql deleted file mode 100644 index 3d490d07..00000000 --- a/db/migrations/sqlite/0027_fix_job_targets_check_enum.sql +++ /dev/null @@ -1,9 +0,0 @@ --- +goose Up --- SQLite's matching migration is a no-op: 0026 in this repo already --- ships the corrected enum (queued / sent / acknowledged / succeeded --- / failed / expired). This file exists so the version numbers stay --- aligned across the postgres + sqlite trees. -SELECT 1; - --- +goose Down -SELECT 1; diff --git a/db/migrations/sqlite/0028_cascade_fk_grants_discovered_instances.sql b/db/migrations/sqlite/0028_cascade_fk_grants_discovered_instances.sql deleted file mode 100644 index 1f4b172b..00000000 --- a/db/migrations/sqlite/0028_cascade_fk_grants_discovered_instances.sql +++ /dev/null @@ -1,124 +0,0 @@ --- +goose Up --- +goose NO TRANSACTION --- Add ON DELETE CASCADE to the remaining FKs that point at agents (id): --- * telemt_instances — 0022 was a no-op based on the wrong --- assumption that 0012_cascade_fk had --- rebuilt this table; it had not. --- * agent_certificate_recovery_grants — never patched. --- * discovered_clients — rebuilt in 0026 without cascade. --- --- Without these, DELETE FROM agents fails with a FOREIGN KEY constraint --- error whenever any of these tables hold rows for the deregistered agent --- (see http_agents.persistAgentDeregister). --- --- Dev-stage policy (matches 0012/0026): orphan rows are purged before --- the rebuild so the new constraint does not abort the copy. - -PRAGMA foreign_keys = OFF; - --- ─── telemt_instances ──────────────────────────────────────────────── --- Each table rebuild below is wrapped in its own explicit transaction so a --- crash between DROP and RENAME can never leave a table dropped-but-not- --- renamed. PRAGMA foreign_keys stays outside every BEGIN/COMMIT — SQLite --- forbids toggling it inside a transaction. -DELETE FROM telemt_instances WHERE agent_id NOT IN (SELECT id FROM agents); - -BEGIN; - -CREATE TABLE telemt_instances_new ( - id TEXT PRIMARY KEY, - agent_id TEXT NOT NULL, - name TEXT NOT NULL, - version TEXT NOT NULL DEFAULT '', - config_fingerprint TEXT NOT NULL DEFAULT '', - connected_users INTEGER NOT NULL DEFAULT 0, - read_only INTEGER NOT NULL DEFAULT 0, - updated_at_unix INTEGER NOT NULL, - FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE -); - -INSERT INTO telemt_instances_new (id, agent_id, name, version, config_fingerprint, connected_users, read_only, updated_at_unix) -SELECT id, agent_id, name, version, config_fingerprint, connected_users, read_only, updated_at_unix FROM telemt_instances; - -DROP TABLE telemt_instances; -ALTER TABLE telemt_instances_new RENAME TO telemt_instances; - -CREATE INDEX IF NOT EXISTS idx_telemt_instances_agent_id ON telemt_instances (agent_id); - -COMMIT; - --- ─── agent_certificate_recovery_grants ─────────────────────────────── -DELETE FROM agent_certificate_recovery_grants WHERE agent_id NOT IN (SELECT id FROM agents); - -BEGIN; - -CREATE TABLE agent_certificate_recovery_grants_new ( - agent_id TEXT PRIMARY KEY, - issued_by TEXT NOT NULL, - issued_at_unix INTEGER NOT NULL, - expires_at_unix INTEGER NOT NULL, - used_at_unix INTEGER, - revoked_at_unix INTEGER, - FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE -); - -INSERT INTO agent_certificate_recovery_grants_new (agent_id, issued_by, issued_at_unix, expires_at_unix, used_at_unix, revoked_at_unix) -SELECT agent_id, issued_by, issued_at_unix, expires_at_unix, used_at_unix, revoked_at_unix FROM agent_certificate_recovery_grants; - -DROP TABLE agent_certificate_recovery_grants; -ALTER TABLE agent_certificate_recovery_grants_new RENAME TO agent_certificate_recovery_grants; - -COMMIT; - --- ─── discovered_clients ────────────────────────────────────────────── --- Schema mirrors the post-0026 definition exactly; only the FK gains --- ON DELETE CASCADE. -DELETE FROM discovered_clients WHERE agent_id NOT IN (SELECT id FROM agents); - -BEGIN; - -CREATE TABLE discovered_clients_new ( - id TEXT PRIMARY KEY, - agent_id TEXT NOT NULL, - client_name TEXT NOT NULL, - secret TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'pending_review' CHECK (status IN ('pending_review','adopted','ignored')), - total_octets INTEGER NOT NULL DEFAULT 0, - current_connections INTEGER NOT NULL DEFAULT 0, - active_unique_ips INTEGER NOT NULL DEFAULT 0, - connection_link TEXT NOT NULL DEFAULT '', - max_tcp_conns INTEGER NOT NULL DEFAULT 0, - max_unique_ips INTEGER NOT NULL DEFAULT 0, - data_quota_bytes INTEGER NOT NULL DEFAULT 0, - expiration TEXT NOT NULL DEFAULT '', - discovered_at_unix INTEGER NOT NULL, - updated_at_unix INTEGER NOT NULL, - UNIQUE (agent_id, client_name), - FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE -); - -INSERT INTO discovered_clients_new ( - id, agent_id, client_name, secret, status, total_octets, current_connections, - active_unique_ips, connection_link, max_tcp_conns, max_unique_ips, - data_quota_bytes, expiration, discovered_at_unix, updated_at_unix -) -SELECT id, agent_id, client_name, secret, status, total_octets, current_connections, - active_unique_ips, connection_link, max_tcp_conns, max_unique_ips, - data_quota_bytes, expiration, discovered_at_unix, updated_at_unix -FROM discovered_clients; - -DROP TABLE discovered_clients; -ALTER TABLE discovered_clients_new RENAME TO discovered_clients; - -CREATE INDEX IF NOT EXISTS idx_discovered_clients_agent_id ON discovered_clients (agent_id); -CREATE UNIQUE INDEX IF NOT EXISTS idx_discovered_clients_pending_unique - ON discovered_clients (agent_id, client_name) - WHERE status = 'pending_review'; - -COMMIT; - -PRAGMA foreign_keys = ON; - --- +goose Down --- Dev-stage: drop+recreate acceptable, no rollback. -SELECT 1; diff --git a/db/migrations/sqlite/0029_connection_links_array.sql b/db/migrations/sqlite/0029_connection_links_array.sql deleted file mode 100644 index 6287eea3..00000000 --- a/db/migrations/sqlite/0029_connection_links_array.sql +++ /dev/null @@ -1,37 +0,0 @@ --- +goose Up --- +goose NO TRANSACTION --- Replace the single-link `connection_link` column on client_deployments --- and discovered_clients with `connection_links` carrying a JSON array. --- Telemt's tls_domains config emits one TLS link per domain (×host); the --- agent used to collapse that to a single string and the panel never saw --- the alternates the operator configured. --- --- Dev-stage: drop the legacy column outright. Existing rows with a single --- non-empty link are migrated into the new JSON array via a one-shot --- UPDATE before the column drop so live data isn't lost. - -PRAGMA foreign_keys = OFF; - --- ─── client_deployments ────────────────────────────────────────────── -ALTER TABLE client_deployments ADD COLUMN connection_links TEXT NOT NULL DEFAULT '[]'; - -UPDATE client_deployments -SET connection_links = json_array(connection_link) -WHERE connection_link != ''; - -ALTER TABLE client_deployments DROP COLUMN connection_link; - --- ─── discovered_clients ────────────────────────────────────────────── -ALTER TABLE discovered_clients ADD COLUMN connection_links TEXT NOT NULL DEFAULT '[]'; - -UPDATE discovered_clients -SET connection_links = json_array(connection_link) -WHERE connection_link != ''; - -ALTER TABLE discovered_clients DROP COLUMN connection_link; - -PRAGMA foreign_keys = ON; - --- +goose Down --- Dev-stage: drop+recreate acceptable, no rollback. -SELECT 1; diff --git a/db/migrations/sqlite/0030_node_transport_mode.sql b/db/migrations/sqlite/0030_node_transport_mode.sql deleted file mode 100644 index e482c616..00000000 --- a/db/migrations/sqlite/0030_node_transport_mode.sql +++ /dev/null @@ -1,27 +0,0 @@ --- +goose Up --- Add transport-mode fields to agents so the panel can distinguish inbound --- agents (the default: agent dials the panel) from outbound/reverse-tunnel --- agents (panel dials the agent via a stored dial_address). --- bootstrap_* columns support a one-time token exchange for outbound agents. - -ALTER TABLE agents ADD COLUMN transport_mode TEXT NOT NULL DEFAULT 'inbound' - CHECK (transport_mode IN ('inbound', 'outbound')); -ALTER TABLE agents ADD COLUMN dial_address TEXT; -ALTER TABLE agents ADD COLUMN bootstrap_state TEXT NOT NULL DEFAULT 'active' - CHECK (bootstrap_state IN ('pending', 'active', 'expired', 'revoked')); -ALTER TABLE agents ADD COLUMN bootstrap_token_hash BLOB; -ALTER TABLE agents ADD COLUMN bootstrap_expires_at INTEGER; - -CREATE INDEX IF NOT EXISTS idx_agents_transport_mode ON agents(transport_mode); - --- Existing agents default to bootstrap_state=active (already enrolled). --- New outbound agents are created with bootstrap_state=pending --- (see bootstrap-flow in subsequent tasks). - --- +goose Down -DROP INDEX IF EXISTS idx_agents_transport_mode; -ALTER TABLE agents DROP COLUMN bootstrap_expires_at; -ALTER TABLE agents DROP COLUMN bootstrap_token_hash; -ALTER TABLE agents DROP COLUMN bootstrap_state; -ALTER TABLE agents DROP COLUMN dial_address; -ALTER TABLE agents DROP COLUMN transport_mode; diff --git a/db/migrations/sqlite/0031_agent_fallback_state.sql b/db/migrations/sqlite/0031_agent_fallback_state.sql deleted file mode 100644 index 48bab8fa..00000000 --- a/db/migrations/sqlite/0031_agent_fallback_state.sql +++ /dev/null @@ -1,13 +0,0 @@ --- +goose Up -CREATE TABLE IF NOT EXISTS agent_fallback_state ( - agent_id TEXT PRIMARY KEY, - entered_at_unix INTEGER NOT NULL, - FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE -); - -CREATE INDEX IF NOT EXISTS idx_agent_fallback_state_entered_at - ON agent_fallback_state (entered_at_unix); - --- +goose Down -DROP INDEX IF EXISTS idx_agent_fallback_state_entered_at; -DROP TABLE IF EXISTS agent_fallback_state; diff --git a/db/migrations/sqlite/0032_password_policy.sql b/db/migrations/sqlite/0032_password_policy.sql deleted file mode 100644 index 748e4ef1..00000000 --- a/db/migrations/sqlite/0032_password_policy.sql +++ /dev/null @@ -1,10 +0,0 @@ --- +goose Up --- S-01: configurable minimum password length on panel_settings. --- SQLite ALTER TABLE supports ADD COLUMN with NOT NULL+DEFAULT since 3.35. --- CHECK constraint must be inline (cannot ADD CHECK on existing column). -ALTER TABLE panel_settings ADD COLUMN password_min_length INTEGER NOT NULL DEFAULT 10; - --- +goose Down --- SQLite < 3.35 cannot DROP COLUMN; modern builds (>= 3.35) used by --- modernc.org/sqlite v1.50 support it. -ALTER TABLE panel_settings DROP COLUMN password_min_length; diff --git a/db/migrations/sqlite/0033_agent_cert_pin.sql b/db/migrations/sqlite/0033_agent_cert_pin.sql deleted file mode 100644 index 22fd5e3f..00000000 --- a/db/migrations/sqlite/0033_agent_cert_pin.sql +++ /dev/null @@ -1,15 +0,0 @@ --- +goose Up --- S-02: SPKI pin column. SQLite stores BLOB; partial indexes are supported --- since 3.8.0 (modernc/sqlite v1.50 ships SQLite 3.46+). --- CHECK constraint must be inline (cannot ADD CHECK on existing column); --- mirrors the same restriction documented in 0032_password_policy.sql. -ALTER TABLE agents ADD COLUMN cert_spki_sha256 BLOB NOT NULL DEFAULT x''; - -CREATE INDEX idx_agents_cert_spki_sha256 - ON agents (cert_spki_sha256) - WHERE length(cert_spki_sha256) > 0; - --- +goose Down --- SQLite >= 3.35 supports DROP COLUMN (modernc v1.50 ships 3.46+). -DROP INDEX IF EXISTS idx_agents_cert_spki_sha256; -ALTER TABLE agents DROP COLUMN IF EXISTS cert_spki_sha256; diff --git a/db/migrations/sqlite/0034_geoip_settings.sql b/db/migrations/sqlite/0034_geoip_settings.sql deleted file mode 100644 index 37c99029..00000000 --- a/db/migrations/sqlite/0034_geoip_settings.sql +++ /dev/null @@ -1,7 +0,0 @@ --- +goose Up -ALTER TABLE panel_settings ADD COLUMN geoip_json TEXT NOT NULL DEFAULT ''; -ALTER TABLE panel_settings ADD COLUMN geoip_state_json TEXT NOT NULL DEFAULT ''; - --- +goose Down -ALTER TABLE panel_settings DROP COLUMN geoip_state_json; -ALTER TABLE panel_settings DROP COLUMN geoip_json; diff --git a/db/migrations/sqlite/0035_telemt_unreachable.sql b/db/migrations/sqlite/0035_telemt_unreachable.sql deleted file mode 100644 index 08043510..00000000 --- a/db/migrations/sqlite/0035_telemt_unreachable.sql +++ /dev/null @@ -1,9 +0,0 @@ --- +goose Up -ALTER TABLE telemt_runtime_current - ADD COLUMN telemt_reachable INTEGER NOT NULL DEFAULT 1; -ALTER TABLE telemt_runtime_current - ADD COLUMN telemt_unreachable_since_unix INTEGER NOT NULL DEFAULT 0; - --- +goose Down -ALTER TABLE telemt_runtime_current DROP COLUMN telemt_unreachable_since_unix; -ALTER TABLE telemt_runtime_current DROP COLUMN telemt_reachable; diff --git a/db/migrations/sqlite/0036_drop_bootstrap_columns_from_panel_settings.sql b/db/migrations/sqlite/0036_drop_bootstrap_columns_from_panel_settings.sql deleted file mode 100644 index 28630103..00000000 --- a/db/migrations/sqlite/0036_drop_bootstrap_columns_from_panel_settings.sql +++ /dev/null @@ -1,30 +0,0 @@ --- +goose Up --- 0036: drop bootstrap-only columns from panel_settings. --- Project is pre-release; no compatibility shim. --- These columns are now sourced from env/config.toml via the settings registry. - -CREATE TABLE panel_settings_backup ( - scope TEXT PRIMARY KEY, - http_public_url TEXT NOT NULL DEFAULT '', - grpc_public_endpoint TEXT NOT NULL DEFAULT '', - password_min_length INTEGER NOT NULL DEFAULT 10, - retention_json TEXT NOT NULL DEFAULT '', - geoip_json TEXT NOT NULL DEFAULT '', - geoip_state_json TEXT NOT NULL DEFAULT '', - updated_at_unix INTEGER NOT NULL -); - -INSERT INTO panel_settings_backup - (scope, http_public_url, grpc_public_endpoint, - password_min_length, retention_json, geoip_json, - geoip_state_json, updated_at_unix) -SELECT scope, http_public_url, grpc_public_endpoint, - password_min_length, retention_json, geoip_json, - geoip_state_json, updated_at_unix - FROM panel_settings; - -DROP TABLE panel_settings; -ALTER TABLE panel_settings_backup RENAME TO panel_settings; - --- +goose Down --- intentionally empty (pre-release, no compatibility shim) diff --git a/db/migrations/sqlite/0037_runtime_settings.sql b/db/migrations/sqlite/0037_runtime_settings.sql deleted file mode 100644 index f129e287..00000000 --- a/db/migrations/sqlite/0037_runtime_settings.sql +++ /dev/null @@ -1,12 +0,0 @@ --- +goose Up --- 0037: kv-table for operational settings without dedicated columns. --- Used for fields produced by sub-project B (poll intervals, etc.). -CREATE TABLE runtime_settings ( - name TEXT PRIMARY KEY, - value_json TEXT NOT NULL, - updated_at INTEGER NOT NULL, - updated_by TEXT NOT NULL DEFAULT '' -); - --- +goose Down -DROP TABLE runtime_settings; diff --git a/db/migrations/sqlite/0038_audit_hash_chain.sql b/db/migrations/sqlite/0038_audit_hash_chain.sql deleted file mode 100644 index a456fe9b..00000000 --- a/db/migrations/sqlite/0038_audit_hash_chain.sql +++ /dev/null @@ -1,22 +0,0 @@ --- +goose Up --- 0038: tamper-evident audit chain. --- Mirror of the postgres 0038 migration. Pre-release: existing rows --- carry the empty default; the chain begins forming on the first --- post-migration write. --- --- SQLite cannot add multiple columns in one ALTER TABLE statement, --- so we issue two separate ALTERs. This is a no-op rewrite under --- modernc.org/sqlite (column adds are O(1) for non-PRIMARY KEY columns). - -ALTER TABLE audit_events ADD COLUMN prev_hash TEXT NOT NULL DEFAULT ''; -ALTER TABLE audit_events ADD COLUMN event_hash TEXT NOT NULL DEFAULT ''; - --- Helper index for the chain walker. SQLite's audit_events uses --- created_at_unix (the postgres companion stores TIMESTAMPTZ), so the --- index columns differ; the verifier reads in (timestamp, id) --- ascending order and the planner picks this index for both forms. -CREATE INDEX IF NOT EXISTS idx_audit_events_chain_walk - ON audit_events (created_at_unix, id); - --- +goose Down --- intentionally empty (pre-release, no compatibility shim) diff --git a/db/migrations/sqlite/0039_webhook_outbox.sql b/db/migrations/sqlite/0039_webhook_outbox.sql deleted file mode 100644 index ab68e9ad..00000000 --- a/db/migrations/sqlite/0039_webhook_outbox.sql +++ /dev/null @@ -1,37 +0,0 @@ --- +goose Up --- 0039: webhook outbox + endpoints (SQLite mirror of postgres). --- SQLite: BOOLEAN as INTEGER (0/1), JSONB as TEXT (stored as JSON), --- TIMESTAMPTZ as TIMESTAMP. The store layer normalises both --- backends to the same Go types. - -CREATE TABLE webhook_endpoints ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL UNIQUE, - url TEXT NOT NULL, - secret_ciphertext TEXT NOT NULL, - event_filter TEXT NOT NULL DEFAULT '', - allow_private INTEGER NOT NULL DEFAULT 0 CHECK (allow_private IN (0, 1)), - enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)), - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE webhook_outbox ( - id TEXT PRIMARY KEY, - endpoint_id TEXT NOT NULL REFERENCES webhook_endpoints(id) ON DELETE CASCADE, - event_action TEXT NOT NULL, - payload TEXT NOT NULL, - attempt INTEGER NOT NULL DEFAULT 0, - next_attempt_at TIMESTAMP NOT NULL, - last_error TEXT NOT NULL DEFAULT '', - dead INTEGER NOT NULL DEFAULT 0 CHECK (dead IN (0, 1)), - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - delivered_at TIMESTAMP -); - -CREATE INDEX idx_webhook_outbox_ready - ON webhook_outbox (next_attempt_at) - WHERE dead = 0 AND delivered_at IS NULL; - --- +goose Down --- intentionally empty (pre-release, no compatibility shim) diff --git a/db/migrations/sqlite/0040_invert_telemt_reachability.sql b/db/migrations/sqlite/0040_invert_telemt_reachability.sql deleted file mode 100644 index 70254094..00000000 --- a/db/migrations/sqlite/0040_invert_telemt_reachability.sql +++ /dev/null @@ -1,16 +0,0 @@ --- +goose Up --- Invert the telemt reachability flag so proto3's bool default (false) --- correctly represents the healthy/reachable case. Previously --- telemt_reachable=1 meant healthy; now telemt_unreachable=0 means healthy. -ALTER TABLE telemt_runtime_current - ADD COLUMN telemt_unreachable INTEGER NOT NULL DEFAULT 0; -UPDATE telemt_runtime_current - SET telemt_unreachable = CASE WHEN telemt_reachable = 0 THEN 1 ELSE 0 END; -ALTER TABLE telemt_runtime_current DROP COLUMN telemt_reachable; - --- +goose Down -ALTER TABLE telemt_runtime_current - ADD COLUMN telemt_reachable INTEGER NOT NULL DEFAULT 1; -UPDATE telemt_runtime_current - SET telemt_reachable = CASE WHEN telemt_unreachable = 0 THEN 1 ELSE 0 END; -ALTER TABLE telemt_runtime_current DROP COLUMN telemt_unreachable; diff --git a/db/migrations/sqlite/0041_enrollment_attempts.sql b/db/migrations/sqlite/0041_enrollment_attempts.sql deleted file mode 100644 index 9f31fc61..00000000 --- a/db/migrations/sqlite/0041_enrollment_attempts.sql +++ /dev/null @@ -1,38 +0,0 @@ --- +goose Up -CREATE TABLE enrollment_attempts ( - id TEXT PRIMARY KEY, - token_id TEXT, - agent_id TEXT, - mode TEXT NOT NULL CHECK (mode IN ('inbound', 'outbound')), - client_addr TEXT, - request_id TEXT NOT NULL, - status TEXT NOT NULL CHECK (status IN ('in_progress', 'success', 'failed')), - error_code TEXT, - error_message TEXT, - started_at TIMESTAMP NOT NULL, - finished_at TIMESTAMP -); - -CREATE INDEX idx_enrollment_attempts_token ON enrollment_attempts(token_id); -CREATE INDEX idx_enrollment_attempts_agent ON enrollment_attempts(agent_id); -CREATE INDEX idx_enrollment_attempts_started ON enrollment_attempts(started_at); - -CREATE TABLE enrollment_events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - attempt_id TEXT NOT NULL REFERENCES enrollment_attempts(id) ON DELETE CASCADE, - ts TIMESTAMP NOT NULL, - step TEXT NOT NULL, - level TEXT NOT NULL CHECK (level IN ('info', 'warn', 'error')), - message TEXT, - fields_json TEXT -); - -CREATE INDEX idx_enrollment_events_attempt ON enrollment_events(attempt_id, ts); - --- +goose Down -DROP INDEX IF EXISTS idx_enrollment_events_attempt; -DROP TABLE IF EXISTS enrollment_events; -DROP INDEX IF EXISTS idx_enrollment_attempts_started; -DROP INDEX IF EXISTS idx_enrollment_attempts_agent; -DROP INDEX IF EXISTS idx_enrollment_attempts_token; -DROP TABLE IF EXISTS enrollment_attempts; diff --git a/db/migrations/sqlite/0042_client_deployment_last_reset.sql b/db/migrations/sqlite/0042_client_deployment_last_reset.sql deleted file mode 100644 index a4f816d9..00000000 --- a/db/migrations/sqlite/0042_client_deployment_last_reset.sql +++ /dev/null @@ -1,12 +0,0 @@ --- +goose Up --- Phase 3: per-(client, agent) record of when the panel last applied a --- successful quota reset. Compared against Telemt's reported --- last_reset_epoch_secs on each ClientUsage snapshot to detect drift — --- i.e. cases where the panel-driven reset job succeeded but Telemt's --- persisted quota state has fallen behind (Telemt restart before --- sidecar flush, raw curl reset out of band, etc.). -ALTER TABLE client_deployments - ADD COLUMN last_reset_epoch_secs INTEGER NOT NULL DEFAULT 0; - --- +goose Down -ALTER TABLE client_deployments DROP COLUMN last_reset_epoch_secs; diff --git a/db/migrations/sqlite/0043_client_usage_quota.sql b/db/migrations/sqlite/0043_client_usage_quota.sql deleted file mode 100644 index b2745a8a..00000000 --- a/db/migrations/sqlite/0043_client_usage_quota.sql +++ /dev/null @@ -1,13 +0,0 @@ --- +goose Up --- IN-H2: persist the per-(client, agent) quota counters alongside the other --- usage fields. Previously quota_used_bytes / quota_last_reset_unix lived --- only in the in-memory mirror, so a panel restart snapped them to 0 until --- the next agent usage tick repopulated them. -ALTER TABLE client_usage - ADD COLUMN quota_used_bytes INTEGER NOT NULL DEFAULT 0; -ALTER TABLE client_usage - ADD COLUMN quota_last_reset_unix INTEGER NOT NULL DEFAULT 0; - --- +goose Down -ALTER TABLE client_usage DROP COLUMN quota_last_reset_unix; -ALTER TABLE client_usage DROP COLUMN quota_used_bytes; diff --git a/db/migrations/sqlite/0044_drop_enrollment_token_value_hash.sql b/db/migrations/sqlite/0044_drop_enrollment_token_value_hash.sql deleted file mode 100644 index c10e8188..00000000 --- a/db/migrations/sqlite/0044_drop_enrollment_token_value_hash.sql +++ /dev/null @@ -1,24 +0,0 @@ --- +goose Up --- L-4: drop the dead enrollment_tokens.value_hash column (see the Postgres --- 0044 mirror). The column is indexed, and SQLite cannot DROP an indexed --- column inline, so rebuild the table without it (same recipe as 0021's --- Down) and recreate the surviving fleet_group_id index. Pre-release, no --- compatibility shim. -DROP INDEX IF EXISTS idx_enrollment_tokens_value_hash; -CREATE TABLE enrollment_tokens_new ( - value TEXT PRIMARY KEY, - fleet_group_id TEXT, - issued_at_unix INTEGER NOT NULL, - expires_at_unix INTEGER NOT NULL, - consumed_at_unix INTEGER, - revoked_at_unix INTEGER -); -INSERT INTO enrollment_tokens_new (value, fleet_group_id, issued_at_unix, expires_at_unix, consumed_at_unix, revoked_at_unix) - SELECT value, fleet_group_id, issued_at_unix, expires_at_unix, consumed_at_unix, revoked_at_unix - FROM enrollment_tokens; -DROP TABLE enrollment_tokens; -ALTER TABLE enrollment_tokens_new RENAME TO enrollment_tokens; -CREATE INDEX idx_enrollment_tokens_fleet_group_id ON enrollment_tokens (fleet_group_id); - --- +goose Down --- intentionally empty (pre-release, no compatibility shim) diff --git a/db/migrations/sqlite/0045_client_deployment_link_diagnostic.sql b/db/migrations/sqlite/0045_client_deployment_link_diagnostic.sql deleted file mode 100644 index 7c1b7980..00000000 --- a/db/migrations/sqlite/0045_client_deployment_link_diagnostic.sql +++ /dev/null @@ -1,11 +0,0 @@ --- +goose Up --- IN-M2: operator-facing diagnostic stamped on a successful (non-delete) --- apply when the node returned no connection links. In that case the --- existing connection_links are kept but may be stale after a host or --- secret change; this column explains why so the dashboard can flag the --- link instead of silently serving it. Empty string means "no issue". -ALTER TABLE client_deployments - ADD COLUMN link_diagnostic TEXT NOT NULL DEFAULT ''; - --- +goose Down -ALTER TABLE client_deployments DROP COLUMN link_diagnostic; diff --git a/db/migrations/sqlite/0046_rename_connected_users_to_connections.sql b/db/migrations/sqlite/0046_rename_connected_users_to_connections.sql deleted file mode 100644 index 426685d9..00000000 --- a/db/migrations/sqlite/0046_rename_connected_users_to_connections.sql +++ /dev/null @@ -1,8 +0,0 @@ --- +goose Up --- IN-M3: rename telemt_instances.connected_users -> connections (see the --- Postgres 0046 mirror). The column holds the telemt CurrentConnections --- counter, not a users count. SQLite 3.25+ supports RENAME COLUMN inline. -ALTER TABLE telemt_instances RENAME COLUMN connected_users TO connections; - --- +goose Down -ALTER TABLE telemt_instances RENAME COLUMN connections TO connected_users; diff --git a/db/migrations/sqlite/0047_drop_telemt_detail_boosts.sql b/db/migrations/sqlite/0047_drop_telemt_detail_boosts.sql deleted file mode 100644 index 7dcd52a7..00000000 --- a/db/migrations/sqlite/0047_drop_telemt_detail_boosts.sql +++ /dev/null @@ -1,14 +0,0 @@ --- +goose Up --- F4: drop the persisted detail-boost table. Detail boost is an ephemeral --- ~10m telemetry-frequency bump for a single agent and now lives only in --- memory on the panel (s.detailBoosts). It is intentionally not durable — --- if the panel restarts the operator simply re-enables it. -DROP TABLE IF EXISTS telemt_detail_boosts; - --- +goose Down -CREATE TABLE IF NOT EXISTS telemt_detail_boosts ( - agent_id TEXT PRIMARY KEY, - expires_at_unix INTEGER NOT NULL, - updated_at_unix INTEGER NOT NULL, - FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE -); diff --git a/db/migrations/sqlite/0048_jobs_status_partial.sql b/db/migrations/sqlite/0048_jobs_status_partial.sql deleted file mode 100644 index 30e5fb89..00000000 --- a/db/migrations/sqlite/0048_jobs_status_partial.sql +++ /dev/null @@ -1,52 +0,0 @@ --- +goose Up --- +goose NO TRANSACTION --- F2: deriveJobStatus now emits a new terminal job status, "partial", for a --- MIXED outcome (at least one target succeeded AND at least one ended --- terminally-unsuccessful). The jobs.status CHECK constraint (born in 0026 / --- baseline) only allowed queued/running/succeeded/failed/expired, so the very --- first attempt to persist a partial job tripped "CHECK constraint failed: --- status IN (...)" and the write was silently dropped — the restored job --- reverted to its last-valid persisted status (running, or expired after TTL). --- --- SQLite has no ALTER TABLE ... ADD/DROP CONSTRAINT, so rebuild the jobs table --- with the widened enum using the rename → recreate → copy → drop → restore --- pattern from 0026. job_targets is untouched: target rows never take the --- "partial" value (it is a job-level rollup only). - -PRAGMA foreign_keys = OFF; - --- The rebuild itself (create/copy/drop/rename/index) is wrapped in an --- explicit transaction so a crash between DROP and RENAME can never leave --- the table dropped-but-not-renamed. PRAGMA foreign_keys cannot be toggled --- inside a transaction, so it stays outside as its own statement (goose --- still runs this whole file outside ITS wrapping transaction because of --- "NO TRANSACTION" above; the BEGIN/COMMIT below is our own explicit one). -BEGIN; - -CREATE TABLE jobs_new ( - id TEXT PRIMARY KEY, - action TEXT NOT NULL, - actor_id TEXT NOT NULL, - status TEXT NOT NULL CHECK (status IN ('queued','running','succeeded','failed','expired','partial')), - created_at_unix INTEGER NOT NULL, - ttl_nanos INTEGER NOT NULL, - idempotency_key TEXT NOT NULL UNIQUE, - payload_json TEXT NOT NULL DEFAULT '' -); - -INSERT INTO jobs_new (id, action, actor_id, status, created_at_unix, ttl_nanos, idempotency_key, payload_json) -SELECT id, action, actor_id, status, created_at_unix, ttl_nanos, idempotency_key, payload_json FROM jobs; - -DROP TABLE jobs; -ALTER TABLE jobs_new RENAME TO jobs; - -CREATE INDEX IF NOT EXISTS idx_jobs_created_at ON jobs (created_at_unix); -CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs (status); -CREATE INDEX IF NOT EXISTS idx_jobs_actor_id ON jobs (actor_id); - -COMMIT; - -PRAGMA foreign_keys = ON; - --- +goose Down -SELECT 1; diff --git a/db/migrations/sqlite/0049_agent_config_targets.sql b/db/migrations/sqlite/0049_agent_config_targets.sql deleted file mode 100644 index 05e78811..00000000 --- a/db/migrations/sqlite/0049_agent_config_targets.sql +++ /dev/null @@ -1,18 +0,0 @@ --- +goose Up --- agent_config_targets stores the operator's DESIRED Telemt config per scope. --- scope_type is 'group' (scope_id = fleet_groups.id) or 'agent' (scope_id = --- agents.id). sections_json is a sparse JSON object of editable config sections --- (general/timeouts/censorship/upstreams/show_link/dc_overrides). The effective --- config of an agent is its group target merged with its agent override. --- SQLite: TIMESTAMPTZ as TIMESTAMP (store layer normalises to Go types). -CREATE TABLE IF NOT EXISTS agent_config_targets ( - scope_type TEXT NOT NULL, - scope_id TEXT NOT NULL, - sections_json TEXT NOT NULL DEFAULT '{}', - created_at TIMESTAMP NOT NULL, - updated_at TIMESTAMP NOT NULL, - PRIMARY KEY (scope_type, scope_id) -); - --- +goose Down -DROP TABLE IF EXISTS agent_config_targets; diff --git a/db/migrations/sqlite/0050_check_parity.sql b/db/migrations/sqlite/0050_check_parity.sql deleted file mode 100644 index ce228f38..00000000 --- a/db/migrations/sqlite/0050_check_parity.sql +++ /dev/null @@ -1,160 +0,0 @@ --- +goose Up --- C1: Align SQLite schemas with Postgres. --- 1. panel_settings: add CHECK (password_min_length >= 8 AND password_min_length <= 128) --- 2. agents: add CHECK (length(cert_spki_sha256) IN (0, 32)) --- 3. enrollment_tokens: add FOREIGN KEY (fleet_group_id) REFERENCES fleet_groups (id) --- ON DELETE SET NULL — a group deleted out from under outstanding tokens should --- null their scope, not block the delete (NO ACTION) and not silently vanish; --- matches the provider_id SET NULL convention and the documented enrollment design. --- SQLite cannot ALTER TABLE to add CHECK or FK constraints; each table must be rebuilt. - -PRAGMA foreign_keys=OFF; - --- 1. panel_settings: add CHECK on password_min_length. -CREATE TABLE panel_settings_new ( - scope TEXT PRIMARY KEY, - http_public_url TEXT NOT NULL DEFAULT '', - grpc_public_endpoint TEXT NOT NULL DEFAULT '', - password_min_length INTEGER NOT NULL DEFAULT 10 - CHECK (password_min_length >= 8 AND password_min_length <= 128), - retention_json TEXT NOT NULL DEFAULT '', - geoip_json TEXT NOT NULL DEFAULT '', - geoip_state_json TEXT NOT NULL DEFAULT '', - updated_at_unix INTEGER NOT NULL -); -INSERT INTO panel_settings_new (scope, http_public_url, grpc_public_endpoint, password_min_length, retention_json, geoip_json, geoip_state_json, updated_at_unix) - SELECT scope, http_public_url, grpc_public_endpoint, password_min_length, retention_json, geoip_json, geoip_state_json, updated_at_unix - FROM panel_settings; -DROP TABLE panel_settings; -ALTER TABLE panel_settings_new RENAME TO panel_settings; --- panel_settings has no non-PK indexes. - --- 2. agents: add CHECK (length(cert_spki_sha256) IN (0, 32)). -CREATE TABLE agents_new ( - id TEXT PRIMARY KEY, - node_name TEXT NOT NULL, - fleet_group_id TEXT, - version TEXT NOT NULL DEFAULT '', - read_only INTEGER NOT NULL DEFAULT 0, - last_seen_at_unix INTEGER NOT NULL, - created_at_unix INTEGER NOT NULL DEFAULT 0, - cert_issued_at_unix INTEGER, - cert_expires_at_unix INTEGER, - cert_serial TEXT NOT NULL DEFAULT '', - transport_mode TEXT NOT NULL DEFAULT 'inbound' - CHECK (transport_mode IN ('inbound', 'outbound')), - dial_address TEXT, - bootstrap_state TEXT NOT NULL DEFAULT 'active' - CHECK (bootstrap_state IN ('pending', 'active', 'expired', 'revoked')), - bootstrap_token_hash BLOB, - bootstrap_expires_at INTEGER, - cert_spki_sha256 BLOB NOT NULL DEFAULT x'' - CHECK (length(cert_spki_sha256) IN (0, 32)), - FOREIGN KEY (fleet_group_id) REFERENCES fleet_groups (id) -); -INSERT INTO agents_new (id, node_name, fleet_group_id, version, read_only, last_seen_at_unix, created_at_unix, cert_issued_at_unix, cert_expires_at_unix, cert_serial, transport_mode, dial_address, bootstrap_state, bootstrap_token_hash, bootstrap_expires_at, cert_spki_sha256) - SELECT id, node_name, fleet_group_id, version, read_only, last_seen_at_unix, created_at_unix, cert_issued_at_unix, cert_expires_at_unix, cert_serial, transport_mode, dial_address, bootstrap_state, bootstrap_token_hash, bootstrap_expires_at, cert_spki_sha256 - FROM agents; -DROP TABLE agents; -ALTER TABLE agents_new RENAME TO agents; --- Recreate all agents indexes. -CREATE INDEX idx_agents_cert_spki_sha256 - ON agents (cert_spki_sha256) - WHERE length(cert_spki_sha256) > 0; -CREATE INDEX idx_agents_fleet_group_id ON agents (fleet_group_id); -CREATE INDEX idx_agents_last_seen_at ON agents (last_seen_at_unix); -CREATE INDEX idx_agents_transport_mode ON agents(transport_mode); - --- 3. enrollment_tokens: add FK ON DELETE SET NULL for fleet_group_id. -CREATE TABLE enrollment_tokens_new ( - value TEXT PRIMARY KEY, - fleet_group_id TEXT, - issued_at_unix INTEGER NOT NULL, - expires_at_unix INTEGER NOT NULL, - consumed_at_unix INTEGER, - revoked_at_unix INTEGER, - FOREIGN KEY (fleet_group_id) REFERENCES fleet_groups (id) ON DELETE SET NULL -); -INSERT INTO enrollment_tokens_new (value, fleet_group_id, issued_at_unix, expires_at_unix, consumed_at_unix, revoked_at_unix) - SELECT value, fleet_group_id, issued_at_unix, expires_at_unix, consumed_at_unix, revoked_at_unix - FROM enrollment_tokens; -DROP TABLE enrollment_tokens; -ALTER TABLE enrollment_tokens_new RENAME TO enrollment_tokens; --- Recreate enrollment_tokens index. -CREATE INDEX idx_enrollment_tokens_fleet_group_id ON enrollment_tokens (fleet_group_id); - -PRAGMA foreign_keys=ON; - --- +goose Down --- Reverse: remove the added CHECKs and the enrollment_tokens FK. - -PRAGMA foreign_keys=OFF; - --- 1. panel_settings: drop CHECK on password_min_length. -CREATE TABLE panel_settings_old ( - scope TEXT PRIMARY KEY, - http_public_url TEXT NOT NULL DEFAULT '', - grpc_public_endpoint TEXT NOT NULL DEFAULT '', - password_min_length INTEGER NOT NULL DEFAULT 10, - retention_json TEXT NOT NULL DEFAULT '', - geoip_json TEXT NOT NULL DEFAULT '', - geoip_state_json TEXT NOT NULL DEFAULT '', - updated_at_unix INTEGER NOT NULL -); -INSERT INTO panel_settings_old (scope, http_public_url, grpc_public_endpoint, password_min_length, retention_json, geoip_json, geoip_state_json, updated_at_unix) - SELECT scope, http_public_url, grpc_public_endpoint, password_min_length, retention_json, geoip_json, geoip_state_json, updated_at_unix - FROM panel_settings; -DROP TABLE panel_settings; -ALTER TABLE panel_settings_old RENAME TO panel_settings; - --- 2. agents: drop CHECK on cert_spki_sha256. -CREATE TABLE agents_old ( - id TEXT PRIMARY KEY, - node_name TEXT NOT NULL, - fleet_group_id TEXT, - version TEXT NOT NULL DEFAULT '', - read_only INTEGER NOT NULL DEFAULT 0, - last_seen_at_unix INTEGER NOT NULL, - created_at_unix INTEGER NOT NULL DEFAULT 0, - cert_issued_at_unix INTEGER, - cert_expires_at_unix INTEGER, - cert_serial TEXT NOT NULL DEFAULT '', - transport_mode TEXT NOT NULL DEFAULT 'inbound' - CHECK (transport_mode IN ('inbound', 'outbound')), - dial_address TEXT, - bootstrap_state TEXT NOT NULL DEFAULT 'active' - CHECK (bootstrap_state IN ('pending', 'active', 'expired', 'revoked')), - bootstrap_token_hash BLOB, - bootstrap_expires_at INTEGER, - cert_spki_sha256 BLOB NOT NULL DEFAULT x'', - FOREIGN KEY (fleet_group_id) REFERENCES fleet_groups (id) -); -INSERT INTO agents_old (id, node_name, fleet_group_id, version, read_only, last_seen_at_unix, created_at_unix, cert_issued_at_unix, cert_expires_at_unix, cert_serial, transport_mode, dial_address, bootstrap_state, bootstrap_token_hash, bootstrap_expires_at, cert_spki_sha256) - SELECT id, node_name, fleet_group_id, version, read_only, last_seen_at_unix, created_at_unix, cert_issued_at_unix, cert_expires_at_unix, cert_serial, transport_mode, dial_address, bootstrap_state, bootstrap_token_hash, bootstrap_expires_at, cert_spki_sha256 - FROM agents; -DROP TABLE agents; -ALTER TABLE agents_old RENAME TO agents; -CREATE INDEX idx_agents_cert_spki_sha256 - ON agents (cert_spki_sha256) - WHERE length(cert_spki_sha256) > 0; -CREATE INDEX idx_agents_fleet_group_id ON agents (fleet_group_id); -CREATE INDEX idx_agents_last_seen_at ON agents (last_seen_at_unix); -CREATE INDEX idx_agents_transport_mode ON agents(transport_mode); - --- 3. enrollment_tokens: remove FK. -CREATE TABLE enrollment_tokens_old ( - value TEXT PRIMARY KEY, - fleet_group_id TEXT, - issued_at_unix INTEGER NOT NULL, - expires_at_unix INTEGER NOT NULL, - consumed_at_unix INTEGER, - revoked_at_unix INTEGER -); -INSERT INTO enrollment_tokens_old (value, fleet_group_id, issued_at_unix, expires_at_unix, consumed_at_unix, revoked_at_unix) - SELECT value, fleet_group_id, issued_at_unix, expires_at_unix, consumed_at_unix, revoked_at_unix - FROM enrollment_tokens; -DROP TABLE enrollment_tokens; -ALTER TABLE enrollment_tokens_old RENAME TO enrollment_tokens; -CREATE INDEX idx_enrollment_tokens_fleet_group_id ON enrollment_tokens (fleet_group_id); - -PRAGMA foreign_keys=ON; diff --git a/db/migrations/sqlite/0051_client_subscription_token.sql b/db/migrations/sqlite/0051_client_subscription_token.sql deleted file mode 100644 index bc1e5293..00000000 --- a/db/migrations/sqlite/0051_client_subscription_token.sql +++ /dev/null @@ -1,10 +0,0 @@ --- +goose Up --- See postgres/0051 for rationale. SQLite UNIQUE indexes treat NULLs as --- distinct, so multiple NULL tokens coexist without a partial predicate. -ALTER TABLE clients ADD COLUMN subscription_token TEXT; -CREATE UNIQUE INDEX IF NOT EXISTS clients_subscription_token_key - ON clients (subscription_token); - --- +goose Down -DROP INDEX IF EXISTS clients_subscription_token_key; -ALTER TABLE clients DROP COLUMN subscription_token; diff --git a/db/migrations/sqlite/0052_json_valid_checks.sql b/db/migrations/sqlite/0052_json_valid_checks.sql deleted file mode 100644 index 3a488219..00000000 --- a/db/migrations/sqlite/0052_json_valid_checks.sql +++ /dev/null @@ -1,346 +0,0 @@ --- +goose Up --- +goose NO TRANSACTION --- M3: On PostgreSQL, JSON-shaped columns are typed JSONB and the server --- rejects malformed JSON at write time. On SQLite these columns are plain --- TEXT with zero validation — malformed JSON is silently accepted, which --- diverges write-acceptance behaviour from PostgreSQL and would let a --- corrupt row survive the offline SQLite→PostgreSQL migration only to --- fail there instead of at the original write. SQLite has no JSONB type, --- so the column stays TEXT; a `json_valid(...)` CHECK constraint is the --- compensating control. --- --- SQLite has no ALTER TABLE ADD CONSTRAINT, so each affected table is --- rebuilt with the create/copy/drop/rename/index recipe established in --- 0026/0048/92b357e5 (M5): every DROP TABLE / RENAME pair is wrapped in --- its own explicit BEGIN/COMMIT so a crash between them can never leave a --- table dropped-but-not-renamed. PRAGMA foreign_keys stays outside every --- BEGIN/COMMIT — SQLite forbids toggling it inside a transaction — which --- is also why this whole file runs under NO TRANSACTION. --- --- Nine columns across eight tables are covered. Per-column CHECK/default --- decision (all chosen so NO existing valid row can be rejected by the --- new CHECK): --- --- jobs.payload_json — default stays '' (permissive CHECK: --- `payload_json = '' OR json_valid(payload_json)`). Unlike the --- other columns, '' is a real, exercised sentinel meaning "no --- payload" (see jobs.Repository's runPutNilPayload contract test --- and PutJob call sites that persist a job before its payload is --- known) — rewriting the default to '{}' would not by itself stop --- callers from continuing to write '' explicitly, so the permissive --- form is the only option that does not reject live traffic. --- audit_events.details — already defaults to '{}' (valid JSON, --- object shape); plain `CHECK (json_valid(details))`. --- metric_snapshots."values" — NOT NULL, no default; every write goes --- through encodeJSON(map[string]uint64), always valid JSON; plain --- `CHECK (json_valid("values"))`. --- integration_providers.config — already defaults to '{}' (object --- shape) but is NOT always plain JSON: fleet.Service. --- encryptProviderConfig seals it under the vault's --- "integration_config" domain when a vault is configured, storing --- a "PVS1:"/"PVS2:"/"PVS3:" prefixed ciphertext string instead --- (internal/controlplane/secretvault). A plain json_valid CHECK --- would reject every encrypted row, so this column gets the --- permissive form: `CHECK (json_valid(config) OR config LIKE --- 'PVS_:%')` — the single-char wildcard covers all three prefix --- generations without hardcoding each one. --- fleet_group_integrations.config — already defaults to '{}' (object --- shape); plain `CHECK (json_valid(config))`. --- client_deployments.connection_links — already defaults to '[]' --- (array shape); every write goes through encodeStringArray, always --- valid JSON; plain `CHECK (json_valid(connection_links))`. --- discovered_clients.connection_links — same as above; plain --- `CHECK (json_valid(connection_links))`. --- enrollment_events.fields_json — JSONB and nullable on PostgreSQL --- (db/migrations/postgres/0041_enrollment_attempts.sql); no default --- on either backend. enrollment.Recorder.Event/Ingest --- (internal/controlplane/enrollment/recorder.go) only ever calls --- json.Marshal(fields) when len(fields) > 0 and otherwise leaves --- FieldsJSON == ""; SQLStore.AppendEvent --- (internal/controlplane/enrollment/sqlstore.go) maps that empty --- string to a NULL bind param, never an empty-string column value. --- So the column is either NULL or valid JSON, never "". CHECK must --- explicitly admit NULL: `CHECK (fields_json IS NULL OR --- json_valid(fields_json))` — SQLite's json_valid(NULL) already --- evaluates to NULL, which a CHECK treats as passing, but the --- explicit IS NULL keeps the constraint's intent unambiguous. --- webhook_outbox.payload — JSONB and NOT NULL on PostgreSQL --- (db/migrations/postgres/0039_webhook_outbox.sql); no default on --- either backend. The only writer, WebhookStore.InsertOutbox --- (internal/controlplane/storage/sqlite/webhooks.go), substitutes --- the literal `{}` whenever the caller-supplied payload is empty, --- so every persisted value is valid JSON; plain --- `CHECK (json_valid(payload))`. --- --- Every rebuilt table's INSERT..SELECT is a straight column-for-column --- copy (no backfill needed): every existing default above is already --- valid JSON, so no live row can trip its column's new CHECK. - -PRAGMA foreign_keys = OFF; - --- ─── jobs ──────────────────────────────────────────────────────────── -BEGIN; - -CREATE TABLE jobs_new ( - id TEXT PRIMARY KEY, - action TEXT NOT NULL, - actor_id TEXT NOT NULL, - status TEXT NOT NULL CHECK (status IN ('queued','running','succeeded','failed','expired','partial')), - created_at_unix INTEGER NOT NULL, - ttl_nanos INTEGER NOT NULL, - idempotency_key TEXT NOT NULL UNIQUE, - payload_json TEXT NOT NULL DEFAULT '' - CHECK (payload_json = '' OR json_valid(payload_json)) -); - -INSERT INTO jobs_new (id, action, actor_id, status, created_at_unix, ttl_nanos, idempotency_key, payload_json) -SELECT id, action, actor_id, status, created_at_unix, ttl_nanos, idempotency_key, payload_json FROM jobs; - -DROP TABLE jobs; -ALTER TABLE jobs_new RENAME TO jobs; - -CREATE INDEX IF NOT EXISTS idx_jobs_created_at ON jobs (created_at_unix); -CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs (status); -CREATE INDEX IF NOT EXISTS idx_jobs_actor_id ON jobs (actor_id); - -COMMIT; - --- ─── audit_events ──────────────────────────────────────────────────── -BEGIN; - -CREATE TABLE audit_events_new ( - id TEXT PRIMARY KEY, - actor_id TEXT NOT NULL, - action TEXT NOT NULL, - target_id TEXT NOT NULL, - created_at_unix INTEGER NOT NULL, - details TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(details)), - prev_hash TEXT NOT NULL DEFAULT '', - event_hash TEXT NOT NULL DEFAULT '' -); - -INSERT INTO audit_events_new (id, actor_id, action, target_id, created_at_unix, details, prev_hash, event_hash) -SELECT id, actor_id, action, target_id, created_at_unix, details, prev_hash, event_hash FROM audit_events; - -DROP TABLE audit_events; -ALTER TABLE audit_events_new RENAME TO audit_events; - -CREATE INDEX IF NOT EXISTS idx_audit_events_chain_walk ON audit_events (created_at_unix, id); -CREATE INDEX IF NOT EXISTS idx_audit_events_created_at ON audit_events (created_at_unix); - -COMMIT; - --- ─── metric_snapshots ──────────────────────────────────────────────── --- `values` is a reserved keyword in SQLite; the identifier stays quoted. -BEGIN; - -CREATE TABLE metric_snapshots_new ( - id TEXT PRIMARY KEY, - agent_id TEXT NOT NULL, - instance_id TEXT NOT NULL DEFAULT '', - captured_at_unix INTEGER NOT NULL, - "values" TEXT NOT NULL CHECK (json_valid("values")), - FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE -); - -INSERT INTO metric_snapshots_new (id, agent_id, instance_id, captured_at_unix, "values") -SELECT id, agent_id, instance_id, captured_at_unix, "values" FROM metric_snapshots; - -DROP TABLE metric_snapshots; -ALTER TABLE metric_snapshots_new RENAME TO metric_snapshots; - -CREATE INDEX IF NOT EXISTS idx_metric_snapshots_agent_captured ON metric_snapshots (agent_id, captured_at_unix); -CREATE INDEX IF NOT EXISTS idx_metric_snapshots_captured_at ON metric_snapshots (captured_at_unix); - -COMMIT; - --- ─── integration_providers ─────────────────────────────────────────── -BEGIN; - -CREATE TABLE integration_providers_new ( - id TEXT PRIMARY KEY, - kind TEXT NOT NULL, - label TEXT NOT NULL DEFAULT '', - -- Permissive: config is either plain JSON or a vault-sealed - -- "PVS1:"/"PVS2:"/"PVS3:" ciphertext string (see file header note). - config TEXT NOT NULL DEFAULT '{}' - CHECK (json_valid(config) OR config LIKE 'PVS_:%'), - created_at_unix INTEGER NOT NULL, - updated_at_unix INTEGER NOT NULL -); - -INSERT INTO integration_providers_new (id, kind, label, config, created_at_unix, updated_at_unix) -SELECT id, kind, label, config, created_at_unix, updated_at_unix FROM integration_providers; - -DROP TABLE integration_providers; -ALTER TABLE integration_providers_new RENAME TO integration_providers; - -CREATE INDEX IF NOT EXISTS idx_integration_providers_kind ON integration_providers (kind); - -COMMIT; - --- ─── fleet_group_integrations ──────────────────────────────────────── -BEGIN; - -CREATE TABLE fleet_group_integrations_new ( - id TEXT PRIMARY KEY, - fleet_group_id TEXT NOT NULL, - kind TEXT NOT NULL, - provider_id TEXT, - config TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(config)), - enabled INTEGER NOT NULL DEFAULT 0, - created_at_unix INTEGER NOT NULL, - updated_at_unix INTEGER NOT NULL, - FOREIGN KEY (fleet_group_id) REFERENCES fleet_groups (id) ON DELETE CASCADE, - FOREIGN KEY (provider_id) REFERENCES integration_providers (id) ON DELETE SET NULL, - UNIQUE (fleet_group_id, kind) -); - -INSERT INTO fleet_group_integrations_new (id, fleet_group_id, kind, provider_id, config, enabled, created_at_unix, updated_at_unix) -SELECT id, fleet_group_id, kind, provider_id, config, enabled, created_at_unix, updated_at_unix FROM fleet_group_integrations; - -DROP TABLE fleet_group_integrations; -ALTER TABLE fleet_group_integrations_new RENAME TO fleet_group_integrations; - -CREATE INDEX IF NOT EXISTS idx_fleet_group_integrations_fleet_group_id ON fleet_group_integrations (fleet_group_id); -CREATE INDEX IF NOT EXISTS idx_fleet_group_integrations_kind ON fleet_group_integrations (kind); - -COMMIT; - --- ─── client_deployments ────────────────────────────────────────────── -BEGIN; - -CREATE TABLE client_deployments_new ( - client_id TEXT NOT NULL, - agent_id TEXT NOT NULL, - desired_operation TEXT NOT NULL, - status TEXT NOT NULL, - last_error TEXT NOT NULL DEFAULT '', - last_applied_at_unix INTEGER, - updated_at_unix INTEGER NOT NULL, - connection_links TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(connection_links)), - last_reset_epoch_secs INTEGER NOT NULL DEFAULT 0, - link_diagnostic TEXT NOT NULL DEFAULT '', - PRIMARY KEY (client_id, agent_id), - FOREIGN KEY (client_id) REFERENCES clients (id) ON DELETE CASCADE, - FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE -); - -INSERT INTO client_deployments_new ( - client_id, agent_id, desired_operation, status, last_error, last_applied_at_unix, - updated_at_unix, connection_links, last_reset_epoch_secs, link_diagnostic -) -SELECT client_id, agent_id, desired_operation, status, last_error, last_applied_at_unix, - updated_at_unix, connection_links, last_reset_epoch_secs, link_diagnostic -FROM client_deployments; - -DROP TABLE client_deployments; -ALTER TABLE client_deployments_new RENAME TO client_deployments; - -CREATE INDEX IF NOT EXISTS idx_client_deployments_client_id ON client_deployments (client_id); - -COMMIT; - --- ─── discovered_clients ────────────────────────────────────────────── -BEGIN; - -CREATE TABLE discovered_clients_new ( - id TEXT PRIMARY KEY, - agent_id TEXT NOT NULL, - client_name TEXT NOT NULL, - secret TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'pending_review' CHECK (status IN ('pending_review','adopted','ignored')), - total_octets INTEGER NOT NULL DEFAULT 0, - current_connections INTEGER NOT NULL DEFAULT 0, - active_unique_ips INTEGER NOT NULL DEFAULT 0, - max_tcp_conns INTEGER NOT NULL DEFAULT 0, - max_unique_ips INTEGER NOT NULL DEFAULT 0, - data_quota_bytes INTEGER NOT NULL DEFAULT 0, - expiration TEXT NOT NULL DEFAULT '', - discovered_at_unix INTEGER NOT NULL, - updated_at_unix INTEGER NOT NULL, - connection_links TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(connection_links)), - UNIQUE (agent_id, client_name), - FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE -); - -INSERT INTO discovered_clients_new ( - id, agent_id, client_name, secret, status, total_octets, current_connections, - active_unique_ips, max_tcp_conns, max_unique_ips, data_quota_bytes, expiration, - discovered_at_unix, updated_at_unix, connection_links -) -SELECT id, agent_id, client_name, secret, status, total_octets, current_connections, - active_unique_ips, max_tcp_conns, max_unique_ips, data_quota_bytes, expiration, - discovered_at_unix, updated_at_unix, connection_links -FROM discovered_clients; - -DROP TABLE discovered_clients; -ALTER TABLE discovered_clients_new RENAME TO discovered_clients; - -CREATE INDEX IF NOT EXISTS idx_discovered_clients_agent_id ON discovered_clients (agent_id); -CREATE UNIQUE INDEX IF NOT EXISTS idx_discovered_clients_pending_unique - ON discovered_clients (agent_id, client_name) - WHERE status = 'pending_review'; - -COMMIT; - --- ─── enrollment_events ─────────────────────────────────────────────── -BEGIN; - -CREATE TABLE enrollment_events_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - attempt_id TEXT NOT NULL REFERENCES enrollment_attempts (id) ON DELETE CASCADE, - ts TIMESTAMP NOT NULL, - step TEXT NOT NULL, - level TEXT NOT NULL CHECK (level IN ('info', 'warn', 'error')), - message TEXT, - fields_json TEXT CHECK (fields_json IS NULL OR json_valid(fields_json)) -); - -INSERT INTO enrollment_events_new (id, attempt_id, ts, step, level, message, fields_json) -SELECT id, attempt_id, ts, step, level, message, fields_json FROM enrollment_events; - -DROP TABLE enrollment_events; -ALTER TABLE enrollment_events_new RENAME TO enrollment_events; - -CREATE INDEX IF NOT EXISTS idx_enrollment_events_attempt ON enrollment_events (attempt_id, ts); - -COMMIT; - --- ─── webhook_outbox ────────────────────────────────────────────────── -BEGIN; - -CREATE TABLE webhook_outbox_new ( - id TEXT PRIMARY KEY, - endpoint_id TEXT NOT NULL REFERENCES webhook_endpoints (id) ON DELETE CASCADE, - event_action TEXT NOT NULL, - payload TEXT NOT NULL CHECK (json_valid(payload)), - attempt INTEGER NOT NULL DEFAULT 0, - next_attempt_at TIMESTAMP NOT NULL, - last_error TEXT NOT NULL DEFAULT '', - dead INTEGER NOT NULL DEFAULT 0 CHECK (dead IN (0, 1)), - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - delivered_at TIMESTAMP -); - -INSERT INTO webhook_outbox_new ( - id, endpoint_id, event_action, payload, attempt, next_attempt_at, - last_error, dead, created_at, delivered_at -) -SELECT id, endpoint_id, event_action, payload, attempt, next_attempt_at, - last_error, dead, created_at, delivered_at -FROM webhook_outbox; - -DROP TABLE webhook_outbox; -ALTER TABLE webhook_outbox_new RENAME TO webhook_outbox; - -CREATE INDEX IF NOT EXISTS idx_webhook_outbox_ready - ON webhook_outbox (next_attempt_at) - WHERE dead = 0 AND delivered_at IS NULL; - -COMMIT; - -PRAGMA foreign_keys = ON; - --- +goose Down -SELECT 1; diff --git a/db/migrations/sqlite/0053_config_apply_batches.sql b/db/migrations/sqlite/0053_config_apply_batches.sql deleted file mode 100644 index 355feb20..00000000 --- a/db/migrations/sqlite/0053_config_apply_batches.sql +++ /dev/null @@ -1,37 +0,0 @@ --- +goose Up --- SQLite mirror of postgres/0053_config_apply_batches.sql. fleet_group_id --- stays TEXT (not UUID) since fleet_groups.id is TEXT on SQLite — see --- sqlite/0014_fleet_groups_redesign.sql's header note; UUID format is --- enforced at the application layer, matching the --- user_fleet_group_scopes / client_assignments convention. Timestamps use --- the project's `*_at_unix` INTEGER convention (see jobs/metric_snapshots --- in sqlite/0001_init.sql) rather than TIMESTAMPTZ. -CREATE TABLE IF NOT EXISTS config_apply_batches ( - id TEXT PRIMARY KEY, - fleet_group_id TEXT NOT NULL REFERENCES fleet_groups (id) ON DELETE CASCADE, - mode TEXT NOT NULL CHECK (mode IN ('all_at_once', 'rolling')), - wave_size INTEGER NOT NULL DEFAULT 1, - expected_revision TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL CHECK (status IN ('running', 'succeeded', 'failed', 'halted')), - created_at_unix INTEGER NOT NULL, - updated_at_unix INTEGER NOT NULL -); - -CREATE INDEX IF NOT EXISTS idx_config_apply_batches_status - ON config_apply_batches (status); - -CREATE TABLE IF NOT EXISTS config_apply_batch_targets ( - batch_id TEXT NOT NULL REFERENCES config_apply_batches (id) ON DELETE CASCADE, - agent_id TEXT NOT NULL, - wave_index INTEGER NOT NULL, - job_id TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL CHECK (status IN ('pending', 'running', 'succeeded', 'failed', 'skipped')), - PRIMARY KEY (batch_id, agent_id) -); - -CREATE INDEX IF NOT EXISTS idx_config_apply_batch_targets_batch_wave - ON config_apply_batch_targets (batch_id, wave_index); - --- +goose Down -DROP TABLE IF EXISTS config_apply_batch_targets; -DROP TABLE IF EXISTS config_apply_batches; diff --git a/db/migrations/sqlite/0054_config_apply_batch_target_message.sql b/db/migrations/sqlite/0054_config_apply_batch_target_message.sql deleted file mode 100644 index faa7cccf..00000000 --- a/db/migrations/sqlite/0054_config_apply_batch_target_message.sql +++ /dev/null @@ -1,13 +0,0 @@ --- +goose Up --- SQLite mirror of postgres/0054_config_apply_batch_target_message.sql. --- Persists a failed (or otherwise terminal) target's message so the --- resumable batch-status view (GET /fleet-groups/{id}/config/apply/batches/{batchId}) --- can surface the failure reason after the underlying config.apply job has --- been evicted from the in-memory jobs store. Adding a NOT NULL column with --- a DEFAULT is a plain ALTER TABLE on SQLite (no CHECK/FK added), so no --- table rebuild is required. -ALTER TABLE config_apply_batch_targets - ADD COLUMN message TEXT NOT NULL DEFAULT ''; - --- +goose Down -ALTER TABLE config_apply_batch_targets DROP COLUMN message; diff --git a/db/migrations/sqlite/0055_runtime_current_json.sql b/db/migrations/sqlite/0055_runtime_current_json.sql deleted file mode 100644 index 8ecc5f3c..00000000 --- a/db/migrations/sqlite/0055_runtime_current_json.sql +++ /dev/null @@ -1,18 +0,0 @@ --- +goose Up --- P3-3.1 (аудит #3): узкоколоночное зеркало AgentRuntime теряло ~44% полей --- при рестарте панели. Runtime теперь хранится каноническим JSON-blob'ом --- (runtime_json = json.Marshal(server.AgentRuntime)); реальными колонками --- остаются только agent_id (PK/FK) и observed_at_unix (ORDER BY). --- Pre-prod: без backfill — drop+recreate, первый же снапшот агента --- заново наполняет таблицу (данные derived, источник — агент). -DROP TABLE telemt_runtime_current; -CREATE TABLE telemt_runtime_current ( - agent_id TEXT PRIMARY KEY, - observed_at_unix INTEGER NOT NULL, - runtime_json TEXT NOT NULL DEFAULT '', - FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE -); - --- +goose Down --- Dev-stage: drop+recreate acceptable, no rollback. -SELECT 1; diff --git a/db/migrations/sqlite/0056_config_apply_batch_group_nullable.sql b/db/migrations/sqlite/0056_config_apply_batch_group_nullable.sql deleted file mode 100644 index e5e41ea1..00000000 --- a/db/migrations/sqlite/0056_config_apply_batch_group_nullable.sql +++ /dev/null @@ -1,31 +0,0 @@ --- +goose Up --- P3-3.4: зеркало postgres/0056 — fleet_group_id становится nullable --- (NULL = agent-scoped batch-of-one). SQLite не умеет ALTER COLUMN DROP --- NOT NULL — пересборка таблицы (приём как в 0050_check_parity.sql). -PRAGMA foreign_keys=OFF; - -CREATE TABLE config_apply_batches_new ( - id TEXT PRIMARY KEY, - fleet_group_id TEXT REFERENCES fleet_groups (id) ON DELETE CASCADE, - mode TEXT NOT NULL CHECK (mode IN ('all_at_once', 'rolling')), - wave_size INTEGER NOT NULL DEFAULT 1, - expected_revision TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL CHECK (status IN ('running', 'succeeded', 'failed', 'halted')), - created_at_unix INTEGER NOT NULL, - updated_at_unix INTEGER NOT NULL -); -INSERT INTO config_apply_batches_new - (id, fleet_group_id, mode, wave_size, expected_revision, status, created_at_unix, updated_at_unix) -SELECT id, fleet_group_id, mode, wave_size, expected_revision, status, created_at_unix, updated_at_unix -FROM config_apply_batches; -DROP TABLE config_apply_batches; -ALTER TABLE config_apply_batches_new RENAME TO config_apply_batches; - -CREATE INDEX IF NOT EXISTS idx_config_apply_batches_status - ON config_apply_batches (status); - -PRAGMA foreign_keys=ON; - --- +goose Down --- Dev-stage: drop+recreate acceptable, no rollback. -SELECT 1; diff --git a/db/migrations/sqlite/0057_client_usage_watermark.sql b/db/migrations/sqlite/0057_client_usage_watermark.sql deleted file mode 100644 index dace6928..00000000 --- a/db/migrations/sqlite/0057_client_usage_watermark.sql +++ /dev/null @@ -1,16 +0,0 @@ --- +goose Up --- P4 (cumulative traffic counters, audit 2026-07-02 #8): add the --- per-(client, agent) watermark of the agent's cumulative counter: --- agent_boot_id — reporting agent process epoch (UUID; '' until the --- first cumulative report, e.g. discovery-seeded rows), --- last_total_bytes — last cumulative total seen for that epoch. --- The panel accumulates max(total - last_total, 0) into --- traffic_used_bytes and rewrites the watermark; a new boot_id restarts --- the epoch. Additive step: last_seq keeps working until the panel --- cutover; it is dropped in 0058. -ALTER TABLE client_usage ADD COLUMN agent_boot_id TEXT NOT NULL DEFAULT ''; -ALTER TABLE client_usage ADD COLUMN last_total_bytes INTEGER NOT NULL DEFAULT 0; - --- +goose Down -ALTER TABLE client_usage DROP COLUMN last_total_bytes; -ALTER TABLE client_usage DROP COLUMN agent_boot_id; diff --git a/db/migrations/sqlite/0058_client_usage_drop_last_seq.sql b/db/migrations/sqlite/0058_client_usage_drop_last_seq.sql deleted file mode 100644 index f310ba8a..00000000 --- a/db/migrations/sqlite/0058_client_usage_drop_last_seq.sql +++ /dev/null @@ -1,8 +0,0 @@ --- +goose Up --- P4 final step: the seq-delta protocol is fully replaced by the --- cumulative watermark (agent_boot_id, last_total_bytes) added in 0057 --- — drop the per-agent report cursor. Greenfield: no data migration. -ALTER TABLE client_usage DROP COLUMN last_seq; - --- +goose Down -ALTER TABLE client_usage ADD COLUMN last_seq INTEGER NOT NULL DEFAULT 0; diff --git a/db/migrations/sqlite/baseline/baseline_v1.sql b/db/migrations/sqlite/baseline/baseline_v1.sql deleted file mode 100644 index 121e12e3..00000000 --- a/db/migrations/sqlite/baseline/baseline_v1.sql +++ /dev/null @@ -1,701 +0,0 @@ --- baseline_v1.sql (SQLite) — autogenerated by migrate.TestRegenerateSQLiteBaseline. --- DO NOT EDIT BY HAND. Re-run the test with -regen-baseline to update. --- See docs/superpowers/plans/2026-05-09-baseline-migration.md - -CREATE TABLE "agent_certificate_recovery_grants" ( - agent_id TEXT PRIMARY KEY, - issued_by TEXT NOT NULL, - issued_at_unix INTEGER NOT NULL, - expires_at_unix INTEGER NOT NULL, - used_at_unix INTEGER, - revoked_at_unix INTEGER, - FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE -); - -CREATE TABLE agent_config_targets ( - scope_type TEXT NOT NULL, - scope_id TEXT NOT NULL, - sections_json TEXT NOT NULL DEFAULT '{}', - created_at TIMESTAMP NOT NULL, - updated_at TIMESTAMP NOT NULL, - PRIMARY KEY (scope_type, scope_id) -); - -CREATE TABLE agent_fallback_state ( - agent_id TEXT PRIMARY KEY, - entered_at_unix INTEGER NOT NULL, - FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE -); - -CREATE TABLE agent_revocations ( - agent_id TEXT PRIMARY KEY, - revoked_at_unix INTEGER NOT NULL, - cert_expires_at_unix INTEGER NOT NULL -); - -CREATE TABLE "agents" ( - id TEXT PRIMARY KEY, - node_name TEXT NOT NULL, - fleet_group_id TEXT, - version TEXT NOT NULL DEFAULT '', - read_only INTEGER NOT NULL DEFAULT 0, - last_seen_at_unix INTEGER NOT NULL, - created_at_unix INTEGER NOT NULL DEFAULT 0, - cert_issued_at_unix INTEGER, - cert_expires_at_unix INTEGER, - cert_serial TEXT NOT NULL DEFAULT '', - transport_mode TEXT NOT NULL DEFAULT 'inbound' - CHECK (transport_mode IN ('inbound', 'outbound')), - dial_address TEXT, - bootstrap_state TEXT NOT NULL DEFAULT 'active' - CHECK (bootstrap_state IN ('pending', 'active', 'expired', 'revoked')), - bootstrap_token_hash BLOB, - bootstrap_expires_at INTEGER, - cert_spki_sha256 BLOB NOT NULL DEFAULT x'' - CHECK (length(cert_spki_sha256) IN (0, 32)), - FOREIGN KEY (fleet_group_id) REFERENCES fleet_groups (id) -); - -CREATE TABLE "audit_events" ( - id TEXT PRIMARY KEY, - actor_id TEXT NOT NULL, - action TEXT NOT NULL, - target_id TEXT NOT NULL, - created_at_unix INTEGER NOT NULL, - details TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(details)), - prev_hash TEXT NOT NULL DEFAULT '', - event_hash TEXT NOT NULL DEFAULT '' -); - -CREATE TABLE certificate_authority ( - scope TEXT PRIMARY KEY, - ca_pem TEXT NOT NULL, - private_key_pem TEXT NOT NULL, - updated_at_unix INTEGER NOT NULL -); - -CREATE TABLE "client_assignments" ( - id TEXT PRIMARY KEY, - client_id TEXT NOT NULL, - target_type TEXT NOT NULL, - fleet_group_id TEXT, - agent_id TEXT, - created_at_unix INTEGER NOT NULL, - FOREIGN KEY (client_id) REFERENCES clients (id) ON DELETE CASCADE, - FOREIGN KEY (fleet_group_id) REFERENCES fleet_groups (id) ON DELETE SET NULL, - FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE SET NULL -); - -CREATE TABLE "client_deployments" ( - client_id TEXT NOT NULL, - agent_id TEXT NOT NULL, - desired_operation TEXT NOT NULL, - status TEXT NOT NULL, - last_error TEXT NOT NULL DEFAULT '', - last_applied_at_unix INTEGER, - updated_at_unix INTEGER NOT NULL, - connection_links TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(connection_links)), - last_reset_epoch_secs INTEGER NOT NULL DEFAULT 0, - link_diagnostic TEXT NOT NULL DEFAULT '', - PRIMARY KEY (client_id, agent_id), - FOREIGN KEY (client_id) REFERENCES clients (id) ON DELETE CASCADE, - FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE -); - -CREATE TABLE client_ip_history ( - agent_id TEXT NOT NULL, - client_id TEXT NOT NULL, - ip_address TEXT NOT NULL, - first_seen_unix INTEGER NOT NULL, - last_seen_unix INTEGER NOT NULL, - PRIMARY KEY (agent_id, client_id, ip_address) -); - -CREATE TABLE client_usage ( - client_id TEXT NOT NULL, - agent_id TEXT NOT NULL, - traffic_used_bytes INTEGER NOT NULL DEFAULT 0, - unique_ips_used INTEGER NOT NULL DEFAULT 0, - active_tcp_conns INTEGER NOT NULL DEFAULT 0, - active_unique_ips INTEGER NOT NULL DEFAULT 0, - observed_at_unix INTEGER NOT NULL, quota_used_bytes INTEGER NOT NULL DEFAULT 0, quota_last_reset_unix INTEGER NOT NULL DEFAULT 0, agent_boot_id TEXT NOT NULL DEFAULT '', last_total_bytes INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (client_id, agent_id), - FOREIGN KEY (client_id) REFERENCES clients (id) ON DELETE CASCADE, - FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE -); - -CREATE TABLE clients ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - secret_ciphertext TEXT NOT NULL, - user_ad_tag TEXT NOT NULL, - enabled INTEGER NOT NULL DEFAULT 1, - max_tcp_conns INTEGER NOT NULL DEFAULT 0, - max_unique_ips INTEGER NOT NULL DEFAULT 0, - data_quota_bytes INTEGER NOT NULL DEFAULT 0, - expiration_rfc3339 TEXT NOT NULL DEFAULT '', - created_at_unix INTEGER NOT NULL, - updated_at_unix INTEGER NOT NULL, - deleted_at_unix INTEGER -, subscription_token TEXT); - -CREATE TABLE config_apply_batch_targets ( - batch_id TEXT NOT NULL REFERENCES config_apply_batches (id) ON DELETE CASCADE, - agent_id TEXT NOT NULL, - wave_index INTEGER NOT NULL, - job_id TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL CHECK (status IN ('pending', 'running', 'succeeded', 'failed', 'skipped')), message TEXT NOT NULL DEFAULT '', - PRIMARY KEY (batch_id, agent_id) -); - -CREATE TABLE "config_apply_batches" ( - id TEXT PRIMARY KEY, - fleet_group_id TEXT REFERENCES fleet_groups (id) ON DELETE CASCADE, - mode TEXT NOT NULL CHECK (mode IN ('all_at_once', 'rolling')), - wave_size INTEGER NOT NULL DEFAULT 1, - expected_revision TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL CHECK (status IN ('running', 'succeeded', 'failed', 'halted')), - created_at_unix INTEGER NOT NULL, - updated_at_unix INTEGER NOT NULL -); - -CREATE TABLE consumed_totp ( - user_id TEXT NOT NULL, - code TEXT NOT NULL, - used_at_unix INTEGER NOT NULL, - PRIMARY KEY (user_id, code) -); - -CREATE TABLE cp_secrets ( - key TEXT PRIMARY KEY, - value BLOB NOT NULL, - updated_at_unix INTEGER NOT NULL -); - -CREATE TABLE "discovered_clients" ( - id TEXT PRIMARY KEY, - agent_id TEXT NOT NULL, - client_name TEXT NOT NULL, - secret TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'pending_review' CHECK (status IN ('pending_review','adopted','ignored')), - total_octets INTEGER NOT NULL DEFAULT 0, - current_connections INTEGER NOT NULL DEFAULT 0, - active_unique_ips INTEGER NOT NULL DEFAULT 0, - max_tcp_conns INTEGER NOT NULL DEFAULT 0, - max_unique_ips INTEGER NOT NULL DEFAULT 0, - data_quota_bytes INTEGER NOT NULL DEFAULT 0, - expiration TEXT NOT NULL DEFAULT '', - discovered_at_unix INTEGER NOT NULL, - updated_at_unix INTEGER NOT NULL, - connection_links TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(connection_links)), - UNIQUE (agent_id, client_name), - FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE -); - -CREATE TABLE enrollment_attempts ( - id TEXT PRIMARY KEY, - token_id TEXT, - agent_id TEXT, - mode TEXT NOT NULL CHECK (mode IN ('inbound', 'outbound')), - client_addr TEXT, - request_id TEXT NOT NULL, - status TEXT NOT NULL CHECK (status IN ('in_progress', 'success', 'failed')), - error_code TEXT, - error_message TEXT, - started_at TIMESTAMP NOT NULL, - finished_at TIMESTAMP -); - -CREATE TABLE "enrollment_events" ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - attempt_id TEXT NOT NULL REFERENCES enrollment_attempts (id) ON DELETE CASCADE, - ts TIMESTAMP NOT NULL, - step TEXT NOT NULL, - level TEXT NOT NULL CHECK (level IN ('info', 'warn', 'error')), - message TEXT, - fields_json TEXT CHECK (fields_json IS NULL OR json_valid(fields_json)) -); - -CREATE TABLE "enrollment_tokens" ( - value TEXT PRIMARY KEY, - fleet_group_id TEXT, - issued_at_unix INTEGER NOT NULL, - expires_at_unix INTEGER NOT NULL, - consumed_at_unix INTEGER, - revoked_at_unix INTEGER, - FOREIGN KEY (fleet_group_id) REFERENCES fleet_groups (id) ON DELETE SET NULL -); - -CREATE TABLE "fleet_group_integrations" ( - id TEXT PRIMARY KEY, - fleet_group_id TEXT NOT NULL, - kind TEXT NOT NULL, - provider_id TEXT, - config TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(config)), - enabled INTEGER NOT NULL DEFAULT 0, - created_at_unix INTEGER NOT NULL, - updated_at_unix INTEGER NOT NULL, - FOREIGN KEY (fleet_group_id) REFERENCES fleet_groups (id) ON DELETE CASCADE, - FOREIGN KEY (provider_id) REFERENCES integration_providers (id) ON DELETE SET NULL, - UNIQUE (fleet_group_id, kind) -); - -CREATE TABLE fleet_groups ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - created_at_unix INTEGER NOT NULL -, label TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '', updated_at_unix INTEGER NOT NULL DEFAULT 0); - -CREATE TABLE goose_db_version ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - version_id INTEGER NOT NULL, - is_applied INTEGER NOT NULL, - tstamp TIMESTAMP DEFAULT (datetime('now')) - ); - -CREATE TABLE "integration_providers" ( - id TEXT PRIMARY KEY, - kind TEXT NOT NULL, - label TEXT NOT NULL DEFAULT '', - -- Permissive: config is either plain JSON or a vault-sealed - -- "PVS1:"/"PVS2:"/"PVS3:" ciphertext string (see file header note). - config TEXT NOT NULL DEFAULT '{}' - CHECK (json_valid(config) OR config LIKE 'PVS_:%'), - created_at_unix INTEGER NOT NULL, - updated_at_unix INTEGER NOT NULL -); - -CREATE TABLE "job_targets" ( - job_id TEXT NOT NULL, - agent_id TEXT NOT NULL, - status TEXT NOT NULL CHECK (status IN ('queued','sent','acknowledged','succeeded','failed','expired')), - result_text TEXT NOT NULL DEFAULT '', - result_json TEXT NOT NULL DEFAULT '', - updated_at_unix INTEGER NOT NULL, - PRIMARY KEY (job_id, agent_id), - FOREIGN KEY (job_id) REFERENCES jobs (id) -); - -CREATE TABLE "jobs" ( - id TEXT PRIMARY KEY, - action TEXT NOT NULL, - actor_id TEXT NOT NULL, - status TEXT NOT NULL CHECK (status IN ('queued','running','succeeded','failed','expired','partial')), - created_at_unix INTEGER NOT NULL, - ttl_nanos INTEGER NOT NULL, - idempotency_key TEXT NOT NULL UNIQUE, - payload_json TEXT NOT NULL DEFAULT '' - CHECK (payload_json = '' OR json_valid(payload_json)) -); - -CREATE TABLE login_lockouts ( - username TEXT PRIMARY KEY, - failures INTEGER NOT NULL DEFAULT 0, - locked_at_unix INTEGER, - updated_at_unix INTEGER NOT NULL -); - -CREATE TABLE "metric_snapshots" ( - id TEXT PRIMARY KEY, - agent_id TEXT NOT NULL, - instance_id TEXT NOT NULL DEFAULT '', - captured_at_unix INTEGER NOT NULL, - "values" TEXT NOT NULL CHECK (json_valid("values")), - FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE -); - -CREATE TABLE "panel_settings" ( - scope TEXT PRIMARY KEY, - http_public_url TEXT NOT NULL DEFAULT '', - grpc_public_endpoint TEXT NOT NULL DEFAULT '', - password_min_length INTEGER NOT NULL DEFAULT 10 - CHECK (password_min_length >= 8 AND password_min_length <= 128), - retention_json TEXT NOT NULL DEFAULT '', - geoip_json TEXT NOT NULL DEFAULT '', - geoip_state_json TEXT NOT NULL DEFAULT '', - updated_at_unix INTEGER NOT NULL -); - -CREATE TABLE runtime_settings ( - name TEXT PRIMARY KEY, - value_json TEXT NOT NULL, - updated_at INTEGER NOT NULL, - updated_by TEXT NOT NULL DEFAULT '' -); - -CREATE TABLE "sessions" ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - created_at_unix INTEGER NOT NULL, last_seen_at_unix INTEGER NOT NULL DEFAULT 0, - FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE -); - -CREATE TABLE telemt_diagnostics_current ( - agent_id TEXT PRIMARY KEY, - observed_at_unix INTEGER NOT NULL, - state TEXT NOT NULL DEFAULT '', - state_reason TEXT NOT NULL DEFAULT '', - system_info_json TEXT NOT NULL DEFAULT '{}', - effective_limits_json TEXT NOT NULL DEFAULT '{}', - security_posture_json TEXT NOT NULL DEFAULT '{}', - minimal_all_json TEXT NOT NULL DEFAULT '{}', - me_pool_json TEXT NOT NULL DEFAULT '{}', - dcs_json TEXT NOT NULL DEFAULT '{}', - FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE -); - -CREATE TABLE "telemt_instances" ( - id TEXT PRIMARY KEY, - agent_id TEXT NOT NULL, - name TEXT NOT NULL, - version TEXT NOT NULL DEFAULT '', - config_fingerprint TEXT NOT NULL DEFAULT '', - connections INTEGER NOT NULL DEFAULT 0, - read_only INTEGER NOT NULL DEFAULT 0, - updated_at_unix INTEGER NOT NULL, - FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE -); - -CREATE TABLE telemt_runtime_current ( - agent_id TEXT PRIMARY KEY, - observed_at_unix INTEGER NOT NULL, - runtime_json TEXT NOT NULL DEFAULT '', - FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE -); - -CREATE TABLE telemt_runtime_dcs_current ( - agent_id TEXT NOT NULL, - dc INTEGER NOT NULL, - observed_at_unix INTEGER NOT NULL, - available_endpoints INTEGER NOT NULL DEFAULT 0, - available_pct REAL NOT NULL DEFAULT 0, - required_writers INTEGER NOT NULL DEFAULT 0, - alive_writers INTEGER NOT NULL DEFAULT 0, - coverage_pct REAL NOT NULL DEFAULT 0, - rtt_ms REAL NOT NULL DEFAULT 0, - load REAL NOT NULL DEFAULT 0, - PRIMARY KEY (agent_id, dc), - FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE -); - -CREATE TABLE telemt_runtime_events ( - agent_id TEXT NOT NULL, - sequence INTEGER NOT NULL, - observed_at_unix INTEGER NOT NULL, - timestamp_unix INTEGER NOT NULL, - event_type TEXT NOT NULL DEFAULT '', - context TEXT NOT NULL DEFAULT '', - severity TEXT NOT NULL DEFAULT '', - PRIMARY KEY (agent_id, sequence), - FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE -); - -CREATE TABLE telemt_runtime_upstreams_current ( - agent_id TEXT NOT NULL, - upstream_id INTEGER NOT NULL, - observed_at_unix INTEGER NOT NULL, - route_kind TEXT NOT NULL DEFAULT '', - address TEXT NOT NULL DEFAULT '', - healthy INTEGER NOT NULL DEFAULT 0, - fails INTEGER NOT NULL DEFAULT 0, - effective_latency_ms REAL NOT NULL DEFAULT 0, - PRIMARY KEY (agent_id, upstream_id), - FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE -); - -CREATE TABLE telemt_security_inventory_current ( - agent_id TEXT PRIMARY KEY, - observed_at_unix INTEGER NOT NULL, - state TEXT NOT NULL DEFAULT '', - state_reason TEXT NOT NULL DEFAULT '', - enabled INTEGER NOT NULL DEFAULT 0, - entries_total INTEGER NOT NULL DEFAULT 0, - entries_json TEXT NOT NULL DEFAULT '[]', - FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE -); - -CREATE TABLE ts_dc_health ( - agent_id TEXT NOT NULL, - captured_at_unix INTEGER NOT NULL, - dc INTEGER NOT NULL, - coverage_pct_avg REAL NOT NULL DEFAULT 0, - coverage_pct_min REAL NOT NULL DEFAULT 0, - rtt_ms_avg REAL NOT NULL DEFAULT 0, - rtt_ms_max REAL NOT NULL DEFAULT 0, - alive_writers_min INTEGER NOT NULL DEFAULT 0, - required_writers INTEGER NOT NULL DEFAULT 0, - load_max INTEGER NOT NULL DEFAULT 0, - sample_count INTEGER NOT NULL DEFAULT 1, - PRIMARY KEY (agent_id, dc, captured_at_unix) -); - -CREATE TABLE ts_server_load ( - agent_id TEXT NOT NULL, - captured_at_unix INTEGER NOT NULL, - cpu_pct_avg REAL NOT NULL DEFAULT 0, - cpu_pct_max REAL NOT NULL DEFAULT 0, - mem_pct_avg REAL NOT NULL DEFAULT 0, - mem_pct_max REAL NOT NULL DEFAULT 0, - disk_pct_avg REAL NOT NULL DEFAULT 0, - disk_pct_max REAL NOT NULL DEFAULT 0, - load_1m REAL NOT NULL DEFAULT 0, - load_5m REAL NOT NULL DEFAULT 0, - load_15m REAL NOT NULL DEFAULT 0, - connections_avg INTEGER NOT NULL DEFAULT 0, - connections_max INTEGER NOT NULL DEFAULT 0, - connections_me_avg INTEGER NOT NULL DEFAULT 0, - connections_direct_avg INTEGER NOT NULL DEFAULT 0, - active_users_avg INTEGER NOT NULL DEFAULT 0, - active_users_max INTEGER NOT NULL DEFAULT 0, - connections_total INTEGER NOT NULL DEFAULT 0, - connections_bad_total INTEGER NOT NULL DEFAULT 0, - handshake_timeouts_total INTEGER NOT NULL DEFAULT 0, - dc_coverage_min_pct REAL NOT NULL DEFAULT 0, - dc_coverage_avg_pct REAL NOT NULL DEFAULT 0, - healthy_upstreams INTEGER NOT NULL DEFAULT 0, - total_upstreams INTEGER NOT NULL DEFAULT 0, - net_bytes_sent INTEGER NOT NULL DEFAULT 0, - net_bytes_recv INTEGER NOT NULL DEFAULT 0, - sample_count INTEGER NOT NULL DEFAULT 1, - PRIMARY KEY (agent_id, captured_at_unix) -); - -CREATE TABLE ts_server_load_hourly ( - agent_id TEXT NOT NULL, - bucket_hour_unix INTEGER NOT NULL, - cpu_pct_avg REAL, - cpu_pct_max REAL, - mem_pct_avg REAL, - mem_pct_max REAL, - connections_avg REAL, - connections_max INTEGER, - active_users_avg REAL, - active_users_max INTEGER, - dc_coverage_min REAL, - dc_coverage_avg REAL, - sample_count INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (agent_id, bucket_hour_unix) -); - -CREATE TABLE update_config ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL -); - -CREATE TABLE user_appearance ( - user_id TEXT PRIMARY KEY, - theme TEXT NOT NULL DEFAULT 'system', - density TEXT NOT NULL DEFAULT 'comfortable', - help_mode TEXT NOT NULL DEFAULT 'basic', - updated_at_unix INTEGER NOT NULL DEFAULT 0, - FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE -); - -CREATE TABLE user_fleet_group_scopes ( - user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, - fleet_group_id TEXT NOT NULL REFERENCES fleet_groups (id) ON DELETE CASCADE, - granted_at_unix INTEGER NOT NULL DEFAULT 0, - granted_by TEXT NOT NULL DEFAULT '', - PRIMARY KEY (user_id, fleet_group_id) -); - -CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL UNIQUE, - password_hash TEXT NOT NULL, - role TEXT NOT NULL, - totp_enabled INTEGER NOT NULL DEFAULT 0, - totp_secret TEXT NOT NULL DEFAULT '', - created_at_unix INTEGER NOT NULL -); - -CREATE TABLE webhook_endpoints ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL UNIQUE, - url TEXT NOT NULL, - secret_ciphertext TEXT NOT NULL, - event_filter TEXT NOT NULL DEFAULT '', - allow_private INTEGER NOT NULL DEFAULT 0 CHECK (allow_private IN (0, 1)), - enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)), - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE "webhook_outbox" ( - id TEXT PRIMARY KEY, - endpoint_id TEXT NOT NULL REFERENCES webhook_endpoints (id) ON DELETE CASCADE, - event_action TEXT NOT NULL, - payload TEXT NOT NULL CHECK (json_valid(payload)), - attempt INTEGER NOT NULL DEFAULT 0, - next_attempt_at TIMESTAMP NOT NULL, - last_error TEXT NOT NULL DEFAULT '', - dead INTEGER NOT NULL DEFAULT 0 CHECK (dead IN (0, 1)), - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - delivered_at TIMESTAMP -); - -CREATE UNIQUE INDEX clients_subscription_token_key - ON clients (subscription_token); - -CREATE UNIQUE INDEX fleet_groups_name_unique - ON fleet_groups (name); - -CREATE INDEX idx_agent_fallback_state_entered_at - ON agent_fallback_state (entered_at_unix); - -CREATE INDEX idx_agent_revocations_cert_expires_at_unix - ON agent_revocations(cert_expires_at_unix); - -CREATE INDEX idx_agents_cert_spki_sha256 - ON agents (cert_spki_sha256) - WHERE length(cert_spki_sha256) > 0; - -CREATE INDEX idx_agents_fleet_group_id ON agents (fleet_group_id); - -CREATE INDEX idx_agents_last_seen_at ON agents (last_seen_at_unix); - -CREATE INDEX idx_agents_transport_mode ON agents(transport_mode); - -CREATE INDEX idx_audit_events_chain_walk ON audit_events (created_at_unix, id); - -CREATE INDEX idx_audit_events_created_at ON audit_events (created_at_unix); - -CREATE INDEX idx_client_assignments_client_id ON client_assignments (client_id); - -CREATE INDEX idx_client_deployments_client_id ON client_deployments (client_id); - -CREATE INDEX idx_client_ip_client ON client_ip_history (client_id, last_seen_unix DESC); - -CREATE INDEX idx_client_ip_client_addr ON client_ip_history (client_id, ip_address); - -CREATE INDEX idx_client_ip_last_seen ON client_ip_history (last_seen_unix); - -CREATE INDEX idx_client_usage_agent_id - ON client_usage (agent_id); - -CREATE INDEX idx_config_apply_batch_targets_batch_wave - ON config_apply_batch_targets (batch_id, wave_index); - -CREATE INDEX idx_config_apply_batches_status - ON config_apply_batches (status); - -CREATE INDEX idx_consumed_totp_used_at ON consumed_totp(used_at_unix); - -CREATE INDEX idx_discovered_clients_agent_id ON discovered_clients (agent_id); - -CREATE UNIQUE INDEX idx_discovered_clients_pending_unique - ON discovered_clients (agent_id, client_name) - WHERE status = 'pending_review'; - -CREATE INDEX idx_enrollment_attempts_agent ON enrollment_attempts(agent_id); - -CREATE INDEX idx_enrollment_attempts_started ON enrollment_attempts(started_at); - -CREATE INDEX idx_enrollment_attempts_token ON enrollment_attempts(token_id); - -CREATE INDEX idx_enrollment_events_attempt ON enrollment_events (attempt_id, ts); - -CREATE INDEX idx_enrollment_tokens_fleet_group_id ON enrollment_tokens (fleet_group_id); - -CREATE INDEX idx_fleet_group_integrations_fleet_group_id ON fleet_group_integrations (fleet_group_id); - -CREATE INDEX idx_fleet_group_integrations_kind ON fleet_group_integrations (kind); - -CREATE INDEX idx_integration_providers_kind ON integration_providers (kind); - -CREATE INDEX idx_job_targets_agent_id ON job_targets (agent_id); - -CREATE INDEX idx_jobs_actor_id ON jobs (actor_id); - -CREATE INDEX idx_jobs_created_at ON jobs (created_at_unix); - -CREATE INDEX idx_jobs_status ON jobs (status); - -CREATE INDEX idx_login_lockouts_locked_at_unix - ON login_lockouts(locked_at_unix); - -CREATE INDEX idx_metric_snapshots_agent_captured ON metric_snapshots (agent_id, captured_at_unix); - -CREATE INDEX idx_metric_snapshots_captured_at ON metric_snapshots (captured_at_unix); - -CREATE INDEX idx_sessions_created_at_unix ON sessions (created_at_unix); - -CREATE INDEX idx_sessions_user_id ON sessions (user_id); - -CREATE INDEX idx_telemt_instances_agent_id ON telemt_instances (agent_id); - -CREATE INDEX idx_ts_dc_health_time ON ts_dc_health (agent_id, captured_at_unix DESC); - -CREATE INDEX idx_ts_server_load_time ON ts_server_load (agent_id, captured_at_unix DESC); - -CREATE INDEX idx_user_fleet_group_scopes_fleet_group_id - ON user_fleet_group_scopes (fleet_group_id); - -CREATE INDEX idx_user_fleet_group_scopes_user_id - ON user_fleet_group_scopes (user_id); - -CREATE INDEX idx_webhook_outbox_ready - ON webhook_outbox (next_attempt_at) - WHERE dead = 0 AND delivered_at IS NULL; - --- Mark every historical migration as already applied so goose.Up sees a no-op. -INSERT INTO goose_db_version (version_id, is_applied) VALUES - (0, 1), - (1, 1), - (2, 1), - (3, 1), - (4, 1), - (5, 1), - (6, 1), - (7, 1), - (8, 1), - (9, 1), - (10, 1), - (11, 1), - (12, 1), - (13, 1), - (14, 1), - (15, 1), - (16, 1), - (17, 1), - (18, 1), - (19, 1), - (20, 1), - (21, 1), - (22, 1), - (23, 1), - (24, 1), - (25, 1), - (26, 1), - (27, 1), - (28, 1), - (29, 1), - (30, 1), - (31, 1), - (32, 1), - (33, 1), - (34, 1), - (35, 1), - (36, 1), - (37, 1), - (38, 1), - (39, 1), - (40, 1), - (41, 1), - (42, 1), - (43, 1), - (44, 1), - (45, 1), - (46, 1), - (47, 1), - (48, 1), - (49, 1), - (50, 1), - (51, 1), - (52, 1), - (53, 1), - (54, 1), - (55, 1), - (56, 1), - (57, 1), - (58, 1); diff --git a/db/migrations/sqlite/baseline/embed.go b/db/migrations/sqlite/baseline/embed.go deleted file mode 100644 index 0fa50453..00000000 --- a/db/migrations/sqlite/baseline/embed.go +++ /dev/null @@ -1,21 +0,0 @@ -// Package sqlitebaseline exposes the consolidated baseline_v1.sql -// schema as an embed.FS, separate from the per-version migration FS -// so goose's filename version-parser doesn't trip over the -// non-numbered baseline file. -// -// Generated by migrate.TestRegenerateSQLiteBaseline. See -// docs/superpowers/plans/2026-05-09-baseline-migration.md for the -// freeze rationale. -package sqlitebaseline - -import "embed" - -//go:embed *.sql -var FS embed.FS - -// LatestVersion is the migration version represented by the baseline. -// Fresh installs apply baseline_v1.sql and mark versions 1..LatestVersion -// as already applied in goose_db_version; goose.Up then only runs -// migrations > LatestVersion. Bump this constant whenever -// TestRegenerateSQLiteBaseline writes a new file. -const LatestVersion = 58 diff --git a/internal/controlplane/storage/migrate/baseline_equivalence_test.go b/internal/controlplane/storage/migrate/baseline_equivalence_test.go deleted file mode 100644 index 54a68433..00000000 --- a/internal/controlplane/storage/migrate/baseline_equivalence_test.go +++ /dev/null @@ -1,117 +0,0 @@ -package migrate - -import ( - "context" - "database/sql" - "path/filepath" - "sort" - "strings" - "testing" - - sqlitebaseline "github.com/lost-coder/panvex/db/migrations/sqlite/baseline" - sqlitestore "github.com/lost-coder/panvex/internal/controlplane/storage/sqlite" - _ "modernc.org/sqlite" -) - -// TestBaselineEquivalence_SQLite proves that the fast-path -// (applyBaselineIfFresh inside sqlite.MigrateContext) produces a -// schema bit-equivalent to the linear apply of every historical -// migration. The committed baseline_v1.sql is auto-generated by -// TestRegenerateSQLiteBaseline; this test catches the case where -// somebody hand-edits one apply path but not the other. -func TestBaselineEquivalence_SQLite(t *testing.T) { - ctx := context.Background() - - // Path A — fast-path via the production MigrateContext. The store - // uses the same code lifecycle real installs see (Open → MigrateContext). - storeA, err := sqlitestore.Open(filepath.Join(t.TempDir(), "fast.db")) - if err != nil { - t.Fatalf("Open fast-path store: %v", err) - } - t.Cleanup(func() { _ = storeA.Close() }) - fastDB := storeA.DB() - fastSchema, err := schemaFingerprint(ctx, fastDB) - if err != nil { - t.Fatalf("dump fast schema: %v", err) - } - - // Path B — apply baseline_v1.sql directly to a virgin DB. This - // must produce the same schema, since the file IS the consolidated - // migration output the production code applies. Sanity check that - // the embed.FS round-trips properly and the SQL is executable. - baseSQL, err := sqlitebaseline.FS.ReadFile("baseline_v1.sql") - if err != nil { - t.Fatalf("read baseline: %v", err) - } - rawDB, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "raw.db")) - if err != nil { - t.Fatalf("open raw db: %v", err) - } - t.Cleanup(func() { _ = rawDB.Close() }) - if _, err := rawDB.ExecContext(ctx, string(baseSQL)); err != nil { - t.Fatalf("exec baseline: %v", err) - } - rawSchema, err := schemaFingerprint(ctx, rawDB) - if err != nil { - t.Fatalf("dump raw schema: %v", err) - } - if rawSchema != fastSchema { - t.Errorf("schema drift between MigrateContext and raw baseline apply "+ - "(baseline_v1.sql is stale — regenerate it with: "+ - "go test -run TestRegenerateSQLiteBaseline -regen-baseline ./internal/controlplane/storage/migrate/...):\n"+ - " fast = %q\n raw = %q", truncate(fastSchema, 400), truncate(rawSchema, 400)) - } - - // Cross-check: the active path is the fast one — confirm that. - // On a fresh DB, applyBaselineIfFresh runs first; goose then sees - // "everything up to v39 already applied" and walks zero further - // migrations. - gooseVer, err := readMaxGooseVersion(ctx, fastDB) - if err != nil { - t.Fatalf("read goose version: %v", err) - } - if gooseVer != int64(sqlitebaseline.LatestVersion) { - t.Errorf("after fast-path MigrateContext, goose_db_version max = %d, want %d", - gooseVer, sqlitebaseline.LatestVersion) - } -} - -// schemaFingerprint produces a deterministic representation of every -// schema object in the DB, modulo SQLite's internal sqlite_sequence -// table (which both paths create on-demand for AUTOINCREMENT rows -// and may diverge by timing). -func schemaFingerprint(ctx context.Context, db *sql.DB) (string, error) { - rows, err := db.QueryContext(ctx, ` - SELECT type, name, COALESCE(sql, '') - FROM sqlite_master - WHERE name NOT LIKE 'sqlite_%' - ORDER BY type, name - `) - if err != nil { - return "", err - } - defer rows.Close() - var lines []string - for rows.Next() { - var typeName, name, ddl string - if err := rows.Scan(&typeName, &name, &ddl); err != nil { - return "", err - } - lines = append(lines, typeName+"\x1f"+name+"\x1f"+strings.TrimSpace(ddl)) - } - if err := rows.Err(); err != nil { - return "", err - } - sort.Strings(lines) - return strings.Join(lines, "\n"), nil -} - -func readMaxGooseVersion(ctx context.Context, db *sql.DB) (int64, error) { - var v int64 - if err := db.QueryRowContext(ctx, - "SELECT COALESCE(MAX(version_id), 0) FROM goose_db_version WHERE is_applied = 1", - ).Scan(&v); err != nil { - return 0, err - } - return v, nil -} diff --git a/internal/controlplane/storage/migrate/baseline_regen_test.go b/internal/controlplane/storage/migrate/baseline_regen_test.go deleted file mode 100644 index 0e04eb3c..00000000 --- a/internal/controlplane/storage/migrate/baseline_regen_test.go +++ /dev/null @@ -1,212 +0,0 @@ -package migrate - -import ( - "context" - "database/sql" - "flag" - "fmt" - "os" - "path/filepath" - "sort" - "strings" - "testing" - - sqlitemigrations "github.com/lost-coder/panvex/db/migrations/sqlite" - "github.com/pressly/goose/v3" - _ "modernc.org/sqlite" -) - -// regenBaseline toggles overwriting the committed -// db/migrations/sqlite/baseline_v1.sql with whatever sqlite_master -// returns after applying every historical migration. Operators run -// this whenever migrations are added or removed; on a normal `go test` -// run we still execute the dump and compare against the committed -// file so a missed update fails CI. -var regenBaseline = flag.Bool("regen-baseline", false, "rewrite db/migrations/sqlite/baseline_v1.sql from current migrations") - -// TestRegenerateSQLiteBaseline produces baseline_v1.sql by replaying -// the embedded migrations against a fresh in-memory SQLite database. -// Without -regen-baseline it compares the produced SQL to the -// committed file; with the flag it overwrites the file. The intent is -// that operators editing migrations run this test once, commit the -// regenerated baseline, and CI then re-runs it in compare mode to -// catch stale baselines on subsequent PRs. -func TestRegenerateSQLiteBaseline(t *testing.T) { - ctx := context.Background() - dir := t.TempDir() - dbPath := filepath.Join(dir, "regen.db") - db, err := sql.Open("sqlite", dbPath) - if err != nil { - t.Fatalf("sql.Open: %v", err) - } - t.Cleanup(func() { _ = db.Close() }) - if _, err := db.ExecContext(ctx, "PRAGMA foreign_keys = ON"); err != nil { - t.Fatalf("enable FK: %v", err) - } - - // Run every migration via goose, then dump. - goose.SetBaseFS(sqlitemigrations.FS) - if err := goose.SetDialect("sqlite3"); err != nil { - t.Fatalf("set dialect: %v", err) - } - if err := goose.UpContext(ctx, db, "."); err != nil { - t.Fatalf("goose up: %v", err) - } - - dump, err := dumpSQLiteSchema(ctx, db) - if err != nil { - t.Fatalf("dump schema: %v", err) - } - - baselinePath := filepath.Join(repoRootForTest(t), "db", "migrations", "sqlite", "baseline", "baseline_v1.sql") - if err := os.MkdirAll(filepath.Dir(baselinePath), 0o750); err != nil { - t.Fatalf("mkdir baseline dir: %v", err) - } - - if *regenBaseline { - if err := os.WriteFile(baselinePath, []byte(dump), 0o600); err != nil { - t.Fatalf("write baseline: %v", err) - } - t.Logf("rewrote %s (%d bytes)", baselinePath, len(dump)) - return - } - - committed, err := os.ReadFile(baselinePath) - if err != nil { - // First-time generation: write the file and pass. Tests on - // subsequent runs will fail-on-diff if migrations changed - // without a baseline refresh. - if !os.IsNotExist(err) { - t.Fatalf("read baseline: %v", err) - } - if err := os.WriteFile(baselinePath, []byte(dump), 0o600); err != nil { - t.Fatalf("write first-time baseline: %v", err) - } - t.Logf("first-time wrote %s (%d bytes)", baselinePath, len(dump)) - return - } - if string(committed) != dump { - t.Fatalf("baseline drift: db/migrations/sqlite/baseline/baseline_v1.sql is out of sync with current migrations.\n"+ - "Re-run with -regen-baseline:\n"+ - " go test -run TestRegenerateSQLiteBaseline -regen-baseline ./internal/controlplane/storage/migrate/...\n"+ - "diff (first 200 chars committed vs got):\n committed: %q\n got: %q", - truncate(string(committed), 200), truncate(dump, 200)) - } -} - -// dumpSQLiteSchema produces a deterministic CREATE-statement dump of -// every persistent schema object plus the goose_db_version table data -// rows so the baseline can be applied to a fresh DB and the -// subsequent goose.Up call sees "everything 1..N already applied". -func dumpSQLiteSchema(ctx context.Context, db *sql.DB) (string, error) { - rows, err := db.QueryContext(ctx, ` - SELECT type, name, sql - FROM sqlite_master - WHERE sql IS NOT NULL - AND name NOT LIKE 'sqlite_%' - ORDER BY - CASE type WHEN 'table' THEN 0 - WHEN 'index' THEN 1 - WHEN 'view' THEN 2 - WHEN 'trigger' THEN 3 - ELSE 4 END, - name - `) - if err != nil { - return "", err - } - defer rows.Close() - - var ( - statements []string - preface = "-- baseline_v1.sql (SQLite) — autogenerated by migrate.TestRegenerateSQLiteBaseline.\n" + - "-- DO NOT EDIT BY HAND. Re-run the test with -regen-baseline to update.\n" + - "-- See docs/superpowers/plans/2026-05-09-baseline-migration.md\n\n" - ) - for rows.Next() { - var typeName, name, ddl string - if err := rows.Scan(&typeName, &name, &ddl); err != nil { - return "", err - } - // SQLite stores the original CREATE without trailing semicolon; - // add one for portable execution. - statements = append(statements, strings.TrimRight(ddl, " \t\n;")+";") - } - if err := rows.Err(); err != nil { - return "", err - } - - // Append the goose_db_version rows so a fresh apply marks every - // historical migration as already-run. The columns are (id, - // version_id, is_applied, tstamp). We INSERT version_id only + - // is_applied=1; id and tstamp default to AUTOINCREMENT / NOW. - versions, err := dumpSQLiteGooseVersions(ctx, db) - if err != nil { - return "", err - } - statements = append(statements, versions) - - return preface + strings.Join(statements, "\n\n") + "\n", nil -} - -func dumpSQLiteGooseVersions(ctx context.Context, db *sql.DB) (string, error) { - rows, err := db.QueryContext(ctx, `SELECT version_id FROM goose_db_version WHERE is_applied = 1 ORDER BY version_id`) - if err != nil { - return "", err - } - defer rows.Close() - var ids []int64 - for rows.Next() { - var v int64 - if err := rows.Scan(&v); err != nil { - return "", err - } - ids = append(ids, v) - } - if err := rows.Err(); err != nil { - return "", err - } - sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) - if len(ids) == 0 { - return "", nil - } - var b strings.Builder - b.WriteString("-- Mark every historical migration as already applied so goose.Up sees a no-op.\n") - b.WriteString("INSERT INTO goose_db_version (version_id, is_applied) VALUES\n") - for i, v := range ids { - if i > 0 { - b.WriteString(",\n") - } - fmt.Fprintf(&b, " (%d, 1)", v) - } - b.WriteString(";") - return b.String(), nil -} - -func truncate(s string, n int) string { - if len(s) <= n { - return s - } - return s[:n] + "…" -} - -// repoRootForTest walks up from the test working directory until it -// finds go.mod. Tests run with cwd = the package dir, so this lets -// us reach into db/migrations/ without hard-coding a path. -func repoRootForTest(t *testing.T) string { - t.Helper() - dir, err := os.Getwd() - if err != nil { - t.Fatalf("getwd: %v", err) - } - for { - if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { - return dir - } - parent := filepath.Dir(dir) - if parent == dir { - t.Fatalf("could not find go.mod walking up from cwd") - } - dir = parent - } -} diff --git a/internal/controlplane/storage/sqlite/migrate.go b/internal/controlplane/storage/sqlite/migrate.go index 0ed01282..15df4f83 100644 --- a/internal/controlplane/storage/sqlite/migrate.go +++ b/internal/controlplane/storage/sqlite/migrate.go @@ -1,24 +1,23 @@ // Package sqlite hosts the SQLite-backed storage.Store implementation. // This file owns schema management — it delegates entirely to goose, which // discovers versioned .sql migrations from an embedded FS and records applied -// versions in the goose_db_version table. Historically this package contained -// a hand-rolled Migrate() with a single big initialSchema string plus a long -// tail of ensureXxxColumn / ensureXxxTable helpers that papered over SQLite's -// lack of "ALTER TABLE ... IF NOT EXISTS"; that approach left no audit trail -// of which migrations had run (see DF-20 / M-F8 in the security review). +// versions in the goose_db_version table. +// +// History: the migration tree was squashed into a single 0001_init.sql in +// P9 (2026-07). Databases created before the squash carry goose versions +// 1..58 and see 0001 as already superseded; new migrations therefore MUST +// use versions >= 0059 — the parity lint in storage/migrate enforces this. +// The former fresh-install baseline fast-path (applyBaselineIfFresh) was +// deleted with the squash: 0001_init.sql IS the baseline now. package sqlite import ( "context" "database/sql" - "errors" "fmt" - "io/fs" - "log/slog" "sync" sqlitemigrations "github.com/lost-coder/panvex/db/migrations/sqlite" - sqlitebaseline "github.com/lost-coder/panvex/db/migrations/sqlite/baseline" "github.com/pressly/goose/v3" ) @@ -36,19 +35,14 @@ func Migrate(db *sql.DB) error { // MigrateContext is the context-aware variant of Migrate. // -// SQLite intentionally skips the migrateguard.CheckAll step: the only -// migration currently in the destructive registry (0014) ships in a -// non-destructive ADD COLUMN + UPDATE form on this dialect (see -// db/migrations/sqlite/0014_fleet_groups_redesign.sql). If a future +// SQLite intentionally skips the migrateguard.CheckAll step: the +// destructive registry is empty since the P9 squash. If a future // destructive migration applies to SQLite as well, wire CheckAll here // the same way postgres/migrate.go does. func MigrateContext(ctx context.Context, db *sql.DB) error { gooseMu.Lock() defer gooseMu.Unlock() - if err := applyBaselineIfFresh(ctx, db); err != nil { - return fmt.Errorf("sqlite: baseline: %w", err) - } if err := configureGoose(); err != nil { return err } @@ -58,73 +52,6 @@ func MigrateContext(ctx context.Context, db *sql.DB) error { return nil } -// applyBaselineIfFresh checks whether the database has never been -// migrated (goose_db_version table missing) and, if so, applies the -// consolidated baseline_v1.sql in a single transaction. The baseline -// recreates the schema as it stood at version 39 and INSERTs every -// historical version row into goose_db_version so the subsequent -// goose.UpContext call sees a no-op for migrations 1..39 and runs -// only the (currently zero) post-baseline migrations. -// -// Pre-existing databases are left untouched; the goose_db_version -// presence check is the cheap idempotency gate. -// -// Wave 6.2 — see docs/superpowers/plans/2026-05-09-baseline-migration.md. -func applyBaselineIfFresh(ctx context.Context, db *sql.DB) error { - hasGoose, err := tableExists(ctx, db, "goose_db_version") - if err != nil { - return err - } - if hasGoose { - // Existing install — leave the linear migration tail to goose. - return nil - } - sqlBytes, err := sqlitebaseline.FS.ReadFile("baseline_v1.sql") - if err != nil { - // Missing baseline (e.g. a fork that stripped it) → fall back - // to the linear apply path so goose.Up still produces a - // correct schema. Log loudly so the operator sees the - // degraded path; embed.FS surfaces missing files via the - // fs.ErrNotExist sentinel. - if errors.Is(err, fs.ErrNotExist) { - slog.Default().Warn("sqlite baseline missing, falling back to linear migrations") - return nil - } - return fmt.Errorf("read baseline: %w", err) - } - tx, err := db.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("begin baseline tx: %w", err) - } - defer func() { _ = tx.Rollback() }() //nolint:errcheck // best-effort on commit-success - // PRAGMA foreign_keys is connection-scoped and silently ignored - // inside a transaction in SQLite, but enabling it on the - // tx-backing connection keeps the baseline apply symmetric with - // the per-migration apply path (each goose migration runs under - // foreign_keys=ON via the pool-wide DSN pragmas). - if _, err := tx.ExecContext(ctx, "PRAGMA foreign_keys = ON"); err != nil { - return fmt.Errorf("enable FK on baseline tx: %w", err) - } - if _, err := tx.ExecContext(ctx, string(sqlBytes)); err != nil { - return fmt.Errorf("exec baseline: %w", err) - } - if err := tx.Commit(); err != nil { - return fmt.Errorf("commit baseline: %w", err) - } - return nil -} - -func tableExists(ctx context.Context, db *sql.DB, name string) (bool, error) { - var n int - if err := db.QueryRowContext(ctx, - "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?", - name, - ).Scan(&n); err != nil { - return false, fmt.Errorf("probe %s: %w", name, err) - } - return n > 0, nil -} - // Status writes the applied/pending migration list to stdout via goose's // default logger. The operator invokes this through the // `migrate-schema status` subcommand on the control-plane binary. diff --git a/internal/controlplane/storage/sqlite/migrate_stress_test.go b/internal/controlplane/storage/sqlite/migrate_stress_test.go deleted file mode 100644 index f3fd298d..00000000 --- a/internal/controlplane/storage/sqlite/migrate_stress_test.go +++ /dev/null @@ -1,380 +0,0 @@ -//go:build stress - -// Stress test for the goose migration chain (P2-TEST-03). -// -// Gated behind the `stress` build tag so it does not run in the default -// `go test ./...` path — seeding 10k agents + 100k metric_snapshots plus the -// full migration chain takes minutes on slower laptops and is unnecessary for -// every CI run. Invoke it explicitly: -// -// go test -tags stress -count=1 -timeout 15m \ -// ./internal/controlplane/storage/sqlite -run TestMigrateStress -// -// What this test proves: -// - Migrate() succeeds against a database already populated with -// production-scale row counts at schema 0001 — not an empty DB. -// - Every post-0001 migration (through 0011) preserves the seeded rows: -// no ALTER/RENAME/INSERT-SELECT step silently drops data. -// - Index-creation migrations (0007, 0008) and the 0011 column rename -// complete within a reasonable wall-clock budget for the operator runbook. -// -// What this test does NOT cover: -// - PostgreSQL. That path is exercised by postgres.TestMigrate* under its -// own `pgtest` tag when PANVEX_POSTGRES_TEST_DSN is set. -// - Down migrations. Goose Down is rarely used in prod and is covered by -// the regular migrate_test.go idempotency check. - -package sqlite - -import ( - "database/sql" - "fmt" - "path/filepath" - "strings" - "testing" - "time" - - // register the pure-Go SQLite driver under "sqlite" for database/sql - _ "modernc.org/sqlite" -) - -// stressSchema0001 is a trimmed copy of the tables every later Phase 2 -// migration touches. We cannot invoke goose to apply only 0001 because goose -// has no "stop at version N" primitive from within a test; instead we -// materialise the exact pre-0002 shape of every table we need, then let the -// real Migrate() run the 0002..HEAD chain over the seeded data. -// -// Keep this schema in sync with db/migrations/sqlite/0001_init.sql for the -// tables listed below. If a future 0001 revision changes a column, this -// const must match — otherwise the later ALTER migrations will see the -// "wrong" starting shape and either fail or silently miss a rename. -const stressSchema0001 = ` -CREATE TABLE fleet_groups ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - created_at_unix INTEGER NOT NULL -); - -CREATE TABLE agents ( - id TEXT PRIMARY KEY, - node_name TEXT NOT NULL, - fleet_group_id TEXT, - version TEXT NOT NULL DEFAULT '', - read_only INTEGER NOT NULL DEFAULT 0, - last_seen_at_unix INTEGER NOT NULL, - created_at_unix INTEGER NOT NULL DEFAULT 0, - FOREIGN KEY (fleet_group_id) REFERENCES fleet_groups (id) -); - -CREATE TABLE audit_events ( - id TEXT PRIMARY KEY, - actor_id TEXT NOT NULL, - action TEXT NOT NULL, - target_id TEXT NOT NULL, - created_at_unix INTEGER NOT NULL, - details_json TEXT NOT NULL DEFAULT '{}' -); - -CREATE TABLE metric_snapshots ( - id TEXT PRIMARY KEY, - agent_id TEXT NOT NULL, - instance_id TEXT NOT NULL DEFAULT '', - captured_at_unix INTEGER NOT NULL, - values_json TEXT NOT NULL -); -` - -// The stress test scale. These numbers are the smallest that still exercise -// the behaviours we care about — large enough that any O(N) anomaly shows up -// in wall-clock time, small enough to finish in under 2 minutes on a laptop. -const ( - stressAgents = 10_000 - stressMetrics = 100_000 - stressAudits = 10_000 - stressGroups = 32 - // Chunk size for multi-row INSERTs. At 5 columns per metric_snapshots row - // this stays well under SQLite's default 32k-parameter ceiling. - stressChunk = 2_000 -) - -// TestMigrateStress seeds a pre-0002 SQLite DB at production scale, runs the -// real Migrate() chain, and asserts that (a) it completes, (b) row counts -// are preserved through every ALTER/RENAME/INSERT-SELECT step, (c) the -// schema-level contracts from later migrations (indexes from 0007/0008, the -// 0011 column rename) are honoured on a non-empty DB. -func TestMigrateStress(t *testing.T) { - // A stress test that takes a minute to fail should give a clear reason - // when it times out, not a truncated testing.T.Fatal. - started := time.Now() - defer func() { - t.Logf("TestMigrateStress wall-clock: %s", time.Since(started)) - }() - - db := openStressSQLite(t) - - // Stage 1 — materialise schema 0001. No goose: we want the exact column - // layout that existed before 0002..0011 ran. - if _, err := db.Exec(stressSchema0001); err != nil { - t.Fatalf("apply stressSchema0001: %v", err) - } - - // Stage 2 — bulk-seed. Tune PRAGMAs for speed; Migrate() will pick its - // own PRAGMAs downstream. - applyPragmas(t, db, - "PRAGMA journal_mode = WAL", - "PRAGMA synchronous = OFF", - "PRAGMA temp_store = MEMORY", - "PRAGMA cache_size = -200000", // ~200MB page cache during seed - ) - seedStart := time.Now() - seedStressData(t, db) - t.Logf("seeded %d agents + %d metric_snapshots + %d audits in %s", - stressAgents, stressMetrics, stressAudits, time.Since(seedStart)) - - // Capture pre-migration row counts so we can diff them after Migrate(). - // Every later migration must be data-preserving on these tables. - pre := countTables(t, db, "fleet_groups", "agents", "audit_events", "metric_snapshots") - - // Stage 3 — run the real migration chain. This is the behaviour under - // test; a panic, error, or hang here is the whole failure mode. - migStart := time.Now() - if err := Migrate(db); err != nil { - t.Fatalf("Migrate() on seeded DB: %v", err) - } - t.Logf("Migrate() applied full chain in %s", time.Since(migStart)) - - // Stage 4 — invariants. - post := countTables(t, db, "fleet_groups", "agents", "audit_events", "metric_snapshots") - for tbl, want := range pre { - if got := post[tbl]; got != want { - t.Errorf("row count drift on %s: pre=%d post=%d", tbl, want, got) - } - } - - // Verify the 0011 column rename actually moved data. Reading the renamed - // column confirms both schema and row survival. - var anyValues string - err := db.QueryRow(`SELECT "values" FROM metric_snapshots LIMIT 1`).Scan(&anyValues) - if err != nil { - t.Fatalf("read metric_snapshots.values after 0011: %v", err) - } - if anyValues == "" { - t.Errorf("metric_snapshots.values is empty — 0011 rename may have dropped data") - } - var anyDetails string - err = db.QueryRow(`SELECT details FROM audit_events LIMIT 1`).Scan(&anyDetails) - if err != nil { - t.Fatalf("read audit_events.details after 0011: %v", err) - } - if anyDetails == "" { - t.Errorf("audit_events.details is empty — 0011 rename may have dropped data") - } - - // P2-DB-02 / DF-22 indexes must be present after 0008. On an empty DB - // that is already covered by TestMigrateCreatesMissingFKIndexes; here we - // assert the same holds when the index is built over real data. - for _, idx := range []string{ - "idx_jobs_status", - "idx_job_targets_agent_id", - "idx_metric_snapshots_captured_at", - "idx_enrollment_tokens_fleet_group_id", - } { - var name string - if err := db.QueryRow( - `SELECT name FROM sqlite_master WHERE type='index' AND name=?`, idx, - ).Scan(&name); err != nil { - t.Errorf("missing index %q after Migrate on seeded DB: %v", idx, err) - } - } - - // goose_db_version must hold one row per applied migration. A missing - // row here means a migration ran without being recorded — the DF-20 - // failure mode we built this whole framework to eliminate. - var versionCount int - if err := db.QueryRow(`SELECT COUNT(*) FROM goose_db_version`).Scan(&versionCount); err != nil { - t.Fatalf("count goose_db_version: %v", err) - } - // At minimum the 11 embedded migrations (0001..0011) plus goose's - // zero-version bookkeeping row. - if versionCount < 11 { - t.Errorf("goose_db_version has %d rows, expected >= 11", versionCount) - } - - // Re-running Migrate on the seeded+migrated DB must be a no-op. This - // catches the case where a migration accidentally re-runs DDL on an - // already-upgraded schema. - if err := Migrate(db); err != nil { - t.Fatalf("Migrate() second pass on seeded DB: %v", err) - } - after := countTables(t, db, "fleet_groups", "agents", "audit_events", "metric_snapshots") - for tbl, want := range post { - if got := after[tbl]; got != want { - t.Errorf("row count changed on second Migrate(): %s pre=%d post=%d", tbl, want, got) - } - } -} - -// openStressSQLite opens a file-backed SQLite DB in t.TempDir. We avoid -// ":memory:" because the seeded dataset (~50 MB) makes memory pressure a -// confounding variable — we want to measure migration cost, not GC cost. -func openStressSQLite(t *testing.T) *sql.DB { - t.Helper() - path := filepath.Join(t.TempDir(), "migrate-stress.db") - db, err := sql.Open("sqlite", path) - if err != nil { - t.Fatalf("sql.Open: %v", err) - } - // Pool of 1: goose and our PRAGMAs both rely on a single connection to - // see consistent session state. - db.SetMaxOpenConns(1) - if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil { - db.Close() - t.Fatalf("PRAGMA foreign_keys: %v", err) - } - t.Cleanup(func() { db.Close() }) - return db -} - -func applyPragmas(t *testing.T, db *sql.DB, pragmas ...string) { - t.Helper() - for _, p := range pragmas { - if _, err := db.Exec(p); err != nil { - t.Fatalf("pragma %q: %v", p, err) - } - } -} - -// seedStressData inserts fleet_groups, agents, audit_events, and -// metric_snapshots. We batch inside a single transaction per table because -// SQLite's autocommit-per-statement is ~100x slower than a single tx. -func seedStressData(t *testing.T, db *sql.DB) { - t.Helper() - now := time.Now().Unix() - - // fleet_groups — referenced by agents.fleet_group_id FK. - seedBulk(t, db, "fleet_groups", - []string{"id", "name", "created_at_unix"}, - stressGroups, - func(i int, a []any) { - a[0] = fmt.Sprintf("fg-%06d", i) - a[1] = fmt.Sprintf("group-%d", i) - a[2] = now - }, - ) - - // agents — some with nil fleet_group_id to exercise the nullable FK path - // that migration 0008 indexes. - seedBulk(t, db, "agents", - []string{"id", "node_name", "fleet_group_id", "version", "read_only", "last_seen_at_unix", "created_at_unix"}, - stressAgents, - func(i int, a []any) { - a[0] = fmt.Sprintf("agent-%08d", i) - a[1] = fmt.Sprintf("node-%d", i) - if i%5 != 0 { - a[2] = fmt.Sprintf("fg-%06d", i%stressGroups) - } else { - a[2] = nil - } - a[3] = "1.2.3" - a[4] = 0 - a[5] = now - int64(i%3600) - a[6] = now - int64(i%86400) - }, - ) - - // audit_events — realistic JSON payload so migration 0011's rename has - // actual bytes to carry forward. - seedBulk(t, db, "audit_events", - []string{"id", "actor_id", "action", "target_id", "created_at_unix", "details_json"}, - stressAudits, - func(i int, a []any) { - a[0] = fmt.Sprintf("audit-%09d", i) - a[1] = fmt.Sprintf("user-%d", i%500) - a[2] = "client.update" - a[3] = fmt.Sprintf("target-%d", i) - a[4] = now - int64(i%86400*30) - a[5] = `{"ip":"10.0.0.1","ua":"panvex-cli/1.0"}` - }, - ) - - // metric_snapshots — the big one. Spread across a 7-day window so the - // captured_at index from 0008 sees realistic selectivity. - seedBulk(t, db, "metric_snapshots", - []string{"id", "agent_id", "instance_id", "captured_at_unix", "values_json"}, - stressMetrics, - func(i int, a []any) { - a[0] = fmt.Sprintf("metric-%010d", i) - a[1] = fmt.Sprintf("agent-%08d", i%stressAgents) - a[2] = "" - a[3] = now - int64(i%(7*24*3600)) - a[4] = `{"cpu":0.5,"mem":0.3,"conns":1234}` - }, - ) -} - -// seedBulk bulk-inserts `total` rows into `table` using chunked multi-row -// INSERTs inside a single transaction. This is 10-50x faster than prepared -// single-row inserts for the scales we care about. -func seedBulk(t *testing.T, db *sql.DB, table string, cols []string, total int, rowFn func(i int, args []any)) { - t.Helper() - if total == 0 { - return - } - tx, err := db.Begin() - if err != nil { - t.Fatalf("begin tx for %s: %v", table, err) - } - defer func() { - if err != nil { - _ = tx.Rollback() - } - }() - - placeholders := "(" + strings.Repeat("?,", len(cols)-1) + "?)" - head := fmt.Sprintf("INSERT INTO %s (%s) VALUES ", table, strings.Join(cols, ",")) - - for start := 0; start < total; start += stressChunk { - end := start + stressChunk - if end > total { - end = total - } - n := end - start - var sb strings.Builder - sb.Grow(len(head) + n*(len(placeholders)+1)) - sb.WriteString(head) - for i := 0; i < n; i++ { - if i > 0 { - sb.WriteByte(',') - } - sb.WriteString(placeholders) - } - - args := make([]any, 0, n*len(cols)) - rowBuf := make([]any, len(cols)) - for i := 0; i < n; i++ { - rowFn(start+i, rowBuf) - args = append(args, rowBuf...) - } - if _, err = tx.Exec(sb.String(), args...); err != nil { - t.Fatalf("insert %s [%d:%d]: %v", table, start, end, err) - } - } - if err = tx.Commit(); err != nil { - t.Fatalf("commit %s: %v", table, err) - } -} - -// countTables returns a map of table name -> COUNT(*). Used to diff row -// counts across the migration boundary. -func countTables(t *testing.T, db *sql.DB, tables ...string) map[string]int64 { - t.Helper() - out := make(map[string]int64, len(tables)) - for _, tbl := range tables { - var n int64 - if err := db.QueryRow(fmt.Sprintf("SELECT COUNT(*) FROM %s", tbl)).Scan(&n); err != nil { - t.Fatalf("count %s: %v", tbl, err) - } - out[tbl] = n - } - return out -} diff --git a/internal/controlplane/storage/sqlite/migrate_test.go b/internal/controlplane/storage/sqlite/migrate_test.go index 8c5da64c..2eff6ba1 100644 --- a/internal/controlplane/storage/sqlite/migrate_test.go +++ b/internal/controlplane/storage/sqlite/migrate_test.go @@ -29,22 +29,21 @@ func TestMigrateCreatesGooseVersionTable(t *testing.T) { t.Fatalf("goose_db_version table not found: %v", err) } - // Count applied versions — we expect at least the 8 embedded migrations - // (0001..0008). The ">=" floor is there so future migrations don't - // force a brittle equality assertion. + // Exactly one embedded migration after the P9 squash: 0001_init.sql. + // Pre-squash DBs carry versions 1..58, but this test always starts + // from an empty file, so the ledger must contain exactly version 1. var count int if err := db.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM goose_db_version WHERE is_applied = 1 AND version_id > 0`).Scan(&count); err != nil { t.Fatalf("count applied versions: %v", err) } - if count < 8 { - t.Fatalf("expected >= 8 applied goose versions, got %d", count) + if count != 1 { + t.Fatalf("expected exactly 1 applied goose version (0001_init), got %d", count) } } -// TestMigrateCreatesMissingFKIndexes verifies that migration 0008 creates the -// four FK/status indexes required by remediation task P2-DB-02. Without these, -// audit finding DF-22 (missing indexes on high-selectivity foreign keys) stays -// open — the retention worker and filter APIs would degrade to full scans. +// TestMigrateCreatesMissingFKIndexes verifies that the squashed 0001_init +// carries the four FK/status indexes required by remediation task P2-DB-02 +// (historically added by migration 0008; audit finding DF-22). func TestMigrateCreatesMissingFKIndexes(t *testing.T) { db := openEmptySQLite(t) @@ -123,13 +122,12 @@ func TestMigrateCreatesCoreTables(t *testing.T) { } } -// TestMigrateRenamesJSONColumns verifies that migration 0011 renames the -// SQLite JSON-blob columns to match the PostgreSQL schema (P2-DB-05 / DF-25). -// Before 0011, SQLite used audit_events.details_json and -// metric_snapshots.values_json; after 0011, the names are `details` and -// `values` respectively. Keeping them aligned across backends lets the Store -// methods share SQL and removes a whole class of copy-paste drift bugs. -func TestMigrateRenamesJSONColumns(t *testing.T) { +// TestSchemaUsesCanonicalJSONColumnNames pins the canonical (PostgreSQL- +// aligned) JSON column names in the SQLite schema: audit_events.details and +// metric_snapshots."values" (P2-DB-05 / DF-25, historically migration 0011). +// If a schema edit reintroduces the *_json names, the Store SQL shared +// between backends silently diverges again — this is the parity canary. +func TestSchemaUsesCanonicalJSONColumnNames(t *testing.T) { db := openEmptySQLite(t) if err := Migrate(db); err != nil { From fcd82361005471b766f6cb7bfca5297b3e05b106 Mon Sep 17 00:00:00 2001 From: Mirotin Artem Date: Tue, 7 Jul 2026 13:24:43 +0300 Subject: [PATCH 3/7] db/postgres: squash migrations 0001..0058 into 0001_init --- db/migrations/postgres/0001_init.sql | 1132 +++++++++++++---- .../postgres/0002_discovered_clients.sql | 22 - .../postgres/0003_agent_cert_dates.sql | 7 - db/migrations/postgres/0004_sessions.sql | 14 - .../postgres/0005_agent_revocations.sql | 21 - .../0006_timeseries_and_update_config.sql | 86 -- db/migrations/postgres/0007_indexes.sql | 36 - .../postgres/0008_missing_indexes.sql | 17 - .../postgres/0009_retention_settings.sql | 12 - ...0010_discovered_clients_pending_unique.sql | 17 - db/migrations/postgres/0012_cascade_fk.sql | 89 -- db/migrations/postgres/0013_login_lockout.sql | 20 - .../postgres/0014_fleet_groups_redesign.sql | 118 -- db/migrations/postgres/0015_client_usage.sql | 29 - .../postgres/0016_sessions_last_seen.sql | 6 - db/migrations/postgres/0017_consumed_totp.sql | 12 - db/migrations/postgres/0018_cp_secrets.sql | 9 - .../postgres/0019_jobs_actor_id_index.sql | 7 - .../postgres/0020_agents_cert_serial.sql | 5 - .../postgres/0021_enrollment_token_hash.sql | 11 - .../0022_cascade_fk_telemt_instances.sql | 15 - .../postgres/0023_check_constraints.sql | 18 - .../postgres/0024_unify_real_to_double.sql | 34 - .../postgres/0025_user_fleet_group_scopes.sql | 25 - .../0027_fix_job_targets_check_enum.sql | 20 - .../0028_cascade_fk_grants_and_discovered.sql | 32 - .../postgres/0029_connection_links_array.sql | 31 - .../postgres/0030_node_transport_mode.sql | 33 - .../postgres/0031_agent_fallback_state.sql | 12 - .../postgres/0032_password_policy.sql | 11 - .../postgres/0033_agent_cert_pin.sql | 23 - .../postgres/0034_geoip_settings.sql | 13 - .../postgres/0035_telemt_unreachable.sql | 13 - ..._bootstrap_columns_from_panel_settings.sql | 13 - .../postgres/0037_runtime_settings.sql | 11 - .../postgres/0038_audit_hash_chain.sql | 26 - .../postgres/0039_webhook_outbox.sql | 40 - .../0040_invert_telemt_reachability.sql | 18 - .../postgres/0041_enrollment_attempts.sql | 38 - .../0042_client_deployment_last_reset.sql | 16 - .../postgres/0043_client_usage_quota.sql | 21 - .../0044_drop_enrollment_token_value_hash.sql | 10 - ...0045_client_deployment_link_diagnostic.sql | 15 - ..._rename_connected_users_to_connections.sql | 9 - .../0047_drop_telemt_detail_boosts.sql | 13 - .../postgres/0048_jobs_status_partial.sql | 14 - .../postgres/0049_agent_config_targets.sql | 17 - db/migrations/postgres/0050_check_parity.sql | 17 - .../0051_client_subscription_token.sql | 19 - ...0052_integration_providers_config_text.sql | 87 -- .../postgres/0053_config_apply_batches.sql | 44 - ...0054_config_apply_batch_target_message.sql | 16 - .../postgres/0055_runtime_current_json.sql | 13 - ...0056_config_apply_batch_group_nullable.sql | 8 - .../postgres/0057_client_usage_watermark.sql | 9 - .../0058_client_usage_drop_last_seq.sql | 6 - .../storage/postgres/migrate_test.go | 7 +- 57 files changed, 858 insertions(+), 1579 deletions(-) delete mode 100644 db/migrations/postgres/0002_discovered_clients.sql delete mode 100644 db/migrations/postgres/0003_agent_cert_dates.sql delete mode 100644 db/migrations/postgres/0004_sessions.sql delete mode 100644 db/migrations/postgres/0005_agent_revocations.sql delete mode 100644 db/migrations/postgres/0006_timeseries_and_update_config.sql delete mode 100644 db/migrations/postgres/0007_indexes.sql delete mode 100644 db/migrations/postgres/0008_missing_indexes.sql delete mode 100644 db/migrations/postgres/0009_retention_settings.sql delete mode 100644 db/migrations/postgres/0010_discovered_clients_pending_unique.sql delete mode 100644 db/migrations/postgres/0012_cascade_fk.sql delete mode 100644 db/migrations/postgres/0013_login_lockout.sql delete mode 100644 db/migrations/postgres/0014_fleet_groups_redesign.sql delete mode 100644 db/migrations/postgres/0015_client_usage.sql delete mode 100644 db/migrations/postgres/0016_sessions_last_seen.sql delete mode 100644 db/migrations/postgres/0017_consumed_totp.sql delete mode 100644 db/migrations/postgres/0018_cp_secrets.sql delete mode 100644 db/migrations/postgres/0019_jobs_actor_id_index.sql delete mode 100644 db/migrations/postgres/0020_agents_cert_serial.sql delete mode 100644 db/migrations/postgres/0021_enrollment_token_hash.sql delete mode 100644 db/migrations/postgres/0022_cascade_fk_telemt_instances.sql delete mode 100644 db/migrations/postgres/0023_check_constraints.sql delete mode 100644 db/migrations/postgres/0024_unify_real_to_double.sql delete mode 100644 db/migrations/postgres/0025_user_fleet_group_scopes.sql delete mode 100644 db/migrations/postgres/0027_fix_job_targets_check_enum.sql delete mode 100644 db/migrations/postgres/0028_cascade_fk_grants_and_discovered.sql delete mode 100644 db/migrations/postgres/0029_connection_links_array.sql delete mode 100644 db/migrations/postgres/0030_node_transport_mode.sql delete mode 100644 db/migrations/postgres/0031_agent_fallback_state.sql delete mode 100644 db/migrations/postgres/0032_password_policy.sql delete mode 100644 db/migrations/postgres/0033_agent_cert_pin.sql delete mode 100644 db/migrations/postgres/0034_geoip_settings.sql delete mode 100644 db/migrations/postgres/0035_telemt_unreachable.sql delete mode 100644 db/migrations/postgres/0036_drop_bootstrap_columns_from_panel_settings.sql delete mode 100644 db/migrations/postgres/0037_runtime_settings.sql delete mode 100644 db/migrations/postgres/0038_audit_hash_chain.sql delete mode 100644 db/migrations/postgres/0039_webhook_outbox.sql delete mode 100644 db/migrations/postgres/0040_invert_telemt_reachability.sql delete mode 100644 db/migrations/postgres/0041_enrollment_attempts.sql delete mode 100644 db/migrations/postgres/0042_client_deployment_last_reset.sql delete mode 100644 db/migrations/postgres/0043_client_usage_quota.sql delete mode 100644 db/migrations/postgres/0044_drop_enrollment_token_value_hash.sql delete mode 100644 db/migrations/postgres/0045_client_deployment_link_diagnostic.sql delete mode 100644 db/migrations/postgres/0046_rename_connected_users_to_connections.sql delete mode 100644 db/migrations/postgres/0047_drop_telemt_detail_boosts.sql delete mode 100644 db/migrations/postgres/0048_jobs_status_partial.sql delete mode 100644 db/migrations/postgres/0049_agent_config_targets.sql delete mode 100644 db/migrations/postgres/0050_check_parity.sql delete mode 100644 db/migrations/postgres/0051_client_subscription_token.sql delete mode 100644 db/migrations/postgres/0052_integration_providers_config_text.sql delete mode 100644 db/migrations/postgres/0053_config_apply_batches.sql delete mode 100644 db/migrations/postgres/0054_config_apply_batch_target_message.sql delete mode 100644 db/migrations/postgres/0055_runtime_current_json.sql delete mode 100644 db/migrations/postgres/0056_config_apply_batch_group_nullable.sql delete mode 100644 db/migrations/postgres/0057_client_usage_watermark.sql delete mode 100644 db/migrations/postgres/0058_client_usage_drop_last_seq.sql diff --git a/db/migrations/postgres/0001_init.sql b/db/migrations/postgres/0001_init.sql index b33bdda5..4f073134 100644 --- a/db/migrations/postgres/0001_init.sql +++ b/db/migrations/postgres/0001_init.sql @@ -1,283 +1,857 @@ -- +goose Up -CREATE TABLE IF NOT EXISTS fleet_groups ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL -); - -CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL UNIQUE, - password_hash TEXT NOT NULL, - role TEXT NOT NULL, - totp_enabled BOOLEAN NOT NULL DEFAULT FALSE, - totp_secret TEXT NOT NULL DEFAULT '', - created_at TIMESTAMPTZ NOT NULL -); - -CREATE TABLE IF NOT EXISTS user_appearance ( - user_id TEXT PRIMARY KEY REFERENCES users (id) ON DELETE CASCADE, - theme TEXT NOT NULL DEFAULT 'system', - density TEXT NOT NULL DEFAULT 'comfortable', - help_mode TEXT NOT NULL DEFAULT 'basic', - updated_at TIMESTAMPTZ NOT NULL DEFAULT TIMESTAMPTZ 'epoch' -); - -CREATE TABLE IF NOT EXISTS agents ( - id TEXT PRIMARY KEY, - node_name TEXT NOT NULL, - fleet_group_id TEXT REFERENCES fleet_groups (id), - version TEXT NOT NULL DEFAULT '', - read_only BOOLEAN NOT NULL DEFAULT FALSE, - last_seen_at TIMESTAMPTZ NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE TABLE IF NOT EXISTS telemt_instances ( - id TEXT PRIMARY KEY, - agent_id TEXT NOT NULL REFERENCES agents (id), - name TEXT NOT NULL, - version TEXT NOT NULL DEFAULT '', - config_fingerprint TEXT NOT NULL DEFAULT '', - connected_users BIGINT NOT NULL DEFAULT 0, - read_only BOOLEAN NOT NULL DEFAULT FALSE, - updated_at TIMESTAMPTZ NOT NULL -); - -CREATE TABLE IF NOT EXISTS telemt_runtime_current ( - agent_id TEXT PRIMARY KEY REFERENCES agents (id) ON DELETE CASCADE, - observed_at TIMESTAMPTZ NOT NULL, - state TEXT NOT NULL DEFAULT '', - state_reason TEXT NOT NULL DEFAULT '', - read_only BOOLEAN NOT NULL DEFAULT FALSE, - accepting_new_connections BOOLEAN NOT NULL DEFAULT FALSE, - me_runtime_ready BOOLEAN NOT NULL DEFAULT FALSE, - me2dc_fallback_enabled BOOLEAN NOT NULL DEFAULT FALSE, - use_middle_proxy BOOLEAN NOT NULL DEFAULT FALSE, - startup_status TEXT NOT NULL DEFAULT '', - startup_stage TEXT NOT NULL DEFAULT '', - startup_progress_pct DOUBLE PRECISION NOT NULL DEFAULT 0, - initialization_status TEXT NOT NULL DEFAULT '', - degraded BOOLEAN NOT NULL DEFAULT FALSE, - initialization_stage TEXT NOT NULL DEFAULT '', - initialization_progress_pct DOUBLE PRECISION NOT NULL DEFAULT 0, - transport_mode TEXT NOT NULL DEFAULT '', - current_connections BIGINT NOT NULL DEFAULT 0, - current_connections_me BIGINT NOT NULL DEFAULT 0, - current_connections_direct BIGINT NOT NULL DEFAULT 0, - active_users BIGINT NOT NULL DEFAULT 0, - uptime_seconds DOUBLE PRECISION NOT NULL DEFAULT 0, - connections_total BIGINT NOT NULL DEFAULT 0, - connections_bad_total BIGINT NOT NULL DEFAULT 0, - handshake_timeouts_total BIGINT NOT NULL DEFAULT 0, - configured_users BIGINT NOT NULL DEFAULT 0, - dc_coverage_pct DOUBLE PRECISION NOT NULL DEFAULT 0, - healthy_upstreams BIGINT NOT NULL DEFAULT 0, - total_upstreams BIGINT NOT NULL DEFAULT 0 -); - -CREATE TABLE IF NOT EXISTS telemt_runtime_dcs_current ( - agent_id TEXT NOT NULL REFERENCES agents (id) ON DELETE CASCADE, - dc BIGINT NOT NULL, - observed_at TIMESTAMPTZ NOT NULL, - available_endpoints BIGINT NOT NULL DEFAULT 0, - available_pct DOUBLE PRECISION NOT NULL DEFAULT 0, - required_writers BIGINT NOT NULL DEFAULT 0, - alive_writers BIGINT NOT NULL DEFAULT 0, - coverage_pct DOUBLE PRECISION NOT NULL DEFAULT 0, - rtt_ms DOUBLE PRECISION NOT NULL DEFAULT 0, - load DOUBLE PRECISION NOT NULL DEFAULT 0, - PRIMARY KEY (agent_id, dc) -); - -CREATE TABLE IF NOT EXISTS telemt_runtime_upstreams_current ( - agent_id TEXT NOT NULL REFERENCES agents (id) ON DELETE CASCADE, - upstream_id BIGINT NOT NULL, - observed_at TIMESTAMPTZ NOT NULL, - route_kind TEXT NOT NULL DEFAULT '', - address TEXT NOT NULL DEFAULT '', - healthy BOOLEAN NOT NULL DEFAULT FALSE, - fails BIGINT NOT NULL DEFAULT 0, - effective_latency_ms DOUBLE PRECISION NOT NULL DEFAULT 0, - PRIMARY KEY (agent_id, upstream_id) -); - --- Column name differs between drivers: SQLite uses timestamp_unix (INTEGER) --- while Postgres uses timestamp_at (TIMESTAMPTZ). Query implementations in --- each driver's telemetry.go must use the correct column name. -CREATE TABLE IF NOT EXISTS telemt_runtime_events ( - agent_id TEXT NOT NULL REFERENCES agents (id) ON DELETE CASCADE, - sequence BIGINT NOT NULL, - observed_at TIMESTAMPTZ NOT NULL, - timestamp_at TIMESTAMPTZ NOT NULL, - event_type TEXT NOT NULL DEFAULT '', - context TEXT NOT NULL DEFAULT '', - severity TEXT NOT NULL DEFAULT '', - PRIMARY KEY (agent_id, sequence) -); - -CREATE TABLE IF NOT EXISTS telemt_diagnostics_current ( - agent_id TEXT PRIMARY KEY REFERENCES agents (id) ON DELETE CASCADE, - observed_at TIMESTAMPTZ NOT NULL, - state TEXT NOT NULL DEFAULT '', - state_reason TEXT NOT NULL DEFAULT '', - system_info_json TEXT NOT NULL DEFAULT '{}', - effective_limits_json TEXT NOT NULL DEFAULT '{}', - security_posture_json TEXT NOT NULL DEFAULT '{}', - minimal_all_json TEXT NOT NULL DEFAULT '{}', - me_pool_json TEXT NOT NULL DEFAULT '{}', - dcs_json TEXT NOT NULL DEFAULT '{}' -); - -CREATE TABLE IF NOT EXISTS telemt_security_inventory_current ( - agent_id TEXT PRIMARY KEY REFERENCES agents (id) ON DELETE CASCADE, - observed_at TIMESTAMPTZ NOT NULL, - state TEXT NOT NULL DEFAULT '', - state_reason TEXT NOT NULL DEFAULT '', - enabled BOOLEAN NOT NULL DEFAULT FALSE, - entries_total BIGINT NOT NULL DEFAULT 0, - entries_json TEXT NOT NULL DEFAULT '[]' -); - -CREATE TABLE IF NOT EXISTS telemt_detail_boosts ( - agent_id TEXT PRIMARY KEY REFERENCES agents (id) ON DELETE CASCADE, - expires_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL -); - -CREATE TABLE IF NOT EXISTS jobs ( - id TEXT PRIMARY KEY, - action TEXT NOT NULL, - idempotency_key TEXT NOT NULL UNIQUE, - actor_id TEXT NOT NULL, - status TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL, - ttl_nanos BIGINT NOT NULL, - payload_json TEXT NOT NULL DEFAULT '' -); - -CREATE TABLE IF NOT EXISTS job_targets ( - job_id TEXT NOT NULL REFERENCES jobs (id), - agent_id TEXT NOT NULL, - status TEXT NOT NULL, - result_text TEXT NOT NULL DEFAULT '', - result_json TEXT NOT NULL DEFAULT '', - updated_at TIMESTAMPTZ NOT NULL, - PRIMARY KEY (job_id, agent_id) -); - -CREATE TABLE IF NOT EXISTS audit_events ( - id TEXT PRIMARY KEY, - actor_id TEXT NOT NULL, - action TEXT NOT NULL, - target_id TEXT NOT NULL, - details JSONB NOT NULL DEFAULT '{}'::jsonb, - created_at TIMESTAMPTZ NOT NULL -); - -CREATE TABLE IF NOT EXISTS metric_snapshots ( - id TEXT PRIMARY KEY, - agent_id TEXT NOT NULL REFERENCES agents (id), - instance_id TEXT NOT NULL DEFAULT '', - captured_at TIMESTAMPTZ NOT NULL, - values JSONB NOT NULL -); - -CREATE TABLE IF NOT EXISTS enrollment_tokens ( - value TEXT PRIMARY KEY, - fleet_group_id TEXT, - issued_at TIMESTAMPTZ NOT NULL, - expires_at TIMESTAMPTZ NOT NULL, - consumed_at TIMESTAMPTZ, - revoked_at TIMESTAMPTZ -); - -CREATE TABLE IF NOT EXISTS agent_certificate_recovery_grants ( - agent_id TEXT PRIMARY KEY REFERENCES agents (id), - issued_by TEXT NOT NULL, - issued_at TIMESTAMPTZ NOT NULL, - expires_at TIMESTAMPTZ NOT NULL, - used_at TIMESTAMPTZ, - revoked_at TIMESTAMPTZ -); - -CREATE TABLE IF NOT EXISTS clients ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - secret_ciphertext TEXT NOT NULL, - user_ad_tag TEXT NOT NULL, - enabled BOOLEAN NOT NULL DEFAULT TRUE, - max_tcp_conns BIGINT NOT NULL DEFAULT 0, - max_unique_ips BIGINT NOT NULL DEFAULT 0, - data_quota_bytes BIGINT NOT NULL DEFAULT 0, - expiration_rfc3339 TEXT NOT NULL DEFAULT '', - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL, - deleted_at TIMESTAMPTZ -); - -CREATE TABLE IF NOT EXISTS client_assignments ( - id TEXT PRIMARY KEY, - client_id TEXT NOT NULL REFERENCES clients (id) ON DELETE CASCADE, - target_type TEXT NOT NULL, - fleet_group_id TEXT REFERENCES fleet_groups (id), - agent_id TEXT REFERENCES agents (id), - created_at TIMESTAMPTZ NOT NULL -); - -CREATE TABLE IF NOT EXISTS client_deployments ( - client_id TEXT NOT NULL REFERENCES clients (id) ON DELETE CASCADE, - agent_id TEXT NOT NULL REFERENCES agents (id), - desired_operation TEXT NOT NULL, - status TEXT NOT NULL, - last_error TEXT NOT NULL DEFAULT '', - connection_link TEXT NOT NULL DEFAULT '', - last_applied_at TIMESTAMPTZ, - updated_at TIMESTAMPTZ NOT NULL, - PRIMARY KEY (client_id, agent_id) -); - -CREATE TABLE IF NOT EXISTS panel_settings ( - scope TEXT PRIMARY KEY, - http_public_url TEXT NOT NULL DEFAULT '', - http_root_path TEXT NOT NULL DEFAULT '', - grpc_public_endpoint TEXT NOT NULL DEFAULT '', - http_listen_address TEXT NOT NULL DEFAULT '', - grpc_listen_address TEXT NOT NULL DEFAULT '', - tls_mode TEXT NOT NULL DEFAULT '', - tls_cert_file TEXT NOT NULL DEFAULT '', - tls_key_file TEXT NOT NULL DEFAULT '', - updated_at TIMESTAMPTZ NOT NULL -); - -CREATE TABLE IF NOT EXISTS certificate_authority ( - scope TEXT PRIMARY KEY, - ca_pem TEXT NOT NULL, - private_key_pem TEXT NOT NULL, - updated_at TIMESTAMPTZ NOT NULL +-- P9 squash (2026-07): консолидация миграций 0001..0058 в один init. +-- Файл сгенерирован pg_dump --schema-only с БД, смигрированной полным +-- историческим деревом (задача P9-4). НЕ редактировать задним числом: +-- изменения схемы = новая миграция с номером >= 0059 (README рядом). + + +CREATE TABLE public.agent_certificate_recovery_grants ( + agent_id text NOT NULL, + issued_by text NOT NULL, + issued_at timestamp with time zone NOT NULL, + expires_at timestamp with time zone NOT NULL, + used_at timestamp with time zone, + revoked_at timestamp with time zone +); + +CREATE TABLE public.agent_config_targets ( + scope_type text NOT NULL, + scope_id text NOT NULL, + sections_json text DEFAULT '{}'::text NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL +); + +CREATE TABLE public.agent_fallback_state ( + agent_id text NOT NULL, + entered_at_unix bigint NOT NULL +); + +CREATE TABLE public.agent_revocations ( + agent_id text NOT NULL, + revoked_at timestamp with time zone DEFAULT now() NOT NULL, + cert_expires_at timestamp with time zone NOT NULL +); + +CREATE TABLE public.agents ( + id text NOT NULL, + node_name text NOT NULL, + fleet_group_id uuid, + version text DEFAULT ''::text NOT NULL, + read_only boolean DEFAULT false NOT NULL, + last_seen_at timestamp with time zone NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + cert_issued_at timestamp with time zone, + cert_expires_at timestamp with time zone, + cert_serial text DEFAULT ''::text NOT NULL, + transport_mode text DEFAULT 'inbound'::text NOT NULL, + dial_address text, + bootstrap_state text DEFAULT 'active'::text NOT NULL, + bootstrap_token_hash bytea, + bootstrap_expires_at timestamp with time zone, + cert_spki_sha256 bytea DEFAULT '\x'::bytea NOT NULL, + CONSTRAINT agents_bootstrap_state_check CHECK ((bootstrap_state = ANY (ARRAY['pending'::text, 'active'::text, 'expired'::text, 'revoked'::text]))), + CONSTRAINT agents_cert_spki_sha256_check CHECK ((length(cert_spki_sha256) = ANY (ARRAY[0, 32]))), + CONSTRAINT agents_transport_mode_check CHECK ((transport_mode = ANY (ARRAY['inbound'::text, 'outbound'::text]))) +); + +CREATE TABLE public.audit_events ( + id text NOT NULL, + actor_id text NOT NULL, + action text NOT NULL, + target_id text NOT NULL, + details jsonb DEFAULT '{}'::jsonb NOT NULL, + created_at timestamp with time zone NOT NULL, + prev_hash text DEFAULT ''::text NOT NULL, + event_hash text DEFAULT ''::text NOT NULL +); + +CREATE TABLE public.certificate_authority ( + scope text NOT NULL, + ca_pem text NOT NULL, + private_key_pem text NOT NULL, + updated_at timestamp with time zone NOT NULL +); + +CREATE TABLE public.client_assignments ( + id text NOT NULL, + client_id text NOT NULL, + target_type text NOT NULL, + fleet_group_id uuid, + agent_id text, + created_at timestamp with time zone NOT NULL +); + +CREATE TABLE public.client_deployments ( + client_id text NOT NULL, + agent_id text NOT NULL, + desired_operation text NOT NULL, + status text NOT NULL, + last_error text DEFAULT ''::text NOT NULL, + last_applied_at timestamp with time zone, + updated_at timestamp with time zone NOT NULL, + connection_links jsonb DEFAULT '[]'::jsonb NOT NULL, + last_reset_epoch_secs bigint DEFAULT 0 NOT NULL, + link_diagnostic text DEFAULT ''::text NOT NULL +); + +CREATE TABLE public.client_ip_history ( + agent_id text NOT NULL, + client_id text NOT NULL, + ip_address text NOT NULL, + first_seen timestamp with time zone NOT NULL, + last_seen timestamp with time zone NOT NULL +); + +CREATE TABLE public.client_usage ( + client_id text NOT NULL, + agent_id text NOT NULL, + traffic_used_bytes bigint DEFAULT 0 NOT NULL, + unique_ips_used integer DEFAULT 0 NOT NULL, + active_tcp_conns integer DEFAULT 0 NOT NULL, + active_unique_ips integer DEFAULT 0 NOT NULL, + observed_at timestamp with time zone NOT NULL, + quota_used_bytes bigint DEFAULT 0 NOT NULL, + quota_last_reset_unix bigint DEFAULT 0 NOT NULL, + agent_boot_id text DEFAULT ''::text NOT NULL, + last_total_bytes bigint DEFAULT 0 NOT NULL +); + +CREATE TABLE public.clients ( + id text NOT NULL, + name text NOT NULL, + secret_ciphertext text NOT NULL, + user_ad_tag text NOT NULL, + enabled boolean DEFAULT true NOT NULL, + max_tcp_conns bigint DEFAULT 0 NOT NULL, + max_unique_ips bigint DEFAULT 0 NOT NULL, + data_quota_bytes bigint DEFAULT 0 NOT NULL, + expiration_rfc3339 text DEFAULT ''::text NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + deleted_at timestamp with time zone, + subscription_token text +); + +CREATE TABLE public.config_apply_batch_targets ( + batch_id text NOT NULL, + agent_id text NOT NULL, + wave_index integer NOT NULL, + job_id text DEFAULT ''::text NOT NULL, + status text NOT NULL, + message text DEFAULT ''::text NOT NULL, + CONSTRAINT config_apply_batch_targets_status_check CHECK ((status = ANY (ARRAY['pending'::text, 'running'::text, 'succeeded'::text, 'failed'::text, 'skipped'::text]))) +); + +CREATE TABLE public.config_apply_batches ( + id text NOT NULL, + fleet_group_id uuid, + mode text NOT NULL, + wave_size integer DEFAULT 1 NOT NULL, + expected_revision text DEFAULT ''::text NOT NULL, + status text NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + CONSTRAINT config_apply_batches_mode_check CHECK ((mode = ANY (ARRAY['all_at_once'::text, 'rolling'::text]))), + CONSTRAINT config_apply_batches_status_check CHECK ((status = ANY (ARRAY['running'::text, 'succeeded'::text, 'failed'::text, 'halted'::text]))) +); + +CREATE TABLE public.consumed_totp ( + user_id text NOT NULL, + code text NOT NULL, + used_at timestamp with time zone DEFAULT now() NOT NULL +); + +CREATE TABLE public.cp_secrets ( + key text NOT NULL, + value bytea NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL +); + +CREATE TABLE public.discovered_clients ( + id text NOT NULL, + agent_id text NOT NULL, + client_name text NOT NULL, + secret text DEFAULT ''::text NOT NULL, + status text DEFAULT 'pending_review'::text NOT NULL, + total_octets bigint DEFAULT 0 NOT NULL, + current_connections integer DEFAULT 0 NOT NULL, + active_unique_ips integer DEFAULT 0 NOT NULL, + max_tcp_conns integer DEFAULT 0 NOT NULL, + max_unique_ips integer DEFAULT 0 NOT NULL, + data_quota_bytes bigint DEFAULT 0 NOT NULL, + expiration text DEFAULT ''::text NOT NULL, + discovered_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + connection_links jsonb DEFAULT '[]'::jsonb NOT NULL, + CONSTRAINT discovered_clients_status_check CHECK ((status = ANY (ARRAY['pending_review'::text, 'adopted'::text, 'ignored'::text]))) +); + +CREATE TABLE public.enrollment_attempts ( + id uuid NOT NULL, + token_id uuid, + agent_id uuid, + mode text NOT NULL, + client_addr text, + request_id text NOT NULL, + status text NOT NULL, + error_code text, + error_message text, + started_at timestamp with time zone NOT NULL, + finished_at timestamp with time zone, + CONSTRAINT enrollment_attempts_mode_check CHECK ((mode = ANY (ARRAY['inbound'::text, 'outbound'::text]))), + CONSTRAINT enrollment_attempts_status_check CHECK ((status = ANY (ARRAY['in_progress'::text, 'success'::text, 'failed'::text]))) +); + +CREATE TABLE public.enrollment_events ( + id bigint NOT NULL, + attempt_id uuid NOT NULL, + ts timestamp with time zone NOT NULL, + step text NOT NULL, + level text NOT NULL, + message text, + fields_json jsonb, + CONSTRAINT enrollment_events_level_check CHECK ((level = ANY (ARRAY['info'::text, 'warn'::text, 'error'::text]))) +); + +CREATE SEQUENCE public.enrollment_events_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.enrollment_events_id_seq OWNED BY public.enrollment_events.id; + +CREATE TABLE public.enrollment_tokens ( + value text NOT NULL, + fleet_group_id uuid, + issued_at timestamp with time zone NOT NULL, + expires_at timestamp with time zone NOT NULL, + consumed_at timestamp with time zone, + revoked_at timestamp with time zone +); + +CREATE TABLE public.fleet_group_integrations ( + id uuid NOT NULL, + fleet_group_id uuid NOT NULL, + kind text NOT NULL, + provider_id uuid, + config jsonb DEFAULT '{}'::jsonb NOT NULL, + enabled boolean DEFAULT false NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL +); + +CREATE TABLE public.fleet_groups ( + id uuid NOT NULL, + name text NOT NULL, + created_at timestamp with time zone NOT NULL, + label text DEFAULT ''::text NOT NULL, + description text DEFAULT ''::text NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL +); + +CREATE TABLE public.integration_providers ( + id uuid NOT NULL, + kind text NOT NULL, + label text DEFAULT ''::text NOT NULL, + config text DEFAULT '{}'::text NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT integration_providers_config_check CHECK (((config ~~ 'PVS_:%'::text) OR (config IS JSON))) +); + +CREATE TABLE public.job_targets ( + job_id text NOT NULL, + agent_id text NOT NULL, + status text NOT NULL, + result_text text DEFAULT ''::text NOT NULL, + result_json text DEFAULT ''::text NOT NULL, + updated_at timestamp with time zone NOT NULL, + CONSTRAINT job_targets_status_check CHECK ((status = ANY (ARRAY['queued'::text, 'sent'::text, 'acknowledged'::text, 'succeeded'::text, 'failed'::text, 'expired'::text]))) +); + +CREATE TABLE public.jobs ( + id text NOT NULL, + action text NOT NULL, + idempotency_key text NOT NULL, + actor_id text NOT NULL, + status text NOT NULL, + created_at timestamp with time zone NOT NULL, + ttl_nanos bigint NOT NULL, + payload_json text DEFAULT ''::text NOT NULL, + CONSTRAINT jobs_status_check CHECK ((status = ANY (ARRAY['queued'::text, 'running'::text, 'succeeded'::text, 'failed'::text, 'expired'::text, 'partial'::text]))) +); + +CREATE TABLE public.login_lockouts ( + username text NOT NULL, + failures integer DEFAULT 0 NOT NULL, + locked_at timestamp with time zone, + updated_at timestamp with time zone DEFAULT now() NOT NULL +); + +CREATE TABLE public.metric_snapshots ( + id text NOT NULL, + agent_id text NOT NULL, + instance_id text DEFAULT ''::text NOT NULL, + captured_at timestamp with time zone NOT NULL, + "values" jsonb NOT NULL ); +CREATE TABLE public.panel_settings ( + scope text NOT NULL, + http_public_url text DEFAULT ''::text NOT NULL, + grpc_public_endpoint text DEFAULT ''::text NOT NULL, + updated_at timestamp with time zone NOT NULL, + retention_json text DEFAULT ''::text NOT NULL, + password_min_length integer DEFAULT 10 NOT NULL, + geoip_json text DEFAULT ''::text NOT NULL, + geoip_state_json text DEFAULT ''::text NOT NULL, + CONSTRAINT panel_settings_password_min_length_check CHECK (((password_min_length >= 8) AND (password_min_length <= 128))) +); + +CREATE TABLE public.runtime_settings ( + name text NOT NULL, + value_json text NOT NULL, + updated_at bigint NOT NULL, + updated_by text DEFAULT ''::text NOT NULL +); + +CREATE TABLE public.sessions ( + id text NOT NULL, + user_id text NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + last_seen_at timestamp with time zone DEFAULT now() NOT NULL +); + +CREATE TABLE public.telemt_diagnostics_current ( + agent_id text NOT NULL, + observed_at timestamp with time zone NOT NULL, + state text DEFAULT ''::text NOT NULL, + state_reason text DEFAULT ''::text NOT NULL, + system_info_json text DEFAULT '{}'::text NOT NULL, + effective_limits_json text DEFAULT '{}'::text NOT NULL, + security_posture_json text DEFAULT '{}'::text NOT NULL, + minimal_all_json text DEFAULT '{}'::text NOT NULL, + me_pool_json text DEFAULT '{}'::text NOT NULL, + dcs_json text DEFAULT '{}'::text NOT NULL +); + +CREATE TABLE public.telemt_instances ( + id text NOT NULL, + agent_id text NOT NULL, + name text NOT NULL, + version text DEFAULT ''::text NOT NULL, + config_fingerprint text DEFAULT ''::text NOT NULL, + connections bigint DEFAULT 0 CONSTRAINT telemt_instances_connected_users_not_null NOT NULL, + read_only boolean DEFAULT false NOT NULL, + updated_at timestamp with time zone NOT NULL +); + +CREATE TABLE public.telemt_runtime_current ( + agent_id text NOT NULL, + observed_at timestamp with time zone NOT NULL, + runtime_json text DEFAULT ''::text NOT NULL +); + +CREATE TABLE public.telemt_runtime_dcs_current ( + agent_id text NOT NULL, + dc bigint NOT NULL, + observed_at timestamp with time zone NOT NULL, + available_endpoints bigint DEFAULT 0 NOT NULL, + available_pct double precision DEFAULT 0 NOT NULL, + required_writers bigint DEFAULT 0 NOT NULL, + alive_writers bigint DEFAULT 0 NOT NULL, + coverage_pct double precision DEFAULT 0 NOT NULL, + rtt_ms double precision DEFAULT 0 NOT NULL, + load double precision DEFAULT 0 NOT NULL +); + +CREATE TABLE public.telemt_runtime_events ( + agent_id text NOT NULL, + sequence bigint NOT NULL, + observed_at timestamp with time zone NOT NULL, + timestamp_at timestamp with time zone NOT NULL, + event_type text DEFAULT ''::text NOT NULL, + context text DEFAULT ''::text NOT NULL, + severity text DEFAULT ''::text NOT NULL +); + +CREATE TABLE public.telemt_runtime_upstreams_current ( + agent_id text NOT NULL, + upstream_id bigint NOT NULL, + observed_at timestamp with time zone NOT NULL, + route_kind text DEFAULT ''::text NOT NULL, + address text DEFAULT ''::text NOT NULL, + healthy boolean DEFAULT false NOT NULL, + fails bigint DEFAULT 0 NOT NULL, + effective_latency_ms double precision DEFAULT 0 NOT NULL +); + +CREATE TABLE public.telemt_security_inventory_current ( + agent_id text NOT NULL, + observed_at timestamp with time zone NOT NULL, + state text DEFAULT ''::text NOT NULL, + state_reason text DEFAULT ''::text NOT NULL, + enabled boolean DEFAULT false NOT NULL, + entries_total bigint DEFAULT 0 NOT NULL, + entries_json text DEFAULT '[]'::text NOT NULL +); + +CREATE TABLE public.ts_dc_health ( + agent_id text NOT NULL, + captured_at timestamp with time zone NOT NULL, + dc integer NOT NULL, + coverage_pct_avg real DEFAULT 0 NOT NULL, + coverage_pct_min real DEFAULT 0 NOT NULL, + rtt_ms_avg real DEFAULT 0 NOT NULL, + rtt_ms_max real DEFAULT 0 NOT NULL, + alive_writers_min integer DEFAULT 0 NOT NULL, + required_writers integer DEFAULT 0 NOT NULL, + load_max integer DEFAULT 0 NOT NULL, + sample_count integer DEFAULT 1 NOT NULL +); + +CREATE TABLE public.ts_server_load ( + agent_id text NOT NULL, + captured_at timestamp with time zone NOT NULL, + cpu_pct_avg double precision DEFAULT 0 NOT NULL, + cpu_pct_max double precision DEFAULT 0 NOT NULL, + mem_pct_avg double precision DEFAULT 0 NOT NULL, + mem_pct_max double precision DEFAULT 0 NOT NULL, + disk_pct_avg double precision DEFAULT 0 NOT NULL, + disk_pct_max double precision DEFAULT 0 NOT NULL, + load_1m double precision DEFAULT 0 NOT NULL, + load_5m double precision DEFAULT 0 NOT NULL, + load_15m double precision DEFAULT 0 NOT NULL, + connections_avg integer DEFAULT 0 NOT NULL, + connections_max integer DEFAULT 0 NOT NULL, + connections_me_avg integer DEFAULT 0 NOT NULL, + connections_direct_avg integer DEFAULT 0 NOT NULL, + active_users_avg integer DEFAULT 0 NOT NULL, + active_users_max integer DEFAULT 0 NOT NULL, + connections_total bigint DEFAULT 0 NOT NULL, + connections_bad_total bigint DEFAULT 0 NOT NULL, + handshake_timeouts_total bigint DEFAULT 0 NOT NULL, + dc_coverage_min_pct double precision DEFAULT 0 NOT NULL, + dc_coverage_avg_pct double precision DEFAULT 0 NOT NULL, + healthy_upstreams integer DEFAULT 0 NOT NULL, + total_upstreams integer DEFAULT 0 NOT NULL, + net_bytes_sent bigint DEFAULT 0 NOT NULL, + net_bytes_recv bigint DEFAULT 0 NOT NULL, + sample_count integer DEFAULT 1 NOT NULL +); + +CREATE TABLE public.ts_server_load_hourly ( + agent_id text NOT NULL, + bucket_hour timestamp with time zone NOT NULL, + cpu_pct_avg real, + cpu_pct_max real, + mem_pct_avg real, + mem_pct_max real, + connections_avg real, + connections_max integer, + active_users_avg real, + active_users_max integer, + dc_coverage_min real, + dc_coverage_avg real, + sample_count integer DEFAULT 0 NOT NULL +); + +CREATE TABLE public.update_config ( + key text NOT NULL, + value text NOT NULL +); + +CREATE TABLE public.user_appearance ( + user_id text NOT NULL, + theme text DEFAULT 'system'::text NOT NULL, + density text DEFAULT 'comfortable'::text NOT NULL, + help_mode text DEFAULT 'basic'::text NOT NULL, + updated_at timestamp with time zone DEFAULT '1970-01-01 00:00:00+00'::timestamp with time zone NOT NULL +); + +CREATE TABLE public.user_fleet_group_scopes ( + user_id text NOT NULL, + fleet_group_id uuid NOT NULL, + granted_at timestamp with time zone DEFAULT now() NOT NULL, + granted_by text DEFAULT ''::text NOT NULL +); + +CREATE TABLE public.users ( + id text NOT NULL, + username text NOT NULL, + password_hash text NOT NULL, + role text NOT NULL, + totp_enabled boolean DEFAULT false NOT NULL, + totp_secret text DEFAULT ''::text NOT NULL, + created_at timestamp with time zone NOT NULL +); + +CREATE TABLE public.webhook_endpoints ( + id text NOT NULL, + name text NOT NULL, + url text NOT NULL, + secret_ciphertext text NOT NULL, + event_filter text DEFAULT ''::text NOT NULL, + allow_private boolean DEFAULT false NOT NULL, + enabled boolean DEFAULT true NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL +); + +CREATE TABLE public.webhook_outbox ( + id text NOT NULL, + endpoint_id text NOT NULL, + event_action text NOT NULL, + payload jsonb NOT NULL, + attempt integer DEFAULT 0 NOT NULL, + next_attempt_at timestamp with time zone NOT NULL, + last_error text DEFAULT ''::text NOT NULL, + dead boolean DEFAULT false NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + delivered_at timestamp with time zone +); + +ALTER TABLE ONLY public.enrollment_events ALTER COLUMN id SET DEFAULT nextval('public.enrollment_events_id_seq'::regclass); + +ALTER TABLE ONLY public.agent_certificate_recovery_grants + ADD CONSTRAINT agent_certificate_recovery_grants_pkey PRIMARY KEY (agent_id); + +ALTER TABLE ONLY public.agent_config_targets + ADD CONSTRAINT agent_config_targets_pkey PRIMARY KEY (scope_type, scope_id); + +ALTER TABLE ONLY public.agent_fallback_state + ADD CONSTRAINT agent_fallback_state_pkey PRIMARY KEY (agent_id); + +ALTER TABLE ONLY public.agent_revocations + ADD CONSTRAINT agent_revocations_pkey PRIMARY KEY (agent_id); + +ALTER TABLE ONLY public.agents + ADD CONSTRAINT agents_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.audit_events + ADD CONSTRAINT audit_events_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.certificate_authority + ADD CONSTRAINT certificate_authority_pkey PRIMARY KEY (scope); + +ALTER TABLE ONLY public.client_assignments + ADD CONSTRAINT client_assignments_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.client_deployments + ADD CONSTRAINT client_deployments_pkey PRIMARY KEY (client_id, agent_id); + +ALTER TABLE ONLY public.client_ip_history + ADD CONSTRAINT client_ip_history_pkey PRIMARY KEY (agent_id, client_id, ip_address); + +ALTER TABLE ONLY public.client_usage + ADD CONSTRAINT client_usage_pkey PRIMARY KEY (client_id, agent_id); + +ALTER TABLE ONLY public.clients + ADD CONSTRAINT clients_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.config_apply_batch_targets + ADD CONSTRAINT config_apply_batch_targets_pkey PRIMARY KEY (batch_id, agent_id); + +ALTER TABLE ONLY public.config_apply_batches + ADD CONSTRAINT config_apply_batches_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.consumed_totp + ADD CONSTRAINT consumed_totp_pkey PRIMARY KEY (user_id, code); + +ALTER TABLE ONLY public.cp_secrets + ADD CONSTRAINT cp_secrets_pkey PRIMARY KEY (key); + +ALTER TABLE ONLY public.discovered_clients + ADD CONSTRAINT discovered_clients_agent_id_client_name_key UNIQUE (agent_id, client_name); + +ALTER TABLE ONLY public.discovered_clients + ADD CONSTRAINT discovered_clients_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.enrollment_attempts + ADD CONSTRAINT enrollment_attempts_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.enrollment_events + ADD CONSTRAINT enrollment_events_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.enrollment_tokens + ADD CONSTRAINT enrollment_tokens_pkey PRIMARY KEY (value); + +ALTER TABLE ONLY public.fleet_group_integrations + ADD CONSTRAINT fleet_group_integrations_fleet_group_id_kind_key UNIQUE (fleet_group_id, kind); + +ALTER TABLE ONLY public.fleet_group_integrations + ADD CONSTRAINT fleet_group_integrations_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.fleet_groups + ADD CONSTRAINT fleet_groups_name_unique UNIQUE (name); + +ALTER TABLE ONLY public.fleet_groups + ADD CONSTRAINT fleet_groups_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.integration_providers + ADD CONSTRAINT integration_providers_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.job_targets + ADD CONSTRAINT job_targets_pkey PRIMARY KEY (job_id, agent_id); + +ALTER TABLE ONLY public.jobs + ADD CONSTRAINT jobs_idempotency_key_key UNIQUE (idempotency_key); + +ALTER TABLE ONLY public.jobs + ADD CONSTRAINT jobs_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.login_lockouts + ADD CONSTRAINT login_lockouts_pkey PRIMARY KEY (username); + +ALTER TABLE ONLY public.metric_snapshots + ADD CONSTRAINT metric_snapshots_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.panel_settings + ADD CONSTRAINT panel_settings_pkey PRIMARY KEY (scope); + +ALTER TABLE ONLY public.runtime_settings + ADD CONSTRAINT runtime_settings_pkey PRIMARY KEY (name); + +ALTER TABLE ONLY public.sessions + ADD CONSTRAINT sessions_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.telemt_diagnostics_current + ADD CONSTRAINT telemt_diagnostics_current_pkey PRIMARY KEY (agent_id); + +ALTER TABLE ONLY public.telemt_instances + ADD CONSTRAINT telemt_instances_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.telemt_runtime_current + ADD CONSTRAINT telemt_runtime_current_pkey PRIMARY KEY (agent_id); + +ALTER TABLE ONLY public.telemt_runtime_dcs_current + ADD CONSTRAINT telemt_runtime_dcs_current_pkey PRIMARY KEY (agent_id, dc); + +ALTER TABLE ONLY public.telemt_runtime_events + ADD CONSTRAINT telemt_runtime_events_pkey PRIMARY KEY (agent_id, sequence); + +ALTER TABLE ONLY public.telemt_runtime_upstreams_current + ADD CONSTRAINT telemt_runtime_upstreams_current_pkey PRIMARY KEY (agent_id, upstream_id); + +ALTER TABLE ONLY public.telemt_security_inventory_current + ADD CONSTRAINT telemt_security_inventory_current_pkey PRIMARY KEY (agent_id); + +ALTER TABLE ONLY public.ts_dc_health + ADD CONSTRAINT ts_dc_health_pkey PRIMARY KEY (agent_id, dc, captured_at); + +ALTER TABLE ONLY public.ts_server_load_hourly + ADD CONSTRAINT ts_server_load_hourly_pkey PRIMARY KEY (agent_id, bucket_hour); + +ALTER TABLE ONLY public.ts_server_load + ADD CONSTRAINT ts_server_load_pkey PRIMARY KEY (agent_id, captured_at); + +ALTER TABLE ONLY public.update_config + ADD CONSTRAINT update_config_pkey PRIMARY KEY (key); + +ALTER TABLE ONLY public.user_appearance + ADD CONSTRAINT user_appearance_pkey PRIMARY KEY (user_id); + +ALTER TABLE ONLY public.user_fleet_group_scopes + ADD CONSTRAINT user_fleet_group_scopes_pkey PRIMARY KEY (user_id, fleet_group_id); + +ALTER TABLE ONLY public.users + ADD CONSTRAINT users_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.users + ADD CONSTRAINT users_username_key UNIQUE (username); + +ALTER TABLE ONLY public.webhook_endpoints + ADD CONSTRAINT webhook_endpoints_name_key UNIQUE (name); + +ALTER TABLE ONLY public.webhook_endpoints + ADD CONSTRAINT webhook_endpoints_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.webhook_outbox + ADD CONSTRAINT webhook_outbox_pkey PRIMARY KEY (id); + +CREATE UNIQUE INDEX clients_subscription_token_key ON public.clients USING btree (subscription_token) WHERE (subscription_token IS NOT NULL); + +CREATE INDEX idx_agent_fallback_state_entered_at ON public.agent_fallback_state USING btree (entered_at_unix); + +CREATE INDEX idx_agent_revocations_cert_expires_at ON public.agent_revocations USING btree (cert_expires_at); + +CREATE INDEX idx_agents_cert_spki_sha256 ON public.agents USING btree (cert_spki_sha256) WHERE (length(cert_spki_sha256) > 0); + +CREATE INDEX idx_agents_fleet_group_id ON public.agents USING btree (fleet_group_id); + +CREATE INDEX idx_agents_last_seen_at ON public.agents USING btree (last_seen_at); + +CREATE INDEX idx_agents_transport_mode ON public.agents USING btree (transport_mode); + +CREATE INDEX idx_audit_events_chain_walk ON public.audit_events USING btree (created_at, id); + +CREATE INDEX idx_audit_events_created_at ON public.audit_events USING btree (created_at); + +CREATE INDEX idx_client_assignments_client_id ON public.client_assignments USING btree (client_id); + +CREATE INDEX idx_client_deployments_client_id ON public.client_deployments USING btree (client_id); + +CREATE INDEX idx_client_ip_client ON public.client_ip_history USING btree (client_id, last_seen DESC); + +CREATE INDEX idx_client_ip_client_addr ON public.client_ip_history USING btree (client_id, ip_address); + +CREATE INDEX idx_client_ip_last_seen ON public.client_ip_history USING btree (last_seen); + +CREATE INDEX idx_client_usage_agent_id ON public.client_usage USING btree (agent_id); + +CREATE INDEX idx_config_apply_batch_targets_batch_wave ON public.config_apply_batch_targets USING btree (batch_id, wave_index); + +CREATE INDEX idx_config_apply_batches_status ON public.config_apply_batches USING btree (status); + +CREATE INDEX idx_consumed_totp_used_at ON public.consumed_totp USING btree (used_at); + +CREATE INDEX idx_discovered_clients_agent_id ON public.discovered_clients USING btree (agent_id); + +CREATE UNIQUE INDEX idx_discovered_clients_pending_unique ON public.discovered_clients USING btree (agent_id, client_name) WHERE (status = 'pending_review'::text); + +CREATE INDEX idx_enrollment_attempts_agent ON public.enrollment_attempts USING btree (agent_id); + +CREATE INDEX idx_enrollment_attempts_started ON public.enrollment_attempts USING btree (started_at); + +CREATE INDEX idx_enrollment_attempts_token ON public.enrollment_attempts USING btree (token_id); + +CREATE INDEX idx_enrollment_events_attempt ON public.enrollment_events USING btree (attempt_id, ts); + +CREATE INDEX idx_enrollment_tokens_fleet_group_id ON public.enrollment_tokens USING btree (fleet_group_id); + +CREATE INDEX idx_fleet_group_integrations_fleet_group_id ON public.fleet_group_integrations USING btree (fleet_group_id); + +CREATE INDEX idx_fleet_group_integrations_kind ON public.fleet_group_integrations USING btree (kind); + +CREATE INDEX idx_integration_providers_kind ON public.integration_providers USING btree (kind); + +CREATE INDEX idx_job_targets_agent_id ON public.job_targets USING btree (agent_id); + +CREATE INDEX idx_jobs_actor_id ON public.jobs USING btree (actor_id); + +CREATE INDEX idx_jobs_created_at ON public.jobs USING btree (created_at); + +CREATE INDEX idx_jobs_status ON public.jobs USING btree (status); + +CREATE INDEX idx_login_lockouts_locked_at ON public.login_lockouts USING btree (locked_at); + +CREATE INDEX idx_metric_snapshots_agent_captured ON public.metric_snapshots USING btree (agent_id, captured_at); + +CREATE INDEX idx_metric_snapshots_captured_at ON public.metric_snapshots USING btree (captured_at); + +CREATE INDEX idx_sessions_created_at ON public.sessions USING btree (created_at); + +CREATE INDEX idx_sessions_user_id ON public.sessions USING btree (user_id); + +CREATE INDEX idx_telemt_instances_agent_id ON public.telemt_instances USING btree (agent_id); + +CREATE INDEX idx_ts_dc_health_time ON public.ts_dc_health USING btree (agent_id, captured_at DESC); + +CREATE INDEX idx_ts_server_load_time ON public.ts_server_load USING btree (agent_id, captured_at DESC); + +CREATE INDEX idx_user_fleet_group_scopes_fleet_group_id ON public.user_fleet_group_scopes USING btree (fleet_group_id); + +CREATE INDEX idx_user_fleet_group_scopes_user_id ON public.user_fleet_group_scopes USING btree (user_id); + +CREATE INDEX idx_webhook_outbox_ready ON public.webhook_outbox USING btree (next_attempt_at) WHERE ((dead = false) AND (delivered_at IS NULL)); + +ALTER TABLE ONLY public.agent_certificate_recovery_grants + ADD CONSTRAINT agent_certificate_recovery_grants_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES public.agents(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.agent_fallback_state + ADD CONSTRAINT agent_fallback_state_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES public.agents(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.agents + ADD CONSTRAINT agents_fleet_group_id_fkey FOREIGN KEY (fleet_group_id) REFERENCES public.fleet_groups(id); + +ALTER TABLE ONLY public.client_assignments + ADD CONSTRAINT client_assignments_client_id_fkey FOREIGN KEY (client_id) REFERENCES public.clients(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.client_deployments + ADD CONSTRAINT client_deployments_client_id_fkey FOREIGN KEY (client_id) REFERENCES public.clients(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.client_usage + ADD CONSTRAINT client_usage_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES public.agents(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.client_usage + ADD CONSTRAINT client_usage_client_id_fkey FOREIGN KEY (client_id) REFERENCES public.clients(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.config_apply_batch_targets + ADD CONSTRAINT config_apply_batch_targets_batch_id_fkey FOREIGN KEY (batch_id) REFERENCES public.config_apply_batches(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.config_apply_batches + ADD CONSTRAINT config_apply_batches_fleet_group_id_fkey FOREIGN KEY (fleet_group_id) REFERENCES public.fleet_groups(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.discovered_clients + ADD CONSTRAINT discovered_clients_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES public.agents(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.enrollment_events + ADD CONSTRAINT enrollment_events_attempt_id_fkey FOREIGN KEY (attempt_id) REFERENCES public.enrollment_attempts(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.enrollment_tokens + ADD CONSTRAINT enrollment_tokens_fleet_group_id_fkey FOREIGN KEY (fleet_group_id) REFERENCES public.fleet_groups(id) ON DELETE SET NULL; + +ALTER TABLE ONLY public.client_assignments + ADD CONSTRAINT fk_client_assignments_agent_id FOREIGN KEY (agent_id) REFERENCES public.agents(id) ON DELETE SET NULL; + +ALTER TABLE ONLY public.client_assignments + ADD CONSTRAINT fk_client_assignments_fleet_group_id FOREIGN KEY (fleet_group_id) REFERENCES public.fleet_groups(id) ON DELETE SET NULL; + +ALTER TABLE ONLY public.client_deployments + ADD CONSTRAINT fk_client_deployments_agent_id FOREIGN KEY (agent_id) REFERENCES public.agents(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.metric_snapshots + ADD CONSTRAINT fk_metric_snapshots_agent_id FOREIGN KEY (agent_id) REFERENCES public.agents(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.sessions + ADD CONSTRAINT fk_sessions_user_id FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.fleet_group_integrations + ADD CONSTRAINT fleet_group_integrations_fleet_group_id_fkey FOREIGN KEY (fleet_group_id) REFERENCES public.fleet_groups(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.fleet_group_integrations + ADD CONSTRAINT fleet_group_integrations_provider_id_fkey FOREIGN KEY (provider_id) REFERENCES public.integration_providers(id) ON DELETE SET NULL; + +ALTER TABLE ONLY public.job_targets + ADD CONSTRAINT job_targets_job_id_fkey FOREIGN KEY (job_id) REFERENCES public.jobs(id); + +ALTER TABLE ONLY public.telemt_diagnostics_current + ADD CONSTRAINT telemt_diagnostics_current_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES public.agents(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.telemt_instances + ADD CONSTRAINT telemt_instances_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES public.agents(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.telemt_runtime_current + ADD CONSTRAINT telemt_runtime_current_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES public.agents(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.telemt_runtime_dcs_current + ADD CONSTRAINT telemt_runtime_dcs_current_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES public.agents(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.telemt_runtime_events + ADD CONSTRAINT telemt_runtime_events_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES public.agents(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.telemt_runtime_upstreams_current + ADD CONSTRAINT telemt_runtime_upstreams_current_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES public.agents(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.telemt_security_inventory_current + ADD CONSTRAINT telemt_security_inventory_current_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES public.agents(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.user_appearance + ADD CONSTRAINT user_appearance_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.user_fleet_group_scopes + ADD CONSTRAINT user_fleet_group_scopes_fleet_group_id_fkey FOREIGN KEY (fleet_group_id) REFERENCES public.fleet_groups(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.user_fleet_group_scopes + ADD CONSTRAINT user_fleet_group_scopes_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.webhook_outbox + ADD CONSTRAINT webhook_outbox_endpoint_id_fkey FOREIGN KEY (endpoint_id) REFERENCES public.webhook_endpoints(id) ON DELETE CASCADE; + + -- +goose Down -DROP TABLE IF EXISTS certificate_authority; -DROP TABLE IF EXISTS panel_settings; -DROP TABLE IF EXISTS client_deployments; -DROP TABLE IF EXISTS client_assignments; -DROP TABLE IF EXISTS clients; -DROP TABLE IF EXISTS agent_certificate_recovery_grants; -DROP TABLE IF EXISTS enrollment_tokens; -DROP TABLE IF EXISTS metric_snapshots; -DROP TABLE IF EXISTS audit_events; -DROP TABLE IF EXISTS job_targets; -DROP TABLE IF EXISTS jobs; -DROP TABLE IF EXISTS telemt_detail_boosts; -DROP TABLE IF EXISTS telemt_security_inventory_current; -DROP TABLE IF EXISTS telemt_diagnostics_current; -DROP TABLE IF EXISTS telemt_runtime_events; -DROP TABLE IF EXISTS telemt_runtime_upstreams_current; -DROP TABLE IF EXISTS telemt_runtime_dcs_current; -DROP TABLE IF EXISTS telemt_runtime_current; -DROP TABLE IF EXISTS telemt_instances; -DROP TABLE IF EXISTS agents; -DROP TABLE IF EXISTS user_appearance; -DROP TABLE IF EXISTS users; -DROP TABLE IF EXISTS fleet_groups; +-- Squash-init: даунгрейда некуда — no-op. +SELECT 1; diff --git a/db/migrations/postgres/0002_discovered_clients.sql b/db/migrations/postgres/0002_discovered_clients.sql deleted file mode 100644 index 55397d8a..00000000 --- a/db/migrations/postgres/0002_discovered_clients.sql +++ /dev/null @@ -1,22 +0,0 @@ --- +goose Up -CREATE TABLE IF NOT EXISTS discovered_clients ( - id TEXT PRIMARY KEY, - agent_id TEXT NOT NULL REFERENCES agents (id), - client_name TEXT NOT NULL, - secret TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'pending_review', - total_octets BIGINT NOT NULL DEFAULT 0, - current_connections INTEGER NOT NULL DEFAULT 0, - active_unique_ips INTEGER NOT NULL DEFAULT 0, - connection_link TEXT NOT NULL DEFAULT '', - max_tcp_conns INTEGER NOT NULL DEFAULT 0, - max_unique_ips INTEGER NOT NULL DEFAULT 0, - data_quota_bytes BIGINT NOT NULL DEFAULT 0, - expiration TEXT NOT NULL DEFAULT '', - discovered_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL, - UNIQUE (agent_id, client_name) -); - --- +goose Down -DROP TABLE IF EXISTS discovered_clients; diff --git a/db/migrations/postgres/0003_agent_cert_dates.sql b/db/migrations/postgres/0003_agent_cert_dates.sql deleted file mode 100644 index 414cafb2..00000000 --- a/db/migrations/postgres/0003_agent_cert_dates.sql +++ /dev/null @@ -1,7 +0,0 @@ --- +goose Up -ALTER TABLE agents ADD COLUMN IF NOT EXISTS cert_issued_at TIMESTAMPTZ; -ALTER TABLE agents ADD COLUMN IF NOT EXISTS cert_expires_at TIMESTAMPTZ; - --- +goose Down -ALTER TABLE agents DROP COLUMN IF EXISTS cert_expires_at; -ALTER TABLE agents DROP COLUMN IF EXISTS cert_issued_at; diff --git a/db/migrations/postgres/0004_sessions.sql b/db/migrations/postgres/0004_sessions.sql deleted file mode 100644 index 7b8ece42..00000000 --- a/db/migrations/postgres/0004_sessions.sql +++ /dev/null @@ -1,14 +0,0 @@ --- +goose Up -CREATE TABLE IF NOT EXISTS sessions ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); -CREATE INDEX IF NOT EXISTS idx_sessions_created_at ON sessions(created_at); - --- +goose Down -DROP INDEX IF EXISTS idx_sessions_created_at; -DROP INDEX IF EXISTS idx_sessions_user_id; -DROP TABLE IF EXISTS sessions; diff --git a/db/migrations/postgres/0005_agent_revocations.sql b/db/migrations/postgres/0005_agent_revocations.sql deleted file mode 100644 index b10233c0..00000000 --- a/db/migrations/postgres/0005_agent_revocations.sql +++ /dev/null @@ -1,21 +0,0 @@ --- +goose Up --- P1-SEC-06: persist the set of deregistered agent IDs so that a control-plane --- restart cannot "forget" a revocation. Without this table, an agent whose --- admin deleted it but whose 30-day mTLS client cert is still valid could --- reconnect and be accepted until cert expiry. --- --- Cleanup removes rows once the cert has expired (cert_expires_at < now): --- at that point the certificate can no longer authenticate regardless of --- whether the agent_id is on the revocation list. -CREATE TABLE IF NOT EXISTS agent_revocations ( - agent_id TEXT PRIMARY KEY, - revoked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - cert_expires_at TIMESTAMPTZ NOT NULL -); - -CREATE INDEX IF NOT EXISTS idx_agent_revocations_cert_expires_at - ON agent_revocations(cert_expires_at); - --- +goose Down -DROP INDEX IF EXISTS idx_agent_revocations_cert_expires_at; -DROP TABLE IF EXISTS agent_revocations; diff --git a/db/migrations/postgres/0006_timeseries_and_update_config.sql b/db/migrations/postgres/0006_timeseries_and_update_config.sql deleted file mode 100644 index c0904b16..00000000 --- a/db/migrations/postgres/0006_timeseries_and_update_config.sql +++ /dev/null @@ -1,86 +0,0 @@ --- +goose Up --- update_config stores internal feature-flag/kv state for the updater subsystem. -CREATE TABLE IF NOT EXISTS update_config ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL -); - --- Per-sample aggregated telemt server load timeseries (minute granularity). -CREATE TABLE IF NOT EXISTS ts_server_load ( - agent_id TEXT NOT NULL, - captured_at TIMESTAMPTZ NOT NULL, - cpu_pct_avg REAL NOT NULL DEFAULT 0, - cpu_pct_max REAL NOT NULL DEFAULT 0, - mem_pct_avg REAL NOT NULL DEFAULT 0, - mem_pct_max REAL NOT NULL DEFAULT 0, - disk_pct_avg REAL NOT NULL DEFAULT 0, - disk_pct_max REAL NOT NULL DEFAULT 0, - load_1m REAL NOT NULL DEFAULT 0, - load_5m REAL NOT NULL DEFAULT 0, - load_15m REAL NOT NULL DEFAULT 0, - connections_avg INTEGER NOT NULL DEFAULT 0, - connections_max INTEGER NOT NULL DEFAULT 0, - connections_me_avg INTEGER NOT NULL DEFAULT 0, - connections_direct_avg INTEGER NOT NULL DEFAULT 0, - active_users_avg INTEGER NOT NULL DEFAULT 0, - active_users_max INTEGER NOT NULL DEFAULT 0, - connections_total BIGINT NOT NULL DEFAULT 0, - connections_bad_total BIGINT NOT NULL DEFAULT 0, - handshake_timeouts_total BIGINT NOT NULL DEFAULT 0, - dc_coverage_min_pct REAL NOT NULL DEFAULT 0, - dc_coverage_avg_pct REAL NOT NULL DEFAULT 0, - healthy_upstreams INTEGER NOT NULL DEFAULT 0, - total_upstreams INTEGER NOT NULL DEFAULT 0, - net_bytes_sent BIGINT NOT NULL DEFAULT 0, - net_bytes_recv BIGINT NOT NULL DEFAULT 0, - sample_count INTEGER NOT NULL DEFAULT 1, - PRIMARY KEY (agent_id, captured_at) -); - -CREATE TABLE IF NOT EXISTS ts_dc_health ( - agent_id TEXT NOT NULL, - captured_at TIMESTAMPTZ NOT NULL, - dc INTEGER NOT NULL, - coverage_pct_avg REAL NOT NULL DEFAULT 0, - coverage_pct_min REAL NOT NULL DEFAULT 0, - rtt_ms_avg REAL NOT NULL DEFAULT 0, - rtt_ms_max REAL NOT NULL DEFAULT 0, - alive_writers_min INTEGER NOT NULL DEFAULT 0, - required_writers INTEGER NOT NULL DEFAULT 0, - load_max INTEGER NOT NULL DEFAULT 0, - sample_count INTEGER NOT NULL DEFAULT 1, - PRIMARY KEY (agent_id, dc, captured_at) -); - -CREATE TABLE IF NOT EXISTS ts_server_load_hourly ( - agent_id TEXT NOT NULL, - bucket_hour TIMESTAMPTZ NOT NULL, - cpu_pct_avg REAL, - cpu_pct_max REAL, - mem_pct_avg REAL, - mem_pct_max REAL, - connections_avg REAL, - connections_max INTEGER, - active_users_avg REAL, - active_users_max INTEGER, - dc_coverage_min REAL, - dc_coverage_avg REAL, - sample_count INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (agent_id, bucket_hour) -); - -CREATE TABLE IF NOT EXISTS client_ip_history ( - agent_id TEXT NOT NULL, - client_id TEXT NOT NULL, - ip_address TEXT NOT NULL, - first_seen TIMESTAMPTZ NOT NULL, - last_seen TIMESTAMPTZ NOT NULL, - PRIMARY KEY (agent_id, client_id, ip_address) -); - --- +goose Down -DROP TABLE IF EXISTS client_ip_history; -DROP TABLE IF EXISTS ts_server_load_hourly; -DROP TABLE IF EXISTS ts_dc_health; -DROP TABLE IF EXISTS ts_server_load; -DROP TABLE IF EXISTS update_config; diff --git a/db/migrations/postgres/0007_indexes.sql b/db/migrations/postgres/0007_indexes.sql deleted file mode 100644 index 6d497ef8..00000000 --- a/db/migrations/postgres/0007_indexes.sql +++ /dev/null @@ -1,36 +0,0 @@ --- +goose NO TRANSACTION --- These indexes target tables already populated in production, so each --- CREATE INDEX must run CONCURRENTLY to avoid an ACCESS EXCLUSIVE lock --- on the table for the duration of the build. CONCURRENTLY is illegal --- inside a transaction, hence the NO TRANSACTION pragma above. --- +goose Up -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_agents_last_seen_at ON agents (last_seen_at); -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_agents_fleet_group_id ON agents (fleet_group_id); -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_telemt_instances_agent_id ON telemt_instances (agent_id); -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_client_assignments_client_id ON client_assignments (client_id); -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_client_deployments_client_id ON client_deployments (client_id); -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_jobs_created_at ON jobs (created_at); -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_audit_events_created_at ON audit_events (created_at); -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_metric_snapshots_agent_captured ON metric_snapshots (agent_id, captured_at); -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_discovered_clients_agent_id ON discovered_clients (agent_id); -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_ts_server_load_time ON ts_server_load (agent_id, captured_at DESC); -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_ts_dc_health_time ON ts_dc_health (agent_id, captured_at DESC); -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_client_ip_last_seen ON client_ip_history (last_seen); -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_client_ip_client ON client_ip_history (client_id, last_seen DESC); -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_client_ip_client_addr ON client_ip_history (client_id, ip_address); - --- +goose Down -DROP INDEX CONCURRENTLY IF EXISTS idx_client_ip_client_addr; -DROP INDEX CONCURRENTLY IF EXISTS idx_client_ip_client; -DROP INDEX CONCURRENTLY IF EXISTS idx_client_ip_last_seen; -DROP INDEX CONCURRENTLY IF EXISTS idx_ts_dc_health_time; -DROP INDEX CONCURRENTLY IF EXISTS idx_ts_server_load_time; -DROP INDEX CONCURRENTLY IF EXISTS idx_discovered_clients_agent_id; -DROP INDEX CONCURRENTLY IF EXISTS idx_metric_snapshots_agent_captured; -DROP INDEX CONCURRENTLY IF EXISTS idx_audit_events_created_at; -DROP INDEX CONCURRENTLY IF EXISTS idx_jobs_created_at; -DROP INDEX CONCURRENTLY IF EXISTS idx_client_deployments_client_id; -DROP INDEX CONCURRENTLY IF EXISTS idx_client_assignments_client_id; -DROP INDEX CONCURRENTLY IF EXISTS idx_telemt_instances_agent_id; -DROP INDEX CONCURRENTLY IF EXISTS idx_agents_fleet_group_id; -DROP INDEX CONCURRENTLY IF EXISTS idx_agents_last_seen_at; diff --git a/db/migrations/postgres/0008_missing_indexes.sql b/db/migrations/postgres/0008_missing_indexes.sql deleted file mode 100644 index 6b85f81a..00000000 --- a/db/migrations/postgres/0008_missing_indexes.sql +++ /dev/null @@ -1,17 +0,0 @@ --- +goose NO TRANSACTION --- These indexes target tables already populated in production --- (jobs, job_targets, metric_snapshots, enrollment_tokens), so each --- CREATE INDEX must run CONCURRENTLY to avoid an ACCESS EXCLUSIVE lock --- on the table for the duration of the build. CONCURRENTLY is illegal --- inside a transaction, hence the NO TRANSACTION pragma above. --- +goose Up -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_jobs_status ON jobs (status); -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_job_targets_agent_id ON job_targets (agent_id); -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_metric_snapshots_captured_at ON metric_snapshots (captured_at); -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_enrollment_tokens_fleet_group_id ON enrollment_tokens (fleet_group_id); - --- +goose Down -DROP INDEX CONCURRENTLY IF EXISTS idx_enrollment_tokens_fleet_group_id; -DROP INDEX CONCURRENTLY IF EXISTS idx_metric_snapshots_captured_at; -DROP INDEX CONCURRENTLY IF EXISTS idx_job_targets_agent_id; -DROP INDEX CONCURRENTLY IF EXISTS idx_jobs_status; diff --git a/db/migrations/postgres/0009_retention_settings.sql b/db/migrations/postgres/0009_retention_settings.sql deleted file mode 100644 index 117970cd..00000000 --- a/db/migrations/postgres/0009_retention_settings.sql +++ /dev/null @@ -1,12 +0,0 @@ --- +goose Up --- P2-REL-03: persist retention settings across restarts. --- Adds a retention_json column to panel_settings so the singleton --- scope='panel' row also carries the operator-managed retention --- configuration as an opaque JSON blob. Empty string means "not --- yet set" and causes the control-plane to fall back to defaults. -ALTER TABLE panel_settings - ADD COLUMN IF NOT EXISTS retention_json TEXT NOT NULL DEFAULT ''; - --- +goose Down -ALTER TABLE panel_settings - DROP COLUMN IF EXISTS retention_json; diff --git a/db/migrations/postgres/0010_discovered_clients_pending_unique.sql b/db/migrations/postgres/0010_discovered_clients_pending_unique.sql deleted file mode 100644 index 326836c7..00000000 --- a/db/migrations/postgres/0010_discovered_clients_pending_unique.sql +++ /dev/null @@ -1,17 +0,0 @@ --- +goose NO TRANSACTION --- +goose Up --- P2-LOG-02 (L-10 / M-C4): belt-and-suspenders guard against duplicate --- pending_review rows. The table already has UNIQUE (agent_id, client_name) --- from 0002, but adding this partial UNIQUE index makes the dedupe intent --- explicit and keeps the invariant intact even if the broader constraint --- is ever loosened (e.g. to allow historical "ignored"/"adopted" copies). --- --- discovered_clients is a populated table in production, so the build --- runs CONCURRENTLY (and therefore outside any transaction) to avoid --- locking writers for the duration. -CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS idx_discovered_clients_pending_unique - ON discovered_clients (agent_id, client_name) - WHERE status = 'pending_review'; - --- +goose Down -DROP INDEX CONCURRENTLY IF EXISTS idx_discovered_clients_pending_unique; diff --git a/db/migrations/postgres/0012_cascade_fk.sql b/db/migrations/postgres/0012_cascade_fk.sql deleted file mode 100644 index 9b7c8773..00000000 --- a/db/migrations/postgres/0012_cascade_fk.sql +++ /dev/null @@ -1,89 +0,0 @@ --- +goose Up --- P2-DB-03 (DF-24 / M-F11): add missing foreign-key constraints and ON DELETE --- CASCADE semantics so deleting a parent row (user, client) does not leave --- orphan rows behind (sessions, enrollment tokens, metric snapshots, client --- assignments, client deployments). --- --- Dev-stage policy (plan v4): we only operate on dev databases. Drop-and- --- recreate is acceptable, which means we clean up obvious orphan rows before --- ADD CONSTRAINT so the migration does not abort on legacy data. - --- Sessions: 0004_sessions.sql created the table without a FK. Clean orphan --- sessions, then attach the CASCADE FK to users(id). -DELETE FROM sessions -WHERE user_id NOT IN (SELECT id FROM users); - -ALTER TABLE sessions - DROP CONSTRAINT IF EXISTS fk_sessions_user_id; - -ALTER TABLE sessions - ADD CONSTRAINT fk_sessions_user_id - FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE; - --- Enrollment tokens: deliberately NOT linked to fleet_groups by a FK. The --- control-plane issues tokens against a fleet group id that may not yet exist --- — agent_flow.consumeEnrollmentToken lazily creates the group on first --- enrollment. Adding a FK here would break that "issue-then-create" flow. --- See internal/controlplane/server/agent_flow.go (consumeEnrollmentToken) --- and P2-DB-03 discussion. - --- Metric snapshots: PG 0001_init already has a FK on agent_id but without --- CASCADE. Replace with a CASCADE variant so DeleteAgent cleans up history. -DELETE FROM metric_snapshots -WHERE agent_id NOT IN (SELECT id FROM agents); - -ALTER TABLE metric_snapshots - DROP CONSTRAINT IF EXISTS metric_snapshots_agent_id_fkey; -ALTER TABLE metric_snapshots - DROP CONSTRAINT IF EXISTS fk_metric_snapshots_agent_id; - -ALTER TABLE metric_snapshots - ADD CONSTRAINT fk_metric_snapshots_agent_id - FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE; - --- client_assignments.client_id already has ON DELETE CASCADE (from 0001_init). --- The remaining FKs (fleet_group_id, agent_id) reference optional pointers and --- should cascade-null when the target disappears. -DELETE FROM client_assignments -WHERE fleet_group_id IS NOT NULL - AND fleet_group_id NOT IN (SELECT id FROM fleet_groups); - -DELETE FROM client_assignments -WHERE agent_id IS NOT NULL - AND agent_id NOT IN (SELECT id FROM agents); - -ALTER TABLE client_assignments - DROP CONSTRAINT IF EXISTS client_assignments_fleet_group_id_fkey; -ALTER TABLE client_assignments - DROP CONSTRAINT IF EXISTS fk_client_assignments_fleet_group_id; - -ALTER TABLE client_assignments - ADD CONSTRAINT fk_client_assignments_fleet_group_id - FOREIGN KEY (fleet_group_id) REFERENCES fleet_groups (id) ON DELETE SET NULL; - -ALTER TABLE client_assignments - DROP CONSTRAINT IF EXISTS client_assignments_agent_id_fkey; -ALTER TABLE client_assignments - DROP CONSTRAINT IF EXISTS fk_client_assignments_agent_id; - -ALTER TABLE client_assignments - ADD CONSTRAINT fk_client_assignments_agent_id - FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE SET NULL; - --- client_deployments.client_id already has ON DELETE CASCADE. Upgrade --- client_deployments.agent_id to CASCADE so removing an agent prunes its rows. -DELETE FROM client_deployments -WHERE agent_id NOT IN (SELECT id FROM agents); - -ALTER TABLE client_deployments - DROP CONSTRAINT IF EXISTS client_deployments_agent_id_fkey; -ALTER TABLE client_deployments - DROP CONSTRAINT IF EXISTS fk_client_deployments_agent_id; - -ALTER TABLE client_deployments - ADD CONSTRAINT fk_client_deployments_agent_id - FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE; - --- +goose Down --- dev-stage: drop+recreate acceptable, no rollback. -SELECT 1; diff --git a/db/migrations/postgres/0013_login_lockout.sql b/db/migrations/postgres/0013_login_lockout.sql deleted file mode 100644 index 919b7540..00000000 --- a/db/migrations/postgres/0013_login_lockout.sql +++ /dev/null @@ -1,20 +0,0 @@ --- +goose Up --- S7: persist login-lockout state so a control-plane restart or --- fail-over cannot be used to reset the failed-attempt counter for --- an account. Key is the raw username as supplied by the client: --- the auth flow already normalises it (strings.ToLower / TrimSpace), --- and the DB-level PK makes upserts serializable without a --- separate unique index. -CREATE TABLE IF NOT EXISTS login_lockouts ( - username TEXT PRIMARY KEY, - failures INTEGER NOT NULL DEFAULT 0, - locked_at TIMESTAMPTZ, - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_login_lockouts_locked_at - ON login_lockouts(locked_at); - --- +goose Down -DROP INDEX IF EXISTS idx_login_lockouts_locked_at; -DROP TABLE IF EXISTS login_lockouts; diff --git a/db/migrations/postgres/0014_fleet_groups_redesign.sql b/db/migrations/postgres/0014_fleet_groups_redesign.sql deleted file mode 100644 index 3aa68479..00000000 --- a/db/migrations/postgres/0014_fleet_groups_redesign.sql +++ /dev/null @@ -1,118 +0,0 @@ --- +goose Up --- Fleet groups redesign (breaking): `id` becomes UUID. The old schema --- stored it as a semantic slug ("default", "edge") that cannot be --- auto-cast; dependent FK rows are cleared so pre-release operators --- re-enroll agents after upgrade. See sqlite/0014 for the equivalent --- path on SQLite (where `id` remains TEXT and UUID format is --- enforced at the application layer). --- --- The display surface splits into `name` (immutable slug — unique) + --- `label` (editable) + `description`. Adds an extensibility layer so --- integrations (DNS round-robin, webhooks, future plugins) attach --- per-group without schema churn every time a new kind ships. -DELETE FROM client_assignments; -DELETE FROM client_deployments; -DELETE FROM discovered_clients; -DELETE FROM enrollment_tokens; -DELETE FROM telemt_runtime_events; -DELETE FROM telemt_runtime_upstreams_current; -DELETE FROM telemt_runtime_dcs_current; -DELETE FROM telemt_runtime_current; -DELETE FROM telemt_diagnostics_current; -DELETE FROM telemt_security_inventory_current; -DELETE FROM telemt_instances; -DELETE FROM agents; -DELETE FROM fleet_groups; - --- Drop FK constraints first: Postgres refuses to retype the parent PK --- while child columns still reference it with a mismatched type, even --- if the dependent tables were just truncated above. -ALTER TABLE agents - DROP CONSTRAINT IF EXISTS agents_fleet_group_id_fkey; -ALTER TABLE enrollment_tokens - DROP CONSTRAINT IF EXISTS enrollment_tokens_fleet_group_id_fkey; -ALTER TABLE client_assignments - DROP CONSTRAINT IF EXISTS client_assignments_fleet_group_id_fkey, - DROP CONSTRAINT IF EXISTS fk_client_assignments_fleet_group_id; - -ALTER TABLE fleet_groups - ALTER COLUMN id DROP DEFAULT, - ALTER COLUMN id TYPE UUID USING gen_random_uuid(), - ADD COLUMN label TEXT NOT NULL DEFAULT '', - ADD COLUMN description TEXT NOT NULL DEFAULT '', - ADD COLUMN updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); - -ALTER TABLE fleet_groups - ADD CONSTRAINT fleet_groups_name_unique UNIQUE (name); - --- Dependent FK columns → UUID, then reattach FKs. -ALTER TABLE agents - ALTER COLUMN fleet_group_id TYPE UUID USING NULL, - ADD CONSTRAINT agents_fleet_group_id_fkey - FOREIGN KEY (fleet_group_id) REFERENCES fleet_groups (id); - -ALTER TABLE enrollment_tokens - ALTER COLUMN fleet_group_id TYPE UUID USING NULL, - ADD CONSTRAINT enrollment_tokens_fleet_group_id_fkey - FOREIGN KEY (fleet_group_id) REFERENCES fleet_groups (id); - -ALTER TABLE client_assignments - ALTER COLUMN fleet_group_id TYPE UUID USING NULL, - ADD CONSTRAINT fk_client_assignments_fleet_group_id - FOREIGN KEY (fleet_group_id) REFERENCES fleet_groups (id) ON DELETE SET NULL; - --- Shared credential store so a single provider config (e.g. one --- Cloudflare account) can back DNS integrations for many groups. -CREATE TABLE IF NOT EXISTS integration_providers ( - id UUID PRIMARY KEY, - kind TEXT NOT NULL, - label TEXT NOT NULL DEFAULT '', - config JSONB NOT NULL DEFAULT '{}'::jsonb, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_integration_providers_kind - ON integration_providers (kind); - --- Per-group integration install: at most one row per (group, kind). --- provider_id is nullable because not every integration ships with a --- shared credential — a local-only integration may embed its whole --- config inline. -CREATE TABLE IF NOT EXISTS fleet_group_integrations ( - id UUID PRIMARY KEY, - fleet_group_id UUID NOT NULL REFERENCES fleet_groups (id) ON DELETE CASCADE, - kind TEXT NOT NULL, - provider_id UUID REFERENCES integration_providers (id) ON DELETE SET NULL, - config JSONB NOT NULL DEFAULT '{}'::jsonb, - enabled BOOLEAN NOT NULL DEFAULT FALSE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE (fleet_group_id, kind) -); - -CREATE INDEX IF NOT EXISTS idx_fleet_group_integrations_fleet_group_id - ON fleet_group_integrations (fleet_group_id); -CREATE INDEX IF NOT EXISTS idx_fleet_group_integrations_kind - ON fleet_group_integrations (kind); - --- +goose Down -DROP INDEX IF EXISTS idx_fleet_group_integrations_kind; -DROP INDEX IF EXISTS idx_fleet_group_integrations_fleet_group_id; -DROP TABLE IF EXISTS fleet_group_integrations; -DROP INDEX IF EXISTS idx_integration_providers_kind; -DROP TABLE IF EXISTS integration_providers; - -ALTER TABLE client_assignments - ALTER COLUMN fleet_group_id TYPE TEXT USING NULL; -ALTER TABLE enrollment_tokens - ALTER COLUMN fleet_group_id TYPE TEXT USING NULL; -ALTER TABLE agents - ALTER COLUMN fleet_group_id TYPE TEXT USING NULL; - -ALTER TABLE fleet_groups - DROP CONSTRAINT IF EXISTS fleet_groups_name_unique, - DROP COLUMN IF EXISTS updated_at, - DROP COLUMN IF EXISTS description, - DROP COLUMN IF EXISTS label, - ALTER COLUMN id TYPE TEXT USING id::TEXT; diff --git a/db/migrations/postgres/0015_client_usage.sql b/db/migrations/postgres/0015_client_usage.sql deleted file mode 100644 index c5e380c7..00000000 --- a/db/migrations/postgres/0015_client_usage.sql +++ /dev/null @@ -1,29 +0,0 @@ --- +goose Up --- Persist per-(client, agent) usage counters so they survive a panel --- restart. Previously these lived only in server.clientUsage (an --- in-memory map) and got re-seeded from discovered_clients.total_octets --- as a stop-gap — fragile because that fallback depends on the agent --- still being online and re-discovering the client. --- --- The row is keyed by (client_id, agent_id) to match the in-memory --- shape; aggregate totals across nodes are computed at read time. --- last_seq carries the per-agent delta cursor (rewinds to 1 on agent --- restart; the higher value wins) so dedupe works across CP restarts. -CREATE TABLE IF NOT EXISTS client_usage ( - client_id TEXT NOT NULL REFERENCES clients (id) ON DELETE CASCADE, - agent_id TEXT NOT NULL REFERENCES agents (id) ON DELETE CASCADE, - traffic_used_bytes BIGINT NOT NULL DEFAULT 0, - unique_ips_used INTEGER NOT NULL DEFAULT 0, - active_tcp_conns INTEGER NOT NULL DEFAULT 0, - active_unique_ips INTEGER NOT NULL DEFAULT 0, - last_seq BIGINT NOT NULL DEFAULT 0, - observed_at TIMESTAMPTZ NOT NULL, - PRIMARY KEY (client_id, agent_id) -); - -CREATE INDEX IF NOT EXISTS idx_client_usage_agent_id - ON client_usage (agent_id); - --- +goose Down -DROP INDEX IF EXISTS idx_client_usage_agent_id; -DROP TABLE IF EXISTS client_usage; diff --git a/db/migrations/postgres/0016_sessions_last_seen.sql b/db/migrations/postgres/0016_sessions_last_seen.sql deleted file mode 100644 index 29946aa7..00000000 --- a/db/migrations/postgres/0016_sessions_last_seen.sql +++ /dev/null @@ -1,6 +0,0 @@ --- +goose Up -ALTER TABLE sessions ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); -UPDATE sessions SET last_seen_at = created_at WHERE last_seen_at < created_at; - --- +goose Down -ALTER TABLE sessions DROP COLUMN IF EXISTS last_seen_at; diff --git a/db/migrations/postgres/0017_consumed_totp.sql b/db/migrations/postgres/0017_consumed_totp.sql deleted file mode 100644 index 44cf061d..00000000 --- a/db/migrations/postgres/0017_consumed_totp.sql +++ /dev/null @@ -1,12 +0,0 @@ --- +goose Up -CREATE TABLE IF NOT EXISTS consumed_totp ( - user_id TEXT NOT NULL, - code TEXT NOT NULL, - used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - PRIMARY KEY (user_id, code) -); -CREATE INDEX IF NOT EXISTS idx_consumed_totp_used_at ON consumed_totp(used_at); - --- +goose Down -DROP INDEX IF EXISTS idx_consumed_totp_used_at; -DROP TABLE IF EXISTS consumed_totp; diff --git a/db/migrations/postgres/0018_cp_secrets.sql b/db/migrations/postgres/0018_cp_secrets.sql deleted file mode 100644 index b1ac5bcc..00000000 --- a/db/migrations/postgres/0018_cp_secrets.sql +++ /dev/null @@ -1,9 +0,0 @@ --- +goose Up -CREATE TABLE IF NOT EXISTS cp_secrets ( - key TEXT PRIMARY KEY, - value BYTEA NOT NULL, - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- +goose Down -DROP TABLE IF EXISTS cp_secrets; diff --git a/db/migrations/postgres/0019_jobs_actor_id_index.sql b/db/migrations/postgres/0019_jobs_actor_id_index.sql deleted file mode 100644 index 7f2b75d3..00000000 --- a/db/migrations/postgres/0019_jobs_actor_id_index.sql +++ /dev/null @@ -1,7 +0,0 @@ --- +goose Up --- +goose NO TRANSACTION -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_jobs_actor_id ON jobs(actor_id); - --- +goose Down --- +goose NO TRANSACTION -DROP INDEX CONCURRENTLY IF EXISTS idx_jobs_actor_id; diff --git a/db/migrations/postgres/0020_agents_cert_serial.sql b/db/migrations/postgres/0020_agents_cert_serial.sql deleted file mode 100644 index 507d0eb3..00000000 --- a/db/migrations/postgres/0020_agents_cert_serial.sql +++ /dev/null @@ -1,5 +0,0 @@ --- +goose Up -ALTER TABLE agents ADD COLUMN IF NOT EXISTS cert_serial TEXT NOT NULL DEFAULT ''; - --- +goose Down -ALTER TABLE agents DROP COLUMN IF EXISTS cert_serial; diff --git a/db/migrations/postgres/0021_enrollment_token_hash.sql b/db/migrations/postgres/0021_enrollment_token_hash.sql deleted file mode 100644 index 09ff4766..00000000 --- a/db/migrations/postgres/0021_enrollment_token_hash.sql +++ /dev/null @@ -1,11 +0,0 @@ --- +goose Up -ALTER TABLE enrollment_tokens ADD COLUMN IF NOT EXISTS value_hash TEXT NOT NULL DEFAULT ''; --- +goose StatementBegin --- +goose NO TRANSACTION -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_enrollment_tokens_value_hash ON enrollment_tokens(value_hash); --- +goose StatementEnd - --- +goose Down --- +goose NO TRANSACTION -DROP INDEX CONCURRENTLY IF EXISTS idx_enrollment_tokens_value_hash; -ALTER TABLE enrollment_tokens DROP COLUMN IF EXISTS value_hash; diff --git a/db/migrations/postgres/0022_cascade_fk_telemt_instances.sql b/db/migrations/postgres/0022_cascade_fk_telemt_instances.sql deleted file mode 100644 index 95078408..00000000 --- a/db/migrations/postgres/0022_cascade_fk_telemt_instances.sql +++ /dev/null @@ -1,15 +0,0 @@ --- +goose Up --- Q4.U-D-03: drop the legacy unconstrained FK and re-add it with --- ON DELETE CASCADE so deleting an agent also drops its instances. --- SQLite already has CASCADE here via 0012_cascade_fk.sql; this brings --- postgres into parity. -ALTER TABLE telemt_instances DROP CONSTRAINT IF EXISTS telemt_instances_agent_id_fkey; -ALTER TABLE telemt_instances - ADD CONSTRAINT telemt_instances_agent_id_fkey - FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE; - --- +goose Down -ALTER TABLE telemt_instances DROP CONSTRAINT IF EXISTS telemt_instances_agent_id_fkey; -ALTER TABLE telemt_instances - ADD CONSTRAINT telemt_instances_agent_id_fkey - FOREIGN KEY (agent_id) REFERENCES agents (id); diff --git a/db/migrations/postgres/0023_check_constraints.sql b/db/migrations/postgres/0023_check_constraints.sql deleted file mode 100644 index ff16db47..00000000 --- a/db/migrations/postgres/0023_check_constraints.sql +++ /dev/null @@ -1,18 +0,0 @@ --- +goose Up --- Q4.U-D-02: explicit CHECK constraints on enum-shaped TEXT columns. --- These columns are already enums in the application — adding the --- check at the DB level catches a stray writer (e.g. a future sqlc --- query bug, an admin-issued UPDATE) before it corrupts the row. -ALTER TABLE jobs ADD CONSTRAINT jobs_status_check - CHECK (status IN ('queued','running','succeeded','failed','expired')); - -ALTER TABLE job_targets ADD CONSTRAINT job_targets_status_check - CHECK (status IN ('queued','dispatched','acknowledged','succeeded','failed','expired')); - -ALTER TABLE discovered_clients ADD CONSTRAINT discovered_clients_status_check - CHECK (status IN ('pending_review','adopted','ignored')); - --- +goose Down -ALTER TABLE jobs DROP CONSTRAINT IF EXISTS jobs_status_check; -ALTER TABLE job_targets DROP CONSTRAINT IF EXISTS job_targets_status_check; -ALTER TABLE discovered_clients DROP CONSTRAINT IF EXISTS discovered_clients_status_check; diff --git a/db/migrations/postgres/0024_unify_real_to_double.sql b/db/migrations/postgres/0024_unify_real_to_double.sql deleted file mode 100644 index b74f51be..00000000 --- a/db/migrations/postgres/0024_unify_real_to_double.sql +++ /dev/null @@ -1,34 +0,0 @@ --- +goose Up --- Q5.U-D-01: postgres REAL is 4-byte single precision; sqlite REAL is --- 8-byte double precision. The drift means a counter that survives --- 7 sig-digits round-trip on sqlite gets quantised to 4 bytes on --- postgres. Promote every timeseries float column to DOUBLE PRECISION --- so both backends carry the same numeric range. The migration is a --- straight ALTER TYPE — postgres rewrites the table once, then both --- drivers behave identically. -ALTER TABLE ts_server_load - ALTER COLUMN cpu_pct_avg TYPE DOUBLE PRECISION, - ALTER COLUMN cpu_pct_max TYPE DOUBLE PRECISION, - ALTER COLUMN mem_pct_avg TYPE DOUBLE PRECISION, - ALTER COLUMN mem_pct_max TYPE DOUBLE PRECISION, - ALTER COLUMN disk_pct_avg TYPE DOUBLE PRECISION, - ALTER COLUMN disk_pct_max TYPE DOUBLE PRECISION, - ALTER COLUMN load_1m TYPE DOUBLE PRECISION, - ALTER COLUMN load_5m TYPE DOUBLE PRECISION, - ALTER COLUMN load_15m TYPE DOUBLE PRECISION, - ALTER COLUMN dc_coverage_min_pct TYPE DOUBLE PRECISION, - ALTER COLUMN dc_coverage_avg_pct TYPE DOUBLE PRECISION; - --- +goose Down -ALTER TABLE ts_server_load - ALTER COLUMN cpu_pct_avg TYPE REAL, - ALTER COLUMN cpu_pct_max TYPE REAL, - ALTER COLUMN mem_pct_avg TYPE REAL, - ALTER COLUMN mem_pct_max TYPE REAL, - ALTER COLUMN disk_pct_avg TYPE REAL, - ALTER COLUMN disk_pct_max TYPE REAL, - ALTER COLUMN load_1m TYPE REAL, - ALTER COLUMN load_5m TYPE REAL, - ALTER COLUMN load_15m TYPE REAL, - ALTER COLUMN dc_coverage_min_pct TYPE REAL, - ALTER COLUMN dc_coverage_avg_pct TYPE REAL; diff --git a/db/migrations/postgres/0025_user_fleet_group_scopes.sql b/db/migrations/postgres/0025_user_fleet_group_scopes.sql deleted file mode 100644 index 8eb930cb..00000000 --- a/db/migrations/postgres/0025_user_fleet_group_scopes.sql +++ /dev/null @@ -1,25 +0,0 @@ --- +goose Up --- R-S-14: per-user fleet-group scope mapping. An operator with at --- least one row in this table is restricted to those fleet groups — --- their /clients, /fleet-groups, /discovered-clients, and job-target --- queries are filtered to the union of their scopes. An operator with --- no rows here keeps the legacy global view (single-tenant default). --- Admins are always global regardless of rows. --- --- Cascade DELETE on both sides so removing a user or a fleet group --- automatically tidies the join rows; no orphans, no manual cleanup. -CREATE TABLE IF NOT EXISTS user_fleet_group_scopes ( - user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, - fleet_group_id UUID NOT NULL REFERENCES fleet_groups (id) ON DELETE CASCADE, - granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - granted_by TEXT NOT NULL DEFAULT '', - PRIMARY KEY (user_id, fleet_group_id) -); - -CREATE INDEX IF NOT EXISTS idx_user_fleet_group_scopes_user_id - ON user_fleet_group_scopes (user_id); -CREATE INDEX IF NOT EXISTS idx_user_fleet_group_scopes_fleet_group_id - ON user_fleet_group_scopes (fleet_group_id); - --- +goose Down -DROP TABLE IF EXISTS user_fleet_group_scopes; diff --git a/db/migrations/postgres/0027_fix_job_targets_check_enum.sql b/db/migrations/postgres/0027_fix_job_targets_check_enum.sql deleted file mode 100644 index fb4161eb..00000000 --- a/db/migrations/postgres/0027_fix_job_targets_check_enum.sql +++ /dev/null @@ -1,20 +0,0 @@ --- +goose Up --- Migration 0023 added a CHECK constraint on job_targets.status with --- the value `dispatched` — but the application enum --- (internal/controlplane/jobs.TargetStatus) is `sent`, not --- `dispatched`. Production writes to target.status="sent" trip the --- constraint and bubble up as "constraint failed: CHECK constraint --- failed" from the persist loop, leaving the row at its previous --- value. The SQLite contract test --- TestServiceUpdateTargetPersistsLatestVersionAfterOutOfOrderWrites --- caught it once SQLite's mirror constraint landed in 0026. --- --- Drop the wrong constraint and re-add it with the correct enum. -ALTER TABLE job_targets DROP CONSTRAINT IF EXISTS job_targets_status_check; -ALTER TABLE job_targets ADD CONSTRAINT job_targets_status_check - CHECK (status IN ('queued','sent','acknowledged','succeeded','failed','expired')); - --- +goose Down -ALTER TABLE job_targets DROP CONSTRAINT IF EXISTS job_targets_status_check; -ALTER TABLE job_targets ADD CONSTRAINT job_targets_status_check - CHECK (status IN ('queued','dispatched','acknowledged','succeeded','failed','expired')); diff --git a/db/migrations/postgres/0028_cascade_fk_grants_and_discovered.sql b/db/migrations/postgres/0028_cascade_fk_grants_and_discovered.sql deleted file mode 100644 index 1a284151..00000000 --- a/db/migrations/postgres/0028_cascade_fk_grants_and_discovered.sql +++ /dev/null @@ -1,32 +0,0 @@ --- +goose Up --- Add ON DELETE CASCADE to the remaining FKs on agents (id): --- * agent_certificate_recovery_grants — never patched. --- * discovered_clients — never patched. --- telemt_instances was handled in 0022_cascade_fk_telemt_instances.sql. --- Without these, DELETE FROM agents fails when the deregistered agent --- still has rows in either table (see http_agents.persistAgentDeregister). - -ALTER TABLE agent_certificate_recovery_grants - DROP CONSTRAINT IF EXISTS agent_certificate_recovery_grants_agent_id_fkey; -ALTER TABLE agent_certificate_recovery_grants - ADD CONSTRAINT agent_certificate_recovery_grants_agent_id_fkey - FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE; - -ALTER TABLE discovered_clients - DROP CONSTRAINT IF EXISTS discovered_clients_agent_id_fkey; -ALTER TABLE discovered_clients - ADD CONSTRAINT discovered_clients_agent_id_fkey - FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE; - --- +goose Down -ALTER TABLE agent_certificate_recovery_grants - DROP CONSTRAINT IF EXISTS agent_certificate_recovery_grants_agent_id_fkey; -ALTER TABLE agent_certificate_recovery_grants - ADD CONSTRAINT agent_certificate_recovery_grants_agent_id_fkey - FOREIGN KEY (agent_id) REFERENCES agents (id); - -ALTER TABLE discovered_clients - DROP CONSTRAINT IF EXISTS discovered_clients_agent_id_fkey; -ALTER TABLE discovered_clients - ADD CONSTRAINT discovered_clients_agent_id_fkey - FOREIGN KEY (agent_id) REFERENCES agents (id); diff --git a/db/migrations/postgres/0029_connection_links_array.sql b/db/migrations/postgres/0029_connection_links_array.sql deleted file mode 100644 index 77f66906..00000000 --- a/db/migrations/postgres/0029_connection_links_array.sql +++ /dev/null @@ -1,31 +0,0 @@ --- +goose Up --- Replace the single-link connection_link column with a JSON array --- (connection_links) on both client_deployments and discovered_clients. --- Telemt's tls_domains config emits one TLS link per domain (×host); the --- agent used to collapse that to a single string. The panel now stores --- and surfaces the full array. --- --- Dev-stage: drop the legacy column. Existing rows with a single non- --- empty link are migrated into the new JSON array first. - -ALTER TABLE client_deployments - ADD COLUMN connection_links JSONB NOT NULL DEFAULT '[]'::jsonb; - -UPDATE client_deployments -SET connection_links = jsonb_build_array(connection_link) -WHERE connection_link <> ''; - -ALTER TABLE client_deployments DROP COLUMN connection_link; - -ALTER TABLE discovered_clients - ADD COLUMN connection_links JSONB NOT NULL DEFAULT '[]'::jsonb; - -UPDATE discovered_clients -SET connection_links = jsonb_build_array(connection_link) -WHERE connection_link <> ''; - -ALTER TABLE discovered_clients DROP COLUMN connection_link; - --- +goose Down --- Dev-stage: drop+recreate acceptable, no rollback. -SELECT 1; diff --git a/db/migrations/postgres/0030_node_transport_mode.sql b/db/migrations/postgres/0030_node_transport_mode.sql deleted file mode 100644 index 13095cec..00000000 --- a/db/migrations/postgres/0030_node_transport_mode.sql +++ /dev/null @@ -1,33 +0,0 @@ --- +goose Up --- Add transport-mode fields to agents so the panel can distinguish inbound --- agents (the default: agent dials the panel) from outbound/reverse-tunnel --- agents (panel dials the agent via a stored dial_address). --- bootstrap_* columns support a one-time token exchange for outbound agents. - -ALTER TABLE agents - ADD COLUMN transport_mode text NOT NULL DEFAULT 'inbound' - CHECK (transport_mode IN ('inbound', 'outbound')), - ADD COLUMN dial_address text, - ADD COLUMN bootstrap_state text NOT NULL DEFAULT 'active' - CHECK (bootstrap_state IN ('pending', 'active', 'expired', 'revoked')), - ADD COLUMN bootstrap_token_hash bytea, - ADD COLUMN bootstrap_expires_at timestamptz; - --- +goose StatementBegin --- +goose NO TRANSACTION -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_agents_transport_mode ON agents(transport_mode); --- +goose StatementEnd - --- Existing agents default to bootstrap_state=active (already enrolled). --- New outbound agents are created with bootstrap_state=pending --- (see bootstrap-flow in subsequent tasks). - --- +goose Down --- +goose NO TRANSACTION -DROP INDEX CONCURRENTLY IF EXISTS idx_agents_transport_mode; -ALTER TABLE agents - DROP COLUMN IF EXISTS bootstrap_expires_at, - DROP COLUMN IF EXISTS bootstrap_token_hash, - DROP COLUMN IF EXISTS bootstrap_state, - DROP COLUMN IF EXISTS dial_address, - DROP COLUMN IF EXISTS transport_mode; diff --git a/db/migrations/postgres/0031_agent_fallback_state.sql b/db/migrations/postgres/0031_agent_fallback_state.sql deleted file mode 100644 index 004fd847..00000000 --- a/db/migrations/postgres/0031_agent_fallback_state.sql +++ /dev/null @@ -1,12 +0,0 @@ --- +goose Up -CREATE TABLE IF NOT EXISTS agent_fallback_state ( - agent_id TEXT PRIMARY KEY REFERENCES agents (id) ON DELETE CASCADE, - entered_at_unix BIGINT NOT NULL -); - -CREATE INDEX IF NOT EXISTS idx_agent_fallback_state_entered_at - ON agent_fallback_state (entered_at_unix); - --- +goose Down -DROP INDEX IF EXISTS idx_agent_fallback_state_entered_at; -DROP TABLE IF EXISTS agent_fallback_state; diff --git a/db/migrations/postgres/0032_password_policy.sql b/db/migrations/postgres/0032_password_policy.sql deleted file mode 100644 index d6d25f11..00000000 --- a/db/migrations/postgres/0032_password_policy.sql +++ /dev/null @@ -1,11 +0,0 @@ --- +goose Up --- S-01: configurable minimum password length stored on the panel_settings --- singleton row. Default 10 mirrors NIST SP 800-63B "memorized secret" floor; --- existing user passwords are NOT invalidated — policy applies only on --- create/change. -ALTER TABLE panel_settings - ADD COLUMN password_min_length INTEGER NOT NULL DEFAULT 10 - CHECK (password_min_length BETWEEN 8 AND 128); - --- +goose Down -ALTER TABLE panel_settings DROP COLUMN password_min_length; diff --git a/db/migrations/postgres/0033_agent_cert_pin.sql b/db/migrations/postgres/0033_agent_cert_pin.sql deleted file mode 100644 index d5564688..00000000 --- a/db/migrations/postgres/0033_agent_cert_pin.sql +++ /dev/null @@ -1,23 +0,0 @@ --- +goose Up --- +goose NO TRANSACTION --- S-02: pin the SubjectPublicKeyInfo (SPKI) SHA-256 of the agent's serving --- certificate. Set on first successful enroll; subsequent dials verify the --- presented cert hashes to the same value. Defends against MITM during --- post-enroll handshakes when an attacker holds a forged cert from any CA --- the agent trusts. --- --- NO TRANSACTION pragma: required because CREATE/DROP INDEX CONCURRENTLY --- cannot run inside a transaction. ALTER TABLE ADD COLUMN does take an --- ACCESS EXCLUSIVE lock briefly, but the column has a constant DEFAULT --- so PG 11+ skips the table rewrite. -ALTER TABLE agents - ADD COLUMN cert_spki_sha256 BYTEA NOT NULL DEFAULT ''::bytea - CHECK (length(cert_spki_sha256) IN (0, 32)); - -CREATE INDEX CONCURRENTLY idx_agents_cert_spki_sha256 - ON agents (cert_spki_sha256) - WHERE length(cert_spki_sha256) > 0; - --- +goose Down -DROP INDEX CONCURRENTLY IF EXISTS idx_agents_cert_spki_sha256; -ALTER TABLE agents DROP COLUMN IF EXISTS cert_spki_sha256; diff --git a/db/migrations/postgres/0034_geoip_settings.sql b/db/migrations/postgres/0034_geoip_settings.sql deleted file mode 100644 index 12b19703..00000000 --- a/db/migrations/postgres/0034_geoip_settings.sql +++ /dev/null @@ -1,13 +0,0 @@ --- +goose Up --- Adds geoip_json (operator-managed config) and geoip_state_json --- (worker-managed runtime state — last check / etag / size / error) --- to the singleton scope='panel' row. Mirrors the retention_json / --- update_settings pattern. -ALTER TABLE panel_settings - ADD COLUMN IF NOT EXISTS geoip_json TEXT NOT NULL DEFAULT ''; -ALTER TABLE panel_settings - ADD COLUMN IF NOT EXISTS geoip_state_json TEXT NOT NULL DEFAULT ''; - --- +goose Down -ALTER TABLE panel_settings DROP COLUMN IF EXISTS geoip_state_json; -ALTER TABLE panel_settings DROP COLUMN IF EXISTS geoip_json; diff --git a/db/migrations/postgres/0035_telemt_unreachable.sql b/db/migrations/postgres/0035_telemt_unreachable.sql deleted file mode 100644 index 2ddee363..00000000 --- a/db/migrations/postgres/0035_telemt_unreachable.sql +++ /dev/null @@ -1,13 +0,0 @@ --- +goose Up --- +goose StatementBegin -ALTER TABLE telemt_runtime_current - ADD COLUMN telemt_reachable BOOLEAN NOT NULL DEFAULT TRUE, - ADD COLUMN telemt_unreachable_since_unix BIGINT NOT NULL DEFAULT 0; --- +goose StatementEnd - --- +goose Down --- +goose StatementBegin -ALTER TABLE telemt_runtime_current - DROP COLUMN IF EXISTS telemt_unreachable_since_unix, - DROP COLUMN IF EXISTS telemt_reachable; --- +goose StatementEnd diff --git a/db/migrations/postgres/0036_drop_bootstrap_columns_from_panel_settings.sql b/db/migrations/postgres/0036_drop_bootstrap_columns_from_panel_settings.sql deleted file mode 100644 index 963e421a..00000000 --- a/db/migrations/postgres/0036_drop_bootstrap_columns_from_panel_settings.sql +++ /dev/null @@ -1,13 +0,0 @@ --- +goose Up --- 0036: drop bootstrap-only columns from panel_settings. --- Project is pre-release; no compatibility shim. - -ALTER TABLE panel_settings DROP COLUMN IF EXISTS http_listen_address; -ALTER TABLE panel_settings DROP COLUMN IF EXISTS grpc_listen_address; -ALTER TABLE panel_settings DROP COLUMN IF EXISTS tls_mode; -ALTER TABLE panel_settings DROP COLUMN IF EXISTS tls_cert_file; -ALTER TABLE panel_settings DROP COLUMN IF EXISTS tls_key_file; -ALTER TABLE panel_settings DROP COLUMN IF EXISTS http_root_path; - --- +goose Down --- intentionally empty (pre-release, no compatibility shim) diff --git a/db/migrations/postgres/0037_runtime_settings.sql b/db/migrations/postgres/0037_runtime_settings.sql deleted file mode 100644 index fd9583be..00000000 --- a/db/migrations/postgres/0037_runtime_settings.sql +++ /dev/null @@ -1,11 +0,0 @@ --- +goose Up --- 0037: kv-table for operational settings without dedicated columns. -CREATE TABLE runtime_settings ( - name TEXT PRIMARY KEY, - value_json TEXT NOT NULL, - updated_at BIGINT NOT NULL, - updated_by TEXT NOT NULL DEFAULT '' -); - --- +goose Down -DROP TABLE runtime_settings; diff --git a/db/migrations/postgres/0038_audit_hash_chain.sql b/db/migrations/postgres/0038_audit_hash_chain.sql deleted file mode 100644 index 4dd7498a..00000000 --- a/db/migrations/postgres/0038_audit_hash_chain.sql +++ /dev/null @@ -1,26 +0,0 @@ --- +goose Up --- +goose NO TRANSACTION --- 0038: tamper-evident audit chain. --- Project is pre-release; existing rows keep the empty default and the --- chain begins forming on the first post-migration write. The --- verify-audit-chain subcommand treats consecutive empty hashes at --- the head of the table as the legacy/genesis prefix. --- --- NO TRANSACTION because CREATE INDEX CONCURRENTLY cannot run inside --- a transaction. ADD COLUMN ... DEFAULT '' is metadata-only on --- PostgreSQL 11+, so the lack of an outer transaction does not impede --- atomicity in practice — the column is visible only after the --- ALTER TABLE returns. - -ALTER TABLE audit_events ADD COLUMN prev_hash TEXT NOT NULL DEFAULT ''; -ALTER TABLE audit_events ADD COLUMN event_hash TEXT NOT NULL DEFAULT ''; - --- Helper index for the chain walker: the verifier reads in --- (created_at, id) ascending order, which the existing primary key --- only partially covers. -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_audit_events_chain_walk - ON audit_events (created_at, id); - --- +goose Down --- +goose NO TRANSACTION --- intentionally empty (pre-release, no compatibility shim) diff --git a/db/migrations/postgres/0039_webhook_outbox.sql b/db/migrations/postgres/0039_webhook_outbox.sql deleted file mode 100644 index c462072c..00000000 --- a/db/migrations/postgres/0039_webhook_outbox.sql +++ /dev/null @@ -1,40 +0,0 @@ --- +goose Up --- 0039: webhook outbox + endpoints. --- Two tables: endpoints define receivers; outbox queues pending --- deliveries with retry / dead-letter state. Worker polls outbox, --- HMAC-signs body, exponential-backoff on failure. - -CREATE TABLE webhook_endpoints ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL UNIQUE, - url TEXT NOT NULL, - secret_ciphertext TEXT NOT NULL, - event_filter TEXT NOT NULL DEFAULT '', - allow_private BOOLEAN NOT NULL DEFAULT FALSE, - enabled BOOLEAN NOT NULL DEFAULT TRUE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE TABLE webhook_outbox ( - id TEXT PRIMARY KEY, - endpoint_id TEXT NOT NULL REFERENCES webhook_endpoints(id) ON DELETE CASCADE, - event_action TEXT NOT NULL, - payload JSONB NOT NULL, - attempt INTEGER NOT NULL DEFAULT 0, - next_attempt_at TIMESTAMPTZ NOT NULL, - last_error TEXT NOT NULL DEFAULT '', - dead BOOLEAN NOT NULL DEFAULT FALSE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - delivered_at TIMESTAMPTZ -); - --- Worker's claim query orders by next_attempt_at ASC for live rows --- (dead=false). Partial index keeps the hot scan small even with a --- huge dead-letter tail. -CREATE INDEX idx_webhook_outbox_ready - ON webhook_outbox (next_attempt_at) - WHERE dead = FALSE AND delivered_at IS NULL; - --- +goose Down --- intentionally empty (pre-release, no compatibility shim) diff --git a/db/migrations/postgres/0040_invert_telemt_reachability.sql b/db/migrations/postgres/0040_invert_telemt_reachability.sql deleted file mode 100644 index 3e20d38c..00000000 --- a/db/migrations/postgres/0040_invert_telemt_reachability.sql +++ /dev/null @@ -1,18 +0,0 @@ --- +goose Up --- +goose StatementBegin --- Invert the telemt reachability flag so proto3's bool default (false) --- correctly represents the healthy/reachable case. Previously --- telemt_reachable=TRUE meant healthy; now telemt_unreachable=FALSE means healthy. -ALTER TABLE telemt_runtime_current - ADD COLUMN telemt_unreachable BOOLEAN NOT NULL DEFAULT FALSE; -UPDATE telemt_runtime_current SET telemt_unreachable = NOT telemt_reachable; -ALTER TABLE telemt_runtime_current DROP COLUMN telemt_reachable; --- +goose StatementEnd - --- +goose Down --- +goose StatementBegin -ALTER TABLE telemt_runtime_current - ADD COLUMN telemt_reachable BOOLEAN NOT NULL DEFAULT TRUE; -UPDATE telemt_runtime_current SET telemt_reachable = NOT telemt_unreachable; -ALTER TABLE telemt_runtime_current DROP COLUMN telemt_unreachable; --- +goose StatementEnd diff --git a/db/migrations/postgres/0041_enrollment_attempts.sql b/db/migrations/postgres/0041_enrollment_attempts.sql deleted file mode 100644 index 7c5be3f9..00000000 --- a/db/migrations/postgres/0041_enrollment_attempts.sql +++ /dev/null @@ -1,38 +0,0 @@ --- +goose Up -CREATE TABLE enrollment_attempts ( - id UUID PRIMARY KEY, - token_id UUID, - agent_id UUID, - mode TEXT NOT NULL CHECK (mode IN ('inbound', 'outbound')), - client_addr TEXT, - request_id TEXT NOT NULL, - status TEXT NOT NULL CHECK (status IN ('in_progress', 'success', 'failed')), - error_code TEXT, - error_message TEXT, - started_at TIMESTAMPTZ NOT NULL, - finished_at TIMESTAMPTZ -); - -CREATE INDEX idx_enrollment_attempts_token ON enrollment_attempts(token_id); -CREATE INDEX idx_enrollment_attempts_agent ON enrollment_attempts(agent_id); -CREATE INDEX idx_enrollment_attempts_started ON enrollment_attempts(started_at); - -CREATE TABLE enrollment_events ( - id BIGSERIAL PRIMARY KEY, - attempt_id UUID NOT NULL REFERENCES enrollment_attempts(id) ON DELETE CASCADE, - ts TIMESTAMPTZ NOT NULL, - step TEXT NOT NULL, - level TEXT NOT NULL CHECK (level IN ('info', 'warn', 'error')), - message TEXT, - fields_json JSONB -); - -CREATE INDEX idx_enrollment_events_attempt ON enrollment_events(attempt_id, ts); - --- +goose Down -DROP INDEX IF EXISTS idx_enrollment_events_attempt; -DROP TABLE IF EXISTS enrollment_events; -DROP INDEX IF EXISTS idx_enrollment_attempts_started; -DROP INDEX IF EXISTS idx_enrollment_attempts_agent; -DROP INDEX IF EXISTS idx_enrollment_attempts_token; -DROP TABLE IF EXISTS enrollment_attempts; diff --git a/db/migrations/postgres/0042_client_deployment_last_reset.sql b/db/migrations/postgres/0042_client_deployment_last_reset.sql deleted file mode 100644 index 92410643..00000000 --- a/db/migrations/postgres/0042_client_deployment_last_reset.sql +++ /dev/null @@ -1,16 +0,0 @@ --- +goose Up --- +goose StatementBegin --- Phase 3: per-(client, agent) record of when the panel last applied a --- successful quota reset. Compared against Telemt's reported --- last_reset_epoch_secs on each ClientUsage snapshot to detect drift — --- i.e. cases where the panel-driven reset job succeeded but Telemt's --- persisted quota state has fallen behind (Telemt restart before --- sidecar flush, raw curl reset out of band, etc.). -ALTER TABLE client_deployments - ADD COLUMN last_reset_epoch_secs BIGINT NOT NULL DEFAULT 0; --- +goose StatementEnd - --- +goose Down --- +goose StatementBegin -ALTER TABLE client_deployments DROP COLUMN IF EXISTS last_reset_epoch_secs; --- +goose StatementEnd diff --git a/db/migrations/postgres/0043_client_usage_quota.sql b/db/migrations/postgres/0043_client_usage_quota.sql deleted file mode 100644 index 97712809..00000000 --- a/db/migrations/postgres/0043_client_usage_quota.sql +++ /dev/null @@ -1,21 +0,0 @@ --- +goose Up --- +goose StatementBegin --- IN-H2: persist the per-(client, agent) quota counters alongside the other --- usage fields. Previously quota_used_bytes / quota_last_reset_unix lived --- only in the in-memory mirror, so a panel restart snapped them to 0 until --- the next agent usage tick repopulated them. -ALTER TABLE client_usage - ADD COLUMN quota_used_bytes BIGINT NOT NULL DEFAULT 0; --- +goose StatementEnd --- +goose StatementBegin -ALTER TABLE client_usage - ADD COLUMN quota_last_reset_unix BIGINT NOT NULL DEFAULT 0; --- +goose StatementEnd - --- +goose Down --- +goose StatementBegin -ALTER TABLE client_usage DROP COLUMN IF EXISTS quota_last_reset_unix; --- +goose StatementEnd --- +goose StatementBegin -ALTER TABLE client_usage DROP COLUMN IF EXISTS quota_used_bytes; --- +goose StatementEnd diff --git a/db/migrations/postgres/0044_drop_enrollment_token_value_hash.sql b/db/migrations/postgres/0044_drop_enrollment_token_value_hash.sql deleted file mode 100644 index 5d6b66d4..00000000 --- a/db/migrations/postgres/0044_drop_enrollment_token_value_hash.sql +++ /dev/null @@ -1,10 +0,0 @@ --- +goose Up --- L-4: drop the dead enrollment_tokens.value_hash column. Enrollment tokens --- are stored plaintext (TTL-bounded); the hashing this column was meant to --- back was never implemented, leaving it a permanently-empty dead column. --- Pre-release, no compatibility shim. DROP COLUMN cascades to the index --- idx_enrollment_tokens_value_hash created in migration 0021. -ALTER TABLE enrollment_tokens DROP COLUMN IF EXISTS value_hash; - --- +goose Down --- intentionally empty (pre-release, no compatibility shim) diff --git a/db/migrations/postgres/0045_client_deployment_link_diagnostic.sql b/db/migrations/postgres/0045_client_deployment_link_diagnostic.sql deleted file mode 100644 index 43a40d5d..00000000 --- a/db/migrations/postgres/0045_client_deployment_link_diagnostic.sql +++ /dev/null @@ -1,15 +0,0 @@ --- +goose Up --- +goose StatementBegin --- IN-M2: operator-facing diagnostic stamped on a successful (non-delete) --- apply when the node returned no connection links. In that case the --- existing connection_links are kept but may be stale after a host or --- secret change; this column explains why so the dashboard can flag the --- link instead of silently serving it. Empty string means "no issue". -ALTER TABLE client_deployments - ADD COLUMN link_diagnostic TEXT NOT NULL DEFAULT ''; --- +goose StatementEnd - --- +goose Down --- +goose StatementBegin -ALTER TABLE client_deployments DROP COLUMN IF EXISTS link_diagnostic; --- +goose StatementEnd diff --git a/db/migrations/postgres/0046_rename_connected_users_to_connections.sql b/db/migrations/postgres/0046_rename_connected_users_to_connections.sql deleted file mode 100644 index 4df28ee1..00000000 --- a/db/migrations/postgres/0046_rename_connected_users_to_connections.sql +++ /dev/null @@ -1,9 +0,0 @@ --- +goose Up --- IN-M3: the telemt_instances.connected_users column actually holds the --- telemt CurrentConnections counter (number of TCP connections), not a --- distinct-users count. Rename the column end-to-end to reflect its true --- meaning. Value semantics are unchanged. -ALTER TABLE telemt_instances RENAME COLUMN connected_users TO connections; - --- +goose Down -ALTER TABLE telemt_instances RENAME COLUMN connections TO connected_users; diff --git a/db/migrations/postgres/0047_drop_telemt_detail_boosts.sql b/db/migrations/postgres/0047_drop_telemt_detail_boosts.sql deleted file mode 100644 index c920c48d..00000000 --- a/db/migrations/postgres/0047_drop_telemt_detail_boosts.sql +++ /dev/null @@ -1,13 +0,0 @@ --- +goose Up --- F4: drop the persisted detail-boost table. Detail boost is an ephemeral --- ~10m telemetry-frequency bump for a single agent and now lives only in --- memory on the panel (s.detailBoosts). It is intentionally not durable — --- if the panel restarts the operator simply re-enables it. -DROP TABLE IF EXISTS telemt_detail_boosts; - --- +goose Down -CREATE TABLE IF NOT EXISTS telemt_detail_boosts ( - agent_id TEXT PRIMARY KEY REFERENCES agents (id) ON DELETE CASCADE, - expires_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL -); diff --git a/db/migrations/postgres/0048_jobs_status_partial.sql b/db/migrations/postgres/0048_jobs_status_partial.sql deleted file mode 100644 index a1d2db80..00000000 --- a/db/migrations/postgres/0048_jobs_status_partial.sql +++ /dev/null @@ -1,14 +0,0 @@ --- +goose Up --- F2: deriveJobStatus now emits a new terminal job status, "partial", for a --- MIXED outcome (at least one target succeeded AND at least one ended --- terminally-unsuccessful). The jobs_status_check constraint added in 0023 --- only allowed queued/running/succeeded/failed/expired, so persisting a --- partial job tripped the CHECK and the write was dropped. Widen the enum. -ALTER TABLE jobs DROP CONSTRAINT IF EXISTS jobs_status_check; -ALTER TABLE jobs ADD CONSTRAINT jobs_status_check - CHECK (status IN ('queued','running','succeeded','failed','expired','partial')); - --- +goose Down -ALTER TABLE jobs DROP CONSTRAINT IF EXISTS jobs_status_check; -ALTER TABLE jobs ADD CONSTRAINT jobs_status_check - CHECK (status IN ('queued','running','succeeded','failed','expired')); diff --git a/db/migrations/postgres/0049_agent_config_targets.sql b/db/migrations/postgres/0049_agent_config_targets.sql deleted file mode 100644 index 4828525d..00000000 --- a/db/migrations/postgres/0049_agent_config_targets.sql +++ /dev/null @@ -1,17 +0,0 @@ --- +goose Up --- agent_config_targets stores the operator's DESIRED Telemt config per scope. --- scope_type is 'group' (scope_id = fleet_groups.id) or 'agent' (scope_id = --- agents.id). sections_json is a sparse JSON object of editable config sections --- (general/timeouts/censorship/upstreams/show_link/dc_overrides). The effective --- config of an agent is its group target merged with its agent override. -CREATE TABLE IF NOT EXISTS agent_config_targets ( - scope_type TEXT NOT NULL, - scope_id TEXT NOT NULL, - sections_json TEXT NOT NULL DEFAULT '{}', - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL, - PRIMARY KEY (scope_type, scope_id) -); - --- +goose Down -DROP TABLE IF EXISTS agent_config_targets; diff --git a/db/migrations/postgres/0050_check_parity.sql b/db/migrations/postgres/0050_check_parity.sql deleted file mode 100644 index c30986c1..00000000 --- a/db/migrations/postgres/0050_check_parity.sql +++ /dev/null @@ -1,17 +0,0 @@ --- +goose Up --- C1: Align enrollment_tokens.fleet_group_id FK with the SET NULL convention. --- A group deleted out from under outstanding enrollment tokens should null their --- scope, not block the delete (NO ACTION) and not silently vanish; this matches --- the provider_id SET NULL convention and the documented enrollment design. --- The two CHECK constraints (panel_settings.password_min_length and --- agents.cert_spki_sha256) already exist in Postgres; no changes needed there. -ALTER TABLE enrollment_tokens DROP CONSTRAINT enrollment_tokens_fleet_group_id_fkey; -ALTER TABLE enrollment_tokens - ADD CONSTRAINT enrollment_tokens_fleet_group_id_fkey - FOREIGN KEY (fleet_group_id) REFERENCES fleet_groups (id) ON DELETE SET NULL; - --- +goose Down -ALTER TABLE enrollment_tokens DROP CONSTRAINT enrollment_tokens_fleet_group_id_fkey; -ALTER TABLE enrollment_tokens - ADD CONSTRAINT enrollment_tokens_fleet_group_id_fkey - FOREIGN KEY (fleet_group_id) REFERENCES fleet_groups (id); diff --git a/db/migrations/postgres/0051_client_subscription_token.sql b/db/migrations/postgres/0051_client_subscription_token.sql deleted file mode 100644 index 38121aa1..00000000 --- a/db/migrations/postgres/0051_client_subscription_token.sql +++ /dev/null @@ -1,19 +0,0 @@ --- +goose Up --- +goose NO TRANSACTION --- subscription_token is the opaque, unguessable handle embedded in a client's --- public subscription URL (/sub/). Nullable: legacy rows get a token --- when the operator rotates (no backfill — pre-prod). UNIQUE so a token maps --- to at most one client; the partial predicate lets multiple rows stay NULL. --- --- NO TRANSACTION pragma: required because CREATE/DROP INDEX CONCURRENTLY --- cannot run inside a transaction. ALTER TABLE ADD COLUMN takes an ACCESS --- EXCLUSIVE lock briefly, but the column has no DEFAULT so PG skips the --- table rewrite. -ALTER TABLE clients ADD COLUMN subscription_token TEXT; -CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS clients_subscription_token_key - ON clients (subscription_token) - WHERE subscription_token IS NOT NULL; - --- +goose Down -DROP INDEX CONCURRENTLY IF EXISTS clients_subscription_token_key; -ALTER TABLE clients DROP COLUMN subscription_token; diff --git a/db/migrations/postgres/0052_integration_providers_config_text.sql b/db/migrations/postgres/0052_integration_providers_config_text.sql deleted file mode 100644 index f601479f..00000000 --- a/db/migrations/postgres/0052_integration_providers_config_text.sql +++ /dev/null @@ -1,87 +0,0 @@ --- +goose Up --- +goose NO TRANSACTION --- bug1/H-6: integration_providers.config is vault-encrypted as a WHOLE --- blob, not per-field. fleet.Service.encryptProviderConfig --- (internal/controlplane/fleet/service.go) seals this column's plaintext --- under the vault's "integration_config" domain whenever a secretvault is --- configured (SetVault) — the production default, since --- config.LoadBootstrap requires PANVEX_ENCRYPTION_KEY. The sealed value is --- a "PVS1:"/"PVS2:"/"PVS3:"-prefixed ciphertext string, not JSON. --- --- On PostgreSQL this column was JSONB (db/migrations/postgres/0001_init.sql), --- and postgres/integrations.go's CreateIntegrationProvider/ --- UpdateIntegrationProvider bound the config parameter with an explicit --- `$N::jsonb` cast. That cast rejects any non-JSON string at write time, --- so creating or updating an integration provider on a PostgreSQL-backed --- install with vault encryption enabled failed with "invalid input syntax --- for type json" (SQLSTATE 22P02) on every write — the write path was --- completely broken whenever the (default-on) vault was active. --- --- SQLite never had this problem: integration_providers.config there is --- plain TEXT, and db/migrations/sqlite/0052_json_valid_checks.sql gave it --- a permissive CHECK — `json_valid(config) OR config LIKE 'PVS_:%'` — that --- accepts either plain JSON or a vault-sealed ciphertext string. This --- migration brings PostgreSQL to the same shape: the column becomes TEXT --- (dropping JSONB's native validation, which is exactly the problem) and --- gains an equivalent CHECK expressed with PostgreSQL's `IS JSON` --- predicate (available since PG 16; this project's CI runs postgres:16- --- alpine and prod runs postgres:18-alpine, so the predicate is safe to --- rely on unconditionally). --- --- fleet_group_integrations.config is intentionally NOT touched: only --- provider configs ever go through encryptProviderConfig/ --- decryptProviderConfig (fleet/service.go's CreateProvider, UpdateProvider, --- GetProvider, ListProviders call sites) — fleet-group-integration configs --- are always plain, never-encrypted JSON, so that column's `::jsonb` casts --- in postgres/integrations.go and its plain JSONB type remain correct. --- --- NO TRANSACTION pragma: matches the convention used by other single-table --- ALTER TABLE migrations in this bundle (e.g. 0051); ALTER COLUMN TYPE here --- takes an ACCESS EXCLUSIVE lock and rewrites the table, but that rewrite --- itself does not require running outside a transaction — NO TRANSACTION --- is kept for consistency with the rest of this migration's straightforward --- single-statement-group shape and to avoid goose wrapping unrelated DDL --- together. -ALTER TABLE integration_providers - ALTER COLUMN config TYPE text USING config::text; - --- ALTER COLUMN TYPE preserves the NOT NULL flag automatically, but the --- DEFAULT survives as a stale `'{}'::jsonb` expression (verified against a --- scratch PostgreSQL 18 table: `\d+` after the TYPE change still shows --- `default '{}'::jsonb` even though the column is now text). PostgreSQL --- happens to still accept inserts against that stale default because it --- implicitly casts jsonb back to text at execution time, but the --- rendered default is misleading (claims a type the column no longer has) --- and would confuse both the schema-sync comparator's diagnostics output --- and any human reading `\d+`. Re-declare it explicitly as a plain text --- default so introspection reports the column truthfully. -ALTER TABLE integration_providers - ALTER COLUMN config SET DEFAULT '{}'; - --- Compensating control matching SQLite's permissive json_valid CHECK (see --- header). The single-char wildcard in 'PVS_:%' covers all three sealed- --- prefix generations (PVS1/PVS2/PVS3) without hardcoding each one, mirroring --- the SQLite side's `LIKE 'PVS_:%'` pattern exactly. -ALTER TABLE integration_providers - ADD CONSTRAINT integration_providers_config_check - CHECK (config LIKE 'PVS_:%' OR config IS JSON); - --- +goose Down --- Reversing this is best-effort: it will fail with a cast error if any row --- currently holds a vault-sealed "PVSn:"-prefixed ciphertext string, since --- that string is not valid JSON and cannot be cast back to jsonb. This --- mirrors how the forward migration itself only became safe to write --- because, at deploy time, no row is yet ciphertext — the Down direction --- carries no such guarantee once the fix has been live and providers have --- been created/updated under vault encryption. Operators rolling back after --- go-live must first re-encrypt or clear sealed rows out-of-band; this is --- an accepted, documented limitation (same posture as the irreversible-ish --- Down blocks elsewhere in this bundle, e.g. 0044's dropped-column Down). -ALTER TABLE integration_providers - DROP CONSTRAINT integration_providers_config_check; - -ALTER TABLE integration_providers - ALTER COLUMN config TYPE jsonb USING config::jsonb; - -ALTER TABLE integration_providers - ALTER COLUMN config SET DEFAULT '{}'; diff --git a/db/migrations/postgres/0053_config_apply_batches.sql b/db/migrations/postgres/0053_config_apply_batches.sql deleted file mode 100644 index 7239f3ac..00000000 --- a/db/migrations/postgres/0053_config_apply_batches.sql +++ /dev/null @@ -1,44 +0,0 @@ --- +goose Up --- config_apply_batches coordinates one group-wide config-apply rollout: an --- operator triggers a config push to every agent in a fleet group, and the --- batch tracks delivery across one or more waves depending on mode --- (all_at_once vs rolling). expected_revision pins the agent_config_targets --- revision this batch is rolling out so a concurrent edit to the group's --- desired config cannot silently change what an in-flight batch delivers. --- --- fleet_group_id is UUID (not TEXT) to match fleet_groups.id's type on --- PostgreSQL (postgres/0014_fleet_groups_redesign.sql) — PostgreSQL refuses --- to create a FOREIGN KEY between mismatched column types. See --- sqlite/0053_config_apply_batches.sql for the TEXT-typed mirror (SQLite --- kept fleet_groups.id as TEXT — see sqlite/0014's header note). -CREATE TABLE IF NOT EXISTS config_apply_batches ( - id TEXT PRIMARY KEY, - fleet_group_id UUID NOT NULL REFERENCES fleet_groups (id) ON DELETE CASCADE, - mode TEXT NOT NULL CHECK (mode IN ('all_at_once', 'rolling')), - wave_size INT NOT NULL DEFAULT 1, - expected_revision TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL CHECK (status IN ('running', 'succeeded', 'failed', 'halted')), - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL -); - -CREATE INDEX IF NOT EXISTS idx_config_apply_batches_status - ON config_apply_batches (status); - --- config_apply_batch_targets is one agent's delivery record within a batch. --- job_id is '' until the target's wave is enqueued. -CREATE TABLE IF NOT EXISTS config_apply_batch_targets ( - batch_id TEXT NOT NULL REFERENCES config_apply_batches (id) ON DELETE CASCADE, - agent_id TEXT NOT NULL, - wave_index INT NOT NULL, - job_id TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL CHECK (status IN ('pending', 'running', 'succeeded', 'failed', 'skipped')), - PRIMARY KEY (batch_id, agent_id) -); - -CREATE INDEX IF NOT EXISTS idx_config_apply_batch_targets_batch_wave - ON config_apply_batch_targets (batch_id, wave_index); - --- +goose Down -DROP TABLE IF EXISTS config_apply_batch_targets; -DROP TABLE IF EXISTS config_apply_batches; diff --git a/db/migrations/postgres/0054_config_apply_batch_target_message.sql b/db/migrations/postgres/0054_config_apply_batch_target_message.sql deleted file mode 100644 index 2bfff448..00000000 --- a/db/migrations/postgres/0054_config_apply_batch_target_message.sql +++ /dev/null @@ -1,16 +0,0 @@ --- +goose Up --- +goose StatementBegin --- Persists a failed (or otherwise terminal) target's message so the --- resumable batch-status view (GET /fleet-groups/{id}/config/apply/batches/{batchId}) --- can surface the failure reason after the underlying config.apply job has --- been evicted from the in-memory jobs store. Previously the message only --- lived on the live jobs.Job target (ResultText), which is lost once the --- job rolls off — defeating the point of a persistent, resumable batch. -ALTER TABLE config_apply_batch_targets - ADD COLUMN message TEXT NOT NULL DEFAULT ''; --- +goose StatementEnd - --- +goose Down --- +goose StatementBegin -ALTER TABLE config_apply_batch_targets DROP COLUMN IF EXISTS message; --- +goose StatementEnd diff --git a/db/migrations/postgres/0055_runtime_current_json.sql b/db/migrations/postgres/0055_runtime_current_json.sql deleted file mode 100644 index 4ab66f87..00000000 --- a/db/migrations/postgres/0055_runtime_current_json.sql +++ /dev/null @@ -1,13 +0,0 @@ --- +goose Up --- P3-3.1 (аудит #3): см. sqlite/0055_runtime_current_json.sql. Колонки и --- типы зеркалят SQLite по конвенции *_at_unix ↔ TIMESTAMPTZ --- (schema_sync_test нормализует суффиксы _unix/_at). -DROP TABLE telemt_runtime_current; -CREATE TABLE telemt_runtime_current ( - agent_id TEXT PRIMARY KEY REFERENCES agents (id) ON DELETE CASCADE, - observed_at TIMESTAMPTZ NOT NULL, - runtime_json TEXT NOT NULL DEFAULT '' -); - --- +goose Down -SELECT 1; diff --git a/db/migrations/postgres/0056_config_apply_batch_group_nullable.sql b/db/migrations/postgres/0056_config_apply_batch_group_nullable.sql deleted file mode 100644 index 71e3e969..00000000 --- a/db/migrations/postgres/0056_config_apply_batch_group_nullable.sql +++ /dev/null @@ -1,8 +0,0 @@ --- +goose Up --- P3-3.4 (аудит #25a): одиночный config-apply становится batch-of-one без --- fleet-group-скоупа. NULL fleet_group_id = agent-scoped батч; групповой --- active-lookup (WHERE fleet_group_id = $1) такие батчи не видит. -ALTER TABLE config_apply_batches ALTER COLUMN fleet_group_id DROP NOT NULL; - --- +goose Down -SELECT 1; diff --git a/db/migrations/postgres/0057_client_usage_watermark.sql b/db/migrations/postgres/0057_client_usage_watermark.sql deleted file mode 100644 index 23fb3c27..00000000 --- a/db/migrations/postgres/0057_client_usage_watermark.sql +++ /dev/null @@ -1,9 +0,0 @@ --- +goose Up --- P4: см. sqlite/0057_client_usage_watermark.sql (watermark кумулятивного --- счётчика агента; additive — last_seq уходит в 0058). -ALTER TABLE client_usage ADD COLUMN IF NOT EXISTS agent_boot_id TEXT NOT NULL DEFAULT ''; -ALTER TABLE client_usage ADD COLUMN IF NOT EXISTS last_total_bytes BIGINT NOT NULL DEFAULT 0; - --- +goose Down -ALTER TABLE client_usage DROP COLUMN IF EXISTS last_total_bytes; -ALTER TABLE client_usage DROP COLUMN IF EXISTS agent_boot_id; diff --git a/db/migrations/postgres/0058_client_usage_drop_last_seq.sql b/db/migrations/postgres/0058_client_usage_drop_last_seq.sql deleted file mode 100644 index 98bb443a..00000000 --- a/db/migrations/postgres/0058_client_usage_drop_last_seq.sql +++ /dev/null @@ -1,6 +0,0 @@ --- +goose Up --- P4: см. sqlite/0058_client_usage_drop_last_seq.sql. -ALTER TABLE client_usage DROP COLUMN IF EXISTS last_seq; - --- +goose Down -ALTER TABLE client_usage ADD COLUMN IF NOT EXISTS last_seq BIGINT NOT NULL DEFAULT 0; diff --git a/internal/controlplane/storage/postgres/migrate_test.go b/internal/controlplane/storage/postgres/migrate_test.go index 3838a56c..ca1f4c9a 100644 --- a/internal/controlplane/storage/postgres/migrate_test.go +++ b/internal/controlplane/storage/postgres/migrate_test.go @@ -44,12 +44,15 @@ func TestMigrateGoosePostgres(t *testing.T) { if err != nil || !exists { t.Fatalf("goose_db_version table missing: err=%v exists=%v", err, exists) } + // Exactly one embedded migration after the P9 squash: 0001_init.sql. + // Pre-squash DBs carry versions 1..58, but this test migrates a fresh + // schema, so the ledger must contain exactly version 1. var count int if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM goose_db_version WHERE is_applied = TRUE AND version_id > 0`).Scan(&count); err != nil { t.Fatalf("count applied versions: %v", err) } - if count < 7 { - t.Fatalf("expected >= 7 applied goose versions, got %d", count) + if count != 1 { + t.Fatalf("expected exactly 1 applied goose version (0001_init), got %d", count) } }) From 0c0661d70ae11b998ede7114b8386f1afdcd984a Mon Sep 17 00:00:00 2001 From: Mirotin Artem Date: Tue, 7 Jul 2026 13:25:32 +0300 Subject: [PATCH 4/7] storage/migrate: lint migration-tree parity and post-squash numbering --- .../migrate/migration_parity_lint_test.go | 196 ++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 internal/controlplane/storage/migrate/migration_parity_lint_test.go diff --git a/internal/controlplane/storage/migrate/migration_parity_lint_test.go b/internal/controlplane/storage/migrate/migration_parity_lint_test.go new file mode 100644 index 00000000..336a4e1c --- /dev/null +++ b/internal/controlplane/storage/migrate/migration_parity_lint_test.go @@ -0,0 +1,196 @@ +package migrate_test + +import ( + "fmt" + "io/fs" + "regexp" + "sort" + "strings" + "testing" + "testing/fstest" + + pgmigrations "github.com/lost-coder/panvex/db/migrations/postgres" + sqlitemigrations "github.com/lost-coder/panvex/db/migrations/sqlite" +) + +// squashedHistoryCeiling — последняя goose-версия, вошедшая в P9-squash +// (0001_init.sql консолидирует 0001..0058). БД, созданные ДО squash, несут +// в goose_db_version версии 1..58, а goose применяет только версии выше +// текущего максимума: новый файл с номером из диапазона 2..58 на такой БД +// молча НЕ применился бы. Поэтому новые миграции обязаны нумероваться +// строго выше ceiling — этот линт делает ошибку нумерации падением CI, а +// не продовым сюрпризом. +const squashedHistoryCeiling = 58 + +// dialectOnlyMarker помечает миграцию, осознанно существующую только в +// одном дереве (пример до squash: json_valid-CHECKи нужны только SQLite, +// JSONB валидирует сам). Номер при этом РЕЗЕРВИРУЕТСЯ в обоих деревьях — +// второе дерево не может занять его другой миграцией. +const dialectOnlyMarker = "-- dialect-only:" + +var migrationNameRE = regexp.MustCompile(`^(\d{4})_(.+)\.sql$`) + +type migrationFile struct { + version int + title string // имя файла без NNNN_ и .sql + body string +} + +// TestMigrationTreesAreParityLocked (закрывает класс дрейфа C9/H6 и +// «0052-коллизию»: до squash оба дерева держали РАЗНЫЕ миграции под одним +// номером). Правила: +// 1. 0001_init.sql существует в обоих деревьях; +// 2. номера 2..squashedHistoryCeiling запрещены (см. константу); +// 3. одинаковый номер в обоих деревьях => одинаковое название файла; +// 4. номер только в одном дереве => файл обязан содержать строку +// `-- dialect-only: <причина>`. +func TestMigrationTreesAreParityLocked(t *testing.T) { + sqliteTree := readMigrationTree(t, sqlitemigrations.FS, "sqlite") + pgTree := readMigrationTree(t, pgmigrations.FS, "postgres") + for _, problem := range checkMigrationParity(sqliteTree, pgTree) { + t.Error(problem) + } +} + +func readMigrationTree(t *testing.T, fsys fs.FS, label string) map[int]migrationFile { + t.Helper() + tree := map[int]migrationFile{} + entries, err := fs.ReadDir(fsys, ".") + if err != nil { + t.Fatalf("%s: read dir: %v", label, err) + } + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") { + continue + } + m := migrationNameRE.FindStringSubmatch(e.Name()) + if m == nil { + t.Errorf("%s: %s does not match NNNN_title.sql", label, e.Name()) + continue + } + var version int + fmt.Sscanf(m[1], "%d", &version) + if prev, dup := tree[version]; dup { + t.Errorf("%s: duplicate version %04d (%s vs %s)", label, version, prev.title, m[2]) + continue + } + raw, err := fs.ReadFile(fsys, e.Name()) + if err != nil { + t.Fatalf("%s: read %s: %v", label, e.Name(), err) + } + tree[version] = migrationFile{version: version, title: m[2], body: string(raw)} + } + return tree +} + +func checkMigrationParity(sqliteTree, pgTree map[int]migrationFile) []string { + var problems []string + if _, ok := sqliteTree[1]; !ok { + problems = append(problems, "sqlite: 0001_init.sql is missing") + } + if _, ok := pgTree[1]; !ok { + problems = append(problems, "postgres: 0001_init.sql is missing") + } + + versions := map[int]bool{} + for v := range sqliteTree { + versions[v] = true + } + for v := range pgTree { + versions[v] = true + } + sorted := make([]int, 0, len(versions)) + for v := range versions { + sorted = append(sorted, v) + } + sort.Ints(sorted) + + for _, v := range sorted { + if v != 1 && v <= squashedHistoryCeiling { + problems = append(problems, fmt.Sprintf( + "version %04d is inside the squashed range 2..%d: pre-squash DBs already record it as applied and goose would silently skip the file; renumber to >= %04d", + v, squashedHistoryCeiling, squashedHistoryCeiling+1)) + continue + } + s, inSQLite := sqliteTree[v] + p, inPG := pgTree[v] + switch { + case inSQLite && inPG: + if s.title != p.title { + problems = append(problems, fmt.Sprintf( + "version %04d means different things per dialect: sqlite=%q postgres=%q — same number MUST be the same logical change; give each change its own number", + v, s.title, p.title)) + } + case inSQLite: + if !strings.Contains(s.body, dialectOnlyMarker) { + problems = append(problems, fmt.Sprintf( + "sqlite %04d_%s.sql has no postgres twin and no %q marker: add the twin or mark it dialect-only with a reason", + v, s.title, dialectOnlyMarker)) + } + case inPG: + if !strings.Contains(p.body, dialectOnlyMarker) { + problems = append(problems, fmt.Sprintf( + "postgres %04d_%s.sql has no sqlite twin and no %q marker: add the twin or mark it dialect-only with a reason", + v, p.title, dialectOnlyMarker)) + } + } + } + return problems +} + +// --- юнит-тесты правил на синтетических деревьях ----------------------- + +func mapTree(t *testing.T, files map[string]string) map[int]migrationFile { + t.Helper() + fsys := fstest.MapFS{} + for name, body := range files { + fsys[name] = &fstest.MapFile{Data: []byte(body)} + } + return readMigrationTree(t, fsys, "synthetic") +} + +func TestParityRuleSquashedRangeForbidden(t *testing.T) { + s := mapTree(t, map[string]string{"0001_init.sql": "x", "0042_oops.sql": "x"}) + p := mapTree(t, map[string]string{"0001_init.sql": "x", "0042_oops.sql": "x"}) + problems := checkMigrationParity(s, p) + if len(problems) != 1 || !strings.Contains(problems[0], "squashed range") { + t.Fatalf("expected exactly one squashed-range problem, got %v", problems) + } +} + +func TestParityRuleTitleMismatch(t *testing.T) { + s := mapTree(t, map[string]string{"0001_init.sql": "x", "0059_add_foo.sql": "x"}) + p := mapTree(t, map[string]string{"0001_init.sql": "x", "0059_add_bar.sql": "x"}) + problems := checkMigrationParity(s, p) + if len(problems) != 1 || !strings.Contains(problems[0], "different things per dialect") { + t.Fatalf("expected exactly one title-mismatch problem, got %v", problems) + } +} + +func TestParityRuleMissingTwinNeedsMarker(t *testing.T) { + s := mapTree(t, map[string]string{"0001_init.sql": "x", "0059_sqlite_only.sql": "no marker here"}) + p := mapTree(t, map[string]string{"0001_init.sql": "x"}) + problems := checkMigrationParity(s, p) + if len(problems) != 1 || !strings.Contains(problems[0], "dialect-only") { + t.Fatalf("expected exactly one missing-twin problem, got %v", problems) + } +} + +func TestParityRuleMarkerSatisfiesMissingTwin(t *testing.T) { + s := mapTree(t, map[string]string{ + "0001_init.sql": "x", + "0059_json_checks.sql": "-- dialect-only: JSONB validates on PG, SQLite needs json_valid CHECKs\nSELECT 1;", + }) + p := mapTree(t, map[string]string{"0001_init.sql": "x"}) + if problems := checkMigrationParity(s, p); len(problems) != 0 { + t.Fatalf("marker must satisfy the rule, got %v", problems) + } +} + +func TestParityRuleCleanTreesPass(t *testing.T) { + s := mapTree(t, map[string]string{"0001_init.sql": "x", "0059_add_foo.sql": "x"}) + p := mapTree(t, map[string]string{"0001_init.sql": "x", "0059_add_foo.sql": "x"}) + if problems := checkMigrationParity(s, p); len(problems) != 0 { + t.Fatalf("clean trees must pass, got %v", problems) + } +} From ad08c5cdf4a3d1ca896005a899ad8995188ffe5c Mon Sep 17 00:00:00 2001 From: Mirotin Artem Date: Tue, 7 Jul 2026 13:27:34 +0300 Subject: [PATCH 5/7] cmd/sqlite-rebuild: generate crash-safe SQLite table-rebuild migrations --- cmd/sqlite-rebuild/main.go | 77 ++++++++++++++ cmd/sqlite-rebuild/script.go | 107 ++++++++++++++++++++ cmd/sqlite-rebuild/script_test.go | 160 ++++++++++++++++++++++++++++++ 3 files changed, 344 insertions(+) create mode 100644 cmd/sqlite-rebuild/main.go create mode 100644 cmd/sqlite-rebuild/script.go create mode 100644 cmd/sqlite-rebuild/script_test.go diff --git a/cmd/sqlite-rebuild/main.go b/cmd/sqlite-rebuild/main.go new file mode 100644 index 00000000..b4a6e30a --- /dev/null +++ b/cmd/sqlite-rebuild/main.go @@ -0,0 +1,77 @@ +// Command sqlite-rebuild печатает готовый goose-файл пересборки SQLite-таблицы +// (create/copy/drop/rename/index в crash-safe транзакционных рамках). +// +// Использование (одна таблица за вызов; для нескольких таблиц в одной +// миграции — объединить блоки между PRAGMA-строками вручную): +// +// go run ./cmd/sqlite-rebuild \ +// -table jobs \ +// -create new_jobs.sql \ +// -columns id,action,payload_json \ +// -index 'CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs (status);' \ +// > db/migrations/sqlite/0059_jobs_add_check.sql +// +// Флаг -copy file.sql заменяет дефолтный INSERT..SELECT кастомным (backfill). +package main + +import ( + "flag" + "fmt" + "os" + "strings" +) + +type stringSlice []string + +func (s *stringSlice) String() string { return strings.Join(*s, "; ") } +func (s *stringSlice) Set(v string) error { + *s = append(*s, v) + return nil +} + +func main() { + table := flag.String("table", "", "имя пересобираемой таблицы (обязателен)") + createFile := flag.String("create", "", "файл с CREATE TABLE _new (...) (обязателен)") + columns := flag.String("columns", "", "общие колонки через запятую (обязателен без -copy)") + copyFile := flag.String("copy", "", "файл с кастомным INSERT..SELECT (вместо -columns)") + var indexes stringSlice + flag.Var(&indexes, "index", "CREATE INDEX statement (повторяемый флаг)") + flag.Parse() + + if err := run(*table, *createFile, *columns, *copyFile, indexes); err != nil { + fmt.Fprintln(os.Stderr, "sqlite-rebuild:", err) + os.Exit(1) + } +} + +func run(table, createFile, columns, copyFile string, indexes []string) error { + if table == "" || createFile == "" { + return fmt.Errorf("-table and -create are required") + } + createSQL, err := os.ReadFile(createFile) + if err != nil { + return err + } + spec := Spec{Table: table, CreateSQL: string(createSQL), Indexes: indexes} + switch { + case copyFile != "": + copySQL, err := os.ReadFile(copyFile) + if err != nil { + return err + } + spec.CopySQL = string(copySQL) + case columns != "": + for _, c := range strings.Split(columns, ",") { + spec.Columns = append(spec.Columns, strings.TrimSpace(c)) + } + default: + return fmt.Errorf("either -columns or -copy is required") + } + + script, err := Script([]Spec{spec}) + if err != nil { + return err + } + _, err = os.Stdout.WriteString(script) + return err +} diff --git a/cmd/sqlite-rebuild/script.go b/cmd/sqlite-rebuild/script.go new file mode 100644 index 00000000..d90ffeb3 --- /dev/null +++ b/cmd/sqlite-rebuild/script.go @@ -0,0 +1,107 @@ +package main + +import ( + "errors" + "fmt" + "strings" +) + +// Spec описывает пересборку одной таблицы по рецепту +// create/copy/drop/rename/index. +type Spec struct { + // Table — имя пересобираемой таблицы (например "jobs"). + Table string + // CreateSQL — полный CREATE TABLE
_new (...) с новой схемой. + CreateSQL string + // Columns — общие колонки для дефолтного копирования + // INSERT INTO
_new (cols) SELECT cols FROM
. + // Игнорируется, если задан CopySQL. + Columns []string + // CopySQL — полный кастомный INSERT ... SELECT ... (с backfill'ом, + // CASE-преобразованиями и т.п.), когда прямого копирования мало. + CopySQL string + // Indexes — CREATE INDEX statements, воссоздаваемые после RENAME + // (DROP TABLE уносит индексы старой таблицы вместе с ней). + Indexes []string +} + +const scriptHeader = `-- +goose Up +-- +goose NO TRANSACTION +-- Сгенерировано cmd/sqlite-rebuild. Рецепт пересборки таблицы (SQLite не +-- умеет ALTER TABLE ADD/DROP CONSTRAINT): create/copy/drop/rename/index. +-- Каждая пара DROP/RENAME — в собственном явном BEGIN/COMMIT, чтобы крэш +-- между ними не оставил таблицу удалённой-но-не-переименованной (guard: +-- migrate.TestSQLiteTableRebuildsAreTransactionWrapped). PRAGMA +-- foreign_keys переключается ВНЕ транзакций — SQLite запрещает менять его +-- внутри, поэтому весь файл идёт под NO TRANSACTION. + +PRAGMA foreign_keys = OFF; +` + +const scriptFooter = ` +PRAGMA foreign_keys = ON; + +-- +goose Down +-- Обратная пересборка не автоматизируется: напиши обратный rebuild вручную +-- или оставь no-op, если даунгрейд не поддерживается. +SELECT 1; +` + +// Script собирает готовый goose-файл из одной или нескольких пересборок. +func Script(specs []Spec) (string, error) { + if len(specs) == 0 { + return "", errors.New("at least one Spec is required") + } + var b strings.Builder + b.WriteString(scriptHeader) + for _, s := range specs { + block, err := rebuildBlock(s) + if err != nil { + return "", err + } + b.WriteString(block) + } + b.WriteString(scriptFooter) + return b.String(), nil +} + +func rebuildBlock(s Spec) (string, error) { + if strings.TrimSpace(s.Table) == "" { + return "", errors.New("Spec.Table is required") + } + newName := s.Table + "_new" + if !strings.Contains(s.CreateSQL, newName) { + return "", fmt.Errorf("Spec.CreateSQL for %q must create %q (got: %.60s...)", s.Table, newName, s.CreateSQL) + } + copyStmt := strings.TrimSpace(s.CopySQL) + if copyStmt == "" { + if len(s.Columns) == 0 { + return "", fmt.Errorf("Spec for %q needs Columns or CopySQL", s.Table) + } + cols := strings.Join(s.Columns, ", ") + copyStmt = fmt.Sprintf("INSERT INTO %s (%s)\nSELECT %s FROM %s;", newName, cols, cols, s.Table) + } + + var b strings.Builder + fmt.Fprintf(&b, "\n-- ─── %s ───\nBEGIN;\n\n", s.Table) + b.WriteString(ensureSemicolon(s.CreateSQL)) + b.WriteString("\n\n") + b.WriteString(ensureSemicolon(copyStmt)) + b.WriteString("\n\n") + fmt.Fprintf(&b, "DROP TABLE %s;\n", s.Table) + fmt.Fprintf(&b, "ALTER TABLE %s RENAME TO %s;\n", newName, s.Table) + if len(s.Indexes) > 0 { + b.WriteString("\n") + for _, idx := range s.Indexes { + b.WriteString(ensureSemicolon(idx)) + b.WriteString("\n") + } + } + b.WriteString("\nCOMMIT;\n") + return b.String(), nil +} + +func ensureSemicolon(stmt string) string { + trimmed := strings.TrimRight(strings.TrimSpace(stmt), ";") + return trimmed + ";" +} diff --git a/cmd/sqlite-rebuild/script_test.go b/cmd/sqlite-rebuild/script_test.go new file mode 100644 index 00000000..c8b023bf --- /dev/null +++ b/cmd/sqlite-rebuild/script_test.go @@ -0,0 +1,160 @@ +package main + +import ( + "context" + "database/sql" + "path/filepath" + "strings" + "testing" + + // register the pure-Go SQLite driver under "sqlite" for database/sql + _ "modernc.org/sqlite" +) + +func TestScriptGolden(t *testing.T) { + got, err := Script([]Spec{{ + Table: "jobs", + CreateSQL: `CREATE TABLE jobs_new ( + id TEXT PRIMARY KEY, + payload_json TEXT NOT NULL DEFAULT '' + CHECK (payload_json = '' OR json_valid(payload_json)) +);`, + Columns: []string{"id", "payload_json"}, + Indexes: []string{"CREATE INDEX IF NOT EXISTS idx_jobs_payload ON jobs (payload_json);"}, + }}) + if err != nil { + t.Fatalf("Script: %v", err) + } + + want := `-- +goose Up +-- +goose NO TRANSACTION +-- Сгенерировано cmd/sqlite-rebuild. Рецепт пересборки таблицы (SQLite не +-- умеет ALTER TABLE ADD/DROP CONSTRAINT): create/copy/drop/rename/index. +-- Каждая пара DROP/RENAME — в собственном явном BEGIN/COMMIT, чтобы крэш +-- между ними не оставил таблицу удалённой-но-не-переименованной (guard: +-- migrate.TestSQLiteTableRebuildsAreTransactionWrapped). PRAGMA +-- foreign_keys переключается ВНЕ транзакций — SQLite запрещает менять его +-- внутри, поэтому весь файл идёт под NO TRANSACTION. + +PRAGMA foreign_keys = OFF; + +-- ─── jobs ─── +BEGIN; + +CREATE TABLE jobs_new ( + id TEXT PRIMARY KEY, + payload_json TEXT NOT NULL DEFAULT '' + CHECK (payload_json = '' OR json_valid(payload_json)) +); + +INSERT INTO jobs_new (id, payload_json) +SELECT id, payload_json FROM jobs; + +DROP TABLE jobs; +ALTER TABLE jobs_new RENAME TO jobs; + +CREATE INDEX IF NOT EXISTS idx_jobs_payload ON jobs (payload_json); + +COMMIT; + +PRAGMA foreign_keys = ON; + +-- +goose Down +-- Обратная пересборка не автоматизируется: напиши обратный rebuild вручную +-- или оставь no-op, если даунгрейд не поддерживается. +SELECT 1; +` + if got != want { + t.Fatalf("golden mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, want) + } +} + +func TestScriptValidation(t *testing.T) { + if _, err := Script([]Spec{{Table: "jobs", CreateSQL: "CREATE TABLE wrong_name (id TEXT);", Columns: []string{"id"}}}); err == nil { + t.Fatal("CreateSQL without
_new must be rejected") + } + if _, err := Script([]Spec{{Table: "jobs", CreateSQL: "CREATE TABLE jobs_new (id TEXT);"}}); err == nil { + t.Fatal("Spec without Columns and without CopySQL must be rejected") + } + if _, err := Script(nil); err == nil { + t.Fatal("empty spec list must be rejected") + } +} + +// TestScriptFunctional applies a generated script to a live DB with FK +// references, rows and an index, and proves the rebuild is lossless. +func TestScriptFunctional(t *testing.T) { + ctx := context.Background() + db, err := sql.Open("sqlite", "file:"+filepath.Join(t.TempDir(), "rebuild.db")) + if err != nil { + t.Fatalf("open: %v", err) + } + defer db.Close() + db.SetMaxOpenConns(1) + + setup := []string{ + `PRAGMA foreign_keys = ON;`, + `CREATE TABLE parents (id TEXT PRIMARY KEY);`, + `CREATE TABLE children ( + id TEXT PRIMARY KEY, + parent_id TEXT NOT NULL REFERENCES parents (id) ON DELETE CASCADE, + note TEXT NOT NULL DEFAULT '' + );`, + `CREATE INDEX idx_children_parent ON children (parent_id);`, + `INSERT INTO parents (id) VALUES ('p1');`, + `INSERT INTO children (id, parent_id, note) VALUES ('c1', 'p1', 'keep'), ('c2', 'p1', 'also');`, + } + for _, stmt := range setup { + if _, err := db.ExecContext(ctx, stmt); err != nil { + t.Fatalf("setup %q: %v", stmt, err) + } + } + + script, err := Script([]Spec{{ + Table: "children", + CreateSQL: `CREATE TABLE children_new ( + id TEXT PRIMARY KEY, + parent_id TEXT NOT NULL REFERENCES parents (id) ON DELETE CASCADE, + note TEXT NOT NULL DEFAULT '' CHECK (length(note) <= 64) +);`, + Columns: []string{"id", "parent_id", "note"}, + Indexes: []string{"CREATE INDEX IF NOT EXISTS idx_children_parent ON children (parent_id);"}, + }}) + if err != nil { + t.Fatalf("Script: %v", err) + } + // Отрезаем goose-аннотации: вне goose это обычный многостейтментный SQL. + // Якорь — сам statement "PRAGMA foreign_keys = OFF", а не слово "PRAGMA" + // из шапки-комментария, которое встречается раньше. + body := script[strings.Index(script, "PRAGMA foreign_keys = OFF"):strings.Index(script, "-- +goose Down")] + if _, err := db.ExecContext(ctx, body); err != nil { + t.Fatalf("apply generated script: %v", err) + } + + var n int + if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM children`).Scan(&n); err != nil || n != 2 { + t.Fatalf("rows after rebuild: n=%d err=%v", n, err) + } + var ddl string + if err := db.QueryRowContext(ctx, `SELECT sql FROM sqlite_master WHERE type='table' AND name='children'`).Scan(&ddl); err != nil { + t.Fatalf("read rebuilt DDL: %v", err) + } + if !strings.Contains(ddl, "length(note) <= 64") { + t.Fatalf("rebuilt table lost the new CHECK: %s", ddl) + } + var idx string + if err := db.QueryRowContext(ctx, `SELECT name FROM sqlite_master WHERE type='index' AND name='idx_children_parent'`).Scan(&idx); err != nil { + t.Fatalf("index not recreated: %v", err) + } + rows, err := db.QueryContext(ctx, `PRAGMA foreign_key_check`) + if err != nil { + t.Fatalf("foreign_key_check: %v", err) + } + defer rows.Close() + if rows.Next() { + t.Fatal("foreign_key_check reported violations after rebuild") + } + if err := rows.Err(); err != nil { + t.Fatalf("foreign_key_check rows: %v", err) + } +} From deb566618a57dbde7a30ef84dc31eeec31293761 Mon Sep 17 00:00:00 2001 From: Mirotin Artem Date: Tue, 7 Jul 2026 13:33:13 +0300 Subject: [PATCH 6/7] db/migrations: document the squash, numbering and rebuild rules --- CLAUDE.md | 3 +- cmd/control-plane/backup.go | 5 +++ db/migrations/README.md | 44 +++++++++++++++++++ db/queries/fleet_groups.sql | 2 +- .../storage/migrate/schema_sync_test.go | 6 +-- .../storage/storagetest/store_contract.go | 2 +- .../store_contract_json_validation.go | 2 +- internal/dbsqlc/fleet_groups.sql.go | 2 +- 8 files changed, 58 insertions(+), 8 deletions(-) create mode 100644 db/migrations/README.md diff --git a/CLAUDE.md b/CLAUDE.md index 21cee194..daad3e41 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,7 +59,8 @@ internal/ gatewayrpc/ shared gRPC transport contract (control-plane <-> agent) dbsqlc/ sqlc-generated DB layer — DO NOT edit manually db/ - migrations/ PostgreSQL schema + migrations/ goose-миграции, два дерева: postgres/ (источник для + sqlc) + sqlite/ — правила в db/migrations/README.md queries/ SQL source for sqlc proto/ Protobuf definitions for gRPC gateway web/ React dashboard (web/src/ui/ is the in-tree UI kit) diff --git a/cmd/control-plane/backup.go b/cmd/control-plane/backup.go index c933965f..510b808f 100644 --- a/cmd/control-plane/backup.go +++ b/cmd/control-plane/backup.go @@ -369,6 +369,11 @@ func verifyRestoreArchive(ctx context.Context, archivePath, storageDriver, stora if err != nil { return err } + // NB: после P9-squash свежие БД несут goose-версию 1, а архивы, + // снятые до squash, — версии <= 58: для такой пары сравнение + // "ahead/older" инвертируется, хотя схемы бит-эквивалентны. + // Pre-prod: принято, восстановление до-squash архивов не + // поддерживается (пересоздать стенд). switch { case meta.SchemaVersion > targetVersion: fmt.Printf(" schema: archive (v%d) is AHEAD of target (v%d) — run migrate-schema after restoring\n", meta.SchemaVersion, targetVersion) diff --git a/db/migrations/README.md b/db/migrations/README.md new file mode 100644 index 00000000..ae62fdab --- /dev/null +++ b/db/migrations/README.md @@ -0,0 +1,44 @@ +# Миграции схемы + +Два дерева goose-миграций — по одному на диалект: + +- `postgres/` — источник схемы для sqlc (`sqlc.yaml` читает эту директорию); +- `sqlite/` — зеркальное дерево для SQLite-бэкенда. + +## Squash 2026-07 (P9) + +Миграции 0001..0058 обоих деревьев консолидированы в `0001_init.sql` +(SQLite — дампом sqlite_master, PostgreSQL — `pg_dump --schema-only`; +эквивалентность доказана schema_sync-тестом и нулевым sqlc-диффом). +БД, созданные до squash, несут goose-версии 1..58 и пропускают 0001. + +## Правила (проверяются `storage/migrate/migration_parity_lint_test.go`) + +1. **Номер новой миграции >= 0059** (`squashedHistoryCeiling+1`). Номера + 2..58 запрещены: на до-squash БД goose молча пропустил бы такой файл. +2. **Один номер = одно логическое изменение в обоих деревьях**, файлы + называются одинаково: `NNNN_the_change.sql` и там и там. +3. Миграция, нужная только одному диалекту, содержит строку + `-- dialect-only: <причина>`; её номер зарезервирован в обоих деревьях. +4. PostgreSQL: `CREATE INDEX` на существующей таблице — только + `CONCURRENTLY` + `-- +goose NO TRANSACTION` + (`TestPostgresIndexesUseConcurrently`). +5. SQLite: пересборка таблицы (ADD CONSTRAINT и прочее, чего SQLite не + умеет через ALTER) — генерируй файл инструментом: + + ```bash + go run ./cmd/sqlite-rebuild -table jobs -create new_jobs.sql \ + -columns id,action,payload_json \ + -index 'CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs (status);' \ + > db/migrations/sqlite/0059_jobs_add_check.sql + ``` + + Рецепт (BEGIN до DROP, COMMIT после RENAME, PRAGMA вне транзакций) + стережёт `TestSQLiteTableRebuildsAreTransactionWrapped`. +6. После любой правки `postgres/` — `sqlc generate` (+ коммит диффа + `internal/dbsqlc/`); после любой правки схемы — зелёный + `TestSchemaSyncPostgresMatchesSQLite` (нужен `PANVEX_POSTGRES_TEST_DSN`, + в CI поднят сервис-контейнер). +7. Деструктивная миграция (DROP/TRUNCATE/DELETE по живым таблицам) — + регистрируется в `storage/migrateguard.DestructiveMigrations` со своим + goose-номером. diff --git a/db/queries/fleet_groups.sql b/db/queries/fleet_groups.sql index 90b9340a..8dec0a20 100644 --- a/db/queries/fleet_groups.sql +++ b/db/queries/fleet_groups.sql @@ -1,6 +1,6 @@ -- R-Q-03: fleet_groups — operator-managed grouping of agents + -- enrollment-tokens + client-assignments. Note: id is UUID on --- postgres (since migration 0014). +-- postgres (UUID id column). -- name: GetFleetGroup :one SELECT id, name, label, description, created_at, updated_at diff --git a/internal/controlplane/storage/migrate/schema_sync_test.go b/internal/controlplane/storage/migrate/schema_sync_test.go index 74af6f85..7f1e7a29 100644 --- a/internal/controlplane/storage/migrate/schema_sync_test.go +++ b/internal/controlplane/storage/migrate/schema_sync_test.go @@ -206,9 +206,9 @@ var acceptedTypePairs = map[[2]string]bool{ // 0041_enrollment_attempts.sql — none of those were touched by 0052). typePairKey("jsonb", "text"): true, // SQLite has no UUID type; fleet_groups.id and dependent FK columns - // are UUID on PostgreSQL (postgres/0014_fleet_groups_redesign.sql) - // but TEXT on SQLite, with UUID format enforced at the application - // layer (see the 0014 sqlite migration header note). + // are UUID on PostgreSQL (fleet_groups redesign, squashed into + // 0001_init.sql) but TEXT on SQLite, with UUID format enforced at the + // application layer. typePairKey("uuid", "text"): true, // agents.bootstrap_token_hash is BYTEA on PostgreSQL // (postgres/0030_node_transport_mode.sql) and BLOB on SQLite diff --git a/internal/controlplane/storage/storagetest/store_contract.go b/internal/controlplane/storage/storagetest/store_contract.go index 1748be5a..650d6d86 100644 --- a/internal/controlplane/storage/storagetest/store_contract.go +++ b/internal/controlplane/storage/storagetest/store_contract.go @@ -15,7 +15,7 @@ type OpenStore func(t *testing.T) storage.MigrationStore // testFleetGroupID is a deterministic UUIDv4 used as the fleet-group // primary key inside contract-test fixtures. Postgres stores ids in a -// UUID column since migration 0014, so every PutFleetGroup call must +// UUID column on PostgreSQL (TEXT on SQLite), so every PutFleetGroup call must // pass a real UUID. We pick a fixed value so assertions that mention // the id stay readable. const testFleetGroupID = "00000000-0000-4000-a000-000000000001" diff --git a/internal/controlplane/storage/storagetest/store_contract_json_validation.go b/internal/controlplane/storage/storagetest/store_contract_json_validation.go index 97fd26d2..3902c119 100644 --- a/internal/controlplane/storage/storagetest/store_contract_json_validation.go +++ b/internal/controlplane/storage/storagetest/store_contract_json_validation.go @@ -62,7 +62,7 @@ func RunJSONValidationContract(t *testing.T, open OpenStore) { ctx := context.Background() err := store.CreateIntegrationProvider(ctx, storage.IntegrationProviderRecord{ - // PostgreSQL's id column is UUID (migration 0014) — SQLite + // PostgreSQL's id column is UUID — SQLite // keeps id as TEXT, but the shared contract must use a value // valid on both backends. ID: "00000000-0000-4000-a000-000000000101", diff --git a/internal/dbsqlc/fleet_groups.sql.go b/internal/dbsqlc/fleet_groups.sql.go index 7f103ce9..7b241e14 100644 --- a/internal/dbsqlc/fleet_groups.sql.go +++ b/internal/dbsqlc/fleet_groups.sql.go @@ -68,7 +68,7 @@ type GetFleetGroupRow struct { // R-Q-03: fleet_groups — operator-managed grouping of agents + // enrollment-tokens + client-assignments. Note: id is UUID on -// postgres (since migration 0014). +// postgres (UUID id column). func (q *Queries) GetFleetGroup(ctx context.Context, id uuid.UUID) (GetFleetGroupRow, error) { row := q.db.QueryRowContext(ctx, getFleetGroup, id) var i GetFleetGroupRow From 8ec0f84b812e1f4ca31edc725319811db7751240 Mon Sep 17 00:00:00 2001 From: Mirotin Artem Date: Tue, 7 Jul 2026 13:35:58 +0300 Subject: [PATCH 7/7] storage/migrate,cmd/sqlite-rebuild: satisfy golangci-lint (errcheck, gosec G304) --- cmd/sqlite-rebuild/main.go | 4 ++-- .../storage/migrate/migration_parity_lint_test.go | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/cmd/sqlite-rebuild/main.go b/cmd/sqlite-rebuild/main.go index b4a6e30a..95cd43d8 100644 --- a/cmd/sqlite-rebuild/main.go +++ b/cmd/sqlite-rebuild/main.go @@ -48,14 +48,14 @@ func run(table, createFile, columns, copyFile string, indexes []string) error { if table == "" || createFile == "" { return fmt.Errorf("-table and -create are required") } - createSQL, err := os.ReadFile(createFile) + createSQL, err := os.ReadFile(createFile) //nolint:gosec // reason: local dev tool; the operator supplies the CREATE-TABLE file path deliberately. if err != nil { return err } spec := Spec{Table: table, CreateSQL: string(createSQL), Indexes: indexes} switch { case copyFile != "": - copySQL, err := os.ReadFile(copyFile) + copySQL, err := os.ReadFile(copyFile) //nolint:gosec // reason: local dev tool; the operator supplies the custom-copy file path deliberately. if err != nil { return err } diff --git a/internal/controlplane/storage/migrate/migration_parity_lint_test.go b/internal/controlplane/storage/migrate/migration_parity_lint_test.go index 336a4e1c..e6710c45 100644 --- a/internal/controlplane/storage/migrate/migration_parity_lint_test.go +++ b/internal/controlplane/storage/migrate/migration_parity_lint_test.go @@ -5,6 +5,7 @@ import ( "io/fs" "regexp" "sort" + "strconv" "strings" "testing" "testing/fstest" @@ -68,8 +69,8 @@ func readMigrationTree(t *testing.T, fsys fs.FS, label string) map[int]migration t.Errorf("%s: %s does not match NNNN_title.sql", label, e.Name()) continue } - var version int - fmt.Sscanf(m[1], "%d", &version) + // The regexp guarantees m[1] is exactly four digits, so Atoi cannot fail. + version, _ := strconv.Atoi(m[1]) if prev, dup := tree[version]; dup { t.Errorf("%s: duplicate version %04d (%s vs %s)", label, version, prev.title, m[2]) continue