225 lines
7.1 KiB
Go
225 lines
7.1 KiB
Go
package config
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/lixenwraith/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()
|
|
if _, err := os.Stat(configPath); errors.Is(err, fs.ErrNotExist) {
|
|
// Without a file lw is a command-line tool: warnings and errors only
|
|
initial.Logging.Level = "warn"
|
|
initial.StatusReporter = false
|
|
}
|
|
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.
|
|
// Data goes to stdout, so lw's own log goes to stderr.
|
|
func defaults() *Config {
|
|
return &Config{
|
|
StatusReporter: true,
|
|
Logging: &LogConfig{
|
|
Output: "stderr",
|
|
Level: "info",
|
|
Format: "txt",
|
|
File: &LogFileConfig{
|
|
Directory: "./log",
|
|
Name: "logwisp",
|
|
MaxSizeMB: 100,
|
|
MaxTotalSizeMB: 1000,
|
|
RetentionHours: 168, // 7 days
|
|
},
|
|
Console: &LogConsoleConfig{
|
|
Target: "stderr",
|
|
},
|
|
},
|
|
Pipelines: []PipelineConfig{pipeDefault("pipe")},
|
|
}
|
|
}
|
|
|
|
// pipeDefault is a filter in the Unix sense: stdin to stdout, line for line
|
|
func pipeDefault(name string) PipelineConfig {
|
|
return PipelineConfig{
|
|
Name: name,
|
|
Flow: &FlowConfig{Format: &FormatConfig{Type: "raw"}},
|
|
PluginSources: []PluginSourceConfig{{ID: "stdin", Type: "console", Config: map[string]any{}}},
|
|
PluginSinks: []PluginSinkConfig{{ID: "stdout", Type: "console", Config: map[string]any{}}},
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|