diff --git a/cmd/wg-portal/main.go b/cmd/wg-portal/main.go index 430d92d..823b42a 100644 --- a/cmd/wg-portal/main.go +++ b/cmd/wg-portal/main.go @@ -9,7 +9,7 @@ import ( "syscall" "time" - "git.prolicht.digital/golib/healthcheck" + "github.com/h44z/wg-portal/internal/common/healthcheck" "github.com/h44z/wg-portal/internal/server" "github.com/sirupsen/logrus" ) diff --git a/go.mod b/go.mod index 0523699..147cc14 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,6 @@ module github.com/h44z/wg-portal go 1.18 require ( - git.prolicht.digital/golib/healthcheck v1.1.1 github.com/evanphx/json-patch v5.6.0+incompatible github.com/gin-contrib/sessions v0.0.5 github.com/gin-gonic/gin v1.8.2 diff --git a/go.sum b/go.sum index d1f4a12..6c6d93a 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -git.prolicht.digital/golib/healthcheck v1.1.1 h1:bdx0MuGqAq0PCooPpiuPXsr4/Ok+yfJwq8P9ITq2eLI= -git.prolicht.digital/golib/healthcheck v1.1.1/go.mod h1:wEqVrqHJ8NsSx5qlFGUlw74wJ/wDSKaA34QoyvsEkdc= github.com/Azure/go-ntlmssp v0.0.0-20220621081337-cb9428e4ac1e/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= diff --git a/internal/common/healthcheck/healthcheck.go b/internal/common/healthcheck/healthcheck.go new file mode 100644 index 0000000..c275d31 --- /dev/null +++ b/internal/common/healthcheck/healthcheck.go @@ -0,0 +1,116 @@ +// source taken from https://git.prolicht.digital/golib/healthcheck/-/blob/master/healthcheck.go + +package healthcheck + +import ( + "context" + "fmt" + "net/http" + "os" + "time" +) + +type service struct { + listenAddress string + checkFunc func() int +} + +type Option func(svc *service) + +// New creates a new healthcheck instance that can be started with either Start() or StartWithContext(). +func New(opts ...Option) *service { + svc := &service{ + listenAddress: ":11223", + checkFunc: func() int { + return http.StatusOK + }, + } + for _, opt := range opts { + opt(svc) + } + return svc +} + +// Start starts a background goroutine with the healthcheck webserver. This goroutine is only stopped +// if the whole program is shut down. +func (s *service) Start() { + s.StartWithContext(context.Background()) +} + +// StartForeground starts a goroutine with the healthcheck webserver. This function will block until the context +// gets canceled or the healthcheck server crashes. +func (s *service) StartForeground(ctx context.Context) { + router := http.NewServeMux() + router.Handle("/health", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(s.checkFunc()) + })) + + srv := &http.Server{ + Addr: s.listenAddress, + Handler: router, + ReadTimeout: 5 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 15 * time.Second, + } + + srvContext, cancelFn := context.WithCancel(ctx) + go func() { + if err := srv.ListenAndServe(); err != nil { + fmt.Printf("[HEALTHCHECK] web service on %s exited: %v\n", s.listenAddress, err) + cancelFn() + } + }() + + // Wait for the main context to end, this call blocks + <-srvContext.Done() + + // 1-second grace period + shutdownCtx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + srv.SetKeepAlivesEnabled(false) // disable keep-alive kills idle connections + _ = srv.Shutdown(shutdownCtx) + + fmt.Println("[HEALTHCHECK] web service stopped") +} + +// StartWithContext starts a background goroutine with the healthcheck webserver. The goroutine will be +// stopped if the context gets canceled or the healthcheck server crashes. +func (s *service) StartWithContext(ctx context.Context) { + go s.StartForeground(ctx) +} + +// ListenOn allows to change the default listening address of ":11223". +func ListenOn(addr string) Option { + return func(svc *service) { + svc.listenAddress = addr + } +} + +// WithCustomCheck allows to use a custom check function. The integer return value of the check +// function is used as HTTP status code. +func WithCustomCheck(fnc func() int) Option { + return func(svc *service) { + if fnc != nil { + svc.checkFunc = fnc + } + } +} + +// ListenOnFromEnv sets the listening address to a value retrieved from the environment variable +// HC_LISTEN_ADDR. +// If the argument list is not empty, the listening address value will be loaded from an +// environment variable with the name of the first list entry. +// If the environment variable was empty, the listening address will not be overridden. +func ListenOnFromEnv(envName ...string) Option { + return func(svc *service) { + varName := "HC_LISTEN_ADDR" + if len(envName) > 0 { + varName = envName[0] + } + + listenAddr := os.Getenv(varName) + if listenAddr != "" { + svc.listenAddress = listenAddr + } + } +} diff --git a/internal/common/healthcheck/healthcheck_test.go b/internal/common/healthcheck/healthcheck_test.go new file mode 100644 index 0000000..3f388e4 --- /dev/null +++ b/internal/common/healthcheck/healthcheck_test.go @@ -0,0 +1,243 @@ +// source taken from https://git.prolicht.digital/golib/healthcheck/-/blob/master/healthcheck_test.go + +package healthcheck + +import ( + "context" + "net/http" + "os" + "reflect" + "testing" + "time" +) + +func TestNew(t *testing.T) { + type args struct { + opts []Option + } + tests := []struct { + name string + args args + want *service + }{ + { + name: "Test Plain", + args: args{}, + want: &service{ + listenAddress: ":11223", + checkFunc: nil, // we cannot compare the check function + }, + }, + { + name: "Test With Empty Options", + args: args{ + opts: []Option{}, + }, + want: &service{ + listenAddress: ":11223", + checkFunc: nil, // we cannot compare the check function + }, + }, + { + name: "Test With Options", + args: args{ + opts: []Option{ListenOn(":123456")}, + }, + want: &service{ + listenAddress: ":123456", + checkFunc: nil, // we cannot compare the check function + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := New(tt.args.opts...) + got.checkFunc = nil + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("New() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestListenOn(t *testing.T) { + type args struct { + addr string + } + tests := []struct { + name string + args args + want *service + }{ + { + name: "Test Port Only", + args: args{ + addr: ":8080", + }, + want: &service{ + listenAddress: ":8080", + checkFunc: nil, // cannot deeply compare check func, + }, + }, + { + name: "Test Addr:Port Only", + args: args{ + addr: "localhost:8080", + }, + want: &service{ + listenAddress: "localhost:8080", + checkFunc: nil, // cannot deeply compare check func, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := New(ListenOn(tt.args.addr)) + got.checkFunc = nil + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ListenOn() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestListenOnEnv(t *testing.T) { + _ = os.Setenv("HC_LISTEN_ADDR", "") + hc := New(ListenOnFromEnv()) + if hc.listenAddress != New().listenAddress { + t.Errorf("ListenOnFromEnv() = %v, want %v", hc.listenAddress, New().listenAddress) + } + + want := ":1337" + _ = os.Setenv("HC_LISTEN_ADDR", want) + hc = New(ListenOnFromEnv()) + if hc.listenAddress != want { + t.Errorf("ListenOnFromEnv() = %v, want %v", hc.listenAddress, want) + } + + hc = New() // check that the env var has no effect + if hc.listenAddress != New().listenAddress { + t.Errorf("ListenOnFromEnv() = %v, want %v", hc.listenAddress, New().listenAddress) + } + + want = ":1338" + _ = os.Setenv("SOME_RANDOM_ENV_VAR", want) + hc = New(ListenOnFromEnv("SOME_RANDOM_ENV_VAR")) + if hc.listenAddress != want { + t.Errorf("ListenOnFromEnv() = %v, want %v", hc.listenAddress, want) + } + + hc = New(ListenOnFromEnv("SOME_RANDOM_ENV_VAR", "ignored", "ignored 2")) + if hc.listenAddress != want { + t.Errorf("ListenOnFromEnv() = %v, want %v", hc.listenAddress, want) + } +} + +func TestWithCustomCheck(t *testing.T) { + customFnc := func() int { return 123 } + + type args struct { + fnc func() int + } + tests := []struct { + name string + args args + want *service + wantFnc func() int + }{ + { + name: "Test Custom Function", + args: args{ + fnc: customFnc, + }, + want: &service{ + listenAddress: New().listenAddress, + checkFunc: nil, // cannot deeply compare check func, + }, + wantFnc: customFnc, + }, + { + name: "Test Nil Function", + args: args{ + fnc: nil, + }, + want: &service{ + listenAddress: New().listenAddress, + checkFunc: nil, // cannot deeply compare check func, + }, + wantFnc: New().checkFunc, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := New(WithCustomCheck(tt.args.fnc)) + gotFnc := got.checkFunc + got.checkFunc = nil + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("WithContext() = %v, want %v", got, tt.want) + } + + if reflect.ValueOf(gotFnc).Pointer() != reflect.ValueOf(tt.wantFnc).Pointer() { + t.Error("WithContext() function mismatch") + } + }) + } +} + +func Test_service_StartForeground(t *testing.T) { + runTime := 550 * time.Millisecond + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + + hc := New() + start := time.Now() + hc.StartForeground(ctx) + elapsed := time.Since(start) + + // check if execution time is within +-10% of the runTime + if elapsed > (runTime+(runTime/10)) || elapsed < (runTime-(runTime/10)) { + t.Errorf("StartForeground() invalid execution time = %v, want %v", elapsed, runTime) + } +} + +func Test_service_HTTPResponse(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + + hc := New() + hc.StartWithContext(ctx) + time.Sleep(200 * time.Millisecond) // ensure that web server is up and running + + cl := http.Client{Timeout: time.Millisecond * 200} + req, _ := http.NewRequest("GET", "http://localhost:11223/health", nil) + resp, err := cl.Do(req) + if err != nil { + t.Errorf("http request failed: %v", err) + return + } + if resp.StatusCode != http.StatusOK { + t.Errorf("http request with wrong response code: %v, want %v", err, http.StatusOK) + } + + <-ctx.Done() // wait for clean shutdown +} + +func Test_service_CustomCheckResponse(t *testing.T) { + want := http.StatusExpectationFailed + hc := New(WithCustomCheck(func() int { + return want + })) + hc.Start() + time.Sleep(200 * time.Millisecond) // ensure that web server is up and running + + cl := http.Client{Timeout: time.Millisecond * 200} + req, _ := http.NewRequest("GET", "http://localhost:11223/health", nil) + resp, err := cl.Do(req) + if err != nil { + t.Errorf("http request failed: %v", err) + return + } + if resp.StatusCode != want { + t.Errorf("http request with wrong response code: %v, want %v", err, want) + } +}