Files
calculate_negative_points/internal/config/config.go
Eugene Howe b0957bfa49
Some checks failed
Docker Build and Publish / publish (push) Failing after 1m33s
webapp
2026-02-17 09:47:30 -05:00

336 lines
11 KiB
Go

package config
import (
"context"
"fmt"
"log"
"net/http"
"os"
"reflect"
"strings"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/dsa0x/sicher"
"github.com/go-playground/validator/v10"
"github.com/go-viper/mapstructure/v2"
"github.com/gorilla/sessions"
"github.com/joho/godotenv"
"github.com/knadh/koanf/parsers/yaml"
"github.com/knadh/koanf/providers/confmap"
"github.com/knadh/koanf/providers/env"
"github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/providers/posflag"
"github.com/knadh/koanf/providers/structs"
"github.com/knadh/koanf/v2"
"github.com/samber/lo"
"github.com/spf13/pflag"
"go.uber.org/zap"
"golang.org/x/oauth2"
)
const defaultEnvironment = "development"
type SicherObject interface {
SetEnvStyle(string)
LoadEnv(string, interface{}) error
}
type SicherConfig struct {
AppSecret string `env:"APP_SECRET" koanf:"app_secret"`
AppSecretBlock string `env:"APP_SECRET_BLOCK" koanf:"app_secret_block"`
NocoDBToken string `env:"NOCODB_TOKEN" koanf:"nocodb.api_token"`
OidcClientId string `env:"OIDC_CLIENT_ID" koanf:"oidc.client_id"`
OidcClientSecret string `env:"OIDC_CLIENT_SECRET" koanf:"oidc.client_secret"`
ServiceAccountId string `env:"IDMS_SERVICE_ACCOUNT_ID" koanf:"idms.service_account_id"`
ServiceAccountPassword string `env:"IDMS_SERVICE_ACCOUNT_PASSWORD" koanf:"idms.service_account_password"`
}
var NewSicherObject = func(environment string, path string) SicherObject {
return sicher.New(environment, path)
}
type OidcConfig struct {
ClientId string `koanf:"oidc.client_id" validate:"required"`
ClientSecret Secret[string] `koanf:"oidc.client_secret" validate:"required"`
Issuer string `koanf:"oidc.issuer" validate:"required"`
Scopes []string `koanf:"oidc.scopes" validate:"required"`
Provider *oidc.Provider
Verifier *oidc.IDTokenVerifier
OAuth2 *oauth2.Config
}
type IdmsConfig struct {
BaseUrl string `koanf:"idms.base_url" validate:"required"`
CarsGroupId string `koanf:"idms.cars_group_id" validate:"required"`
Id Secret[string] `koanf:"idms.service_account_id" validate:"required"`
Password Secret[string] `koanf:"idms.service_account_password" validate:"required"`
}
type NocoDBConfig struct {
ApiToken Secret[string] `koanf:"nocodb.api_token" validate:"required"`
BaseUrl string `koanf:"nocodb.base_url" validate:"required"`
EmployeesTableId string `koanf:"nocodb.employees_table_id" validate:"required"`
InfractionsTableId string `koanf:"nocodb.infractions_table_id" validate:"required"`
NegativeInfractionId int `koanf:"nocodb.negative_infraction_id" validate:"required"`
NoPointsViewId string `koanf:"nocodb.no_points_view_id" validate:"required"`
}
type ApplicationConfig struct {
AppSecret Secret[string] `koanf:"app_secret"`
AppSecretBlock Secret[string] `koanf:"app_secret_block"`
CookieStore *sessions.CookieStore
Environment string `koanf:"environment" validate:"required"`
Listen string `koanf:"listen" validate:"required,hostname_port"`
MatchThreshold int `koanf:"match_threshold" validate:"required"`
NocoDBConfig NocoDBConfig `koanf:"nocodb" validate:"required"`
OidcConfig OidcConfig `koanf:"oidc" validate:"required"`
PublicPath string `koanf:"paths.public"`
Idms IdmsConfig `koanf:"idms" validate:"required"`
SessionName string
ViewPath string `koanf:"paths.views"`
}
type ParsedCliArgs struct {
ConfigFiles *[]string
DatabasePasswordPath *string
Environment *string
Listen *string
Version *bool
}
func ParseArgs(version Version, exit func(int), args []string) (*ParsedCliArgs, *pflag.FlagSet) {
f := pflag.NewFlagSet("config", pflag.ContinueOnError)
f.Usage = func() {
fmt.Println(version.AppNameAndVersion(false))
fmt.Println(f.FlagUsages())
exit(0)
}
parsedCliArgs := &ParsedCliArgs{
ConfigFiles: f.StringSliceP("conf", "c", []string{}, "path to one or more .yaml config files"),
DatabasePasswordPath: f.StringP("db-secret-path", "d", "", "path to database secret"),
Environment: f.StringP("environment", "e", defaultEnvironment, "Environment for the running binary"),
Listen: f.StringP("listen", "l", "0.0.0.0:3000", "address and port to listen on"),
Version: f.Bool("version", false, "Show version"),
}
err := f.Parse(args[1:])
if err != nil {
_, _ = fmt.Fprintf(
os.Stderr,
"%s\n\nERROR: %s\n\n%s",
version.AppNameAndVersion(false), err, f.FlagUsages(),
)
exit(1)
}
return parsedCliArgs, f
}
// Load application configuration from config files, command line flags, and environment variables.
// An error will be returned only if _unexpected_ error happened, such as a file not being found.
// If the user specified a --help or a --version flag, the application will exit with a 0 status code.
// If the user specified an unsupported flag, the application will exit with a 1 status code and print the help message.
func Load(version Version, exit func(int), args []string, logger *zap.Logger) (*ApplicationConfig, error) {
k := koanf.New(".")
config := defaultConfig()
err := k.Load(confmap.Provider(config, "."), nil)
if err != nil {
log.Fatal("error initializing config", zap.Error(err))
}
parsedCliArgs, f := ParseArgs(version, exit, args)
// Load the config files provided in the command line.
for _, c := range *parsedCliArgs.ConfigFiles {
err := k.Load(file.Provider(c), yaml.Parser())
if err != nil {
return nil, fmt.Errorf("error loading configuration file: %v", err)
}
}
err = godotenv.Load()
if err != nil {
logger.Info("No .env file found")
}
configPath := func(p string) string {
combinedPath := fmt.Sprintf("%s/%s", k.String("path_prefix"), p)
return combinedPath
}
// Override with environment variables
lo.ForEach([]string{envPrefix, pgPrefix, databasePrefix, idmsPrefix, oidcPrefix}, func(prefix string, _ int) {
if err := k.Load(env.Provider(prefix, "_", mapEnvVarNames(prefix)), nil); err != nil {
logger.Fatal("error parsing environment variables", zap.Error(err))
}
})
// Override with command-line values
if err := k.Load(posflag.Provider(f, ".", k), nil); err != nil {
return nil, fmt.Errorf("error parsing command-line parameters: %v", err)
}
if err := k.Unmarshal("", &config); err != nil {
return nil, err
}
if *parsedCliArgs.Version {
fmt.Println(version.AppNameAndVersion(true))
exit(0)
}
var sicherConfig SicherConfig
s := NewSicherObject(k.String("environment"), configPath(k.String("paths.credentials")))
s.SetEnvStyle("yaml") // default is dotenv
err = s.LoadEnv("", &sicherConfig)
cookieStore := sessions.NewCookieStore([]byte(k.String("app_secret")))
cookieStore.Options.HttpOnly = true
cookieStore.Options.Secure = *parsedCliArgs.Environment != "dev" && *parsedCliArgs.Environment != "testEnvironment" // Use HTTPS
cookieStore.Options.SameSite = http.SameSiteLaxMode
if err != nil {
log.Println(err)
}
if err = k.Load(structs.Provider(sicherConfig, "koanf"), nil); err != nil {
return nil, fmt.Errorf("error parsing sicher parameters: %v", err)
}
var appConfig ApplicationConfig
unmarshalConf := koanf.UnmarshalConf{
DecoderConfig: &mapstructure.DecoderConfig{
DecodeHook: mapstructure.ComposeDecodeHookFunc(
SecretFilePathUnmarshalHookFunc(),
mapstructure.StringToTimeDurationHookFunc(),
mapstructure.StringToSliceHookFunc(","),
mapstructure.TextUnmarshallerHookFunc(),
),
Metadata: nil,
Result: &appConfig,
WeaklyTypedInput: true,
},
}
if err := k.UnmarshalWithConf("", &config, unmarshalConf); err != nil {
return nil, err
}
appConfig.AppSecret = SecretFromValue(k.String("app_secret"))
appConfig.AppSecretBlock = SecretFromValue(k.String("app_secret_block"))
nocodbConfig := NocoDBConfig{
ApiToken: SecretFromValue(k.String("nocodb.api_token")),
BaseUrl: k.String("nocodb.base_url"),
EmployeesTableId: k.String("nocodb.employees_table_id"),
InfractionsTableId: k.String("nocodb.infractions_table_id"),
NegativeInfractionId: k.Int("nocodb.negative_infraction_id"),
NoPointsViewId: k.String("nocodb.no_points_view_id"),
}
oidcConfig := OidcConfig{
ClientId: k.String("oidc.client_id"),
ClientSecret: SecretFromValue(k.String("oidc.client_secret")),
Issuer: k.String("oidc.issuer"),
Scopes: k.Strings("oidc.scopes"),
}
if *parsedCliArgs.Environment != "testEnvironment" {
authProvider, err := oidc.NewProvider(context.Background(), k.String("oidc.issuer"))
if err != nil {
return nil, err
}
oidcConfig.Provider = authProvider
oidcConfig.Verifier = authProvider.Verifier(&oidc.Config{ClientID: k.String("oidc.client_id")})
redirectUrl := fmt.Sprintf("%s/auth/callback", k.String("oidc.redirect_url"))
oidcConfig.OAuth2 = &oauth2.Config{
ClientID: k.String("oidc.client_id"),
ClientSecret: k.String("oidc.client_secret"),
Endpoint: authProvider.Endpoint(),
RedirectURL: redirectUrl,
Scopes: k.Strings("oidc.scopes"),
}
}
appConfig.CookieStore = cookieStore
appConfig.Environment = *parsedCliArgs.Environment
appConfig.Idms = IdmsConfig{
BaseUrl: k.String("idms.base_url"),
CarsGroupId: k.String("idms.cars_group_id"),
Id: SecretFromValue(k.String("idms.service_account_id")),
Password: SecretFromValue(k.String("idms.service_account_password")),
}
appConfig.Listen = *parsedCliArgs.Listen
appConfig.MatchThreshold = k.Int("match_threshold")
appConfig.NocoDBConfig = nocodbConfig
appConfig.OidcConfig = oidcConfig
appConfig.PublicPath = configPath(k.String("paths.public"))
appConfig.SessionName = "calculate-negative-points"
appConfig.ViewPath = configPath(k.String("paths.views"))
validate := validator.New()
validate.RegisterCustomTypeFunc(secretTypeTranslator[string], Secret[string]{})
err = validate.Struct(appConfig)
if err != nil {
log.Println(err)
fmt.Print("Could not create config")
exit(1)
return nil, err
}
return &appConfig, nil
}
const envPrefix = "APP_"
const pgPrefix = "PG_"
const databasePrefix = "DATABASE_"
const idmsPrefix = "IDMS_"
const oidcPrefix = "OIDC_"
/*
* Rio passes in PG_HOST and PG_PORT so we need to replace the PG string with DATABASE and then not trim the prefix
*/
func mapEnvVarNames(prefix string) func(s string) string {
return func(s string) string {
if prefix == databasePrefix {
return strings.ToLower(s)
}
replacedPg := strings.Replace(s, "PG", "DATABASE", -1)
lower := strings.ToLower(strings.TrimPrefix(replacedPg, prefix))
return lower
}
}
func defaultConfig() map[string]interface{} {
dbConfig := map[string]interface{}{
"host": "localhost",
"port": 5432,
"user": "postgres",
"name": "calculate-negative-points",
}
return map[string]interface{}{
"database": dbConfig,
"environment": defaultEnvironment,
"listen": "127.0.0.1:3000",
}
}
func secretTypeTranslator[T SecretValue](field reflect.Value) interface{} {
if secret, ok := field.Interface().(Secret[T]); ok {
return secret.Value()
}
return nil
}