完善配置快照预检与数据库迁移失败处理
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-08-30 21:34:23 +08:00
parent 71a052fbc9
commit 70a2f39042
9 changed files with 147 additions and 2 deletions

View File

@ -59,6 +59,9 @@ func (m *ConfigManager) SwitchDatabase(ctx context.Context, expectedGeneration u
if err := ValidateRuntimeSecrets(m.vault, candidate); err != nil {
return err
}
if err := m.snapshots.Validate(candidate); err != nil {
return err
}
if _, _, err := m.transactions.CreateBackupIfDue(current, time.Now().UTC()); err != nil {
return err
}

View File

@ -108,11 +108,17 @@ type LegacyPluginSecretImporter func(context.Context, []LegacyPluginSecret) erro
var ErrPluginSecretMigrationRequired = errors.New("plugin secret migration is required")
type runtimeSnapshotStore interface {
Validate(RuntimeConfig) error
Publish(RuntimeConfig) error
Snapshot() (RuntimeConfig, error)
}
type ConfigManager struct {
mu sync.RWMutex
writeMu sync.Mutex
loader *ConfigLoader
snapshots *SnapshotStore
snapshots runtimeSnapshotStore
current RuntimeConfig
pending *pendingConfig
status ConfigStatus
@ -349,6 +355,9 @@ func (m *ConfigManager) apply(ctx context.Context, generation uint64, actor Audi
if err := ValidateRuntimeSecrets(m.vault, candidate); err != nil {
return nil, err
}
if err := m.snapshots.Validate(candidate); err != nil {
return nil, err
}
if _, _, err := m.transactions.CreateBackupIfDue(current, time.Now().UTC()); err != nil {
return nil, err
}
@ -523,6 +532,9 @@ func (m *ConfigManager) rollback(ctx context.Context, generation uint64, actor A
if err := ValidateRuntimeConfig(candidate, m.options); err != nil {
return nil, err
}
if err := m.snapshots.Validate(candidate); err != nil {
return nil, err
}
if _, _, err := m.transactions.CreateBackupIfDue(current, time.Now().UTC()); err != nil {
return nil, err
}
@ -831,6 +843,9 @@ func (m *ConfigManager) MigrateLegacyPluginSecrets(ctx context.Context, importer
if err := ValidateRuntimeConfig(candidate, m.options); err != nil {
return false, err
}
if err := m.snapshots.Validate(candidate); err != nil {
return false, err
}
if _, _, err := m.transactions.CreateBackupIfDue(current, time.Now().UTC()); err != nil {
return false, err
}

View File

@ -56,6 +56,23 @@ func authorizedConfigActor() AuditActor {
return AuditActor{UserID: "1", Role: "super_admin", SessionID: "test-session", RecentAuth: true, RecoveryCodeConfigured: true}
}
type rejectingRuntimeSnapshotStore struct {
current RuntimeConfig
err error
}
func (s *rejectingRuntimeSnapshotStore) Validate(RuntimeConfig) error {
return s.err
}
func (s *rejectingRuntimeSnapshotStore) Publish(RuntimeConfig) error {
return s.err
}
func (s *rejectingRuntimeSnapshotStore) Snapshot() (RuntimeConfig, error) {
return cloneRuntimeConfig(s.current), nil
}
func TestConfigLoaderLoadsFixedPartitionsAndRejectsDigestMismatch(t *testing.T) {
root := t.TempDir()
writeRuntimeFixture(t, root, validRuntimeConfig())
@ -122,6 +139,40 @@ func TestConfigLoaderRejectsMissingPartitionAndGenerationMismatch(t *testing.T)
}
}
func TestConfigManagerApplyPreflightsSnapshotBeforePersisting(t *testing.T) {
root := t.TempDir()
writeRuntimeFixture(t, root, validRuntimeConfig())
options := ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}}
manager, err := NewConfigManager(root, options)
if err != nil {
t.Fatal(err)
}
current := manager.Snapshot()
manager.snapshots = &rejectingRuntimeSnapshotStore{current: current, err: errors.New("snapshot validation rejected")}
email := current.Email
email.Policy.MaxMessageBytes = 8 << 20
payload, err := json.Marshal(email)
if err != nil {
t.Fatal(err)
}
updated, err := manager.Update(context.Background(), UpdateRequest{
Section: SectionEmail, ExpectedGeneration: current.Generation, Payload: payload, Actor: authorizedConfigActor(),
})
if err != nil {
t.Fatal(err)
}
if _, err := manager.Apply(context.Background(), updated.Generation); !errors.Is(err, manager.snapshots.(*rejectingRuntimeSnapshotStore).err) {
t.Fatalf("apply error = %v, want snapshot validation error", err)
}
loaded, err := NewConfigLoader(root, options).Load()
if err != nil {
t.Fatal(err)
}
if loaded.Generation != current.Generation || loaded.Email.Policy.MaxMessageBytes == 8<<20 {
t.Fatalf("candidate was persisted before snapshot validation: %+v", loaded)
}
}
func TestConfigManagerUpdateApplyUsesPendingGeneration(t *testing.T) {
root := t.TempDir()
writeRuntimeFixture(t, root, validRuntimeConfig())

View File

@ -20,12 +20,19 @@ func NewSnapshotStore(initial RuntimeConfig, options RuntimeValidationOptions) (
return store, nil
}
func (s *SnapshotStore) Validate(candidate RuntimeConfig) error {
if s == nil {
return ErrRuntimeSnapshotUnavailable
}
return ValidateRuntimeConfig(candidate, s.options)
}
func (s *SnapshotStore) Publish(candidate RuntimeConfig) error {
if s == nil {
return ErrRuntimeSnapshotUnavailable
}
candidate = cloneRuntimeConfig(candidate)
if err := ValidateRuntimeConfig(candidate, s.options); err != nil {
if err := s.Validate(candidate); err != nil {
return err
}
for {

View File

@ -24,6 +24,28 @@ func TestSnapshotStoreRejectsInvalidCandidate(t *testing.T) {
}
}
func TestSnapshotStoreValidateDoesNotPublishCandidate(t *testing.T) {
initial := validRuntimeConfig()
store, err := NewSnapshotStore(initial, RuntimeValidationOptions{HasSuperAdmin: true, Production: true})
if err != nil {
t.Fatal(err)
}
candidate := validRuntimeConfig()
candidate.Generation = 2
candidate.Root.Generation = 2
candidate.Root.Sections = validSectionReferences(2)
candidate.SMTP.Meta.Generation = 2
candidate.Email.Meta.Generation = 2
candidate.SSL.Meta.Generation = 2
candidate.Plugins.Meta.Generation = 2
if err := store.Validate(candidate); err != nil {
t.Fatal(err)
}
if got := store.Generation(); got != initial.Generation {
t.Fatalf("validation changed active generation: got %d, want %d", got, initial.Generation)
}
}
func TestSnapshotStoreReturnsDeepCopies(t *testing.T) {
initial := validRuntimeConfig()
store, err := NewSnapshotStore(initial, RuntimeValidationOptions{HasSuperAdmin: true, Production: true})

View File

@ -126,6 +126,10 @@ func (e *SQLExecutor) Run(ctx context.Context, plan Plan, update func(Status) er
return ErrSwitchUnavailable
}
if err := e.authorizedSwitch(ctx, plan.expectedGeneration, plan.targetConfig, plan.actor); err != nil {
if isKnownSwitchRejection(err) {
cleanupStructuredTarget(e.copier, plan.sourceConfig, plan.targetConfig)
return err
}
return errors.Join(ErrSwitchOutcomeUnknown, err)
}
if cleanup, ok := e.copier.(StructuredDatabaseCleanup); ok {
@ -134,6 +138,14 @@ func (e *SQLExecutor) Run(ctx context.Context, plan Plan, update func(Status) er
return nil
}
func isKnownSwitchRejection(err error) bool {
return errors.Is(err, config.ErrAuthorizationRequired) ||
errors.Is(err, config.ErrDatabaseMigrationTarget) ||
errors.Is(err, config.ErrGenerationConflict) ||
errors.Is(err, config.ErrStepUpRequired) ||
errors.Is(err, config.ErrInvalidRuntimeConfig)
}
func cleanupStructuredTarget(copier StructuredDatabaseCopier, source, target config.DatabaseConfig) {
cleanup, ok := copier.(StructuredDatabaseCleanup)
if !ok {

View File

@ -190,6 +190,20 @@ func (c *cleanupRecordingCopier) Complete(config.DatabaseConfig, config.Database
c.completeCalled = true
}
func TestSQLExecutorCleansTargetForKnownConfigurationSwitchRejection(t *testing.T) {
copier := &cleanupRecordingCopier{}
executor := NewSQLExecutor(copier, func(context.Context, uint64, config.DatabaseConfig, config.AuditActor) error {
return config.ErrGenerationConflict
})
err := executor.Run(context.Background(), validStructuredPlan(t), func(Status) error { return nil })
if !errors.Is(err, config.ErrGenerationConflict) || errors.Is(err, ErrSwitchOutcomeUnknown) {
t.Fatalf("known switch rejection = %v", err)
}
if !copier.cleanupCalled {
t.Fatal("structured target was not cleaned after known switch rejection")
}
}
func TestSQLExecutorPreservesStructuredTargetWhenConfigurationSwitchOutcomeIsUnknown(t *testing.T) {
copier := &cleanupRecordingCopier{}
executor := NewSQLExecutor(copier, func(context.Context, uint64, config.DatabaseConfig, config.AuditActor) error {

View File

@ -134,6 +134,10 @@ func (e *SQLiteExecutor) Run(ctx context.Context, plan Plan, update func(Status)
switchErr = e.switchConfig(ctx, plan.targetConfig)
}
if switchErr != nil {
if isKnownSwitchRejection(switchErr) {
_ = os.Remove(targetPath)
return switchErr
}
return errors.Join(ErrSwitchOutcomeUnknown, switchErr)
}
switched = true

View File

@ -139,6 +139,23 @@ func TestSQLiteExecutorRequiresConfigurationSwitch(t *testing.T) {
}
}
func TestSQLiteExecutorCleansTargetForKnownConfigurationSwitchRejection(t *testing.T) {
service, root := newSQLiteMigrationFixture(t, func(context.Context, config.DatabaseConfig) error {
return config.ErrGenerationConflict
})
record, token, err := service.Preview(context.Background(), config.DatabaseConfig{Type: config.DatabaseTypeSQLite, LocalRef: "smail.db"}, 1, "known-rejection.db")
if err != nil {
t.Fatal(err)
}
failed, err := service.Execute(context.Background(), record.ID, token)
if !errors.Is(err, config.ErrGenerationConflict) || errors.Is(err, ErrSwitchOutcomeUnknown) {
t.Fatalf("known switch rejection = %v, record = %+v", err, failed)
}
if _, statErr := os.Stat(filepath.Join(root, "config", "known-rejection.db")); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("target database remained after known switch rejection, stat error = %v", statErr)
}
}
func TestSQLiteExecutorPreservesTargetWhenConfigurationSwitchOutcomeIsUnknown(t *testing.T) {
service, root := newSQLiteMigrationFixture(t, func(context.Context, config.DatabaseConfig) error {
return errors.New("配置切换失败")