228 lines
7.2 KiB
Go
228 lines
7.2 KiB
Go
package config
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"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
|
|
}
|
|
|
|
// Args is a parsed command line; cmd/lw owns its grammar.
|
|
type Args struct {
|
|
File string // -c: the configuration file, "" for the environment or discovery
|
|
Specs []Spec // pipeline flags, in order
|
|
Overrides []string // --path=value settings and bare switches, for lixenwraith/config
|
|
}
|
|
|
|
// Settings maps each --path setting Load takes to whether it is a switch, which
|
|
// takes no separate value; the parser must not leave config to guess.
|
|
func Settings() map[string]bool {
|
|
c := lconfig.New()
|
|
if err := c.RegisterStructWithTags("", defaults(), lconfig.FormatTOML); err != nil {
|
|
panic(err) // Config is static: every run fails or none does
|
|
}
|
|
settings := map[string]bool{}
|
|
for path, value := range c.GetRegisteredPathsWithDefaults() {
|
|
_, settings[path] = value.(bool)
|
|
}
|
|
return settings
|
|
}
|
|
|
|
// Load reads the startup sources and validates the initial configuration.
|
|
// Watching starts only when Watch is called after successful service startup.
|
|
func Load(args Args) (*Manager, error) {
|
|
configPath, isExplicit := resolveConfigPath(args.File)
|
|
specs, err := cliSpecs(args.Specs)
|
|
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(args.Overrides).
|
|
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
|
|
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
|
|
}
|
|
// The top-level color is each console sink's default; the maps are fresh
|
|
for _, p := range cfg.Pipelines {
|
|
for i, s := range p.PluginSinks {
|
|
if _, set := s.Config["color"]; s.Type == "console" && !set {
|
|
if s.Config == nil {
|
|
p.PluginSinks[i].Config = map[string]any{}
|
|
}
|
|
p.PluginSinks[i].Config["color"] = cfg.Color
|
|
}
|
|
}
|
|
}
|
|
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,
|
|
Color: "auto",
|
|
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 picks the file: -c, then LOGWISP_CONFIG_FILE and
|
|
// LOGWISP_CONFIG_DIR, then the first of the user's and the working directory's
|
|
// logwisp.toml. Only the discovered default may be missing.
|
|
func resolveConfigPath(file string) (path string, isExplicit bool) {
|
|
if file != "" {
|
|
return file, true
|
|
}
|
|
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
|
|
}
|
|
if configDir := os.Getenv("LOGWISP_CONFIG_DIR"); configDir != "" {
|
|
return filepath.Join(configDir, "logwisp.toml"), true
|
|
}
|
|
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
|
|
}
|
|
}
|
|
return "logwisp.toml", false
|
|
}
|