diff --git a/backend/OpenFlare/plugins/server/migrator/clickhouse.go b/backend/OpenFlare/plugins/server/chmigrate/chmigrate.go similarity index 68% rename from backend/OpenFlare/plugins/server/migrator/clickhouse.go rename to backend/OpenFlare/plugins/server/chmigrate/chmigrate.go index 57aad2e0..ad62d38f 100644 --- a/backend/OpenFlare/plugins/server/migrator/clickhouse.go +++ b/backend/OpenFlare/plugins/server/chmigrate/chmigrate.go @@ -2,7 +2,8 @@ // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 -package migrator +// Package chmigrate applies ClickHouse of_node_* goose migrations. +package chmigrate import ( "context" @@ -25,15 +26,13 @@ const ( clickhouseReadTimeoutFactor = 2 ) -// clickhouseMigrationFS contains SQL migrations under goose/clickhouse. -// //go:embed goose/clickhouse/*.sql var clickhouseMigrationFS embed.FS -// MigrateClickHouse runs goose migrations against ClickHouse when enabled. -func MigrateClickHouse() Report { +// Up runs of_node_* goose migrations against ClickHouse when enabled. +func Up() error { if !runtimeconfig.ClickHouseEnabled() { - return Report{Backend: "ClickHouse"} + return nil } cfg := runtimeconfig.Get().ClickHouse @@ -58,10 +57,11 @@ func MigrateClickHouse() Report { BlockBufferSize: cfg.BlockBufferSize, }) - subFS, err := fs.Sub(clickhouseMigrationFS, "goose/clickhouse") + subFS, err := fs.Sub(clickhouseMigrationFS, clickhouseMigrationDir) if err != nil { closeClickHouseDB(sqlDB) - log.Fatalf("[ClickHouse] get sub fs failed: %v\n", err) + log.Printf("[ClickHouse] get sub fs failed: %v\n", err) + return err } provider, err := goose.NewProvider( @@ -73,32 +73,29 @@ func MigrateClickHouse() Report { ) if err != nil { closeClickHouseDB(sqlDB) - log.Fatalf("[ClickHouse] create goose provider failed: %v\n", err) + log.Printf("[ClickHouse] create goose provider failed: %v\n", err) + return err } - previousVersion, err := provider.GetDBVersion(context.Background()) - if err != nil { + if _, err := provider.GetDBVersion(context.Background()); err != nil { closeClickHouseDB(sqlDB) - log.Fatalf("[ClickHouse] get goose version failed: %v\n", err) + log.Printf("[ClickHouse] get goose version failed: %v\n", err) + return err } if _, err := provider.Up(context.Background()); err != nil { closeClickHouseDB(sqlDB) - log.Fatalf("[ClickHouse] goose migrate failed: %v\n", err) + log.Printf("[ClickHouse] goose migrate failed: %v\n", err) + return err } - currentVersion, err := provider.GetDBVersion(context.Background()) - if err != nil { + if _, err := provider.GetDBVersion(context.Background()); err != nil { closeClickHouseDB(sqlDB) - log.Fatalf("[ClickHouse] get migrated goose version failed: %v\n", err) + log.Printf("[ClickHouse] get migrated goose version failed: %v\n", err) + return err } closeClickHouseDB(sqlDB) log.Println("[ClickHouse] goose migrate success") - return Report{ - Backend: "ClickHouse", - Enabled: true, - Version: currentVersion, - Applied: currentVersion != previousVersion, - } + return nil } func closeClickHouseDB(sqlDB *sql.DB) { diff --git a/backend/OpenFlare/plugins/server/migrator/clickhouse_test.go b/backend/OpenFlare/plugins/server/chmigrate/chmigrate_test.go similarity index 60% rename from backend/OpenFlare/plugins/server/migrator/clickhouse_test.go rename to backend/OpenFlare/plugins/server/chmigrate/chmigrate_test.go index e5124110..041494f7 100644 --- a/backend/OpenFlare/plugins/server/migrator/clickhouse_test.go +++ b/backend/OpenFlare/plugins/server/chmigrate/chmigrate_test.go @@ -1,9 +1,10 @@ // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 -package migrator +package chmigrate import ( + "strings" "testing" "Wavelet/OpenFlare/plugins/server/runtimeconfig" @@ -20,22 +21,20 @@ func TestClickHouseMigrationFilesEmbedded(t *testing.T) { t.Fatal("expected embedded ClickHouse migrations, got none") } - expected := map[string]bool{ - "202606190001_create_user_access_logs.sql": false, - "202606200001_create_node_access_logs.sql": false, - } + foundNodeLogs := false for _, entry := range entries { if entry.IsDir() { continue } - if _, ok := expected[entry.Name()]; ok { - expected[entry.Name()] = true + if strings.Contains(entry.Name(), "user_access") { + t.Errorf("user access log migration %s must not be embedded", entry.Name()) + } + if entry.Name() == "202606200001_create_node_access_logs.sql" { + foundNodeLogs = true } } - for name, found := range expected { - if !found { - t.Fatalf("expected %s in embedded migrations", name) - } + if !foundNodeLogs { + t.Fatal("expected 202606200001_create_node_access_logs.sql in embedded migrations") } } @@ -45,7 +44,9 @@ func TestClickHouseGooseDialect(t *testing.T) { } } -func TestMigrateClickHouseSkipsWhenDisabled(t *testing.T) { +func TestUpSkipsWhenDisabled(t *testing.T) { t.Cleanup(runtimeconfig.Override(runtimeconfig.DatabaseEnabled(), false)) - MigrateClickHouse() + if err := Up(); err != nil { + t.Fatalf("Up() error = %v, want nil when ClickHouse disabled", err) + } } diff --git a/backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202606200001_create_node_access_logs.sql b/backend/OpenFlare/plugins/server/chmigrate/goose/clickhouse/202606200001_create_node_access_logs.sql similarity index 100% rename from backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202606200001_create_node_access_logs.sql rename to backend/OpenFlare/plugins/server/chmigrate/goose/clickhouse/202606200001_create_node_access_logs.sql diff --git a/backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202606200002_create_node_observability_tables.sql b/backend/OpenFlare/plugins/server/chmigrate/goose/clickhouse/202606200002_create_node_observability_tables.sql similarity index 100% rename from backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202606200002_create_node_observability_tables.sql rename to backend/OpenFlare/plugins/server/chmigrate/goose/clickhouse/202606200002_create_node_observability_tables.sql diff --git a/backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202607020001_optimize_analytics_tables.sql b/backend/OpenFlare/plugins/server/chmigrate/goose/clickhouse/202607020001_optimize_analytics_tables.sql similarity index 91% rename from backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202607020001_optimize_analytics_tables.sql rename to backend/OpenFlare/plugins/server/chmigrate/goose/clickhouse/202607020001_optimize_analytics_tables.sql index afa7bf94..b3a91898 100644 --- a/backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202607020001_optimize_analytics_tables.sql +++ b/backend/OpenFlare/plugins/server/chmigrate/goose/clickhouse/202607020001_optimize_analytics_tables.sql @@ -1,7 +1,5 @@ -- +goose Up -- Add TTL policies to analytics tables so ClickHouse can expire rows automatically. -ALTER TABLE w_user_access_logs MODIFY TTL created_at + INTERVAL 180 DAY; - -- DateTime64 columns must be cast for TTL (ClickHouse requires DateTime/Date in TTL expr). ALTER TABLE of_node_access_logs MODIFY TTL toDateTime(logged_at) + INTERVAL 90 DAY; diff --git a/backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202607020002_create_node_traffic_hourly_mv.sql b/backend/OpenFlare/plugins/server/chmigrate/goose/clickhouse/202607020002_create_node_traffic_hourly_mv.sql similarity index 100% rename from backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202607020002_create_node_traffic_hourly_mv.sql rename to backend/OpenFlare/plugins/server/chmigrate/goose/clickhouse/202607020002_create_node_traffic_hourly_mv.sql diff --git a/backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202607100001_create_node_metric_openresty_hourly.sql b/backend/OpenFlare/plugins/server/chmigrate/goose/clickhouse/202607100001_create_node_metric_openresty_hourly.sql similarity index 100% rename from backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202607100001_create_node_metric_openresty_hourly.sql rename to backend/OpenFlare/plugins/server/chmigrate/goose/clickhouse/202607100001_create_node_metric_openresty_hourly.sql diff --git a/backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202607100002_node_traffic_hourly_ttl_uv.sql b/backend/OpenFlare/plugins/server/chmigrate/goose/clickhouse/202607100002_node_traffic_hourly_ttl_uv.sql similarity index 100% rename from backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202607100002_node_traffic_hourly_ttl_uv.sql rename to backend/OpenFlare/plugins/server/chmigrate/goose/clickhouse/202607100002_node_traffic_hourly_ttl_uv.sql diff --git a/backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202607100003_backfill_metric_openresty_hourly.sql b/backend/OpenFlare/plugins/server/chmigrate/goose/clickhouse/202607100003_backfill_metric_openresty_hourly.sql similarity index 100% rename from backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202607100003_backfill_metric_openresty_hourly.sql rename to backend/OpenFlare/plugins/server/chmigrate/goose/clickhouse/202607100003_backfill_metric_openresty_hourly.sql diff --git a/backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202607120001_add_bytes_sent_to_node_access_logs.sql b/backend/OpenFlare/plugins/server/chmigrate/goose/clickhouse/202607120001_add_bytes_sent_to_node_access_logs.sql similarity index 100% rename from backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202607120001_add_bytes_sent_to_node_access_logs.sql rename to backend/OpenFlare/plugins/server/chmigrate/goose/clickhouse/202607120001_add_bytes_sent_to_node_access_logs.sql diff --git a/backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202607180001_access_log_request_length.sql b/backend/OpenFlare/plugins/server/chmigrate/goose/clickhouse/202607180001_access_log_request_length.sql similarity index 100% rename from backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202607180001_access_log_request_length.sql rename to backend/OpenFlare/plugins/server/chmigrate/goose/clickhouse/202607180001_access_log_request_length.sql diff --git a/backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202607180002_edge_health_access_log_hourly_drop_legacy.sql b/backend/OpenFlare/plugins/server/chmigrate/goose/clickhouse/202607180002_edge_health_access_log_hourly_drop_legacy.sql similarity index 100% rename from backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202607180002_edge_health_access_log_hourly_drop_legacy.sql rename to backend/OpenFlare/plugins/server/chmigrate/goose/clickhouse/202607180002_edge_health_access_log_hourly_drop_legacy.sql diff --git a/backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202607180003_backfill_access_log_hourly.sql b/backend/OpenFlare/plugins/server/chmigrate/goose/clickhouse/202607180003_backfill_access_log_hourly.sql similarity index 100% rename from backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202607180003_backfill_access_log_hourly.sql rename to backend/OpenFlare/plugins/server/chmigrate/goose/clickhouse/202607180003_backfill_access_log_hourly.sql diff --git a/backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202607180004_access_log_user_agent.sql b/backend/OpenFlare/plugins/server/chmigrate/goose/clickhouse/202607180004_access_log_user_agent.sql similarity index 100% rename from backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202607180004_access_log_user_agent.sql rename to backend/OpenFlare/plugins/server/chmigrate/goose/clickhouse/202607180004_access_log_user_agent.sql diff --git a/backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202607180005_access_log_cache_status.sql b/backend/OpenFlare/plugins/server/chmigrate/goose/clickhouse/202607180005_access_log_cache_status.sql similarity index 100% rename from backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202607180005_access_log_cache_status.sql rename to backend/OpenFlare/plugins/server/chmigrate/goose/clickhouse/202607180005_access_log_cache_status.sql diff --git a/backend/OpenFlare/plugins/server/migrations/postgres/00001_initial.sql b/backend/OpenFlare/plugins/server/migrations/postgres/00001_initial.sql new file mode 100644 index 00000000..8014cf12 --- /dev/null +++ b/backend/OpenFlare/plugins/server/migrations/postgres/00001_initial.sql @@ -0,0 +1,499 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS of_acme_accounts ( + id BIGSERIAL PRIMARY KEY, + email VARCHAR(255) NOT NULL DEFAULT '', + url VARCHAR(255) NOT NULL DEFAULT '', + private_key TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS of_apply_logs ( + id BIGSERIAL PRIMARY KEY, + node_id VARCHAR(64) NOT NULL, + version VARCHAR(32) NOT NULL, + result VARCHAR(32) NOT NULL, + message TEXT, + checksum VARCHAR(64) NOT NULL DEFAULT '', + main_config_checksum VARCHAR(64) NOT NULL DEFAULT '', + route_config_checksum VARCHAR(64) NOT NULL DEFAULT '', + support_file_count INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_of_apply_logs_created_at ON of_apply_logs(created_at); +CREATE INDEX IF NOT EXISTS idx_of_apply_logs_node_id ON of_apply_logs(node_id); + +CREATE TABLE IF NOT EXISTS of_cf_connections ( + id BIGSERIAL PRIMARY KEY, + source VARCHAR(32) NOT NULL DEFAULT '', + dns_account_id BIGINT, + "authorization" TEXT NOT NULL DEFAULT '', + status VARCHAR(16) NOT NULL DEFAULT '', + verified_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_of_cf_connections_dns_account_id ON of_cf_connections (dns_account_id); + +CREATE TABLE IF NOT EXISTS of_cf_pointing_groups ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(128) NOT NULL, + primary_node_id BIGINT NOT NULL, + backup_node_id BIGINT, + active_node_id BIGINT NOT NULL, + default_proxied BOOLEAN NOT NULL DEFAULT FALSE, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_of_cf_pointing_groups_active_node_id ON of_cf_pointing_groups (active_node_id); +CREATE INDEX IF NOT EXISTS idx_of_cf_pointing_groups_backup_node_id ON of_cf_pointing_groups (backup_node_id); +CREATE INDEX IF NOT EXISTS idx_of_cf_pointing_groups_primary_node_id ON of_cf_pointing_groups (primary_node_id); + +CREATE TABLE IF NOT EXISTS of_cf_pointing_members ( + id BIGSERIAL PRIMARY KEY, + group_id BIGINT NOT NULL, + zone_domain_id BIGINT NOT NULL, + proxied BOOLEAN NOT NULL DEFAULT FALSE, + cf_zone_id VARCHAR(64) NOT NULL DEFAULT '', + cf_record_id VARCHAR(64) NOT NULL DEFAULT '', + desired_ip VARCHAR(64) NOT NULL DEFAULT '', + sync_status VARCHAR(16) NOT NULL DEFAULT 'pending', + last_error TEXT NOT NULL DEFAULT '', + synced_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_of_cf_pointing_members_group_id ON of_cf_pointing_members (group_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_of_cf_pointing_members_zone_domain_id ON of_cf_pointing_members (zone_domain_id); + +CREATE TABLE IF NOT EXISTS of_config_versions ( + version VARCHAR(32) PRIMARY KEY, + snapshot_json TEXT NOT NULL, + main_config TEXT NOT NULL DEFAULT '', + rendered_config TEXT NOT NULL, + support_files_json TEXT NOT NULL DEFAULT '[]', + checksum VARCHAR(64) NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT FALSE, + created_by VARCHAR(64) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_of_config_versions_is_active ON of_config_versions (is_active); + +CREATE TABLE IF NOT EXISTS of_dns_accounts ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + type VARCHAR(64) NOT NULL, + "authorization" TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS of_node_access_logs ( + id BIGINT NOT NULL, + node_id VARCHAR(64) NOT NULL DEFAULT '', + logged_at TIMESTAMPTZ NOT NULL, + remote_addr VARCHAR(128) NOT NULL DEFAULT '', + region VARCHAR(128) NOT NULL DEFAULT '', + host VARCHAR(255) NOT NULL DEFAULT '', + path VARCHAR(2048) NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '', + cache_status VARCHAR(64) NOT NULL DEFAULT '', + status_code INTEGER NOT NULL DEFAULT 0, + bytes_sent BIGINT NOT NULL DEFAULT 0, + request_length BIGINT NOT NULL DEFAULT 0, + request_time_ms INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id, logged_at) +) PARTITION BY RANGE (logged_at); +CREATE INDEX IF NOT EXISTS idx_of_node_access_logs_host ON of_node_access_logs (host, logged_at DESC); +CREATE INDEX IF NOT EXISTS idx_of_node_access_logs_host_lower ON of_node_access_logs (lower(trim(host))); +CREATE INDEX IF NOT EXISTS idx_of_node_access_logs_logged_at ON of_node_access_logs (logged_at DESC, id DESC); +CREATE INDEX IF NOT EXISTS idx_of_node_access_logs_node_id ON of_node_access_logs (node_id, logged_at DESC); +CREATE INDEX IF NOT EXISTS idx_of_node_access_logs_remote_addr ON of_node_access_logs (remote_addr, logged_at DESC); +CREATE INDEX IF NOT EXISTS idx_of_node_access_logs_status_code ON of_node_access_logs (status_code, logged_at DESC); + +-- +goose StatementBegin +DO $$ +DECLARE + d date; +BEGIN + FOR d IN SELECT generate_series(date_trunc('month', now())::date, (date_trunc('month', now()) + interval '2 months')::date, interval '1 month')::date + LOOP + EXECUTE format('CREATE TABLE IF NOT EXISTS of_node_access_logs_%s PARTITION OF of_node_access_logs FOR VALUES FROM (%L) TO (%L)', + to_char(d, 'YYYYMM'), d, d + interval '1 month'); + END LOOP; +END $$; +-- +goose StatementEnd + +CREATE TABLE IF NOT EXISTS of_node_edge_health ( + id BIGINT NOT NULL PRIMARY KEY, + node_id VARCHAR(64) NOT NULL DEFAULT '', + captured_at TIMESTAMPTZ NOT NULL, + status VARCHAR(64) NOT NULL DEFAULT '', + connections BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_of_node_edge_health_node ON of_node_edge_health (node_id, captured_at DESC); + +CREATE TABLE IF NOT EXISTS of_node_health_events ( + id BIGSERIAL PRIMARY KEY, + node_id VARCHAR(64) NOT NULL, + event_type VARCHAR(64) NOT NULL, + severity VARCHAR(16) NOT NULL, + status VARCHAR(16) NOT NULL, + message TEXT, + first_triggered_at TIMESTAMPTZ NOT NULL, + last_triggered_at TIMESTAMPTZ NOT NULL, + reported_at TIMESTAMPTZ NOT NULL, + resolved_at TIMESTAMPTZ, + metadata_json TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_of_node_health_events_event_type ON of_node_health_events (event_type); +CREATE INDEX IF NOT EXISTS idx_of_node_health_events_first_triggered_at ON of_node_health_events (first_triggered_at); +CREATE INDEX IF NOT EXISTS idx_of_node_health_events_last_triggered_at ON of_node_health_events (last_triggered_at); +CREATE INDEX IF NOT EXISTS idx_of_node_health_events_node_id ON of_node_health_events (node_id); +CREATE INDEX IF NOT EXISTS idx_of_node_health_events_reported_at ON of_node_health_events (reported_at); +CREATE INDEX IF NOT EXISTS idx_of_node_health_events_resolved_at ON of_node_health_events (resolved_at); +CREATE INDEX IF NOT EXISTS idx_of_node_health_events_status ON of_node_health_events (status); + +CREATE TABLE IF NOT EXISTS of_node_metric_snapshots ( + id BIGINT NOT NULL PRIMARY KEY, + node_id VARCHAR(64) NOT NULL DEFAULT '', + captured_at TIMESTAMPTZ NOT NULL, + cpu_usage_percent DOUBLE PRECISION NOT NULL DEFAULT 0, + memory_used_bytes BIGINT NOT NULL DEFAULT 0, + memory_total_bytes BIGINT NOT NULL DEFAULT 0, + storage_used_bytes BIGINT NOT NULL DEFAULT 0, + storage_total_bytes BIGINT NOT NULL DEFAULT 0, + disk_read_bytes BIGINT NOT NULL DEFAULT 0, + disk_write_bytes BIGINT NOT NULL DEFAULT 0, + network_rx_bytes BIGINT NOT NULL DEFAULT 0, + network_tx_bytes BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_of_node_metric_snapshots_node ON of_node_metric_snapshots (node_id, captured_at DESC); + +CREATE TABLE IF NOT EXISTS of_node_obs_frpc ( + id BIGINT NOT NULL PRIMARY KEY, + node_id VARCHAR(64) NOT NULL DEFAULT '', + captured_at TIMESTAMPTZ NOT NULL, + tunnel_status VARCHAR(16) NOT NULL DEFAULT '', + connected_relays_count INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_of_node_obs_frpc_node ON of_node_obs_frpc (node_id, captured_at DESC); + +CREATE TABLE IF NOT EXISTS of_node_obs_frps ( + id BIGINT NOT NULL PRIMARY KEY, + node_id VARCHAR(64) NOT NULL DEFAULT '', + captured_at TIMESTAMPTZ NOT NULL, + frps_connections INTEGER NOT NULL DEFAULT 0, + frps_proxy_count INTEGER NOT NULL DEFAULT 0, + frps_client_count INTEGER NOT NULL DEFAULT 0, + frps_proxies TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_of_node_obs_frps_node ON of_node_obs_frps (node_id, captured_at DESC); + +CREATE TABLE IF NOT EXISTS of_node_system_profiles ( + id BIGSERIAL PRIMARY KEY, + node_id VARCHAR(64) NOT NULL, + hostname VARCHAR(255) NOT NULL DEFAULT '', + os_name VARCHAR(128) NOT NULL DEFAULT '', + os_version VARCHAR(128) NOT NULL DEFAULT '', + kernel_version VARCHAR(128) NOT NULL DEFAULT '', + architecture VARCHAR(64) NOT NULL DEFAULT '', + cpu_model VARCHAR(255) NOT NULL DEFAULT '', + cpu_cores INTEGER NOT NULL DEFAULT 0, + total_memory_bytes BIGINT NOT NULL DEFAULT 0, + total_disk_bytes BIGINT NOT NULL DEFAULT 0, + uptime_seconds BIGINT NOT NULL DEFAULT 0, + reported_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_of_node_system_profiles_node_id ON of_node_system_profiles (node_id); +CREATE INDEX IF NOT EXISTS idx_of_node_system_profiles_reported_at ON of_node_system_profiles (reported_at); + +CREATE TABLE IF NOT EXISTS of_nodes ( + id BIGSERIAL PRIMARY KEY, + node_id VARCHAR(64) NOT NULL, + name VARCHAR(128) NOT NULL, + ip VARCHAR(64) NOT NULL DEFAULT '', + ip_manual_override BOOLEAN NOT NULL DEFAULT FALSE, + geo_name VARCHAR(128) NOT NULL DEFAULT '', + geo_latitude DOUBLE PRECISION, + geo_longitude DOUBLE PRECISION, + geo_manual_override BOOLEAN NOT NULL DEFAULT FALSE, + access_token VARCHAR(128) NOT NULL DEFAULT '', + auto_update_enabled BOOLEAN NOT NULL DEFAULT FALSE, + update_requested BOOLEAN NOT NULL DEFAULT FALSE, + update_channel VARCHAR(16) NOT NULL DEFAULT 'stable', + update_tag VARCHAR(64) NOT NULL DEFAULT '', + restart_openresty_requested BOOLEAN NOT NULL DEFAULT FALSE, + version VARCHAR(64) NOT NULL DEFAULT '', + ext_version VARCHAR(64) NOT NULL DEFAULT '', + openresty_status VARCHAR(16) NOT NULL DEFAULT 'unknown', + openresty_message TEXT, + status VARCHAR(16) NOT NULL DEFAULT 'offline', + current_version VARCHAR(32) NOT NULL DEFAULT '', + last_seen_at TIMESTAMPTZ, + last_error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + node_type VARCHAR(32) NOT NULL DEFAULT 'edge_node', + relay_bind_port INTEGER NOT NULL DEFAULT 0, + relay_vhost_http_port INTEGER NOT NULL DEFAULT 0, + relay_auth_token VARCHAR(128) NOT NULL DEFAULT '', + relay_agent_access_addr VARCHAR(255) NOT NULL DEFAULT '', + relay_client_access_addr VARCHAR(255) NOT NULL DEFAULT '', + relay_client_proxy_url VARCHAR(512) NOT NULL DEFAULT '', + capabilities_json TEXT NOT NULL DEFAULT '[]', + relay_status VARCHAR(16) NOT NULL DEFAULT 'unknown', + relay_web_server_enabled BOOLEAN NOT NULL DEFAULT FALSE +); +CREATE INDEX IF NOT EXISTS idx_of_nodes_access_token ON of_nodes (access_token); +CREATE UNIQUE INDEX IF NOT EXISTS idx_of_nodes_node_id ON of_nodes (node_id); + +CREATE TABLE IF NOT EXISTS of_origins ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + address VARCHAR(255) NOT NULL, + remark VARCHAR(255) NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_of_origins_address ON of_origins (address); + +CREATE TABLE IF NOT EXISTS of_pages_deployment_files ( + id BIGSERIAL PRIMARY KEY, + deployment_id BIGINT NOT NULL, + path VARCHAR(2048) NOT NULL, + size BIGINT NOT NULL DEFAULT 0, + checksum VARCHAR(64) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_of_pages_deployment_files_deployment_id ON of_pages_deployment_files (deployment_id); + +CREATE TABLE IF NOT EXISTS of_pages_deployments ( + id BIGSERIAL PRIMARY KEY, + project_id BIGINT NOT NULL, + deployment_number INTEGER NOT NULL, + checksum VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'uploaded', + artifact_path VARCHAR(2048) NOT NULL DEFAULT '', + file_count INTEGER NOT NULL DEFAULT 0, + total_size BIGINT NOT NULL DEFAULT 0, + created_by VARCHAR(64) NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + activated_at TIMESTAMPTZ, + upload_id BIGINT NOT NULL DEFAULT 0, + source_type VARCHAR(32) NOT NULL DEFAULT '', + source_identity CHAR(64), + source_revision CHAR(64), + source_label VARCHAR(255) NOT NULL DEFAULT '', + source_meta TEXT NOT NULL DEFAULT '', + trigger_type VARCHAR(32) NOT NULL DEFAULT '' +); +CREATE INDEX IF NOT EXISTS idx_of_pages_deployments_checksum ON of_pages_deployments (checksum); +CREATE INDEX IF NOT EXISTS idx_of_pages_deployments_project_id ON of_pages_deployments (project_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_of_pages_deployments_project_number + ON of_pages_deployments (project_id, deployment_number); +CREATE UNIQUE INDEX IF NOT EXISTS idx_of_pages_deployments_source_revision + ON of_pages_deployments (project_id, source_identity, source_revision) + WHERE source_identity IS NOT NULL AND source_revision IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_of_pages_deployments_status ON of_pages_deployments (status); +CREATE INDEX IF NOT EXISTS idx_of_pages_deployments_upload_id ON of_pages_deployments (upload_id); + +CREATE TABLE IF NOT EXISTS of_pages_project_source_runtime ( + source_id BIGINT PRIMARY KEY, + etag VARCHAR(512) NOT NULL DEFAULT '', + last_seen_revision CHAR(64) NOT NULL DEFAULT '', + last_seen_detail TEXT NOT NULL DEFAULT '', + last_applied_revision CHAR(64) NOT NULL DEFAULT '', + last_applied_detail TEXT NOT NULL DEFAULT '', + sync_status VARCHAR(32) NOT NULL DEFAULT '', + last_error TEXT NOT NULL DEFAULT '', + last_checked_at TIMESTAMPTZ, + last_synced_at TIMESTAMPTZ, + next_check_at TIMESTAMPTZ, + lease_expires_at TIMESTAMPTZ, + lease_token VARCHAR(64) NOT NULL DEFAULT '', + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_of_pages_project_source_runtime_next_check_at + ON of_pages_project_source_runtime (next_check_at); + +CREATE TABLE IF NOT EXISTS of_pages_project_sources ( + id BIGSERIAL PRIMARY KEY, + project_id BIGINT NOT NULL, + source_type VARCHAR(32) NOT NULL DEFAULT '', + remote_url TEXT NOT NULL DEFAULT '', + allow_insecure BOOLEAN NOT NULL DEFAULT FALSE, + github_repository VARCHAR(255) NOT NULL DEFAULT '', + release_selector VARCHAR(16) NOT NULL DEFAULT '', + release_tag VARCHAR(255) NOT NULL DEFAULT '', + asset_name VARCHAR(255) NOT NULL DEFAULT '', + auto_update_enabled BOOLEAN NOT NULL DEFAULT FALSE, + check_interval_minutes INTEGER NOT NULL DEFAULT 0, + config_version INTEGER NOT NULL DEFAULT 0, + source_identity CHAR(64) NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_of_pages_project_sources_project_id + ON of_pages_project_sources (project_id); + +CREATE TABLE IF NOT EXISTS of_pages_projects ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + slug VARCHAR(128) NOT NULL, + description TEXT NOT NULL DEFAULT '', + enabled BOOLEAN NOT NULL DEFAULT TRUE, + spa_fallback_enabled BOOLEAN NOT NULL DEFAULT FALSE, + spa_fallback_path VARCHAR(512) NOT NULL DEFAULT '/index.html', + api_proxy_enabled BOOLEAN NOT NULL DEFAULT FALSE, + api_proxy_path VARCHAR(255) NOT NULL DEFAULT '', + api_proxy_pass VARCHAR(2048) NOT NULL DEFAULT '', + api_proxy_rewrite VARCHAR(255) NOT NULL DEFAULT '', + active_deployment_id BIGINT, + root_dir VARCHAR(512) NOT NULL DEFAULT '', + entry_file VARCHAR(512) NOT NULL DEFAULT 'index.html', + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + content_config_version INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_of_pages_projects_active_deployment_id ON of_pages_projects (active_deployment_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_of_pages_projects_slug ON of_pages_projects (slug); + +CREATE TABLE IF NOT EXISTS of_proxy_routes ( + id BIGSERIAL PRIMARY KEY, + site_name VARCHAR(255) NOT NULL DEFAULT '', + origin_id BIGINT, + origin_url VARCHAR(2048) NOT NULL, + origin_host VARCHAR(255) NOT NULL DEFAULT '', + upstreams TEXT NOT NULL DEFAULT '[]', + enabled BOOLEAN NOT NULL DEFAULT TRUE, + enable_https BOOLEAN NOT NULL DEFAULT FALSE, + redirect_http BOOLEAN NOT NULL DEFAULT FALSE, + limit_conn_per_server INTEGER NOT NULL DEFAULT 0, + limit_conn_per_ip INTEGER NOT NULL DEFAULT 0, + limit_rate VARCHAR(32) NOT NULL DEFAULT '', + cache_enabled BOOLEAN NOT NULL DEFAULT FALSE, + cache_policy VARCHAR(32) NOT NULL DEFAULT '', + cache_rules TEXT NOT NULL DEFAULT '[]', + custom_headers TEXT NOT NULL DEFAULT '[]', + basic_auth_enabled BOOLEAN NOT NULL DEFAULT FALSE, + basic_auth_username VARCHAR(255) NOT NULL DEFAULT '', + basic_auth_password VARCHAR(255) NOT NULL DEFAULT '', + upstream_type VARCHAR(32) NOT NULL DEFAULT 'direct', + tunnel_node_id BIGINT, + tunnel_target_addr VARCHAR(512) NOT NULL DEFAULT '', + tunnel_target_protocol VARCHAR(16) NOT NULL DEFAULT '', + pages_project_id BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + limit_req_per_ip VARCHAR(32) NOT NULL DEFAULT '' +); +CREATE INDEX IF NOT EXISTS idx_of_proxy_routes_origin_id ON of_proxy_routes (origin_id); +CREATE INDEX IF NOT EXISTS idx_of_proxy_routes_pages_project_id ON of_proxy_routes (pages_project_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_of_proxy_routes_site_name ON of_proxy_routes (site_name); +CREATE INDEX IF NOT EXISTS idx_of_proxy_routes_tunnel_node_id ON of_proxy_routes (tunnel_node_id); + +CREATE TABLE IF NOT EXISTS of_tls_certificates ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + cert_pem TEXT NOT NULL, + key_pem TEXT NOT NULL, + not_before TIMESTAMPTZ, + not_after TIMESTAMPTZ, + remark VARCHAR(255) NOT NULL DEFAULT '', + provider VARCHAR(64) NOT NULL DEFAULT 'upload', + acme_account_id BIGINT NOT NULL DEFAULT 0, + dns_account_id BIGINT NOT NULL DEFAULT 0, + key_algorithm VARCHAR(32) NOT NULL DEFAULT '', + auto_renew BOOLEAN NOT NULL DEFAULT FALSE, + primary_domain VARCHAR(255) NOT NULL DEFAULT '', + other_domains TEXT NOT NULL DEFAULT '', + disable_cname BOOLEAN NOT NULL DEFAULT FALSE, + skip_dns BOOLEAN NOT NULL DEFAULT FALSE, + dns1 VARCHAR(128) NOT NULL DEFAULT '', + dns2 VARCHAR(128) NOT NULL DEFAULT '', + apply_status VARCHAR(64) NOT NULL DEFAULT 'ready', + apply_message TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_of_tls_certificates_name ON of_tls_certificates (name); + +CREATE TABLE IF NOT EXISTS of_waf_ip_groups ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + type VARCHAR(32) NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + ip_list TEXT NOT NULL DEFAULT '[]', + auto_config TEXT NOT NULL DEFAULT '{}', + ext_ips TEXT NOT NULL DEFAULT '[]', + subscription_url VARCHAR(2048) NOT NULL DEFAULT '', + subscription_format VARCHAR(32) NOT NULL DEFAULT 'text', + subscription_mapping_rule VARCHAR(255) NOT NULL DEFAULT '', + sync_interval_minutes INTEGER NOT NULL DEFAULT 1440, + last_synced_at TIMESTAMPTZ, + next_sync_at TIMESTAMPTZ, + last_sync_status VARCHAR(32) NOT NULL DEFAULT '', + last_sync_message TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_of_waf_ip_groups_next_sync_at ON of_waf_ip_groups (next_sync_at); +CREATE INDEX IF NOT EXISTS idx_of_waf_ip_groups_type ON of_waf_ip_groups (type); + +CREATE TABLE IF NOT EXISTS of_waf_rule_group_bindings ( + id BIGSERIAL PRIMARY KEY, + rule_group_id BIGINT NOT NULL, + proxy_route_id BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + sequence INTEGER NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_of_waf_group_route ON of_waf_rule_group_bindings (rule_group_id, proxy_route_id); +CREATE INDEX IF NOT EXISTS idx_of_waf_rule_group_bindings_proxy_route_id ON of_waf_rule_group_bindings (proxy_route_id); + +CREATE TABLE IF NOT EXISTS of_waf_rule_groups ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + is_global BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + graph TEXT NOT NULL DEFAULT '', + revision BIGINT NOT NULL DEFAULT 1 +); +CREATE INDEX IF NOT EXISTS idx_of_waf_rule_groups_is_global ON of_waf_rule_groups (is_global); + +CREATE TABLE IF NOT EXISTS of_zone_domains ( + id BIGSERIAL PRIMARY KEY, + zone_id BIGINT NOT NULL, + proxy_route_id BIGINT, + domain VARCHAR(255) NOT NULL, + cert_id BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_of_zone_domains_cert_id ON of_zone_domains (cert_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_of_zone_domains_domain ON of_zone_domains (domain); +CREATE INDEX IF NOT EXISTS idx_of_zone_domains_proxy_route_id ON of_zone_domains (proxy_route_id); +CREATE INDEX IF NOT EXISTS idx_of_zone_domains_zone_id ON of_zone_domains (zone_id); + +CREATE TABLE IF NOT EXISTS of_zones ( + id BIGSERIAL PRIMARY KEY, + domain VARCHAR(255) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_of_zones_domain ON of_zones (domain); + +-- +goose Down +SELECT 1; diff --git a/backend/OpenFlare/plugins/server/migrations/sqlite/00001_initial.sql b/backend/OpenFlare/plugins/server/migrations/sqlite/00001_initial.sql new file mode 100644 index 00000000..950ffd41 --- /dev/null +++ b/backend/OpenFlare/plugins/server/migrations/sqlite/00001_initial.sql @@ -0,0 +1,485 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS of_acme_accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL DEFAULT '', + url TEXT NOT NULL DEFAULT '', + private_key TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS of_apply_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + node_id TEXT NOT NULL, + version TEXT NOT NULL, + result TEXT NOT NULL, + message TEXT, + checksum TEXT NOT NULL DEFAULT '', + main_config_checksum TEXT NOT NULL DEFAULT '', + route_config_checksum TEXT NOT NULL DEFAULT '', + support_file_count INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_of_apply_logs_created_at ON of_apply_logs(created_at); +CREATE INDEX IF NOT EXISTS idx_of_apply_logs_node_id ON of_apply_logs(node_id); + +CREATE TABLE IF NOT EXISTS of_cf_connections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source TEXT NOT NULL DEFAULT '', + dns_account_id INTEGER, + authorization TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT '', + verified_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_of_cf_connections_dns_account_id ON of_cf_connections (dns_account_id); + +CREATE TABLE IF NOT EXISTS of_cf_pointing_groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + primary_node_id INTEGER NOT NULL, + backup_node_id INTEGER, + active_node_id INTEGER NOT NULL, + default_proxied BOOLEAN NOT NULL DEFAULT FALSE, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_of_cf_pointing_groups_active_node_id ON of_cf_pointing_groups (active_node_id); +CREATE INDEX IF NOT EXISTS idx_of_cf_pointing_groups_backup_node_id ON of_cf_pointing_groups (backup_node_id); +CREATE INDEX IF NOT EXISTS idx_of_cf_pointing_groups_primary_node_id ON of_cf_pointing_groups (primary_node_id); + +CREATE TABLE IF NOT EXISTS of_cf_pointing_members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + group_id INTEGER NOT NULL, + zone_domain_id INTEGER NOT NULL, + proxied BOOLEAN NOT NULL DEFAULT FALSE, + cf_zone_id TEXT NOT NULL DEFAULT '', + cf_record_id TEXT NOT NULL DEFAULT '', + desired_ip TEXT NOT NULL DEFAULT '', + sync_status TEXT NOT NULL DEFAULT 'pending', + last_error TEXT NOT NULL DEFAULT '', + synced_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_of_cf_pointing_members_group_id ON of_cf_pointing_members (group_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_of_cf_pointing_members_zone_domain_id ON of_cf_pointing_members (zone_domain_id); + +CREATE TABLE IF NOT EXISTS of_config_versions ( + version VARCHAR(32) PRIMARY KEY, + snapshot_json TEXT NOT NULL, + main_config TEXT NOT NULL DEFAULT '', + rendered_config TEXT NOT NULL, + support_files_json TEXT NOT NULL DEFAULT '[]', + checksum VARCHAR(64) NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT FALSE, + created_by VARCHAR(64) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_of_config_versions_is_active ON of_config_versions (is_active); + +CREATE TABLE IF NOT EXISTS of_dns_accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + type TEXT NOT NULL, + authorization TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS of_node_access_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + node_id TEXT NOT NULL DEFAULT '', + logged_at DATETIME NOT NULL, + remote_addr TEXT NOT NULL DEFAULT '', + region TEXT NOT NULL DEFAULT '', + host TEXT NOT NULL DEFAULT '', + path TEXT NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '', + cache_status TEXT NOT NULL DEFAULT '', + status_code INTEGER NOT NULL DEFAULT 0, + bytes_sent INTEGER NOT NULL DEFAULT 0, + request_length INTEGER NOT NULL DEFAULT 0, + request_time_ms INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_of_node_access_logs_host ON of_node_access_logs (host, logged_at DESC); +CREATE INDEX IF NOT EXISTS idx_of_node_access_logs_host_lower ON of_node_access_logs (lower(trim(host))); +CREATE INDEX IF NOT EXISTS idx_of_node_access_logs_logged_at ON of_node_access_logs (logged_at DESC, id DESC); +CREATE INDEX IF NOT EXISTS idx_of_node_access_logs_node_id ON of_node_access_logs (node_id, logged_at DESC); +CREATE INDEX IF NOT EXISTS idx_of_node_access_logs_remote_addr ON of_node_access_logs (remote_addr, logged_at DESC); +CREATE INDEX IF NOT EXISTS idx_of_node_access_logs_status_code ON of_node_access_logs (status_code, logged_at DESC); + +CREATE TABLE IF NOT EXISTS of_node_edge_health ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + node_id TEXT NOT NULL DEFAULT '', + captured_at DATETIME NOT NULL, + status TEXT NOT NULL DEFAULT '', + connections INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_of_node_edge_health_node ON of_node_edge_health (node_id, captured_at DESC); + +CREATE TABLE IF NOT EXISTS of_node_health_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + node_id TEXT NOT NULL, + event_type TEXT NOT NULL, + severity TEXT NOT NULL, + status TEXT NOT NULL, + message TEXT, + first_triggered_at DATETIME NOT NULL, + last_triggered_at DATETIME NOT NULL, + reported_at DATETIME NOT NULL, + resolved_at DATETIME, + metadata_json TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_of_node_health_events_event_type ON of_node_health_events (event_type); +CREATE INDEX IF NOT EXISTS idx_of_node_health_events_first_triggered_at ON of_node_health_events (first_triggered_at); +CREATE INDEX IF NOT EXISTS idx_of_node_health_events_last_triggered_at ON of_node_health_events (last_triggered_at); +CREATE INDEX IF NOT EXISTS idx_of_node_health_events_node_id ON of_node_health_events (node_id); +CREATE INDEX IF NOT EXISTS idx_of_node_health_events_reported_at ON of_node_health_events (reported_at); +CREATE INDEX IF NOT EXISTS idx_of_node_health_events_resolved_at ON of_node_health_events (resolved_at); +CREATE INDEX IF NOT EXISTS idx_of_node_health_events_status ON of_node_health_events (status); + +CREATE TABLE IF NOT EXISTS of_node_metric_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + node_id TEXT NOT NULL DEFAULT '', + captured_at DATETIME NOT NULL, + cpu_usage_percent REAL NOT NULL DEFAULT 0, + memory_used_bytes INTEGER NOT NULL DEFAULT 0, + memory_total_bytes INTEGER NOT NULL DEFAULT 0, + storage_used_bytes INTEGER NOT NULL DEFAULT 0, + storage_total_bytes INTEGER NOT NULL DEFAULT 0, + disk_read_bytes INTEGER NOT NULL DEFAULT 0, + disk_write_bytes INTEGER NOT NULL DEFAULT 0, + network_rx_bytes INTEGER NOT NULL DEFAULT 0, + network_tx_bytes INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_of_node_metric_snapshots_node ON of_node_metric_snapshots (node_id, captured_at DESC); + +CREATE TABLE IF NOT EXISTS of_node_obs_frpc ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + node_id TEXT NOT NULL DEFAULT '', + captured_at DATETIME NOT NULL, + tunnel_status TEXT NOT NULL DEFAULT '', + connected_relays_count INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_of_node_obs_frpc_node ON of_node_obs_frpc (node_id, captured_at DESC); + +CREATE TABLE IF NOT EXISTS of_node_obs_frps ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + node_id TEXT NOT NULL DEFAULT '', + captured_at DATETIME NOT NULL, + frps_connections INTEGER NOT NULL DEFAULT 0, + frps_proxy_count INTEGER NOT NULL DEFAULT 0, + frps_client_count INTEGER NOT NULL DEFAULT 0, + frps_proxies TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_of_node_obs_frps_node ON of_node_obs_frps (node_id, captured_at DESC); + +CREATE TABLE IF NOT EXISTS of_node_system_profiles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + node_id TEXT NOT NULL, + hostname TEXT NOT NULL DEFAULT '', + os_name TEXT NOT NULL DEFAULT '', + os_version TEXT NOT NULL DEFAULT '', + kernel_version TEXT NOT NULL DEFAULT '', + architecture TEXT NOT NULL DEFAULT '', + cpu_model TEXT NOT NULL DEFAULT '', + cpu_cores INTEGER NOT NULL DEFAULT 0, + total_memory_bytes INTEGER NOT NULL DEFAULT 0, + total_disk_bytes INTEGER NOT NULL DEFAULT 0, + uptime_seconds INTEGER NOT NULL DEFAULT 0, + reported_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_of_node_system_profiles_node_id ON of_node_system_profiles (node_id); +CREATE INDEX IF NOT EXISTS idx_of_node_system_profiles_reported_at ON of_node_system_profiles (reported_at); + +CREATE TABLE IF NOT EXISTS of_nodes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + node_id TEXT NOT NULL, + name TEXT NOT NULL, + ip TEXT NOT NULL DEFAULT '', + ip_manual_override INTEGER NOT NULL DEFAULT 0, + geo_name TEXT NOT NULL DEFAULT '', + geo_latitude REAL, + geo_longitude REAL, + geo_manual_override INTEGER NOT NULL DEFAULT 0, + access_token TEXT NOT NULL DEFAULT '', + auto_update_enabled INTEGER NOT NULL DEFAULT 0, + update_requested INTEGER NOT NULL DEFAULT 0, + update_channel TEXT NOT NULL DEFAULT 'stable', + update_tag TEXT NOT NULL DEFAULT '', + restart_openresty_requested INTEGER NOT NULL DEFAULT 0, + version TEXT NOT NULL DEFAULT '', + ext_version TEXT NOT NULL DEFAULT '', + openresty_status TEXT NOT NULL DEFAULT 'unknown', + openresty_message TEXT, + status TEXT NOT NULL DEFAULT 'offline', + current_version TEXT NOT NULL DEFAULT '', + last_seen_at DATETIME, + last_error TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + node_type TEXT NOT NULL DEFAULT 'edge_node', + relay_bind_port INTEGER NOT NULL DEFAULT 0, + relay_vhost_http_port INTEGER NOT NULL DEFAULT 0, + relay_auth_token TEXT NOT NULL DEFAULT '', + relay_agent_access_addr TEXT NOT NULL DEFAULT '', + relay_client_access_addr TEXT NOT NULL DEFAULT '', + relay_client_proxy_url TEXT NOT NULL DEFAULT '', + capabilities_json TEXT NOT NULL DEFAULT '[]', + relay_status TEXT NOT NULL DEFAULT 'unknown', + relay_web_server_enabled INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_of_nodes_access_token ON of_nodes (access_token); +CREATE UNIQUE INDEX IF NOT EXISTS idx_of_nodes_node_id ON of_nodes (node_id); + +CREATE TABLE IF NOT EXISTS of_origins ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + address TEXT NOT NULL, + remark TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_of_origins_address ON of_origins (address); + +CREATE TABLE IF NOT EXISTS of_pages_deployment_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + deployment_id INTEGER NOT NULL, + path TEXT NOT NULL, + size INTEGER NOT NULL DEFAULT 0, + checksum TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_of_pages_deployment_files_deployment_id ON of_pages_deployment_files (deployment_id); + +CREATE TABLE IF NOT EXISTS of_pages_deployments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL, + deployment_number INTEGER NOT NULL, + checksum TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'uploaded', + artifact_path TEXT NOT NULL DEFAULT '', + file_count INTEGER NOT NULL DEFAULT 0, + total_size INTEGER NOT NULL DEFAULT 0, + created_by TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + activated_at DATETIME, + upload_id INTEGER NOT NULL DEFAULT 0, + source_type TEXT NOT NULL DEFAULT '', + source_identity TEXT, + source_revision TEXT, + source_label TEXT NOT NULL DEFAULT '', + source_meta TEXT NOT NULL DEFAULT '', + trigger_type TEXT NOT NULL DEFAULT '' +); +CREATE INDEX IF NOT EXISTS idx_of_pages_deployments_checksum ON of_pages_deployments (checksum); +CREATE INDEX IF NOT EXISTS idx_of_pages_deployments_project_id ON of_pages_deployments (project_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_of_pages_deployments_project_number + ON of_pages_deployments (project_id, deployment_number); +CREATE UNIQUE INDEX IF NOT EXISTS idx_of_pages_deployments_source_revision + ON of_pages_deployments (project_id, source_identity, source_revision) + WHERE source_identity IS NOT NULL AND source_revision IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_of_pages_deployments_status ON of_pages_deployments (status); +CREATE INDEX IF NOT EXISTS idx_of_pages_deployments_upload_id ON of_pages_deployments (upload_id); + +CREATE TABLE IF NOT EXISTS of_pages_project_source_runtime ( + source_id INTEGER PRIMARY KEY, + etag TEXT NOT NULL DEFAULT '', + last_seen_revision TEXT NOT NULL DEFAULT '', + last_seen_detail TEXT NOT NULL DEFAULT '', + last_applied_revision TEXT NOT NULL DEFAULT '', + last_applied_detail TEXT NOT NULL DEFAULT '', + sync_status TEXT NOT NULL DEFAULT '', + last_error TEXT NOT NULL DEFAULT '', + last_checked_at DATETIME, + last_synced_at DATETIME, + next_check_at DATETIME, + lease_expires_at DATETIME, + lease_token TEXT NOT NULL DEFAULT '', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_of_pages_project_source_runtime_next_check_at + ON of_pages_project_source_runtime (next_check_at); + +CREATE TABLE IF NOT EXISTS of_pages_project_sources ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL, + source_type TEXT NOT NULL DEFAULT '', + remote_url TEXT NOT NULL DEFAULT '', + allow_insecure INTEGER NOT NULL DEFAULT 0, + github_repository TEXT NOT NULL DEFAULT '', + release_selector TEXT NOT NULL DEFAULT '', + release_tag TEXT NOT NULL DEFAULT '', + asset_name TEXT NOT NULL DEFAULT '', + auto_update_enabled INTEGER NOT NULL DEFAULT 0, + check_interval_minutes INTEGER NOT NULL DEFAULT 0, + config_version INTEGER NOT NULL DEFAULT 0, + source_identity TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_of_pages_project_sources_project_id + ON of_pages_project_sources (project_id); + +CREATE TABLE IF NOT EXISTS of_pages_projects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + slug TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + enabled INTEGER NOT NULL DEFAULT 1, + spa_fallback_enabled INTEGER NOT NULL DEFAULT 0, + spa_fallback_path TEXT NOT NULL DEFAULT '/index.html', + api_proxy_enabled INTEGER NOT NULL DEFAULT 0, + api_proxy_path TEXT NOT NULL DEFAULT '', + api_proxy_pass TEXT NOT NULL DEFAULT '', + api_proxy_rewrite TEXT NOT NULL DEFAULT '', + active_deployment_id INTEGER, + root_dir TEXT NOT NULL DEFAULT '', + entry_file TEXT NOT NULL DEFAULT 'index.html', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + content_config_version INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_of_pages_projects_active_deployment_id ON of_pages_projects (active_deployment_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_of_pages_projects_slug ON of_pages_projects (slug); + +CREATE TABLE IF NOT EXISTS of_proxy_routes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_name TEXT NOT NULL DEFAULT '', + origin_id INTEGER, + origin_url TEXT NOT NULL, + origin_host TEXT NOT NULL DEFAULT '', + upstreams TEXT NOT NULL DEFAULT '[]', + enabled INTEGER NOT NULL DEFAULT 1, + enable_https INTEGER NOT NULL DEFAULT 0, + redirect_http INTEGER NOT NULL DEFAULT 0, + limit_conn_per_server INTEGER NOT NULL DEFAULT 0, + limit_conn_per_ip INTEGER NOT NULL DEFAULT 0, + limit_rate TEXT NOT NULL DEFAULT '', + cache_enabled INTEGER NOT NULL DEFAULT 0, + cache_policy TEXT NOT NULL DEFAULT '', + cache_rules TEXT NOT NULL DEFAULT '[]', + custom_headers TEXT NOT NULL DEFAULT '[]', + basic_auth_enabled INTEGER NOT NULL DEFAULT 0, + basic_auth_username TEXT NOT NULL DEFAULT '', + basic_auth_password TEXT NOT NULL DEFAULT '', + upstream_type TEXT NOT NULL DEFAULT 'direct', + tunnel_node_id INTEGER, + tunnel_target_addr TEXT NOT NULL DEFAULT '', + tunnel_target_protocol TEXT NOT NULL DEFAULT '', + pages_project_id INTEGER, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + limit_req_per_ip VARCHAR(32) NOT NULL DEFAULT '' +); +CREATE INDEX IF NOT EXISTS idx_of_proxy_routes_origin_id ON of_proxy_routes (origin_id); +CREATE INDEX IF NOT EXISTS idx_of_proxy_routes_pages_project_id ON of_proxy_routes (pages_project_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_of_proxy_routes_site_name ON of_proxy_routes (site_name); +CREATE INDEX IF NOT EXISTS idx_of_proxy_routes_tunnel_node_id ON of_proxy_routes (tunnel_node_id); + +CREATE TABLE IF NOT EXISTS of_tls_certificates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + cert_pem TEXT NOT NULL, + key_pem TEXT NOT NULL, + not_before DATETIME, + not_after DATETIME, + remark TEXT NOT NULL DEFAULT '', + provider TEXT NOT NULL DEFAULT 'upload', + acme_account_id INTEGER NOT NULL DEFAULT 0, + dns_account_id INTEGER NOT NULL DEFAULT 0, + key_algorithm TEXT NOT NULL DEFAULT '', + auto_renew INTEGER NOT NULL DEFAULT 0, + primary_domain TEXT NOT NULL DEFAULT '', + other_domains TEXT NOT NULL DEFAULT '', + disable_cname INTEGER NOT NULL DEFAULT 0, + skip_dns INTEGER NOT NULL DEFAULT 0, + dns1 TEXT NOT NULL DEFAULT '', + dns2 TEXT NOT NULL DEFAULT '', + apply_status TEXT NOT NULL DEFAULT 'ready', + apply_message TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_of_tls_certificates_name ON of_tls_certificates (name); + +CREATE TABLE IF NOT EXISTS of_waf_ip_groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + type TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + ip_list TEXT NOT NULL DEFAULT '[]', + auto_config TEXT NOT NULL DEFAULT '{}', + ext_ips TEXT NOT NULL DEFAULT '[]', + subscription_url TEXT NOT NULL DEFAULT '', + subscription_format TEXT NOT NULL DEFAULT 'text', + subscription_mapping_rule TEXT NOT NULL DEFAULT '', + sync_interval_minutes INTEGER NOT NULL DEFAULT 1440, + last_synced_at DATETIME, + next_sync_at DATETIME, + last_sync_status TEXT NOT NULL DEFAULT '', + last_sync_message TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_of_waf_ip_groups_next_sync_at ON of_waf_ip_groups (next_sync_at); +CREATE INDEX IF NOT EXISTS idx_of_waf_ip_groups_type ON of_waf_ip_groups (type); + +CREATE TABLE IF NOT EXISTS of_waf_rule_group_bindings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rule_group_id INTEGER NOT NULL, + proxy_route_id INTEGER NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + sequence INTEGER NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_of_waf_group_route ON of_waf_rule_group_bindings (rule_group_id, proxy_route_id); +CREATE INDEX IF NOT EXISTS idx_of_waf_rule_group_bindings_proxy_route_id ON of_waf_rule_group_bindings (proxy_route_id); + +CREATE TABLE IF NOT EXISTS of_waf_rule_groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + is_global BOOLEAN NOT NULL DEFAULT FALSE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + graph TEXT NOT NULL DEFAULT '', + revision INTEGER NOT NULL DEFAULT 1 +); +CREATE INDEX IF NOT EXISTS idx_of_waf_rule_groups_is_global ON of_waf_rule_groups (is_global); + +CREATE TABLE IF NOT EXISTS of_zone_domains ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + zone_id INTEGER NOT NULL, + proxy_route_id INTEGER, + domain TEXT NOT NULL, + cert_id INTEGER, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_of_zone_domains_cert_id ON of_zone_domains (cert_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_of_zone_domains_domain ON of_zone_domains (domain); +CREATE INDEX IF NOT EXISTS idx_of_zone_domains_proxy_route_id ON of_zone_domains (proxy_route_id); +CREATE INDEX IF NOT EXISTS idx_of_zone_domains_zone_id ON of_zone_domains (zone_id); + +CREATE TABLE IF NOT EXISTS of_zones ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + domain TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_of_zones_domain ON of_zones (domain); + +-- +goose Down +SELECT 1; diff --git a/backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202606190001_create_user_access_logs.sql b/backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202606190001_create_user_access_logs.sql deleted file mode 100644 index 578912b8..00000000 --- a/backend/OpenFlare/plugins/server/migrator/goose/clickhouse/202606190001_create_user_access_logs.sql +++ /dev/null @@ -1,21 +0,0 @@ --- +goose Up -CREATE TABLE IF NOT EXISTS w_user_access_logs -( - id UInt64, - user_id UInt64, - path String, - method String, - ip String, - user_agent String, - headers String, - status Int32, - latency Int64, - created_at DateTime -) -ENGINE = MergeTree() -PARTITION BY toYYYYMM(created_at) -ORDER BY (created_at, ip, user_id) -SETTINGS index_granularity = 8192; - --- +goose Down -DROP TABLE IF EXISTS w_user_access_logs; \ No newline at end of file diff --git a/backend/OpenFlare/plugins/server/migrator/legacy_dump_test.go b/backend/OpenFlare/plugins/server/migrator/legacy_dump_test.go deleted file mode 100644 index d72aa134..00000000 --- a/backend/OpenFlare/plugins/server/migrator/legacy_dump_test.go +++ /dev/null @@ -1,140 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package migrator - -import ( - "context" - "database/sql" - "fmt" - "os" - "strings" - "testing" - - "Wavelet/OpenFlare/plugins/server/openflare/zone" - - "github.com/glebarez/sqlite" - "github.com/pressly/goose/v3" - "github.com/stretchr/testify/require" - "gorm.io/gorm" -) - -// 记账表随迁移路径不同而不同(历史链用 goose_db_version,插件化后用 w_schema_versions), -// 比对 schema 时必须排除,否则 A/B 两路必然假性不一致。 -var bookkeepingTables = []string{"goose_db_version", "w_schema_versions"} - -// TestDumpLegacySchema 把当前 embed 内的完整历史链应用到临时 sqlite 库并导出 schema, -// 作为 Cordis 改造前后 schema 一致性的基线(A 路)与新架构产出(B 路)的唯一事实来源。 -// -// OF_DUMP_SCHEMA= 导出表/索引定义;OF_DUMP_VERSIONS= 导出已应用版本序列。 -func TestDumpLegacySchema(t *testing.T) { - dumpPath := os.Getenv("OF_DUMP_SCHEMA") - if dumpPath == "" && os.Getenv("OF_DUMP_VERSIONS") == "" { - t.Skip("OF_DUMP_SCHEMA / OF_DUMP_VERSIONS not set") - } - - conn, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - require.NoError(t, err) - sqlDB, err := conn.DB() - require.NoError(t, err) - // :memory: 连接池仅一条连接可见,否则 goose 与 dump 会看到不同的空库。 - sqlDB.SetMaxOpenConns(1) - - goose.SetBaseFS(migrationFS) - require.NoError(t, goose.SetDialect(dialectSqlite)) - - // 与 Migrate() 保持同一顺序:SQL 到 zone 导入标记 → Go 侧导入 → 其余 SQL。 - require.NoError(t, goose.UpTo(sqlDB, "goose/sqlite", zoneImportSQLVersion)) - require.NoError(t, runZoneImport(sqlDB)) - require.NoError(t, goose.Up(sqlDB, "goose/sqlite")) - - if dumpPath != "" { - schema := dumpSchema(t, sqlDB) - require.NoError(t, os.WriteFile(dumpPath, []byte(schema), 0o600)) - } - if vPath := os.Getenv("OF_DUMP_VERSIONS"); vPath != "" { - versions := dumpVersions(t, sqlDB) - require.NoError(t, os.WriteFile(vPath, []byte(versions), 0o600)) - } -} - -// runZoneImport 在空库上执行 Go 侧 zone 导入,验证升级窗口钩子本身可运行(空库应为零导入)。 -func runZoneImport(sqlDB *sql.DB) error { - ctx := context.Background() - tx, err := sqlDB.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("begin zone import: %w", err) - } - report, err := zone.ImportLegacyTx(ctx, tx, false) - if err != nil { - _ = tx.Rollback() - return report.LogAndReturn(err) - } - if err := tx.Commit(); err != nil { - return fmt.Errorf("commit zone import: %w", err) - } - return nil -} - -func dumpSchema(t *testing.T, sqlDB *sql.DB) string { - t.Helper() - query := `SELECT type, name, COALESCE(sql, '') FROM sqlite_master - WHERE type IN ('table', 'index') AND name NOT LIKE 'sqlite_%' - ORDER BY type, name` - rows, err := sqlDB.Query(query) - require.NoError(t, err) - defer func() { _ = rows.Close() }() - - var b strings.Builder - for rows.Next() { - var objType, name, ddl string - require.NoError(t, rows.Scan(&objType, &name, &ddl)) - if isBookkeeping(name) { - continue - } - fmt.Fprintf(&b, "-- %s %s\n%s;\n", objType, name, strings.TrimSpace(ddl)) - } - require.NoError(t, rows.Err()) - return b.String() + fmt.Sprintf("-- total objects (excl. bookkeeping): %d\n", countObjects(t, sqlDB)) -} - -func countObjects(t *testing.T, sqlDB *sql.DB) int { - t.Helper() - query := `SELECT COUNT(*) FROM sqlite_master WHERE type IN ('table','index') - AND name NOT LIKE 'sqlite_%'` - var n int - require.NoError(t, sqlDB.QueryRow(query).Scan(&n)) - for _, tbl := range bookkeepingTables { - var has int - require.NoError(t, sqlDB.QueryRow( - "SELECT COUNT(*) FROM sqlite_master WHERE name = ?", tbl).Scan(&has)) - n -= has - } - return n -} - -func dumpVersions(t *testing.T, sqlDB *sql.DB) string { - t.Helper() - rows, err := sqlDB.Query( - "SELECT version_id FROM goose_db_version WHERE is_applied = 1 ORDER BY version_id") - require.NoError(t, err) - defer func() { _ = rows.Close() }() - - var b strings.Builder - for rows.Next() { - var v int64 - require.NoError(t, rows.Scan(&v)) - fmt.Fprintf(&b, "%d\n", v) - } - require.NoError(t, rows.Err()) - return b.String() -} - -func isBookkeeping(name string) bool { - for _, tbl := range bookkeepingTables { - if name == tbl { - return true - } - } - return false -} diff --git a/backend/OpenFlare/plugins/server/migrator/migrator.go b/backend/OpenFlare/plugins/server/migrator/migrator.go deleted file mode 100644 index eae6f35a..00000000 --- a/backend/OpenFlare/plugins/server/migrator/migrator.go +++ /dev/null @@ -1,190 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -// Package migrator 提供数据库迁移功能 -package migrator - -import ( - "context" - "database/sql" - "embed" - "fmt" - "log" - - "Wavelet/OpenFlare/plugins/server/runtimeconfig" - db "Wavelet/plugins/infra/database" - "Wavelet/OpenFlare/plugins/server/openflare/zone" - "Wavelet/OpenFlare/plugins/server/repository" - - "github.com/pressly/goose/v3" -) - -// migrationFS contains SQL migrations under goose/. -// -//go:embed goose/postgres/*.sql goose/sqlite/*.sql -var migrationFS embed.FS - -// dbType 返回当前数据库类型名称(用于日志输出) -func dbType() string { - if !runtimeconfig.DatabaseEnabled() { - return "SQLite" - } - return "PostgreSQL" -} - -const ( - dialectSqlite = "sqlite3" - dialectPostgres = "postgres" - // zoneImportSQLVersion is the goose SQL marker after of_zones creation and - // before drop of legacy route domain columns. Zone data import runs only - // while DB version is in [zoneImportSQLVersion, zoneDropLegacySQLVersion). - zoneImportSQLVersion int64 = 202607120002 - zoneDropLegacySQLVersion int64 = 202607130001 -) - -// Report describes the database migration state observed during startup. -type Report struct { - Backend string - Enabled bool - Version int64 - Applied bool -} - -func gooseDialect() string { - if !runtimeconfig.DatabaseEnabled() { - return dialectSqlite - } - return dialectPostgres -} - -func migrationDir() string { - if !runtimeconfig.DatabaseEnabled() { - return "goose/sqlite" - } - return "goose/postgres" -} - -// Migrate 执行数据库迁移:全部结构变更走 goose SQL;Zone 历史域名导入在 SQL 之后自动执行。 -func Migrate() Report { - gormDB := db.DB(context.Background()) - if gormDB == nil { - log.Fatalf("[%s] database not initialized\n", dbType()) - } - - sqlDB, err := gormDB.DB() - if err != nil { - log.Fatalf("[%s] load sql db failed: %v\n", dbType(), err) - } - - goose.SetBaseFS(migrationFS) - if err := goose.SetDialect(gooseDialect()); err != nil { - log.Fatalf("[%s] set goose dialect failed: %v\n", dbType(), err) - } - if err := resyncGooseVersionSequence(sqlDB); err != nil { - log.Fatalf("[%s] resync goose_db_version sequence failed: %v\n", dbType(), err) - } - previousVersion, err := goose.GetDBVersion(sqlDB) - if err != nil { - log.Fatalf("[%s] get goose version failed: %v\n", dbType(), err) - } - // 1) SQL up to zone-import marker (includes of_zones DDL; still has legacy columns). - if err := goose.UpTo(sqlDB, migrationDir(), zoneImportSQLVersion); err != nil { - log.Fatalf("[%s] goose migrate (up to zone import) failed: %v\n", dbType(), err) - } - // 2) Import legacy domains only during the one-time upgrade window: - // version in [202607120002, 202607130001). After phase-2 drop is applied, - // this is skipped on every subsequent startup. - if err := maybeImportZoneDomains(sqlDB); err != nil { - log.Fatalf("[%s] zone domain import failed: %v\n", dbType(), err) - } - // 3) Remaining SQL (drop legacy columns / managed_domains, later migrations). - if err := goose.Up(sqlDB, migrationDir()); err != nil { - log.Fatalf("[%s] goose migrate failed: %v\n", dbType(), err) - } - - clearSystemConfigCache() - currentVersion, err := goose.GetDBVersion(sqlDB) - if err != nil { - log.Fatalf("[%s] get migrated goose version failed: %v\n", dbType(), err) - } - - log.Printf("[%s] goose migrate success\n", dbType()) - return Report{ - Backend: dbType(), - Enabled: true, - Version: currentVersion, - Applied: currentVersion != previousVersion, - } -} - -// maybeImportZoneDomains runs legacy→Zone import only when the DB is still in the -// pre-drop upgrade window. After 202607130001 is applied, this is a no-op and is -// not executed on normal restarts. -func maybeImportZoneDomains(sqlDB *sql.DB) error { - version, err := goose.GetDBVersion(sqlDB) - if err != nil { - return fmt.Errorf("get goose db version: %w", err) - } - if version < zoneImportSQLVersion || version >= zoneDropLegacySQLVersion { - return nil - } - - ctx := context.Background() - tx, err := sqlDB.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("begin zone import transaction: %w", err) - } - report, err := zone.ImportLegacyTx(ctx, tx, gooseDialect() == dialectPostgres) - if err != nil { - _ = tx.Rollback() - return report.LogAndReturn(err) - } - if err := tx.Commit(); err != nil { - return fmt.Errorf("commit zone import: %w", err) - } - if report.Zones > 0 || report.Domains > 0 { - log.Printf( - "[%s] imported zone domains automatically: zones=%d domains=%d\n", - dbType(), report.Zones, report.Domains, - ) - } - return nil -} - -// resyncGooseVersionSequence 修复 PostgreSQL 下 goose_db_version.id 自增序列落后于 -// MAX(id) 的问题(常见于从 dump 恢复或历史迁移以显式 id 复制数据后)。序列落后会 -// 导致 goose 记录新版本号时 INSERT 命中 goose_db_version_pkey 唯一约束冲突。 -// 仅在表已存在且为 PostgreSQL 方言时执行;SQLite 使用 AUTOINCREMENT 不受影响。 -func resyncGooseVersionSequence(sqlDB *sql.DB) error { - if gooseDialect() != dialectPostgres { - return nil - } - - ctx := context.Background() - var exists bool - if err := sqlDB.QueryRowContext(ctx, - "SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema='public' AND table_name='goose_db_version')", - ).Scan(&exists); err != nil { - return fmt.Errorf("check goose_db_version existence failed: %w", err) - } - if !exists { - return nil - } - - const resyncSQL = `SELECT setval( - pg_get_serial_sequence('goose_db_version', 'id'), - GREATEST(COALESCE((SELECT MAX(id) FROM goose_db_version), 1), 1), - (SELECT MAX(id) IS NOT NULL FROM goose_db_version) - )` - if _, err := sqlDB.ExecContext(ctx, resyncSQL); err != nil { - return fmt.Errorf("setval goose_db_version sequence failed: %w", err) - } - return nil -} - -func clearSystemConfigCache() { - if err := repository.InvalidateAllSystemConfigCaches(context.Background()); err != nil { - log.Printf("[%s] clear system config cache failed: %v\n", dbType(), err) - } -} diff --git a/backend/OpenFlare/plugins/server/migrator/migrator_test.go b/backend/OpenFlare/plugins/server/migrator/migrator_test.go deleted file mode 100644 index 5016569c..00000000 --- a/backend/OpenFlare/plugins/server/migrator/migrator_test.go +++ /dev/null @@ -1,178 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package migrator - -import ( - "context" - "strings" - "testing" - - "Wavelet/OpenFlare/plugins/server/runtimeconfig" - db "Wavelet/plugins/infra/database" - "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/OpenFlare/plugins/server/repository" - - "github.com/alicebob/miniredis/v2" - "github.com/glebarez/sqlite" - "github.com/redis/go-redis/v9" - "github.com/redis/go-redis/v9/maintnotifications" - "gorm.io/gorm" -) - -// expectedMigratedSystemConfigCount 为全新库执行全部迁移后 w_system_configs 的行数 -// (初始系统配置 + 各期配置迁移/新增 seed:of_options 迁移、文件白名单、磁盘缓存、 -// 登录会话 TTL、升级源、存储、FRPS Web UI、Pages、OpenResty 限流、单 IP 限频、 -// 错误页、SW 离线、日志保留期、指标保留期等);新增配置 seed 迁移时需同步更新本常量。 -const expectedMigratedSystemConfigCount = 95 - -func TestMigrateInitializesSQLiteDatabase(t *testing.T) { - sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ - DisableForeignKeyConstraintWhenMigrating: true, - }) - if err != nil { - t.Fatalf("gorm.Open(sqlite) error = %v", err) - } - - mr, err := miniredis.Run() - if err != nil { - t.Fatalf("miniredis.Run() error = %v", err) - } - redisClient := redis.NewClient(&redis.Options{ - Addr: mr.Addr(), - MaintNotificationsConfig: &maintnotifications.Config{ - Mode: maintnotifications.ModeDisabled, - }, - }) - - db.SetDB(sqliteDB) - t.Cleanup(func() { - db.SetDB(nil) - _ = redisClient.Close() - mr.Close() - }) - t.Cleanup(runtimeconfig.Override(false, runtimeconfig.ClickHouseEnabled())) - - Migrate() - - var systemConfigCount int64 - if err := sqliteDB.Table("w_system_configs").Count(&systemConfigCount).Error; err != nil { - t.Fatalf("Migrate() count w_system_configs error = %v", err) - } - if systemConfigCount != expectedMigratedSystemConfigCount { - t.Errorf("Migrate() w_system_configs count = %d, want %d", systemConfigCount, expectedMigratedSystemConfigCount) - } - - var adminCount int64 - if err := sqliteDB.Table("w_users").Where("username = ?", "admin").Count(&adminCount).Error; err != nil { - t.Fatalf("Migrate() count admin user error = %v", err) - } - if adminCount != 1 { - t.Errorf("Migrate() admin user count = %d, want %d", adminCount, 1) - } - - var templateCount int64 - if err := sqliteDB.Table("w_templates").Count(&templateCount).Error; err != nil { - t.Fatalf("Migrate() count templates error = %v", err) - } - if templateCount != 2 { - t.Errorf("Migrate() templates count = %d, want %d", templateCount, 2) - } - - if !sqliteDB.Migrator().HasTable("of_zones") { - t.Error("Migrate() did not create of_zones") - } - if !sqliteDB.Migrator().HasTable("of_zone_domains") { - t.Error("Migrate() did not create of_zone_domains") - } - for _, table := range []string{ - "of_cf_connections", - "of_cf_pointing_groups", - "of_cf_pointing_members", - } { - if !sqliteDB.Migrator().HasTable(table) { - t.Errorf("Migrate() did not create %s", table) - } - } - if !sqliteDB.Migrator().HasColumn("of_cf_connections", "authorization") { - t.Error("Migrate() did not create of_cf_connections.authorization") - } - if sqliteDB.Migrator().HasTable("of_managed_domains") { - t.Error("Migrate() should drop of_managed_domains after phase-2 cleanup") - } - if sqliteDB.Migrator().HasColumn(&model.ProxyRoute{}, "domain") { - t.Error("Migrate() should drop of_proxy_routes.domain after phase-2 cleanup") - } - if sqliteDB.Migrator().HasColumn(&model.ProxyRoute{}, "domains") { - t.Error("Migrate() should drop of_proxy_routes.domains after phase-2 cleanup") - } - if sqliteDB.Migrator().HasColumn(&model.ProxyRoute{}, "cert_id") { - t.Error("Migrate() should drop of_proxy_routes.cert_id after phase-2 cleanup") - } - if sqliteDB.Migrator().HasColumn(&model.ProxyRoute{}, "cert_ids") { - t.Error("Migrate() should drop of_proxy_routes.cert_ids after phase-2 cleanup") - } - if sqliteDB.Migrator().HasColumn(&model.ProxyRoute{}, "domain_cert_ids") { - t.Error("Migrate() should drop of_proxy_routes.domain_cert_ids after phase-2 cleanup") - } - - zone := model.Zone{Domain: "example.com"} - if err := sqliteDB.Create(&zone).Error; err != nil { - t.Fatalf("Migrate() create Zone error = %v", err) - } - if err := sqliteDB.Create(&model.Zone{Domain: zone.Domain}).Error; err == nil { - t.Error("Migrate() allowed duplicate of_zones.domain") - } - - domain := model.ZoneDomain{ZoneID: zone.ID, Domain: "api.example.com"} - if err := sqliteDB.Create(&domain).Error; err != nil { - t.Fatalf("Migrate() create ZoneDomain error = %v", err) - } - if err := sqliteDB.Create(&model.ZoneDomain{ZoneID: zone.ID, Domain: domain.Domain}).Error; err == nil { - t.Error("Migrate() allowed duplicate of_zone_domains.domain") - } - - if err := sqliteDB.Exec(`INSERT INTO of_cf_pointing_members - (group_id, zone_domain_id, proxied, cf_zone_id, cf_record_id, desired_ip, sync_status, last_error) - VALUES (?, ?, ?, '', '', '', 'pending', '')`, 1, domain.ID, false).Error; err != nil { - t.Fatalf("Migrate() insert Cloudflare member error = %v", err) - } - if err := sqliteDB.Exec(`INSERT INTO of_cf_pointing_members - (group_id, zone_domain_id, proxied, cf_zone_id, cf_record_id, desired_ip, sync_status, last_error) - VALUES (?, ?, ?, '', '', '', 'pending', '')`, 2, domain.ID, false).Error; err == nil { - t.Error("Migrate() allowed duplicate of_cf_pointing_members.zone_domain_id") - } -} - -func TestCloudflarePointingPostgresMigrationQuotesAuthorizationColumn(t *testing.T) { - content, err := migrationFS.ReadFile("goose/postgres/202608040001_create_cloudflare_pointing.sql") - if err != nil { - t.Fatalf("read Cloudflare pointing migration: %v", err) - } - if !strings.Contains(string(content), `"authorization" TEXT NOT NULL DEFAULT ''`) { - t.Error("Cloudflare pointing PostgreSQL migration must quote reserved column authorization") - } -} - -func TestMigrateSeedsSystemConfigs(t *testing.T) { - sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ - DisableForeignKeyConstraintWhenMigrating: true, - }) - if err != nil { - t.Fatalf("gorm.Open(sqlite) error = %v", err) - } - - db.SetDB(sqliteDB) - t.Cleanup(func() { db.SetDB(nil) }) - t.Cleanup(runtimeconfig.Override(false, runtimeconfig.ClickHouseEnabled())) - - Migrate() - - enabled, err := repository.GetBoolByKey(context.Background(), model.ConfigKeyEmailLoginVerificationEnabled) - if err != nil { - t.Fatalf("GetBoolByKey(%s) error = %v", model.ConfigKeyEmailLoginVerificationEnabled, err) - } - if enabled { - t.Fatalf("GetBoolByKey(%s) = true, want false", model.ConfigKeyEmailLoginVerificationEnabled) - } -} diff --git a/backend/OpenFlare/plugins/server/migrator/pages_source_migration_test.go b/backend/OpenFlare/plugins/server/migrator/pages_source_migration_test.go deleted file mode 100644 index e44c2f2b..00000000 --- a/backend/OpenFlare/plugins/server/migrator/pages_source_migration_test.go +++ /dev/null @@ -1,323 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package migrator - -import ( - "database/sql" - "fmt" - "os" - "strings" - "testing" - "time" - - "Wavelet/OpenFlare/plugins/server/model" - - "github.com/glebarez/sqlite" - "github.com/pressly/goose/v3" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "gorm.io/driver/postgres" - "gorm.io/gorm" -) - -const ( - pagesSourcePreviousMigration = int64(202607190001) - pagesSourceMigration = int64(202607190002) - pagesMigrationProjectID = uint(900001) - pagesMigrationDeploymentID = uint(900001) -) - -func TestPagesSourceMigrationSQLiteUpDownUp(t *testing.T) { - dbPath := t.TempDir() + "/pages-source-migration.db" - gormDB, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{ - DisableForeignKeyConstraintWhenMigrating: true, - }) - require.NoError(t, err) - - sqlDB, err := gormDB.DB() - require.NoError(t, err) - sqlDB.SetMaxOpenConns(1) - t.Cleanup(func() { require.NoError(t, sqlDB.Close()) }) - - runPagesSourceMigrationUpDownUp(t, gormDB, sqlDB, dialectSqlite, "goose/sqlite") - - var indexSQL string - require.NoError(t, gormDB.Raw( - "SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?", - "idx_of_pages_deployments_source_revision", - ).Scan(&indexSQL).Error) - assert.Contains(t, strings.ToUpper(indexSQL), "WHERE SOURCE_IDENTITY IS NOT NULL AND SOURCE_REVISION IS NOT NULL") -} - -func TestPagesSourceMigrationPostgresUpDownUp(t *testing.T) { - dsn := strings.TrimSpace(os.Getenv("OPENFLARE_TEST_POSTGRES_DSN")) - if dsn == "" { - t.Skip("OPENFLARE_TEST_POSTGRES_DSN is not set") - } - - gormDB, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ - DisableForeignKeyConstraintWhenMigrating: true, - }) - require.NoError(t, err) - sqlDB, err := gormDB.DB() - require.NoError(t, err) - sqlDB.SetMaxOpenConns(1) - - schema := fmt.Sprintf("pages_source_migration_%d", time.Now().UnixNano()) - require.Regexp(t, `^[a-z0-9_]+$`, schema) - require.NoError(t, gormDB.Exec(`CREATE SCHEMA "`+schema+`"`).Error) - require.NoError(t, gormDB.Exec(`SET search_path TO "`+schema+`"`).Error) - t.Cleanup(func() { - assert.NoError(t, gormDB.Exec("SET search_path TO public").Error) - assert.NoError(t, gormDB.Exec(`DROP SCHEMA IF EXISTS "`+schema+`" CASCADE`).Error) - assert.NoError(t, sqlDB.Close()) - }) - - runPagesSourceMigrationUpDownUp(t, gormDB, sqlDB, dialectPostgres, "goose/postgres") -} - -func runPagesSourceMigrationUpDownUp( - t *testing.T, - gormDB *gorm.DB, - sqlDB *sql.DB, - dialect string, - dir string, -) { - t.Helper() - - goose.SetBaseFS(migrationFS) - require.NoError(t, goose.SetDialect(dialect)) - require.NoError(t, goose.UpTo(sqlDB, dir, pagesSourcePreviousMigration)) - seedPrePagesSourceMigrationData(t, gormDB) - - require.NoError(t, goose.UpTo(sqlDB, dir, pagesSourceMigration)) - assertPagesSourceMigrationUp(t, gormDB) - - require.NoError(t, goose.DownTo(sqlDB, dir, pagesSourcePreviousMigration)) - assertPagesSourceMigrationDown(t, gormDB) - - require.NoError(t, goose.UpTo(sqlDB, dir, pagesSourceMigration)) - assertPagesSourceMigrationUpAgain(t, gormDB) -} - -func seedPrePagesSourceMigrationData(t *testing.T, gormDB *gorm.DB) { - t.Helper() - - require.NoError(t, gormDB.Exec(` - INSERT INTO of_pages_projects ( - id, name, slug, description, enabled, active_deployment_id, root_dir, entry_file - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, - pagesMigrationProjectID, - "Migration Site", - "migration-site", - "keep-project-data", - true, - pagesMigrationDeploymentID, - "public", - "home.html", - ).Error) - require.NoError(t, gormDB.Exec(` - INSERT INTO of_pages_deployments ( - id, project_id, deployment_number, checksum, status, upload_id, artifact_path, - file_count, total_size, created_by - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - pagesMigrationDeploymentID, - pagesMigrationProjectID, - 1, - strings.Repeat("a", 64), - model.PagesDeploymentStatusActive, - uint64(700001), - "legacy/package.zip", - 2, - int64(128), - "user:1", - ).Error) -} - -func assertPagesSourceMigrationUp(t *testing.T, gormDB *gorm.DB) { - t.Helper() - migrator := gormDB.Migrator() - assert.True(t, migrator.HasTable(&model.PagesProjectSource{})) - assert.True(t, migrator.HasTable(&model.PagesProjectSourceRuntime{})) - assert.True(t, migrator.HasColumn(&model.PagesProject{}, "ContentConfigVersion")) - assert.True(t, migrator.HasColumn(&model.PagesDeployment{}, "SourceType")) - assert.True(t, migrator.HasColumn(&model.PagesDeployment{}, "SourceIdentity")) - assert.True(t, migrator.HasColumn(&model.PagesDeployment{}, "SourceRevision")) - assert.True(t, migrator.HasIndex(&model.PagesProjectSource{}, "idx_of_pages_project_sources_project_id")) - assert.True(t, migrator.HasIndex(&model.PagesProjectSourceRuntime{}, "idx_of_pages_project_source_runtime_next_check_at")) - assert.True(t, migrator.HasIndex(&model.PagesDeployment{}, "idx_of_pages_deployments_project_number")) - assert.True(t, migrator.HasIndex(&model.PagesDeployment{}, "idx_of_pages_deployments_source_revision")) - - var project model.PagesProject - require.NoError(t, gormDB.First(&project, pagesMigrationProjectID).Error) - assert.Equal(t, 0, project.ContentConfigVersion) - assert.Equal(t, "keep-project-data", project.Description) - assert.Equal(t, "public", project.RootDir) - assert.Equal(t, "home.html", project.EntryFile) - - var deployment model.PagesDeployment - require.NoError(t, gormDB.First(&deployment, pagesMigrationDeploymentID).Error) - assert.Equal(t, "manual_upload", deployment.SourceType) - assert.Equal(t, "manual_upload", deployment.TriggerType) - assert.Nil(t, deployment.SourceIdentity) - assert.Nil(t, deployment.SourceRevision) - assert.Equal(t, uint64(700001), deployment.UploadID) - - sourceID := createMigrationSourceRuntime(t, gormDB) - assertPagesSourceConstraints(t, gormDB, sourceID) -} - -func createMigrationSourceRuntime(t *testing.T, gormDB *gorm.DB) uint { - t.Helper() - source := model.PagesProjectSource{ - ProjectID: pagesMigrationProjectID, - SourceType: "remote_url", - RemoteURL: "https://example.com/site.zip?token=secret", - AllowInsecure: false, - CheckIntervalMinutes: 0, - ConfigVersion: 1, - SourceIdentity: strings.Repeat("b", 64), - } - require.NoError(t, gormDB.Create(&source).Error) - require.NotZero(t, source.ID) - require.NoError(t, gormDB.Create(&model.PagesProjectSourceRuntime{ - SourceID: source.ID, - SyncStatus: "idle", - }).Error) - return source.ID -} - -func assertPagesSourceConstraints(t *testing.T, gormDB *gorm.DB, sourceID uint) { - t.Helper() - - duplicateSource := model.PagesProjectSource{ - ProjectID: pagesMigrationProjectID, - SourceType: "remote_url", - ConfigVersion: 1, - SourceIdentity: strings.Repeat("c", 64), - } - require.Error(t, gormDB.Create(&duplicateSource).Error) - - for number := 2; number <= 3; number++ { - require.NoError(t, createMigrationDeployment( - gormDB, - number, - strings.Repeat(string(rune('a'+number)), 64), - nil, - nil, - )) - } - - identity := strings.Repeat("d", 64) - revision := strings.Repeat("e", 64) - require.NoError(t, createMigrationDeployment( - gormDB, - 4, - strings.Repeat("f", 64), - &identity, - &revision, - )) - require.Error(t, createMigrationDeployment( - gormDB, - 5, - strings.Repeat("0", 64), - &identity, - &revision, - )) - require.Error(t, createMigrationDeployment( - gormDB, - 1, - strings.Repeat("1", 64), - nil, - nil, - )) - - var runtime model.PagesProjectSourceRuntime - require.NoError(t, gormDB.First(&runtime, sourceID).Error) - assert.Equal(t, "idle", runtime.SyncStatus) -} - -func createMigrationDeployment( - gormDB *gorm.DB, - deploymentNumber int, - checksum string, - identity *string, - revision *string, -) error { - return gormDB.Create(&model.PagesDeployment{ - ProjectID: pagesMigrationProjectID, - DeploymentNumber: deploymentNumber, - Checksum: checksum, - Status: model.PagesDeploymentStatusUploaded, - UploadID: uint64(710000 + deploymentNumber), - ArtifactPath: fmt.Sprintf("legacy/%d.zip", deploymentNumber), - SourceType: "manual_upload", - SourceIdentity: identity, - SourceRevision: revision, - TriggerType: "manual_upload", - }).Error -} - -func assertPagesSourceMigrationDown(t *testing.T, gormDB *gorm.DB) { - t.Helper() - migrator := gormDB.Migrator() - assert.False(t, migrator.HasTable(&model.PagesProjectSource{})) - assert.False(t, migrator.HasTable(&model.PagesProjectSourceRuntime{})) - assert.False(t, migrator.HasColumn(&model.PagesProject{}, "ContentConfigVersion")) - assert.False(t, migrator.HasColumn(&model.PagesDeployment{}, "SourceType")) - assert.False(t, migrator.HasColumn(&model.PagesDeployment{}, "SourceIdentity")) - assert.False(t, migrator.HasColumn(&model.PagesDeployment{}, "SourceRevision")) - assert.False(t, migrator.HasIndex(&model.PagesDeployment{}, "idx_of_pages_deployments_project_number")) - assert.False(t, migrator.HasIndex(&model.PagesDeployment{}, "idx_of_pages_deployments_source_revision")) - assert.True(t, migrator.HasIndex(&model.PagesDeployment{}, "idx_of_pages_deployments_project_id")) - assert.True(t, migrator.HasIndex(&model.PagesDeployment{}, "idx_of_pages_deployments_upload_id")) - - var project struct { - Description string - RootDir string - EntryFile string - ActiveDeploymentID *uint - } - require.NoError(t, gormDB.Table("of_pages_projects").Where("id = ?", pagesMigrationProjectID).Take(&project).Error) - assert.Equal(t, "keep-project-data", project.Description) - assert.Equal(t, "public", project.RootDir) - assert.Equal(t, "home.html", project.EntryFile) - require.NotNil(t, project.ActiveDeploymentID) - assert.Equal(t, pagesMigrationDeploymentID, *project.ActiveDeploymentID) - - var deployment struct { - UploadID uint64 - ArtifactPath string - FileCount int - TotalSize int64 - } - require.NoError(t, gormDB.Table("of_pages_deployments").Where("id = ?", pagesMigrationDeploymentID).Take(&deployment).Error) - assert.Equal(t, uint64(700001), deployment.UploadID) - assert.Equal(t, "legacy/package.zip", deployment.ArtifactPath) - assert.Equal(t, 2, deployment.FileCount) - assert.Equal(t, int64(128), deployment.TotalSize) - - var count int64 - require.NoError(t, gormDB.Table("of_pages_deployments").Where("project_id = ?", pagesMigrationProjectID).Count(&count).Error) - assert.Equal(t, int64(4), count) -} - -func assertPagesSourceMigrationUpAgain(t *testing.T, gormDB *gorm.DB) { - t.Helper() - assert.True(t, gormDB.Migrator().HasTable(&model.PagesProjectSource{})) - assert.True(t, gormDB.Migrator().HasTable(&model.PagesProjectSourceRuntime{})) - assert.True(t, gormDB.Migrator().HasColumn(&model.PagesProject{}, "ContentConfigVersion")) - assert.True(t, gormDB.Migrator().HasColumn(&model.PagesDeployment{}, "SourceRevision")) - - var count int64 - require.NoError(t, gormDB.Table("of_pages_deployments"). - Where("project_id = ? AND source_type = ? AND trigger_type = ?", pagesMigrationProjectID, "manual_upload", "manual_upload"). - Count(&count).Error) - assert.Equal(t, int64(4), count) - - require.NoError(t, gormDB.Table("of_pages_project_sources").Count(&count).Error) - assert.Zero(t, count, "source config is intentionally removed by Down and is not reconstructable") -} diff --git a/backend/OpenFlare/plugins/server/migrator/pages_source_scan_migration_test.go b/backend/OpenFlare/plugins/server/migrator/pages_source_scan_migration_test.go deleted file mode 100644 index e26ce032..00000000 --- a/backend/OpenFlare/plugins/server/migrator/pages_source_scan_migration_test.go +++ /dev/null @@ -1,161 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package migrator - -import ( - "database/sql" - "fmt" - "os" - "strings" - "testing" - "time" - - "Wavelet/OpenFlare/plugins/server/model" - - "github.com/glebarez/sqlite" - "github.com/pressly/goose/v3" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "gorm.io/driver/postgres" - "gorm.io/gorm" -) - -const ( - pagesSourceScanPreviousMigration = int64(202607190002) - pagesSourceScanMigration = int64(202607190003) - pagesSourceScanTaskType = "of_pages_source_scan" -) - -func TestPagesSourceScanScheduleMigrationSQLite(t *testing.T) { - dbPath := t.TempDir() + "/pages-source-scan-migration.db" - gormDB, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{ - DisableForeignKeyConstraintWhenMigrating: true, - }) - require.NoError(t, err) - sqlDB, err := gormDB.DB() - require.NoError(t, err) - sqlDB.SetMaxOpenConns(1) - t.Cleanup(func() { require.NoError(t, sqlDB.Close()) }) - - runPagesSourceScanScheduleMigration(t, gormDB, sqlDB, dialectSqlite, "goose/sqlite") -} - -func TestPagesSourceScanScheduleMigrationPostgres(t *testing.T) { - dsn := strings.TrimSpace(os.Getenv("OPENFLARE_TEST_POSTGRES_DSN")) - if dsn == "" { - t.Skip("OPENFLARE_TEST_POSTGRES_DSN is not set") - } - gormDB, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ - DisableForeignKeyConstraintWhenMigrating: true, - }) - require.NoError(t, err) - sqlDB, err := gormDB.DB() - require.NoError(t, err) - sqlDB.SetMaxOpenConns(1) - - schema := fmt.Sprintf("pages_source_scan_migration_%d", time.Now().UnixNano()) - require.Regexp(t, `^[a-z0-9_]+$`, schema) - require.NoError(t, gormDB.Exec(`CREATE SCHEMA "`+schema+`"`).Error) - require.NoError(t, gormDB.Exec(`SET search_path TO "`+schema+`"`).Error) - t.Cleanup(func() { - assert.NoError(t, gormDB.Exec("SET search_path TO public").Error) - assert.NoError(t, gormDB.Exec(`DROP SCHEMA IF EXISTS "`+schema+`" CASCADE`).Error) - assert.NoError(t, sqlDB.Close()) - }) - - runPagesSourceScanScheduleMigration(t, gormDB, sqlDB, dialectPostgres, "goose/postgres") -} - -func runPagesSourceScanScheduleMigration( - t *testing.T, - gormDB *gorm.DB, - sqlDB *sql.DB, - dialect string, - dir string, -) { - t.Helper() - goose.SetBaseFS(migrationFS) - require.NoError(t, goose.SetDialect(dialect)) - require.NoError(t, goose.UpTo(sqlDB, dir, pagesSourceScanPreviousMigration)) - - var previousMaxID uint64 - require.NoError(t, gormDB.Table("w_schedules").Select("COALESCE(MAX(id), 0)").Scan(&previousMaxID).Error) - require.NoError(t, goose.UpTo(sqlDB, dir, pagesSourceScanMigration)) - seeded := assertPagesSourceScanSchedule(t, gormDB) - assert.NotZero(t, seeded.ID) - if dialect == dialectPostgres { - assert.Greater(t, seeded.ID, previousMaxID) - } - - require.NoError(t, goose.DownTo(sqlDB, dir, pagesSourceScanPreviousMigration)) - assertPagesSourceScanScheduleMissing(t, gormDB) - - custom := model.Schedule{ - ID: 900001, - Name: "用户保留的 Pages 扫描任务", - TaskType: pagesSourceScanTaskType, - Cron: "0 * * * *", - Payload: `{"custom":true}`, - IsActive: false, - } - require.NoError(t, gormDB.Create(&custom).Error) - require.NoError(t, goose.UpTo(sqlDB, dir, pagesSourceScanMigration)) - var schedules []model.Schedule - require.NoError(t, gormDB.Where("task_type = ?", pagesSourceScanTaskType).Find(&schedules).Error) - require.Len(t, schedules, 1) - assert.Equal(t, custom.ID, schedules[0].ID) - assert.Equal(t, custom.Name, schedules[0].Name) - - require.NoError(t, goose.DownTo(sqlDB, dir, pagesSourceScanPreviousMigration)) - var retained model.Schedule - require.NoError(t, gormDB.First(&retained, custom.ID).Error) - assert.Equal(t, custom.TaskType, retained.TaskType) -} - -func TestPagesSourceScanScheduleMigrationsUseDatabaseGeneratedIDs(t *testing.T) { - for _, name := range []string{ - "goose/postgres/202607190003_seed_pages_source_scan.sql", - "goose/sqlite/202607190003_seed_pages_source_scan.sql", - } { - t.Run(name, func(t *testing.T) { - content, err := migrationFS.ReadFile(name) - require.NoError(t, err) - normalized := strings.ToLower(string(content)) - assert.NotContains(t, normalized, "insert into w_schedules (id,") - assert.NotContains(t, normalized, "coalesce(max(id)") - }) - } - - postgresContent, err := migrationFS.ReadFile("goose/postgres/202607190003_seed_pages_source_scan.sql") - require.NoError(t, err) - compactPostgres := strings.Join(strings.Fields(strings.ToLower(string(postgresContent))), " ") - assert.Contains( - t, - compactPostgres, - "select setval( pg_get_serial_sequence('w_schedules', 'id'), greatest( 1,", - "sequence synchronization must retain a valid lower bound for an empty table", - ) -} - -func assertPagesSourceScanSchedule(t *testing.T, gormDB *gorm.DB) model.Schedule { - t.Helper() - var schedules []model.Schedule - require.NoError(t, gormDB.Where("task_type = ?", pagesSourceScanTaskType).Find(&schedules).Error) - require.Len(t, schedules, 1) - schedule := schedules[0] - assert.Equal(t, "OpenFlare Pages 部署源扫描", schedule.Name) - assert.Equal(t, "0 0 * * *", schedule.Cron) - assert.Equal(t, "{}", schedule.Payload) - assert.True(t, schedule.IsActive) - return schedule -} - -func assertPagesSourceScanScheduleMissing(t *testing.T, gormDB *gorm.DB) { - t.Helper() - var count int64 - require.NoError(t, gormDB.Model(&model.Schedule{}). - Where("task_type = ?", pagesSourceScanTaskType). - Count(&count).Error) - assert.Zero(t, count) -} diff --git a/backend/OpenFlare/plugins/server/migrator/system_cleanup_migration_test.go b/backend/OpenFlare/plugins/server/migrator/system_cleanup_migration_test.go deleted file mode 100644 index a5d8bdfc..00000000 --- a/backend/OpenFlare/plugins/server/migrator/system_cleanup_migration_test.go +++ /dev/null @@ -1,96 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package migrator - -import ( - "database/sql" - "fmt" - "os" - "strings" - "testing" - "time" - - "Wavelet/OpenFlare/plugins/server/model" - - "github.com/glebarez/sqlite" - "github.com/pressly/goose/v3" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "gorm.io/driver/postgres" - "gorm.io/gorm" -) - -const ( - systemCleanupPreviousMigration = int64(202608090001) - systemCleanupMigration = int64(202608090002) - systemCleanupTaskType = "system_cleanup" -) - -func TestSystemCleanupScheduleMigrationSQLite(t *testing.T) { - dbPath := t.TempDir() + "/system-cleanup-migration.db" - gormDB, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{ - DisableForeignKeyConstraintWhenMigrating: true, - }) - require.NoError(t, err) - sqlDB, err := gormDB.DB() - require.NoError(t, err) - sqlDB.SetMaxOpenConns(1) - t.Cleanup(func() { require.NoError(t, sqlDB.Close()) }) - - runSystemCleanupScheduleMigration(t, gormDB, sqlDB, dialectSqlite, "goose/sqlite") -} - -func TestSystemCleanupScheduleMigrationPostgres(t *testing.T) { - dsn := strings.TrimSpace(os.Getenv("OPENFLARE_TEST_POSTGRES_DSN")) - if dsn == "" { - t.Skip("OPENFLARE_TEST_POSTGRES_DSN is not set") - } - gormDB, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ - DisableForeignKeyConstraintWhenMigrating: true, - }) - require.NoError(t, err) - sqlDB, err := gormDB.DB() - require.NoError(t, err) - sqlDB.SetMaxOpenConns(1) - - schema := fmt.Sprintf("system_cleanup_migration_%d", time.Now().UnixNano()) - require.Regexp(t, `^[a-z0-9_]+$`, schema) - require.NoError(t, gormDB.Exec(`CREATE SCHEMA "`+schema+`"`).Error) - require.NoError(t, gormDB.Exec(`SET search_path TO "`+schema+`"`).Error) - t.Cleanup(func() { - assert.NoError(t, gormDB.Exec("SET search_path TO public").Error) - assert.NoError(t, gormDB.Exec(`DROP SCHEMA IF EXISTS "`+schema+`" CASCADE`).Error) - assert.NoError(t, sqlDB.Close()) - }) - - runSystemCleanupScheduleMigration(t, gormDB, sqlDB, dialectPostgres, "goose/postgres") -} - -func runSystemCleanupScheduleMigration( - t *testing.T, - gormDB *gorm.DB, - sqlDB *sql.DB, - dialect string, - dir string, -) { - t.Helper() - goose.SetBaseFS(migrationFS) - require.NoError(t, goose.SetDialect(dialect)) - require.NoError(t, goose.UpTo(sqlDB, dir, systemCleanupPreviousMigration)) - - assertSystemCleanupCron(t, gormDB, "0 */2 * * *", "迁移前应为每 2 小时") - - require.NoError(t, goose.UpTo(sqlDB, dir, systemCleanupMigration)) - assertSystemCleanupCron(t, gormDB, "0 3 * * *", "迁移后应为每日凌晨 3 点") - - require.NoError(t, goose.DownTo(sqlDB, dir, systemCleanupPreviousMigration)) - assertSystemCleanupCron(t, gormDB, "0 */2 * * *", "回滚后恢复每 2 小时") -} - -func assertSystemCleanupCron(t *testing.T, gormDB *gorm.DB, wantCron, msg string) { - t.Helper() - var schedule model.Schedule - require.NoError(t, gormDB.Where("task_type = ?", systemCleanupTaskType).First(&schedule).Error) - assert.Equal(t, wantCron, schedule.Cron, msg) -} diff --git a/backend/OpenFlare/plugins/server/plugin.go b/backend/OpenFlare/plugins/server/plugin.go index c051ffda..d7b4cbec 100644 --- a/backend/OpenFlare/plugins/server/plugin.go +++ b/backend/OpenFlare/plugins/server/plugin.go @@ -7,6 +7,7 @@ package server import ( + "Wavelet/OpenFlare/plugins/server/chmigrate" "Wavelet/OpenFlare/plugins/server/ofevents" "Wavelet/OpenFlare/plugins/server/openflare/chwriter" ofgeoip "Wavelet/OpenFlare/plugins/server/openflare/geoip" @@ -22,6 +23,7 @@ import ( "Wavelet/pkg/logger" "Wavelet/plugins/infra/database" "context" + "embed" "reflect" "Wavelet/OpenFlare/plugins/server/openflare/credential" @@ -34,6 +36,9 @@ import ( "net/http" ) +//go:embed migrations/*/*.sql +var serverMigrations embed.FS + // Plugin 实现 core.Plugin,是 OpenFlare 控制面的装载入口。 type Plugin struct{} @@ -66,6 +71,11 @@ func (p *Plugin) Apply(ctx *core.Context) error { }) credential.SetSessionSecret(runtimeconfig.SessionSecret()) + ctx.Migrations().Register("server", serverMigrations) + if err := chmigrate.Up(); err != nil { + return err + } + if ts, err := core.Inject[contracts.TaskService](ctx); err == nil && ts != nil { oftask.SetService(ts) } else { diff --git a/backend/OpenFlare/plugins/server/stamp/legacy.go b/backend/OpenFlare/plugins/server/stamp/legacy.go deleted file mode 100644 index 3663c838..00000000 --- a/backend/OpenFlare/plugins/server/stamp/legacy.go +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -// Package stamp records pre-Cordis schema versions into w_schema_versions. -package stamp - -import "Wavelet/core" - -// Legacy is the pre-Cordis schema stamp hook. Task 14 fills the body; until then -// it is a no-op so plugin migrations can proceed on a fresh database. -func Legacy(*core.Context) error { - return nil -} diff --git a/backend/OpenFlare/plugins/server/stamp/stamp.go b/backend/OpenFlare/plugins/server/stamp/stamp.go new file mode 100644 index 00000000..10aa2b37 --- /dev/null +++ b/backend/OpenFlare/plugins/server/stamp/stamp.go @@ -0,0 +1,200 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package stamp records pre-Cordis schema versions into w_schema_versions. +package stamp + +import ( + "context" + "database/sql" + "fmt" + "unicode" + + "Wavelet/OpenFlare/plugins/server/openflare/zone" + "Wavelet/core" + "Wavelet/core/contracts" +) + +const ( + legacyPluginID = "openflare/legacy" + serverPluginID = "server" + serverInitialVersion = int64(1) + zoneImportSQLVersion = int64(202607120002) + zoneDropLegacySQLVersion = int64(202607130001) + gooseVersionTable = "goose_db_version" +) + +// Legacy copies goose_db_version into w_schema_versions so the 76-file mixed +// chain is not re-run. Fresh databases have no goose table and are left alone. +func Legacy(ctx *core.Context) error { + dbSvc, err := core.Inject[contracts.DBService](ctx) + if err != nil { + return fmt.Errorf("stamp: inject DBService: %w", err) + } + gormDB := dbSvc.GORM() + if gormDB == nil { + return fmt.Errorf("stamp: DBService.GORM() returned nil") + } + sqlDB, err := gormDB.DB() + if err != nil { + return fmt.Errorf("stamp: get sql.DB: %w", err) + } + + goCtx := context.Background() + if ctx != nil && ctx.GoContext() != nil { + goCtx = ctx.GoContext() + } + postgres := gormDB.Dialector != nil && gormDB.Dialector.Name() == "postgres" + + exists, err := gooseTableExists(goCtx, sqlDB, postgres) + if err != nil { + return err + } + if !exists { + return nil + } + + col, err := gooseVersionColumn(goCtx, sqlDB, postgres) + if err != nil { + return err + } + maxVer, err := gooseMaxVersion(goCtx, sqlDB, col) + if err != nil { + return err + } + if err := insertStamps(goCtx, sqlDB, postgres, maxVer); err != nil { + return err + } + return maybeImportZones(goCtx, sqlDB, postgres, maxVer) +} + +func gooseTableExists(ctx context.Context, db *sql.DB, postgres bool) (bool, error) { + var n int + var err error + if postgres { + err = db.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = $1 + `, gooseVersionTable).Scan(&n) + } else { + err = db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, + gooseVersionTable, + ).Scan(&n) + } + if err != nil { + return false, fmt.Errorf("stamp: detect %s: %w", gooseVersionTable, err) + } + return n > 0, nil +} + +func gooseVersionColumn(ctx context.Context, db *sql.DB, postgres bool) (string, error) { + var rows *sql.Rows + var err error + if postgres { + rows, err = db.QueryContext(ctx, ` + SELECT column_name FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = $1 + `, gooseVersionTable) + } else { + rows, err = db.QueryContext(ctx, `SELECT name FROM pragma_table_info(?)`, gooseVersionTable) + } + if err != nil { + return "", fmt.Errorf("stamp: list %s columns: %w", gooseVersionTable, err) + } + defer func() { _ = rows.Close() }() + + var names []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return "", fmt.Errorf("stamp: scan %s columns: %w", gooseVersionTable, err) + } + names = append(names, name) + } + if err := rows.Err(); err != nil { + return "", fmt.Errorf("stamp: list %s columns: %w", gooseVersionTable, err) + } + + hasVersionID, hasVersion := false, false + for _, name := range names { + switch name { + case "version_id": + hasVersionID = true + case "version": + hasVersion = true + } + } + switch { + case hasVersionID: + return "version_id", nil + case hasVersion: + return "version", nil + default: + return "", fmt.Errorf("stamp: %s has no version_id or version column", gooseVersionTable) + } +} + +func gooseMaxVersion(ctx context.Context, db *sql.DB, column string) (int64, error) { + if !safeIdent(column) { + return 0, fmt.Errorf("stamp: unsafe version column %q", column) + } + var maxVer int64 + q := fmt.Sprintf("SELECT COALESCE(MAX(%s), 0) FROM %s", column, gooseVersionTable) + if err := db.QueryRowContext(ctx, q).Scan(&maxVer); err != nil { + return 0, fmt.Errorf("stamp: max %s: %w", gooseVersionTable, err) + } + return maxVer, nil +} + +func insertStamps(ctx context.Context, db *sql.DB, postgres bool, maxVer int64) error { + q := `INSERT INTO w_schema_versions (plugin_id, version_id) VALUES (?, ?) ON CONFLICT (plugin_id, version_id) DO NOTHING` + if postgres { + q = `INSERT INTO w_schema_versions (plugin_id, version_id) VALUES ($1, $2) ON CONFLICT (plugin_id, version_id) DO NOTHING` + } + stamps := []struct { + plugin string + ver int64 + }{ + {legacyPluginID, 0}, + {legacyPluginID, maxVer}, + {serverPluginID, serverInitialVersion}, + } + for _, s := range stamps { + if _, err := db.ExecContext(ctx, q, s.plugin, s.ver); err != nil { + return fmt.Errorf("stamp: insert (%s, %d): %w", s.plugin, s.ver, err) + } + } + return nil +} + +func maybeImportZones(ctx context.Context, db *sql.DB, postgres bool, maxVer int64) error { + if maxVer < zoneImportSQLVersion || maxVer >= zoneDropLegacySQLVersion { + return nil + } + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("stamp: begin zone import: %w", err) + } + report, err := zone.ImportLegacyTx(ctx, tx, postgres) + if err != nil { + _ = tx.Rollback() + return report.LogAndReturn(err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("stamp: commit zone import: %w", err) + } + return nil +} + +func safeIdent(name string) bool { + if name == "" { + return false + } + for _, r := range name { + if r != '_' && !unicode.IsLetter(r) && !unicode.IsDigit(r) { + return false + } + } + return true +} diff --git a/backend/OpenFlare/plugins/server/stamp/stamp_test.go b/backend/OpenFlare/plugins/server/stamp/stamp_test.go new file mode 100644 index 00000000..eeb23c66 --- /dev/null +++ b/backend/OpenFlare/plugins/server/stamp/stamp_test.go @@ -0,0 +1,151 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package stamp + +import ( + "context" + "database/sql" + "path/filepath" + "testing" + + "Wavelet/core" + "Wavelet/core/contracts" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +const goldGooseVersion int64 = 202608090003 + +type testDB struct { + db *gorm.DB +} + +func (s testDB) GORM() *gorm.DB { return s.db } + +func (s testDB) DB(ctx context.Context) *gorm.DB { return s.db.WithContext(ctx) } + +func (s testDB) Named(string) *gorm.DB { return s.db } + +func openStampDB(t *testing.T) (*gorm.DB, *sql.DB) { + t.Helper() + gdb, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "stamp.db")), &gorm.Config{}) + if err != nil { + t.Fatalf("gorm.Open(sqlite) error = %v", err) + } + sqlDB, err := gdb.DB() + if err != nil { + t.Fatalf("gdb.DB() error = %v", err) + } + return gdb, sqlDB +} + +func createSchemaVersions(t *testing.T, db *sql.DB) { + t.Helper() + _, err := db.Exec(`CREATE TABLE IF NOT EXISTS w_schema_versions ( + plugin_id VARCHAR(64) NOT NULL, + version_id BIGINT NOT NULL, + applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (plugin_id, version_id) + )`) + if err != nil { + t.Fatalf("create w_schema_versions error = %v", err) + } +} + +func createGooseDBVersion(t *testing.T, db *sql.DB, version int64) { + t.Helper() + _, err := db.Exec(`CREATE TABLE goose_db_version ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + version_id INTEGER NOT NULL, + is_applied INTEGER NOT NULL, + tstamp DATETIME + )`) + if err != nil { + t.Fatalf("create goose_db_version error = %v", err) + } + _, err = db.Exec(`INSERT INTO goose_db_version (version_id, is_applied) VALUES (0, 1), (?, 1)`, version) + if err != nil { + t.Fatalf("insert goose_db_version error = %v", err) + } +} + +func callLegacy(t *testing.T, gdb *gorm.DB) { + t.Helper() + ctx := core.NewContext(context.Background()) + core.Provide[contracts.DBService](ctx, testDB{db: gdb}) + if err := Legacy(ctx); err != nil { + t.Fatalf("Legacy() error = %v", err) + } +} + +func listStamps(t *testing.T, db *sql.DB) []stampRow { + t.Helper() + rows, err := db.Query(`SELECT plugin_id, version_id FROM w_schema_versions ORDER BY plugin_id, version_id`) + if err != nil { + t.Fatalf("list w_schema_versions error = %v", err) + } + defer func() { _ = rows.Close() }() + + var got []stampRow + for rows.Next() { + var r stampRow + if err := rows.Scan(&r.PluginID, &r.VersionID); err != nil { + t.Fatalf("scan w_schema_versions error = %v", err) + } + got = append(got, r) + } + if err := rows.Err(); err != nil { + t.Fatalf("rows.Err() = %v", err) + } + return got +} + +type stampRow struct { + PluginID string + VersionID int64 +} + +func TestLegacyStampsGoldVersionIdempotent(t *testing.T) { + gdb, sqlDB := openStampDB(t) + createSchemaVersions(t, sqlDB) + createGooseDBVersion(t, sqlDB, goldGooseVersion) + + callLegacy(t, gdb) + + got := listStamps(t, sqlDB) + want := []stampRow{ + {PluginID: "openflare/legacy", VersionID: 0}, + {PluginID: "openflare/legacy", VersionID: goldGooseVersion}, + {PluginID: "server", VersionID: 1}, + } + if len(got) != len(want) { + t.Fatalf("Legacy() stamps = %#v, want %#v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("Legacy() stamps[%d] = %+v, want %+v", i, got[i], want[i]) + } + } + + callLegacy(t, gdb) + gotAgain := listStamps(t, sqlDB) + if len(gotAgain) != len(want) { + t.Fatalf("Legacy() second call rows = %d, want %d", len(gotAgain), len(want)) + } +} + +func TestLegacyWithoutGooseTableDoesNotStampLegacy(t *testing.T) { + gdb, sqlDB := openStampDB(t) + createSchemaVersions(t, sqlDB) + + callLegacy(t, gdb) + + got := listStamps(t, sqlDB) + for _, row := range got { + if row.PluginID == "openflare/legacy" { + t.Errorf("Legacy() without goose_db_version wrote openflare/legacy %+v, want none", row) + } + } +} diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 15e27b03..d0ad344c 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -21,6 +21,7 @@ sidebar: false - 控制面改为与上游 Wavelet 同构装配:`newOpenFlareApp` 挂载 Wavelet 平台插件后再挂 OpenFlare `server` 业务路由,健康检查/用户/验证码由上游插件提供;`app.redirect_trailing_slash` 默认关闭,避免列表接口尾部斜杠被 301。 - 删除 OpenFlare 内与 Wavelet 重复的 oauth/cap/user/upload/config/health/admin 平台副本,业务改走契约(登录中间件、公共配置、推送注册、异步任务);用户/文件/系统配置由上游插件提供,控制台接口形状保持金标准子集。 - 控制面读写系统配置改为走上游管理仓储并同步失效缓存,避免选项/节点/日志库切换写入后 `/api/v1/config/public` 仍返回旧值。 +- 已部署库启动时把历史 `goose_db_version` 一次性写入 `w_schema_versions`(`openflare/legacy` 与 `server`),不再重跑 76 条混合迁移;全新安装只创建当前的 `of_*` 业务表。ClickHouse 继续只升级节点访问/可观测相关表,用户访问日志表改由上游负责。 ## [v3.5.4] - 2026-08-29 ### ✨ 新功能