From db84a9a752f8ee78656c4d74aa9714dd11f42b3c Mon Sep 17 00:00:00 2001 From: abhishek Date: Fri, 25 Sep 2026 09:53:16 +0530 Subject: [PATCH] login --- Dockerfile | 16 +++- controllers/healthController.go | 103 ++++++++++++++++++++ controllers/health_test.go | 165 ++++++++++++++++++++++++++++++++ facade/container.go | 6 ++ routes/routes.go | 8 ++ routes/startup_test.go | 20 ++++ 6 files changed, 317 insertions(+), 1 deletion(-) create mode 100644 controllers/healthController.go create mode 100644 controllers/health_test.go diff --git a/Dockerfile b/Dockerfile index 46f8e32..f7ea73d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,21 @@ FROM golang:1.24 AS builder WORKDIR /app COPY . . -RUN CGO_ENABLED=0 GOOS=linux go build -o server +# Which commit this image is. Reported by GET /live/api/v1/health, so "I pushed +# it" and "it is running" stop being the same sentence — a redeploy can reuse a +# cached image, and there was no way to tell from outside. +# +# Passed by the platform as a build argument: +# docker build --build-arg BUILD_VERSION=$(git rev-parse --short HEAD) . +# In Dokploy this goes under the application's Build settings. Left unset it +# reads "unknown", which is itself worth seeing — it means nothing stamped it. +# +# `.git` is not in the build context (see .dockerignore), so the build cannot +# work this out for itself. +ARG BUILD_VERSION=unknown + +RUN CGO_ENABLED=0 GOOS=linux go build \ + -ldflags "-X nearle/controllers.Version=${BUILD_VERSION}" -o server # ---------- Runtime Stage ---------- FROM alpine:latest diff --git a/controllers/healthController.go b/controllers/healthController.go new file mode 100644 index 0000000..6e433e5 --- /dev/null +++ b/controllers/healthController.go @@ -0,0 +1,103 @@ +package controllers + +import ( + "net/http" + "runtime/debug" + "strings" + + "nearle/services" + + "github.com/gofiber/fiber/v2" +) + +// What is running here, and is it wired up? +// +// ── Why this exists ───────────────────────────────────────────────────────── +// +// On 2026-09-24 the assistant sat switched off in production for most of a day, +// and neither of us could establish WHY from outside the container. Two +// questions had no answer: +// +// 1. which build is deployed? A redeploy can reuse a cached image, so +// "I pushed it" and "it is running" are different facts. +// 2. does the server have a model? `/assistant/status` knows, but it sits +// behind the session guard, and a 401 from `/v1/web` proves nothing — +// the middleware answers before routing, so a route that does not exist +// returns exactly the same 401 as one that does. +// +// Every diagnosis that day was guesswork for want of one request. Hours went +// into probing CORS headers and comparing nginx versions to infer a commit, +// which is what people do when a server will not simply say. +// +// ── What it deliberately does not say ─────────────────────────────────────── +// +// Booleans, never values. "The assistant has a model" is operational; WHICH +// model, at which endpoint, under which key is not, and the reason string on +// `/assistant/status` names environment variables — that stays behind the +// guard. Nothing here distinguishes a tenant, so there is nothing to scope. +// +// Unauthenticated on purpose. A health check that needs a credential cannot be +// used by the person trying to work out why credentials are not working, and +// that is precisely when it is wanted. +type HealthController struct { + assistant services.AssistantService + // hasDatabase is a construction-time fact, not a live ping. A query per + // health check is a query per uptime probe, and "configured" is the thing + // that actually differs between a broken deployment and a working one. + hasDatabase bool +} + +func NewHealthController(assistant services.AssistantService, hasDatabase bool) *HealthController { + return &HealthController{assistant: assistant, hasDatabase: hasDatabase} +} + +// Version is stamped at build time: +// +// go build -ldflags "-X nearle/controllers.Version=$(git rev-parse --short HEAD)" +// +// Left as "unknown" when nothing stamps it, which is honest — and itself worth +// seeing, because it means the image was not built by the pipeline that does. +var Version = "unknown" + +// buildVersion falls back to whatever the toolchain recorded. +// +// `debug.ReadBuildInfo` carries the VCS revision for a build made inside a git +// checkout, so even an image built by hand usually knows its own commit. The +// ldflag is preferred because a Docker build copies the tree without `.git`. +func buildVersion() string { + if Version != "unknown" && strings.TrimSpace(Version) != "" { + return Version + } + info, ok := debug.ReadBuildInfo() + if !ok { + return "unknown" + } + for _, setting := range info.Settings { + if setting.Key == "vcs.revision" && setting.Value != "" { + if len(setting.Value) > 7 { + return setting.Value[:7] + } + return setting.Value + } + } + return "unknown" +} + +func (ctl *HealthController) Health(c *fiber.Ctx) error { + assistant := false + if ctl.assistant != nil { + assistant = ctl.assistant.Available() + } + + return c.Status(http.StatusOK).JSON(fiber.Map{ + "code": http.StatusOK, "status": true, "message": "Success", + "details": fiber.Map{ + "version": buildVersion(), + // True when a model is configured and the assistant can answer. False + // is the answer to "I set the key and redeployed, did it take?" — + // which took a day to establish without it. + "assistant": assistant, + "database": ctl.hasDatabase, + }, + }) +} diff --git a/controllers/health_test.go b/controllers/health_test.go new file mode 100644 index 0000000..c791421 --- /dev/null +++ b/controllers/health_test.go @@ -0,0 +1,165 @@ +package controllers + +import ( + "context" + "encoding/json" + "io" + "net/http/httptest" + "strings" + "testing" + + "nearle/services" + "nearle/services/tools" + + "github.com/gofiber/fiber/v2" +) + +/* +A server that can say what it is. + +This exists because of a day spent unable to answer two questions about a +running deployment: which build is it, and does the assistant have a model. Both +were knowable inside the container and neither was reachable from outside — +`/assistant/status` sits behind the session guard, and a 401 from `/v1/web` +proves nothing, because the middleware answers before routing and a route that +does not exist returns the same 401 as one that does. + +So the tests that matter here are about what it answers WITHOUT a session, and +about what it refuses to include. +*/ + +func healthApp(t *testing.T, assistantReady bool, hasDatabase bool) *fiber.App { + t.Helper() + + app := fiber.New() + controller := NewHealthController(stubAssistant{ready: assistantReady}, hasDatabase) + app.Get("/live/api/v1/health", controller.Health) + return app +} + +func readHealth(t *testing.T, app *fiber.App) (int, map[string]any, string) { + t.Helper() + + resp, err := app.Test(httptest.NewRequest("GET", "/live/api/v1/health", nil), -1) + if err != nil { + t.Fatalf("health: %v", err) + } + raw, _ := io.ReadAll(resp.Body) + + var envelope struct { + Details map[string]any `json:"details"` + } + if err := json.Unmarshal(raw, &envelope); err != nil { + t.Fatalf("not the envelope the console unwraps: %s", raw) + } + return resp.StatusCode, envelope.Details, string(raw) +} + +func TestHealthAnswersWithoutASession(t *testing.T) { + // The point. A health check that needs a credential cannot be used by the + // person working out why credentials are not working — which is exactly + // when somebody reaches for it. + status, details, body := readHealth(t, healthApp(t, true, true)) + + if status != fiber.StatusOK { + t.Fatalf("HTTP %d without a session: %s", status, body) + } + if details["version"] == nil { + t.Fatalf("no build id: %s", body) + } +} + +func TestHealthSaysWhetherTheAssistantHasAModel(t *testing.T) { + // "I set the key and redeployed — did it take?" took a day to answer. This + // is that answer, in one unauthenticated request. + _, ready, _ := readHealth(t, healthApp(t, true, true)) + if ready["assistant"] != true { + t.Fatalf("a configured assistant reported as %v", ready["assistant"]) + } + + _, off, body := readHealth(t, healthApp(t, false, true)) + if off["assistant"] != false { + t.Fatalf("an unconfigured assistant reported as %v: %s", off["assistant"], body) + } +} + +func TestHealthNeverLeaksTheConfiguration(t *testing.T) { + // Booleans, never values. WHICH model, at which endpoint, under which key is + // not operational information, and the `reason` string on /assistant/status + // names environment variables — that stays behind the guard. + _, _, body := readHealth(t, healthApp(t, false, true)) + + for _, secret := range []string{ + "ASSISTANT_", "api.groq.com", "gsk_", "openai/gpt-oss", "POS_TOKEN", "password", + } { + if strings.Contains(strings.ToLower(body), strings.ToLower(secret)) { + t.Fatalf("%q is exposed on an unauthenticated endpoint: %s", secret, body) + } + } +} + +func TestHealthSurvivesAServerWithNothingWiredUp(t *testing.T) { + // A deployment with no database and no model must still ANSWER. This is the + // state in which somebody is most likely to ask, and a 500 here would leave + // them exactly where they started. + app := fiber.New() + app.Get("/live/api/v1/health", NewHealthController(nil, false).Health) + + resp, err := app.Test(httptest.NewRequest("GET", "/live/api/v1/health", nil), -1) + if err != nil { + t.Fatalf("health: %v", err) + } + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("a bare server could not report its own health: HTTP %d", resp.StatusCode) + } + + raw, _ := io.ReadAll(resp.Body) + var envelope struct { + Details map[string]any `json:"details"` + } + _ = json.Unmarshal(raw, &envelope) + + if envelope.Details["assistant"] != false || envelope.Details["database"] != false { + t.Fatalf("a bare server claimed to be wired up: %s", raw) + } +} + +func TestAnUnstampedBuildSaysSoRatherThanGuessing(t *testing.T) { + // "unknown" is informative: it means nothing stamped the image, so the + // version cannot be trusted to date it. Inventing one would be worse than + // admitting it. + original := Version + Version = "unknown" + defer func() { Version = original }() + + got := buildVersion() + // Either the toolchain recorded a revision, or it says unknown. What it must + // not do is return an empty string, which renders as a blank field and reads + // like the endpoint is broken. + if strings.TrimSpace(got) == "" { + t.Fatal("the build id is blank") + } +} + +func TestAStampedBuildIsReported(t *testing.T) { + original := Version + Version = "abc1234" + defer func() { Version = original }() + + _, details, body := readHealth(t, healthApp(t, true, true)) + if details["version"] != "abc1234" { + t.Fatalf("the stamped build id was not reported: %s", body) + } +} + +// stubAssistant is only ever asked one question. +type stubAssistant struct{ ready bool } + +func (s stubAssistant) Available() bool { return s.ready } +func (s stubAssistant) Unavailable() string { return "" } +func (s stubAssistant) Ask(_ context.Context, _, _ string, _ tools.Caller) (services.AssistantAnswer, error) { + return services.AssistantAnswer{}, nil +} +func (s stubAssistant) Approve(_ context.Context, _, _ string, _ tools.Caller) (services.AssistantAnswer, error) { + return services.AssistantAnswer{}, nil +} diff --git a/facade/container.go b/facade/container.go index 284bc11..1560036 100644 --- a/facade/container.go +++ b/facade/container.go @@ -28,6 +28,7 @@ type Facade struct { CatalogueUploadController *controllers.CatalogueUploadController ScanController *controllers.ScanController AssistantController *controllers.AssistantController + HealthController *controllers.HealthController MCPController *controllers.MCPController // Tools is what Nearle Buddy is allowed to do. @@ -189,6 +190,10 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB, embedder utils.Embedder, chat } assistantController := controllers.NewAssistantController(assistantService) + // What is running here. Unauthenticated, booleans only — see healthController.go + // for why a server that cannot say which build it is costs a day. + healthController := controllers.NewHealthController(assistantService, db != nil) + // The second door. Same registry, same agents, same session — see // controllers/mcpController.go for why it is a door rather than a service. mcpController := controllers.NewMCPController(toolRegistry, agents) @@ -209,6 +214,7 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB, embedder utils.Embedder, chat CatalogueUploadController: catalogueUploadController, ScanController: scanController, AssistantController: assistantController, + HealthController: healthController, MCPController: mcpController, Tools: toolRegistry, posService: posService, diff --git a/routes/routes.go b/routes/routes.go index 6d4ef98..451ba26 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -40,4 +40,12 @@ func RegisterRoutes(app *fiber.App, f *facade.Facade) { RegisterUploadRoutes(api, f) RegisterScanRoutes(api, f) RegisterAssistantRoutes(api, f) + + // What is running here. + // + // Registered on `api` and NOT under `/v1/web`, so it answers without a + // session — which is the whole point. The question it exists for is "why + // does nothing work", and a health check that needs a working credential + // cannot answer that. It returns booleans and a build id, never values. + api.Get("/v1/health", f.HealthController.Health) } diff --git a/routes/startup_test.go b/routes/startup_test.go index 75a2c59..fd385f1 100644 --- a/routes/startup_test.go +++ b/routes/startup_test.go @@ -90,6 +90,26 @@ func TestEveryAssistantRouteIsReachable(t *testing.T) { } } +func TestHealthAnswersThroughTheRealRouteTableWithoutASession(t *testing.T) { + // Registered on `api` rather than under `/v1/web`, which is what keeps it + // outside the session guard. Asserted here rather than trusted, because the + // difference is one path segment and getting it wrong makes the endpoint + // useless for the only situation it exists for: nothing else works. + // + // It also has to survive a facade built with no database, no model and no + // embedder — the state somebody is most likely to be asking from. + app := fiber.New() + RegisterRoutes(app, testFacade(t)) + + resp, err := app.Test(httptest.NewRequest("GET", "/live/api/v1/health", nil), -1) + if err != nil { + t.Fatalf("calling health: %v", err) + } + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("health needs a session or is unregistered: HTTP %d", resp.StatusCode) + } +} + func TestTheAssistantSurfaceSitsBehindTheSessionGuard(t *testing.T) { // The assistant reads the same data the console does and must read it as // the same person. Being under `/v1/web` is what puts it behind WebAuth —