// Command server serves the site the build wrote. // // It reads a directory produced by cmd/build and serves it on loopback. It does // not read content/, does not need Qdrant, OmniRoute or any other service, and // makes no outbound connection, so every page it serves works with the AI stack // stopped (acceptance test A1). // // Configuration comes from the environment so the same binary works under // `make dev`, systemd and compose: // // RESUME_ADDR listen address (default 127.0.0.1:8096) // RESUME_DIST build directory (default "dist") // RESUME_STREAM_DELAY pacing for the streamed panel, e.g. 350ms // // Flags with the same names override the environment for local work. package main import ( "context" "encoding/json" "errors" "flag" "fmt" "io" "log/slog" "net" "net/http" "os" "os/signal" "syscall" "time" "gitea.lab.audasmedia.com.au/sam/ai-resume/internal/server" ) func main() { addr := flag.String("addr", env("RESUME_ADDR", server.DefaultAddr), "listen address") dist := flag.String("dist", env("RESUME_DIST", server.DefaultDist), "build directory") streamDelay := flag.Duration("stream-delay", envDuration("RESUME_STREAM_DELAY", server.DefaultStreamDelay), "delay between streamed panel frames") healthCheck := flag.Bool("health-check", false, "ask a running server for /healthz and exit 0 only if static is ready") flag.Usage = func() { fmt.Fprintf(flag.CommandLine.Output(), "usage: server [--addr HOST:PORT] [--dist DIR] [--stream-delay DURATION] [--health-check]\n") fmt.Fprintf(flag.CommandLine.Output(), "environment: RESUME_ADDR, RESUME_DIST, RESUME_STREAM_DELAY\n") } flag.Parse() // The container image has no shell and no curl, so the health probe is this // binary talking to itself (deploy/compose.yml healthcheck). if *healthCheck { if err := probe(*addr); err != nil { fmt.Fprintf(os.Stderr, "health check failed: %v\n", err) os.Exit(1) } fmt.Println("static ready") return } log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) site, err := server.New(server.Config{ Addr: *addr, DistDir: *dist, StreamDelay: *streamDelay, Logger: log, }) if err != nil { log.Error("start failed", "error", err) os.Exit(1) } defer site.Close() // No WriteTimeout on purpose: the stage stream keeps the response open for // as long as it is producing frames. Reads are bounded. srv := &http.Server{ Addr: *addr, Handler: site.Handler(), ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 120 * time.Second, MaxHeaderBytes: 64 << 10, ErrorLog: slog.NewLogLogger(log.Handler(), slog.LevelWarn), } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() go func() { <-ctx.Done() log.Info("shutting down") shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := srv.Shutdown(shutdownCtx); err != nil { log.Warn("shutdown", "error", err) } }() log.Info("listening", "addr", *addr, "dist", *dist) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Error("serve failed", "error", err) os.Exit(1) } } // probe asks a running server whether the static site is ready. Only the static // field decides the exit code: Mode A must report ready with every AI service // stopped (acceptance test A1). func probe(addr string) error { host, port, err := net.SplitHostPort(addr) if err != nil { return err } if host == "" || host == "0.0.0.0" || host == "::" { host = "127.0.0.1" } client := &http.Client{Timeout: 3 * time.Second} res, err := client.Get("http://" + net.JoinHostPort(host, port) + "/healthz") if err != nil { return err } defer res.Body.Close() if res.StatusCode != http.StatusOK { return fmt.Errorf("/healthz returned %s", res.Status) } var body struct { Static struct { Ready bool `json:"ready"` } `json:"static"` } if err := json.NewDecoder(io.LimitReader(res.Body, 64<<10)).Decode(&body); err != nil { return fmt.Errorf("decode /healthz: %w", err) } if !body.Static.Ready { return errors.New("static is not ready") } return nil } func env(key, fallback string) string { if v, ok := os.LookupEnv(key); ok && v != "" { return v } return fallback } func envDuration(key string, fallback time.Duration) time.Duration { v, ok := os.LookupEnv(key) if !ok || v == "" { return fallback } d, err := time.ParseDuration(v) if err != nil { fmt.Fprintf(os.Stderr, "%s=%q is not a duration: %v\n", key, v, err) os.Exit(2) } return d }