From 05b01348d506598dac6dd58b9b4df184eeec9837 Mon Sep 17 00:00:00 2001 From: Justin Visser Date: Thu, 11 Jun 2026 14:47:29 +0200 Subject: [PATCH] Portainer stack docs and a real container healthcheck, slice 4 of M4 The README deploy section documents the GHCR image, the compose stack that joins Caddy's external network, and that the Caddyfile entry is managed by hand on the server. distroless has no shell, so the healthcheck is the binary itself: a new -check flag probes /healthz and exits 0 or 1, and the Dockerfile bakes it in as HEALTHCHECK. Verified locally: the container reports healthy. --- Dockerfile | 3 +++ README.md | 40 ++++++++++++++++++++++++++++++++++++++-- cmd/spreadlab/main.go | 38 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 6a53d7d..251e3df 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,6 +26,9 @@ RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /spreadlab ./cmd/spread FROM gcr.io/distroless/static-debian12:nonroot COPY --from=build /spreadlab /spreadlab EXPOSE 8080 +# No shell or curl in distroless, so the healthcheck is the binary +# probing its own /healthz (the -addr default targets localhost:8080). +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s CMD ["/spreadlab", "-check"] # 0.0.0.0, not the dev default localhost: inside a container the # loopback interface is unreachable from outside. ENTRYPOINT ["/spreadlab", "-addr", ":8080"] diff --git a/README.md b/README.md index 0636938..a1600dc 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,9 @@ Early development; interface and API are not stable yet. - [x] Simulation engine in Go (tested, deterministic, benchmarked) - [x] JSON API + TypeScript types generated from the Go structs - [x] Web frontend reproducing the three-scenario comparison from live data -- [ ] Interactive dashboard (controls, network view, spread animation) -- [ ] Single-binary deploy (embedded frontend), Docker image, hosted demo +- [x] Interactive dashboard (controls, network view, spread animation) +- [x] Single-binary deploy (embedded frontend), public Docker image +- [ ] Hosted demo - [ ] Intervention optimisation under a budget ## Quick start (development) @@ -75,6 +76,41 @@ cd web && npm run dev # frontend on localhost:5173 `go run ./cmd/spreadlab -table` prints the three-scenario comparison to the terminal as a quick engine sanity check. +## Deploy + +Every merge to main publishes a public image to +`ghcr.io/justinzeus/spreadlab`, tagged `latest` and the commit SHA (for +rollbacks). The image is self-contained: one Go binary with the built +frontend embedded, serving the dashboard on `/`, the API under `/api`, +and `/healthz`. A healthcheck is baked in (the binary probes its own +`/healthz`; the distroless base has no shell). + +The production setup is a Portainer stack behind Caddy. The stack joins +the reverse proxy's external docker network, so no ports are published; +Caddy reaches the app at `spreadlab:8080` over that network: + +```yaml +services: + spreadlab: + image: ghcr.io/justinzeus/spreadlab:latest + container_name: spreadlab + restart: unless-stopped + networks: + - caddy + +networks: + caddy: + external: true +``` + +The network name must match the one Caddy actually uses (`docker network +ls` on the server). The Caddyfile entry itself is managed by hand on the +server, not in this repo: add a site block for the public hostname that +reverse-proxies to `spreadlab:8080`, and reload Caddy. + +To run the image anywhere else: `docker run -p 8080:8080 +ghcr.io/justinzeus/spreadlab:latest`. + ## Project layout | Path | What it is | diff --git a/cmd/spreadlab/main.go b/cmd/spreadlab/main.go index 11890f5..6898e7e 100644 --- a/cmd/spreadlab/main.go +++ b/cmd/spreadlab/main.go @@ -9,9 +9,11 @@ import ( "fmt" "io" "log" + "net" "net/http" "os" "strings" + "time" "github.com/JustinZeus/spreadlab/internal/api" "github.com/JustinZeus/spreadlab/internal/engine" @@ -21,6 +23,7 @@ import ( func main() { addr := flag.String("addr", "localhost:8080", "address to serve the API on") table := flag.Bool("table", false, "print the three-scenario comparison and exit") + check := flag.Bool("check", false, "probe a running server's /healthz and exit 0 or 1") flag.Parse() if *table { @@ -31,6 +34,16 @@ func main() { return } + // The runtime image has no shell or curl, so the compose healthcheck + // runs this same binary against the server instance. + if *check { + if err := probeHealthz(*addr); err != nil { + fmt.Fprintln(os.Stderr, "spreadlab:", err) + os.Exit(1) + } + return + } + // One mux, one origin: the longer /api/ pattern wins over / for API // calls, everything else falls through to the embedded frontend. mux := http.NewServeMux() @@ -46,6 +59,31 @@ func main() { } } +// probeHealthz asks a running server whether it is healthy. The -addr +// flag doubles as the target, so the healthcheck and the server agree +// on the port by using the same default. A "localhost:8080" style addr +// (or ":8080", where the host part is empty) becomes a loopback URL. +func probeHealthz(addr string) error { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return fmt.Errorf("invalid addr %q: %w", addr, err) + } + if host == "" { + host = "localhost" + } + + client := http.Client{Timeout: 3 * time.Second} + response, err := client.Get(fmt.Sprintf("http://%s/healthz", net.JoinHostPort(host, port))) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode >= 400 { + return fmt.Errorf("healthz answered %s", response.Status) + } + return nil +} + // run executes the three-scenario comparison in the default world and // writes the table to out. Formatting is pure string building; the single // write at the end is the only error to handle.