v0.30.0 c899179

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	"crypto/rsa"
10	"errors"
11	"fmt"
12	"net"
13	"os"
14	"path/filepath"
15	"strings"
16	"time"
17
18	"github.com/bnema/zerowrap"
19
20	"bnema.dev/ermite/internal/adapters/ciartifacts"
21	"bnema.dev/ermite/internal/adapters/cilogs"
22	"bnema.dev/ermite/internal/adapters/cisummaries"
23	"bnema.dev/ermite/internal/adapters/ermitegit"
24	"bnema.dev/ermite/internal/adapters/eventbus"
25	"bnema.dev/ermite/internal/adapters/gitcli"
26	"bnema.dev/ermite/internal/adapters/githttp"
27	"bnema.dev/ermite/internal/adapters/githubapi"
28	"bnema.dev/ermite/internal/adapters/githubraw"
29	"bnema.dev/ermite/internal/adapters/gitpush"
30	"bnema.dev/ermite/internal/adapters/gitremote"
31	"bnema.dev/ermite/internal/adapters/httpserver"
32	"bnema.dev/ermite/internal/adapters/kvstore"
33	"bnema.dev/ermite/internal/adapters/oidchttp"
34	"bnema.dev/ermite/internal/adapters/rpc"
35	"bnema.dev/ermite/internal/adapters/secretbox"
36	"bnema.dev/ermite/internal/adapters/sigverify"
37	"bnema.dev/ermite/internal/adapters/sshsig"
38	"bnema.dev/ermite/internal/adapters/web"
39	"bnema.dev/ermite/internal/adapters/webauthn"
40	"bnema.dev/ermite/internal/clientlimit"
41	"bnema.dev/ermite/internal/config"
42	"bnema.dev/ermite/internal/domain"
43	"bnema.dev/ermite/internal/ports"
44	"bnema.dev/ermite/internal/usecase"
45)
46
47// maxReceiveBytes bounds the body of a push, both at the HTTP handler and in
48// Git (receive.maxInputSize).
49const maxReceiveBytes = githttp.DefaultMaxBodyBytes
50
51// options are the parts of the wiring that tests replace.
52type options struct {
53	// pusher and mirrors override mirror transport/settings in local tests only.
54	pusher  ports.MirrorPusher
55	mirrors func(*usecase.MirrorsConfig)
56	// hookExe is the executable the hook scripts exec; empty means this
57	// process's own executable.
58	hookExe string
59	// maintenanceDelay overrides usecase.MaintenanceDelay when positive.
60	maintenanceDelay time.Duration
61	// maxBodyBytes overrides the limit of a push body when positive.
62	maxBodyBytes int64
63	// eventBuffer overrides usecase.DefaultEventBuffer when positive.
64	eventBuffer int
65	// revalidateEvery overrides usecase.DefaultRevalidateEvery when not zero.
66	revalidateEvery time.Duration
67	// archivesPerClient overrides the archives one client downloads at once
68	// when positive.
69	archivesPerClient int
70	// receivePacks overrides the git-receive-pack backends running at once
71	// when positive (the default follows GOMAXPROCS).
72	receivePacks int
73	// remote replaces the lister of github.com refs (tests never reach the
74	// network).
75	remoteLister ports.GitRemote
76	// workflowFetcher replaces the downloader of github.com reusable workflows
77	// (tests never reach the network).
78	workflowFetcher ports.WorkflowFetcher
79	// ci overrides the settings of the CI engine (tests shorten its
80	// durations).
81	ci func(*usecase.CIConfig)
82	// oidcKey generates the signing keys of the ID tokens; nil generates RSA
83	// keys (tests give prepared ones: a 3072-bit key takes a while).
84	oidcKey func() (*rsa.PrivateKey, error)
85	// issuePoW lowers the proof of work of the public issue form (the base
86	// and the highest difficulty, in bits) when positive, and shortens the
87	// minimum fill time to a nanosecond; tests only.
88	issuePoW int
89}
90
91// remote returns the lister of github.com refs: the test's, else def.
92func (o options) remote(def ports.GitRemote) ports.GitRemote {
93	if o.remoteLister != nil {
94		return o.remoteLister
95	}
96	return def
97}
98
99// workflows returns the downloader of github.com reusable workflows: the
100// test's, else def.
101func (o options) workflows(def ports.WorkflowFetcher) ports.WorkflowFetcher {
102	if o.workflowFetcher != nil {
103		return o.workflowFetcher
104	}
105	return def
106}
107
108// forge is the wired server: every adapter and use case, ready to serve.
109type forge struct {
110	cfg    config.Config
111	store  *kvstore.Store
112	server *httpserver.Server
113	hooks  *githttp.HookServer
114	maint  *usecase.Maintainer
115	git    *gitcli.Git
116	// reader is the ermite-git reader when ERMITE_GIT_READER selects it.
117	reader *ermitegit.Adapter
118
119	// The use cases and the API are exposed for tests and for the phases
120	// that add the web.
121	Repos  *usecase.Repos
122	Tokens *usecase.Tokens
123	Auth   *usecase.Auth
124	Pushes *usecase.Pushes
125	Browse *usecase.Browse
126	// MergeRequests and SigningKey serve MergeRequestService and
127	// ServerService.GetSigningKey.
128	MergeRequests *usecase.MergeRequests
129	// Issues serves IssueService, and tells what a number names.
130	Issues *usecase.Issues
131	// IssueModeration serves the moderation calls of IssueService.
132	IssueModeration *usecase.IssueModeration
133	// IssueKeys are the subkeys of the public issue form, and the stores and
134	// the limiter its submissions use (the form itself comes later).
135	IssueKeys *usecase.IssueKeys
136	// IssueSubmissions serves the anonymous submission of issues.
137	IssueSubmissions  *usecase.IssueSubmissions
138	SubmissionLimiter *clientlimit.SubmissionLimiter
139	PendingIssues     ports.PendingIssueStore
140	IssueBans         ports.IssueBanStore
141	// Inbox serves the owner's inbox of the web.
142	Inbox      *usecase.Inbox
143	SigningKey *usecase.SigningKey
144	// Mergeability keeps the mergeability of the open merge requests up to date.
145	Mergeability *usecase.Mergeability
146	// Signatures are the trusted keys and the commit signature checks
147	// (ADR 0030).
148	Signatures *usecase.Signatures
149	Sessions   *usecase.Sessions
150	// Passkeys is nil when passkeys are not available (passkeysAvailable).
151	Passkeys *usecase.Passkeys
152	EventLog *usecase.Events
153	// CI is the CI engine: workflow trigger, planner, runner protocol and the
154	// owner side. It starts with the server and stops with it.
155	CI *usecase.CI
156	// Settings are the secrets, variables and environments of CI.
157	Settings *usecase.CISettings
158	// Mirrors exposes Warning and Badge for the hook server and web composition.
159	Mirrors *usecase.Mirrors
160	// OIDC is the provider of ID tokens, disabled without a secrets key.
161	OIDC *usecase.OIDC
162	API  *rpc.API
163	Bus  ports.EventBus
164}
165
166// newSigningSeed makes the seed of a new ed25519 signing key.
167func newSigningSeed() ([]byte, error) {
168	seed := make([]byte, ed25519.SeedSize)
169	if _, err := rand.Read(seed); err != nil {
170		return nil, err
171	}
172	return seed, nil
173}
174
175// serverVersion is the version GetVersion reports: the module version of the
176// build, or "devel" for an untagged one.
177func serverVersion() string {
178	if v := buildVersion(); v != "(devel)" {
179		return v
180	}
181	return "devel"
182}
183
184// newForge opens the metadata store, resets run/, checks git, reconciles the repositories,
185// writes the hook scripts, opens the hook socket and builds the HTTP server.
186// The data directory must be prepared (prepareDataDir). Close it after the
187// server stopped.
188func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, err error) {
189	log := zerowrap.FromCtx(ctx)
190	store, err := kvstore.Open(ctx, filepath.Join(cfg.DataDir, "kv"))
191	if err != nil {
192		return nil, err
193	}
194	f := &forge{cfg: cfg, store: store}
195	defer func() {
196		if err != nil {
197			f.Close(context.WithoutCancel(ctx))
198		}
199	}()
200	// The kv store holds the directory lock: only now is run/ ours to reset.
201	// The hook scripts and the socket are written into it afterwards.
202	if err := resetRunDir(cfg.DataDir); err != nil {
203		return nil, err
204	}
205
206	path := os.Getenv("PATH")
207	if path == "" {
208		path = "/usr/local/bin:/usr/bin:/bin"
209	}
210	maxBody := maxReceiveBytes
211	if opts.maxBodyBytes > 0 {
212		maxBody = opts.maxBodyBytes
213	}
214	git := gitcli.New(gitcli.Config{Bin: cfg.Git, DataDir: cfg.DataDir, Path: path, MaxReceiveBytes: maxBody})
215	f.git = git
216	gitVersion, err := git.CheckVersion(ctx)
217	if err != nil {
218		return nil, fmt.Errorf("%s=%q: %w", config.EnvGit, cfg.Git, err)
219	}
220	log.Info().Str("git", gitVersion.String()).Msg("git checked")
221	if _, err := git.HTTPBackend(ctx, false); err != nil {
222		return nil, fmt.Errorf("%s=%q: %w", config.EnvGit, cfg.Git, err)
223	}
224
225	clock := systemClock{}
226	f.Bus = eventbus.New()
227	f.Repos = usecase.NewRepos(store, git, f.Bus, clock)
228	report, err := f.Repos.Reconcile(ctx)
229	if err != nil {
230		return nil, fmt.Errorf("reconcile repositories: %w", err)
231	}
232	log.Info().
233		Int("registered", len(report.Registered)).
234		Int("missing_dirs", len(report.MissingDirs)).
235		Int("invalid_dirs", len(report.InvalidDirs)).
236		Msg("repositories reconciled")
237
238	f.Tokens = usecase.NewTokens(store.Tokens(), clock, store.Sessions())
239	f.Auth = usecase.NewAuth(cfg.AdminTokenHash, store.Tokens(), clock).WithRepoTokens(store.CI())
240	f.maint = usecase.NewMaintainer(ctx, git, opts.maintenanceDelay)
241	f.Pushes = usecase.NewPushes(store, f.Repos, git, f.Bus, clock, f.maint, f.Auth)
242
243	exe := opts.hookExe
244	if exe == "" {
245		if exe, err = os.Executable(); err != nil {
246			return nil, fmt.Errorf("find the ermite executable for the hooks: %w", err)
247		}
248	}
249	if err := githttp.WriteHookScripts(gitcli.HooksDir(cfg.DataDir), exe); err != nil {
250		return nil, err
251	}
252	f.hooks = githttp.NewHookServer(filepath.Join(cfg.DataDir, "run", "hook.sock"), f.Pushes)
253	if err := f.hooks.Listen(ctx); err != nil {
254		return nil, err
255	}
256
257	var reader ports.RepoReader = git
258	if cfg.GitReader == config.GitReaderErmiteGit {
259		f.reader = ermitegit.New(ermitegit.Config{
260			Fallback: git,
261			RepoDir:  func(name domain.RepoName) string { return gitcli.RepoDir(cfg.DataDir, name) },
262		})
263		git.OnTrash(f.reader.Forget)
264		f.maint.WithFreshener(f.reader)
265		f.Pushes.WithFreshener(f.reader)
266		reader = f.reader
267	}
268	log.Info().Str("reader", cfg.GitReader).Msg("git reader selected")
269	f.Browse = usecase.NewBrowse(store, reader)
270
271	// Secrets, variables and environments (ADR 0019, CI-3). With a key the
272	// secrets are sealed under it, and the server refuses to start when the
273	// key is not the one they were sealed with; without one they cannot be set
274	// and jobs receive none.
275	var sealer ports.SecretSealer // a nil *secretbox.Box must not become a non-nil interface
276	if cfg.SecretsKey != nil {
277		box, err := secretbox.New(cfg.SecretsKey)
278		if err != nil {
279			return nil, fmt.Errorf("%s: %w", config.EnvSecretsKey, err)
280		}
281		sealer = box
282	}
283	pusher := opts.pusher
284	if pusher == nil {
285		pusher = gitpush.New(gitpush.Config{
286			Bin: cfg.Git, Path: path, Home: filepath.Join(cfg.DataDir, "run", "git-home"),
287			KnownHosts: filepath.Join(cfg.DataDir, "mirror_known_hosts"),
288			TempDir:    filepath.Join(cfg.DataDir, "run", "mirror"),
289			RepoDir:    func(name domain.RepoName) string { return gitcli.RepoDir(cfg.DataDir, name) },
290		})
291	}
292	mirrorCfg := usecase.MirrorsConfig{
293		Store: store, Keys: store, Sealer: sealer, Pusher: pusher,
294		Meta: store, Git: git, Reader: reader, Bus: f.Bus, Clock: clock, PublicURL: cfg.PublicURL,
295	}
296	if opts.mirrors != nil {
297		opts.mirrors(&mirrorCfg)
298	}
299	f.Mirrors = usecase.NewMirrors(mirrorCfg)
300	f.Pushes.WithMirror(f.Mirrors)
301	f.Repos.WithMirror(f.Mirrors)
302	f.hooks.WithMirrorWarner(f.Mirrors)
303
304	f.Settings, err = usecase.NewCISettings(ctx, store, store.Settings(), sealer, clock)
305	if err != nil {
306		return nil, fmt.Errorf("%s: %w", config.EnvSecretsKey, err)
307	}
308	// The OIDC provider of CI jobs (CI-4): its signing key is sealed with the
309	// secrets key, so there is none without one.
310	f.OIDC, err = usecase.NewOIDC(ctx, usecase.OIDCConfig{
311		Store: store, Sealer: sealer, Tokens: store.CI(), Clock: clock, PublicURL: cfg.PublicURL, NewKey: opts.oidcKey,
312	})
313	if err != nil {
314		return nil, fmt.Errorf("oidc: %w", err)
315	}
316
317	// CI (ADR 0025): the engine reads workflows through the reader, and plans
318	// them in the background; a push only queues its refs.
319	committer := domain.Identity{Name: cfg.CommitterName, Email: cfg.CommitterEmail}
320	ciCfg := usecase.CIConfig{
321		Store: store.CI(), Logs: cilogs.New(cilogs.Dir(cfg.DataDir)), Summaries: cisummaries.New(cisummaries.Dir(cfg.DataDir)), Meta: store, Repos: git, Reader: reader,
322		Git: git, Diff: git, Bus: f.Bus, Clock: clock, Auth: f.Auth,
323		Owner: cfg.Owner, PublicURL: cfg.PublicURL, Retention: cfg.CIRetention,
324		PollTimeout: cfg.CIPollTimeout, Archive: git,
325		Remote: opts.remote(gitremote.New(gitremote.Config{
326			Bin: cfg.Git, Path: path, Home: filepath.Join(cfg.DataDir, "run", "git-home"),
327			Proxy: gitremote.ProxyEnv(os.Getenv),
328		})),
329		Tokens:        store.Tokens(),
330		RefTips:       store.CI(),
331		WorkflowState: store.CI(),
332		Settings:      f.Settings,
333		Workflows:     opts.workflows(githubraw.New(githubraw.Config{})),
334		OIDC:          f.OIDC,
335	}
336	artifacts(&ciCfg, cfg, store)
337	f.pullRequests(&ciCfg, store, git, committer)
338	if opts.ci != nil {
339		opts.ci(&ciCfg)
340	}
341	f.CI = usecase.NewCI(ctx, ciCfg)
342	f.Pushes.WithWorkflowTrigger(f.CI).WithPushTreeReader(git).WithRefTips(store.CI())
343	f.Repos.WithCIRunCleaner(f.CI)
344	f.CI.Start()
345	f.EventLog = usecase.NewEvents(ctx, f.Repos, f.Bus, clock, usecase.EventsConfig{
346		Size: opts.eventBuffer, Auth: f.Auth, RevalidateEvery: opts.revalidateEvery,
347	})
348
349	// The instance signing key (ADR 0022): generated on the first start.
350	seed, err := store.SigningSeed(ctx, newSigningSeed, clock.Now().UTC())
351	if err != nil {
352		return nil, err
353	}
354	signer, err := sshsig.New(seed)
355	if err != nil {
356		return nil, err
357	}
358	f.SigningKey = usecase.NewSigningKey(signer, committer)
359	if err := f.signatures(store, reader, signer.PublicKey(), committer, clock); err != nil {
360		return nil, err
361	}
362	f.Mergeability = usecase.NewMergeability(ctx, usecase.MergeabilityConfig{
363		Store: store.MergeRequests(), Meta: store, Git: git, Bus: f.Bus, Clock: clock,
364	})
365	f.Pushes.WithMergeability(f.Mergeability)
366	f.MergeRequests = usecase.NewMergeRequests(usecase.MergeRequestsConfig{
367		Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,
368		Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint,
369		CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(), Mergeability: f.Mergeability,
370	})
371	f.Mergeability.Start()
372	f.MergeRequests.WithVerifier(f.Signatures).WithMirror(f.Mirrors)
373	f.Issues = usecase.NewIssues(usecase.IssuesConfig{
374		Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: f.Bus, Clock: clock,
375	})
376	f.IssueModeration = usecase.NewIssueModeration(usecase.IssueModerationConfig{
377		Meta: store, Pending: store.PendingIssues(), Bans: store.IssueBans(), Bus: f.Bus, Clock: clock,
378	})
379	f.SubmissionLimiter = clientlimit.NewSubmissionLimiter(nil)
380	f.PendingIssues, f.IssueBans = store.PendingIssues(), store.IssueBans()
381	f.IssueKeys, err = usecase.NewIssueKeys(ctx, store.IssueKeys())
382	if err != nil {
383		return nil, err
384	}
385	f.IssueSubmissions = usecase.NewIssueSubmissions(usecase.IssueSubmissionsConfig{
386		Meta: store, Pending: f.PendingIssues, Bans: f.IssueBans, Limiter: f.SubmissionLimiter,
387		Keys: f.IssueKeys, Bus: f.Bus, Clock: clock,
388		BaseDifficulty: opts.issuePoW, MaxDifficulty: opts.issuePoW, MinFillTime: fastFill(opts.issuePoW),
389	})
390	f.Inbox = usecase.NewInbox(usecase.InboxConfig{
391		Meta: store, MergeRequests: store.MergeRequests(), Issues: store.Issues(), Pending: store.PendingIssues(),
392	})
393	f.CI.SetPullGate(f.MergeRequests)
394	log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready")
395
396	f.Sessions, err = usecase.NewSessions(ctx, store.Sessions(), clock, cfg.PublicURL)
397	if err != nil {
398		return nil, err
399	}
400
401	// Owner passkeys (ADR 0026): built only when the public URL allows them.
402	// When it does, a verifier that cannot be built is fatal.
403	if ok, reason := passkeysAvailable(cfg.PublicURL); ok {
404		verifier, err := webauthn.New(cfg.PublicURL, cfg.Owner)
405		if err != nil {
406			return nil, fmt.Errorf("passkeys: %w", err)
407		}
408		f.Passkeys = usecase.NewPasskeys(store.Passkeys(), verifier, f.Sessions, clock)
409		log.Info().Str("public_url", cfg.PublicURL).Msg("passkeys enabled")
410	} else {
411		log.Info().Str("reason", reason).Msg("passkeys disabled")
412	}
413
414	// One budget of failed authentications per client, for Git, the API and
415	// the web login.
416	limiter := clientlimit.NewLimiter(nil)
417	// A nil *usecase.Passkeys must not become a non-nil interface: the API
418	// tests for nil to answer failed_precondition and to leave the feature out.
419	var apiPasskeys ports.PasskeyService
420	if f.Passkeys != nil {
421		apiPasskeys = f.Passkeys
422	}
423	var jobArtifacts ports.JobArtifactService
424	var artifacts ports.ArtifactService
425	if cfg.CIArtifacts {
426		jobArtifacts, artifacts = f.CI, f.CI
427	}
428	f.API = rpc.New(rpc.Config{
429		Auth:            f.Auth,
430		Mirrors:         f.Mirrors,
431		Repos:           f.Repos,
432		Tokens:          f.Tokens,
433		Browse:          f.Browse,
434		Events:          f.EventLog,
435		MergeRequests:   f.MergeRequests,
436		Issues:          f.Issues,
437		IssueModeration: f.IssueModeration,
438		SigningKey:      f.SigningKey,
439		TrustedKeys:     f.Signatures,
440		Sessions:        f.Sessions,
441		Passkeys:        apiPasskeys,
442		Runner:          f.CI,
443		Runs:            f.CI,
444		Secrets:         f.Settings,
445		Variables:       f.Settings,
446		Environments:    f.Settings,
447		OIDCKeys:        f.OIDC,
448		JobArtifacts:    jobArtifacts,
449		Artifacts:       artifacts,
450		Version:         serverVersion(),
451		TrustedProxies:  cfg.TrustedProxies,
452		Limiter:         limiter,
453	})
454
455	gh := githttp.New(githttp.Config{
456		Auth:            f.Auth,
457		Repos:           f.Repos,
458		Pushes:          f.Pushes,
459		Backend:         git,
460		Owner:           cfg.Owner,
461		Hooks:           f.hooks,
462		TrustedProxies:  cfg.TrustedProxies,
463		Limiter:         limiter,
464		MaxBodyBytes:    maxBody,
465		MaxReceivePacks: opts.receivePacks,
466	})
467	routes := make([]httpserver.Route, 0, 2*len(githttp.Patterns)+len(githubapi.Patterns)+len(oidchttp.Patterns)+len(web.Patterns))
468	for _, p := range githttp.Patterns {
469		routes = append(routes, httpserver.Route{Pattern: p, Handler: gh})
470	}
471	// The owner form of the clone URL (github.repository), which GitHub's
472	// actions use. config.Owner follows the rules of repository names, so it is
473	// a literal segment that never overlaps /-/.
474	for _, p := range githttp.OwnerPatterns(cfg.Owner) {
475		routes = append(routes, httpserver.Route{Pattern: p, Handler: gh})
476	}
477	// The REST subset the actions call on github.api_url.
478	api := githubapi.New(githubapi.Config{Auth: f.Auth, API: f.CI, TrustedProxies: cfg.TrustedProxies, Limiter: limiter, MaxArchivesPerClient: opts.archivesPerClient})
479	for _, p := range githubapi.Patterns {
480		routes = append(routes, httpserver.Route{Pattern: p, Handler: api})
481	}
482	// The OIDC provider of CI jobs (issuer <public URL>/-/oidc).
483	idp := oidchttp.New(oidchttp.Config{OIDC: f.CI, TrustedProxies: cfg.TrustedProxies, Limiter: limiter})
484	for _, p := range oidchttp.Patterns {
485		routes = append(routes, httpserver.Route{Pattern: p, Handler: idp})
486	}
487	warnSessionsNeedHTTPS(ctx, cfg.PublicURL)
488	warnSharedClientKey(ctx, cfg.TrustedProxies, githttp.DefaultMaxPerClient)
489	site, err := newWeb(cfg, f, limiter)
490	if err != nil {
491		return nil, fmt.Errorf("web: %w", err)
492	}
493	routes = append(routes, webRoutes(site)...)
494	f.server = httpserver.New(httpserver.Config{
495		Addr:         cfg.Addr,
496		PublicURL:    cfg.PublicURL,
497		HealthChecks: []httpserver.Check{store.Check, httpserver.DirWritable(cfg.DataDir)},
498		Routes:       routes,
499		API:          f.API.Handler(),
500		// Event streams never end by themselves: close them when the
501		// shutdown begins, or it would wait for them until its timeout.
502		OnShutdown: []func(){func() { f.EventLog.Close(context.WithoutCancel(ctx)) }},
503	})
504	return f, nil
505}
506
507// Run listens on the configured address and serves until ctx ends.
508func (f *forge) Run(ctx context.Context) error {
509	ln, err := net.Listen("tcp", f.cfg.Addr)
510	if err != nil {
511		return fmt.Errorf("listen on %s: %w", f.cfg.Addr, err)
512	}
513	return f.Serve(ctx, ln)
514}
515
516// Serve serves HTTP on ln and the hook socket until ctx ends. The hook socket
517// outlives the HTTP server: requests still in flight while it drains are
518// pushes whose hooks need it. A failure of either stops both.
519func (f *forge) Serve(ctx context.Context, ln net.Listener) error {
520	f.Mirrors.Start()
521	ctx, cancel := context.WithCancel(ctx)
522	defer cancel()
523	hookCtx, stopHooks := context.WithCancel(context.WithoutCancel(ctx))
524	defer stopHooks()
525
526	hookErr := make(chan error, 1)
527	go func() {
528		err := f.hooks.Serve(hookCtx)
529		cancel() // a dead hook socket would reject every push: stop serving
530		hookErr <- err
531	}()
532
533	httpErr := f.server.Serve(ctx, ln)
534	stopHooks()
535	return errors.Join(httpErr, <-hookErr)
536}
537
538// Close releases what newForge opened. It is safe to call twice. Call it after
539// the HTTP server stopped: it stops the event log and the long-lived Git
540// processes of the read side, which requests in flight may still use.
541func (f *forge) Close(ctx context.Context) {
542	log := zerowrap.FromCtx(ctx)
543	if f.hooks != nil {
544		f.hooks.Close(ctx)
545	}
546	if f.MergeRequests != nil {
547		f.MergeRequests.Wait(ctx) // its side effects use CI and Mergeability
548	}
549	if f.Mirrors != nil {
550		f.Mirrors.Close(ctx)
551	}
552	if f.CI != nil {
553		f.CI.Close(ctx)
554	}
555	if f.Mergeability != nil {
556		f.Mergeability.Close(ctx)
557	}
558	if f.EventLog != nil {
559		f.EventLog.Close(ctx)
560	}
561	if f.reader != nil {
562		f.reader.Close()
563	}
564	if f.git != nil {
565		if err := f.git.Close(ctx); err != nil {
566			log.Error().Err(err).Msg("close git reader")
567		}
568	}
569	if f.maint != nil {
570		f.maint.Close(ctx)
571	}
572	if f.store != nil {
573		if err := f.store.Close(ctx); err != nil {
574			log.Error().Err(err).Msg("close metadata store")
575		}
576		f.store = nil
577	}
578}
579
580// artifacts gives the CI the stores and limits of the artifacts (CI-4 D3):
581// records in the kv store, contents under ci/artifacts. ERMITE_CI_ARTIFACTS=0
582// gives none, and the API then serves no artifact service.
583func artifacts(ci *usecase.CIConfig, cfg config.Config, store *kvstore.Store) {
584	if !cfg.CIArtifacts {
585		return
586	}
587	ci.Artifacts, ci.ArtifactFiles = store.Artifacts(), ciartifacts.New(ciartifacts.Dir(cfg.DataDir))
588	ci.ArtifactLimits = ports.ArtifactLimits{Retention: cfg.CIArtifactRetention, Quota: cfg.CIArtifactQuota, MaxSize: cfg.CIArtifactMax}
589}
590
591// pullRequests gives the CI the merge requests, the refs/pull refs and the
592// identity of the test merge commits (CI-3, D5).
593func (f *forge) pullRequests(cfg *usecase.CIConfig, store *kvstore.Store, git *gitcli.Git, committer domain.Identity) {
594	cfg.MRs, cfg.Pull, cfg.Committer = store.MergeRequests(), git, committer
595	if f.reader != nil {
596		cfg.Fresh = f.reader // a nil *ermitegit.Adapter must not become a non-nil interface
597	}
598}
599
600// signatures wires the commit signature checks (ADR 0030): the stored trusted
601// keys, the instance key as a builtin one for the committer email, the SSH and
602// OpenPGP verifiers, and the raw commit objects of the selected reader.
603func (f *forge) signatures(store *kvstore.Store, reader ports.RepoReader, key domain.SigningPublicKey, committer domain.Identity, clock ports.Clock) error {
604	objects, ok := reader.(ports.CommitObjectReader)
605	if !ok {
606		return errors.New("the git reader cannot read raw commit objects")
607	}
608	builtin := domain.TrustedKey{
609		ID: key.Fingerprint, Format: domain.FormatSSH, Title: "ermite instance key",
610		PublicKey: key.AuthorizedKey, Emails: []string{strings.ToLower(committer.Email)}, Builtin: true,
611	}
612	verifiers := map[domain.SignatureFormat]ports.SignatureVerifier{
613		domain.FormatSSH: sigverify.SSH{}, domain.FormatGPG: sigverify.GPG{},
614	}
615	sigs, err := usecase.NewSignatures(store.TrustedKeys(), sigverify.Inspector{}, verifiers, objects, builtin, clock)
616	if err != nil {
617		return err
618	}
619	f.Signatures = sigs
620	f.Browse.WithVerifier(sigs)
621	return nil
622}
623
624// fastFill is the minimum fill time of the public issue form for a test that
625// lowers the proof of work: next to none. Zero keeps the production value.
626func fastFill(issuePoW int) time.Duration {
627	if issuePoW > 0 {
628		return time.Nanosecond
629	}
630	return 0
631}

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 44

↑↓ moveenter openesc close