优化结构
This commit is contained in:
@@ -17,15 +17,15 @@ func TaskRegistry(catalog module.Catalog) *platformtask.Registry {
|
||||
return registry
|
||||
}
|
||||
|
||||
// RuntimeContributions groups runtime objects that need constructed
|
||||
// dependencies. The binary composition root supplies the concrete modules.
|
||||
type RuntimeContributions struct {
|
||||
// Composition groups runtime objects that need constructed dependencies. The
|
||||
// binary composition root supplies the concrete modules.
|
||||
type Composition struct {
|
||||
Routes []module.RouteRegistrar
|
||||
Tasks []platformtask.Contributor
|
||||
}
|
||||
|
||||
// Runtime activates dependency-bearing tasks and composes module routes.
|
||||
func Runtime(contributions RuntimeContributions, registry *platformtask.Registry) *module.Runtime {
|
||||
// Build activates dependency-bearing tasks and composes module routes.
|
||||
func Build(contributions Composition, registry *platformtask.Registry) *module.Runtime {
|
||||
platformtask.Apply(registry, contributions.Tasks...)
|
||||
return module.NewRuntime(contributions.Routes...)
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ func (contributor testTaskContributor) RegisterTasks(registry *platformtask.Regi
|
||||
func TestRuntimeAppliesAllContributions(t *testing.T) {
|
||||
registry := platformtask.NewRegistry()
|
||||
route := &testRouteRegistrar{}
|
||||
runtime := Runtime(RuntimeContributions{
|
||||
runtime := Build(Composition{
|
||||
Routes: []module.RouteRegistrar{route},
|
||||
Tasks: []platformtask.Contributor{testTaskContributor{name: "test.runtime"}},
|
||||
}, registry)
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"kra/internal/utils/uploadpolicy"
|
||||
)
|
||||
|
||||
type MediaRepo interface {
|
||||
@@ -39,23 +40,8 @@ func (uc *MediaUsecase) maxMediaFileSize() int64 {
|
||||
return uc.settings.MediaSettings().EffectiveMaxFileSize()
|
||||
}
|
||||
|
||||
var allowedMediaExtensions = map[string]bool{
|
||||
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, ".bmp": true, ".ico": true, ".avif": true,
|
||||
".mp3": true, ".wav": true, ".ogg": true, ".m4a": true, ".flac": true, ".aac": true,
|
||||
".mp4": true, ".webm": true, ".mov": true, ".avi": true, ".mkv": true,
|
||||
".txt": true, ".md": true, ".csv": true, ".json": true, ".log": true, ".pdf": true,
|
||||
".doc": true, ".docx": true, ".xls": true, ".xlsx": true, ".ppt": true, ".pptx": true,
|
||||
".zip": true, ".rar": true, ".7z": true, ".tar": true, ".gz": true, ".tgz": true, ".bin": true,
|
||||
}
|
||||
|
||||
func validateMediaName(name string) error {
|
||||
if name == "" || strings.TrimSpace(name) != name || strings.ContainsAny(name, `/\`) {
|
||||
return errors.New("file extension is not allowed")
|
||||
}
|
||||
if !allowedMediaExtensions[strings.ToLower(filepath.Ext(name))] {
|
||||
return errors.New("file extension is not allowed")
|
||||
}
|
||||
return nil
|
||||
return uploadpolicy.ValidateFileName(name)
|
||||
}
|
||||
func (uc *MediaUsecase) Upload(ctx context.Context, userID uint, name, suppliedMIME string, categoryID int, reader io.Reader, save bool) (*MediaFile, error) {
|
||||
if err := validateMediaName(name); err != nil {
|
||||
@@ -69,7 +55,7 @@ func (uc *MediaUsecase) Upload(ctx context.Context, userID uint, name, suppliedM
|
||||
// http.DetectContentType changes the media record for common uploads
|
||||
// (for example SVG/JSON), and diverges from the compatible DetectMIME helper.
|
||||
if strings.TrimSpace(suppliedMIME) == "" {
|
||||
if detected := mediaMIMEByExtension(ext); detected != "" {
|
||||
if detected := uploadpolicy.MIMEByExtension(ext); detected != "" {
|
||||
suppliedMIME = detected
|
||||
} else if header, _ := buffered.Peek(512); len(header) > 0 {
|
||||
suppliedMIME = http.DetectContentType(header)
|
||||
@@ -113,29 +99,6 @@ func (uc *MediaUsecase) Upload(ctx context.Context, userID uint, name, suppliedM
|
||||
return media, nil
|
||||
}
|
||||
|
||||
// mediaMIMEByExtension mirrors the small extension fallback table. It is
|
||||
// intentionally narrower than mime.TypeByExtension: all other types are sniffed.
|
||||
// types from the first 512 bytes.
|
||||
func mediaMIMEByExtension(ext string) string {
|
||||
switch ext {
|
||||
case ".json":
|
||||
return "application/json"
|
||||
case ".xml":
|
||||
return "application/xml"
|
||||
case ".pdf":
|
||||
return "application/pdf"
|
||||
case ".zip":
|
||||
return "application/zip"
|
||||
case ".mp4":
|
||||
return "video/mp4"
|
||||
case ".mp3":
|
||||
return "audio/mpeg"
|
||||
case ".csv":
|
||||
return "text/csv"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
func (uc *MediaUsecase) Delete(ctx context.Context, id uint) error {
|
||||
media, err := uc.FindMedia(ctx, id)
|
||||
if err != nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"kra/internal/config"
|
||||
)
|
||||
|
||||
var ErrTaskRuntimeReload = errors.New("task runtime reload failed")
|
||||
@@ -22,8 +23,7 @@ type InitializationRepo interface {
|
||||
IsInitialized(context.Context) (bool, error)
|
||||
Initialize(context.Context, *DatabaseConfig) error
|
||||
PersistConfig(context.Context) error
|
||||
PersistAdminConfig(context.Context, []byte) error
|
||||
PersistRuntimeConfig(context.Context, []byte, []byte) error
|
||||
PersistRuntimeConfig(context.Context, *config.Config) error
|
||||
ReloadConfig(context.Context) error
|
||||
ConfigurationJSON() (json.RawMessage, error)
|
||||
SaveConfigurationJSON(context.Context, json.RawMessage) error
|
||||
@@ -63,11 +63,8 @@ func (uc *SystemConfigUsecase) Initialize(ctx context.Context, config *DatabaseC
|
||||
func (uc *SystemConfigUsecase) PersistConfig(ctx context.Context) error {
|
||||
return uc.repo.PersistConfig(ctx)
|
||||
}
|
||||
func (uc *SystemConfigUsecase) PersistAdminConfig(ctx context.Context, value []byte) error {
|
||||
return uc.repo.PersistAdminConfig(ctx, value)
|
||||
}
|
||||
func (uc *SystemConfigUsecase) PersistRuntimeConfig(ctx context.Context, data, admin []byte) error {
|
||||
return uc.repo.PersistRuntimeConfig(ctx, data, admin)
|
||||
func (uc *SystemConfigUsecase) PersistRuntimeConfig(ctx context.Context, value *config.Config) error {
|
||||
return uc.repo.PersistRuntimeConfig(ctx, value)
|
||||
}
|
||||
func (uc *SystemConfigUsecase) ReloadConfig(ctx context.Context) error {
|
||||
if err := uc.repo.ReloadConfig(ctx); err != nil {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
platformtask "kra/pkg/task"
|
||||
)
|
||||
|
||||
type TimedTask struct {
|
||||
@@ -77,13 +78,9 @@ type TaskUsecase struct {
|
||||
methods TaskMethodRegistry
|
||||
}
|
||||
|
||||
func NewTaskUsecase(repo TaskRepo) *TaskUsecase {
|
||||
return NewTaskUsecaseWithRegistry(repo, DefaultTaskMethodRegistry())
|
||||
}
|
||||
|
||||
func NewTaskUsecaseWithRegistry(repo TaskRepo, methods TaskMethodRegistry) *TaskUsecase {
|
||||
if methods == nil {
|
||||
methods = DefaultTaskMethodRegistry()
|
||||
methods = platformtask.NewRegistry()
|
||||
}
|
||||
return &TaskUsecase{TaskRepo: repo, methods: methods}
|
||||
}
|
||||
|
||||
@@ -6,23 +6,6 @@ type TaskMethodFunc = platformtask.MethodFunc
|
||||
type TaskMethod = platformtask.Method
|
||||
|
||||
type TaskMethodRegistry interface {
|
||||
Register(platformtask.Method)
|
||||
Lookup(string) (TaskMethodFunc, bool)
|
||||
List() []TaskMethod
|
||||
}
|
||||
|
||||
var defaultTaskMethods = platformtask.NewRegistry()
|
||||
|
||||
// RegisterTaskMethod remains as a compatibility helper for tests and callers
|
||||
// that have not moved to constructor injection yet.
|
||||
func RegisterTaskMethod(name, description string, fn TaskMethodFunc) {
|
||||
defaultTaskMethods.Register(platformtask.Method{Name: name, Description: description, Run: fn})
|
||||
}
|
||||
|
||||
func TaskMethodByName(name string) (TaskMethodFunc, bool) {
|
||||
return defaultTaskMethods.Lookup(name)
|
||||
}
|
||||
|
||||
func RegisteredTaskMethods() []TaskMethod { return defaultTaskMethods.List() }
|
||||
|
||||
func DefaultTaskMethodRegistry() TaskMethodRegistry { return defaultTaskMethods }
|
||||
|
||||
@@ -4,11 +4,14 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
platformtask "kra/pkg/task"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var testTaskMethods = platformtask.NewRegistry()
|
||||
|
||||
type applicationTaskRepo struct {
|
||||
mu sync.Mutex
|
||||
deleted bool
|
||||
@@ -112,14 +115,14 @@ func (r *applicationTaskRuntime) Unsubscribe(uint, chan []byte) {}
|
||||
|
||||
func validApplicationTask(id uint) *TimedTask {
|
||||
const methodName = "biz-test-application-task"
|
||||
RegisterTaskMethod(methodName, "test", func(context.Context, json.RawMessage) error { return nil })
|
||||
testTaskMethods.Register(platformtask.Method{Name: methodName, Description: "test", Run: func(context.Context, json.RawMessage) error { return nil }})
|
||||
return &TimedTask{ID: id, Name: "test", Spec: "0 0 * * *", ExecutorType: TaskExecutorMethod, MethodName: methodName, Enabled: true}
|
||||
}
|
||||
|
||||
func TestTaskApplicationRepairsRuntimeAfterScheduleFailure(t *testing.T) {
|
||||
repo := &applicationTaskRepo{createdID: 41}
|
||||
runtime := &applicationTaskRuntime{scheduleErr: errors.New("schedule failed")}
|
||||
uc := NewTaskApplicationUsecase(NewTaskUsecase(repo), runtime)
|
||||
uc := NewTaskApplicationUsecase(NewTaskUsecaseWithRegistry(repo, testTaskMethods), runtime)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
@@ -137,7 +140,7 @@ func TestTaskApplicationRepairsRuntimeAfterScheduleFailure(t *testing.T) {
|
||||
func TestTaskApplicationReturnsScheduleErrorWhenRepairFails(t *testing.T) {
|
||||
repo := &applicationTaskRepo{createdID: 42}
|
||||
runtime := &applicationTaskRuntime{scheduleErr: errors.New("schedule failed"), reloadErr: errors.New("reload failed")}
|
||||
uc := NewTaskApplicationUsecase(NewTaskUsecase(repo), runtime)
|
||||
uc := NewTaskApplicationUsecase(NewTaskUsecaseWithRegistry(repo, testTaskMethods), runtime)
|
||||
|
||||
err := uc.Create(context.Background(), validApplicationTask(0))
|
||||
if !errors.Is(err, ErrTaskSchedule) {
|
||||
@@ -158,7 +161,7 @@ func TestTaskApplicationUpdateAndToggleUseRuntimeRepair(t *testing.T) {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
repo := &applicationTaskRepo{}
|
||||
runtime := &applicationTaskRuntime{scheduleErr: errors.New("schedule failed")}
|
||||
uc := NewTaskApplicationUsecase(NewTaskUsecase(repo), runtime)
|
||||
uc := NewTaskApplicationUsecase(NewTaskUsecaseWithRegistry(repo, testTaskMethods), runtime)
|
||||
if err := test.run(uc); err != nil {
|
||||
t.Fatalf("operation error = %v", err)
|
||||
}
|
||||
@@ -172,7 +175,7 @@ func TestTaskApplicationUpdateAndToggleUseRuntimeRepair(t *testing.T) {
|
||||
func TestTaskApplicationDeleteRemovesRuntimeAfterDatabase(t *testing.T) {
|
||||
repo := &applicationTaskRepo{}
|
||||
runtime := &applicationTaskRuntime{repo: repo}
|
||||
uc := NewTaskApplicationUsecase(NewTaskUsecase(repo), runtime)
|
||||
uc := NewTaskApplicationUsecase(NewTaskUsecaseWithRegistry(repo, testTaskMethods), runtime)
|
||||
if err := uc.Delete(context.Background(), 8); err != nil {
|
||||
t.Fatalf("Delete() error = %v", err)
|
||||
}
|
||||
@@ -187,7 +190,7 @@ func TestTaskApplicationDeleteRemovesRuntimeAfterDatabase(t *testing.T) {
|
||||
func TestTaskApplicationDeleteKeepsRuntimeWhenDatabaseFails(t *testing.T) {
|
||||
repo := &applicationTaskRepo{deleteErr: errors.New("delete failed")}
|
||||
runtime := &applicationTaskRuntime{repo: repo}
|
||||
uc := NewTaskApplicationUsecase(NewTaskUsecase(repo), runtime)
|
||||
uc := NewTaskApplicationUsecase(NewTaskUsecaseWithRegistry(repo, testTaskMethods), runtime)
|
||||
if err := uc.Delete(context.Background(), 8); err == nil {
|
||||
t.Fatal("Delete() error = nil, want database error")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package config
|
||||
|
||||
// Clone returns a deep copy of a configuration snapshot. The copy helpers are
|
||||
// intentionally kept in the config package so data/integration code does not
|
||||
// need protobuf cloning or storage-specific serializers.
|
||||
func Clone(value *Config) *Config { return cloneConfig(value) }
|
||||
|
||||
func CloneData(value *Data) *Data {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return cloneConfig(&Config{Data: value}).Data
|
||||
}
|
||||
|
||||
func CloneAdmin(value *Admin) *Admin {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return cloneConfig(&Config{Admin: value}).Admin
|
||||
}
|
||||
|
||||
func CloneDatabase(value *Database) *Database {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return cloneConfig(&Config{Data: &Data{Database: value}}).Data.Database
|
||||
}
|
||||
|
||||
func CloneStorage(value *Storage) *Storage {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return cloneConfig(&Config{Admin: &Admin{Storage: value}}).Admin.Storage
|
||||
}
|
||||
|
||||
func CloneEmail(value *Email) *Email {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return cloneConfig(&Config{Admin: &Admin{Email: value}}).Admin.Email
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"github.com/go-viper/mapstructure/v2"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
var ErrConfigPathRequired = errors.New("configuration path is required")
|
||||
|
||||
// Store owns an immutable configuration snapshot and its Viper-backed file
|
||||
// watcher.
|
||||
// Every accessor returns a deep copy, so readers can safely use it without
|
||||
// coordinating with a reload in another goroutine.
|
||||
type Store struct {
|
||||
snapshot atomic.Pointer[Config]
|
||||
path string
|
||||
mu sync.RWMutex
|
||||
listeners map[uint64]func(*Config)
|
||||
nextID uint64
|
||||
watcher *fsnotify.Watcher
|
||||
stop chan struct{}
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// Load reads a YAML/JSON/TOML configuration file or a directory containing
|
||||
// config.yaml. Environment variables with KRA_ prefix override file values;
|
||||
// nested keys use underscores (for example KRA_ADMIN_JWT_SIGNING_KEY).
|
||||
func Load(path string) (*Config, error) {
|
||||
resolved, err := resolvePath(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v := newViper(resolved)
|
||||
if err = v.ReadInConfig(); err != nil {
|
||||
return nil, fmt.Errorf("read configuration %q: %w", resolved, err)
|
||||
}
|
||||
config, err := decode(v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode configuration %q: %w", resolved, err)
|
||||
}
|
||||
setConfigPath(config, resolved)
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// NewStore creates a store from an already decoded snapshot. The input is
|
||||
// copied immediately and may be reused or mutated by the caller afterwards.
|
||||
func NewStore(config *Config) *Store {
|
||||
store := &Store{listeners: make(map[uint64]func(*Config))}
|
||||
if config != nil && config.Admin != nil {
|
||||
store.path = config.Admin.ConfigPath
|
||||
}
|
||||
store.Replace(config)
|
||||
return store
|
||||
}
|
||||
|
||||
// LoadStore loads the initial snapshot and starts watching its source file.
|
||||
func LoadStore(path string) (*Store, error) {
|
||||
config, err := Load(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
store := NewStore(config)
|
||||
if err = store.Watch(); err != nil {
|
||||
store.Close()
|
||||
return nil, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func (r *Store) Snapshot() *Config {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return cloneConfig(r.snapshot.Load())
|
||||
}
|
||||
|
||||
func (r *Store) Server() *Server {
|
||||
config := r.Snapshot()
|
||||
if config == nil {
|
||||
return nil
|
||||
}
|
||||
return config.Server
|
||||
}
|
||||
|
||||
func (r *Store) Data() *Data {
|
||||
config := r.Snapshot()
|
||||
if config == nil {
|
||||
return nil
|
||||
}
|
||||
return config.Data
|
||||
}
|
||||
|
||||
func (r *Store) Admin() *Admin {
|
||||
config := r.Snapshot()
|
||||
if config == nil {
|
||||
return nil
|
||||
}
|
||||
return config.Admin
|
||||
}
|
||||
|
||||
func (r *Store) Values() (*Data, *Admin) {
|
||||
config := r.Snapshot()
|
||||
if config == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return config.Data, config.Admin
|
||||
}
|
||||
|
||||
func (r *Store) UpdateDatabase(database *Database) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
config := r.Snapshot()
|
||||
if config == nil {
|
||||
config = &Config{}
|
||||
}
|
||||
if config.Data == nil {
|
||||
config.Data = &Data{}
|
||||
}
|
||||
config.Data.Database = cloneDatabase(database)
|
||||
r.replace(config, false)
|
||||
}
|
||||
|
||||
func (r *Store) ConfigPath() string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
r.mu.RLock()
|
||||
path := r.path
|
||||
r.mu.RUnlock()
|
||||
return path
|
||||
}
|
||||
|
||||
func (r *Store) Replace(config *Config) {
|
||||
r.replace(config, true)
|
||||
}
|
||||
|
||||
func (r *Store) replace(config *Config, notify bool) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
next := cloneConfig(config)
|
||||
if next == nil {
|
||||
next = &Config{}
|
||||
}
|
||||
if path := r.ConfigPath(); path != "" {
|
||||
setConfigPath(next, path)
|
||||
}
|
||||
r.snapshot.Store(next)
|
||||
if !notify {
|
||||
return
|
||||
}
|
||||
r.mu.RLock()
|
||||
listeners := make([]func(*Config), 0, len(r.listeners))
|
||||
for _, listener := range r.listeners {
|
||||
listeners = append(listeners, listener)
|
||||
}
|
||||
r.mu.RUnlock()
|
||||
for _, listener := range listeners {
|
||||
listener(cloneConfig(next))
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe registers a callback after each successful replacement. The
|
||||
// callback is invoked outside the runtime lock, and the returned function is
|
||||
// idempotent so cleanup can safely be deferred by multiple owners.
|
||||
func (r *Store) Subscribe(listener func(*Config)) func() {
|
||||
if r == nil || listener == nil {
|
||||
return func() {}
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.nextID++
|
||||
id := r.nextID
|
||||
r.listeners[id] = listener
|
||||
r.mu.Unlock()
|
||||
var once sync.Once
|
||||
return func() {
|
||||
once.Do(func() {
|
||||
r.mu.Lock()
|
||||
delete(r.listeners, id)
|
||||
r.mu.Unlock()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Watch starts watching the source directory. Repeated calls are harmless.
|
||||
func (r *Store) Watch() error {
|
||||
if r == nil {
|
||||
return ErrConfigPathRequired
|
||||
}
|
||||
path := r.ConfigPath()
|
||||
if path == "" {
|
||||
return ErrConfigPathRequired
|
||||
}
|
||||
r.mu.Lock()
|
||||
if r.watcher != nil {
|
||||
r.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
r.mu.Unlock()
|
||||
return fmt.Errorf("create configuration watcher: %w", err)
|
||||
}
|
||||
if err = watcher.Add(filepath.Dir(path)); err != nil {
|
||||
_ = watcher.Close()
|
||||
r.mu.Unlock()
|
||||
return fmt.Errorf("watch configuration directory: %w", err)
|
||||
}
|
||||
r.watcher = watcher
|
||||
r.stop = make(chan struct{})
|
||||
r.done = make(chan struct{})
|
||||
stop, done := r.stop, r.done
|
||||
r.mu.Unlock()
|
||||
go r.watchLoop(watcher, stop, done, path)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Store) watchLoop(watcher *fsnotify.Watcher, stop <-chan struct{}, done chan<- struct{}, path string) {
|
||||
defer close(done)
|
||||
const debounce = 100 * time.Millisecond
|
||||
var timer *time.Timer
|
||||
var timerC <-chan time.Time
|
||||
events := watcher.Events
|
||||
errors := watcher.Errors
|
||||
for {
|
||||
select {
|
||||
case event, ok := <-events:
|
||||
if !ok {
|
||||
events = nil
|
||||
if errors == nil {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if filepath.Clean(event.Name) != path || event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Rename) == 0 {
|
||||
continue
|
||||
}
|
||||
if timer == nil {
|
||||
timer = time.NewTimer(debounce)
|
||||
} else {
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
timer.Reset(debounce)
|
||||
}
|
||||
timerC = timer.C
|
||||
case <-timerC:
|
||||
timerC = nil
|
||||
config, err := Load(path)
|
||||
if err != nil {
|
||||
// Keep the last valid snapshot. A partially-written file must not
|
||||
// take down a running process or publish invalid state.
|
||||
continue
|
||||
}
|
||||
r.Replace(config)
|
||||
case _, ok := <-errors:
|
||||
// fsnotify errors are intentionally non-fatal; the watcher remains
|
||||
// useful for subsequent events and Close always terminates it.
|
||||
if !ok {
|
||||
errors = nil
|
||||
if events == nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
case <-stop:
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Store) Close() {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
watcher, stop, done := r.watcher, r.stop, r.done
|
||||
r.watcher = nil
|
||||
r.stop = nil
|
||||
r.done = nil
|
||||
r.mu.Unlock()
|
||||
if watcher == nil {
|
||||
return
|
||||
}
|
||||
close(stop)
|
||||
_ = watcher.Close()
|
||||
<-done
|
||||
}
|
||||
|
||||
func resolvePath(path string) (string, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return "", ErrConfigPathRequired
|
||||
}
|
||||
absolute, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve configuration path: %w", err)
|
||||
}
|
||||
if info, statErr := os.Stat(absolute); statErr == nil && info.IsDir() {
|
||||
absolute = filepath.Join(absolute, "config.yaml")
|
||||
}
|
||||
return filepath.Clean(absolute), nil
|
||||
}
|
||||
|
||||
func newViper(path string) *viper.Viper {
|
||||
v := viper.New()
|
||||
v.SetConfigFile(path)
|
||||
v.SetConfigType(strings.TrimPrefix(filepath.Ext(path), "."))
|
||||
v.SetEnvPrefix("KRA")
|
||||
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_"))
|
||||
v.AutomaticEnv()
|
||||
return v
|
||||
}
|
||||
|
||||
func decode(v *viper.Viper) (*Config, error) {
|
||||
var config Config
|
||||
hook := mapstructure.ComposeDecodeHookFunc(
|
||||
mapstructure.StringToTimeDurationHookFunc(),
|
||||
mapstructure.StringToSliceHookFunc(","),
|
||||
)
|
||||
if err := v.Unmarshal(&config, viper.DecodeHook(hook)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
func setConfigPath(config *Config, path string) {
|
||||
if config != nil && config.Admin != nil {
|
||||
config.Admin.ConfigPath = path
|
||||
}
|
||||
}
|
||||
|
||||
func cloneConfig(value *Config) *Config {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
// Viper's mapstructure output contains only value fields and pointers;
|
||||
// round-tripping through a temporary map is unnecessarily lossy for
|
||||
// durations. Explicit copy via YAML gives us a robust deep copy while
|
||||
// preserving time.Duration values through the text hook.
|
||||
copy := *value
|
||||
if value.Server != nil {
|
||||
server := *value.Server
|
||||
copy.Server = &server
|
||||
if value.Server.HTTP != nil {
|
||||
http := *value.Server.HTTP
|
||||
copy.Server.HTTP = &http
|
||||
}
|
||||
}
|
||||
if value.Data != nil {
|
||||
data := *value.Data
|
||||
copy.Data = &data
|
||||
data.Database = cloneDatabase(value.Data.Database)
|
||||
data.Redis = cloneRedis(value.Data.Redis)
|
||||
data.Mongo = cloneMongo(value.Data.Mongo)
|
||||
data.DatabaseList = make([]*Database, len(value.Data.DatabaseList))
|
||||
for i, item := range value.Data.DatabaseList {
|
||||
data.DatabaseList[i] = cloneDatabase(item)
|
||||
}
|
||||
data.RedisList = make([]*Redis, len(value.Data.RedisList))
|
||||
for i, item := range value.Data.RedisList {
|
||||
data.RedisList[i] = cloneRedis(item)
|
||||
}
|
||||
}
|
||||
if value.Admin != nil {
|
||||
copy.Admin = cloneAdmin(value.Admin)
|
||||
}
|
||||
return ©
|
||||
}
|
||||
|
||||
func cloneDatabase(value *Database) *Database {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
copy := *value
|
||||
return ©
|
||||
}
|
||||
func cloneRedis(value *Redis) *Redis {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
copy := *value
|
||||
copy.ClusterAddrs = append([]string(nil), value.ClusterAddrs...)
|
||||
return ©
|
||||
}
|
||||
func cloneMongo(value *Mongo) *Mongo {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
copy := *value
|
||||
copy.Hosts = make([]*MongoHost, len(value.Hosts))
|
||||
for i, item := range value.Hosts {
|
||||
if item != nil {
|
||||
host := *item
|
||||
copy.Hosts[i] = &host
|
||||
}
|
||||
}
|
||||
return ©
|
||||
}
|
||||
|
||||
func cloneAdmin(value *Admin) *Admin {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
copy := *value
|
||||
if value.JWT != nil {
|
||||
item := *value.JWT
|
||||
copy.JWT = &item
|
||||
}
|
||||
if value.Captcha != nil {
|
||||
item := *value.Captcha
|
||||
copy.Captcha = &item
|
||||
}
|
||||
if value.Local != nil {
|
||||
item := *value.Local
|
||||
copy.Local = &item
|
||||
}
|
||||
if value.Email != nil {
|
||||
item := *value.Email
|
||||
copy.Email = &item
|
||||
}
|
||||
if value.Media != nil {
|
||||
item := *value.Media
|
||||
copy.Media = &item
|
||||
}
|
||||
if value.System != nil {
|
||||
item := *value.System
|
||||
copy.System = &item
|
||||
}
|
||||
if value.Zap != nil {
|
||||
item := *value.Zap
|
||||
item.FileOnlyModules = append([]string(nil), value.Zap.FileOnlyModules...)
|
||||
copy.Zap = &item
|
||||
}
|
||||
if value.App != nil {
|
||||
item := *value.App
|
||||
copy.App = &item
|
||||
}
|
||||
if value.CORS != nil {
|
||||
item := *value.CORS
|
||||
item.Whitelist = make([]*CORSRule, len(value.CORS.Whitelist))
|
||||
for i, rule := range value.CORS.Whitelist {
|
||||
if rule != nil {
|
||||
next := *rule
|
||||
item.Whitelist[i] = &next
|
||||
}
|
||||
}
|
||||
copy.CORS = &item
|
||||
}
|
||||
if value.Storage != nil {
|
||||
copy.Storage = cloneStorage(value.Storage)
|
||||
}
|
||||
copy.DiskList = make([]*Disk, len(value.DiskList))
|
||||
for i, item := range value.DiskList {
|
||||
if item != nil {
|
||||
next := *item
|
||||
copy.DiskList[i] = &next
|
||||
}
|
||||
}
|
||||
return ©
|
||||
}
|
||||
|
||||
func cloneStorage(value *Storage) *Storage {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
copy := *value
|
||||
if value.Qiniu != nil {
|
||||
item := *value.Qiniu
|
||||
copy.Qiniu = &item
|
||||
}
|
||||
copy.AliyunOSS = cloneObjectStore(value.AliyunOSS)
|
||||
copy.HuaweiOBS = cloneObjectStore(value.HuaweiOBS)
|
||||
copy.TencentCOS = cloneObjectStore(value.TencentCOS)
|
||||
copy.AWSS3 = cloneObjectStore(value.AWSS3)
|
||||
copy.CloudflareR2 = cloneObjectStore(value.CloudflareR2)
|
||||
copy.Minio = cloneObjectStore(value.Minio)
|
||||
return ©
|
||||
}
|
||||
func cloneObjectStore(value *ObjectStore) *ObjectStore {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
copy := *value
|
||||
return ©
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func writeConfig(t *testing.T, path, body string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDirectoryAndDurations(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
writeConfig(t, path, "server:\n http:\n addr: :9000\n timeout: 600s\ndata:\n redis:\n read_timeout: 200ms\nadmin:\n jwt:\n expires_time: 168h\n media:\n session_ttl: 36\n")
|
||||
loaded, err := Load(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.Server == nil || loaded.Server.HTTP == nil || loaded.Server.HTTP.Timeout != 10*time.Minute {
|
||||
t.Fatalf("server timeout = %#v", loaded.Server)
|
||||
}
|
||||
if loaded.Data == nil || loaded.Data.Redis == nil || loaded.Data.Redis.ReadTimeout != 200*time.Millisecond {
|
||||
t.Fatalf("redis timeout = %#v", loaded.Data)
|
||||
}
|
||||
if loaded.Admin == nil || loaded.Admin.JWT == nil || loaded.Admin.JWT.ExpiresTime != 7*24*time.Hour {
|
||||
t.Fatalf("jwt duration = %#v", loaded.Admin)
|
||||
}
|
||||
if loaded.Admin.ConfigPath != path {
|
||||
t.Fatalf("config path = %q, want %q", loaded.Admin.ConfigPath, path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsInvalidDuration(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
writeConfig(t, path, "server:\n http:\n timeout: definitely-not-a-duration\n")
|
||||
if _, err := Load(path); err == nil {
|
||||
t.Fatal("invalid duration was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadEnvironmentOverride(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
writeConfig(t, path, "server:\n http:\n addr: :8000\n")
|
||||
t.Setenv("KRA_SERVER_HTTP_ADDR", ":9100")
|
||||
loaded, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.Server == nil || loaded.Server.HTTP == nil || loaded.Server.HTTP.Addr != ":9100" {
|
||||
t.Fatalf("environment override = %#v", loaded.Server)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreSnapshotIsolationAndSubscriptions(t *testing.T) {
|
||||
store := NewStore(&Config{Admin: &Admin{JWT: &JWT{SigningKey: "before"}}})
|
||||
var calls atomic.Int32
|
||||
stop := store.Subscribe(func(value *Config) {
|
||||
calls.Add(1)
|
||||
value.Admin.JWT.SigningKey = "callback-mutation"
|
||||
})
|
||||
store.Replace(&Config{Admin: &Admin{JWT: &JWT{SigningKey: "after"}}})
|
||||
stop()
|
||||
stop()
|
||||
value := store.Snapshot()
|
||||
if value == nil || value.Admin == nil || value.Admin.JWT == nil || value.Admin.JWT.SigningKey != "after" {
|
||||
t.Fatalf("snapshot was mutated by callback: %#v", value)
|
||||
}
|
||||
if calls.Load() != 1 {
|
||||
t.Fatalf("callback count = %d", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreConcurrentReadersAndReplacements(t *testing.T) {
|
||||
store := NewStore(&Config{Data: &Data{Database: &Database{Name: "initial"}}})
|
||||
var wait sync.WaitGroup
|
||||
for i := 0; i < 8; i++ {
|
||||
wait.Add(1)
|
||||
go func() {
|
||||
defer wait.Done()
|
||||
for j := 0; j < 500; j++ {
|
||||
_ = store.Snapshot()
|
||||
}
|
||||
}()
|
||||
}
|
||||
for i := 0; i < 500; i++ {
|
||||
store.Replace(&Config{Data: &Data{Database: &Database{Name: "next"}}})
|
||||
}
|
||||
wait.Wait()
|
||||
}
|
||||
|
||||
func TestStoreWatchReloadAndClose(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
writeConfig(t, path, "admin:\n app:\n app_id: first\n")
|
||||
store, err := LoadStore(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
writeConfig(t, path, "admin:\n app:\n app_id: second\n")
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
value := store.Snapshot()
|
||||
if value != nil && value.Admin != nil && value.Admin.App != nil && value.Admin.App.AppID == "second" {
|
||||
return
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("watcher did not reload configuration: %#v", store.Snapshot())
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package config
|
||||
|
||||
import "time"
|
||||
|
||||
// Config is the complete application configuration loaded from config.yaml.
|
||||
// It intentionally uses ordinary Go types so configuration is independent of
|
||||
// protobuf generation and can be consumed by every internal module.
|
||||
type Config struct {
|
||||
Server *Server `mapstructure:"server" yaml:"server" json:"server"`
|
||||
Data *Data `mapstructure:"data" yaml:"data" json:"data"`
|
||||
Admin *Admin `mapstructure:"admin" yaml:"admin" json:"admin"`
|
||||
}
|
||||
|
||||
type Bootstrap = Config
|
||||
|
||||
type Server struct {
|
||||
HTTP *ServerHTTP `mapstructure:"http" yaml:"http" json:"http"`
|
||||
}
|
||||
|
||||
type ServerHTTP struct {
|
||||
Network string `mapstructure:"network" yaml:"network" json:"network"`
|
||||
Addr string `mapstructure:"addr" yaml:"addr" json:"addr"`
|
||||
Timeout time.Duration `mapstructure:"timeout" yaml:"timeout" json:"timeout"`
|
||||
}
|
||||
|
||||
type Data struct {
|
||||
Database *Database `mapstructure:"database" yaml:"database" json:"database"`
|
||||
Redis *Redis `mapstructure:"redis" yaml:"redis" json:"redis"`
|
||||
DatabaseList []*Database `mapstructure:"database_list" yaml:"database_list" json:"database_list"`
|
||||
RedisList []*Redis `mapstructure:"redis_list" yaml:"redis_list" json:"redis_list"`
|
||||
Mongo *Mongo `mapstructure:"mongo" yaml:"mongo" json:"mongo"`
|
||||
}
|
||||
|
||||
type Database struct {
|
||||
Driver string `mapstructure:"driver" yaml:"driver" json:"driver"`
|
||||
Source string `mapstructure:"source" yaml:"source" json:"source"`
|
||||
Host string `mapstructure:"host" yaml:"host" json:"host"`
|
||||
Port string `mapstructure:"port" yaml:"port" json:"port"`
|
||||
User string `mapstructure:"user" yaml:"user" json:"user"`
|
||||
Password string `mapstructure:"password" yaml:"password" json:"password"`
|
||||
Name string `mapstructure:"name" yaml:"name" json:"name"`
|
||||
Config string `mapstructure:"config" yaml:"config" json:"config"`
|
||||
Path string `mapstructure:"path" yaml:"path" json:"path"`
|
||||
AliasName string `mapstructure:"alias_name" yaml:"alias_name" json:"alias_name"`
|
||||
Disable bool `mapstructure:"disable" yaml:"disable" json:"disable"`
|
||||
Prefix string `mapstructure:"prefix" yaml:"prefix" json:"prefix"`
|
||||
Engine string `mapstructure:"engine" yaml:"engine" json:"engine"`
|
||||
LogMode string `mapstructure:"log_mode" yaml:"log_mode" json:"log_mode"`
|
||||
MaxIdleConns int `mapstructure:"max_idle_conns" yaml:"max_idle_conns" json:"max_idle_conns"`
|
||||
MaxOpenConns int `mapstructure:"max_open_conns" yaml:"max_open_conns" json:"max_open_conns"`
|
||||
// ConnMaxLifetime is expressed in seconds for compatibility with the
|
||||
// existing configuration contract. A bare YAML number must not mean ns.
|
||||
ConnMaxLifetime int `mapstructure:"conn_max_lifetime" yaml:"conn_max_lifetime" json:"conn_max_lifetime"`
|
||||
Singular bool `mapstructure:"singular" yaml:"singular" json:"singular"`
|
||||
}
|
||||
|
||||
type Redis struct {
|
||||
Network string `mapstructure:"network" yaml:"network" json:"network"`
|
||||
Addr string `mapstructure:"addr" yaml:"addr" json:"addr"`
|
||||
ReadTimeout time.Duration `mapstructure:"read_timeout" yaml:"read_timeout" json:"read_timeout"`
|
||||
WriteTimeout time.Duration `mapstructure:"write_timeout" yaml:"write_timeout" json:"write_timeout"`
|
||||
Name string `mapstructure:"name" yaml:"name" json:"name"`
|
||||
Password string `mapstructure:"password" yaml:"password" json:"password"`
|
||||
DB int `mapstructure:"db" yaml:"db" json:"db"`
|
||||
UseCluster bool `mapstructure:"use_cluster" yaml:"use_cluster" json:"use_cluster"`
|
||||
ClusterAddrs []string `mapstructure:"cluster_addrs" yaml:"cluster_addrs" json:"cluster_addrs"`
|
||||
}
|
||||
|
||||
type MongoHost struct {
|
||||
Host string `mapstructure:"host" yaml:"host" json:"host"`
|
||||
Port string `mapstructure:"port" yaml:"port" json:"port"`
|
||||
}
|
||||
|
||||
type Mongo struct {
|
||||
Coll string `mapstructure:"coll" yaml:"coll" json:"coll"`
|
||||
Options string `mapstructure:"options" yaml:"options" json:"options"`
|
||||
Database string `mapstructure:"database" yaml:"database" json:"database"`
|
||||
Username string `mapstructure:"username" yaml:"username" json:"username"`
|
||||
Password string `mapstructure:"password" yaml:"password" json:"password"`
|
||||
AuthSource string `mapstructure:"auth_source" yaml:"auth_source" json:"auth_source"`
|
||||
MinPoolSize uint64 `mapstructure:"min_pool_size" yaml:"min_pool_size" json:"min_pool_size"`
|
||||
MaxPoolSize uint64 `mapstructure:"max_pool_size" yaml:"max_pool_size" json:"max_pool_size"`
|
||||
SocketTimeoutMs int64 `mapstructure:"socket_timeout_ms" yaml:"socket_timeout_ms" json:"socket_timeout_ms"`
|
||||
ConnectTimeoutMs int64 `mapstructure:"connect_timeout_ms" yaml:"connect_timeout_ms" json:"connect_timeout_ms"`
|
||||
IsZap bool `mapstructure:"is_zap" yaml:"is_zap" json:"is_zap"`
|
||||
Hosts []*MongoHost `mapstructure:"hosts" yaml:"hosts" json:"hosts"`
|
||||
}
|
||||
|
||||
// Admin contains administration-service settings. ConfigPath is runtime
|
||||
// metadata populated by the loader and never decoded from or written to YAML.
|
||||
type Admin struct {
|
||||
RouterPrefix string `mapstructure:"router_prefix" yaml:"router_prefix" json:"router_prefix"`
|
||||
JWT *JWT `mapstructure:"jwt" yaml:"jwt" json:"jwt"`
|
||||
Captcha *Captcha `mapstructure:"captcha" yaml:"captcha" json:"captcha"`
|
||||
Local *Local `mapstructure:"local" yaml:"local" json:"local"`
|
||||
Email *Email `mapstructure:"email" yaml:"email" json:"email"`
|
||||
Storage *Storage `mapstructure:"storage" yaml:"storage" json:"storage"`
|
||||
ConfigPath string `mapstructure:"-" yaml:"-" json:"-"`
|
||||
Media *Media `mapstructure:"media" yaml:"media" json:"media"`
|
||||
DiskList []*Disk `mapstructure:"disk_list" yaml:"disk_list" json:"disk_list"`
|
||||
System *System `mapstructure:"system" yaml:"system" json:"system"`
|
||||
Zap *Zap `mapstructure:"zap" yaml:"zap" json:"zap"`
|
||||
CORS *CORS `mapstructure:"cors" yaml:"cors" json:"cors"`
|
||||
App *App `mapstructure:"app" yaml:"app" json:"app"`
|
||||
}
|
||||
|
||||
type JWT struct {
|
||||
SigningKey string `mapstructure:"signing_key" yaml:"signing_key" json:"signing_key"`
|
||||
ExpiresTime time.Duration `mapstructure:"expires_time" yaml:"expires_time" json:"expires_time"`
|
||||
BufferTime time.Duration `mapstructure:"buffer_time" yaml:"buffer_time" json:"buffer_time"`
|
||||
Issuer string `mapstructure:"issuer" yaml:"issuer" json:"issuer"`
|
||||
}
|
||||
|
||||
type Captcha struct {
|
||||
KeyLong int `mapstructure:"key_long" yaml:"key_long" json:"key_long"`
|
||||
ImgWidth int `mapstructure:"img_width" yaml:"img_width" json:"img_width"`
|
||||
ImgHeight int `mapstructure:"img_height" yaml:"img_height" json:"img_height"`
|
||||
StoreExpiration time.Duration `mapstructure:"store_expiration" yaml:"store_expiration" json:"store_expiration"`
|
||||
}
|
||||
|
||||
type Local struct {
|
||||
StorePath string `mapstructure:"store_path" yaml:"store_path" json:"store_path"`
|
||||
PathPrefix string `mapstructure:"path_prefix" yaml:"path_prefix" json:"path_prefix"`
|
||||
}
|
||||
|
||||
type Email struct {
|
||||
To string `mapstructure:"to" yaml:"to" json:"to"`
|
||||
From string `mapstructure:"from" yaml:"from" json:"from"`
|
||||
Host string `mapstructure:"host" yaml:"host" json:"host"`
|
||||
Secret string `mapstructure:"secret" yaml:"secret" json:"secret"`
|
||||
Nickname string `mapstructure:"nickname" yaml:"nickname" json:"nickname"`
|
||||
Port int `mapstructure:"port" yaml:"port" json:"port"`
|
||||
IsSSL bool `mapstructure:"is_ssl" yaml:"is_ssl" json:"is_ssl"`
|
||||
IsLoginAuth bool `mapstructure:"is_login_auth" yaml:"is_login_auth" json:"is_login_auth"`
|
||||
}
|
||||
|
||||
type Media struct {
|
||||
SessionTTL int `mapstructure:"session_ttl" yaml:"session_ttl" json:"session_ttl"`
|
||||
MaxFileSize int64 `mapstructure:"max_file_size" yaml:"max_file_size" json:"max_file_size"`
|
||||
ChunkDir string `mapstructure:"chunk_dir" yaml:"chunk_dir" json:"chunk_dir"`
|
||||
}
|
||||
|
||||
type Disk struct {
|
||||
MountPoint string `mapstructure:"mount_point" yaml:"mount_point" json:"mount_point"`
|
||||
}
|
||||
|
||||
type System struct {
|
||||
UseRedis bool `mapstructure:"use_redis" yaml:"use_redis" json:"use_redis"`
|
||||
UseMultipoint bool `mapstructure:"use_multipoint" yaml:"use_multipoint" json:"use_multipoint"`
|
||||
UseStrictAuth bool `mapstructure:"use_strict_auth" yaml:"use_strict_auth" json:"use_strict_auth"`
|
||||
DisableAutoMigrate bool `mapstructure:"disable_auto_migrate" yaml:"disable_auto_migrate" json:"disable_auto_migrate"`
|
||||
UseMongo bool `mapstructure:"use_mongo" yaml:"use_mongo" json:"use_mongo"`
|
||||
Addr int `mapstructure:"addr" yaml:"addr" json:"addr"`
|
||||
IplimitCount int `mapstructure:"iplimit_count" yaml:"iplimit_count" json:"iplimit_count"`
|
||||
IplimitTime int `mapstructure:"iplimit_time" yaml:"iplimit_time" json:"iplimit_time"`
|
||||
}
|
||||
|
||||
type Zap struct {
|
||||
Level string `mapstructure:"level" yaml:"level" json:"level"`
|
||||
Prefix string `mapstructure:"prefix" yaml:"prefix" json:"prefix"`
|
||||
Format string `mapstructure:"format" yaml:"format" json:"format"`
|
||||
Director string `mapstructure:"director" yaml:"director" json:"director"`
|
||||
EncodeLevel string `mapstructure:"encode_level" yaml:"encode_level" json:"encode_level"`
|
||||
StacktraceKey string `mapstructure:"stacktrace_key" yaml:"stacktrace_key" json:"stacktrace_key"`
|
||||
ShowLine bool `mapstructure:"show_line" yaml:"show_line" json:"show_line"`
|
||||
LogInConsole bool `mapstructure:"log_in_console" yaml:"log_in_console" json:"log_in_console"`
|
||||
RetentionDay int `mapstructure:"retention_day" yaml:"retention_day" json:"retention_day"`
|
||||
AccessReqBody bool `mapstructure:"access_req_body" yaml:"access_req_body" json:"access_req_body"`
|
||||
AccessRespData bool `mapstructure:"access_resp_data" yaml:"access_resp_data" json:"access_resp_data"`
|
||||
AccessReqHeaders bool `mapstructure:"access_req_headers" yaml:"access_req_headers" json:"access_req_headers"`
|
||||
AccessLogMaxBytes int `mapstructure:"access_log_max_bytes" yaml:"access_log_max_bytes" json:"access_log_max_bytes"`
|
||||
FileOnlyModules []string `mapstructure:"file_only_modules" yaml:"file_only_modules" json:"file_only_modules"`
|
||||
}
|
||||
|
||||
type CORS struct {
|
||||
Mode string `mapstructure:"mode" yaml:"mode" json:"mode"`
|
||||
Whitelist []*CORSRule `mapstructure:"whitelist" yaml:"whitelist" json:"whitelist"`
|
||||
}
|
||||
|
||||
type CORSRule struct {
|
||||
AllowOrigin string `mapstructure:"allow_origin" yaml:"allow_origin" json:"allow_origin"`
|
||||
AllowMethods string `mapstructure:"allow_methods" yaml:"allow_methods" json:"allow_methods"`
|
||||
AllowHeaders string `mapstructure:"allow_headers" yaml:"allow_headers" json:"allow_headers"`
|
||||
ExposeHeaders string `mapstructure:"expose_headers" yaml:"expose_headers" json:"expose_headers"`
|
||||
AllowCredentials bool `mapstructure:"allow_credentials" yaml:"allow_credentials" json:"allow_credentials"`
|
||||
}
|
||||
|
||||
type App struct {
|
||||
Node string `mapstructure:"node" yaml:"node" json:"node"`
|
||||
AppID string `mapstructure:"app_id" yaml:"app_id" json:"app_id"`
|
||||
Env string `mapstructure:"env" yaml:"env" json:"env"`
|
||||
}
|
||||
|
||||
type Storage struct {
|
||||
Type string `mapstructure:"type" yaml:"type" json:"type"`
|
||||
Qiniu *Qiniu `mapstructure:"qiniu" yaml:"qiniu" json:"qiniu"`
|
||||
AliyunOSS *ObjectStore `mapstructure:"aliyun_oss" yaml:"aliyun_oss" json:"aliyun_oss"`
|
||||
HuaweiOBS *ObjectStore `mapstructure:"huawei_obs" yaml:"huawei_obs" json:"huawei_obs"`
|
||||
TencentCOS *ObjectStore `mapstructure:"tencent_cos" yaml:"tencent_cos" json:"tencent_cos"`
|
||||
AWSS3 *ObjectStore `mapstructure:"aws_s3" yaml:"aws_s3" json:"aws_s3"`
|
||||
CloudflareR2 *ObjectStore `mapstructure:"cloudflare_r2" yaml:"cloudflare_r2" json:"cloudflare_r2"`
|
||||
Minio *ObjectStore `mapstructure:"minio" yaml:"minio" json:"minio"`
|
||||
}
|
||||
|
||||
type Qiniu struct {
|
||||
Zone string `mapstructure:"zone" yaml:"zone" json:"zone"`
|
||||
Bucket string `mapstructure:"bucket" yaml:"bucket" json:"bucket"`
|
||||
BaseURL string `mapstructure:"base_url" yaml:"base_url" json:"base_url"`
|
||||
AccessKey string `mapstructure:"access_key" yaml:"access_key" json:"access_key"`
|
||||
SecretKey string `mapstructure:"secret_key" yaml:"secret_key" json:"secret_key"`
|
||||
UseHTTPS bool `mapstructure:"use_https" yaml:"use_https" json:"use_https"`
|
||||
UseCDNDomains bool `mapstructure:"use_cdn_domains" yaml:"use_cdn_domains" json:"use_cdn_domains"`
|
||||
}
|
||||
|
||||
type ObjectStore struct {
|
||||
Endpoint string `mapstructure:"endpoint" yaml:"endpoint" json:"endpoint"`
|
||||
Region string `mapstructure:"region" yaml:"region" json:"region"`
|
||||
Bucket string `mapstructure:"bucket" yaml:"bucket" json:"bucket"`
|
||||
AccessKey string `mapstructure:"access_key" yaml:"access_key" json:"access_key"`
|
||||
SecretKey string `mapstructure:"secret_key" yaml:"secret_key" json:"secret_key"`
|
||||
BaseURL string `mapstructure:"base_url" yaml:"base_url" json:"base_url"`
|
||||
PathPrefix string `mapstructure:"path_prefix" yaml:"path_prefix" json:"path_prefix"`
|
||||
UseSSL bool `mapstructure:"use_ssl" yaml:"use_ssl" json:"use_ssl"`
|
||||
ForcePathStyle bool `mapstructure:"force_path_style" yaml:"force_path_style" json:"force_path_style"`
|
||||
AccountID string `mapstructure:"account_id" yaml:"account_id" json:"account_id"`
|
||||
}
|
||||
@@ -1,30 +1,28 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"kra/internal/conf"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
"kra/internal/config"
|
||||
)
|
||||
|
||||
func cloneAdminConfig(value *conf.AdminBackend) *conf.AdminBackend {
|
||||
func cloneAdminConfig(value *config.Admin) *config.Admin {
|
||||
if value == nil {
|
||||
return &conf.AdminBackend{}
|
||||
return &config.Admin{}
|
||||
}
|
||||
return proto.Clone(value).(*conf.AdminBackend)
|
||||
return config.CloneAdmin(value)
|
||||
}
|
||||
|
||||
func maskStorageSecrets(storage *conf.AdminBackend_Storage) {
|
||||
func maskStorageSecrets(storage *config.Storage) {
|
||||
if storage == nil {
|
||||
return
|
||||
}
|
||||
if storage.Qiniu != nil && storage.Qiniu.SecretKey != "" {
|
||||
storage.Qiniu.SecretKey = "******"
|
||||
}
|
||||
for _, item := range []*conf.AdminBackend_ObjectStore{
|
||||
storage.AliyunOss,
|
||||
storage.HuaweiObs,
|
||||
storage.TencentCos,
|
||||
storage.AwsS3,
|
||||
for _, item := range []*config.ObjectStore{
|
||||
storage.AliyunOSS,
|
||||
storage.HuaweiOBS,
|
||||
storage.TencentCOS,
|
||||
storage.AWSS3,
|
||||
storage.CloudflareR2,
|
||||
storage.Minio,
|
||||
} {
|
||||
|
||||
@@ -9,29 +9,23 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"kra/internal/conf"
|
||||
"kra/internal/config"
|
||||
"kra/internal/integration/storage"
|
||||
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"gopkg.in/yaml.v3"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func protoMap(message proto.Message) (map[string]any, error) {
|
||||
raw, err := (protojson.MarshalOptions{
|
||||
UseProtoNames: true,
|
||||
EmitDefaultValues: true,
|
||||
}).Marshal(message)
|
||||
func configMap(input any) (map[string]any, error) {
|
||||
raw, err := yaml.Marshal(input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var value map[string]any
|
||||
if err = json.Unmarshal(raw, &value); err != nil {
|
||||
var output map[string]any
|
||||
if err = yaml.Unmarshal(raw, &output); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
delete(value, "config_path")
|
||||
return value, nil
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func setYAMLMapping(node *yaml.Node, key string, value any) error {
|
||||
@@ -60,9 +54,8 @@ func setYAMLMapping(node *yaml.Node, key string, value any) error {
|
||||
}
|
||||
|
||||
// mergeYAMLNode updates values produced from the runtime configuration while
|
||||
// retaining keys that are not represented by the protobuf and comments that
|
||||
// were already present in the user's config file. Replacing an entire mapping
|
||||
// with protojson output would discard configuration hints and extension keys.
|
||||
// retaining keys that are not represented by the typed configuration and
|
||||
// comments that were already present in the user's config file.
|
||||
func mergeYAMLNode(dst, src *yaml.Node) {
|
||||
if dst.Kind == yaml.MappingNode && src.Kind == yaml.MappingNode {
|
||||
for i := 0; i+1 < len(src.Content); i += 2 {
|
||||
@@ -138,7 +131,7 @@ func deleteYAMLMapping(node *yaml.Node, keys ...string) {
|
||||
}
|
||||
}
|
||||
|
||||
func setServerHTTPPort(document *yaml.Node, port int32) error {
|
||||
func setServerHTTPPort(document *yaml.Node, port int) error {
|
||||
if port <= 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -156,13 +149,13 @@ func (d *Data) persistConfig() error {
|
||||
return d.persistConfigValues(dataConfig, adminConfig)
|
||||
}
|
||||
|
||||
func (d *Data) persistConfigValues(dataConfig *conf.Data, adminConfig *conf.AdminBackend) error {
|
||||
func (d *Data) persistConfigValues(dataConfig *config.Data, adminConfig *config.Admin) error {
|
||||
d.configMu.Lock()
|
||||
defer d.configMu.Unlock()
|
||||
return d.persistConfigValuesLocked(dataConfig, adminConfig)
|
||||
}
|
||||
|
||||
func (d *Data) persistConfigValuesLocked(dataConfig *conf.Data, adminConfig *conf.AdminBackend) error {
|
||||
func (d *Data) persistConfigValuesLocked(dataConfig *config.Data, adminConfig *config.Admin) error {
|
||||
if adminConfig == nil || adminConfig.ConfigPath == "" {
|
||||
return nil
|
||||
}
|
||||
@@ -175,14 +168,14 @@ func (d *Data) persistConfigValuesLocked(dataConfig *conf.Data, adminConfig *con
|
||||
if err = yaml.Unmarshal(raw, &document); err != nil {
|
||||
return err
|
||||
}
|
||||
dataValue, err := protoMap(dataConfig)
|
||||
dataValue, err := configMap(dataConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fileAdmin := cloneAdminConfig(adminConfig)
|
||||
fileAdmin.Storage = nil
|
||||
fileAdmin.Email = nil
|
||||
adminValue, err := protoMap(fileAdmin)
|
||||
adminValue, err := configMap(fileAdmin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -207,7 +200,7 @@ func (d *Data) persistConfigValuesLocked(dataConfig *conf.Data, adminConfig *con
|
||||
// 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 *conf.Data_Database, signingKey string) error {
|
||||
func (d *Data) persistDatabaseConfig(database *config.Database, signingKey string) error {
|
||||
d.configMu.Lock()
|
||||
defer d.configMu.Unlock()
|
||||
|
||||
@@ -227,11 +220,8 @@ func (d *Data) persistDatabaseConfig(database *conf.Data_Database, signingKey st
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Do not marshal the init request through protojson here. Proto3 omits
|
||||
// empty and zero values, which makes a merge retain stale values from the
|
||||
// template (an empty database password is the common example). Update the
|
||||
// fields owned by the init page explicitly and leave advanced database,
|
||||
// Redis, Mongo, admin and server settings untouched.
|
||||
// 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,
|
||||
@@ -428,7 +418,7 @@ func (d *Data) reloadConfig(ctx context.Context) error {
|
||||
closeCandidate = false
|
||||
candidateDBListAccepted = true
|
||||
candidateRedisAccepted = true
|
||||
d.runtime.Replace(next.Data, next.Admin)
|
||||
d.runtime.Replace(next)
|
||||
if d.integrations != nil {
|
||||
d.integrations.Replace(integrationConfigs)
|
||||
}
|
||||
@@ -438,22 +428,6 @@ func (d *Data) reloadConfig(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func readBootstrap(configPath string) (*conf.Bootstrap, error) {
|
||||
raw, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var plain map[string]any
|
||||
if err = yaml.Unmarshal(raw, &plain); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
encoded, err := json.Marshal(plain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var next conf.Bootstrap
|
||||
if err = (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(encoded, &next); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &next, nil
|
||||
func readBootstrap(configPath string) (*config.Config, error) {
|
||||
return config.Load(configPath)
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ func (d *Data) watchConfig() func() {
|
||||
next.Admin.Email = current.Email
|
||||
}
|
||||
next.Admin.ConfigPath = absolute
|
||||
d.runtime.Replace(next.Data, next.Admin)
|
||||
d.runtime.Replace(next)
|
||||
logger.Info("config file changed", "mod", "system", "path", absolute)
|
||||
case watchErr, ok := <-watchErrors:
|
||||
if !ok {
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"kra/internal/conf"
|
||||
"kra/internal/config"
|
||||
)
|
||||
|
||||
func TestConfigWatcherStopWaitsForDebouncedReload(t *testing.T) {
|
||||
@@ -17,11 +17,11 @@ func TestConfigWatcherStopWaitsForDebouncedReload(t *testing.T) {
|
||||
if err := os.WriteFile(configPath, config, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runtime := conf.NewRuntime(&conf.Data{}, &conf.AdminBackend{ConfigPath: configPath})
|
||||
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(*conf.Data, *conf.AdminBackend) {
|
||||
stopListener := runtime.Subscribe(func(*config.Data, *config.Admin) {
|
||||
startOnce.Do(func() { close(started) })
|
||||
<-release
|
||||
})
|
||||
|
||||
+16
-16
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/google/wire"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/gorm"
|
||||
"kra/internal/conf"
|
||||
"kra/internal/config"
|
||||
dataintegration "kra/internal/data/integration"
|
||||
datapayment "kra/internal/data/payment"
|
||||
datasystem "kra/internal/data/system"
|
||||
@@ -49,7 +49,7 @@ type Data struct {
|
||||
gormDB *reloadableDB
|
||||
redis *reloadableRedis
|
||||
mongo *reloadableMongo
|
||||
runtime *conf.Runtime
|
||||
runtime *config.Store
|
||||
integrations *runtimeconfig.Store
|
||||
storage *storage.Reloadable
|
||||
dbListMu sync.RWMutex
|
||||
@@ -76,7 +76,7 @@ func (d *Data) DatabaseReady() bool {
|
||||
|
||||
// Runtime exposes the immutable runtime configuration snapshot to data
|
||||
// submodules that need system settings while keeping Data itself private.
|
||||
func (d *Data) Runtime() *conf.Runtime {
|
||||
func (d *Data) Runtime() *config.Store {
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -112,7 +112,7 @@ func (d *Data) logger() *slog.Logger {
|
||||
return slog.Default()
|
||||
}
|
||||
|
||||
func openDatabaseList(configs []*conf.Data_Database, appLogger ...*slog.Logger) (map[string]*gorm.DB, error) {
|
||||
func openDatabaseList(configs []*config.Database, appLogger ...*slog.Logger) (map[string]*gorm.DB, error) {
|
||||
items := make(map[string]*gorm.DB)
|
||||
for _, config := range configs {
|
||||
if config == nil || config.Disable || config.AliasName == "" {
|
||||
@@ -161,19 +161,19 @@ func (d *Data) database(name string) (*gorm.DB, error) {
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func NewData(runtime *conf.Runtime, appLogger *slog.Logger, storageManager *storage.Reloadable, catalog module.Catalog) (*Data, func(), error) {
|
||||
func NewData(runtime *config.Store, appLogger *slog.Logger, storageManager *storage.Reloadable, catalog module.Catalog) (*Data, func(), error) {
|
||||
if appLogger == nil {
|
||||
appLogger = slog.Default()
|
||||
}
|
||||
c := runtime.Data()
|
||||
if c == nil {
|
||||
c = &conf.Data{}
|
||||
c = &config.Data{}
|
||||
}
|
||||
if c.Database == nil {
|
||||
// An empty database block starts the service on the bootstrap database
|
||||
// so /init/checkdb
|
||||
// and /init/initdb remain available.
|
||||
c.Database = &conf.Data_Database{}
|
||||
c.Database = &config.Database{}
|
||||
}
|
||||
d := &Data{runtime: runtime, integrations: runtimeconfig.NewStore(), appLogger: appLogger, storage: storageManager, catalog: catalog}
|
||||
var stopConfigWatcher func()
|
||||
@@ -234,7 +234,7 @@ func NewData(runtime *conf.Runtime, appLogger *slog.Logger, storageManager *stor
|
||||
}
|
||||
admin := runtime.Admin()
|
||||
if admin == nil {
|
||||
admin = &conf.AdminBackend{}
|
||||
admin = &config.Admin{}
|
||||
}
|
||||
disableAutoMigrate := admin.System != nil && admin.System.DisableAutoMigrate
|
||||
if !usingFallback && !disableAutoMigrate {
|
||||
@@ -253,7 +253,7 @@ func NewData(runtime *conf.Runtime, appLogger *slog.Logger, storageManager *stor
|
||||
}
|
||||
admin.Storage = storageConfig
|
||||
admin.Email = emailConfig
|
||||
runtime.Replace(c, admin)
|
||||
runtime.Replace(&config.Config{Server: runtime.Server(), Data: c, Admin: admin})
|
||||
if err = d.loadIntegrationRuntime(db); err != nil {
|
||||
return nil, nil, fmt.Errorf("load integration runtime: %w", err)
|
||||
}
|
||||
@@ -287,7 +287,7 @@ func NewData(runtime *conf.Runtime, appLogger *slog.Logger, storageManager *stor
|
||||
return d, cleanup, nil
|
||||
}
|
||||
|
||||
func openRedis(config *conf.Data_Redis, enabled bool, appLogger ...*slog.Logger) redis.UniversalClient {
|
||||
func openRedis(config *config.Redis, enabled bool, appLogger ...*slog.Logger) redis.UniversalClient {
|
||||
if !enabled || config == nil || (config.Addr == "" && len(config.ClusterAddrs) == 0) {
|
||||
return nil
|
||||
}
|
||||
@@ -299,12 +299,12 @@ func openRedis(config *conf.Data_Redis, enabled bool, appLogger ...*slog.Logger)
|
||||
}
|
||||
candidate = redis.NewClusterClient(&redis.ClusterOptions{Addrs: addresses, Password: config.Password})
|
||||
} else {
|
||||
options := &redis.Options{Addr: config.Addr, Network: config.Network, Password: config.Password, DB: int(config.Db)}
|
||||
if config.ReadTimeout != nil {
|
||||
options.ReadTimeout = config.ReadTimeout.AsDuration()
|
||||
options := &redis.Options{Addr: config.Addr, Network: config.Network, Password: config.Password, DB: int(config.DB)}
|
||||
if config.ReadTimeout > 0 {
|
||||
options.ReadTimeout = config.ReadTimeout
|
||||
}
|
||||
if config.WriteTimeout != nil {
|
||||
options.WriteTimeout = config.WriteTimeout.AsDuration()
|
||||
if config.WriteTimeout > 0 {
|
||||
options.WriteTimeout = config.WriteTimeout
|
||||
}
|
||||
candidate = redis.NewClient(options)
|
||||
}
|
||||
@@ -322,7 +322,7 @@ func openRedis(config *conf.Data_Redis, enabled bool, appLogger ...*slog.Logger)
|
||||
return candidate
|
||||
}
|
||||
|
||||
func (d *Data) activateDatabase(db *gorm.DB, config *conf.Data_Database) {
|
||||
func (d *Data) activateDatabase(db *gorm.DB, config *config.Database) {
|
||||
d.gormDB.replace(db, d.enqueueDataScopeAudit)
|
||||
d.runtime.UpdateDatabase(config)
|
||||
d.databaseReady.Store(true)
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
"gorm.io/gorm/schema"
|
||||
"kra/internal/conf"
|
||||
"kra/internal/config"
|
||||
"kra/pkg/database/gormkit"
|
||||
)
|
||||
|
||||
@@ -29,7 +29,7 @@ var databaseNamePattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_$-]*$`)
|
||||
// administration backend: an explicitly selected database (or a standalone
|
||||
// DSN) means a real database connection is expected to exist. The internal
|
||||
// bootstrap database is deliberately excluded from this state.
|
||||
func databaseConnectionConfigured(c *conf.Data_Database) bool {
|
||||
func databaseConnectionConfigured(c *config.Database) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
@@ -56,7 +56,7 @@ func normalizedDriver(driver string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func databaseDSN(c *conf.Data_Database, name string) (string, error) {
|
||||
func databaseDSN(c *config.Database, name string) (string, error) {
|
||||
if c.Source != "" && c.Host == "" && c.Path == "" {
|
||||
return c.Source, nil
|
||||
}
|
||||
@@ -118,7 +118,7 @@ func databaseDSN(c *conf.Data_Database, name string) (string, error) {
|
||||
return "", fmt.Errorf("unsupported database driver %q", c.Driver)
|
||||
}
|
||||
|
||||
func gormConfig(config *conf.Data_Database, appLogger ...*slog.Logger) *gorm.Config {
|
||||
func gormConfig(config *config.Database, appLogger ...*slog.Logger) *gorm.Config {
|
||||
level := logger.Info
|
||||
switch strings.ToLower(config.LogMode) {
|
||||
case "silent":
|
||||
@@ -136,10 +136,10 @@ func gormConfig(config *conf.Data_Database, appLogger ...*slog.Logger) *gorm.Con
|
||||
}
|
||||
|
||||
func openWithDriver(driver, dsn string, appLogger ...*slog.Logger) (*gorm.DB, error) {
|
||||
return openWithDriverConfig(driver, dsn, &conf.Data_Database{Driver: driver}, appLogger...)
|
||||
return openWithDriverConfig(driver, dsn, &config.Database{Driver: driver}, appLogger...)
|
||||
}
|
||||
|
||||
func openWithDriverConfig(driver, dsn string, config *conf.Data_Database, appLogger ...*slog.Logger) (*gorm.DB, error) {
|
||||
func openWithDriverConfig(driver, dsn string, config *config.Database, appLogger ...*slog.Logger) (*gorm.DB, error) {
|
||||
gormConfig := gormConfig(config, appLogger...)
|
||||
var db *gorm.DB
|
||||
var err error
|
||||
@@ -179,7 +179,7 @@ func openWithDriverConfig(driver, dsn string, config *conf.Data_Database, appLog
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func openDatabase(c *conf.Data_Database, create bool, template string, appLogger ...*slog.Logger) (*gorm.DB, error) {
|
||||
func openDatabase(c *config.Database, create bool, template string, appLogger ...*slog.Logger) (*gorm.DB, error) {
|
||||
driver := normalizedDriver(c.Driver)
|
||||
if driver == "" {
|
||||
return nil, fmt.Errorf("unsupported database driver %q", c.Driver)
|
||||
|
||||
@@ -4,22 +4,23 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"kra/internal/biz/system"
|
||||
|
||||
"kra/internal/conf"
|
||||
"kra/internal/biz/system"
|
||||
configpkg "kra/internal/config"
|
||||
"kra/internal/integration/storage"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (d *Data) RuntimeValues() (*conf.Data, *conf.AdminBackend) { return d.runtime.Values() }
|
||||
func (d *Data) RuntimeAdmin() *conf.AdminBackend { return d.runtime.Admin() }
|
||||
func (d *Data) RefreshDatabaseSources(value *conf.Data) error { return refreshDatabaseSources(value) }
|
||||
func (d *Data) Config() *configpkg.Config {
|
||||
if d == nil || d.runtime == nil {
|
||||
return nil
|
||||
}
|
||||
return d.runtime.Snapshot()
|
||||
}
|
||||
|
||||
func refreshDatabaseSources(value *conf.Data) error {
|
||||
func refreshDatabaseSources(value *configpkg.Data) error {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -37,7 +38,7 @@ func refreshDatabaseSources(value *conf.Data) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func refreshDatabaseSource(database *conf.Data_Database) error {
|
||||
func refreshDatabaseSource(database *configpkg.Database) error {
|
||||
if database == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -57,72 +58,50 @@ func refreshDatabaseSource(database *conf.Data_Database) error {
|
||||
}
|
||||
|
||||
func (d *Data) PersistConfig(context.Context) error { return d.persistConfig() }
|
||||
func (d *Data) PersistAdminConfig(ctx context.Context, raw []byte) error {
|
||||
currentData, currentAdmin := d.runtime.Values()
|
||||
next := proto.Clone(currentAdmin).(*conf.AdminBackend)
|
||||
if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(raw, next); err != nil {
|
||||
func (d *Data) PersistRuntimeConfig(ctx context.Context, value *configpkg.Config) error {
|
||||
current := d.Config()
|
||||
if current == nil {
|
||||
current = &configpkg.Config{}
|
||||
}
|
||||
next := configpkg.Clone(value)
|
||||
if next == nil {
|
||||
next = configpkg.Clone(current)
|
||||
}
|
||||
if next.Data == nil {
|
||||
next.Data = configpkg.CloneData(current.Data)
|
||||
}
|
||||
if next.Admin == nil {
|
||||
next.Admin = configpkg.CloneAdmin(current.Admin)
|
||||
}
|
||||
if next.Admin != nil && current.Admin != nil {
|
||||
if next.Admin.Storage == nil {
|
||||
next.Admin.Storage = configpkg.CloneStorage(current.Admin.Storage)
|
||||
}
|
||||
if next.Admin.Email == nil {
|
||||
next.Admin.Email = configpkg.CloneEmail(current.Admin.Email)
|
||||
}
|
||||
next.Admin.ConfigPath = current.Admin.ConfigPath
|
||||
}
|
||||
if next.Data == nil {
|
||||
next.Data = &configpkg.Data{}
|
||||
}
|
||||
if err := refreshDatabaseSources(next.Data); err != nil {
|
||||
return err
|
||||
}
|
||||
if next.Storage == nil {
|
||||
next.Storage = currentAdmin.Storage
|
||||
}
|
||||
if next.Email == nil {
|
||||
next.Email = currentAdmin.Email
|
||||
}
|
||||
next.ConfigPath = currentAdmin.ConfigPath
|
||||
candidateStorage, err := storage.New(next)
|
||||
candidateStorage, err := storage.New(next.Admin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.persistStorageIntegrationConfig(ctx, next.Storage); err != nil {
|
||||
if err := d.persistStorageIntegrationConfig(ctx, next.Admin.Storage); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.persistEmailIntegrationConfig(ctx, next.Email); err != nil {
|
||||
if err := d.persistEmailIntegrationConfig(ctx, next.Admin.Email); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.persistConfigValues(currentData, next); err != nil {
|
||||
if err := d.persistConfigValues(next.Data, next.Admin); err != nil {
|
||||
return err
|
||||
}
|
||||
// Writing through the management API updates the same in-memory values
|
||||
// immediately; the file watcher remains the fallback for external edits.
|
||||
d.runtime.Replace(currentData, next)
|
||||
if d.storage != nil {
|
||||
d.storage.Replace(candidateStorage)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (d *Data) PersistRuntimeConfig(ctx context.Context, dataRaw, adminRaw []byte) error {
|
||||
currentData, currentAdmin := d.runtime.Values()
|
||||
nextData := proto.Clone(currentData).(*conf.Data)
|
||||
nextAdmin := proto.Clone(currentAdmin).(*conf.AdminBackend)
|
||||
options := protojson.UnmarshalOptions{DiscardUnknown: true}
|
||||
if err := options.Unmarshal(dataRaw, nextData); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := options.Unmarshal(adminRaw, nextAdmin); err != nil {
|
||||
return err
|
||||
}
|
||||
if nextAdmin.Storage == nil {
|
||||
nextAdmin.Storage = currentAdmin.Storage
|
||||
}
|
||||
if nextAdmin.Email == nil {
|
||||
nextAdmin.Email = currentAdmin.Email
|
||||
}
|
||||
nextAdmin.ConfigPath = currentAdmin.ConfigPath
|
||||
candidateStorage, err := storage.New(nextAdmin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.persistStorageIntegrationConfig(ctx, nextAdmin.Storage); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.persistEmailIntegrationConfig(ctx, nextAdmin.Email); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.persistConfigValues(nextData, nextAdmin); err != nil {
|
||||
return err
|
||||
}
|
||||
d.runtime.Replace(nextData, nextAdmin)
|
||||
d.runtime.Replace(next)
|
||||
if d.storage != nil {
|
||||
d.storage.Replace(candidateStorage)
|
||||
}
|
||||
@@ -140,24 +119,24 @@ func (d *Data) IsInitialized(context.Context) (bool, error) {
|
||||
// is the application-level first-install hook; data owns only lifecycle and
|
||||
// schema migration, while initialize owns system seed orchestration.
|
||||
func (d *Data) InitializeDatabase(ctx context.Context, input *system.DatabaseConfig, seed func(context.Context, *gorm.DB) error) error {
|
||||
config := &conf.Data_Database{}
|
||||
database := &configpkg.Database{}
|
||||
if current := d.runtime.Data(); current != nil && current.Database != nil {
|
||||
config = proto.Clone(current.Database).(*conf.Data_Database)
|
||||
database = configpkg.CloneDatabase(current.Database)
|
||||
}
|
||||
config.Driver = input.Driver
|
||||
config.Host = input.Host
|
||||
config.Port = input.Port
|
||||
config.User = input.User
|
||||
config.Password = input.Password
|
||||
config.Name = input.Name
|
||||
config.Path = input.Path
|
||||
config.Config = input.Config
|
||||
config.Source = ""
|
||||
source, err := databaseDSN(config, "")
|
||||
database.Driver = input.Driver
|
||||
database.Host = input.Host
|
||||
database.Port = input.Port
|
||||
database.User = input.User
|
||||
database.Password = input.Password
|
||||
database.Name = input.Name
|
||||
database.Path = input.Path
|
||||
database.Config = input.Config
|
||||
database.Source = ""
|
||||
source, err := databaseDSN(database, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
config.Source = source
|
||||
database.Source = source
|
||||
d.initMu.Lock()
|
||||
defer d.initMu.Unlock()
|
||||
initialized, err := d.IsInitialized(ctx)
|
||||
@@ -167,7 +146,7 @@ func (d *Data) InitializeDatabase(ctx context.Context, input *system.DatabaseCon
|
||||
if initialized {
|
||||
return errors.New("数据库已初始化,无需重复初始化")
|
||||
}
|
||||
candidate, err := openDatabase(config, true, input.Template, d.logger())
|
||||
candidate, err := openDatabase(database, true, input.Template, d.logger())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -189,7 +168,7 @@ func (d *Data) InitializeDatabase(ctx context.Context, input *system.DatabaseCon
|
||||
}
|
||||
}
|
||||
currentAdmin := d.runtime.Admin()
|
||||
var legacyStorage *conf.AdminBackend_Storage
|
||||
var legacyStorage *configpkg.Storage
|
||||
if currentAdmin != nil {
|
||||
legacyStorage = currentAdmin.Storage
|
||||
}
|
||||
@@ -197,7 +176,7 @@ func (d *Data) InitializeDatabase(ctx context.Context, input *system.DatabaseCon
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialize storage integration configuration: %w", err)
|
||||
}
|
||||
var legacyEmail *conf.AdminBackend_Email
|
||||
var legacyEmail *configpkg.Email
|
||||
if currentAdmin != nil {
|
||||
legacyEmail = currentAdmin.Email
|
||||
}
|
||||
@@ -206,26 +185,30 @@ func (d *Data) InitializeDatabase(ctx context.Context, input *system.DatabaseCon
|
||||
return fmt.Errorf("initialize email integration configuration: %w", err)
|
||||
}
|
||||
signingKey := uuid.NewString()
|
||||
if err := d.persistDatabaseConfig(config, signingKey); err != nil {
|
||||
if err := d.persistDatabaseConfig(database, signingKey); err != nil {
|
||||
return fmt.Errorf("persist database configuration: %w", err)
|
||||
}
|
||||
integrationConfigs, err := readIntegrationRuntime(candidate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialize integration runtime: %w", err)
|
||||
}
|
||||
d.activateDatabase(candidate, config)
|
||||
d.activateDatabase(candidate, database)
|
||||
activated = true
|
||||
currentData, currentAdmin := d.runtime.Values()
|
||||
current := d.Config()
|
||||
if current == nil {
|
||||
current = &configpkg.Config{}
|
||||
}
|
||||
currentData, currentAdmin := current.Data, current.Admin
|
||||
if currentAdmin == nil {
|
||||
currentAdmin = &conf.AdminBackend{}
|
||||
currentAdmin = &configpkg.Admin{}
|
||||
}
|
||||
if currentAdmin.Jwt == nil {
|
||||
currentAdmin.Jwt = &conf.AdminBackend_JWT{}
|
||||
if currentAdmin.JWT == nil {
|
||||
currentAdmin.JWT = &configpkg.JWT{}
|
||||
}
|
||||
currentAdmin.Jwt.SigningKey = signingKey
|
||||
currentAdmin.JWT.SigningKey = signingKey
|
||||
currentAdmin.Storage = storageConfig
|
||||
currentAdmin.Email = emailConfig
|
||||
d.runtime.Replace(currentData, currentAdmin)
|
||||
d.runtime.Replace(&configpkg.Config{Server: current.Server, Data: currentData, Admin: currentAdmin})
|
||||
if d.integrations != nil {
|
||||
d.integrations.Replace(integrationConfigs)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"kra/internal/conf"
|
||||
"kra/internal/config"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
@@ -15,14 +15,14 @@ import (
|
||||
func TestDatabaseConnectionConfigured(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config *conf.Data_Database
|
||||
config *config.Database
|
||||
want bool
|
||||
}{
|
||||
{name: "missing config", config: nil, want: false},
|
||||
{name: "empty database", config: &conf.Data_Database{Driver: "mysql"}, want: false},
|
||||
{name: "named database", config: &conf.Data_Database{Driver: "mysql", Name: "kra"}, want: true},
|
||||
{name: "standalone DSN", config: &conf.Data_Database{Driver: "sqlite", Source: "file:kra.db"}, want: true},
|
||||
{name: "generated DSN still requires a name", config: &conf.Data_Database{Driver: "mysql", Source: "root@tcp(localhost)/", Host: "localhost"}, want: false},
|
||||
{name: "empty database", config: &config.Database{Driver: "mysql"}, want: false},
|
||||
{name: "named database", config: &config.Database{Driver: "mysql", Name: "kra"}, want: true},
|
||||
{name: "standalone DSN", config: &config.Database{Driver: "sqlite", Source: "file:kra.db"}, want: true},
|
||||
{name: "generated DSN still requires a name", config: &config.Database{Driver: "mysql", Source: "root@tcp(localhost)/", Host: "localhost"}, want: false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
@@ -78,9 +78,9 @@ extension:
|
||||
if err := os.WriteFile(configPath, []byte(original), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runtime := conf.NewRuntime(&conf.Data{}, &conf.AdminBackend{ConfigPath: configPath})
|
||||
runtime := config.NewStore(&config.Data{}, &config.Admin{ConfigPath: configPath})
|
||||
data := &Data{runtime: runtime}
|
||||
if err := data.persistDatabaseConfig(&conf.Data_Database{Driver: "mysql", Host: "db", Port: "3306", User: "root", Password: "secret", Name: "kra", Config: "parseTime=True"}, "new-key"); err != nil {
|
||||
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)
|
||||
}
|
||||
raw, err := os.ReadFile(configPath)
|
||||
|
||||
@@ -7,11 +7,9 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"kra/internal/conf"
|
||||
"kra/internal/config"
|
||||
dataintegration "kra/internal/data/integration"
|
||||
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -39,24 +37,24 @@ func normalizeStorageType(value string) string {
|
||||
return value
|
||||
}
|
||||
|
||||
func storageProviderMessage(storage *conf.AdminBackend_Storage, provider string) proto.Message {
|
||||
func storageProviderValue(storage *config.Storage, provider string) any {
|
||||
if storage == nil {
|
||||
storage = &conf.AdminBackend_Storage{}
|
||||
storage = &config.Storage{}
|
||||
}
|
||||
switch provider {
|
||||
case "qiniu":
|
||||
if storage.Qiniu == nil {
|
||||
storage.Qiniu = &conf.AdminBackend_Qiniu{}
|
||||
storage.Qiniu = &config.Qiniu{}
|
||||
}
|
||||
return storage.Qiniu
|
||||
case "aliyun-oss":
|
||||
return ensureObjectStore(&storage.AliyunOss)
|
||||
return ensureObjectStore(&storage.AliyunOSS)
|
||||
case "huawei-obs":
|
||||
return ensureObjectStore(&storage.HuaweiObs)
|
||||
return ensureObjectStore(&storage.HuaweiOBS)
|
||||
case "tencent-cos":
|
||||
return ensureObjectStore(&storage.TencentCos)
|
||||
return ensureObjectStore(&storage.TencentCOS)
|
||||
case "aws-s3":
|
||||
return ensureObjectStore(&storage.AwsS3)
|
||||
return ensureObjectStore(&storage.AWSS3)
|
||||
case "cloudflare-r2":
|
||||
return ensureObjectStore(&storage.CloudflareR2)
|
||||
case "minio":
|
||||
@@ -66,45 +64,45 @@ func storageProviderMessage(storage *conf.AdminBackend_Storage, provider string)
|
||||
}
|
||||
}
|
||||
|
||||
func ensureObjectStore(value **conf.AdminBackend_ObjectStore) proto.Message {
|
||||
func ensureObjectStore(value **config.ObjectStore) *config.ObjectStore {
|
||||
if *value == nil {
|
||||
*value = &conf.AdminBackend_ObjectStore{}
|
||||
*value = &config.ObjectStore{}
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func marshalStorageProvider(storage *conf.AdminBackend_Storage, provider string) (string, error) {
|
||||
message := storageProviderMessage(storage, provider)
|
||||
if message == nil {
|
||||
func marshalStorageProvider(storage *config.Storage, provider string) (string, error) {
|
||||
value := storageProviderValue(storage, provider)
|
||||
if value == nil {
|
||||
return "{}", nil
|
||||
}
|
||||
raw, err := protojson.MarshalOptions{UseProtoNames: true, EmitDefaultValues: true}.Marshal(message)
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
func unmarshalStorageProvider(storage *conf.AdminBackend_Storage, provider, value string) error {
|
||||
func unmarshalStorageProvider(storage *config.Storage, provider, value string) error {
|
||||
if provider == "local" || strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
message := storageProviderMessage(storage, provider)
|
||||
if message == nil {
|
||||
target := storageProviderValue(storage, provider)
|
||||
if target == nil {
|
||||
return nil
|
||||
}
|
||||
if !json.Valid([]byte(value)) {
|
||||
return fmt.Errorf("invalid %s integration configuration", provider)
|
||||
}
|
||||
if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal([]byte(value), message); err != nil {
|
||||
if err := json.Unmarshal([]byte(value), target); err != nil {
|
||||
return fmt.Errorf("decode %s integration configuration: %w", provider, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveStorageIntegrationConfig(db *gorm.DB, storage *conf.AdminBackend_Storage) error {
|
||||
func saveStorageIntegrationConfig(db *gorm.DB, storage *config.Storage) error {
|
||||
if storage == nil {
|
||||
storage = &conf.AdminBackend_Storage{}
|
||||
storage = &config.Storage{}
|
||||
}
|
||||
active := normalizeStorageType(storage.Type)
|
||||
known := false
|
||||
@@ -145,7 +143,7 @@ func saveStorageIntegrationConfig(db *gorm.DB, storage *conf.AdminBackend_Storag
|
||||
})
|
||||
}
|
||||
|
||||
func loadStorageIntegrationConfig(db *gorm.DB) (*conf.AdminBackend_Storage, bool, error) {
|
||||
func loadStorageIntegrationConfig(db *gorm.DB) (*config.Storage, bool, error) {
|
||||
var rows []dataintegration.ConfigPO
|
||||
err := db.Session(&gorm.Session{NewDB: true}).
|
||||
Where("kind = ?", integrationKindStorage).
|
||||
@@ -158,7 +156,7 @@ func loadStorageIntegrationConfig(db *gorm.DB) (*conf.AdminBackend_Storage, bool
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
storage := &conf.AdminBackend_Storage{Type: "local"}
|
||||
storage := &config.Storage{Type: "local"}
|
||||
for _, row := range rows {
|
||||
if err = unmarshalStorageProvider(storage, row.Provider, row.Config); err != nil {
|
||||
return nil, false, err
|
||||
@@ -173,13 +171,13 @@ func loadStorageIntegrationConfig(db *gorm.DB) (*conf.AdminBackend_Storage, bool
|
||||
// resolveStorageIntegrationConfig upgrades a legacy YAML configuration only
|
||||
// when the database has no storage rows yet. From then on the database is the
|
||||
// sole source of truth.
|
||||
func resolveStorageIntegrationConfig(db *gorm.DB, legacy *conf.AdminBackend_Storage) (*conf.AdminBackend_Storage, error) {
|
||||
func resolveStorageIntegrationConfig(db *gorm.DB, legacy *config.Storage) (*config.Storage, error) {
|
||||
clean := db.Session(&gorm.Session{NewDB: true})
|
||||
if !clean.Migrator().HasTable(&dataintegration.ConfigPO{}) {
|
||||
if legacy == nil {
|
||||
return &conf.AdminBackend_Storage{Type: "local"}, nil
|
||||
return &config.Storage{Type: "local"}, nil
|
||||
}
|
||||
return proto.Clone(legacy).(*conf.AdminBackend_Storage), nil
|
||||
return config.CloneStorage(legacy), nil
|
||||
}
|
||||
storage, found, err := loadStorageIntegrationConfig(clean)
|
||||
if err != nil {
|
||||
@@ -189,7 +187,7 @@ func resolveStorageIntegrationConfig(db *gorm.DB, legacy *conf.AdminBackend_Stor
|
||||
return storage, nil
|
||||
}
|
||||
if legacy == nil {
|
||||
legacy = &conf.AdminBackend_Storage{Type: "local"}
|
||||
legacy = &config.Storage{Type: "local"}
|
||||
}
|
||||
if err = saveStorageIntegrationConfig(clean, legacy); err != nil {
|
||||
return nil, err
|
||||
@@ -198,7 +196,7 @@ func resolveStorageIntegrationConfig(db *gorm.DB, legacy *conf.AdminBackend_Stor
|
||||
return storage, err
|
||||
}
|
||||
|
||||
func (d *Data) persistStorageIntegrationConfig(ctx context.Context, storage *conf.AdminBackend_Storage) error {
|
||||
func (d *Data) persistStorageIntegrationConfig(ctx context.Context, storage *config.Storage) error {
|
||||
if !d.databaseReady.Load() {
|
||||
return errors.New("database is not initialized")
|
||||
}
|
||||
@@ -209,15 +207,15 @@ func (d *Data) persistStorageIntegrationConfig(ctx context.Context, storage *con
|
||||
return saveStorageIntegrationConfig(db, storage)
|
||||
}
|
||||
|
||||
func defaultEmailIntegrationConfig() *conf.AdminBackend_Email {
|
||||
return &conf.AdminBackend_Email{Port: 465, IsSsl: true}
|
||||
func defaultEmailIntegrationConfig() *config.Email {
|
||||
return &config.Email{Port: 465, IsSSL: true}
|
||||
}
|
||||
|
||||
func saveEmailIntegrationConfig(db *gorm.DB, email *conf.AdminBackend_Email) error {
|
||||
func saveEmailIntegrationConfig(db *gorm.DB, email *config.Email) error {
|
||||
if email == nil {
|
||||
email = defaultEmailIntegrationConfig()
|
||||
}
|
||||
raw, err := protojson.MarshalOptions{UseProtoNames: true, EmitDefaultValues: true}.Marshal(email)
|
||||
raw, err := json.Marshal(email)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode smtp integration configuration: %w", err)
|
||||
}
|
||||
@@ -237,7 +235,7 @@ func saveEmailIntegrationConfig(db *gorm.DB, email *conf.AdminBackend_Email) err
|
||||
}
|
||||
}
|
||||
|
||||
func loadEmailIntegrationConfig(db *gorm.DB) (*conf.AdminBackend_Email, bool, error) {
|
||||
func loadEmailIntegrationConfig(db *gorm.DB) (*config.Email, bool, error) {
|
||||
var row dataintegration.ConfigPO
|
||||
err := db.Session(&gorm.Session{NewDB: true}).
|
||||
Where("kind = ? AND provider = ?", integrationKindEmail, "smtp").
|
||||
@@ -252,19 +250,19 @@ func loadEmailIntegrationConfig(db *gorm.DB) (*conf.AdminBackend_Email, bool, er
|
||||
return nil, false, errors.New("invalid smtp integration configuration")
|
||||
}
|
||||
email := defaultEmailIntegrationConfig()
|
||||
if err = (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal([]byte(row.Config), email); err != nil {
|
||||
if err = json.Unmarshal([]byte(row.Config), email); err != nil {
|
||||
return nil, false, fmt.Errorf("decode smtp integration configuration: %w", err)
|
||||
}
|
||||
return email, true, nil
|
||||
}
|
||||
|
||||
func resolveEmailIntegrationConfig(db *gorm.DB, legacy *conf.AdminBackend_Email) (*conf.AdminBackend_Email, error) {
|
||||
func resolveEmailIntegrationConfig(db *gorm.DB, legacy *config.Email) (*config.Email, error) {
|
||||
clean := db.Session(&gorm.Session{NewDB: true})
|
||||
if !clean.Migrator().HasTable(&dataintegration.ConfigPO{}) {
|
||||
if legacy == nil {
|
||||
return defaultEmailIntegrationConfig(), nil
|
||||
}
|
||||
return proto.Clone(legacy).(*conf.AdminBackend_Email), nil
|
||||
return config.CloneEmail(legacy), nil
|
||||
}
|
||||
email, found, err := loadEmailIntegrationConfig(clean)
|
||||
if err != nil {
|
||||
@@ -283,7 +281,7 @@ func resolveEmailIntegrationConfig(db *gorm.DB, legacy *conf.AdminBackend_Email)
|
||||
return email, err
|
||||
}
|
||||
|
||||
func (d *Data) persistEmailIntegrationConfig(ctx context.Context, email *conf.AdminBackend_Email) error {
|
||||
func (d *Data) persistEmailIntegrationConfig(ctx context.Context, email *config.Email) error {
|
||||
if !d.databaseReady.Load() {
|
||||
return errors.New("database is not initialized")
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"testing"
|
||||
|
||||
integrationbiz "kra/internal/biz/integration"
|
||||
"kra/internal/conf"
|
||||
"kra/internal/config"
|
||||
dataintegration "kra/internal/data/integration"
|
||||
"kra/internal/integration/storage"
|
||||
|
||||
@@ -54,21 +54,21 @@ func TestMigrateAllCreatesIntegrationConfigTable(t *testing.T) {
|
||||
|
||||
func TestStorageIntegrationConfigRoundTrip(t *testing.T) {
|
||||
db := openIntegrationConfigTestDB(t)
|
||||
storage := &conf.AdminBackend_Storage{
|
||||
storage := &config.Storage{
|
||||
Type: "aliyun-oss",
|
||||
Qiniu: &conf.AdminBackend_Qiniu{
|
||||
Qiniu: &config.Qiniu{
|
||||
Zone: "ZoneHuadong", Bucket: "qiniu-bucket", AccessKey: "qiniu-key", SecretKey: "qiniu-secret",
|
||||
},
|
||||
AliyunOss: &conf.AdminBackend_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",
|
||||
},
|
||||
Minio: &conf.AdminBackend_ObjectStore{Endpoint: "127.0.0.1:9000", Bucket: "local", ForcePathStyle: true},
|
||||
Minio: &config.ObjectStore{Endpoint: "127.0.0.1:9000", Bucket: "local", ForcePathStyle: true},
|
||||
}
|
||||
if err := saveStorageIntegrationConfig(db, storage); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
email := &conf.AdminBackend_Email{
|
||||
email := &config.Email{
|
||||
To: "ops@example.com", From: "mailer@example.com", Host: "smtp.example.com",
|
||||
Secret: "smtp-secret", Nickname: "Kra", Port: 465, IsSsl: true,
|
||||
}
|
||||
@@ -89,8 +89,8 @@ func TestStorageIntegrationConfigRoundTrip(t *testing.T) {
|
||||
if loaded.Type != "aliyun-oss" {
|
||||
t.Fatalf("storage type = %q, want aliyun-oss", loaded.Type)
|
||||
}
|
||||
if loaded.AliyunOss == nil || loaded.AliyunOss.SecretKey != "aliyun-secret" || loaded.AliyunOss.PathPrefix != "uploads" {
|
||||
t.Fatalf("aliyun configuration = %#v", loaded.AliyunOss)
|
||||
if loaded.AliyunOSS == nil || loaded.AliyunOSS.SecretKey != "aliyun-secret" || loaded.AliyunOSS.PathPrefix != "uploads" {
|
||||
t.Fatalf("aliyun configuration = %#v", loaded.AliyunOSS)
|
||||
}
|
||||
if loaded.Qiniu == nil || loaded.Qiniu.SecretKey != "qiniu-secret" {
|
||||
t.Fatalf("qiniu configuration = %#v", loaded.Qiniu)
|
||||
@@ -127,9 +127,9 @@ func TestStorageIntegrationConfigRoundTrip(t *testing.T) {
|
||||
|
||||
func TestResolveStorageIntegrationConfigMigratesLegacyOnlyOnce(t *testing.T) {
|
||||
db := openIntegrationConfigTestDB(t)
|
||||
legacy := &conf.AdminBackend_Storage{
|
||||
legacy := &config.Storage{
|
||||
Type: "qiniu",
|
||||
Qiniu: &conf.AdminBackend_Qiniu{Bucket: "legacy", SecretKey: "legacy-secret"},
|
||||
Qiniu: &config.Qiniu{Bucket: "legacy", SecretKey: "legacy-secret"},
|
||||
}
|
||||
loaded, err := resolveStorageIntegrationConfig(db, legacy)
|
||||
if err != nil {
|
||||
@@ -139,9 +139,9 @@ func TestResolveStorageIntegrationConfigMigratesLegacyOnlyOnce(t *testing.T) {
|
||||
t.Fatalf("migrated storage = %#v", loaded)
|
||||
}
|
||||
|
||||
other := &conf.AdminBackend_Storage{
|
||||
other := &config.Storage{
|
||||
Type: "minio",
|
||||
Minio: &conf.AdminBackend_ObjectStore{Bucket: "must-not-replace-database"},
|
||||
Minio: &config.ObjectStore{Bucket: "must-not-replace-database"},
|
||||
}
|
||||
loaded, err = resolveStorageIntegrationConfig(db, other)
|
||||
if err != nil {
|
||||
@@ -153,23 +153,23 @@ func TestResolveStorageIntegrationConfigMigratesLegacyOnlyOnce(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMaskStorageSecretsLeavesUnconfiguredProvidersEmpty(t *testing.T) {
|
||||
storage := &conf.AdminBackend_Storage{
|
||||
Qiniu: &conf.AdminBackend_Qiniu{},
|
||||
AliyunOss: &conf.AdminBackend_ObjectStore{SecretKey: "configured-secret"},
|
||||
Minio: &conf.AdminBackend_ObjectStore{},
|
||||
storage := &config.Storage{
|
||||
Qiniu: &config.Qiniu{},
|
||||
AliyunOss: &config.ObjectStore{SecretKey: "configured-secret"},
|
||||
Minio: &config.ObjectStore{},
|
||||
}
|
||||
maskStorageSecrets(storage)
|
||||
if storage.Qiniu.SecretKey != "" || storage.Minio.SecretKey != "" {
|
||||
t.Fatalf("empty provider secrets were masked: %#v", storage)
|
||||
}
|
||||
if storage.AliyunOss.SecretKey != "******" {
|
||||
t.Fatalf("configured secret was not masked: %q", storage.AliyunOss.SecretKey)
|
||||
if storage.AliyunOSS.SecretKey != "******" {
|
||||
t.Fatalf("configured secret was not masked: %q", storage.AliyunOSS.SecretKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEmailIntegrationConfigMigratesLegacyOnlyOnce(t *testing.T) {
|
||||
db := openIntegrationConfigTestDB(t)
|
||||
legacy := &conf.AdminBackend_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)
|
||||
@@ -177,7 +177,7 @@ func TestResolveEmailIntegrationConfigMigratesLegacyOnlyOnce(t *testing.T) {
|
||||
if loaded.Host != legacy.Host || loaded.Secret != legacy.Secret {
|
||||
t.Fatalf("migrated email = %#v", loaded)
|
||||
}
|
||||
loaded, err = resolveEmailIntegrationConfig(db, &conf.AdminBackend_Email{Host: "must-not-replace.example.com"})
|
||||
loaded, err = resolveEmailIntegrationConfig(db, &config.Email{Host: "must-not-replace.example.com"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -193,16 +193,16 @@ func TestPersistConfigValuesRemovesStorageFromYAML(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := &Data{}
|
||||
admin := &conf.AdminBackend{
|
||||
admin := &config.Admin{
|
||||
ConfigPath: path,
|
||||
RouterPrefix: "/api",
|
||||
Storage: &conf.AdminBackend_Storage{
|
||||
Storage: &config.Storage{
|
||||
Type: "qiniu",
|
||||
Qiniu: &conf.AdminBackend_Qiniu{SecretKey: "database-only-secret"},
|
||||
Qiniu: &config.Qiniu{SecretKey: "database-only-secret"},
|
||||
},
|
||||
Email: &conf.AdminBackend_Email{Host: "smtp.database.example.com", Secret: "database-only-email-secret"},
|
||||
Email: &config.Email{Host: "smtp.database.example.com", Secret: "database-only-email-secret"},
|
||||
}
|
||||
if err := d.persistConfigValues(&conf.Data{}, admin); err != nil {
|
||||
if err := d.persistConfigValues(&config.Data{}, admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -237,10 +237,10 @@ func TestPersistRuntimeConfigReplacesActiveStorage(t *testing.T) {
|
||||
}
|
||||
oldRoot := filepath.Join(t.TempDir(), "old")
|
||||
newRoot := filepath.Join(t.TempDir(), "new")
|
||||
currentAdmin := &conf.AdminBackend{
|
||||
currentAdmin := &config.Admin{
|
||||
ConfigPath: configPath,
|
||||
Local: &conf.AdminBackend_Local{StorePath: oldRoot, PathPrefix: "old-files"},
|
||||
Storage: &conf.AdminBackend_Storage{Type: "local"},
|
||||
Local: &config.Local{StorePath: oldRoot, PathPrefix: "old-files"},
|
||||
Storage: &config.Storage{Type: "local"},
|
||||
}
|
||||
currentStorage, err := storage.NewReloadable(currentAdmin)
|
||||
if err != nil {
|
||||
@@ -249,19 +249,19 @@ func TestPersistRuntimeConfigReplacesActiveStorage(t *testing.T) {
|
||||
reloadableDB := &reloadableDB{}
|
||||
reloadableDB.current.Store(db)
|
||||
d := &Data{
|
||||
runtime: conf.NewRuntime(&conf.Data{}, currentAdmin),
|
||||
runtime: config.NewStore(&config.Config{Data: &config.Data{}, Admin: currentAdmin}),
|
||||
gormDB: reloadableDB,
|
||||
storage: currentStorage,
|
||||
}
|
||||
d.databaseReady.Store(true)
|
||||
|
||||
nextAdmin := cloneAdminConfig(currentAdmin)
|
||||
nextAdmin.Local = &conf.AdminBackend_Local{StorePath: newRoot, PathPrefix: "new-files"}
|
||||
nextAdmin.Email = &conf.AdminBackend_Email{
|
||||
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,
|
||||
}
|
||||
dataRaw, err := protojson.Marshal(&conf.Data{})
|
||||
dataRaw, err := protojson.Marshal(&config.Data{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -6,13 +6,13 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"kra/internal/conf"
|
||||
"kra/internal/config"
|
||||
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
)
|
||||
|
||||
func mongoURI(config *conf.Data_Mongo) string {
|
||||
func mongoURI(config *config.Mongo) string {
|
||||
hosts := make([]string, 0, len(config.Hosts))
|
||||
for _, host := range config.Hosts {
|
||||
if host != nil && host.Host != "" && host.Port != "" {
|
||||
@@ -26,7 +26,7 @@ func mongoURI(config *conf.Data_Mongo) string {
|
||||
return uri
|
||||
}
|
||||
|
||||
func openMongo(config *conf.Data_Mongo, enabled bool) (*mongo.Client, error) {
|
||||
func openMongo(config *config.Mongo, enabled bool) (*mongo.Client, error) {
|
||||
if !enabled {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"kra/internal/conf"
|
||||
"kra/internal/config"
|
||||
)
|
||||
|
||||
func newPolicyTestData(t *testing.T) *Data {
|
||||
@@ -20,9 +20,11 @@ func newPolicyTestData(t *testing.T) *Data {
|
||||
}
|
||||
data := &Data{
|
||||
gormDB: newReloadableDB(db, nil),
|
||||
runtime: conf.NewRuntime(
|
||||
&conf.Data{Database: &conf.Data_Database{Driver: "sqlite"}},
|
||||
&conf.AdminBackend{System: &conf.AdminBackend_System{UseStrictAuth: true}},
|
||||
runtime: config.NewStore(
|
||||
&config.Config{
|
||||
Data: &config.Data{Database: &config.Database{Driver: "sqlite"}},
|
||||
Admin: &config.Admin{System: &config.System{UseStrictAuth: true}},
|
||||
},
|
||||
),
|
||||
}
|
||||
t.Cleanup(func() { data.gormDB.close() })
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"kra/internal/biz/system"
|
||||
"testing"
|
||||
|
||||
"kra/internal/conf"
|
||||
"kra/internal/config"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -15,7 +15,7 @@ func authorityUintPointer(value uint) *uint { return &value }
|
||||
|
||||
func enableStrictAuthorityTestMode(data *Data) {
|
||||
currentData, _ := data.runtime.Values()
|
||||
data.runtime.Replace(currentData, &conf.AdminBackend{System: &conf.AdminBackend_System{UseStrictAuth: true}})
|
||||
data.runtime.Replace(&config.Config{Data: currentData, Admin: &config.Admin{System: &config.System{UseStrictAuth: true}}})
|
||||
}
|
||||
|
||||
func TestCopyAuthorityStrictPolicyValidationMatchesAdministrationContract(t *testing.T) {
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"kra/internal/conf"
|
||||
"kra/internal/config"
|
||||
)
|
||||
|
||||
func TestLogViewerReadsNestedCategoryFiles(t *testing.T) {
|
||||
@@ -22,7 +22,7 @@ func TestLogViewerReadsNestedCategoryFiles(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
runtime := conf.NewRuntime(nil, &conf.AdminBackend{Zap: &conf.AdminBackend_Zap{Director: root}})
|
||||
runtime := config.NewStore(&config.Config{Admin: &config.Admin{Zap: &config.Zap{Director: root}}})
|
||||
repo := &logFileRepo{data: &Data{runtime: runtime}}
|
||||
files, err := repo.LogFiles(context.Background(), date)
|
||||
if err != nil {
|
||||
@@ -41,7 +41,7 @@ func TestLogViewerReadsNestedCategoryFiles(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLogViewerRejectsPathTraversal(t *testing.T) {
|
||||
runtime := conf.NewRuntime(nil, &conf.AdminBackend{Zap: &conf.AdminBackend_Zap{Director: t.TempDir()}})
|
||||
runtime := config.NewStore(&config.Config{Admin: &config.Admin{Zap: &config.Zap{Director: t.TempDir()}}})
|
||||
repo := &logFileRepo{data: &Data{runtime: runtime}}
|
||||
_, err := repo.LogContent(context.Background(), "2026-08-16", "../application.log", nil)
|
||||
if !errors.Is(err, system.ErrInvalidLogPath) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"kra/internal/conf"
|
||||
"kra/internal/config"
|
||||
)
|
||||
|
||||
func newMenuTestData(t *testing.T) *Data {
|
||||
@@ -20,7 +20,7 @@ func newMenuTestData(t *testing.T) *Data {
|
||||
}
|
||||
data := &Data{
|
||||
gormDB: newReloadableDB(db, nil),
|
||||
runtime: conf.NewRuntime(&conf.Data{Database: &conf.Data_Database{Driver: "sqlite"}}, &conf.AdminBackend{}),
|
||||
runtime: config.NewStore(&config.Config{Data: &config.Data{Database: &config.Database{Driver: "sqlite"}}, Admin: &config.Admin{}}),
|
||||
}
|
||||
t.Cleanup(func() { data.gormDB.close() })
|
||||
return data
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"kra/internal/biz/system"
|
||||
"testing"
|
||||
|
||||
"kra/internal/conf"
|
||||
"kra/internal/config"
|
||||
)
|
||||
|
||||
func TestPermissionSelectedButtonsEmptyShape(t *testing.T) {
|
||||
@@ -16,7 +16,7 @@ func TestPermissionSelectedButtonsEmptyShape(t *testing.T) {
|
||||
if err := db.AutoMigrate(&authorityButtonPO{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := &Data{gormDB: newReloadableDB(db, nil), runtime: conf.NewRuntime(&conf.Data{Database: &conf.Data_Database{Driver: "sqlite"}}, &conf.AdminBackend{})}
|
||||
data := &Data{gormDB: newReloadableDB(db, nil), runtime: config.NewStore(&config.Config{Data: &config.Data{Database: &config.Database{Driver: "sqlite"}}, Admin: &config.Admin{}})}
|
||||
t.Cleanup(func() { data.gormDB.close() })
|
||||
|
||||
ids, err := (&permissionRepo{data: data}).SelectedButtons(context.Background(), 1, 2)
|
||||
@@ -36,7 +36,7 @@ func TestPermissionCanRemoveButtonUsesRawQueryID(t *testing.T) {
|
||||
if err := db.AutoMigrate(&authorityButtonPO{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := &Data{gormDB: newReloadableDB(db, nil), runtime: conf.NewRuntime(&conf.Data{Database: &conf.Data_Database{Driver: "sqlite"}}, &conf.AdminBackend{})}
|
||||
data := &Data{gormDB: newReloadableDB(db, nil), runtime: config.NewStore(&config.Config{Data: &config.Data{Database: &config.Database{Driver: "sqlite"}}, Admin: &config.Admin{}})}
|
||||
t.Cleanup(func() { data.gormDB.close() })
|
||||
repo := &permissionRepo{data: data}
|
||||
if err := data.gormDB.WithContext(context.Background()).Create(&authorityButtonPO{AuthorityID: 888, MenuID: 10, ButtonID: 31}).Error; err != nil {
|
||||
@@ -67,7 +67,7 @@ func TestPermissionSetSelectedButtonsPreservesDuplicates(t *testing.T) {
|
||||
if err := db.AutoMigrate(&authorityButtonPO{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := &Data{gormDB: newReloadableDB(db, nil), runtime: conf.NewRuntime(&conf.Data{Database: &conf.Data_Database{Driver: "sqlite"}}, &conf.AdminBackend{})}
|
||||
data := &Data{gormDB: newReloadableDB(db, nil), runtime: config.NewStore(&config.Config{Data: &config.Data{Database: &config.Database{Driver: "sqlite"}}, Admin: &config.Admin{}})}
|
||||
t.Cleanup(func() { data.gormDB.close() })
|
||||
repo := &permissionRepo{data: data}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"kra/internal/conf"
|
||||
"kra/internal/config"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -12,5 +12,5 @@ type Provider interface {
|
||||
DB() *gorm.DB
|
||||
Database(name string) (*gorm.DB, error)
|
||||
DatabaseReady() bool
|
||||
Runtime() *conf.Runtime
|
||||
Runtime() *config.Store
|
||||
}
|
||||
|
||||
@@ -7,15 +7,15 @@ import (
|
||||
"kra/internal/biz/system"
|
||||
"time"
|
||||
|
||||
"kra/internal/conf"
|
||||
"kra/internal/config"
|
||||
"kra/internal/security"
|
||||
|
||||
jwt "github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type runtimeSettings struct{ runtime *conf.Runtime }
|
||||
type runtimeSettings struct{ runtime *config.Store }
|
||||
|
||||
func NewRuntimeSettings(runtime *conf.Runtime) system.RuntimeSettings {
|
||||
func NewRuntimeSettings(runtime *config.Store) system.RuntimeSettings {
|
||||
return &runtimeSettings{runtime: runtime}
|
||||
}
|
||||
|
||||
@@ -30,18 +30,18 @@ func (s *runtimeSettings) RouterPrefix() string {
|
||||
func (s *runtimeSettings) JWTSettings() system.JWTSettings {
|
||||
value := system.JWTSettings{Issuer: "kra", Expires: 7 * 24 * time.Hour, Buffer: 24 * time.Hour}
|
||||
config := s.runtime.Admin()
|
||||
if config == nil || config.Jwt == nil {
|
||||
if config == nil || config.JWT == nil {
|
||||
return value
|
||||
}
|
||||
value.SigningKey = config.Jwt.SigningKey
|
||||
if config.Jwt.Issuer != "" {
|
||||
value.Issuer = config.Jwt.Issuer
|
||||
value.SigningKey = config.JWT.SigningKey
|
||||
if config.JWT.Issuer != "" {
|
||||
value.Issuer = config.JWT.Issuer
|
||||
}
|
||||
if config.Jwt.ExpiresTime != nil {
|
||||
value.Expires = config.Jwt.ExpiresTime.AsDuration()
|
||||
if config.JWT.ExpiresTime > 0 {
|
||||
value.Expires = config.JWT.ExpiresTime
|
||||
}
|
||||
if config.Jwt.BufferTime != nil {
|
||||
value.Buffer = config.Jwt.BufferTime.AsDuration()
|
||||
if config.JWT.BufferTime > 0 {
|
||||
value.Buffer = config.JWT.BufferTime
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -53,16 +53,16 @@ func (s *runtimeSettings) CaptchaSettings() system.CaptchaSettings {
|
||||
return value
|
||||
}
|
||||
if config.Captcha.KeyLong > 0 {
|
||||
value.KeyLong = int(config.Captcha.KeyLong)
|
||||
value.KeyLong = config.Captcha.KeyLong
|
||||
}
|
||||
if config.Captcha.ImgWidth > 0 {
|
||||
value.ImageWidth = int(config.Captcha.ImgWidth)
|
||||
value.ImageWidth = config.Captcha.ImgWidth
|
||||
}
|
||||
if config.Captcha.ImgHeight > 0 {
|
||||
value.ImageHeight = int(config.Captcha.ImgHeight)
|
||||
value.ImageHeight = config.Captcha.ImgHeight
|
||||
}
|
||||
if config.Captcha.StoreExpiration != nil {
|
||||
value.StoreExpiration = config.Captcha.StoreExpiration.AsDuration()
|
||||
if config.Captcha.StoreExpiration > 0 {
|
||||
value.StoreExpiration = config.Captcha.StoreExpiration
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -72,7 +72,7 @@ func (s *runtimeSettings) MediaSettings() system.MediaSettings {
|
||||
if config == nil || config.Media == nil {
|
||||
return system.MediaSettings{}
|
||||
}
|
||||
return system.MediaSettings{SessionTTL: int(config.Media.SessionTtl), MaxFileSize: config.Media.MaxFileSize, ChunkDir: config.Media.ChunkDir}
|
||||
return system.MediaSettings{SessionTTL: config.Media.SessionTTL, MaxFileSize: config.Media.MaxFileSize, ChunkDir: config.Media.ChunkDir}
|
||||
}
|
||||
|
||||
func (s *runtimeSettings) UseMultipoint() bool {
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"kra/internal/conf"
|
||||
"kra/internal/config"
|
||||
)
|
||||
|
||||
// Data is a small in-package test harness. Production repositories depend on
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
type Data struct {
|
||||
gormDB *reloadableDB
|
||||
redis *reloadableRedis
|
||||
runtime *conf.Runtime
|
||||
runtime *config.Store
|
||||
databaseReady atomic.Bool
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ func (d *Data) DB() *gorm.DB {
|
||||
|
||||
func (d *Data) Database(string) (*gorm.DB, error) { return d.DB(), nil }
|
||||
func (d *Data) DatabaseReady() bool { return d != nil && d.databaseReady.Load() }
|
||||
func (d *Data) Runtime() *conf.Runtime {
|
||||
func (d *Data) Runtime() *config.Store {
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"kra/internal/conf"
|
||||
"kra/internal/config"
|
||||
)
|
||||
|
||||
func newTransactionTestData(t *testing.T) *Data {
|
||||
@@ -19,7 +19,7 @@ func newTransactionTestData(t *testing.T) *Data {
|
||||
if err = migrateAll(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := &Data{gormDB: newReloadableDB(db, nil), redis: newReloadableRedis(nil), runtime: conf.NewRuntime(&conf.Data{Database: &conf.Data_Database{Driver: "sqlite"}}, &conf.AdminBackend{})}
|
||||
data := &Data{gormDB: newReloadableDB(db, nil), redis: newReloadableRedis(nil), runtime: config.NewStore(&config.Config{Data: &config.Data{Database: &config.Database{Driver: "sqlite"}}, Admin: &config.Admin{}})}
|
||||
t.Cleanup(func() { data.gormDB.close() })
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
package global
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"reflect"
|
||||
"sync"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"gorm.io/gorm"
|
||||
platformmq "kra/pkg/mq"
|
||||
platformws "kra/pkg/websocket"
|
||||
)
|
||||
|
||||
// FileStorage is the transport-neutral subset shared by every file storage
|
||||
// implementation. Domain operations that return business models remain in
|
||||
// the biz-owned FileStorage interface instead of making global depend on biz.
|
||||
type FileStorage interface {
|
||||
Open(context.Context, string) (io.ReadCloser, error)
|
||||
Delete(context.Context, string) error
|
||||
DeletePrefix(context.Context, string) error
|
||||
}
|
||||
|
||||
// Scheduler owns the lifecycle of the process-wide task scheduler. Scheduling
|
||||
// business operations remain behind the task domain interfaces.
|
||||
type Scheduler interface {
|
||||
Start(context.Context) error
|
||||
Stop(context.Context) error
|
||||
}
|
||||
|
||||
// Resources is an immutable snapshot from the registry's point of view.
|
||||
// Replacing a resource publishes a new snapshot; it does not close the old
|
||||
// resource because lifecycle ownership belongs to the composition root.
|
||||
type Resources struct {
|
||||
Logger *slog.Logger
|
||||
DB *gorm.DB
|
||||
NamedDBs map[string]*gorm.DB
|
||||
Redis redis.UniversalClient
|
||||
NamedRedis map[string]redis.UniversalClient
|
||||
Mongo *mongo.Client
|
||||
Storage FileStorage
|
||||
MQ platformmq.Registry
|
||||
WebSocket platformws.Hub
|
||||
Scheduler Scheduler
|
||||
}
|
||||
|
||||
// ResourceRegistry provides concurrency-safe access to process-wide runtime
|
||||
// resources. Prefer constructor injection for domain code; the registry is for
|
||||
// framework callbacks and integration points that cannot be wired directly.
|
||||
type ResourceRegistry struct {
|
||||
mu sync.RWMutex
|
||||
resources Resources
|
||||
}
|
||||
|
||||
func NewResourceRegistry() *ResourceRegistry {
|
||||
return &ResourceRegistry{resources: normalizeResources(Resources{})}
|
||||
}
|
||||
|
||||
func (r *ResourceRegistry) Snapshot() Resources {
|
||||
if r == nil {
|
||||
return normalizeResources(Resources{})
|
||||
}
|
||||
r.mu.RLock()
|
||||
resources := cloneResources(r.resources)
|
||||
r.mu.RUnlock()
|
||||
return resources
|
||||
}
|
||||
|
||||
func (r *ResourceRegistry) Replace(resources Resources) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.resources = normalizeResources(resources)
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *ResourceRegistry) Logger() *slog.Logger { return r.Snapshot().Logger }
|
||||
|
||||
func (r *ResourceRegistry) SetLogger(logger *slog.Logger) {
|
||||
r.update(func(resources *Resources) { resources.Logger = logger })
|
||||
}
|
||||
|
||||
func (r *ResourceRegistry) DB() *gorm.DB { return r.Snapshot().DB }
|
||||
|
||||
func (r *ResourceRegistry) SetDB(db *gorm.DB) {
|
||||
r.update(func(resources *Resources) { resources.DB = db })
|
||||
}
|
||||
|
||||
// NamedDB returns a configured secondary database by alias. An empty alias
|
||||
// resolves to the primary database.
|
||||
func (r *ResourceRegistry) NamedDB(name string) *gorm.DB {
|
||||
if name == "" {
|
||||
return r.DB()
|
||||
}
|
||||
return r.Snapshot().NamedDBs[name]
|
||||
}
|
||||
|
||||
// SetNamedDBs replaces the complete secondary-database alias set.
|
||||
func (r *ResourceRegistry) SetNamedDBs(databases map[string]*gorm.DB) {
|
||||
r.update(func(resources *Resources) { resources.NamedDBs = cloneDBMap(databases) })
|
||||
}
|
||||
|
||||
func (r *ResourceRegistry) Redis() redis.UniversalClient { return r.Snapshot().Redis }
|
||||
|
||||
func (r *ResourceRegistry) SetRedis(client redis.UniversalClient) {
|
||||
r.update(func(resources *Resources) { resources.Redis = client })
|
||||
}
|
||||
|
||||
// NamedRedis returns a configured secondary Redis client by alias. An empty
|
||||
// alias resolves to the primary Redis client.
|
||||
func (r *ResourceRegistry) NamedRedis(name string) redis.UniversalClient {
|
||||
if name == "" {
|
||||
return r.Redis()
|
||||
}
|
||||
return r.Snapshot().NamedRedis[name]
|
||||
}
|
||||
|
||||
// SetNamedRedis replaces the complete secondary-Redis alias set.
|
||||
func (r *ResourceRegistry) SetNamedRedis(clients map[string]redis.UniversalClient) {
|
||||
r.update(func(resources *Resources) { resources.NamedRedis = cloneRedisMap(clients) })
|
||||
}
|
||||
|
||||
func (r *ResourceRegistry) Mongo() *mongo.Client { return r.Snapshot().Mongo }
|
||||
|
||||
func (r *ResourceRegistry) SetMongo(client *mongo.Client) {
|
||||
r.update(func(resources *Resources) { resources.Mongo = client })
|
||||
}
|
||||
|
||||
func (r *ResourceRegistry) Storage() FileStorage { return r.Snapshot().Storage }
|
||||
|
||||
func (r *ResourceRegistry) SetStorage(storage FileStorage) {
|
||||
r.update(func(resources *Resources) { resources.Storage = storage })
|
||||
}
|
||||
|
||||
func (r *ResourceRegistry) MQ() platformmq.Registry { return r.Snapshot().MQ }
|
||||
|
||||
func (r *ResourceRegistry) SetMQ(registry platformmq.Registry) {
|
||||
r.update(func(resources *Resources) { resources.MQ = registry })
|
||||
}
|
||||
|
||||
func (r *ResourceRegistry) WebSocket() platformws.Hub { return r.Snapshot().WebSocket }
|
||||
|
||||
func (r *ResourceRegistry) SetWebSocket(hub platformws.Hub) {
|
||||
r.update(func(resources *Resources) { resources.WebSocket = hub })
|
||||
}
|
||||
|
||||
func (r *ResourceRegistry) Scheduler() Scheduler { return r.Snapshot().Scheduler }
|
||||
|
||||
func (r *ResourceRegistry) SetScheduler(scheduler Scheduler) {
|
||||
r.update(func(resources *Resources) { resources.Scheduler = scheduler })
|
||||
}
|
||||
|
||||
func (r *ResourceRegistry) update(change func(*Resources)) {
|
||||
if r == nil || change == nil {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
resources := cloneResources(r.resources)
|
||||
change(&resources)
|
||||
r.resources = normalizeResources(resources)
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func normalizeResources(resources Resources) Resources {
|
||||
if resources.Logger == nil {
|
||||
resources.Logger = slog.Default()
|
||||
}
|
||||
resources.Redis = normalizeInterface(resources.Redis)
|
||||
resources.Storage = normalizeInterface(resources.Storage)
|
||||
resources.MQ = normalizeInterface(resources.MQ)
|
||||
resources.WebSocket = normalizeInterface(resources.WebSocket)
|
||||
resources.Scheduler = normalizeInterface(resources.Scheduler)
|
||||
resources.NamedDBs = cloneDBMap(resources.NamedDBs)
|
||||
resources.NamedRedis = cloneRedisMap(resources.NamedRedis)
|
||||
return resources
|
||||
}
|
||||
|
||||
func cloneResources(resources Resources) Resources {
|
||||
resources.NamedDBs = cloneDBMap(resources.NamedDBs)
|
||||
resources.NamedRedis = cloneRedisMap(resources.NamedRedis)
|
||||
return resources
|
||||
}
|
||||
|
||||
func cloneDBMap(source map[string]*gorm.DB) map[string]*gorm.DB {
|
||||
if len(source) == 0 {
|
||||
return nil
|
||||
}
|
||||
copyOf := make(map[string]*gorm.DB, len(source))
|
||||
for name, db := range source {
|
||||
if name != "" && db != nil {
|
||||
copyOf[name] = db
|
||||
}
|
||||
}
|
||||
if len(copyOf) == 0 {
|
||||
return nil
|
||||
}
|
||||
return copyOf
|
||||
}
|
||||
|
||||
func cloneRedisMap(source map[string]redis.UniversalClient) map[string]redis.UniversalClient {
|
||||
if len(source) == 0 {
|
||||
return nil
|
||||
}
|
||||
copyOf := make(map[string]redis.UniversalClient, len(source))
|
||||
for name, client := range source {
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
client = normalizeInterface(client)
|
||||
if client != nil {
|
||||
copyOf[name] = client
|
||||
}
|
||||
}
|
||||
if len(copyOf) == 0 {
|
||||
return nil
|
||||
}
|
||||
return copyOf
|
||||
}
|
||||
|
||||
func normalizeInterface[T any](value T) T {
|
||||
reflected := reflect.ValueOf(value)
|
||||
if reflected.IsValid() {
|
||||
switch reflected.Kind() {
|
||||
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
|
||||
if !reflected.IsNil() {
|
||||
return value
|
||||
}
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
var zero T
|
||||
return zero
|
||||
}
|
||||
|
||||
var defaultResources = NewResourceRegistry()
|
||||
|
||||
func ResourceSnapshot() Resources { return defaultResources.Snapshot() }
|
||||
|
||||
func ReplaceResources(resources Resources) { defaultResources.Replace(resources) }
|
||||
|
||||
func Logger() *slog.Logger { return defaultResources.Logger() }
|
||||
|
||||
func SetLogger(logger *slog.Logger) { defaultResources.SetLogger(logger) }
|
||||
|
||||
func DB() *gorm.DB { return defaultResources.DB() }
|
||||
|
||||
func SetDB(db *gorm.DB) { defaultResources.SetDB(db) }
|
||||
|
||||
func NamedDB(name string) *gorm.DB { return defaultResources.NamedDB(name) }
|
||||
|
||||
func SetNamedDBs(databases map[string]*gorm.DB) { defaultResources.SetNamedDBs(databases) }
|
||||
|
||||
func Redis() redis.UniversalClient { return defaultResources.Redis() }
|
||||
|
||||
func SetRedis(client redis.UniversalClient) { defaultResources.SetRedis(client) }
|
||||
|
||||
func NamedRedis(name string) redis.UniversalClient { return defaultResources.NamedRedis(name) }
|
||||
|
||||
func SetNamedRedis(clients map[string]redis.UniversalClient) { defaultResources.SetNamedRedis(clients) }
|
||||
|
||||
func Mongo() *mongo.Client { return defaultResources.Mongo() }
|
||||
|
||||
func SetMongo(client *mongo.Client) { defaultResources.SetMongo(client) }
|
||||
|
||||
func Storage() FileStorage { return defaultResources.Storage() }
|
||||
|
||||
func SetStorage(storage FileStorage) { defaultResources.SetStorage(storage) }
|
||||
|
||||
func MQ() platformmq.Registry { return defaultResources.MQ() }
|
||||
|
||||
func SetMQ(registry platformmq.Registry) { defaultResources.SetMQ(registry) }
|
||||
|
||||
func WebSocket() platformws.Hub { return defaultResources.WebSocket() }
|
||||
|
||||
func SetWebSocket(hub platformws.Hub) { defaultResources.SetWebSocket(hub) }
|
||||
|
||||
func TaskScheduler() Scheduler { return defaultResources.Scheduler() }
|
||||
|
||||
func SetTaskScheduler(scheduler Scheduler) { defaultResources.SetScheduler(scheduler) }
|
||||
@@ -0,0 +1,153 @@
|
||||
package global
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"gorm.io/gorm"
|
||||
platformmq "kra/pkg/mq"
|
||||
platformws "kra/pkg/websocket"
|
||||
)
|
||||
|
||||
type storageStub struct{}
|
||||
|
||||
func (*storageStub) Open(context.Context, string) (io.ReadCloser, error) { return nil, nil }
|
||||
func (*storageStub) Delete(context.Context, string) error { return nil }
|
||||
func (*storageStub) DeletePrefix(context.Context, string) error { return nil }
|
||||
|
||||
type schedulerStub struct{}
|
||||
|
||||
func (*schedulerStub) Start(context.Context) error { return nil }
|
||||
func (*schedulerStub) Stop(context.Context) error { return nil }
|
||||
|
||||
type mqStub struct{}
|
||||
|
||||
func (*mqStub) Register(platformmq.SubscriptionSet) error { return nil }
|
||||
func (*mqStub) Unregister(string) error { return nil }
|
||||
func (*mqStub) Client(string) platformmq.Client { return nil }
|
||||
func (*mqStub) PublishTo(context.Context, string, string, []byte, byte, bool) error {
|
||||
return nil
|
||||
}
|
||||
func (*mqStub) SubscribeTo(context.Context, string, string, byte, platformmq.Handler) error {
|
||||
return nil
|
||||
}
|
||||
func (*mqStub) UnsubscribeFrom(context.Context, string, ...string) error { return nil }
|
||||
func (*mqStub) ConnectedTo(string) bool { return true }
|
||||
|
||||
type webSocketStub struct{}
|
||||
|
||||
func (*webSocketStub) Enabled() bool { return true }
|
||||
func (*webSocketStub) Broadcast([]byte) error { return nil }
|
||||
func (*webSocketStub) BroadcastBinary([]byte) error { return nil }
|
||||
func (*webSocketStub) Sessions() ([]*platformws.Session, error) { return nil, nil }
|
||||
func (*webSocketStub) Len() int { return 0 }
|
||||
func (*webSocketStub) Send(*platformws.Session, []byte) error { return nil }
|
||||
func (*webSocketStub) SendBinary(*platformws.Session, []byte) error { return nil }
|
||||
func (*webSocketStub) OnMessage(func(*platformws.Session, []byte)) {}
|
||||
func (*webSocketStub) OnBinaryMessage(func(*platformws.Session, []byte)) {}
|
||||
func (*webSocketStub) OnConnect(func(*platformws.Session)) {}
|
||||
func (*webSocketStub) OnDisconnect(func(*platformws.Session)) {}
|
||||
|
||||
func TestResourceRegistryDefaults(t *testing.T) {
|
||||
registry := NewResourceRegistry()
|
||||
if registry.Logger() == nil {
|
||||
t.Fatal("default logger is nil")
|
||||
}
|
||||
resources := registry.Snapshot()
|
||||
if resources.DB != nil || len(resources.NamedDBs) != 0 || resources.Redis != nil || len(resources.NamedRedis) != 0 || resources.Mongo != nil || resources.Storage != nil || resources.MQ != nil || resources.WebSocket != nil || resources.Scheduler != nil {
|
||||
t.Fatalf("unexpected initialized resource: %#v", resources)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceRegistryReplaceAndSetters(t *testing.T) {
|
||||
logger := slog.Default().With("test", true)
|
||||
db := &gorm.DB{}
|
||||
redisClient := redis.NewClient(&redis.Options{Addr: "127.0.0.1:6379"})
|
||||
t.Cleanup(func() { _ = redisClient.Close() })
|
||||
mongoClient := &mongo.Client{}
|
||||
storage := &storageStub{}
|
||||
mq := &mqStub{}
|
||||
webSocket := &webSocketStub{}
|
||||
scheduler := &schedulerStub{}
|
||||
|
||||
registry := NewResourceRegistry()
|
||||
registry.Replace(Resources{
|
||||
Logger: logger, DB: db, NamedDBs: map[string]*gorm.DB{"reporting": db},
|
||||
Redis: redisClient, NamedRedis: map[string]redis.UniversalClient{"cache": redisClient}, Mongo: mongoClient,
|
||||
Storage: storage, MQ: mq, WebSocket: webSocket, Scheduler: scheduler,
|
||||
})
|
||||
resources := registry.Snapshot()
|
||||
if resources.Logger != logger || resources.DB != db || resources.Redis != redisClient || resources.Mongo != mongoClient || resources.Storage != storage || resources.MQ != mq || resources.WebSocket != webSocket || resources.Scheduler != scheduler {
|
||||
t.Fatalf("replacement snapshot does not match: %#v", resources)
|
||||
}
|
||||
|
||||
nextDB := &gorm.DB{}
|
||||
registry.SetDB(nextDB)
|
||||
if registry.DB() != nextDB {
|
||||
t.Fatal("database setter did not publish the new database")
|
||||
}
|
||||
if registry.Redis() != redisClient || registry.Storage() != storage {
|
||||
t.Fatal("database setter changed unrelated resources")
|
||||
}
|
||||
if registry.NamedDB("reporting") != db || registry.NamedDB("") != nextDB {
|
||||
t.Fatal("named database lookup returned the wrong resource")
|
||||
}
|
||||
if registry.NamedRedis("cache") != redisClient || registry.NamedRedis("") != redisClient {
|
||||
t.Fatal("named redis lookup returned the wrong resource")
|
||||
}
|
||||
inputDBs := map[string]*gorm.DB{"analytics": db}
|
||||
registry.SetNamedDBs(inputDBs)
|
||||
inputDBs["mutated"] = nextDB
|
||||
if registry.NamedDB("mutated") != nil || registry.NamedDB("analytics") != db {
|
||||
t.Fatal("named database map was not copied")
|
||||
}
|
||||
inputRedis := map[string]redis.UniversalClient{"sessions": redisClient}
|
||||
registry.SetNamedRedis(inputRedis)
|
||||
inputRedis["mutated"] = redisClient
|
||||
if registry.NamedRedis("mutated") != nil || registry.NamedRedis("sessions") != redisClient {
|
||||
t.Fatal("named redis map was not copied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceRegistryNormalizesTypedNilInterfaces(t *testing.T) {
|
||||
registry := NewResourceRegistry()
|
||||
var redisClient *redis.Client
|
||||
var storage *storageStub
|
||||
var mq *mqStub
|
||||
var webSocket *webSocketStub
|
||||
var scheduler *schedulerStub
|
||||
registry.Replace(Resources{Redis: redisClient, Storage: storage, MQ: mq, WebSocket: webSocket, Scheduler: scheduler})
|
||||
resources := registry.Snapshot()
|
||||
if resources.Redis != nil || resources.Storage != nil || resources.MQ != nil || resources.WebSocket != nil || resources.Scheduler != nil {
|
||||
t.Fatalf("typed nil resource was retained: %#v", resources)
|
||||
}
|
||||
if resources.Logger == nil {
|
||||
t.Fatal("nil logger was not replaced with the default logger")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceRegistryConcurrentAccess(t *testing.T) {
|
||||
registry := NewResourceRegistry()
|
||||
const iterations = 1000
|
||||
var wait sync.WaitGroup
|
||||
for index := 0; index < 8; index++ {
|
||||
wait.Add(1)
|
||||
go func() {
|
||||
defer wait.Done()
|
||||
for step := 0; step < iterations; step++ {
|
||||
registry.SetDB(&gorm.DB{})
|
||||
_ = registry.Snapshot()
|
||||
_ = registry.Logger()
|
||||
}
|
||||
}()
|
||||
}
|
||||
wait.Wait()
|
||||
if registry.DB() == nil {
|
||||
t.Fatal("concurrent setters lost the database")
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,7 @@ package initialize
|
||||
import (
|
||||
"context"
|
||||
"kra/internal/biz/system"
|
||||
|
||||
"kra/internal/conf"
|
||||
"kra/internal/config"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -15,10 +14,7 @@ type Backend interface {
|
||||
IsInitialized(context.Context) (bool, error)
|
||||
InitializeDatabase(context.Context, *system.DatabaseConfig, func(context.Context, *gorm.DB) error) error
|
||||
PersistConfig(context.Context) error
|
||||
PersistAdminConfig(context.Context, []byte) error
|
||||
PersistRuntimeConfig(context.Context, []byte, []byte) error
|
||||
PersistRuntimeConfig(context.Context, *config.Config) error
|
||||
ReloadConfig(context.Context) error
|
||||
RuntimeValues() (*conf.Data, *conf.AdminBackend)
|
||||
RuntimeAdmin() *conf.AdminBackend
|
||||
RefreshDatabaseSources(*conf.Data) error
|
||||
Config() *config.Config
|
||||
}
|
||||
|
||||
@@ -1,559 +0,0 @@
|
||||
package initialize
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"kra/internal/conf"
|
||||
|
||||
"google.golang.org/protobuf/types/known/durationpb"
|
||||
)
|
||||
|
||||
func compatibleDatabase(value *conf.Data_Database) map[string]any {
|
||||
if value == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
path := value.Host
|
||||
if value.Driver == "sqlite" {
|
||||
path = value.Path
|
||||
}
|
||||
return map[string]any{
|
||||
"prefix": value.Prefix, "port": value.Port, "config": value.Config,
|
||||
"db-name": value.Name, "username": value.User, "password": value.Password,
|
||||
"path": path, "engine": value.Engine, "log-mode": value.LogMode,
|
||||
"max-idle-conns": value.MaxIdleConns, "max-open-conns": value.MaxOpenConns,
|
||||
"conn-max-lifetime": value.ConnMaxLifetime, "singular": value.Singular,
|
||||
}
|
||||
}
|
||||
|
||||
func compatibleRedis(value *conf.Data_Redis) map[string]any {
|
||||
if value == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return map[string]any{"name": value.Name, "addr": value.Addr, "password": value.Password, "db": value.Db, "useCluster": value.UseCluster, "clusterAddrs": value.ClusterAddrs}
|
||||
}
|
||||
|
||||
func compatibleObjectStore(value *conf.AdminBackend_ObjectStore) map[string]any {
|
||||
if value == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return map[string]any{
|
||||
"endpoint": value.Endpoint, "region": value.Region, "bucket": value.Bucket,
|
||||
"access-key": value.AccessKey, "secret-key": value.SecretKey, "base-url": value.BaseUrl,
|
||||
"path-prefix": value.PathPrefix, "use-ssl": value.UseSsl,
|
||||
"s3-force-path-style": value.ForcePathStyle, "account-id": value.AccountId,
|
||||
}
|
||||
}
|
||||
|
||||
func compatibleObjectStores(storage *conf.AdminBackend_Storage) map[string]map[string]any {
|
||||
if storage == nil {
|
||||
storage = &conf.AdminBackend_Storage{}
|
||||
}
|
||||
aliyun, huawei := storage.AliyunOss, storage.HuaweiObs
|
||||
tencent, aws := storage.TencentCos, storage.AwsS3
|
||||
cloudflare, minio := storage.CloudflareR2, storage.Minio
|
||||
if aliyun == nil {
|
||||
aliyun = &conf.AdminBackend_ObjectStore{}
|
||||
}
|
||||
if huawei == nil {
|
||||
huawei = &conf.AdminBackend_ObjectStore{}
|
||||
}
|
||||
if tencent == nil {
|
||||
tencent = &conf.AdminBackend_ObjectStore{}
|
||||
}
|
||||
if aws == nil {
|
||||
aws = &conf.AdminBackend_ObjectStore{}
|
||||
}
|
||||
if cloudflare == nil {
|
||||
cloudflare = &conf.AdminBackend_ObjectStore{}
|
||||
}
|
||||
if minio == nil {
|
||||
minio = &conf.AdminBackend_ObjectStore{}
|
||||
}
|
||||
return map[string]map[string]any{
|
||||
"aliyun-oss": {"endpoint": aliyun.Endpoint, "access-key-id": aliyun.AccessKey, "access-key-secret": aliyun.SecretKey, "bucket-name": aliyun.Bucket, "bucket-url": aliyun.BaseUrl, "base-path": aliyun.PathPrefix},
|
||||
"hua-wei-obs": {"path": huawei.PathPrefix, "bucket": huawei.Bucket, "endpoint": huawei.Endpoint, "access-key": huawei.AccessKey, "secret-key": huawei.SecretKey},
|
||||
"tencent-cos": {"bucket": tencent.Bucket, "region": tencent.Region, "secret-id": tencent.AccessKey, "secret-key": tencent.SecretKey, "base-url": tencent.BaseUrl, "path-prefix": tencent.PathPrefix},
|
||||
"aws-s3": {"bucket": aws.Bucket, "region": aws.Region, "endpoint": aws.Endpoint, "secret-id": aws.AccessKey, "secret-key": aws.SecretKey, "base-url": aws.BaseUrl, "path-prefix": aws.PathPrefix, "s3-force-path-style": aws.ForcePathStyle, "disable-ssl": !aws.UseSsl},
|
||||
"cloudflare-r2": {"bucket": cloudflare.Bucket, "base-url": cloudflare.BaseUrl, "path": cloudflare.PathPrefix, "account-id": cloudflare.AccountId, "access-key-id": cloudflare.AccessKey, "secret-access-key": cloudflare.SecretKey},
|
||||
"minio": {"endpoint": minio.Endpoint, "access-key-id": minio.AccessKey, "access-key-secret": minio.SecretKey, "bucket-name": minio.Bucket, "use-ssl": minio.UseSsl, "base-path": minio.PathPrefix, "bucket-url": minio.BaseUrl},
|
||||
}
|
||||
}
|
||||
|
||||
func compatibleConfiguration(data *conf.Data, admin *conf.AdminBackend) map[string]any {
|
||||
result := map[string]any{}
|
||||
if data == nil {
|
||||
data = &conf.Data{}
|
||||
}
|
||||
if admin == nil {
|
||||
admin = &conf.AdminBackend{}
|
||||
}
|
||||
jwt := admin.Jwt
|
||||
if jwt == nil {
|
||||
jwt = &conf.AdminBackend_JWT{}
|
||||
}
|
||||
result["jwt"] = map[string]any{"signing-key": jwt.SigningKey, "expires-time": durationString(jwt.ExpiresTime), "buffer-time": durationString(jwt.BufferTime), "issuer": jwt.Issuer}
|
||||
zap := admin.Zap
|
||||
if zap == nil {
|
||||
zap = &conf.AdminBackend_Zap{}
|
||||
}
|
||||
result["zap"] = map[string]any{
|
||||
"level": zap.Level, "prefix": zap.Prefix, "format": zap.Format, "director": zap.Director,
|
||||
"encode-level": zap.EncodeLevel, "stacktrace-key": zap.StacktraceKey, "show-line": zap.ShowLine,
|
||||
"log-in-console": zap.LogInConsole, "retention-day": zap.RetentionDay,
|
||||
"access-req-body": zap.AccessReqBody, "access-resp-data": zap.AccessRespData,
|
||||
"access-req-headers": zap.AccessReqHeaders, "access-log-max-bytes": zap.AccessLogMaxBytes,
|
||||
"file-only-modules": zap.FileOnlyModules,
|
||||
}
|
||||
result["redis"] = compatibleRedis(data.Redis)
|
||||
redisList := make([]map[string]any, 0, len(data.RedisList))
|
||||
for _, item := range data.RedisList {
|
||||
redisList = append(redisList, compatibleRedis(item))
|
||||
}
|
||||
result["redis-list"] = redisList
|
||||
mongo := data.Mongo
|
||||
if mongo == nil {
|
||||
mongo = &conf.Data_Mongo{}
|
||||
}
|
||||
hosts := make([]map[string]any, 0, len(mongo.Hosts))
|
||||
for _, host := range mongo.Hosts {
|
||||
if host != nil {
|
||||
hosts = append(hosts, map[string]any{"host": host.Host, "port": host.Port})
|
||||
}
|
||||
}
|
||||
result["mongo"] = map[string]any{"coll": mongo.Coll, "options": mongo.Options, "database": mongo.Database, "username": mongo.Username, "password": mongo.Password, "auth-source": mongo.AuthSource, "min-pool-size": mongo.MinPoolSize, "max-pool-size": mongo.MaxPoolSize, "socket-timeout-ms": mongo.SocketTimeoutMs, "connect-timeout-ms": mongo.ConnectTimeoutMs, "is-zap": mongo.IsZap, "hosts": hosts}
|
||||
email := admin.Email
|
||||
if email == nil {
|
||||
email = &conf.AdminBackend_Email{}
|
||||
}
|
||||
result["email"] = map[string]any{"to": email.To, "from": email.From, "host": email.Host, "secret": email.Secret, "nickname": email.Nickname, "port": email.Port, "is-ssl": email.IsSsl, "is-loginauth": email.IsLoginAuth}
|
||||
system := admin.System
|
||||
if system == nil {
|
||||
system = &conf.AdminBackend_System{}
|
||||
}
|
||||
storageType := "local"
|
||||
if admin.Storage != nil && admin.Storage.Type != "" {
|
||||
storageType = admin.Storage.Type
|
||||
}
|
||||
driver := ""
|
||||
if data.Database != nil {
|
||||
driver = data.Database.Driver
|
||||
}
|
||||
result["system"] = map[string]any{"db-type": driver, "oss-type": storageType, "router-prefix": admin.RouterPrefix, "addr": system.Addr, "iplimit-count": system.IplimitCount, "iplimit-time": system.IplimitTime, "use-multipoint": system.UseMultipoint, "use-redis": system.UseRedis, "use-mongo": system.UseMongo, "use-strict-auth": system.UseStrictAuth, "disable-auto-migrate": system.DisableAutoMigrate}
|
||||
for _, name := range []string{"mysql", "mssql", "pgsql", "oracle", "sqlite"} {
|
||||
result[name] = map[string]any{}
|
||||
}
|
||||
if driver != "" {
|
||||
result[driver] = compatibleDatabase(data.Database)
|
||||
}
|
||||
dbList := make([]map[string]any, 0, len(data.DatabaseList))
|
||||
for _, item := range data.DatabaseList {
|
||||
row := compatibleDatabase(item)
|
||||
if item != nil {
|
||||
row["type"], row["alias-name"], row["disable"] = item.Driver, item.AliasName, item.Disable
|
||||
}
|
||||
dbList = append(dbList, row)
|
||||
}
|
||||
result["db-list"] = dbList
|
||||
local := admin.Local
|
||||
if local == nil {
|
||||
local = &conf.AdminBackend_Local{}
|
||||
}
|
||||
result["local"] = map[string]any{"path": local.PathPrefix, "store-path": local.StorePath}
|
||||
media := admin.Media
|
||||
if media == nil {
|
||||
media = &conf.AdminBackend_Media{}
|
||||
}
|
||||
chunkDir := media.ChunkDir
|
||||
if chunkDir == "" {
|
||||
chunkDir = "uploads/chunks"
|
||||
}
|
||||
result["media"] = map[string]any{"session-ttl": media.SessionTtl, "max-file-size": media.MaxFileSize, "chunk-dir": chunkDir}
|
||||
if admin.Storage == nil {
|
||||
admin.Storage = &conf.AdminBackend_Storage{}
|
||||
}
|
||||
qiniu := admin.Storage.Qiniu
|
||||
if qiniu == nil {
|
||||
qiniu = &conf.AdminBackend_Qiniu{}
|
||||
}
|
||||
result["qiniu"] = map[string]any{"zone": qiniu.Zone, "bucket": qiniu.Bucket, "img-path": qiniu.BaseUrl, "access-key": qiniu.AccessKey, "secret-key": qiniu.SecretKey, "use-https": qiniu.UseHttps, "use-cdn-domains": qiniu.UseCdnDomains}
|
||||
for key, value := range compatibleObjectStores(admin.Storage) {
|
||||
result[key] = value
|
||||
}
|
||||
disks := make([]map[string]any, 0, len(admin.DiskList))
|
||||
for _, disk := range admin.DiskList {
|
||||
if disk != nil {
|
||||
disks = append(disks, map[string]any{"mount-point": disk.MountPoint})
|
||||
}
|
||||
}
|
||||
result["disk-list"] = disks
|
||||
cors := admin.Cors
|
||||
if cors == nil {
|
||||
cors = &conf.AdminBackend_CORS{}
|
||||
}
|
||||
whitelist := make([]map[string]any, 0, len(cors.Whitelist))
|
||||
for _, rule := range cors.Whitelist {
|
||||
if rule != nil {
|
||||
whitelist = append(whitelist, map[string]any{"allow-origin": rule.AllowOrigin, "allow-methods": rule.AllowMethods, "allow-headers": rule.AllowHeaders, "expose-headers": rule.ExposeHeaders, "allow-credentials": rule.AllowCredentials})
|
||||
}
|
||||
}
|
||||
result["cors"] = map[string]any{"mode": cors.Mode, "whitelist": whitelist}
|
||||
result["app"] = map[string]any{"node": admin.GetApp().GetNode(), "app-id": admin.GetApp().GetAppId(), "env": admin.GetApp().GetEnv()}
|
||||
return result
|
||||
}
|
||||
|
||||
func objectMap(value any) map[string]any {
|
||||
result, _ := value.(map[string]any)
|
||||
return result
|
||||
}
|
||||
|
||||
func stringValue(values map[string]any, key string) string {
|
||||
value, _ := values[key].(string)
|
||||
return value
|
||||
}
|
||||
|
||||
func boolValue(values map[string]any, key string, fallback bool) bool {
|
||||
value, ok := values[key].(bool)
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func int64Value(values map[string]any, key string, fallback int64) int64 {
|
||||
value, ok := values[key].(float64)
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
return int64(value)
|
||||
}
|
||||
|
||||
func stringSliceValue(values map[string]any, key string, fallback []string) []string {
|
||||
items, ok := values[key].([]any)
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
result := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if value, ok := item.(string); ok {
|
||||
result = append(result, value)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func applyCompatibleRedis(target *conf.Data_Redis, values map[string]any) {
|
||||
if target == nil || values == nil {
|
||||
return
|
||||
}
|
||||
target.Name, target.Addr = stringValue(values, "name"), stringValue(values, "addr")
|
||||
if password := stringValue(values, "password"); password != "" && password != "******" {
|
||||
target.Password = password
|
||||
}
|
||||
target.Db = int32(int64Value(values, "db", int64(target.Db)))
|
||||
target.UseCluster = boolValue(values, "useCluster", target.UseCluster)
|
||||
target.ClusterAddrs = stringSliceValue(values, "clusterAddrs", target.ClusterAddrs)
|
||||
}
|
||||
|
||||
func durationValue(raw string, fallback *durationpb.Duration) *durationpb.Duration {
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
value, err := parseCompatibleDuration(raw)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return durationpb.New(value)
|
||||
}
|
||||
|
||||
func parseCompatibleDuration(raw string) (time.Duration, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if value, err := time.ParseDuration(raw); err == nil {
|
||||
return value, nil
|
||||
}
|
||||
if index := strings.Index(raw, "d"); index >= 0 {
|
||||
if index == 0 {
|
||||
return 0, strconv.ErrSyntax
|
||||
}
|
||||
days, err := strconv.Atoi(raw[:index])
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
value := time.Duration(days) * 24 * time.Hour
|
||||
remainderText := strings.TrimSpace(raw[index+1:])
|
||||
if remainderText == "" {
|
||||
return value, nil
|
||||
}
|
||||
remainder, err := time.ParseDuration(remainderText)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return value + remainder, nil
|
||||
}
|
||||
nanoseconds, err := strconv.ParseInt(raw, 10, 64)
|
||||
return time.Duration(nanoseconds), err
|
||||
}
|
||||
|
||||
func applyCompatibleDatabase(target *conf.Data_Database, values map[string]any, driver string) {
|
||||
if target == nil || values == nil {
|
||||
return
|
||||
}
|
||||
target.Driver = driver
|
||||
target.Prefix, target.Port, target.Config = stringValue(values, "prefix"), stringValue(values, "port"), stringValue(values, "config")
|
||||
target.Name, target.User = stringValue(values, "db-name"), stringValue(values, "username")
|
||||
if password := stringValue(values, "password"); password != "" && password != "******" {
|
||||
target.Password = password
|
||||
}
|
||||
if driver == "sqlite" {
|
||||
target.Path = stringValue(values, "path")
|
||||
} else {
|
||||
target.Host = stringValue(values, "path")
|
||||
}
|
||||
target.Engine, target.LogMode = stringValue(values, "engine"), stringValue(values, "log-mode")
|
||||
target.MaxIdleConns = int32(int64Value(values, "max-idle-conns", int64(target.MaxIdleConns)))
|
||||
target.MaxOpenConns = int32(int64Value(values, "max-open-conns", int64(target.MaxOpenConns)))
|
||||
target.ConnMaxLifetime = int32(int64Value(values, "conn-max-lifetime", int64(target.ConnMaxLifetime)))
|
||||
target.Singular = boolValue(values, "singular", target.Singular)
|
||||
}
|
||||
|
||||
func applyCompatibleObjectStore(target *conf.AdminBackend_ObjectStore, values map[string]any) {
|
||||
if target == nil || values == nil {
|
||||
return
|
||||
}
|
||||
target.Endpoint, target.Region, target.Bucket = stringValue(values, "endpoint"), stringValue(values, "region"), stringValue(values, "bucket")
|
||||
if target.Bucket == "" {
|
||||
target.Bucket = stringValue(values, "bucket-name")
|
||||
}
|
||||
target.AccessKey = stringValue(values, "access-key")
|
||||
if target.AccessKey == "" {
|
||||
target.AccessKey = stringValue(values, "access-key-id")
|
||||
}
|
||||
if target.AccessKey == "" {
|
||||
target.AccessKey = stringValue(values, "secret-id")
|
||||
}
|
||||
secret := stringValue(values, "secret-key")
|
||||
if secret == "" {
|
||||
secret = stringValue(values, "access-key-secret")
|
||||
}
|
||||
if secret == "" {
|
||||
secret = stringValue(values, "secret-access-key")
|
||||
}
|
||||
if secret != "" && secret != "******" {
|
||||
target.SecretKey = secret
|
||||
}
|
||||
target.BaseUrl = stringValue(values, "base-url")
|
||||
if target.BaseUrl == "" {
|
||||
target.BaseUrl = stringValue(values, "bucket-url")
|
||||
}
|
||||
target.PathPrefix = stringValue(values, "path-prefix")
|
||||
if target.PathPrefix == "" {
|
||||
target.PathPrefix = stringValue(values, "base-path")
|
||||
}
|
||||
if target.PathPrefix == "" {
|
||||
target.PathPrefix = stringValue(values, "path")
|
||||
}
|
||||
target.UseSsl = boolValue(values, "use-ssl", !boolValue(values, "disable-ssl", !target.UseSsl))
|
||||
target.ForcePathStyle = boolValue(values, "s3-force-path-style", target.ForcePathStyle)
|
||||
target.AccountId = stringValue(values, "account-id")
|
||||
}
|
||||
|
||||
func applyCompatibleConfiguration(raw json.RawMessage, data *conf.Data, admin *conf.AdminBackend) error {
|
||||
var values map[string]any
|
||||
if err := json.Unmarshal(raw, &values); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, compatibleShape := values["system"]; !compatibleShape {
|
||||
return nil
|
||||
}
|
||||
if data.Database == nil {
|
||||
data.Database = &conf.Data_Database{}
|
||||
}
|
||||
if admin.System == nil {
|
||||
admin.System = &conf.AdminBackend_System{}
|
||||
}
|
||||
if admin.Storage == nil {
|
||||
admin.Storage = &conf.AdminBackend_Storage{}
|
||||
}
|
||||
system := objectMap(values["system"])
|
||||
driver := stringValue(system, "db-type")
|
||||
if driver == "" {
|
||||
driver = data.Database.Driver
|
||||
}
|
||||
applyCompatibleDatabase(data.Database, objectMap(values[driver]), driver)
|
||||
admin.Storage.Type = stringValue(system, "oss-type")
|
||||
admin.RouterPrefix = stringValue(system, "router-prefix")
|
||||
admin.System.UseMultipoint = boolValue(system, "use-multipoint", admin.System.UseMultipoint)
|
||||
admin.System.UseRedis = boolValue(system, "use-redis", admin.System.UseRedis)
|
||||
admin.System.UseMongo = boolValue(system, "use-mongo", admin.System.UseMongo)
|
||||
admin.System.UseStrictAuth = boolValue(system, "use-strict-auth", admin.System.UseStrictAuth)
|
||||
admin.System.DisableAutoMigrate = boolValue(system, "disable-auto-migrate", admin.System.DisableAutoMigrate)
|
||||
admin.System.Addr = int32(int64Value(system, "addr", int64(admin.System.Addr)))
|
||||
admin.System.IplimitCount = int32(int64Value(system, "iplimit-count", int64(admin.System.IplimitCount)))
|
||||
admin.System.IplimitTime = int32(int64Value(system, "iplimit-time", int64(admin.System.IplimitTime)))
|
||||
if admin.Jwt == nil {
|
||||
admin.Jwt = &conf.AdminBackend_JWT{}
|
||||
}
|
||||
jwt := objectMap(values["jwt"])
|
||||
if secret := stringValue(jwt, "signing-key"); secret != "" && secret != "******" {
|
||||
admin.Jwt.SigningKey = secret
|
||||
}
|
||||
admin.Jwt.ExpiresTime = durationValue(stringValue(jwt, "expires-time"), admin.Jwt.ExpiresTime)
|
||||
admin.Jwt.BufferTime = durationValue(stringValue(jwt, "buffer-time"), admin.Jwt.BufferTime)
|
||||
admin.Jwt.Issuer = stringValue(jwt, "issuer")
|
||||
if admin.Zap == nil {
|
||||
admin.Zap = &conf.AdminBackend_Zap{}
|
||||
}
|
||||
zap := objectMap(values["zap"])
|
||||
admin.Zap.Level, admin.Zap.Prefix, admin.Zap.Format, admin.Zap.Director = stringValue(zap, "level"), stringValue(zap, "prefix"), stringValue(zap, "format"), stringValue(zap, "director")
|
||||
admin.Zap.EncodeLevel, admin.Zap.StacktraceKey = stringValue(zap, "encode-level"), stringValue(zap, "stacktrace-key")
|
||||
admin.Zap.ShowLine, admin.Zap.LogInConsole = boolValue(zap, "show-line", admin.Zap.ShowLine), boolValue(zap, "log-in-console", admin.Zap.LogInConsole)
|
||||
admin.Zap.RetentionDay = int32(int64Value(zap, "retention-day", int64(admin.Zap.RetentionDay)))
|
||||
admin.Zap.AccessReqBody = boolValue(zap, "access-req-body", admin.Zap.AccessReqBody)
|
||||
admin.Zap.AccessRespData = boolValue(zap, "access-resp-data", admin.Zap.AccessRespData)
|
||||
admin.Zap.AccessReqHeaders = boolValue(zap, "access-req-headers", admin.Zap.AccessReqHeaders)
|
||||
admin.Zap.AccessLogMaxBytes = int32(int64Value(zap, "access-log-max-bytes", int64(admin.Zap.AccessLogMaxBytes)))
|
||||
if list, ok := zap["file-only-modules"].([]any); ok {
|
||||
admin.Zap.FileOnlyModules = admin.Zap.FileOnlyModules[:0]
|
||||
for _, item := range list {
|
||||
if value, ok := item.(string); ok {
|
||||
admin.Zap.FileOnlyModules = append(admin.Zap.FileOnlyModules, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
if data.Redis == nil {
|
||||
data.Redis = &conf.Data_Redis{}
|
||||
}
|
||||
applyCompatibleRedis(data.Redis, objectMap(values["redis"]))
|
||||
if items, ok := values["redis-list"].([]any); ok {
|
||||
current := make(map[string]*conf.Data_Redis, len(data.RedisList))
|
||||
for _, item := range data.RedisList {
|
||||
if item != nil {
|
||||
current[item.Name] = item
|
||||
}
|
||||
}
|
||||
data.RedisList = make([]*conf.Data_Redis, 0, len(items))
|
||||
for _, item := range items {
|
||||
values := objectMap(item)
|
||||
target := current[stringValue(values, "name")]
|
||||
if target == nil {
|
||||
target = &conf.Data_Redis{}
|
||||
}
|
||||
applyCompatibleRedis(target, values)
|
||||
data.RedisList = append(data.RedisList, target)
|
||||
}
|
||||
}
|
||||
if mongo := objectMap(values["mongo"]); mongo != nil {
|
||||
if data.Mongo == nil {
|
||||
data.Mongo = &conf.Data_Mongo{}
|
||||
}
|
||||
data.Mongo.Coll, data.Mongo.Options, data.Mongo.Database = stringValue(mongo, "coll"), stringValue(mongo, "options"), stringValue(mongo, "database")
|
||||
data.Mongo.Username, data.Mongo.AuthSource = stringValue(mongo, "username"), stringValue(mongo, "auth-source")
|
||||
if password := stringValue(mongo, "password"); password != "" && password != "******" {
|
||||
data.Mongo.Password = password
|
||||
}
|
||||
data.Mongo.MinPoolSize = uint64(int64Value(mongo, "min-pool-size", int64(data.Mongo.MinPoolSize)))
|
||||
data.Mongo.MaxPoolSize = uint64(int64Value(mongo, "max-pool-size", int64(data.Mongo.MaxPoolSize)))
|
||||
data.Mongo.SocketTimeoutMs = int64Value(mongo, "socket-timeout-ms", data.Mongo.SocketTimeoutMs)
|
||||
data.Mongo.ConnectTimeoutMs = int64Value(mongo, "connect-timeout-ms", data.Mongo.ConnectTimeoutMs)
|
||||
data.Mongo.IsZap = boolValue(mongo, "is-zap", data.Mongo.IsZap)
|
||||
if hosts, ok := mongo["hosts"].([]any); ok {
|
||||
data.Mongo.Hosts = make([]*conf.Data_MongoHost, 0, len(hosts))
|
||||
for _, item := range hosts {
|
||||
host := objectMap(item)
|
||||
data.Mongo.Hosts = append(data.Mongo.Hosts, &conf.Data_MongoHost{Host: stringValue(host, "host"), Port: stringValue(host, "port")})
|
||||
}
|
||||
}
|
||||
}
|
||||
if items, ok := values["db-list"].([]any); ok {
|
||||
current := make(map[string]*conf.Data_Database, len(data.DatabaseList))
|
||||
for _, item := range data.DatabaseList {
|
||||
if item != nil {
|
||||
current[item.AliasName] = item
|
||||
}
|
||||
}
|
||||
data.DatabaseList = make([]*conf.Data_Database, 0, len(items))
|
||||
for _, item := range items {
|
||||
row := objectMap(item)
|
||||
alias := stringValue(row, "alias-name")
|
||||
target := current[alias]
|
||||
if target == nil {
|
||||
target = &conf.Data_Database{}
|
||||
}
|
||||
applyCompatibleDatabase(target, row, stringValue(row, "type"))
|
||||
target.AliasName, target.Disable = alias, boolValue(row, "disable", target.Disable)
|
||||
data.DatabaseList = append(data.DatabaseList, target)
|
||||
}
|
||||
}
|
||||
if admin.Email == nil {
|
||||
admin.Email = &conf.AdminBackend_Email{}
|
||||
}
|
||||
email := objectMap(values["email"])
|
||||
admin.Email.To, admin.Email.From, admin.Email.Host = stringValue(email, "to"), stringValue(email, "from"), stringValue(email, "host")
|
||||
admin.Email.Nickname = stringValue(email, "nickname")
|
||||
admin.Email.Port = int32(int64Value(email, "port", int64(admin.Email.Port)))
|
||||
admin.Email.IsSsl, admin.Email.IsLoginAuth = boolValue(email, "is-ssl", admin.Email.IsSsl), boolValue(email, "is-loginauth", admin.Email.IsLoginAuth)
|
||||
if secret := stringValue(email, "secret"); secret != "" && secret != "******" {
|
||||
admin.Email.Secret = secret
|
||||
}
|
||||
if admin.Local == nil {
|
||||
admin.Local = &conf.AdminBackend_Local{}
|
||||
}
|
||||
local := objectMap(values["local"])
|
||||
admin.Local.PathPrefix, admin.Local.StorePath = stringValue(local, "path"), stringValue(local, "store-path")
|
||||
if admin.Media == nil {
|
||||
admin.Media = &conf.AdminBackend_Media{}
|
||||
}
|
||||
media := objectMap(values["media"])
|
||||
admin.Media.SessionTtl = int32(int64Value(media, "session-ttl", int64(admin.Media.SessionTtl)))
|
||||
admin.Media.MaxFileSize = int64Value(media, "max-file-size", admin.Media.MaxFileSize)
|
||||
admin.Media.ChunkDir = stringValue(media, "chunk-dir")
|
||||
if admin.Storage.Qiniu == nil {
|
||||
admin.Storage.Qiniu = &conf.AdminBackend_Qiniu{}
|
||||
}
|
||||
qiniu := objectMap(values["qiniu"])
|
||||
admin.Storage.Qiniu.Zone, admin.Storage.Qiniu.Bucket, admin.Storage.Qiniu.BaseUrl = stringValue(qiniu, "zone"), stringValue(qiniu, "bucket"), stringValue(qiniu, "img-path")
|
||||
admin.Storage.Qiniu.AccessKey = stringValue(qiniu, "access-key")
|
||||
if secret := stringValue(qiniu, "secret-key"); secret != "" && secret != "******" {
|
||||
admin.Storage.Qiniu.SecretKey = secret
|
||||
}
|
||||
admin.Storage.Qiniu.UseHttps, admin.Storage.Qiniu.UseCdnDomains = boolValue(qiniu, "use-https", admin.Storage.Qiniu.UseHttps), boolValue(qiniu, "use-cdn-domains", admin.Storage.Qiniu.UseCdnDomains)
|
||||
stores := []struct {
|
||||
key string
|
||||
target **conf.AdminBackend_ObjectStore
|
||||
}{{"aliyun-oss", &admin.Storage.AliyunOss}, {"hua-wei-obs", &admin.Storage.HuaweiObs}, {"tencent-cos", &admin.Storage.TencentCos}, {"aws-s3", &admin.Storage.AwsS3}, {"cloudflare-r2", &admin.Storage.CloudflareR2}, {"minio", &admin.Storage.Minio}}
|
||||
for _, store := range stores {
|
||||
if *store.target == nil {
|
||||
*store.target = &conf.AdminBackend_ObjectStore{}
|
||||
}
|
||||
applyCompatibleObjectStore(*store.target, objectMap(values[store.key]))
|
||||
}
|
||||
if disks, ok := values["disk-list"].([]any); ok {
|
||||
admin.DiskList = make([]*conf.AdminBackend_Disk, 0, len(disks))
|
||||
for _, item := range disks {
|
||||
disk := objectMap(item)
|
||||
admin.DiskList = append(admin.DiskList, &conf.AdminBackend_Disk{MountPoint: stringValue(disk, "mount-point")})
|
||||
}
|
||||
}
|
||||
if cors := objectMap(values["cors"]); cors != nil {
|
||||
if admin.Cors == nil {
|
||||
admin.Cors = &conf.AdminBackend_CORS{}
|
||||
}
|
||||
admin.Cors.Mode = stringValue(cors, "mode")
|
||||
if rules, ok := cors["whitelist"].([]any); ok {
|
||||
admin.Cors.Whitelist = make([]*conf.AdminBackend_CORSRule, 0, len(rules))
|
||||
for _, item := range rules {
|
||||
rule := objectMap(item)
|
||||
admin.Cors.Whitelist = append(admin.Cors.Whitelist, &conf.AdminBackend_CORSRule{AllowOrigin: stringValue(rule, "allow-origin"), AllowMethods: stringValue(rule, "allow-methods"), AllowHeaders: stringValue(rule, "allow-headers"), ExposeHeaders: stringValue(rule, "expose-headers"), AllowCredentials: boolValue(rule, "allow-credentials", false)})
|
||||
}
|
||||
}
|
||||
}
|
||||
if app := objectMap(values["app"]); app != nil {
|
||||
if admin.App == nil {
|
||||
admin.App = &conf.AdminBackend_App{}
|
||||
}
|
||||
admin.App.Node, admin.App.AppId, admin.App.Env = stringValue(app, "node"), stringValue(app, "app-id"), stringValue(app, "env")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package initialize
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseCompatibleDuration(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
want time.Duration
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "days only", raw: "3d", want: 72 * time.Hour},
|
||||
{name: "days and duration", raw: "3d4h", want: 76 * time.Hour},
|
||||
{name: "missing day count", raw: "xd", wantErr: true},
|
||||
{name: "invalid remainder", raw: "3dgarbage", wantErr: true},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, err := parseCompatibleDuration(test.raw)
|
||||
if test.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("parseCompatibleDuration(%q) accepted malformed input", test.raw)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("parseCompatibleDuration(%q) error = %v", test.raw, err)
|
||||
}
|
||||
if got != test.want {
|
||||
t.Fatalf("parseCompatibleDuration(%q) = %s, want %s", test.raw, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,184 +3,68 @@ package initialize
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"kra/internal/conf"
|
||||
"kra/pkg/protoutil"
|
||||
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/types/known/durationpb"
|
||||
"kra/internal/config"
|
||||
)
|
||||
|
||||
type configurationEnvelope struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
Admin json.RawMessage `json:"admin"`
|
||||
Email *struct {
|
||||
To string `json:"to"`
|
||||
From string `json:"from"`
|
||||
Host string `json:"host"`
|
||||
Secret string `json:"secret"`
|
||||
Nickname string `json:"nickname"`
|
||||
Port int32 `json:"port"`
|
||||
IsSSL bool `json:"is-ssl"`
|
||||
IsLoginAuth bool `json:"is-loginauth"`
|
||||
} `json:"email"`
|
||||
Email json.RawMessage `json:"email"`
|
||||
}
|
||||
|
||||
func (r *Repo) ConfigurationJSON() (json.RawMessage, error) {
|
||||
dataConfig, adminConfig := r.backend.RuntimeValues()
|
||||
admin := map[string]any{"routerPrefix": ""}
|
||||
email := map[string]any{}
|
||||
if adminConfig != nil {
|
||||
admin["routerPrefix"] = adminConfig.RouterPrefix
|
||||
if adminConfig.System != nil {
|
||||
admin["system"] = map[string]any{
|
||||
"useRedis": adminConfig.System.UseRedis,
|
||||
"useMultipoint": adminConfig.System.UseMultipoint,
|
||||
"useStrictAuth": adminConfig.System.UseStrictAuth,
|
||||
"disableAutoMigrate": adminConfig.System.DisableAutoMigrate,
|
||||
"useMongo": adminConfig.System.UseMongo,
|
||||
"addr": adminConfig.System.Addr,
|
||||
"iplimitCount": adminConfig.System.IplimitCount,
|
||||
"iplimitTime": adminConfig.System.IplimitTime,
|
||||
}
|
||||
}
|
||||
if adminConfig.Jwt != nil {
|
||||
admin["jwt"] = map[string]any{"signingKey": "******", "expiresTime": durationString(adminConfig.Jwt.ExpiresTime), "bufferTime": durationString(adminConfig.Jwt.BufferTime), "issuer": adminConfig.Jwt.Issuer}
|
||||
}
|
||||
if adminConfig.Captcha != nil {
|
||||
admin["captcha"] = map[string]any{"keyLong": adminConfig.Captcha.KeyLong, "imgWidth": adminConfig.Captcha.ImgWidth, "imgHeight": adminConfig.Captcha.ImgHeight, "storeExpiration": durationString(adminConfig.Captcha.StoreExpiration)}
|
||||
}
|
||||
if adminConfig.Local != nil {
|
||||
admin["local"] = map[string]any{"storePath": adminConfig.Local.StorePath, "pathPrefix": adminConfig.Local.PathPrefix}
|
||||
}
|
||||
if adminConfig.Media != nil {
|
||||
chunkDir := adminConfig.Media.ChunkDir
|
||||
if chunkDir == "" {
|
||||
chunkDir = "uploads/chunks"
|
||||
}
|
||||
admin["media"] = map[string]any{"sessionTtl": adminConfig.Media.SessionTtl, "maxFileSize": adminConfig.Media.MaxFileSize, "chunkDir": chunkDir}
|
||||
}
|
||||
if adminConfig.Email != nil {
|
||||
secret := ""
|
||||
if adminConfig.Email.Secret != "" {
|
||||
secret = "******"
|
||||
}
|
||||
email = map[string]any{"to": adminConfig.Email.To, "from": adminConfig.Email.From, "host": adminConfig.Email.Host, "secret": secret, "nickname": adminConfig.Email.Nickname, "port": adminConfig.Email.Port, "is-ssl": adminConfig.Email.IsSsl, "is-loginauth": adminConfig.Email.IsLoginAuth}
|
||||
}
|
||||
if adminConfig.Storage != nil {
|
||||
storage := proto.Clone(adminConfig.Storage).(*conf.AdminBackend_Storage)
|
||||
maskStorageSecrets(storage)
|
||||
admin["storage"] = storage
|
||||
}
|
||||
disks := make([]map[string]any, 0, len(adminConfig.DiskList))
|
||||
for _, disk := range adminConfig.DiskList {
|
||||
if disk != nil {
|
||||
disks = append(disks, map[string]any{"mount_point": disk.MountPoint})
|
||||
}
|
||||
}
|
||||
admin["disk_list"] = disks
|
||||
if adminConfig.Zap != nil {
|
||||
admin["zap"] = adminConfig.Zap
|
||||
}
|
||||
if adminConfig.Cors != nil {
|
||||
admin["cors"] = adminConfig.Cors
|
||||
}
|
||||
if adminConfig.App != nil {
|
||||
admin["app"] = adminConfig.App
|
||||
}
|
||||
current := r.backend.Config()
|
||||
if current == nil {
|
||||
current = &config.Config{}
|
||||
}
|
||||
// Never mask secrets on the live runtime object. ConfigurationJSON is a
|
||||
// read-only operation; mutating dataConfig here would replace the actual
|
||||
// database/Redis/Mongo credentials with "******" and make the next reload
|
||||
// fail. Work on a clone, just as we already do for adminConfig.
|
||||
safeData := cloneDataConfig(dataConfig)
|
||||
maskDataSecrets(safeData)
|
||||
safeAdmin := cloneAdminConfig(adminConfig)
|
||||
if safeAdmin != nil {
|
||||
if safeAdmin.Jwt != nil {
|
||||
safeAdmin.Jwt.SigningKey = "******"
|
||||
}
|
||||
if safeAdmin.Email != nil && safeAdmin.Email.Secret != "" {
|
||||
safeAdmin.Email.Secret = "******"
|
||||
}
|
||||
maskStorageSecrets(safeAdmin.Storage)
|
||||
}
|
||||
dataMap := map[string]any{}
|
||||
if safeData != nil {
|
||||
raw, err := protojson.MarshalOptions{UseProtoNames: true}.Marshal(safeData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = json.Unmarshal(raw, &dataMap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
config := compatibleConfiguration(safeData, safeAdmin)
|
||||
config["admin"], config["email"], config["data"] = admin, email, dataMap
|
||||
return json.Marshal(map[string]any{"config": config})
|
||||
safe := config.Clone(current)
|
||||
maskConfigSecrets(safe)
|
||||
return json.Marshal(map[string]any{"config": managementConfig(safe)})
|
||||
}
|
||||
|
||||
func (r *Repo) SaveConfigurationJSON(ctx context.Context, raw json.RawMessage) error {
|
||||
currentData, currentAdmin := r.backend.RuntimeValues()
|
||||
if currentAdmin == nil {
|
||||
return nil
|
||||
}
|
||||
nextData := cloneDataConfig(currentData)
|
||||
nextAdmin := proto.Clone(currentAdmin).(*conf.AdminBackend)
|
||||
if err := applyCompatibleConfiguration(raw, nextData, nextAdmin); err != nil {
|
||||
return err
|
||||
current := r.backend.Config()
|
||||
if current == nil {
|
||||
current = &config.Config{}
|
||||
}
|
||||
next := config.Clone(current)
|
||||
var value configurationEnvelope
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return err
|
||||
}
|
||||
options := protojson.UnmarshalOptions{DiscardUnknown: true}
|
||||
if len(value.Data) > 0 && string(value.Data) != "null" {
|
||||
if err := protoutil.MergeProtoJSON(nextData, value.Data, options); err != nil {
|
||||
if err := mergeJSON(value.Data, &next.Data); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := mergeJSON(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 {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(value.Admin) > 0 && string(value.Admin) != "null" {
|
||||
if err := protoutil.MergeProtoJSON(nextAdmin, value.Admin, options); err != nil {
|
||||
return err
|
||||
}
|
||||
preserveConfigSecrets(next, current)
|
||||
if next.Admin != nil && current.Admin != nil {
|
||||
next.Admin.ConfigPath = current.Admin.ConfigPath
|
||||
}
|
||||
preserveDataSecrets(nextData, currentData)
|
||||
preserveAdminSecrets(nextAdmin, currentAdmin)
|
||||
if value.Email != nil {
|
||||
if nextAdmin.Email == nil {
|
||||
nextAdmin.Email = &conf.AdminBackend_Email{}
|
||||
}
|
||||
nextAdmin.Email.To, nextAdmin.Email.From, nextAdmin.Email.Host = value.Email.To, value.Email.From, value.Email.Host
|
||||
nextAdmin.Email.Nickname, nextAdmin.Email.Port = value.Email.Nickname, value.Email.Port
|
||||
nextAdmin.Email.IsSsl, nextAdmin.Email.IsLoginAuth = value.Email.IsSSL, value.Email.IsLoginAuth
|
||||
if value.Email.Secret != "" && value.Email.Secret != "******" {
|
||||
nextAdmin.Email.Secret = value.Email.Secret
|
||||
}
|
||||
}
|
||||
if err := r.backend.RefreshDatabaseSources(nextData); err != nil {
|
||||
if err := refreshDatabaseSources(next.Data); err != nil {
|
||||
return err
|
||||
}
|
||||
nextAdmin.ConfigPath = currentAdmin.ConfigPath
|
||||
dataRaw, err := protojson.MarshalOptions{UseProtoNames: true}.Marshal(nextData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
adminRaw, err := protojson.MarshalOptions{UseProtoNames: true}.Marshal(nextAdmin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return r.PersistRuntimeConfig(ctx, dataRaw, adminRaw)
|
||||
return r.PersistRuntimeConfig(ctx, next)
|
||||
}
|
||||
|
||||
func (r *Repo) DiskMountPoints() []string {
|
||||
config := r.backend.RuntimeAdmin()
|
||||
if config == nil {
|
||||
current := r.backend.Config()
|
||||
if current == nil || current.Admin == nil {
|
||||
return nil
|
||||
}
|
||||
points := make([]string, 0, len(config.DiskList))
|
||||
for _, item := range config.DiskList {
|
||||
points := make([]string, 0, len(current.Admin.DiskList))
|
||||
for _, item := range current.Admin.DiskList {
|
||||
if item != nil && item.MountPoint != "" {
|
||||
points = append(points, item.MountPoint)
|
||||
}
|
||||
@@ -188,37 +72,105 @@ func (r *Repo) DiskMountPoints() []string {
|
||||
return points
|
||||
}
|
||||
|
||||
func cloneDataConfig(value *conf.Data) *conf.Data {
|
||||
if value == nil {
|
||||
return &conf.Data{}
|
||||
func mergeJSON(raw json.RawMessage, target any) error {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return nil
|
||||
}
|
||||
return proto.Clone(value).(*conf.Data)
|
||||
return json.Unmarshal(raw, target)
|
||||
}
|
||||
|
||||
func cloneAdminConfig(value *conf.AdminBackend) *conf.AdminBackend {
|
||||
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 &conf.AdminBackend{}
|
||||
return result
|
||||
}
|
||||
return proto.Clone(value).(*conf.AdminBackend)
|
||||
if value.Data != nil {
|
||||
result["data"] = value.Data
|
||||
}
|
||||
if value.Admin != nil {
|
||||
admin := value.Admin
|
||||
result["admin"] = map[string]any{
|
||||
"routerPrefix": admin.RouterPrefix,
|
||||
"jwt": managementJWT(admin.JWT),
|
||||
"captcha": managementCaptcha(admin.Captcha),
|
||||
"local": managementLocal(admin.Local),
|
||||
"media": managementMedia(admin.Media),
|
||||
"system": managementSystem(admin.System),
|
||||
"storage": admin.Storage,
|
||||
"disk_list": admin.DiskList,
|
||||
"zap": admin.Zap,
|
||||
"cors": admin.CORS,
|
||||
"app": admin.App,
|
||||
}
|
||||
if admin.Email != nil {
|
||||
result["email"] = map[string]any{
|
||||
"to": admin.Email.To, "from": admin.Email.From, "host": admin.Email.Host,
|
||||
"secret": admin.Email.Secret, "nickname": admin.Email.Nickname, "port": admin.Email.Port,
|
||||
"is-ssl": admin.Email.IsSSL, "is-loginauth": admin.Email.IsLoginAuth,
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func durationString(value *durationpb.Duration) string {
|
||||
func managementJWT(value *config.JWT) any {
|
||||
if value == nil {
|
||||
return "0s"
|
||||
return map[string]any{}
|
||||
}
|
||||
return value.AsDuration().String()
|
||||
return map[string]any{"signingKey": value.SigningKey, "expiresTime": value.ExpiresTime.String(), "bufferTime": value.BufferTime.String(), "issuer": value.Issuer}
|
||||
}
|
||||
func managementCaptcha(value *config.Captcha) any {
|
||||
if value == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return map[string]any{"keyLong": value.KeyLong, "imgWidth": value.ImgWidth, "imgHeight": value.ImgHeight, "storeExpiration": value.StoreExpiration.String()}
|
||||
}
|
||||
func managementLocal(value *config.Local) any {
|
||||
if value == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return map[string]any{"storePath": value.StorePath, "pathPrefix": value.PathPrefix}
|
||||
}
|
||||
func managementMedia(value *config.Media) any {
|
||||
if value == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
chunkDir := value.ChunkDir
|
||||
if chunkDir == "" {
|
||||
chunkDir = "uploads/chunks"
|
||||
}
|
||||
return map[string]any{"sessionTtl": value.SessionTTL, "maxFileSize": value.MaxFileSize, "chunkDir": chunkDir}
|
||||
}
|
||||
func managementSystem(value *config.System) any {
|
||||
if value == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return map[string]any{"useRedis": value.UseRedis, "useMultipoint": value.UseMultipoint, "useStrictAuth": value.UseStrictAuth, "disableAutoMigrate": value.DisableAutoMigrate, "useMongo": value.UseMongo, "addr": value.Addr, "iplimitCount": value.IplimitCount, "iplimitTime": value.IplimitTime}
|
||||
}
|
||||
|
||||
func maskDataSecrets(value *conf.Data) {
|
||||
func maskConfigSecrets(value *config.Config) {
|
||||
if value == nil {
|
||||
return
|
||||
}
|
||||
maskDataSecrets(value.Data)
|
||||
if value.Admin == nil {
|
||||
return
|
||||
}
|
||||
if value.Admin.JWT != nil && value.Admin.JWT.SigningKey != "" {
|
||||
value.Admin.JWT.SigningKey = "******"
|
||||
}
|
||||
if value.Admin.Email != nil && value.Admin.Email.Secret != "" {
|
||||
value.Admin.Email.Secret = "******"
|
||||
}
|
||||
maskStorageSecrets(value.Admin.Storage)
|
||||
}
|
||||
|
||||
func maskDataSecrets(value *config.Data) {
|
||||
if value == nil {
|
||||
return
|
||||
}
|
||||
if value.Database != nil {
|
||||
value.Database.Password = "******"
|
||||
// Source may contain an embedded password (for example a MySQL DSN).
|
||||
// The management response must never expose it. Structured database
|
||||
// fields are retained and refreshDatabaseSources rebuilds Source when
|
||||
// the configuration is saved.
|
||||
value.Database.Source = ""
|
||||
}
|
||||
if value.Redis != nil {
|
||||
@@ -240,15 +192,34 @@ func maskDataSecrets(value *conf.Data) {
|
||||
}
|
||||
}
|
||||
|
||||
func preserveDataSecrets(next, current *conf.Data) {
|
||||
func preserveConfigSecrets(next, current *config.Config) {
|
||||
if next == nil || current == nil {
|
||||
return
|
||||
}
|
||||
if next.Database != nil && current.Database != nil && maskedSecret(next.Database.Password) {
|
||||
next.Database.Password = current.Database.Password
|
||||
preserveDataSecrets(next.Data, current.Data)
|
||||
if next.Admin == nil || current.Admin == nil {
|
||||
return
|
||||
}
|
||||
if next.Database != nil && current.Database != nil && next.Database.Source == "" {
|
||||
next.Database.Source = current.Database.Source
|
||||
if next.Admin.JWT != nil && current.Admin.JWT != nil && maskedSecret(next.Admin.JWT.SigningKey) {
|
||||
next.Admin.JWT.SigningKey = current.Admin.JWT.SigningKey
|
||||
}
|
||||
if next.Admin.Email != nil && current.Admin.Email != nil && maskedSecret(next.Admin.Email.Secret) {
|
||||
next.Admin.Email.Secret = current.Admin.Email.Secret
|
||||
}
|
||||
preserveStorageSecrets(next.Admin.Storage, current.Admin.Storage)
|
||||
}
|
||||
|
||||
func preserveDataSecrets(next, current *config.Data) {
|
||||
if next == nil || current == nil {
|
||||
return
|
||||
}
|
||||
if next.Database != nil && current.Database != nil {
|
||||
if maskedSecret(next.Database.Password) {
|
||||
next.Database.Password = current.Database.Password
|
||||
}
|
||||
if next.Database.Source == "" {
|
||||
next.Database.Source = current.Database.Source
|
||||
}
|
||||
}
|
||||
if next.Redis != nil && current.Redis != nil && maskedSecret(next.Redis.Password) {
|
||||
next.Redis.Password = current.Redis.Password
|
||||
@@ -256,83 +227,80 @@ func preserveDataSecrets(next, current *conf.Data) {
|
||||
if next.Mongo != nil && current.Mongo != nil && maskedSecret(next.Mongo.Password) {
|
||||
next.Mongo.Password = current.Mongo.Password
|
||||
}
|
||||
currentDatabases := make(map[string]*conf.Data_Database, len(current.DatabaseList))
|
||||
for _, item := range current.DatabaseList {
|
||||
preserveDatabaseListSecrets(next.DatabaseList, current.DatabaseList)
|
||||
preserveRedisListSecrets(next.RedisList, current.RedisList)
|
||||
}
|
||||
|
||||
func preserveDatabaseListSecrets(next, current []*config.Database) {
|
||||
byName := make(map[string]*config.Database, len(current))
|
||||
for _, item := range current {
|
||||
if item != nil && item.AliasName != "" {
|
||||
currentDatabases[item.AliasName] = item
|
||||
byName[item.AliasName] = item
|
||||
}
|
||||
}
|
||||
for i, item := range next.DatabaseList {
|
||||
var previous *conf.Data_Database
|
||||
if item != nil && item.AliasName != "" {
|
||||
previous = currentDatabases[item.AliasName]
|
||||
for index, item := range next {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
if previous == nil && i < len(current.DatabaseList) {
|
||||
previous = current.DatabaseList[i]
|
||||
previous := byName[item.AliasName]
|
||||
if previous == nil && index < len(current) {
|
||||
previous = current[index]
|
||||
}
|
||||
if item != nil && previous != nil {
|
||||
if maskedSecret(item.Password) {
|
||||
item.Password = previous.Password
|
||||
}
|
||||
if item.Source == "" {
|
||||
item.Source = previous.Source
|
||||
}
|
||||
if previous == nil {
|
||||
continue
|
||||
}
|
||||
if maskedSecret(item.Password) {
|
||||
item.Password = previous.Password
|
||||
}
|
||||
if item.Source == "" {
|
||||
item.Source = previous.Source
|
||||
}
|
||||
}
|
||||
currentRedis := make(map[string]*conf.Data_Redis, len(current.RedisList))
|
||||
for _, item := range current.RedisList {
|
||||
}
|
||||
|
||||
func preserveRedisListSecrets(next, current []*config.Redis) {
|
||||
byName := make(map[string]*config.Redis, len(current))
|
||||
for _, item := range current {
|
||||
if item != nil && item.Name != "" {
|
||||
currentRedis[item.Name] = item
|
||||
byName[item.Name] = item
|
||||
}
|
||||
}
|
||||
for i, item := range next.RedisList {
|
||||
var previous *conf.Data_Redis
|
||||
if item != nil && item.Name != "" {
|
||||
previous = currentRedis[item.Name]
|
||||
for index, item := range next {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
if previous == nil && i < len(current.RedisList) {
|
||||
previous = current.RedisList[i]
|
||||
previous := byName[item.Name]
|
||||
if previous == nil && index < len(current) {
|
||||
previous = current[index]
|
||||
}
|
||||
if item != nil && previous != nil && maskedSecret(item.Password) {
|
||||
if previous != nil && maskedSecret(item.Password) {
|
||||
item.Password = previous.Password
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func preserveAdminSecrets(next, current *conf.AdminBackend) {
|
||||
if next.Jwt != nil && current.Jwt != nil && maskedSecret(next.Jwt.SigningKey) {
|
||||
next.Jwt.SigningKey = current.Jwt.SigningKey
|
||||
}
|
||||
if next.Email != nil && current.Email != nil && maskedSecret(next.Email.Secret) {
|
||||
next.Email.Secret = current.Email.Secret
|
||||
}
|
||||
preserveStorageSecrets(next.Storage, current.Storage)
|
||||
}
|
||||
|
||||
func maskedSecret(value string) bool { return value == "" || value == "******" }
|
||||
|
||||
func objectStores(storage *conf.AdminBackend_Storage) []*conf.AdminBackend_ObjectStore {
|
||||
if storage == nil {
|
||||
func objectStores(value *config.Storage) []*config.ObjectStore {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return []*conf.AdminBackend_ObjectStore{storage.AliyunOss, storage.HuaweiObs, storage.TencentCos, storage.AwsS3, storage.CloudflareR2, storage.Minio}
|
||||
return []*config.ObjectStore{value.AliyunOSS, value.HuaweiOBS, value.TencentCOS, value.AWSS3, value.CloudflareR2, value.Minio}
|
||||
}
|
||||
|
||||
func maskStorageSecrets(storage *conf.AdminBackend_Storage) {
|
||||
if storage == nil {
|
||||
func maskStorageSecrets(value *config.Storage) {
|
||||
if value == nil {
|
||||
return
|
||||
}
|
||||
if storage.Qiniu != nil && storage.Qiniu.SecretKey != "" {
|
||||
storage.Qiniu.SecretKey = "******"
|
||||
if value.Qiniu != nil && value.Qiniu.SecretKey != "" {
|
||||
value.Qiniu.SecretKey = "******"
|
||||
}
|
||||
for _, item := range objectStores(storage) {
|
||||
for _, item := range objectStores(value) {
|
||||
if item != nil && item.SecretKey != "" {
|
||||
item.SecretKey = "******"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func preserveStorageSecrets(next, current *conf.AdminBackend_Storage) {
|
||||
func preserveStorageSecrets(next, current *config.Storage) {
|
||||
if next == nil || current == nil {
|
||||
return
|
||||
}
|
||||
@@ -340,9 +308,43 @@ func preserveStorageSecrets(next, current *conf.AdminBackend_Storage) {
|
||||
next.Qiniu.SecretKey = current.Qiniu.SecretKey
|
||||
}
|
||||
nextItems, currentItems := objectStores(next), objectStores(current)
|
||||
for i := range nextItems {
|
||||
if nextItems[i] != nil && currentItems[i] != nil && maskedSecret(nextItems[i].SecretKey) {
|
||||
nextItems[i].SecretKey = currentItems[i].SecretKey
|
||||
for index := range nextItems {
|
||||
if nextItems[index] != nil && currentItems[index] != nil && maskedSecret(nextItems[index].SecretKey) {
|
||||
nextItems[index].SecretKey = currentItems[index].SecretKey
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func refreshDatabaseSources(value *config.Data) error {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
if err := refreshDatabaseSource(value.Database); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, database := range value.DatabaseList {
|
||||
if database == nil || database.Disable {
|
||||
continue
|
||||
}
|
||||
if err := refreshDatabaseSource(database); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
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 {
|
||||
// Driver-specific DSN construction remains in data. Clearing Source
|
||||
// makes the data backend rebuild it from structured values.
|
||||
database.Source = ""
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,29 +1,74 @@
|
||||
package initialize
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"kra/internal/conf"
|
||||
"kra/pkg/protoutil"
|
||||
"kra/internal/biz/system"
|
||||
"kra/internal/config"
|
||||
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestMergeProtoJSONPreservesOmittedFieldsAndNormalizesKeys(t *testing.T) {
|
||||
target := &conf.AdminBackend{
|
||||
RouterPrefix: "/api",
|
||||
System: &conf.AdminBackend_System{UseRedis: true, IplimitCount: 3},
|
||||
}
|
||||
patch := json.RawMessage(`{"router_prefix":"/admin","system":{"iplimit_count":9}}`)
|
||||
type configurationBackend struct {
|
||||
value *config.Config
|
||||
persisted *config.Config
|
||||
}
|
||||
|
||||
if err := protoutil.MergeProtoJSON(target, patch, protojson.UnmarshalOptions{DiscardUnknown: true}); err != nil {
|
||||
func (b *configurationBackend) IsInitialized(context.Context) (bool, error) { return true, nil }
|
||||
func (b *configurationBackend) InitializeDatabase(context.Context, *system.DatabaseConfig, func(context.Context, *gorm.DB) error) error {
|
||||
return nil
|
||||
}
|
||||
func (b *configurationBackend) PersistConfig(context.Context) error { return nil }
|
||||
func (b *configurationBackend) PersistRuntimeConfig(_ context.Context, value *config.Config) error {
|
||||
b.persisted = config.Clone(value)
|
||||
return nil
|
||||
}
|
||||
func (b *configurationBackend) ReloadConfig(context.Context) error { return nil }
|
||||
func (b *configurationBackend) Config() *config.Config { return config.Clone(b.value) }
|
||||
|
||||
func TestConfigurationJSONMasksSecretsWithoutMutatingConfig(t *testing.T) {
|
||||
backend := &configurationBackend{value: &config.Config{
|
||||
Data: &config.Data{Database: &config.Database{Password: "database-secret", Source: "dsn"}, Redis: &config.Redis{Password: "redis-secret"}},
|
||||
Admin: &config.Admin{JWT: &config.JWT{SigningKey: "jwt-secret", ExpiresTime: 24 * time.Hour}, Email: &config.Email{Secret: "email-secret"}, Storage: &config.Storage{Qiniu: &config.Qiniu{SecretKey: "storage-secret"}}},
|
||||
}}
|
||||
raw, err := (&Repo{backend: backend}).ConfigurationJSON()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if target.RouterPrefix != "/admin" || target.System.IplimitCount != 9 {
|
||||
t.Fatalf("patch was not applied: %#v", target)
|
||||
if !json.Valid(raw) {
|
||||
t.Fatalf("invalid response: %s", raw)
|
||||
}
|
||||
if !target.System.UseRedis {
|
||||
t.Fatal("an omitted nested field was cleared")
|
||||
for _, secret := range []string{"database-secret", "redis-secret", "jwt-secret", "email-secret", "storage-secret", "dsn"} {
|
||||
if bytes.Contains(raw, []byte(secret)) {
|
||||
t.Fatalf("configuration response exposed %q: %s", secret, raw)
|
||||
}
|
||||
}
|
||||
if backend.value.Data.Database.Password != "database-secret" || backend.value.Admin.JWT.SigningKey != "jwt-secret" {
|
||||
t.Fatal("ConfigurationJSON mutated the live configuration")
|
||||
}
|
||||
}
|
||||
|
||||
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"}},
|
||||
}}
|
||||
raw := json.RawMessage(`{"data":{"database":{"host":"new","password":"******"}},"admin":{"jwt":{"issuer":"new","signing_key":"******"}},"email":{"host":"new.smtp","secret":"******"}}`)
|
||||
if err := (&Repo{backend: backend}).SaveConfigurationJSON(context.Background(), raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := backend.persisted
|
||||
if got == nil || got.Data.Database.Host != "new" || got.Admin.JWT.Issuer != "new" || got.Admin.Email.Host != "new.smtp" {
|
||||
t.Fatalf("partial update was not applied: %#v", got)
|
||||
}
|
||||
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.Admin.ConfigPath != "config.yaml" {
|
||||
t.Fatalf("config path = %q", got.Admin.ConfigPath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package initialize
|
||||
import (
|
||||
"context"
|
||||
"kra/internal/biz/system"
|
||||
"kra/internal/config"
|
||||
|
||||
datasystem "kra/internal/data/system"
|
||||
datatask "kra/internal/data/task"
|
||||
@@ -37,12 +38,8 @@ func (r *Repo) PersistConfig(ctx context.Context) error {
|
||||
return r.backend.PersistConfig(ctx)
|
||||
}
|
||||
|
||||
func (r *Repo) PersistAdminConfig(ctx context.Context, value []byte) error {
|
||||
return r.backend.PersistAdminConfig(ctx, value)
|
||||
}
|
||||
|
||||
func (r *Repo) PersistRuntimeConfig(ctx context.Context, data, admin []byte) error {
|
||||
return r.backend.PersistRuntimeConfig(ctx, data, admin)
|
||||
func (r *Repo) PersistRuntimeConfig(ctx context.Context, value *config.Config) error {
|
||||
return r.backend.PersistRuntimeConfig(ctx, value)
|
||||
}
|
||||
|
||||
func (r *Repo) ReloadConfig(ctx context.Context) error {
|
||||
|
||||
@@ -13,18 +13,16 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"kra/internal/conf"
|
||||
"kra/internal/config"
|
||||
)
|
||||
|
||||
type emailRepo struct {
|
||||
runtime *conf.Runtime
|
||||
}
|
||||
type emailRepo struct{ runtime *config.Store }
|
||||
|
||||
func NewEmailRepo(runtime *conf.Runtime) system.EmailRepo {
|
||||
func NewEmailRepo(runtime *config.Store) system.EmailRepo {
|
||||
return &emailRepo{runtime: runtime}
|
||||
}
|
||||
|
||||
func (r *emailRepo) email() *conf.AdminBackend_Email {
|
||||
func (r *emailRepo) email() *config.Email {
|
||||
if r.runtime == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -47,7 +45,7 @@ func (r *emailRepo) DefaultRecipients() []string {
|
||||
return []string{strings.TrimSpace(config.To)}
|
||||
}
|
||||
|
||||
func emailEnabled(config *conf.AdminBackend_Email) bool {
|
||||
func emailEnabled(config *config.Email) bool {
|
||||
return config != nil && strings.TrimSpace(config.Host) != "" &&
|
||||
strings.TrimSpace(config.From) != "" && strings.TrimSpace(config.Secret) != "" && config.Port > 0
|
||||
}
|
||||
@@ -82,7 +80,7 @@ func (r *emailRepo) Send(ctx context.Context, to []string, subject, body string)
|
||||
dialer := &net.Dialer{Timeout: 10 * time.Second}
|
||||
var conn net.Conn
|
||||
var err error
|
||||
if config.IsSsl {
|
||||
if config.IsSSL {
|
||||
conn, err = (&tls.Dialer{NetDialer: dialer, Config: &tls.Config{ServerName: config.Host, MinVersion: tls.VersionTLS12}}).DialContext(ctx, "tcp", address)
|
||||
} else {
|
||||
conn, err = dialer.DialContext(ctx, "tcp", address)
|
||||
|
||||
@@ -4,11 +4,11 @@ import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"kra/internal/conf"
|
||||
"kra/internal/config"
|
||||
)
|
||||
|
||||
func TestDefaultRecipientsIgnoreEmptyConfiguration(t *testing.T) {
|
||||
runtime := conf.NewRuntime(&conf.Data{}, &conf.AdminBackend{Email: &conf.AdminBackend_Email{To: " "}})
|
||||
runtime := config.NewStore(&config.Config{Data: &config.Data{}, Admin: &config.Admin{Email: &config.Email{To: " "}}})
|
||||
repo := &emailRepo{runtime: runtime}
|
||||
if got := repo.DefaultRecipients(); len(got) != 0 {
|
||||
t.Fatalf("DefaultRecipients() = %#v, want empty", got)
|
||||
@@ -16,7 +16,7 @@ func TestDefaultRecipientsIgnoreEmptyConfiguration(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSendRejectsEmptyRecipientsBeforeNetworkDial(t *testing.T) {
|
||||
runtime := conf.NewRuntime(&conf.Data{}, &conf.AdminBackend{Email: &conf.AdminBackend_Email{Host: "smtp.example.com", From: "from@example.com", Secret: "secret", Port: 25}})
|
||||
runtime := config.NewStore(&config.Config{Data: &config.Data{}, Admin: &config.Admin{Email: &config.Email{Host: "smtp.example.com", From: "from@example.com", Secret: "secret", Port: 25}}})
|
||||
repo := &emailRepo{runtime: runtime}
|
||||
if err := repo.Send(context.Background(), []string{" ", ""}, "subject", "body"); err == nil {
|
||||
t.Fatal("Send() accepted empty recipients")
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"kra/internal/conf"
|
||||
"kra/internal/config"
|
||||
|
||||
"github.com/aliyun/aliyun-oss-go-sdk/oss"
|
||||
)
|
||||
@@ -18,7 +18,7 @@ type aliyunStorage struct {
|
||||
baseURL, prefix string
|
||||
}
|
||||
|
||||
func newAliyunStorage(config *conf.AdminBackend_ObjectStore) (system.FileStorage, error) {
|
||||
func newAliyunStorage(config *config.ObjectStore) (system.FileStorage, error) {
|
||||
if config == nil || config.Endpoint == "" || config.Bucket == "" || config.AccessKey == "" || config.SecretKey == "" {
|
||||
return nil, fmt.Errorf("aliyun-oss storage configuration is incomplete")
|
||||
}
|
||||
@@ -30,7 +30,7 @@ func newAliyunStorage(config *conf.AdminBackend_ObjectStore) (system.FileStorage
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &aliyunStorage{bucket: bucket, baseURL: strings.TrimSuffix(config.BaseUrl, "/"), prefix: strings.Trim(config.PathPrefix, "/")}, nil
|
||||
return &aliyunStorage{bucket: bucket, baseURL: strings.TrimSuffix(config.BaseURL, "/"), prefix: strings.Trim(config.PathPrefix, "/")}, nil
|
||||
}
|
||||
func (s *aliyunStorage) key(name string) string {
|
||||
if s.prefix == "" {
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"kra/internal/conf"
|
||||
"kra/internal/config"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
awsconfig "github.com/aws/aws-sdk-go-v2/config"
|
||||
@@ -22,7 +22,7 @@ type awsStorage struct {
|
||||
bucket, baseURL, prefix string
|
||||
}
|
||||
|
||||
func newAWSStorage(provider string, config *conf.AdminBackend_ObjectStore) (system.FileStorage, error) {
|
||||
func newAWSStorage(provider string, config *config.ObjectStore) (system.FileStorage, error) {
|
||||
if config == nil || config.Bucket == "" || config.AccessKey == "" || config.SecretKey == "" {
|
||||
return nil, fmt.Errorf("%s storage configuration is incomplete", provider)
|
||||
}
|
||||
@@ -31,10 +31,10 @@ func newAWSStorage(provider string, config *conf.AdminBackend_ObjectStore) (syst
|
||||
if provider == "cloudflare-r2" {
|
||||
region = "auto"
|
||||
if endpoint == "" {
|
||||
if config.AccountId == "" {
|
||||
if config.AccountID == "" {
|
||||
return nil, fmt.Errorf("cloudflare-r2 account id is required")
|
||||
}
|
||||
endpoint = fmt.Sprintf("https://%s.r2.cloudflarestorage.com", config.AccountId)
|
||||
endpoint = fmt.Sprintf("https://%s.r2.cloudflarestorage.com", config.AccountID)
|
||||
}
|
||||
}
|
||||
if region == "" {
|
||||
@@ -47,7 +47,7 @@ func newAWSStorage(provider string, config *conf.AdminBackend_ObjectStore) (syst
|
||||
client := s3.NewFromConfig(loaded, func(options *s3.Options) {
|
||||
if endpoint != "" {
|
||||
if !strings.HasPrefix(endpoint, "http://") && !strings.HasPrefix(endpoint, "https://") {
|
||||
if config.UseSsl {
|
||||
if config.UseSSL {
|
||||
endpoint = "https://" + endpoint
|
||||
} else {
|
||||
endpoint = "http://" + endpoint
|
||||
@@ -57,7 +57,7 @@ func newAWSStorage(provider string, config *conf.AdminBackend_ObjectStore) (syst
|
||||
}
|
||||
options.UsePathStyle = config.ForcePathStyle
|
||||
})
|
||||
return &awsStorage{client: client, bucket: config.Bucket, baseURL: strings.TrimSuffix(config.BaseUrl, "/"), prefix: strings.Trim(config.PathPrefix, "/")}, nil
|
||||
return &awsStorage{client: client, bucket: config.Bucket, baseURL: strings.TrimSuffix(config.BaseURL, "/"), prefix: strings.Trim(config.PathPrefix, "/")}, nil
|
||||
}
|
||||
|
||||
func (s *awsStorage) key(name string) string {
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"kra/internal/conf"
|
||||
"kra/internal/config"
|
||||
|
||||
"github.com/huaweicloud/huaweicloud-sdk-go-obs/obs"
|
||||
)
|
||||
@@ -18,7 +18,7 @@ type huaweiStorage struct {
|
||||
bucket, baseURL, prefix string
|
||||
}
|
||||
|
||||
func newHuaweiStorage(config *conf.AdminBackend_ObjectStore) (system.FileStorage, error) {
|
||||
func newHuaweiStorage(config *config.ObjectStore) (system.FileStorage, error) {
|
||||
if config == nil || config.Endpoint == "" || config.Bucket == "" || config.AccessKey == "" || config.SecretKey == "" {
|
||||
return nil, fmt.Errorf("huawei-obs storage configuration is incomplete")
|
||||
}
|
||||
@@ -26,7 +26,7 @@ func newHuaweiStorage(config *conf.AdminBackend_ObjectStore) (system.FileStorage
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &huaweiStorage{client: client, bucket: config.Bucket, baseURL: strings.TrimSuffix(config.BaseUrl, "/"), prefix: strings.Trim(config.PathPrefix, "/")}, nil
|
||||
return &huaweiStorage{client: client, bucket: config.Bucket, baseURL: strings.TrimSuffix(config.BaseURL, "/"), prefix: strings.Trim(config.PathPrefix, "/")}, nil
|
||||
}
|
||||
func (s *huaweiStorage) key(name string) string {
|
||||
if s.prefix == "" {
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"kra/internal/conf"
|
||||
"kra/internal/config"
|
||||
)
|
||||
|
||||
type fileStorage struct {
|
||||
@@ -19,41 +19,41 @@ type fileStorage struct {
|
||||
urlPrefix string
|
||||
}
|
||||
|
||||
func New(config *conf.AdminBackend) (system.FileStorage, error) {
|
||||
func New(admin *config.Admin) (system.FileStorage, error) {
|
||||
storageType := "local"
|
||||
if config != nil && config.Storage != nil && config.Storage.Type != "" {
|
||||
storageType = strings.ToLower(config.Storage.Type)
|
||||
if admin != nil && admin.Storage != nil && admin.Storage.Type != "" {
|
||||
storageType = strings.ToLower(admin.Storage.Type)
|
||||
}
|
||||
if storageType == "qiniu" {
|
||||
return newQiniuStorage(config.Storage.Qiniu)
|
||||
return newQiniuStorage(admin.Storage.Qiniu)
|
||||
}
|
||||
if storageType != "local" {
|
||||
var object *conf.AdminBackend_ObjectStore
|
||||
var object *config.ObjectStore
|
||||
switch storageType {
|
||||
case "aliyun-oss":
|
||||
return newAliyunStorage(config.Storage.AliyunOss)
|
||||
return newAliyunStorage(admin.Storage.AliyunOSS)
|
||||
case "huawei-obs":
|
||||
return newHuaweiStorage(config.Storage.HuaweiObs)
|
||||
return newHuaweiStorage(admin.Storage.HuaweiOBS)
|
||||
case "tencent-cos":
|
||||
return newTencentStorage(config.Storage.TencentCos)
|
||||
return newTencentStorage(admin.Storage.TencentCOS)
|
||||
case "aws-s3":
|
||||
return newAWSStorage(storageType, config.Storage.AwsS3)
|
||||
return newAWSStorage(storageType, admin.Storage.AWSS3)
|
||||
case "cloudflare-r2":
|
||||
return newAWSStorage(storageType, config.Storage.CloudflareR2)
|
||||
return newAWSStorage(storageType, admin.Storage.CloudflareR2)
|
||||
case "minio":
|
||||
object = config.Storage.Minio
|
||||
object = admin.Storage.Minio
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported storage type %q", storageType)
|
||||
}
|
||||
return newS3Storage(storageType, object)
|
||||
}
|
||||
root, prefix := "uploads/file", "uploads/file"
|
||||
if config != nil && config.Local != nil {
|
||||
if config.Local.StorePath != "" {
|
||||
root = config.Local.StorePath
|
||||
if admin != nil && admin.Local != nil {
|
||||
if admin.Local.StorePath != "" {
|
||||
root = admin.Local.StorePath
|
||||
}
|
||||
if config.Local.PathPrefix != "" {
|
||||
prefix = config.Local.PathPrefix
|
||||
if admin.Local.PathPrefix != "" {
|
||||
prefix = admin.Local.PathPrefix
|
||||
}
|
||||
}
|
||||
root = filepath.Clean(root)
|
||||
|
||||
@@ -11,24 +11,24 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"kra/internal/conf"
|
||||
"kra/internal/config"
|
||||
|
||||
"github.com/qiniu/go-sdk/v7/auth/qbox"
|
||||
qstorage "github.com/qiniu/go-sdk/v7/storage"
|
||||
)
|
||||
|
||||
type qiniuStorage struct {
|
||||
config *conf.AdminBackend_Qiniu
|
||||
config *config.Qiniu
|
||||
upload *qstorage.FormUploader
|
||||
manager *qstorage.BucketManager
|
||||
mac *qbox.Mac
|
||||
}
|
||||
|
||||
func newQiniuStorage(config *conf.AdminBackend_Qiniu) (system.FileStorage, error) {
|
||||
func newQiniuStorage(config *config.Qiniu) (system.FileStorage, error) {
|
||||
if config == nil || config.Bucket == "" || config.AccessKey == "" || config.SecretKey == "" {
|
||||
return nil, fmt.Errorf("qiniu storage configuration is incomplete")
|
||||
}
|
||||
cfg := qstorage.Config{UseHTTPS: config.UseHttps, UseCdnDomains: config.UseCdnDomains}
|
||||
cfg := qstorage.Config{UseHTTPS: config.UseHTTPS, UseCdnDomains: config.UseCDNDomains}
|
||||
switch config.Zone {
|
||||
case "ZoneHuadong":
|
||||
cfg.Zone = &qstorage.ZoneHuadong
|
||||
@@ -51,7 +51,7 @@ func (s *qiniuStorage) uploadToken() string {
|
||||
}
|
||||
|
||||
func (s *qiniuStorage) file(key string, size int64) *system.StoredFile {
|
||||
return &system.StoredFile{Name: path.Base(key), Path: key, URL: strings.TrimSuffix(s.config.BaseUrl, "/") + "/" + key, Size: size}
|
||||
return &system.StoredFile{Name: path.Base(key), Path: key, URL: strings.TrimSuffix(s.config.BaseURL, "/") + "/" + key, Size: size}
|
||||
}
|
||||
func (s *qiniuStorage) Put(ctx context.Context, name string, reader io.Reader) (*system.StoredFile, error) {
|
||||
temporary, err := os.CreateTemp("", "kra-qiniu-upload-*")
|
||||
@@ -77,7 +77,7 @@ func (s *qiniuStorage) Put(ctx context.Context, name string, reader io.Reader) (
|
||||
return s.file(ret.Key, size), nil
|
||||
}
|
||||
func (s *qiniuStorage) Open(ctx context.Context, name string) (io.ReadCloser, error) {
|
||||
downloadURL := qstorage.MakePrivateURL(s.mac, strings.TrimSuffix(s.config.BaseUrl, "/"), strings.TrimPrefix(name, "/"), time.Now().Add(time.Hour).Unix())
|
||||
downloadURL := qstorage.MakePrivateURL(s.mac, strings.TrimSuffix(s.config.BaseURL, "/"), strings.TrimPrefix(name, "/"), time.Now().Add(time.Hour).Unix())
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"kra/internal/biz/system"
|
||||
"sync"
|
||||
|
||||
"kra/internal/conf"
|
||||
"kra/internal/config"
|
||||
)
|
||||
|
||||
type Reloadable struct {
|
||||
@@ -15,15 +15,15 @@ type Reloadable struct {
|
||||
current system.FileStorage
|
||||
}
|
||||
|
||||
func NewFileStorage(runtime *conf.Runtime) (*Reloadable, error) {
|
||||
var config *conf.AdminBackend
|
||||
func NewFileStorage(runtime *config.Store) (*Reloadable, error) {
|
||||
var config *config.Admin
|
||||
if runtime != nil {
|
||||
config = runtime.Admin()
|
||||
}
|
||||
return NewReloadable(config)
|
||||
}
|
||||
|
||||
func NewReloadable(config *conf.AdminBackend) (*Reloadable, error) {
|
||||
func NewReloadable(config *config.Admin) (*Reloadable, error) {
|
||||
current, err := New(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"kra/internal/conf"
|
||||
"kra/internal/config"
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
@@ -20,7 +20,7 @@ type s3Storage struct {
|
||||
bucket, baseURL, prefix string
|
||||
}
|
||||
|
||||
func newS3Storage(provider string, config *conf.AdminBackend_ObjectStore) (system.FileStorage, error) {
|
||||
func newS3Storage(provider string, config *config.ObjectStore) (system.FileStorage, error) {
|
||||
if config == nil || config.Bucket == "" || config.AccessKey == "" || config.SecretKey == "" {
|
||||
return nil, fmt.Errorf("%s storage configuration is incomplete", provider)
|
||||
}
|
||||
@@ -30,7 +30,7 @@ func newS3Storage(provider string, config *conf.AdminBackend_ObjectStore) (syste
|
||||
case "aws-s3":
|
||||
endpoint = "s3." + config.Region + ".amazonaws.com"
|
||||
case "cloudflare-r2":
|
||||
endpoint = config.AccountId + ".r2.cloudflarestorage.com"
|
||||
endpoint = config.AccountID + ".r2.cloudflarestorage.com"
|
||||
case "aliyun-oss":
|
||||
endpoint = "oss-" + config.Region + ".aliyuncs.com"
|
||||
case "huawei-obs":
|
||||
@@ -39,7 +39,7 @@ func newS3Storage(provider string, config *conf.AdminBackend_ObjectStore) (syste
|
||||
endpoint = "cos." + config.Region + ".myqcloud.com"
|
||||
}
|
||||
}
|
||||
secure := config.UseSsl
|
||||
secure := config.UseSSL
|
||||
if parsed, err := url.Parse(endpoint); err == nil && parsed.Host != "" {
|
||||
secure = parsed.Scheme == "https"
|
||||
endpoint = parsed.Host
|
||||
@@ -56,7 +56,7 @@ func newS3Storage(provider string, config *conf.AdminBackend_ObjectStore) (syste
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s3Storage{client: client, bucket: config.Bucket, baseURL: strings.TrimSuffix(config.BaseUrl, "/"), prefix: strings.Trim(config.PathPrefix, "/")}, nil
|
||||
return &s3Storage{client: client, bucket: config.Bucket, baseURL: strings.TrimSuffix(config.BaseURL, "/"), prefix: strings.Trim(config.PathPrefix, "/")}, nil
|
||||
}
|
||||
func (s *s3Storage) key(name string) string {
|
||||
if s.prefix == "" {
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"kra/internal/conf"
|
||||
"kra/internal/config"
|
||||
|
||||
cos "github.com/tencentyun/cos-go-sdk-v5"
|
||||
)
|
||||
@@ -21,7 +21,7 @@ type tencentStorage struct {
|
||||
baseURL, prefix string
|
||||
}
|
||||
|
||||
func newTencentStorage(config *conf.AdminBackend_ObjectStore) (system.FileStorage, error) {
|
||||
func newTencentStorage(config *config.ObjectStore) (system.FileStorage, error) {
|
||||
if config == nil || config.Bucket == "" || config.Region == "" || config.AccessKey == "" || config.SecretKey == "" {
|
||||
return nil, fmt.Errorf("tencent-cos storage configuration is incomplete")
|
||||
}
|
||||
@@ -29,7 +29,7 @@ func newTencentStorage(config *conf.AdminBackend_ObjectStore) (system.FileStorag
|
||||
if endpoint == "" {
|
||||
endpoint = fmt.Sprintf("https://%s.cos.%s.myqcloud.com", config.Bucket, config.Region)
|
||||
} else if !strings.HasPrefix(endpoint, "http://") && !strings.HasPrefix(endpoint, "https://") {
|
||||
if config.UseSsl {
|
||||
if config.UseSSL {
|
||||
endpoint = "https://" + endpoint
|
||||
} else {
|
||||
endpoint = "http://" + endpoint
|
||||
@@ -40,7 +40,7 @@ func newTencentStorage(config *conf.AdminBackend_ObjectStore) (system.FileStorag
|
||||
return nil, err
|
||||
}
|
||||
client := cos.NewClient(&cos.BaseURL{BucketURL: bucketURL}, &http.Client{Transport: &cos.AuthorizationTransport{SecretID: config.AccessKey, SecretKey: config.SecretKey}})
|
||||
return &tencentStorage{client: client, baseURL: strings.TrimSuffix(config.BaseUrl, "/"), prefix: strings.Trim(config.PathPrefix, "/")}, nil
|
||||
return &tencentStorage{client: client, baseURL: strings.TrimSuffix(config.BaseURL, "/"), prefix: strings.Trim(config.PathPrefix, "/")}, nil
|
||||
}
|
||||
func (s *tencentStorage) key(name string) string {
|
||||
if s.prefix == "" {
|
||||
|
||||
+15
-15
@@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"kra/internal/conf"
|
||||
"kra/internal/config"
|
||||
websocket "kra/internal/integration/websocket"
|
||||
"kra/internal/server/handler"
|
||||
"kra/internal/server/middleware"
|
||||
@@ -20,13 +20,13 @@ import (
|
||||
kratoshttp "github.com/go-kratos/kratos/v3/transport/http"
|
||||
)
|
||||
|
||||
func NewGinEngine(runtime *conf.Runtime, access *service.AccessControlService, handlers *handler.Set, auth *service.AuthService, security *service.SecurityService, audit *service.AuditRecorder, logger *slog.Logger, version string) *gin.Engine {
|
||||
func NewGinEngine(runtime *config.Store, access *service.AccessControlService, handlers *handler.Set, auth *service.AuthService, security *service.SecurityService, audit *service.AuditRecorder, logger *slog.Logger, version string) *gin.Engine {
|
||||
return NewGinEngineWithRuntime(runtime, access, auth, security, audit, logger, version, platformmodule.NewRuntime(router.NewRoutes(handlers)), nil)
|
||||
}
|
||||
|
||||
func NewGinEngineWithRuntime(runtime *conf.Runtime, access *service.AccessControlService, auth middleware.TokenAuthenticator, security *service.SecurityService, audit *service.AuditRecorder, logger *slog.Logger, version string, routes *platformmodule.Runtime, ws *websocket.Server) *gin.Engine {
|
||||
func NewGinEngineWithRuntime(runtime *config.Store, access *service.AccessControlService, auth middleware.TokenAuthenticator, security *service.SecurityService, audit *service.AuditRecorder, logger *slog.Logger, version string, routes *platformmodule.Runtime, ws *websocket.Server) *gin.Engine {
|
||||
if runtime == nil {
|
||||
runtime = conf.NewRuntime(nil, nil)
|
||||
runtime = config.NewStore(nil)
|
||||
}
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
engine := gin.New()
|
||||
@@ -36,9 +36,9 @@ func NewGinEngineWithRuntime(runtime *conf.Runtime, access *service.AccessContro
|
||||
engine.Use(middleware.RequestMeta(), middleware.Recovery(logger), middleware.AccessLog(runtime, logger, version), middleware.CORS(runtime), middleware.ErrorAudit(logger), middleware.SecurityRateLimit(security))
|
||||
|
||||
prefix := ""
|
||||
config := runtime.Admin()
|
||||
if config != nil {
|
||||
prefix = strings.TrimSuffix(config.RouterPrefix, "/")
|
||||
snapshot := runtime.Snapshot()
|
||||
if snapshot != nil && snapshot.Admin != nil {
|
||||
prefix = strings.TrimSuffix(snapshot.Admin.RouterPrefix, "/")
|
||||
}
|
||||
public := engine.Group(prefix)
|
||||
public.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, "ok") })
|
||||
@@ -89,19 +89,19 @@ func NewGinEngineWithRuntime(runtime *conf.Runtime, access *service.AccessContro
|
||||
return engine
|
||||
}
|
||||
|
||||
func NewGinServer(c *conf.Server, engine *gin.Engine) *kratoshttp.Server {
|
||||
func NewGinServer(c *config.Server, engine *gin.Engine) *kratoshttp.Server {
|
||||
network, address := "tcp", ":8000"
|
||||
if c != nil && c.Http != nil {
|
||||
if c.Http.Network != "" {
|
||||
network = c.Http.Network
|
||||
if c != nil && c.HTTP != nil {
|
||||
if c.HTTP.Network != "" {
|
||||
network = c.HTTP.Network
|
||||
}
|
||||
if c.Http.Addr != "" {
|
||||
address = c.Http.Addr
|
||||
if c.HTTP.Addr != "" {
|
||||
address = c.HTTP.Addr
|
||||
}
|
||||
}
|
||||
timeout := 10 * time.Minute
|
||||
if c != nil && c.Http != nil && c.Http.Timeout != nil && c.Http.Timeout.AsDuration() > 0 {
|
||||
timeout = c.Http.Timeout.AsDuration()
|
||||
if c != nil && c.HTTP != nil && c.HTTP.Timeout > 0 {
|
||||
timeout = c.HTTP.Timeout
|
||||
}
|
||||
server := kratoshttp.NewServer(kratoshttp.Network(network), kratoshttp.Address(address), kratoshttp.Timeout(timeout))
|
||||
server.HandlePrefix("/", engine)
|
||||
|
||||
+15
-15
@@ -11,7 +11,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"kra/internal/conf"
|
||||
"kra/internal/config"
|
||||
"kra/internal/routecatalog"
|
||||
"kra/internal/server/handler"
|
||||
|
||||
@@ -32,7 +32,7 @@ func emptyHandlers() *handler.Set {
|
||||
}
|
||||
|
||||
func TestGinRouteContract(t *testing.T) {
|
||||
engine := NewGinEngine(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, emptyHandlers(), nil, nil, nil, nil, "test")
|
||||
engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{}}), nil, emptyHandlers(), nil, nil, nil, nil, "test")
|
||||
routes := engine.Routes()
|
||||
actual := make([]string, 0, len(routes))
|
||||
seen := make(map[string]struct{}, len(routes))
|
||||
@@ -66,7 +66,7 @@ func TestGinRouteContract(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGinRoutesMatchSharedCatalog(t *testing.T) {
|
||||
engine := NewGinEngine(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, emptyHandlers(), nil, nil, nil, nil, "test")
|
||||
engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{}}), nil, emptyHandlers(), nil, nil, nil, nil, "test")
|
||||
registered := make(map[string]struct{}, len(engine.Routes()))
|
||||
for _, route := range engine.Routes() {
|
||||
key := route.Method + " " + route.Path
|
||||
@@ -86,7 +86,7 @@ func TestGinRoutesMatchSharedCatalog(t *testing.T) {
|
||||
func TestGinStartupLogsEveryRegisteredRoute(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
logger := slog.New(slog.NewJSONHandler(&output, nil))
|
||||
engine := NewGinEngine(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, emptyHandlers(), nil, nil, nil, logger, "test")
|
||||
engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{}}), nil, emptyHandlers(), nil, nil, nil, logger, "test")
|
||||
text := output.String()
|
||||
if got, want := strings.Count(text, `"msg":"router registered"`), len(engine.Routes()); got != want {
|
||||
t.Fatalf("registered route log count = %d, want %d", got, want)
|
||||
@@ -97,7 +97,7 @@ func TestGinStartupLogsEveryRegisteredRoute(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSwaggerUsesRegisteredGinRoutes(t *testing.T) {
|
||||
engine := NewGinEngine(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, emptyHandlers(), nil, nil, nil, nil, "v1.0.0")
|
||||
engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{}}), nil, emptyHandlers(), nil, nil, nil, nil, "v1.0.0")
|
||||
response := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/swagger/doc.json", nil)
|
||||
engine.ServeHTTP(response, request)
|
||||
@@ -113,7 +113,7 @@ func TestSwaggerUsesRegisteredGinRoutes(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSwaggerSupportsRouterPrefix(t *testing.T) {
|
||||
engine := NewGinEngine(conf.NewRuntime(nil, &conf.AdminBackend{RouterPrefix: "/admin"}), nil, emptyHandlers(), nil, nil, nil, nil, "v1.0.0")
|
||||
engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{RouterPrefix: "/admin"}}), nil, emptyHandlers(), nil, nil, nil, nil, "v1.0.0")
|
||||
for _, path := range []string{"/admin/swagger/index.html", "/admin/swagger/doc.json"} {
|
||||
response := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
@@ -128,7 +128,7 @@ func TestSwaggerSupportsRouterPrefix(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSwaggerUsesRootBasePathWithoutRouterPrefix(t *testing.T) {
|
||||
engine := NewGinEngine(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, emptyHandlers(), nil, nil, nil, nil, "v1.0.0")
|
||||
engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{}}), nil, emptyHandlers(), nil, nil, nil, nil, "v1.0.0")
|
||||
response := httptest.NewRecorder()
|
||||
engine.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/swagger/doc.json", nil))
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"basePath":"/"`) {
|
||||
@@ -137,7 +137,7 @@ func TestSwaggerUsesRootBasePathWithoutRouterPrefix(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGinDoesNotTrustForwardedIPByDefault(t *testing.T) {
|
||||
engine := NewGinEngine(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, emptyHandlers(), nil, nil, nil, nil, "test")
|
||||
engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{}}), nil, emptyHandlers(), nil, nil, nil, nil, "test")
|
||||
engine.GET("/client-ip", func(c *gin.Context) { c.String(http.StatusOK, c.ClientIP()) })
|
||||
request := httptest.NewRequest(http.MethodGet, "/client-ip", nil)
|
||||
request.RemoteAddr = "203.0.113.10:4321"
|
||||
@@ -151,7 +151,7 @@ func TestGinDoesNotTrustForwardedIPByDefault(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAnnouncementDataSourceRequiresAuthentication(t *testing.T) {
|
||||
engine := NewGinEngine(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, emptyHandlers(), nil, nil, nil, nil, "test")
|
||||
engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{}}), nil, emptyHandlers(), nil, nil, nil, nil, "test")
|
||||
response := httptest.NewRecorder()
|
||||
engine.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/info/getInfoDataSource", nil))
|
||||
if response.Code != http.StatusUnauthorized {
|
||||
@@ -166,7 +166,7 @@ func TestLocalStorageResponseHeaders(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
runtime := conf.NewRuntime(nil, &conf.AdminBackend{Local: &conf.AdminBackend_Local{StorePath: root, PathPrefix: "uploads/file"}, Storage: &conf.AdminBackend_Storage{Type: "local"}})
|
||||
runtime := config.NewStore(&config.Config{Admin: &config.Admin{Local: &config.Local{StorePath: root, PathPrefix: "uploads/file"}, Storage: &config.Storage{Type: "local"}}})
|
||||
engine := NewGinEngine(runtime, nil, emptyHandlers(), nil, nil, nil, nil, "test")
|
||||
|
||||
for _, test := range []struct {
|
||||
@@ -190,7 +190,7 @@ func TestLocalStorageResponseHeaders(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLocalStorageRoutesAreRegistered(t *testing.T) {
|
||||
runtime := conf.NewRuntime(nil, &conf.AdminBackend{Local: &conf.AdminBackend_Local{StorePath: t.TempDir(), PathPrefix: "uploads/file"}})
|
||||
runtime := config.NewStore(&config.Config{Admin: &config.Admin{Local: &config.Local{StorePath: t.TempDir(), PathPrefix: "uploads/file"}}})
|
||||
engine := NewGinEngine(runtime, nil, emptyHandlers(), nil, nil, nil, nil, "test")
|
||||
seen := map[string]bool{}
|
||||
for _, route := range engine.Routes() {
|
||||
@@ -213,9 +213,9 @@ func TestLocalStorageOldPrefixStopsServingAfterReload(t *testing.T) {
|
||||
if err := os.WriteFile(filepath.Join(root, "image.png"), []byte("png"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runtime := conf.NewRuntime(nil, &conf.AdminBackend{Local: &conf.AdminBackend_Local{StorePath: root, PathPrefix: "uploads/file"}, Storage: &conf.AdminBackend_Storage{Type: "local"}})
|
||||
runtime := config.NewStore(&config.Config{Admin: &config.Admin{Local: &config.Local{StorePath: root, PathPrefix: "uploads/file"}, Storage: &config.Storage{Type: "local"}}})
|
||||
engine := NewGinEngine(runtime, nil, emptyHandlers(), nil, nil, nil, nil, "test")
|
||||
runtime.Replace(nil, &conf.AdminBackend{Local: &conf.AdminBackend_Local{StorePath: root, PathPrefix: "files"}, Storage: &conf.AdminBackend_Storage{Type: "local"}})
|
||||
runtime.Replace(&config.Config{Admin: &config.Admin{Local: &config.Local{StorePath: root, PathPrefix: "files"}, Storage: &config.Storage{Type: "local"}}})
|
||||
|
||||
oldResponse := httptest.NewRecorder()
|
||||
engine.ServeHTTP(oldResponse, httptest.NewRequest(http.MethodGet, "/uploads/file/image.png", nil))
|
||||
@@ -230,7 +230,7 @@ func TestLocalStorageOldPrefixStopsServingAfterReload(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLocalStorageConflictingPrefixFallsBackWithoutStartupPanic(t *testing.T) {
|
||||
runtime := conf.NewRuntime(nil, &conf.AdminBackend{Local: &conf.AdminBackend_Local{StorePath: t.TempDir(), PathPrefix: "api"}, Storage: &conf.AdminBackend_Storage{Type: "local"}})
|
||||
runtime := config.NewStore(&config.Config{Admin: &config.Admin{Local: &config.Local{StorePath: t.TempDir(), PathPrefix: "api"}, Storage: &config.Storage{Type: "local"}}})
|
||||
engine := NewGinEngine(runtime, nil, emptyHandlers(), nil, nil, nil, nil, "test")
|
||||
for _, route := range engine.Routes() {
|
||||
if route.Method == http.MethodGet && route.Path == "/api/*filepath" {
|
||||
@@ -240,7 +240,7 @@ func TestLocalStorageConflictingPrefixFallsBackWithoutStartupPanic(t *testing.T)
|
||||
}
|
||||
|
||||
func TestLocalStorageRouteIsNotRegisteredForRemoteStorage(t *testing.T) {
|
||||
runtime := conf.NewRuntime(nil, &conf.AdminBackend{Local: &conf.AdminBackend_Local{StorePath: t.TempDir(), PathPrefix: "uploads/file"}, Storage: &conf.AdminBackend_Storage{Type: "s3"}})
|
||||
runtime := config.NewStore(&config.Config{Admin: &config.Admin{Local: &config.Local{StorePath: t.TempDir(), PathPrefix: "uploads/file"}, Storage: &config.Storage{Type: "s3"}}})
|
||||
engine := NewGinEngine(runtime, nil, emptyHandlers(), nil, nil, nil, nil, "test")
|
||||
for _, route := range engine.Routes() {
|
||||
if strings.HasSuffix(route.Path, "/*filepath") && strings.HasPrefix(route.Path, "/uploads/file/") {
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"kra/internal/biz/system"
|
||||
|
||||
"kra/internal/conf"
|
||||
"kra/internal/config"
|
||||
"kra/internal/utils/routepath"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -15,7 +15,7 @@ type accessController interface {
|
||||
ContextWithDataScope(context.Context, uint, uint) (context.Context, error)
|
||||
}
|
||||
|
||||
func AccessControl(runtime *conf.Runtime, access accessController) gin.HandlerFunc {
|
||||
func AccessControl(runtime *config.Store, access accessController) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
claims := Claims(c)
|
||||
if claims == nil {
|
||||
@@ -29,8 +29,10 @@ func AccessControl(runtime *conf.Runtime, access accessController) gin.HandlerFu
|
||||
}
|
||||
path := c.Request.URL.Path
|
||||
policyPath := path
|
||||
if config := runtime.Admin(); config != nil {
|
||||
policyPath = routepath.Normalize(policyPath, config.RouterPrefix)
|
||||
if runtime != nil {
|
||||
if snapshot := runtime.Snapshot(); snapshot != nil && snapshot.Admin != nil {
|
||||
policyPath = routepath.Normalize(policyPath, snapshot.Admin.RouterPrefix)
|
||||
}
|
||||
}
|
||||
allowed, err := access.Authorize(c.Request.Context(), claims.AuthorityID, policyPath, c.Request.Method)
|
||||
if err != nil || !allowed {
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"kra/internal/conf"
|
||||
"kra/internal/config"
|
||||
"kra/internal/routecatalog"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -25,21 +25,23 @@ const defaultRequestBodyLimit int64 = 8 << 20
|
||||
|
||||
// AccessLog is the single global request/response capture point, matching
|
||||
// the reference middleware ordering and making every HTTP request observable.
|
||||
func AccessLog(runtime *conf.Runtime, logger *slog.Logger, version string) gin.HandlerFunc {
|
||||
func AccessLog(runtime *config.Store, logger *slog.Logger, version string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
started := time.Now()
|
||||
var requestBody []byte
|
||||
multipart := strings.Contains(c.GetHeader("Content-Type"), "multipart/form-data")
|
||||
mediaUpload := multipart && isMediaUploadRoute(c.FullPath())
|
||||
var config *conf.AdminBackend
|
||||
var admin *config.Admin
|
||||
if runtime != nil {
|
||||
config = runtime.Admin()
|
||||
if snapshot := runtime.Snapshot(); snapshot != nil {
|
||||
admin = snapshot.Admin
|
||||
}
|
||||
}
|
||||
bodyLimit := defaultRequestBodyLimit
|
||||
if mediaUpload {
|
||||
bodyLimit = system.DefaultMaxMediaFileSize + (1 << 20)
|
||||
if config != nil && config.Media != nil && config.Media.MaxFileSize > 0 {
|
||||
bodyLimit = config.Media.MaxFileSize + (1 << 20)
|
||||
if admin != nil && admin.Media != nil && admin.Media.MaxFileSize > 0 {
|
||||
bodyLimit = admin.Media.MaxFileSize + (1 << 20)
|
||||
}
|
||||
}
|
||||
bytesIn := c.Request.ContentLength
|
||||
@@ -73,8 +75,8 @@ func AccessLog(runtime *conf.Runtime, logger *slog.Logger, version string) gin.H
|
||||
bytesIn = 0
|
||||
}
|
||||
logLimit := 1024
|
||||
if config != nil && config.Zap != nil && config.Zap.AccessLogMaxBytes > 0 {
|
||||
logLimit = int(config.Zap.AccessLogMaxBytes)
|
||||
if admin != nil && admin.Zap != nil && admin.Zap.AccessLogMaxBytes > 0 {
|
||||
logLimit = admin.Zap.AccessLogMaxBytes
|
||||
}
|
||||
bodyPolicy := routecatalog.BodyPolicyFor(c.Request.Method, c.Request.URL.Path)
|
||||
paymentCallback := bodyPolicy == routecatalog.BodyPolicyPaymentCallback
|
||||
@@ -140,13 +142,13 @@ func AccessLog(runtime *conf.Runtime, logger *slog.Logger, version string) gin.H
|
||||
"req_query", redactQuery(c.Request.URL.RawQuery),
|
||||
}
|
||||
}
|
||||
if !paymentCallback && config != nil && config.Zap != nil && config.Zap.AccessReqHeaders {
|
||||
if !paymentCallback && admin != nil && admin.Zap != nil && admin.Zap.AccessReqHeaders {
|
||||
attributes = append(attributes, "req_headers", redactHeaders(c.Request.Header))
|
||||
}
|
||||
if !paymentCallback && config != nil && config.Zap != nil && config.Zap.AccessReqBody {
|
||||
if !paymentCallback && admin != nil && admin.Zap != nil && admin.Zap.AccessReqBody {
|
||||
attributes = append(attributes, "req_body", requestText)
|
||||
}
|
||||
if !paymentCallback && config != nil && config.Zap != nil && config.Zap.AccessRespData {
|
||||
if !paymentCallback && admin != nil && admin.Zap != nil && admin.Zap.AccessRespData {
|
||||
attributes = append(attributes, "resp_data", responseText)
|
||||
}
|
||||
if !paymentCallback && privateErrors != "" {
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"kra/internal/conf"
|
||||
"kra/internal/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -18,7 +18,7 @@ func TestAccessLogRejectsOversizedRequestBody(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
called := false
|
||||
engine.Use(AccessLog(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, "test"))
|
||||
engine.Use(AccessLog(config.NewStore(&config.Config{Admin: &config.Admin{}}), nil, "test"))
|
||||
engine.POST("/payload", func(c *gin.Context) { called = true })
|
||||
request := httptest.NewRequest(http.MethodPost, "/payload", strings.NewReader(strings.Repeat("a", int(defaultRequestBodyLimit+1))))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
@@ -34,7 +34,7 @@ func TestAccessLogRejectsOversizedMultipartOnOrdinaryRoute(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
called := false
|
||||
engine.Use(AccessLog(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, "test"))
|
||||
engine.Use(AccessLog(config.NewStore(&config.Config{Admin: &config.Admin{}}), nil, "test"))
|
||||
engine.POST("/login", func(c *gin.Context) { called = true })
|
||||
request := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(strings.Repeat("a", int(defaultRequestBodyLimit+1))))
|
||||
request.ContentLength = -1
|
||||
@@ -51,7 +51,7 @@ func TestAccessLogAllowsMediaLimitOnlyOnUploadRoute(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
called := false
|
||||
engine.Use(AccessLog(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, "test"))
|
||||
engine.Use(AccessLog(config.NewStore(&config.Config{Admin: &config.Admin{}}), nil, "test"))
|
||||
engine.POST("/api/fileUploadAndDownload/upload", func(c *gin.Context) {
|
||||
called = true
|
||||
_, _ = io.Copy(io.Discard, c.Request.Body)
|
||||
@@ -70,7 +70,7 @@ func TestAccessLogRedactsPaymentCallbackPayloadAndHeaders(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
var logs bytes.Buffer
|
||||
logger := slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
runtime := conf.NewRuntime(nil, &conf.AdminBackend{Zap: &conf.AdminBackend_Zap{AccessReqBody: true, AccessReqHeaders: true, AccessRespData: true}})
|
||||
runtime := config.NewStore(&config.Config{Admin: &config.Admin{Zap: &config.Zap{AccessReqBody: true, AccessReqHeaders: true, AccessRespData: true}}})
|
||||
engine := gin.New()
|
||||
engine.Use(AccessLog(runtime, logger, "test"))
|
||||
engine.POST("/api/payment/callback/:provider", func(c *gin.Context) {
|
||||
@@ -101,7 +101,7 @@ func TestAccessLogOmitsPaymentIntegrationConfigBody(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
var logs bytes.Buffer
|
||||
logger := slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
runtime := conf.NewRuntime(nil, &conf.AdminBackend{Zap: &conf.AdminBackend_Zap{AccessReqBody: true, AccessReqHeaders: true, AccessRespData: true}})
|
||||
runtime := config.NewStore(&config.Config{Admin: &config.Admin{Zap: &config.Zap{AccessReqBody: true, AccessReqHeaders: true, AccessRespData: true}}})
|
||||
engine := gin.New()
|
||||
engine.Use(AccessLog(runtime, logger, "test"))
|
||||
engine.PUT("/api/integration/configs/:kind/:provider", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"code": 0}) })
|
||||
@@ -127,7 +127,7 @@ func TestAccessLogRedactsWebSocketQueryToken(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
var logs bytes.Buffer
|
||||
logger := slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
runtime := conf.NewRuntime(nil, &conf.AdminBackend{})
|
||||
runtime := config.NewStore(&config.Config{Admin: &config.Admin{}})
|
||||
engine := gin.New()
|
||||
engine.Use(AccessLog(runtime, logger, "test"))
|
||||
engine.GET("/ws", func(c *gin.Context) { c.Status(http.StatusSwitchingProtocols) })
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"kra/internal/conf"
|
||||
"kra/internal/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -36,7 +36,7 @@ func TestAccessControlFailsClosedWhenDataScopeResolutionFails(t *testing.T) {
|
||||
c.Set(claimsKey, &system.AuthClaims{ID: 7, AuthorityID: 888})
|
||||
c.Next()
|
||||
})
|
||||
engine.Use(AccessControl(conf.NewRuntime(nil, &conf.AdminBackend{}), &accessControllerStub{scopeErr: errors.New("database unavailable")}))
|
||||
engine.Use(AccessControl(config.NewStore(&config.Config{Admin: &config.Admin{}}), &accessControllerStub{scopeErr: errors.New("database unavailable")}))
|
||||
called := false
|
||||
engine.GET("/protected", func(c *gin.Context) { called = true })
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"kra/internal/conf"
|
||||
"kra/internal/config"
|
||||
"kra/internal/routecatalog"
|
||||
"kra/internal/service"
|
||||
"kra/internal/service/dto"
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
|
||||
const ctxOperationAuditPersistFailedKey = "operation_audit_persist_failed"
|
||||
|
||||
func OperationAudit(runtime *conf.Runtime, recorder *service.AuditRecorder) gin.HandlerFunc {
|
||||
func OperationAudit(runtime *config.Store, recorder *service.AuditRecorder) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if runtime == nil || recorder == nil {
|
||||
c.Next()
|
||||
@@ -39,8 +39,8 @@ func OperationAudit(runtime *conf.Runtime, recorder *service.AuditRecorder) gin.
|
||||
}
|
||||
var requestBody []byte
|
||||
maxBytes := 1024
|
||||
if config := runtime.Admin(); config != nil && config.Zap != nil && config.Zap.AccessLogMaxBytes > 0 {
|
||||
maxBytes = int(config.Zap.AccessLogMaxBytes)
|
||||
if snapshot := runtime.Snapshot(); snapshot != nil && snapshot.Admin != nil && snapshot.Admin.Zap != nil && snapshot.Admin.Zap.AccessLogMaxBytes > 0 {
|
||||
maxBytes = snapshot.Admin.Zap.AccessLogMaxBytes
|
||||
}
|
||||
if c.Request.Method == http.MethodGet {
|
||||
requestBody = operationQueryBody(c.Request.URL.RawQuery)
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"kra/internal/conf"
|
||||
"kra/internal/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -17,18 +17,19 @@ const (
|
||||
|
||||
// CORS applies the current administration CORS rules on every request so a
|
||||
// configuration reload takes effect without rebuilding the Gin engine.
|
||||
func CORS(runtime *conf.Runtime) gin.HandlerFunc {
|
||||
func CORS(runtime *config.Store) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if runtime == nil {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
config := runtime.Admin()
|
||||
if config == nil || config.Cors == nil {
|
||||
snapshot := runtime.Snapshot()
|
||||
if snapshot == nil || snapshot.Admin == nil || snapshot.Admin.CORS == nil {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
mode := strings.TrimSpace(config.Cors.Mode)
|
||||
cors := snapshot.Admin.CORS
|
||||
mode := strings.TrimSpace(cors.Mode)
|
||||
origin := c.GetHeader("Origin")
|
||||
corsHandled := false
|
||||
if mode == "allow-all" {
|
||||
@@ -36,7 +37,7 @@ func CORS(runtime *conf.Runtime) gin.HandlerFunc {
|
||||
setCORSHeaders(c, origin, defaultCORSHeaders, defaultCORSMethods, defaultCORSExpose, true)
|
||||
corsHandled = true
|
||||
}
|
||||
} else if rule := matchingCORSRule(config.Cors.Whitelist, origin); rule != nil {
|
||||
} else if rule := matchingCORSRule(cors.Whitelist, origin); rule != nil {
|
||||
setCORSHeaders(c, rule.AllowOrigin, rule.AllowHeaders, rule.AllowMethods, rule.ExposeHeaders, rule.AllowCredentials)
|
||||
corsHandled = true
|
||||
} else if mode == "strict-whitelist" && !(c.Request.Method == http.MethodGet && isHealthPath(c.Request.URL.Path)) {
|
||||
@@ -56,7 +57,7 @@ func isHealthPath(path string) bool {
|
||||
return path == "/health" || strings.HasSuffix(path, "/health")
|
||||
}
|
||||
|
||||
func matchingCORSRule(rules []*conf.AdminBackend_CORSRule, origin string) *conf.AdminBackend_CORSRule {
|
||||
func matchingCORSRule(rules []*config.CORSRule, origin string) *config.CORSRule {
|
||||
for _, rule := range rules {
|
||||
if rule != nil && origin == rule.AllowOrigin {
|
||||
return rule
|
||||
|
||||
@@ -5,15 +5,15 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"kra/internal/conf"
|
||||
"kra/internal/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func runCORSTest(t *testing.T, admin *conf.AdminBackend, method, origin string) *httptest.ResponseRecorder {
|
||||
func runCORSTest(t *testing.T, admin *config.Admin, method, origin string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
engine := gin.New()
|
||||
engine.Use(CORS(conf.NewRuntime(nil, admin)))
|
||||
engine.Use(CORS(config.NewStore(&config.Config{Admin: admin})))
|
||||
engine.Any("/test", func(c *gin.Context) { c.Status(http.StatusOK) })
|
||||
request := httptest.NewRequest(method, "/test", nil)
|
||||
if origin != "" {
|
||||
@@ -25,14 +25,14 @@ func runCORSTest(t *testing.T, admin *conf.AdminBackend, method, origin string)
|
||||
}
|
||||
|
||||
func TestCORSDoesNotConsumeUnmatchedWhitelistPreflight(t *testing.T) {
|
||||
response := runCORSTest(t, &conf.AdminBackend{Cors: &conf.AdminBackend_CORS{Mode: "whitelist"}}, http.MethodOptions, "https://unknown.example")
|
||||
response := runCORSTest(t, &config.Admin{CORS: &config.CORS{Mode: "whitelist"}}, http.MethodOptions, "https://unknown.example")
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("unmatched whitelist preflight status = %d, want %d", response.Code, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSConsumesMatchedWhitelistPreflight(t *testing.T) {
|
||||
response := runCORSTest(t, &conf.AdminBackend{Cors: &conf.AdminBackend_CORS{Mode: "whitelist", Whitelist: []*conf.AdminBackend_CORSRule{{AllowOrigin: "https://admin.example"}}}}, http.MethodOptions, "https://admin.example")
|
||||
response := runCORSTest(t, &config.Admin{CORS: &config.CORS{Mode: "whitelist", Whitelist: []*config.CORSRule{{AllowOrigin: "https://admin.example"}}}}, http.MethodOptions, "https://admin.example")
|
||||
if response.Code != http.StatusNoContent {
|
||||
t.Fatalf("matched whitelist preflight status = %d, want %d", response.Code, http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -9,14 +9,15 @@ import (
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"kra/internal/conf"
|
||||
"kra/internal/config"
|
||||
"kra/internal/utils/uploadpolicy"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Register adds local upload routes when local storage is enabled. A route is
|
||||
// skipped when its root conflicts with an existing GET/HEAD route.
|
||||
func Register(engine *gin.Engine, runtime *conf.Runtime) {
|
||||
func Register(engine *gin.Engine, runtime *config.Store) {
|
||||
config := localConfig(runtime)
|
||||
if config == nil || strings.Trim(config.PathPrefix, "/") == "" {
|
||||
return
|
||||
@@ -36,7 +37,7 @@ func Register(engine *gin.Engine, runtime *conf.Runtime) {
|
||||
|
||||
// Serve resolves the current runtime configuration on every request so a
|
||||
// configuration reload takes effect without rebuilding the Gin engine.
|
||||
func Serve(c *gin.Context, runtime *conf.Runtime) bool {
|
||||
func Serve(c *gin.Context, runtime *config.Store) bool {
|
||||
config := localConfig(runtime)
|
||||
if config == nil {
|
||||
return false
|
||||
@@ -45,18 +46,18 @@ func Serve(c *gin.Context, runtime *conf.Runtime) bool {
|
||||
return serveAt(c, runtime, prefix)
|
||||
}
|
||||
|
||||
func localConfig(runtime *conf.Runtime) *conf.AdminBackend_Local {
|
||||
func localConfig(runtime *config.Store) *config.Local {
|
||||
if runtime == nil {
|
||||
return nil
|
||||
}
|
||||
config := runtime.Admin()
|
||||
if config == nil || config.Local == nil || config.Local.StorePath == "" {
|
||||
snapshot := runtime.Snapshot()
|
||||
if snapshot == nil || snapshot.Admin == nil || snapshot.Admin.Local == nil || snapshot.Admin.Local.StorePath == "" {
|
||||
return nil
|
||||
}
|
||||
if config.Storage != nil && config.Storage.Type != "" && config.Storage.Type != "local" {
|
||||
if snapshot.Admin.Storage != nil && snapshot.Admin.Storage.Type != "" && snapshot.Admin.Storage.Type != "local" {
|
||||
return nil
|
||||
}
|
||||
return config.Local
|
||||
return snapshot.Admin.Local
|
||||
}
|
||||
|
||||
func localStorageRouteConflicts(routes []gin.RouteInfo, prefix string) bool {
|
||||
@@ -73,7 +74,7 @@ func localStorageRouteConflicts(routes []gin.RouteInfo, prefix string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func serveAt(c *gin.Context, runtime *conf.Runtime, prefix string) bool {
|
||||
func serveAt(c *gin.Context, runtime *config.Store, prefix string) bool {
|
||||
config := localConfig(runtime)
|
||||
if config == nil || "/"+strings.Trim(config.PathPrefix, "/") != prefix {
|
||||
return false
|
||||
@@ -83,24 +84,13 @@ func serveAt(c *gin.Context, runtime *conf.Runtime, prefix string) bool {
|
||||
}
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
filename := path.Base(c.Request.URL.Path)
|
||||
if !canServeInline(filename) {
|
||||
if !uploadpolicy.CanServeInline(filename) {
|
||||
c.Header("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": filename}))
|
||||
}
|
||||
http.StripPrefix(prefix, http.FileServer(filesOnly{FileSystem: http.Dir(config.StorePath)})).ServeHTTP(c.Writer, c.Request)
|
||||
return true
|
||||
}
|
||||
|
||||
func canServeInline(filename string) bool {
|
||||
switch strings.ToLower(path.Ext(filename)) {
|
||||
case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".ico", ".avif",
|
||||
".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac",
|
||||
".mp4", ".webm", ".mov", ".avi", ".mkv":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type filesOnly struct{ http.FileSystem }
|
||||
|
||||
func (f filesOnly) Open(name string) (http.File, error) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"kra/internal/conf"
|
||||
"kra/internal/config"
|
||||
"kra/internal/integration/runtimeconfig"
|
||||
websocketintegration "kra/internal/integration/websocket"
|
||||
"kra/internal/server/middleware"
|
||||
@@ -44,7 +44,7 @@ func TestWebSocketRouteRequiresAndAcceptsLoginToken(t *testing.T) {
|
||||
defer cleanup()
|
||||
|
||||
auth := &websocketAuthStub{claims: &system.AuthClaims{ID: 7}}
|
||||
engine := NewGinEngineWithRuntime(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, auth, nil, nil, nil, "test", nil, ws)
|
||||
engine := NewGinEngineWithRuntime(config.NewStore(&config.Config{Admin: &config.Admin{}}), nil, auth, nil, nil, nil, "test", nil, ws)
|
||||
|
||||
unauthorized := httptest.NewRecorder()
|
||||
engine.ServeHTTP(unauthorized, httptest.NewRequest(http.MethodGet, "/ws", nil))
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// Package uploadpolicy contains the stateless filename and content policy
|
||||
// shared by upload handling and local file serving.
|
||||
package uploadpolicy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
allowedExtensions = map[string]struct{}{
|
||||
".jpg": {}, ".jpeg": {}, ".png": {}, ".gif": {}, ".webp": {}, ".bmp": {}, ".ico": {}, ".avif": {},
|
||||
".mp3": {}, ".wav": {}, ".ogg": {}, ".m4a": {}, ".flac": {}, ".aac": {},
|
||||
".mp4": {}, ".webm": {}, ".mov": {}, ".avi": {}, ".mkv": {},
|
||||
".txt": {}, ".md": {}, ".csv": {}, ".json": {}, ".log": {}, ".pdf": {},
|
||||
".doc": {}, ".docx": {}, ".xls": {}, ".xlsx": {}, ".ppt": {}, ".pptx": {},
|
||||
".zip": {}, ".rar": {}, ".7z": {}, ".tar": {}, ".gz": {}, ".tgz": {}, ".bin": {},
|
||||
}
|
||||
|
||||
inlineExtensions = map[string]struct{}{
|
||||
".jpg": {}, ".jpeg": {}, ".png": {}, ".gif": {}, ".webp": {}, ".bmp": {}, ".ico": {}, ".avif": {},
|
||||
".mp3": {}, ".wav": {}, ".ogg": {}, ".m4a": {}, ".flac": {}, ".aac": {},
|
||||
".mp4": {}, ".webm": {}, ".mov": {}, ".avi": {}, ".mkv": {},
|
||||
}
|
||||
)
|
||||
|
||||
var errFileExtensionNotAllowed = errors.New("file extension is not allowed")
|
||||
|
||||
// ValidateFileName rejects paths, surrounding whitespace, and extensions
|
||||
// outside the upload allowlist.
|
||||
func ValidateFileName(filename string) error {
|
||||
if filename == "" || strings.TrimSpace(filename) != filename || strings.ContainsAny(filename, `/\`) {
|
||||
return errFileExtensionNotAllowed
|
||||
}
|
||||
if _, ok := allowedExtensions[strings.ToLower(filepath.Ext(filename))]; !ok {
|
||||
return errFileExtensionNotAllowed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CanServeInline reports whether a stored object may be rendered by a
|
||||
// browser instead of being forced to download.
|
||||
func CanServeInline(filename string) bool {
|
||||
if ValidateFileName(filename) != nil {
|
||||
return false
|
||||
}
|
||||
_, ok := inlineExtensions[strings.ToLower(filepath.Ext(filename))]
|
||||
return ok
|
||||
}
|
||||
|
||||
// MIMEByExtension returns the small extension fallback table used when a
|
||||
// client did not provide a content type. Unknown extensions are left for
|
||||
// content sniffing by the caller.
|
||||
func MIMEByExtension(ext string) string {
|
||||
switch strings.ToLower(ext) {
|
||||
case ".json":
|
||||
return "application/json"
|
||||
case ".xml":
|
||||
return "application/xml"
|
||||
case ".pdf":
|
||||
return "application/pdf"
|
||||
case ".zip":
|
||||
return "application/zip"
|
||||
case ".mp4":
|
||||
return "video/mp4"
|
||||
case ".mp3":
|
||||
return "audio/mpeg"
|
||||
case ".csv":
|
||||
return "text/csv"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package uploadpolicy
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateFileName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "known extension", value: "photo.PNG"},
|
||||
{name: "document", value: "report.pdf"},
|
||||
{name: "empty", value: "", wantErr: true},
|
||||
{name: "unknown extension", value: "payload.exe", wantErr: true},
|
||||
{name: "path", value: "../photo.png", wantErr: true},
|
||||
{name: "backslash path", value: `dir\photo.png`, wantErr: true},
|
||||
{name: "surrounding whitespace", value: "photo.png ", wantErr: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if err := ValidateFileName(tt.value); (err != nil) != tt.wantErr {
|
||||
t.Fatalf("ValidateFileName(%q) error = %v, wantErr %v", tt.value, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanServeInline(t *testing.T) {
|
||||
for _, filename := range []string{"photo.png", "track.MP3", "movie.webm"} {
|
||||
if !CanServeInline(filename) {
|
||||
t.Errorf("CanServeInline(%q) = false", filename)
|
||||
}
|
||||
}
|
||||
for _, filename := range []string{"report.pdf", "archive.zip", "payload.exe", "../photo.png"} {
|
||||
if CanServeInline(filename) {
|
||||
t.Errorf("CanServeInline(%q) = true", filename)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMIMEByExtension(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
".JSON": "application/json",
|
||||
".mp3": "audio/mpeg",
|
||||
".csv": "text/csv",
|
||||
".exe": "",
|
||||
}
|
||||
for ext, want := range tests {
|
||||
if got := MIMEByExtension(ext); got != want {
|
||||
t.Errorf("MIMEByExtension(%q) = %q, want %q", ext, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,9 +23,6 @@ type TaskExecutor struct {
|
||||
}
|
||||
|
||||
func NewTaskExecutorWithRegistry(tasks *taskbiz.TaskUsecase, methods taskbiz.TaskMethodRegistry) *TaskExecutor {
|
||||
if methods == nil {
|
||||
methods = taskbiz.DefaultTaskMethodRegistry()
|
||||
}
|
||||
return &TaskExecutor{tasks: tasks, methods: methods}
|
||||
}
|
||||
|
||||
@@ -139,8 +136,6 @@ func (e *TaskExecutor) runMethod(ctx context.Context, task *taskbiz.TimedTask) e
|
||||
var ok bool
|
||||
if e.methods != nil {
|
||||
method, ok = e.methods.Lookup(task.MethodName)
|
||||
} else {
|
||||
method, ok = taskbiz.TaskMethodByName(task.MethodName)
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("方法 %s 未注册(需通过 platform/task.Registry 注册)", task.MethodName)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
taskbiz "kra/internal/biz/task"
|
||||
platformtask "kra/pkg/task"
|
||||
)
|
||||
|
||||
func TestPrivateIP(t *testing.T) {
|
||||
@@ -25,11 +26,12 @@ func TestPrivateIP(t *testing.T) {
|
||||
|
||||
func TestRunMethodReportsParentDeadlineAsTimeout(t *testing.T) {
|
||||
const methodName = "worker-test-parent-deadline"
|
||||
taskbiz.RegisterTaskMethod(methodName, "test", func(ctx context.Context, _ json.RawMessage) error {
|
||||
methods := platformtask.NewRegistry()
|
||||
methods.Register(platformtask.Method{Name: methodName, Description: "test", Run: func(ctx context.Context, _ json.RawMessage) error {
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
})
|
||||
executor := &TaskExecutor{}
|
||||
}})
|
||||
executor := &TaskExecutor{methods: methods}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
||||
defer cancel()
|
||||
if err := executor.runMethod(ctx, &taskbiz.TimedTask{MethodName: methodName}); !errors.Is(err, errTaskTimeout) {
|
||||
@@ -39,10 +41,11 @@ func TestRunMethodReportsParentDeadlineAsTimeout(t *testing.T) {
|
||||
|
||||
func TestRunMethodConvertsPanicToError(t *testing.T) {
|
||||
const methodName = "worker-test-panic"
|
||||
taskbiz.RegisterTaskMethod(methodName, "test", func(context.Context, json.RawMessage) error {
|
||||
methods := platformtask.NewRegistry()
|
||||
methods.Register(platformtask.Method{Name: methodName, Description: "test", Run: func(context.Context, json.RawMessage) error {
|
||||
panic("boom")
|
||||
})
|
||||
executor := &TaskExecutor{}
|
||||
}})
|
||||
executor := &TaskExecutor{methods: methods}
|
||||
err := executor.runMethod(context.Background(), &taskbiz.TimedTask{MethodName: methodName})
|
||||
if err == nil || !strings.Contains(err.Error(), "panic: boom") {
|
||||
t.Fatalf("runMethod() error = %v, want recovered panic", err)
|
||||
@@ -52,12 +55,13 @@ func TestRunMethodConvertsPanicToError(t *testing.T) {
|
||||
func TestRunMethodUsesParentContext(t *testing.T) {
|
||||
const methodName = "worker-test-parent-context"
|
||||
started := make(chan struct{})
|
||||
taskbiz.RegisterTaskMethod(methodName, "test", func(ctx context.Context, _ json.RawMessage) error {
|
||||
methods := platformtask.NewRegistry()
|
||||
methods.Register(platformtask.Method{Name: methodName, Description: "test", Run: func(ctx context.Context, _ json.RawMessage) error {
|
||||
close(started)
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
})
|
||||
executor := &TaskExecutor{}
|
||||
}})
|
||||
executor := &TaskExecutor{methods: methods}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
@@ -82,11 +86,12 @@ func TestRunMethodUsesParentContext(t *testing.T) {
|
||||
func TestRunMethodHonorsDeadlineWhenMethodIgnoresContext(t *testing.T) {
|
||||
const methodName = "worker-test-ignores-context"
|
||||
release := make(chan struct{})
|
||||
taskbiz.RegisterTaskMethod(methodName, "test", func(context.Context, json.RawMessage) error {
|
||||
methods := platformtask.NewRegistry()
|
||||
methods.Register(platformtask.Method{Name: methodName, Description: "test", Run: func(context.Context, json.RawMessage) error {
|
||||
<-release
|
||||
return nil
|
||||
})
|
||||
executor := &TaskExecutor{}
|
||||
}})
|
||||
executor := &TaskExecutor{methods: methods}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
||||
defer cancel()
|
||||
if err := executor.runMethod(ctx, &taskbiz.TimedTask{MethodName: methodName}); !errors.Is(err, errTaskTimeout) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"kra/internal/biz/system"
|
||||
taskbiz "kra/internal/biz/task"
|
||||
|
||||
"kra/internal/conf"
|
||||
"kra/internal/config"
|
||||
platformtask "kra/pkg/task"
|
||||
)
|
||||
|
||||
@@ -17,10 +17,10 @@ type TaskMethods struct {
|
||||
tasks *taskbiz.TaskUsecase
|
||||
maintenance *system.MaintenanceUsecase
|
||||
media *system.MediaUsecase
|
||||
runtime *conf.Runtime
|
||||
runtime *config.Store
|
||||
}
|
||||
|
||||
func NewTaskMethods(tasks *taskbiz.TaskUsecase, maintenance *system.MaintenanceUsecase, media *system.MediaUsecase, runtime *conf.Runtime) *TaskMethods {
|
||||
func NewTaskMethods(tasks *taskbiz.TaskUsecase, maintenance *system.MaintenanceUsecase, media *system.MediaUsecase, runtime *config.Store) *TaskMethods {
|
||||
return &TaskMethods{tasks: tasks, maintenance: maintenance, media: media, runtime: runtime}
|
||||
}
|
||||
|
||||
@@ -53,12 +53,14 @@ func (methods *TaskMethods) RegisterTasks(registry *platformtask.Registry) {
|
||||
return errors.New("CleanStaleUploads 任务依赖未就绪")
|
||||
}
|
||||
ttl := 24
|
||||
var config *conf.AdminBackend
|
||||
var admin *config.Admin
|
||||
if methods.runtime != nil {
|
||||
config = methods.runtime.Admin()
|
||||
if snapshot := methods.runtime.Snapshot(); snapshot != nil {
|
||||
admin = snapshot.Admin
|
||||
}
|
||||
}
|
||||
if config != nil && config.Media != nil && config.Media.SessionTtl > 0 {
|
||||
ttl = int(config.Media.SessionTtl)
|
||||
if admin != nil && admin.Media != nil && admin.Media.SessionTTL > 0 {
|
||||
ttl = admin.Media.SessionTTL
|
||||
}
|
||||
return methods.media.CleanupStale(ctx, ttl)
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
taskbiz "kra/internal/biz/task"
|
||||
platformtask "kra/pkg/task"
|
||||
)
|
||||
|
||||
type workerTaskRepo struct {
|
||||
@@ -63,8 +64,9 @@ func (r *workerTaskRepo) ListTaskLogs(context.Context, int, int, uint, string) (
|
||||
}
|
||||
|
||||
func newTestTaskScheduler(repo *workerTaskRepo) *TaskScheduler {
|
||||
tasks := taskbiz.NewTaskUsecase(repo)
|
||||
executor := &TaskExecutor{tasks: tasks}
|
||||
methods := platformtask.NewRegistry()
|
||||
tasks := taskbiz.NewTaskUsecaseWithRegistry(repo, methods)
|
||||
executor := &TaskExecutor{tasks: tasks, methods: methods}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
return NewTaskScheduler(tasks, nil, executor, logger)
|
||||
}
|
||||
@@ -136,16 +138,18 @@ func TestManualTriggerSkipsOverlappingExecution(t *testing.T) {
|
||||
started := make(chan struct{}, 2)
|
||||
release := make(chan struct{})
|
||||
var calls atomic.Int32
|
||||
taskbiz.RegisterTaskMethod(methodName, "test", func(context.Context, json.RawMessage) error {
|
||||
methods := platformtask.NewRegistry()
|
||||
methods.Register(platformtask.Method{Name: methodName, Description: "test", Run: func(context.Context, json.RawMessage) error {
|
||||
calls.Add(1)
|
||||
started <- struct{}{}
|
||||
<-release
|
||||
return nil
|
||||
})
|
||||
}})
|
||||
|
||||
task := &taskbiz.TimedTask{ID: 9, Name: "single", ExecutorType: taskbiz.TaskExecutorMethod, MethodName: methodName}
|
||||
repo := &workerTaskRepo{items: []*taskbiz.TimedTask{task}, logs: make(chan *taskbiz.TimedTaskLog, 2)}
|
||||
scheduler := newTestTaskScheduler(repo)
|
||||
tasks := taskbiz.NewTaskUsecaseWithRegistry(repo, methods)
|
||||
scheduler := NewTaskScheduler(tasks, nil, &TaskExecutor{tasks: tasks, methods: methods}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err := scheduler.TriggerID(context.Background(), task.ID); err != nil {
|
||||
t.Fatalf("first manual trigger failed: %v", err)
|
||||
}
|
||||
@@ -193,16 +197,18 @@ func TestAutomaticAndManualTriggersShareOverlapGate(t *testing.T) {
|
||||
started := make(chan struct{}, 1)
|
||||
release := make(chan struct{})
|
||||
var calls atomic.Int32
|
||||
taskbiz.RegisterTaskMethod(methodName, "test", func(context.Context, json.RawMessage) error {
|
||||
methods := platformtask.NewRegistry()
|
||||
methods.Register(platformtask.Method{Name: methodName, Description: "test", Run: func(context.Context, json.RawMessage) error {
|
||||
calls.Add(1)
|
||||
started <- struct{}{}
|
||||
<-release
|
||||
return nil
|
||||
})
|
||||
}})
|
||||
|
||||
task := &taskbiz.TimedTask{ID: 10, Name: "shared-gate", ExecutorType: taskbiz.TaskExecutorMethod, MethodName: methodName}
|
||||
repo := &workerTaskRepo{logs: make(chan *taskbiz.TimedTaskLog, 1)}
|
||||
scheduler := newTestTaskScheduler(repo)
|
||||
tasks := taskbiz.NewTaskUsecaseWithRegistry(repo, methods)
|
||||
scheduler := NewTaskScheduler(tasks, nil, &TaskExecutor{tasks: tasks, methods: methods}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if !scheduler.dispatch(task, "auto", true) {
|
||||
t.Fatal("automatic dispatch was rejected while idle")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user