cmd/gitbayd/main.go
687 lines · 22290 bytes
15 symbols in this file
1// gitbayd is the forge server daemon. The same binary also runs in hook mode
2// (invoked by git via core.hooksPath) and hosts the host-local admin commands.
3package main
4
5import (
6 "context"
7 "errors"
8 "fmt"
9 "io"
10 "io/fs"
11 "log/slog"
12 "net"
13 "net/http"
14 "os"
15 "os/signal"
16 "path/filepath"
17 "strconv"
18 "strings"
19 "syscall"
20 "time"
21
22 "github.com/spf13/cobra"
23 "golang.org/x/crypto/acme/autocert"
24
25 "gitbay.org/gitbay/internal/buildinfo"
26 "gitbay.org/gitbay/internal/ci"
27 "gitbay.org/gitbay/internal/config"
28 "gitbay.org/gitbay/internal/control"
29 "gitbay.org/gitbay/internal/deps"
30 "gitbay.org/gitbay/internal/gitd"
31 "gitbay.org/gitbay/internal/hookd"
32 "gitbay.org/gitbay/internal/httpd"
33 "gitbay.org/gitbay/internal/mailin"
34 "gitbay.org/gitbay/internal/mirror"
35 "gitbay.org/gitbay/internal/notify"
36 "gitbay.org/gitbay/internal/packlimit"
37 "gitbay.org/gitbay/internal/push"
38 "gitbay.org/gitbay/internal/seal"
39 "gitbay.org/gitbay/internal/sshd"
40 "gitbay.org/gitbay/internal/store"
41 "gitbay.org/gitbay/internal/symbols"
42 "gitbay.org/gitbay/internal/toolpath"
43 "gitbay.org/gitbay/internal/webhook"
44)
45
46func openStore(cfg config.Config) (*store.Store, error) {
47 // The key file seals the secret columns (#273). Without it the
48 // database's secrets cannot be read or written, so nothing that
49 // opens the database runs.
50 keys, err := seal.Load(cfg.Server.SecretKeyFile)
51 if errors.Is(err, fs.ErrNotExist) {
52 return nil, fmt.Errorf("secret key file %s does not exist: create it with gitbayd admin secrets init, or restore it from its off-host copy (backups do not carry it)", cfg.Server.SecretKeyFile)
53 }
54 if err != nil {
55 return nil, fmt.Errorf("secret key file: %w", err)
56 }
57 s, err := store.Open(filepath.Join(cfg.Server.Root, "gitbay.db"))
58 if err != nil {
59 return nil, err
60 }
61 s.SetKeyring(keys)
62 // Say so when the schema moves. A restart migrates in silence otherwise,
63 // which makes an unexpected schema version hard to attribute to the deploy
64 // that caused it.
65 before, err := s.Version()
66 if err != nil {
67 s.Close()
68 return nil, err
69 }
70 if err := s.MigrateUp(); err != nil {
71 s.Close()
72 return nil, err
73 }
74 after, err := s.Version()
75 if err != nil {
76 s.Close()
77 return nil, err
78 }
79 if after != before {
80 note, err := s.TakeMigrationNote()
81 if err != nil {
82 s.Close()
83 return nil, err
84 }
85 args := []any{"from", before, "to", after}
86 if note != "" {
87 args = append(args, "note", note)
88 }
89 slog.Info("schema migrated", args...)
90 }
91 return s, nil
92}
93
94var configPath string
95
96func main() {
97 root := &cobra.Command{
98 Use: "gitbayd",
99 Short: "gitbay server daemon",
100 SilenceUsage: true,
101 SilenceErrors: true,
102 }
103 root.PersistentFlags().StringVar(&configPath, "config", "/etc/gitbay/config.toml", "path to config file")
104
105 root.AddCommand(
106 checkConfigCmd(),
107 serveCmd(),
108 migrateCmd(),
109 adminCmd(),
110 hookCmd(),
111 authorizedKeysCmd(),
112 shellCmd(),
113 versionCmd(),
114 )
115
116 if err := root.Execute(); err != nil {
117 fmt.Fprintln(os.Stderr, "gitbayd:", err)
118 os.Exit(1)
119 }
120}
121
122func checkConfigCmd() *cobra.Command {
123 var noHost bool
124 cmd := &cobra.Command{
125 Use: "check-config",
126 Short: "validate the configuration and exit",
127 RunE: func(cmd *cobra.Command, args []string) error {
128 cfg, err := config.Load(configPath)
129 if err != nil {
130 return err
131 }
132 if !noHost {
133 if err := cfg.CheckHost(); err != nil {
134 return err
135 }
136 }
137 fmt.Println("config ok")
138 return nil
139 },
140 }
141 cmd.Flags().BoolVar(&noHost, "no-host-checks", false, "skip host environment probes (port binding, paths)")
142 return cmd
143}
144
145func serveCmd() *cobra.Command {
146 return &cobra.Command{
147 Use: "serve",
148 Short: "run the ssh, http, and git listeners",
149 RunE: func(cmd *cobra.Command, args []string) error {
150 // First line of every run: the journal then says which commit is
151 // serving, without rebuilding the binary to find out.
152 logBuild()
153 // The tools this daemon shells out to are resolved once, at
154 // package init. Say so now rather than failing on whichever
155 // request first needed git.
156 if err := toolpath.Verify(); err != nil {
157 return fmt.Errorf("required tools missing: %w", err)
158 }
159 cfg, err := config.Load(configPath)
160 if err != nil {
161 return err
162 }
163 warnIfUnmerged(cfg)
164 st, err := openStore(cfg)
165 if err != nil {
166 return err
167 }
168 defer st.Close()
169 // The daemon's stderr is the service journal: a copy of each
170 // audit row outside the database the daemon can write. Rows
171 // are logged at Info, which the default handler always emits.
172 st.AuditJournal = slog.Default()
173 // Values stored before sealing existed, or under a key a
174 // rotation retired, are sealed under the current key before
175 // anything reads them. A value the key file cannot open
176 // stops the start here rather than failing each delivery.
177 if n, err := st.ResealSecrets(); err != nil {
178 return fmt.Errorf("sealing secrets under %s: %w (gitbayd admin secrets check lists every value that does not open)", cfg.Server.SecretKeyFile, err)
179 } else if n > 0 {
180 slog.Info("sealed secret values", "count", n)
181 }
182
183 // Regenerate hook scripts so a moved binary self-heals, then
184 // start the hook policy socket.
185 self, err := os.Executable()
186 if err != nil {
187 return err
188 }
189 if err := hookd.WriteHookScripts(control.HooksDir(cfg.Server.Root), self); err != nil {
190 return err
191 }
192 stopHookd, err := hookd.Serve(cfg, st)
193 if err != nil {
194 return err
195 }
196 defer stopHookd()
197
198 // SIGTERM is how a deploy restarts the daemon: stop accepting,
199 // let what is in flight finish, exit 0 (#105).
200 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
201 defer stop()
202
203 // Outbound webhook deliveries, and the mail queue when SMTP is
204 // configured, share one retry base. It is overridable for
205 // tests via GITBAY_WEBHOOK_RETRY_BASE; notify.DefaultRetryBase
206 // names the production default so nothing else has to guess it.
207 retryBase := notify.DefaultRetryBase
208 if v := os.Getenv("GITBAY_WEBHOOK_RETRY_BASE"); v != "" {
209 if d, err := time.ParseDuration(v); err == nil {
210 retryBase = d
211 }
212 }
213 whCtx, whCancel := context.WithCancel(context.Background())
214 defer whCancel()
215 go webhook.New(st, cfg.Webhooks.AllowLocal, retryBase).Run(whCtx)
216 if cfg.Mail.SMTPHost != "" {
217 go notify.New(st, cfg, retryBase).Run(whCtx)
218 }
219 if in := cfg.Mail.Inbound; in.Enabled {
220 // An unreadable password file is a misconfiguration:
221 // refuse to start rather than poll and fail every tick.
222 if _, err := in.Password(); err != nil {
223 return err
224 }
225 if !in.Authenticated() {
226 slog.Warn("mail reply: [mail.inbound] require_dkim and trusted_authserv_id are unset, so a reply's From is not authenticated; set one on any instance reachable from the internet")
227 }
228 go (&mailin.Poller{P: &mailin.Processor{St: st, Cfg: cfg}, In: in}).Run(whCtx)
229 }
230 if cfg.Push.Enabled {
231 p, err := push.New(st, cfg.Push, cfg.Server.SiteURL, retryBase)
232 if err != nil {
233 // Config validation already parsed the key, so this
234 // is not a misconfiguration; fail loudly rather than
235 // running with a silent delivery route.
236 slog.Error("push: starting deliverer", "err", err)
237 } else {
238 go p.Run(whCtx)
239 }
240 }
241 go mirror.New(st, cfg).Run(whCtx)
242 if d := cfg.Registration.PendingExpiryDuration(); d > 0 {
243 go reapPending(whCtx, st, d)
244 }
245 go purgeDeletions(whCtx, st, cfg)
246 go sweep(whCtx, st, cfg)
247 go (&ci.Scheduler{St: st, SiteURL: cfg.Server.SiteURL,
248 RepoDir: func(owner, name string) string {
249 return control.RepoDir(cfg.Server.Root, owner, name)
250 }}).Run(whCtx)
251 go deps.New(st, cfg, func(owner, name string) string {
252 return control.RepoDir(cfg.Server.Root, owner, name)
253 }, buildinfo.String()).Run(whCtx)
254 go symbols.New(st, func(owner, name string) string {
255 return control.RepoDir(cfg.Server.Root, owner, name)
256 }).Run(whCtx)
257
258 // One pack-generation budget for SSH, smart HTTP and git://.
259 // Anonymous clients ("ip:" principals) share all but one
260 // slot, so an account can always get the last.
261 packMax, packPer, packQueue, packWait := cfg.Limits.PackLimits()
262 packs := packlimit.New(packMax, packPer, packQueue, packWait)
263 if packMax > 1 {
264 packs.CapClass("ip:", packMax-1)
265 }
266 // receive-pack has a budget of its own. Parallel pushes
267 // from one account (scripts, bots, several terminals)
268 // queue, up to half the queue, rather than being refused
269 // once one is waiting.
270 pushMax, pushPer, pushQueue, pushWait := cfg.Limits.PushLimits()
271 pushes := packlimit.New(pushMax, pushPer, pushQueue, pushWait)
272 pushes.Name("push")
273 pushes.CapQueue(pushPerQueue(pushQueue))
274
275 errCh := make(chan error, 3)
276 var sshSrv *sshd.Server
277 var sshLn, gitLn net.Listener
278 if cfg.SSH.Mode == "embedded" {
279 srv, err := sshd.New(cfg, st, packs, pushes)
280 if err != nil {
281 return err
282 }
283 ln, err := net.Listen("tcp", net.JoinHostPort("", strconv.Itoa(cfg.SSH.Port)))
284 if err != nil {
285 return err
286 }
287 slog.Info("ssh listening", "addr", ln.Addr())
288 sshSrv, sshLn = srv, ln
289 go func() { errCh <- srv.Serve(ln) }()
290 } else {
291 // system mode: the host sshd owns the SSH port and invokes
292 // this binary via AuthorizedKeysCommand + forced command.
293 slog.Info("ssh handled by host sshd (ssh.mode = system)")
294 }
295
296 web := httpd.New(cfg, st, packs)
297 // Header and idle timeouts bound what an idle or slow client can
298 // hold open. No write timeout: archives and upload-pack stream
299 // for as long as they take (#104).
300 hs := &http.Server{
301 Addr: cfg.HTTP.Addr,
302 Handler: web.Handler(),
303 ReadHeaderTimeout: 10 * time.Second,
304 IdleTimeout: 2 * time.Minute,
305 MaxHeaderBytes: 64 << 10,
306 }
307 go func() {
308 slog.Info("http listening", "addr", cfg.HTTP.Addr, "tls", cfg.HTTP.TLS)
309 switch cfg.HTTP.TLS {
310 case "off":
311 errCh <- hs.ListenAndServe()
312 case "files":
313 hs.TLSConfig = serverTLS(nil)
314 errCh <- hs.ListenAndServeTLS(cfg.HTTP.CertFile, cfg.HTTP.KeyFile)
315 case "acme":
316 host := cfg.SiteHost()
317 stripPort := func(hp string) string {
318 if h, _, err := net.SplitHostPort(hp); err == nil {
319 return h
320 }
321 return hp
322 }
323 // Beyond the site host, allow <owner>.<pages domain>
324 // for owners that exist — certs come on demand per
325 // subdomain, no wildcard needed.
326 hostPolicy := func(ctx context.Context, h string) error {
327 if h == host {
328 return nil
329 }
330 if pd := cfg.Pages.Domain; pd != "" {
331 if h == pd {
332 return nil // apex: serves a redirect to the forge
333 }
334 if owner, ok := strings.CutSuffix(h, "."+pd); ok &&
335 !strings.Contains(owner, ".") && st.OwnerExists(owner) {
336 return nil
337 }
338 }
339 // Custom pages domains: certs only for claimed hosts.
340 if _, err := st.PageDomainRepo(h); err == nil {
341 return nil
342 }
343 return fmt.Errorf("host %q not served here", h)
344 }
345 m := &autocert.Manager{
346 Prompt: autocert.AcceptTOS,
347 Cache: autocert.DirCache(filepath.Join(cfg.Server.Root, "acme")),
348 HostPolicy: hostPolicy,
349 Email: cfg.HTTP.ACMEEmail,
350 }
351 // TLS-ALPN-01 rides the HTTPS port itself. The optional
352 // plain-HTTP listener adds HTTP-01 and a redirect; losing
353 // it (port 80 taken, no privileges) is not fatal.
354 if addr := cfg.HTTP.ACMEHTTPAddr; addr != "" && addr != "off" {
355 redirect := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
356 // Pages hosts redirect to themselves, not the
357 // forge host.
358 target := host
359 if hostPolicy(r.Context(), stripPort(r.Host)) == nil {
360 target = stripPort(r.Host)
361 }
362 http.Redirect(w, r, "https://"+target+r.URL.RequestURI(), http.StatusMovedPermanently)
363 })
364 go func() {
365 slog.Info("acme http listening", "addr", addr)
366 acmeHTTP := &http.Server{Addr: addr, Handler: m.HTTPHandler(redirect),
367 ReadHeaderTimeout: 10 * time.Second, IdleTimeout: time.Minute}
368 if err := acmeHTTP.ListenAndServe(); err != nil {
369 slog.Warn("acme http listener failed; continuing with TLS-ALPN only", "err", err)
370 }
371 }()
372 }
373 hs.TLSConfig = serverTLS(m.TLSConfig())
374 errCh <- hs.ListenAndServeTLS("", "")
375 }
376 }()
377
378 if cfg.GitDaemon.Enabled {
379 gln, err := net.Listen("tcp", net.JoinHostPort("", strconv.Itoa(cfg.GitDaemon.Port)))
380 if err != nil {
381 return err
382 }
383 slog.Info("git-daemon listening", "addr", gln.Addr())
384 gitLn = gln
385 go func() { errCh <- gitd.New(cfg, st, packs).Serve(gln) }()
386 }
387
388 select {
389 case err := <-errCh:
390 return err
391 case <-ctx.Done():
392 }
393 slog.Info("shutting down")
394 stop()
395 for _, ln := range []net.Listener{sshLn, gitLn} {
396 if ln != nil {
397 ln.Close()
398 }
399 }
400 // Follows run until a build ends; end them first so the drain
401 // waits only for work that finishes.
402 web.Stop()
403 if sshSrv != nil {
404 sshSrv.Stop()
405 }
406 drain, cancel := context.WithTimeout(context.Background(), 30*time.Second)
407 defer cancel()
408 if err := hs.Shutdown(drain); err != nil {
409 slog.Warn("http shutdown", "err", err)
410 }
411 if sshSrv != nil {
412 if err := sshSrv.Shutdown(drain); err != nil {
413 slog.Warn("ssh shutdown", "err", err)
414 }
415 }
416 return nil
417 },
418 }
419}
420
421func migrateCmd() *cobra.Command {
422 var to int
423 cmd := &cobra.Command{
424 Use: "migrate",
425 Short: "apply schema migrations",
426 RunE: func(cmd *cobra.Command, args []string) error {
427 cfg, err := config.Load(configPath)
428 if err != nil {
429 return err
430 }
431 s, err := store.Open(cfg.Server.Root + "/gitbay.db")
432 if err != nil {
433 return err
434 }
435 defer s.Close()
436 if err := s.MigrateTo(to); err != nil {
437 return err
438 }
439 v, err := s.Version()
440 if err != nil {
441 return err
442 }
443 fmt.Println("schema version", v)
444 return nil
445 },
446 }
447 cmd.Flags().IntVar(&to, "to", -1, "target schema version (-1 = latest)")
448 return cmd
449}
450
451func adminCmd() *cobra.Command {
452 admin := &cobra.Command{
453 Use: "admin",
454 Short: "host-local administration",
455 }
456 userCmd := &cobra.Command{Use: "user", Short: "manage users"}
457 userCmd.AddCommand(
458 hostUserCreateCmd(),
459 hostCmd("list [--state active|pending|disabled|admin] [--limit n] [--cursor c]", "list accounts", "admin", "user", "list"),
460 hostCmd("show <username>", "show an account: keys, emails, orgs, tokens, sessions", "admin", "user", "show"),
461 hostCmd("disable <username>", "suspend an account: keys and sessions refused until re-enabled", "admin", "user", "disable"),
462 hostCmd("enable <username>", "restore a suspended account", "admin", "user", "enable"),
463 hostCmd("delete <username> --yes", "delete an account that anchors nothing (keys, emails, and sessions go with it)", "admin", "user", "delete"),
464 hostCmd("promote <username>", "make an account an instance admin", "admin", "user", "promote"),
465 hostCmd("demote <username>", "remove instance admin from an account (never the last one)", "admin", "user", "demote"),
466 hostCmd("limits <username> [--repos n|default] [--bytes n|default]", "show or set repository and storage caps", "admin", "user", "limits"),
467 )
468 emailCmd := &cobra.Command{Use: "email", Short: "manage user emails"}
469 emailCmd.AddCommand(hostCmd("verify <username> <address>", "mark an email verified by admin assertion", "admin", "email", "verify"))
470 repoCmd := &cobra.Command{Use: "repo", Short: "any repository, for moderation (audited)"}
471 repoCmd.AddCommand(
472 hostCmd("list [--owner o] [--visibility public|private] [--limit n] [--cursor c]", "every repository with size and last push", "admin", "repo", "list"),
473 hostCmd("archive <owner/name>", "archive a repository", "admin", "repo", "archive"),
474 hostCmd("unarchive <owner/name>", "unarchive a repository", "admin", "repo", "unarchive"),
475 hostCmd("visibility <owner/name> public|private", "set a repository's visibility", "admin", "repo", "visibility"),
476 hostCmd("delete <owner/name> --yes", "delete a repository", "admin", "repo", "delete"),
477 )
478 configCmd := &cobra.Command{Use: "config", Short: "the configuration in effect"}
479 configCmd.AddCommand(configShowCmd())
480 auditCmd := hostCmd("audit [--limit n] [--json]", "print the security audit log, newest first", "audit")
481 auditCmd.AddCommand(auditVerifyCmd())
482 admin.AddCommand(
483 userCmd,
484 emailCmd,
485 repoCmd,
486 configCmd,
487 hostCmd("invite --email <address>", "issue a registration invite and email its code", "admin", "invite"),
488 hostCmd("stats [--json]", "instance statistics: counts and per-repository disk usage", "admin", "stats"),
489 hostCmd("runners [--json]", "runner accounts: last poll, scope, the build each holds", "admin", "runners"),
490 auditCmd,
491 backupCmd(),
492 restoreDrillCmd(),
493 secretsCmd(),
494 gcCmd(),
495 adminMigrateCommitRefsCmd(),
496 adminMigrateProfileAboutCmd(),
497 adminBackfillActivityCmd(),
498 )
499 return admin
500}
501
502// hostCmd runs a registry command as the host itself: an admin context
503// with no account behind it, so audit rows carry no actor and the source
504// "host". Arguments pass through untouched; the registry owns the flags,
505// which is what keeps this surface and an admin's SSH session from
506// drifting.
507func hostCmd(use, short string, path ...string) *cobra.Command {
508 return &cobra.Command{
509 Use: use,
510 Short: short,
511 DisableFlagParsing: true,
512 RunE: func(cmd *cobra.Command, args []string) error {
513 for _, a := range args {
514 if a == "--help" || a == "-h" {
515 return cmd.Help()
516 }
517 }
518 return runAsHost(path, args, os.Stdin)
519 },
520 }
521}
522
523// hostArgs pulls the root's --config out of args: with flag parsing off,
524// cobra hands the persistent flag through untouched.
525func hostArgs(args []string) []string {
526 var rest []string
527 for i := 0; i < len(args); i++ {
528 switch {
529 case args[i] == "--config" && i+1 < len(args):
530 configPath = args[i+1]
531 i++
532 case strings.HasPrefix(args[i], "--config="):
533 configPath = strings.TrimPrefix(args[i], "--config=")
534 default:
535 rest = append(rest, args[i])
536 }
537 }
538 return rest
539}
540
541func runAsHost(path, args []string, stdin io.Reader) error {
542 args = hostArgs(args)
543 cfg, err := config.Load(configPath)
544 if err != nil {
545 return err
546 }
547 st, err := openStore(cfg)
548 if err != nil {
549 return err
550 }
551 c := &control.Ctx{
552 User: store.User{Username: "host", IsAdmin: true},
553 Scope: "full",
554 Store: st,
555 Cfg: cfg,
556 Stdin: stdin,
557 Stdout: os.Stdout,
558 Stderr: os.Stderr,
559 Source: "host",
560 }
561 code := control.Dispatch(c, append(append([]string{}, path...), args...))
562 st.Close()
563 if code != 0 {
564 os.Exit(code)
565 }
566 return nil
567}
568
569// hostUserCreateCmd keeps --key <path>, which the registry command cannot
570// take (no file paths over SSH): the file becomes the command's stdin.
571func hostUserCreateCmd() *cobra.Command {
572 return &cobra.Command{
573 Use: "create <username> [--admin] [--email <address> [--verified]] [--key <file>]",
574 Short: "create a user (host-local bootstrap; the only path in closed mode)",
575 DisableFlagParsing: true,
576 RunE: func(cmd *cobra.Command, args []string) error {
577 var stdin io.Reader = os.Stdin
578 var rest []string
579 args = hostArgs(args)
580 for i := 0; i < len(args); i++ {
581 switch {
582 case args[i] == "--help" || args[i] == "-h":
583 return cmd.Help()
584 case args[i] == "--key" && i+1 < len(args) && args[i+1] != "-":
585 f, err := os.Open(args[i+1])
586 if err != nil {
587 return err
588 }
589 defer f.Close()
590 stdin = f
591 rest = append(rest, "--key", "-")
592 i++
593 default:
594 rest = append(rest, args[i])
595 }
596 }
597 return runAsHost([]string{"admin", "user", "create"}, rest, stdin)
598 },
599 }
600}
601
602// sweep prunes expired sessions and tokens, and rows past their
603// configured retention, hourly and once at start. GITBAY_SWEEP_TICK
604// shortens the interval for tests.
605func sweep(ctx context.Context, st *store.Store, cfg config.Config) {
606 tick := time.Hour
607 if v := os.Getenv("GITBAY_SWEEP_TICK"); v != "" {
608 if d, err := time.ParseDuration(v); err == nil {
609 tick = d
610 }
611 }
612 audit, events, deliveries, mail, push := cfg.Retention.Durations()
613 r := store.Retention{Audit: audit, Events: events,
614 WebhookDeliveries: deliveries, Mail: mail, Push: push}
615 t := time.NewTicker(tick)
616 defer t.Stop()
617 for {
618 swept, err := st.Sweep(r, time.Now())
619 if err != nil {
620 slog.Error("sweeping", "err", err, "removed", swept.Total())
621 } else if n := swept.Total(); n > 0 {
622 slog.Info("swept expired rows", "removed", n, "tables", swept)
623 }
624 select {
625 case <-ctx.Done():
626 return
627 case <-t.C:
628 }
629 }
630}
631
632// reapPending removes self-registered accounts still unverified after
633// maxAge, hourly and once at start. GITBAY_REAP_TICK shortens the
634// interval for tests.
635func reapPending(ctx context.Context, st *store.Store, maxAge time.Duration) {
636 tick := time.Hour
637 if v := os.Getenv("GITBAY_REAP_TICK"); v != "" {
638 if d, err := time.ParseDuration(v); err == nil {
639 tick = d
640 }
641 }
642 t := time.NewTicker(tick)
643 defer t.Stop()
644 for {
645 if removed, err := st.ReapPendingUsers(maxAge); err != nil {
646 slog.Error("reaping pending accounts", "err", err)
647 } else if len(removed) > 0 {
648 slog.Info("removed unverified accounts", "users", removed)
649 }
650 select {
651 case <-ctx.Done():
652 return
653 case <-t.C:
654 }
655 }
656}
657
658// purgeDeletions deletes accounts whose deletion grace has passed,
659// hourly and once at start, on the same tick override as reapPending.
660func purgeDeletions(ctx context.Context, st *store.Store, cfg config.Config) {
661 tick := time.Hour
662 if v := os.Getenv("GITBAY_REAP_TICK"); v != "" {
663 if d, err := time.ParseDuration(v); err == nil {
664 tick = d
665 }
666 }
667 t := time.NewTicker(tick)
668 defer t.Stop()
669 for {
670 if purged, err := control.PurgeDueAccounts(cfg, st, time.Now()); err != nil {
671 slog.Error("purging deleted accounts", "err", err)
672 } else if len(purged) > 0 {
673 slog.Info("purged deleted accounts", "users", purged)
674 }
675 select {
676 case <-ctx.Done():
677 return
678 case <-t.C:
679 }
680 }
681}
682
683// pushPerQueue is how many pushes one principal may have waiting: half
684// the queue, at least one.
685func pushPerQueue(queue int) int {
686 return max(1, queue/2)
687}