From 41b35076bb0e9ba60e1e4437ebe81b38f9062178 Mon Sep 17 00:00:00 2001 From: Rafael Benevides Date: Tue, 6 Jan 2026 11:58:36 -0300 Subject: [PATCH 1/3] HYPERFLEET-464 - fix: Dockerfile.openapi for ARM64 architecture support Replace AMD64-only base image with multi-arch eclipse-temurin:17-jdk. Auto-detect architecture to download correct Go binary (arm64/amd64). Download openapi-generator-cli JAR directly from Maven Central. This enables `make generate` to work on ARM64 Macs without QEMU crashes. --- Dockerfile.openapi | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/Dockerfile.openapi b/Dockerfile.openapi index a71b541..5038264 100755 --- a/Dockerfile.openapi +++ b/Dockerfile.openapi @@ -1,17 +1,27 @@ -FROM openapitools/openapi-generator-cli:v7.16.0 +# Multi-arch base image with Java for openapi-generator +FROM eclipse-temurin:17-jdk # -o APT::Sandbox::User=root is a workaround for rootless podman setgroups error in Prow env RUN apt-get -o APT::Sandbox::User=root update RUN apt-get -o APT::Sandbox::User=root install -y make sudo git wget -# Install Go 1.24 -RUN wget https://go.dev/dl/go1.24.0.linux-amd64.tar.gz && \ - tar -C /usr/local -xzf go1.24.0.linux-amd64.tar.gz && \ - rm go1.24.0.linux-amd64.tar.gz +# Install Go 1.24 (auto-detect architecture) +RUN ARCH=$(dpkg --print-architecture) && \ + if [ "$ARCH" = "arm64" ] || [ "$ARCH" = "aarch64" ]; then \ + GO_ARCH="arm64"; \ + else \ + GO_ARCH="amd64"; \ + fi && \ + wget https://go.dev/dl/go1.24.0.linux-${GO_ARCH}.tar.gz && \ + tar -C /usr/local -xzf go1.24.0.linux-${GO_ARCH}.tar.gz && \ + rm go1.24.0.linux-${GO_ARCH}.tar.gz + +# Download openapi-generator-cli +RUN wget https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/7.16.0/openapi-generator-cli-7.16.0.jar \ + -O /usr/local/bin/openapi-generator-cli.jar RUN mkdir -p /local - # Copy the rest of the project COPY openapi/openapi.yaml /local/openapi/openapi.yaml @@ -20,12 +30,16 @@ ENV GOPATH="/uhc" ENV GOBIN /usr/local/go/bin/ ENV CGO_ENABLED=0 -# these git and go flags to avoid self signed certificate errors - WORKDIR /local # Install go-bindata RUN go install -a github.com/go-bindata/go-bindata/...@v3.1.2 -RUN bash /usr/local/bin/docker-entrypoint.sh generate -i /local/openapi/openapi.yaml -g go -o /local/pkg/api/openapi + +# Generate OpenAPI client +RUN java -jar /usr/local/bin/openapi-generator-cli.jar generate \ + -i /local/openapi/openapi.yaml \ + -g go \ + -o /local/pkg/api/openapi + RUN rm /local/pkg/api/openapi/go.mod /local/pkg/api/openapi/go.sum RUN rm -r /local/pkg/api/openapi/test From 94e573f3db904b5d3aa17d6d8ef3b4f80e1a12d4 Mon Sep 17 00:00:00 2001 From: Rafael Benevides Date: Tue, 6 Jan 2026 11:58:45 -0300 Subject: [PATCH 2/3] HYPERFLEET-458 - feat: migrate from glog to slog-based structured logging Implement HyperFleet Logging Specification with Go's slog package: - Add LoggingConfig with LOG_LEVEL, LOG_FORMAT, LOG_OUTPUT env vars - Rewrite pkg/logger to use slog internally (OCMLogger interface preserved) - Add context helpers for trace_id, request_id, account_id correlation - Support JSON format (production) and text format (development) - Include required fields: timestamp, level, message, component, version, hostname Replace all glog imports across 18 files with slog equivalents. --- Dockerfile.openapi | 2 +- cmd/hyperfleet-api/environments/framework.go | 45 ++- cmd/hyperfleet-api/main.go | 21 +- cmd/hyperfleet-api/migrate/cmd.go | 11 +- cmd/hyperfleet-api/servecmd/cmd.go | 10 +- cmd/hyperfleet-api/server/api_server.go | 20 +- .../server/healthcheck_server.go | 8 +- .../server/logging/formatter_json.go | 14 +- cmd/hyperfleet-api/server/metrics_server.go | 9 +- cmd/hyperfleet-api/server/server.go | 5 +- go.mod | 2 +- pkg/api/error.go | 22 +- pkg/auth/authz_middleware_mock.go | 4 +- pkg/config/config.go | 4 + pkg/config/logging.go | 56 ++++ pkg/db/db_session/test.go | 6 +- pkg/db/db_session/testcontainer.go | 47 ++- pkg/db/migrations.go | 6 +- pkg/errors/errors.go | 5 +- pkg/handlers/openapi.go | 6 +- pkg/logger/context.go | 91 ++++++ pkg/logger/http.go | 57 ++++ pkg/logger/logger.go | 299 +++++++++++++----- test/helper.go | 44 +-- test/integration/integration_test.go | 11 +- 25 files changed, 597 insertions(+), 208 deletions(-) create mode 100644 pkg/config/logging.go create mode 100644 pkg/logger/context.go create mode 100644 pkg/logger/http.go diff --git a/Dockerfile.openapi b/Dockerfile.openapi index 5038264..c9d3b9c 100755 --- a/Dockerfile.openapi +++ b/Dockerfile.openapi @@ -17,7 +17,7 @@ RUN ARCH=$(dpkg --print-architecture) && \ rm go1.24.0.linux-${GO_ARCH}.tar.gz # Download openapi-generator-cli -RUN wget https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/7.16.0/openapi-generator-cli-7.16.0.jar \ +RUN wget https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/7.18.0/openapi-generator-cli-7.18.0.jar \ -O /usr/local/bin/openapi-generator-cli.jar RUN mkdir -p /local diff --git a/cmd/hyperfleet-api/environments/framework.go b/cmd/hyperfleet-api/environments/framework.go index deaf8f7..32797fd 100755 --- a/cmd/hyperfleet-api/environments/framework.go +++ b/cmd/hyperfleet-api/environments/framework.go @@ -1,16 +1,18 @@ package environments import ( + "log/slog" "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/api" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/client/ocm" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/config" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" ) func init() { @@ -64,25 +66,37 @@ func (e *Env) AddFlags(flags *pflag.FlagSet) error { // 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 { - glog.Infof("Initializing %s environment", e.Name) + // Initialize structured logging first, before any other operations + logger.Initialize(logger.Config{ + Level: e.Config.Logging.Level, + Format: e.Config.Logging.Format, + Output: e.Config.Logging.Output, + Version: api.Version, + }) + + slog.Info("Initializing environment", "environment", e.Name) envImpl, found := environments[e.Name] if !found { - glog.Fatalf("Unknown runtime environment: %s", e.Name) + slog.Error("Unknown runtime environment", "environment", e.Name) + os.Exit(1) } if err := envImpl.OverrideConfig(e.Config); err != nil { - glog.Fatalf("Failed to configure ApplicationConfig: %s", err) + slog.Error("Failed to configure ApplicationConfig", "error", err) + os.Exit(1) } messages := environment.Config.ReadFiles() if len(messages) != 0 { - glog.Fatalf("unable to read configuration files:\n%s", strings.Join(messages, "\n")) + slog.Error("Unable to read configuration files", "errors", strings.Join(messages, "\n")) + os.Exit(1) } // each env will set db explicitly because the DB impl has a `once` init section if err := envImpl.OverrideDatabase(&e.Database); err != nil { - glog.Fatalf("Failed to configure Database: %s", err) + slog.Error("Failed to configure Database", "error", err) + os.Exit(1) } err := e.LoadClients() @@ -90,12 +104,14 @@ func (e *Env) Initialize() error { return err } if err := envImpl.OverrideClients(&e.Clients); err != nil { - glog.Fatalf("Failed to configure Clients: %s", err) + slog.Error("Failed to configure Clients", "error", err) + os.Exit(1) } e.LoadServices() if err := envImpl.OverrideServices(&e.Services); err != nil { - glog.Fatalf("Failed to configure Services: %s", err) + slog.Error("Failed to configure Services", "error", err) + os.Exit(1) } seedErr := e.Seed() @@ -104,7 +120,8 @@ func (e *Env) Initialize() error { } if err := envImpl.OverrideHandlers(&e.Handlers); err != nil { - glog.Fatalf("Failed to configure Handlers: %s", err) + slog.Error("Failed to configure Handlers", "error", err) + os.Exit(1) } return nil @@ -136,13 +153,13 @@ func (e *Env) LoadClients() error { // Create OCM Authz client if e.Config.OCM.EnableMock { - glog.Infof("Using Mock OCM Authz Client") + slog.Info("Using Mock OCM Authz Client") e.Clients.OCM, err = ocm.NewClientMock(ocmConfig) } else { e.Clients.OCM, err = ocm.NewClient(ocmConfig) } if err != nil { - glog.Errorf("Unable to create OCM Authz client: %s", err.Error()) + slog.Error("Unable to create OCM Authz client", "error", err) return err } @@ -152,12 +169,12 @@ func (e *Env) LoadClients() error { func (e *Env) Teardown() { if e.Database.SessionFactory != nil { if err := e.Database.SessionFactory.Close(); err != nil { - glog.Errorf("Error closing database session factory: %s", err.Error()) + slog.Error("Error closing database session factory", "error", err) } } if e.Clients.OCM != nil { if err := e.Clients.OCM.Close(); err != nil { - glog.Errorf("Error closing OCM client: %v", err) + slog.Error("Error closing OCM client", "error", err) } } } @@ -165,7 +182,7 @@ 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) + slog.Error("Error setting flag", "flag", name, "error", err) return err } } diff --git a/cmd/hyperfleet-api/main.go b/cmd/hyperfleet-api/main.go index 7048b44..f4bdcc3 100755 --- a/cmd/hyperfleet-api/main.go +++ b/cmd/hyperfleet-api/main.go @@ -1,9 +1,9 @@ package main import ( - "flag" + "log/slog" + "os" - "github.com/golang/glog" "github.com/spf13/cobra" "github.com/openshift-hyperfleet/hyperfleet-api/cmd/hyperfleet-api/migrate" @@ -20,20 +20,6 @@ import ( // nolint func main() { - // This is needed to make `glog` believe that the flags have already been parsed, otherwise - // every log messages is prefixed by an error message stating the the flags haven't been - // parsed. - if err := flag.CommandLine.Parse([]string{}); err != nil { - glog.Fatalf("Failed to parse flags: %v", err) - } - - //pflag.CommandLine.AddGoFlagSet(flag.CommandLine) - - // Always log to stderr by default - if err := flag.Set("logtostderr", "true"); err != nil { - glog.Infof("Unable to set logtostderr to true") - } - rootCmd := &cobra.Command{ Use: "hyperfleet", Long: "hyperfleet serves as a template for new microservices", @@ -47,6 +33,7 @@ func main() { rootCmd.AddCommand(migrateCmd, serveCmd) if err := rootCmd.Execute(); err != nil { - glog.Fatalf("error running command: %v", err) + slog.Error("Error running command", "error", err) + os.Exit(1) } } diff --git a/cmd/hyperfleet-api/migrate/cmd.go b/cmd/hyperfleet-api/migrate/cmd.go index adcbb71..e472762 100755 --- a/cmd/hyperfleet-api/migrate/cmd.go +++ b/cmd/hyperfleet-api/migrate/cmd.go @@ -3,13 +3,14 @@ package migrate import ( "context" "flag" + "log/slog" + "os" - "github.com/golang/glog" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db/db_session" "github.com/spf13/cobra" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/config" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db/db_session" ) var dbConfig = config.NewDatabaseConfig() @@ -31,11 +32,13 @@ func NewMigrateCommand() *cobra.Command { func runMigrate(_ *cobra.Command, _ []string) { err := dbConfig.ReadFiles() if err != nil { - glog.Fatal(err) + slog.Error("Failed to read database config", "error", err) + os.Exit(1) } connection := db_session.NewProdFactory(dbConfig) if err := db.Migrate(connection.New(context.Background())); err != nil { - glog.Fatal(err) + slog.Error("Migration failed", "error", err) + os.Exit(1) } } diff --git a/cmd/hyperfleet-api/servecmd/cmd.go b/cmd/hyperfleet-api/servecmd/cmd.go index dce3fd3..73773ad 100755 --- a/cmd/hyperfleet-api/servecmd/cmd.go +++ b/cmd/hyperfleet-api/servecmd/cmd.go @@ -1,7 +1,9 @@ package servecmd import ( - "github.com/golang/glog" + "log/slog" + "os" + "github.com/spf13/cobra" "github.com/openshift-hyperfleet/hyperfleet-api/cmd/hyperfleet-api/environments" @@ -17,7 +19,8 @@ func NewServeCommand() *cobra.Command { } err := environments.Environment().AddFlags(cmd.PersistentFlags()) if err != nil { - glog.Fatalf("Unable to add environment flags to serve command: %s", err.Error()) + slog.Error("Unable to add environment flags to serve command", "error", err) + os.Exit(1) } return cmd @@ -26,7 +29,8 @@ func NewServeCommand() *cobra.Command { func runServe(cmd *cobra.Command, args []string) { err := environments.Environment().Initialize() if err != nil { - glog.Fatalf("Unable to initialize environment: %s", err.Error()) + slog.Error("Unable to initialize environment", "error", err) + os.Exit(1) } // Run the servers diff --git a/cmd/hyperfleet-api/server/api_server.go b/cmd/hyperfleet-api/server/api_server.go index 58fce4a..4e7f5e7 100755 --- a/cmd/hyperfleet-api/server/api_server.go +++ b/cmd/hyperfleet-api/server/api_server.go @@ -3,13 +3,14 @@ package server import ( "context" "fmt" + "log/slog" "net" "net/http" + "os" "time" _ "github.com/auth0/go-jwt-middleware" _ "github.com/golang-jwt/jwt/v4" - "github.com/golang/glog" gorillahandlers "github.com/gorilla/handlers" sdk "github.com/openshift-online/ocm-sdk-go" "github.com/openshift-online/ocm-sdk-go/authentication" @@ -37,9 +38,9 @@ func NewAPIServer() Server { if env().Config.Server.EnableJWT { // Create the logger for the authentication handler: - authnLogger, err := sdk.NewGlogLoggerBuilder(). - InfoV(glog.Level(1)). - DebugV(glog.Level(5)). + authnLogger, err := sdk.NewStdLoggerBuilder(). + Streams(os.Stdout, os.Stderr). + Debug(false). Build() check(err, "Unable to create authentication logger") @@ -117,16 +118,16 @@ func (s apiServer) Serve(listener net.Listener) { } // Serve with TLS - glog.Infof("Serving with TLS at %s", env().Config.Server.BindAddress) + slog.Info("Serving with TLS", "address", env().Config.Server.BindAddress) err = s.httpServer.ServeTLS(listener, env().Config.Server.HTTPSCertFile, env().Config.Server.HTTPSKeyFile) } else { - glog.Infof("Serving without TLS at %s", env().Config.Server.BindAddress) + slog.Info("Serving without TLS", "address", env().Config.Server.BindAddress) err = s.httpServer.Serve(listener) } // Web server terminated. check(err, "Web server terminated with errors") - glog.Info("Web server terminated") + slog.Info("Web server terminated") } // Listen only start the listener, not the server. @@ -139,7 +140,8 @@ func (s apiServer) Listen() (listener net.Listener, err error) { func (s apiServer) Start() { listener, err := s.Listen() if err != nil { - glog.Fatalf("Unable to start API server: %s", err) + slog.Error("Unable to start API server", "error", err) + os.Exit(1) } s.Serve(listener) @@ -147,7 +149,7 @@ func (s apiServer) Start() { // we need to explicitly close Go's sql connection pool. // this needs to be called *exactly* once during an app's lifetime. if err := env().Database.SessionFactory.Close(); err != nil { - glog.Errorf("Error closing database connection: %v", err) + slog.Error("Error closing database connection", "error", err) } } diff --git a/cmd/hyperfleet-api/server/healthcheck_server.go b/cmd/hyperfleet-api/server/healthcheck_server.go index f677234..3aa7962 100755 --- a/cmd/hyperfleet-api/server/healthcheck_server.go +++ b/cmd/hyperfleet-api/server/healthcheck_server.go @@ -3,11 +3,11 @@ package server import ( "context" "fmt" + "log/slog" "net" "net/http" health "github.com/docker/go-healthcheck" - "github.com/golang/glog" "github.com/gorilla/mux" ) @@ -50,14 +50,14 @@ func (s healthCheckServer) Start() { } // Serve with TLS - glog.Infof("Serving HealthCheck with TLS at %s", env().Config.HealthCheck.BindAddress) + slog.Info("Serving HealthCheck with TLS", "address", env().Config.HealthCheck.BindAddress) err = s.httpServer.ListenAndServeTLS(env().Config.Server.HTTPSCertFile, env().Config.Server.HTTPSKeyFile) } else { - glog.Infof("Serving HealthCheck without TLS at %s", env().Config.HealthCheck.BindAddress) + slog.Info("Serving HealthCheck without TLS", "address", env().Config.HealthCheck.BindAddress) err = s.httpServer.ListenAndServe() } check(err, "HealthCheck server terminated with errors") - glog.Infof("HealthCheck server terminated") + slog.Info("HealthCheck server terminated") } func (s healthCheckServer) Stop() error { diff --git a/cmd/hyperfleet-api/server/logging/formatter_json.go b/cmd/hyperfleet-api/server/logging/formatter_json.go index 9d0062c..4b704af 100755 --- a/cmd/hyperfleet-api/server/logging/formatter_json.go +++ b/cmd/hyperfleet-api/server/logging/formatter_json.go @@ -4,8 +4,8 @@ import ( "encoding/json" "io" "net/http" - - "github.com/golang/glog" + "os" + "strings" ) func NewJSONLogFormatter() *jsonLogFormatter { @@ -16,13 +16,19 @@ type jsonLogFormatter struct{} var _ LogFormatter = &jsonLogFormatter{} +// isDebugEnabled checks if debug logging is enabled via LOG_LEVEL +func isDebugEnabled() bool { + level := strings.ToLower(os.Getenv("LOG_LEVEL")) + return level == "debug" +} + func (f *jsonLogFormatter) FormatRequestLog(r *http.Request) (string, error) { jsonlog := jsonRequestLog{ Method: r.Method, RequestURI: r.RequestURI, RemoteAddr: r.RemoteAddr, } - if glog.V(10) { + if isDebugEnabled() { jsonlog.Header = r.Header jsonlog.Body = r.Body } @@ -36,7 +42,7 @@ func (f *jsonLogFormatter) FormatRequestLog(r *http.Request) (string, error) { func (f *jsonLogFormatter) FormatResponseLog(info *ResponseInfo) (string, error) { jsonlog := jsonResponseLog{Header: nil, Status: info.Status, Elapsed: info.Elapsed} - if glog.V(10) { + if isDebugEnabled() { jsonlog.Body = string(info.Body[:]) } log, err := json.Marshal(jsonlog) diff --git a/cmd/hyperfleet-api/server/metrics_server.go b/cmd/hyperfleet-api/server/metrics_server.go index 6fa14b0..5de249a 100755 --- a/cmd/hyperfleet-api/server/metrics_server.go +++ b/cmd/hyperfleet-api/server/metrics_server.go @@ -3,6 +3,7 @@ package server import ( "context" "fmt" + "log/slog" "net" "net/http" @@ -10,7 +11,6 @@ import ( "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/handlers" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" ) func NewMetricsServer() Server { @@ -45,7 +45,6 @@ func (s metricsServer) Serve(listener net.Listener) { } 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 == "" { @@ -56,14 +55,14 @@ func (s metricsServer) Start() { } // Serve with TLS - log.Infof("Serving Metrics with TLS at %s", env().Config.Server.BindAddress) + slog.Info("Serving Metrics with TLS", "address", env().Config.Metrics.BindAddress) err = s.httpServer.ListenAndServeTLS(env().Config.Server.HTTPSCertFile, env().Config.Server.HTTPSKeyFile) } else { - log.Infof("Serving Metrics without TLS at %s", env().Config.Metrics.BindAddress) + slog.Info("Serving Metrics without TLS", "address", env().Config.Metrics.BindAddress) err = s.httpServer.ListenAndServe() } check(err, "Metrics server terminated with errors") - log.Infof("Metrics server terminated") + slog.Info("Metrics server terminated") } func (s metricsServer) Stop() error { diff --git a/cmd/hyperfleet-api/server/server.go b/cmd/hyperfleet-api/server/server.go index 5d197f7..811d894 100755 --- a/cmd/hyperfleet-api/server/server.go +++ b/cmd/hyperfleet-api/server/server.go @@ -1,12 +1,11 @@ package server import ( + "log/slog" "net" "net/http" "os" "strings" - - "github.com/golang/glog" ) type Server interface { @@ -26,7 +25,7 @@ func removeTrailingSlash(next http.Handler) http.Handler { // Exit on error func check(err error, msg string) { if err != nil && err != http.ErrServerClosed { - glog.Errorf("%s: %s", msg, err) + slog.Error(msg, "error", err) os.Exit(1) } } diff --git a/go.mod b/go.mod index 88bdc01..0d4f6a8 100755 --- a/go.mod +++ b/go.mod @@ -14,7 +14,6 @@ require ( github.com/ghodss/yaml v1.0.0 github.com/go-gormigrate/gormigrate/v2 v2.0.0 github.com/golang-jwt/jwt/v4 v4.5.2 - github.com/golang/glog v1.2.5 github.com/google/uuid v1.6.0 github.com/gorilla/handlers v1.4.2 github.com/gorilla/mux v1.8.0 @@ -65,6 +64,7 @@ require ( github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/go-sql-driver/mysql v1.8.1 // indirect + github.com/golang/glog v1.2.5 // 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 diff --git a/pkg/api/error.go b/pkg/api/error.go index f5d990a..010d850 100755 --- a/pkg/api/error.go +++ b/pkg/api/error.go @@ -1,13 +1,14 @@ package api import ( + "context" "encoding/json" "fmt" "net/http" "os" - "github.com/golang/glog" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" ) // SendNotFound sends a 404 response with some details about the non existing resource. @@ -38,8 +39,7 @@ func SendNotFound(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) _, err = w.Write(data) if err != nil { - err = fmt.Errorf("can't send response body for request '%s'", r.URL.Path) - glog.Error(err) + logger.NewOCMLogger(r.Context()).Extra("path", r.URL.Path).Extra("error", err.Error()).Error("Can't send response body") return } } @@ -59,8 +59,7 @@ func SendUnauthorized(w http.ResponseWriter, r *http.Request, message string) { w.WriteHeader(http.StatusUnauthorized) _, err = w.Write(data) if err != nil { - err = fmt.Errorf("can't send response body for request '%s'", r.URL.Path) - glog.Error(err) + logger.NewOCMLogger(r.Context()).Extra("path", r.URL.Path).Extra("error", err.Error()).Error("Can't send response body") return } } @@ -70,12 +69,7 @@ func SendPanic(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, err := w.Write(panicBody) if err != nil { - err = fmt.Errorf( - "can't send panic response for request '%s': %s", - r.URL.Path, - err.Error(), - ) - glog.Error(err) + logger.NewOCMLogger(r.Context()).Extra("path", r.URL.Path).Extra("error", err.Error()).Error("Can't send panic response") } } @@ -101,11 +95,7 @@ func init() { // Convert it to JSON: panicBody, err = json.Marshal(panicError) if err != nil { - err = fmt.Errorf( - "can't create the panic error body: %s", - err.Error(), - ) - glog.Error(err) + logger.NewOCMLogger(context.Background()).Extra("error", err.Error()).Error("Can't create the panic error body") os.Exit(1) } } diff --git a/pkg/auth/authz_middleware_mock.go b/pkg/auth/authz_middleware_mock.go index 9a0a345..cf19f46 100755 --- a/pkg/auth/authz_middleware_mock.go +++ b/pkg/auth/authz_middleware_mock.go @@ -3,7 +3,7 @@ package auth import ( "net/http" - "github.com/golang/glog" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" ) type authzMiddlewareMock struct{} @@ -16,7 +16,7 @@ func NewAuthzMiddlewareMock() AuthorizationMiddleware { func (a authzMiddlewareMock) AuthorizeApi(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - glog.Infof("Mock authz allows / for %q/%q", r.Method, r.URL) + logger.NewOCMLogger(r.Context()).Extra("method", r.Method).Extra("url", r.URL.String()).Info("Mock authz allows /") next.ServeHTTP(w, r) }) } diff --git a/pkg/config/config.go b/pkg/config/config.go index f7c44ff..d5851d9 100755 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -18,6 +18,7 @@ type ApplicationConfig struct { HealthCheck *HealthCheckConfig `json:"health_check"` Database *DatabaseConfig `json:"database"` OCM *OCMConfig `json:"ocm"` + Logging *LoggingConfig `json:"logging"` } func NewApplicationConfig() *ApplicationConfig { @@ -27,6 +28,7 @@ func NewApplicationConfig() *ApplicationConfig { HealthCheck: NewHealthCheckConfig(), Database: NewDatabaseConfig(), OCM: NewOCMConfig(), + Logging: NewLoggingConfig(), } } @@ -37,6 +39,7 @@ func (c *ApplicationConfig) AddFlags(flagset *pflag.FlagSet) { c.HealthCheck.AddFlags(flagset) c.Database.AddFlags(flagset) c.OCM.AddFlags(flagset) + c.Logging.AddFlags(flagset) } func (c *ApplicationConfig) ReadFiles() []string { @@ -49,6 +52,7 @@ func (c *ApplicationConfig) ReadFiles() []string { {c.OCM.ReadFiles, "OCM"}, {c.Metrics.ReadFiles, "Metrics"}, {c.HealthCheck.ReadFiles, "HealthCheck"}, + {c.Logging.ReadFiles, "Logging"}, } var messages []string for _, rf := range readFiles { diff --git a/pkg/config/logging.go b/pkg/config/logging.go new file mode 100644 index 0000000..6edd3e8 --- /dev/null +++ b/pkg/config/logging.go @@ -0,0 +1,56 @@ +package config + +import ( + "os" + + "github.com/spf13/pflag" +) + +// LoggingConfig contains configuration for structured logging +type LoggingConfig struct { + // Level is the minimum log level (debug, info, warn, error) + Level string `json:"level"` + // Format is the output format (text, json) + Format string `json:"format"` + // Output is the destination (stdout, stderr) + Output string `json:"output"` +} + +// NewLoggingConfig creates a new LoggingConfig with defaults +// Precedence: flags → environment variables → defaults +// Env vars are read here as initial values, flags can override via AddFlags() +func NewLoggingConfig() *LoggingConfig { + cfg := &LoggingConfig{ + Level: "info", + Format: "text", + Output: "stdout", + } + + // Apply environment variables as overrides to defaults + // Flags (via AddFlags + Parse) will override these if explicitly set + if level := os.Getenv("LOG_LEVEL"); level != "" { + cfg.Level = level + } + if format := os.Getenv("LOG_FORMAT"); format != "" { + cfg.Format = format + } + if output := os.Getenv("LOG_OUTPUT"); output != "" { + cfg.Output = output + } + + return cfg +} + +// AddFlags adds logging configuration flags +func (c *LoggingConfig) AddFlags(flagset *pflag.FlagSet) { + flagset.StringVar(&c.Level, "log-level", c.Level, "Minimum log level: debug, info, warn, error") + flagset.StringVar(&c.Format, "log-format", c.Format, "Log output format: text, json") + flagset.StringVar(&c.Output, "log-output", c.Output, "Log output destination: stdout, stderr") +} + +// ReadFiles is a no-op for LoggingConfig. +// Environment variables are read in NewLoggingConfig() to ensure proper precedence: +// flags → environment variables → defaults +func (c *LoggingConfig) ReadFiles() error { + return nil +} diff --git a/pkg/db/db_session/test.go b/pkg/db/db_session/test.go index 1262e9f..70bad02 100755 --- a/pkg/db/db_session/test.go +++ b/pkg/db/db_session/test.go @@ -4,9 +4,9 @@ import ( "context" "database/sql" "fmt" + "log/slog" "time" - "github.com/golang/glog" "github.com/lib/pq" "gorm.io/driver/postgres" @@ -51,12 +51,12 @@ func (f *Test) Init(config *config.DatabaseConfig) { // Only the first time once.Do(func() { if err := initDatabase(config, db.Migrate); err != nil { - glog.Errorf("error initializing test database: %s", err) + slog.Error("Error initializing test database", "error", err) return } if err := resetDB(config); err != nil { - glog.Errorf("error resetting test database: %s", err) + slog.Error("Error resetting test database", "error", err) return } }) diff --git a/pkg/db/db_session/testcontainer.go b/pkg/db/db_session/testcontainer.go index 9c288cb..c301b3e 100755 --- a/pkg/db/db_session/testcontainer.go +++ b/pkg/db/db_session/testcontainer.go @@ -4,9 +4,10 @@ import ( "context" "database/sql" "fmt" + "net/url" + "os" "time" - "github.com/golang/glog" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/modules/postgres" "github.com/testcontainers/testcontainers-go/wait" @@ -17,6 +18,7 @@ import ( "github.com/openshift-hyperfleet/hyperfleet-api/pkg/config" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db" + pkglogger "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" ) type Testcontainer struct { @@ -40,8 +42,9 @@ func NewTestcontainerFactory(config *config.DatabaseConfig) *Testcontainer { func (f *Testcontainer) Init(config *config.DatabaseConfig) { ctx := context.Background() + log := pkglogger.NewOCMLogger(ctx) - glog.Infof("Starting PostgreSQL testcontainer...") + log.Info("Starting PostgreSQL testcontainer...") // Create PostgreSQL container container, err := postgres.Run(ctx, @@ -54,7 +57,8 @@ func (f *Testcontainer) Init(config *config.DatabaseConfig) { WithStartupTimeout(60*time.Second)), ) if err != nil { - glog.Fatalf("Failed to start PostgreSQL testcontainer: %s", err) + log.Extra("error", err.Error()).Error("Failed to start PostgreSQL testcontainer") + os.Exit(1) } f.container = container @@ -62,15 +66,22 @@ func (f *Testcontainer) Init(config *config.DatabaseConfig) { // Get connection string from container connStr, err := container.ConnectionString(ctx, "sslmode=disable") if err != nil { - glog.Fatalf("Failed to get connection string from testcontainer: %s", err) + log.Extra("error", err.Error()).Error("Failed to get connection string from testcontainer") + os.Exit(1) } - glog.Infof("PostgreSQL testcontainer started at: %s", connStr) + // Log sanitized connection info (without credentials) + if parsedURL, parseErr := url.Parse(connStr); parseErr == nil { + log.Extra("host", parsedURL.Host).Extra("database", config.Name).Info("PostgreSQL testcontainer started") + } else { + log.Info("PostgreSQL testcontainer started") + } // Open SQL connection f.sqlDB, err = sql.Open("postgres", connStr) if err != nil { - glog.Fatalf("Failed to connect to testcontainer database: %s", err) + log.Extra("error", err.Error()).Error("Failed to connect to testcontainer database") + os.Exit(1) } // Configure connection pool @@ -93,16 +104,18 @@ func (f *Testcontainer) Init(config *config.DatabaseConfig) { PreferSimpleProtocol: true, }), conf) if err != nil { - glog.Fatalf("Failed to connect GORM to testcontainer database: %s", err) + log.Extra("error", err.Error()).Error("Failed to connect GORM to testcontainer database") + os.Exit(1) } // Run migrations - glog.Infof("Running database migrations on testcontainer...") + log.Info("Running database migrations on testcontainer...") if err := db.Migrate(f.g2); err != nil { - glog.Fatalf("Failed to run migrations on testcontainer: %s", err) + log.Extra("error", err.Error()).Error("Failed to run migrations on testcontainer") + os.Exit(1) } - glog.Infof("Testcontainer database initialized successfully") + log.Info("Testcontainer database initialized successfully") } func (f *Testcontainer) DirectDB() *sql.DB { @@ -127,21 +140,22 @@ func (f *Testcontainer) CheckConnection() error { func (f *Testcontainer) Close() error { ctx := context.Background() + log := pkglogger.NewOCMLogger(ctx) // Close SQL connection if f.sqlDB != nil { if err := f.sqlDB.Close(); err != nil { - glog.Errorf("Error closing SQL connection: %s", err) + log.Extra("error", err.Error()).Error("Error closing SQL connection") } } // Terminate container if f.container != nil { - glog.Infof("Stopping PostgreSQL testcontainer...") + log.Info("Stopping PostgreSQL testcontainer...") if err := f.container.Terminate(ctx); err != nil { return fmt.Errorf("failed to terminate testcontainer: %s", err) } - glog.Infof("PostgreSQL testcontainer stopped") + log.Info("PostgreSQL testcontainer stopped") } return nil @@ -150,6 +164,7 @@ func (f *Testcontainer) Close() error { func (f *Testcontainer) ResetDB() { // For testcontainers, we can just truncate all tables ctx := context.Background() + log := pkglogger.NewOCMLogger(ctx) g2 := f.New(ctx) // Truncate all business tables in the correct order (respecting FK constraints) @@ -163,17 +178,19 @@ func (f *Testcontainer) ResetDB() { for _, table := range tables { if g2.Migrator().HasTable(table) { if err := g2.Exec(fmt.Sprintf("TRUNCATE TABLE %s CASCADE", table)).Error; err != nil { - glog.Errorf("Error truncating table %s: %s", table, err) + log.Extra("table", table).Extra("error", err.Error()).Error("Error truncating table") } } } } func (f *Testcontainer) NewListener(ctx context.Context, channel string, callback func(id string)) { + log := pkglogger.NewOCMLogger(ctx) + // Get the connection string for the listener connStr, err := f.container.ConnectionString(ctx, "sslmode=disable") if err != nil { - glog.Errorf("Failed to get connection string for listener: %s", err) + log.Extra("error", err.Error()).Error("Failed to get connection string for listener") return } diff --git a/pkg/db/migrations.go b/pkg/db/migrations.go index e65e7af..8369ee6 100755 --- a/pkg/db/migrations.go +++ b/pkg/db/migrations.go @@ -2,9 +2,10 @@ package db import ( "context" + "log/slog" + "os" "github.com/go-gormigrate/gormigrate/v2" - "github.com/golang/glog" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db/migrations" "gorm.io/gorm" @@ -30,7 +31,8 @@ func MigrateTo(sessionFactory SessionFactory, migrationID string) { m := newGormigrate(g2) if err := m.MigrateTo(migrationID); err != nil { - glog.Fatalf("Could not migrate: %v", err) + slog.Error("Could not migrate", "migrationID", migrationID, "error", err) + os.Exit(1) } } diff --git a/pkg/errors/errors.go b/pkg/errors/errors.go index d29fa7a..8985681 100755 --- a/pkg/errors/errors.go +++ b/pkg/errors/errors.go @@ -2,11 +2,10 @@ package errors import ( "fmt" + "log/slog" "net/http" "strconv" - "github.com/golang/glog" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/openapi" ) @@ -114,7 +113,7 @@ func New(code ServiceErrorCode, reason string, values ...interface{}) *ServiceEr var err *ServiceError exists, err := Find(code) if !exists { - glog.Errorf("Undefined error code used: %d", code) + slog.Error("Undefined error code used", "code", code) err = &ServiceError{ Code: ErrorGeneral, Reason: "Unspecified error", diff --git a/pkg/handlers/openapi.go b/pkg/handlers/openapi.go index 5fee317..c38e1b0 100755 --- a/pkg/handlers/openapi.go +++ b/pkg/handlers/openapi.go @@ -3,10 +3,10 @@ package handlers import ( "embed" "io/fs" + "log/slog" "net/http" "github.com/ghodss/yaml" - "github.com/golang/glog" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" @@ -38,7 +38,7 @@ func NewOpenAPIHandler() (*openAPIHandler, error) { err, ) } - glog.Info("Loaded fully resolved OpenAPI specification from embedded pkg/api/openapi/api/openapi.yaml") + slog.Info("Loaded fully resolved OpenAPI specification from embedded pkg/api/openapi/api/openapi.yaml") // Load the OpenAPI UI HTML content uiContent, err := fs.ReadFile(openapiui, "openapi-ui.html") @@ -48,7 +48,7 @@ func NewOpenAPIHandler() (*openAPIHandler, error) { err, ) } - glog.Info("Loaded OpenAPI UI HTML from embedded file") + slog.Info("Loaded OpenAPI UI HTML from embedded file") return &openAPIHandler{ openAPIDefinitions: data, diff --git a/pkg/logger/context.go b/pkg/logger/context.go new file mode 100644 index 0000000..088117c --- /dev/null +++ b/pkg/logger/context.go @@ -0,0 +1,91 @@ +package logger + +import ( + "context" +) + +// Context keys for logging fields +type contextKey string + +const ( + // TraceIDKey is the context key for distributed trace ID (OpenTelemetry) + TraceIDKey contextKey = "trace_id" + // SpanIDKey is the context key for current span ID (OpenTelemetry) + SpanIDKey contextKey = "span_id" + // RequestIDKey is the context key for HTTP request ID + RequestIDKey contextKey = "request_id" + // AccountIDKey is the context key for account ID + AccountIDKey contextKey = "accountID" + // TransactionIDKey is the context key for transaction ID + TransactionIDKey contextKey = "txid" +) + +// WithTraceID adds a trace ID to the context +func WithTraceID(ctx context.Context, traceID string) context.Context { + return context.WithValue(ctx, TraceIDKey, traceID) +} + +// GetTraceID retrieves the trace ID from context +func GetTraceID(ctx context.Context) string { + if traceID, ok := ctx.Value(TraceIDKey).(string); ok { + return traceID + } + return "" +} + +// WithSpanID adds a span ID to the context +func WithSpanID(ctx context.Context, spanID string) context.Context { + return context.WithValue(ctx, SpanIDKey, spanID) +} + +// GetSpanID retrieves the span ID from context +func GetSpanID(ctx context.Context) string { + if spanID, ok := ctx.Value(SpanIDKey).(string); ok { + return spanID + } + return "" +} + +// WithRequestID adds a request ID to the context +func WithRequestID(ctx context.Context, requestID string) context.Context { + return context.WithValue(ctx, RequestIDKey, requestID) +} + +// GetRequestID retrieves the request ID from context +func GetRequestID(ctx context.Context) string { + if requestID, ok := ctx.Value(RequestIDKey).(string); ok { + return requestID + } + return "" +} + +// NOTE: WithOperationID and GetOperationID are defined in operationid_middleware.go +// The operation ID uses OpIDKey from that file for context storage + +// WithAccountID adds an account ID to the context +func WithAccountID(ctx context.Context, accountID string) context.Context { + return context.WithValue(ctx, AccountIDKey, accountID) +} + +// GetAccountID retrieves the account ID from context +func GetAccountID(ctx context.Context) string { + if accountID := ctx.Value(AccountIDKey); accountID != nil { + if s, ok := accountID.(string); ok { + return s + } + } + return "" +} + +// WithTransactionID adds a transaction ID to the context +func WithTransactionID(ctx context.Context, txid int64) context.Context { + return context.WithValue(ctx, TransactionIDKey, txid) +} + +// GetTransactionID retrieves the transaction ID from context +func GetTransactionID(ctx context.Context) int64 { + if txid, ok := ctx.Value(TransactionIDKey).(int64); ok { + return txid + } + return 0 +} diff --git a/pkg/logger/http.go b/pkg/logger/http.go new file mode 100644 index 0000000..14ed938 --- /dev/null +++ b/pkg/logger/http.go @@ -0,0 +1,57 @@ +package logger + +import ( + "log/slog" + "net/http" + "time" +) + +// HTTP request logging helpers per HyperFleet Logging Specification. +// These functions create slog attributes for API-specific fields: +// - method: HTTP method (GET, POST, etc.) +// - path: Request path +// - status_code: HTTP response status code +// - duration_ms: Request duration in milliseconds +// - user_agent: Client user agent string + +// HTTPMethod returns an slog attribute for the HTTP method +func HTTPMethod(method string) slog.Attr { + return slog.String("method", method) +} + +// HTTPPath returns an slog attribute for the request path +func HTTPPath(path string) slog.Attr { + return slog.String("path", path) +} + +// HTTPStatusCode returns an slog attribute for the response status code +func HTTPStatusCode(code int) slog.Attr { + return slog.Int("status_code", code) +} + +// HTTPDuration returns an slog attribute for request duration in milliseconds +func HTTPDuration(d time.Duration) slog.Attr { + return slog.Int64("duration_ms", d.Milliseconds()) +} + +// HTTPUserAgent returns an slog attribute for the user agent +func HTTPUserAgent(ua string) slog.Attr { + return slog.String("user_agent", ua) +} + +// HTTPRequestAttrs returns common HTTP request attributes for logging +func HTTPRequestAttrs(r *http.Request) []slog.Attr { + return []slog.Attr{ + HTTPMethod(r.Method), + HTTPPath(r.URL.Path), + HTTPUserAgent(r.UserAgent()), + } +} + +// HTTPResponseAttrs returns HTTP response attributes for logging +func HTTPResponseAttrs(statusCode int, duration time.Duration) []slog.Attr { + return []slog.Attr{ + HTTPStatusCode(statusCode), + HTTPDuration(duration), + } +} diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go index dafca42..c80d0e6 100755 --- a/pkg/logger/logger.go +++ b/pkg/logger/logger.go @@ -3,125 +3,276 @@ package logger import ( "context" "fmt" + "io" + "log/slog" + "os" "strings" + "sync" +) + +// Global logger configuration +var ( + globalLogger *slog.Logger + globalMu sync.RWMutex + initOnce sync.Once - "github.com/golang/glog" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/util" + // Build info set at initialization + component = "api" + version = "unknown" + hostname = "unknown" ) -type OCMLogger interface { - V(level int32) OCMLogger - Infof(format string, args ...interface{}) - Extra(key string, value interface{}) OCMLogger - Info(message string) - Warning(message string) - Error(message string) - Fatal(message string) +// Config holds the logger configuration +type Config struct { + Level string // debug, info, warn, error + Format string // text, json + Output string // stdout, stderr + Version string // application version } -var _ OCMLogger = &logger{} +// Initialize sets up the global logger with the given configuration +func Initialize(cfg Config) { + globalMu.Lock() + defer globalMu.Unlock() -type extra map[string]interface{} + // Set version + if cfg.Version != "" { + version = cfg.Version + } -type logger struct { - context context.Context - level int32 - accountID string - // TODO username is unused, should we be logging it? Could be pii - username string - extra extra -} + // Get hostname + if h, err := os.Hostname(); err == nil { + hostname = h + } -// NewOCMLogger creates a new logger instance with a default verbosity of 1 -func NewOCMLogger(ctx context.Context) OCMLogger { - logger := &logger{ - context: ctx, - level: 1, - extra: make(extra), - accountID: util.GetAccountIDFromContext(ctx), + // Determine output writer + var output io.Writer + switch strings.ToLower(cfg.Output) { + case "stderr": + output = os.Stderr + default: + output = os.Stdout } - return logger + + // Determine log level + var level slog.Level + switch strings.ToLower(cfg.Level) { + case "debug": + level = slog.LevelDebug + case "warn", "warning": + level = slog.LevelWarn + case "error": + level = slog.LevelError + default: + level = slog.LevelInfo + } + + // Create handler options + opts := &slog.HandlerOptions{ + Level: level, + ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { + // Rename time to timestamp for HyperFleet standard + if a.Key == slog.TimeKey { + a.Key = "timestamp" + } + // Rename msg to message for HyperFleet standard + if a.Key == slog.MessageKey { + a.Key = "message" + } + return a + }, + } + + // Create handler based on format + var handler slog.Handler + switch strings.ToLower(cfg.Format) { + case "json": + handler = slog.NewJSONHandler(output, opts) + default: + handler = slog.NewTextHandler(output, opts) + } + + // Wrap handler to add default attributes + handler = &contextHandler{ + Handler: handler, + } + + // Create and set global logger with base attributes + globalLogger = slog.New(handler).With( + slog.String("component", component), + slog.String("version", version), + slog.String("hostname", hostname), + ) + + // Set as default logger + slog.SetDefault(globalLogger) } -func (l *logger) prepareLogPrefix(message string, extra extra) string { - prefix := " " +// contextHandler wraps a slog.Handler to add context-based attributes +type contextHandler struct { + slog.Handler +} - if txid, ok := l.context.Value("txid").(int64); ok { - prefix = fmt.Sprintf("[tx_id=%d]%s", txid, prefix) +func (h *contextHandler) Handle(ctx context.Context, r slog.Record) error { + // Add trace_id if present in context (OpenTelemetry) + if traceID := GetTraceID(ctx); traceID != "" { + r.AddAttrs(slog.String("trace_id", traceID)) } - if l.accountID != "" { - prefix = fmt.Sprintf("[accountID=%s]%s", l.accountID, prefix) + // Add span_id if present in context (OpenTelemetry) + if spanID := GetSpanID(ctx); spanID != "" { + r.AddAttrs(slog.String("span_id", spanID)) } - if opid, ok := l.context.Value(OpIDKey).(string); ok { - prefix = fmt.Sprintf("[opid=%s]%s", opid, prefix) + // Add request_id if present in context + if requestID := GetRequestID(ctx); requestID != "" { + r.AddAttrs(slog.String("request_id", requestID)) } - var args []string - for k, v := range extra { - args = append(args, fmt.Sprintf("%s=%v", k, v)) + // Add operation_id if present in context + if opID := GetOperationID(ctx); opID != "" { + r.AddAttrs(slog.String("operation_id", opID)) } - return fmt.Sprintf("%s %s %s", prefix, message, strings.Join(args, " ")) + return h.Handler.Handle(ctx, r) +} + +func (h *contextHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return &contextHandler{Handler: h.Handler.WithAttrs(attrs)} +} + +func (h *contextHandler) WithGroup(name string) slog.Handler { + return &contextHandler{Handler: h.Handler.WithGroup(name)} } -func (l *logger) prepareLogPrefixf(format string, args ...interface{}) string { - orig := fmt.Sprintf(format, args...) - prefix := " " +// Default returns the global logger, initializing with defaults if necessary +func Default() *slog.Logger { + globalMu.RLock() + l := globalLogger + globalMu.RUnlock() - if txid, ok := l.context.Value("txid").(int64); ok { - prefix = fmt.Sprintf("[tx_id=%d]%s", txid, prefix) + if l == nil { + // Use sync.Once to ensure atomic initialization + initOnce.Do(func() { + Initialize(Config{ + Level: "info", + Format: "text", + Output: "stdout", + }) + }) + globalMu.RLock() + l = globalLogger + globalMu.RUnlock() } + return l +} + +// With returns a logger with the given attributes +func With(args ...any) *slog.Logger { + return Default().With(args...) +} + +// OCMLogger is the legacy interface for backward compatibility +type OCMLogger interface { + V(level int32) OCMLogger + Infof(format string, args ...interface{}) + Extra(key string, value interface{}) OCMLogger + Info(message string) + Warning(message string) + Error(message string) + Fatal(message string) +} + +var _ OCMLogger = &legacyLogger{} - if l.accountID != "" { - prefix = fmt.Sprintf("[accountID=%s]%s", l.accountID, prefix) +type legacyLogger struct { + ctx context.Context + logger *slog.Logger + level int32 + attrs []any +} + +// NewOCMLogger creates a new logger instance with a default verbosity of 1 +// This maintains backward compatibility with existing code +func NewOCMLogger(ctx context.Context) OCMLogger { + l := Default() + + // Add context-based attributes that are not handled by contextHandler + // Note: operation_id is added by contextHandler.Handle to avoid duplication + attrs := []any{} + + if accountID := GetAccountID(ctx); accountID != "" { + attrs = append(attrs, slog.String("account_id", accountID)) + } + + if txid := GetTransactionID(ctx); txid != 0 { + attrs = append(attrs, slog.Int64("tx_id", txid)) } - if opid, ok := l.context.Value(OpIDKey).(string); ok { - prefix = fmt.Sprintf("[opid=%s]%s", opid, prefix) + if len(attrs) > 0 { + l = l.With(attrs...) } - return fmt.Sprintf("%s%s", prefix, orig) + return &legacyLogger{ + ctx: ctx, + logger: l, + level: 1, + attrs: []any{}, + } } -func (l *logger) V(level int32) OCMLogger { - return &logger{ - context: l.context, - accountID: l.accountID, - username: l.username, - level: level, +func (l *legacyLogger) V(level int32) OCMLogger { + return &legacyLogger{ + ctx: l.ctx, + logger: l.logger, + level: level, + attrs: l.attrs, } } -// Infof doesn't trigger Sentry error -func (l *logger) Infof(format string, args ...interface{}) { - prefixed := l.prepareLogPrefixf(format, args...) - glog.V(glog.Level(l.level)).Infof("%s", prefixed) +func (l *legacyLogger) Infof(format string, args ...interface{}) { + // V() levels > 1 are treated as debug + if l.level > 1 { + l.logger.With(l.attrs...).DebugContext(l.ctx, sprintf(format, args...)) + } else { + l.logger.With(l.attrs...).InfoContext(l.ctx, sprintf(format, args...)) + } } -func (l *logger) Extra(key string, value interface{}) OCMLogger { - l.extra[key] = value - return l +func (l *legacyLogger) Extra(key string, value interface{}) OCMLogger { + newAttrs := make([]any, len(l.attrs), len(l.attrs)+2) + copy(newAttrs, l.attrs) + newAttrs = append(newAttrs, slog.Any(key, value)) + + return &legacyLogger{ + ctx: l.ctx, + logger: l.logger, + level: l.level, + attrs: newAttrs, + } } -func (l *logger) Info(message string) { - l.log(message, glog.V(glog.Level(l.level)).Infoln) +func (l *legacyLogger) Info(message string) { + l.logger.With(l.attrs...).InfoContext(l.ctx, message) } -func (l *logger) Warning(message string) { - l.log(message, glog.Warningln) +func (l *legacyLogger) Warning(message string) { + l.logger.With(l.attrs...).WarnContext(l.ctx, message) } -func (l *logger) Error(message string) { - l.log(message, glog.Errorln) +func (l *legacyLogger) Error(message string) { + l.logger.With(l.attrs...).ErrorContext(l.ctx, message) } -func (l *logger) Fatal(message string) { - l.log(message, glog.Fatalln) +func (l *legacyLogger) Fatal(message string) { + l.logger.With(l.attrs...).ErrorContext(l.ctx, message) + os.Exit(1) } -func (l *logger) log(message string, glogFunc func(args ...interface{})) { - prefixed := l.prepareLogPrefix(message, l.extra) - glogFunc(prefixed) +// sprintf is a helper for formatting strings +func sprintf(format string, args ...interface{}) string { + if len(args) == 0 { + return format + } + return fmt.Sprintf(format, args...) } diff --git a/test/helper.go b/test/helper.go index f79b9fa..3a17f5f 100755 --- a/test/helper.go +++ b/test/helper.go @@ -6,6 +6,7 @@ import ( "encoding/base64" "encoding/json" "fmt" + "log/slog" "os" "strings" "sync" @@ -14,7 +15,6 @@ import ( "github.com/bxcodec/faker/v3" "github.com/golang-jwt/jwt/v4" - "github.com/golang/glog" "github.com/google/uuid" "github.com/segmentio/ksuid" "github.com/spf13/pflag" @@ -76,10 +76,11 @@ func NewHelper(t *testing.T) *Helper { env := environments.Environment() err = env.AddFlags(pflag.CommandLine) if err != nil { - glog.Fatalf("Unable to add environment flags: %s", err.Error()) + slog.Error("Unable to add environment flags", "error", err) + os.Exit(1) } if logLevel := os.Getenv("LOGLEVEL"); logLevel != "" { - glog.Infof("Using custom loglevel: %s", logLevel) + slog.Info("Using custom loglevel", "level", logLevel) // Intentionally ignore error from Set — acceptable for tests _ = pflag.CommandLine.Set("-v", logLevel) } @@ -87,7 +88,8 @@ func NewHelper(t *testing.T) *Helper { err = env.Initialize() if err != nil { - glog.Fatalf("Unable to initialize testing environment: %s", err.Error()) + slog.Error("Unable to initialize testing environment", "error", err) + os.Exit(1) } helper = &Helper{ @@ -137,12 +139,13 @@ func (helper *Helper) startAPIServer() { helper.APIServer = server.NewAPIServer() listener, err := helper.APIServer.Listen() if err != nil { - glog.Fatalf("Unable to start Test API server: %s", err) + slog.Error("Unable to start Test API server", "error", err) + os.Exit(1) } go func() { - glog.V(10).Info("Test API server started") + slog.Debug("Test API server started") helper.APIServer.Serve(listener) - glog.V(10).Info("Test API server stopped") + slog.Debug("Test API server stopped") }() } @@ -156,59 +159,62 @@ func (helper *Helper) stopAPIServer() error { func (helper *Helper) startMetricsServer() { helper.MetricsServer = server.NewMetricsServer() go func() { - glog.V(10).Info("Test Metrics server started") + slog.Debug("Test Metrics server started") helper.MetricsServer.Start() - glog.V(10).Info("Test Metrics server stopped") + slog.Debug("Test Metrics server stopped") }() } func (helper *Helper) stopMetricsServer() { if err := helper.MetricsServer.Stop(); err != nil { - glog.Fatalf("Unable to stop metrics server: %s", err.Error()) + slog.Error("Unable to stop metrics server", "error", err) + os.Exit(1) } } func (helper *Helper) startHealthCheckServer() { helper.HealthCheckServer = server.NewHealthCheckServer() go func() { - glog.V(10).Info("Test health check server started") + slog.Debug("Test health check server started") helper.HealthCheckServer.Start() - glog.V(10).Info("Test health check server stopped") + slog.Debug("Test health check server stopped") }() } func (helper *Helper) RestartServer() { if err := helper.stopAPIServer(); err != nil { - glog.Warningf("unable to stop api server on restart: %v", err) + slog.Warn("unable to stop api server on restart", "error", err) } helper.startAPIServer() - glog.V(10).Info("Test API server restarted") + slog.Debug("Test API server restarted") } func (helper *Helper) RestartMetricsServer() { helper.stopMetricsServer() helper.startMetricsServer() - glog.V(10).Info("Test metrics server restarted") + slog.Debug("Test metrics server restarted") } func (helper *Helper) Reset() { - glog.Infof("Reseting testing environment") + slog.Info("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 + // Also on reset, we don't care to be re-defining 'v' and other 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()) + slog.Error("Unable to add environment flags on Reset", "error", err) + os.Exit(1) } pflag.Parse() err := env.Initialize() if err != nil { - glog.Fatalf("Unable to reset testing environment: %s", err.Error()) + slog.Error("Unable to reset testing environment", "error", err) + os.Exit(1) } helper.AppConfig = env.Config helper.RestartServer() diff --git a/test/integration/integration_test.go b/test/integration/integration_test.go index d0b68ff..b2158e6 100755 --- a/test/integration/integration_test.go +++ b/test/integration/integration_test.go @@ -2,19 +2,18 @@ package integration import ( "flag" + "log/slog" "os" "path/filepath" "runtime" "testing" - "github.com/golang/glog" - "github.com/openshift-hyperfleet/hyperfleet-api/test" ) func TestMain(m *testing.M) { flag.Parse() - glog.Infof("Starting integration test using go version %s", runtime.Version()) + slog.Info("Starting integration test", "go_version", runtime.Version()) // Set OPENAPI_SCHEMA_PATH for integration tests if not already set // This enables schema validation middleware during tests @@ -23,7 +22,7 @@ func TestMain(m *testing.M) { // Use runtime.Caller to find this file's path _, filename, _, ok := runtime.Caller(0) if !ok { - glog.Warningf("Failed to determine current file path via runtime.Caller, skipping OPENAPI_SCHEMA_PATH setup") + slog.Warn("Failed to determine current file path via runtime.Caller, skipping OPENAPI_SCHEMA_PATH setup") } else { // filename is like: /path/to/repo/test/integration/integration_test.go // Navigate up: integration_test.go -> integration -> test -> repo @@ -36,10 +35,10 @@ func TestMain(m *testing.M) { // Verify the schema file exists before setting the env var if _, err := os.Stat(schemaPath); err != nil { - glog.Warningf("Schema file not found at %s: %v, skipping OPENAPI_SCHEMA_PATH setup", schemaPath, err) + slog.Warn("Schema file not found, skipping OPENAPI_SCHEMA_PATH setup", "path", schemaPath, "error", err) } else { _ = os.Setenv("OPENAPI_SCHEMA_PATH", schemaPath) - glog.Infof("Set OPENAPI_SCHEMA_PATH=%s for integration tests", schemaPath) + slog.Info("Set OPENAPI_SCHEMA_PATH for integration tests", "path", schemaPath) } } } From 93ce867c3dbfe6d20137a119b498deaa4c8ac977 Mon Sep 17 00:00:00 2001 From: Rafael Benevides Date: Tue, 6 Jan 2026 14:34:33 -0300 Subject: [PATCH 3/3] HYPERFLEET-458 - fix: update Go version and fix typo - Update Go from 1.24.0 to 1.24.9 in Dockerfile.openapi - Fix typo: "Reseting" -> "Resetting" in test/helper.go --- Dockerfile.openapi | 8 ++++---- test/helper.go | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Dockerfile.openapi b/Dockerfile.openapi index c9d3b9c..37a30a9 100755 --- a/Dockerfile.openapi +++ b/Dockerfile.openapi @@ -5,16 +5,16 @@ FROM eclipse-temurin:17-jdk RUN apt-get -o APT::Sandbox::User=root update RUN apt-get -o APT::Sandbox::User=root install -y make sudo git wget -# Install Go 1.24 (auto-detect architecture) +# Install Go 1.24.9 (auto-detect architecture) RUN ARCH=$(dpkg --print-architecture) && \ if [ "$ARCH" = "arm64" ] || [ "$ARCH" = "aarch64" ]; then \ GO_ARCH="arm64"; \ else \ GO_ARCH="amd64"; \ fi && \ - wget https://go.dev/dl/go1.24.0.linux-${GO_ARCH}.tar.gz && \ - tar -C /usr/local -xzf go1.24.0.linux-${GO_ARCH}.tar.gz && \ - rm go1.24.0.linux-${GO_ARCH}.tar.gz + wget https://go.dev/dl/go1.24.9.linux-${GO_ARCH}.tar.gz && \ + tar -C /usr/local -xzf go1.24.9.linux-${GO_ARCH}.tar.gz && \ + rm go1.24.9.linux-${GO_ARCH}.tar.gz # Download openapi-generator-cli RUN wget https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/7.18.0/openapi-generator-cli-7.18.0.jar \ diff --git a/test/helper.go b/test/helper.go index 3a17f5f..9b72890 100755 --- a/test/helper.go +++ b/test/helper.go @@ -196,7 +196,7 @@ func (helper *Helper) RestartMetricsServer() { } func (helper *Helper) Reset() { - slog.Info("Reseting testing environment") + slog.Info("Resetting testing environment") env := environments.Environment() // Reset the configuration env.Config = config.NewApplicationConfig()