修复配置原子写中断后的启动恢复
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-22 01:09:18 +08:00
parent 079ebee495
commit 5e7b57d754
2 changed files with 125 additions and 0 deletions

View File

@ -7,6 +7,7 @@ import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
@ -60,6 +61,8 @@ var obsoleteFiles = map[string]bool{
"config.dev.json": true, "config_mysql.json": true, "config.json.legacy": true,
}
var atomicTemporaryFilePattern = regexp.MustCompile(`^\.(?:config\.json|smtp\.json|email\.json|ssl\.json|plugins\.json)-[0-9a-f]{32}$`)
type Move struct {
Source string
Target string
@ -90,6 +93,16 @@ func Plan(root string) ([]Move, error) {
var moves []Move
for _, entry := range entries {
name := entry.Name()
if atomicTemporaryFilePattern.MatchString(name) {
info, err := os.Lstat(filepath.Join(configDirectory, name))
if err != nil {
return nil, err
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return nil, errors.New("配置临时文件不能是目录")
}
continue
}
if configurationFiles[name] && !entry.IsDir() {
continue
}
@ -153,6 +166,9 @@ func CheckReady(root string) error {
if err := CheckKeyLayout(root); err != nil {
return err
}
if err := cleanAtomicTemporaryFiles(root); err != nil {
return err
}
if _, err := os.Lstat(filepath.Join(root, "data", migrationLock)); err == nil || !os.IsNotExist(err) {
return errors.New("运行目录迁移尚未结束,请保留现场并核对迁移锁")
}
@ -219,6 +235,35 @@ func CheckReady(root string) error {
return nil
}
// cleanAtomicTemporaryFiles 丢弃未完成的原子写入临时文件,不能触碰未知文件。
func cleanAtomicTemporaryFiles(root string) error {
configDirectory := filepath.Join(root, "config")
entries, err := os.ReadDir(configDirectory)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
for _, entry := range entries {
if !atomicTemporaryFilePattern.MatchString(entry.Name()) {
continue
}
path := filepath.Join(configDirectory, entry.Name())
info, err := os.Lstat(path)
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return errors.New("配置原子临时文件无效")
}
if err := os.Remove(path); err != nil {
return err
}
}
return nil
}
// Migrate 必须停服调用;同卷重命名保留文件内容,失败逆序回滚,重试保持幂等。
func Migrate(root string) ([]Move, error) {
return migrate(root, os.Rename)

View File

@ -0,0 +1,80 @@
package runtimepath
import (
"os"
"path/filepath"
"runtime"
"testing"
)
func TestCheckReadyRemovesInterruptedAtomicConfigurationTemporaryFiles(t *testing.T) {
root := t.TempDir()
if err := os.Mkdir(filepath.Join(root, "config"), 0700); err != nil {
t.Fatal(err)
}
temporary := filepath.Join(root, "config", ".config.json-0123456789abcdef0123456789abcdef")
if err := os.WriteFile(temporary, []byte("incomplete"), 0600); err != nil {
t.Fatal(err)
}
if err := CheckReady(root); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(temporary); !os.IsNotExist(err) {
t.Fatalf("temporary file still exists: %v", err)
}
}
func TestCheckReadyRejectsAtomicTemporaryDirectory(t *testing.T) {
root := t.TempDir()
configDirectory := filepath.Join(root, "config")
if err := os.Mkdir(configDirectory, 0700); err != nil {
t.Fatal(err)
}
path := filepath.Join(configDirectory, ".config.json-0123456789abcdef0123456789abcdef")
if err := os.Mkdir(path, 0700); err != nil {
t.Fatal(err)
}
if err := CheckReady(root); err == nil {
t.Fatal("expected invalid temporary directory to be rejected")
}
}
func TestCheckReadyRejectsAtomicTemporarySymlink(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("当前 Windows 测试环境通常没有创建符号链接的权限")
}
root := t.TempDir()
configDirectory := filepath.Join(root, "config")
if err := os.Mkdir(configDirectory, 0700); err != nil {
t.Fatal(err)
}
path := filepath.Join(configDirectory, ".config.json-0123456789abcdef0123456789abcdef")
if err := os.Symlink(filepath.Join(configDirectory, "missing"), path); err != nil {
t.Fatal(err)
}
if err := CheckReady(root); err == nil {
t.Fatal("expected invalid temporary symlink to be rejected")
}
}
func TestPlanIgnoresKnownAtomicTemporaryFilesButRejectsUnknownFiles(t *testing.T) {
root := t.TempDir()
configDirectory := filepath.Join(root, "config")
if err := os.Mkdir(configDirectory, 0700); err != nil {
t.Fatal(err)
}
known := filepath.Join(configDirectory, ".smtp.json-0123456789abcdef0123456789abcdef")
if err := os.WriteFile(known, []byte("incomplete"), 0600); err != nil {
t.Fatal(err)
}
if moves, err := Plan(root); err != nil || len(moves) != 0 {
t.Fatalf("known temporary file should be ignored, moves=%v err=%v", moves, err)
}
unknown := filepath.Join(configDirectory, ".not-an-atomic-file")
if err := os.WriteFile(unknown, []byte("unexpected"), 0600); err != nil {
t.Fatal(err)
}
if _, err := Plan(root); err == nil {
t.Fatal("unknown file should be rejected")
}
}