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.