kra-new/internal/data/config_store.go

265 lines
8.5 KiB
Go

package data
import (
"context"
"fmt"
"kra/internal/config"
dataintegration "kra/internal/data/integration"
"kra/internal/integration/storage"
"gorm.io/gorm"
)
// Writing config.yaml goes through config.Document, which owns the file format
// for both directions. This file only decides which sections to write.
// cloneAdminConfig copies an admin section, normalizing nil to an empty value so
// callers can edit the result without a nil check.
func cloneAdminConfig(value *config.Admin) *config.Admin {
if value == nil {
return &config.Admin{}
}
return config.CloneAdmin(value)
}
func (d *Data) persistConfig() error {
dataConfig, adminConfig := d.runtime.Values()
return d.persistConfigValues(dataConfig, adminConfig)
}
func (d *Data) persistConfigValues(dataConfig *config.Data, adminConfig *config.Admin) error {
d.configMu.Lock()
defer d.configMu.Unlock()
if adminConfig == nil || adminConfig.ConfigPath == "" {
return nil
}
document, err := config.OpenDocument(adminConfig.ConfigPath)
if err != nil {
return err
}
if err = document.Set("data", dataConfig); err != nil {
return err
}
// Storage and email live in the database; the file must not shadow them.
fileAdmin := cloneAdminConfig(adminConfig)
fileAdmin.Storage = nil
fileAdmin.Email = nil
if err = document.Set("admin", fileAdmin); err != nil {
return err
}
document.Delete("admin", "storage")
document.Delete("admin", "email")
if adminConfig.System != nil {
if err = document.SetServerHTTPPort(adminConfig.System.Addr); err != nil {
return err
}
}
return document.Save()
}
// persistDatabaseConfig writes only the database selected on the init page and
// the freshly generated JWT signing key. Initialization must not serialize the
// partially populated runtime config back over the template: doing so removes
// all omitted/default settings from data and admin and leaves the next startup
// with no visible configuration.
func (d *Data) persistDatabaseConfig(database *config.Database, signingKey string) error {
d.configMu.Lock()
defer d.configMu.Unlock()
configPath := d.runtime.ConfigPath()
if configPath == "" {
return nil
}
document, err := config.OpenDocument(configPath)
if err != nil {
return err
}
source, err := databaseDSN(database, "")
if err != nil {
return err
}
// Update the fields owned by the init page explicitly and leave advanced
// database, Redis, Mongo, admin and server settings untouched.
databaseValue := map[string]any{
"driver": database.Driver,
"source": source,
"host": database.Host,
"port": database.Port,
"user": database.User,
"password": database.Password,
"name": database.Name,
"config": database.Config,
"path": database.Path,
}
if err = document.Set("data", map[string]any{"database": databaseValue}); err != nil {
return err
}
if signingKey != "" {
if err = document.Set("admin", map[string]any{"jwt": map[string]any{"signing_key": signingKey}}); err != nil {
return err
}
}
document.Delete("admin", "storage")
document.Delete("admin", "email")
return document.Save()
}
func (d *Data) removeIntegrationConfigFromFile() error {
d.configMu.Lock()
defer d.configMu.Unlock()
configPath := d.runtime.ConfigPath()
if configPath == "" {
return nil
}
document, err := config.OpenDocument(configPath)
if err != nil {
return err
}
if !document.Has("admin", "storage") && !document.Has("admin", "email") {
return nil
}
document.Delete("admin", "storage")
document.Delete("admin", "email")
return document.Save()
}
func (d *Data) reloadConfig(ctx context.Context) error {
d.configMu.Lock()
defer d.configMu.Unlock()
configPath := d.runtime.ConfigPath()
if configPath == "" {
return fmt.Errorf("configuration path is not set")
}
next, err := config.Load(configPath)
if err != nil {
return err
}
if next.Data == nil || next.Data.Database == nil || next.Admin == nil {
return fmt.Errorf("data.database and admin configuration are required")
}
next.Admin.ConfigPath = configPath
// Every client below is opened before anything is published, so a failure at
// any step leaves the running process untouched. undo closes whatever this
// call opened; commit at the end hands all of it over instead.
var undo rollback
defer undo.run()
databaseReady := databaseConnectionConfigured(next.Data.Database)
var candidateDB *gorm.DB
if databaseReady {
candidateDB, err = openDatabase(next.Data.Database, false, "", d.logger())
if err != nil {
return fmt.Errorf("reload database: %w", err)
}
} else {
candidateDB, err = openFallbackDatabase(d.logger())
if err != nil {
return fmt.Errorf("reload bootstrap database: %w", err)
}
}
undo.add(func() { closeGormDB(candidateDB) })
if databaseReady {
if sqlDB, dbErr := candidateDB.DB(); dbErr != nil {
return dbErr
} else if err = sqlDB.PingContext(ctx); err != nil {
return fmt.Errorf("reload database: %w", err)
}
}
if databaseReady && (next.Admin.System == nil || !next.Admin.System.DisableAutoMigrate) {
if err = migrateAll(candidateDB.WithContext(ctx), d.catalog); err != nil {
return fmt.Errorf("reload database migrations: %w", err)
}
}
legacyStorage := next.Admin.Storage
legacyEmail := next.Admin.Email
currentAdmin := d.runtime.Admin()
if legacyStorage == nil {
if currentAdmin != nil {
legacyStorage = currentAdmin.Storage
}
}
if legacyEmail == nil && currentAdmin != nil {
legacyEmail = currentAdmin.Email
}
storageConfig, err := dataintegration.ResolveStorageConfig(candidateDB.WithContext(ctx), legacyStorage)
if err != nil {
return fmt.Errorf("reload storage configuration: %w", err)
}
next.Admin.Storage = storageConfig
emailConfig, err := dataintegration.ResolveEmailConfig(candidateDB.WithContext(ctx), legacyEmail)
if err != nil {
return fmt.Errorf("reload email configuration: %w", err)
}
next.Admin.Email = emailConfig
candidateStorage, err := storage.New(next.Admin)
if err != nil {
return fmt.Errorf("reload storage: %w", err)
}
useRedis := next.Admin.System != nil && next.Admin.System.UseRedis
candidateRedis := openRedis(next.Data.Redis, useRedis, d.logger())
undo.add(func() {
if candidateRedis != nil {
closeRedisClient(candidateRedis)
}
})
useRedisList := useRedis && next.Admin.System.UseMultipoint
candidateRedisList := openRedisList(next.Data.RedisList, useRedisList, d.logger())
undo.add(func() { closeRedisList(candidateRedisList) })
useMongo := next.Admin.System != nil && next.Admin.System.UseMongo
// openMongo always reports a nil client alongside its error, so an unreachable
// Mongo only warns: it must not fail an otherwise valid reload.
candidateMongo, mongoErr := openMongo(next.Data.Mongo, useMongo)
if mongoErr != nil {
d.logger().Error("mongo unavailable during configuration reload", "mod", "mongo", "error", mongoErr)
}
undo.add(func() {
if candidateMongo != nil {
closeMongoClient(candidateMongo)
}
})
candidateDBList, err := openDatabaseList(next.Data.DatabaseList, d.logger())
if err != nil {
return err
}
undo.add(func() { closeDatabaseList(candidateDBList) })
integrationConfigs, err := dataintegration.ReadRuntime(candidateDB.WithContext(ctx))
if err != nil {
return fmt.Errorf("reload integration runtime: %w", err)
}
d.replacePrimaryDB(candidateDB)
d.databaseReady.Store(databaseReady)
for _, item := range candidateDBList {
registerDataScopeCallbacks(item, d.enqueueDataScopeAudit)
}
d.replaceDatabaseList(candidateDBList)
// openRedis and openRedisList return nil when a ping fails, so replacing
// unconditionally would let a transient Redis outage during an unrelated
// configuration reload retire the still-healthy clients and silently downgrade
// the process to the in-memory cache until the next reload.
if candidateRedis != nil || !useRedis || !redisConnectionConfigured(next.Data.Redis) {
d.redis.replace(candidateRedis)
} else {
d.logger().Warn("keeping the previous redis client because the reloaded configuration failed to connect", "mod", "redis")
}
if candidateRedisList != nil || !useRedisList || len(next.Data.RedisList) == 0 {
d.replaceRedisList(candidateRedisList)
} else {
d.logger().Warn("keeping the previous redis list because the reloaded configuration failed to connect", "mod", "redis")
}
// A nil client here means Mongo is switched off in the reloaded configuration,
// which must still retire the running client; only an outright failure keeps it.
if mongoErr == nil {
d.mongo.replace(candidateMongo)
}
undo.commit()
d.runtime.Replace(next)
if d.integrations != nil {
d.integrations.Replace(integrationConfigs)
}
if d.storage != nil {
d.storage.Replace(candidateStorage)
}
return nil
}