webapp
Some checks failed
Docker Build and Publish / publish (push) Failing after 1m33s

This commit is contained in:
Eugene Howe
2026-02-17 09:47:30 -05:00
parent af09672ee3
commit b0957bfa49
102 changed files with 4213 additions and 378 deletions

335
internal/config/config.go Normal file
View File

@@ -0,0 +1,335 @@
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
}

View File

@@ -0,0 +1,13 @@
package config_test
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestConfig(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Config Suite")
}

View File

@@ -0,0 +1,41 @@
package config_test
import (
"log"
"os"
"clintonambulance.com/calculate_negative_points/internal/config"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
type FakeSicherObject struct{}
func (_ FakeSicherObject) SetEnvStyle(_ string) {}
func (_ FakeSicherObject) LoadEnv(_ string, obj interface{}) error {
var err error
return err
}
var _ = Describe("ConfigTest", func() {
var version config.Version
var logger, _ = config.NewLogger(version, os.Stdout, []string{"cmd", "-e", "testEnvironment"})
shouldNotExit := func(code int) {
// "Fatal" to mimic os.Exit
log.Default().Print("exit called with code", code)
}
BeforeEach(func() {
config.NewSicherObject = func(_ string, _ string) config.SicherObject {
return FakeSicherObject{}
}
version = config.Version{Release: "1.0.0", Commit: "abcdef", Date: "2023-01-01"}
})
It("Errors if the config file is not found", func() {
argv := []string{"cmd", "-c", "config/no-such-file.yml"}
_, err := config.Load(version, shouldNotExit, argv, logger)
Expect(err.Error()).To(Equal("error loading configuration file: open config/no-such-file.yml: no such file or directory"))
})
})

View File

@@ -0,0 +1,34 @@
package config
import (
"io"
"os"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
func NewLogger(version Version, output io.Writer, args []string) (*zap.Logger, func()) {
parsedCliArgs, _ := ParseArgs(version, os.Exit, args)
syncs := []zapcore.WriteSyncer{zapcore.AddSync(output)}
encoderConfig := zap.NewProductionEncoderConfig()
encoderConfig.EncodeTime = zapcore.RFC3339NanoTimeEncoder
core := zapcore.NewCore(
zapcore.NewJSONEncoder(encoderConfig),
zap.CombineWriteSyncers(syncs...),
zap.DebugLevel,
)
logger := zap.New(core).WithOptions(
zap.AddStacktrace(zap.ErrorLevel),
zap.WithCaller(false),
).With(
zap.String("version", version.Release),
zap.String("environment", *parsedCliArgs.Environment),
)
finalizer := func() {
// This might fail if logging to console, but we don't care (https://github.com/uber-go/zap/issues/880)
_ = logger.Sync()
}
return logger, finalizer
}

85
internal/config/secret.go Normal file
View File

@@ -0,0 +1,85 @@
package config
import (
"bytes"
"encoding/json"
"os"
"reflect"
"github.com/mitchellh/mapstructure"
)
// SecretValue defines what types of secrets this secret container supports
type SecretValue interface {
~string | ~[]string | ~map[string]string
}
// SecretFilePath is the source to the file containing the secret.
type SecretFilePath string
const SecretPlaceholder = "[SECRET]"
// Secret encapsulates a secret value, which is loaded from a file, or directly from configuration / environment variable
type Secret[T SecretValue] struct {
value T
source SecretFilePath
}
// MarshalJSON implements the json.Marshaler interface, used to hide the actual secret value in logs etc.
func (s Secret[T]) MarshalJSON() ([]byte, error) {
return json.Marshal(map[string]string{"value": SecretPlaceholder, "source": string(s.source)})
}
// MarshalText implements the encoding.TextMarshaler interface, used to hide the actual secret value in logs etc.
func (s *Secret[T]) MarshalText() ([]byte, error) {
return []byte(SecretPlaceholder), nil
}
// UnmarshalText implements the encoding.TextUnmarshaler interface, used to load the secret from config files and env. values
func (s *Secret[T]) UnmarshalText(text []byte) error {
var val T
switch any(val).(type) {
case string:
s.value = any(string(text)).(T)
return nil
default:
return json.Unmarshal(text, &s.value)
}
}
func (s Secret[T]) Value() T {
return s.value
}
func SecretFromSecretPath[T SecretValue](path SecretFilePath) (Secret[T], error) {
contentBytes, err := os.ReadFile(string(path))
if err != nil {
return Secret[T]{}, err
}
secret := Secret[T]{source: path}
err = secret.UnmarshalText(bytes.TrimSpace(contentBytes))
return secret, err
}
func SecretFromValue[T SecretValue](value T) Secret[T] {
return Secret[T]{value: value}
}
// SecretFilePathUnmarshalHookFunc is a mapstructure.DecodeHookFunc that will convert a SecretFilePath to a Secret
func SecretFilePathUnmarshalHookFunc() mapstructure.DecodeHookFuncType {
return func(from, to reflect.Type, data interface{}) (interface{}, error) {
if from != reflect.TypeOf(SecretFilePath("")) {
return data, nil
}
// Reflection does not work with generics as of 1.20, so we have to do this manually
if to == reflect.TypeOf(Secret[map[string]string]{}) {
return SecretFromSecretPath[map[string]string](data.(SecretFilePath))
} else if to == reflect.TypeOf(Secret[[]string]{}) {
return SecretFromSecretPath[[]string](data.(SecretFilePath))
} else if to == reflect.TypeOf(Secret[string]{}) {
return SecretFromSecretPath[string](data.(SecretFilePath))
} else {
return data, nil
}
}
}

View File

@@ -0,0 +1 @@
fakesecret

View File

@@ -0,0 +1,22 @@
app_secret: "app secret"
app_secret_block: "app secret block hash"
path_prefix: "."
paths:
migrations: "db/migrations"
credentials: "config/credentials"
match_threshold: 3
nocodb:
api_token: test
base_url: https://example.com
employees_table_id: "1234567890"
infractions_table_id: "2468013579"
negative_infraction_id: 1
no_points_view_id: "1357924680"
oidc:
issuer: http://example.com
redirect_url: http://example.com
idms:
base_url: https://example.com
cars_group_id: "1234"
id: "1234"
password: "5678"

View File

@@ -0,0 +1,34 @@
package config
import "fmt"
type Version struct {
Release string
Commit string
Date string
}
var (
release = "dev"
commit = ""
date = ""
)
func (v *Version) AppNameAndVersion(details bool) string {
result := fmt.Sprintf("Basin Feature Flag Service %s", v.Release)
if details && v.Commit != "" {
result = fmt.Sprintf("%s\nGit commit: %s", result, v.Commit)
}
if details && v.Date != "" {
result = fmt.Sprintf("%s\nBuilt at: %s", result, v.Date)
}
return result
}
func NewVersion() Version {
return Version{
Release: release,
Commit: commit,
Date: date,
}
}