Initial cloud-services repo - gateway service + pkg modules

This commit is contained in:
Chris Rai
2026-01-30 23:14:52 -05:00
commit fbb820d7b3
1037 changed files with 171318 additions and 0 deletions

22
pkg/health/clickhouse.go Normal file
View File

@@ -0,0 +1,22 @@
package health
import (
"context"
)
type GetClickhouseConsumerFunc func() (ClickhouseConnCheckInterface, error)
func NewClickhouseCheck(fn GetClickhouseConsumerFunc) CheckFunc {
return func(ctx context.Context) error {
conn, err := fn()
if err != nil {
return err
}
return conn.Ping(ctx)
}
}
type ClickhouseConnCheckInterface interface {
Ping(ctx context.Context) error
}

View File

@@ -0,0 +1,33 @@
package health_test
import (
"context"
"testing"
"fiskerinc.com/modules/health"
"fiskerinc.com/modules/testhelper"
"github.com/pkg/errors"
)
func TestClickhouseCheck(t *testing.T) {
mock := MockClickhouseDB{}
fn := func() (health.ClickhouseConnCheckInterface, error) {
return &mock, nil
}
check := health.NewClickhouseCheck(fn)
err := check(context.Background())
testhelper.NoError(t, "No error", err)
mock.Error = errors.New("ping error")
err = check(context.Background())
testhelper.Error(t, "Ping error", err)
}
type MockClickhouseDB struct {
Error error
}
func (m *MockClickhouseDB) Ping(ctx context.Context) error {
return m.Error
}

18
pkg/health/goroutines.go Normal file
View File

@@ -0,0 +1,18 @@
package health
import (
"context"
"runtime"
"github.com/pkg/errors"
)
func NewGoRoutinesCheck(threshold int) CheckFunc {
return func(ctx context.Context) error {
count := runtime.NumGoroutine()
if count > threshold {
return errors.Errorf("too many goroutines (%d > %d)", count, threshold)
}
return nil
}
}

View File

@@ -0,0 +1,19 @@
package health_test
import (
"context"
"testing"
"fiskerinc.com/modules/health"
"fiskerinc.com/modules/testhelper"
)
func TestGoRoutinesCheck(t *testing.T) {
check := health.NewGoRoutinesCheck(100)
err := check(context.Background())
testhelper.NoError(t, "no error", err)
check = health.NewGoRoutinesCheck(0)
err = check(context.Background())
testhelper.Error(t, "has error", err)
}

298
pkg/health/health.go Normal file
View File

