main 2aca1df

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

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 43

↑↓ moveenter openesc close