internal/hookd/hookd.go

7b6134a766399b5b256ee9c2d7ab1c4fb5b810e7
gitbay/internal/hookd/hookd.go history · blame · raw

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}