@@ -0,0 +1,298 @@
package health
import (
"context"
"encoding/json"
"fmt"
"net/http"
"runtime"
"sync"
"time"
"fiskerinc.com/modules/logger"
"github.com/gomodule/redigo/redis"
"github.com/pkg/errors"
)
// Based on https://github.com/hellofresh/health-go
// Status type represents health status
type Status string
// Possible health statuses
const (
StatusOK Status = "OK"
StatusPartiallyAvailable Status = "partially available"
StatusUnavailable Status = "unavailable"
StatusTimeout Status = "timeout"
)
type (
// CheckFunc is the func which executes the check.
CheckFunc func(context.Context) error
// InfoFunc is the func which executes to return check info
InfoFunc func(system *System)
// Config carries the parameters to run the check.
Config struct {
// Name is the name of the resource to be checked.
Name string
// Timeout is the timeout defined for every check.
Timeout time.Duration
// SkipOnErr if set to true, it will retrieve StatusOK providing the error message from the failed resource.
SkipOnErr bool
// Check is the func which executes the check.
Check CheckFunc
// Info func sets System information
Info InfoFunc
// If Vital is set to true, it means that the service won't work without this resource.
Vital bool
}
// Check represents the health check response.
Check struct {
// Status is the check status.
Status Status `json:"status"`
// Timestamp is the time in which the check occurred.
Timestamp time.Time `json:"timestamp"`
// Failures holds the failed checks along with their messages.
Failures map[string]string `json:"failures,omitempty"`
// System holds information of the go process.
System *System `json:"system"`
}
// System runtime variables about the go process.
System struct {
// Version is the go version.
Version string `json:"version"`
// GoroutinesCount is the number of the current goroutines.
GoroutinesCount int `json:"goroutines_count"`
// TotalAllocBytes is the total bytes allocated.
TotalAllocBytes int `json:"total_alloc_bytes"`
// HeapObjectsCount is the number of objects in the go heap.
HeapObjectsCount int `json:"heap_objects_count"`
// TotalAllocBytes is the bytes allocated and not yet freed.
AllocBytes int `json:"alloc_bytes"`
// RedisPoolCount is the current Redis connection pool count
RedisStats *redis.PoolStats `json:"redis_stats,omitempty"`
}
// Health is the health-checks container
Health struct {
mu sync.Mutex
checks map[string]Config
}
checkResponse struct {
config Config
err error
}
filterChecks func(checks map[string]Config) map[string]Config
)
// New instantiates and build new health check container
func New(opts ...Option) (*Health, error) {
h := &Health{
checks: make(map[string]Config),
}
for _, o := range opts {
if err := o(h); err != nil {
return nil, err
}
}
return h, nil
}
// Register registers a check config to be performed.
func (h *Health) Register(c Config) error {
if c.Timeout == 0 {
c.Timeout = time.Second * 1
}
if c.Name == "" {
return errors.New("health check must have a name to be registered")
}
h.mu.Lock()
defer h.mu.Unlock()
if _, ok := h.checks[c.Name]; ok {
return fmt.Errorf("health check %q is already registered", c.Name)
}
h.checks[c.Name] = c
return nil
}
// ReadinessHandler returns an HTTP handler (http.HandlerFunc).
func (h *Health) ReadinessHandler() http.Handler {
return http.HandlerFunc(h.ReadinessFunc)
}
// LivenessHandler returns an HTTP handler (http.HandlerFunc).
func (h *Health) LivenessHandler() http.Handler {
return http.HandlerFunc(h.LivenessFunc)
}
// LivenessFunc is the HTTP handler function.
func (h *Health) LivenessFunc(w http.ResponseWriter, r *http.Request) {
c := h.Measure(r.Context(), getLivenessCheck)
w.Header().Set("Content-Type", "application/json")
data, err := json.Marshal(c)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
code := http.StatusOK
if c.Status == StatusUnavailable {
code = http.StatusServiceUnavailable
}
w.WriteHeader(code)
w.Write(data)
}
// ReadinessFunc is the HTTP handler function.
func (h *Health) ReadinessFunc(w http.ResponseWriter, r *http.Request) {
c := h.Measure(r.Context(), getReadinessCheck)
w.Header().Set("Content-Type", "application/json")
data, err := json.Marshal(c)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
code := http.StatusOK
if c.Status == StatusUnavailable {
code = http.StatusServiceUnavailable
}
w.WriteHeader(code)
w.Write(data)
}
func (h *Health) info(system *System) {
for _, c := range h.checks {
if c.Info != nil {
c.Info(system)
}
}
}
// Measure runs all the registered health checks and returns summary status
func (h *Health) Measure(ctx context.Context, getChecks filterChecks) Check {
errTimeout := errors.New("timeout error")
h.mu.Lock()
defer h.mu.Unlock()
checksList := getChecks(h.checks)
total := len(checksList)
checkRespChan := make(chan checkResponse, total)
var wgRes sync.WaitGroup
wgRes.Add(total)
go func() {
wgRes.Wait()
close(checkRespChan)
}()
for _, c := range checksList {
go func(ctx context.Context, c Config, respChan chan<- checkResponse) {
defer wgRes.Done()
locResp := make(chan error)
go func(ctx context.Context, locResp chan<- error) {
defer close(locResp)
locResp <- c.Check(ctx)
}(ctx, locResp)
select {
case <-time.After(c.Timeout):
respChan <- checkResponse{config: c, err: errTimeout}
case err := <-locResp:
respChan <- checkResponse{config: c, err: err}
}
}(ctx, c, checkRespChan)
}
status := StatusOK
checks := make(map[string]string)
for resp := range checkRespChan {
if resp.err == errTimeout {
checks[resp.config.Name] = string(StatusTimeout)
status = getAvailability(status, resp.config)
continue
}
if resp.err != nil {
checks[resp.config.Name] = resp.err.Error()
status = getAvailability(status, resp.config)
logger.Error().Err(errors.WithMessage(resp.err, resp.config.Name)).Send()
continue
}
checks[resp.config.Name] = string(StatusOK)
}
system := newSystemMetrics()
h.info(&system)
return newCheck(status, checks, &system)
}
func getReadinessCheck(checks map[string]Config) map[string]Config {
return checks
}
func getLivenessCheck(checks map[string]Config) map[string]Config {
rez := make(map[string]Config)
for key, conf := range checks {
if conf.Vital {
rez[key] = conf
}
}
return rez
}
func newCheck(s Status, failures map[string]string, system *System) Check {
return Check{
Status: s,
Timestamp: time.Now(),
Failures: failures,
System: system,
}
}
func newSystemMetrics() System {
s := runtime.MemStats{}
runtime.ReadMemStats(&s)
return System{
Version: runtime.Version(),
GoroutinesCount: runtime.NumGoroutine(),
TotalAllocBytes: int(s.TotalAlloc),
HeapObjectsCount: int(s.HeapObjects),
AllocBytes: int(s.Alloc),
}
}
func getAvailability(s Status, c Config) Status {
if c.SkipOnErr && s != StatusUnavailable {
return StatusPartiallyAvailable
}
return StatusUnavailable
}

