v0.8.1 b8e893f

forge.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"context"
7	"crypto/ed25519"
8	"crypto/rand"
9	"errors"
10	"fmt"
11	"net"
12	"os"
13	"path/filepath"
14	"time"
15
16	"github.com/bnema/zerowrap"
17
18	"bnema.dev/ermite/internal/adapters/cilogs"
19	"bnema.dev/ermite/internal/adapters/ermitegit"
20	"bnema.dev/ermite/internal/adapters/eventbus"
21	"bnema.dev/ermite/internal/adapters/gitcli"
22	"bnema.dev/ermite/internal/adapters/githttp"
23	"bnema.dev/ermite/internal/adapters/httpserver"
24	"bnema.dev/ermite/internal/adapters/kvstore"
25	"bnema.dev/ermite/internal/adapters/rpc"
26	"bnema.dev/ermite/internal/adapters/sshsig"
27	"bnema.dev/ermite/internal/adapters/web"
28	"bnema.dev/ermite/internal/adapters/webauthn"
29	"bnema.dev/ermite/internal/clientlimit"
30	"bnema.dev/ermite/internal/config"
31	"bnema.dev/ermite/internal/domain"
32	"bnema.dev/ermite/internal/ports"
33	"bnema.dev/ermite/internal/usecase"
34)
35
36// maxReceiveBytes bounds the body of a push, both at the HTTP handler and in
37// Git (receive.maxInputSize).
38const maxReceiveBytes = githttp.DefaultMaxBodyBytes
39
40// options are the parts of the wiring that tests replace.
41type options struct {
42	// hookExe is the executable the hook scripts exec; empty means this
43	// process's own executable.
44	hookExe string
45	// maintenanceDelay overrides usecase.MaintenanceDelay when positive.
46	maintenanceDelay time.Duration
47	// maxBodyBytes overrides the limit of a push body when positive.
48	maxBodyBytes int64
49	// eventBuffer overrides usecase.DefaultEventBuffer when positive.
50	eventBuffer int
51	// revalidateEvery overrides usecase.DefaultRevalidateEvery when not zero.
52	revalidateEvery time.Duration
53	// ci overrides the settings of the CI engine (tests shorten its
54	// durations).
55	ci func(*usecase.CIConfig)
56}
57
58// forge is the wired server: every adapter and use case, ready to serve.
59type forge struct {
60	cfg    config.Config
61	store  *kvstore.Store
62	server *httpserver.Server
63	hooks  *githttp.HookServer
64	maint  *usecase.Maintainer
65	git    *gitcli.Git
66	// reader is the ermite-git reader when ERMITE_GIT_READER selects it.
67	reader *ermitegit.Adapter
68
69	// The use cases and the API are exposed for tests and for the phases
70	// that add the web.
71	Repos  *usecase.Repos
72	Tokens *usecase.Tokens
73	Auth   *usecase.Auth
74	Pushes *usecase.Pushes
75	Browse *usecase.Browse
76	// MergeRequests and SigningKey serve MergeRequestService and
77	// ServerService.GetSigningKey.
78	MergeRequests *usecase.MergeRequests
79	SigningKey    *usecase.SigningKey
80	Sessions      *usecase.Sessions
81	// Passkeys is nil when passkeys are not available (passkeysAvailable).
82	Passkeys *usecase.Passkeys
83	EventLog *usecase.Events
84	// CI is the CI engine: workflow trigger, planner, runner protocol and the
85	// owner side. It starts with the server and stops with it.
86	CI  *usecase.CI
87	API *rpc.API
88	Bus ports.EventBus
89}
90
91// newSigningSeed makes the seed of a new ed25519 signing key.
92func newSigningSeed() ([]byte, error) {
93	seed := make([]byte, ed25519.SeedSize)
94	if _, err := rand.Read(seed); err != nil {
95		return nil, err
96	}
97	return seed, nil
98}
99
100// serverVersion is the version GetVersion reports: the module version of the
101// build, or "devel" for an untagged one.
102func serverVersion() string {
103	if v := buildVersion(); v != "(devel)" {
104		return v
105	}
106	return "devel"
107}
108
109// newForge opens the metadata store, resets run/, checks git, reconciles the repositories,
110// writes the hook scripts, opens the hook socket and builds the HTTP server.
111// The data directory must be prepared (prepareDataDir). Close it after the
112// server stopped.
113func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, err error) {
114	log := zerowrap.FromCtx(ctx)
115	store, err := kvstore.Open(ctx, filepath.Join(cfg.DataDir, "kv"))
116	if err != nil {
117		return nil, err
118	}
119	f := &forge{cfg: cfg, store: store}
120	defer func() {
121		if err != nil {
122			f.Close(context.WithoutCancel(ctx))
123		}
124	}()
125	// The kv store holds the directory lock: only now is run/ ours to reset.
126	// The hook scripts and the socket are written into it afterwards.
127	if err := resetRunDir(cfg.DataDir); err != nil {
128		return nil, err
129	}
130
131	path := os.Getenv("PATH")
132	if path == "" {
133		path = "/usr/local/bin:/usr/bin:/bin"
134	}
135	maxBody := maxReceiveBytes
136	if opts.maxBodyBytes > 0 {
137		maxBody = opts.maxBodyBytes
138	}
139	git := gitcli.New(gitcli.Config{Bin: cfg.Git, DataDir: cfg.DataDir, Path: path, MaxReceiveBytes: maxBody})
140	f.git = git
141	gitVersion, err := git.CheckVersion(ctx)
142	if err != nil {
143		return nil, fmt.Errorf("%s=%q: %w", config.EnvGit, cfg.Git, err)
144	}
145	log.Info().Str("git", gitVersion.String()).Msg("git checked")
146	if _, err := git.HTTPBackend(ctx, false); err != nil {
147		return nil, fmt.Errorf("%s=%q: %w", config.EnvGit, cfg.Git, err)
148	}
149
150	clock := systemClock{}
151	f.Bus = eventbus.New()
152	f.Repos = usecase.NewRepos(store, git, f.Bus, clock)
153	report, err := f.Repos.Reconcile(ctx)
154	if err != nil {
155		return nil, fmt.Errorf("reconcile repositories: %w", err)
156	}
157	log.Info().
158		Int("registered", len(report.Registered)).
159		Int("missing_dirs", len(report.MissingDirs)).
160		Int("invalid_dirs", len(report.InvalidDirs)).
161		Msg("repositories reconciled")
162
163	f.Tokens = usecase.NewTokens(store.Tokens(), clock, store.Sessions())
164	f.Auth = usecase.NewAuth(cfg.AdminTokenHash, store.Tokens(), clock)
165	f.maint = usecase.NewMaintainer(ctx, git, opts.maintenanceDelay)
166	f.Pushes = usecase.NewPushes(store, f.Repos, git, f.Bus, clock, f.maint, f.Auth)
167
168	exe := opts.hookExe
169	if exe == "" {
170		if exe, err = os.Executable(); err != nil {
171			return nil, fmt.Errorf("find the ermite executable for the hooks: %w", err)
172		}
173	}
174	if err := githttp.WriteHookScripts(gitcli.HooksDir(cfg.DataDir), exe); err != nil {
175		return nil, err
176	}
177	f.hooks = githttp.NewHookServer(filepath.Join(cfg.DataDir, "run", "hook.sock"), f.Pushes)
178	if err := f.hooks.Listen(ctx); err != nil {
179		return nil, err
180	}
181
182	var reader ports.RepoReader = git
183	if cfg.GitReader == config.GitReaderErmiteGit {
184		f.reader = ermitegit.New(ermitegit.Config{
185			Fallback: git,
186			RepoDir:  func(name domain.RepoName) string { return gitcli.RepoDir(cfg.DataDir, name) },
187		})
188		git.OnTrash(f.reader.Forget)
189		f.maint.WithFreshener(f.reader)
190		f.Pushes.WithFreshener(f.reader)
191		reader = f.reader
192	}
193	log.Info().Str("reader", cfg.GitReader).Msg("git reader selected")
194	f.Browse = usecase.NewBrowse(store, reader)
195
196	// CI (ADR 0025): the engine reads workflows through the reader, and plans
197	// them in the background; a push only queues its refs.
198	ciCfg := usecase.CIConfig{
199		Store: store.CI(), Logs: cilogs.New(cilogs.Dir(cfg.DataDir)), Meta: store, Repos: git, Reader: reader,
200		Git: git, Diff: git, Bus: f.Bus, Clock: clock, Auth: f.Auth,
201		Owner: cfg.Owner, PublicURL: cfg.PublicURL, Retention: cfg.CIRetention,
202		PollTimeout: cfg.CIPollTimeout,
203	}
204	if opts.ci != nil {
205		opts.ci(&ciCfg)
206	}
207	f.CI = usecase.NewCI(ctx, ciCfg)
208	f.Pushes.WithWorkflowTrigger(f.CI)
209	f.Repos.WithCIRunCleaner(f.CI)
210	f.CI.Start()
211	f.EventLog = usecase.NewEvents(ctx, f.Repos, f.Bus, clock, usecase.EventsConfig{
212		Size: opts.eventBuffer, Auth: f.Auth, RevalidateEvery: opts.revalidateEvery,
213	})
214
215	// The instance signing key (ADR 0022): generated on the first start.
216	seed, err := store.SigningSeed(ctx, newSigningSeed, clock.Now().UTC())
217	if err != nil {
218		return nil, err
219	}
220	signer, err := sshsig.New(seed)
221	if err != nil {
222		return nil, err
223	}
224	committer := domain.Identity{Name: cfg.CommitterName, Email: cfg.CommitterEmail}
225	f.SigningKey = usecase.NewSigningKey(signer, committer)
226	f.MergeRequests = usecase.NewMergeRequests(usecase.MergeRequestsConfig{
227		Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,
228		Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint,
229		CI: f.CI, Checks: f.CI,
230	})
231	log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready")
232
233	f.Sessions, err = usecase.NewSessions(ctx, store.Sessions(), clock, cfg.PublicURL)
234	if err != nil {
235		return nil, err
236	}
237
238	// Owner passkeys (ADR 0026): built only when the public URL allows them.
239	// When it does, a verifier that cannot be built is fatal.
240	if ok, reason := passkeysAvailable(cfg.PublicURL); ok {
241		verifier, err := webauthn.New(cfg.PublicURL)
242		if err != nil {
243			return nil, fmt.Errorf("passkeys: %w", err)
244		}
245		f.Passkeys = usecase.NewPasskeys(store.Passkeys(), verifier, f.Sessions, clock)
246		log.Info().Str("public_url", cfg.PublicURL).Msg("passkeys enabled")
247	} else {
248		log.Info().Str("reason", reason).Msg("passkeys disabled")
249	}
250
251	// One budget of failed authentications per client, for Git, the API and
252	// the web login.
253	limiter := clientlimit.NewLimiter(nil)
254	// A nil *usecase.Passkeys must not become a non-nil interface: the API
255	// tests for nil to answer failed_precondition and to leave the feature out.
256	var apiPasskeys ports.PasskeyService
257	if f.Passkeys != nil {
258		apiPasskeys = f.Passkeys
259	}
260	f.API = rpc.New(rpc.Config{
261		Auth:           f.Auth,
262		Repos:          f.Repos,
263		Tokens:         f.Tokens,
264		Browse:         f.Browse,
265		Events:         f.EventLog,
266		MergeRequests:  f.MergeRequests,
267		SigningKey:     f.SigningKey,
268		Sessions:       f.Sessions,
269		Passkeys:       apiPasskeys,
270		Runner:         f.CI,
271		Runs:           f.CI,
272		Version:        serverVersion(),
273		TrustedProxies: cfg.TrustedProxies,
274		Limiter:        limiter,
275	})
276
277	gh := githttp.New(githttp.Config{
278		Auth:           f.Auth,
279		Repos:          f.Repos,
280		Pushes:         f.Pushes,
281		Backend:        git,
282		Hooks:          f.hooks,
283		TrustedProxies: cfg.TrustedProxies,
284		Limiter:        limiter,
285		MaxBodyBytes:   maxBody,
286	})
287	routes := make([]httpserver.Route, 0, len(githttp.Patterns)+len(web.Patterns))
288	for _, p := range githttp.Patterns {
289		routes = append(routes, httpserver.Route{Pattern: p, Handler: gh})
290	}
291	warnSessionsNeedHTTPS(ctx, cfg.PublicURL)
292	warnSharedClientKey(ctx, cfg.TrustedProxies, githttp.DefaultMaxPerClient)
293	site, err := newWeb(cfg, f, limiter)
294	if err != nil {
295		return nil, fmt.Errorf("web: %w", err)
296	}
297	routes = append(routes, webRoutes(site)...)
298	f.server = httpserver.New(httpserver.Config{
299		Addr:         cfg.Addr,
300		PublicURL:    cfg.PublicURL,
301		HealthChecks: []httpserver.Check{store.Check, httpserver.DirWritable(cfg.DataDir)},
302		Routes:       routes,
303		API:          f.API.Handler(),
304		// Event streams never end by themselves: close them when the
305		// shutdown begins, or it would wait for them until its timeout.
306		OnShutdown: []func(){func() { f.EventLog.Close(context.WithoutCancel(ctx)) }},
307	})
308	return f, nil
309}
310
311// Run listens on the configured address and serves until ctx ends.
312func (f *forge) Run(ctx context.Context) error {
313	ln, err := net.Listen("tcp", f.cfg.Addr)
314	if err != nil {
315		return fmt.Errorf("listen on %s: %w", f.cfg.Addr, err)
316	}
317	return f.Serve(ctx, ln)
318}
319
320// Serve serves HTTP on ln and the hook socket until ctx ends. The hook socket
321// outlives the HTTP server: requests still in flight while it drains are
322// pushes whose hooks need it. A failure of either stops both.
323func (f *forge) Serve(ctx context.Context, ln net.Listener) error {
324	ctx, cancel := context.WithCancel(ctx)
325	defer cancel()
326	hookCtx, stopHooks := context.WithCancel(context.WithoutCancel(ctx))
327	defer stopHooks()
328
329	hookErr := make(chan error, 1)
330	go func() {
331		err := f.hooks.Serve(hookCtx)
332		cancel() // a dead hook socket would reject every push: stop serving
333		hookErr <- err
334	}()
335
336	httpErr := f.server.Serve(ctx, ln)
337	stopHooks()
338	return errors.Join(httpErr, <-hookErr)
339}
340
341// Close releases what newForge opened. It is safe to call twice. Call it after
342// the HTTP server stopped: it stops the event log and the long-lived Git
343// processes of the read side, which requests in flight may still use.
344func (f *forge) Close(ctx context.Context) {
345	log := zerowrap.FromCtx(ctx)
346	if f.hooks != nil {
347		f.hooks.Close(ctx)
348	}
349	if f.CI != nil {
350		f.CI.Close(ctx)
351	}
352	if f.EventLog != nil {
353		f.EventLog.Close(ctx)
354	}
355	if f.reader != nil {
356		f.reader.Close()
357	}
358	if f.git != nil {
359		if err := f.git.Close(ctx); err != nil {
360			log.Error().Err(err).Msg("close git reader")
361		}
362	}
363	if f.maint != nil {
364		f.maint.Close(ctx)
365	}
366	if f.store != nil {
367		if err := f.store.Close(ctx); err != nil {
368			log.Error().Err(err).Msg("close metadata store")
369		}
370		f.store = nil
371	}
372}

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