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(),
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}