Restructure project
We previously didn't really had any structure in our project apart from creating a new folder for each package in our project root. Now that we have accumulated some packages, we use the well-known Golang project layout in order to clearly communicate our intent with packages. See https://github.com/golang-standards/project-layout
This commit is contained in:
195
internal/config/config.go
Normal file
195
internal/config/config.go
Normal file
@ -0,0 +1,195 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"github.com/sirupsen/logrus"
|
||||
"gitlab.hpi.de/codeocean/codemoon/poseidon/pkg/logging"
|
||||
"gopkg.in/yaml.v3"
|
||||
"net/url"
|
||||
"os"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Config contains the default configuration of Poseidon.
|
||||
var (
|
||||
Config = &configuration{
|
||||
Server: server{
|
||||
Address: "127.0.0.1",
|
||||
Port: 7200,
|
||||
Token: "",
|
||||
TLS: false,
|
||||
CertFile: "",
|
||||
KeyFile: "",
|
||||
InteractiveStderr: true,
|
||||
},
|
||||
Nomad: nomad{
|
||||
Address: "127.0.0.1",
|
||||
Port: 4646,
|
||||
Token: "",
|
||||
TLS: false,
|
||||
Namespace: "default",
|
||||
},
|
||||
Logger: logger{
|
||||
Level: "INFO",
|
||||
},
|
||||
}
|
||||
configurationFilePath = "./configuration.yaml"
|
||||
configurationInitialized = false
|
||||
log = logging.GetLogger("config")
|
||||
TLSConfig = &tls.Config{
|
||||
MinVersion: tls.VersionTLS13,
|
||||
CurvePreferences: []tls.CurveID{tls.CurveP521, tls.CurveP384, tls.CurveP256},
|
||||
PreferServerCipherSuites: true,
|
||||
}
|
||||
ErrConfigInitialized = errors.New("configuration is already initialized")
|
||||
)
|
||||
|
||||
// server configures the Poseidon webserver.
|
||||
type server struct {
|
||||
Address string
|
||||
Port int
|
||||
Token string
|
||||
TLS bool
|
||||
CertFile string
|
||||
KeyFile string
|
||||
InteractiveStderr bool
|
||||
}
|
||||
|
||||
// nomad configures the used Nomad cluster.
|
||||
type nomad struct {
|
||||
Address string
|
||||
Port int
|
||||
Token string
|
||||
TLS bool
|
||||
Namespace string
|
||||
}
|
||||
|
||||
// logger configures the used logger.
|
||||
type logger struct {
|
||||
Level string
|
||||
}
|
||||
|
||||
// configuration contains the complete configuration of Poseidon.
|
||||
type configuration struct {
|
||||
Server server
|
||||
Nomad nomad
|
||||
Logger logger
|
||||
}
|
||||
|
||||
// InitConfig merges configuration options from environment variables and
|
||||
// a configuration file into the default configuration. Calls of InitConfig
|
||||
// after the first call have no effect and return an error. InitConfig
|
||||
// should be called directly after starting the program.
|
||||
func InitConfig() error {
|
||||
if configurationInitialized {
|
||||
return ErrConfigInitialized
|
||||
}
|
||||
configurationInitialized = true
|
||||
content := readConfigFile()
|
||||
Config.mergeYaml(content)
|
||||
Config.mergeEnvironmentVariables()
|
||||
return nil
|
||||
}
|
||||
|
||||
// NomadAPIURL returns the URL for the configured Nomad cluster.
|
||||
func (c *configuration) NomadAPIURL() *url.URL {
|
||||
return parseURL(Config.Nomad.Address, Config.Nomad.Port, Config.Nomad.TLS)
|
||||
}
|
||||
|
||||
// PoseidonAPIURL returns the URL of the Poseidon webserver.
|
||||
func (c *configuration) PoseidonAPIURL() *url.URL {
|
||||
return parseURL(Config.Server.Address, Config.Server.Port, false)
|
||||
}
|
||||
|
||||
func parseURL(address string, port int, tlsEnabled bool) *url.URL {
|
||||
scheme := "http"
|
||||
if tlsEnabled {
|
||||
scheme = "https"
|
||||
}
|
||||
return &url.URL{
|
||||
Scheme: scheme,
|
||||
Host: fmt.Sprintf("%s:%d", address, port),
|
||||
}
|
||||
}
|
||||
|
||||
func readConfigFile() []byte {
|
||||
parseFlags()
|
||||
data, err := os.ReadFile(configurationFilePath)
|
||||
if err != nil {
|
||||
log.WithError(err).Info("Using default configuration...")
|
||||
return nil
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func parseFlags() {
|
||||
if flag.Lookup("config") == nil {
|
||||
flag.StringVar(&configurationFilePath, "config", configurationFilePath, "path of the yaml config file")
|
||||
}
|
||||
flag.Parse()
|
||||
}
|
||||
|
||||
func (c *configuration) mergeYaml(content []byte) {
|
||||
if err := yaml.Unmarshal(content, c); err != nil {
|
||||
log.WithError(err).Fatal("Could not parse configuration file")
|
||||
}
|
||||
}
|
||||
|
||||
func (c *configuration) mergeEnvironmentVariables() {
|
||||
readFromEnvironment("POSEIDON", reflect.ValueOf(c).Elem())
|
||||
}
|
||||
|
||||
func readFromEnvironment(prefix string, value reflect.Value) {
|
||||
logEntry := log.WithField("prefix", prefix)
|
||||
// if value was not derived from a pointer, it is not possible to alter its contents
|
||||
if !value.CanSet() {
|
||||
logEntry.Warn("Cannot overwrite struct field that can not be set")
|
||||
return
|
||||
}
|
||||
|
||||
if value.Kind() != reflect.Struct {
|
||||
loadValue(prefix, value, logEntry)
|
||||
} else {
|
||||
for i := 0; i < value.NumField(); i++ {
|
||||
fieldName := value.Type().Field(i).Name
|
||||
newPrefix := fmt.Sprintf("%s_%s", prefix, strings.ToUpper(fieldName))
|
||||
readFromEnvironment(newPrefix, value.Field(i))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadValue(prefix string, value reflect.Value, logEntry *logrus.Entry) {
|
||||
content, ok := os.LookupEnv(prefix)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
logEntry = logEntry.WithField("content", content)
|
||||
|
||||
switch value.Kind() {
|
||||
case reflect.String:
|
||||
value.SetString(content)
|
||||
case reflect.Int:
|
||||
integer, err := strconv.Atoi(content)
|
||||
if err != nil {
|
||||
logEntry.Warn("Could not parse environment variable as integer")
|
||||
return
|
||||
}
|
||||
value.SetInt(int64(integer))
|
||||
case reflect.Bool:
|
||||
boolean, err := strconv.ParseBool(content)
|
||||
if err != nil {
|
||||
logEntry.Warn("Could not parse environment variable as boolean")
|
||||
return
|
||||
}
|
||||
value.SetBool(boolean)
|
||||
default:
|
||||
// ignore this field
|
||||
logEntry.WithField("type", value.Type().Name()).
|
||||
Warn("Setting configuration option via environment variables is not supported")
|
||||
}
|
||||
}
|
208
internal/config/config_test.go
Normal file
208
internal/config/config_test.go
Normal file
@ -0,0 +1,208 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/sirupsen/logrus/hooks/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var (
|
||||
getServerPort = func(c *configuration) interface{} { return c.Server.Port }
|
||||
getNomadToken = func(c *configuration) interface{} { return c.Nomad.Token }
|
||||
getNomadTLS = func(c *configuration) interface{} { return c.Nomad.TLS }
|
||||
)
|
||||
|
||||
func newTestConfiguration() *configuration {
|
||||
return &configuration{
|
||||
Server: server{
|
||||
Address: "127.0.0.1",
|
||||
Port: 3000,
|
||||
},
|
||||
Nomad: nomad{
|
||||
Address: "127.0.0.2",
|
||||
Port: 4646,
|
||||
Token: "SECRET",
|
||||
TLS: false,
|
||||
},
|
||||
Logger: logger{
|
||||
Level: "INFO",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *configuration) getReflectValue() reflect.Value {
|
||||
return reflect.ValueOf(c).Elem()
|
||||
}
|
||||
|
||||
// writeConfigurationFile creates a file on disk and returns the path to it.
|
||||
func writeConfigurationFile(t *testing.T, name string, content []byte) string {
|
||||
t.Helper()
|
||||
directory := t.TempDir()
|
||||
filePath := filepath.Join(directory, name)
|
||||
file, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
t.Fatal("Could not create config file")
|
||||
}
|
||||
defer file.Close()
|
||||
_, err = file.Write(content)
|
||||
require.NoError(t, err)
|
||||
return filePath
|
||||
}
|
||||
|
||||
func TestCallingInitConfigTwiceReturnsError(t *testing.T) {
|
||||
configurationInitialized = false
|
||||
err := InitConfig()
|
||||
assert.NoError(t, err)
|
||||
err = InitConfig()
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestCallingInitConfigTwiceDoesNotChangeConfig(t *testing.T) {
|
||||
configurationInitialized = false
|
||||
err := InitConfig()
|
||||
require.NoError(t, err)
|
||||
Config = newTestConfiguration()
|
||||
filePath := writeConfigurationFile(t, "test.yaml", []byte("server:\n port: 5000\n"))
|
||||
oldArgs := os.Args
|
||||
defer func() { os.Args = oldArgs }()
|
||||
os.Args = append(os.Args, "-config", filePath)
|
||||
err = InitConfig()
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, 3000, Config.Server.Port)
|
||||
}
|
||||
|
||||
func TestReadEnvironmentVariables(t *testing.T) {
|
||||
var environmentTests = []struct {
|
||||
variableSuffix string
|
||||
valueToSet string
|
||||
expectedValue interface{}
|
||||
getTargetField func(*configuration) interface{}
|
||||
}{
|
||||
{"SERVER_PORT", "4000", 4000, getServerPort},
|
||||
{"SERVER_PORT", "hello", 3000, getServerPort},
|
||||
{"NOMAD_TOKEN", "ACCESS", "ACCESS", getNomadToken},
|
||||
{"NOMAD_TLS", "true", true, getNomadTLS},
|
||||
{"NOMAD_TLS", "hello", false, getNomadTLS},
|
||||
}
|
||||
prefix := "POSEIDON_TEST"
|
||||
for _, testCase := range environmentTests {
|
||||
config := newTestConfiguration()
|
||||
environmentVariable := fmt.Sprintf("%s_%s", prefix, testCase.variableSuffix)
|
||||
_ = os.Setenv(environmentVariable, testCase.valueToSet)
|
||||
readFromEnvironment(prefix, config.getReflectValue())
|
||||
_ = os.Unsetenv(environmentVariable)
|
||||
assert.Equal(t, testCase.expectedValue, testCase.getTargetField(config))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadEnvironmentIgnoresNonPointerValue(t *testing.T) {
|
||||
config := newTestConfiguration()
|
||||
_ = os.Setenv("POSEIDON_TEST_SERVER_PORT", "4000")
|
||||
readFromEnvironment("POSEIDON_TEST", reflect.ValueOf(config))
|
||||
_ = os.Unsetenv("POSEIDON_TEST_SERVER_PORT")
|
||||
assert.Equal(t, 3000, config.Server.Port)
|
||||
}
|
||||
|
||||
func TestReadEnvironmentIgnoresNotSupportedType(t *testing.T) {
|
||||
config := &struct{ Timeout float64 }{1.0}
|
||||
_ = os.Setenv("POSEIDON_TEST_TIMEOUT", "2.5")
|
||||
readFromEnvironment("POSEIDON_TEST", reflect.ValueOf(config).Elem())
|
||||
_ = os.Unsetenv("POSEIDON_TEST_TIMEOUT")
|
||||
assert.Equal(t, 1.0, config.Timeout)
|
||||
}
|
||||
|
||||
func TestUnsetEnvironmentVariableDoesNotChangeConfig(t *testing.T) {
|
||||
config := newTestConfiguration()
|
||||
readFromEnvironment("POSEIDON_TEST", config.getReflectValue())
|
||||
assert.Equal(t, "INFO", config.Logger.Level)
|
||||
}
|
||||
|
||||
func TestReadYamlConfigFile(t *testing.T) {
|
||||
var yamlTests = []struct {
|
||||
content []byte
|
||||
expectedValue interface{}
|
||||
getTargetField func(*configuration) interface{}
|
||||
}{
|
||||
{[]byte("server:\n port: 5000\n"), 5000, getServerPort},
|
||||
{[]byte("nomad:\n token: ACCESS\n"), "ACCESS", getNomadToken},
|
||||
{[]byte("nomad:\n tls: true\n"), true, getNomadTLS},
|
||||
{[]byte(""), false, getNomadTLS},
|
||||
{[]byte("nomad:\n token:\n"), "SECRET", getNomadToken},
|
||||
}
|
||||
for _, testCase := range yamlTests {
|
||||
config := newTestConfiguration()
|
||||
config.mergeYaml(testCase.content)
|
||||
assert.Equal(t, testCase.expectedValue, testCase.getTargetField(config))
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidYamlExitsProgram(t *testing.T) {
|
||||
logger, hook := test.NewNullLogger()
|
||||
// this function is used when calling log.Fatal() and
|
||||
// prevents the program from exiting during this test
|
||||
logger.ExitFunc = func(code int) {}
|
||||
log = logger.WithField("package", "config_test")
|
||||
config := newTestConfiguration()
|
||||
config.mergeYaml([]byte("logger: level: DEBUG"))
|
||||
assert.Equal(t, 1, len(hook.Entries))
|
||||
assert.Equal(t, logrus.FatalLevel, hook.LastEntry().Level)
|
||||
}
|
||||
|
||||
func TestReadConfigFileOverwritesConfig(t *testing.T) {
|
||||
Config = newTestConfiguration()
|
||||
filePath := writeConfigurationFile(t, "test.yaml", []byte("server:\n port: 5000\n"))
|
||||
oldArgs := os.Args
|
||||
defer func() { os.Args = oldArgs }()
|
||||
os.Args = append(os.Args, "-config", filePath)
|
||||
configurationInitialized = false
|
||||
err := InitConfig()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 5000, Config.Server.Port)
|
||||
}
|
||||
|
||||
func TestReadNonExistingConfigFileDoesNotOverwriteConfig(t *testing.T) {
|
||||
Config = newTestConfiguration()
|
||||
oldArgs := os.Args
|
||||
defer func() { os.Args = oldArgs }()
|
||||
os.Args = append(os.Args, "-config", "file_does_not_exist.yaml")
|
||||
configurationInitialized = false
|
||||
err := InitConfig()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 3000, Config.Server.Port)
|
||||
}
|
||||
|
||||
func TestURLParsing(t *testing.T) {
|
||||
var urlTests = []struct {
|
||||
address string
|
||||
port int
|
||||
tls bool
|
||||
expectedScheme string
|
||||
expectedHost string
|
||||
}{
|
||||
{"localhost", 3000, false, "http", "localhost:3000"},
|
||||
{"127.0.0.1", 4000, true, "https", "127.0.0.1:4000"},
|
||||
}
|
||||
for _, testCase := range urlTests {
|
||||
url := parseURL(testCase.address, testCase.port, testCase.tls)
|
||||
assert.Equal(t, testCase.expectedScheme, url.Scheme)
|
||||
assert.Equal(t, testCase.expectedHost, url.Host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNomadAPIURL(t *testing.T) {
|
||||
config := newTestConfiguration()
|
||||
assert.Equal(t, "http", config.NomadAPIURL().Scheme)
|
||||
assert.Equal(t, "127.0.0.2:4646", config.NomadAPIURL().Host)
|
||||
}
|
||||
|
||||
func TestPoseidonAPIURL(t *testing.T) {
|
||||
config := newTestConfiguration()
|
||||
assert.Equal(t, "http", config.PoseidonAPIURL().Scheme)
|
||||
assert.Equal(t, "127.0.0.1:3000", config.PoseidonAPIURL().Host)
|
||||
}
|
Reference in New Issue
Block a user