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