补齐配置切换真实进程终止与损坏拒绝启动测试
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-21 23:50:27 +08:00
parent adc0d31dca
commit b8dbd3bb2b

View File

@ -0,0 +1,308 @@
package config
import (
"bytes"
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"reflect"
"testing"
"time"
)
const transactionCrashRoot = "SMAIL_TEST_TRANSACTION_CRASH_ROOT"
const transactionCrashPoint = "SMAIL_TEST_TRANSACTION_CRASH_POINT"
func crashOptions() ConfigLoaderOptions {
return ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}}
}
func crashCandidate(current RuntimeConfig) RuntimeConfig {
candidate := cloneRuntimeConfig(current)
setCandidateGeneration(&candidate, current.Generation+1)
candidate.Email.Policy.MaxMessageBytes = 9 << 20
if candidate.Root.Database.Type == DatabaseTypeSQLite {
candidate.Root.Database.LocalRef = "next.db"
} else {
candidate.Root.Database.Name = "smail_next"
}
return candidate
}
func crashTransactionDirectory(t *testing.T, root string) string {
t.Helper()
base := filepath.Join(root, "data", "transaction")
entries, err := os.ReadDir(base)
if err != nil || len(entries) != 1 {
t.Fatalf("事务目录数量异常:%d,%v", len(entries), err)
}
return filepath.Join(base, entries[0].Name())
}
// TestTransactionCrashChild 只在测试二进制中运行,不向正式程序添加故障注入入口。
func TestTransactionCrashChild(t *testing.T) {
root, point := os.Getenv(transactionCrashRoot), os.Getenv(transactionCrashPoint)
if root == "" {
return
}
current, err := NewConfigLoader(root, crashOptions()).Load()
if err != nil {
t.Fatal(err)
}
phase := TransactionPhase(point)
if point == "partial_sections" {
phase = TransactionPrepared
}
if point == "root_before_manifest" {
phase = TransactionSectionsCommitted
}
store := NewTransactionStore(root, crashOptions().Validation).WithHooks(TransactionHooks{
BeforePhase: func(reached TransactionPhase) error {
if reached != phase {
return nil
}
// 复用正式原子替换函数构造阶段内部的落盘状态;不声称命中了实际指令间隙。
if point == "partial_sections" || point == "root_before_manifest" {
name := "config.json"
if point == "partial_sections" {
name, _ = FixedSectionFile(requiredPartitionSections[0])
}
source := filepath.Join(crashTransactionDirectory(t, root), "candidate", name)
if err := replaceLiveConfigFile(filepath.Join(root, "config"), source, name); err != nil {
return err
}
}
if err := os.Mkdir(filepath.Join(root, "crash-ready"), 0700); err != nil {
return err
}
// 必须等待父进程强制终止,不能返回错误而执行提交函数的 defer。
for {
time.Sleep(time.Hour)
}
},
})
if err := store.Commit(current, crashCandidate(current)); err != nil {
t.Fatal(err)
}
t.Fatal("子进程没有停在要求的提交阶段")
}
func killTransactionChild(t *testing.T, root, point string) {
t.Helper()
executable, err := os.Executable()
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
command := exec.CommandContext(ctx, executable, "-test.run=^TestTransactionCrashChild$", "-test.timeout=45s")
command.Env = append(os.Environ(), transactionCrashRoot+"="+root, transactionCrashPoint+"="+point)
var output bytes.Buffer
command.Stdout, command.Stderr = &output, &output
if err := command.Start(); err != nil {
t.Fatal(err)
}
wait := make(chan error, 1)
go func() { wait <- command.Wait() }()
finished := false
defer func() {
if !finished {
_ = command.Process.Kill()
<-wait
}
}()
ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop()
for {
select {
case err := <-wait:
finished = true
t.Fatalf("到达故障点前子进程退出:%v\n%s", err, output.String())
case <-ctx.Done():
t.Fatal("等待事务故障点超时")
case <-ticker.C:
ready, err := os.Stat(filepath.Join(root, "crash-ready"))
if os.IsNotExist(err) {
continue
}
if err != nil || !ready.IsDir() {
t.Fatalf("故障点确认失败:%v", err)
}
if err := command.Process.Kill(); err != nil {
t.Fatal(err)
}
err = <-wait
finished = true
var exitError *exec.ExitError
if !errors.As(err, &exitError) || command.ProcessState.Success() {
t.Fatalf("子进程不是强制终止:%v", err)
}
t.Logf("已确认阶段 %s,子进程 %d,退出状态 %s", point, command.Process.Pid, command.ProcessState)
return
}
}
}
func crashFixture(t *testing.T, kind DatabaseType) (string, RuntimeConfig) {
t.Helper()
root := t.TempDir()
if err := InitializeDeployment(root); err != nil {
t.Fatal(err)
}
current := validRuntimeConfig()
if kind != DatabaseTypeSQLite {
current = validMySQLRuntimeConfig(t, root)
current.Root.Database.Type = kind
// 本专项只验证配置落盘恢复,不访问共享数据库或创建替代 SQLite。
current.Root.Database.Port = 1
}
writeRuntimeFixture(t, root, current)
loaded, err := NewConfigLoader(root, crashOptions()).Load()
if err != nil {
t.Fatal(err)
}
current = loaded
return root, current
}
func readCrashConfigFiles(t *testing.T, directory string) map[string][]byte {
t.Helper()
files := make(map[string][]byte)
for _, name := range transactionFileNames() {
data, err := os.ReadFile(filepath.Join(directory, name))
if err != nil {
t.Fatal(err)
}
files[name] = data
}
return files
}
func assertCrashConfigFiles(t *testing.T, root string, expected map[string][]byte) {
t.Helper()
for name, want := range expected {
got, err := os.ReadFile(filepath.Join(root, "config", name))
if err != nil || !bytes.Equal(got, want) {
t.Fatalf("恢复后的配置文件不匹配:%s,%v", name, err)
}
}
}
func TestTransactionProcessCrashRecovery(t *testing.T) {
cases := []struct {
point string
phase TransactionPhase
new bool
}{
{"prepared", TransactionPrepared, false},
{"partial_sections", TransactionPrepared, false},
{"sections_committed", TransactionSectionsCommitted, false},
{"root_before_manifest", TransactionSectionsCommitted, true},
{"root_committed", TransactionRootCommitted, true},
{"verified", TransactionVerified, true},
}
for _, kind := range []DatabaseType{DatabaseTypeSQLite, DatabaseTypeMySQL, DatabaseTypePostgreSQL} {
t.Run(string(kind), func(t *testing.T) {
for _, test := range cases {
t.Run(test.point, func(t *testing.T) {
root, current := crashFixture(t, kind)
killTransactionChild(t, root, test.point)
manifest, err := readTransactionManifest(crashTransactionDirectory(t, root))
if err != nil || manifest.Phase != test.phase {
t.Fatalf("强制退出后事务清单状态异常:%v", err)
}
before, loadErr := NewConfigLoader(root, crashOptions()).Load()
partial := test.point == "partial_sections" || test.point == "sections_committed"
if partial && loadErr == nil {
t.Fatal("尚未恢复的不完整配置被错误接受")
}
want := current
if test.new {
want = crashCandidate(current)
}
part := "original"
if test.new {
part = "candidate"
}
files := readCrashConfigFiles(t, filepath.Join(crashTransactionDirectory(t, root), part))
if !partial && (loadErr != nil || before.Generation != want.Generation) {
t.Fatalf("恢复前完整配置代次异常:%v", loadErr)
}
// 两次走正式启动恢复路径,同时验证锁释放和恢复幂等。
for attempt := 0; attempt < 2; attempt++ {
manager, err := NewConfigManager(root, crashOptions())
if err != nil {
t.Fatalf("第 %d 次启动恢复失败:%v", attempt+1, err)
}
got := manager.Snapshot()
if got.Generation != want.Generation || !reflect.DeepEqual(got.Root.Database, want.Root.Database) {
t.Fatal("启动后选中的数据库或配置代次不匹配")
}
assertCrashConfigFiles(t, root, files)
}
entries, err := os.ReadDir(filepath.Join(root, "data", "transaction"))
if err != nil || len(entries) != 0 {
t.Fatalf("恢复后仍有活动事务:%v", err)
}
entries, err = os.ReadDir(filepath.Join(root, "data"))
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
if entry.Name() != ".config-state.lock" && entry.Name() != "transaction" {
t.Fatalf("纯配置恢复意外创建数据文件:%s", entry.Name())
}
}
})
}
})
}
}
func TestTransactionProcessCrashRejectsTamperedRecovery(t *testing.T) {
for _, part := range []string{"original", "candidate"} {
t.Run(part, func(t *testing.T) {
root, _ := crashFixture(t, DatabaseTypeSQLite)
files := readCrashConfigFiles(t, filepath.Join(root, "config"))
killTransactionChild(t, root, "prepared")
directory := crashTransactionDirectory(t, root)
path := filepath.Join(directory, part, "config.json")
if err := os.WriteFile(path, []byte("{}"), 0600); err != nil {
t.Fatal(err)
}
for attempt := 0; attempt < 2; attempt++ {
if _, err := NewConfigManager(root, crashOptions()); !errors.Is(err, ErrConfigDigestMismatch) {
t.Fatalf("损坏事务没有失败关闭:%v", err)
}
assertCrashConfigFiles(t, root, files)
if _, err := os.Stat(path); err != nil {
t.Fatal("失败关闭不应删除损坏证据")
}
}
})
}
}
// TestTransactionUnpublishedDirectoryFailsClosed 复现首份清单发布前的磁盘状态。
// 无法证明来源的残留必须保留并阻止启动,不能把安全拒绝报告成自动恢复成功。
func TestTransactionUnpublishedDirectoryFailsClosed(t *testing.T) {
root, _ := crashFixture(t, DatabaseTypeSQLite)
files := readCrashConfigFiles(t, filepath.Join(root, "config"))
directory := filepath.Join(root, "data", "transaction", "0123456789abcdef0123456789abcdef")
for _, part := range []string{"original", "candidate"} {
if err := os.MkdirAll(filepath.Join(directory, part), 0700); err != nil {
t.Fatal(err)
}
}
for attempt := 0; attempt < 2; attempt++ {
if _, err := NewConfigManager(root, crashOptions()); !errors.Is(err, ErrTransactionInvalid) {
t.Fatalf("无清单残留没有阻止启动:%v", err)
}
assertCrashConfigFiles(t, root, files)
if _, err := os.Stat(directory); err != nil {
t.Fatal("无清单残留被错误删除")
}
}
}