This commit is contained in:
parent
71a052fbc9
commit
70a2f39042
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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})
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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("配置切换失败")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user