Skip to content

Commit

Permalink
Merge pull request #27 from ccremer/koanf
Browse files Browse the repository at this point in the history
Replace viper with koanf
  • Loading branch information
ccremer authored May 22, 2021
2 parents 791ad26 + 7e9768d commit 65d7e1c
Show file tree
Hide file tree
Showing 7 changed files with 217 additions and 192 deletions.
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ clean: ## Clean the project

.PHONY: test
test: ## Run unit tests
@go test -coverprofile cover.out ./...
@go test -coverprofile cover.out -v ./...

.PHONY: run
run: ## Run locally
Expand Down
18 changes: 13 additions & 5 deletions README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -56,18 +56,26 @@ With https://ccremer.github.io/charts/fronius-exporter[fronius-exporter]
fronius-exporter --url http://symo.ip.or.hostname/solar_api/v1/GetPowerFlowRealtimeData.fcgi
----

Upon each call to `/metrics`, the exporter will do a GET request on the given URL, and translate the JSON
response to Prometheus metrics format.
Upon each call to `/metrics`, the exporter will do a GET request on the given URL, and translate the JSON response to Prometheus metrics format.

== Configuration

`fronius-exporter` can be configured with CLI flags. Call the binary with `--help` to get a list of options.
`fronius-exporter` can be configured with CLI flags.
Call the binary with `--help` to get a list of options.

[TIP]
====
All flags are also configurable with Environment variables.
Replace the `.` char with `_` and uppercase the names in order for them to be recognized, e.g. `--log.level debug` becomes `LOG_LEVEL=debug`.
CLI flags take precedence though.
* Replace the `-` char with `_` in the names and uppercase the names
* Replace the `.` delimiter with `__`
* CLI flags take precedence
.Following calls are requivalent
----
fronius-exporter --symo.url http://...
SYMO__URL=http://... fronius-exporter
----
====

== As a client API
Expand Down
87 changes: 69 additions & 18 deletions cfg/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,45 +7,55 @@ import (
"strings"
"time"

"github.com/knadh/koanf"
"github.com/knadh/koanf/providers/env"
"github.com/knadh/koanf/providers/posflag"
log "github.com/sirupsen/logrus"
flag "github.com/spf13/pflag"
"github.com/spf13/viper"
)

// ParseConfig overrides internal config defaults with up CLI flags, environment variables and ensures basic validation.
func ParseConfig(version, commit, date string, fs *flag.FlagSet, args []string) *Configuration {
config := NewDefaultConfig()

setupCliFlags(fmt.Sprintf("version %s, %s, %s", version, commit, date), fs, config)

loadConfigHierarchy(fs, args, config)

postLoadProcess(config)

log.WithField("config", *config).Debug("Parsed config")
return config
}

func setupCliFlags(version string, fs *flag.FlagSet, config *Configuration) {
fs.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage of %s (version %s, %s, %s):\n", os.Args[0], version, commit, date)
fmt.Fprintf(os.Stderr, "Usage of %s (%s):\n", os.Args[0], version)
fs.PrintDefaults()
}
fs.String("bindAddr", config.BindAddr, "IP Address to bind to listen for Prometheus scrapes")
fs.String("bind-addr", config.BindAddr, "IP Address to bind to listen for Prometheus scrapes")
fs.String("log.level", config.Log.Level, "Logging level")
fs.BoolP("log.verbose", "v", config.Log.Verbose, "Shortcut for --log.level=debug")
fs.StringSlice("symo.header", []string{},
fs.StringSlice("symo.header", config.Symo.Headers,
"List of \"key: value\" headers to append to the requests going to Fronius Symo")
fs.StringP("symo.url", "u", config.Symo.URL, "Target URL of Fronius Symo device")
fs.Int64("symo.timeout", int64(config.Symo.Timeout.Seconds()),
"Timeout in seconds when collecting metrics from Fronius Symo. Should not be larger than the scrape interval")
if err := viper.BindPFlags(fs); err != nil {
log.WithError(err).Fatal("Could not bind flags")
}

if err := fs.Parse(args); err != nil {
log.WithError(err).Fatal("Could not parse flags")
}
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_"))
viper.AutomaticEnv()

if err := viper.Unmarshal(config); err != nil {
log.WithError(err).Fatal("Could not read config")
}
}

func postLoadProcess(config *Configuration) {
config.Symo.Timeout *= time.Second
if config.Log.Verbose {
config.Log.Level = "debug"
}

var parsedHeaders []string
for _, header := range config.Symo.Headers {
parsedHeaders = splitHeaderStrings(header, parsedHeaders)
}
config.Symo.Headers = parsedHeaders

level, err := log.ParseLevel(config.Log.Level)
if err != nil {
log.WithError(err).Warn("Could not parse log level, fallback to info level")
Expand All @@ -54,8 +64,49 @@ func ParseConfig(version, commit, date string, fs *flag.FlagSet, args []string)
} else {
log.SetLevel(level)
}
log.WithField("config", *config).Debug("Parsed config")
return config
}