166
pkg/health/health_test.go Normal file
View File

@@ -0,0 +1,166 @@
package health
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"fiskerinc.com/modules/testhelper"
)
const (
checkErr = "failed during RabbitMQ health check"
)
func TestRegisterWithNoName(t *testing.T) {
h, err := New()
testhelper.NoError(t, "New", err)
err = h.Register(Config{
Name: "",
Check: func(context.Context) error {
return nil
},
})
testhelper.Error(t, "Register", err)
}
func TestDoubleRegister(t *testing.T) {
h, err := New()
testhelper.NoError(t, "New", err)
healthCheckName := "health-check"
conf := Config{
Name: healthCheckName,
Check: func(context.Context) error {
return nil
},
}
err = h.Register(conf)
testhelper.NoError(t, "Register", err)
err = h.Register(conf)
testhelper.Error(t, "the second registration of a health check config should return an error, but did not", err)
err = h.Register(Config{
Name: healthCheckName,
Check: func(context.Context) error {
return errors.New("health checks registered")
},
})
testhelper.Error(t, "registration with same name, but different details should still return an error, but did not", err)
}
func TestReadinessHandler(t *testing.T) {
h, err := New()
testhelper.NoError(t, "New", err)
res := httptest.NewRecorder()
req, err := http.NewRequest("GET", "http://localhost/readiness", nil)
testhelper.NoError(t, "NewRequest", err)
err = h.Register(Config{
Name: "rabbitmq",
SkipOnErr: true,
Check: func(context.Context) error { return errors.New(checkErr) },
})
testhelper.NoError(t, "Register rabbitmq", err)
err = h.Register(Config{
Name: "mongodb",
Check: func(context.Context) error { return nil },
})
testhelper.NoError(t, "Register mongodb", err)
err = h.Register(Config{
Name: "snail-service",
SkipOnErr: true,
Timeout: time.Second * 1,
Check: func(context.Context) error {
time.Sleep(time.Second * 2)
return nil
},
})
testhelper.NoError(t, "Register snail-service", err)
handler := h.ReadinessHandler()
handler.ServeHTTP(res, req)
testhelper.Equal(t, "status handler returned wrong status code", http.StatusOK, res.Code)
body := make(map[string]interface{})
err = json.NewDecoder(res.Body).Decode(&body)
testhelper.NoError(t, "NewDecoder", err)
testhelper.Equal(t, "body returned wrong status", string(StatusPartiallyAvailable), body["status"])
failure, ok := body["failures"]
testhelper.True(t, "body returned nil failures field", ok)
f, ok := failure.(map[string]interface{})
testhelper.True(t, "body returned nil failures.rabbitmq field", ok)
testhelper.Equal(t, "body returned wrong status for rabbitmq", checkErr, f["rabbitmq"])
testhelper.Equal(t, "body returned wrong status for snail-service", string(StatusTimeout), f["snail-service"])
}
func TestLivenessHandler(t *testing.T) {
h, err := New()
testhelper.NoError(t, "New", err)
res := httptest.NewRecorder()
req, err := http.NewRequest("GET", "http://localhost/liveness", nil)
testhelper.NoError(t, "NewRequest", err)
err = h.Register(Config{
Name: "rabbitmq",
SkipOnErr: true,
Check: func(context.Context) error { return errors.New(checkErr) },
Vital: true,
})
testhelper.NoError(t, "Register rabbitmq", err)
err = h.Register(Config{
Name: "mongodb",
Check: func(context.Context) error { return nil },
})
testhelper.NoError(t, "Register mongodb", err)
err = h.Register(Config{
Name: "snail-service",
SkipOnErr: true,
Timeout: time.Second * 1,
Check: func(context.Context) error {
time.Sleep(time.Second * 2)
return nil
},
Vital: true,
})
testhelper.NoError(t, "Register snail-service", err)
handler := h.LivenessHandler()
handler.ServeHTTP(res, req)
testhelper.Equal(t, "status handler returned wrong status code", http.StatusOK, res.Code)
body := make(map[string]interface{})
err = json.NewDecoder(res.Body).Decode(&body)
testhelper.NoError(t, "NewDecoder", err)
testhelper.Equal(t, "body returned wrong status", string(StatusPartiallyAvailable), body["status"])
failure, ok := body["failures"]
testhelper.True(t, "body returned nil failures field", ok)
f, ok := failure.(map[string]interface{})
testhelper.True(t, "body returned nil failures.rabbitmq field", ok)
testhelper.Equal(t, "body returned wrong status for rabbitmq", checkErr, f["rabbitmq"])
testhelper.Equal(t, "body returned wrong status for snail-service", string(StatusTimeout), f["snail-service"])
}

