feat(server): stamp legacy goose versions and own of_* migrations only

This commit is contained in:
ryan
2026-08-30 13:43:38 +08:00
parent 6c7090aa8e
commit d3a91dcc3f
29 changed files with 1379 additions and 1159 deletions
@@ -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) {
@@ -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)
}
}
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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=<path> 导出表/索引定义;OF_DUMP_VERSIONS=<path> 导出已应用版本序列。
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
}
@@ -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/<dialect>.
//
//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)
}
}
@@ -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)
}
}
@@ -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")
}
@@ -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)
}
@@ -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)
}
@@ -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 {
@@ -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
}
@@ -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
}
@@ -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)
}
}
}
+1
View File
@@ -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
### ✨ 新功能