优化结构
This commit is contained in:
@@ -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
|
||||
|
||||
Generated
-2322
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package data
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user