751 строка
36 KiB
Go
751 строка
36 KiB
Go
package models
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
|
|
"rsgit.ru/rsmon/rsmon/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
|
|
})
|
|
}
|