~bigbes/lethe

ref: ef738a28b65454441af0c98c23a389cfa24fb22c lethe/internal/collector/config/config.go -rw-r--r-- 5.0 KiB
ef738a28 — Eugene Blikh collector: align ingest sender with server response 24 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
// Package config loads and validates the lethe collector configuration from a
// YAML file plus LETHE_COLLECTOR_* environment overrides.
//
// Validation runs in fail-fast mode: any unknown YAML key, missing required
// field, or out-of-range value rejects the load with a culpa-wrapped error.
// The only field without a default is host — the operator must choose a stable
// identity for this machine.
package config

import (
	"errors"
	"io/fs"
	"os"
	"path/filepath"
	"strings"
	"time"

	"github.com/go-playground/validator/v10"
	"github.com/go-viper/mapstructure/v2"
	"github.com/spf13/viper"
	"go.bigb.es/auxilia/culpa"
)

// Config is the root collector configuration.
type Config struct {
	Host      string         `mapstructure:"host"       validate:"required"`
	ServerURL string         `mapstructure:"server_url" validate:"required,url"`
	StateDir  string         `mapstructure:"state_dir"`
	HTTP      HTTPConfig     `mapstructure:"http"`
	Outbox    OutboxConfig   `mapstructure:"outbox"`
	Sources   []SourceConfig `mapstructure:"sources"    validate:"required,min=1,dive"`
	Log       LogConfig      `mapstructure:"log"`
}

// HTTPConfig tunes the outbound POST client.
type HTTPConfig struct {
	Timeout  time.Duration `mapstructure:"timeout"    validate:"gt=0"`
	RetryMax int           `mapstructure:"retry_max"  validate:"gte=0"`
}

// OutboxConfig caps the local safety-net buffer.
type OutboxConfig struct {
	MaxBytes int64 `mapstructure:"max_bytes" validate:"gt=0"`
}

// SourceConfig describes one tool's source root and polling behaviour.
type SourceConfig struct {
	Tool          string        `mapstructure:"tool"            validate:"required"`
	Path          string        `mapstructure:"path"            validate:"required"`
	PollInterval  time.Duration `mapstructure:"poll_interval"   validate:"gt=0"`
	BatchMaxLines int           `mapstructure:"batch_max_lines" validate:"gt=0"`
	BatchMaxBytes int64         `mapstructure:"batch_max_bytes" validate:"gt=0"`
}

// LogConfig selects log level and formatter.
type LogConfig struct {
	Level  string `mapstructure:"level"  validate:"required,oneof=debug info warn error"`
	Format string `mapstructure:"format" validate:"required,oneof=tint json human"`
}

// Load reads YAML from path, applies env overrides, fills documented defaults,
// expands ~ in paths, and validates. Errors carry a culpa code.
func Load(path string) (*Config, error) {
	v := viper.New()
	v.SetConfigFile(path)
	v.SetEnvPrefix("LETHE_COLLECTOR")
	v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
	v.AutomaticEnv()

	registerDefaults(v)

	if err := v.ReadInConfig(); err != nil {
		if _, ok := err.(viper.ConfigFileNotFoundError); ok {
			return nil, culpa.WithCode(culpa.Wrap(err, "config file not found"), "CONFIG_NOT_FOUND")
		}
		if isFileMissing(err) {
			return nil, culpa.WithCode(culpa.Wrap(err, "config file not found"), "CONFIG_NOT_FOUND")
		}
		return nil, culpa.WithCode(culpa.Wrap(err, "parse config"), "CONFIG_PARSE")
	}

	var cfg Config
	if err := v.UnmarshalExact(
		&cfg,
		viper.DecodeHook(mapstructure.ComposeDecodeHookFunc(
			mapstructure.StringToTimeDurationHookFunc(),
			mapstructure.StringToSliceHookFunc(","),
		)),
		func(c *mapstructure.DecoderConfig) {
			c.ErrorUnused = true
		},
	); err != nil {
		return nil, culpa.WithCode(culpa.Wrap(err, "decode config"), "CONFIG_PARSE")
	}

	applySourceDefaults(&cfg)
	expandTildes(&cfg)

	val := validator.New(validator.WithRequiredStructEnabled())
	if err := val.Struct(&cfg); err != nil {
		return nil, culpa.WithCode(culpa.Wrap(err, "validate config"), "CONFIG_VALIDATE")
	}
	return &cfg, nil
}

// MustLoad calls Load and panics on error.
func MustLoad(path string) *Config {
	cfg, err := Load(path)
	if err != nil {
		panic(err)
	}
	return cfg
}

func registerDefaults(v *viper.Viper) {
	home, _ := os.UserHomeDir()
	v.SetDefault("state_dir", filepath.Join(home, ".local/state/lethe"))
	v.SetDefault("http.timeout", 30*time.Second)
	v.SetDefault("http.retry_max", 5)
	v.SetDefault("outbox.max_bytes", int64(104857600)) // 100 MiB
	v.SetDefault("log.level", "info")
	v.SetDefault("log.format", "human")
}

func applySourceDefaults(cfg *Config) {
	for i := range cfg.Sources {
		if cfg.Sources[i].PollInterval == 0 {
			cfg.Sources[i].PollInterval = 30 * time.Second
		}
		if cfg.Sources[i].BatchMaxLines == 0 {
			cfg.Sources[i].BatchMaxLines = 500
		}
		if cfg.Sources[i].BatchMaxBytes == 0 {
			cfg.Sources[i].BatchMaxBytes = 8388608 // 8 MiB
		}
	}
}

func expandTildes(cfg *Config) {
	cfg.StateDir = expandTilde(cfg.StateDir)
	for i := range cfg.Sources {
		cfg.Sources[i].Path = expandTilde(cfg.Sources[i].Path)
	}
}

func expandTilde(path string) string {
	if path == "~" || strings.HasPrefix(path, "~/") {
		home, err := os.UserHomeDir()
		if err != nil {
			return path
		}
		if path == "~" {
			return home
		}
		return filepath.Join(home, path[2:])
	}
	return path
}

func isFileMissing(err error) bool {
	var pe *fs.PathError
	if errors.As(err, &pe) {
		return errors.Is(pe.Err, fs.ErrNotExist)
	}
	return errors.Is(err, fs.ErrNotExist)
}