feat: add durable Kuknos settlement coordination
This commit is contained in:
@@ -3,6 +3,8 @@ package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/knadh/koanf/parsers/toml"
|
||||
@@ -11,21 +13,22 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Environment string `koanf:"environment"`
|
||||
GRPC GRPCConfig `koanf:"grpc"`
|
||||
Database DatabaseConfig `koanf:"database"`
|
||||
Environment string `koanf:"environment"`
|
||||
GRPC GRPCConfig `koanf:"grpc"`
|
||||
Database DatabaseConfig `koanf:"db"`
|
||||
Settlement SettlementConfig `koanf:"settlement"`
|
||||
}
|
||||
|
||||
type DashboardConfig struct {
|
||||
Environment string `koanf:"environment"`
|
||||
HTTP HTTPConfig `koanf:"http"`
|
||||
Database DatabaseConfig `koanf:"database"`
|
||||
Database DatabaseConfig `koanf:"db"`
|
||||
}
|
||||
|
||||
type GRPCConfig struct {
|
||||
Host string `koanf:"host"`
|
||||
Port int `koanf:"port"`
|
||||
ShutdownTimeout time.Duration `koanf:"shutdown-timeout"`
|
||||
ShutdownTimeout time.Duration `koanf:"timeout"`
|
||||
}
|
||||
|
||||
type HTTPConfig struct {
|
||||
@@ -36,12 +39,23 @@ type HTTPConfig struct {
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
Host string `koanf:"host"`
|
||||
Port int `koanf:"port"`
|
||||
Name string `koanf:"name"`
|
||||
User string `koanf:"user"`
|
||||
Password string `koanf:"password"`
|
||||
SSLMode string `koanf:"ssl-mode"`
|
||||
Host string `koanf:"host"`
|
||||
Port int `koanf:"port"`
|
||||
Name string `koanf:"name"`
|
||||
User string `koanf:"user"`
|
||||
Password string `koanf:"password"`
|
||||
GormLogLevel int `koanf:"gorm-log-level"`
|
||||
SSLMode string `koanf:"-"`
|
||||
}
|
||||
|
||||
type SettlementConfig struct {
|
||||
Enabled bool `koanf:"enabled"`
|
||||
Endpoint string `koanf:"endpoint"`
|
||||
WorkerID string `koanf:"worker-id"`
|
||||
PollInterval time.Duration `koanf:"poll-interval"`
|
||||
BatchSize int `koanf:"batch-size"`
|
||||
MaxAttempts int `koanf:"max-attempts"`
|
||||
AdminToken string `koanf:"admin-token"`
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
@@ -52,12 +66,14 @@ func Load(path string) (*Config, error) {
|
||||
Port: 8600,
|
||||
ShutdownTimeout: 10 * time.Second,
|
||||
},
|
||||
Database: defaultDatabaseConfig(),
|
||||
Database: defaultDatabaseConfig(),
|
||||
Settlement: SettlementConfig{WorkerID: "gl-settlement-1", PollInterval: 5 * time.Second, BatchSize: 20, MaxAttempts: 8},
|
||||
}
|
||||
|
||||
if err := load(path, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
applyEnvironment(&cfg.Database, &cfg.Settlement)
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -78,12 +94,28 @@ func LoadDashboard(path string) (*DashboardConfig, error) {
|
||||
if err := load(path, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
applyEnvironment(&cfg.Database, nil)
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func applyEnvironment(database *DatabaseConfig, settlement *SettlementConfig) {
|
||||
if value, ok := os.LookupEnv("DARANO_GL_DB_PASSWORD"); ok {
|
||||
database.Password = value
|
||||
}
|
||||
if settlement == nil {
|
||||
return
|
||||
}
|
||||
if value, ok := os.LookupEnv("DARANO_GL_SETTLEMENT_ENDPOINT"); ok {
|
||||
settlement.Endpoint = strings.TrimRight(value, "/")
|
||||
}
|
||||
if value, ok := os.LookupEnv("DARANO_GL_ADMIN_TOKEN"); ok {
|
||||
settlement.AdminToken = value
|
||||
}
|
||||
}
|
||||
|
||||
func load(path string, target any) error {
|
||||
k := koanf.New(".")
|
||||
if err := k.Load(file.Provider(path), toml.Parser()); err != nil {
|
||||
@@ -115,6 +147,9 @@ func (c *Config) Validate() error {
|
||||
if c.GRPC.ShutdownTimeout <= 0 {
|
||||
return fmt.Errorf("grpc shutdown timeout must be positive")
|
||||
}
|
||||
if c.Environment == "production" && c.Settlement.AdminToken == "" {
|
||||
return fmt.Errorf("settlement admin token is required in production")
|
||||
}
|
||||
return validateDatabase(c.Database)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
func TestLoadUsesDefaultsAndOverrides(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "gl.toml")
|
||||
contents := []byte("[grpc]\nport = 0\nshutdown-timeout = \"3s\"\n")
|
||||
contents := []byte("[grpc]\nport = 0\ntimeout = \"3s\"\n")
|
||||
if err := os.WriteFile(path, contents, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -30,7 +30,7 @@ func TestLoadUsesDefaultsAndOverrides(t *testing.T) {
|
||||
func TestLoadDashboardUsesHTTPDefaultsAndOverrides(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "dashboard.toml")
|
||||
contents := []byte("[http]\nport = 0\nshutdown-timeout = \"3s\"\n")
|
||||
contents := []byte("[http]\nport = 0\nshutdown-timeout = \"3s\"\n[db]\nname = \"dashboard_db\"\n")
|
||||
if err := os.WriteFile(path, contents, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -42,7 +42,7 @@ func TestLoadDashboardUsesHTTPDefaultsAndOverrides(t *testing.T) {
|
||||
if cfg.HTTP.Port != 0 || cfg.HTTP.ShutdownTimeout != 3*time.Second || cfg.HTTP.ReadHeaderTimeout != 5*time.Second {
|
||||
t.Fatalf("unexpected http config: %+v", cfg.HTTP)
|
||||
}
|
||||
if cfg.Database.Name != "gl_db" || cfg.Database.Port != 5432 {
|
||||
if cfg.Database.Name != "dashboard_db" || cfg.Database.Port != 5432 {
|
||||
t.Fatalf("unexpected database defaults: %+v", cfg.Database)
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,7 @@ func TestLoadDashboardUsesHTTPDefaultsAndOverrides(t *testing.T) {
|
||||
func TestLoadRejectsInvalidConfiguration(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "gl.toml")
|
||||
if err := os.WriteFile(path, []byte("[grpc]\nshutdown-timeout = \"0s\"\n"), 0o600); err != nil {
|
||||
if err := os.WriteFile(path, []byte("[grpc]\ntimeout = \"0s\"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -64,3 +64,46 @@ func TestLoadReturnsMissingFileError(t *testing.T) {
|
||||
t.Fatal("expected missing file error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAppliesSecretEnvironmentOverrides(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "gl.toml")
|
||||
contents := []byte("environment = \"production\"\n[grpc]\ntimeout = \"3s\"\n[settlement]\nenabled = true\nendpoint = \"https://invalid.example\"\n")
|
||||
if err := os.WriteFile(path, contents, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("DARANO_GL_DB_PASSWORD", "runtime-db-secret")
|
||||
t.Setenv("DARANO_GL_ADMIN_TOKEN", "runtime-admin-secret")
|
||||
t.Setenv("DARANO_GL_SETTLEMENT_ENDPOINT", "https://horizon.example/")
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Database.Password != "runtime-db-secret" {
|
||||
t.Fatal("database password was not loaded from the environment")
|
||||
}
|
||||
if cfg.Settlement.AdminToken != "runtime-admin-secret" {
|
||||
t.Fatal("admin token was not loaded from the environment")
|
||||
}
|
||||
if cfg.Settlement.Endpoint != "https://horizon.example" {
|
||||
t.Fatalf("unexpected settlement endpoint: %q", cfg.Settlement.Endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDashboardAppliesDatabasePasswordEnvironmentOverride(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "dashboard.toml")
|
||||
if err := os.WriteFile(path, []byte("[http]\nport = 0\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("DARANO_GL_DB_PASSWORD", "runtime-db-secret")
|
||||
|
||||
cfg, err := LoadDashboard(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Database.Password != "runtime-db-secret" {
|
||||
t.Fatal("database password was not loaded from the environment")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user