package models import ( "context" "errors" "fmt" "log" "strings" "sync" "time" "gorm.io/gorm" "rocketgit.ru/rsmon/worker/app/models/authidentity" ) // isTypeExistsError returns true if the error is a Postgres "type already exists" error. // This happens when AutoMigrate is called concurrently from multiple test processes. func isTypeExistsError(err error) bool { if err == nil { return false } s := err.Error() // SQLSTATE 42710 = duplicate_object (type already exists) return strings.Contains(s, "42710") || strings.Contains(s, "already exists") || strings.Contains(s, "pg_type_typname_nsp_index") } // migrateOrIgnoreTypeExists runs AutoMigrate and ignores "type already exists" errors // that can occur when parallel test processes both try to create the same Postgres types. func migrateOrIgnoreTypeExists(models ...interface{}) { err := DB().AutoMigrate(models...) if err != nil && !isTypeExistsError(err) { panic(err) } if err != nil { log.Printf("migrate: ignoring type-exists error (expected during parallel test runs): %v", err) } } // ensureSingleCurrentEventInvariant pins cleanup and index creation to one // transaction/connection. The global migration lock is session-scoped through a // pool, so it is not sufficient for this multi-statement invariant by itself. func ensureSingleCurrentEventInvariant() error { return DB().Transaction(func(tx *gorm.DB) error { if err := tx.Exec("SELECT pg_advisory_xact_lock(?)", int64(1234567892)).Error; err != nil { return err } if err := tx.Exec(`WITH ranked AS ( SELECT id, row_number() OVER (PARTITION BY monitor_id ORDER BY start_time DESC NULLS LAST, id DESC) AS n FROM events WHERE state = 'current' ) UPDATE events SET state = 'ended', end_time = COALESCE(end_time, now()) FROM ranked WHERE events.id = ranked.id AND ranked.n > 1`).Error; err != nil { return err } return tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS events_one_current_per_monitor ON events (monitor_id) WHERE state = 'current'`).Error }) } // Migrate run db migration var migrateMu sync.Mutex func Migrate() { migrateMu.Lock() defer migrateMu.Unlock() withMigrationAdvisoryLock(migrateLocked) } // withMigrationAdvisoryLock holds the session lock on a dedicated connection // while migration work uses GORM's normal pool. Reusing the lock connection // for GORM transactions can leave its *sql.Conn closed after commit. func withMigrationAdvisoryLock(migrate func()) { const migrateAdvisoryLock = int64(1234567890) sqlDB, err := DB().DB() if err != nil { panic(fmt.Sprintf("migrate: database handle: %v", err)) } ctx := context.Background() conn, err := sqlDB.Conn(ctx) if err != nil { panic(fmt.Sprintf("migrate: lock connection: %v", err)) } defer conn.Close() //nolint:errcheck // closing releases the session lock after a migration panic if _, err = conn.ExecContext(ctx, "SELECT pg_advisory_lock($1)", migrateAdvisoryLock); err != nil { panic(fmt.Sprintf("migrate: advisory lock: %v", err)) } unlocked := false defer func() { if unlocked { return } if _, unlockErr := conn.ExecContext(ctx, "SELECT pg_advisory_unlock($1)", migrateAdvisoryLock); unlockErr != nil { log.Printf("migrate: unlock after failure: %v", unlockErr) } }() migrate() if _, err = conn.ExecContext(ctx, "SELECT pg_advisory_unlock($1)", migrateAdvisoryLock); err != nil { panic(fmt.Sprintf("migrate: unlock: %v", err)) } unlocked = true } func migrateLocked() { // M0 replaces the old flat plans table without rewriting historical rows. // Rename before AutoMigrate so GORM creates the canonical table rather than // adding columns to the incompatible legacy schema. if err := prepareCanonicalPlansTable(); err != nil { panic(fmt.Sprintf("migrate: prepare canonical plans: %v", err)) } // Step 0: create inventory Postgres enum types FIRST. The DO/EXCEPTION // blocks are idempotent so concurrent migrateOrIgnoreTypeExists // reruns from parallel test binaries are safe (the type already // exists → duplicate_object is swallowed). The enum types MUST // exist before any AutoMigrate below because GORM emits // `kind server_kind` literals in CREATE TABLE for the Server // struct (referenced transitively from Monitor.Site → Site → Server). for _, ddl := range []string{ `DO $$ BEGIN CREATE TYPE server_kind AS ENUM ('production','staging','old'); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, `DO $$ BEGIN CREATE TYPE deployment_kind AS ENUM ('production','production_prev','production_next','internal','staging','old'); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, `DO $$ BEGIN CREATE TYPE deployment_mode AS ENUM ('kubernetes','compose','dedicated','vds','user'); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, `DO $$ BEGIN CREATE TYPE deployment_action AS ENUM ('ok','pending','pending_move','pending_drop','deleted','missing'); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, } { if err := DB().Exec(ddl).Error; err != nil { panic(fmt.Sprintf("migrate: enum creation: %v", err)) } } var err error // Step 1: Migrate core models (User, Plan, Account, ApiKey, AuthIdentity, Invite) // Note: Access is moved to Step 2 because it has FKs to groups/monitors // Seed and remap plans before Account migration recreates its plan FK. migrateOrIgnoreTypeExists(&Plan{}) if err := seedCanonicalPlansAndBackfill(true); err != nil { panic(fmt.Sprintf("migrate: billing catalog: %v", err)) } migrateOrIgnoreTypeExists( &User{}, &Account{}, &ApiKey{}, &authidentity.AuthIdentity{}, // After User (has FK to users) &Invite{}, // After Account/User (has FKs to accounts/users) &Subscription{}, // After Account/Plan &SubscriptionEvent{}, // After Subscription ) // Monitor and metric rows reference worker_nodes, while worker_nodes itself // references servers. Create the two roots without their associations before // migrating Monitor/ServerMetric on a fresh database. migrateOrIgnoreTypeExists(&Region{}, &LLM{}) if err = DB().Omit("Monitors", "Workers").AutoMigrate(&Server{}); err != nil { panic(err) } migrateOrIgnoreTypeExists(&WorkerNode{}) // Step 2: Migrate Group, Monitor, and Access (which has FKs to groups/monitors) // This ensures the groups table exists when GORM creates foreign keys migrateOrIgnoreTypeExists( &Group{}, &Monitor{}, &Check{}, // Server is a customer-facing logical host, distinct from the // WorkerNode executor. Keep the join/cache models here so a fresh // database gets the complete server metrics schema in one migration. &Server{}, &AccountMCPToken{}, &MonitorServer{}, &ServerMetric{}, &ServerAlertRule{}, &ServerAlertEvent{}, &RknIP{}, &RknDomain{}, &DNSRecord{}, &Contact{}, &Whois{}, &Payment{}, &Message{}, &TelegramBotMessage{}, &TelegramBotStatus{}, &Event{}, &SelfCheck{}, &Notification{}, // After Group/Monitor so notification_groups FK works &Access{}, // After Group/Monitor so access FKs work &NotificationCredential{}, // No FKs to other domain tables; safe here. ) DB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS servers_account_slug_unique ON servers (account_id, slug)`) DB().Exec(`CREATE INDEX IF NOT EXISTS monitor_servers_position_idx ON monitor_servers (server_id, position)`) DB().Exec(`CREATE INDEX IF NOT EXISTS server_metrics_server_recent_idx ON server_metrics (server_id, id DESC)`) DB().Exec(`CREATE INDEX IF NOT EXISTS server_metrics_worker_idx ON server_metrics (worker_id)`) // Step 3: Clean up orphaned references (now that all tables exist) // Fix typo in old table name (only present on DBs migrated from older versions) err = DB().Exec("ALTER TABLE IF EXISTS envent_checks RENAME TO event_checks;").Error if err != nil { log.Println(err) } err = DB().Exec("DROP TABLE IF EXISTS envent_messages;").Error if err != nil { log.Println(err) } // Defense-in-depth: make sure the columns that the in-process notifier // scheduler eagerly queries at startup exist, even if AutoMigrate above // was skipped or the column was dropped by a manual operation. Without // these, a fresh restore from a pre-soft-delete production dump will // panic the first time RunExp preloads Contacts or // ProcessPendingDeletions queries Users (see internal/notifier for the // defensive recover() that catches the resulting query errors). err = DB().Exec( "ALTER TABLE contacts ADD COLUMN IF NOT EXISTS is_system BOOLEAN NOT NULL DEFAULT FALSE", ).Error if err != nil { log.Println(err) } err = DB().Exec( "ALTER TABLE contacts ADD COLUMN IF NOT EXISTS enabled BOOLEAN NOT NULL DEFAULT TRUE", ).Error if err != nil { log.Println(err) } err = DB().Exec( "ALTER TABLE accounts ADD COLUMN IF NOT EXISTS disabled BOOLEAN NOT NULL DEFAULT FALSE", ).Error if err != nil { log.Println(err) } err = DB().Exec( "ALTER TABLE accounts ADD COLUMN IF NOT EXISTS blocked BOOLEAN NOT NULL DEFAULT FALSE", ).Error if err != nil { log.Println(err) } err = DB().Exec( "ALTER TABLE users ADD COLUMN IF NOT EXISTS deletion_requested_at TIMESTAMPTZ", ).Error if err != nil { log.Println(err) } err = DB().Exec( "ALTER TABLE notification_credentials ADD COLUMN IF NOT EXISTS webhook_token VARCHAR(128)", ).Error if err != nil { log.Println(err) } var telegramCreds []NotificationCredential if err = DB().Where("kind = ? AND (webhook_token IS NULL OR webhook_token = '')", CredentialKindTelegram).Find(&telegramCreds).Error; err != nil { log.Println(err) } for i := range telegramCreds { telegramCreds[i].EnsureWebhookToken() if err = DB().Save(&telegramCreds[i]).Error; err != nil { log.Println(err) } } err = DB().Exec("DROP INDEX IF EXISTS idx_notification_credentials_webhook_token").Error if err != nil { log.Println(err) } err = DB().Exec( "CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_credentials_webhook_token ON notification_credentials (webhook_token) WHERE webhook_token IS NOT NULL AND webhook_token <> ''", ).Error if err != nil { log.Println(err) } // Replace the early M0 per-event-id index with provider-scoped webhook // idempotency: PSP event IDs are only unique inside a provider. if err = DB().Exec("DROP INDEX IF EXISTS idx_subscription_events_provider_event_id").Error; err != nil { log.Println(err) } if err = DB().Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_subscription_events_provider_event ON subscription_events (provider, provider_event_id) WHERE provider_event_id IS NOT NULL").Error; err != nil { log.Println(err) } // Clean up orphaned event_checks err = DB().Exec("DELETE FROM event_checks where event_id NOT IN (select id from events)").Error if err != nil { panic(err) } err = DB().Exec("DELETE FROM event_checks where check_id NOT IN (select id from checks)").Error if err != nil { panic(err) } // Clean up orphaned event_messages err = DB().Exec("DELETE FROM event_messages where event_id NOT IN (select id from events)").Error if err != nil { panic(err) } err = DB().Exec("DELETE FROM event_messages where message_id NOT IN (select id from messages)").Error if err != nil { panic(err) } // Clean up orphaned notification_contacts err = DB().Exec("DELETE FROM notification_contacts where notification_id NOT IN (select id from notifications)").Error if err != nil { panic(err) } err = DB().Exec("DELETE FROM notification_contacts where contact_id NOT IN (select id from contacts)").Error if err != nil { panic(err) } // Clean up orphaned notification_groups (now safe - groups table exists) err = DB().Exec("DELETE FROM notification_groups where notification_id NOT IN (select id from notifications)").Error if err != nil { panic(err) } err = DB().Exec("DELETE FROM notification_groups where group_id NOT IN (select id from groups)").Error if err != nil { panic(err) } DB().Raw("CREATE INDEX IF NOT EXISTS not_old_events on events (monitor_id, id) where state != 'old'") DB().Raw("CREATE INDEX IF NOT EXISTS current_events ON event (monitor_id, start_time) WHERE state = 'current'") DB().Raw("CREATE INDEX IF NOT EXISTS ended_events ON event (monitor_id, start_time) WHERE state = 'ended'") DB().Raw("CREATE INDEX IF NOT EXISTS queued_messages ON message (id) WHERE state = 'queued'") DB().Raw("CREATE UNIQUE INDEX IF NOT EXISTS access_accounts ON accesses (user_id, account_id)") DB().Raw("CREATE UNIQUE INDEX IF NOT EXISTS access_accounts ON accesses (user_id, group_id)") DB().Raw("CREATE UNIQUE INDEX IF NOT EXISTS invite_email ON invites (account_id, email)") // RKN indexes — see app/models/rkn_ip.go EnsureRknIndexes. GORM // AutoMigrate above declared the uniqueIndex on RknDomain.Domain // and the cidr column type on RknIP, but GiST on rkn_ips.network // is not expressible via the GORM tag language; we add it here so // the (>>=) containment operator used by IsRknIPBlocked has an // index to back it. if err := EnsureRknIndexes(); err != nil { log.Printf("migrate: EnsureRknIndexes failed: %v", err) } // Distributed worker models migrateOrIgnoreTypeExists( &WorkerLogEvent{}, &CheckAttempt{}, &DiagnosticAuditEvent{}, &CheckRegionResult{}, &Task{}, &TaskReplay{}, &NotificationDelivery{}, &Tag{}, ) // Tags — (account_id, name) is the unique key so a single account // cannot register two metadata rows for the same tag string. The // tag name itself is also the join key against monitors.tags, so // uniqueness is enforced at the table level (not just on the // metadata row). DB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS tags_account_name_unique ON tags (account_id, name)`) // Status pages use globally unique public slugs. A public URL has no // account component, so account-scoped uniqueness would make /status/:slug // ambiguous. Soft-deleted rows do not reserve their slug. // subscriber email index uses lower(email) for case-insensitive // matching (the codebase does not adopt citext). All five tables // are created together so M0 ships a consistent schema baseline // regardless of which milestone first writes rows. migrateOrIgnoreTypeExists( &StatusPage{}, &StatusPageSubscriber{}, &StatusPageIncident{}, &StatusPageMaintenance{}, &StatusPageDomain{}, &StatusPageDelivery{}, &StatusPageDigestSchedule{}, &Maintenance{}, ) DB().Exec(`DROP INDEX IF EXISTS status_pages_account_slug_unique`) DB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS status_pages_slug_unique ON status_pages (slug) WHERE deleted_at IS NULL`) if err := ensureSingleCurrentEventInvariant(); err != nil { panic(fmt.Sprintf("migrate: current event invariant: %v", err)) } DB().Exec( `CREATE UNIQUE INDEX IF NOT EXISTS status_page_subscribers_active_email ON status_page_subscribers (status_page_id, lower(email)) WHERE unsubscribed_at IS NULL`, ) // Existing installations can already have subscriber rows. Keep the legacy // token column during the nullable transition: outstanding links remain // valid, while each resend/confirmation rotates it into a hash. DB().Exec(`ALTER TABLE status_page_subscribers ADD COLUMN IF NOT EXISTS confirm_token_hash varchar(64)`) DB().Exec(`ALTER TABLE status_page_subscribers ADD COLUMN IF NOT EXISTS token_expires_at timestamptz`) DB().Exec(`ALTER TABLE status_page_subscribers ADD COLUMN IF NOT EXISTS unsubscribe_token_hash varchar(64)`) DB().Exec(`ALTER TABLE status_page_subscribers ADD COLUMN IF NOT EXISTS contact_id bigint REFERENCES contacts(id) ON DELETE SET NULL`) DB().Exec(`UPDATE status_page_subscribers SET token_expires_at = created_at + interval '24 hours' WHERE token_expires_at IS NULL`) for { var subscribers []StatusPageSubscriber if err := DB().Where("confirm_token_hash IS NULL AND confirm_token IS NOT NULL").Limit(500).Find(&subscribers).Error; err != nil || len(subscribers) == 0 { break } for i := range subscribers { subscribers[i].ConfirmTokenHash = HashStatusPageToken(*subscribers[i].LegacyConfirmToken) _ = DB().Model(&subscribers[i]).Update("confirm_token_hash", subscribers[i].ConfirmTokenHash).Error } } DB().Exec(`UPDATE status_page_subscribers SET confirm_token_hash = '' WHERE confirm_token_hash IS NULL`) DB().Exec(`UPDATE status_page_subscribers SET unsubscribe_token_hash = '' WHERE unsubscribe_token_hash IS NULL`) // A page may expose more than one verified hostname; older schema reserved // only one domain per page. DB().Exec(`DROP INDEX IF EXISTS idx_status_page_domains_status_page_id`) DB().Exec( `CREATE INDEX IF NOT EXISTS status_page_incidents_started ON status_page_incidents (status_page_id, started_at DESC)`, ) DB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS status_page_incidents_event_unique ON status_page_incidents (status_page_id, event_id) WHERE event_id IS NOT NULL`) DB().Exec( `CREATE INDEX IF NOT EXISTS status_page_maintenance_starts ON status_page_maintenance (status_page_id, starts_at DESC)`, ) DB().Exec(`CREATE INDEX IF NOT EXISTS maintenances_account_active ON maintenances (account_id) WHERE deleted_at IS NULL`) // Legacy status-page rows predate account-scoped maintenance. Keep their // source IDs so equal title/start rows remain distinct and reruns can safely // preserve every window and each of its joins. DB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS maintenances_legacy_status_page_maintenance_unique ON maintenances (legacy_status_page_maintenance_id) WHERE legacy_status_page_maintenance_id IS NOT NULL`) DB().Exec(`INSERT INTO maintenances (account_id, title, description, strategy, duration_sec, start_date, end_date, timezone, active, show_on_all_status_pages, legacy_status_page_maintenance_id, created_at, updated_at) SELECT sp.account_id, old.title, old.description, 'single', EXTRACT(EPOCH FROM (old.ends_at - old.starts_at))::int, old.starts_at, old.ends_at, 'UTC', TRUE, FALSE, old.id, old.created_at, old.updated_at FROM status_page_maintenance old JOIN status_pages sp ON sp.id = old.status_page_id ON CONFLICT (legacy_status_page_maintenance_id) WHERE legacy_status_page_maintenance_id IS NOT NULL DO NOTHING`) DB().Exec(`INSERT INTO maintenance_status_pages (maintenance_id, status_page_id) SELECT m.id, old.status_page_id FROM status_page_maintenance old JOIN maintenances m ON m.legacy_status_page_maintenance_id = old.id ON CONFLICT DO NOTHING`) DB().Exec(`INSERT INTO maintenance_monitors (maintenance_id, monitor_id) SELECT m.id, monitor_id FROM status_page_maintenance old JOIN status_pages sp ON sp.id = old.status_page_id JOIN maintenances m ON m.legacy_status_page_maintenance_id = old.id, LATERAL unnest(CASE WHEN cardinality(old.monitor_ids) > 0 THEN old.monitor_ids ELSE sp.monitor_ids END) AS monitor_id ON CONFLICT DO NOTHING`) // Worker-driven task queue indexes. DB().Exec(`CREATE INDEX IF NOT EXISTS tasks_queued_due ON tasks (kind, not_before) WHERE state = 'queued'`) DB().Exec(`CREATE INDEX IF NOT EXISTS tasks_leased_expires ON tasks (lease_expires_at) WHERE state = 'leased'`) DB().Exec(`CREATE INDEX IF NOT EXISTS tasks_failed_retry_due ON tasks (not_before) WHERE state = 'failed_retry'`) DB().Exec(`CREATE INDEX IF NOT EXISTS tasks_dead_kind ON tasks (kind, account_id) WHERE state = 'dead'`) DB().Exec(`CREATE INDEX IF NOT EXISTS check_attempts_worker_finished ON check_attempts (worker_node_id, finished_at DESC)`) err = DB().Exec("ALTER TABLE worker_nodes ALTER COLUMN concurrency SET DEFAULT 10").Error if err != nil { log.Println(err) } err = DB().Exec("ALTER TABLE checks ALTER COLUMN settings SET DEFAULT '{}'::jsonb").Error if err != nil { panic(err) } err = DB().Exec("UPDATE checks SET settings = '{}'::jsonb WHERE settings IS NULL").Error if err != nil { panic(err) } err = DB().Exec("ALTER TABLE checks ALTER COLUMN settings SET NOT NULL").Error if err != nil { panic(err) } // Inventory models (docs/plans/inventory-management.md §6, §10 M0). // Enum types are created at Step 0 above so they exist before any // AutoMigrate. The numeric labels of each enum value are taken // verbatim from rstuff (`/data/int/rstuff/app/models/*.rb`) so a // future sync layer does not need a value-mapping table — see // docs/parity/rstuff-inventory.md §6.1. // Servers — extend with the rstuff-shaped inventory fields. All // statements are IF NOT EXISTS so existing rows keep working // untouched (ext_id/token/price_cents default sensibly; meta gets // an empty jsonb). DB().Exec(`ALTER TABLE servers ADD COLUMN IF NOT EXISTS ext_id VARCHAR(64)`) DB().Raw(`CREATE UNIQUE INDEX IF NOT EXISTS servers_ext_id_unique ON servers (ext_id) WHERE ext_id IS NOT NULL`) DB().Exec(`ALTER TABLE servers ADD COLUMN IF NOT EXISTS kind server_kind NOT NULL DEFAULT 'production'`) DB().Exec(`ALTER TABLE servers ADD COLUMN IF NOT EXISTS token VARCHAR(64)`) DB().Raw(`CREATE UNIQUE INDEX IF NOT EXISTS servers_token_unique ON servers (token) WHERE token IS NOT NULL`) DB().Exec(`ALTER TABLE servers ADD COLUMN IF NOT EXISTS price_cents INTEGER NOT NULL DEFAULT 0`) DB().Exec(`ALTER TABLE servers ADD COLUMN IF NOT EXISTS comment TEXT`) DB().Exec(`ALTER TABLE servers ADD COLUMN IF NOT EXISTS meta JSONB NOT NULL DEFAULT '{}'::jsonb`) // WorkerNodes — optional server_id join for inventory correlation. DB().Exec(`ALTER TABLE worker_nodes ADD COLUMN IF NOT EXISTS server_id BIGINT REFERENCES servers(id) ON DELETE SET NULL`) DB().Exec(`CREATE INDEX IF NOT EXISTS worker_nodes_server_idx ON worker_nodes (server_id)`) // WorkerNodes — optional account_id FK marking a private (customer- // operated) worker per docs/distributed/private-workers.md. NULL // rows are platform-operated workers eligible to serve any account; // non-NULL rows are pinned to a single account and are removed by // HardDeleteAccount before the account row itself is dropped. DB().Exec(`ALTER TABLE worker_nodes ADD COLUMN IF NOT EXISTS account_id BIGINT REFERENCES accounts(id) ON DELETE SET NULL`) DB().Exec(`CREATE INDEX IF NOT EXISTS worker_nodes_account_idx ON worker_nodes (account_id)`) // Notification credentials are either platform-managed (account_id IS NULL) // or owned by one account. Replace the legacy global name constraint with // scope-aware unique indexes. DB().Exec(`ALTER TABLE notification_credentials ADD COLUMN IF NOT EXISTS account_id BIGINT REFERENCES accounts(id) ON DELETE CASCADE`) DB().Exec(`CREATE INDEX IF NOT EXISTS notification_credentials_account_idx ON notification_credentials (account_id)`) DB().Exec(`DROP INDEX IF EXISTS cred_kind_name`) DB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS notification_credentials_system_kind_name_unique ON notification_credentials (kind, name) WHERE account_id IS NULL`) DB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS notification_credentials_account_kind_name_unique ON notification_credentials (account_id, kind, name) WHERE account_id IS NOT NULL`) // Inventory entities. Order matters: server_ips before sites // (FK), sites before deployments (FK), deployments before // domains (FK). Audit columns (creator_id/updater_id) use the // Audited mixin via concerns.Timestamped + Audited. migrateOrIgnoreTypeExists( &ServerIp{}, &Repo{}, &Site{}, &Deployment{}, &SiteRepo{}, &Domain{}, ) // Monitors is migrated earlier for historical FK ordering. Add the optional // site reference only after sites exists on fresh databases. DB().Exec(`ALTER TABLE monitors ADD COLUMN IF NOT EXISTS site_id BIGINT REFERENCES sites(id) ON DELETE SET NULL`) DB().Exec(`CREATE INDEX IF NOT EXISTS monitors_site_idx ON monitors (site_id)`) // Indexes — kept here so the AutoMigrate path stays the single // source of truth. IF NOT EXISTS guards the rerun case. DB().Exec(`CREATE INDEX IF NOT EXISTS server_ips_server_idx ON server_ips (server_id)`) DB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS server_ips_address_unique ON server_ips (server_id, address)`) DB().Exec(`CREATE INDEX IF NOT EXISTS sites_account_idx ON sites (account_id)`) DB().Exec(`CREATE INDEX IF NOT EXISTS sites_server_idx ON sites (server_id)`) DB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS sites_ext_id_unique ON sites (ext_id) WHERE ext_id IS NOT NULL`) DB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS sites_account_slug_unique ON sites (account_id, slug)`) DB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS repos_ext_id_unique ON repos (ext_id) WHERE ext_id IS NOT NULL`) DB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS repos_gitlab_id_unique ON repos (gitlab_id) WHERE gitlab_id IS NOT NULL`) DB().Exec(`CREATE INDEX IF NOT EXISTS deployments_account_idx ON deployments (account_id)`) DB().Exec(`CREATE INDEX IF NOT EXISTS deployments_server_idx ON deployments (server_id)`) DB().Exec(`CREATE INDEX IF NOT EXISTS deployments_site_idx ON deployments (site_id)`) DB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS deployments_ext_id_unique ON deployments (ext_id) WHERE ext_id IS NOT NULL`) DB().Exec(`CREATE INDEX IF NOT EXISTS domains_account_idx ON domains (account_id)`) DB().Exec(`CREATE INDEX IF NOT EXISTS domains_server_idx ON domains (server_id)`) DB().Exec(`CREATE INDEX IF NOT EXISTS domains_site_idx ON domains (site_id)`) DB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS domains_name_unique ON domains (name)`) // Dedupe by (server_id, config_path) for nginx sites and by // (server_id, site_id, service_name) for compose services — matches // the upsert keys in app/controllers/api/inventory_deploymentd.go. DB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS deployments_unique_nginx ON deployments (server_id, config_path) WHERE mode = 'dedicated' AND config_path IS NOT NULL`) DB().Exec(`DROP INDEX IF EXISTS deployments_unique_compose`) DB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS deployments_unique_compose ON deployments (server_id, site_id, service_name) WHERE mode = 'compose' AND site_id IS NOT NULL AND service_name IS NOT NULL`) if err := seedCanonicalPlansAndBackfill(false); err != nil { panic(fmt.Sprintf("migrate: billing catalog: %v", err)) } DB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS plans_active_code_unique ON plans (code) WHERE archived = FALSE`) DB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS subscriptions_provider_external_unique ON subscriptions (provider, provider_subscription_id) WHERE provider_subscription_id IS NOT NULL`) DB().Exec(`CREATE INDEX IF NOT EXISTS subscriptions_account_status_idx ON subscriptions (account_id, status)`) DB().Exec(`DO $$ BEGIN ALTER TABLE plans ADD CONSTRAINT plans_price_nonnegative CHECK (price_monthly_minor >= 0 AND price_annual_minor >= 0); EXCEPTION WHEN duplicate_object THEN NULL; END $$`) DB().Exec(`DO $$ BEGIN ALTER TABLE plans ADD CONSTRAINT plans_limits_valid CHECK (monitor_cap >= 0 AND interval_min_seconds >= 30); EXCEPTION WHEN duplicate_object THEN NULL; END $$`) // Seed default region. Use defaultRegionCode (declared in check_jobs.go) // so the literal does not appear three times in the package. localRegion := Region{} DB().Where("code = ?", defaultRegionCode).First(&localRegion) if localRegion.ID == 0 { DB().Create(&Region{Code: defaultRegionCode, Name: "Local (default)", Enabled: true, Priority: 100}) } DB().Exec(` WITH t AS ( select u.id as user_id, i.encrypted_password as encrypted_password from users as u join identities as i on u.id = i.user_id where u.encrypted_password is NULL ) UPDATE users SET encrypted_password = t.encrypted_password from t where users.id = t.user_id `) log.Println("migrated DB.") } func prepareCanonicalPlansTable() error { if !DB().Migrator().HasTable("plans") || DB().Migrator().HasColumn("plans", "code") { return nil } return DB().Transaction(func(tx *gorm.DB) error { return tx.Exec(`ALTER TABLE plans RENAME TO plans_legacy`).Error }) } func planForeignKeyReferences(tx *gorm.DB, table, target string) (bool, error) { var references bool err := tx.Raw(`SELECT EXISTS ( SELECT 1 FROM pg_constraint c JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY(c.conkey) WHERE c.contype = 'f' AND c.conrelid = to_regclass(?) AND c.confrelid = to_regclass(?) AND a.attname = 'plan_id' )`, table, target).Scan(&references).Error return references, err } func seedCanonicalPlansAndBackfill(remapAccounts bool) error { plans := []Plan{ {Code: "free", NameRU: "Бесплатный", NameEN: "Free", Currency: "RUB", MonitorCap: 50, IntervalMinSeconds: 300, StatusPagesCap: 1, MaintenanceCap: -1, LoginSeatsIncluded: 3, NotifySeatsIncluded: 0, Integrations: []string{"email", "telegram"}, CheckKinds: []string{"http", "ssl", "dns", "whois", "ping"}, DataRetentionMonths: 3, IsDefault: true}, {Code: "solo", NameRU: "Соло", NameEN: "Solo", PriceMonthlyMinor: 74900, PriceAnnualMinor: 64900, Currency: "RUB", MonitorCap: 10, IntervalMinSeconds: 60, StatusPagesCap: 3, MaintenanceCap: 5, LoginSeatsIncluded: 5, NotifySeatsIncluded: 3, Integrations: []string{"email", "telegram", "sms", "voice", "mattermost", "webhook"}, CheckKinds: []string{"http", "ssl", "dns", "ssh", "ftp", "whois", "ping"}, DataRetentionMonths: 12, Confirmations: true, AllowHardAlerts: true, ConfirmTimeoutSec: 60}, {Code: "team", NameRU: "Команда", NameEN: "Team", PriceMonthlyMinor: 299000, PriceAnnualMinor: 254900, Currency: "RUB", MonitorCap: 100, IntervalMinSeconds: 60, StatusPagesCap: 100, MaintenanceCap: 50, LoginSeatsIncluded: 5, NotifySeatsIncluded: 5, Integrations: []string{"email", "telegram", "sms", "voice", "mattermost", "webhook"}, CheckKinds: []string{"http", "ssl", "dns", "ssh", "ftp", "whois", "ping", "rkn_blocklist", "llm"}, DataRetentionMonths: 24, DistributedWorkers: true, Confirmations: true, AllowHardAlerts: true, ConfirmTimeoutSec: 45}, {Code: "enterprise", NameRU: "Предприятие", NameEN: "Enterprise", PriceMonthlyMinor: 549000, PriceAnnualMinor: 464900, Currency: "RUB", MonitorCap: 200, IntervalMinSeconds: 30, StatusPagesCap: 0, MaintenanceCap: 0, LoginSeatsIncluded: 0, NotifySeatsIncluded: 0, UnlimitedSeats: true, Integrations: []string{"email", "telegram", "sms", "voice", "mattermost", "webhook", "sso_saml"}, CheckKinds: []string{"http", "ssl", "dns", "ssh", "ftp", "whois", "ping", "rkn_blocklist", "llm"}, DataRetentionMonths: 36, DistributedWorkers: true, Confirmations: true, AllowHardAlerts: true, ConfirmTimeoutSec: 30, SOC2: true, GDPRDPA: true}, } return DB().Transaction(func(tx *gorm.DB) error { for i := range plans { var existing Plan err := tx.Where("code = ?", plans[i].Code).First(&existing).Error if errors.Is(err, gorm.ErrRecordNotFound) { if err := tx.Create(&plans[i]).Error; err != nil { return err } } else if err != nil { return err } } // Catalog rows already existed before maintenance_cap was introduced. // Update this new entitlement only; do not overwrite customer-adjusted // prices or other existing plan attributes during a normal migration. if err := tx.Exec(`UPDATE plans SET maintenance_cap = CASE code WHEN 'free' THEN -1 WHEN 'solo' THEN 5 WHEN 'team' THEN 50 WHEN 'enterprise' THEN 0 ELSE maintenance_cap END WHERE code IN ('free', 'solo', 'team', 'enterprise')`).Error; err != nil { return err } hasLegacy := tx.Migrator().HasTable("plans_legacy") hasMigratedLegacy := tx.Migrator().HasTable("plans_legacy_migrated") if !hasLegacy && !hasMigratedLegacy { return nil } hasAccounts := tx.Migrator().HasTable("accounts") accountsNeedRemap := false if remapAccounts && hasLegacy && hasAccounts { referencesCanonical, err := planForeignKeyReferences(tx, "accounts", "plans") if err != nil { return err } if err := tx.Exec(`ALTER TABLE accounts DROP CONSTRAINT IF EXISTS accounts_plan_id_fkey`).Error; err != nil { return err } if err := tx.Exec(`ALTER TABLE accounts DROP CONSTRAINT IF EXISTS fk_accounts_plan`).Error; err != nil { return err } accountsNeedRemap = !referencesCanonical if accountsNeedRemap { if err := tx.Exec(`UPDATE accounts SET plan_id = CASE WHEN EXISTS (SELECT 1 FROM plans_legacy l WHERE l.id = accounts.plan_id AND (l."default" = TRUE OR l.price = 0)) THEN (SELECT id FROM plans WHERE code = 'free') WHEN EXISTS (SELECT 1 FROM plans_legacy l WHERE l.id = accounts.plan_id AND l.total_monitors > 100) THEN (SELECT id FROM plans WHERE code = 'team') ELSE (SELECT id FROM plans WHERE code = 'solo') END WHERE EXISTS (SELECT 1 FROM plans_legacy l WHERE l.id = accounts.plan_id)`).Error; err != nil { return err } } } hasSubscriptions := tx.Migrator().HasTable("subscriptions") subscriptionsNeedRemap := false if remapAccounts && hasLegacy && hasSubscriptions { referencesCanonical, err := planForeignKeyReferences(tx, "subscriptions", "plans") if err != nil { return err } if err := tx.Exec(`ALTER TABLE subscriptions DROP CONSTRAINT IF EXISTS subscriptions_plan_id_fkey`).Error; err != nil { return err } if err := tx.Exec(`ALTER TABLE subscriptions DROP CONSTRAINT IF EXISTS fk_subscriptions_plan`).Error; err != nil { return err } subscriptionsNeedRemap = !referencesCanonical if subscriptionsNeedRemap { if err := tx.Exec(`UPDATE subscriptions SET plan_id = CASE WHEN EXISTS (SELECT 1 FROM plans_legacy l WHERE l.id = subscriptions.plan_id AND (l."default" = TRUE OR l.price = 0)) THEN (SELECT id FROM plans WHERE code = 'free') WHEN EXISTS (SELECT 1 FROM plans_legacy l WHERE l.id = subscriptions.plan_id AND l.total_monitors > 100) THEN (SELECT id FROM plans WHERE code = 'team') ELSE (SELECT id FROM plans WHERE code = 'solo') END WHERE EXISTS (SELECT 1 FROM plans_legacy l WHERE l.id = subscriptions.plan_id)`).Error; err != nil { return err } } } if remapAccounts && hasLegacy && (accountsNeedRemap || subscriptionsNeedRemap) && tx.Migrator().HasTable("subscription_events") { if err := tx.Exec(`UPDATE subscription_events e SET from_plan_id = CASE WHEN l."default" = TRUE OR l.price = 0 THEN (SELECT id FROM plans WHERE code = 'free') WHEN l.total_monitors > 100 THEN (SELECT id FROM plans WHERE code = 'team') ELSE (SELECT id FROM plans WHERE code = 'solo') END FROM plans_legacy l WHERE e.from_plan_id = l.id`).Error; err != nil { return err } if err := tx.Exec(`UPDATE subscription_events e SET to_plan_id = CASE WHEN l."default" = TRUE OR l.price = 0 THEN (SELECT id FROM plans WHERE code = 'free') WHEN l.total_monitors > 100 THEN (SELECT id FROM plans WHERE code = 'team') ELSE (SELECT id FROM plans WHERE code = 'solo') END FROM plans_legacy l WHERE e.to_plan_id = l.id`).Error; err != nil { return err } } if !remapAccounts && hasAccounts && hasSubscriptions { now := time.Now().UTC() if err := tx.Exec(`INSERT INTO subscriptions (account_id, plan_id, provider, status, billing_cycle, current_period_start, current_period_end, currency, amount_minor, metadata_json, created_at, updated_at) SELECT a.id, a.plan_id, 'manual', 'active', 'monthly', ?, ?, p.currency, p.price_monthly_minor, '{}'::jsonb, ?, ? FROM accounts a JOIN plans p ON p.id = a.plan_id WHERE NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.account_id = a.id)`, now, now.AddDate(0, 0, 30), now, now).Error; err != nil { return err } } if !remapAccounts || !hasLegacy { return nil } if hasAccounts { // AutoMigrate names this association fk_accounts_plan, while an older // migration used accounts_plan_id_fkey. Either name may still point at // plans_legacy after the table rename, so replace both deterministically. if err := tx.Exec(`ALTER TABLE accounts DROP CONSTRAINT IF EXISTS accounts_plan_id_fkey`).Error; err != nil { return err } if err := tx.Exec(`ALTER TABLE accounts DROP CONSTRAINT IF EXISTS fk_accounts_plan`).Error; err != nil { return err } if err := tx.Exec(`ALTER TABLE accounts ADD CONSTRAINT fk_accounts_plan FOREIGN KEY (plan_id) REFERENCES plans(id)`).Error; err != nil { return err } } if hasSubscriptions { if err := tx.Exec(`ALTER TABLE subscriptions DROP CONSTRAINT IF EXISTS subscriptions_plan_id_fkey`).Error; err != nil { return err } if err := tx.Exec(`ALTER TABLE subscriptions DROP CONSTRAINT IF EXISTS fk_subscriptions_plan`).Error; err != nil { return err } if err := tx.Exec(`ALTER TABLE subscriptions ADD CONSTRAINT fk_subscriptions_plan FOREIGN KEY (plan_id) REFERENCES plans(id)`).Error; err != nil { return err } } return tx.Exec("ALTER TABLE plans_legacy RENAME TO plans_legacy_migrated").Error }) }