login
This commit is contained in:
16
Dockerfile
16
Dockerfile
@@ -4,7 +4,21 @@ FROM golang:1.24 AS builder
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY . .
|
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 ----------
|
# ---------- Runtime Stage ----------
|
||||||
FROM alpine:latest
|
FROM alpine:latest
|
||||||
|
|||||||
103
controllers/healthController.go
Normal file
103
controllers/healthController.go
Normal file
@@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
165
controllers/health_test.go
Normal file
165
controllers/health_test.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ type Facade struct {
|
|||||||
CatalogueUploadController *controllers.CatalogueUploadController
|
CatalogueUploadController *controllers.CatalogueUploadController
|
||||||
ScanController *controllers.ScanController
|
ScanController *controllers.ScanController
|
||||||
AssistantController *controllers.AssistantController
|
AssistantController *controllers.AssistantController
|
||||||
|
HealthController *controllers.HealthController
|
||||||
MCPController *controllers.MCPController
|
MCPController *controllers.MCPController
|
||||||
|
|
||||||
// Tools is what Nearle Buddy is allowed to do.
|
// 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)
|
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
|
// The second door. Same registry, same agents, same session — see
|
||||||
// controllers/mcpController.go for why it is a door rather than a service.
|
// controllers/mcpController.go for why it is a door rather than a service.
|
||||||
mcpController := controllers.NewMCPController(toolRegistry, agents)
|
mcpController := controllers.NewMCPController(toolRegistry, agents)
|
||||||
@@ -209,6 +214,7 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB, embedder utils.Embedder, chat
|
|||||||
CatalogueUploadController: catalogueUploadController,
|
CatalogueUploadController: catalogueUploadController,
|
||||||
ScanController: scanController,
|
ScanController: scanController,
|
||||||
AssistantController: assistantController,
|
AssistantController: assistantController,
|
||||||
|
HealthController: healthController,
|
||||||
MCPController: mcpController,
|
MCPController: mcpController,
|
||||||
Tools: toolRegistry,
|
Tools: toolRegistry,
|
||||||
posService: posService,
|
posService: posService,
|
||||||
|
|||||||
@@ -40,4 +40,12 @@ func RegisterRoutes(app *fiber.App, f *facade.Facade) {
|
|||||||
RegisterUploadRoutes(api, f)
|
RegisterUploadRoutes(api, f)
|
||||||
RegisterScanRoutes(api, f)
|
RegisterScanRoutes(api, f)
|
||||||
RegisterAssistantRoutes(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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestTheAssistantSurfaceSitsBehindTheSessionGuard(t *testing.T) {
|
||||||
// The assistant reads the same data the console does and must read it as
|
// 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 —
|
// the same person. Being under `/v1/web` is what puts it behind WebAuth —
|
||||||
|
|||||||
Reference in New Issue
Block a user