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