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