252 lines
8.2 KiB
Go
252 lines
8.2 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.DeleteIntegrationConfig()
|
|
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.DeleteIntegrationConfig()
|
|
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.DeleteIntegrationConfig()
|
|
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
|
|
}
|
|
next = config.MergeRuntimeConfig(d.Config(), next)
|
|
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)
|
|
}
|
|
}
|
|
storageConfig, err := dataintegration.ResolveStorageConfig(candidateDB.WithContext(ctx), next.Admin.Storage)
|
|
if err != nil {
|
|
return fmt.Errorf("reload storage configuration: %w", err)
|
|
}
|
|
next.Admin.Storage = storageConfig
|
|
emailConfig, err := dataintegration.ResolveEmailConfig(candidateDB.WithContext(ctx), next.Admin.Email)
|
|
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
|
|
}
|