298 lines
10 KiB
Go
298 lines
10 KiB
Go
package migrations_test
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
mysql "github.com/go-sql-driver/mysql"
|
|
"kra/internal/modules"
|
|
)
|
|
|
|
func cleanupSQL(t *testing.T) string {
|
|
t.Helper()
|
|
raw, err := os.ReadFile("cleanup-legacy.sql")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(raw)
|
|
}
|
|
|
|
func cleanupManifest(t *testing.T, source, name string) []string {
|
|
t.Helper()
|
|
block := regexp.MustCompile(`(?s)INSERT INTO ` + regexp.QuoteMeta(name) + `\([^\n]+\) VALUES\s*(.*?);`).FindStringSubmatch(source)
|
|
if len(block) != 2 {
|
|
t.Fatalf("missing manifest %s", name)
|
|
}
|
|
matches := regexp.MustCompile(`\('([a-z0-9_]+)'`).FindAllStringSubmatch(block[1], -1)
|
|
var values []string
|
|
seen := map[string]bool{}
|
|
for _, match := range matches {
|
|
if seen[match[1]] {
|
|
t.Fatalf("duplicate %s entry %s", name, match[1])
|
|
}
|
|
seen[match[1]] = true
|
|
values = append(values, match[1])
|
|
}
|
|
return values
|
|
}
|
|
|
|
func TestCleanupManifestMatchesMigrationAndRetainsCurrentTables(t *testing.T) {
|
|
source := cleanupSQL(t)
|
|
targets := cleanupManifest(t, source, "_sw_cleanup_targets_20261106")
|
|
required := cleanupManifest(t, source, "_sw_cleanup_required_tables_20261106")
|
|
versions := cleanupManifest(t, source, "_sw_cleanup_required_versions_20261106")
|
|
if len(targets) != 79 || len(required) != 76 {
|
|
t.Fatalf("cleanup manifest changed: %d targets, %d required", len(targets), len(required))
|
|
}
|
|
set := map[string]bool{}
|
|
for _, name := range targets {
|
|
if strings.HasPrefix(name, "tab_") || name == "sys_business_upgrade_state" {
|
|
t.Fatalf("current data included: %s", name)
|
|
}
|
|
set[name] = true
|
|
}
|
|
for _, name := range required {
|
|
if set[name] {
|
|
t.Fatalf("required table selected for deletion: %s", name)
|
|
}
|
|
}
|
|
legacy, err := os.ReadFile("legacy-framework.sql")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, pattern := range []string{"TO `(legacy_sw_20261103_[a-z0-9_]+)`", "CREATE TABLE `(bak_sw2kra_20261103_[a-z0-9_]+)`"} {
|
|
for _, match := range regexp.MustCompile(pattern).FindAllStringSubmatch(string(legacy), -1) {
|
|
if !set[match[1]] {
|
|
t.Fatalf("migration archive omitted: %s", match[1])
|
|
}
|
|
}
|
|
}
|
|
known := map[string]bool{}
|
|
for _, id := range versions {
|
|
known[id] = true
|
|
}
|
|
steps := modules.Catalog().MigrationSteps()
|
|
if len(steps) != len(versions) {
|
|
t.Fatal("refresh the cleanup version gate for the current catalog")
|
|
}
|
|
for _, step := range steps {
|
|
if !known[step.ID] {
|
|
t.Fatalf("missing required migration %s", step.ID)
|
|
}
|
|
}
|
|
if !strings.Contains(source, "SET @sw_cleanup_execute = 0;") {
|
|
t.Fatal("cleanup must default to preview")
|
|
}
|
|
}
|
|
|
|
// DELIMITER belongs to the mysql client. Keep one connection for the routine,
|
|
// session variables, advisory lock and temporary manifests.
|
|
func runCleanupSQL(ctx context.Context, conn *sql.Conn, source, database string, execute bool) error {
|
|
source = strings.Replace(source, "USE `sw10`;", "USE `"+database+"`;", 1)
|
|
if execute {
|
|
source = strings.Replace(source, "SET @sw_cleanup_execute = 0;", "SET @sw_cleanup_execute = 1;", 1)
|
|
}
|
|
delimiter, buffer := ";", ""
|
|
for _, line := range strings.Split(source, "\n") {
|
|
trimmed := strings.TrimSpace(line)
|
|
if strings.HasPrefix(trimmed, "DELIMITER ") {
|
|
delimiter = strings.TrimSpace(strings.TrimPrefix(trimmed, "DELIMITER "))
|
|
continue
|
|
}
|
|
buffer += line + "\n"
|
|
if strings.HasSuffix(trimmed, delimiter) {
|
|
statement := strings.TrimSuffix(strings.TrimSpace(buffer), delimiter)
|
|
if _, err := conn.ExecContext(ctx, statement); err != nil {
|
|
return err
|
|
}
|
|
buffer = ""
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func TestMySQLCleanupPreflightPreviewDeletionAndRetry(t *testing.T) {
|
|
dsn := os.Getenv("SW_TEST_MYSQL_DSN")
|
|
if dsn == "" {
|
|
t.Skip("set SW_TEST_MYSQL_DSN to an isolated local MySQL")
|
|
}
|
|
cfg, err := mysql.ParseDSN(dsn)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cfg.Net != "tcp" || !strings.HasPrefix(cfg.Addr, "127.0.0.1:") {
|
|
t.Fatal("cleanup tests require a local isolated MySQL")
|
|
}
|
|
cfg.DBName = ""
|
|
admin, err := sql.Open("mysql", cfg.FormatDSN())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
name := fmt.Sprintf("sw_test_cleanup_%d", time.Now().UnixNano())
|
|
if _, err := admin.Exec("CREATE DATABASE `" + name + "` CHARACTER SET utf8mb4"); err != nil {
|
|
admin.Close()
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_, err := admin.Exec("DROP DATABASE `" + name + "`")
|
|
if err != nil {
|
|
t.Error(err)
|
|
}
|
|
admin.Close()
|
|
})
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
|
defer cancel()
|
|
conn, err := admin.Conn(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer conn.Close()
|
|
exec := func(query string, args ...any) {
|
|
t.Helper()
|
|
if _, err := conn.ExecContext(ctx, query, args...); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
exec("USE `" + name + "`")
|
|
source := cleanupSQL(t)
|
|
targets := cleanupManifest(t, source, "_sw_cleanup_targets_20261106")
|
|
required := cleanupManifest(t, source, "_sw_cleanup_required_tables_20261106")
|
|
versions := cleanupManifest(t, source, "_sw_cleanup_required_versions_20261106")
|
|
for _, table := range append(targets, required...) {
|
|
switch table {
|
|
case "sw_kra_state", "sw_kra_checks", "sys_schema_migrations", "sys_business_upgrade_state", "sys_authorities":
|
|
continue
|
|
}
|
|
exec("CREATE TABLE `" + table + "` (id BIGINT PRIMARY KEY) ENGINE=InnoDB")
|
|
exec("INSERT INTO `" + table + "` VALUES (1)")
|
|
}
|
|
exec("CREATE TABLE sys_authorities(authority_id BIGINT PRIMARY KEY,business_level TINYINT UNSIGNED,deleted_at DATETIME,UNIQUE KEY uk_authority_business_level(business_level))")
|
|
exec("INSERT INTO sys_authorities VALUES(101,1,NULL),(102,2,NULL),(103,3,NULL)")
|
|
exec("CREATE TABLE sys_business_upgrade_state(name VARCHAR(64) PRIMARY KEY,version VARCHAR(64),phase VARCHAR(32),verified_at DATETIME,verified_migrations VARCHAR(64),cleanup_backup_path TEXT,cleanup_sha256 VARCHAR(64))")
|
|
exec("INSERT INTO sys_business_upgrade_state VALUES('legacy-sw','20261106_automatic_v1','cleanup',NOW(),REPEAT('a',64),'verified-backup.sql.gz',REPEAT('b',64))")
|
|
exec("CREATE TABLE sw_kra_state(id INT PRIMARY KEY,version VARCHAR(80),phase VARCHAR(40),completed_at DATETIME)")
|
|
exec("INSERT INTO sw_kra_state VALUES(1,'20261103_framework_migration_v5','completed',NOW())")
|
|
exec("CREATE TABLE sw_kra_checks(stage VARCHAR(32),check_name VARCHAR(160),expected_value BIGINT,actual_value BIGINT,passed TINYINT)")
|
|
for stage, count := range map[string]int{"backup": 52, "migration": 24} {
|
|
for i := 0; i < count; i++ {
|
|
exec("INSERT INTO sw_kra_checks VALUES(?,?,?,?,?)", stage, fmt.Sprintf("check_%d", i), 1, 1, 1)
|
|
}
|
|
}
|
|
exec("CREATE TABLE sys_schema_migrations(id VARCHAR(255) PRIMARY KEY)")
|
|
for _, id := range versions {
|
|
exec("INSERT INTO sys_schema_migrations VALUES(?)", id)
|
|
}
|
|
exec("CREATE TABLE bak_sw2kra_20261103_not_owned(id INT)")
|
|
count := func(query string, args ...any) int {
|
|
t.Helper()
|
|
var n int
|
|
if err := conn.QueryRowContext(ctx, query, args...).Scan(&n); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return n
|
|
}
|
|
has := func(table string) bool {
|
|
return count("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=? AND table_name=?", name, table) == 1
|
|
}
|
|
assertTargets := func() {
|
|
t.Helper()
|
|
for _, table := range targets {
|
|
if !has(table) {
|
|
t.Fatalf("table deleted before all checks passed: %s", table)
|
|
}
|
|
}
|
|
}
|
|
fails := func(want string) {
|
|
t.Helper()
|
|
err := runCleanupSQL(ctx, conn, source, name, true)
|
|
if err == nil || !strings.Contains(err.Error(), want) {
|
|
t.Fatalf("want rejection %q, got %v", want, err)
|
|
}
|
|
assertTargets()
|
|
}
|
|
if err := runCleanupSQL(ctx, conn, source, name, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertTargets()
|
|
exec("UPDATE sw_kra_state SET phase='data_validated'")
|
|
fails("has not completed")
|
|
exec("UPDATE sw_kra_state SET phase='completed'")
|
|
exec("UPDATE sw_kra_checks SET passed=0 WHERE stage='backup' AND check_name='check_0'")
|
|
fails("verification records")
|
|
exec("UPDATE sw_kra_checks SET passed=1")
|
|
const attachments = "202611030007_business_alert_attachments"
|
|
exec("DELETE FROM sys_schema_migrations WHERE id=?", attachments)
|
|
fails("incremental migrations")
|
|
exec("INSERT INTO sys_schema_migrations VALUES(?)", attachments)
|
|
exec("RENAME TABLE sys_alert_rule TO user_saved_rule")
|
|
fails("Required current table")
|
|
exec("RENAME TABLE user_saved_rule TO sys_alert_rule")
|
|
exec("CREATE TABLE sys_user(id INT)")
|
|
fails("Unarchived legacy")
|
|
exec("DROP TABLE sys_user")
|
|
exec("CREATE TABLE retained_reference(id BIGINT,FOREIGN KEY(id) REFERENCES legacy_sw_20261103_sys_user(id))")
|
|
fails("Foreign keys")
|
|
exec("DROP TABLE retained_reference")
|
|
exec("CREATE VIEW retained_view AS SELECT * FROM legacy_sw_20261103_sys_user")
|
|
fails("views referencing")
|
|
exec("DROP VIEW retained_view")
|
|
exec("CREATE TRIGGER retained_trigger AFTER INSERT ON sys_users FOR EACH ROW INSERT INTO sj_job(id) VALUES(NEW.id)")
|
|
fails("triggers referencing")
|
|
exec("DROP TRIGGER retained_trigger")
|
|
exec("CREATE PROCEDURE retained_routine() SELECT COUNT(*) FROM sj_job")
|
|
fails("routines referencing")
|
|
exec("DROP PROCEDURE retained_routine")
|
|
exec("CREATE EVENT retained_event ON SCHEDULE EVERY 1 YEAR STARTS CURRENT_TIMESTAMP + INTERVAL 1 YEAR DISABLE DO INSERT INTO sj_job(id) VALUES(2)")
|
|
fails("events referencing")
|
|
exec("DROP EVENT retained_event")
|
|
exec("SET SESSION foreign_key_checks=0")
|
|
fails("foreign_key_checks")
|
|
exec("SET SESSION foreign_key_checks=1")
|
|
other, err := admin.Conn(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var lock string
|
|
if err := conn.QueryRowContext(ctx, "SELECT CONCAT('sw_to_kra:',MD5(DATABASE()))").Scan(&lock); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var locked int
|
|
if err := other.QueryRowContext(ctx, "SELECT GET_LOCK(?,0)", lock).Scan(&locked); err != nil || locked != 1 {
|
|
t.Fatalf("lock: %d %v", locked, err)
|
|
}
|
|
fails("already running")
|
|
if _, err := other.ExecContext(ctx, "DO RELEASE_LOCK(?)", lock); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
other.Close()
|
|
oldTimeout := count("SELECT @@SESSION.lock_wait_timeout")
|
|
if err := runCleanupSQL(ctx, conn, source, name, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, table := range targets {
|
|
if has(table) {
|
|
t.Fatalf("old table retained: %s", table)
|
|
}
|
|
}
|
|
for _, table := range required {
|
|
if !has(table) {
|
|
t.Fatalf("current table deleted: %s", table)
|
|
}
|
|
}
|
|
if !has("bak_sw2kra_20261103_not_owned") || count("SELECT COUNT(*) FROM sys_business_upgrade_state") != 1 || count("SELECT COUNT(*) FROM sys_users") != 1 || count("SELECT COUNT(*) FROM tab_user") != 1 {
|
|
t.Fatal("non-target data changed")
|
|
}
|
|
if count("SELECT @@SESSION.lock_wait_timeout") != oldTimeout {
|
|
t.Fatal("session lock timeout not restored")
|
|
}
|
|
if err := runCleanupSQL(ctx, conn, source, name, true); err != nil {
|
|
t.Fatalf("retry after deletion: %v", err)
|
|
}
|
|
}
|