优化结构

This commit is contained in:
Yvan
2026-08-24 13:56:06 +08:00
parent 0b6b8ddf1a
commit acaee94858
21 changed files with 249 additions and 3025 deletions
+1 -1
View File
@@ -65,7 +65,7 @@ func (s MediaSettings) EffectiveMaxFileSize() int64 {
}
// RuntimeSettings exposes only the active values needed by the application.
// The data implementation resolves every call from conf.Runtime so hot reloads
// The data implementation resolves every call from config.Store so hot reloads
// take effect without rebuilding services.
type RuntimeSettings interface {
RouterPrefix() string
-2322
View File
File diff suppressed because it is too large Load Diff
-218
View File
@@ -1,218 +0,0 @@
syntax = "proto3";
package kratos.api;
import "google/protobuf/duration.proto";
option go_package = "kra/internal/conf;conf";
message Bootstrap {
Server server = 1;
Data data = 2;
AdminBackend admin = 3;
}
message Server {
message HTTP {
string network = 1;
string addr = 2;
google.protobuf.Duration timeout = 3;
}
HTTP http = 1;
}
message Data {
message Database {
string driver = 1;
string source = 2;
string host = 3;
string port = 4;
string user = 5;
string password = 6;
string name = 7;
string config = 8;
string path = 9;
string alias_name = 10;
bool disable = 11;
string prefix = 12;
string engine = 13;
string log_mode = 14;
int32 max_idle_conns = 15;
int32 max_open_conns = 16;
int32 conn_max_lifetime = 17;
bool singular = 18;
}
message Redis {
string network = 1;
string addr = 2;
google.protobuf.Duration read_timeout = 3;
google.protobuf.Duration write_timeout = 4;
string name = 5;
string password = 6;
int32 db = 7;
bool use_cluster = 8;
repeated string cluster_addrs = 9;
}
message MongoHost {
string host = 1;
string port = 2;
}
message Mongo {
string coll = 1;
string options = 2;
string database = 3;
string username = 4;
string password = 5;
string auth_source = 6;
uint64 min_pool_size = 7;
uint64 max_pool_size = 8;
int64 socket_timeout_ms = 9;
int64 connect_timeout_ms = 10;
bool is_zap = 11;
repeated MongoHost hosts = 12;
}
Database database = 1;
Redis redis = 2;
repeated Database database_list = 3;
repeated Redis redis_list = 4;
Mongo mongo = 5;
}
// AdminBackend contains settings for the administration HTTP transport.
message AdminBackend {
string router_prefix = 1;
JWT jwt = 2;
Captcha captcha = 3;
Local local = 4;
Email email = 5;
Storage storage = 6;
// ConfigPath is populated by the entrypoint and is not required in YAML.
string config_path = 7;
Media media = 8;
repeated Disk disk_list = 9;
System system = 10;
Zap zap = 11;
CORS cors = 12;
App app = 13;
message JWT {
string signing_key = 1;
google.protobuf.Duration expires_time = 2;
google.protobuf.Duration buffer_time = 3;
string issuer = 4;
}
message Captcha {
int32 key_long = 1;
int32 img_width = 2;
int32 img_height = 3;
google.protobuf.Duration store_expiration = 4;
}
message Local {
string store_path = 1;
string path_prefix = 2;
}
message Email {
string to = 1;
string from = 2;
string host = 3;
string secret = 4;
string nickname = 5;
int32 port = 6;
bool is_ssl = 7;
bool is_login_auth = 8;
}
message Media {
int32 session_ttl = 1;
int64 max_file_size = 2;
string chunk_dir = 3;
}
message Disk {
string mount_point = 1;
}
message System {
bool use_redis = 1;
bool use_multipoint = 2;
bool use_strict_auth = 3;
bool disable_auto_migrate = 4;
bool use_mongo = 5;
int32 addr = 6;
int32 iplimit_count = 7;
int32 iplimit_time = 8;
}
message Zap {
string level = 1;
string prefix = 2;
string format = 3;
string director = 4;
string encode_level = 5;
string stacktrace_key = 6;
bool show_line = 7;
bool log_in_console = 8;
int32 retention_day = 9;
bool access_req_body = 10;
bool access_resp_data = 11;
bool access_req_headers = 12;
int32 access_log_max_bytes = 13;
repeated string file_only_modules = 14;
}
message CORS {
string mode = 1;
repeated CORSRule whitelist = 2;
}
message CORSRule {
string allow_origin = 1;
string allow_methods = 2;
string allow_headers = 3;
string expose_headers = 4;
bool allow_credentials = 5;
}
message App {
string node = 1;
string app_id = 2;
string env = 3;
}
message Storage {
string type = 1;
Qiniu qiniu = 2;
ObjectStore aliyun_oss = 3;
ObjectStore huawei_obs = 4;
ObjectStore tencent_cos = 5;
ObjectStore aws_s3 = 6;
ObjectStore cloudflare_r2 = 7;
ObjectStore minio = 8;
}
message Qiniu {
string zone = 1;
string bucket = 2;
string base_url = 3;
string access_key = 4;
string secret_key = 5;
bool use_https = 6;
bool use_cdn_domains = 7;
}
// ObjectStore covers S3-compatible configuration used by public cloud
// providers and self-hosted MinIO.
message ObjectStore {
string endpoint = 1;
string region = 2;
string bucket = 3;
string access_key = 4;
string secret_key = 5;
string base_url = 6;
string path_prefix = 7;
bool use_ssl = 8;
bool force_path_style = 9;
string account_id = 10;
}
}
-128
View File
@@ -1,128 +0,0 @@
package conf
import (
"sync"
"sync/atomic"
"google.golang.org/protobuf/proto"
)
// Runtime owns the active immutable configuration snapshot. Callers always
// receive clones so a config file reload cannot race with request handling.
type Runtime struct {
snapshot atomic.Pointer[runtimeSnapshot]
mu sync.RWMutex
nextID uint64
listeners map[uint64]func(*Data, *AdminBackend)
}
type runtimeSnapshot struct {
data *Data
admin *AdminBackend
}
func NewRuntime(data *Data, admin *AdminBackend) *Runtime {
runtime := &Runtime{listeners: make(map[uint64]func(*Data, *AdminBackend))}
runtime.Replace(data, admin)
return runtime
}
func cloneData(value *Data) *Data {
if value == nil {
return nil
}
return proto.Clone(value).(*Data)
}
func cloneAdmin(value *AdminBackend) *AdminBackend {
if value == nil {
return nil
}
return proto.Clone(value).(*AdminBackend)
}
func (r *Runtime) Replace(data *Data, admin *AdminBackend) {
data = cloneData(data)
admin = cloneAdmin(admin)
r.snapshot.Store(&runtimeSnapshot{data: data, admin: admin})
r.mu.RLock()
listeners := make([]func(*Data, *AdminBackend), 0, len(r.listeners))
for _, listener := range r.listeners {
listeners = append(listeners, listener)
}
r.mu.RUnlock()
for _, listener := range listeners {
listener(cloneData(data), cloneAdmin(admin))
}
}
// Subscribe registers a callback invoked after every successful runtime
// configuration replacement. It is used by long-lived clients such as the
// logger and other long-lived clients that must follow runtime config changes.
func (r *Runtime) Subscribe(listener func(*Data, *AdminBackend)) func() {
if listener == nil {
return func() {}
}
r.mu.Lock()
r.nextID++
id := r.nextID
r.listeners[id] = listener
r.mu.Unlock()
return func() {
r.mu.Lock()
delete(r.listeners, id)
r.mu.Unlock()
}
}
func (r *Runtime) Data() *Data {
snapshot := r.snapshot.Load()
if snapshot == nil {
return nil
}
return cloneData(snapshot.data)
}
func (r *Runtime) Admin() *AdminBackend {
snapshot := r.snapshot.Load()
if snapshot == nil {
return nil
}
return cloneAdmin(snapshot.admin)
}
func (r *Runtime) Values() (*Data, *AdminBackend) {
snapshot := r.snapshot.Load()
if snapshot == nil {
return nil, nil
}
return cloneData(snapshot.data), cloneAdmin(snapshot.admin)
}
func (r *Runtime) ConfigPath() string {
snapshot := r.snapshot.Load()
if snapshot == nil || snapshot.admin == nil {
return ""
}
return snapshot.admin.ConfigPath
}
func (r *Runtime) UpdateDatabase(database *Data_Database) {
for {
current := r.snapshot.Load()
var data *Data
var admin *AdminBackend
if current != nil {
data = cloneData(current.data)
admin = cloneAdmin(current.admin)
}
if data == nil {
data = &Data{}
}
data.Database = proto.Clone(database).(*Data_Database)
next := &runtimeSnapshot{data: data, admin: admin}
if r.snapshot.CompareAndSwap(current, next) {
return
}
}
}
-41
View File
@@ -1,41 +0,0 @@
package conf
import (
"sync"
"testing"
)
func TestRuntimeSnapshotsAreImmutable(t *testing.T) {
runtime := NewRuntime(&Data{Database: &Data_Database{Name: "first"}}, &AdminBackend{RouterPrefix: "/api", Jwt: &AdminBackend_JWT{SigningKey: "secret"}})
admin := runtime.Admin()
admin.RouterPrefix = "/changed"
admin.Jwt.SigningKey = "changed"
data := runtime.Data()
data.Database.Name = "changed"
if got := runtime.Admin(); got.RouterPrefix != "/api" || got.Jwt.SigningKey != "secret" {
t.Fatalf("admin snapshot mutated: %+v", got)
}
if got := runtime.Data().Database.Name; got != "first" {
t.Fatalf("data snapshot mutated: %s", got)
}
}
func TestRuntimeConcurrentReplaceAndRead(t *testing.T) {
runtime := NewRuntime(&Data{Database: &Data_Database{Name: "initial"}}, &AdminBackend{Jwt: &AdminBackend_JWT{SigningKey: "initial"}})
var wait sync.WaitGroup
for worker := 0; worker < 8; worker++ {
wait.Add(1)
go func() {
defer wait.Done()
for index := 0; index < 500; index++ {
_ = runtime.Admin().Jwt.SigningKey
_ = runtime.Data().Database.Name
}
}()
}
for index := 0; index < 500; index++ {
runtime.Replace(&Data{Database: &Data_Database{Name: "next"}}, &AdminBackend{Jwt: &AdminBackend_JWT{SigningKey: "next"}})
}
wait.Wait()
}
-1
View File
@@ -2,7 +2,6 @@ package data
import (
"context"
"encoding/json"
"fmt"
"net"
"os"
+6 -2
View File
@@ -6,6 +6,7 @@ import (
"time"
"github.com/fsnotify/fsnotify"
"kra/internal/config"
)
// watchConfig refreshes the in-memory config snapshot on file changes, while
@@ -79,10 +80,13 @@ func (d *Data) watchConfig() func() {
logger.Error("reload changed config", "mod", "system", "error", loadErr)
continue
}
if next.Data == nil || next.Admin == nil {
logger.Error("reload changed config: data and admin configuration are required", "mod", "system")
if next.Data == nil {
logger.Error("reload changed config: data configuration is required", "mod", "system")
continue
}
if next.Admin == nil {
next.Admin = &config.Admin{}
}
if current := d.runtime.Admin(); current != nil {
next.Admin.Storage = current.Storage
next.Admin.Email = current.Email
+4 -4
View File
@@ -13,15 +13,15 @@ import (
func TestConfigWatcherStopWaitsForDebouncedReload(t *testing.T) {
root := t.TempDir()
configPath := filepath.Join(root, "config.yaml")
config := []byte("data:\n database:\n driver: sqlite\n path: " + filepath.ToSlash(root) + "\nadmin: {}\n")
if err := os.WriteFile(configPath, config, 0o600); err != nil {
rawConfig := []byte("data:\n database:\n driver: sqlite\n path: " + filepath.ToSlash(root) + "\nadmin: {}\n")
if err := os.WriteFile(configPath, rawConfig, 0o600); err != nil {
t.Fatal(err)
}
runtime := config.NewStore(&config.Config{Data: &config.Data{}, Admin: &config.Admin{ConfigPath: configPath}})
started := make(chan struct{})
release := make(chan struct{})
var startOnce sync.Once
stopListener := runtime.Subscribe(func(*config.Data, *config.Admin) {
stopListener := runtime.Subscribe(func(*config.Config) {
startOnce.Do(func() { close(started) })
<-release
})
@@ -30,7 +30,7 @@ func TestConfigWatcherStopWaitsForDebouncedReload(t *testing.T) {
stop := data.watchConfig()
// A write event schedules the 100ms debounce reload.
if err := os.WriteFile(configPath, config, 0o600); err != nil {
if err := os.WriteFile(configPath, rawConfig, 0o600); err != nil {
t.Fatal(err)
}
select {
+1 -1
View File
@@ -78,7 +78,7 @@ extension:
if err := os.WriteFile(configPath, []byte(original), 0o600); err != nil {
t.Fatal(err)
}
runtime := config.NewStore(&config.Data{}, &config.Admin{ConfigPath: configPath})
runtime := config.NewStore(&config.Config{Data: &config.Data{}, Admin: &config.Admin{ConfigPath: configPath}})
data := &Data{runtime: runtime}
if err := data.persistDatabaseConfig(&config.Database{Driver: "mysql", Host: "db", Port: "3306", User: "root", Password: "secret", Name: "kra", Config: "parseTime=True"}, "new-key"); err != nil {
t.Fatal(err)
+9 -18
View File
@@ -12,7 +12,6 @@ import (
dataintegration "kra/internal/data/integration"
"kra/internal/integration/storage"
"google.golang.org/protobuf/encoding/protojson"
"gopkg.in/yaml.v3"
"gorm.io/gorm"
)
@@ -59,9 +58,9 @@ func TestStorageIntegrationConfigRoundTrip(t *testing.T) {
Qiniu: &config.Qiniu{
Zone: "ZoneHuadong", Bucket: "qiniu-bucket", AccessKey: "qiniu-key", SecretKey: "qiniu-secret",
},
AliyunOss: &config.ObjectStore{
AliyunOSS: &config.ObjectStore{
Endpoint: "oss-cn-hangzhou.aliyuncs.com", Region: "cn-hangzhou", Bucket: "assets",
AccessKey: "aliyun-key", SecretKey: "aliyun-secret", BaseUrl: "https://cdn.example.com", PathPrefix: "uploads",
AccessKey: "aliyun-key", SecretKey: "aliyun-secret", BaseURL: "https://cdn.example.com", PathPrefix: "uploads",
},
Minio: &config.ObjectStore{Endpoint: "127.0.0.1:9000", Bucket: "local", ForcePathStyle: true},
}
@@ -70,7 +69,7 @@ func TestStorageIntegrationConfigRoundTrip(t *testing.T) {
}
email := &config.Email{
To: "ops@example.com", From: "mailer@example.com", Host: "smtp.example.com",
Secret: "smtp-secret", Nickname: "Kra", Port: 465, IsSsl: true,
Secret: "smtp-secret", Nickname: "Kra", Port: 465, IsSSL: true,
}
if err := saveEmailIntegrationConfig(db, email); err != nil {
t.Fatal(err)
@@ -135,7 +134,7 @@ func TestResolveStorageIntegrationConfigMigratesLegacyOnlyOnce(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if loaded.Type != "qiniu" || loaded.Qiniu.GetBucket() != "legacy" {
if loaded.Type != "qiniu" || loaded.Qiniu.Bucket != "legacy" {
t.Fatalf("migrated storage = %#v", loaded)
}
@@ -147,7 +146,7 @@ func TestResolveStorageIntegrationConfigMigratesLegacyOnlyOnce(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if loaded.Type != "qiniu" || loaded.Qiniu.GetSecretKey() != "legacy-secret" {
if loaded.Type != "qiniu" || loaded.Qiniu.SecretKey != "legacy-secret" {
t.Fatalf("database configuration was replaced by legacy config: %#v", loaded)
}
}
@@ -155,7 +154,7 @@ func TestResolveStorageIntegrationConfigMigratesLegacyOnlyOnce(t *testing.T) {
func TestMaskStorageSecretsLeavesUnconfiguredProvidersEmpty(t *testing.T) {
storage := &config.Storage{
Qiniu: &config.Qiniu{},
AliyunOss: &config.ObjectStore{SecretKey: "configured-secret"},
AliyunOSS: &config.ObjectStore{SecretKey: "configured-secret"},
Minio: &config.ObjectStore{},
}
maskStorageSecrets(storage)
@@ -169,7 +168,7 @@ func TestMaskStorageSecretsLeavesUnconfiguredProvidersEmpty(t *testing.T) {
func TestResolveEmailIntegrationConfigMigratesLegacyOnlyOnce(t *testing.T) {
db := openIntegrationConfigTestDB(t)
legacy := &config.Email{To: "ops@example.com", From: "old@example.com", Host: "smtp.old.example.com", Secret: "old-secret", Port: 465, IsSsl: true}
legacy := &config.Email{To: "ops@example.com", From: "old@example.com", Host: "smtp.old.example.com", Secret: "old-secret", Port: 465, IsSSL: true}
loaded, err := resolveEmailIntegrationConfig(db, legacy)
if err != nil {
t.Fatal(err)
@@ -259,17 +258,9 @@ func TestPersistRuntimeConfigReplacesActiveStorage(t *testing.T) {
nextAdmin.Local = &config.Local{StorePath: newRoot, PathPrefix: "new-files"}
nextAdmin.Email = &config.Email{
To: "ops@example.com", From: "mailer@example.com", Host: "smtp.example.com",
Secret: "runtime-secret", Port: 465, IsSsl: true,
Secret: "runtime-secret", Port: 465, IsSSL: true,
}
dataRaw, err := protojson.Marshal(&config.Data{})
if err != nil {
t.Fatal(err)
}
adminRaw, err := protojson.Marshal(nextAdmin)
if err != nil {
t.Fatal(err)
}
if err = d.PersistRuntimeConfig(context.Background(), dataRaw, adminRaw); err != nil {
if err = d.PersistRuntimeConfig(context.Background(), &config.Config{Data: &config.Data{}, Admin: nextAdmin}); err != nil {
t.Fatal(err)
}
+177 -15
View File
@@ -3,7 +3,8 @@ package initialize
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"kra/internal/config"
)
@@ -34,17 +35,20 @@ func (r *Repo) SaveConfigurationJSON(ctx context.Context, raw json.RawMessage) e
if err := json.Unmarshal(raw, &value); err != nil {
return err
}
if err := mergeJSON(value.Data, &next.Data); err != nil {
if next.Data == nil {
next.Data = &config.Data{}
}
if next.Admin == nil {
next.Admin = &config.Admin{}
}
if err := mergeDataJSON(value.Data, &next.Data); err != nil {
return err
}
if err := mergeJSON(value.Admin, &next.Admin); err != nil {
if err := mergeAdminJSON(value.Admin, &next.Admin); err != nil {
return err
}
if len(value.Email) > 0 && string(value.Email) != "null" {
if next.Admin == nil {
next.Admin = &config.Admin{}
}
if err := mergeJSON(value.Email, &next.Admin.Email); err != nil {
if err := mergeEmailJSON(value.Email, &next.Admin.Email); err != nil {
return err
}
}
@@ -79,13 +83,144 @@ func mergeJSON(raw json.RawMessage, target any) error {
return json.Unmarshal(raw, target)
}
func mergeDataJSON(raw json.RawMessage, target **config.Data) error {
return mergeJSONMap(raw, target, func(values map[string]any) error {
if redis := jsonObject(values["redis"]); redis != nil {
if err := normalizeDuration(redis, "read_timeout", "readTimeout"); err != nil {
return err
}
if err := normalizeDuration(redis, "write_timeout", "writeTimeout"); err != nil {
return err
}
}
if items, ok := values["redis_list"].([]any); ok {
for _, item := range items {
redis := jsonObject(item)
if err := normalizeDuration(redis, "read_timeout", "readTimeout"); err != nil {
return err
}
if err := normalizeDuration(redis, "write_timeout", "writeTimeout"); err != nil {
return err
}
}
}
return nil
})
}
func mergeAdminJSON(raw json.RawMessage, target **config.Admin) error {
return mergeJSONMap(raw, target, func(values map[string]any) error {
moveJSONKey(values, "routerPrefix", "router_prefix")
if jwt := jsonObject(values["jwt"]); jwt != nil {
moveJSONKey(jwt, "signingKey", "signing_key")
moveJSONKey(jwt, "expiresTime", "expires_time")
moveJSONKey(jwt, "bufferTime", "buffer_time")
if err := normalizeDuration(jwt, "expires_time", "expiresTime"); err != nil {
return err
}
if err := normalizeDuration(jwt, "buffer_time", "bufferTime"); err != nil {
return err
}
}
if captcha := jsonObject(values["captcha"]); captcha != nil {
moveJSONKey(captcha, "keyLong", "key_long")
moveJSONKey(captcha, "imgWidth", "img_width")
moveJSONKey(captcha, "imgHeight", "img_height")
moveJSONKey(captcha, "storeExpiration", "store_expiration")
return normalizeDuration(captcha, "store_expiration", "storeExpiration")
}
if local := jsonObject(values["local"]); local != nil {
moveJSONKey(local, "storePath", "store_path")
moveJSONKey(local, "pathPrefix", "path_prefix")
}
if media := jsonObject(values["media"]); media != nil {
moveJSONKey(media, "sessionTtl", "session_ttl")
moveJSONKey(media, "maxFileSize", "max_file_size")
moveJSONKey(media, "chunkDir", "chunk_dir")
}
if system := jsonObject(values["system"]); system != nil {
moveJSONKey(system, "useRedis", "use_redis")
moveJSONKey(system, "useMultipoint", "use_multipoint")
moveJSONKey(system, "useStrictAuth", "use_strict_auth")
moveJSONKey(system, "disableAutoMigrate", "disable_auto_migrate")
moveJSONKey(system, "useMongo", "use_mongo")
moveJSONKey(system, "iplimitCount", "iplimit_count")
moveJSONKey(system, "iplimitTime", "iplimit_time")
}
return nil
})
}
func mergeEmailJSON(raw json.RawMessage, target **config.Email) error {
return mergeJSONMap(raw, target, func(values map[string]any) error {
moveJSONKey(values, "is-ssl", "is_ssl")
moveJSONKey(values, "is-loginauth", "is_login_auth")
return nil
})
}
func mergeJSONMap(raw json.RawMessage, target any, transform func(map[string]any) error) error {
if len(raw) == 0 || string(raw) == "null" {
return nil
}
var values map[string]any
if err := json.Unmarshal(raw, &values); err != nil {
return err
}
if transform != nil {
if err := transform(values); err != nil {
return err
}
}
normalized, err := json.Marshal(values)
if err != nil {
return err
}
return mergeJSON(normalized, target)
}
func jsonObject(value any) map[string]any {
result, _ := value.(map[string]any)
return result
}
func normalizeDuration(values map[string]any, keys ...string) error {
if values == nil {
return nil
}
for _, key := range keys {
raw, ok := values[key]
if !ok {
continue
}
text, ok := raw.(string)
if !ok {
return nil
}
value, err := time.ParseDuration(text)
if err != nil {
return fmt.Errorf("invalid duration %q: %w", text, err)
}
values[key] = int64(value)
return nil
}
return nil
}
func moveJSONKey(values map[string]any, oldKey, newKey string) {
if value, ok := values[oldKey]; ok {
values[newKey] = value
delete(values, oldKey)
}
}
func managementConfig(value *config.Config) map[string]any {
result := map[string]any{"data": map[string]any{}, "admin": map[string]any{}, "email": map[string]any{}}
if value == nil {
return result
}
if value.Data != nil {
result["data"] = value.Data
result["data"] = managementData(value.Data)
}
if value.Admin != nil {
admin := value.Admin
@@ -113,6 +248,36 @@ func managementConfig(value *config.Config) map[string]any {
return result
}
func managementData(value *config.Data) any {
if value == nil {
return map[string]any{}
}
raw, err := json.Marshal(value)
if err != nil {
return value
}
var result map[string]any
if json.Unmarshal(raw, &result) != nil {
return value
}
if redis := jsonObject(result["redis"]); redis != nil {
redis["read_timeout"] = value.Redis.ReadTimeout.String()
redis["write_timeout"] = value.Redis.WriteTimeout.String()
}
if items, ok := result["redis_list"].([]any); ok {
for index, item := range items {
if index >= len(value.RedisList) || value.RedisList[index] == nil {
continue
}
if redis := jsonObject(item); redis != nil {
redis["read_timeout"] = value.RedisList[index].ReadTimeout.String()
redis["write_timeout"] = value.RedisList[index].WriteTimeout.String()
}
}
}
return result
}
func managementJWT(value *config.JWT) any {
if value == nil {
return map[string]any{}
@@ -336,14 +501,11 @@ func refreshDatabaseSource(database *config.Database) error {
if database == nil {
return nil
}
if database.Driver == "sqlite" && database.Path == "" {
return errors.New("sqlite database path is required")
}
if database.Driver == "sqlite" {
database.Source = database.Path
} else {
hasStructuredConfig := database.Host != "" || database.Port != "" || database.User != "" || database.Password != "" || database.Name != "" || database.Config != "" || database.Path != ""
if hasStructuredConfig {
// Driver-specific DSN construction remains in data. Clearing Source
// makes the data backend rebuild it from structured values.
// makes the data backend rebuild it from structured values, while a
// standalone DSN is preserved when no structured fields are supplied.
database.Source = ""
}
return nil
+19 -3
View File
@@ -54,10 +54,10 @@ func TestConfigurationJSONMasksSecretsWithoutMutatingConfig(t *testing.T) {
func TestSaveConfigurationJSONMergesPartialValuesAndPreservesSecrets(t *testing.T) {
backend := &configurationBackend{value: &config.Config{
Data: &config.Data{Database: &config.Database{Driver: "mysql", Host: "old", Password: "database-secret", Source: "old-dsn"}, Redis: &config.Redis{Name: "main", Password: "redis-secret"}},
Admin: &config.Admin{ConfigPath: "config.yaml", JWT: &config.JWT{SigningKey: "jwt-secret", Issuer: "old"}, Email: &config.Email{Host: "old.smtp", Secret: "email-secret"}},
Data: &config.Data{Database: &config.Database{Driver: "mysql", Host: "old", Password: "database-secret", Source: "old-dsn"}, Redis: &config.Redis{Name: "main", Password: "redis-secret", ReadTimeout: time.Second}},
Admin: &config.Admin{ConfigPath: "config.yaml", JWT: &config.JWT{SigningKey: "jwt-secret", Issuer: "old", ExpiresTime: time.Hour}, Captcha: &config.Captcha{StoreExpiration: time.Minute}, Email: &config.Email{Host: "old.smtp", Secret: "email-secret", IsSSL: true}},
}}
raw := json.RawMessage(`{"data":{"database":{"host":"new","password":"******"}},"admin":{"jwt":{"issuer":"new","signing_key":"******"}},"email":{"host":"new.smtp","secret":"******"}}`)
raw := json.RawMessage(`{"data":{"database":{"host":"new","password":"******"},"redis":{"read_timeout":"250ms"}},"admin":{"jwt":{"issuer":"new","signingKey":"******","expiresTime":"48h"},"captcha":{"storeExpiration":"5m"}},"email":{"host":"new.smtp","secret":"******","is-ssl":false}}`)
if err := (&Repo{backend: backend}).SaveConfigurationJSON(context.Background(), raw); err != nil {
t.Fatal(err)
}
@@ -68,7 +68,23 @@ func TestSaveConfigurationJSONMergesPartialValuesAndPreservesSecrets(t *testing.
if got.Data.Database.Password != "database-secret" || got.Admin.JWT.SigningKey != "jwt-secret" || got.Admin.Email.Secret != "email-secret" {
t.Fatal("masked secrets were not preserved")
}
if got.Data.Redis.ReadTimeout != 250*time.Millisecond || got.Admin.JWT.ExpiresTime != 48*time.Hour || got.Admin.Captcha.StoreExpiration != 5*time.Minute || got.Admin.Email.IsSSL {
t.Fatalf("management values were not decoded: redis=%s jwt=%s captcha=%s ssl=%t", got.Data.Redis.ReadTimeout, got.Admin.JWT.ExpiresTime, got.Admin.Captcha.StoreExpiration, got.Admin.Email.IsSSL)
}
if got.Admin.ConfigPath != "config.yaml" {
t.Fatalf("config path = %q", got.Admin.ConfigPath)
}
}
func TestSaveConfigurationJSONPreservesStandaloneDSN(t *testing.T) {
backend := &configurationBackend{value: &config.Config{
Data: &config.Data{Database: &config.Database{Driver: "mysql", Source: "user:secret@tcp(database.example:3306)/kra"}},
Admin: &config.Admin{},
}}
if err := (&Repo{backend: backend}).SaveConfigurationJSON(context.Background(), json.RawMessage(`{"admin":{"routerPrefix":"/api"}}`)); err != nil {
t.Fatal(err)
}
if got := backend.persisted.Data.Database.Source; got != "user:secret@tcp(database.example:3306)/kra" {
t.Fatalf("standalone DSN = %q", got)
}
}