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.
|
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 | } |