func splitHeaderStrings(rest string, headers []string) []string {
s := strings.TrimPrefix(rest, ",")
arr := strings.SplitN(s, ",", 2)
if v := arr[0]; v != "" {
headers = append(headers, strings.TrimSpace(v))
}
if len(arr) < 2 {
// No more key-value pairs to parse
return headers
}
return splitHeaderStrings(arr[1], headers)
}

func loadConfigHierarchy(fs *flag.FlagSet, args []string, config *Configuration) {
koanfInstance := koanf.New(".")

// Environment variables
if err := koanfInstance.Load(env.Provider("", ".", func(s string) string {
/*
Configuration can contain hierarchies (YAML, etc.) and CLI flags dashes.
To read environment variables with hierarchies and dashes we replace the hierarchy delimiter with double underscore and dashes with single underscore.
So that parent.child-with-dash becomes PARENT__CHILD_WITH_DASH
*/
s = strings.Replace(strings.ToLower(s), "__", ".", -1)
s = strings.Replace(strings.ToLower(s), "_", "-", -1)
return s
}), nil); err != nil {
log.WithError(err).Fatal("Could not parse flags")
}

// CLI Flags
if err := fs.Parse(args); err != nil {
log.WithError(err).Fatal("Could not parse flags")
}
if err := koanfInstance.Load(posflag.Provider(fs, ".", koanfInstance), nil); err != nil {
log.WithError(err).Fatal("Could not process flags")
}

if err := koanfInstance.Unmarshal("", &config); err != nil {
log.WithError(err).Fatal("Could not merge defaults with settings from environment variables")
}
}

