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 { // Explicit reload is the lifecycle boundary that rebuilds database, cache, // Mongo, storage, and integration clients after a file snapshot change. 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 }