Рыба проекта. Минимальная функциональность
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// outputWriter is used for logging so tests can capture output.
|
||||
var outputWriter io.Writer = os.Stderr
|
||||
|
||||
func logf(format string, args ...interface{}) {
|
||||
_, _ = fmt.Fprintf(outputWriter, format+"\n", args...)
|
||||
}
|
||||
|
||||
// Config holds the full application configuration.
|
||||
type Config struct {
|
||||
PG struct {
|
||||
Host string `mapstructure:"host"`
|
||||
Port int `mapstructure:"port"`
|
||||
User string `mapstructure:"user"`
|
||||
Password string `mapstructure:"password"`
|
||||
Database string `mapstructure:"database"`
|
||||
SSLMode string `mapstructure:"sslmode"`
|
||||
ExcludeTables []string `mapstructure:"exclude_tables"`
|
||||
} `mapstructure:"pg"`
|
||||
Backup struct {
|
||||
Dir string `mapstructure:"dir"`
|
||||
RetentionDays int `mapstructure:"retention_days"`
|
||||
Cron string `mapstructure:"cron"`
|
||||
} `mapstructure:"backup"`
|
||||
PQScheme uint16 `mapstructure:"pq_scheme"`
|
||||
ClassicalScheme uint16 `mapstructure:"classical_scheme"`
|
||||
PQPublicKeyPath string `mapstructure:"pq_public_key_path"`
|
||||
ClassicalPublicKeyPath string `mapstructure:"classical_public_key_path"`
|
||||
Healthz struct {
|
||||
Port int `mapstructure:"port"`
|
||||
} `mapstructure:"healthz"`
|
||||
Log struct {
|
||||
Level string `mapstructure:"level"`
|
||||
} `mapstructure:"log"`
|
||||
ShutdownTimeout time.Duration `mapstructure:"shutdown_timeout"`
|
||||
}
|
||||
|
||||
// RegisterFlags adds all configuration flags to the provided Cobra command.
|
||||
func RegisterFlags(cmd *cobra.Command) {
|
||||
flags := cmd.Flags()
|
||||
|
||||
flags.String("pg-host", "", "PostgreSQL host")
|
||||
flags.Int("pg-port", 0, "PostgreSQL port")
|
||||
flags.String("pg-user", "", "PostgreSQL user")
|
||||
flags.String("pg-password", "", "PostgreSQL password")
|
||||
flags.String("pg-database", "", "PostgreSQL database name")
|
||||
flags.String("pg-sslmode", "", "PostgreSQL SSL mode")
|
||||
flags.StringSlice("pg-exclude-tables", nil, "PostgreSQL tables to exclude from backup")
|
||||
flags.String("backup-dir", "", "Backup directory")
|
||||
flags.Int("backup-retention-days", 0, "Backup retention period in days")
|
||||
flags.String("backup-cron", "", "Cron expression for backup schedule")
|
||||
flags.Uint16("pq-scheme", 0, "Post-quantum KEM scheme ID")
|
||||
flags.Uint16("classical-scheme", 0, "Classical KEM scheme ID")
|
||||
flags.String("pq-public-key-path", "", "Path to post-quantum public key")
|
||||
flags.String("classical-public-key-path", "", "Path to classical public key")
|
||||
flags.Int("healthz-port", 0, "Health check HTTP port")
|
||||
flags.String("log-level", "", "Log level")
|
||||
flags.Duration("shutdown-timeout", 0, "Graceful shutdown timeout")
|
||||
}
|
||||
|
||||
// Load reads configuration from all sources in priority order:
|
||||
// launch arguments (Cobra flags) → APP_* env variables → config file → defaults.
|
||||
func Load(cmd *cobra.Command) (*Config, error) {
|
||||
// Use a fresh Viper instance so that successive calls do not leak state.
|
||||
v := viper.New()
|
||||
|
||||
// Defaults (lowest priority).
|
||||
v.SetDefault("pg.port", 5432)
|
||||
v.SetDefault("pg.sslmode", "prefer")
|
||||
v.SetDefault("pg.exclude_tables", []string{"e2e_one_time_keys_json"})
|
||||
v.SetDefault("backup.retention_days", 180)
|
||||
v.SetDefault("backup.cron", "0 0 3 * * *")
|
||||
v.SetDefault("pq_scheme", uint16(0x0006))
|
||||
v.SetDefault("classical_scheme", uint16(0x0007))
|
||||
v.SetDefault("healthz.port", 8080)
|
||||
v.SetDefault("log.level", "info")
|
||||
v.SetDefault("shutdown_timeout", 30*time.Second)
|
||||
|
||||
// Bind parsed Cobra flags to Viper keys.
|
||||
if cmd != nil {
|
||||
_ = v.BindPFlag("pg.host", cmd.Flags().Lookup("pg-host"))
|
||||
_ = v.BindPFlag("pg.port", cmd.Flags().Lookup("pg-port"))
|
||||
_ = v.BindPFlag("pg.user", cmd.Flags().Lookup("pg-user"))
|
||||
_ = v.BindPFlag("pg.password", cmd.Flags().Lookup("pg-password"))
|
||||
_ = v.BindPFlag("pg.database", cmd.Flags().Lookup("pg-database"))
|
||||
_ = v.BindPFlag("pg.sslmode", cmd.Flags().Lookup("pg-sslmode"))
|
||||
_ = v.BindPFlag("pg.exclude_tables", cmd.Flags().Lookup("pg-exclude-tables"))
|
||||
_ = v.BindPFlag("backup.dir", cmd.Flags().Lookup("backup-dir"))
|
||||
_ = v.BindPFlag("backup.retention_days", cmd.Flags().Lookup("backup-retention-days"))
|
||||
_ = v.BindPFlag("backup.cron", cmd.Flags().Lookup("backup-cron"))
|
||||
_ = v.BindPFlag("pq_scheme", cmd.Flags().Lookup("pq-scheme"))
|
||||
_ = v.BindPFlag("classical_scheme", cmd.Flags().Lookup("classical-scheme"))
|
||||
_ = v.BindPFlag("pq_public_key_path", cmd.Flags().Lookup("pq-public-key-path"))
|
||||
_ = v.BindPFlag("classical_public_key_path", cmd.Flags().Lookup("classical-public-key-path"))
|
||||
_ = v.BindPFlag("healthz.port", cmd.Flags().Lookup("healthz-port"))
|
||||
_ = v.BindPFlag("log.level", cmd.Flags().Lookup("log-level"))
|
||||
_ = v.BindPFlag("shutdown_timeout", cmd.Flags().Lookup("shutdown-timeout"))
|
||||
}
|
||||
|
||||
// Config file search with logging.
|
||||
v.SetConfigName("config")
|
||||
v.SetConfigType("yaml")
|
||||
|
||||
if envLoc := os.Getenv("APP_CONFIG_LOCATION"); envLoc != "" {
|
||||
v.SetConfigFile(envLoc)
|
||||
if err := v.ReadInConfig(); err == nil {
|
||||
logf("Config file found: %s (from APP_CONFIG_LOCATION)", v.ConfigFileUsed())
|
||||
} else {
|
||||
return nil, fmt.Errorf("config file specified in APP_CONFIG_LOCATION not found: %s", envLoc)
|
||||
}
|
||||
} else {
|
||||
v.AddConfigPath(".")
|
||||
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
appName := "synapse-backupper"
|
||||
userConfigPath := filepath.Join(homeDir, ".config", appName)
|
||||
v.AddConfigPath(userConfigPath)
|
||||
|
||||
exePath, _ := os.Executable()
|
||||
exeDir := filepath.Dir(exePath)
|
||||
v.AddConfigPath(exeDir)
|
||||
|
||||
etcPath := filepath.Join("/etc", appName)
|
||||
v.AddConfigPath(etcPath)
|
||||
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
|
||||
logf("Configuration file not found. Using defaults and env/args.")
|
||||
} else {
|
||||
return nil, fmt.Errorf("error reading config: %w", err)
|
||||
}
|
||||
} else {
|
||||
logf("Config file found: %s", v.ConfigFileUsed())
|
||||
}
|
||||
}
|
||||
|
||||
// Environment variables.
|
||||
v.SetEnvPrefix("APP")
|
||||
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_"))
|
||||
v.AutomaticEnv()
|
||||
|
||||
var cfg Config
|
||||
if err := v.Unmarshal(&cfg); err != nil {
|
||||
return nil, fmt.Errorf("config unmarshal failed: %w", err)
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestEnvOverridesYAML(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
configPath := filepath.Join(tempDir, "config.yaml")
|
||||
content := `
|
||||
pg:
|
||||
host: localhost
|
||||
port: 5433
|
||||
backup:
|
||||
retention_days: 90
|
||||
`
|
||||
if err := os.WriteFile(configPath, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("failed to write test config: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("APP_CONFIG_LOCATION", configPath)
|
||||
t.Setenv("APP_PG_HOST", "remote")
|
||||
|
||||
cmd := &cobra.Command{}
|
||||
RegisterFlags(cmd)
|
||||
|
||||
var buf bytes.Buffer
|
||||
oldOutput := outputWriter
|
||||
outputWriter = &buf
|
||||
defer func() { outputWriter = oldOutput }()
|
||||
|
||||
cfg, err := Load(cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
|
||||
if cfg.PG.Host != "remote" {
|
||||
t.Errorf("PG.Host = %q, want %q (env should override YAML)", cfg.PG.Host, "remote")
|
||||
}
|
||||
if cfg.PG.Port != 5433 {
|
||||
t.Errorf("PG.Port = %d, want %d (YAML value should be preserved when env not set)", cfg.PG.Port, 5433)
|
||||
}
|
||||
if cfg.Backup.RetentionDays != 90 {
|
||||
t.Errorf("Backup.RetentionDays = %d, want %d (from YAML)", cfg.Backup.RetentionDays, 90)
|
||||
}
|
||||
|
||||
logOutput := buf.String()
|
||||
if !strings.Contains(logOutput, configPath) {
|
||||
t.Errorf("log output did not contain config path %q: %s", configPath, logOutput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaults(t *testing.T) {
|
||||
cmd := &cobra.Command{}
|
||||
RegisterFlags(cmd)
|
||||
|
||||
var buf bytes.Buffer
|
||||
oldOutput := outputWriter
|
||||
outputWriter = &buf
|
||||
defer func() { outputWriter = oldOutput }()
|
||||
|
||||
cfg, err := Load(cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
|
||||
if cfg.PG.Port != 5432 {
|
||||
t.Errorf("PG.Port = %d, want %d", cfg.PG.Port, 5432)
|
||||
}
|
||||
if cfg.PG.SSLMode != "prefer" {
|
||||
t.Errorf("PG.SSLMode = %q, want %q", cfg.PG.SSLMode, "prefer")
|
||||
}
|
||||
if len(cfg.PG.ExcludeTables) != 1 || cfg.PG.ExcludeTables[0] != "e2e_one_time_keys_json" {
|
||||
t.Errorf("PG.ExcludeTables = %v, want [e2e_one_time_keys_json]", cfg.PG.ExcludeTables)
|
||||
}
|
||||
if cfg.Backup.RetentionDays != 180 {
|
||||
t.Errorf("Backup.RetentionDays = %d, want %d", cfg.Backup.RetentionDays, 180)
|
||||
}
|
||||
if cfg.Backup.Cron != "0 0 3 * * *" {
|
||||
t.Errorf("Backup.Cron = %q, want %q", cfg.Backup.Cron, "0 0 3 * * *")
|
||||
}
|
||||
if cfg.PQScheme != 0x0006 {
|
||||
t.Errorf("PQScheme = 0x%04x, want 0x%04x", cfg.PQScheme, 0x0006)
|
||||
}
|
||||
if cfg.ClassicalScheme != 0x0007 {
|
||||
t.Errorf("ClassicalScheme = 0x%04x, want 0x%04x", cfg.ClassicalScheme, 0x0007)
|
||||
}
|
||||
if cfg.Healthz.Port != 8080 {
|
||||
t.Errorf("Healthz.Port = %d, want %d", cfg.Healthz.Port, 8080)
|
||||
}
|
||||
if cfg.Log.Level != "info" {
|
||||
t.Errorf("Log.Level = %q, want %q", cfg.Log.Level, "info")
|
||||
}
|
||||
|
||||
logOutput := buf.String()
|
||||
if !strings.Contains(logOutput, "Configuration file not found") {
|
||||
t.Errorf("log output did not contain 'Configuration file not found': %s", logOutput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagsOverrideEnv(t *testing.T) {
|
||||
t.Setenv("APP_PG_HOST", "env-host")
|
||||
|
||||
cmd := &cobra.Command{}
|
||||
RegisterFlags(cmd)
|
||||
if err := cmd.ParseFlags([]string{"--pg-host", "flag-host"}); err != nil {
|
||||
t.Fatalf("ParseFlags failed: %v", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
oldOutput := outputWriter
|
||||
outputWriter = &buf
|
||||
defer func() { outputWriter = oldOutput }()
|
||||
|
||||
cfg, err := Load(cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
|
||||
if cfg.PG.Host != "flag-host" {
|
||||
t.Errorf("PG.Host = %q, want %q (flag should override env)", cfg.PG.Host, "flag-host")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllConfigFields(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
configPath := filepath.Join(tempDir, "config.yaml")
|
||||
content := `
|
||||
pg:
|
||||
host: db.example.com
|
||||
port: 5432
|
||||
user: synapse
|
||||
password: secret
|
||||
database: synapse_db
|
||||
sslmode: require
|
||||
exclude_tables:
|
||||
- table1
|
||||
- table2
|
||||
backup:
|
||||
dir: /backups
|
||||
retention_days: 30
|
||||
cron: "0 0 * * *"
|
||||
pq_scheme: 0x0001
|
||||
classical_scheme: 0x0002
|
||||
pq_public_key_path: /keys/pq.pub
|
||||
classical_public_key_path: /keys/classical.pub
|
||||
healthz:
|
||||
port: 9090
|
||||
log:
|
||||
level: debug
|
||||
`
|
||||
if err := os.WriteFile(configPath, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("failed to write test config: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("APP_CONFIG_LOCATION", configPath)
|
||||
|
||||
cmd := &cobra.Command{}
|
||||
RegisterFlags(cmd)
|
||||
|
||||
var buf bytes.Buffer
|
||||
oldOutput := outputWriter
|
||||
outputWriter = &buf
|
||||
defer func() { outputWriter = oldOutput }()
|
||||
|
||||
cfg, err := Load(cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
|
||||
if cfg.PG.Host != "db.example.com" {
|
||||
t.Errorf("PG.Host = %q, want %q", cfg.PG.Host, "db.example.com")
|
||||
}
|
||||
if cfg.PG.User != "synapse" {
|
||||
t.Errorf("PG.User = %q, want %q", cfg.PG.User, "synapse")
|
||||
}
|
||||
if cfg.PG.Password != "secret" {
|
||||
t.Errorf("PG.Password = %q, want %q", cfg.PG.Password, "secret")
|
||||
}
|
||||
if cfg.PG.Database != "synapse_db" {
|
||||
t.Errorf("PG.Database = %q, want %q", cfg.PG.Database, "synapse_db")
|
||||
}
|
||||
if cfg.PG.SSLMode != "require" {
|
||||
t.Errorf("PG.SSLMode = %q, want %q", cfg.PG.SSLMode, "require")
|
||||
}
|
||||
if len(cfg.PG.ExcludeTables) != 2 || cfg.PG.ExcludeTables[0] != "table1" {
|
||||
t.Errorf("PG.ExcludeTables = %v, want [table1 table2]", cfg.PG.ExcludeTables)
|
||||
}
|
||||
if cfg.Backup.Dir != "/backups" {
|
||||
t.Errorf("Backup.Dir = %q, want %q", cfg.Backup.Dir, "/backups")
|
||||
}
|
||||
if cfg.Backup.Cron != "0 0 * * *" {
|
||||
t.Errorf("Backup.Cron = %q, want %q", cfg.Backup.Cron, "0 0 * * *")
|
||||
}
|
||||
if cfg.PQScheme != 0x0001 {
|
||||
t.Errorf("PQScheme = 0x%04x, want 0x%04x", cfg.PQScheme, 0x0001)
|
||||
}
|
||||
if cfg.ClassicalScheme != 0x0002 {
|
||||
t.Errorf("ClassicalScheme = 0x%04x, want 0x%04x", cfg.ClassicalScheme, 0x0002)
|
||||
}
|
||||
if cfg.PQPublicKeyPath != "/keys/pq.pub" {
|
||||
t.Errorf("PQPublicKeyPath = %q, want %q", cfg.PQPublicKeyPath, "/keys/pq.pub")
|
||||
}
|
||||
if cfg.ClassicalPublicKeyPath != "/keys/classical.pub" {
|
||||
t.Errorf("ClassicalPublicKeyPath = %q, want %q", cfg.ClassicalPublicKeyPath, "/keys/classical.pub")
|
||||
}
|
||||
if cfg.Healthz.Port != 9090 {
|
||||
t.Errorf("Healthz.Port = %d, want %d", cfg.Healthz.Port, 9090)
|
||||
}
|
||||
if cfg.Log.Level != "debug" {
|
||||
t.Errorf("Log.Level = %q, want %q", cfg.Log.Level, "debug")
|
||||
}
|
||||
|
||||
logOutput := buf.String()
|
||||
if !strings.Contains(logOutput, configPath) {
|
||||
t.Errorf("log output did not contain config path %q: %s", configPath, logOutput)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user