cmd/gitbayd/main.go

e6cd75b5f28bacf51620bb531320c30fd4e66bfd
gitbay/cmd/gitbayd/main.go history · blame · raw

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}