Files
netbird/util/config/loader.go
jnfrati c8cd6b4dca [management,signal,proxy,relay,misc] Unify service configuration loading
Service entry points currently resolve defaults, files, environment variables, and flags differently, which makes precedence inconsistent and prevents some services from using config files.

Introduce one Viper-backed loader and migrate Combined, Management, Relay, Signal, and Proxy while preserving compatibility aliases and Management template expansion.
2026-08-24 18:18:59 +02:00

385 lines
9.6 KiB
Go

// Package config loads service configuration from defaults, files, environment
// variables, and command-line flags. Values are applied in that order, so an
// explicitly changed flag has the highest precedence.
//
// Configuration keys come from the struct tag selected by [Options.TagName],
// which defaults to "mapstructure". Environment variable names are inferred
// from those keys with the NB prefix. For example, server.listen-address maps
// to NB_SERVER_LISTEN_ADDRESS. The env and flag tags can provide explicit names,
// comma-separated compatibility aliases, or "-" to disable a source.
//
// A typical service configuration can be loaded as follows:
//
// type Config struct {
// Address string `yaml:"address" env:"NB_ADDRESS" flag:"address"`
// Timeout time.Duration `yaml:"timeout"`
// }
//
// flags := pflag.NewFlagSet("service", pflag.ContinueOnError)
// flags.String("address", ":443", "service listen address")
//
// cfg, err := config.Load("config.yaml", &Config{
// Address: ":443",
// Timeout: 30 * time.Second,
// }, config.Options{
// TagName: "yaml",
// FlagSet: flags,
// Strict: true,
// })
//
// Set [Options.AllowMissing] when the service must start without a configuration
// file. [Options.Transform] can preprocess file contents before decoding, such
// as with [ExpandEnvTemplate].
package config
import (
"bytes"
"encoding"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"reflect"
"slices"
"strings"
"github.com/go-viper/mapstructure/v2"
"github.com/spf13/pflag"
"github.com/spf13/viper"
)
const envPrefix = "NB"
var (
textUnmarshalerType = reflect.TypeFor[encoding.TextUnmarshaler]()
jsonUnmarshalerType = reflect.TypeFor[json.Unmarshaler]()
)
// Options controls how Load resolves files and fields.
type Options struct {
// TagName selects the struct tag used for configuration keys.
TagName string
// AllowMissing permits an empty path or a file that does not exist.
AllowMissing bool
// FlagSet provides command-line flags referenced by `flag` struct tags.
FlagSet *pflag.FlagSet
// Transform rewrites configuration file contents before decoding.
Transform func([]byte) ([]byte, error)
// Strict rejects configuration keys that are not represented by the target type.
Strict bool
}
// Load reads configuration into a default-initialized value. Environment values
// override file values, and file values override defaults.
func Load[T any](configPath string, cfg *T, options Options) (*T, error) {
if cfg == nil {
return nil, fmt.Errorf("default config is nil")
}
configType := reflect.TypeFor[T]()
if configType.Kind() != reflect.Struct {
return nil, fmt.Errorf("config type %s must be a struct", configType)
}
if configPath == "" && !options.AllowMissing {
return nil, errors.New("config file path is required")
}
tagName := options.TagName
if tagName == "" {
tagName = "mapstructure"
}
configData, err := readConfigFile(configPath, options.AllowMissing)
if err != nil {
return nil, err
}
configFormat := ""
if configData != nil {
configFormat, err = resolveConfigType(configPath, tagName)
if err != nil {
return nil, err
}
if options.Transform != nil {
configData, err = options.Transform(configData)
if err != nil {
return nil, fmt.Errorf("transform config: %w", err)
}
}
}
v := viper.New()
if configFormat != "" {
v.SetConfigType(configFormat)
}
v.SetEnvPrefix(envPrefix)
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_"))
v.AllowEmptyEnv(true)
v.AutomaticEnv()
if err := bindConfigSources(
v,
configType,
"",
tagName,
options.FlagSet,
true,
true,
make(map[reflect.Type]bool),
); err != nil {
return nil, fmt.Errorf("bind config sources: %w", err)
}
if configData != nil {
if err := v.ReadConfig(bytes.NewReader(configData)); err != nil {
return nil, fmt.Errorf("read config: %w", err)
}
}
var unmarshalErr error
if options.Strict {
unmarshalErr = v.UnmarshalExact(cfg, decoderConfig(tagName))
} else {
unmarshalErr = v.Unmarshal(cfg, decoderConfig(tagName))
}
if unmarshalErr != nil {
return nil, fmt.Errorf("unmarshal config: %w", unmarshalErr)
}
return cfg, nil
}
func readConfigFile(configPath string, allowMissing bool) ([]byte, error) {
if configPath == "" {
return nil, nil
}
data, err := os.ReadFile(configPath)
if err == nil {
return data, nil
}
if allowMissing && errors.Is(err, fs.ErrNotExist) {
return nil, nil
}
return nil, fmt.Errorf("read config file: %w", err)
}
func resolveConfigType(configPath, fallbackType string) (string, error) {
extension := strings.TrimPrefix(strings.ToLower(filepath.Ext(configPath)), ".")
if slices.Contains(viper.SupportedExts, extension) {
return extension, nil
}
fallbackType = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(fallbackType)), ".")
if fallbackType != "" {
if !slices.Contains(viper.SupportedExts, fallbackType) {
return "", fmt.Errorf("unsupported default config type %q", fallbackType)
}
return fallbackType, nil
}
if extension == "" {
return "", errors.New("config file extension is required")
}
return "", fmt.Errorf("unsupported config file extension %q", extension)
}
func decoderConfig(tagName string) viper.DecoderConfigOption {
return func(config *mapstructure.DecoderConfig) {
config.TagName = tagName
config.DecodeHook = mapstructure.ComposeDecodeHookFunc(
decodeLegacyBoolean,
mapstructure.TextUnmarshallerHookFunc(),
jsonUnmarshallerHook,
config.DecodeHook,
)
}
}
func bindConfigSources(
v *viper.Viper,
configType reflect.Type,
prefix string,
tagName string,
flagSet *pflag.FlagSet,
bindEnvironment bool,
bindFlags bool,
visiting map[reflect.Type]bool,
) error {
for configType.Kind() == reflect.Pointer {
configType = configType.Elem()
}
visiting[configType] = true
defer delete(visiting, configType)
for i := range configType.NumField() {
field := configType.Field(i)
if !field.IsExported() {
continue
}
key, inline, skip := configFieldKey(field, tagName)
if skip {
continue
}
if inline {
key = prefix
} else if prefix != "" {
key = prefix + "." + key
}
fieldEnvironment := field.Tag.Get("env")
fieldFlags := field.Tag.Get("flag")
bindFieldEnvironment := bindEnvironment && fieldEnvironment != "-"
bindFieldFlags := bindFlags && fieldFlags != "-"
fieldType := field.Type
for fieldType.Kind() == reflect.Pointer {
fieldType = fieldType.Elem()
}
if fieldType.Kind() == reflect.Struct && !isScalarUnmarshaler(fieldType) {
if !visiting[fieldType] {
if err := bindConfigSources(
v,
fieldType,
key,
tagName,
flagSet,
bindFieldEnvironment,
bindFieldFlags,
visiting,
); err != nil {
return err
}
}
continue
}
if key == "" {
return fmt.Errorf("empty config key for field %s", field.Name)
}
if bindFieldEnvironment {
if err := bindEnvironmentVariable(v, key, fieldEnvironment); err != nil {
return err
}
}
if bindFieldFlags && flagSet != nil && fieldFlags != "" {
flagName, flag, err := selectFlag(flagSet, fieldFlags)
if err != nil {
return fmt.Errorf("config field %s: %w", field.Name, err)
}
if err := v.BindPFlag(key, flag); err != nil {
return fmt.Errorf("bind flag %s: %w", flagName, err)
}
}
}
return nil
}
func configFieldKey(field reflect.StructField, tagName string) (key string, inline, skip bool) {
tagParts := strings.Split(field.Tag.Get(tagName), ",")
key = tagParts[0]
if key == "-" {
return "", false, true
}
if key == "" {
key = field.Name
}
for _, option := range tagParts[1:] {
if option == "inline" || option == "squash" {
inline = true
break
}
}
return key, inline, false
}
func selectFlag(flagSet *pflag.FlagSet, names string) (string, *pflag.Flag, error) {
var selected *pflag.Flag
selectedName := ""
for _, name := range strings.Split(names, ",") {
flag := flagSet.Lookup(name)
if flag == nil {
return "", nil, fmt.Errorf("references unknown flag %q", name)
}
if selected == nil || flag.Changed {
selected = flag
selectedName = name
}
if flag.Changed {
break
}
}
return selectedName, selected, nil
}
func bindEnvironmentVariable(v *viper.Viper, key, environmentName string) error {
var err error
if environmentName == "" {
err = v.BindEnv(key)
} else {
names := strings.Split(environmentName, ",")
arguments := append([]string{key}, names...)
err = v.BindEnv(arguments...)
}
if err != nil {
return fmt.Errorf("bind environment for %s: %w", key, err)
}
return nil
}
func isScalarUnmarshaler(configType reflect.Type) bool {
return implements(configType, textUnmarshalerType) ||
implements(configType, jsonUnmarshalerType)
}
func implements(configType, interfaceType reflect.Type) bool {
return configType.Implements(interfaceType) ||
reflect.PointerTo(configType).Implements(interfaceType)
}
func jsonUnmarshallerHook(from, to reflect.Type, data any) (any, error) {
if !implements(to, jsonUnmarshalerType) {
return data, nil
}
raw, err := json.Marshal(data)
if err != nil {
return nil, err
}
targetType := to
if targetType.Kind() == reflect.Pointer {
targetType = targetType.Elem()
}
target := reflect.New(targetType)
unmarshaler, ok := target.Interface().(json.Unmarshaler)
if !ok {
return data, nil
}
if err := unmarshaler.UnmarshalJSON(raw); err != nil {
return nil, err
}
if to.Kind() == reflect.Pointer {
return target.Interface(), nil
}
return target.Elem().Interface(), nil
}
func decodeLegacyBoolean(from, to reflect.Kind, data any) (any, error) {
if from != reflect.String || to != reflect.Bool {
return data, nil
}
switch strings.ToLower(data.(string)) {
case "y", "yes", "on":
return true, nil
case "n", "no", "off":
return false, nil
default:
return data, nil
}
}