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/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

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

Code

395 commit(s) on main

update readme Keenan Nemetz · 28m ago 32290114
history blame raw 18.7 KiB
1package ssh
2
3// Reading and managing a run over the SSH channel.
4//
5// vole-run-list <repository> <status|-> <branch|-> <sha|-> <limit>
6// vole-run-show <repository> <run-id>
7// vole-run-log <repository> <run-id> <attempt|0> <step|0>
8// vole-run-cancel <repository> <run-id>
9// vole-run-rerun <repository> <run-id>
10//
11// The queue and its logs are instance-side, so the CLI asks the server the same way
12// `vole change merge` does. The read verbs are the CLI half of the runs page and a
13// run page's log; the write verbs are the counterparts of its buttons. The same
14// account that may look at a page may look here, the same status decides what a
15// command means, and the audit record names the account, the repository, and the run.
16//
17// The verbs are not a shell. Each takes one repository and, except for list, one run
18// id, and answers one line or one log. A reader sees the same rows the browser shows
19// for that repository; a run from another repository is not found, even when the id
20// is known.
21
22import (
23 "fmt"
24 "io"
25 "os"
26 "strconv"
27 "strings"
28 "time"
29
30 xssh "golang.org/x/crypto/ssh"
31
32 "rodent.software/vole/ci/runs"
33 "rodent.software/vole/core/id"
34 "rodent.software/vole/forge/access"
35 "rodent.software/vole/internal/audit"
36 "rodent.software/vole/internal/format"
37)
38
39// maxRunListLimit bounds one listing. A diagnosis reads the newest few runs; a
40// larger answer is a page, and the runs page already has one.
41const maxRunListLimit = 200
42
43// openRunScope resolves the repository and capability shared by every run verb. It
44// writes the refusal to out and reports false when the caller should stop. Reading a
45// run is the repository's read capability, the same rule the runs page uses; managing
46// one is checked separately by openRunFor, because a reader may look and may not act.
47func (s *Server) openRunScope(out io.Writer, argv []string, user, usage string) (string, *access.ACL, bool) {
48 if len(argv) != 3 {
49 fmt.Fprintf(out, "vole: usage: %s\n", usage)
50 return "", nil, false
51 }
52 name, ok := repoName(argv[1])
53 if !ok {
54 fmt.Fprint(out, "vole: invalid repository name\n")
55 return "", nil, false
56 }
57 runID := strings.TrimSpace(argv[2])
58 if !id.Valid(runID) {
59 fmt.Fprintf(out, "vole: %q is not a run id; use the 26-character id from the run page\n", runID)
60 return "", nil, false
61 }
62 if s.cfg.Runs == nil {
63 fmt.Fprint(out, "vole: this instance has no CI queue\n")
64 return "", nil, false
65 }
66 m, err := s.cfg.Repos.Open(name)
67 if err != nil {
68 fmt.Fprintf(out, "vole: repository %q not found\n", name)
69 return "", nil, false
70 }
71 a, err := m.ACL()
72 if err != nil {
73 fmt.Fprintf(out, "vole: cannot read access rules for %s\n", name)
74 return "", nil, false
75 }
76 role := a.EffectiveRole(user, s.cfg.Identity != nil && s.cfg.Identity.IsAdmin(user))
77 if !role.AtLeast(access.Read) {
78 fmt.Fprintf(out, "vole: %s may not read runs in %s\n", user, name)
79 return "", nil, false
80 }
81 return name, a, true
82}
83
84// runIn fetches the run named by argv, scoped to name. An id from another repository
85// is a miss, not a cross-repository read.
86func (s *Server) runIn(out io.Writer, name, runID string) (*runs.Run, bool) {
87 run, err := s.cfg.Runs.Get(runID)
88 if err != nil {
89 fmt.Fprintf(out, "vole: cannot read run %s: %v\n", runID, err)
90 return nil, false
91 }
92 if run == nil || run.Repo != name {
93 fmt.Fprintf(out, "vole: no run %s in %s\n", runID, name)
94 return nil, false
95 }
96 return run, true
97}
98
99// openRunFor resolves the target of a run-management verb. Both verbs ask the same
100// questions in the same order, so a name that is refused for one is refused for the
101// other with the same words: the account, the repository, the maintainer capability,
102// and then the run.
103func (s *Server) openRunFor(ch xssh.Channel, argv []string, user, usage string) (*runs.Run, string, bool) {
104 name, a, ok := s.openRunScope(ch, argv, user, usage)
105 if !ok {
106 return nil, "", false
107 }
108 // Managing a run is the maintainer capability, not triage of one item. This is
109 // the same check the browser's actions make, on the account this key resolved to.
110 if !a.CanTriage(user) {
111 fmt.Fprintf(ch, "vole: %s may not manage runs in %s; only a maintainer or admin may manage CI\n", user, name)
112 return nil, "", false
113 }
114 run, ok := s.runIn(ch, name, strings.TrimSpace(argv[2]))
115 if !ok {
116 return nil, "", false
117 }
118 return run, name, true
119}
120
121// openRunForRead resolves one run for a verb that only looks. It is openRunScope plus
122// the run itself; the read capability was already checked.
123func (s *Server) openRunForRead(out io.Writer, argv []string, user, usage string) (*runs.Run, string, *access.ACL, bool) {
124 name, a, ok := s.openRunScope(out, argv, user, usage)
125 if !ok {
126 return nil, "", nil, false
127 }
128 run, ok := s.runIn(out, name, strings.TrimSpace(argv[2]))
129 if !ok {
130 return nil, "", nil, false
131 }
132 return run, name, a, true
133}
134
135// serveRunList answers the runs page as lines. The fields are tab-separated and fixed
136// in order because the client formats them; they are the run page's own columns.
137func (s *Server) serveRunList(ch xssh.Channel, argv []string, user string) int {
138 if len(argv) != 6 {
139 fmt.Fprint(ch, "vole: usage: vole-run-list <repository> <status|-> <branch|-> <sha|-> <limit>\n")
140 return 129
141 }
142 name, ok := repoName(argv[1])
143 if !ok {
144 fmt.Fprint(ch, "vole: invalid repository name\n")
145 return 129
146 }
147 if s.cfg.Runs == nil {
148 fmt.Fprint(ch, "vole: this instance has no CI queue\n")
149 return 1
150 }
151 m, err := s.cfg.Repos.Open(name)
152 if err != nil {
153 fmt.Fprintf(ch, "vole: repository %q not found\n", name)
154 return 1
155 }
156 a, err := m.ACL()
157 if err != nil {
158 fmt.Fprintf(ch, "vole: cannot read access rules for %s\n", name)
159 return 1
160 }
161 role := a.EffectiveRole(user, s.cfg.Identity != nil && s.cfg.Identity.IsAdmin(user))
162 if !role.AtLeast(access.Read) {
163 fmt.Fprintf(ch, "vole: %s may not read runs in %s\n", user, name)
164 return 1
165 }
166
167 status := dash(argv[2])
168 if status != "" && !runs.ValidStatus(status) {
169 fmt.Fprintf(ch, "vole: %q is not a run status; use queued, running, success, failed, canceled or error\n", status)
170 return 129
171 }
172 branch := dash(argv[3])
173 sha := strings.ToLower(dash(argv[4]))
174 if sha != "" {
175 if len(sha) < 4 || len(sha) > 40 || !isHex(sha) {
176 fmt.Fprintf(ch, "vole: %q is not a commit id or prefix\n", argv[4])
177 return 129
178 }
179 }
180 limit, err := strconv.Atoi(dash(argv[5]))
181 if err != nil || limit < 1 || limit > maxRunListLimit {
182 fmt.Fprintf(ch, "vole: limit must be between 1 and %d\n", maxRunListLimit)
183 return 129
184 }
185
186 list, err := s.cfg.Runs.Search(name, runs.SearchFilter{
187 Status: status, Branch: branch, SHA: sha, Limit: limit,
188 })
189 if err != nil {
190 fmt.Fprintf(ch, "vole: cannot read the queue: %v\n", err)
191 return 1
192 }
193 for _, r := range list {
194 hasLog := "no"
195 if r.HasLog {
196 hasLog = "yes"
197 }
198 fmt.Fprintf(ch, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%d\t%d\t%s\t%s\n",
199 r.ID, r.Status, r.Workflow, r.Job, r.Branch, r.SHA, r.Trigger,
200 r.TriggerTitle(), r.ExitCode, r.Attempts, hasLog, r.Created.Format(time.RFC3339))
201 }
202 return 0
203}
204
205// serveRunShow answers one run the way the run page does: what it ran, how it ended,
206// the steps that ran, the attempts that have a log, and whether the result counts as
207// a check. It is the page a failure is diagnosed on, in text.
208func (s *Server) serveRunShow(ch xssh.Channel, argv []string, user string) int {
209 run, name, acl, ok := s.openRunForRead(ch, argv, user, "vole-run-show <repository> <run-id>")
210 if !ok {
211 return 129
212 }
213
214 rec, recErr := s.cfg.Runs.NewestAttempt(run.ID)
215 if recErr != nil {
216 fmt.Fprintf(ch, "vole: cannot read the attempt record: %v\n", recErr)
217 }
218 // A requeued run is on a newer attempt. The previous attempt's steps are history,
219 // reachable through its log; showing them as this attempt's would be a lie.
220 if rec != nil && rec.Attempt != run.Attempts {
221 rec = nil
222 }
223 logs, err := s.cfg.Runs.AttemptLogs(run.ID)
224 if err != nil {
225 fmt.Fprintf(ch, "vole: cannot read the logs: %v\n", err)
226 }
227 arts, err := s.cfg.Runs.Artifacts(run.ID)
228 if err != nil {
229 fmt.Fprintf(ch, "vole: cannot read the artifacts: %v\n", err)
230 }
231 att, err := s.cfg.Runs.Attestation(run.ID)
232 if err != nil {
233 fmt.Fprintf(ch, "vole: cannot read who reported the run: %v\n", err)
234 }
235
236 fmt.Fprintf(ch, "id: %s\n", run.ID)
237 fmt.Fprintf(ch, "repository: %s\n", name)
238 fmt.Fprintf(ch, "status: %s\n", runStatusLine(run))
239 if run.FailureMessage != "" {
240 fmt.Fprintf(ch, "failure: %s\n", run.FailureMessage)
241 }
242 fmt.Fprintf(ch, "workflow: %s\n", nameAndPath(run.WorkflowTitle(), run.Workflow))
243 fmt.Fprintf(ch, "job: %s\n", nameAndPath(run.JobTitle(), run.Job))
244 fmt.Fprintf(ch, "commit: %s\n", commitLine(run))
245 if t := run.TriggerTitle(); t != "" {
246 fmt.Fprintf(ch, "trigger: %s\n", t)
247 }
248 fmt.Fprintf(ch, "attempt: %d\n", run.Attempts)
249 fmt.Fprintf(ch, "created: %s\n", run.Created.Format(time.RFC3339))
250 if !run.Started.IsZero() {
251 fmt.Fprintf(ch, "started: %s\n", run.Started.Format(time.RFC3339))
252 }
253 if !run.Finished.IsZero() {
254 fmt.Fprintf(ch, "finished: %s\n", run.Finished.Format(time.RFC3339))
255 }
256 fmt.Fprintf(ch, "duration: %s\n", format.Duration(run.Duration()))
257 if run.LeaseOwner != "" {
258 fmt.Fprintf(ch, "runner: %s\n", run.LeaseOwner)
259 }
260 counts, why := runCounts(run, acl, att)
261 if counts {
262 fmt.Fprint(ch, "counts: yes\n")
263 } else {
264 fmt.Fprintf(ch, "counts: no (%s)\n", why)
265 }
266
267 fmt.Fprint(ch, "steps:\n")
268 if rec == nil {
269 label := "not reported"
270 switch run.Status {
271 case runs.StatusQueued:
272 label = "queued"
273 case runs.StatusRunning:
274 label = "waiting"
275 case runs.StatusCanceled:
276 label = "not run"
277 }
278 for i, step := range run.Steps {
279 fmt.Fprintf(ch, " %d. %-24s %s\n", i+1, step.Name, label)
280 }
281 } else {
282 for i, step := range rec.Steps {
283 status, label := "ok", ""
284 if step.Exit != 0 {
285 status, label = "failed", fmt.Sprintf(" (exit %d)", step.Exit)
286 if run.Status == runs.StatusCanceled {
287 status, label = "stopped", ""
288 }
289 }
290 log := ""
291 if step.LogSize > 0 {
292 log = ", log " + format.Bytes(step.LogSize)
293 }
294 fmt.Fprintf(ch, " %d. %-24s %-8s %s%s%s\n", i+1, step.Name, status,
295 format.Duration(step.Duration()), log, label)
296 }
297 for i := len(rec.Steps); i < len(run.Steps); i++ {
298 fmt.Fprintf(ch, " %d. %-24s not run\n", i+1, run.Steps[i].Name)
299 }
300 }
301
302 if len(logs) == 0 {
303 fmt.Fprint(ch, "logs: (none)\n")
304 } else {
305 fmt.Fprint(ch, "logs:\n")
306 for _, l := range logs {
307 fmt.Fprintf(ch, " attempt %d %s %s\n", l.Attempt, format.Bytes(l.Size), l.Created.Format(time.RFC3339))
308 }
309 }
310 if len(arts) == 0 {
311 fmt.Fprint(ch, "artifacts: (none)\n")
312 } else {
313 fmt.Fprint(ch, "artifacts:\n")
314 for _, art := range arts {
315 fmt.Fprintf(ch, " %s %s\n", art.Name, format.Bytes(art.Size))
316 }
317 }
318 return 0
319}
320
321// serveRunLog streams one attempt's log, or one step's byte range inside it, as the
322// bytes the runner wrote. The output is the data, so every refusal goes to the
323// session's stderr; a caller that redirects stdout gets a log or an empty file, never
324// an error message at the top of it.
325func (s *Server) serveRunLog(ch xssh.Channel, argv []string, user string) int {
326 out := ch.Stderr()
327 if len(argv) != 5 {
328 fmt.Fprint(out, "vole: usage: vole-run-log <repository> <run-id> <attempt|0> <step|0>\n")
329 return 129
330 }
331 name, ok := repoName(argv[1])
332 if !ok {
333 fmt.Fprint(out, "vole: invalid repository name\n")
334 return 129
335 }
336 runID := strings.TrimSpace(argv[2])
337 if !id.Valid(runID) {
338 fmt.Fprintf(out, "vole: %q is not a run id; use the 26-character id from the run page\n", runID)
339 return 129
340 }
341 attempt, attemptErr := strconv.Atoi(strings.TrimSpace(argv[3]))
342 step, stepErr := strconv.Atoi(strings.TrimSpace(argv[4]))
343 if attemptErr != nil || attempt < 0 || stepErr != nil || step < 0 {
344 fmt.Fprint(out, "vole: attempt and step are numbers; 0 means the default\n")
345 return 129
346 }
347 if s.cfg.Runs == nil {
348 fmt.Fprint(out, "vole: this instance has no CI queue\n")
349 return 1
350 }
351 if s.cfg.Logs == nil {
352 fmt.Fprint(out, "vole: this instance has no log directory\n")
353 return 1
354 }
355 m, err := s.cfg.Repos.Open(name)
356 if err != nil {
357 fmt.Fprintf(out, "vole: repository %q not found\n", name)
358 return 1
359 }
360 acl, err := m.ACL()
361 if err != nil {
362 fmt.Fprintf(out, "vole: cannot read access rules for %s\n", name)
363 return 1
364 }
365 role := acl.EffectiveRole(user, s.cfg.Identity != nil && s.cfg.Identity.IsAdmin(user))
366 if !role.AtLeast(access.Read) {
367 fmt.Fprintf(out, "vole: %s may not read runs in %s\n", user, name)
368 return 1
369 }
370 run, ok := s.runIn(out, name, runID)
371 if !ok {
372 return 1
373 }
374
375 // The default is the attempt the run is on while it runs, and the newest logged
376 // attempt after it has finished. That is the attempt a reader means by "the log".
377 logs, err := s.cfg.Runs.AttemptLogs(run.ID)
378 if err != nil {
379 fmt.Fprintf(out, "vole: cannot read the log index: %v\n", err)
380 return 1
381 }
382 if attempt == 0 {
383 attempt = run.Attempts
384 if run.Status != runs.StatusRunning && run.Status != runs.StatusQueued && len(logs) > 0 {
385 attempt = logs[len(logs)-1].Attempt
386 }
387 }
388 if attempt < 1 {
389 fmt.Fprint(out, "vole: this run has not started, so it has no log yet\n")
390 return 1
391 }
392
393 var offset, length int64 = 0, -1
394 if step > 0 {
395 rec, err := s.cfg.Runs.Attempt(run.ID, attempt)
396 if err != nil {
397 fmt.Fprintf(out, "vole: cannot read attempt %d: %v\n", attempt, err)
398 return 1
399 }
400 if rec == nil || step > len(rec.Steps) {
401 fmt.Fprintf(out, "vole: attempt %d has no step %d\n", attempt, step)
402 return 1
403 }
404 sr := rec.Steps[step-1]
405 if sr.LogSize <= 0 || sr.LogOffset < 0 {
406 fmt.Fprintf(out, "vole: step %d of attempt %d has no log\n", step, attempt)
407 return 1
408 }
409 offset, length = sr.LogOffset, sr.LogSize
410 }
411
412 f, info, err := s.cfg.Logs.Open(run.Repo, run.ID, attempt)
413 if err != nil {
414 if os.IsNotExist(err) {
415 fmt.Fprintf(out, "vole: attempt %d has no log (it may not have produced output, or its runner was killed)\n", attempt)
416 return 1
417 }
418 fmt.Fprintf(out, "vole: cannot open the log: %v\n", err)
419 return 1
420 }
421 defer f.Close()
422 if length < 0 {
423 length = info.Size() - offset
424 }
425 if offset > info.Size() || offset+length > info.Size() {
426 fmt.Fprintf(out, "vole: step %d of attempt %d points outside its log\n", step, attempt)
427 return 1
428 }
429 if _, err := io.CopyN(ch, f, length); err != nil {
430 s.cfg.Log.Warn("ssh: run log copy failed", "run", run.ID, "attempt", attempt, "err", err)
431 return 1
432 }
433 return 0
434}
435
436func (s *Server) serveRunCancel(ch xssh.Channel, argv []string, user string) int {
437 run, name, ok := s.openRunFor(ch, argv, user, "vole-run-cancel <repository> <run-id>")
438 if !ok {
439 return 129
440 }
441 now := time.Now().UTC()
442 switch run.Status {
443 case runs.StatusQueued:
444 canceled, err := s.cfg.Runs.CancelQueued(run.ID, name, user, now)
445 if err != nil {
446 fmt.Fprintf(ch, "vole: cannot cancel %s: %v\n", run.ID, err)
447 return 1
448 }
449 if !canceled {
450 fmt.Fprintf(ch, "vole: %s started before it could be canceled; try again\n", run.ID)
451 return 1
452 }
453 s.auditRun("run.cancel", user, name, run.ID)
454 fmt.Fprintf(ch, "canceled %s while it was waiting for a runner\n", run.ID)
455 return 0
456 case runs.StatusRunning:
457 canceled, err := s.cfg.Runs.CancelRunning(run.ID, name, user, now)
458 if err != nil {
459 fmt.Fprintf(ch, "vole: cannot stop %s: %v\n", run.ID, err)
460 return 1
461 }
462 if !canceled {
463 fmt.Fprintf(ch, "vole: %s finished before it could be stopped\n", run.ID)
464 return 1
465 }
466 s.auditRun("run.cancel", user, name, run.ID)
467 fmt.Fprintf(ch, "asked %s to stop; its runner stops at the next heartbeat\n", run.ID)
468 return 0
469 default:
470 fmt.Fprintf(ch, "vole: %s is %s and cannot be canceled; only a queued or running run can be canceled\n",
471 run.ID, run.Status)
472 return 1
473 }
474}
475
476func (s *Server) serveRunRerun(ch xssh.Channel, argv []string, user string) int {
477 run, name, ok := s.openRunFor(ch, argv, user, "vole-run-rerun <repository> <run-id>")
478 if !ok {
479 return 129
480 }
481 if run.Status == runs.StatusQueued || run.Status == runs.StatusRunning {
482 fmt.Fprintf(ch, "vole: %s is %s and has not finished yet; only a finished run can be rerun\n",
483 run.ID, run.Status)
484 return 1
485 }
486 queued, err := s.cfg.Runs.Requeue(run.ID, name, time.Now().UTC())
487 if err != nil {
488 fmt.Fprintf(ch, "vole: cannot queue %s again: %v\n", run.ID, err)
489 return 1
490 }
491 if !queued {
492 fmt.Fprintf(ch, "vole: %s changed before it could be rerun; try again\n", run.ID)
493 return 1
494 }
495 s.auditRun("run.rerun", user, name, run.ID)
496 fmt.Fprintf(ch, "queued %s again with the same pinned job\n", run.ID)
497 return 0
498}
499
500// auditRun records the decision. It is the same action name the browser writes,
501// because it is the same action: the transport is not what an operator looking for
502// canceled or rerun work wants to filter on. A failure is reported in the server log
503// rather than refusing the action, which has already happened in the queue — the
504// rule the browser's audit follows for the same reason.
505func (s *Server) auditRun(action, user, repo, runID string) {
506 if err := audit.Append(s.cfg.AuditPath, audit.Record{
507 Action: action,
508 Actor: user,
509 Subject: repo,
510 Detail: runID,
511 }); err != nil {
512 s.cfg.Log.Error("audit log write failed", "account", user, "repo", repo, "run", runID, "error", err)
513 }
514}
515
516// runStatusLine is the stored status with the one fact the status alone does not
517// carry. "error" is a run that never started, so it says that rather than sounding
518// like a verdict about the commit.
519func runStatusLine(r *runs.Run) string {
520 switch r.Status {
521 case runs.StatusError:
522 return "error (could not run; nothing was tested)"
523 case runs.StatusFailed:
524 return fmt.Sprintf("failed (exit %d)", r.ExitCode)
525 default:
526 return r.Status
527 }
528}
529
530// nameAndPath avoids repeating a display name that is the resource key itself.
531func nameAndPath(name, key string) string {
532 if name == "" || name == key {
533 return key
534 }
535 return fmt.Sprintf("%s (%s)", name, key)
536}
537
538func commitLine(r *runs.Run) string {
539 line := format.SHA(r.SHA)
540 switch {
541 case r.Tag != "" && r.Branch != "":
542 line += " on " + r.Branch + ", tag " + r.Tag
543 case r.Tag != "":
544 line += " (tag " + r.Tag + ")"
545 case r.Branch != "":
546 line += " on " + r.Branch
547 }
548 return line
549}
550
551// runCounts answers the question a green run raises when a gate still refuses it,
552// by the same rule the run page and the landing gate use.
553func runCounts(r *runs.Run, acl *access.ACL, att *runs.Attestation) (bool, string) {
554 switch {
555 case r.Status != runs.StatusSuccess:
556 return false, "only a successful run counts as a check"
557 case att == nil:
558 return false, "this run has no record of who reported it"
559 case acl != nil && !acl.CanReport(att.Signer):
560 return false, fmt.Sprintf("%s is not a runner for this repository", att.Signer)
561 default:
562 return true, ""
563 }
564}
565
566// dash turns the "-" placeholder into the empty string a filter means by "unset".
567func dash(v string) string {
568 v = strings.TrimSpace(v)
569 if v == "-" {
570 return ""
571 }
572 return v
573}
574
575func isHex(s string) bool {
576 for _, c := range s {
577 if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
578 return false
579 }
580 }
581 return true
582}
583