mirror of
https://github.com/oauth2-proxy/oauth2-proxy.git
synced 2026-10-18 13:18:26 +02:00
Add Viper based config loader
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
package options
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/mitchellh/mapstructure"
|
||||
"github.com/spf13/pflag"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// Load reads in the config file at the path given, then merges in environment
|
||||
// variables (prefixed with `OAUTH2_PROXY`) and finally merges in flags from the flagSet.
|
||||
// If a config value is unset and the flag has a non-zero value default, this default will be used.
|
||||
// Eg. A field defined:
|
||||
// FooBar `cfg:"foo_bar" flag:"foo-bar"`
|
||||
// Can be set in the config file as `foo_bar="baz"`, in the environment as `OAUTH2_PROXY_FOO_BAR=baz`,
|
||||
// or via the command line flag `--foo-bar=baz`.
|
||||
func Load(configFileName string, flagSet *pflag.FlagSet, into interface{}) error {
|
||||
v := viper.New()
|
||||
v.SetConfigFile(configFileName)
|
||||
v.SetConfigType("toml") // Config is in toml format
|
||||
v.SetEnvPrefix("OAUTH2_PROXY")
|
||||
v.AutomaticEnv()
|
||||
v.SetTypeByDefaultValue(true)
|
||||
|
||||
if configFileName != "" {
|
||||
err := v.ReadInConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to load config file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
err := registerFlags(v, "", flagSet, into)
|
||||
if err != nil {
|
||||
// This should only happen if there is a programming error
|
||||
return fmt.Errorf("unable to register flags: %w", err)
|
||||
}
|
||||
|
||||
err = v.Unmarshal(into, decodeFromCfgTag)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error unmarshalling config: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// registerFlags uses `cfg` and `flag` tags to associate flags in the flagSet
|
||||
// to the fields in the options interface provided.
|
||||
// Each exported field in the options must have a `cfg` tag otherwise an error will occur.
|
||||
// - For fields, set `cfg` and `flag` so that `flag` is the name of the flag associated to this config option
|
||||
// - For exported fields that are not user facing, set the `cfg` to `,internal`
|
||||
// - For structs containing user facing fields, set the `cfg` to `,squash`
|
||||
func registerFlags(v *viper.Viper, prefix string, flagSet *pflag.FlagSet, options interface{}) error {
|
||||
val := reflect.ValueOf(options)
|
||||
var typ reflect.Type
|
||||
if val.Kind() == reflect.Ptr {
|
||||
typ = val.Elem().Type()
|
||||
} else {
|
||||
typ = val.Type()
|
||||
}
|
||||
|
||||
for i := 0; i < typ.NumField(); i++ {
|
||||
// pull out the struct tags:
|
||||
// flag - the name of the command line flag
|
||||
// cfg - the name of the config file option
|
||||
field := typ.Field(i)
|
||||
fieldV := reflect.Indirect(val).Field(i)
|
||||
fieldName := strings.Join([]string{prefix, field.Name}, ".")
|
||||
|
||||
cfgName := field.Tag.Get("cfg")
|
||||
if cfgName == ",internal" {
|
||||
// Public but internal types that should not be exposed to users, skip them
|
||||
continue
|
||||
}
|
||||
|
||||
if isUnexported(field.Name) {
|
||||
// Unexported fields cannot be set by a user, so won't have tags or flags, skip them
|
||||
continue
|
||||
}
|
||||
|
||||
if field.Type.Kind() == reflect.Struct {
|
||||
if cfgName != ",squash" {
|
||||
return fmt.Errorf("field %q does not have required cfg tag: `,squash`", fieldName)
|
||||
}
|
||||
err := registerFlags(v, fieldName, flagSet, fieldV.Interface())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
flagName := field.Tag.Get("flag")
|
||||
if flagName == "" || cfgName == "" {
|
||||
return fmt.Errorf("field %q does not have required tags (cfg, flag)", fieldName)
|
||||
}
|
||||
|
||||
if flagSet == nil {
|
||||
return fmt.Errorf("flagset cannot be nil")
|
||||
}
|
||||
|
||||
f := flagSet.Lookup(flagName)
|
||||
if f == nil {
|
||||
return fmt.Errorf("field %q does not have a registered flag", flagName)
|
||||
}
|
||||
err := v.BindPFlag(cfgName, f)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error binding flag for field %q: %w", fieldName, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodeFromCfgTag sets the Viper decoder to read the names from the `cfg` tag
|
||||
// on each struct entry.
|
||||
func decodeFromCfgTag(c *mapstructure.DecoderConfig) {
|
||||
c.TagName = "cfg"
|
||||
}
|
||||
|
||||
// isUnexported checks if a field name starts with a lowercase letter and therefore
|
||||
// if it is unexported.
|
||||
func isUnexported(name string) bool {
|
||||
if len(name) == 0 {
|
||||
// This should never happen
|
||||
panic("field name has len 0")
|
||||
}
|
||||
|
||||
first := string(name[0])
|
||||
return first == strings.ToLower(first)
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package options
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/ginkgo/extensions/table"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
func TestOptionsSuite(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Options Suite")
|
||||
}
|
||||
|
||||
var _ = Describe("Load", func() {
|
||||
Context("with a testOptions structure", func() {
|
||||
type TestOptionSubStruct struct {
|
||||
StringSliceOption []string `flag:"string-slice-option" cfg:"string_slice_option"`
|
||||
}
|
||||
|
||||
type TestOptions struct {
|
||||
StringOption string `flag:"string-option" cfg:"string_option"`
|
||||
Sub TestOptionSubStruct `cfg:",squash"`
|
||||
// Check exported but internal fields do not break loading
|
||||
Internal *string `cfg:",internal"`
|
||||
// Check unexported fields do not break loading
|
||||
unexported string
|
||||
}
|
||||
|
||||
type MissingSquashTestOptions struct {
|
||||
StringOption string `flag:"string-option" cfg:"string_option"`
|
||||
Sub TestOptionSubStruct
|
||||
}
|
||||
|
||||
type MissingCfgTestOptions struct {
|
||||
StringOption string `flag:"string-option"`
|
||||
Sub TestOptionSubStruct `cfg:",squash"`
|
||||
}
|
||||
|
||||
type MissingFlagTestOptions struct {
|
||||
StringOption string `cfg:"string_option"`
|
||||
Sub TestOptionSubStruct `cfg:",squash"`
|
||||
}
|
||||
|
||||
var testOptionsConfigBytes = []byte(`
|
||||
string_option="foo"
|
||||
string_slice_option="a,b,c,d"
|
||||
`)
|
||||
|
||||
var testOptionsFlagSet *pflag.FlagSet
|
||||
|
||||
type testOptionsTableInput struct {
|
||||
env map[string]string
|
||||
args []string
|
||||
configFile []byte
|
||||
flagSet func() *pflag.FlagSet
|
||||
expectedErr error
|
||||
input interface{}
|
||||
expectedOutput interface{}
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
testOptionsFlagSet = pflag.NewFlagSet("testFlagSet", pflag.ExitOnError)
|
||||
testOptionsFlagSet.String("string-option", "default", "")
|
||||
testOptionsFlagSet.StringSlice("string-slice-option", []string{"a", "b"}, "")
|
||||
})
|
||||
|
||||
DescribeTable("Load",
|
||||
func(o *testOptionsTableInput) {
|
||||
var configFileName string
|
||||
|
||||
if o.configFile != nil {
|
||||
By("Creating a config file")
|
||||
configFile, err := ioutil.TempFile("", "oauth2-proxy-test-legacy-config-file")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer configFile.Close()
|
||||
|
||||
_, err = configFile.Write(o.configFile)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer os.Remove(configFile.Name())
|
||||
|
||||
configFileName = configFile.Name()
|
||||
}
|
||||
|
||||
if len(o.env) > 0 {
|
||||
By("Setting environment variables")
|
||||
for k, v := range o.env {
|
||||
os.Setenv(k, v)
|
||||
defer os.Unsetenv(k)
|
||||
}
|
||||
}
|
||||
|
||||
Expect(o.flagSet).ToNot(BeNil())
|
||||
flagSet := o.flagSet()
|
||||
Expect(flagSet).ToNot(BeNil())
|
||||
|
||||
if len(o.args) > 0 {
|
||||
By("Parsing flag arguments")
|
||||
Expect(flagSet.Parse(o.args)).To(Succeed())
|
||||
}
|
||||
|
||||
var input interface{}
|
||||
if o.input != nil {
|
||||
input = o.input
|
||||
} else {
|
||||
input = &TestOptions{}
|
||||
}
|
||||
err := Load(configFileName, flagSet, input)
|
||||
if o.expectedErr != nil {
|
||||
Expect(err).To(MatchError(o.expectedErr.Error()))
|
||||
} else {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
Expect(input).To(Equal(o.expectedOutput))
|
||||
},
|
||||
Entry("with just a config file", &testOptionsTableInput{
|
||||
configFile: testOptionsConfigBytes,
|
||||
flagSet: func() *pflag.FlagSet { return testOptionsFlagSet },
|
||||
expectedOutput: &TestOptions{
|
||||
StringOption: "foo",
|
||||
Sub: TestOptionSubStruct{
|
||||
StringSliceOption: []string{"a", "b", "c", "d"},
|
||||
},
|
||||
},
|
||||
}),
|
||||
Entry("when setting env variables", &testOptionsTableInput{
|
||||
configFile: testOptionsConfigBytes,
|
||||
env: map[string]string{
|
||||
"OAUTH2_PROXY_STRING_OPTION": "bar",
|
||||
"OAUTH2_PROXY_STRING_SLICE_OPTION": "a,b,c",
|
||||
},
|
||||
flagSet: func() *pflag.FlagSet { return testOptionsFlagSet },
|
||||
expectedOutput: &TestOptions{
|
||||
StringOption: "bar",
|
||||
Sub: TestOptionSubStruct{
|
||||
StringSliceOption: []string{"a", "b", "c"},
|
||||
},
|
||||
},
|
||||
}),
|
||||
Entry("when setting flags", &testOptionsTableInput{
|
||||
configFile: testOptionsConfigBytes,
|
||||
env: map[string]string{
|
||||
"OAUTH2_PROXY_STRING_OPTION": "bar",
|
||||
"OAUTH2_PROXY_STRING_SLICE_OPTION": "a,b,c",
|
||||
},
|
||||
args: []string{
|
||||
"--string-option", "baz",
|
||||
"--string-slice-option", "a,b,c,d,e",
|
||||
},
|
||||
flagSet: func() *pflag.FlagSet { return testOptionsFlagSet },
|
||||
expectedOutput: &TestOptions{
|
||||
StringOption: "baz",
|
||||
Sub: TestOptionSubStruct{
|
||||
StringSliceOption: []string{"a", "b", "c", "d", "e"},
|
||||
},
|
||||
},
|
||||
}),
|
||||
Entry("when setting flags multiple times", &testOptionsTableInput{
|
||||
configFile: testOptionsConfigBytes,
|
||||
env: map[string]string{
|
||||
"OAUTH2_PROXY_STRING_OPTION": "bar",
|
||||
"OAUTH2_PROXY_STRING_SLICE_OPTION": "a,b,c",
|
||||
},
|
||||
args: []string{
|
||||
"--string-option", "baz",
|
||||
"--string-slice-option", "x",
|
||||
"--string-slice-option", "y",
|
||||
"--string-slice-option", "z",
|
||||
},
|
||||
flagSet: func() *pflag.FlagSet { return testOptionsFlagSet },
|
||||
expectedOutput: &TestOptions{
|
||||
StringOption: "baz",
|
||||
Sub: TestOptionSubStruct{
|
||||
StringSliceOption: []string{"x", "y", "z"},
|
||||
},
|
||||
},
|
||||
}),
|
||||
Entry("when setting env variables without a config file", &testOptionsTableInput{
|
||||
env: map[string]string{
|
||||
"OAUTH2_PROXY_STRING_OPTION": "bar",
|
||||
"OAUTH2_PROXY_STRING_SLICE_OPTION": "a,b,c",
|
||||
},
|
||||
flagSet: func() *pflag.FlagSet { return testOptionsFlagSet },
|
||||
expectedOutput: &TestOptions{
|
||||
StringOption: "bar",
|
||||
Sub: TestOptionSubStruct{
|
||||
StringSliceOption: []string{"a", "b", "c"},
|
||||
},
|
||||
},
|
||||
}),
|
||||
Entry("when setting flags without a config file", &testOptionsTableInput{
|
||||
env: map[string]string{
|
||||
"OAUTH2_PROXY_STRING_OPTION": "bar",
|
||||
"OAUTH2_PROXY_STRING_SLICE_OPTION": "a,b,c",
|
||||
},
|
||||
args: []string{
|
||||
"--string-option", "baz",
|
||||
"--string-slice-option", "a,b,c,d,e",
|
||||
},
|
||||
flagSet: func() *pflag.FlagSet { return testOptionsFlagSet },
|
||||
expectedOutput: &TestOptions{
|
||||
StringOption: "baz",
|
||||
Sub: TestOptionSubStruct{
|
||||
StringSliceOption: []string{"a", "b", "c", "d", "e"},
|
||||
},
|
||||
},
|
||||
}),
|
||||
Entry("when setting flags without a config file", &testOptionsTableInput{
|
||||
env: map[string]string{
|
||||
"OAUTH2_PROXY_STRING_OPTION": "bar",
|
||||
"OAUTH2_PROXY_STRING_SLICE_OPTION": "a,b,c",
|
||||
},
|
||||
args: []string{
|
||||
"--string-option", "baz",
|
||||
"--string-slice-option", "a,b,c,d,e",
|
||||
},
|
||||
flagSet: func() *pflag.FlagSet { return testOptionsFlagSet },
|
||||
expectedOutput: &TestOptions{
|
||||
StringOption: "baz",
|
||||
Sub: TestOptionSubStruct{
|
||||
StringSliceOption: []string{"a", "b", "c", "d", "e"},
|
||||
},
|
||||
},
|
||||
}),
|
||||
Entry("when nothing is set it should use flag defaults", &testOptionsTableInput{
|
||||
flagSet: func() *pflag.FlagSet { return testOptionsFlagSet },
|
||||
expectedOutput: &TestOptions{
|
||||
StringOption: "default",
|
||||
Sub: TestOptionSubStruct{
|
||||
StringSliceOption: []string{"a", "b"},
|
||||
},
|
||||
},
|
||||
}),
|
||||
Entry("with an invalid config file", &testOptionsTableInput{
|
||||
configFile: []byte(`slice_option = foo`),
|
||||
flagSet: func() *pflag.FlagSet { return testOptionsFlagSet },
|
||||
expectedErr: fmt.Errorf("unable to load config file: While parsing config: (1, 16): never reached"),
|
||||
expectedOutput: &TestOptions{},
|
||||
}),
|
||||
Entry("with an invalid flagset", &testOptionsTableInput{
|
||||
flagSet: func() *pflag.FlagSet {
|
||||
// Missing a flag
|
||||
f := pflag.NewFlagSet("testFlagSet", pflag.ExitOnError)
|
||||
f.String("string-option", "default", "")
|
||||
return f
|
||||
},
|
||||
expectedErr: fmt.Errorf("unable to register flags: field \"string-slice-option\" does not have a registered flag"),
|
||||
expectedOutput: &TestOptions{},
|
||||
}),
|
||||
Entry("with an struct is missing the squash tag", &testOptionsTableInput{
|
||||
flagSet: func() *pflag.FlagSet { return testOptionsFlagSet },
|
||||
expectedErr: fmt.Errorf("unable to register flags: field \".Sub\" does not have required cfg tag: `,squash`"),
|
||||
input: &MissingSquashTestOptions{},
|
||||
expectedOutput: &MissingSquashTestOptions{},
|
||||
}),
|
||||
Entry("with a field is missing the cfg tag", &testOptionsTableInput{
|
||||
flagSet: func() *pflag.FlagSet { return testOptionsFlagSet },
|
||||
expectedErr: fmt.Errorf("unable to register flags: field \".StringOption\" does not have required tags (cfg, flag)"),
|
||||
input: &MissingCfgTestOptions{},
|
||||
expectedOutput: &MissingCfgTestOptions{},
|
||||
}),
|
||||
Entry("with a field is missing the flag tag", &testOptionsTableInput{
|
||||
flagSet: func() *pflag.FlagSet { return testOptionsFlagSet },
|
||||
expectedErr: fmt.Errorf("unable to register flags: field \".StringOption\" does not have required tags (cfg, flag)"),
|
||||
input: &MissingFlagTestOptions{},
|
||||
expectedOutput: &MissingFlagTestOptions{},
|
||||
}),
|
||||
Entry("with existing unexported fields", &testOptionsTableInput{
|
||||
flagSet: func() *pflag.FlagSet { return testOptionsFlagSet },
|
||||
input: &TestOptions{
|
||||
unexported: "unexported",
|
||||
},
|
||||
expectedOutput: &TestOptions{
|
||||
StringOption: "default",
|
||||
Sub: TestOptionSubStruct{
|
||||
StringSliceOption: []string{"a", "b"},
|
||||
},
|
||||
unexported: "unexported",
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user