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.
This commit is contained in:
Justin Visser 2026-06-11 14:47:29 +02:00
parent 51af5acf6a
commit 05b01348d5
3 changed files with 79 additions and 2 deletions

View file

@ -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.