repos
/ orchard main

orchard

mirror

Every site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.

blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics

3.3 KB · 105 lines · Go Raw History
  1package web
  2
  3import (
  4	"context"
  5	"errors"
  6	"fmt"
  7	"io"
  8	"log/slog"
  9	"net/http"
 10	"os"
 11	"os/signal"
 12	"syscall"
 13	"time"
 14)
 15
 16// SetupLogging installs the process-wide structured logger. JSON, because these
 17// logs are read by machine before they are read by a person. Source positions
 18// are off: they are noise in a request log, and the message and attributes
 19// already say where a record came from.
 20func SetupLogging() {
 21	slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
 22		Level: slog.LevelInfo,
 23		// UTC, which is not slog's default. Local time in a container
 24		// silently differs from the host's.
 25		ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
 26			if a.Key == slog.TimeKey && len(groups) == 0 {
 27				a.Value = slog.TimeValue(a.Value.Time().UTC())
 28			}
 29			return a
 30		},
 31	})))
 32}
 33
 34// Serve runs h on addr until SIGINT or SIGTERM, then drains in-flight requests
 35// before returning.
 36func Serve(addr string, h http.Handler) error {
 37	srv := &http.Server{
 38		Addr:    addr,
 39		Handler: h,
 40		// No body timeouts here, unlike the copy in the other five sites. This
 41		// one serves the git wire, where one request carries a whole packfile,
 42		// and Go's ReadTimeout and WriteTimeout bound the entire body and the
 43		// entire response, so a 60s write bound cuts a large clone off
 44		// mid-stream. ReadHeaderTimeout still closes a connection that dribbles
 45		// headers forever, and body size is bounded by the spool cap in wire.go
 46		// and by Cloudflare at the edge.
 47		ReadHeaderTimeout: 10 * time.Second,
 48		ReadTimeout:       0,
 49		WriteTimeout:      0,
 50		IdleTimeout:       120 * time.Second,
 51	}
 52
 53	stop := make(chan os.Signal, 1)
 54	signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
 55
 56	errs := make(chan error, 1)
 57	go func() {
 58		slog.Info("listening", slog.String("addr", addr))
 59		if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
 60			errs <- err
 61		}
 62	}()
 63
 64	select {
 65	case err := <-errs:
 66		return err
 67	case sig := <-stop:
 68		slog.Info("shutting down", slog.String("signal", sig.String()))
 69	}
 70
 71	// Inside the 30s stop_grace_period the compose file asks for, so a
 72	// receive-pack in flight at deploy time finishes writing its pack instead
 73	// of being cut off and leaving a stale lock. Ten seconds, the value the
 74	// other five sites use, silently defeated that grace period.
 75	ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
 76	defer cancel()
 77	return srv.Shutdown(ctx)
 78}
 79
 80// HealthCheck probes a running server over the loopback and reports whether it
 81// answered 200, so that a container can check itself. Two of these images are
 82// FROM scratch: no shell, no curl, nothing a HEALTHCHECK can call except the
 83// binary. The Alpine images could use wget, but one behaviour everywhere beats
 84// two.
 85//
 86// The timeout is short. Docker treats a timed out check as a failure anyway, so
 87// a slow check only delays finding out.
 88func HealthCheck(url string, timeout time.Duration) error {
 89	client := &http.Client{Timeout: timeout}
 90
 91	resp, err := client.Get(url)
 92	if err != nil {
 93		return err
 94	}
 95	// Drained as well as closed: an undrained body leaks the connection out
 96	// of the pool.
 97	defer resp.Body.Close()
 98	_, _ = io.Copy(io.Discard, resp.Body)
 99
100	if resp.StatusCode != http.StatusOK {
101		return fmt.Errorf("%s: %s", url, resp.Status)
102	}
103	return nil
104}