internal/hookd/hookd.go
380 lines · 11888 bytes
1// Package hookd is the unix-socket bridge between git hooks and the daemon.
2// The hook process (gitbayd in hook mode) computes git facts — it inherits
3// git's quarantine environment, which the daemon does not see — and sends
4// them here; the daemon answers with a policy decision.
5//
6// pre-receive is two-phase when the repo requires signed commits: the first
7// response sets NeedCommits, and the hook answers with the raw commit
8// objects (only the hook can read them out of quarantine) for verification.
9package hookd
10
11import (
12 "crypto/sha256"
13 "encoding/json"
14 "fmt"
15 "log/slog"
16 "net"
17 "os"
18 "path"
19 "path/filepath"
20 "strings"
21 "time"
22
23 "gitbay.org/gitbay/internal/ci"
24 "gitbay.org/gitbay/internal/config"
25 "gitbay.org/gitbay/internal/control"
26 "gitbay.org/gitbay/internal/gitutil"
27 "gitbay.org/gitbay/internal/policy"
28 "gitbay.org/gitbay/internal/sig"
29 "gitbay.org/gitbay/internal/store"
30)
31
32// Env variable names passed to git transport subprocesses and inherited by
33// hooks.
34const (
35 EnvSocket = "GITBAY_HOOK_SOCKET"
36 EnvRepoID = "GITBAY_REPO_ID"
37 EnvUserID = "GITBAY_USER_ID"
38)
39
40type Request struct {
41 Hook string `json:"hook"` // pre-receive | post-receive
42 RepoID int64 `json:"repo_id"`
43 UserID int64 `json:"user_id"`
44 Updates []policy.RefUpdate `json:"updates"`
45}
46
47type RawCommit struct {
48 SHA string `json:"sha"`
49 Raw []byte `json:"raw"`
50}
51
52// CommitsPayload is the hook's second message when NeedCommits was set.
53type CommitsPayload struct {
54 Commits []RawCommit `json:"commits"`
55}
56
57type Response struct {
58 Allow bool `json:"allow"`
59 Message string `json:"message,omitempty"`
60 NeedCommits bool `json:"need_commits,omitempty"`
61}
62
63// SocketPath returns the hook socket location. It prefers the server root,
64// but unix socket paths are capped (~104 bytes on macOS, 108 on Linux), so
65// deep roots fall back to a hashed name under the system temp directory.
66// Hooks receive the chosen path via GITBAY_HOOK_SOCKET, so both sides always
67// agree.
68func SocketPath(root string) string {
69 p := filepath.Join(root, "hook.sock")
70 if len(p) <= 100 {
71 return p
72 }
73 sum := sha256.Sum256([]byte(root))
74 return filepath.Join(os.TempDir(), fmt.Sprintf("gitbay-%x.sock", sum[:8]))
75}
76
77type Server struct {
78 cfg config.Config
79 st *store.Store
80}
81
82// Serve listens on the unix socket until the listener is closed.
83func Serve(cfg config.Config, st *store.Store) (func() error, error) {
84 path := SocketPath(cfg.Server.Root)
85 os.Remove(path)
86 ln, err := net.Listen("unix", path)
87 if err != nil {
88 return nil, err
89 }
90 s := &Server{cfg: cfg, st: st}
91 go func() {
92 for {
93 conn, err := ln.Accept()
94 if err != nil {
95 return
96 }
97 go s.handle(conn)
98 }
99 }()
100 return ln.Close, nil
101}
102
103func (s *Server) handle(conn net.Conn) {
104 defer conn.Close()
105 dec := json.NewDecoder(conn)
106 enc := json.NewEncoder(conn)
107 var req Request
108 if err := dec.Decode(&req); err != nil {
109 enc.Encode(Response{Allow: false, Message: "bad hook request"})
110 return
111 }
112 switch req.Hook {
113 case "pre-receive":
114 s.preReceive(req, dec, enc)
115 case "post-receive":
116 s.postReceive(req)
117 enc.Encode(Response{Allow: true})
118 default:
119 enc.Encode(Response{Allow: false, Message: fmt.Sprintf("unknown hook %q", req.Hook)})
120 }
121}
122
123func (s *Server) preReceive(req Request, dec *json.Decoder, enc *json.Encoder) {
124 repo, err := s.st.RepoByID(req.RepoID)
125 if err != nil {
126 enc.Encode(Response{Allow: false, Message: "unknown repository"})
127 return
128 }
129 if msg := policy.CheckPush(repo, req.Updates); msg != "" {
130 enc.Encode(Response{Allow: false, Message: msg})
131 return
132 }
133 if !repo.Settings.RequireSignedCommits {
134 enc.Encode(Response{Allow: true})
135 return
136 }
137
138 // Phase two: ask the hook for the incoming commit objects.
139 if err := enc.Encode(Response{Allow: true, NeedCommits: true}); err != nil {
140 return
141 }
142 var payload CommitsPayload
143 if err := dec.Decode(&payload); err != nil {
144 enc.Encode(Response{Allow: false, Message: "bad commits payload"})
145 return
146 }
147 db := store.SigDB{Store: s.st}
148 for _, rc := range payload.Commits {
149 parsed, err := sig.ParseCommit(rc.Raw)
150 if err != nil {
151 enc.Encode(Response{Allow: false, Message: fmt.Sprintf("unparseable commit %s", rc.SHA)})
152 return
153 }
154 res, err := sig.VerifyCommit(db, parsed)
155 if err != nil || res.State != sig.Verified {
156 state := "error"
157 if err == nil {
158 state = string(res.State)
159 }
160 enc.Encode(Response{Allow: false, Message: fmt.Sprintf(
161 "this repository requires signed commits: %.10s is %s", rc.SHA, state)})
162 return
163 }
164 }
165 enc.Encode(Response{Allow: true})
166}
167
168// postReceive applies the cross-repo MR effect: a push to a source branch
169// refreshes refs/merge-requests/N/head in every target repo, by fetching —
170// the target owns the objects, so the MR outlives the fork. This is the only
171// place a hook writes outside its own repository.
172func (s *Server) postReceive(req Request) {
173 pushedRepo, pushedRepoErr := s.st.RepoByID(req.RepoID)
174 for _, u := range req.Updates {
175 // Every ref update is an event webhooks can subscribe to.
176 s.st.RecordEvent(req.RepoID, req.UserID, "push", fmt.Sprintf(
177 `{"ref":%q,"old":%q,"new":%q,"forced":%v,"deleted":%v}`,
178 u.Ref, u.Old, u.New, u.IsForce, u.IsDelete))
179
180 // Any ref update — branch or tag — schedules the push mirrors.
181 s.st.MarkMirrorsDirty(req.RepoID, "push")
182
183 // Tag pushes run the tag-triggered CI jobs.
184 if tag, ok := strings.CutPrefix(u.Ref, "refs/tags/"); ok && !u.IsDelete && pushedRepoErr == nil {
185 s.queueTagBuilds(pushedRepo, req.UserID, tag, u.New)
186 }
187
188 branch, ok := cutHeads(u.Ref)
189 if !ok {
190 continue
191 }
192 // Commits landing on the default branch act on issue references
193 // in their messages (closes #N, plain #N).
194 if pushedRepoErr == nil && branch == pushedRepo.DefaultBranch && !u.IsDelete {
195 dir := control.RepoDir(s.cfg.Server.Root, pushedRepo.OwnerName, pushedRepo.Name)
196 control.ProcessCommitMessages(s.st, dir, pushedRepo, req.UserID, u.Old, u.New)
197 control.RecordLandedCommits(s.st, dir, pushedRepo, u.Old, u.New)
198 }
199 // A branch push with a .gitbay/ci.yml queues one build per job.
200 if pushedRepoErr == nil && !u.IsDelete {
201 s.queueBuilds(pushedRepo, req.UserID, branch, u.New)
202 }
203 if u.IsForce {
204 s.st.Audit(req.UserID, "push.forced", map[string]any{
205 "repo": req.RepoID, "ref": u.Ref, "old": u.Old, "new": u.New})
206 }
207 mrs, err := s.st.OpenMRsBySource(req.RepoID, branch)
208 if err != nil {
209 slog.Error("post-receive: listing MRs", "err", err)
210 continue
211 }
212 srcRepo, err := s.st.RepoByID(req.RepoID)
213 if err != nil {
214 continue
215 }
216 srcDir := control.RepoDir(s.cfg.Server.Root, srcRepo.OwnerName, srcRepo.Name)
217 for _, mr := range mrs {
218 target, err := s.st.RepoByID(mr.RepoID)
219 if err != nil {
220 continue
221 }
222 if u.IsDelete {
223 if mr.State == "open" {
224 s.st.SetMRState(mr.ID, "source_gone")
225 }
226 continue // head ref retained: the diff stays viewable
227 }
228 dstDir := control.RepoDir(s.cfg.Server.Root, target.OwnerName, target.Name)
229 headRef := fmt.Sprintf("refs/merge-requests/%d/head", mr.Number)
230 if err := gitutil.FetchInto(dstDir, srcDir, u.New, headRef); err != nil {
231 slog.Error("post-receive: refreshing MR head", "mr", mr.Number, "err", err)
232 continue
233 }
234 if err := s.st.UpdateMRHead(mr.ID, u.New); err != nil {
235 slog.Error("post-receive: recording MR head", "mr", mr.Number, "err", err)
236 }
237 if mr.State == "source_gone" {
238 s.st.SetMRState(mr.ID, "open") // branch came back
239 }
240 }
241 }
242}
243
244// queueBuilds reads .gitbay/ci.yml at the pushed commit and creates one
245// pending build per job, with a pending commit status the runner resolves.
246// A broken config surfaces as a failed "ci/config" status, not silence.
247func (s *Server) queueBuilds(repo store.Repo, userID int64, branch, sha string) {
248 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
249 raw, err := gitutil.ReadBlob(dir, sha, ci.ConfigPath, 1<<16)
250 if err != nil {
251 return // no CI config at this commit
252 }
253 jobs, err := ci.Parse(raw)
254 if err != nil {
255 s.st.SetCommitStatus(repo.ID, sha, "ci/config", "failure", err.Error(), "", userID)
256 return
257 }
258 now := time.Now()
259 var schedules []store.Schedule
260 for _, j := range jobs {
261 // Tag jobs run on matching tag pushes only.
262 if j.Tags != "" {
263 continue
264 }
265 // Scheduled jobs run on their cron, not on push; a default-branch
266 // push (re)registers them.
267 if j.Schedule != "" {
268 if branch == repo.DefaultBranch {
269 schedules = append(schedules, store.Schedule{
270 RepoID: repo.ID, Job: j.Name, Cron: j.Schedule,
271 NextRun: ci.NextRun(j.Schedule, now),
272 })
273 }
274 continue
275 }
276 steps, _ := json.Marshal(j.Steps)
277 n, err := s.st.CreateBuild(repo.ID, j.Name, sha, branch, string(steps))
278 if err != nil {
279 slog.Error("queueing build", "repo", repo.Path(), "job", j.Name, "err", err)
280 continue
281 }
282 url := fmt.Sprintf("%s/%s/builds/%d", s.cfg.Server.SiteURL, repo.Path(), n)
283 s.st.SetCommitStatus(repo.ID, sha, "ci/"+j.Name, "pending", "queued", url, userID)
284 }
285 if branch == repo.DefaultBranch {
286 if err := s.st.SyncSchedules(repo.ID, schedules); err != nil {
287 slog.Error("syncing schedules", "repo", repo.Path(), "err", err)
288 }
289 }
290}
291
292// queueTagBuilds runs the jobs whose tag pattern matches a pushed tag.
293// The build records the tag as its ref and the peeled commit as its sha,
294// so statuses land on the commit, not an annotated tag object.
295func (s *Server) queueTagBuilds(repo store.Repo, userID int64, tag, pushed string) {
296 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
297 sha, err := gitutil.PeelToCommit(dir, pushed)
298 if err != nil {
299 return
300 }
301 raw, err := gitutil.ReadBlob(dir, sha, ci.ConfigPath, 1<<16)
302 if err != nil {
303 return
304 }
305 jobs, err := ci.Parse(raw)
306 if err != nil {
307 return // the branch push already reported ci/config
308 }
309 for _, j := range jobs {
310 if j.Tags == "" {
311 continue
312 }
313 if ok, _ := path.Match(j.Tags, tag); !ok {
314 continue
315 }
316 steps, _ := json.Marshal(j.Steps)
317 n, err := s.st.CreateBuild(repo.ID, j.Name, sha, tag, string(steps))
318 if err != nil {
319 slog.Error("queueing tag build", "repo", repo.Path(), "job", j.Name, "err", err)
320 continue
321 }
322 url := fmt.Sprintf("%s/%s/builds/%d", s.cfg.Server.SiteURL, repo.Path(), n)
323 s.st.SetCommitStatus(repo.ID, sha, "ci/"+j.Name, "pending", "tag "+tag, url, userID)
324 }
325}
326
327func cutHeads(ref string) (string, bool) {
328 const p = "refs/heads/"
329 if len(ref) > len(p) && ref[:len(p)] == p {
330 return ref[len(p):], true
331 }
332 return "", false
333}
334
335// Ask sends one request from the hook process to the daemon. commits is
336// called if the daemon asks for the incoming commit objects.
337func Ask(socketPath string, req Request, commits func() (CommitsPayload, error)) (Response, error) {
338 conn, err := net.Dial("unix", socketPath)
339 if err != nil {
340 return Response{}, err
341 }
342 defer conn.Close()
343 enc := json.NewEncoder(conn)
344 dec := json.NewDecoder(conn)
345 if err := enc.Encode(req); err != nil {
346 return Response{}, err
347 }
348 var resp Response
349 if err := dec.Decode(&resp); err != nil {
350 return Response{}, err
351 }
352 if !resp.NeedCommits {
353 return resp, nil
354 }
355 payload, err := commits()
356 if err != nil {
357 return Response{}, err
358 }
359 if err := enc.Encode(payload); err != nil {
360 return Response{}, err
361 }
362 err = dec.Decode(&resp)
363 return resp, err
364}
365
366// WriteHookScripts (re)generates the shared hooks directory. Called at
367// daemon startup so a moved binary self-heals; every repo points here via
368// core.hooksPath.
369func WriteHookScripts(hooksDir, gitbaydPath string) error {
370 if err := os.MkdirAll(hooksDir, 0o755); err != nil {
371 return err
372 }
373 for _, hook := range []string{"pre-receive", "post-receive"} {
374 script := fmt.Sprintf("#!/bin/sh\nexec %q hook %s\n", gitbaydPath, hook)
375 if err := os.WriteFile(filepath.Join(hooksDir, hook), []byte(script), 0o755); err != nil {
376 return err
377 }
378 }
379 return nil
380}