diff --git a/Makefile b/Makefile index 2cbf533..e3a0b67 100755 --- a/Makefile +++ b/Makefile @@ -31,7 +31,6 @@ db_host=hyperfleet-db.$(namespace) db_port=5432 db_user:=hyperfleet db_password:=foobar-bizz-buzz -db_password_file=${PWD}/secrets/db.password db_sslmode:=disable db_image?=docker.io/library/postgres:14.2 @@ -152,20 +151,6 @@ install: check-gopath generate-all ) .PHONY: install -# Initialize secrets directory with default values -secrets: - @mkdir -p secrets - @printf "localhost" > secrets/db.host - @printf "$(db_name)" > secrets/db.name - @printf "$(db_password)" > secrets/db.password - @printf "$(db_port)" > secrets/db.port - @printf "$(db_user)" > secrets/db.user - @printf "ocm-hyperfleet-testing" > secrets/ocm-service.clientId - @printf "your-client-secret-here" > secrets/ocm-service.clientSecret - @printf "your-token-here" > secrets/ocm-service.token - @echo "Secrets directory initialized with default values" -.PHONY: secrets - # Runs the unit tests. # # Args: @@ -173,8 +158,16 @@ secrets: # # Examples: # make test TESTFLAGS="-run TestSomething" -test: install secrets $(GOTESTSUM) - OCM_ENV=unit_testing $(GOTESTSUM) --format $(TEST_SUMMARY_FORMAT) -- -p 1 -v $(TESTFLAGS) \ +test: install $(GOTESTSUM) + OCM_ENV=unit_testing \ + HYPERFLEET_APP_NAME=hyperfleet-api-test \ + HYPERFLEET_OCM_MOCK=true \ + HYPERFLEET_OCM_DEBUG=false \ + HYPERFLEET_OCM_BASE_URL=https://api.integration.openshift.com \ + HYPERFLEET_SERVER_HTTPS_ENABLED=false \ + HYPERFLEET_METRICS_HTTPS_ENABLED=false \ + HYPERFLEET_AUTH_AUTHZ_ENABLED=true \ + $(GOTESTSUM) --format $(TEST_SUMMARY_FORMAT) -- -p 1 -v $(TESTFLAGS) \ ./pkg/... \ ./cmd/... .PHONY: test @@ -186,8 +179,16 @@ test: install secrets $(GOTESTSUM) # # Examples: # make test-unit-json TESTFLAGS="-run TestSomething" -ci-test-unit: install secrets $(GOTESTSUM) - OCM_ENV=unit_testing $(GOTESTSUM) --jsonfile-timing-events=$(unit_test_json_output) --format $(TEST_SUMMARY_FORMAT) -- -p 1 -v $(TESTFLAGS) \ +ci-test-unit: install $(GOTESTSUM) + OCM_ENV=unit_testing \ + HYPERFLEET_APP_NAME=hyperfleet-api-test \ + HYPERFLEET_OCM_MOCK=true \ + HYPERFLEET_OCM_DEBUG=false \ + HYPERFLEET_OCM_BASE_URL=https://api.integration.openshift.com \ + HYPERFLEET_SERVER_HTTPS_ENABLED=false \ + HYPERFLEET_METRICS_HTTPS_ENABLED=false \ + HYPERFLEET_AUTH_AUTHZ_ENABLED=true \ + $(GOTESTSUM) --jsonfile-timing-events=$(unit_test_json_output) --format $(TEST_SUMMARY_FORMAT) -- -p 1 -v $(TESTFLAGS) \ ./pkg/... \ ./cmd/... .PHONY: ci-test-unit @@ -202,8 +203,17 @@ ci-test-unit: install secrets $(GOTESTSUM) # make test-integration TESTFLAGS="-run TestAccounts" acts as TestAccounts* and run TestAccountsGet, TestAccountsPost, etc. # make test-integration TESTFLAGS="-run TestAccountsGet" runs TestAccountsGet # make test-integration TESTFLAGS="-short" skips long-run tests -ci-test-integration: install secrets $(GOTESTSUM) - TESTCONTAINERS_RYUK_DISABLED=true OCM_ENV=integration_testing $(GOTESTSUM) --jsonfile-timing-events=$(integration_test_json_output) --format $(TEST_SUMMARY_FORMAT) -- -p 1 -ldflags -s -v -timeout 1h $(TESTFLAGS) \ +ci-test-integration: install $(GOTESTSUM) + TESTCONTAINERS_RYUK_DISABLED=true \ + OCM_ENV=integration_testing \ + HYPERFLEET_APP_NAME=hyperfleet-api-integration-test \ + HYPERFLEET_OCM_MOCK=true \ + HYPERFLEET_OCM_DEBUG=false \ + HYPERFLEET_OCM_BASE_URL=https://api.integration.openshift.com \ + HYPERFLEET_SERVER_HTTPS_ENABLED=false \ + HYPERFLEET_METRICS_HTTPS_ENABLED=false \ + HYPERFLEET_AUTH_AUTHZ_ENABLED=true \ + $(GOTESTSUM) --jsonfile-timing-events=$(integration_test_json_output) --format $(TEST_SUMMARY_FORMAT) -- -p 1 -ldflags -s -v -timeout 1h $(TESTFLAGS) \ ./test/integration .PHONY: ci-test-integration @@ -217,8 +227,20 @@ ci-test-integration: install secrets $(GOTESTSUM) # make test-integration TESTFLAGS="-run TestAccounts" acts as TestAccounts* and run TestAccountsGet, TestAccountsPost, etc. # make test-integration TESTFLAGS="-run TestAccountsGet" runs TestAccountsGet # make test-integration TESTFLAGS="-short" skips long-run tests -test-integration: install secrets $(GOTESTSUM) - TESTCONTAINERS_RYUK_DISABLED=true OCM_ENV=integration_testing $(GOTESTSUM) --format $(TEST_SUMMARY_FORMAT) -- -p 1 -ldflags -s -v -timeout 1h $(TESTFLAGS) \ +test-integration: install $(GOTESTSUM) + TESTCONTAINERS_RYUK_DISABLED=true \ + OCM_ENV=integration_testing \ + HYPERFLEET_APP_NAME=hyperfleet-api-integration-test \ + HYPERFLEET_OCM_MOCK=true \ + HYPERFLEET_OCM_DEBUG=false \ + HYPERFLEET_OCM_BASE_URL=https://api.integration.openshift.com \ + HYPERFLEET_SERVER_HTTPS_ENABLED=false \ + HYPERFLEET_METRICS_HTTPS_ENABLED=false \ + HYPERFLEET_SERVER_AUTH_AUTHZ_ENABLED=false \ + HYPERFLEET_DATABASE_NAME=$(db_name) \ + HYPERFLEET_DATABASE_USERNAME=$(db_user) \ + HYPERFLEET_DATABASE_PASSWORD=$(db_password) \ + $(GOTESTSUM) --format $(TEST_SUMMARY_FORMAT) -- -p 1 -ldflags -s -v -timeout 1h $(TESTFLAGS) \ ./test/integration .PHONY: test-integration @@ -268,8 +290,7 @@ run/docs: clean: rm -rf \ $(binary) \ - data/generated/openapi/*.json \ - secrets \ + data/generated/openapi/*.json .PHONY: clean .PHONY: cmds @@ -284,8 +305,7 @@ cmds: .PHONY: db/setup -db/setup: secrets - @echo $(db_password) > $(db_password_file) +db/setup: $(container_tool) run --name psql-hyperfleet -e POSTGRES_DB=$(db_name) -e POSTGRES_USER=$(db_user) -e POSTGRES_PASSWORD=$(db_password) -p $(db_port):5432 -d $(db_image) .PHONY: db/login diff --git a/cmd/hyperfleet-api/environments/e_development.go b/cmd/hyperfleet-api/environments/e_development.go index 6aaa7a1..26c63ad 100755 --- a/cmd/hyperfleet-api/environments/e_development.go +++ b/cmd/hyperfleet-api/environments/e_development.go @@ -18,8 +18,6 @@ func (e *devEnvImpl) OverrideDatabase(c *Database) error { } func (e *devEnvImpl) OverrideConfig(c *config.ApplicationConfig) error { - c.Server.EnableJWT = false - c.Server.EnableHTTPS = false return nil } @@ -34,16 +32,3 @@ func (e *devEnvImpl) OverrideHandlers(h *Handlers) error { func (e *devEnvImpl) OverrideClients(c *Clients) error { return nil } - -func (e *devEnvImpl) Flags() map[string]string { - return map[string]string{ - "v": "10", - "enable-authz": "false", - "ocm-debug": "false", - "enable-ocm-mock": "true", - "enable-https": "false", - "enable-metrics-https": "false", - "api-server-hostname": "localhost", - "api-server-bindaddress": "localhost:8000", - } -} diff --git a/cmd/hyperfleet-api/environments/e_integration_testing.go b/cmd/hyperfleet-api/environments/e_integration_testing.go index 8db7ea0..4cbbc46 100755 --- a/cmd/hyperfleet-api/environments/e_integration_testing.go +++ b/cmd/hyperfleet-api/environments/e_integration_testing.go @@ -38,16 +38,3 @@ func (e *integrationTestingEnvImpl) OverrideHandlers(h *Handlers) error { func (e *integrationTestingEnvImpl) OverrideClients(c *Clients) error { return nil } - -func (e *integrationTestingEnvImpl) Flags() map[string]string { - return map[string]string{ - "v": "0", - "logtostderr": "true", - "ocm-base-url": "https://api.integration.openshift.com", - "enable-https": "false", - "enable-metrics-https": "false", - "enable-authz": "true", - "ocm-debug": "false", - "enable-ocm-mock": "true", - } -} diff --git a/cmd/hyperfleet-api/environments/e_production.go b/cmd/hyperfleet-api/environments/e_production.go index 05151e2..e9bc0c2 100755 --- a/cmd/hyperfleet-api/environments/e_production.go +++ b/cmd/hyperfleet-api/environments/e_production.go @@ -32,11 +32,3 @@ func (e *productionEnvImpl) OverrideHandlers(h *Handlers) error { func (e *productionEnvImpl) OverrideClients(c *Clients) error { return nil } - -func (e *productionEnvImpl) Flags() map[string]string { - return map[string]string{ - "v": "1", - "ocm-debug": "false", - "enable-ocm-mock": "false", - } -} diff --git a/cmd/hyperfleet-api/environments/e_unit_testing.go b/cmd/hyperfleet-api/environments/e_unit_testing.go index 81127e3..1bf6c84 100755 --- a/cmd/hyperfleet-api/environments/e_unit_testing.go +++ b/cmd/hyperfleet-api/environments/e_unit_testing.go @@ -38,16 +38,3 @@ func (e *unitTestingEnvImpl) OverrideHandlers(h *Handlers) error { func (e *unitTestingEnvImpl) OverrideClients(c *Clients) error { return nil } - -func (e *unitTestingEnvImpl) Flags() map[string]string { - return map[string]string{ - "v": "0", - "logtostderr": "true", - "ocm-base-url": "https://api.integration.openshift.com", - "enable-https": "false", - "enable-metrics-https": "false", - "enable-authz": "true", - "ocm-debug": "false", - "enable-ocm-mock": "true", - } -} diff --git a/cmd/hyperfleet-api/environments/framework.go b/cmd/hyperfleet-api/environments/framework.go index deaf8f7..3361da4 100755 --- a/cmd/hyperfleet-api/environments/framework.go +++ b/cmd/hyperfleet-api/environments/framework.go @@ -2,10 +2,8 @@ package environments import ( "os" - "strings" "github.com/golang/glog" - "github.com/spf13/pflag" "github.com/openshift-hyperfleet/hyperfleet-api/cmd/hyperfleet-api/environments/registry" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/client/ocm" @@ -17,8 +15,8 @@ func init() { once.Do(func() { environment = &Env{} - // Create the configuration - environment.Config = config.NewApplicationConfig() + // DO NOT create Config here + // Config will be provided by commands via Initialize() environment.Name = GetEnvironmentStrFromEnv() environments = map[string]EnvironmentImpl{ @@ -31,10 +29,8 @@ func init() { } // EnvironmentImpl defines a set of behaviors for an OCM environment. -// Each environment provides a set of flags for basic set/override of the environment -// and configuration functions for each component type. +// Each environment provides configuration functions for each component type. type EnvironmentImpl interface { - Flags() map[string]string OverrideConfig(c *config.ApplicationConfig) error OverrideServices(s *Services) error OverrideDatabase(s *Database) error @@ -54,16 +50,9 @@ func Environment() *Env { return environment } -// AddFlags Adds environment flags, using the environment's config struct, to the flagset 'flags' -func (e *Env) AddFlags(flags *pflag.FlagSet) error { - e.Config.AddFlags(flags) - return setConfigDefaults(flags, environments[e.Name].Flags()) -} - -// Initialize loads the environment's resources -// This should be called after the e.Config has been set appropriately though AddFlags and pasing, done elsewhere -// The environment does NOT handle flag parsing -func (e *Env) Initialize() error { +// Initialize loads the environment's resources with pre-loaded configuration +// Configuration must be loaded by the caller using config.LoadConfig() +func (e *Env) Initialize(appConfig *config.ApplicationConfig) error { glog.Infof("Initializing %s environment", e.Name) envImpl, found := environments[e.Name] @@ -71,20 +60,20 @@ func (e *Env) Initialize() error { glog.Fatalf("Unknown runtime environment: %s", e.Name) } + // Store the provided config + e.Config = appConfig + + // Allow environment to apply runtime overrides (e.g., DB_DEBUG for tests) if err := envImpl.OverrideConfig(e.Config); err != nil { glog.Fatalf("Failed to configure ApplicationConfig: %s", err) } - messages := environment.Config.ReadFiles() - if len(messages) != 0 { - glog.Fatalf("unable to read configuration files:\n%s", strings.Join(messages, "\n")) - } - - // each env will set db explicitly because the DB impl has a `once` init section + // Initialize database with config if err := envImpl.OverrideDatabase(&e.Database); err != nil { glog.Fatalf("Failed to configure Database: %s", err) } + // Initialize clients err := e.LoadClients() if err != nil { return err @@ -93,16 +82,19 @@ func (e *Env) Initialize() error { glog.Fatalf("Failed to configure Clients: %s", err) } + // Initialize services e.LoadServices() if err := envImpl.OverrideServices(&e.Services); err != nil { glog.Fatalf("Failed to configure Services: %s", err) } + // Seed data seedErr := e.Seed() if seedErr != nil { return seedErr } + // Initialize handlers if err := envImpl.OverrideHandlers(&e.Handlers); err != nil { glog.Fatalf("Failed to configure Handlers: %s", err) } @@ -161,13 +153,3 @@ func (e *Env) Teardown() { } } } - -func setConfigDefaults(flags *pflag.FlagSet, defaults map[string]string) error { - for name, value := range defaults { - if err := flags.Set(name, value); err != nil { - glog.Errorf("Error setting flag %s: %v", name, err) - return err - } - } - return nil -} diff --git a/cmd/hyperfleet-api/environments/framework_test.go b/cmd/hyperfleet-api/environments/framework_test.go index 0beaffb..f53650c 100755 --- a/cmd/hyperfleet-api/environments/framework_test.go +++ b/cmd/hyperfleet-api/environments/framework_test.go @@ -1,11 +1,14 @@ package environments import ( + "os" "os/exec" "reflect" "testing" "github.com/spf13/pflag" + + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/config" ) func BenchmarkGetDynos(b *testing.B) { @@ -23,16 +26,36 @@ func BenchmarkGetDynos(b *testing.B) { } func TestLoadServices(t *testing.T) { - env := Environment() - err := env.AddFlags(pflag.CommandLine) + // Set required environment variables for testing + os.Setenv("HYPERFLEET_APP_NAME", "hyperfleet-api-test") + os.Setenv("HYPERFLEET_OCM_MOCK", "true") + os.Setenv("HYPERFLEET_OCM_DEBUG", "false") + os.Setenv("HYPERFLEET_OCM_BASE_URL", "https://api.integration.openshift.com") + os.Setenv("HYPERFLEET_SERVER_HTTPS_ENABLED", "false") + os.Setenv("HYPERFLEET_METRICS_HTTPS_ENABLED", "false") + os.Setenv("HYPERFLEET_AUTH_AUTHZ_ENABLED", "true") + + // Create config + appConfig := config.NewApplicationConfig() + + // Create viper and configure flags + v := config.NewCommandConfig() + appConfig.ConfigureFlags(v, pflag.CommandLine) + + pflag.Parse() + + // Load config + loadedConfig, err := config.LoadConfig(v, pflag.CommandLine) if err != nil { - t.Errorf("Unable to add flags for testing environment: %s", err.Error()) + t.Errorf("Failed to load configuration: %v", err) return } - pflag.Parse() - err = env.Initialize() + + // Initialize environment with loaded config + env := Environment() + err = env.Initialize(loadedConfig) if err != nil { - t.Errorf("Unable to load testing environment: %s", err.Error()) + t.Errorf("Unable to initialize testing environment: %s", err.Error()) return } diff --git a/cmd/hyperfleet-api/migrate/cmd.go b/cmd/hyperfleet-api/migrate/cmd.go index adcbb71..c9f9f80 100755 --- a/cmd/hyperfleet-api/migrate/cmd.go +++ b/cmd/hyperfleet-api/migrate/cmd.go @@ -2,40 +2,54 @@ package migrate import ( "context" - "flag" "github.com/golang/glog" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db/db_session" "github.com/spf13/cobra" + "github.com/spf13/viper" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/config" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db" ) -var dbConfig = config.NewDatabaseConfig() - // NewMigrateCommand migrate sub-command handles running migrations func NewMigrateCommand() *cobra.Command { + // Create viper instance for this command (isolated from other commands) + v := config.NewCommandConfig() + cmd := &cobra.Command{ Use: "migrate", Short: "Run hyperfleet service data migrations", Long: "Run hyperfleet service data migrations", - Run: runMigrate, + Run: func(cmd *cobra.Command, args []string) { + // v is captured in closure, available here + runMigrateWithConfig(v, cmd, args) + }, } - dbConfig.AddFlags(cmd.PersistentFlags()) - cmd.PersistentFlags().AddGoFlagSet(flag.CommandLine) + // Create config and configure flags (defines and binds in one step) + migrateConfig := config.NewMigrateConfig() + migrateConfig.ConfigureFlags(v, cmd.PersistentFlags()) + return cmd } -func runMigrate(_ *cobra.Command, _ []string) { - err := dbConfig.ReadFiles() +func runMigrateWithConfig(v *viper.Viper, cmd *cobra.Command, args []string) { + // Load configuration using command's viper instance + migrateConfig, err := config.LoadMigrateConfig(v, cmd.Flags()) if err != nil { - glog.Fatal(err) + glog.Fatalf("Failed to load configuration: %v", err) } - connection := db_session.NewProdFactory(dbConfig) + glog.Infof("Running database migrations...") + + // Create database connection factory + connection := db_session.NewProdFactory(migrateConfig.Database) + + // Run migrations if err := db.Migrate(connection.New(context.Background())); err != nil { - glog.Fatal(err) + glog.Fatalf("Migration failed: %v", err) } + + glog.Infof("Migrations completed successfully") } diff --git a/cmd/hyperfleet-api/servecmd/cmd.go b/cmd/hyperfleet-api/servecmd/cmd.go index dce3fd3..ed624cd 100755 --- a/cmd/hyperfleet-api/servecmd/cmd.go +++ b/cmd/hyperfleet-api/servecmd/cmd.go @@ -3,28 +3,49 @@ package servecmd import ( "github.com/golang/glog" "github.com/spf13/cobra" + "github.com/spf13/viper" "github.com/openshift-hyperfleet/hyperfleet-api/cmd/hyperfleet-api/environments" "github.com/openshift-hyperfleet/hyperfleet-api/cmd/hyperfleet-api/server" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/config" ) func NewServeCommand() *cobra.Command { + // Create viper instance for this command (isolated from other commands) + v := config.NewCommandConfig() + cmd := &cobra.Command{ Use: "serve", Short: "Serve the hyperfleet", Long: "Serve the hyperfleet.", - Run: runServe, - } - err := environments.Environment().AddFlags(cmd.PersistentFlags()) - if err != nil { - glog.Fatalf("Unable to add environment flags to serve command: %s", err.Error()) + Run: func(cmd *cobra.Command, args []string) { + // v is captured in closure, available here + runServeWithViper(v, cmd, args) + }, } + // Create config and configure flags (defines and binds in one step) + serveConfig := config.NewServeConfig() + serveConfig.ConfigureFlags(v, cmd.PersistentFlags()) + return cmd } -func runServe(cmd *cobra.Command, args []string) { - err := environments.Environment().Initialize() +func runServeWithViper(v *viper.Viper, cmd *cobra.Command, args []string) { + // Load configuration using command's viper instance + serveConfig, err := config.LoadServeConfig(v, cmd.Flags()) + if err != nil { + glog.Fatalf("Failed to load configuration: %v", err) + } + + // Display merged configuration + serveConfig.DisplayConfig() + + // Convert to ApplicationConfig for environment initialization + appConfig := serveConfig.ToApplicationConfig() + + // Initialize environment with loaded config + err = environments.Environment().Initialize(appConfig) if err != nil { glog.Fatalf("Unable to initialize environment: %s", err.Error()) } diff --git a/cmd/hyperfleet-api/server/api_server.go b/cmd/hyperfleet-api/server/api_server.go index 58fce4a..84b7a83 100755 --- a/cmd/hyperfleet-api/server/api_server.go +++ b/cmd/hyperfleet-api/server/api_server.go @@ -35,7 +35,7 @@ func NewAPIServer() Server { // referring to the router as type http.Handler allows us to add middleware via more handlers var mainHandler http.Handler = mainRouter - if env().Config.Server.EnableJWT { + if env().Config.Server.Auth.JWT.Enabled { // Create the logger for the authentication handler: authnLogger, err := sdk.NewGlogLoggerBuilder(). InfoV(glog.Level(1)). @@ -46,10 +46,11 @@ func NewAPIServer() Server { // Create the handler that verifies that tokens are valid: mainHandler, err = authentication.NewHandler(). Logger(authnLogger). - KeysFile(env().Config.Server.JwkCertFile). - KeysURL(env().Config.Server.JwkCertURL). - ACLFile(env().Config.Server.ACLFile). + KeysFile(env().Config.Server.Auth.JWT.CertFile). + KeysURL(env().Config.Server.Auth.JWT.CertURL). + ACLFile(env().Config.Server.Auth.Authz.ACLFile). Public("^/api/hyperfleet/?$"). + Public("^/api/hyperfleet/config/?$"). Public("^/api/hyperfleet/v1/?$"). Public("^/api/hyperfleet/v1/openapi/?$"). Public("^/api/hyperfleet/v1/openapi.html/?$"). @@ -96,7 +97,7 @@ func NewAPIServer() Server { mainHandler = removeTrailingSlash(mainHandler) s.httpServer = &http.Server{ - Addr: env().Config.Server.BindAddress, + Addr: env().Config.Server.GetBindAddress(), Handler: mainHandler, } @@ -107,20 +108,20 @@ func NewAPIServer() Server { // Useful for breaking up ListenAndServer (Start) when you require the server to be listening before continuing func (s apiServer) Serve(listener net.Listener) { var err error - if env().Config.Server.EnableHTTPS { + if env().Config.Server.HTTPS.Enabled { // Check https cert and key path path - if env().Config.Server.HTTPSCertFile == "" || env().Config.Server.HTTPSKeyFile == "" { + if env().Config.Server.HTTPS.CertFile == "" || env().Config.Server.HTTPS.KeyFile == "" { check( - fmt.Errorf("unspecified required --https-cert-file, --https-key-file"), + fmt.Errorf("unspecified required --server-https-cert-file, --server-https-key-file"), "Can't start https server", ) } // Serve with TLS - glog.Infof("Serving with TLS at %s", env().Config.Server.BindAddress) - err = s.httpServer.ServeTLS(listener, env().Config.Server.HTTPSCertFile, env().Config.Server.HTTPSKeyFile) + glog.Infof("Serving with TLS at %s", env().Config.Server.GetBindAddress()) + err = s.httpServer.ServeTLS(listener, env().Config.Server.HTTPS.CertFile, env().Config.Server.HTTPS.KeyFile) } else { - glog.Infof("Serving without TLS at %s", env().Config.Server.BindAddress) + glog.Infof("Serving without TLS at %s", env().Config.Server.GetBindAddress()) err = s.httpServer.Serve(listener) } @@ -132,7 +133,7 @@ func (s apiServer) Serve(listener net.Listener) { // Listen only start the listener, not the server. // Useful for breaking up ListenAndServer (Start) when you require the server to be listening before continuing func (s apiServer) Listen() (listener net.Listener, err error) { - return net.Listen("tcp", env().Config.Server.BindAddress) + return net.Listen("tcp", env().Config.Server.GetBindAddress()) } // Start listening on the configured port and start the server. This is a convenience wrapper for Listen() and Serve(listener Listener) diff --git a/cmd/hyperfleet-api/server/healthcheck_server.go b/cmd/hyperfleet-api/server/healthcheck_server.go index f677234..ac39c72 100755 --- a/cmd/hyperfleet-api/server/healthcheck_server.go +++ b/cmd/hyperfleet-api/server/healthcheck_server.go @@ -31,7 +31,7 @@ func NewHealthCheckServer() *healthCheckServer { srv := &http.Server{ Handler: router, - Addr: env().Config.HealthCheck.BindAddress, + Addr: env().Config.HealthCheck.GetBindAddress(), } return &healthCheckServer{ @@ -42,18 +42,18 @@ func NewHealthCheckServer() *healthCheckServer { func (s healthCheckServer) Start() { var err error if env().Config.HealthCheck.EnableHTTPS { - if env().Config.Server.HTTPSCertFile == "" || env().Config.Server.HTTPSKeyFile == "" { + if env().Config.Server.HTTPS.CertFile == "" || env().Config.Server.HTTPS.KeyFile == "" { check( - fmt.Errorf("unspecified required --https-cert-file, --https-key-file"), + fmt.Errorf("unspecified required --server-https-cert-file, --server-https-key-file"), "Can't start https server", ) } // Serve with TLS - glog.Infof("Serving HealthCheck with TLS at %s", env().Config.HealthCheck.BindAddress) - err = s.httpServer.ListenAndServeTLS(env().Config.Server.HTTPSCertFile, env().Config.Server.HTTPSKeyFile) + glog.Infof("Serving HealthCheck with TLS at %s", env().Config.HealthCheck.GetBindAddress()) + err = s.httpServer.ListenAndServeTLS(env().Config.Server.HTTPS.CertFile, env().Config.Server.HTTPS.KeyFile) } else { - glog.Infof("Serving HealthCheck without TLS at %s", env().Config.HealthCheck.BindAddress) + glog.Infof("Serving HealthCheck without TLS at %s", env().Config.HealthCheck.GetBindAddress()) err = s.httpServer.ListenAndServe() } check(err, "HealthCheck server terminated with errors") diff --git a/cmd/hyperfleet-api/server/metrics_server.go b/cmd/hyperfleet-api/server/metrics_server.go index 6fa14b0..942469d 100755 --- a/cmd/hyperfleet-api/server/metrics_server.go +++ b/cmd/hyperfleet-api/server/metrics_server.go @@ -25,7 +25,7 @@ func NewMetricsServer() Server { s := &metricsServer{} s.httpServer = &http.Server{ - Addr: env().Config.Metrics.BindAddress, + Addr: env().Config.Metrics.GetBindAddress(), Handler: mainHandler, } return s @@ -48,18 +48,18 @@ func (s metricsServer) Start() { log := logger.NewOCMLogger(context.Background()) var err error if env().Config.Metrics.EnableHTTPS { - if env().Config.Server.HTTPSCertFile == "" || env().Config.Server.HTTPSKeyFile == "" { + if env().Config.Server.HTTPS.CertFile == "" || env().Config.Server.HTTPS.KeyFile == "" { check( - fmt.Errorf("unspecified required --https-cert-file, --https-key-file"), + fmt.Errorf("unspecified required --server-https-cert-file, --server-https-key-file"), "Can't start https server", ) } // Serve with TLS - log.Infof("Serving Metrics with TLS at %s", env().Config.Server.BindAddress) - err = s.httpServer.ListenAndServeTLS(env().Config.Server.HTTPSCertFile, env().Config.Server.HTTPSKeyFile) + log.Infof("Serving Metrics with TLS at %s", env().Config.Metrics.GetBindAddress()) + err = s.httpServer.ListenAndServeTLS(env().Config.Server.HTTPS.CertFile, env().Config.Server.HTTPS.KeyFile) } else { - log.Infof("Serving Metrics without TLS at %s", env().Config.Metrics.BindAddress) + log.Infof("Serving Metrics without TLS at %s", env().Config.Metrics.GetBindAddress()) err = s.httpServer.ListenAndServe() } check(err, "Metrics server terminated with errors") diff --git a/cmd/hyperfleet-api/server/routes.go b/cmd/hyperfleet-api/server/routes.go index 3b867ba..09cf0f5 100755 --- a/cmd/hyperfleet-api/server/routes.go +++ b/cmd/hyperfleet-api/server/routes.go @@ -45,7 +45,7 @@ func (s *apiServer) routes() *mux.Router { var authMiddleware auth.JWTMiddleware authMiddleware = &auth.MiddlewareMock{} - if env().Config.Server.EnableJWT { + if env().Config.Server.Auth.JWT.Enabled { var err error authMiddleware, err = auth.NewAuthMiddleware() check(err, "Unable to create auth middleware") @@ -75,6 +75,10 @@ func (s *apiServer) routes() *mux.Router { apiRouter := mainRouter.PathPrefix("/api/hyperfleet").Subrouter() apiRouter.HandleFunc("", metadataHandler.Get).Methods(http.MethodGet) + // /api/hyperfleet/config + configHandler := handlers.NewConfigHandler(env().Config) + apiRouter.HandleFunc("/config", configHandler.Get).Methods(http.MethodGet) + // /api/hyperfleet/v1 apiV1Router := apiRouter.PathPrefix("/v1").Subrouter() diff --git a/configs/config.yaml b/configs/config.yaml new file mode 100644 index 0000000..3c03ca0 --- /dev/null +++ b/configs/config.yaml @@ -0,0 +1,63 @@ +# HyperFleet API Configuration +# This is a sample configuration file showing all available options + +# Application configuration +app: + name: "hyperfleet-api" + version: "1.0.0" + +# Server configuration +server: + hostname: "" + host: "localhost" + port: 8000 + timeout: + read: 5s + write: 30s + https: + enabled: false + cert_file: "" + key_file: "" + + # Authentication and Authorization + auth: + jwt: + enabled: false + cert_file: "" + cert_url: "https://sso.redhat.com/auth/realms/redhat-external/protocol/openid-connect/certs" + authz: + enabled: false + acl_file: "" + +# Database configuration +database: + dialect: "postgres" + host: "localhost" + port: 5432 + name: "hyperfleet" + username: "hyperfleet" + password: "foobar-bizz-buzz" + sslmode: "disable" + rootcert_file: "" + debug: false + max_open_connections: 50 + +# Metrics configuration +metrics: + host: "localhost" + port: 8080 + +# Health check configuration +health_check: + host: "localhost" + port: 8083 + +# OCM (OpenShift Cluster Manager) configuration +ocm: + base_url: "" + client_id: "" + client_secret: "" + self_token: "" + token_url: "" + debug: false + enable_mock: true diff --git a/docs/config.md b/docs/config.md new file mode 100644 index 0000000..deebbaa --- /dev/null +++ b/docs/config.md @@ -0,0 +1,384 @@ +# HyperFleet API Configuration Documentation + +This document describes all configuration options available for the HyperFleet API service. + +## Configuration Sources + +The HyperFleet API follows the [HyperFleet Configuration Standard](../configuration-standard.md) and loads configuration from multiple sources with the following precedence (highest to lowest): + +1. **Command-line flags** (highest priority) +2. **Environment variables** (prefixed with `HYPERFLEET_`) +3. **Configuration files** (YAML format) +4. **Default values** (lowest priority) + +## Configuration File Location + +The configuration file path is determined by: + +1. Path specified via `--config` flag (if provided) +2. Path specified via `HYPERFLEET_CONFIG` environment variable +3. Default paths (first found is used): + - Development: `./configs/config.yaml` + - Production: `/etc/hyperfleet/config.yaml` + +If no configuration file is found, the application continues with flags, environment variables, and defaults only. + +## Global Configuration Options + +### Application Settings + +| Field | Flag | Environment Variable | Type | Default | Description | Required | +|-------|------|---------------------|------|---------|-------------|----------| +| `app.name` | `--name`, `-n` | `HYPERFLEET_APP_NAME` | string | `""` | Component name | Yes | +| `app.version` | `--version`, `-v` | `HYPERFLEET_APP_VERSION` | string | `"1.0.0"` | Component version | No | + +**Example:** +```yaml +app: + name: hyperfleet-api + version: 1.0.0 +``` + +```bash +# Via flags +./hyperfleet-api serve --name hyperfleet-api --version 1.0.0 + +# Via environment variables +export HYPERFLEET_APP_NAME=hyperfleet-api +export HYPERFLEET_APP_VERSION=1.0.0 +``` + +## Server Configuration + +### Server Settings + +| Field | Flag | Environment Variable | Type | Default | Description | Validation | +|-------|------|---------------------|------|---------|-------------|------------| +| `server.hostname` | `--server-hostname` | `HYPERFLEET_SERVER_HOSTNAME` | string | `""` | Server's public hostname | | +| `server.host` | `--server-host` | `HYPERFLEET_SERVER_HOST` | string | `"localhost"` | Server bind host | Required | +| `server.port` | `--server-port`, `-p` | `HYPERFLEET_SERVER_PORT` | int | `8000` | Server bind port | Required, 1-65535 | +| `server.timeout.read` | `--server-timeout-read` | `HYPERFLEET_SERVER_TIMEOUT_READ` | duration | `5s` | HTTP server read timeout | | +| `server.timeout.write` | `--server-timeout-write` | `HYPERFLEET_SERVER_TIMEOUT_WRITE` | duration | `30s` | HTTP server write timeout | | + +**Example:** +```yaml +server: + hostname: "" + host: localhost + port: 8000 + timeout: + read: 5s + write: 30s +``` + +```bash +# Via flags +./hyperfleet-api serve --server-host 0.0.0.0 --server-port 8000 + +# Via environment variables +export HYPERFLEET_SERVER_HOST=0.0.0.0 +export HYPERFLEET_SERVER_PORT=8000 +``` + +### HTTPS Configuration + +| Field | Flag | Environment Variable | Type | Default | Description | +|-------|------|---------------------|------|---------|-------------| +| `server.https.enabled` | `--server-https-enabled` | `HYPERFLEET_SERVER_HTTPS_ENABLED` | bool | `false` | Enable HTTPS | +| `server.https.cert_file` | `--server-https-cert-file` | `HYPERFLEET_SERVER_HTTPS_CERT_FILE` | string | `""` | Path to TLS certificate file | +| `server.https.key_file` | `--server-https-key-file` | `HYPERFLEET_SERVER_HTTPS_KEY_FILE` | string | `""` | Path to TLS key file | + +**Example:** +```yaml +server: + https: + enabled: true + cert_file: /etc/certs/tls.crt + key_file: /etc/certs/tls.key +``` + +```bash +# Via flags +./hyperfleet-api serve --server-https-enabled --server-https-cert-file /etc/certs/tls.crt --server-https-key-file /etc/certs/tls.key + +# Via environment variables +export HYPERFLEET_SERVER_HTTPS_ENABLED=true +export HYPERFLEET_SERVER_HTTPS_CERT_FILE=/etc/certs/tls.crt +export HYPERFLEET_SERVER_HTTPS_KEY_FILE=/etc/certs/tls.key +``` + +### JWT Authentication Configuration + +| Field | Flag | Environment Variable | Type | Default | Description | +|-------|------|---------------------|------|---------|-------------| +| `server.auth.jwt.enabled` | `--auth-jwt-enabled` | `HYPERFLEET_SERVER_AUTH_JWT_ENABLED` | bool | `true` | Enable JWT authentication | +| `server.auth.jwt.cert_file` | `--auth-jwt-cert-file` | `HYPERFLEET_SERVER_AUTH_JWT_CERT_FILE` | string | `""` | JWK certificate file path | +| `server.auth.jwt.cert_url` | `--auth-jwt-cert-url` | `HYPERFLEET_SERVER_AUTH_JWT_CERT_URL` | string | `https://sso.redhat.com/auth/realms/redhat-external/protocol/openid-connect/certs` | JWK certificate URL | + +**Example:** +```yaml +server: + auth: + jwt: + enabled: true + cert_file: "" + cert_url: https://sso.redhat.com/auth/realms/redhat-external/protocol/openid-connect/certs +``` + +### Authorization Configuration + +| Field | Flag | Environment Variable | Type | Default | Description | +|-------|------|---------------------|------|---------|-------------| +| `server.auth.authz.enabled` | `--auth-authz-enabled` | `HYPERFLEET_SERVER_AUTH_AUTHZ_ENABLED` | bool | `true` | Enable authorization | +| `server.auth.authz.acl_file` | `--auth-authz-acl-file` | `HYPERFLEET_SERVER_AUTH_AUTHZ_ACL_FILE` | string | `""` | Access control list file | + +**Example:** +```yaml +server: + auth: + authz: + enabled: true + acl_file: /etc/config/acl.yaml +``` + +## Database Configuration + +| Field | Flag | Environment Variable | Type | Default | Description | Validation | +|-------|------|---------------------|------|---------|-------------|------------| +| `database.dialect` | | `HYPERFLEET_DATABASE_DIALECT` | string | `"postgres"` | Database dialect | Required | +| `database.host` | `--db-host` | `HYPERFLEET_DATABASE_HOST` | string | `""` | Database host | | +| `database.port` | `--db-port` | `HYPERFLEET_DATABASE_PORT` | int | `0` | Database port | 0-65535 | +| `database.name` | `--db-name`, `-d` | `HYPERFLEET_DATABASE_NAME` | string | `""` | Database name | | +| `database.username` | `--db-username`, `-u` | `HYPERFLEET_DATABASE_USERNAME` | string | `""` | Database username | | +| `database.password` | `--db-password` | `HYPERFLEET_DATABASE_PASSWORD` | string | `""` | Database password (prefer env var) | | +| `database.sslmode` | `--db-sslmode` | `HYPERFLEET_DATABASE_SSLMODE` | string | `"disable"` | SSL mode | disable, require, verify-ca, verify-full | +| `database.rootcert_file` | `--db-rootcert` | `HYPERFLEET_DATABASE_ROOTCERT_FILE` | string | `"secrets/db.rootcert"` | Root certificate file | | +| `database.debug` | `--db-debug` | `HYPERFLEET_DATABASE_DEBUG` | bool | `false` | Enable database debug mode | | +| `database.max_open_connections` | `--db-max-open-connections` | `HYPERFLEET_DATABASE_MAX_OPEN_CONNECTIONS` | int | `50` | Maximum open connections | Min: 1 | + +### File-based Secrets + +For enhanced security, database credentials can be loaded from files: + +| Field | Flag | Default | +|-------|------|---------| +| `database.host_file` | `--db-host-file` | `secrets/db.host` | +| `database.port_file` | `--db-port-file` | `secrets/db.port` | +| `database.name_file` | `--db-name-file` | `secrets/db.name` | +| `database.username_file` | `--db-username-file` | `secrets/db.user` | +| `database.password_file` | `--db-password-file` | `secrets/db.password` | + +**Note:** File-based values are only used if the corresponding direct value is not set. + +**Example:** +```yaml +database: + dialect: postgres + host: db.example.com + port: 5432 + name: hyperfleet + username: hyperfleet_user + # Password should be set via environment variable or file + sslmode: require + max_open_connections: 50 +``` + +```bash +# Via environment variables (recommended for credentials) +export HYPERFLEET_DATABASE_HOST=db.example.com +export HYPERFLEET_DATABASE_PORT=5432 +export HYPERFLEET_DATABASE_NAME=hyperfleet +export HYPERFLEET_DATABASE_USERNAME=hyperfleet_user +export HYPERFLEET_DATABASE_PASSWORD=super_secret_password +``` + +## Metrics Configuration + +| Field | Flag | Environment Variable | Type | Default | Description | Validation | +|-------|------|---------------------|------|---------|-------------|------------| +| `metrics.host` | `--metrics-host` | `HYPERFLEET_METRICS_HOST` | string | `"localhost"` | Metrics server bind host | | +| `metrics.port` | `--metrics-port` | `HYPERFLEET_METRICS_PORT` | int | `8080` | Metrics server bind port | 1-65535 | +| `metrics.enable_https` | `--metrics-https-enabled` | `HYPERFLEET_METRICS_ENABLE_HTTPS` | bool | `false` | Enable HTTPS for metrics | | +| `metrics.label_metrics_inclusion_duration` | `--metrics-label-inclusion-duration` | `HYPERFLEET_METRICS_LABEL_METRICS_INCLUSION_DURATION` | duration | `168h` | Label metrics inclusion duration | | + +**Example:** +```yaml +metrics: + host: localhost + port: 8080 + enable_https: false + label_metrics_inclusion_duration: 168h # 7 days +``` + +## Health Check Configuration + +| Field | Flag | Environment Variable | Type | Default | Description | Validation | +|-------|------|---------------------|------|---------|-------------|------------| +| `health_check.host` | `--health-check-host` | `HYPERFLEET_HEALTH_CHECK_HOST` | string | `"localhost"` | Health check server bind host | | +| `health_check.port` | `--health-check-port` | `HYPERFLEET_HEALTH_CHECK_PORT` | int | `8083` | Health check server bind port | 1-65535 | +| `health_check.enable_https` | `--health-check-https-enabled` | `HYPERFLEET_HEALTH_CHECK_ENABLE_HTTPS` | bool | `false` | Enable HTTPS for health check | | + +**Example:** +```yaml +health_check: + host: localhost + port: 8083 + enable_https: false +``` + +## OCM (OpenShift Cluster Manager) Configuration + +| Field | Flag | Environment Variable | Type | Default | Description | +|-------|------|---------------------|------|---------|-------------| +| `ocm.base_url` | `--ocm-base-url` | `HYPERFLEET_OCM_BASE_URL` | string | `https://api.integration.openshift.com` | OCM API base URL | +| `ocm.token_url` | `--ocm-token-url` | `HYPERFLEET_OCM_TOKEN_URL` | string | `https://sso.redhat.com/auth/realms/redhat-external/protocol/openid-connect/token` | OCM token URL | +| `ocm.debug` | `--ocm-debug` | `HYPERFLEET_OCM_DEBUG` | bool | `false` | Enable OCM debug mode | +| `ocm.enable_mock` | `--ocm-mock` | `HYPERFLEET_OCM_ENABLE_MOCK` | bool | `true` | Enable mock OCM client | + +### File-based Secrets + +OCM credentials can be loaded from files: + +| Field | Flag | Default | +|-------|------|---------| +| `ocm.client_id_file` | `--ocm-client-id-file` | `secrets/ocm-service.clientId` | +| `ocm.client_secret_file` | `--ocm-client-secret-file` | `secrets/ocm-service.clientSecret` | +| `ocm.self_token_file` | `--ocm-self-token-file` | `""` | + +**Example:** +```yaml +ocm: + base_url: https://api.integration.openshift.com + token_url: https://sso.redhat.com/auth/realms/redhat-external/protocol/openid-connect/token + debug: false + enable_mock: false + client_id_file: secrets/ocm-service.clientId + client_secret_file: secrets/ocm-service.clientSecret +``` + +## Configuration Validation + +The service performs validation on startup and will fail to start if: + +- Required fields are missing (e.g., `app.name`) +- Values are outside valid ranges (e.g., port numbers) +- Unknown fields are present in the configuration file +- Required files cannot be read + +Validation errors include helpful hints showing how to provide the correct values via flags, environment variables, or configuration files. + +**Example validation error:** +``` +Configuration validation failed: + - Field 'Config.App.Name' failed validation: required + Value: + Please provide via: + • Flag: --name or -n + • Environment variable: HYPERFLEET_APP_NAME + • Config file: app.name +``` + +## Configuration Display + +On startup, the merged configuration is displayed in the logs with sensitive values redacted. The following fields are automatically redacted: + +- `database.password` +- `ocm.client_secret` +- `ocm.self_token` + +Redacted values are shown as `***` to indicate they are set but not displayed. + +## Complete Configuration Example + +```yaml +# Application configuration +app: + name: hyperfleet-api + version: 1.0.0 + +# Server configuration +server: + hostname: "" + host: 0.0.0.0 + port: 8000 + timeout: + read: 5s + write: 30s + https: + enabled: false + cert_file: "" + key_file: "" + auth: + jwt: + enabled: true + cert_file: "" + cert_url: https://sso.redhat.com/auth/realms/redhat-external/protocol/openid-connect/certs + authz: + enabled: true + acl_file: "" + +# Database configuration +database: + dialect: postgres + host: db.example.com + port: 5432 + name: hyperfleet + username: hyperfleet_user + sslmode: require + debug: false + max_open_connections: 50 + # File-based secrets + host_file: secrets/db.host + port_file: secrets/db.port + name_file: secrets/db.name + username_file: secrets/db.user + password_file: secrets/db.password + rootcert_file: secrets/db.rootcert + +# Metrics configuration +metrics: + host: localhost + port: 8080 + enable_https: false + label_metrics_inclusion_duration: 168h + +# Health check configuration +health_check: + host: localhost + port: 8083 + enable_https: false + +# OCM configuration +ocm: + base_url: https://api.integration.openshift.com + token_url: https://sso.redhat.com/auth/realms/redhat-external/protocol/openid-connect/token + debug: false + enable_mock: false + client_id_file: secrets/ocm-service.clientId + client_secret_file: secrets/ocm-service.clientSecret + self_token_file: "" +``` + +## Environment-Specific Configuration + +The HyperFleet API supports multiple runtime environments controlled via the `OCM_ENV` environment variable: + +- `development` (default) +- `integration-testing` +- `unit-testing` +- `production` + +Each environment can have specific default configurations that override the base defaults. + +## Best Practices + +1. **Never commit secrets**: Use environment variables or file-based secrets for sensitive data +2. **Use configuration files for non-sensitive defaults**: Keep your deployment-specific settings in YAML files +3. **Override at runtime**: Use flags for quick testing and overrides +4. **Validate early**: The application will fail fast on invalid configuration +5. **Document exceptions**: If you add custom configuration options, document them in this file + +## See Also + +- [HyperFleet Configuration Standard](../configuration-standard.md) +- [Configuration Implementation Example](https://github.com/rh-amarin/viper-cobra-validation-poc) diff --git a/go.mod b/go.mod index 88bdc01..33b3c6b 100755 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/getkin/kin-openapi v0.133.0 github.com/ghodss/yaml v1.0.0 github.com/go-gormigrate/gormigrate/v2 v2.0.0 + github.com/go-playground/validator/v10 v10.30.1 github.com/golang-jwt/jwt/v4 v4.5.2 github.com/golang/glog v1.2.5 github.com/google/uuid v1.6.0 @@ -26,7 +27,9 @@ require ( github.com/prometheus/client_golang v1.16.0 github.com/segmentio/ksuid v1.0.2 github.com/spf13/cobra v0.0.5 - github.com/spf13/pflag v1.0.5 + github.com/spf13/pflag v1.0.10 + github.com/spf13/viper v1.21.0 + github.com/stretchr/testify v1.11.1 github.com/testcontainers/testcontainers-go v0.33.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.33.0 github.com/yaacov/tree-search-language v0.0.0-20190923184055-1c2dad2e354b @@ -52,6 +55,7 @@ require ( github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/distribution v2.8.1+incompatible // indirect @@ -59,12 +63,17 @@ require ( github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-sql-driver/mysql v1.8.1 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/gorilla/css v1.0.0 // indirect @@ -80,6 +89,7 @@ require ( github.com/klauspost/compress v1.18.0 // indirect github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/mailru/easyjson v0.7.7 // indirect @@ -101,16 +111,23 @@ require ( github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/prometheus/client_model v0.3.0 // indirect github.com/prometheus/common v0.42.0 // indirect github.com/prometheus/procfs v0.10.1 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/shirou/gopsutil/v3 v3.23.12 // indirect github.com/shoenig/go-m1cpu v0.1.6 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/smartystreets/goconvey v1.8.1 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect github.com/urfave/negroni v1.0.0 // indirect @@ -123,11 +140,12 @@ require ( go.opentelemetry.io/otel/metric v1.38.0 // indirect go.opentelemetry.io/otel/sdk v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect - golang.org/x/crypto v0.41.0 // indirect - golang.org/x/net v0.43.0 // indirect - golang.org/x/sync v0.17.0 // indirect - golang.org/x/sys v0.36.0 // indirect - golang.org/x/text v0.29.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect golang.org/x/time v0.3.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20251014184007-4626949a642f // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f // indirect diff --git a/go.sum b/go.sum index f143ba0..7792f70 100755 --- a/go.sum +++ b/go.sum @@ -141,8 +141,14 @@ github.com/evanphx/json-patch/v5 v5.6.0/go.mod h1:G79N1coSVB93tBe7j6PhzjmR3/2Vvl github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= @@ -169,6 +175,14 @@ github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1 github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +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.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= @@ -178,6 +192,8 @@ github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/me github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= @@ -399,6 +415,8 @@ github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= @@ -502,6 +520,8 @@ github.com/openshift-online/ocm-sdk-go v0.1.334 h1:45WSkXEsmpGekMa9kO6NpEG8PW5/g github.com/openshift-online/ocm-sdk-go v0.1.334/go.mod h1:KYOw8kAKAHyPrJcQoVR82CneQ4ofC02Na4cXXaTq4Nw= github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= @@ -551,6 +571,8 @@ github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/segmentio/ksuid v1.0.2 h1:9yBfKyw4ECGTdALaF09Snw3sLJmYIX6AbPJrAy6MrDc= github.com/segmentio/ksuid v1.0.2/go.mod h1:BXuJDr2byAiHuQaQtSKoXh1J0YmUDurywOXgB2w+OSU= @@ -573,15 +595,23 @@ github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGB github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec= github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cobra v0.0.5 h1:f0B+LkLX6DtmRH1isoNA9VTtNUK9K8xYd28JNNfOv/s= github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= @@ -597,6 +627,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/testcontainers/testcontainers-go v0.33.0 h1:zJS9PfXYT5O0ZFXM2xxXfk4J5UMw/kRiISng037Gxdw= github.com/testcontainers/testcontainers-go v0.33.0/go.mod h1:W80YpTa8D5C3Yy16icheD01UTDu+LmXIA2Keo+jWtT8= github.com/testcontainers/testcontainers-go/modules/postgres v0.33.0 h1:c+Gt+XLJjqFAejgX4hSpnHIpC9eAhvgI/TFWL/PbrFI= @@ -665,6 +697,8 @@ go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9E go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -683,8 +717,8 @@ golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220427172511-eb4f295cb31f/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= -golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -762,8 +796,8 @@ golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190523182746-aaccbc9213b0/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -782,8 +816,8 @@ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -848,13 +882,13 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= -golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -863,8 +897,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= diff --git a/pkg/config/app.go b/pkg/config/app.go new file mode 100644 index 0000000..1b13aca --- /dev/null +++ b/pkg/config/app.go @@ -0,0 +1,24 @@ +package config + +import ( + "github.com/spf13/pflag" + "github.com/spf13/viper" +) + +type AppConfig struct { + Name string `mapstructure:"name" json:"name" validate:"required"` + Version string `mapstructure:"version" json:"version"` +} + +func NewAppConfig() *AppConfig { + return &AppConfig{ + Name: "", + Version: "1.0.0", + } +} + +// defineAndBindFlags defines app flags and binds them to viper keys in a single pass +func (c *AppConfig) defineAndBindFlags(v *viper.Viper, fs *pflag.FlagSet) { + defineAndBindStringFlag(v, fs, "app.name", "name", "n", c.Name, "Component name (REQUIRED)") + defineAndBindStringFlag(v, fs, "app.version", "version", "", c.Version, "Component version") +} diff --git a/pkg/config/config.go b/pkg/config/config.go index f7c44ff..6eb882e 100755 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -1,27 +1,95 @@ package config import ( + "encoding/json" "flag" "fmt" "os" - "path/filepath" - "runtime" - "strconv" + "reflect" "strings" + "time" + "github.com/go-playground/validator/v10" + "github.com/golang/glog" "github.com/spf13/pflag" + "github.com/spf13/viper" ) +const ( + EnvPrefix = "HYPERFLEET" + DefaultConfigFileProd = "/etc/hyperfleet/config.yaml" + DefaultConfigFileDev = "./configs/config.yaml" + ConfigEnvVar = "HYPERFLEET_CONFIG" +) + +// NewCommandConfig creates and configures a new Viper instance for a command +// Each command should have its own viper instance to avoid configuration pollution +func NewCommandConfig() *viper.Viper { + v := viper.New() + v.SetEnvPrefix(EnvPrefix) + v.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_")) + v.AutomaticEnv() + return v +} + +// Flag definition helpers that define flags and bind them to viper keys in a single pass + +func defineAndBindStringFlag(v *viper.Viper, fs *pflag.FlagSet, viperKey, flagName, shorthand, defaultVal, usage string) { + // Define the flag + if shorthand != "" { + fs.StringP(flagName, shorthand, defaultVal, usage) + } else { + fs.String(flagName, defaultVal, usage) + } + // Bind to viper key + bindFlag(v, fs, viperKey, flagName) +} + +func defineAndBindIntFlag(v *viper.Viper, fs *pflag.FlagSet, viperKey, flagName, shorthand string, defaultVal int, usage string) { + // Define the flag + if shorthand != "" { + fs.IntP(flagName, shorthand, defaultVal, usage) + } else { + fs.Int(flagName, defaultVal, usage) + } + // Bind to viper key + bindFlag(v, fs, viperKey, flagName) +} + +func defineAndBindBoolFlag(v *viper.Viper, fs *pflag.FlagSet, viperKey, flagName, shorthand string, defaultVal bool, usage string) { + // Define the flag + if shorthand != "" { + fs.BoolP(flagName, shorthand, defaultVal, usage) + } else { + fs.Bool(flagName, defaultVal, usage) + } + // Bind to viper key + bindFlag(v, fs, viperKey, flagName) +} + +func defineAndBindDurationFlag(v *viper.Viper, fs *pflag.FlagSet, viperKey, flagName, shorthand string, defaultVal time.Duration, usage string) { + // Define the flag + if shorthand != "" { + fs.DurationP(flagName, shorthand, defaultVal, usage) + } else { + fs.Duration(flagName, defaultVal, usage) + } + // Bind to viper key + bindFlag(v, fs, viperKey, flagName) +} + type ApplicationConfig struct { - Server *ServerConfig `json:"server"` - Metrics *MetricsConfig `json:"metrics"` - HealthCheck *HealthCheckConfig `json:"health_check"` - Database *DatabaseConfig `json:"database"` - OCM *OCMConfig `json:"ocm"` + App *AppConfig `mapstructure:"app" json:"app" validate:"required"` + Server *ServerConfig `mapstructure:"server" json:"server" validate:"required"` + Metrics *MetricsConfig `mapstructure:"metrics" json:"metrics" validate:"required"` + HealthCheck *HealthCheckConfig `mapstructure:"health_check" json:"health_check" validate:"required"` + Database *DatabaseConfig `mapstructure:"database" json:"database" validate:"required"` + OCM *OCMConfig `mapstructure:"ocm" json:"ocm" validate:"required"` } func NewApplicationConfig() *ApplicationConfig { return &ApplicationConfig{ + App: NewAppConfig(), Server: NewServerConfig(), Metrics: NewMetricsConfig(), HealthCheck: NewHealthCheckConfig(), @@ -30,99 +98,310 @@ func NewApplicationConfig() *ApplicationConfig { } } -func (c *ApplicationConfig) AddFlags(flagset *pflag.FlagSet) { +// defineAndBindFlags defines application flags and binds them to viper keys in a single pass +func (c *ApplicationConfig) defineAndBindFlags(v *viper.Viper, flagset *pflag.FlagSet) { + // Global flags + // Note: config flag is defined but NOT bound to viper (special case) + flagset.String("config", "", "Config file path") + + // Define and bind sub-config flags + c.App.defineAndBindFlags(v, flagset) + c.Server.defineAndBindFlags(v, flagset) + c.Metrics.defineAndBindFlags(v, flagset) + c.HealthCheck.defineAndBindFlags(v, flagset) + c.Database.defineAndBindFlags(v, flagset) + c.OCM.defineAndBindFlags(v, flagset) +} + +// ConfigureFlags defines configuration flags and binds them to viper for precedence handling +func (c *ApplicationConfig) ConfigureFlags(v *viper.Viper, flagset *pflag.FlagSet) { flagset.AddGoFlagSet(flag.CommandLine) - c.Server.AddFlags(flagset) - c.Metrics.AddFlags(flagset) - c.HealthCheck.AddFlags(flagset) - c.Database.AddFlags(flagset) - c.OCM.AddFlags(flagset) -} - -func (c *ApplicationConfig) ReadFiles() []string { - readFiles := []struct { - f func() error - name string - }{ - {c.Server.ReadFiles, "Server"}, - {c.Database.ReadFiles, "Database"}, - {c.OCM.ReadFiles, "OCM"}, - {c.Metrics.ReadFiles, "Metrics"}, - {c.HealthCheck.ReadFiles, "HealthCheck"}, - } - var messages []string - for _, rf := range readFiles { - if err := rf.f(); err != nil { - msg := fmt.Sprintf("%s %s", rf.name, err.Error()) - messages = append(messages, msg) + c.defineAndBindFlags(v, flagset) +} + +// bindFlag is a simple helper to bind an existing flag to a viper key +func bindFlag(v *viper.Viper, fs *pflag.FlagSet, viperKey, flagName string) { + if err := v.BindPFlag(viperKey, fs.Lookup(flagName)); err != nil { + panic(fmt.Sprintf("failed to bind flag %s to %s: %v", flagName, viperKey, err)) + } +} + +// LoadConfig loads configuration from multiple sources with proper precedence: +// 1. Command-line flags (highest priority) +// 2. Environment variables (HYPERFLEET_ prefix) +// 3. Configuration files +// 4. Defaults (lowest priority) +// +// The viper instance should already be configured and have flags bound via ConfigureFlags() +func LoadConfig(v *viper.Viper, flags *pflag.FlagSet) (*ApplicationConfig, error) { + // Create config instance + // Note: Viper is already configured with env support and flags are already bound + config := NewApplicationConfig() + + // Determine config file path + configFile := getConfigFilePath(flags, v) + + // Load config file if it exists + if configFile != "" { + v.SetConfigFile(configFile) + if err := v.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + return nil, fmt.Errorf("error reading config file %s: %w", configFile, err) + } + glog.Infof("Config file not found: %s, continuing with flags and environment variables", configFile) + } else { + glog.Infof("Loaded configuration from: %s", configFile) } } - return messages + + // Unmarshal into config struct + // Viper now contains: config file values < env vars < bound flags + // This gives us the correct precedence automatically + if err := v.UnmarshalExact(config); err != nil { + return nil, fmt.Errorf("error unmarshaling config: %w", err) + } + + // Validate configuration + if err := config.Validate(); err != nil { + return nil, err + } + + return config, nil } -// Read the contents of file into integer value -func readFileValueInt(file string, val *int) error { - fileContents, err := ReadFile(file) - if err != nil { - return err +// getConfigFilePath determines the config file path based on precedence: +// 1. --config flag +// 2. HYPERFLEET_CONFIG environment variable +// 3. Default paths +func getConfigFilePath(flags *pflag.FlagSet, v *viper.Viper) string { + // Check --config flag first + if flags != nil { + if configFlag := flags.Lookup("config"); configFlag != nil && configFlag.Changed { + return configFlag.Value.String() + } } - *val, err = strconv.Atoi(fileContents) - return err + // Check environment variable + if configEnv := os.Getenv(ConfigEnvVar); configEnv != "" { + return configEnv + } + + // Try default paths in order + defaultPaths := []string{ + DefaultConfigFileDev, // Try development path first + DefaultConfigFileProd, // Then production path + } + + for _, path := range defaultPaths { + if _, err := os.Stat(path); err == nil { + return path + } + } + + // No config file found + return "" } -// Read the contents of file into string value -func readFileValueString(file string, val *string) error { - fileContents, err := ReadFile(file) - if err != nil { - return err +// Validate validates the configuration using struct tags +func (c *ApplicationConfig) Validate() error { + validate := validator.New() + + if err := validate.Struct(c); err != nil { + return formatValidationError(err) + } + + return nil +} + +// formatValidationError formats validation errors following the HyperFleet standard +func formatValidationError(err error) error { + if validationErrors, ok := err.(validator.ValidationErrors); ok { + var messages []string + messages = append(messages, "Configuration validation failed:") + + for _, fieldError := range validationErrors { + fieldPath := getFieldPath(fieldError) + + msg := fmt.Sprintf(" - Field '%s' failed validation: %s", fieldPath, fieldError.Tag()) + + if fieldError.Param() != "" { + msg += fmt.Sprintf(" (param: %s)", fieldError.Param()) + } + + msg += fmt.Sprintf("\n Value: %v", fieldError.Value()) + msg += getHelpfulHint(fieldPath, fieldError.Tag()) + + messages = append(messages, msg) + } + + return fmt.Errorf("%s", strings.Join(messages, "\n")) } - *val = strings.TrimSuffix(fileContents, "\n") return err } -// Read the contents of file into boolean value -func readFileValueBool(file string, val *bool) error { - fileContents, err := ReadFile(file) +// getFieldPath extracts the full field path from a validation error +func getFieldPath(fieldError validator.FieldError) string { + namespace := fieldError.Namespace() + // Remove the root struct name (ApplicationConfig) + parts := strings.Split(namespace, ".") + if len(parts) > 1 { + return "Config." + strings.Join(parts[1:], ".") + } + return namespace +} + +// getHelpfulHint provides hints for how to fix validation errors +func getHelpfulHint(fieldPath, tag string) string { + // Convert field path to flag and env var names + // E.g., "Config.App.Name" -> "app.name" + parts := strings.Split(fieldPath, ".") + if len(parts) <= 1 { + return "" + } + + // Remove "Config" prefix + configParts := parts[1:] + + // Convert to lowercase and join with dots + var lowerParts []string + for _, part := range configParts { + lowerParts = append(lowerParts, strings.ToLower(part)) + } + configPath := strings.Join(lowerParts, ".") + + // Create flag name (replace dots and underscores with hyphens) + flagName := "--" + strings.ReplaceAll(strings.ReplaceAll(configPath, ".", "-"), "_", "-") + + // Create env var name (uppercase, replace dots with underscores) + envVarName := EnvPrefix + "_" + strings.ToUpper(strings.ReplaceAll(configPath, ".", "_")) + + hint := "\n Please provide via:\n" + hint += fmt.Sprintf(" • Flag: %s\n", flagName) + hint += fmt.Sprintf(" • Environment variable: %s\n", envVarName) + hint += fmt.Sprintf(" • Config file: %s", configPath) + + return hint +} + +// DisplayConfig logs the merged configuration at startup +// Sensitive values are redacted +func (c *ApplicationConfig) DisplayConfig() { + glog.Info("=== Merged Configuration ===") + + // Create a copy for display with sensitive values redacted + displayCopy := c.redactSensitiveValues() + + // Convert to JSON for pretty display + jsonBytes, err := json.MarshalIndent(displayCopy, "", " ") if err != nil { - return err + glog.Errorf("Error marshaling config for display: %v", err) + return } - *val, err = strconv.ParseBool(fileContents) - return err + glog.Infof("\n%s", string(jsonBytes)) + glog.Info("============================") } -func ReadFile(file string) (string, error) { - // If the value is in quotes, unquote it - unquotedFile, err := strconv.Unquote(file) +// redactSensitiveValues creates a copy of the config with sensitive values redacted +// It uses reflection to automatically redact any field whose name contains +// sensitive keywords (password, secret, token, key, cert) +func (c *ApplicationConfig) redactSensitiveValues() *ApplicationConfig { + // Marshal to JSON and back to create a deep copy + jsonBytes, err := json.Marshal(c) if err != nil { - // values without quotes will raise an error, ignore it. - unquotedFile = file + glog.Errorf("Error marshaling config for redaction: %v", err) + return c } - // If no file is provided, leave val unchanged. - if unquotedFile == "" { - return "", nil + var copy ApplicationConfig + if err := json.Unmarshal(jsonBytes, ©); err != nil { + glog.Errorf("Error unmarshaling config for redaction: %v", err) + return c } - // Ensure the absolute file path is used - absFilePath := unquotedFile - if !filepath.IsAbs(unquotedFile) { - absFilePath = filepath.Join(GetProjectRootDir(), unquotedFile) + // Recursively redact sensitive fields + redactSensitiveFields(reflect.ValueOf(©).Elem()) + + return © +} + +// redactSensitiveFields recursively walks through a struct and redacts +// any string field whose name matches sensitive patterns +func redactSensitiveFields(v reflect.Value) { + if !v.IsValid() { + return } - // Read the file - buf, err := os.ReadFile(absFilePath) + switch v.Kind() { + case reflect.Ptr: + if !v.IsNil() { + redactSensitiveFields(v.Elem()) + } + + case reflect.Struct: + t := v.Type() + for i := 0; i < v.NumField(); i++ { + field := v.Field(i) + fieldType := t.Field(i) + + // Skip unexported fields + if !field.CanSet() { + continue + } + + // Check if this field is sensitive + if isSensitiveField(fieldType.Name) { + // Redact string fields + if field.Kind() == reflect.String && field.String() != "" { + field.SetString("***") + } + } else { + // Recursively process nested structs and pointers + redactSensitiveFields(field) + } + } + + case reflect.Slice, reflect.Array: + for i := 0; i < v.Len(); i++ { + redactSensitiveFields(v.Index(i)) + } + + case reflect.Map: + for _, key := range v.MapKeys() { + mapValue := v.MapIndex(key) + if mapValue.Kind() == reflect.Ptr || mapValue.Kind() == reflect.Struct { + redactSensitiveFields(mapValue) + } + } + } +} + +// GetJSONConfig returns the configuration as a JSON string +// Sensitive values are redacted +func (c *ApplicationConfig) GetJSONConfig() (string, error) { + displayCopy := c.redactSensitiveValues() + + jsonBytes, err := json.MarshalIndent(displayCopy, "", " ") if err != nil { - return "", err + return "", fmt.Errorf("error marshaling config to JSON: %w", err) } - return string(buf), nil + + return string(jsonBytes), nil } -// GetProjectRootDir Return project root path based on the relative path of this file -func GetProjectRootDir() string { - _, b, _, _ := runtime.Caller(0) - basepath := filepath.Dir(filepath.Join(b, "..", "..")) - return basepath +// isSensitiveField checks if a field name contains sensitive data keywords +func isSensitiveField(fieldName string) bool { + sensitiveFields := []string{ + "password", "secret", "token", "key", "cert", + } + + lowerName := strings.ToLower(fieldName) + for _, sensitive := range sensitiveFields { + if strings.Contains(lowerName, sensitive) { + return true + } + } + + return false } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go old mode 100755 new mode 100644 index 0cc470f..2e494a5 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -1,80 +1,438 @@ package config import ( - "log" "os" + "path/filepath" "testing" - . "github.com/onsi/gomega" + "github.com/spf13/pflag" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -func TestConfigReadStringFile(t *testing.T) { - RegisterTestingT(t) +// testLoadConfig is a helper that loads config (flags must already be configured and parsed) +func testLoadConfig(v *viper.Viper, flags *pflag.FlagSet) (*ApplicationConfig, error) { + return LoadConfig(v, flags) +} + +// TestConfigPrecedence_CommandLineOverridesEnvVar tests that command-line flags +// have higher precedence than environment variables +func TestConfigPrecedence_CommandLineOverridesEnvVar(t *testing.T) { + // Set environment variable + os.Setenv("HYPERFLEET_APP_NAME", "env-name") + defer os.Unsetenv("HYPERFLEET_APP_NAME") + + // Create flag set + flags := pflag.NewFlagSet("test", pflag.ContinueOnError) + cfg := NewApplicationConfig() + + // Configure flags (define and bind) + v := NewCommandConfig() + cfg.ConfigureFlags(v, flags) + + // Parse command-line flag (simulating --name=cli-name) + err := flags.Parse([]string{"--name=cli-name"}) + require.NoError(t, err) + + // Load config + loadedCfg, err := testLoadConfig(v, flags) + require.NoError(t, err) + + // Command-line flag should win + assert.Equal(t, "cli-name", loadedCfg.App.Name, "Command-line flag should override environment variable") +} + +// TestConfigPrecedence_EnvVarOverridesConfigFile tests that environment variables +// have higher precedence than config file values +func TestConfigPrecedence_EnvVarOverridesConfigFile(t *testing.T) { + // Create temporary config file + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.yaml") + + configYAML := ` +app: + name: file-name + version: 1.0.0 +server: + host: localhost + port: 8000 +metrics: + host: localhost + port: 8080 +health_check: + host: localhost + port: 8083 +database: + dialect: postgres +ocm: + base_url: https://api.integration.openshift.com +` + err := os.WriteFile(configFile, []byte(configYAML), 0o644) + require.NoError(t, err) + + // Set environment variable + os.Setenv("HYPERFLEET_APP_NAME", "env-name") + defer os.Unsetenv("HYPERFLEET_APP_NAME") + + // Create flag set and specify config file + flags := pflag.NewFlagSet("test", pflag.ContinueOnError) + cfg := NewApplicationConfig() + + // Configure flags (define and bind) + v := NewCommandConfig() + cfg.ConfigureFlags(v, flags) + + err = flags.Parse([]string{"--config=" + configFile}) + require.NoError(t, err) + + // Load config + loadedCfg, err := testLoadConfig(v, flags) + require.NoError(t, err) + + // Environment variable should win over config file + assert.Equal(t, "env-name", loadedCfg.App.Name, "Environment variable should override config file") +} + +// TestConfigPrecedence_ConfigFileOverridesDefaults tests that config file values +// have higher precedence than default values +func TestConfigPrecedence_ConfigFileOverridesDefaults(t *testing.T) { + // Create temporary config file with custom port + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.yaml") + + configYAML := ` +app: + name: file-name + version: 2.0.0 +server: + host: localhost + port: 9999 + auth: + jwt: + enabled: false +metrics: + host: localhost + port: 8080 +health_check: + host: localhost + port: 8083 +database: + dialect: postgres +ocm: + base_url: https://api.integration.openshift.com +` + err := os.WriteFile(configFile, []byte(configYAML), 0o644) + require.NoError(t, err) + + // Create flag set and specify config file + flags := pflag.NewFlagSet("test", pflag.ContinueOnError) + cfg := NewApplicationConfig() + + // Configure flags (define and bind) + v := NewCommandConfig() + cfg.ConfigureFlags(v, flags) + + err = flags.Parse([]string{"--config=" + configFile}) + require.NoError(t, err) + + // Load config + loadedCfg, err := testLoadConfig(v, flags) + require.NoError(t, err) + + // Config file values should override defaults + assert.Equal(t, "file-name", loadedCfg.App.Name, "Config file should override default app name") + assert.Equal(t, "2.0.0", loadedCfg.App.Version, "Config file should override default version") + assert.Equal(t, 9999, loadedCfg.Server.Port, "Config file should override default port") + assert.Equal(t, false, loadedCfg.Server.Auth.JWT.Enabled, "Config file should override default auth") +} + +// TestConfigPrecedence_FullPrecedenceChain tests the complete precedence chain: +// CLI > Env Var > Config File > Defaults +func TestConfigPrecedence_FullPrecedenceChain(t *testing.T) { + // Create temporary config file + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.yaml") + + configYAML := ` +app: + name: file-name + version: 2.0.0 +server: + host: file-host + port: 7000 +metrics: + host: localhost + port: 7070 +health_check: + host: localhost + port: 8083 +database: + dialect: postgres + port: 5432 +ocm: + base_url: https://api.integration.openshift.com +` + err := os.WriteFile(configFile, []byte(configYAML), 0o644) + require.NoError(t, err) - stringFile, err := createConfigFile("string", "example\n") - defer os.Remove(stringFile.Name()) //nolint:errcheck - if err != nil { - log.Fatal(err) - } + // Set environment variables + os.Setenv("HYPERFLEET_APP_VERSION", "env-version") + os.Setenv("HYPERFLEET_SERVER_PORT", "8888") + defer os.Unsetenv("HYPERFLEET_APP_VERSION") + defer os.Unsetenv("HYPERFLEET_SERVER_PORT") - var stringConfig string - err = readFileValueString(stringFile.Name(), &stringConfig) - Expect(err).NotTo(HaveOccurred()) - Expect(stringConfig).To(Equal("example")) + // Create flag set with command-line values + flags := pflag.NewFlagSet("test", pflag.ContinueOnError) + cfg := NewApplicationConfig() + + // Configure flags (define and bind) + v := NewCommandConfig() + cfg.ConfigureFlags(v, flags) + + err = flags.Parse([]string{ + "--config=" + configFile, + "--name=cli-name", // CLI overrides all + // version: env var should override file + // server-host: file should override default + // server-port: env var should override file + "--metrics-port=9090", // CLI overrides all + }) + require.NoError(t, err) + + // Load config + loadedCfg, err := testLoadConfig(v, flags) + require.NoError(t, err) + + // Verify precedence + assert.Equal(t, "cli-name", loadedCfg.App.Name, "CLI should have highest precedence") + assert.Equal(t, "env-version", loadedCfg.App.Version, "Env var should override config file") + assert.Equal(t, "file-host", loadedCfg.Server.Host, "Config file should override default") + assert.Equal(t, 8888, loadedCfg.Server.Port, "Env var should override config file") + assert.Equal(t, 9090, loadedCfg.Metrics.Port, "CLI should override all") } -func TestConfigReadIntFile(t *testing.T) { - RegisterTestingT(t) +// TestConfigFile_SpecifiedByFlag tests that config file can be specified via --config flag +func TestConfigFile_SpecifiedByFlag(t *testing.T) { + // Create temporary config file + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "custom-config.yaml") + + configYAML := ` +app: + name: custom-app + version: 3.0.0 +server: + host: localhost + port: 8000 +metrics: + host: localhost + port: 8080 +health_check: + host: localhost + port: 8083 +database: + dialect: postgres +ocm: + base_url: https://api.integration.openshift.com +` + err := os.WriteFile(configFile, []byte(configYAML), 0o644) + require.NoError(t, err) + + // Create flag set with --config flag + flags := pflag.NewFlagSet("test", pflag.ContinueOnError) + cfg := NewApplicationConfig() + + // Configure flags (define and bind) + v := NewCommandConfig() + cfg.ConfigureFlags(v, flags) - intFile, err := createConfigFile("int", "123") - defer os.Remove(intFile.Name()) //nolint:errcheck - if err != nil { - log.Fatal(err) - } + err = flags.Parse([]string{"--config=" + configFile}) + require.NoError(t, err) - var intConfig int - err = readFileValueInt(intFile.Name(), &intConfig) - Expect(err).NotTo(HaveOccurred()) - Expect(intConfig).To(Equal(123)) + // Load config + loadedCfg, err := testLoadConfig(v, flags) + require.NoError(t, err) + + // Verify config was loaded from the specified file + assert.Equal(t, "custom-app", loadedCfg.App.Name) + assert.Equal(t, "3.0.0", loadedCfg.App.Version) } -func TestConfigReadBoolFile(t *testing.T) { - RegisterTestingT(t) +// TestConfigFile_SpecifiedByEnvVar tests that config file can be specified via HYPERFLEET_CONFIG env var +func TestConfigFile_SpecifiedByEnvVar(t *testing.T) { + // Create temporary config file + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "env-config.yaml") + + configYAML := ` +app: + name: env-config-app + version: 4.0.0 +server: + host: localhost + port: 8000 +metrics: + host: localhost + port: 8080 +health_check: + host: localhost + port: 8083 +database: + dialect: postgres +ocm: + base_url: https://api.integration.openshift.com +` + err := os.WriteFile(configFile, []byte(configYAML), 0o644) + require.NoError(t, err) + + // Set HYPERFLEET_CONFIG environment variable + os.Setenv("HYPERFLEET_CONFIG", configFile) + defer os.Unsetenv("HYPERFLEET_CONFIG") + + // Create flag set without --config flag + flags := pflag.NewFlagSet("test", pflag.ContinueOnError) + cfg := NewApplicationConfig() - boolFile, err := createConfigFile("bool", "true") - defer os.Remove(boolFile.Name()) //nolint:errcheck - if err != nil { - log.Fatal(err) - } + // Configure flags (define and bind) + v := NewCommandConfig() + cfg.ConfigureFlags(v, flags) - var boolConfig = false - err = readFileValueBool(boolFile.Name(), &boolConfig) - Expect(err).NotTo(HaveOccurred()) - Expect(boolConfig).To(Equal(true)) + err = flags.Parse([]string{}) + require.NoError(t, err) + + // Load config + loadedCfg, err := testLoadConfig(v, flags) + require.NoError(t, err) + + // Verify config was loaded from the env-specified file + assert.Equal(t, "env-config-app", loadedCfg.App.Name) + assert.Equal(t, "4.0.0", loadedCfg.App.Version) } -func TestConfigReadQuotedFile(t *testing.T) { - RegisterTestingT(t) +// TestConfigFile_FlagOverridesEnvVar tests that --config flag takes precedence over HYPERFLEET_CONFIG env var +func TestConfigFile_FlagOverridesEnvVar(t *testing.T) { + tmpDir := t.TempDir() + + // Create config file for env var + envConfigFile := filepath.Join(tmpDir, "env-config.yaml") + envConfigYAML := ` +app: + name: env-config + version: 1.0.0 +server: + host: localhost + port: 8000 +metrics: + host: localhost + port: 8080 +health_check: + host: localhost + port: 8083 +database: + dialect: postgres +ocm: + base_url: https://api.integration.openshift.com +` + err := os.WriteFile(envConfigFile, []byte(envConfigYAML), 0o644) + require.NoError(t, err) - stringFile, err := createConfigFile("string", "example") - defer os.Remove(stringFile.Name()) //nolint:errcheck - if err != nil { - log.Fatal(err) - } + // Create config file for flag + flagConfigFile := filepath.Join(tmpDir, "flag-config.yaml") + flagConfigYAML := ` +app: + name: flag-config + version: 2.0.0 +server: + host: localhost + port: 8000 +metrics: + host: localhost + port: 8080 +health_check: + host: localhost + port: 8083 +database: + dialect: postgres +ocm: + base_url: https://api.integration.openshift.com +` + err = os.WriteFile(flagConfigFile, []byte(flagConfigYAML), 0o644) + require.NoError(t, err) - quotedFileName := "\"" + stringFile.Name() + "\"" - val, err := ReadFile(quotedFileName) - Expect(err).NotTo(HaveOccurred()) - Expect(val).To(Equal("example")) + // Set env var to one file + os.Setenv("HYPERFLEET_CONFIG", envConfigFile) + defer os.Unsetenv("HYPERFLEET_CONFIG") + + // Create flag set with different config file + flags := pflag.NewFlagSet("test", pflag.ContinueOnError) + cfg := NewApplicationConfig() + + // Configure flags (define and bind) + v := NewCommandConfig() + cfg.ConfigureFlags(v, flags) + + err = flags.Parse([]string{"--config=" + flagConfigFile}) + require.NoError(t, err) + + // Load config + loadedCfg, err := testLoadConfig(v, flags) + require.NoError(t, err) + + // Flag should win over env var + assert.Equal(t, "flag-config", loadedCfg.App.Name, "--config flag should override HYPERFLEET_CONFIG env var") } -func createConfigFile(namePrefix, contents string) (*os.File, error) { - configFile, err := os.CreateTemp("", namePrefix) - if err != nil { - return nil, err - } - if _, err = configFile.Write([]byte(contents)); err != nil { - return configFile, err - } - err = configFile.Close() - return configFile, err + +// TestConfigPrecedence_DatabasePassword tests password precedence specifically +func TestConfigPrecedence_DatabasePassword(t *testing.T) { + // Create temporary config file + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.yaml") + + configYAML := ` +app: + name: test + version: 1.0.0 +server: + host: localhost + port: 8000 +metrics: + host: localhost + port: 8080 +health_check: + host: localhost + port: 8083 +database: + dialect: postgres + password: file-password +ocm: + base_url: https://api.integration.openshift.com +` + err := os.WriteFile(configFile, []byte(configYAML), 0o644) + require.NoError(t, err) + + // Set environment variable + os.Setenv("HYPERFLEET_DATABASE_PASSWORD", "env-password") + defer os.Unsetenv("HYPERFLEET_DATABASE_PASSWORD") + + // Create flag set + flags := pflag.NewFlagSet("test", pflag.ContinueOnError) + cfg := NewApplicationConfig() + + // Configure flags (define and bind) + v := NewCommandConfig() + cfg.ConfigureFlags(v, flags) + + err = flags.Parse([]string{ + "--config=" + configFile, + "--db-password=cli-password", + }) + require.NoError(t, err) + + // Load config + loadedCfg, err := testLoadConfig(v, flags) + require.NoError(t, err) + + // CLI password should win + assert.Equal(t, "cli-password", loadedCfg.Database.Password, "CLI password should override env and file") } diff --git a/pkg/config/db.go b/pkg/config/db.go index 65c95ae..3c377aa 100755 --- a/pkg/config/db.go +++ b/pkg/config/db.go @@ -4,26 +4,20 @@ import ( "fmt" "github.com/spf13/pflag" + "github.com/spf13/viper" ) type DatabaseConfig struct { - Dialect string `json:"dialect"` - SSLMode string `json:"sslmode"` - Debug bool `json:"debug"` - MaxOpenConnections int `json:"max_connections"` - - Host string `json:"host"` - Port int `json:"port"` - Name string `json:"name"` - Username string `json:"username"` - Password string `json:"password"` - - HostFile string `json:"host_file"` - PortFile string `json:"port_file"` - NameFile string `json:"name_file"` - UsernameFile string `json:"username_file"` - PasswordFile string `json:"password_file"` - RootCertFile string `json:"certificate_file"` + Dialect string `mapstructure:"dialect" json:"dialect" validate:"required"` + Host string `mapstructure:"host" json:"host" validate:""` + Port int `mapstructure:"port" json:"port" validate:"min=0,max=65535"` + Name string `mapstructure:"name" json:"name" validate:""` + Username string `mapstructure:"username" json:"username" validate:""` + Password string `mapstructure:"password" json:"password" validate:""` + SSLMode string `mapstructure:"sslmode" json:"sslmode" validate:"oneof=disable require verify-ca verify-full"` + RootCertFile string `mapstructure:"rootcert_file" json:"rootcert_file" validate:""` + Debug bool `mapstructure:"debug" json:"debug"` + MaxOpenConnections int `mapstructure:"max_open_connections" json:"max_open_connections" validate:"min=1"` } func NewDatabaseConfig() *DatabaseConfig { @@ -32,57 +26,31 @@ func NewDatabaseConfig() *DatabaseConfig { SSLMode: "disable", Debug: false, MaxOpenConnections: 50, - - HostFile: "secrets/db.host", - PortFile: "secrets/db.port", - NameFile: "secrets/db.name", - UsernameFile: "secrets/db.user", - PasswordFile: "secrets/db.password", - RootCertFile: "secrets/db.rootcert", } } -func (c *DatabaseConfig) AddFlags(fs *pflag.FlagSet) { - fs.StringVar(&c.HostFile, "db-host-file", c.HostFile, "Database host string file") - fs.StringVar(&c.PortFile, "db-port-file", c.PortFile, "Database port file") - fs.StringVar(&c.UsernameFile, "db-user-file", c.UsernameFile, "Database username file") - fs.StringVar(&c.PasswordFile, "db-password-file", c.PasswordFile, "Database password file") - fs.StringVar(&c.NameFile, "db-name-file", c.NameFile, "Database name file") - fs.StringVar(&c.RootCertFile, "db-rootcert", c.RootCertFile, "Database root certificate file") - fs.StringVar(&c.SSLMode, "db-sslmode", c.SSLMode, "Database ssl mode (disable | require | verify-ca | verify-full)") - fs.BoolVar(&c.Debug, "enable-db-debug", c.Debug, " framework's debug mode") - fs.IntVar(&c.MaxOpenConnections, "db-max-open-connections", c.MaxOpenConnections, "Maximum open DB connections for this instance") -} - -func (c *DatabaseConfig) ReadFiles() error { - err := readFileValueString(c.HostFile, &c.Host) - if err != nil { - return err - } - - err = readFileValueInt(c.PortFile, &c.Port) - if err != nil { - return err - } - - err = readFileValueString(c.UsernameFile, &c.Username) - if err != nil { - return err - } - - err = readFileValueString(c.PasswordFile, &c.Password) - if err != nil { - return err - } - - err = readFileValueString(c.NameFile, &c.Name) - return err +// defineAndBindFlags defines database flags and binds them to viper keys in a single pass +func (c *DatabaseConfig) defineAndBindFlags(v *viper.Viper, fs *pflag.FlagSet) { + // Direct database connection parameters + defineAndBindStringFlag(v, fs, "database.host", "db-host", "", c.Host, "Database host") + defineAndBindIntFlag(v, fs, "database.port", "db-port", "", c.Port, "Database port") + defineAndBindStringFlag(v, fs, "database.username", "db-username", "u", c.Username, "Database username") + defineAndBindStringFlag(v, fs, "database.password", "db-password", "", c.Password, "Database password (prefer using env var)") + defineAndBindStringFlag(v, fs, "database.name", "db-name", "d", c.Name, "Database name") + + // Connection options + defineAndBindStringFlag(v, fs, "database.rootcert_file", "db-rootcert", "", c.RootCertFile, "Database root certificate file") + defineAndBindStringFlag(v, fs, "database.sslmode", "db-sslmode", "", c.SSLMode, "Database SSL mode (disable | require | verify-ca | verify-full)") + defineAndBindBoolFlag(v, fs, "database.debug", "db-debug", "", c.Debug, "Enable database debug mode") + defineAndBindIntFlag(v, fs, "database.max_open_connections", "db-max-open-connections", "", c.MaxOpenConnections, "Maximum open DB connections") } +// ConnectionString returns the database connection string func (c *DatabaseConfig) ConnectionString(withSSL bool) string { return c.ConnectionStringWithName(c.Name, withSSL) } +// ConnectionStringWithName returns the database connection string with a specific database name func (c *DatabaseConfig) ConnectionStringWithName(name string, withSSL bool) string { var cmd string if withSSL { @@ -100,20 +68,21 @@ func (c *DatabaseConfig) ConnectionStringWithName(name string, withSSL bool) str return cmd } +// LogSafeConnectionString returns a connection string with sensitive data redacted func (c *DatabaseConfig) LogSafeConnectionString(withSSL bool) string { return c.LogSafeConnectionStringWithName(c.Name, withSSL) } +// LogSafeConnectionStringWithName returns a connection string with sensitive data redacted func (c *DatabaseConfig) LogSafeConnectionStringWithName(name string, withSSL bool) string { if withSSL { return fmt.Sprintf( "host=%s port=%d user=%s password='' dbname=%s sslmode=%s sslrootcert=''", c.Host, c.Port, c.Username, name, c.SSLMode, ) - } else { - return fmt.Sprintf( - "host=%s port=%d user=%s password='' dbname=%s", - c.Host, c.Port, c.Username, name, - ) } + return fmt.Sprintf( + "host=%s port=%d user=%s password='' dbname=%s", + c.Host, c.Port, c.Username, name, + ) } diff --git a/pkg/config/health_check.go b/pkg/config/health_check.go index e3aaa79..a1f9182 100755 --- a/pkg/config/health_check.go +++ b/pkg/config/health_check.go @@ -1,26 +1,41 @@ package config import ( + "fmt" + "github.com/spf13/pflag" + "github.com/spf13/viper" ) type HealthCheckConfig struct { - BindAddress string `json:"bind_address"` - EnableHTTPS bool `json:"enable_https"` + Host string `mapstructure:"host" json:"host" validate:""` + Port int `mapstructure:"port" json:"port" validate:"min=1,max=65535"` + EnableHTTPS bool `mapstructure:"enable_https" json:"enable_https"` + + // Legacy field for backward compatibility + BindAddress string `mapstructure:"bind_address" json:"bind_address,omitempty"` } func NewHealthCheckConfig() *HealthCheckConfig { return &HealthCheckConfig{ - BindAddress: "localhost:8083", + Host: "localhost", + Port: 8083, EnableHTTPS: false, + BindAddress: "localhost:8083", } } -func (c *HealthCheckConfig) AddFlags(fs *pflag.FlagSet) { - fs.StringVar(&c.BindAddress, "health-check-server-bindaddress", c.BindAddress, "Health check server bind adddress") - fs.BoolVar(&c.EnableHTTPS, "enable-health-check-https", c.EnableHTTPS, "Enable HTTPS for health check server") +// defineAndBindFlags defines & binds flags to viper keys in a single pass +func (c *HealthCheckConfig) defineAndBindFlags(v *viper.Viper, fs *pflag.FlagSet) { + defineAndBindStringFlag(v, fs, "health_check.host", "health-check-host", "", c.Host, "Health check server bind host") + defineAndBindIntFlag(v, fs, "health_check.port", "health-check-port", "", c.Port, "Health check server bind port") + defineAndBindBoolFlag(v, fs, "health_check.enable_https", "health-check-https-enabled", "", c.EnableHTTPS, "Enable HTTPS for health check server") } -func (c *HealthCheckConfig) ReadFiles() error { - return nil +// GetBindAddress returns the bind address in host:port format +func (c *HealthCheckConfig) GetBindAddress() string { + if c.BindAddress != "" { + return c.BindAddress + } + return fmt.Sprintf("%s:%d", c.Host, c.Port) } diff --git a/pkg/config/metrics.go b/pkg/config/metrics.go index b01c3c3..a742446 100755 --- a/pkg/config/metrics.go +++ b/pkg/config/metrics.go @@ -1,31 +1,46 @@ package config import ( + "fmt" "time" "github.com/spf13/pflag" + "github.com/spf13/viper" ) type MetricsConfig struct { - BindAddress string `json:"bind_address"` - EnableHTTPS bool `json:"enable_https"` - LabelMetricsInclusionDuration time.Duration `json:"label_metrics_inclusion_duration"` + Host string `mapstructure:"host" json:"host" validate:""` + Port int `mapstructure:"port" json:"port" validate:"min=1,max=65535"` + EnableHTTPS bool `mapstructure:"enable_https" json:"enable_https"` + LabelMetricsInclusionDuration time.Duration `mapstructure:"label_metrics_inclusion_duration" json:"label_metrics_inclusion_duration"` + + // Legacy field for backward compatibility + BindAddress string `mapstructure:"bind_address" json:"bind_address,omitempty"` } func NewMetricsConfig() *MetricsConfig { return &MetricsConfig{ - BindAddress: "localhost:8080", + Host: "localhost", + Port: 8080, EnableHTTPS: false, LabelMetricsInclusionDuration: 7 * 24 * time.Hour, + BindAddress: "localhost:8080", } } -func (s *MetricsConfig) AddFlags(fs *pflag.FlagSet) { - fs.StringVar(&s.BindAddress, "metrics-server-bindaddress", s.BindAddress, "Metrics server bind adddress") - fs.BoolVar(&s.EnableHTTPS, "enable-metrics-https", s.EnableHTTPS, "Enable HTTPS for metrics server") - fs.DurationVar(&s.LabelMetricsInclusionDuration, "label-metrics-inclusion-duration", 7*24*time.Hour, "A cluster's last telemetry date needs be within in this duration in order to have labels collected") +// defineAndBindFlags defines & binds flags to viper keys in a single pass +func (s *MetricsConfig) defineAndBindFlags(v *viper.Viper, fs *pflag.FlagSet) { + defineAndBindStringFlag(v, fs, "metrics.host", "metrics-host", "", s.Host, "Metrics server bind host") + defineAndBindIntFlag(v, fs, "metrics.port", "metrics-port", "", s.Port, "Metrics server bind port") + defineAndBindBoolFlag(v, fs, "metrics.enable_https", "metrics-https-enabled", "", s.EnableHTTPS, "Enable HTTPS for metrics server") + defineAndBindDurationFlag(v, fs, "metrics.label_metrics_inclusion_duration", "metrics-label-inclusion-duration", "", s.LabelMetricsInclusionDuration, + "A cluster's last telemetry date needs to be within this duration to have labels collected") } -func (s *MetricsConfig) ReadFiles() error { - return nil +// GetBindAddress returns the bind address in host:port format +func (s *MetricsConfig) GetBindAddress() string { + if s.BindAddress != "" { + return s.BindAddress + } + return fmt.Sprintf("%s:%d", s.Host, s.Port) } diff --git a/pkg/config/migrate_config.go b/pkg/config/migrate_config.go new file mode 100644 index 0000000..e8af416 --- /dev/null +++ b/pkg/config/migrate_config.go @@ -0,0 +1,155 @@ +package config + +import ( + "encoding/json" + "flag" + "fmt" + "reflect" + + "github.com/go-playground/validator/v10" + "github.com/golang/glog" + "github.com/spf13/pflag" + "github.com/spf13/viper" +) + +// MigrateConfig holds configuration for the migrate command +// Only requires App and Database configuration +type MigrateConfig struct { + App *AppConfig `mapstructure:"app" json:"app" validate:"required"` + Database *DatabaseConfig `mapstructure:"database" json:"database" validate:"required"` +} + +// NewMigrateConfig creates a new MigrateConfig with default values +func NewMigrateConfig() *MigrateConfig { + return &MigrateConfig{ + App: NewAppConfig(), + Database: NewDatabaseConfig(), + } +} + +// defineAndBindFlags defines migrate command flags and binds them to viper keys in a single pass +func (c *MigrateConfig) defineAndBindFlags(v *viper.Viper, flagset *pflag.FlagSet) { + // Global flags + // Note: config flag is defined but NOT bound to viper (special case) + flagset.String("config", "", "Config file path") + + // Define and bind sub-config flags (only App and Database for migrate) + c.App.defineAndBindFlags(v, flagset) + c.Database.defineAndBindFlags(v, flagset) +} + +// ConfigureFlags defines configuration flags and binds them to viper for precedence handling +func (c *MigrateConfig) ConfigureFlags(v *viper.Viper, flagset *pflag.FlagSet) { + flagset.AddGoFlagSet(flag.CommandLine) + c.defineAndBindFlags(v, flagset) +} + +// LoadMigrateConfig loads configuration for the migrate command from multiple sources with proper precedence: +// 1. Command-line flags (highest priority) +// 2. Environment variables (HYPERFLEET_ prefix) +// 3. Configuration files +// 4. Defaults (lowest priority) +// +// The viper instance should already be configured and have flags bound via ConfigureFlags() +func LoadMigrateConfig(v *viper.Viper, flags *pflag.FlagSet) (*MigrateConfig, error) { + // Create config instance + // Note: Viper is already configured with env support and flags are already bound + config := NewMigrateConfig() + + // Determine config file path + configFile := getConfigFilePath(flags, v) + + // Load config file if it exists + if configFile != "" { + v.SetConfigFile(configFile) + if err := v.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + return nil, fmt.Errorf("error reading config file %s: %w", configFile, err) + } + glog.Infof("Config file not found: %s, continuing with flags and environment variables", configFile) + } else { + glog.Infof("Loaded configuration from: %s", configFile) + } + } + + // Unmarshal into config struct + // Viper now contains: config file values < env vars < bound flags + // This gives us the correct precedence automatically + // Note: Using Unmarshal (not UnmarshalExact) to allow extra fields in config file + // (e.g., shared config files with server, metrics, etc. will be ignored) + if err := v.Unmarshal(config); err != nil { + return nil, fmt.Errorf("error unmarshaling config: %w", err) + } + + // Validate configuration + if err := config.Validate(); err != nil { + return nil, err + } + + return config, nil +} + +// Validate validates the migrate configuration using struct tags +// Only App and Database are required for migrate command +func (c *MigrateConfig) Validate() error { + validate := validator.New() + + if err := validate.Struct(c); err != nil { + return formatValidationError(err) + } + + return nil +} + +// DisplayConfig logs the merged configuration at startup +// Sensitive values are redacted +func (c *MigrateConfig) DisplayConfig() { + glog.Info("=== Merged Configuration (Migrate Command) ===") + + // Create a copy for display with sensitive values redacted + displayCopy := c.redactSensitiveValues() + + // Convert to JSON for pretty display + jsonBytes, err := json.MarshalIndent(displayCopy, "", " ") + if err != nil { + glog.Errorf("Error marshaling config for display: %v", err) + return + } + + glog.Infof("\n%s", string(jsonBytes)) + glog.Info("===============================================") +} + +// GetJSONConfig returns the configuration as a JSON string +// Sensitive values are redacted +func (c *MigrateConfig) GetJSONConfig() (string, error) { + displayCopy := c.redactSensitiveValues() + + jsonBytes, err := json.MarshalIndent(displayCopy, "", " ") + if err != nil { + return "", fmt.Errorf("error marshaling config to JSON: %w", err) + } + + return string(jsonBytes), nil +} + +// redactSensitiveValues creates a copy of the config with sensitive values redacted +func (c *MigrateConfig) redactSensitiveValues() *MigrateConfig { + // Marshal to JSON and back to create a deep copy + jsonBytes, err := json.Marshal(c) + if err != nil { + glog.Errorf("Error marshaling config for redaction: %v", err) + return c + } + + var copy MigrateConfig + if err := json.Unmarshal(jsonBytes, ©); err != nil { + glog.Errorf("Error unmarshaling config for redaction: %v", err) + return c + } + + // Recursively redact sensitive fields using the shared function from config.go + redactSensitiveFields(reflect.ValueOf(©).Elem()) + + return © +} diff --git a/pkg/config/migrate_config_test.go b/pkg/config/migrate_config_test.go new file mode 100644 index 0000000..e209cf7 --- /dev/null +++ b/pkg/config/migrate_config_test.go @@ -0,0 +1,301 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/spf13/pflag" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestMigrateConfig_MinimalRequiredConfig tests that migrate only requires App and Database +func TestMigrateConfig_MinimalRequiredConfig(t *testing.T) { + // Create minimal config file with only required sections + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.yaml") + + configYAML := ` +app: + name: migrate-test + version: 1.0.0 +database: + dialect: postgres + host: localhost + port: 5432 + name: hyperfleet + username: hyperfleet + password: secret +` + err := os.WriteFile(configFile, []byte(configYAML), 0o644) + require.NoError(t, err) + + // Create flag set + flags := pflag.NewFlagSet("test", pflag.ContinueOnError) + cfg := NewMigrateConfig() + + // Create viper and configure flags + v := NewCommandConfig() + cfg.ConfigureFlags(v, flags) + + err = flags.Parse([]string{"--config=" + configFile}) + require.NoError(t, err) + + // Load config + loadedCfg, err := LoadMigrateConfig(v, flags) + require.NoError(t, err) + + // Verify required fields loaded + assert.Equal(t, "migrate-test", loadedCfg.App.Name) + assert.Equal(t, "postgres", loadedCfg.Database.Dialect) + assert.Equal(t, "localhost", loadedCfg.Database.Host) +} + +// TestMigrateConfig_IgnoresExtraConfigSections tests that migrate ignores extra config sections +// This allows using shared config files with serve (backward compatibility) +func TestMigrateConfig_IgnoresExtraConfigSections(t *testing.T) { + // Create full config file (as used by serve command) + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.yaml") + + configYAML := ` +app: + name: full-config-test + version: 1.0.0 +server: + host: localhost + port: 8000 +metrics: + host: localhost + port: 8080 +health_check: + host: localhost + port: 8083 +database: + dialect: postgres + host: localhost + port: 5432 + name: hyperfleet + username: hyperfleet + password: secret +ocm: + base_url: https://api.integration.openshift.com +` + err := os.WriteFile(configFile, []byte(configYAML), 0o644) + require.NoError(t, err) + + // Create flag set + flags := pflag.NewFlagSet("test", pflag.ContinueOnError) + cfg := NewMigrateConfig() + + // Create viper and configure flags + v := NewCommandConfig() + cfg.ConfigureFlags(v, flags) + + err = flags.Parse([]string{"--config=" + configFile}) + require.NoError(t, err) + + // Load config - should not fail even with extra sections (they're ignored) + loadedCfg, err := LoadMigrateConfig(v, flags) + require.NoError(t, err) + + // Verify required fields loaded correctly + assert.Equal(t, "full-config-test", loadedCfg.App.Name) + assert.Equal(t, "postgres", loadedCfg.Database.Dialect) + assert.Equal(t, "localhost", loadedCfg.Database.Host) + + // Extra sections (server, metrics, health_check, ocm) are ignored + // MigrateConfig only has App and Database fields +} + +// TestMigrateConfig_Precedence tests command-line flags override environment variables +func TestMigrateConfig_Precedence(t *testing.T) { + // Set environment variable + os.Setenv("HYPERFLEET_DATABASE_HOST", "env-host") + defer os.Unsetenv("HYPERFLEET_DATABASE_HOST") + + // Create minimal config file + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.yaml") + + configYAML := ` +app: + name: precedence-test + version: 1.0.0 +database: + dialect: postgres + host: file-host + port: 5432 + name: hyperfleet + username: hyperfleet + password: secret +` + err := os.WriteFile(configFile, []byte(configYAML), 0o644) + require.NoError(t, err) + + // Create flag set with command-line value + flags := pflag.NewFlagSet("test", pflag.ContinueOnError) + cfg := NewMigrateConfig() + + // Create viper and configure flags + v := NewCommandConfig() + cfg.ConfigureFlags(v, flags) + + err = flags.Parse([]string{ + "--config=" + configFile, + "--db-host=cli-host", + }) + require.NoError(t, err) + + // Load config + loadedCfg, err := LoadMigrateConfig(v, flags) + require.NoError(t, err) + + // CLI should win + assert.Equal(t, "cli-host", loadedCfg.Database.Host, "CLI flag should override env and file") +} + +// TestMigrateConfig_EnvVarOverridesFile tests environment variable precedence +func TestMigrateConfig_EnvVarOverridesFile(t *testing.T) { + // Create minimal config file + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.yaml") + + configYAML := ` +app: + name: file-name + version: 1.0.0 +database: + dialect: postgres + host: localhost + port: 5432 + name: hyperfleet + username: hyperfleet + password: file-password +` + err := os.WriteFile(configFile, []byte(configYAML), 0o644) + require.NoError(t, err) + + // Set environment variable + os.Setenv("HYPERFLEET_DATABASE_PASSWORD", "env-password") + defer os.Unsetenv("HYPERFLEET_DATABASE_PASSWORD") + + // Create flag set + flags := pflag.NewFlagSet("test", pflag.ContinueOnError) + cfg := NewMigrateConfig() + + // Create viper and configure flags + v := NewCommandConfig() + cfg.ConfigureFlags(v, flags) + + err = flags.Parse([]string{"--config=" + configFile}) + require.NoError(t, err) + + // Load config + loadedCfg, err := LoadMigrateConfig(v, flags) + require.NoError(t, err) + + // Env var should override file + assert.Equal(t, "env-password", loadedCfg.Database.Password, "Environment variable should override config file") +} + +// TestMigrateConfig_ValidatesRequiredDatabaseDialect tests that database dialect is validated +func TestMigrateConfig_ValidatesRequiredDatabaseDialect(t *testing.T) { + // Create config file with database section but missing dialect + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.yaml") + + configYAML := ` +app: + name: incomplete-test + version: 1.0.0 +database: + dialect: "" + host: localhost + port: 5432 +` + err := os.WriteFile(configFile, []byte(configYAML), 0o644) + require.NoError(t, err) + + // Create flag set + flags := pflag.NewFlagSet("test", pflag.ContinueOnError) + cfg := NewMigrateConfig() + + // Create viper and configure flags + v := NewCommandConfig() + cfg.ConfigureFlags(v, flags) + + err = flags.Parse([]string{"--config=" + configFile}) + require.NoError(t, err) + + // Load config - should fail validation due to empty dialect + _, err = LoadMigrateConfig(v, flags) + require.Error(t, err, "Should fail when database dialect is empty") + assert.Contains(t, err.Error(), "Dialect") +} + +// TestMigrateConfig_FlagsOnly tests loading from flags without config file +func TestMigrateConfig_FlagsOnly(t *testing.T) { + // Create flag set with all required flags + // Note: dialect doesn't have a flag, it uses the default value "postgres" + flags := pflag.NewFlagSet("test", pflag.ContinueOnError) + cfg := NewMigrateConfig() + + // Create viper and configure flags + v := NewCommandConfig() + cfg.ConfigureFlags(v, flags) + + err := flags.Parse([]string{ + "--name=flags-only-test", + "--version=2.0.0", + "--db-host=localhost", + "--db-port=5432", + "--db-name=hyperfleet", + "--db-username=admin", + "--db-password=secret123", + }) + require.NoError(t, err) + + // Load config + loadedCfg, err := LoadMigrateConfig(v, flags) + require.NoError(t, err) + + // Verify config loaded from flags + assert.Equal(t, "flags-only-test", loadedCfg.App.Name) + assert.Equal(t, "postgres", loadedCfg.Database.Dialect) // Uses default value + assert.Equal(t, "localhost", loadedCfg.Database.Host) + assert.Equal(t, "admin", loadedCfg.Database.Username) +} + +// TestMigrateConfig_DisplayConfig tests that DisplayConfig doesn't panic +func TestMigrateConfig_DisplayConfig(t *testing.T) { + // Create minimal config + cfg := NewMigrateConfig() + cfg.App.Name = "display-test" + cfg.Database.Dialect = "postgres" + cfg.Database.Password = "secret-password" + + // Should not panic + assert.NotPanics(t, func() { + cfg.DisplayConfig() + }) +} + +// TestMigrateConfig_GetJSONConfig tests JSON output with redaction +func TestMigrateConfig_GetJSONConfig(t *testing.T) { + // Create minimal config with sensitive data + cfg := NewMigrateConfig() + cfg.App.Name = "json-test" + cfg.Database.Dialect = "postgres" + cfg.Database.Password = "super-secret-password" + + // Get JSON + jsonStr, err := cfg.GetJSONConfig() + require.NoError(t, err) + + // Verify JSON contains app name but password is redacted + assert.Contains(t, jsonStr, "json-test") + assert.NotContains(t, jsonStr, "super-secret-password") + assert.Contains(t, jsonStr, "***") // Redacted password +} diff --git a/pkg/config/ocm.go b/pkg/config/ocm.go index 1f7c6ab..823f1e3 100755 --- a/pkg/config/ocm.go +++ b/pkg/config/ocm.go @@ -2,55 +2,38 @@ package config import ( "github.com/spf13/pflag" + "github.com/spf13/viper" ) type OCMConfig struct { - BaseURL string `json:"base_url"` - ClientID string `json:"client-id"` - ClientIDFile string `json:"client-id_file"` - ClientSecret string `json:"client-secret"` - ClientSecretFile string `json:"client-secret_file"` - SelfToken string `json:"self_token"` - SelfTokenFile string `json:"self_token_file"` - TokenURL string `json:"token_url"` - Debug bool `json:"debug"` - EnableMock bool `json:"enable_mock"` + BaseURL string `mapstructure:"base_url" json:"base_url" validate:""` + ClientID string `mapstructure:"client_id" json:"client_id" validate:""` + ClientSecret string `mapstructure:"client_secret" json:"client_secret" validate:""` + SelfToken string `mapstructure:"self_token" json:"self_token" validate:""` + TokenURL string `mapstructure:"token_url" json:"token_url" validate:""` + Debug bool `mapstructure:"debug" json:"debug"` + EnableMock bool `mapstructure:"enable_mock" json:"enable_mock"` } func NewOCMConfig() *OCMConfig { return &OCMConfig{ - BaseURL: "https://api.integration.openshift.com", - TokenURL: "https://sso.redhat.com/auth/realms/redhat-external/protocol/openid-connect/token", - ClientIDFile: "secrets/ocm-service.clientId", - ClientSecretFile: "secrets/ocm-service.clientSecret", - SelfTokenFile: "", - Debug: false, - EnableMock: true, + BaseURL: "https://api.integration.openshift.com", + TokenURL: "https://sso.redhat.com/auth/realms/redhat-external/protocol/openid-connect/token", + Debug: false, + EnableMock: true, } } -func (c *OCMConfig) AddFlags(fs *pflag.FlagSet) { - fs.StringVar(&c.ClientIDFile, "ocm-client-id-file", c.ClientIDFile, "File containing OCM API privileged account client-id") - fs.StringVar(&c.ClientSecretFile, "ocm-client-secret-file", c.ClientSecretFile, "File containing OCM API privileged account client-secret") - fs.StringVar(&c.SelfTokenFile, "self-token-file", c.SelfTokenFile, "File containing OCM API privileged offline SSO token") - fs.StringVar(&c.BaseURL, "ocm-base-url", c.BaseURL, "The base URL of the OCM API, integration by default") - fs.StringVar(&c.TokenURL, "ocm-token-url", c.TokenURL, "The base URL that OCM uses to request tokens, stage by default") - fs.BoolVar(&c.Debug, "ocm-debug", c.Debug, "Debug flag for OCM API") - fs.BoolVar(&c.EnableMock, "enable-ocm-mock", c.EnableMock, "Enable mock ocm clients") -} +// defineAndBindFlags defines & binds flags to viper keys in a single pass +func (c *OCMConfig) defineAndBindFlags(v *viper.Viper, fs *pflag.FlagSet) { + // OCM connection parameters + defineAndBindStringFlag(v, fs, "ocm.base_url", "ocm-base-url", "", c.BaseURL, "OCM API base URL") + defineAndBindStringFlag(v, fs, "ocm.token_url", "ocm-token-url", "", c.TokenURL, "OCM token URL") + defineAndBindStringFlag(v, fs, "ocm.client_id", "ocm-client-id", "", c.ClientID, "OCM client ID") + defineAndBindStringFlag(v, fs, "ocm.client_secret", "ocm-client-secret", "", c.ClientSecret, "OCM client secret (prefer using env var)") + defineAndBindStringFlag(v, fs, "ocm.self_token", "ocm-self-token", "", c.SelfToken, "OCM self token (prefer using env var)") -func (c *OCMConfig) ReadFiles() error { - if c.EnableMock { - return nil - } - err := readFileValueString(c.ClientIDFile, &c.ClientID) - if err != nil { - return err - } - err = readFileValueString(c.ClientSecretFile, &c.ClientSecret) - if err != nil { - return err - } - err = readFileValueString(c.SelfTokenFile, &c.SelfToken) - return err + // Options + defineAndBindBoolFlag(v, fs, "ocm.debug", "ocm-debug", "", c.Debug, "Enable OCM debug mode") + defineAndBindBoolFlag(v, fs, "ocm.enable_mock", "ocm-mock", "", c.EnableMock, "Enable mock OCM client") } diff --git a/pkg/config/serve_config.go b/pkg/config/serve_config.go new file mode 100644 index 0000000..926a4ea --- /dev/null +++ b/pkg/config/serve_config.go @@ -0,0 +1,178 @@ +package config + +import ( + "encoding/json" + "flag" + "fmt" + "reflect" + + "github.com/go-playground/validator/v10" + "github.com/golang/glog" + "github.com/spf13/pflag" + "github.com/spf13/viper" +) + +// ServeConfig holds configuration for the serve command +// Requires all configuration sections (App, Server, Metrics, HealthCheck, Database, OCM) +type ServeConfig struct { + App *AppConfig `mapstructure:"app" json:"app" validate:"required"` + Server *ServerConfig `mapstructure:"server" json:"server" validate:"required"` + Metrics *MetricsConfig `mapstructure:"metrics" json:"metrics" validate:"required"` + HealthCheck *HealthCheckConfig `mapstructure:"health_check" json:"health_check" validate:"required"` + Database *DatabaseConfig `mapstructure:"database" json:"database" validate:"required"` + OCM *OCMConfig `mapstructure:"ocm" json:"ocm" validate:"required"` +} + +// NewServeConfig creates a new ServeConfig with default values +func NewServeConfig() *ServeConfig { + return &ServeConfig{ + App: NewAppConfig(), + Server: NewServerConfig(), + Metrics: NewMetricsConfig(), + HealthCheck: NewHealthCheckConfig(), + Database: NewDatabaseConfig(), + OCM: NewOCMConfig(), + } +} + +// defineAndBindFlags defines serve command flags and binds them to viper keys in a single pass +func (c *ServeConfig) defineAndBindFlags(v *viper.Viper, flagset *pflag.FlagSet) { + // Global flags + // Note: config flag is defined but NOT bound to viper (special case) + flagset.String("config", "", "Config file path") + + // Define and bind sub-config flags (all required for serve) + c.App.defineAndBindFlags(v, flagset) + c.Server.defineAndBindFlags(v, flagset) + c.Metrics.defineAndBindFlags(v, flagset) + c.HealthCheck.defineAndBindFlags(v, flagset) + c.Database.defineAndBindFlags(v, flagset) + c.OCM.defineAndBindFlags(v, flagset) +} + +// ConfigureFlags defines configuration flags and binds them to viper for precedence handling +func (c *ServeConfig) ConfigureFlags(v *viper.Viper, flagset *pflag.FlagSet) { + flagset.AddGoFlagSet(flag.CommandLine) + c.defineAndBindFlags(v, flagset) +} + +// LoadServeConfig loads configuration for the serve command from multiple sources with proper precedence: +// 1. Command-line flags (highest priority) +// 2. Environment variables (HYPERFLEET_ prefix) +// 3. Configuration files +// 4. Defaults (lowest priority) +// +// The viper instance should already be configured and have flags bound via ConfigureFlags() +func LoadServeConfig(v *viper.Viper, flags *pflag.FlagSet) (*ServeConfig, error) { + // Create config instance + // Note: Viper is already configured with env support and flags are already bound + config := NewServeConfig() + + // Determine config file path + configFile := getConfigFilePath(flags, v) + + // Load config file if it exists + if configFile != "" { + v.SetConfigFile(configFile) + if err := v.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + return nil, fmt.Errorf("error reading config file %s: %w", configFile, err) + } + glog.Infof("Config file not found: %s, continuing with flags and environment variables", configFile) + } else { + glog.Infof("Loaded configuration from: %s", configFile) + } + } + + // Unmarshal into config struct + // Viper now contains: config file values < env vars < bound flags + // This gives us the correct precedence automatically + if err := v.UnmarshalExact(config); err != nil { + return nil, fmt.Errorf("error unmarshaling config: %w", err) + } + + // Validate configuration + if err := config.Validate(); err != nil { + return nil, err + } + + return config, nil +} + +// Validate validates the serve configuration using struct tags +// All sections (App, Server, Metrics, HealthCheck, Database, OCM) are required for serve command +func (c *ServeConfig) Validate() error { + validate := validator.New() + + if err := validate.Struct(c); err != nil { + return formatValidationError(err) + } + + return nil +} + +// DisplayConfig logs the merged configuration at startup +// Sensitive values are redacted +func (c *ServeConfig) DisplayConfig() { + glog.Info("=== Merged Configuration (Serve Command) ===") + + // Create a copy for display with sensitive values redacted + displayCopy := c.redactSensitiveValues() + + // Convert to JSON for pretty display + jsonBytes, err := json.MarshalIndent(displayCopy, "", " ") + if err != nil { + glog.Errorf("Error marshaling config for display: %v", err) + return + } + + glog.Infof("\n%s", string(jsonBytes)) + glog.Info("============================================") +} + +// GetJSONConfig returns the configuration as a JSON string +// Sensitive values are redacted +func (c *ServeConfig) GetJSONConfig() (string, error) { + displayCopy := c.redactSensitiveValues() + + jsonBytes, err := json.MarshalIndent(displayCopy, "", " ") + if err != nil { + return "", fmt.Errorf("error marshaling config to JSON: %w", err) + } + + return string(jsonBytes), nil +} + +// redactSensitiveValues creates a copy of the config with sensitive values redacted +func (c *ServeConfig) redactSensitiveValues() *ServeConfig { + // Marshal to JSON and back to create a deep copy + jsonBytes, err := json.Marshal(c) + if err != nil { + glog.Errorf("Error marshaling config for redaction: %v", err) + return c + } + + var copy ServeConfig + if err := json.Unmarshal(jsonBytes, ©); err != nil { + glog.Errorf("Error unmarshaling config for redaction: %v", err) + return c + } + + // Recursively redact sensitive fields using the shared function from config.go + redactSensitiveFields(reflect.ValueOf(©).Elem()) + + return © +} + +// ToApplicationConfig converts ServeConfig to ApplicationConfig for compatibility +// with existing code (e.g., environments.Initialize()) +func (c *ServeConfig) ToApplicationConfig() *ApplicationConfig { + return &ApplicationConfig{ + App: c.App, + Server: c.Server, + Metrics: c.Metrics, + HealthCheck: c.HealthCheck, + Database: c.Database, + OCM: c.OCM, + } +} diff --git a/pkg/config/serve_config_test.go b/pkg/config/serve_config_test.go new file mode 100644 index 0000000..aa2caf6 --- /dev/null +++ b/pkg/config/serve_config_test.go @@ -0,0 +1,305 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/spf13/pflag" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestServeConfig_FullConfig tests that serve requires all configuration sections +func TestServeConfig_FullConfig(t *testing.T) { + // Create full config file with all required sections + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.yaml") + + configYAML := ` +app: + name: serve-test + version: 1.0.0 +server: + host: localhost + port: 8000 +metrics: + host: localhost + port: 8080 +health_check: + host: localhost + port: 8083 +database: + dialect: postgres + host: localhost + port: 5432 + name: hyperfleet + username: hyperfleet + password: secret +ocm: + base_url: https://api.integration.openshift.com +` + err := os.WriteFile(configFile, []byte(configYAML), 0o644) + require.NoError(t, err) + + // Create flag set + flags := pflag.NewFlagSet("test", pflag.ContinueOnError) + cfg := NewServeConfig() + + // Create viper and configure flags + v := NewCommandConfig() + cfg.ConfigureFlags(v, flags) + + err = flags.Parse([]string{"--config=" + configFile}) + require.NoError(t, err) + + // Load config + loadedCfg, err := LoadServeConfig(v, flags) + require.NoError(t, err) + + // Verify all fields loaded + assert.Equal(t, "serve-test", loadedCfg.App.Name) + assert.Equal(t, 8000, loadedCfg.Server.Port) + assert.Equal(t, 8080, loadedCfg.Metrics.Port) + assert.Equal(t, 8083, loadedCfg.HealthCheck.Port) + assert.Equal(t, "postgres", loadedCfg.Database.Dialect) + assert.Equal(t, "https://api.integration.openshift.com", loadedCfg.OCM.BaseURL) +} + +// TestServeConfig_Precedence tests command-line flags override environment variables +func TestServeConfig_Precedence(t *testing.T) { + // Set environment variables + os.Setenv("HYPERFLEET_SERVER_PORT", "9999") + defer os.Unsetenv("HYPERFLEET_SERVER_PORT") + + // Create full config file + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.yaml") + + configYAML := ` +app: + name: precedence-test + version: 1.0.0 +server: + host: localhost + port: 7000 +metrics: + host: localhost + port: 8080 +health_check: + host: localhost + port: 8083 +database: + dialect: postgres +ocm: + base_url: https://api.integration.openshift.com +` + err := os.WriteFile(configFile, []byte(configYAML), 0o644) + require.NoError(t, err) + + // Create flag set with command-line value + flags := pflag.NewFlagSet("test", pflag.ContinueOnError) + cfg := NewServeConfig() + + // Create viper and configure flags + v := NewCommandConfig() + cfg.ConfigureFlags(v, flags) + + err = flags.Parse([]string{ + "--config=" + configFile, + "--server-port=8888", + }) + require.NoError(t, err) + + // Load config + loadedCfg, err := LoadServeConfig(v, flags) + require.NoError(t, err) + + // CLI should win + assert.Equal(t, 8888, loadedCfg.Server.Port, "CLI flag should override env and file") +} + +// TestServeConfig_EnvVarOverridesFile tests environment variable precedence +func TestServeConfig_EnvVarOverridesFile(t *testing.T) { + // Create full config file + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.yaml") + + configYAML := ` +app: + name: file-name + version: 1.0.0 +server: + host: localhost + port: 8000 +metrics: + host: localhost + port: 7070 +health_check: + host: localhost + port: 8083 +database: + dialect: postgres +ocm: + base_url: https://api.integration.openshift.com +` + err := os.WriteFile(configFile, []byte(configYAML), 0o644) + require.NoError(t, err) + + // Set environment variable + os.Setenv("HYPERFLEET_METRICS_PORT", "9090") + defer os.Unsetenv("HYPERFLEET_METRICS_PORT") + + // Create flag set + flags := pflag.NewFlagSet("test", pflag.ContinueOnError) + cfg := NewServeConfig() + + // Create viper and configure flags + v := NewCommandConfig() + cfg.ConfigureFlags(v, flags) + + err = flags.Parse([]string{"--config=" + configFile}) + require.NoError(t, err) + + // Load config + loadedCfg, err := LoadServeConfig(v, flags) + require.NoError(t, err) + + // Env var should override file + assert.Equal(t, 9090, loadedCfg.Metrics.Port, "Environment variable should override config file") +} + +// TestServeConfig_ToApplicationConfig tests conversion to ApplicationConfig +func TestServeConfig_ToApplicationConfig(t *testing.T) { + // Create serve config + cfg := NewServeConfig() + cfg.App.Name = "conversion-test" + cfg.Server.Port = 8000 + cfg.Metrics.Port = 8080 + cfg.HealthCheck.Port = 8083 + cfg.Database.Dialect = "postgres" + cfg.OCM.BaseURL = "https://api.test.com" + + // Convert to ApplicationConfig + appConfig := cfg.ToApplicationConfig() + + // Verify conversion + assert.Equal(t, "conversion-test", appConfig.App.Name) + assert.Equal(t, 8000, appConfig.Server.Port) + assert.Equal(t, 8080, appConfig.Metrics.Port) + assert.Equal(t, 8083, appConfig.HealthCheck.Port) + assert.Equal(t, "postgres", appConfig.Database.Dialect) + assert.Equal(t, "https://api.test.com", appConfig.OCM.BaseURL) + + // Verify they share the same underlying config objects + assert.Same(t, cfg.App, appConfig.App) + assert.Same(t, cfg.Server, appConfig.Server) + assert.Same(t, cfg.Metrics, appConfig.Metrics) + assert.Same(t, cfg.HealthCheck, appConfig.HealthCheck) + assert.Same(t, cfg.Database, appConfig.Database) + assert.Same(t, cfg.OCM, appConfig.OCM) +} + +// TestServeConfig_DisplayConfig tests that DisplayConfig doesn't panic +func TestServeConfig_DisplayConfig(t *testing.T) { + // Create full config + cfg := NewServeConfig() + cfg.App.Name = "display-test" + cfg.Server.Port = 8000 + cfg.Database.Password = "secret-password" + cfg.OCM.ClientSecret = "secret-token" + + // Should not panic + assert.NotPanics(t, func() { + cfg.DisplayConfig() + }) +} + +// TestServeConfig_GetJSONConfig tests JSON output with redaction +func TestServeConfig_GetJSONConfig(t *testing.T) { + // Create full config with sensitive data + cfg := NewServeConfig() + cfg.App.Name = "json-test" + cfg.Server.Port = 8000 + cfg.Database.Password = "super-secret-password" + cfg.OCM.ClientSecret = "super-secret-token" + + // Get JSON + jsonStr, err := cfg.GetJSONConfig() + require.NoError(t, err) + + // Verify JSON contains app name but sensitive data is redacted + assert.Contains(t, jsonStr, "json-test") + assert.NotContains(t, jsonStr, "super-secret-password") + assert.NotContains(t, jsonStr, "super-secret-token") + assert.Contains(t, jsonStr, "***") // Redacted values +} + +// TestServeConfig_SameFileAsMapping tests that same config file works for serve +func TestServeConfig_SameFileAsMigrate(t *testing.T) { + // Create full config file that would be used by both commands + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.yaml") + + configYAML := ` +app: + name: shared-config + version: 1.0.0 +server: + host: localhost + port: 8000 +metrics: + host: localhost + port: 8080 +health_check: + host: localhost + port: 8083 +database: + dialect: postgres + host: localhost + port: 5432 + name: hyperfleet + username: hyperfleet + password: secret +ocm: + base_url: https://api.integration.openshift.com +` + err := os.WriteFile(configFile, []byte(configYAML), 0o644) + require.NoError(t, err) + + // Load as ServeConfig + serveFlags := pflag.NewFlagSet("serve", pflag.ContinueOnError) + serveCfg := NewServeConfig() + serveViper := NewCommandConfig() + serveCfg.ConfigureFlags(serveViper, serveFlags) + err = serveFlags.Parse([]string{"--config=" + configFile}) + require.NoError(t, err) + serveConfig, err := LoadServeConfig(serveViper, serveFlags) + require.NoError(t, err) + + // Load as MigrateConfig + migrateFlags := pflag.NewFlagSet("migrate", pflag.ContinueOnError) + migrateCfg := NewMigrateConfig() + migrateViper := NewCommandConfig() + migrateCfg.ConfigureFlags(migrateViper, migrateFlags) + err = migrateFlags.Parse([]string{"--config=" + configFile}) + require.NoError(t, err) + migrateConfig, err := LoadMigrateConfig(migrateViper, migrateFlags) + require.NoError(t, err) + + // Verify both loaded the same shared values (App and Database) + assert.Equal(t, "shared-config", serveConfig.App.Name) + assert.Equal(t, "shared-config", migrateConfig.App.Name) + assert.Equal(t, "postgres", serveConfig.Database.Dialect) + assert.Equal(t, "postgres", migrateConfig.Database.Dialect) + + // Serve has all sections loaded + assert.NotNil(t, serveConfig.Server) + assert.NotNil(t, serveConfig.Metrics) + assert.NotNil(t, serveConfig.HealthCheck) + assert.NotNil(t, serveConfig.OCM) + + // Migrate only has App and Database (other sections are ignored from config file) + assert.NotNil(t, migrateConfig.App) + assert.NotNil(t, migrateConfig.Database) +} diff --git a/pkg/config/server.go b/pkg/config/server.go index 9258fd8..4803c5c 100755 --- a/pkg/config/server.go +++ b/pkg/config/server.go @@ -1,58 +1,111 @@ package config import ( + "fmt" "time" "github.com/spf13/pflag" + "github.com/spf13/viper" ) type ServerConfig struct { - Hostname string `json:"hostname"` - BindAddress string `json:"bind_address"` - ReadTimeout time.Duration `json:"read_timeout"` - WriteTimeout time.Duration `json:"write_timeout"` - HTTPSCertFile string `json:"https_cert_file"` - HTTPSKeyFile string `json:"https_key_file"` - EnableHTTPS bool `json:"enable_https"` - EnableJWT bool `json:"enable_jwt"` - EnableAuthz bool `json:"enable_authz"` - JwkCertFile string `json:"jwk_cert_file"` - JwkCertURL string `json:"jwk_cert_url"` - ACLFile string `json:"acl_file"` + Hostname string `mapstructure:"hostname" json:"hostname" validate:""` + Host string `mapstructure:"host" json:"host" validate:"required"` + Port int `mapstructure:"port" json:"port" validate:"required,min=1,max=65535"` + Timeout TimeoutConfig `mapstructure:"timeout" json:"timeout"` + HTTPS HTTPSConfig `mapstructure:"https" json:"https"` + Auth AuthConfig `mapstructure:"auth" json:"auth"` + + // Legacy field for backward compatibility (combines host:port) + BindAddress string `mapstructure:"bind_address" json:"bind_address,omitempty" validate:""` +} + +type TimeoutConfig struct { + Read time.Duration `mapstructure:"read" json:"read" validate:""` + Write time.Duration `mapstructure:"write" json:"write" validate:""` +} + +type HTTPSConfig struct { + Enabled bool `mapstructure:"enabled" json:"enabled"` + CertFile string `mapstructure:"cert_file" json:"cert_file"` + KeyFile string `mapstructure:"key_file" json:"key_file"` +} + +type AuthConfig struct { + JWT JWTConfig `mapstructure:"jwt" json:"jwt"` + Authz AuthzConfig `mapstructure:"authz" json:"authz"` +} + +type JWTConfig struct { + Enabled bool `mapstructure:"enabled" json:"enabled"` + CertFile string `mapstructure:"cert_file" json:"cert_file"` + CertURL string `mapstructure:"cert_url" json:"cert_url"` +} + +type AuthzConfig struct { + Enabled bool `mapstructure:"enabled" json:"enabled"` + ACLFile string `mapstructure:"acl_file" json:"acl_file"` } func NewServerConfig() *ServerConfig { return &ServerConfig{ - Hostname: "", - BindAddress: "localhost:8000", - ReadTimeout: 5 * time.Second, - WriteTimeout: 30 * time.Second, - EnableHTTPS: false, - EnableJWT: true, - EnableAuthz: true, - JwkCertFile: "", - JwkCertURL: "https://sso.redhat.com/auth/realms/redhat-external/protocol/openid-connect/certs", - ACLFile: "", - HTTPSCertFile: "", - HTTPSKeyFile: "", + Hostname: "", + Host: "localhost", + Port: 8000, + Timeout: TimeoutConfig{ + Read: 5 * time.Second, + Write: 30 * time.Second, + }, + HTTPS: HTTPSConfig{ + Enabled: false, + CertFile: "", + KeyFile: "", + }, + Auth: AuthConfig{ + JWT: JWTConfig{ + Enabled: true, + CertFile: "", + CertURL: "https://sso.redhat.com/auth/realms/redhat-external/protocol/openid-connect/certs", + }, + Authz: AuthzConfig{ + Enabled: true, + ACLFile: "", + }, + }, + BindAddress: "localhost:8000", } } -func (s *ServerConfig) AddFlags(fs *pflag.FlagSet) { - fs.StringVar(&s.BindAddress, "api-server-bindaddress", s.BindAddress, "API server bind adddress") - fs.StringVar(&s.Hostname, "api-server-hostname", s.Hostname, "Server's public hostname") - fs.DurationVar(&s.ReadTimeout, "http-read-timeout", s.ReadTimeout, "HTTP server read timeout") - fs.DurationVar(&s.WriteTimeout, "http-write-timeout", s.WriteTimeout, "HTTP server write timeout") - fs.StringVar(&s.HTTPSCertFile, "https-cert-file", s.HTTPSCertFile, "The path to the tls.crt file.") - fs.StringVar(&s.HTTPSKeyFile, "https-key-file", s.HTTPSKeyFile, "The path to the tls.key file.") - fs.BoolVar(&s.EnableHTTPS, "enable-https", s.EnableHTTPS, "Enable HTTPS rather than HTTP") - fs.BoolVar(&s.EnableJWT, "enable-jwt", s.EnableJWT, "Enable JWT authentication validation") - fs.BoolVar(&s.EnableAuthz, "enable-authz", s.EnableAuthz, "Enable Authorization on endpoints, should only be disabled for debug") - fs.StringVar(&s.JwkCertFile, "jwk-cert-file", s.JwkCertFile, "JWK Certificate file") - fs.StringVar(&s.JwkCertURL, "jwk-cert-url", s.JwkCertURL, "JWK Certificate URL") - fs.StringVar(&s.ACLFile, "acl-file", s.ACLFile, "Access control list file") +// defineAndBindFlags defines & binds flags to viper keys in a single pass +func (s *ServerConfig) defineAndBindFlags(v *viper.Viper, fs *pflag.FlagSet) { + // Server flags + defineAndBindStringFlag(v, fs, "server.host", "server-host", "", s.Host, "Server bind host") + defineAndBindIntFlag(v, fs, "server.port", "server-port", "p", s.Port, "Server bind port") + defineAndBindStringFlag(v, fs, "server.hostname", "server-hostname", "", s.Hostname, "Server's public hostname") + + // Timeout flags + defineAndBindDurationFlag(v, fs, "server.timeout.read", "server-timeout-read", "", s.Timeout.Read, "HTTP server read timeout") + defineAndBindDurationFlag(v, fs, "server.timeout.write", "server-timeout-write", "", s.Timeout.Write, "HTTP server write timeout") + + // HTTPS flags + defineAndBindBoolFlag(v, fs, "server.https.enabled", "server-https-enabled", "", s.HTTPS.Enabled, "Enable HTTPS rather than HTTP") + defineAndBindStringFlag(v, fs, "server.https.cert_file", "server-https-cert-file", "", s.HTTPS.CertFile, "Path to the tls.crt file") + defineAndBindStringFlag(v, fs, "server.https.key_file", "server-https-key-file", "", s.HTTPS.KeyFile, "Path to the tls.key file") + + // JWT flags + defineAndBindBoolFlag(v, fs, "server.auth.jwt.enabled", "auth-jwt-enabled", "", s.Auth.JWT.Enabled, "Enable JWT authentication validation") + defineAndBindStringFlag(v, fs, "server.auth.jwt.cert_file", "auth-jwt-cert-file", "", s.Auth.JWT.CertFile, "JWK Certificate file") + defineAndBindStringFlag(v, fs, "server.auth.jwt.cert_url", "auth-jwt-cert-url", "", s.Auth.JWT.CertURL, "JWK Certificate URL") + + // Authz flags + defineAndBindBoolFlag(v, fs, "server.auth.authz.enabled", "auth-authz-enabled", "", s.Auth.Authz.Enabled, "Enable Authorization on endpoints") + defineAndBindStringFlag(v, fs, "server.auth.authz.acl_file", "auth-authz-acl-file", "", s.Auth.Authz.ACLFile, "Access control list file") } -func (s *ServerConfig) ReadFiles() error { - return nil +// GetBindAddress returns the bind address in host:port format +func (s *ServerConfig) GetBindAddress() string { + if s.BindAddress != "" { + return s.BindAddress + } + return fmt.Sprintf("%s:%d", s.Host, s.Port) } diff --git a/pkg/handlers/config.go b/pkg/handlers/config.go new file mode 100644 index 0000000..ce17f01 --- /dev/null +++ b/pkg/handlers/config.go @@ -0,0 +1,60 @@ +/* +Copyright (c) 2018 Red Hat, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package handlers + +import ( + "net/http" + + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/config" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" +) + +type configHandler struct { + config *config.ApplicationConfig +} + +func NewConfigHandler(cfg *config.ApplicationConfig) *configHandler { + return &configHandler{ + config: cfg, + } +} + +// Get sends the merged configuration response with sensitive values redacted. +func (h configHandler) Get(w http.ResponseWriter, r *http.Request) { + // Set the content type: + w.Header().Set("Content-Type", "application/json") + + // Get redacted configuration JSON + jsonConfig, err := h.config.GetJSONConfig() + if err != nil { + log := logger.NewOCMLogger(r.Context()) + log.Extra("endpoint", r.URL.Path).Extra("method", r.Method).Extra("error", err.Error()). + Error("Failed to generate configuration JSON") + api.SendPanic(w, r) + return + } + + // Send the response: + _, err = w.Write([]byte(jsonConfig)) + if err != nil { + log := logger.NewOCMLogger(r.Context()) + log.Extra("endpoint", r.URL.Path).Extra("method", r.Method).Extra("error", err.Error()). + Error("Failed to send configuration response body") + return + } +} diff --git a/pkg/handlers/config_test.go b/pkg/handlers/config_test.go new file mode 100644 index 0000000..daf24b1 --- /dev/null +++ b/pkg/handlers/config_test.go @@ -0,0 +1,207 @@ +package handlers + +import ( + "encoding/json" + "math/rand" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/spf13/pflag" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/config" +) + +var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") + +func randSeq(n int) string { + b := make([]rune, n) + for i := range b { + b[i] = letters[rand.Intn(len(letters))] + } + return string(b) +} + +// TestConfigEndpoint_ReturnsExpectedValues tests that the /config endpoint returns correct values +func TestConfigEndpoint_ReturnsExpectedValues(t *testing.T) { + // Create a config with known values + cfg := config.NewApplicationConfig() + cfg.App.Name = "test-app" + cfg.App.Version = "5.0.0" + cfg.Server.Port = 9000 + cfg.Database.Host = "db.example.com" + cfg.Database.Password = randSeq(10) + cfg.OCM.ClientSecret = randSeq(10) + cfg.Server.Auth.JWT.CertURL = "https://example.com/certs" // Should be redacted (contains 'cert') + + // Create handler + handler := NewConfigHandler(cfg) + + // Create test request + req := httptest.NewRequest("GET", "/api/hyperfleet/config", nil) + w := httptest.NewRecorder() + + // Call handler + handler.Get(w, req) + + // Verify response + assert.Equal(t, http.StatusOK, w.Code, "Should return 200 OK") + assert.Equal(t, "application/json", w.Header().Get("Content-Type"), "Should return JSON") + + // Parse response body + var response config.ApplicationConfig + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err, "Response should be valid JSON") + + // Verify non-sensitive values are present + assert.Equal(t, "test-app", response.App.Name, "App name should be present") + assert.Equal(t, "5.0.0", response.App.Version, "App version should be present") + assert.Equal(t, 9000, response.Server.Port, "Server port should be present") + assert.Equal(t, "db.example.com", response.Database.Host, "Database host should be present") + + // Verify sensitive values are redacted + assert.Equal(t, "***", response.Database.Password, "Database password should be redacted") + assert.Equal(t, "***", response.OCM.ClientSecret, "OCM client secret should be redacted") + assert.Equal(t, "***", response.Server.Auth.JWT.CertURL, "JWT cert URL should be redacted") +} + +// TestConfigEndpoint_SensitiveFieldsRedacted tests various sensitive field patterns +func TestConfigEndpoint_SensitiveFieldsRedacted(t *testing.T) { + cfg := config.NewApplicationConfig() + cfg.Database.Password = randSeq(10) + cfg.OCM.ClientSecret = randSeq(10) + cfg.OCM.SelfToken = "token-789" + cfg.Server.Auth.JWT.CertFile = "/path/to/cert.pem" + cfg.Server.Auth.JWT.CertURL = "https://certs.example.com" + cfg.Server.HTTPS.CertFile = "/path/to/server-cert.pem" + cfg.Server.HTTPS.KeyFile = "/path/to/server-key.pem" + cfg.Database.RootCertFile = "/path/to/root-cert.pem" + + // Create handler + handler := NewConfigHandler(cfg) + + // Create test request + req := httptest.NewRequest("GET", "/api/hyperfleet/config", nil) + w := httptest.NewRecorder() + + // Call handler + handler.Get(w, req) + + // Parse response + var response config.ApplicationConfig + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + // All sensitive fields should be redacted + assert.Equal(t, "***", response.Database.Password, "Password should be redacted") + assert.Equal(t, "***", response.OCM.ClientSecret, "ClientSecret should be redacted") + assert.Equal(t, "***", response.OCM.SelfToken, "SelfToken should be redacted") + assert.Equal(t, "***", response.Server.Auth.JWT.CertFile, "CertFile should be redacted") + assert.Equal(t, "***", response.Server.Auth.JWT.CertURL, "CertURL should be redacted") + assert.Equal(t, "***", response.Server.HTTPS.CertFile, "HTTPS CertFile should be redacted") + assert.Equal(t, "***", response.Server.HTTPS.KeyFile, "KeyFile should be redacted") + assert.Equal(t, "***", response.Database.RootCertFile, "RootCertFile should be redacted") +} + +// TestConfigEndpoint_EmptySensitiveFieldsNotRedacted tests that empty sensitive fields remain empty +func TestConfigEndpoint_EmptySensitiveFieldsNotRedacted(t *testing.T) { + cfg := config.NewApplicationConfig() + // Leave sensitive fields empty + + // Create handler + handler := NewConfigHandler(cfg) + + // Create test request + req := httptest.NewRequest("GET", "/api/hyperfleet/config", nil) + w := httptest.NewRecorder() + + // Call handler + handler.Get(w, req) + + // Parse response + var response config.ApplicationConfig + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + // Empty fields should remain empty, not "***" + assert.Empty(t, response.Database.Password, "Empty password should remain empty") + assert.Empty(t, response.OCM.ClientSecret, "Empty client secret should remain empty") +} + +// TestConfigEndpoint_IntegrationWithLoadConfig tests the full integration +func TestConfigEndpoint_IntegrationWithLoadConfig(t *testing.T) { + // Create temporary config file + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.yaml") + + configYAML := ` +app: + name: integration-test + version: 6.0.0 +server: + host: file-host + port: 7777 +metrics: + host: localhost + port: 8080 +health_check: + host: localhost + port: 8083 +database: + dialect: postgres + password: secret123 + username: testuser +ocm: + base_url: https://api.integration.openshift.com + client_secret: ocmsecret +` + err := os.WriteFile(configFile, []byte(configYAML), 0o644) + require.NoError(t, err) + + // Override with environment variable + os.Setenv("HYPERFLEET_SERVER_PORT", "8888") + defer os.Unsetenv("HYPERFLEET_SERVER_PORT") + + // Create flag set + flags := pflag.NewFlagSet("test", pflag.ContinueOnError) + cfg := config.NewApplicationConfig() + + // Create viper and configure flags + v := config.NewCommandConfig() + cfg.ConfigureFlags(v, flags) + + err = flags.Parse([]string{"--config=" + configFile}) + require.NoError(t, err) + + // Load config with full precedence + loadedCfg, err := config.LoadConfig(v, flags) + require.NoError(t, err) + + // Create handler with loaded config + handler := NewConfigHandler(loadedCfg) + + // Create test request + req := httptest.NewRequest("GET", "/api/hyperfleet/config", nil) + w := httptest.NewRecorder() + + // Call handler + handler.Get(w, req) + + // Parse response + var response config.ApplicationConfig + err = json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + // Verify precedence in endpoint response + assert.Equal(t, "file-host", response.Server.Host, "Should get hostname from file") + assert.Equal(t, 8888, response.Server.Port, "Should get port from env var (overrides file)") + assert.Equal(t, "testuser", response.Database.Username, "Should get username from file") + + // Verify redaction in endpoint response + assert.Equal(t, "***", response.Database.Password, "Password should be redacted in endpoint") + assert.Equal(t, "***", response.OCM.ClientSecret, "Client secret should be redacted in endpoint") +} diff --git a/test/helper.go b/test/helper.go index f79b9fa..c9fadc3 100755 --- a/test/helper.go +++ b/test/helper.go @@ -73,19 +73,32 @@ func NewHelper(t *testing.T) *Helper { fmt.Println("Unable to read JWT keys - this may affect tests that make authenticated server requests") } - env := environments.Environment() - err = env.AddFlags(pflag.CommandLine) - if err != nil { - glog.Fatalf("Unable to add environment flags: %s", err.Error()) - } + // Create config + serveConfig := config.NewServeConfig() + + // Allow custom log level if logLevel := os.Getenv("LOGLEVEL"); logLevel != "" { glog.Infof("Using custom loglevel: %s", logLevel) - // Intentionally ignore error from Set — acceptable for tests - _ = pflag.CommandLine.Set("-v", logLevel) + _ = pflag.CommandLine.Set("v", logLevel) } + + // Create viper and configure flags (tests need full serve config) + v := config.NewCommandConfig() + serveConfig.ConfigureFlags(v, pflag.CommandLine) + pflag.Parse() - err = env.Initialize() + // Load serve config (tests run full server environment) + loadedServeConfig, err := config.LoadServeConfig(v, pflag.CommandLine) + if err != nil { + glog.Fatalf("Failed to load configuration: %v", err) + } + + // Convert to ApplicationConfig for environment initialization + loadedConfig := loadedServeConfig.ToApplicationConfig() + + // Initialize environment with config + err = environments.Environment().Initialize(loadedConfig) if err != nil { glog.Fatalf("Unable to initialize testing environment: %s", err.Error()) } @@ -97,7 +110,7 @@ func NewHelper(t *testing.T) *Helper { JWTCA: jwtCA, } - // Start JWK certificate mock server for testing + // Start JWK certificate mock server and other test infrastructure jwkMockTeardown := helper.StartJWKCertServerMock() helper.teardowns = []func() error{ helper.CleanDB, @@ -133,7 +146,7 @@ func (helper *Helper) Teardown() { func (helper *Helper) startAPIServer() { // Configure JWK certificate URL for API server - helper.Env().Config.Server.JwkCertURL = jwkURL + helper.Env().Config.Server.Auth.JWT.CertURL = jwkURL helper.APIServer = server.NewAPIServer() listener, err := helper.APIServer.Listen() if err != nil { @@ -193,23 +206,29 @@ func (helper *Helper) RestartMetricsServer() { func (helper *Helper) Reset() { glog.Infof("Reseting testing environment") - env := environments.Environment() - // Reset the configuration - env.Config = config.NewApplicationConfig() - - // Re-read command-line configuration into a NEW flagset - // This new flag set ensures we don't hit conflicts defining the same flag twice - // Also on reset, we don't care to be re-defining 'v' and other glog flags - flagset := pflag.NewFlagSet(helper.NewID(), pflag.ContinueOnError) - if err := env.AddFlags(flagset); err != nil { - glog.Fatalf("Unable to add environment flags on Reset: %s", err.Error()) - } + + // Create new config + appConfig := config.NewApplicationConfig() + + // Create viper and configure flags + v := config.NewCommandConfig() + appConfig.ConfigureFlags(v, pflag.CommandLine) + pflag.Parse() - err := env.Initialize() + // Load config + loadedConfig, err := config.LoadConfig(v, pflag.CommandLine) + if err != nil { + glog.Fatalf("Failed to load configuration: %v", err) + } + + // Reinitialize environment + env := environments.Environment() + err = env.Initialize(loadedConfig) if err != nil { glog.Fatalf("Unable to reset testing environment: %s", err.Error()) } + helper.AppConfig = env.Config helper.RestartServer() } @@ -227,7 +246,7 @@ func (helper *Helper) NewUUID() string { func (helper *Helper) RestURL(path string) string { protocol := "http" - if helper.AppConfig.Server.EnableHTTPS { + if helper.AppConfig.Server.Auth.Authz.Enabled { protocol = "https" } return fmt.Sprintf("%s://%s/api/hyperfleet/v1%s", protocol, helper.AppConfig.Server.BindAddress, path) @@ -245,7 +264,7 @@ func (helper *Helper) NewApiClient() *openapi.APIClient { config := openapi.NewConfiguration() // Override the server URL to use the local test server protocol := "http" - if helper.AppConfig.Server.EnableHTTPS { + if helper.AppConfig.Server.Auth.Authz.Enabled { protocol = "https" } config.Host = helper.AppConfig.Server.BindAddress @@ -290,7 +309,7 @@ func (helper *Helper) NewAuthenticatedContext(account *amv1.Account) context.Con func (helper *Helper) StartJWKCertServerMock() (teardown func() error) { jwkURL, teardown = mocks.NewJWKCertServerMock(helper.T, helper.JWTCA, jwkKID, jwkAlg) - helper.Env().Config.Server.JwkCertURL = jwkURL + helper.Env().Config.Server.Auth.JWT.CertURL = jwkURL return teardown } diff --git a/test/integration/metadata_test.go b/test/integration/metadata_test.go index 96779b6..1ba3739 100755 --- a/test/integration/metadata_test.go +++ b/test/integration/metadata_test.go @@ -34,7 +34,7 @@ func TestMetadataGet(t *testing.T) { // Build the metadata URL (metadata endpoint is at /api/hyperfleet, not /api/hyperfleet/v1) protocol := "http" - if h.AppConfig.Server.EnableHTTPS { + if h.AppConfig.Server.Auth.Authz.Enabled { protocol = "https" } metadataURL := fmt.Sprintf("%s://%s/api/hyperfleet", protocol, h.AppConfig.Server.BindAddress) diff --git a/test/integration/openapi_test.go b/test/integration/openapi_test.go index c2cf740..97cd5df 100755 --- a/test/integration/openapi_test.go +++ b/test/integration/openapi_test.go @@ -32,7 +32,7 @@ func TestOpenAPIGet(t *testing.T) { h, _ := test.RegisterIntegration(t) protocol := "http" - if h.AppConfig.Server.EnableHTTPS { + if h.AppConfig.Server.Auth.Authz.Enabled { protocol = "https" } openAPIURL := fmt.Sprintf("%s://%s/api/hyperfleet/v1/openapi", protocol, h.AppConfig.Server.BindAddress) @@ -72,7 +72,7 @@ func TestOpenAPIUIGet(t *testing.T) { h, _ := test.RegisterIntegration(t) protocol := "http" - if h.AppConfig.Server.EnableHTTPS { + if h.AppConfig.Server.Auth.Authz.Enabled { protocol = "https" } openAPIUIURL := fmt.Sprintf("%s://%s/api/hyperfleet/v1/openapi.html", protocol, h.AppConfig.Server.BindAddress)