server.go
1package bridge
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "log/slog"
8 "net/http"
9 "os"
10 "path/filepath"
11 "sort"
12 "strings"
13 "sync"
14 "time"
15
16 "git.kilimanjaro.io/ci/internal/eventlog"
17 "git.kilimanjaro.io/ci/internal/ident"
18 "git.kilimanjaro.io/ci/internal/lex"
19)
20
21// Server ingests the spool into the event log and serves the stream.
22type Server struct {
23 Log *eventlog.Log
24 SpoolDir string
25 // ForgeHost names repos: did:web:<ForgeHost>:<repo>.
26 ForgeHost string
27 // OwnerDid is the owner (and, for lack of per-user DIDs, the committer)
28 // of every repo. Default: did:web:<ForgeHost>.
29 OwnerDid string
30 // Token, if set, is required as a bearer token on /events and /ingest.
31 Token string
32 Logger *slog.Logger
33
34 mu sync.Mutex
35 poke chan struct{}
36}
37
38func (s *Server) owner() string {
39 if s.OwnerDid != "" {
40 return s.OwnerDid
41 }
42 return ident.ServiceDID(s.ForgeHost)
43}
44
45func (s *Server) logger() *slog.Logger {
46 if s.Logger != nil {
47 return s.Logger
48 }
49 return slog.Default()
50}
51
52// Ingest appends the events of every spooled push, oldest first, and deletes
53// each file once its events are in the log. A file that can't be parsed is
54// moved aside (<name>.bad) so it can't block the spool.
55func (s *Server) Ingest() (int, error) {
56 s.mu.Lock()
57 defer s.mu.Unlock()
58 files, err := filepath.Glob(filepath.Join(s.SpoolDir, "*.json"))
59 if err != nil {
60 return 0, err
61 }
62 sort.Strings(files)
63 n := 0
64 for _, f := range files {
65 b, err := os.ReadFile(f)
66 if err != nil {
67 return n, err
68 }
69 var p Push
70 if err := json.Unmarshal(b, &p); err != nil || p.Repo == "" {
71 s.logger().Error("bad spool file", "file", f, "err", err)
72 _ = os.Rename(f, f+".bad")
73 continue
74 }
75 owner := s.owner()
76 for _, u := range p.Updates {
77 u.LexiconTypeID = lex.GitRefUpdateNSID
78 u.Repo = ident.RepoDID(s.ForgeHost, p.Repo)
79 u.OwnerDid = &owner
80 u.CommitterDid = owner
81 if u.Meta == nil {
82 u.Meta = &lex.GitRefUpdateMeta{CommitCount: &lex.GitRefUpdateCommitCount{}}
83 }
84 if _, err := s.Log.Append(lex.GitRefUpdateNSID, "", u); err != nil {
85 return n, err
86 }
87 n++
88 s.logger().Info("ref update", "repo", p.Repo, "ref", u.Ref, "new", u.NewSha)
89 }
90 if err := os.Remove(f); err != nil {
91 return n, err
92 }
93 }
94 return n, nil
95}
96
97// Run ingests on start, whenever /ingest is poked, and every interval.
98func (s *Server) Run(ctx context.Context, interval time.Duration) {
99 s.mu.Lock()
100 if s.poke == nil {
101 s.poke = make(chan struct{}, 1)
102 }
103 poke := s.poke
104 s.mu.Unlock()
105 for {
106 if _, err := s.Ingest(); err != nil {
107 s.logger().Error("ingest", "err", err)
108 }
109 select {
110 case <-ctx.Done():
111 return
112 case <-poke:
113 case <-time.After(interval):
114 }
115 }
116}
117
118// Handler serves:
119//
120// GET /events the knot-compatible event stream
121// POST /ingest a hook's poke: ingest the spool now
122// GET /.well-known/did.json the bridge's did:web document
123// GET /healthz 200 when the log is readable
124func (s *Server) Handler() http.Handler {
125 mux := http.NewServeMux()
126 mux.Handle("GET /events", eventlog.RequireToken(s.Token, s.Log))
127 mux.Handle("POST /ingest", eventlog.RequireToken(s.Token, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
128 s.mu.Lock()
129 if s.poke == nil {
130 s.poke = make(chan struct{}, 1)
131 }
132 select {
133 case s.poke <- struct{}{}:
134 default:
135 }
136 s.mu.Unlock()
137 w.WriteHeader(http.StatusAccepted)
138 })))
139 mux.HandleFunc("GET /.well-known/did.json", func(w http.ResponseWriter, r *http.Request) {
140 w.Header().Set("Content-Type", "application/did+json")
141 _ = json.NewEncoder(w).Encode(map[string]any{
142 "@context": []string{"https://www.w3.org/ns/did/v1"},
143 "id": ident.ServiceDID(s.ForgeHost),
144 "service": []map[string]string{{
145 "id": "#tangled_knot", "type": "TangledKnot", "serviceEndpoint": "https://" + s.ForgeHost,
146 }},
147 })
148 })
149 mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
150 if _, err := s.Log.List(0, 1); err != nil {
151 http.Error(w, err.Error(), http.StatusServiceUnavailable)
152 return
153 }
154 fmt.Fprintln(w, "ok")
155 })
156 return mux
157}
158
159// Notify is the hook side: spool a push, then poke the server (best
160// effort). Errors are returned for logging; the hook still exits 0.
161func Notify(ctx context.Context, spoolDir, server, token string, p Push) error {
162 if len(p.Updates) == 0 {
163 return nil
164 }
165 if _, err := Spool(spoolDir, p); err != nil {
166 return fmt.Errorf("spool: %w", err)
167 }
168 if server == "" {
169 return nil
170 }
171 ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
172 defer cancel()
173 req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimSuffix(server, "/")+"/ingest", nil)
174 if err != nil {
175 return err
176 }
177 if token != "" {
178 req.Header.Set("Authorization", "Bearer "+token)
179 }
180 resp, err := http.DefaultClient.Do(req)
181 if err != nil {
182 return nil // spooled; the server picks it up on its next pass
183 }
184 resp.Body.Close()
185 return nil
186}