step one
This commit is contained in:
parent
7a8d5d13fa
commit
9e663db9dc
68 changed files with 5647 additions and 2958 deletions
252
internal/config/config.go
Normal file
252
internal/config/config.go
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
// Package config holds the service's runtime configuration, loaded by
|
||||
// merging (in order of increasing precedence): built-in defaults, a YAML
|
||||
// config file, environment variables, and command-line flags.
|
||||
//
|
||||
// Validation is performed once on load; downstream consumers receive an
|
||||
// immutable struct.
|
||||
package config
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
// Config is the top-level configuration tree.
|
||||
type Config struct {
|
||||
HTTP HTTPConfig `yaml:"http"`
|
||||
Data DataConfig `yaml:"data"`
|
||||
Download DownloadConfig `yaml:"download"`
|
||||
Metrics MetricsConfig `yaml:"metrics"`
|
||||
Log LogConfig `yaml:"log"`
|
||||
}
|
||||
|
||||
// HTTPConfig configures the HTTP server.
|
||||
type HTTPConfig struct {
|
||||
Port int `yaml:"port"`
|
||||
}
|
||||
|
||||
// DataConfig configures dataset and elevation storage.
|
||||
type DataConfig struct {
|
||||
Dir string `yaml:"dir"`
|
||||
ElevationPath string `yaml:"elevation_path"`
|
||||
// Source is the dataset source identifier; only "noaa-gfs-0p50" is supported today.
|
||||
Source string `yaml:"source"`
|
||||
}
|
||||
|
||||
// DownloadConfig configures the dataset downloader.
|
||||
type DownloadConfig struct {
|
||||
Parallel int `yaml:"parallel"`
|
||||
BandwidthBytesPerSecond int64 `yaml:"bandwidth_bytes_per_second"`
|
||||
UpdateInterval time.Duration `yaml:"update_interval"`
|
||||
FreshnessTTL time.Duration `yaml:"freshness_ttl"`
|
||||
}
|
||||
|
||||
// MetricsConfig configures the metrics endpoint.
|
||||
type MetricsConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Path string `yaml:"path"`
|
||||
}
|
||||
|
||||
// LogConfig configures logging.
|
||||
type LogConfig struct {
|
||||
Level string `yaml:"level"` // "debug", "info", "warn", "error"
|
||||
}
|
||||
|
||||
// Defaults returns a Config with reasonable default values.
|
||||
func Defaults() Config {
|
||||
return Config{
|
||||
HTTP: HTTPConfig{Port: 8080},
|
||||
Data: DataConfig{
|
||||
Dir: "/tmp/predictor-data",
|
||||
ElevationPath: "/srv/ruaumoko-dataset",
|
||||
Source: "noaa-gfs-0p50",
|
||||
},
|
||||
Download: DownloadConfig{
|
||||
Parallel: 8,
|
||||
BandwidthBytesPerSecond: 0,
|
||||
UpdateInterval: 6 * time.Hour,
|
||||
FreshnessTTL: 48 * time.Hour,
|
||||
},
|
||||
Metrics: MetricsConfig{
|
||||
Enabled: true,
|
||||
Path: "/metrics",
|
||||
},
|
||||
Log: LogConfig{Level: "info"},
|
||||
}
|
||||
}
|
||||
|
||||
// Load resolves the configuration by merging built-in defaults with
|
||||
// (in increasing precedence): a YAML file (path from PREDICTOR_CONFIG_FILE
|
||||
// env var or --config flag), environment variables, and command-line flags.
|
||||
//
|
||||
// args is os.Args[1:] in production code; tests pass a custom slice.
|
||||
func Load(args []string) (Config, error) {
|
||||
cfg := Defaults()
|
||||
|
||||
fs := flag.NewFlagSet("predictor", flag.ContinueOnError)
|
||||
// Surface a deterministic usage by suppressing the default output:
|
||||
fs.SetOutput(os.Stderr)
|
||||
|
||||
var (
|
||||
configPath = fs.String("config", os.Getenv("PREDICTOR_CONFIG_FILE"), "path to YAML config file")
|
||||
// Flag-driven overrides. Empty / -1 means "not specified".
|
||||
flagPort = fs.Int("port", -1, "HTTP listen port")
|
||||
flagDataDir = fs.String("data-dir", "", "directory for dataset files")
|
||||
flagElevation = fs.String("elevation", "", "path to ruaumoko elevation dataset")
|
||||
flagParallel = fs.Int("download-parallel", -1, "max concurrent GRIB downloads")
|
||||
flagBandwidth = fs.Int64("download-bandwidth", -1, "download bandwidth limit in bytes/sec (0 = unlimited)")
|
||||
flagInterval = fs.Duration("update-interval", 0, "scheduler refresh interval")
|
||||
flagTTL = fs.Duration("freshness-ttl", 0, "max age before a dataset is considered stale")
|
||||
flagMetricsEnabled = fs.Bool("metrics", true, "enable Prometheus-compatible metrics endpoint")
|
||||
flagMetricsPath = fs.String("metrics-path", "", "HTTP path for the metrics endpoint")
|
||||
flagLogLevel = fs.String("log-level", "", "log level: debug|info|warn|error")
|
||||
)
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return Config{}, fmt.Errorf("parse flags: %w", err)
|
||||
}
|
||||
|
||||
// 1. File.
|
||||
if *configPath != "" {
|
||||
data, err := os.ReadFile(*configPath)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("read config %s: %w", *configPath, err)
|
||||
}
|
||||
if err := yaml.UnmarshalStrict(data, &cfg); err != nil {
|
||||
return Config{}, fmt.Errorf("parse config %s: %w", *configPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Env vars.
|
||||
applyEnv(&cfg)
|
||||
|
||||
// 3. Flags (only when explicitly set).
|
||||
if *flagPort >= 0 {
|
||||
cfg.HTTP.Port = *flagPort
|
||||
}
|
||||
if *flagDataDir != "" {
|
||||
cfg.Data.Dir = *flagDataDir
|
||||
}
|
||||
if *flagElevation != "" {
|
||||
cfg.Data.ElevationPath = *flagElevation
|
||||
}
|
||||
if *flagParallel >= 0 {
|
||||
cfg.Download.Parallel = *flagParallel
|
||||
}
|
||||
if *flagBandwidth >= 0 {
|
||||
cfg.Download.BandwidthBytesPerSecond = *flagBandwidth
|
||||
}
|
||||
if *flagInterval != 0 {
|
||||
cfg.Download.UpdateInterval = *flagInterval
|
||||
}
|
||||
if *flagTTL != 0 {
|
||||
cfg.Download.FreshnessTTL = *flagTTL
|
||||
}
|
||||
// flag.Bool defaults to true here so we only override if user explicitly disables it.
|
||||
if isFlagSet(fs, "metrics") {
|
||||
cfg.Metrics.Enabled = *flagMetricsEnabled
|
||||
}
|
||||
if *flagMetricsPath != "" {
|
||||
cfg.Metrics.Path = *flagMetricsPath
|
||||
}
|
||||
if *flagLogLevel != "" {
|
||||
cfg.Log.Level = *flagLogLevel
|
||||
}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func isFlagSet(fs *flag.FlagSet, name string) bool {
|
||||
set := false
|
||||
fs.Visit(func(f *flag.Flag) {
|
||||
if f.Name == name {
|
||||
set = true
|
||||
}
|
||||
})
|
||||
return set
|
||||
}
|
||||
|
||||
// applyEnv overlays PREDICTOR_* environment variables onto cfg.
|
||||
func applyEnv(cfg *Config) {
|
||||
if v := os.Getenv("PREDICTOR_PORT"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
cfg.HTTP.Port = n
|
||||
}
|
||||
}
|
||||
if v := os.Getenv("PREDICTOR_DATA_DIR"); v != "" {
|
||||
cfg.Data.Dir = v
|
||||
}
|
||||
if v := os.Getenv("PREDICTOR_ELEVATION_DATASET"); v != "" {
|
||||
cfg.Data.ElevationPath = v
|
||||
}
|
||||
if v := os.Getenv("PREDICTOR_SOURCE"); v != "" {
|
||||
cfg.Data.Source = v
|
||||
}
|
||||
if v := os.Getenv("PREDICTOR_DOWNLOAD_PARALLEL"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
cfg.Download.Parallel = n
|
||||
}
|
||||
}
|
||||
if v := os.Getenv("PREDICTOR_DOWNLOAD_BANDWIDTH"); v != "" {
|
||||
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||
cfg.Download.BandwidthBytesPerSecond = n
|
||||
}
|
||||
}
|
||||
if v := os.Getenv("PREDICTOR_UPDATE_INTERVAL"); v != "" {
|
||||
if d, err := time.ParseDuration(v); err == nil {
|
||||
cfg.Download.UpdateInterval = d
|
||||
}
|
||||
}
|
||||
if v := os.Getenv("PREDICTOR_DATASET_TTL"); v != "" {
|
||||
if d, err := time.ParseDuration(v); err == nil {
|
||||
cfg.Download.FreshnessTTL = d
|
||||
}
|
||||
}
|
||||
if v := os.Getenv("PREDICTOR_METRICS_ENABLED"); v != "" {
|
||||
cfg.Metrics.Enabled = v == "1" || v == "true" || v == "yes"
|
||||
}
|
||||
if v := os.Getenv("PREDICTOR_METRICS_PATH"); v != "" {
|
||||
cfg.Metrics.Path = v
|
||||
}
|
||||
if v := os.Getenv("PREDICTOR_LOG_LEVEL"); v != "" {
|
||||
cfg.Log.Level = v
|
||||
}
|
||||
}
|
||||
|
||||
// Validate reports configuration errors.
|
||||
func (c Config) Validate() error {
|
||||
if c.HTTP.Port < 0 || c.HTTP.Port > 65535 {
|
||||
return fmt.Errorf("http.port %d outside [0, 65535]", c.HTTP.Port)
|
||||
}
|
||||
if c.Data.Dir == "" {
|
||||
return fmt.Errorf("data.dir is required")
|
||||
}
|
||||
if c.Data.Source == "" {
|
||||
return fmt.Errorf("data.source is required")
|
||||
}
|
||||
if c.Download.Parallel <= 0 {
|
||||
return fmt.Errorf("download.parallel must be > 0")
|
||||
}
|
||||
if c.Download.UpdateInterval <= 0 {
|
||||
return fmt.Errorf("download.update_interval must be > 0")
|
||||
}
|
||||
if c.Download.FreshnessTTL <= 0 {
|
||||
return fmt.Errorf("download.freshness_ttl must be > 0")
|
||||
}
|
||||
if c.Metrics.Enabled && c.Metrics.Path == "" {
|
||||
return fmt.Errorf("metrics.path is required when metrics enabled")
|
||||
}
|
||||
switch c.Log.Level {
|
||||
case "debug", "info", "warn", "error":
|
||||
default:
|
||||
return fmt.Errorf("log.level %q is not one of debug|info|warn|error", c.Log.Level)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
76
internal/config/config_test.go
Normal file
76
internal/config/config_test.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoadDefaults(t *testing.T) {
|
||||
t.Setenv("PREDICTOR_DATA_DIR", "")
|
||||
t.Setenv("PREDICTOR_PORT", "")
|
||||
t.Setenv("PREDICTOR_CONFIG_FILE", "")
|
||||
|
||||
cfg, err := Load(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.HTTP.Port != 8080 {
|
||||
t.Errorf("default port = %d, want 8080", cfg.HTTP.Port)
|
||||
}
|
||||
if cfg.Download.Parallel != 8 {
|
||||
t.Errorf("default parallel = %d, want 8", cfg.Download.Parallel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadEnvOverridesDefaults(t *testing.T) {
|
||||
t.Setenv("PREDICTOR_PORT", "9090")
|
||||
t.Setenv("PREDICTOR_UPDATE_INTERVAL", "30m")
|
||||
|
||||
cfg, err := Load(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.HTTP.Port != 9090 {
|
||||
t.Errorf("env port = %d, want 9090", cfg.HTTP.Port)
|
||||
}
|
||||
if cfg.Download.UpdateInterval != 30*time.Minute {
|
||||
t.Errorf("env update interval = %v, want 30m", cfg.Download.UpdateInterval)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFlagsOverrideEnv(t *testing.T) {
|
||||
t.Setenv("PREDICTOR_PORT", "9090")
|
||||
cfg, err := Load([]string{"-port", "7777"})
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.HTTP.Port != 7777 {
|
||||
t.Errorf("flag should override env: port = %d, want 7777", cfg.HTTP.Port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFileOverridesDefaults(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "predictor.yml")
|
||||
if err := os.WriteFile(path, []byte("http:\n port: 12345\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err := Load([]string{"-config", path})
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.HTTP.Port != 12345 {
|
||||
t.Errorf("file port = %d, want 12345", cfg.HTTP.Port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
cfg := Defaults()
|
||||
cfg.Data.Dir = ""
|
||||
if err := cfg.Validate(); err == nil {
|
||||
t.Error("expected validation error for empty data dir")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue