mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-12 01:39:07 +02:00
566 lines
15 KiB
Go
566 lines
15 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.
|
|
package config
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding"
|
|
"encoding/csv"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-viper/mapstructure/v2"
|
|
"github.com/spf13/pflag"
|
|
"github.com/spf13/viper"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
var (
|
|
textUnmarshalerType = reflect.TypeFor[encoding.TextUnmarshaler]()
|
|
jsonUnmarshalerType = reflect.TypeFor[json.Unmarshaler]()
|
|
)
|
|
|
|
// InvalidEnvironmentAction controls how invalid environment values are handled.
|
|
type InvalidEnvironmentAction uint8
|
|
|
|
const (
|
|
// InvalidEnvironmentFatal returns the parsing error.
|
|
InvalidEnvironmentFatal InvalidEnvironmentAction = iota
|
|
// InvalidEnvironmentIgnore keeps the file or default value.
|
|
InvalidEnvironmentIgnore
|
|
// InvalidEnvironmentUsePartial keeps scalar values returned with parsing errors.
|
|
InvalidEnvironmentUsePartial
|
|
)
|
|
|
|
// Options controls how Load resolves files and fields.
|
|
type Options struct {
|
|
// TagName selects the struct tag and decoder 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
|
|
// InvalidEnvironment controls how parsing failures affect the loaded value.
|
|
InvalidEnvironment InvalidEnvironmentAction
|
|
// DecodeErrorPrefix preserves service-specific context for file decoding errors.
|
|
DecodeErrorPrefix string
|
|
}
|
|
|
|
// Load reads configuration into a default-initialized value. Explicitly tagged
|
|
// environment values and changed flags override file values.
|
|
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
|
|
}
|
|
if configData != nil {
|
|
if options.Transform != nil {
|
|
configData, err = options.Transform(configData)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("transform config: %w", err)
|
|
}
|
|
}
|
|
if err := decodeConfigFile(configData, cfg, tagName, options.Strict); err != nil {
|
|
if options.DecodeErrorPrefix != "" {
|
|
return nil, fmt.Errorf("%s: %w", options.DecodeErrorPrefix, err)
|
|
}
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
v := viper.New()
|
|
v.AllowEmptyEnv(true)
|
|
|
|
if err := bindConfigSources(
|
|
v,
|
|
configType,
|
|
"",
|
|
tagName,
|
|
options.FlagSet,
|
|
options.InvalidEnvironment,
|
|
true,
|
|
true,
|
|
make(map[reflect.Type]bool),
|
|
); err != nil {
|
|
return nil, fmt.Errorf("bind config sources: %w", err)
|
|
}
|
|
|
|
if err := v.Unmarshal(cfg, decoderConfig(tagName)); err != nil {
|
|
return nil, fmt.Errorf("unmarshal config overlay: %w", err)
|
|
}
|
|
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 decodeConfigFile(data []byte, cfg any, tagName string, strict bool) error {
|
|
switch tagName {
|
|
case "json":
|
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
|
if strict {
|
|
decoder.DisallowUnknownFields()
|
|
}
|
|
if err := decoder.Decode(cfg); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
case "yaml":
|
|
decoder := yaml.NewDecoder(bytes.NewReader(data))
|
|
decoder.KnownFields(strict)
|
|
if err := decoder.Decode(cfg); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("unsupported config decoder %q", tagName)
|
|
}
|
|
}
|
|
func decoderConfig(tagName string) viper.DecoderConfigOption {
|
|
return func(config *mapstructure.DecoderConfig) {
|
|
config.TagName = tagName
|
|
config.Squash = true
|
|
config.DecodeHook = mapstructure.ComposeDecodeHookFunc(
|
|
mapstructure.TextUnmarshallerHookFunc(),
|
|
jsonUnmarshallerHook,
|
|
config.DecodeHook,
|
|
)
|
|
}
|
|
}
|
|
|
|
func bindConfigSources(
|
|
v *viper.Viper,
|
|
configType reflect.Type,
|
|
prefix string,
|
|
tagName string,
|
|
flagSet *pflag.FlagSet,
|
|
invalidEnvironment InvalidEnvironmentAction,
|
|
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() {
|
|
if err := bindConfigField(
|
|
v,
|
|
configType.Field(i),
|
|
prefix,
|
|
tagName,
|
|
flagSet,
|
|
invalidEnvironment,
|
|
bindEnvironment,
|
|
bindFlags,
|
|
visiting,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func bindConfigField(
|
|
v *viper.Viper,
|
|
field reflect.StructField,
|
|
prefix string,
|
|
tagName string,
|
|
flagSet *pflag.FlagSet,
|
|
invalidEnvironment InvalidEnvironmentAction,
|
|
bindEnvironment bool,
|
|
bindFlags bool,
|
|
visiting map[reflect.Type]bool,
|
|
) error {
|
|
if !field.IsExported() {
|
|
return nil
|
|
}
|
|
|
|
key, inline, skip := configFieldKey(field, tagName)
|
|
if skip {
|
|
return nil
|
|
}
|
|
if inline {
|
|
key = prefix
|
|
} else if prefix != "" {
|
|
key = prefix + "." + key
|
|
}
|
|
|
|
fieldEnvironment := field.Tag.Get("env")
|
|
fieldFlags := field.Tag.Get("flag")
|
|
bindEnvironment = bindEnvironment && fieldEnvironment != "-"
|
|
bindFlags = bindFlags && fieldFlags != "-"
|
|
|
|
fieldType := field.Type
|
|
for fieldType.Kind() == reflect.Pointer {
|
|
fieldType = fieldType.Elem()
|
|
}
|
|
if fieldType.Kind() == reflect.Struct && !isScalarUnmarshaler(fieldType) {
|
|
if visiting[fieldType] {
|
|
return nil
|
|
}
|
|
return bindConfigSources(
|
|
v,
|
|
fieldType,
|
|
key,
|
|
tagName,
|
|
flagSet,
|
|
invalidEnvironment,
|
|
bindEnvironment,
|
|
bindFlags,
|
|
visiting,
|
|
)
|
|
}
|
|
|
|
return bindScalarSources(
|
|
v,
|
|
field,
|
|
key,
|
|
fieldEnvironment,
|
|
fieldFlags,
|
|
flagSet,
|
|
invalidEnvironment,
|
|
bindEnvironment,
|
|
bindFlags,
|
|
)
|
|
}
|
|
|
|
func bindScalarSources(
|
|
v *viper.Viper,
|
|
field reflect.StructField,
|
|
key string,
|
|
environmentNames string,
|
|
flagNames string,
|
|
flagSet *pflag.FlagSet,
|
|
invalidEnvironment InvalidEnvironmentAction,
|
|
bindEnvironment bool,
|
|
bindFlags bool,
|
|
) error {
|
|
if key == "" {
|
|
return fmt.Errorf("empty config key for field %s", field.Name)
|
|
}
|
|
|
|
var environmentValue any
|
|
environmentSet := false
|
|
if bindEnvironment && environmentNames != "" {
|
|
value, set, err := environmentValueForField(
|
|
v,
|
|
key,
|
|
environmentNames,
|
|
field.Type,
|
|
invalidEnvironment,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("environment for %s: %w", key, err)
|
|
}
|
|
environmentValue = value
|
|
environmentSet = set
|
|
}
|
|
|
|
var flag *pflag.Flag
|
|
if bindFlags && flagSet != nil && flagNames != "" {
|
|
flagName, selected, err := selectFlag(flagSet, flagNames)
|
|
if err != nil {
|
|
return fmt.Errorf("config field %s: %w", field.Name, err)
|
|
}
|
|
flag = selected
|
|
if flag != nil {
|
|
if err := v.BindPFlag(key, flag); err != nil {
|
|
return fmt.Errorf("bind flag %s: %w", flagName, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
switch {
|
|
case environmentSet && flag != nil && indirectKind(field.Type) == reflect.Slice:
|
|
flagValue, err := parsedFlagValue(field.Type, flag)
|
|
if err != nil {
|
|
return fmt.Errorf("parse flag %s: %w", flag.Name, err)
|
|
}
|
|
combined := reflect.AppendSlice(reflect.ValueOf(environmentValue), reflect.ValueOf(flagValue))
|
|
v.Set(key, combined.Interface())
|
|
case environmentSet && flag == nil:
|
|
v.Set(key, environmentValue)
|
|
}
|
|
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) {
|
|
for _, name := range strings.Split(names, ",") {
|
|
flag := flagSet.Lookup(name)
|
|
if flag == nil {
|
|
return "", nil, fmt.Errorf("references unknown flag %q", name)
|
|
}
|
|
if flag.Changed {
|
|
return name, flag, nil
|
|
}
|
|
}
|
|
return "", nil, nil
|
|
}
|
|
|
|
func environmentValueForField(
|
|
v *viper.Viper,
|
|
key string,
|
|
environmentNames string,
|
|
fieldType reflect.Type,
|
|
action InvalidEnvironmentAction,
|
|
) (any, bool, error) {
|
|
for _, name := range strings.Split(environmentNames, ",") {
|
|
value, present := os.LookupEnv(name)
|
|
if !present {
|
|
continue
|
|
}
|
|
if value == "" && indirectKind(fieldType) != reflect.String {
|
|
return nil, false, nil
|
|
}
|
|
|
|
parsed, partial, err := parseScalarValue(fieldType, value)
|
|
if err == nil {
|
|
if err := v.BindEnv(key, name); err != nil {
|
|
return nil, false, fmt.Errorf("bind %s: %w", name, err)
|
|
}
|
|
return parsed, true, nil
|
|
}
|
|
switch action {
|
|
case InvalidEnvironmentIgnore:
|
|
return nil, false, nil
|
|
case InvalidEnvironmentUsePartial:
|
|
if !partial {
|
|
return nil, false, nil
|
|
}
|
|
if err := v.BindEnv(key, name); err != nil {
|
|
return nil, false, fmt.Errorf("bind %s: %w", name, err)
|
|
}
|
|
return parsed, true, nil
|
|
default:
|
|
return nil, false, fmt.Errorf("parse %s=%q: %w", name, value, err)
|
|
}
|
|
}
|
|
return nil, false, nil
|
|
}
|
|
|
|
func parsedFlagValue(fieldType reflect.Type, flag *pflag.Flag) (any, error) {
|
|
if indirectKind(fieldType) != reflect.Slice {
|
|
value, _, err := parseScalarValue(fieldType, flag.Value.String())
|
|
return value, err
|
|
}
|
|
|
|
sliceValue, ok := flag.Value.(pflag.SliceValue)
|
|
if !ok {
|
|
return nil, fmt.Errorf("flag is not a slice")
|
|
}
|
|
rawValues := sliceValue.GetSlice()
|
|
result := reflect.MakeSlice(indirectType(fieldType), 0, len(rawValues))
|
|
for _, rawValue := range rawValues {
|
|
value, _, err := parseScalarValue(indirectType(fieldType).Elem(), rawValue)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result = reflect.Append(result, reflect.ValueOf(value).Convert(result.Type().Elem()))
|
|
}
|
|
return result.Interface(), nil
|
|
}
|
|
|
|
func parseScalarValue(fieldType reflect.Type, value string) (any, bool, error) {
|
|
targetType := indirectType(fieldType)
|
|
if implements(targetType, textUnmarshalerType) {
|
|
target := reflect.New(targetType)
|
|
unmarshaler := target.Interface().(encoding.TextUnmarshaler)
|
|
if err := unmarshaler.UnmarshalText([]byte(value)); err != nil {
|
|
return target.Interface(), false, err
|
|
}
|
|
if fieldType.Kind() == reflect.Pointer {
|
|
return target.Interface(), true, nil
|
|
}
|
|
return target.Elem().Interface(), true, nil
|
|
}
|
|
if targetType == reflect.TypeFor[time.Duration]() {
|
|
parsed, err := time.ParseDuration(value)
|
|
return parsed, true, err
|
|
}
|
|
|
|
switch targetType.Kind() {
|
|
case reflect.String:
|
|
return reflect.ValueOf(value).Convert(targetType).Interface(), true, nil
|
|
case reflect.Bool:
|
|
parsed, err := strconv.ParseBool(value)
|
|
return parsed, true, err
|
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
|
parsed, err := strconv.ParseInt(value, 0, targetType.Bits())
|
|
return reflect.ValueOf(parsed).Convert(targetType).Interface(), true, err
|
|
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
|
parsed, err := strconv.ParseUint(value, 10, targetType.Bits())
|
|
return reflect.ValueOf(parsed).Convert(targetType).Interface(), true, err
|
|
case reflect.Slice:
|
|
return parseSliceValue(targetType, value)
|
|
default:
|
|
return value, true, nil
|
|
}
|
|
}
|
|
|
|
func parseSliceValue(sliceType reflect.Type, value string) (any, bool, error) {
|
|
switch sliceType.Elem().Kind() {
|
|
case reflect.String:
|
|
values, err := csv.NewReader(strings.NewReader(value)).Read()
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
result := reflect.MakeSlice(sliceType, len(values), len(values))
|
|
for i, item := range values {
|
|
result.Index(i).Set(reflect.ValueOf(item).Convert(sliceType.Elem()))
|
|
}
|
|
return result.Interface(), true, nil
|
|
case reflect.Int:
|
|
rawValues := strings.Split(value, ",")
|
|
result := reflect.MakeSlice(sliceType, len(rawValues), len(rawValues))
|
|
for i, rawValue := range rawValues {
|
|
parsed, err := strconv.Atoi(rawValue)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
result.Index(i).SetInt(int64(parsed))
|
|
}
|
|
return result.Interface(), true, nil
|
|
default:
|
|
return nil, false, fmt.Errorf("unsupported environment slice type %s", sliceType)
|
|
}
|
|
}
|
|
|
|
func indirectType(configType reflect.Type) reflect.Type {
|
|
for configType.Kind() == reflect.Pointer {
|
|
configType = configType.Elem()
|
|
}
|
|
return configType
|
|
}
|
|
|
|
func indirectKind(configType reflect.Type) reflect.Kind {
|
|
return indirectType(configType).Kind()
|
|
}
|
|
|
|
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
|
|
}
|