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