Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce config.FromEnv() #41

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package config
import (
stderrors "errors"
"fmt"
"github.com/caarlos0/env/v11"
"github.com/creasty/defaults"
"github.com/goccy/go-yaml"
"github.com/jessevdk/go-flags"
Expand Down Expand Up @@ -50,6 +51,28 @@ func FromYAMLFile(name string, v Validator) error {
return nil
}

// EnvOptions is a type alias for [env.Options], so that only this package needs to import [env].
type EnvOptions = env.Options

// FromEnv parses environment variables and stores the result in the value pointed to by v.
// If v is nil or not a pointer, FromEnv returns an [ErrInvalidArgument] error.
func FromEnv(v Validator, options EnvOptions) error {
rv := reflect.ValueOf(v)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is the third time we are using this check I would move that to a utility function validateNonNilPointer().

if rv.Kind() != reflect.Ptr || rv.IsNil() {
return errors.Wrapf(ErrInvalidArgument, "non-nil pointer expected, got %T", v)
}

if err := defaults.Set(v); err != nil {
return errors.Wrap(err, "can't set config defaults")
}

if err := env.ParseWithOptions(v, options); err != nil {
return errors.Wrap(err, "can't parse environment variables")
}

return errors.Wrap(v.Validate(), "invalid configuration")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I'm not mistaken, we've discussed in other PRs whether we should reduce error checks before return by directly returning errors.Wrap() (as it handles nil), but I'm not entirely sure we've agreed on doing that or not. Compared to the validate handling in FromYAMLFile(), the reduced version here gives the first impression that it will return an error.

I opt for not returning errors.Wrap() if the error could be nil as a rule.

@yhabteab @oxzi Please add your opinion.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally, since I discovered that errors.Wrap() also handles nil errors, I do tend to use it as the last statement as well, instead of using an if statement and returning nil at the end. However, @oxzi also requested a change in #63 (comment) for not using it for readability reasons, so if you think this makes the code more readable/understandable, we can make it the standard.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the referenced comment, I primarily wanted a consistence between the two checks. Furthermore, when I am reading errors.Wrap(…), I am expecting an error to be handled and wrapped. Adding the if guard makes it more readable, imo. But honestly, I am fine with both.

}

// ParseFlags parses CLI flags and stores the result
// in the value pointed to by v. If v is nil or not a pointer,
// ParseFlags returns an [ErrInvalidArgument] error.
Expand Down
101 changes: 101 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package config
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think these tests are of any value as I have first have to understand what this code is actually doing. See #71 for my approach. Though I'd wait for #71 before changing tests here.


import (
"errors"
"github.com/stretchr/testify/require"
"reflect"
"testing"
)

type simpleValidator struct {
Foo int `env:"FOO"`
}

func (sv simpleValidator) Validate() error {
if sv.Foo == 42 {
return nil
} else {
return errors.New("invalid value")
}
}

type nonStructValidator int

func (nonStructValidator) Validate() error {
return nil
}

type defaultValidator struct {
Foo int `env:"FOO" default:"42"`
}

func (defaultValidator) Validate() error {
return nil
}

type prefixValidator struct {
Nested simpleValidator `envPrefix:"PREFIX_"`
}

func (prefixValidator) Validate() error {
return nil
}

func TestFromEnv(t *testing.T) {
subtests := []struct {
name string
opts EnvOptions
io Validator
error bool
}{
{name: "nil", error: true},
{name: "nonptr", io: simpleValidator{}, error: true},
{name: "nilptr", io: (*simpleValidator)(nil), error: true},
{name: "defaulterr", io: new(nonStructValidator), error: true},
{
name: "parseeerr",
opts: EnvOptions{Environment: map[string]string{"FOO": "bar"}},
io: &simpleValidator{},
error: true,
},
{
name: "invalid",
opts: EnvOptions{Environment: map[string]string{"FOO": "23"}},
io: &simpleValidator{},
error: true,
},
{name: "simple", opts: EnvOptions{Environment: map[string]string{"FOO": "42"}}, io: &simpleValidator{42}},
{name: "default", io: &defaultValidator{42}},
{name: "override", opts: EnvOptions{Environment: map[string]string{"FOO": "23"}}, io: &defaultValidator{23}},
{
name: "prefix",
opts: EnvOptions{Environment: map[string]string{"PREFIX_FOO": "42"}, Prefix: "PREFIX_"},
io: &simpleValidator{42},
},
{
name: "nested",
opts: EnvOptions{Environment: map[string]string{"PREFIX_FOO": "42"}},
io: &prefixValidator{simpleValidator{42}},
},
}

for _, st := range subtests {
t.Run(st.name, func(t *testing.T) {
var actual Validator
if vActual := reflect.ValueOf(st.io); vActual != (reflect.Value{}) {
if vActual.Kind() == reflect.Ptr && !vActual.IsNil() {
vActual = reflect.New(vActual.Type().Elem())
}

actual = vActual.Interface().(Validator)
}

if err := FromEnv(actual, st.opts); st.error {
require.Error(t, err)
} else {
require.NoError(t, err)
require.Equal(t, st.io, actual)
}
})
}
}
10 changes: 5 additions & 5 deletions config/tls.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ import (

// TLS provides TLS configuration options.
type TLS struct {
Enable bool `yaml:"tls"`
Cert string `yaml:"cert"`
Key string `yaml:"key"`
Ca string `yaml:"ca"`
Insecure bool `yaml:"insecure"`
Enable bool `yaml:"tls" env:"TLS"`
Cert string `yaml:"cert" env:"CERT"`
Key string `yaml:"key" env:"KEY"`
Ca string `yaml:"ca" env:"CA"`
Insecure bool `yaml:"insecure" env:"INSECURE"`
}

// MakeConfig assembles a tls.Config from t and serverName.
Expand Down
14 changes: 7 additions & 7 deletions database/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@ import (

// Config defines database client configuration.
type Config struct {
Type string `yaml:"type" default:"mysql"`
Host string `yaml:"host"`
Port int `yaml:"port"`
Database string `yaml:"database"`
User string `yaml:"user"`
Password string `yaml:"password"`
Type string `yaml:"type" env:"TYPE" default:"mysql"`
Host string `yaml:"host" env:"HOST"`
Port int `yaml:"port" env:"PORT"`
Database string `yaml:"database" env:"DATABASE"`
User string `yaml:"user" env:"USER"`
Password string `yaml:"password" env:"PASSWORD,unset"`
yhabteab marked this conversation as resolved.
Show resolved Hide resolved
TlsOptions config.TLS `yaml:",inline"`
Options Options `yaml:"options"`
Options Options `yaml:"options" envPrefix:"OPTIONS_"`
}

// Validate checks constraints in the supplied database configuration and returns an error if they are violated.
Expand Down
104 changes: 104 additions & 0 deletions database/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package database

import (
"github.com/creasty/defaults"
"github.com/icinga/icinga-go-library/config"
"github.com/stretchr/testify/require"
"testing"
)

func TestConfig(t *testing.T) {
var defaultOptions Options
require.NoError(t, defaults.Set(&defaultOptions), "setting default options")

subtests := []struct {
name string
opts config.EnvOptions
expected Config
error bool
}{
{
name: "empty-missing-fields",
opts: config.EnvOptions{},
error: true,
},
{
name: "unknown-db-type",
opts: config.EnvOptions{Environment: map[string]string{"TYPE": "☃"}},
error: true,
},
{
name: "minimal-config",
opts: config.EnvOptions{Environment: map[string]string{
"HOST": "db.example.com",
"USER": "user",
"DATABASE": "db",
}},
expected: Config{
Type: "mysql",
Host: "db.example.com",
Database: "db",
User: "user",
Options: defaultOptions,
},
},
{
name: "tls",
opts: config.EnvOptions{Environment: map[string]string{
"HOST": "db.example.com",
"USER": "user",
"DATABASE": "db",
"TLS": "true",
"CERT": "/var/empty/db.crt",
"CA": "/var/empty/ca.crt",
}},
expected: Config{
Type: "mysql",
Host: "db.example.com",
Database: "db",
User: "user",
TlsOptions: config.TLS{
Enable: true,
Cert: "/var/empty/db.crt",
Ca: "/var/empty/ca.crt",
},
Options: defaultOptions,
},
},
{
name: "options",
opts: config.EnvOptions{Environment: map[string]string{
"HOST": "db.example.com",
"USER": "user",
"DATABASE": "db",
"OPTIONS_MAX_CONNECTIONS": "1",
"OPTIONS_MAX_ROWS_PER_TRANSACTION": "65535",
}},
expected: Config{
Type: "mysql",
Host: "db.example.com",
Database: "db",
User: "user",
Options: Options{
MaxConnections: 1,
MaxConnectionsPerTable: 8,
MaxPlaceholdersPerStatement: 8192,
MaxRowsPerTransaction: 65535,
WsrepSyncWait: 7,
},
},
},
}

for _, test := range subtests {
t.Run(test.name, func(t *testing.T) {
var out Config
if err := config.FromEnv(&out, test.opts); test.error {
require.Error(t, err)
} else {
require.NoError(t, err)
require.Equal(t, test.expected, out)
}
})
}
}
10 changes: 5 additions & 5 deletions database/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,29 +46,29 @@ type DB struct {
// Options define user configurable database options.
type Options struct {
// Maximum number of open connections to the database.
MaxConnections int `yaml:"max_connections" default:"16"`
MaxConnections int `yaml:"max_connections" env:"MAX_CONNECTIONS" default:"16"`

// Maximum number of connections per table,
// regardless of what the connection is actually doing,
// e.g. INSERT, UPDATE, DELETE.
MaxConnectionsPerTable int `yaml:"max_connections_per_table" default:"8"`
MaxConnectionsPerTable int `yaml:"max_connections_per_table" env:"MAX_CONNECTIONS_PER_TABLE" default:"8"`

// MaxPlaceholdersPerStatement defines the maximum number of placeholders in an
// INSERT, UPDATE or DELETE statement. Theoretically, MySQL can handle up to 2^16-1 placeholders,
// but this increases the execution time of queries and thus reduces the number of queries
// that can be executed in parallel in a given time.
// The default is 2^13, which in our tests showed the best performance in terms of execution time and parallelism.
MaxPlaceholdersPerStatement int `yaml:"max_placeholders_per_statement" default:"8192"`
MaxPlaceholdersPerStatement int `yaml:"max_placeholders_per_statement" env:"MAX_PLACEHOLDERS_PER_STATEMENT" default:"8192"`

// MaxRowsPerTransaction defines the maximum number of rows per transaction.
// The default is 2^13, which in our tests showed the best performance in terms of execution time and parallelism.
MaxRowsPerTransaction int `yaml:"max_rows_per_transaction" default:"8192"`
MaxRowsPerTransaction int `yaml:"max_rows_per_transaction" env:"MAX_ROWS_PER_TRANSACTION" default:"8192"`

// WsrepSyncWait enforces Galera cluster nodes to perform strict cluster-wide causality checks
// before executing specific SQL queries determined by the number you provided.
// Please refer to the below link for a detailed description.
// https://icinga.com/docs/icinga-db/latest/doc/03-Configuration/#galera-cluster
WsrepSyncWait int `yaml:"wsrep_sync_wait" default:"7"`
WsrepSyncWait int `yaml:"wsrep_sync_wait" env:"WSREP_SYNC_WAIT" default:"7"`
}

// Validate checks constraints in the supplied database options and returns an error if they are violated.
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ module github.com/icinga/icinga-go-library
go 1.22

require (
github.com/caarlos0/env/v11 v11.1.0
github.com/creasty/defaults v1.8.0
github.com/go-sql-driver/mysql v1.8.1
github.com/goccy/go-yaml v1.12.0
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/caarlos0/env/v11 v11.1.0 h1:a5qZqieE9ZfzdvbbdhTalRrHT5vu/4V1/ad1Ka6frhI=
github.com/caarlos0/env/v11 v11.1.0/go.mod h1:LwgkYk1kDvfGpHthrWWLof3Ny7PezzFwS4QrsJdHTMo=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/creasty/defaults v1.8.0 h1:z27FJxCAa0JKt3utc0sCImAEb+spPucmKoOdLHvHYKk=
Expand Down
35 changes: 31 additions & 4 deletions logging/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,48 @@ import (
"github.com/pkg/errors"
"go.uber.org/zap/zapcore"
"os"
"strings"
"time"
)

// Options define child loggers with their desired log level.
type Options map[string]zapcore.Level

// UnmarshalText implements encoding.TextUnmarshaler to allow Options to be parsed by env.
//
// This custom TextUnmarshaler is necessary as - for the moment - env does not support map[T]encoding.TextUnmarshaler.
// After <https://github.com/caarlos0/env/pull/323> got merged and a new env release was drafted, this method can be
// removed.
func (o *Options) UnmarshalText(text []byte) error {
optionsMap := make(map[string]zapcore.Level)

for _, entry := range strings.Split(string(text), ",") {
key, valueStr, found := strings.Cut(entry, ":")
if !found {
return fmt.Errorf("entry %q cannot be unmarshalled as an Option entry", entry)
}

valueLvl, err := zapcore.ParseLevel(valueStr)
if err != nil {
return fmt.Errorf("entry %q cannot be unmarshalled as level, %w", entry, err)
}

optionsMap[key] = valueLvl
}

*o = optionsMap
return nil
}

// Config defines Logger configuration.
type Config struct {
// zapcore.Level at 0 is for info level.
Level zapcore.Level `yaml:"level" default:"0"`
Output string `yaml:"output"`
Level zapcore.Level `yaml:"level" env:"LEVEL" default:"0"`
Output string `yaml:"output" env:"OUTPUT"`
// Interval for periodic logging.
Interval time.Duration `yaml:"interval" default:"20s"`
Interval time.Duration `yaml:"interval" env:"INTERVAL" default:"20s"`

Options `yaml:"options"`
Options `yaml:"options" env:"OPTIONS"`
oxzi marked this conversation as resolved.
Show resolved Hide resolved
}

// Validate checks constraints in the configuration and returns an error if they are violated.
Expand Down
Loading
Loading