56
pkg/health/http.go Normal file
View File

@@ -0,0 +1,56 @@
package health
import (
"context"
"errors"
"fmt"
"net/http"
"time"
)
const defaultRequestTimeout = 5 * time.Second
// Config is the HTTP checker configuration settings container.
type HTTPConfig struct {
// URL is the remote service health check URL.
URL string
// RequestTimeout is the duration that health check will try to consume published test message.
// If not set - 5 seconds
RequestTimeout time.Duration
}
// New creates new HTTP service health check that verifies the following:
// - connection establishing
// - getting response status from defined URL
// - verifying that status code is less than 500
func NewHTTPCheck(config HTTPConfig) CheckFunc {
if config.RequestTimeout == 0 {
config.RequestTimeout = defaultRequestTimeout
}
return func(ctx context.Context) error {
req, err := http.NewRequest(http.MethodGet, config.URL, nil)
if err != nil {
return fmt.Errorf("creating the request for the health check failed: %w", err)
}
ctx, cancel := context.WithTimeout(ctx, config.RequestTimeout)
defer cancel()
// Inform remote service to close the connection after the transaction is complete
req.Header.Set("Connection", "close")
req = req.WithContext(ctx)
res, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("making the request for the health check failed: %w", err)
}
defer res.Body.Close()
if res.StatusCode >= http.StatusInternalServerError {
return errors.New("remote service is not available at the moment")
}
return nil
}
}

20
pkg/health/http_test.go Normal file
View File

@@ -0,0 +1,20 @@
package health_test
import (
"context"
"testing"
"time"
"fiskerinc.com/modules/health"
"fiskerinc.com/modules/testhelper"
)
func TestHTTPCheck(t *testing.T) {
check := health.NewHTTPCheck(health.HTTPConfig{
URL: "http://0.0.0.0:9876",
RequestTimeout: time.Second,
})
err := check(context.Background())
testhelper.Error(t, "has error", err)
}

41
pkg/health/kafka.go Normal file
View File

@@ -0,0 +1,41 @@
package health
import (
"context"
)
type GetKafkaConsumerFunc func() (KafkaConnCheckInterface, error)
func NewKafkaCheck(fn GetKafkaConsumerFunc) CheckFunc {
return func(ctx context.Context) error {
conn, err := fn()
if err != nil {
return err
}
return conn.Check(ctx)
}
}
type KafkaConnCheckInterface interface {
Check(ctx context.Context) error
}
type GetMultiKafkaConsumerFunc func() ([]KafkaConnCheckInterface, error)
func NewKafkaMultiCheck(fn GetMultiKafkaConsumerFunc) CheckFunc {
return func(ctx context.Context) error {
conns, err := fn()
if err != nil {
return err
}
for _, connection := range conns {
err = connection.Check(ctx)
if err != nil {
return err
}
}
return err
}
}

