From b95d601ff6144610ca967ca439885f2a045b2cc7 Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Sat, 30 May 2026 20:45:15 -0400 Subject: [PATCH] feat: automate health check with a written file --- app/server/main.ts | 7 +++ cmd/hp_healthcheck/hp_healthcheck.go | 67 +++++++++++++--------------- docs/configuration/index.md | 10 ++--- runtime/http.ts | 17 ++++++- 4 files changed, 57 insertions(+), 44 deletions(-) diff --git a/app/server/main.ts b/app/server/main.ts index bc198ea..e6766ef 100644 --- a/app/server/main.ts +++ b/app/server/main.ts @@ -42,10 +42,17 @@ if (certPath || keyPath) { } } +// Read by the bundled Docker healthcheck. Includes the basename +// (`__PREFIX__`) so the Go binary can stay completely dumb — just +// GET whatever URL is in this file. `/tmp` is writable in +// distroless and in every dev shell we care about. +const healthURL = `${tls ? "https" : "http"}://127.0.0.1:${config.server.port}${__PREFIX__}/healthz`; + startHttpServer({ host: config.server.host, port: config.server.port, tls, + listenFile: { path: "/tmp/headplane-listen", url: healthURL }, listener: composeListener({ basename: __PREFIX__, staticRoot: clientDir, diff --git a/cmd/hp_healthcheck/hp_healthcheck.go b/cmd/hp_healthcheck/hp_healthcheck.go index 4162335..c0a7669 100644 --- a/cmd/hp_healthcheck/hp_healthcheck.go +++ b/cmd/hp_healthcheck/hp_healthcheck.go @@ -5,27 +5,37 @@ import ( "fmt" "net/http" "os" + "strings" "time" ) -// hp_healthcheck pings the Headplane /healthz endpoint. It's invoked -// from the Docker HEALTHCHECK directive, so it must work whether the -// container is serving plain HTTP or has TLS termination enabled. +// hp_healthcheck pings the Headplane healthz endpoint. It's invoked +// from the Docker HEALTHCHECK directive inside the same container +// as Headplane itself. // -// Configuration (via env, all optional): -// - HEADPLANE_HEALTHCHECK_URL full URL, takes precedence over the -// pieces below (default: http://localhost:3000/admin/healthz) -// - HEADPLANE_HEALTHCHECK_TLS "true" to use https:// -// - HEADPLANE_HEALTHCHECK_HOST default: localhost -// - HEADPLANE_HEALTHCHECK_PORT default: 3000 -// - HEADPLANE_HEALTHCHECK_PATH default: /admin/healthz +// To stay fully zero-config, Headplane writes the exact URL the +// healthcheck should hit — scheme, port, and basename included — to +// listenFile when it starts accepting connections (see +// runtime/http.ts and app/server/main.ts). This binary just reads +// that file and GETs the URL verbatim. No env vars, no YAML +// parsing, no path-joining, no compile-time knowledge of the +// basename. +// +// If the file is missing (e.g. the server hasn't finished booting +// on the very first probe, or this is an old image being run with +// a new healthcheck) we fall back to the historical default. +const ( + listenFile = "/tmp/headplane-listen" + defaultURL = "http://localhost:3000/admin/healthz" +) + func main() { - url := healthcheckURL() + url := readListenFile() client := http.Client{ Timeout: 5 * time.Second, Transport: &http.Transport{ - // We just care that the server is alive and with us + // Self-signed certs are normal for in-process TLS termination TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, }, } @@ -43,33 +53,16 @@ func main() { } fmt.Println("Health check passed.") - os.Exit(0) } -func healthcheckURL() string { - if v := os.Getenv("HEADPLANE_HEALTHCHECK_URL"); v != "" { - return v +func readListenFile() string { + data, err := os.ReadFile(listenFile) + if err != nil { + return defaultURL } - - scheme := "http" - if os.Getenv("HEADPLANE_HEALTHCHECK_TLS") == "true" { - scheme = "https" + url := strings.TrimSpace(string(data)) + if url == "" { + return defaultURL } - - host := os.Getenv("HEADPLANE_HEALTHCHECK_HOST") - if host == "" { - host = "localhost" - } - - port := os.Getenv("HEADPLANE_HEALTHCHECK_PORT") - if port == "" { - port = "3000" - } - - path := os.Getenv("HEADPLANE_HEALTHCHECK_PATH") - if path == "" { - path = "/admin/healthz" - } - - return fmt.Sprintf("%s://%s:%s%s", scheme, host, port, path) + return url } diff --git a/docs/configuration/index.md b/docs/configuration/index.md index c20e523..a94e22d 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -113,12 +113,10 @@ For most deployments we still recommend terminating TLS at a reverse proxy and other services. Built-in TLS is meant for the simpler "Headplane on a single box" scenarios. -If you're running Headplane in Docker with TLS enabled, the bundled -healthcheck binary needs to know it should use HTTPS. Set the following -environment variables on the container: - -- `HEADPLANE_HEALTHCHECK_TLS=true` -- `HEADPLANE_HEALTHCHECK_PORT` (if you've changed it from the default `3000`) +The bundled Docker healthcheck picks up the right scheme and port +automatically — Headplane writes its loopback URL to `/tmp/headplane-listen` +when it starts, and the healthcheck reads it from there. No extra environment +variables, no duplicated config. ## Reverse Proxying diff --git a/runtime/http.ts b/runtime/http.ts index ca79147..85e9dd2 100644 --- a/runtime/http.ts +++ b/runtime/http.ts @@ -4,7 +4,7 @@ // React Router request listener from `@react-router/node`) and serves // static assets out of a directory. import { createReadStream } from "node:fs"; -import { stat } from "node:fs/promises"; +import { stat, writeFile } from "node:fs/promises"; import { type IncomingMessage, type RequestListener, @@ -172,6 +172,14 @@ export interface StartOptions { listener: RequestListener; tls?: HttpsServerOptions; logger?: Logger; + /** + * If set, writes `url` to `path` once the server is accepting + * connections. Used by the bundled Docker healthcheck binary to + * discover the right scheme, port, and basename without any + * duplicate configuration. The caller assembles the URL so that + * the runtime stays generic. + */ + listenFile?: { path: string; url: string }; /** * Optional async hook invoked on SIGINT/SIGTERM after the HTTP * server stops accepting new connections but before the process @@ -194,6 +202,13 @@ export function startHttpServer(opts: StartOptions): Server { server.listen(opts.port, opts.host, () => { const proto = opts.tls ? "https" : "http"; log.info("Listening on %s://%s:%s", proto, opts.host, opts.port); + + if (opts.listenFile) { + const { path, url } = opts.listenFile; + writeFile(path, `${url}\n`, "utf8").catch((err) => { + log.error("Failed to write listen file %s: %s", path, err); + }); + } }); const shutdown = async (signal: string) => {