Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Blame · forge/landing/gate.go

Line attribution on main · 332 line(s)

38b30251
Keenan Nemetz
13d ago
1 // Package landing answers one question, "may this commit land on this branch, and
2 // where does it go?", for both callers that can move a branch: the pre-receive hook
3 // and the server's own merge operation.
4 //
5 // It exists because those two callers must not disagree. The hook is the gate a
6 // push meets; a merge performed inside the server moves the ref without running the
7 // hook at all, so if the rule lived only in the hook, the merge button would be the
8 // easiest way to land an unapproved change. One function, called twice, is the whole
9 // design.
10 package landing
11
12 import (
e5478085
Keenan Nemetz
11d ago
13 "errors"
38b30251
Keenan Nemetz
13d ago
14 "fmt"
15 "strings"
16
d37d301f
Keenan Nemetz
16h ago
17 "rodent.software/vole/ci"
18 "rodent.software/vole/ci/runs"
19 "rodent.software/vole/core/git"
20 "rodent.software/vole/forge/access"
21 "rodent.software/vole/internal/format"
22 "rodent.software/vole/metadata"
38b30251
Keenan Nemetz
13d ago
23 )
24
25 // Gate is what one check needs to know: which repository, whose turn it is, and
26 // what the rules are. It is the same struct for a push and for a merge.
27 type Gate struct {
28 // Repo is the repository the commit lands in.
29 Repo *git.Repo
30 // Name is the repository's name, which is the key the run queue files results
31 // under.
32 Name string
33 // Runs returns the run queue. Nil, or a nil store, means this instance has no
34 // queue, which only matters when a workflow requires a check. It is a function
35 // rather than a path because the two callers hold the queue differently: the
36 // pre-receive hook is a separate process that must open it, and the server
37 // already has it open. Neither is closed here — the caller owns it.
38 Runs func() (*runs.Store, error)
39 // ACL holds the approvals and checks settings for the repository.
40 ACL *access.ACL
b1499c72
Keenan Nemetz
13d ago
41 // Audit records one landing. It is called after the ref has moved, because the
42 // landing is the event; the four fields are the action, the account, the
43 // repository and the detail, matching internal/audit. Nil means the caller
44 // records it another way.
45 Audit func(action, actor, subject, detail string)
38b30251
Keenan Nemetz
13d ago
46 // Actor is the account landing the change, and Role is what the ACL says about
47 // it. The two are passed rather than looked up because the hook already resolved
48 // them for this push, and the name it resolved is the one the key owns.
49 Actor string
50 Role access.Role
51 }
52
53 // Ref is the protected ref a landing writes.
54 func (g Gate) Ref(branch string) string { return "refs/heads/" + branch }
55
56 // Admin reports whether this actor keeps the bypass the hook has always had: an
57 // admin may move a protected branch directly, and the caller records that it
58 // happened.
59 func (g Gate) Admin() bool { return g.Role.AtLeast(access.Admin) }
60
61 // RequiresApprovals reports whether this branch is review-gated at all. An
62 // unprotected branch is not: push access was the only rule, and inventing a review
63 // requirement for it here would refuse landings the hook accepts.
64 func (g Gate) RequiresApprovals(branch string) bool {
65 return g.ACL != nil && g.ACL.IsProtected(g.Ref(branch))
66 }
67
68 // Check decides whether newTip may land on branch, replacing oldTip. It returns the
69 // request that authorised the landing when one did.
70 //
71 // The three answers, which the callers word for their own audience:
72 //
73 // (nil, nil) the landing needs no approval: unprotected branch, or an admin
74 // (merge, nil) an approved request whose head is exactly this commit
75 // (nil, err) refused, and err says why
76 func (g Gate) Check(branch, newTip, oldTip string) (*metadata.Merge, error) {
77 if g.ACL == nil {
78 return nil, fmt.Errorf("no access rules for this repository, refusing the landing")
79 }
80 if !g.RequiresApprovals(branch) {
81 return nil, nil
82 }
83 if g.Admin() {
84 return nil, nil
85 }
e5478085
Keenan Nemetz
11d ago
86 m, refusal, err := g.Approve(branch, newTip, oldTip)
87 if err != nil {
88 var mismatch *metadata.TreeMismatchError
89 if errors.As(err, &mismatch) {
90 return nil, fmt.Errorf("%s: %w", g.Ref(branch), err)
91 }
92 return nil, fmt.Errorf("cannot evaluate approvals, refusing the landing: %w", err)
93 }
94 if m == nil {
95 return nil, errors.New(refusal)
38b30251
Keenan Nemetz
13d ago
96 }
e5478085
Keenan Nemetz
11d ago
97 // Approvals say a person read the change; checks say it works. Both are required
98 // unless the repository turns checks off. For a merge landing the checks are the
99 // ones for the combined result, not for the reviewed head alone: the tree that
100 // lands is the tree that has to pass.
101 if g.ACL.RequiresChecks() {
102 // A repository with no workflows owes no checks, and a merge of two commits
103 // with none cannot acquire one. Ask both sides before demanding a candidate:
104 // publishing one just to discover there is nothing to run would make an
105 // ordinary merge push wait on a job that will never exist.
106 headHas, err := HasRequiredChecks(g.Repo, m.Request.Head, branch)
107 if err != nil {
108 return nil, fmt.Errorf("cannot verify required checks: %w", err)
109 }
110 baseHas, err := HasRequiredChecks(g.Repo, oldTip, branch)
111 if err != nil {
112 return nil, fmt.Errorf("cannot verify required checks: %w", err)
113 }
114 if !headHas && !baseHas {
115 return m, nil
116 }
117 tested, err := g.testedCommit(m.Request, newTip)
118 if err != nil {
119 return nil, err
120 }
121 gaps, err := g.CheckGaps(tested, branch)
122 if err != nil {
123 return nil, fmt.Errorf("cannot verify required checks: %w", err)
124 }
125 if len(gaps) > 0 {
126 return nil, fmt.Errorf("checks have not passed for %s:\n %s",
127 format.SHA(tested), strings.Join(gaps, "\n "))
128 }
129 }
130 return m, nil
131 }
132
133 // Approve answers the approval half of the gate: which request authorises this
134 // landing, or why none does. It is separate from Check so a plan can show the
135 // combined result even while approvals are still missing.
136 func (g Gate) Approve(branch, newTip, oldTip string) (*metadata.Merge, string, error) {
38b30251
Keenan Nemetz
13d ago
137 reqs, err := metadata.LoadRequests(g.Repo, metadata.Ref)
138 if err != nil {
e5478085
Keenan Nemetz
11d ago
139 return nil, "", fmt.Errorf("cannot read change requests: %w", err)
140 }
141 required := g.ACL.RequiredApprovals()
142 if required <= 0 {
143 return nil, fmt.Sprintf(
144 "%s is protected and this repository requires approvals_required=0, so only admins may land it (role: %s)",
145 g.Ref(branch), g.Role), nil
38b30251
Keenan Nemetz
13d ago
146 }
147 m, err := metadata.FindApprovedMatch(g.Repo, reqs, metadata.ApprovalRequirement{
148 Branch: branch, NewTip: newTip, OldTip: oldTip, Required: required,
62b59a76
Keenan Nemetz
12d ago
149 // The same rule the page counts with: an approval counts only while its
150 // author is an active account that still maintains the repository.
151 Eligible: g.ACL.EligibleApprover,
38b30251
Keenan Nemetz
13d ago
152 })
153 if err != nil {
e5478085
Keenan Nemetz
11d ago
154 return nil, "", err
38b30251
Keenan Nemetz
13d ago
155 }
156 if m == nil {
e5478085
Keenan Nemetz
11d ago
157 return nil, fmt.Sprintf(
38b30251
Keenan Nemetz
13d ago
158 "%s is protected: %s needs %d approval(s) on an open change request whose head is exactly %s. "+
b492b557
Keenan Nemetz
3d ago
159 "Add changes/<ulid>/change.md with base=%q and head=%s, then have an approver commit approvals/<their-name>.md naming that same head.",
e5478085
Keenan Nemetz
11d ago
160 g.Ref(branch), g.Actor, required, format.SHA(newTip), branch, format.SHA(newTip)), nil
38b30251
Keenan Nemetz
13d ago
161 }
e5478085
Keenan Nemetz
11d ago
162 return m, "", nil
163 }
164
165 // testedCommit names the commit whose checks decide this landing. A fast-forward
166 // lands the reviewed head, so that is the commit. A merge lands a tree that only
167 // exists in the published candidate, so the candidate is the commit whose results
168 // count; without one the combined result has not been tested.
169 func (g Gate) testedCommit(req *metadata.Request, newTip string) (string, error) {
170 if newTip == req.Head {
171 return req.Head, nil
172 }
173 c, ok, err := LookupCandidate(g.Repo, req)
174 if err != nil {
175 return "", fmt.Errorf("cannot read the combined result for change request %s: %w", req.ID, err)
176 }
177 if !ok {
178 return "", fmt.Errorf(
179 "the combined result for %s and %s has not been tested yet; Vole tests it automatically, then retry",
180 req.Base, format.SHA(req.Head))
181 }
182 return c.SHA, nil
183 }
184
185 // HasRequiredChecks reports whether any workflow at sha applies to branch. It runs
186 // before the candidate is required: a merge of two commits with no workflows owes
187 // no combined result.
188 func HasRequiredChecks(repo *git.Repo, sha, branch string) (bool, error) {
189 wfs, problems := ci.At(repo, sha)
190 if len(problems) > 0 {
191 return false, fmt.Errorf("cannot read the workflows at %s: %s", format.SHA(sha), strings.Join(problems, "; "))
192 }
193 for _, wf := range wfs {
194 if wf.Applies(branch) {
195 return true, nil
38b30251
Keenan Nemetz
13d ago
196 }
197 }
e5478085
Keenan Nemetz
11d ago
198 return false, nil
38b30251
Keenan Nemetz
13d ago
199 }
200
201 // CheckGaps reports which required CI jobs have not passed for a commit.
202 //
203 // A green run only counts when the host has allowed the account that reported it: a
204 // required check is a claim about a commit, and without that rule any writer could
205 // satisfy it by running CI on their own machine and saying it passed.
206 func (g Gate) CheckGaps(sha, branch string) ([]string, error) {
e5478085
Keenan Nemetz
11d ago
207 gaps, _, err := g.CheckSummary(sha, branch)
208 return gaps, err
209 }
210
211 // CheckSummary is CheckGaps plus whether every gap is a wait. The page uses the
212 // second answer to tell "testing" from "failed": a queued run is not a reason to
213 // give up, a red or unattributable run is.
214 func (g Gate) CheckSummary(sha, branch string) ([]string, bool, error) {
38b30251
Keenan Nemetz
13d ago
215 wfs, problems := ci.At(g.Repo, sha)
216 if len(problems) > 0 {
e5478085
Keenan Nemetz
11d ago
217 return nil, false, fmt.Errorf("cannot read the workflows at %s: %s", format.SHA(sha), strings.Join(problems, "; "))
38b30251
Keenan Nemetz
13d ago
218 }
219 var required []*ci.Workflow
220 for _, wf := range wfs {
221 if wf.Applies(branch) {
222 required = append(required, wf)
223 }
224 }
225 if len(required) == 0 {
e5478085
Keenan Nemetz
11d ago
226 return nil, false, nil // nothing to run, so nothing to wait for
38b30251
Keenan Nemetz
13d ago
227 }
228 if g.Runs == nil {
e5478085
Keenan Nemetz
11d ago
229 return nil, false, fmt.Errorf("this repository has workflows but no queue is configured, so required checks can never pass")
38b30251
Keenan Nemetz
13d ago
230 }
231 store, err := g.Runs()
232 if err != nil {
e5478085
Keenan Nemetz
11d ago
233 return nil, false, fmt.Errorf("cannot open the run queue: %w", err)
38b30251
Keenan Nemetz
13d ago
234 }
235 if store == nil {
e5478085
Keenan Nemetz
11d ago
236 return nil, false, fmt.Errorf("this repository has workflows but no queue is configured, so required checks can never pass")
38b30251
Keenan Nemetz
13d ago
237 }
238
239 results, err := store.ForSHA(g.Name, sha)
240 if err != nil {
e5478085
Keenan Nemetz
11d ago
241 return nil, false, fmt.Errorf("cannot read run results: %w", err)
38b30251
Keenan Nemetz
13d ago
242 }
243
244 var gaps []string
e5478085
Keenan Nemetz
11d ago
245 pending := true
246 add := func(problem string, wait bool) {
247 gaps = append(gaps, problem)
248 if !wait {
249 pending = false
250 }
251 }
38b30251
Keenan Nemetz
13d ago
252 for _, wf := range required {
253 for _, job := range wf.Jobs {
254 run := results[runs.Key(wf.Path, job.ID)]
70c9ad4e
Keenan Nemetz
2h ago
255 if problem, wait, gap := checkGap(wf, job, sha, run); gap {
256 add(problem, wait)
38b30251
Keenan Nemetz
13d ago
257 continue
258 }
259 // The result has to be attributable to somebody the host allowed, or it
260 // is a green light from a party with nothing at stake in this branch.
261 att, err := store.Attestation(run.ID)
262 if err != nil {
e5478085
Keenan Nemetz
11d ago
263 return nil, false, fmt.Errorf("cannot read who reported the run for %s: %w", format.SHA(sha), err)
38b30251
Keenan Nemetz
13d ago
264 }
265 switch {
266 case att == nil:
e5478085
Keenan Nemetz
11d ago
267 add(fmt.Sprintf(
38b30251
Keenan Nemetz
13d ago
268 "%s: job %q passed for %s but the result is unsigned, so it cannot count as a check; "+
e5478085
Keenan Nemetz
11d ago
269 "re-run it with a current `vole runner`", wf.Path, job.ID, format.SHA(sha)), false)
38b30251
Keenan Nemetz
13d ago
270 case !g.ACL.CanReport(att.Signer):
e5478085
Keenan Nemetz
11d ago
271 add(fmt.Sprintf(
38b30251
Keenan Nemetz
13d ago
272 "%s: job %q passed for %s, but %s reported it and is not a runner for this "+
273 "repository (runners: %s) — so the result is recorded and does not count. "+
274 "An admin can allow that account with `vole admin acl runners add %s %s`, "+
275 "or a permitted runner can run the job",
276 wf.Path, job.ID, format.SHA(sha), att.Signer,
e5478085
Keenan Nemetz
11d ago
277 RunnerSummary(g.ACL), g.Name, att.Signer), false)
38b30251
Keenan Nemetz
13d ago
278 }
279 }
280 }
e5478085
Keenan Nemetz
11d ago
281 return gaps, len(gaps) > 0 && pending, nil
38b30251
Keenan Nemetz
13d ago
282 }
283
70c9ad4e
Keenan Nemetz
2h ago
284 // checkGap is what one job's newest run means for a landing: why it is not
285 // satisfied, and whether that reason is a wait. Success returns no gap, and the
286 // caller then asks who reported it, which is the one question a status cannot answer.
287 //
288 // A setup failure is a wait rather than a refusal: the job never ran, so the commit is
289 // not what failed, and a required check must not red it. The run page says which
290 // machine stopped it and why, and a rerun is one command away.
291 func checkGap(wf *ci.Workflow, job ci.Job, sha string, run *runs.Run) (problem string, wait, gap bool) {
292 switch {
293 case run == nil:
294 return fmt.Sprintf(
295 "%s: job %q has no run for %s — push the branch and let CI run on it "+
296 "(this job filters on [%s]; a commit tested only on other branches has no result here)",
297 wf.Path, job.ID, format.SHA(sha), strings.Join(wf.Branches, " ")), true, true
298 case run.Status == runs.StatusFailed:
299 return fmt.Sprintf("%s: job %q failed for %s (exit %d)",
300 wf.Path, job.ID, format.SHA(sha), run.ExitCode), false, true
301 case run.Status == runs.StatusCanceled:
302 return fmt.Sprintf("%s: job %q was canceled for %s",
303 wf.Path, job.ID, format.SHA(sha)), false, true
304 case run.Status == runs.StatusError:
305 return fmt.Sprintf(
306 "%s: job %q could not run for %s (%s); the job never started, so nothing was "+
307 "tested. Re-run it with `vole run rerun %s` once the machine can build",
308 wf.Path, job.ID, format.SHA(sha), failureReason(run), run.ID), true, true
309 case run.Status != runs.StatusSuccess:
310 return fmt.Sprintf("%s: job %q is %s for %s; wait for it to finish",
311 wf.Path, job.ID, run.Status, format.SHA(sha)), true, true
312 }
313 return "", false, false
314 }
315
316 // failureReason is the runner's own words for a run that never started, or a
317 // sentence saying it left none. It is one line by construction, so it fits a gap.
318 func failureReason(run *runs.Run) string {
319 if run.FailureMessage != "" {
320 return run.FailureMessage
321 }
322 return "the runner recorded no reason"
323 }
324
38b30251
Keenan Nemetz
13d ago
325 // RunnerSummary renders the allowlist for a message. Admins are named as implicit
326 // because an operator reading the refusal needs to know who already counts.
327 func RunnerSummary(a *access.ACL) string {
328 if len(a.Runners) == 0 {
329 return "none named, so only admins"
330 }
331 return strings.Join(a.Runners, ", ") + " and admins"
332 }