22
pkg/health/mongodb.go Normal file
View File

@@ -0,0 +1,22 @@
package health
import (
"context"
)
type GetMongoClientFunc func() (MongoConnCheckInterface, error)
func NewMongoDBCheck(fn GetMongoClientFunc) CheckFunc {
return func(ctx context.Context) error {
conn, err := fn()
if err != nil {
return err
}
return conn.Ping(ctx)
}
}
type MongoConnCheckInterface interface {
Ping(ctx context.Context) error
}

View File

@@ -0,0 +1,33 @@
package health_test
import (
"context"
"testing"
"fiskerinc.com/modules/health"
"fiskerinc.com/modules/testhelper"
"github.com/pkg/errors"
)
func TestMongoDBCheck(t *testing.T) {
mock := MockMongoDB{}
fn := func() (health.MongoConnCheckInterface, error) {
return &mock, nil
}
check := health.NewMongoDBCheck(fn)
err := check(context.Background())
testhelper.NoError(t, "No error", err)
mock.Error = errors.New("ping error")
err = check(context.Background())
testhelper.Error(t, "Ping error", err)
}
type MockMongoDB struct {
Error error
}
func (m *MockMongoDB) Ping(ctx context.Context) error {
return m.Error
}

21
pkg/health/options.go Normal file
View File

@@ -0,0 +1,21 @@
package health
import (
"fmt"
)
// Option is the health-container options type
type Option func(*Health) error
// WithChecks adds checks to newly instantiated health-container
func WithChecks(checks ...Config) Option {
return func(h *Health) error {
for _, c := range checks {
if err := h.Register(c); err != nil {
return fmt.Errorf("could not register check %q: %w", c.Name, err)
}
}
return nil
}
}

View File

@@ -0,0 +1,28 @@
package health
import (
"testing"
"fiskerinc.com/modules/testhelper"
)
func TestWithChecks(t *testing.T) {
h1, err := New()
testhelper.NoError(t, "New", err)
testhelper.Len(t, "New", h1.checks, 0)
h2, err := New(WithChecks(Config{
Name: "foo",
}, Config{
Name: "bar",
}))
testhelper.NoError(t, "New WithChecks", err)
testhelper.Len(t, "New WithChecks", h2.checks, 2)
_, err = New(WithChecks(Config{
Name: "foo",
}, Config{
Name: "foo",
}))
testhelper.Error(t, "New WithChecks", err)
}

19
pkg/health/postgres.go Normal file
View File

@@ -0,0 +1,19 @@
package health
import (
"context"
"github.com/go-pg/pg/v10/orm"
)
type CheckDBInterface interface {
Ping(ctx context.Context) error
Exec(query interface{}, params ...interface{}) (res orm.Result, err error)
}
func NewPostgresCheck(conn CheckDBInterface) CheckFunc {
return func(ctx context.Context) error {
err := conn.Ping(ctx)
return err
}
}

View File

@@ -0,0 +1,38 @@
package health_test
import (
"context"
"testing"
"fiskerinc.com/modules/health"
"fiskerinc.com/modules/testhelper"
"github.com/go-pg/pg/v10/orm"
"github.com/pkg/errors"
)
func TestPostgresCheck(t *testing.T) {
mock := MockPostgres{}
check := health.NewPostgresCheck(&mock)
err := check(context.Background())
testhelper.NoError(t, "No error", err)
mock.PingError = errors.New("ping error")
err = check(context.Background())
testhelper.Error(t, "Ping error", err)
}
type MockPostgres struct {
OrmResult orm.Result
PingError error
ExecError error
}
func (m *MockPostgres) Ping(ctx context.Context) error {
return m.PingError
}
func (m *MockPostgres) Exec(query interface{}, params ...interface{}) (res orm.Result, err error) {
return m.OrmResult, m.ExecError
}

53
pkg/health/redis.go Normal file
View File

