Files
worker/app/models/migrate.go
Gleb Tv 2c884c5612
Некоторые проверки не удались
CI / test (push) Successful in 2m5s
Docker / Build and publish worker image (push) Failing after 31s
refactor: adopt worker module path
2026-07-13 17:56:12 +03:00

751 строка
36 KiB
Go

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
})
}