Federated via graft
3.3 kB
134 lines
1// Command sync runs the Forgejo <-> Radicle sync daemon: on an interval, it
2// mirrors git content, issues and patches for every repo pair listed in the
3// config file, then sleeps until the next pass.
4package main
5
6import (
7 "flag"
8 "log/slog"
9 "net/http"
10 "net/url"
11 "os"
12 "strings"
13 "time"
14
15 "graft/internal/config"
16 "graft/internal/state"
17 "graft/internal/status"
18 "graft/internal/sync"
19)
20
21func main() {
22 configPath := flag.String("config", "/etc/graft/config.yaml", "path to config.yaml")
23 once := flag.Bool("once", false, "run a single pass and exit, instead of looping")
24 listen := flag.String("listen", ":8090", "address to serve /healthz and / (status) on; empty disables it")
25 flag.Parse()
26
27 log := slog.New(slog.NewJSONHandler(os.Stdout, nil))
28
29 cfg, err := config.Load(*configPath)
30 if err != nil {
31 log.Error("load config", "err", err)
32 os.Exit(1)
33 }
34
35 st, err := state.Open(cfg.StateDB)
36 if err != nil {
37 log.Error("open state db", "err", err)
38 os.Exit(1)
39 }
40 defer st.Close()
41
42 stateDir := cfg.StateDB
43 if idx := lastSlash(stateDir); idx >= 0 {
44 stateDir = stateDir[:idx]
45 }
46
47 syncers := make([]*sync.RepoSyncer, 0, len(cfg.Repos))
48 for _, pair := range cfg.Repos {
49 rs, err := sync.New(pair, st, sync.WorkDirFor(stateDir, pair.Name))
50 if err != nil {
51 log.Error("set up repo pair", "pair", pair.Name, "err", err)
52 os.Exit(1)
53 }
54 syncers = append(syncers, rs)
55 }
56
57 tracker := status.NewTracker(st, cfg.SourceURL)
58 tracker.SetTopology(buildTopology(cfg))
59 runAll := func() {
60 for _, rs := range syncers {
61 gitErr, issuesErr, patchErr := rs.Run(log)
62 tracker.Record(rs.Name(), rs.Series(), gitErr, issuesErr, patchErr)
63 }
64 }
65
66 if *listen != "" {
67 go func() {
68 if err := http.ListenAndServe(*listen, tracker.Handler()); err != nil {
69 log.Error("status server stopped", "err", err)
70 }
71 }()
72 }
73
74 runAll()
75 if *once {
76 return
77 }
78
79 ticker := time.NewTicker(cfg.SyncInterval)
80 defer ticker.Stop()
81 for range ticker.C {
82 runAll()
83 }
84}
85
86// buildTopology declares every mirror target configured for each series
87// (its Forgejo side and its Radicle side), so the dashboard can show a
88// side that's never produced an event yet — not just sides the activity
89// log happens to mention.
90func buildTopology(cfg *config.Config) map[string][]status.ServerRef {
91 topology := map[string][]status.ServerRef{}
92 seen := map[string]bool{}
93 add := func(series, host, repoURL string) {
94 if host == "" {
95 return
96 }
97 key := series + "|" + host
98 if seen[key] {
99 return
100 }
101 seen[key] = true
102 topology[series] = append(topology[series], status.ServerRef{Label: host, URL: repoURL})
103 }
104
105 for _, pair := range cfg.Repos {
106 series := pair.Series
107 if series == "" {
108 series = pair.Name
109 }
110
111 fHost := ""
112 if u, err := url.Parse(pair.Forgejo.BaseURL); err == nil {
113 fHost = u.Host
114 }
115 fURL := strings.TrimRight(pair.Forgejo.BaseURL, "/") + "/" + pair.Forgejo.Owner + "/" + pair.Forgejo.Repo
116 add(series, fHost, fURL)
117
118 rHost := ""
119 if u, err := url.Parse(pair.Radicle.HTTPBaseURL); err == nil {
120 rHost = u.Host
121 }
122 add(series, rHost, sync.RadicleExplorerLink(pair.Radicle))
123 }
124 return topology
125}
126
127func lastSlash(s string) int {
128 for i := len(s) - 1; i >= 0; i-- {
129 if s[i] == '/' {
130 return i
131 }
132 }
133 return -1
134}