From 9a99d0d76aca0984d03869ded7f93413ad4222e9 Mon Sep 17 00:00:00 2001 From: Heiko Kast <63250259+H777K@users.noreply.github.com> Date: Tue, 21 Nov 2023 15:58:50 +0100 Subject: [PATCH] feat: improve configuration parsing (#24) This PR ensures, that the configuration of the garm-operator can be done with `ENVs`, `Flags` and `Config File (yaml)` --------- Co-authored-by: bavarianbidi --- .github/workflows/build.yml | 2 +- .github/workflows/foss.yaml | 2 +- .github/workflows/release.yml | 2 +- Dockerfile | 2 +- README.md | 3 + cmd/main.go | 153 ++++++++++----------------- config/manager/manager.yaml | 8 +- docs/config/configuration-parsing.md | 122 +++++++++++++++++++++ go.mod | 17 ++- go.sum | 33 +++++- pkg/config/config.go | 89 ++++++++++++++++ pkg/config/config_test.go | 152 ++++++++++++++++++++++++++ pkg/config/test_config.yaml | 11 ++ pkg/defaults/defaults.go | 14 +++ pkg/flags/flags.go | 38 +++++++ 15 files changed, 536 insertions(+), 112 deletions(-) create mode 100644 docs/config/configuration-parsing.md create mode 100644 pkg/config/config.go create mode 100644 pkg/config/config_test.go create mode 100644 pkg/config/test_config.yaml create mode 100644 pkg/defaults/defaults.go create mode 100644 pkg/flags/flags.go diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f6d40f72..dcbae37f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -21,7 +21,7 @@ jobs: - uses: actions/setup-go@v4 with: - go-version: '1.21.3' + go-version: '1.21.4' - name: make verify run: make verify diff --git a/.github/workflows/foss.yaml b/.github/workflows/foss.yaml index 0f40a386..3e6a7280 100644 --- a/.github/workflows/foss.yaml +++ b/.github/workflows/foss.yaml @@ -16,7 +16,7 @@ jobs: - name: Set up Go 1.x uses: actions/setup-go@v4 with: - go-version: '1.21.3' + go-version: '1.21.4' id: go - name: Checkout code diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3334277c..6139cca3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,7 +29,7 @@ jobs: - run: git fetch --force --tags - uses: actions/setup-go@v4 with: - go-version: '1.21.3' + go-version: '1.21.4' - name: Synopsys Detect run: | diff --git a/Dockerfile b/Dockerfile index 970f80b8..4968fb08 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # SPDX-License-Identifier: MIT # Build the manager binary -FROM golang:1.21.3 as builder +FROM golang:1.21.4 as builder ARG TARGETOS ARG TARGETARCH diff --git a/README.md b/README.md index ab3c9972..c8af7d1b 100644 --- a/README.md +++ b/README.md @@ -61,9 +61,12 @@ export GARM_OPERATOR_VERSION= export GARM_SERVER_URL= export GARM_SERVER_USERNAME= export GARM_SERVER_PASSWORD= +export OPERATOR_WATCH_NAMESPACE= curl -L https://github.com/mercedes-benz/garm-operator/releases/download/${GARM_OPERATOR_VERSION}/garm-operator-all.yaml | envsubst | kubectl apply -f - ``` +The full configuration parsing documentation can be found in the [configuration parsing guide](./docs/config/configuration-parsing.md) + #### Custom Resources The CRD documentation can be also seen via [docs.crds.dev](https://doc.crds.dev/github.com/mercedes-benz/garm-operator). diff --git a/cmd/main.go b/cmd/main.go index 96dc306b..ba960c61 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -3,17 +3,14 @@ package main import ( - "errors" - "flag" + "fmt" "os" - "time" - "github.com/spf13/pflag" + "gopkg.in/yaml.v2" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" _ "k8s.io/client-go/plugin/pkg/client/auth" - "k8s.io/klog/v2" "k8s.io/klog/v2/klogr" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/cache" @@ -21,6 +18,8 @@ import ( garmoperatorv1alpha1 "github.com/mercedes-benz/garm-operator/api/v1alpha1" "github.com/mercedes-benz/garm-operator/internal/controller" + "github.com/mercedes-benz/garm-operator/pkg/config" + "github.com/mercedes-benz/garm-operator/pkg/flags" ) var ( @@ -36,68 +35,45 @@ func init() { } func main() { - var ( - metricsAddr string - enableLeaderElection bool - probeAddr string - syncPeriod time.Duration - - watchNamespace string - - garmServer string - garmUsername string - garmPassword string - ) - - flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.") - flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") - flag.BoolVar(&enableLeaderElection, "leader-elect", false, - "Enable leader election for controller manager. "+ - "Enabling this will ensure there is only one active controller manager.") - flag.DurationVar(&syncPeriod, "sync-period", 5*time.Minute, - "The minimum interval at which watched resources are reconciled (e.g. 15m)") - - flag.StringVar(&watchNamespace, "namespace", "", - "Namespace that the controller watches to reconcile garm objects. If unspecified, the controller watches for garm objects across all namespaces.") - - flag.StringVar(&garmServer, "garm-server", "", "The address of the GARM server") - flag.StringVar(&garmUsername, "garm-username", "", "The username for the GARM server") - flag.StringVar(&garmPassword, "garm-password", "", "The password for the GARM server") - - klog.InitFlags(flag.CommandLine) - pflag.CommandLine.AddGoFlagSet(flag.CommandLine) - pflag.Parse() ctrl.SetLogger(klogr.New()) - // configure garm client from environment variables - if len(os.Getenv("GARM_SERVER")) > 0 { - setupLog.Info("Using garm-server from environment variable") - garmServer = os.Getenv("GARM_SERVER") - } - if len(os.Getenv("GARM_USERNAME")) > 0 { - setupLog.Info("Using garm-username from environment variable") - garmUsername = os.Getenv("GARM_USERNAME") - } - if len(os.Getenv("GARM_PASSWORD")) > 0 { - setupLog.Info("Using garm-password from environment variable") - garmPassword = os.Getenv("GARM_PASSWORD") + // initiate flags + f := flags.InitiateFlags() + + // retrieve config flag value for GenerateConfig() function + configFile := f.Lookup("config").Value.String() + + // call GenerateConfig() function from config package + if err := config.GenerateConfig(f, configFile); err != nil { + setupLog.Error(err, "failed to read config") + os.Exit(1) } - if len(os.Getenv("WATCH_NAMESPACE")) > 0 { - setupLog.Info("using watch-namespace from environment variable") - watchNamespace = os.Getenv("WATCH_NAMESPACE") + + // check if dry-run flag is set to true + dryRun, _ := f.GetBool("dry-run") + + // perform dry-run if enabled and print out the generated Config as yaml + if dryRun { + yamlConfig, err := yaml.Marshal(config.Config) + if err != nil { + setupLog.Error(err, "failed to marshal config as yaml") + os.Exit(1) + } + fmt.Printf("generated Config as yaml:\n%s\n", yamlConfig) + os.Exit(0) } var watchNamespaces []string - if watchNamespace != "" { - watchNamespaces = []string{watchNamespace} + if config.Config.Operator.WatchNamespace != "" { + watchNamespaces = []string{config.Config.Operator.WatchNamespace} } mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ Scheme: scheme, - MetricsBindAddress: metricsAddr, + MetricsBindAddress: config.Config.Operator.MetricsBindAddress, Port: 9443, - HealthProbeBindAddress: probeAddr, - LeaderElection: enableLeaderElection, + HealthProbeBindAddress: config.Config.Operator.HealthProbeBindAddress, + LeaderElection: config.Config.Operator.LeaderElection, LeaderElectionID: "b608d8b3.mercedes-benz.com", // LeaderElectionReleaseOnCancel defines if the leader should step down voluntarily // when the Manager ends. This requires the binary to immediately end when the @@ -113,10 +89,10 @@ func main() { // // Default Sync Period = 10 hours. // Set default via flag to 5 minutes - SyncPeriod: &syncPeriod, + SyncPeriod: &config.Config.Operator.SyncPeriod, Cache: cache.Options{ Namespaces: watchNamespaces, - SyncPeriod: &syncPeriod, + SyncPeriod: &config.Config.Operator.SyncPeriod, }, }) if err != nil { @@ -124,27 +100,14 @@ func main() { os.Exit(1) } - if garmServer == "" { - setupLog.Error(errors.New("unable to fetch garm server from either flag or os_env"), "unable to start manager") - os.Exit(1) - } - if garmUsername == "" { - setupLog.Error(errors.New("unable to fetch garm username from either flag or os_env"), "unable to start manager") - os.Exit(1) - } - if garmPassword == "" { - setupLog.Error(errors.New("unable to fetch garm password from either flag or os_env"), "unable to start manager") - os.Exit(1) - } - if err = (&controller.EnterpriseReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), Recorder: mgr.GetEventRecorderFor("enterprise-controller"), - BaseURL: garmServer, - Username: garmUsername, - Password: garmPassword, + BaseURL: config.Config.Garm.Server, + Username: config.Config.Garm.Username, + Password: config.Config.Garm.Password, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "Enterprise") os.Exit(1) @@ -154,27 +117,25 @@ func main() { Scheme: mgr.GetScheme(), Recorder: mgr.GetEventRecorderFor("pool-controller"), - BaseURL: garmServer, - Username: garmUsername, - Password: garmPassword, + BaseURL: config.Config.Garm.Server, + Username: config.Config.Garm.Username, + Password: config.Config.Garm.Password, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "Pool") os.Exit(1) } - if os.Getenv("CREATE_WEBHOOK") == "true" { - if err = (&garmoperatorv1alpha1.Pool{}).SetupWebhookWithManager(mgr); err != nil { - setupLog.Error(err, "unable to create webhook", "webhook", "Pool") - os.Exit(1) - } - if err = (&garmoperatorv1alpha1.Image{}).SetupWebhookWithManager(mgr); err != nil { - setupLog.Error(err, "unable to create webhook", "webhook", "Image") - os.Exit(1) - } - if err = (&garmoperatorv1alpha1.Repository{}).SetupWebhookWithManager(mgr); err != nil { - setupLog.Error(err, "unable to create webhook", "webhook", "Repository") - os.Exit(1) - } + if err = (&garmoperatorv1alpha1.Pool{}).SetupWebhookWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create webhook", "webhook", "Pool") + os.Exit(1) + } + if err = (&garmoperatorv1alpha1.Image{}).SetupWebhookWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create webhook", "webhook", "Image") + os.Exit(1) + } + if err = (&garmoperatorv1alpha1.Repository{}).SetupWebhookWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create webhook", "webhook", "Repository") + os.Exit(1) } if err = (&controller.OrganizationReconciler{ @@ -182,9 +143,9 @@ func main() { Scheme: mgr.GetScheme(), Recorder: mgr.GetEventRecorderFor("organization-controller"), - BaseURL: garmServer, - Username: garmUsername, - Password: garmPassword, + BaseURL: config.Config.Garm.Server, + Username: config.Config.Garm.Username, + Password: config.Config.Garm.Password, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "Organization") os.Exit(1) @@ -195,9 +156,9 @@ func main() { Scheme: mgr.GetScheme(), Recorder: mgr.GetEventRecorderFor("repository-controller"), - BaseURL: garmServer, - Username: garmUsername, - Password: garmPassword, + BaseURL: config.Config.Garm.Server, + Username: config.Config.Garm.Username, + Password: config.Config.Garm.Password, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "Repository") os.Exit(1) diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml index 42a95990..03f12398 100644 --- a/config/manager/manager.yaml +++ b/config/manager/manager.yaml @@ -50,14 +50,8 @@ spec: - --garm-server=$GARM_SERVER_URL - --garm-username=$GARM_SERVER_USERNAME - --garm-password=$GARM_SERVER_PASSWORD + - --operator-watch-namespace=$OPERATOR_WATCH_NAMESPACE image: controller:latest - env: - - name: CREATE_WEBHOOK - value: "true" - - name: WATCH_NAMESPACE - valueFrom: - fieldRef: - fieldPath: metadata.namespace name: manager securityContext: allowPrivilegeEscalation: false diff --git a/docs/config/configuration-parsing.md b/docs/config/configuration-parsing.md new file mode 100644 index 00000000..15a802dc --- /dev/null +++ b/docs/config/configuration-parsing.md @@ -0,0 +1,122 @@ + + +# Configuration Parsing + +The configuration parsing for the Garm Operator is implemented with [koanf](https://github.com/knadh/koanf). + +The configuration can be defined with `ENVs`, `Flags` and `config file (yaml)`. + + +- [Parsing Order](#parsing-order) +- [ENVs](#envs) +- [Flags](#flags) + - [Additional Flags](#additional-flags) +- [Config File (yaml)](#config-file-yaml) +- [Configuration Default Values](#configuration-default-values) +- [Parsing Validation](#parsing-validation) + + +## Parsing Order + +Koanf does not specify any order of priority for the various configuration options. + +The order is determined by the order in which the Read() function is called. + +For the Garm Operator the following order is defined, which is to be considered in ascending order from lowest to highest priority: + +1. Defined default values ([see section configuration default values](#configuration-default-values)) +1. ENVs +1. Flags +1. Config File (yaml) + +## ENVs + +All ENVs with the `OPERATOR_` and `GARM_` prefix will be merged by koanf. However, only the following ENVs will be parsed: + +``` +GARM_SERVER +GARM_USERNAME +GARM_PASSWORD + +OPERATOR_METRICS_BIND_ADDRESS +OPERATOR_HEALTH_PROBE_BIND_ADDRESS +OPERATOR_LEADER_ELECTION +OPERATOR_SYNC_PERIOD +OPERATOR_WATCH_NAMESPACE +``` + +## Flags + +The following flags will be parsed and can be found in the [flags package](../../pkg/flags/flags.go): + +``` +--garm-server +--garm-username +--garm-password + +--operator-metrics-bind-address +--operator-health-probe-bind-address +--operator-leader-election +--operator-sync-period +--operator-watch-namespace +``` + +### Additional Flags + +In addition to the previously mentioned flags, there are two additional flags: + +``` +--config +--dry-run +``` + +The `--config` flag can be set to specify the path to the `config file (yaml)` which contains the configuration ([see section config file structure](#config-file-structure)). + +The `--dry-run` flag can be set to show the parsed configuration, without starting the Garm Operator. The output can be similar to the following: + +``` +generated Config as yaml: +garm: + server: http://garm-server:9997 + username: admin + password: 123456789 +operator: + metricsbindaddress: :8080 + healthprobebindaddress: :8081 + leaderelection: false + syncperiod: 5m0s + watchnamespace: garm-operator-system +``` + +## Config File (yaml) + +The following keys in the config file (yaml) will be parsed: + +```yaml +# config.yaml +garm: + server: "http://garm-server:9997" + username: "garm-username" + password: "garm-password" + +operator: + metrics_bind_address: ":7000" + health_probe_bind_address: ":7001" + leader_election: true + sync_period: "10m" + watch_namespace: "garm-operator-namespace" +``` + +## Configuration Default Values + +The defined default values for the configuration can be found in the [defaults package](../../pkg/defaults/defaults.go). + +## Parsing Validation + +After the configuration has been parsed by koanf and unmarshalled into a struct, the [validator](https://github.com/go-playground/validator) checks whether the generated struct is valid or not. + +For example, if the `Garm Username` is not set, the following error message is returned by the validator: + +``` +setup "msg"="failed to read config" "error"="invalid config: set with env, flag or in config file: Key: 'AppConfig.Garm.Username' Error:Field validation for 'Username' failed on the 'required' tag" +``` diff --git a/go.mod b/go.mod index e6f2c845..262b4e93 100644 --- a/go.mod +++ b/go.mod @@ -1,16 +1,23 @@ // SPDX-License-Identifier: MIT module github.com/mercedes-benz/garm-operator -go 1.21.3 +go 1.21.4 require ( github.com/cloudbase/garm v0.1.3 github.com/cloudbase/garm-provider-common v0.1.0 github.com/go-openapi/runtime v0.26.0 + github.com/go-playground/validator/v10 v10.16.0 + github.com/knadh/koanf/parsers/yaml v0.1.0 + github.com/knadh/koanf/providers/env v0.1.0 + github.com/knadh/koanf/providers/file v0.1.0 + github.com/knadh/koanf/providers/posflag v0.1.0 + github.com/knadh/koanf/v2 v2.0.1 github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.16.0 github.com/spf13/pflag v1.0.5 go.uber.org/mock v0.2.0 + gopkg.in/yaml.v2 v2.4.0 k8s.io/api v0.27.2 k8s.io/apimachinery v0.27.2 k8s.io/client-go v0.27.2 @@ -30,6 +37,7 @@ require ( github.com/evanphx/json-patch v4.12.0+incompatible // indirect github.com/evanphx/json-patch/v5 v5.6.0 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.2 // indirect github.com/go-logr/logr v1.2.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/analysis v0.21.4 // indirect @@ -41,6 +49,8 @@ require ( github.com/go-openapi/strfmt v0.21.7 // indirect github.com/go-openapi/swag v0.22.4 // indirect github.com/go-openapi/validate v0.22.1 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.3 // indirect @@ -53,9 +63,13 @@ require ( github.com/imdario/mergo v0.3.6 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/knadh/koanf/maps v0.1.1 // indirect + github.com/leodido/go-urn v1.2.4 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect @@ -78,7 +92,6 @@ require ( google.golang.org/appengine v1.6.7 // indirect google.golang.org/protobuf v1.30.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/apiextensions-apiserver v0.27.2 // indirect k8s.io/component-base v0.27.2 // indirect diff --git a/go.sum b/go.sum index 2ac81863..57acecd6 100644 --- a/go.sum +++ b/go.sum @@ -16,8 +16,6 @@ github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cloudbase/garm v0.1.3 h1:/8F7Rnk/tKfK9G3z1mm3IyvizbaZ5WbNFWNA1kpviHE= github.com/cloudbase/garm v0.1.3/go.mod h1:R+EGVGriGx/t9TNUwfIQFnu/58rh1Inka08fsV6IB/c= -github.com/cloudbase/garm-provider-common v0.0.0-20230724114054-7aa0a3dfbce0 h1:5ScMXea/ZIcUbw1aXAgN8xTqSG84AOf5Maf5hBC82wQ= -github.com/cloudbase/garm-provider-common v0.0.0-20230724114054-7aa0a3dfbce0/go.mod h1:RKzgL0MXkNeGfloQpE2swz/y4LWJr5+2Wd45bSXPB0k= github.com/cloudbase/garm-provider-common v0.1.0 h1:gc2n8nsLjt7G3InAfqZ+75iZjSIUkIx86d6/DFA2+jc= github.com/cloudbase/garm-provider-common v0.1.0/go.mod h1:igxJRT3OlykERYc6ssdRQXcb+BCaeSfnucg6I0OSoDc= github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I= @@ -38,6 +36,8 @@ github.com/evanphx/json-patch/v5 v5.6.0 h1:b91NhWfaz02IuVxO9faSllyAtNXHMPkC5J8sJ github.com/evanphx/json-patch/v5 v5.6.0/go.mod h1:G79N1coSVB93tBe7j6PhzjmR3/2VvlbKOFpnXhI9Bw4= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= +github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= +github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= @@ -84,6 +84,14 @@ github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogB github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/go-openapi/validate v0.22.1 h1:G+c2ub6q47kfX1sOBLwIQwzBVt8qmOAARyo/9Fqs9NU= github.com/go-openapi/validate v0.22.1/go.mod h1:rjnrwK57VJ7A8xqfpAOEKRH8yQSGUriMu5/zuPSQ1hg= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.16.0 h1:x+plE831WK4vaKHO/jpgUGsvLKIqRRkz6M78GuJAfGE= +github.com/go-playground/validator/v10 v10.16.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= @@ -167,6 +175,18 @@ github.com/karrick/godirwalk v1.10.3/go.mod h1:RoGL9dQei4vP9ilrpETWE8CLOZ1kiN0Lh github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= +github.com/knadh/koanf/maps v0.1.1 h1:G5TjmUh2D7G2YWf5SQQqSiHRJEjaicvU0KpypqB3NIs= +github.com/knadh/koanf/maps v0.1.1/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= +github.com/knadh/koanf/parsers/yaml v0.1.0 h1:ZZ8/iGfRLvKSaMEECEBPM1HQslrZADk8fP1XFUxVI5w= +github.com/knadh/koanf/parsers/yaml v0.1.0/go.mod h1:cvbUDC7AL23pImuQP0oRw/hPuccrNBS2bps8asS0CwY= +github.com/knadh/koanf/providers/env v0.1.0 h1:LqKteXqfOWyx5Ab9VfGHmjY9BvRXi+clwyZozgVRiKg= +github.com/knadh/koanf/providers/env v0.1.0/go.mod h1:RE8K9GbACJkeEnkl8L/Qcj8p4ZyPXZIQ191HJi44ZaQ= +github.com/knadh/koanf/providers/file v0.1.0 h1:fs6U7nrV58d3CFAFh8VTde8TM262ObYf3ODrc//Lp+c= +github.com/knadh/koanf/providers/file v0.1.0/go.mod h1:rjJ/nHQl64iYCtAW2QQnF0eSmDEX/YZ/eNFj5yR6BvA= +github.com/knadh/koanf/providers/posflag v0.1.0 h1:mKJlLrKPcAP7Ootf4pBZWJ6J+4wHYujwipe7Ie3qW6U= +github.com/knadh/koanf/providers/posflag v0.1.0/go.mod h1:SYg03v/t8ISBNrMBRMlojH8OsKowbkXV7giIbBVgbz0= +github.com/knadh/koanf/v2 v2.0.1 h1:1dYGITt1I23x8cfx8ZnldtezdyaZtfAuRtIFOiRzK7g= +github.com/knadh/koanf/v2 v2.0.1/go.mod h1:ZeiIlIDXTE7w1lMT6UVcNiRAS2/rCeLn/GdLNvY1Dus= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -178,6 +198,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= +github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= @@ -187,10 +209,14 @@ github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2/go.mod h1:Ld9puTsI github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0= github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/mapstructure v1.3.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -250,8 +276,9 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/tidwall/pretty v1.0.0 h1:HsD+QiTn7sK6flMKIvNmpqz1qrpP3Ps6jOKIKMooyg4= github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 00000000..bd03c824 --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: MIT + +package config + +import ( + "strings" + "time" + + "github.com/go-playground/validator/v10" + "github.com/knadh/koanf/parsers/yaml" + "github.com/knadh/koanf/providers/env" + "github.com/knadh/koanf/providers/file" + "github.com/knadh/koanf/providers/posflag" + "github.com/knadh/koanf/v2" + "github.com/pkg/errors" + "github.com/spf13/pflag" +) + +type GarmConfig struct { + Server string `koanf:"server" validate:"required,url"` + Username string `koanf:"username" validate:"required"` + Password string `koanf:"password" validate:"required"` +} + +type OperatorConfig struct { + MetricsBindAddress string `koanf:"metrics_bind_address" validate:"required,hostname_port"` + HealthProbeBindAddress string `koanf:"health_probe_bind_address" validate:"required,hostname_port"` + LeaderElection bool `koanf:"leader_election"` + SyncPeriod time.Duration `koanf:"sync_period" validate:"required"` + WatchNamespace string `koanf:"watch_namespace"` +} + +type AppConfig struct { + Garm GarmConfig `koanf:"garm"` + Operator OperatorConfig `koanf:"operator"` +} + +var Config AppConfig + +func GenerateConfig(f *pflag.FlagSet, configFile string) error { + // create koanf instance + k := koanf.New(".") + + // load config from envs with prefix OPERATOR_ + k.Load(env.Provider("OPERATOR_", ".", func(s string) string { + // Transform env e.g. from OPERATOR_SYNC_PERIOD to operator.syncperiod + key := strings.Replace(strings.ToLower(s), "_", ".", 1) + return key + }), nil) + + // load config from envs with prefix GARM_ + k.Load(env.Provider("GARM_", ".", func(s string) string { + return strings.Replace(strings.ToLower(s), "_", ".", 1) + }), nil) + + // load config from flags + if f != nil { + k.Load(posflag.ProviderWithFlag(f, ".", k, func(pf *pflag.Flag) (string, interface{}) { + // Transform flag e.g. from operator-sync-period to operator.syncperiod + key := strings.Replace(pf.Name, "-", ".", 1) + key2 := strings.ReplaceAll(key, "-", "_") + + // Use FlagVal() and then transform the value, or don't use it at all + // and add custom logic to parse the value. + val := posflag.FlagVal(f, pf) + + return key2, val + }), nil) + } + + // load config from file + if configFile != "" { + if err := k.Load(file.Provider(f.Lookup("config").Value.String()), yaml.Parser()); err != nil { + return errors.Wrap(err, "failed to load config file") + } + } + + // unmarshal all koanf config keys into AppConfig struct + if err := k.Unmarshal("", &Config); err != nil { + return errors.Wrap(err, "failed to unmarshal config") + } + + validate := validator.New(validator.WithRequiredStructEnabled()) + if err := validate.Struct(&Config); err != nil { + return errors.Wrap(err, "invalid config: set with env, flag or in config file") + } + + return nil +} diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go new file mode 100644 index 00000000..bb8a3d3b --- /dev/null +++ b/pkg/config/config_test.go @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: MIT + +package config + +import ( + "reflect" + "testing" + "time" + + "github.com/mercedes-benz/garm-operator/pkg/flags" +) + +func TestGenerateConfig(t *testing.T) { + f := flags.InitiateFlags() + + tests := []struct { + name string + wantErr bool + wantCfg AppConfig + flags map[string]string + envvars map[string]string + }{ + { + name: "expectedFailureGarmConfig", + wantErr: true, + }, + { + name: "ConfigFromDefaultAndEnvs", + envvars: map[string]string{ + "GARM_SERVER": "http://localhost:9997", + "GARM_USERNAME": "admin", + "GARM_PASSWORD": "password", + }, + wantCfg: AppConfig{ + Operator: OperatorConfig{ + MetricsBindAddress: ":8080", + HealthProbeBindAddress: ":8081", + LeaderElection: false, + SyncPeriod: 5 * time.Minute, + WatchNamespace: "", + }, + Garm: GarmConfig{ + Server: "http://localhost:9997", + Username: "admin", + Password: "password", + }, + }, + }, + { + name: "ConfigFromDefaultAndFlags", + flags: map[string]string{ + "garm-server": "http://localhost:9997", + "garm-username": "admin", + "garm-password": "password", + }, + wantCfg: AppConfig{ + Operator: OperatorConfig{ + MetricsBindAddress: ":8080", + HealthProbeBindAddress: ":8081", + LeaderElection: false, + SyncPeriod: 5 * time.Minute, + WatchNamespace: "", + }, + Garm: GarmConfig{ + Server: "http://localhost:9997", + Username: "admin", + Password: "password", + }, + }, + }, + { + name: "ConfigFromDefaultEnvsAndFlags", + envvars: map[string]string{ + "GARM_SERVER": "http://localhost:1234", + "GARM_USERNAME": "admin1234", + "GARM_PASSWORD": "password1234", + }, + flags: map[string]string{ + "garm-server": "http://localhost:9997", + "garm-username": "admin", + "garm-password": "password", + }, + wantCfg: AppConfig{ + Operator: OperatorConfig{ + MetricsBindAddress: ":8080", + HealthProbeBindAddress: ":8081", + LeaderElection: false, + SyncPeriod: 5 * time.Minute, + WatchNamespace: "", + }, + Garm: GarmConfig{ + Server: "http://localhost:9997", + Username: "admin", + Password: "password", + }, + }, + }, + { + name: "ConfigFromEnvsFlagsAndFile", + envvars: map[string]string{ + "GARM_SERVER": "http://localhost:9997", + "GARM_USERNAME": "admin", + "GARM_PASSWORD": "password", + }, + flags: map[string]string{ + "operator-metrics-bind-address": ":1234", + "config": "test_config.yaml", + }, + wantCfg: AppConfig{ + Operator: OperatorConfig{ + MetricsBindAddress: ":7000", + HealthProbeBindAddress: ":7001", + LeaderElection: true, + SyncPeriod: 10 * time.Minute, + WatchNamespace: "garm-operator-namespace", + }, + Garm: GarmConfig{ + Server: "http://garm-server:9997", + Username: "garm-username", + Password: "garm-password", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // set env vars + for k, v := range tt.envvars { + t.Setenv(k, v) + } + + var configFile string + + for k, v := range tt.flags { + f.Set(k, v) + if k == "config" { + configFile = v + } + } + + if err := GenerateConfig(f, configFile); err != nil { + if tt.wantErr { + return + } + t.Errorf("GenerateConfig() error = %v, wantErr = %v", err, tt.wantErr) + } + if !reflect.DeepEqual(Config, tt.wantCfg) { + t.Errorf("GenerateConfig() Config = \n%+v\n, wantCfg = \n%+v\n", Config, tt.wantCfg) + } + }) + } +} diff --git a/pkg/config/test_config.yaml b/pkg/config/test_config.yaml new file mode 100644 index 00000000..8730ef68 --- /dev/null +++ b/pkg/config/test_config.yaml @@ -0,0 +1,11 @@ +garm: + username: "garm-username" + password: "garm-password" + server: "http://garm-server:9997" + +operator: + metrics_bind_address: ":7000" + health_probe_bind_address: ":7001" + leader_election: true + sync_period: "10m" + watch_namespace: "garm-operator-namespace" diff --git a/pkg/defaults/defaults.go b/pkg/defaults/defaults.go new file mode 100644 index 00000000..4cc4b3bc --- /dev/null +++ b/pkg/defaults/defaults.go @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT + +package defaults + +import "time" + +const ( + // default values for operator configuration + DefaultMetricsBindAddress = ":8080" + DefaultHealthProbeBindAddress = ":8081" + DefaultLeaderElection = false + DefaultSyncPeriod = 5 * time.Minute + DefaultWatchNamespace = "" +) diff --git a/pkg/flags/flags.go b/pkg/flags/flags.go new file mode 100644 index 00000000..73371d55 --- /dev/null +++ b/pkg/flags/flags.go @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT + +package flags + +import ( + "fmt" + "os" + + "github.com/spf13/pflag" + + "github.com/mercedes-benz/garm-operator/pkg/defaults" +) + +func InitiateFlags() *pflag.FlagSet { + f := pflag.NewFlagSet("config", pflag.PanicOnError) + f.Usage = func() { + fmt.Println(f.FlagUsages()) + os.Exit(0) + } + + // configure f for koanf + f.String("config", "", "path to .yaml config file") + f.String("operator-metrics-bind-address", defaults.DefaultMetricsBindAddress, "The address the metric endpoint binds to.") + f.String("operator-health-probe-bind-address", defaults.DefaultHealthProbeBindAddress, "The address the probe endpoint binds to.") + f.Bool("operator-leader-election", defaults.DefaultLeaderElection, "Enable leader election for controller manager. "+"Enabling this will ensure there is only one active controller manager.") + f.Duration("operator-sync-period", defaults.DefaultSyncPeriod, "The minimum interval at which watched resources are reconciled (e.g. 5m)") + f.String("operator-watch-namespace", defaults.DefaultWatchNamespace, "Namespace that the controller watches to reconcile garm objects. "+"If unspecified, the controller watches for garm objects across all namespaces.") + + f.String("garm-server", "", "The address of the GARM server") + f.String("garm-username", "", "The username for the GARM server") + f.String("garm-password", "", "The password for the GARM server") + + f.Bool("dry-run", false, "If true, only print the object that would be sent, without sending it.") + + f.Parse(os.Args[1:]) + + return f +}