package config import ( "errors" "fmt" "os" "path/filepath" "strings" "logwisp/internal/core" lconfig "github.com/lixenwraith/config" ) // ErrConfigNotFound identifies an explicitly requested missing configuration file. var ErrConfigNotFound = lconfig.ErrConfigNotFound // Manager owns the configuration sources and watcher for one application instance. // Its snapshots are detached from each other and from the running service. type Manager struct { config *lconfig.Config path string explicit bool read bool // the file was loaded once specs []pipelineSpec // command-line or environment pipelines, kept across reloads } // Load reads the startup sources and validates the initial configuration. // Watching starts only when Watch is called after successful service startup. func Load(args []string) (*Manager, error) { configPath, isExplicit, configArgs, err := resolveConfigPath(args) if err != nil { return nil, err } specs, configArgs, err := takePipelineSpecs(configArgs) if err != nil { return nil, err } if len(specs) == 0 { specs = envPipelineSpecs() } if _, err := buildPipelines(specs); err != nil { return nil, err } m := &Manager{path: configPath, explicit: isExplicit, specs: specs} initial := defaults() cfg, err := lconfig.NewBuilder(). WithTarget(initial). WithEnvPrefix("LOGWISP_"). WithArgs(configArgs). WithFile(configPath). WithTypedValidator(func(cfg *Config) error { effective := *cfg // the builder keeps cfg; only the copy is replaced if err := m.usePipelines(&effective); err != nil { return err } return ValidateConfig(&effective) }). WithSecurityOptions(lconfig.SecurityOptions{ PreventPathTraversal: true, MaxFileSize: 10 * 1024 * 1024, }). Build() if err != nil { if !errors.Is(err, lconfig.ErrConfigNotFound) { return nil, fmt.Errorf("failed to load or validate config: %w", err) } if isExplicit { return nil, fmt.Errorf("config file %q: %w", configPath, err) } // A missing discovered default still permits valid CLI/env/default values. } m.read = err == nil if unknown := cfg.UnknownCLIKeys(); len(unknown) > 0 && !initial.Quiet { fmt.Fprintf(os.Stderr, "Warning: unrecognized flags ignored: %v\n", unknown) } m.config = cfg return m, nil } // Snapshot validates a detached candidate before it is used to build a service. // Builder validators only run during Load, so every reload needs this check too. func (m *Manager) Snapshot() (*Config, error) { value, err := m.config.AsStruct() if err != nil { return nil, fmt.Errorf("decode configuration: %w", err) } cfg := value.(*Config) cfg.ConfigFile = m.path if err := m.usePipelines(cfg); err != nil { return nil, err } if err := checkFileKeys(m.path); err != nil { return nil, fmt.Errorf("config file %q: %w", m.path, err) } if err := ValidateConfig(cfg); err != nil { return nil, fmt.Errorf("validate configuration: %w", err) } return cfg, nil } // usePipelines replaces the file's or default pipelines with the spec ones. func (m *Manager) usePipelines(cfg *Config) error { if len(m.specs) == 0 { return nil } pipelines, err := buildPipelines(m.specs) cfg.Pipelines = pipelines return err } // Reload rereads the selected file, retaining the startup CLI/environment sources. // Signals must call this even when automatic watching is disabled. func (m *Manager) Reload() (*Config, error) { // As at startup, a discovered default that does not exist is no error, so a // file-less instance still rebuilds and rotates its certificates. Once read, // its removal is one: the loader would keep the removed file's values. err := m.config.LoadFile(m.path) if err != nil && (m.explicit || m.read || !errors.Is(err, ErrConfigNotFound)) { return nil, fmt.Errorf("reload %q: %w", m.path, err) } m.read = m.read || err == nil return m.Snapshot() } // Watch subscribes with logwisp's polling and debounce settings. func (m *Manager) Watch() <-chan string { opts := lconfig.DefaultWatchOptions() opts.PollInterval = core.ReloadWatchPollInterval opts.Debounce = core.ReloadWatchDebounce opts.ReloadTimeout = core.ReloadWatchTimeout return m.config.WatchWithOptions(opts) } func (m *Manager) Close() { m.config.StopAutoUpdate() } // defaults provides the default configuration values for the application func defaults() *Config { return &Config{ // Top-level flag defaults ShowVersion: false, Quiet: false, // Runtime behavior defaults StatusReporter: true, ConfigAutoReload: false, // Existing defaults Logging: &LogConfig{ Output: "stdout", Level: "info", Format: "txt", File: &LogFileConfig{ Directory: "./log", Name: "logwisp", MaxSizeMB: 100, MaxTotalSizeMB: 1000, RetentionHours: 168, // 7 days }, Console: &LogConsoleConfig{ Target: "stdout", }, }, Pipelines: []PipelineConfig{ { Name: "default_pipeline", Flow: &FlowConfig{ RateLimit: &RateLimitConfig{ Rate: 5, Burst: 10, Policy: "drop", MaxEntrySizeBytes: 65536, }, Format: &FormatConfig{ Type: "json", SanitizerPolicy: "json", }, }, PluginSources: []PluginSourceConfig{ { ID: "default_source", Type: "random", Config: map[string]any{ "special": true, }, }, }, PluginSinks: []PluginSinkConfig{ { ID: "default_sink", Type: "console", Config: map[string]any{ "target": "stdout", "buffer_size": 100, }, }, }, }, }, } } // resolveConfigPath consumes file-selection flags before schema CLI parsing. // The last selection wins, and -- terminates option handling. func resolveConfigPath(args []string) (path string, isExplicit bool, remaining []string, err error) { remaining = make([]string, 0, len(args)) for i := 0; i < len(args); i++ { arg := args[i] if arg == "--" { remaining = append(remaining, args[i:]...) break } switch { case arg == "-c" || arg == "--config": if i+1 == len(args) || args[i+1] == "" || strings.HasPrefix(args[i+1], "-") { return "", false, nil, fmt.Errorf("%s requires a configuration file path", arg) } i++ path, isExplicit = args[i], true case strings.HasPrefix(arg, "--config=") || strings.HasPrefix(arg, "-c="): _, path, _ = strings.Cut(arg, "=") if path == "" { return "", false, nil, fmt.Errorf("%s requires a configuration file path", arg) } isExplicit = true default: remaining = append(remaining, arg) } } if isExplicit { return path, true, remaining, nil } if configFile := os.Getenv("LOGWISP_CONFIG_FILE"); configFile != "" { path = configFile if configDir := os.Getenv("LOGWISP_CONFIG_DIR"); configDir != "" { path = filepath.Join(configDir, configFile) } return path, true, remaining, nil } if configDir := os.Getenv("LOGWISP_CONFIG_DIR"); configDir != "" { return filepath.Join(configDir, "logwisp.toml"), true, remaining, nil } if homeDir, err := os.UserHomeDir(); err == nil { path = filepath.Join(homeDir, ".config", "logwisp", "logwisp.toml") if _, err := os.Stat(path); err == nil { return path, false, remaining, nil } } return "logwisp.toml", false, remaining, nil }