1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3// Package httpserver is the HTTP entry of ermite: the router, the security
4// headers, the request log and the graceful shutdown. Routes are added here
5// as the other adapters arrive: /-/health and the routes given in Config.
6package httpserver
7
8import (
9 "context"
10 "errors"
11 "fmt"
12 "net"
13 "net/http"
14 "os"
15 "time"
16
17 "github.com/bnema/zerowrap"
18)
19
20// ShutdownTimeout is how long Run waits for requests in flight after the
21// context ends.
22const ShutdownTimeout = 10 * time.Second
23
24// Check reports whether a dependency works. A nil result means healthy.
25type Check func(ctx context.Context) error
26
27// Route mounts a handler on the router.
28type Route struct {
29 // Pattern is a net/http.ServeMux pattern, such as "GET /{repo}/info/refs".
30 // It must not overlap the /-/ routes of the server.
31 Pattern string
32 Handler http.Handler
33}
34
35// Config configures a Server.
36type Config struct {
37 // Addr is the listen address, "host:port".
38 Addr string
39 // HealthChecks run on every GET /-/health; one failure answers 503.
40 HealthChecks []Check
41 // Routes are added to the router next to /-/health.
42 Routes []Route
43 // API serves the ConnectRPC API under /-/api/ (the prefix is stripped, so
44 // it sees "/ermite.RepoService/ListRepos"). Nil serves none.
45 API http.Handler
46 // OnShutdown functions run, each in its own goroutine, when the graceful
47 // shutdown begins. Use them to end long-lived responses such as event
48 // streams, which the shutdown would otherwise wait for.
49 OnShutdown []func()
50}
51
52// APIPrefix is where the API is mounted.
53const APIPrefix = "/-/api"
54
55// Server is the HTTP server of ermite.
56type Server struct {
57 addr string
58 handler http.Handler
59 onShutdown []func()
60}
61
62// New builds the server and its routes.
63func New(cfg Config) *Server {
64 mux := http.NewServeMux()
65 // "GET" patterns also match HEAD; other methods get 405 with Allow, and
66 // unknown paths get 404.
67 mux.HandleFunc("GET /-/health", healthHandler(cfg.HealthChecks))
68 if cfg.API != nil {
69 mux.Handle(APIPrefix+"/", http.StripPrefix(APIPrefix, cfg.API))
70 }
71 for _, r := range cfg.Routes {
72 mux.Handle(r.Pattern, r.Handler)
73 }
74 return &Server{addr: cfg.Addr, handler: withRequestLog(withSecurityHeaders(mux)), onShutdown: cfg.OnShutdown}
75}
76
77// Handler returns the root handler, for tests.
78func (s *Server) Handler() http.Handler { return s.handler }
79
80// Run listens on the configured address and serves until ctx ends, then
81// shuts down gracefully within ShutdownTimeout. It returns nil after a clean
82// shutdown. The logger of ctx is used for the server and its requests.
83func (s *Server) Run(ctx context.Context) error {
84 ln, err := net.Listen("tcp", s.addr)
85 if err != nil {
86 return fmt.Errorf("listen on %s: %w", s.addr, err)
87 }
88 return s.Serve(ctx, ln)
89}
90
91// Serve is Run on an existing listener, which it closes.
92func (s *Server) Serve(ctx context.Context, ln net.Listener) error {
93 log := zerowrap.FromCtx(ctx)
94 srv := &http.Server{
95 Handler: s.handler,
96 // Requests keep the logger of ctx but outlive its cancellation, so
97 // that a graceful shutdown lets them finish.
98 BaseContext: func(net.Listener) context.Context { return context.WithoutCancel(ctx) },
99 ReadHeaderTimeout: 10 * time.Second,
100 IdleTimeout: 2 * time.Minute,
101 // No read or write timeout: Git clones and pushes can be long.
102 }
103
104 for _, f := range s.onShutdown {
105 srv.RegisterOnShutdown(f)
106 }
107
108 errc := make(chan error, 1)
109 go func() { errc <- srv.Serve(ln) }()
110 log.Info().Str("addr", ln.Addr().String()).Msg("listening")
111
112 select {
113 case err := <-errc:
114 // Serve returns before Shutdown only on a failure.
115 return fmt.Errorf("serve: %w", err)
116 case <-ctx.Done():
117 }
118
119 log.Info().Dur("timeout", ShutdownTimeout).Msg("shutting down")
120 sctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), ShutdownTimeout)
121 defer cancel()
122 if err := srv.Shutdown(sctx); err != nil {
123 _ = srv.Close()
124 return fmt.Errorf("shutdown: %w", err)
125 }
126 if err := <-errc; err != nil && !errors.Is(err, http.ErrServerClosed) {
127 return fmt.Errorf("serve: %w", err)
128 }
129 log.Info().Msg("http server stopped")
130 return nil
131}
132
133// DirWritable returns a Check that creates and removes a file in dir.
134func DirWritable(dir string) Check {
135 return func(context.Context) error {
136 f, err := os.CreateTemp(dir, ".health-*")
137 if err != nil {
138 return fmt.Errorf("data directory is not writable: %w", err)
139 }
140 name := f.Name()
141 cerr := f.Close()
142 rerr := os.Remove(name)
143 if cerr != nil {
144 return fmt.Errorf("data directory is not writable: %w", cerr)
145 }
146 if rerr != nil {
147 return fmt.Errorf("data directory is not writable: %w", rerr)
148 }
149 return nil
150 }
151}