v0.8.1 b8e893f

config.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3// Package config parses and validates the server configuration from the
4// environment. It is a composition-root helper: only internal/app imports it, and it never
5// reads the process environment itself, the caller passes a lookup function.
6package config
7
8import (
9	"crypto/sha256"
10	"fmt"
11	"net"
12	"net/netip"
13	"net/url"
14	"path/filepath"
15	"strconv"
16	"strings"
17	"time"
18	"unicode/utf8"
19)
20
21// Variable names.
22const (
23	EnvAddr           = "ERMITE_ADDR"
24	EnvDataDir        = "ERMITE_DATA_DIR"
25	EnvPublicURL      = "ERMITE_PUBLIC_URL"
26	EnvAdminToken     = "ERMITE_ADMIN_TOKEN"
27	EnvTrustedProxies = "ERMITE_TRUSTED_PROXIES"
28	EnvGit            = "ERMITE_GIT"
29	EnvGitReader      = "ERMITE_GIT_READER"
30	EnvSourceURL      = "ERMITE_SOURCE_URL"
31	EnvLogLevel       = "ERMITE_LOG_LEVEL"
32	EnvLogFormat      = "ERMITE_LOG_FORMAT"
33	// EnvCommitterName and EnvCommitterEmail name the committer of the
34	// commits ermite creates (squash merges, ADR 0022).
35	EnvCommitterName  = "ERMITE_COMMITTER_NAME"
36	EnvCommitterEmail = "ERMITE_COMMITTER_EMAIL"
37	// EnvOwner is the owner login CI shows in the github context
38	// ("<owner>/<repo>"); it follows the rules of repository names.
39	EnvOwner = "ERMITE_OWNER"
40	// EnvCILogRetention is how long CI runs, jobs, checks and logs are kept.
41	EnvCILogRetention = "ERMITE_CI_LOG_RETENTION"
42	// EnvCIPollTimeout is how long FetchJob holds a runner's call. Lower it
43	// when a reverse proxy cuts idle responses sooner than the default 50 s.
44	EnvCIPollTimeout = "ERMITE_CI_POLL_TIMEOUT"
45)
46
47// Defaults.
48const (
49	DefaultAddr      = ":7341"
50	DefaultGit       = "git"
51	DefaultGitReader = GitReaderErmiteGit
52	DefaultLogLevel  = "info"
53	DefaultLogFormat = "json"
54	// DefaultCommitterName is the committer name of the commits ermite creates.
55	DefaultCommitterName = "ermite"
56	// DefaultOwner is the owner login when ERMITE_OWNER is not set.
57	DefaultOwner = "owner"
58)
59
60// Values of ERMITE_GIT_READER.
61const (
62	// GitReaderCLI reads repositories with the git binary only.
63	GitReaderCLI = "cli"
64	// GitReaderErmiteGit reads them with the ermite-git library where it is
65	// proven faster, and with the git binary elsewhere.
66	GitReaderErmiteGit = "ermite-git"
67)
68
69// Bounds of ERMITE_CI_POLL_TIMEOUT. The upper bound keeps the poll under the 60 s
70// that the runner waits for one FetchJob; the lower one stops runners from
71// polling in a tight loop.
72const (
73	MinCIPollTimeout = time.Second
74	MaxCIPollTimeout = 55 * time.Second
75)
76
77// MinAdminTokenLen is the shortest accepted admin token, in characters.
78const MinAdminTokenLen = 32
79
80// Config is the validated server configuration.
81type Config struct {
82	// Addr is the listen address, "host:port".
83	Addr string
84	// DataDir is the absolute, clean data root.
85	DataDir string
86	// PublicURL is the base URL without a trailing slash.
87	PublicURL string
88	// AdminTokenHash is the SHA-256 of the admin token. The token itself is
89	// dropped; compare with a constant-time function.
90	AdminTokenHash [sha256.Size]byte
91	// TrustedProxies are the networks allowed to set X-Forwarded-For.
92	TrustedProxies []netip.Prefix
93	// Git is the git executable, a name or a path.
94	Git string
95	// GitReader is GitReaderCLI or GitReaderErmiteGit.
96	GitReader string
97	// SourceURL is the link to the source of the running version.
98	SourceURL string
99	// LogLevel is one of trace, debug, info, warn, error.
100	LogLevel string
101	// LogFormat is json or console.
102	LogFormat string
103	// CommitterName and CommitterEmail are recorded as the committer of the
104	// commits ermite creates. The email defaults to "ermite@<host of
105	// PublicURL>".
106	CommitterName, CommitterEmail string
107	// Owner is the owner login of the github context of CI runs.
108	Owner string
109	// CIRetention is how long CI data is kept; 0 (variable unset) leaves the
110	// default to the CI use case.
111	CIRetention time.Duration
112	// CIPollTimeout is how long FetchJob holds a call; 0 (variable unset)
113	// leaves the default to the CI use case.
114	CIPollTimeout time.Duration
115}
116
117// Error lists every configuration problem found, in variable order.
118type Error struct {
119	Problems []string
120}
121
122// Error implements error.
123func (e *Error) Error() string {
124	var b strings.Builder
125	b.WriteString("invalid configuration:")
126	for _, p := range e.Problems {
127		b.WriteString("\n  - ")
128		b.WriteString(p)
129	}
130	return b.String()
131}
132
133// Load reads the configuration through lookup, which returns the value of a
134// variable, or "" when it is unset (os.Getenv fits). An empty value counts as
135// unset. All problems are reported together in an *Error. Messages name the
136// variable and never contain the admin token.
137func Load(get func(string) string) (Config, error) {
138	var problems []string
139	bad := func(key, format string, args ...any) {
140		problems = append(problems, key+": "+fmt.Sprintf(format, args...))
141	}
142	var c Config
143
144	c.Addr = get(EnvAddr)
145	if c.Addr == "" {
146		c.Addr = DefaultAddr
147	} else if err := validateAddr(c.Addr); err != nil {
148		bad(EnvAddr, "%v", err)
149	}
150
151	switch dir := get(EnvDataDir); {
152	case dir == "":
153		bad(EnvDataDir, "required")
154	case !filepath.IsAbs(dir):
155		bad(EnvDataDir, "must be an absolute path")
156	default:
157		c.DataDir = filepath.Clean(dir)
158	}
159
160	publicOK := false
161	if raw := get(EnvPublicURL); raw == "" {
162		bad(EnvPublicURL, "required")
163	} else if u, err := parseURL(raw, false); err != nil {
164		bad(EnvPublicURL, "%v", err)
165	} else {
166		c.PublicURL = u
167		publicOK = true
168	}
169
170	if token := get(EnvAdminToken); token == "" {
171		bad(EnvAdminToken, "required")
172	} else if n := utf8.RuneCountInString(token); n < MinAdminTokenLen {
173		bad(EnvAdminToken, "must be at least %d characters (got %d)", MinAdminTokenLen, n)
174	} else {
175		c.AdminTokenHash = sha256.Sum256([]byte(token))
176	}
177
178	if raw := get(EnvTrustedProxies); raw != "" {
179		prefixes, err := parseCIDRs(raw)
180		if err != nil {
181			bad(EnvTrustedProxies, "%v", err)
182		}
183		c.TrustedProxies = prefixes
184	}
185
186	c.Git = get(EnvGit)
187	if c.Git == "" {
188		c.Git = DefaultGit
189	}
190
191	c.GitReader = strings.ToLower(strings.TrimSpace(get(EnvGitReader)))
192	switch c.GitReader {
193	case "":
194		c.GitReader = DefaultGitReader
195	case GitReaderCLI, GitReaderErmiteGit:
196	default:
197		bad(EnvGitReader, "must be %s or %s (got %q)", GitReaderCLI, GitReaderErmiteGit, get(EnvGitReader))
198	}
199
200	switch raw := get(EnvSourceURL); {
201	case raw != "":
202		if u, err := parseURL(raw, true); err != nil {
203			bad(EnvSourceURL, "%v", err)
204		} else {
205			c.SourceURL = u
206		}
207	case publicOK:
208		c.SourceURL = c.PublicURL + "/ermite"
209	}
210
211	c.LogLevel = strings.ToLower(get(EnvLogLevel))
212	switch c.LogLevel {
213	case "":
214		c.LogLevel = DefaultLogLevel
215	case "trace", "debug", "info", "warn", "error":
216	default:
217		bad(EnvLogLevel, "must be one of trace, debug, info, warn, error (got %q)", get(EnvLogLevel))
218	}
219
220	c.LogFormat = strings.ToLower(get(EnvLogFormat))
221	switch c.LogFormat {
222	case "":
223		c.LogFormat = DefaultLogFormat
224	case "json", "console":
225	default:
226		bad(EnvLogFormat, "must be json or console (got %q)", get(EnvLogFormat))
227	}
228
229	c.CommitterName = strings.TrimSpace(get(EnvCommitterName))
230	if c.CommitterName == "" {
231		c.CommitterName = DefaultCommitterName
232	} else if err := validateIdentity(c.CommitterName); err != nil {
233		bad(EnvCommitterName, "%v", err)
234	}
235	c.CommitterEmail = strings.TrimSpace(get(EnvCommitterEmail))
236	switch {
237	case c.CommitterEmail != "":
238		if err := validateIdentity(c.CommitterEmail); err != nil {
239			bad(EnvCommitterEmail, "%v", err)
240		} else if !strings.Contains(c.CommitterEmail, "@") || strings.ContainsAny(c.CommitterEmail, " \t") {
241			bad(EnvCommitterEmail, "must be an email address such as ermite@example.org")
242		}
243	case publicOK:
244		u, _ := url.Parse(c.PublicURL)
245		c.CommitterEmail = "ermite@" + u.Hostname()
246	}
247
248	// The name rules of the owner are those of repository names (the domain);
249	// config imports nothing, so internal/app checks them (see loadConfig).
250	c.Owner = strings.TrimSpace(get(EnvOwner))
251	if c.Owner == "" {
252		c.Owner = DefaultOwner
253	}
254
255	if raw := strings.TrimSpace(get(EnvCILogRetention)); raw != "" {
256		d, err := parseRetention(raw)
257		if err != nil {
258			bad(EnvCILogRetention, "%v", err)
259		} else {
260			c.CIRetention = d
261		}
262	}
263
264	if raw := strings.TrimSpace(get(EnvCIPollTimeout)); raw != "" {
265		d, err := time.ParseDuration(raw)
266		switch {
267		case err != nil:
268			bad(EnvCIPollTimeout, "must be a duration such as 25s (got %q)", raw)
269		case d < MinCIPollTimeout || d > MaxCIPollTimeout:
270			bad(EnvCIPollTimeout, "must be between %v and %v (got %q)", MinCIPollTimeout, MaxCIPollTimeout, raw)
271		default:
272			c.CIPollTimeout = d
273		}
274	}
275
276	if len(problems) > 0 {
277		return Config{}, &Error{Problems: problems}
278	}
279	return c, nil
280}
281
282// parseRetention reads a duration in days ("90d") or in Go's syntax ("720h"),
283// at least one hour.
284func parseRetention(raw string) (time.Duration, error) {
285	var d time.Duration
286	if days, ok := strings.CutSuffix(raw, "d"); ok {
287		n, err := strconv.Atoi(days)
288		if err != nil || n < 1 || n > 36500 {
289			return 0, fmt.Errorf("must be a number of days such as 90d (got %q)", raw)
290		}
291		d = time.Duration(n) * 24 * time.Hour
292	} else {
293		var err error
294		if d, err = time.ParseDuration(raw); err != nil {
295			return 0, fmt.Errorf("must be a duration such as 90d or 720h (got %q)", raw)
296		}
297	}
298	if d < time.Hour {
299		return 0, fmt.Errorf("must be at least one hour (got %q)", raw)
300	}
301	return d, nil
302}
303
304// validateIdentity accepts a name or an email address for a Git identity: valid
305// UTF-8 without control characters or the angle brackets and line breaks that
306// Git does not allow in an identity.
307func validateIdentity(s string) error {
308	if !utf8.ValidString(s) {
309		return fmt.Errorf("must be valid UTF-8")
310	}
311	for _, r := range s {
312		if r < 0x20 || r == 0x7f || r == '<' || r == '>' {
313			return fmt.Errorf("must not contain control characters, '<' or '>'")
314		}
315	}
316	return nil
317}
318
319func validateAddr(addr string) error {
320	_, port, err := net.SplitHostPort(addr)
321	if err != nil {
322		return fmt.Errorf("must be host:port, such as %q (%v)", DefaultAddr, err)
323	}
324	n, err := strconv.Atoi(port)
325	if err != nil || n < 1 || n > 65535 {
326		return fmt.Errorf("port must be a number from 1 to 65535 (got %q)", port)
327	}
328	return nil
329}
330
331// parseURL accepts an http(s) URL with a host and no credentials, query or
332// fragment, and returns it without a trailing slash. A path is accepted only
333// with allowPath.
334func parseURL(raw string, allowPath bool) (string, error) {
335	u, err := url.Parse(raw)
336	if err != nil {
337		return "", fmt.Errorf("not a valid URL: %v", err)
338	}
339	switch {
340	case u.Scheme != "http" && u.Scheme != "https":
341		return "", fmt.Errorf("scheme must be http or https (got %q)", u.Scheme)
342	case u.Host == "" || u.Hostname() == "":
343		return "", fmt.Errorf("host is missing")
344	case u.User != nil:
345		return "", fmt.Errorf("must not contain credentials")
346	case !allowPath && u.Path != "" && u.Path != "/":
347		return "", fmt.Errorf("must not contain a path (got %q)", u.Path)
348	case u.RawQuery != "" || u.ForceQuery:
349		return "", fmt.Errorf("must not contain a query")
350	case u.Fragment != "" || strings.Contains(raw, "#"):
351		return "", fmt.Errorf("must not contain a fragment")
352	}
353	return strings.TrimSuffix(u.String(), "/"), nil
354}
355
356func parseCIDRs(raw string) ([]netip.Prefix, error) {
357	var out []netip.Prefix
358	for part := range strings.SplitSeq(raw, ",") {
359		part = strings.TrimSpace(part)
360		if part == "" {
361			return nil, fmt.Errorf("empty entry in the comma-separated list")
362		}
363		p, err := netip.ParsePrefix(part)
364		if err != nil {
365			return nil, fmt.Errorf("%q is not a CIDR such as 10.0.0.0/8 or fd00::/8", part)
366		}
367		out = append(out, p.Masked())
368	}
369	return out, nil
370}

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 2

Tags 45

↑↓ moveenter openesc close