// ConvertHeaders takes a list of `key=value` headers and adds those trimmed to the specified header struct. It ignores
Expand Down
114 changes: 75 additions & 39 deletions cfg/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,11 @@ func TestConvertHeaders(t *testing.T) {
headers []string
header *http.Header
}
tests := []struct {
name string
tests := map[string]struct {
args args
verify func(header *http.Header)
}{
{
name: "WhenEmptyArray_ThenDoNothing",
"WhenEmptyArray_ThenDoNothing": {
args: args{
headers: []string{},
header: &http.Header{},
Expand All @@ -30,8 +28,7 @@ func TestConvertHeaders(t *testing.T) {
assert.Empty(t, header)
},
},
{
name: "WhenInvalidEntry_ThenIgnore",
"WhenInvalidEntry_ThenIgnore": {
args: args{
headers: []string{"invalid"},
header: &http.Header{},
Expand All @@ -40,8 +37,7 @@ func TestConvertHeaders(t *testing.T) {
assert.Empty(t, header)
},
},
{
name: "WhenValidEntry_ThenParse",
"WhenValidEntry_ThenParse": {
args: args{
headers: []string{"Authentication= Bearer <token>"},
header: &http.Header{},
Expand All @@ -50,8 +46,7 @@ func TestConvertHeaders(t *testing.T) {
assert.Equal(t, "Bearer <token>", header.Get("Authentication"))
},
},
{
name: "GivenValidEntry_WhenSpacesAroundValues_ThenTrim",
"GivenValidEntry_WhenSpacesAroundValues_ThenTrim": {
args: args{
headers: []string{" Authentication = Bearer <token> "},
header: &http.Header{},
Expand All @@ -61,102 +56,100 @@ func TestConvertHeaders(t *testing.T) {
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
ConvertHeaders(tt.args.headers, tt.args.header)
tt.verify(tt.args.header)
})
}
}

func TestParseConfig(t *testing.T) {
tests := []struct {
name string
tests := map[string]struct {
args []string
envs map[string]string
want *Configuration
fs flag.FlagSet
verify func(c *Configuration)
}{
{
name: "GivenNoFlags_ThenReturnDefaultConfig",
"GivenNoFlags_ThenReturnDefaultConfig": {
args: []string{},
verify: func(c *Configuration) {
assert.Equal(t, "info", c.Log.Level)
},
},
{
name: "GivenLogFlags_WhenVerboseEnabled_ThenSetLoggingLevelToDebug",
"GivenLogFlags_WhenVerboseEnabled_ThenSetLoggingLevelToDebug": {
args: []string{"-v"},
verify: func(c *Configuration) {
assert.Equal(t, "debug", c.Log.Level)
assert.Equal(t, true, c.Log.Verbose)
},
},
{
name: "GivenLogFlags_WhenLogLevelSpecified_ThenOverrideLogLevel",
"GivenLogFlags_WhenLogLevelSpecified_ThenOverrideLogLevel": {
args: []string{"--log.level=warn"},
verify: func(c *Configuration) {
assert.Equal(t, "warn", c.Log.Level)
},
},
{
name: "GivenLogFlags_WhenInvalidLogLevelSpecified_ThenSetLoggingLevelToInfo",
"GivenLogFlags_WhenInvalidLogLevelSpecified_ThenSetLoggingLevelToInfo": {
args: []string{"--log.level=invalid"},
verify: func(c *Configuration) {
assert.Equal(t, "info", c.Log.Level)
},
},
{
name: "GivenLogLevel_WhenVerboseEnabled_ThenSetLoggingLevelToDebug",
"GivenLogLevel_WhenVerboseEnabled_ThenSetLoggingLevelToDebug": {
args: []string{"--log.level=fatal", "-v"},
verify: func(c *Configuration) {
assert.Equal(t, "debug", c.Log.Level)
assert.Equal(t, true, c.Log.Verbose)
},
},
{
name: "GivenFlags_WhenBindAddrSpecified_ThenOverridePort",
args: []string{"--bindAddr", ":9090"},
"GivenFlags_WhenBindAddrSpecified_ThenOverridePort": {
args: []string{"--bind-addr", ":9090"},
verify: func(c *Configuration) {
assert.Equal(t, ":9090", c.BindAddr)
},
},
{
name: "GivenHeaderFlags_WhenMultipleHeadersSpecified_ThenFillArray",
"GivenHeaderFlags_WhenMultipleHeadersSpecified_ThenFillArray": {
args: []string{"--symo.header", "key1=value1", "--symo.header", "KEY2= value2"},
verify: func(c *Configuration) {
assert.Contains(t, c.Symo.Headers, "key1=value1")
assert.Contains(t, c.Symo.Headers, "KEY2= value2")
},
},
{
name: "GivenHeaderEnvVar_WhenMultipleHeadersSpecified_ThenFillArray",
"GivenHeaderEnvVar_WhenMultipleHeadersSpecified_ThenFillArray": {
envs: map[string]string{
"SYMO_HEADER": "key1=value1, KEY2= value2",
"SYMO__HEADER": "key1=value1, KEY2= value2",
},
verify: func(c *Configuration) {
assert.Contains(t, c.Symo.Headers, "key1=value1")
assert.Contains(t, c.Symo.Headers, " KEY2= value2")
assert.Contains(t, c.Symo.Headers, "KEY2= value2")
},
},
"GivenHeaderEnvVarAndFlag_WhenMultipleHeadersSpecified_ThenTakePrecedenceFromCLI": {
envs: map[string]string{
"SYMO__HEADER": "key1=value1, KEY2= value2",
},
args: []string{"--symo.header", "key3=value3"},
verify: func(c *Configuration) {
assert.Equal(t, c.Symo.Headers, []string{"key3=value3"})
},
},
{
name: "GivenUrlFlag_ThenOverrideDefault",
"GivenUrlFlag_ThenOverrideDefault": {
args: []string{"--symo.url", "myurl"},
verify: func(c *Configuration) {
assert.Equal(t, "myurl", c.Symo.URL)
},
},
{
name: "GivenTimeoutFlag_WhenSpecified_ThenOverrideDefault",
"GivenTimeoutFlag_WhenSpecified_ThenOverrideDefault": {
args: []string{"--symo.timeout", "3"},
verify: func(c *Configuration) {
assert.Equal(t, 3*time.Second, c.Symo.Timeout)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
setEnv(tt.envs)
result := ParseConfig("version", "commit", "date", &tt.fs, tt.args)
tt.verify(result)
Expand All @@ -176,3 +169,46 @@ func unsetEnv(m map[string]string) {
os.Unsetenv(key)
}
}

func Test_parseHeaderString(t *testing.T) {
tests := map[string]struct {
given string
expected []string
}{
"GivenSingleHeader_WhenParsing_LeaveUnchanged": {
given: "key=value",
expected: []string{"key=value"},
},
"GivenTwoHeaders_WhenParsing_SplitInTwo": {
given: "key1=value1,key2=value2",
expected: []string{"key1=value1", "key2=value2"},
},
"GivenThreeHeaders_WhenParsing_SplitInThree": {
given: "key1=value1,key2=value2,key3=value3",
expected: []string{"key1=value1", "key2=value2", "key3=value3"},
},
"GivenMalformedHeaders_WhenParsing_RegardAsPartOfPreviousHeader": {
given: "key1=value1,key2value2",
expected: []string{"key1=value1", "key2value2"},
},
"GivenHeadersWithSpace_WhenParsing_TrimSpaceAfterComma": {
given: "key1=value1 , key2=value2",
expected: []string{"key1=value1", "key2=value2"},
},
"GivenHeadersWithTrailingComma_WhenParsing_IgnoreEmptyString": {
given: "key1=value1 ,",
expected: []string{"key1=value1"},
},
"GivenHeadersWithSpaces_WhenParsing_Include": {
given: "key1=value with space,",
expected: []string{"key1=value with space"},
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
var result []string
result = splitHeaderStrings(tt.given, result)
assert.Equal(t, tt.expected, result)
})
}
}
Loading

0 comments on commit 65d7e1c

Please sign in to comment.