From 5f8d7db40ec50c9c6eb01f7259778ebee499443e Mon Sep 17 00:00:00 2001 From: eccles <1104895+eccles@users.noreply.github.com> Date: Tue, 25 Nov 2025 14:04:39 +0000 Subject: [PATCH 1/2] Improvements Better listener interface. Add generic grpcserver, httpserver, grpchealth --- .github/workflows/ci.yml | 3 +- go.mod | 1 + go.sum | 2 + grpchealth/grpchealth.go | 109 +++++++++++++++++++++++ grpchealth/logger.go | 7 ++ grpcserver/docs.go | 5 ++ grpcserver/grpcserver.go | 147 +++++++++++++++++++++++++++++++ grpcserver/logger.go | 7 ++ httpserver/httpserver.go | 116 ++++++++++++++++++++++++ httpserver/logger.go | 7 ++ justfile | 23 ++--- logger/interface.go | 15 ---- logger/logger.go | 21 ++--- scripts/README.md | 11 +++ scripts/{source => }/environment | 3 + scripts/{source => }/log | 0 scripts/{source => }/os | 0 scripts/source/README.md | 13 --- startup/listener.go | 116 ++++++++++++++++++++++++ 19 files changed, 550 insertions(+), 56 deletions(-) create mode 100644 grpchealth/grpchealth.go create mode 100644 grpchealth/logger.go create mode 100644 grpcserver/docs.go create mode 100644 grpcserver/grpcserver.go create mode 100644 grpcserver/logger.go create mode 100644 httpserver/httpserver.go create mode 100644 httpserver/logger.go create mode 100644 scripts/README.md rename scripts/{source => }/environment (92%) rename scripts/{source => }/log (100%) rename scripts/{source => }/os (100%) delete mode 100644 scripts/source/README.md create mode 100644 startup/listener.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b8a750..c9fd0f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,8 +30,7 @@ jobs: run: just qa shell: bash - name: Check if there are any uncommitted changes - run: | - git diff --exit-code + run: just check shell: bash - name: unittests run: just unittest diff --git a/go.mod b/go.mod index a552494..5732913 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/opentracing/opentracing-go v1.1.0 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/sagikazarmark/locafero v0.7.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect diff --git a/go.sum b/go.sum index 7c9ba46..408c70d 100644 --- a/go.sum +++ b/go.sum @@ -25,6 +25,7 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 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/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= @@ -52,6 +53,7 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/opentracing/opentracing-go v1.1.0 h1:pWlfV3Bxv7k65HYwkikxat0+s3pV4bsqf19k25Ur8rU= github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= diff --git a/grpchealth/grpchealth.go b/grpchealth/grpchealth.go new file mode 100644 index 0000000..94e0218 --- /dev/null +++ b/grpchealth/grpchealth.go @@ -0,0 +1,109 @@ +package grpchealth + +import ( + "context" + "sync" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "google.golang.org/grpc/health/grpc_health_v1" +) + +const ( + livenessServiceName = "liveness" + readinessServiceName = "readiness" +) + +type HealthCheckingService struct { + grpc_health_v1.UnimplementedHealthServer + sync.RWMutex + healthStatus map[string]grpc_health_v1.HealthCheckResponse_ServingStatus + log Logger +} + +func New(log Logger) HealthCheckingService { + return HealthCheckingService{ + healthStatus: map[string]grpc_health_v1.HealthCheckResponse_ServingStatus{ + livenessServiceName: grpc_health_v1.HealthCheckResponse_SERVING, + readinessServiceName: grpc_health_v1.HealthCheckResponse_NOT_SERVING, + }, + log: log, + } +} + +func (s *HealthCheckingService) serving(service string) { + s.Lock() + defer s.Unlock() + s.healthStatus[service] = grpc_health_v1.HealthCheckResponse_SERVING + s.log.Info("Health set to 'SERVING': %s", service) +} + +func (s *HealthCheckingService) notServing(service string) { + s.Lock() + defer s.Unlock() + s.healthStatus[service] = grpc_health_v1.HealthCheckResponse_NOT_SERVING + s.log.Info("Health set to 'NOT_SERVING': %s", service) +} + +// Dead - changes status of service to dead. +func (s *HealthCheckingService) Dead() { + s.notServing(livenessServiceName) +} + +// Live - changes status of service to alive. +func (s *HealthCheckingService) Live() { + s.serving(livenessServiceName) +} + +// NotReady - changes status of service to not ready. +func (s *HealthCheckingService) NotReady() { + s.notServing(readinessServiceName) +} + +// Ready - changes status of service to ready. +func (s *HealthCheckingService) Ready() { + s.serving(readinessServiceName) +} + +// Check implements `service Health`. +func (s *HealthCheckingService) Check(ctx context.Context, in *grpc_health_v1.HealthCheckRequest) ( + *grpc_health_v1.HealthCheckResponse, error) { + s.RLock() + defer s.RUnlock() + + // logger.Sugar.Debug("Health Check for '%s'", in.Service) + if in.Service == "" { + for _, v := range s.healthStatus { + // logger.Sugar.Debug("Health Check for '%s'-> '%s'", in.Service, v.String()) + if v != grpc_health_v1.HealthCheckResponse_SERVING { + s.log.Info("Health Check '%s' is NOT SERVING: '%s'", in.Service, v.String()) + return &grpc_health_v1.HealthCheckResponse{ + Status: v, + }, nil + } + } + s.log.Info("Health Check '%s' is SERVING", in.Service) + return &grpc_health_v1.HealthCheckResponse{ + Status: grpc_health_v1.HealthCheckResponse_SERVING, + }, nil + } + if stat, ok := s.healthStatus[in.Service]; ok { + s.log.Debug("Health Check '%s' is `%s'", in.Service, stat) + return &grpc_health_v1.HealthCheckResponse{ + Status: stat, + }, nil + } + err := status.Error(codes.NotFound, "unknown service: "+in.Service) + + s.log.Info("Health Check failed: %v", err) + return nil, err +} + +func (s *HealthCheckingService) Watch( + in *grpc_health_v1.HealthCheckRequest, + w grpc_health_v1.Health_WatchServer, +) error { + s.log.Info("Health Check watch not supported") + return status.Error(codes.Unimplemented, "watch not supported") +} diff --git a/grpchealth/logger.go b/grpchealth/logger.go new file mode 100644 index 0000000..5c3257d --- /dev/null +++ b/grpchealth/logger.go @@ -0,0 +1,7 @@ +package grpchealth + +import ( + "github.com/eccles/hestia/logger" +) + +type Logger = logger.Logger diff --git a/grpcserver/docs.go b/grpcserver/docs.go new file mode 100644 index 0000000..54fb3ae --- /dev/null +++ b/grpcserver/docs.go @@ -0,0 +1,5 @@ +// Package grpcserver provides a server instance for grpc communications. +// +// The server instance has a Listener interface so it can be started and stoped by the +// Listener subsystem. Additionally interceptors for various functionality such as +package grpcserver diff --git a/grpcserver/grpcserver.go b/grpcserver/grpcserver.go new file mode 100644 index 0000000..67bee65 --- /dev/null +++ b/grpcserver/grpcserver.go @@ -0,0 +1,147 @@ +package grpcserver + +import ( + "context" + "errors" + "fmt" + "net" + "strings" + + grpc_middleware "github.com/grpc-ecosystem/go-grpc-middleware" + grpc_otrace "github.com/grpc-ecosystem/go-grpc-middleware/tracing/opentracing" + grpc_validator "github.com/grpc-ecosystem/go-grpc-middleware/validator" + "google.golang.org/grpc" + "google.golang.org/grpc/reflection" + + grpcHealth "google.golang.org/grpc/health/grpc_health_v1" + + "github.com/eccles/hestia/grpchealth" +) + +// so we dont have to import grpc when using this package. +type grpcServer = grpc.Server +type grpcUnaryServerInterceptor = grpc.UnaryServerInterceptor + +type RegisterServer func(*grpcServer) + +func defaultRegisterServer(g *grpcServer) {} + +type GRPCServer struct { + name string + log Logger + listenStr string + health bool + healthService *grpchealth.HealthCheckingService + interceptors []grpcUnaryServerInterceptor + register RegisterServer + server *grpcServer + reflection bool +} + +type GRPCServerOption func(*GRPCServer) + +func WithAppendedInterceptor(i grpcUnaryServerInterceptor) GRPCServerOption { + return func(g *GRPCServer) { + g.interceptors = append(g.interceptors, i) + } +} + +func WithPrependedInterceptor(i grpcUnaryServerInterceptor) GRPCServerOption { + return func(g *GRPCServer) { + g.interceptors = append([]grpcUnaryServerInterceptor{i}, g.interceptors...) + } +} + +func WithRegisterServer(r RegisterServer) GRPCServerOption { + return func(g *GRPCServer) { + g.register = r + } +} + +func WithoutHealth() GRPCServerOption { + return func(g *GRPCServer) { + g.health = false + } +} + +func WithReflection(r bool) GRPCServerOption { + return func(g *GRPCServer) { + g.reflection = r + } +} + +func tracingFilter(ctx context.Context, fullMethodName string) bool { + return fullMethodName != grpcHealth.Health_Check_FullMethodName +} + +// New creates a new GRPCServer that is bound to a specific GRPC API. This object complies with +// the standard Listener service and can be managed by the startup.Listeners object. +func New(log Logger, name string, port string, opts ...GRPCServerOption) GRPCServer { + g := GRPCServer{ + name: strings.ToLower(name), + listenStr: fmt.Sprintf(":%s", port), + register: defaultRegisterServer, + interceptors: []grpc.UnaryServerInterceptor{ + grpc_otrace.UnaryServerInterceptor(grpc_otrace.WithFilterFunc(tracingFilter)), + grpc_validator.UnaryServerInterceptor(), + }, + health: true, + } + for _, opt := range opts { + opt(&g) + } + server := grpc.NewServer( + grpc.UnaryInterceptor( + grpc_middleware.ChainUnaryServer(g.interceptors...), + ), + ) + + g.register(server) + + if g.health { + healthService := grpchealth.New(log) + g.healthService = &healthService + grpcHealth.RegisterHealthServer(server, g.healthService) + } + + if g.reflection { + reflection.Register(server) + } + + g.server = server + g.log = log.With("grpcserver", g.String()) + return g +} + +func (g *GRPCServer) String() string { + // No logging in this method please. + return fmt.Sprintf("%s%s", g.name, g.listenStr) +} + +func (g *GRPCServer) Listen() error { + listen, err := net.Listen("tcp", g.listenStr) + if err != nil { + return fmt.Errorf("failed to listen %s: %w", g, err) + } + + if g.healthService != nil { + g.healthService.Ready() // readiness + } + + g.log.Info("Listen") + err = g.server.Serve(listen) + if err != nil && !errors.Is(err, context.Canceled) { + return fmt.Errorf("failed to serve %s: %w", g, err) + } + return nil +} + +func (g *GRPCServer) Shutdown(_ context.Context) error { + g.log.Info("Shutdown") + if g.healthService != nil { + g.healthService.NotReady() // readiness + g.healthService.Dead() // liveness + } + g.server.GracefulStop() + return nil +} diff --git a/grpcserver/logger.go b/grpcserver/logger.go new file mode 100644 index 0000000..c052e27 --- /dev/null +++ b/grpcserver/logger.go @@ -0,0 +1,7 @@ +package grpcserver + +import ( + "github.com/eccles/hestia/logger" +) + +type Logger = logger.Logger diff --git a/httpserver/httpserver.go b/httpserver/httpserver.go new file mode 100644 index 0000000..2e5259c --- /dev/null +++ b/httpserver/httpserver.go @@ -0,0 +1,116 @@ +package httpserver + +import ( + "context" + "errors" + "fmt" + "net/http" + "reflect" + "strings" + "time" +) + +var ( + ErrNilHandler = errors.New("nil Handler") + ErrNilHandlerValue = errors.New("nil Handler value") + ErrHandlerFuncReturnsNil = errors.New("handler function returns nil") +) + +type HandleChainFunc func(http.Handler) http.Handler + +// A http server that has an inbuilt logger, name and complies with the Listener interface in +// startup.Listeners. + +type Server struct { + log Logger + name string + server http.Server + handler http.Handler + handlers []HandleChainFunc +} + +type ServerOption func(*Server) + +// WithHandlers adds a handler on the http endpoint. If the handler is nil +// then an error will occur when executing the Listen() method. +func WithHandlers(handlers ...HandleChainFunc) ServerOption { + return func(s *Server) { + s.handlers = append(s.handlers, handlers...) + } +} + +// WithOptionalHandlers adds a handler on the http endpoint. If the handler is nil +// it is ignored. +func WithOptionalHandlers(handlers ...HandleChainFunc) ServerOption { + return func(s *Server) { + for i := range len(handlers) { + handler := handlers[i] + if handler != nil && !reflect.ValueOf(handler).IsNil() { + s.handlers = append(s.handlers, handler) + } + } + } +} + +// New creates a new httpserver. +func New(log Logger, name string, port string, h http.Handler, opts ...ServerOption) *Server { + s := Server{ + server: http.Server{ + Addr: ":" + port, + // To prvent SlowLoris Attack + ReadHeaderTimeout: 5 * time.Second, + }, + handler: h, + name: strings.ToLower(name), + } + s.log = log.With("httpserver", s.String()) + for _, opt := range opts { + opt(&s) + } + // It is preferable to return a copy rather than a reference. Unfortunately http.Server has an + // internal mutex and this cannot or should not be copied so we will return a reference instead. + return &s +} + +func (s *Server) String() string { + // No logging statements here please + return fmt.Sprintf("%s%s", s.name, s.server.Addr) +} + +func (s *Server) Listen() error { + s.log.Info("Listen") + h := s.handler + for i, handler := range s.handlers { + s.log.Debug("%d: handler %v", i, handler) + if handler == nil { + return ErrNilHandler + } + if reflect.ValueOf(handler).IsNil() { + return ErrNilHandlerValue + } + h1 := handler(h) + if h1 == nil { + return ErrHandlerFuncReturnsNil + } + h = h1 + } + s.server.Handler = h + + // this is a blocking operation + err := s.server.ListenAndServe() + if err != nil { + return fmt.Errorf("%s server terminated: %v", s, err) + } + return nil +} + +func (s *Server) Shutdown(ctx context.Context) error { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + s.log.Info("Shutdown") + err := s.server.Shutdown(ctx) + if err != nil && !errors.Is(err, http.ErrServerClosed) && !errors.Is(err, context.Canceled) { + return err + } + return nil +} diff --git a/httpserver/logger.go b/httpserver/logger.go new file mode 100644 index 0000000..bb2fb2d --- /dev/null +++ b/httpserver/logger.go @@ -0,0 +1,7 @@ +package httpserver + +import ( + "github.com/eccles/hestia/logger" +) + +type Logger = logger.Logger diff --git a/justfile b/justfile index ee595d1..0f9537e 100644 --- a/justfile +++ b/justfile @@ -9,8 +9,7 @@ default: tools: #!/usr/bin/env bash set -euxo pipefail - source ./scripts/source/log - source ./scripts/source/environment + source ./scripts/environment log_info "Install go tools" which go go version @@ -22,8 +21,7 @@ tools: generate: #!/usr/bin/env bash set -euo pipefail - source ./scripts/source/log - source ./scripts/source/environment + source ./scripts/environment log_info "Generate code" which go go generate ./... @@ -32,8 +30,7 @@ generate: qa: #!/usr/bin/env bash set -euo pipefail - source ./scripts/source/log - source ./scripts/source/environment + source ./scripts/environment log_info "Check go.mod and lint code" which go go mod tidy @@ -47,12 +44,19 @@ qa: log_info "Vulnerability checking" go run golang.org/x/vuln/cmd/govulncheck@latest --show verbose ./... +# check if there are ny uncommitted artifacts +check: + #!/usr/bin/env bash + set -euo pipefail + source ./scripts/environment + log_info "Check for uncommitted artifacts" + git diff --exit-code + # unittest all code unittest: #!/usr/bin/env bash set -euo pipefail - source ./scripts/source/log - source ./scripts/source/environment + source ./scripts/environment log_info "Run unittests" which go go test -v -coverprofile=coverage.out ./... @@ -62,8 +66,7 @@ unittest: build: #!/usr/bin/env bash set -euo pipefail - source ./scripts/source/log - source ./scripts/source/environment + source ./scripts/environment log_info "Build binariers" which go go build -o bin/ ./... diff --git a/logger/interface.go b/logger/interface.go index 6ffd20a..e1d6cca 100644 --- a/logger/interface.go +++ b/logger/interface.go @@ -1,18 +1,3 @@ -// .*@mycompany\.com MY COMPANY 2025 -// SPDX-License-Identifier: Apache-2.0 -// -// 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 logger import ( diff --git a/logger/logger.go b/logger/logger.go index cddf2dd..36ddbbd 100644 --- a/logger/logger.go +++ b/logger/logger.go @@ -1,18 +1,3 @@ -// .*@mycompany\.com MY COMPANY 2025 -// SPDX-License-Identifier: Apache-2.0 -// -// 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 logger // Creates global logger from which all loggers are derived. @@ -43,6 +28,10 @@ func New(level string) { func Close() { } +func WithIndex(key, value string) *slog.Logger { + return RootLogger.With(key, value) +} + func WithServiceName(serviceName string) *slog.Logger { - return RootLogger.With(serviceLogKey, serviceName) + return WithIndex(serviceLogKey, serviceName) } diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..90d53e9 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,11 @@ +# Scripts + +Files with a .sh extension are executable. All others are sourced. + +## Sourced scripts + +Most scripts will always have this line at top of the script: + +```bash +source scripts/environment +``` diff --git a/scripts/source/environment b/scripts/environment similarity index 92% rename from scripts/source/environment rename to scripts/environment index 88a8152..b3127eb 100644 --- a/scripts/source/environment +++ b/scripts/environment @@ -3,9 +3,12 @@ # # this file is sourced - do not execute directly # +source scripts/log + # set local go environment from asdf - other users may not be using asdf ASDF=${ASDF_DATA_DIR:-$HOME/.asdf}/plugins/golang/set-env.bash if [ -s ${ASDF} ] then . ${ASDF} fi + diff --git a/scripts/source/log b/scripts/log similarity index 100% rename from scripts/source/log rename to scripts/log diff --git a/scripts/source/os b/scripts/os similarity index 100% rename from scripts/source/os rename to scripts/os diff --git a/scripts/source/README.md b/scripts/source/README.md deleted file mode 100644 index 387f532..0000000 --- a/scripts/source/README.md +++ /dev/null @@ -1,13 +0,0 @@ - -Sourced scripts -=============== - -All files in this directory are sourced in all scripts called from the -root of the repo. - -Most scripts will always have these 2 lines at top of the script: - -```bash -. scripts/source/log -. scripts/source/environment -``` diff --git a/startup/listener.go b/startup/listener.go new file mode 100644 index 0000000..ea66b90 --- /dev/null +++ b/startup/listener.go @@ -0,0 +1,116 @@ +package startup + +import ( + "context" + "errors" + "fmt" + "os/signal" + "reflect" + "strings" + "syscall" + "time" + + "golang.org/x/sync/errgroup" +) + +var ( + ErrNilListener = errors.New("nil Listener") + ErrNilListenerValue = errors.New("nil Listener value") +) + +// based on gist found at +// https://gist.github.com/pteich/c0bb58b0b7c8af7cc6a689dd0d3d26ef?permalink_comment_id=4053701 + +// Listener is an interface that describes any kind of listener - HTTP Server, GRPC Server +// or servicebus receiver. +type Listener interface { + Listen() error + Shutdown(context.Context) error +} + +// Listeners contains all servers that comply with the service. +type Listeners struct { + name string + log Logger + listeners []Listener +} + +type ListenersOption func(*Listeners) + +// WithListeners add multiple listeners. Nil listeners will cause +// an error to be returned. +func WithListeners(listeners ...Listener) ListenersOption { + return func(l *Listeners) { + l.listeners = append(l.listeners, listeners...) + } +} + +func NewListeners(log Logger, name string, opts ...ListenersOption) Listeners { + l := Listeners{name: strings.ToLower(name)} + for _, opt := range opts { + opt(&l) + } + l.log = log.With("listener", l.String()) + return l +} + +func (l *Listeners) String() string { + return l.name +} + +func (l *Listeners) Listen() error { + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + g, errCtx := errgroup.WithContext(ctx) + + for i := range l.listeners { + h := l.listeners[i] + if h == nil { + return ErrNilListener + } + if reflect.ValueOf(h).IsNil() { + return ErrNilListenerValue + } + l.log.Debug("Start %d %s", i, h) + g.Go(func() error { + err := h.Listen() + if err != nil { + return err + } + return nil + }) + } + + g.Go(func() error { + <-errCtx.Done() + l.log.Info("Cancel from signal") + return l.Shutdown() + }) + + err := g.Wait() + if err != nil && !errors.Is(err, context.Canceled) { + return err + } + + return nil +} + +func (l *Listeners) Shutdown() error { + var err error + for _, h := range l.listeners { + func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + e := h.Shutdown(ctx) + if e != nil { + if err != nil { + err = fmt.Errorf("cannot shutdown %s: %w: %w", h, err, e) + } else { + err = fmt.Errorf("cannot shutdown %s: %w", h, e) + } + } + }() + } + return err +} From 5603c5d9a1bd29a2c675fc69a0963b9b96252b18 Mon Sep 17 00:00:00 2001 From: eccles <1104895+eccles@users.noreply.github.com> Date: Tue, 25 Nov 2025 16:37:11 +0000 Subject: [PATCH 2/2] fixup! Improvements --- cmd/hestia/main.go | 15 ------------ cmd/hestiaservice/main.go | 49 ++++++++++++++++++++++++++++++++++++++ services/hestia/Dockerfile | 13 ++++++++++ services/hestia/config.go | 5 ++++ services/hestia/health.go | 30 +++++++++++++++++++++++ services/hestia/logger.go | 7 ++++++ services/hestia/methods.go | 11 +++++++++ services/hestia/mux.go | 13 ++++++++++ services/hestia/service.go | 45 ++++++++++++++++++++++++++++++++++ startup/run.go | 4 ++-- widgets/service/runner.go | 2 +- 11 files changed, 176 insertions(+), 18 deletions(-) create mode 100644 cmd/hestiaservice/main.go create mode 100644 services/hestia/Dockerfile create mode 100644 services/hestia/config.go create mode 100644 services/hestia/health.go create mode 100644 services/hestia/logger.go create mode 100644 services/hestia/methods.go create mode 100644 services/hestia/mux.go create mode 100644 services/hestia/service.go diff --git a/cmd/hestia/main.go b/cmd/hestia/main.go index 0e28b55..26f9b17 100644 --- a/cmd/hestia/main.go +++ b/cmd/hestia/main.go @@ -1,18 +1,3 @@ -// .*@mycompany\.com MY COMPANY 2025 -// SPDX-License-Identifier: Apache-2.0 -// -// 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 main import ( diff --git a/cmd/hestiaservice/main.go b/cmd/hestiaservice/main.go new file mode 100644 index 0000000..62b228d --- /dev/null +++ b/cmd/hestiaservice/main.go @@ -0,0 +1,49 @@ +package main + +import ( + "fmt" + "os" + + "github.com/eccles/hestia/httpserver" + "github.com/eccles/hestia/logger" + "github.com/eccles/hestia/services/hestia" + "github.com/eccles/hestia/startup" +) + +const ( + serviceName = "hestiat" +) + +func main() { + startup.Run(serviceName, run) +} + +func run(log logger.Logger) error { + port, ok := os.LookupEnv("PORT") + if !ok { + err := fmt.Errorf("required environment variable is not defined: %s", "PORT") + log.Info(err.Error()) + return err + } + + service := hestia.New( + serviceName, + log, + &hestia.Config{}, + ) + defer service.Close() + + h := httpserver.New( + log, + serviceName, + port, + service.Mux(), + ) + + s := startup.NewListeners( + log, + serviceName, + startup.WithListeners(h), + ) + return s.Listen() // blocks until either one listener fails or sigterm is received. +} diff --git a/services/hestia/Dockerfile b/services/hestia/Dockerfile new file mode 100644 index 0000000..96531de --- /dev/null +++ b/services/hestia/Dockerfile @@ -0,0 +1,13 @@ +# syntax=docker/dockerfile:1.3 + +FROM --platform=linux/amd64 gcr.io/distroless/static + +WORKDIR /service + +COPY bin/whoamiv1 . + +COPY service/whoamiv1/files files + +ENV PATH=$PATH:/service + +ENTRYPOINT ["/service/whoamiv1"] diff --git a/services/hestia/config.go b/services/hestia/config.go new file mode 100644 index 0000000..9380b37 --- /dev/null +++ b/services/hestia/config.go @@ -0,0 +1,5 @@ +package hestia + +// Config is the configuration. +type Config struct { +} diff --git a/services/hestia/health.go b/services/hestia/health.go new file mode 100644 index 0000000..3e59e55 --- /dev/null +++ b/services/hestia/health.go @@ -0,0 +1,30 @@ +package hestia + +import ( + "fmt" + "net/http" + + "google.golang.org/grpc/health/grpc_health_v1" +) + +// NotServing changes status to NOT_SERVING. +func (s *Service) NotServing() { + // s.Lock() + // defer s.Unlock() + s.HealthStatus = grpc_health_v1.HealthCheckResponse_NOT_SERVING + s.log.Info("Health set to 'NOT_SERVING'") +} + +// Health implements health check. +func (s *Service) Health(w http.ResponseWriter, r *http.Request) { + // s.RLock() + // defer s.RUnlock() + if s.HealthStatus == grpc_health_v1.HealthCheckResponse_SERVING { + w.WriteHeader(200) + fmt.Fprint(w, "OK") + return + } + s.log.Debug("Health check: 'NOT_SERVING'") + w.WriteHeader(500) + fmt.Fprint(w, "NOT_SERVING") +} diff --git a/services/hestia/logger.go b/services/hestia/logger.go new file mode 100644 index 0000000..c595d9e --- /dev/null +++ b/services/hestia/logger.go @@ -0,0 +1,7 @@ +package hestia + +import ( + "github.com/eccles/hestia/logger" +) + +type Logger = logger.Logger diff --git a/services/hestia/methods.go b/services/hestia/methods.go new file mode 100644 index 0000000..c0521d0 --- /dev/null +++ b/services/hestia/methods.go @@ -0,0 +1,11 @@ +package hestia + +import ( + "net/http" +) + +// Method is a simple method that can be connected to an endpoint in the mux. +// Nothing implemented yet. +func (s *Service) Method(w http.ResponseWriter, r *http.Request) { + // ctx := r.Context() +} diff --git a/services/hestia/mux.go b/services/hestia/mux.go new file mode 100644 index 0000000..dee1d2d --- /dev/null +++ b/services/hestia/mux.go @@ -0,0 +1,13 @@ +package hestia + +import ( + "net/http" +) + +func (s *Service) Mux() *http.ServeMux { + m := http.NewServeMux() + + m.HandleFunc("GET /health", s.Health) + + return m +} diff --git a/services/hestia/service.go b/services/hestia/service.go new file mode 100644 index 0000000..fcad9e9 --- /dev/null +++ b/services/hestia/service.go @@ -0,0 +1,45 @@ +package hestia + +import ( + "net/http" + + "google.golang.org/grpc/health/grpc_health_v1" +) + +type HTTPHandlerFunc = func(http.Handler) http.Handler + +// Service implements handlers. +type Service struct { + + // make a copy so that users cannot change anything after the service has started + cfg Config + + HealthStatus grpc_health_v1.HealthCheckResponse_ServingStatus + + log Logger + + name string +} + +// New creates a new hestia service instance. +func New(name string, log Logger, cfg *Config) Service { + return Service{ + name: "httpmux" + name, + cfg: *cfg, + log: log.With("httpmux", name), + HealthStatus: grpc_health_v1.HealthCheckResponse_SERVING, + } +} + +func (s *Service) String() string { + return s.name +} + +// Open will instantiate any inter-service communication channels. +func (s *Service) Open() error { + return nil +} + +// Close will close any inter-service communication channels. +func (s *Service) Close() { +} diff --git a/startup/run.go b/startup/run.go index 1e1a7f7..4607d5a 100644 --- a/startup/run.go +++ b/startup/run.go @@ -22,7 +22,7 @@ import ( "github.com/eccles/hestia/logger" ) -type Runner func(string, Logger) error +type Runner func(Logger) error var ErrLogLevelUndefined = errors.New("no loglevel specified") @@ -46,7 +46,7 @@ func Run(serviceName string, run Runner) { log := logger.WithServiceName(serviceName) - err := run(serviceName, log) + err := run(log) if err != nil { log.Info("Error terminating", "err", err) exitCode = 1 diff --git a/widgets/service/runner.go b/widgets/service/runner.go index 3e04167..9875142 100644 --- a/widgets/service/runner.go +++ b/widgets/service/runner.go @@ -39,7 +39,7 @@ func port() string { // Run initialises the Service struct and executes its Run method. // The Service struct specifies the grpc server code and any other interfaces // to external services defined in connect.go. -func Run(serviceName string, log Logger) error { +func Run(log Logger) error { var err error s := Service{