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