v0.36.0 1fb2421

server.go RawHistoryFolder

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	// VideoHosts are exact external video hosts; nil uses the defaults.
38	VideoHosts []string
39	// Addr is the listen address, "host:port".
40	Addr string
41	// PublicURL is the public base URL of the server. When it is https://,
42	// every response carries Strict-Transport-Security.
43	PublicURL string
44	// HealthChecks run on GET /-/health, at most once per healthCacheTTL
45	// (the answer is cached, the endpoint being unauthenticated); one
46	// failure answers 503.
47	HealthChecks []Check
48	// Routes are added to the router next to /-/health.
49	Routes []Route
50	// API serves the ConnectRPC API under /-/api/ (the prefix is stripped, so
51	// it sees "/ermite.RepoService/ListRepos"). Nil serves none.
52	API http.Handler
53	// OnShutdown functions run, each in its own goroutine, when the graceful
54	// shutdown begins. Use them to end long-lived responses such as event
55	// streams, which the shutdown would otherwise wait for.
56	OnShutdown []func()
57}
58
59// APIPrefix is where the API is mounted.
60const APIPrefix = "/-/api"
61
62// Server is the HTTP server of ermite.
63type Server struct {
64	addr       string
65	handler    http.Handler
66	onShutdown []func()
67}
68
69// New builds the server and its routes.
70func New(cfg Config) *Server {
71	mux := http.NewServeMux()
72	// "GET" patterns also match HEAD; other methods get 405 with Allow, and
73	// unknown paths get 404.
74	mux.HandleFunc("GET /-/health", healthHandler(cfg.HealthChecks))
75	if cfg.API != nil {
76		mux.Handle(APIPrefix+"/", http.StripPrefix(APIPrefix, cfg.API))
77	}
78	for _, r := range cfg.Routes {
79		mux.Handle(r.Pattern, r.Handler)
80	}
81	return &Server{addr: cfg.Addr, handler: withRequestLog(withSecurityHeaders(mux, isHTTPS(cfg.PublicURL), cfg.VideoHosts)), onShutdown: cfg.OnShutdown}
82}
83
84// Handler returns the root handler, for tests.
85func (s *Server) Handler() http.Handler { return s.handler }
86
87// Run listens on the configured address and serves until ctx ends, then
88// shuts down gracefully within ShutdownTimeout. It returns nil after a clean
89// shutdown. The logger of ctx is used for the server and its requests.
90func (s *Server) Run(ctx context.Context) error {
91	ln, err := net.Listen("tcp", s.addr)
92	if err != nil {
93		return fmt.Errorf("listen on %s: %w", s.addr, err)
94	}
95	return s.Serve(ctx, ln)
96}
97
98// Serve is Run on an existing listener, which it closes.
99func (s *Server) Serve(ctx context.Context, ln net.Listener) error {
100	log := zerowrap.FromCtx(ctx)
101	srv := &http.Server{
102		Handler: s.handler,
103		// Requests keep the logger of ctx but outlive its cancellation, so
104		// that a graceful shutdown lets them finish.
105		BaseContext:       func(net.Listener) context.Context { return context.WithoutCancel(ctx) },
106		ReadHeaderTimeout: 10 * time.Second,
107		IdleTimeout:       2 * time.Minute,
108		// No read or write timeout: Git clones and pushes can be long.
109	}
110
111	for _, f := range s.onShutdown {
112		srv.RegisterOnShutdown(f)
113	}
114
115	errc := make(chan error, 1)
116	go func() { errc <- srv.Serve(ln) }()
117	log.Info().Str("addr", ln.Addr().String()).Msg("listening")
118
119	select {
120	case err := <-errc:
121		// Serve returns before Shutdown only on a failure.
122		return fmt.Errorf("serve: %w", err)
123	case <-ctx.Done():
124	}
125
126	log.Info().Dur("timeout", ShutdownTimeout).Msg("shutting down")
127	sctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), ShutdownTimeout)
128	defer cancel()
129	if err := srv.Shutdown(sctx); err != nil {
130		_ = srv.Close()
131		return fmt.Errorf("shutdown: %w", err)
132	}
133	if err := <-errc; err != nil && !errors.Is(err, http.ErrServerClosed) {
134		return fmt.Errorf("serve: %w", err)
135	}
136	log.Info().Msg("http server stopped")
137	return nil
138}
139
140// DirWritable returns a Check that creates and removes a file in dir.
141func DirWritable(dir string) Check {
142	return func(context.Context) error {
143		f, err := os.CreateTemp(dir, ".health-*")
144		if err != nil {
145			return fmt.Errorf("data directory is not writable: %w", err)
146		}
147		name := f.Name()
148		cerr := f.Close()
149		rerr := os.Remove(name)
150		if cerr != nil {
151			return fmt.Errorf("data directory is not writable: %w", cerr)
152		}
153		if rerr != nil {
154			return fmt.Errorf("data directory is not writable: %w", rerr)
155		}
156		return nil
157	}
158}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close