@@ -0,0 +1,53 @@
package health
import (
"context"
"fiskerinc.com/modules/redis"
"fiskerinc.com/modules/redisv2"
)
type RedisHealth struct {
pool redis.ClientPoolInterface
}
func NewRedisHealth(pool redis.ClientPoolInterface) *RedisHealth {
return &RedisHealth{
pool: pool,
}
}
func (h *RedisHealth) Check(ctx context.Context) error {
client := h.pool.GetFromPool()
defer client.Close()
return client.Ping()
}
func (h *RedisHealth) RedisStatus(system *System) {
stats := h.pool.GetPool().Stats()
system.RedisStats = &stats
}
type RedisHealthV2 struct {
redisClient redisv2.ClientInterface
}
func NewRedisHealthV2(redisClient redisv2.ClientInterface) *RedisHealthV2 {
return &RedisHealthV2{
redisClient: redisClient,
}
}
func (h *RedisHealthV2) Check(ctx context.Context) error {
return h.redisClient.Ping()
}
func (h *RedisHealthV2) RedisStatus(system *System) {
// Im not so sure what
// stats := h.redisClient.GetClient().PoolStats()
system.RedisStats = nil
}

19
pkg/health/redis_test.go Normal file
View File

@@ -0,0 +1,19 @@
package health_test
import (
"context"
"testing"
"fiskerinc.com/modules/health"
"fiskerinc.com/modules/redis"
"fiskerinc.com/modules/redis/tester"
"fiskerinc.com/modules/testhelper"
)
func TestRedisCheck(t *testing.T) {
redis.MockRedisConnection()
health := health.NewRedisHealth(tester.NewMockClientPool())
err := health.Check(context.Background())
testhelper.NoError(t, "No error", err)
}

34
pkg/health/server.go Normal file
View File

@@ -0,0 +1,34 @@
package health
import (
"fmt"
"net/http"
"fiskerinc.com/modules/logger"
"fiskerinc.com/modules/utils/envtool"
)
type HealthCheckServer struct {
}
func (h *HealthCheckServer) Serve(configs []Config) error {
port := envtool.GetEnv("HEALTHCHECK_PORT", "11011")
server, err := New()
if err != nil {
return err
}
for _, config := range configs {
err = server.Register(config)
if err != nil {
return err
}
}
logger.Info().Msgf("Health check listening on http://0.0.0.0:%s", port)
http.Handle("/readiness", server.ReadinessHandler())
http.Handle("/liveness", server.LivenessHandler())
logger.Fatal().Err(http.ListenAndServe(fmt.Sprintf(":%s", port), nil)).Send()
return nil
}

12
pkg/health/server_test.go Normal file
View File

@@ -0,0 +1,12 @@
package health_test
import (
"testing"
"fiskerinc.com/modules/health"
)
func TestHealthCheckServer(t *testing.T) {
server := health.HealthCheckServer{}
go server.Serve([]health.Config{})
}

23
pkg/health/tmobile.go Normal file
View File

@@ -0,0 +1,23 @@
package health
import (
"context"
)
type GetTmobileConnCheckFunc func() (TmobileConnCheckInterface, error)
func NewTmobileCheck(fn GetTmobileConnCheckFunc) CheckFunc {
return func(ctx context.Context) error {
client, err := fn()
if err != nil {
return err
}
err = client.Ping(ctx)
return err
}
}
type TmobileConnCheckInterface interface {
Ping(ctx context.Context) error
}

View File

@@ -0,0 +1,33 @@
package health_test
import (
"context"
"testing"
"fiskerinc.com/modules/health"
"fiskerinc.com/modules/testhelper"
"github.com/pkg/errors"
)
func TestTmobileCheck(t *testing.T) {
mock := MockTmobile{}
fn := func() (health.TmobileConnCheckInterface, error) {
return &mock, nil
}
check := health.NewTmobileCheck(fn)
err := check(context.Background())
testhelper.NoError(t, "No error", err)
mock.PingError = errors.New("ping error")
err = check(context.Background())
testhelper.Error(t, "Ping error", err)
}
type MockTmobile struct {
PingError error
}
func (m *MockTmobile) Ping(ctx context.Context) error {
return m.PingError
}