Files
kra-new/deploy/migrations/cleanup_test.go
T

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