From 3786e1faab07de7e13e42b55124119c2d955aa32 Mon Sep 17 00:00:00 2001 From: Mohamed Sallam Date: Sat, 9 Aug 2025 02:20:53 +0300 Subject: [PATCH 1/9] Allow load balancers and kubernetes to call the health check endpoint when basic auth is applied --- cmd/riverui/auth_middleware.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cmd/riverui/auth_middleware.go b/cmd/riverui/auth_middleware.go index acc6d727..dddf634c 100644 --- a/cmd/riverui/auth_middleware.go +++ b/cmd/riverui/auth_middleware.go @@ -3,6 +3,7 @@ package main import ( "crypto/subtle" "net/http" + "strings" ) type authMiddleware struct { @@ -25,7 +26,10 @@ func (m *authMiddleware) Middleware(next http.Handler) http.Handler { func isReqAuthorized(req *http.Request, username, password string) bool { reqUsername, reqPassword, ok := req.BasicAuth() - return ok && + isHealthCheck := strings.Contains(req.URL.Path, "/api/health-checks/") + isValidAuth := ok && subtle.ConstantTimeCompare([]byte(reqUsername), []byte(username)) == 1 && subtle.ConstantTimeCompare([]byte(reqPassword), []byte(password)) == 1 + + return isHealthCheck || isValidAuth } From 61e5dc597095c6af935c5a77bfb96a0bfe5d768a Mon Sep 17 00:00:00 2001 From: Mohamed Sallam Date: Sat, 9 Aug 2025 02:21:38 +0300 Subject: [PATCH 2/9] Provide health check command for AWS ECS and Docker Compose without external dependencies --- cmd/riverui/main.go | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/cmd/riverui/main.go b/cmd/riverui/main.go index a9228e50..42b83d25 100644 --- a/cmd/riverui/main.go +++ b/cmd/riverui/main.go @@ -7,6 +7,7 @@ import ( "flag" "fmt" "log/slog" + "net" "net/http" "os" "strings" @@ -31,8 +32,20 @@ func main() { var pathPrefix string flag.StringVar(&pathPrefix, "prefix", "/", "path prefix to use for the API and UI HTTP requests") + + var healthCheckName string + flag.StringVar(&healthCheckName, "healthcheck", "", "the name of the health checks: minimal or complete") + flag.Parse() + if healthCheckName != "" { + if err := checkHealth(ctx, pathPrefix, healthCheckName); err != nil { + logger.ErrorContext(ctx, "Error checking for server health", slog.String("error", err.Error())) + os.Exit(1) + } + os.Exit(0) + } + initRes, err := initServer(ctx, logger, pathPrefix) if err != nil { logger.ErrorContext(ctx, "Error initializing server", slog.String("error", err.Error())) @@ -180,3 +193,30 @@ func startAndListen(ctx context.Context, logger *slog.Logger, initRes *initServe return nil } + +func checkHealth(ctx context.Context, pathPrefix string, healthCheckName string) error { + host := cmp.Or(os.Getenv("RIVER_HOST"), "localhost") + port := cmp.Or(os.Getenv("PORT"), "8080") + pathPrefix = riverui.NormalizePathPrefix(pathPrefix) + hostname := net.JoinHostPort(host, port) + url := fmt.Sprintf("http://%s%s/api/health-checks/%s", hostname, pathPrefix, healthCheckName) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("error constructing request to health endpoint: %w", err) + } + response, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("error requesting health endpoint: %w", err) + } + + err = response.Body.Close() + if err != nil { + return fmt.Errorf("error closing health endpoint response body: %w", err) + } + + if response.StatusCode != http.StatusOK { + return fmt.Errorf("health endpoint returned status code %d instead of 200", response.StatusCode) + } + return nil +} From 26e2929b303617c697eb7bd1d8ace5bf054d5644 Mon Sep 17 00:00:00 2001 From: Mohamed Sallam Date: Sat, 9 Aug 2025 02:22:08 +0300 Subject: [PATCH 3/9] Document health checks --- docs/README.md | 51 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/docs/README.md b/docs/README.md index 0130eac7..c4d2801a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -42,6 +42,57 @@ $ docker pull ghcr.io/riverqueue/riverui:latest $ docker run -p 8080:8080 --env DATABASE_URL ghcr.io/riverqueue/riverui:latest ``` +## Health Checks +River UI exposes two types of health checks: +1. `minimal`: Will succeed if the server can return a response regardless of the database connection. +2. `complete`: Will succeed if the database connection is working. + +For production deployments, it is recommended to use the `complete` health check. + +### How to use +#### HTTP Endpoint +Useful when running on Kubernetes or behind load balancer that can hit the HTTP endpoint. + +The URL would be `{prefix}/api/health-checks/{name}` + +- `{prefix}` is the path prefix set in the environment variable `PATH_PREFIX` or `-prefix` flag +- `{name}` is the health check name. Can be `minimal` or `complete`.}` + +**Example:** When setting `PATH_PREFIX=/my-prefix` and wanting to include the database connection in the health check the path would be +`/my-prefix/api/health-checks/complete` + +#### CLI Flag +Useful when running under something like AWS ECS where it cannot query the HTTP endpoint natively. + +The CLI flag will query the HTTP endpoint internally and return the result. + +This keeps the image small since we don't rely on an http client like `curl` + +**Example:** When using a prefix like `/my-prefix` and wanting to include the database connection in the health check the command would be +`riverui -prefix=/my-prefix -healthcheck=complete` + +When setting this command in ECS tasks for healtechecks it would something like this: +```json +{ + "containerDefinitions": [ + { + "name": "riverui", + "image": "ghcr.io/riverqueue/riverui:latest", + "essential": true, + "healthCheck": { + "command": [ + "CMD", + "/bin/riverui", + "-prefix=/my-prefix", + "-healthcheck=complete" + ] + } + } + ] +} +``` + + ## Configuration ### Custom path prefix From c545ec2114d66fc3d301d0d407428b2787e07494 Mon Sep 17 00:00:00 2001 From: Mohamed Sallam Date: Sat, 9 Aug 2025 21:37:53 +0300 Subject: [PATCH 4/9] Move health check documentation to its own file --- docs/README.md | 49 +-------------------------------------- docs/healthchecks.md | 55 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 48 deletions(-) create mode 100644 docs/healthchecks.md diff --git a/docs/README.md b/docs/README.md index c4d2801a..ab50f3a8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -43,54 +43,7 @@ $ docker run -p 8080:8080 --env DATABASE_URL ghcr.io/riverqueue/riverui:latest ``` ## Health Checks -River UI exposes two types of health checks: -1. `minimal`: Will succeed if the server can return a response regardless of the database connection. -2. `complete`: Will succeed if the database connection is working. - -For production deployments, it is recommended to use the `complete` health check. - -### How to use -#### HTTP Endpoint -Useful when running on Kubernetes or behind load balancer that can hit the HTTP endpoint. - -The URL would be `{prefix}/api/health-checks/{name}` - -- `{prefix}` is the path prefix set in the environment variable `PATH_PREFIX` or `-prefix` flag -- `{name}` is the health check name. Can be `minimal` or `complete`.}` - -**Example:** When setting `PATH_PREFIX=/my-prefix` and wanting to include the database connection in the health check the path would be -`/my-prefix/api/health-checks/complete` - -#### CLI Flag -Useful when running under something like AWS ECS where it cannot query the HTTP endpoint natively. - -The CLI flag will query the HTTP endpoint internally and return the result. - -This keeps the image small since we don't rely on an http client like `curl` - -**Example:** When using a prefix like `/my-prefix` and wanting to include the database connection in the health check the command would be -`riverui -prefix=/my-prefix -healthcheck=complete` - -When setting this command in ECS tasks for healtechecks it would something like this: -```json -{ - "containerDefinitions": [ - { - "name": "riverui", - "image": "ghcr.io/riverqueue/riverui:latest", - "essential": true, - "healthCheck": { - "command": [ - "CMD", - "/bin/riverui", - "-prefix=/my-prefix", - "-healthcheck=complete" - ] - } - } - ] -} -``` +See [health checks](./healthchecks.md). ## Configuration diff --git a/docs/healthchecks.md b/docs/healthchecks.md new file mode 100644 index 00000000..739931f4 --- /dev/null +++ b/docs/healthchecks.md @@ -0,0 +1,55 @@ +# River UI Health Checks +River UI exposes two types of health checks: +1. `minimal`: Will succeed if the server can return a response regardless of the database connection. +2. `complete`: Will succeed if the database connection is working. + +For production deployments, it is recommended to use the `complete` health check. + +## How to use +### HTTP Endpoint +Useful when running on Kubernetes or behind load balancer that can hit the HTTP endpoint. + +The URL would be `{prefix}/api/health-checks/{name}` + +- `{prefix}` is the path prefix set in the environment variable `PATH_PREFIX` or `-prefix` flag +- `{name}` is the health check name. Can be `minimal` or `complete`. + +**Example:** When setting `PATH_PREFIX=/my-prefix` and wanting to include the database connection in the health check the path would be +```text +/my-prefix/api/health-checks/complete +``` + +### CLI Flag +The riverui binary provides `-healthcheck=` flag. This flag allows the binary to perform a health check as a command. + +This useful when the container orchestrator cannot hit the health check endpoint natively. Like in AWS ECS Tasks or Docker Compose file. + +The CLI flag will query the HTTP endpoint internally and exit based on the response. + +This keeps the container image small without having to install additional dependencies. + +**Example:** When using a prefix like `/my-prefix` and wanting to include the database connection in the health check the command would be +```text +/bin/riverui -prefix=/my-prefix -healthcheck=complete +``` + +When setting this command in ECS tasks for healtechecks it would something like this: +```json +{ + "containerDefinitions": [ + { + "name": "riverui", + "image": "ghcr.io/riverqueue/riverui:latest", + "essential": true, + "healthCheck": { + "command": [ + "CMD", + "/bin/riverui", + "-prefix=/my-prefix", + "-healthcheck=complete" + ] + } + } + ] +} +``` \ No newline at end of file From 1ce54add5596ce69d77819b28ae1cfa2ff81329e Mon Sep 17 00:00:00 2001 From: Mohamed Sallam Date: Sat, 9 Aug 2025 22:57:11 +0300 Subject: [PATCH 5/9] Fix whitespace --- docs/README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/README.md b/docs/README.md index ab50f3a8..94c39b85 100644 --- a/docs/README.md +++ b/docs/README.md @@ -45,7 +45,6 @@ $ docker run -p 8080:8080 --env DATABASE_URL ghcr.io/riverqueue/riverui:latest ## Health Checks See [health checks](./healthchecks.md). - ## Configuration ### Custom path prefix From 37aeaeda641f22f91cb3bcbc6cfb789e696d0704 Mon Sep 17 00:00:00 2001 From: Mohamed Sallam Date: Sun, 10 Aug 2025 00:15:34 +0300 Subject: [PATCH 6/9] Add tests for basic auth middleware --- cmd/riverui/auth_middlware_test.go | 83 ++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 cmd/riverui/auth_middlware_test.go diff --git a/cmd/riverui/auth_middlware_test.go b/cmd/riverui/auth_middlware_test.go new file mode 100644 index 00000000..d0471be0 --- /dev/null +++ b/cmd/riverui/auth_middlware_test.go @@ -0,0 +1,83 @@ +package main + +import ( + "cmp" + "context" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/riverqueue/river/rivershared/riversharedtest" +) + +func TestAuthMiddleware(t *testing.T) { + var ( + ctx = context.Background() + databaseURL = cmp.Or(os.Getenv("TEST_DATABASE_URL"), "postgres://localhost/river_test") + basicAuthUser = "test_auth_user" + basicAuthPassword = "test_auth_pass" + ) + + t.Setenv("DEV", "true") + t.Setenv("DATABASE_URL", databaseURL) + t.Setenv("RIVER_BASIC_AUTH_USER", basicAuthUser) + t.Setenv("RIVER_BASIC_AUTH_PASS", basicAuthPassword) + + setup := func(t *testing.T, prefix string) http.Handler { + t.Helper() + initRes, err := initServer(ctx, riversharedtest.Logger(t), prefix) + require.NoError(t, err) + t.Cleanup(initRes.dbPool.Close) + + return initRes.httpServer.Handler + } + + t.Run("Unauthorized", func(t *testing.T) { + t.Parallel() + handler := setup(t, "/") + req := httptest.NewRequest(http.MethodGet, "/api/jobs", nil) + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, req) + + require.Equal(t, http.StatusUnauthorized, recorder.Code) + }) + + t.Run("Authorized", func(t *testing.T) { + t.Parallel() + handler := setup(t, "/") + req := httptest.NewRequest(http.MethodGet, "/api/jobs", nil) + req.SetBasicAuth(basicAuthUser, basicAuthPassword) + + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, req) + + require.Equal(t, http.StatusOK, recorder.Code) + }) + + t.Run("Healthcheck exemption", func(t *testing.T) { + t.Parallel() + handler := setup(t, "/") + req := httptest.NewRequest(http.MethodGet, "/api/health-checks/complete", nil) + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, req) + + require.Equal(t, http.StatusOK, recorder.Code) + }) + + t.Run("Healthcheck exemption with prefix", func(t *testing.T) { + t.Parallel() + handler := setup(t, "/test-prefix") + req := httptest.NewRequest(http.MethodGet, "/test-prefix/api/health-checks/complete", nil) + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, req) + + require.Equal(t, http.StatusOK, recorder.Code) + }) +} From d7e08f4e355301821acfcc32aa43c440ad1e5df0 Mon Sep 17 00:00:00 2001 From: Mohamed Sallam Date: Sun, 10 Aug 2025 00:19:04 +0300 Subject: [PATCH 7/9] Fix filename --- cmd/riverui/{auth_middlware_test.go => auth_middleware_test.go} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename cmd/riverui/{auth_middlware_test.go => auth_middleware_test.go} (100%) diff --git a/cmd/riverui/auth_middlware_test.go b/cmd/riverui/auth_middleware_test.go similarity index 100% rename from cmd/riverui/auth_middlware_test.go rename to cmd/riverui/auth_middleware_test.go From 8b413c236116e5a06d9532ef2b0cfc1d648cc518 Mon Sep 17 00:00:00 2001 From: Mohamed Sallam Date: Sun, 10 Aug 2025 00:39:50 +0300 Subject: [PATCH 8/9] Follow file naming convention --- docs/README.md | 2 +- docs/{healthchecks.md => health_checks.md} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename docs/{healthchecks.md => health_checks.md} (100%) diff --git a/docs/README.md b/docs/README.md index 94c39b85..67c8114e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -43,7 +43,7 @@ $ docker run -p 8080:8080 --env DATABASE_URL ghcr.io/riverqueue/riverui:latest ``` ## Health Checks -See [health checks](./healthchecks.md). +See [health checks](health_checks.md). ## Configuration diff --git a/docs/healthchecks.md b/docs/health_checks.md similarity index 100% rename from docs/healthchecks.md rename to docs/health_checks.md From 5b8d5148acaeb55edf0a58152d982ad4004eac48 Mon Sep 17 00:00:00 2001 From: Mohamed Sallam Date: Sun, 10 Aug 2025 00:47:31 +0300 Subject: [PATCH 9/9] Disable parallel tests for auth middleware --- cmd/riverui/auth_middleware_test.go | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/cmd/riverui/auth_middleware_test.go b/cmd/riverui/auth_middleware_test.go index d0471be0..392b1ddb 100644 --- a/cmd/riverui/auth_middleware_test.go +++ b/cmd/riverui/auth_middleware_test.go @@ -35,8 +35,7 @@ func TestAuthMiddleware(t *testing.T) { return initRes.httpServer.Handler } - t.Run("Unauthorized", func(t *testing.T) { - t.Parallel() + t.Run("Unauthorized", func(t *testing.T) { //nolint:paralleltest handler := setup(t, "/") req := httptest.NewRequest(http.MethodGet, "/api/jobs", nil) recorder := httptest.NewRecorder() @@ -46,8 +45,7 @@ func TestAuthMiddleware(t *testing.T) { require.Equal(t, http.StatusUnauthorized, recorder.Code) }) - t.Run("Authorized", func(t *testing.T) { - t.Parallel() + t.Run("Authorized", func(t *testing.T) { //nolint:paralleltest handler := setup(t, "/") req := httptest.NewRequest(http.MethodGet, "/api/jobs", nil) req.SetBasicAuth(basicAuthUser, basicAuthPassword) @@ -59,8 +57,7 @@ func TestAuthMiddleware(t *testing.T) { require.Equal(t, http.StatusOK, recorder.Code) }) - t.Run("Healthcheck exemption", func(t *testing.T) { - t.Parallel() + t.Run("Healthcheck exemption", func(t *testing.T) { //nolint:paralleltest handler := setup(t, "/") req := httptest.NewRequest(http.MethodGet, "/api/health-checks/complete", nil) recorder := httptest.NewRecorder() @@ -70,8 +67,7 @@ func TestAuthMiddleware(t *testing.T) { require.Equal(t, http.StatusOK, recorder.Code) }) - t.Run("Healthcheck exemption with prefix", func(t *testing.T) { - t.Parallel() + t.Run("Healthcheck exemption with prefix", func(t *testing.T) { //nolint:paralleltest handler := setup(t, "/test-prefix") req := httptest.NewRequest(http.MethodGet, "/test-prefix/api/health-checks/complete", nil) recorder := httptest.NewRecorder()