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.
Let the CLI cancel a run, not only the browser
deb024d49d894a5a51b1a5bdc07e66c672f113ab · Keenan Nemetz · 6d ago · parent 0c14f078
Let the CLI cancel a run, not only the browser #168 promised the same account the same actions through either transport, and canceling a running run had just become a browser-only action. The queue is instance-side, so the CLI asks over the key it already pushes with: `vole run cancel <id>` retires a queued run or asks a running one to stop, checks the same maintainer capability the page checks, and writes the same audit record. The server verb takes one repository and one run id and answers one line; the id is the 26-character one in the run page URL. The audit action is now `run.cancel` for both transports, because it is the same action: an operator looking for canceled runs should not have to know which surface asked. Part of #162.
server/ssh/run.go +123
| @@ -0,0 +1,123 @@ | |||
| 1 | package ssh | ||
| 2 | |||
| 3 | // Canceling a run over the SSH channel. | ||
| 4 | // | ||
| 5 | // vole-run-cancel <repository> <run-id> | ||
| 6 | // | ||
| 7 | // Canceling is a write to the queue, and the queue is instance-side, so the CLI | ||
| 8 | // asks the server the same way `vole change merge` does. This is the counterpart of | ||
| 9 | // the browser's cancel button: the same account that may cancel from the page may | ||
| 10 | // cancel here, the same two outcomes follow from the run's status, and the audit | ||
| 11 | // record names the account, the repository, and the run. | ||
| 12 | // | ||
| 13 | // The verb is not a shell. It takes one repository and one run id and answers one | ||
| 14 | // line; a queued run is retired, and a running one is asked to stop at its runner's | ||
| 15 | // next heartbeat, exactly as the page does. | ||
| 16 | |||
| 17 | import ( | ||
| 18 | "fmt" | ||
| 19 | "strings" | ||
| 20 | "time" | ||
| 21 | |||
| 22 | xssh "golang.org/x/crypto/ssh" | ||
| 23 | |||
| 24 | "github.com/rodent-software/vole/ci/runs" | ||
| 25 | "github.com/rodent-software/vole/core/id" | ||
| 26 | "github.com/rodent-software/vole/internal/audit" | ||
| 27 | ) | ||
| 28 | |||
| 29 | func (s *Server) serveRunCancel(ch xssh.Channel, argv []string, user string) int { | ||
| 30 | if len(argv) != 3 { | ||
| 31 | fmt.Fprint(ch, "vole: usage: vole-run-cancel <repository> <run-id>\n") | ||
| 32 | return 129 | ||
| 33 | } | ||
| 34 | name, ok := repoName(argv[1]) | ||
| 35 | if !ok { | ||
| 36 | fmt.Fprint(ch, "vole: invalid repository name\n") | ||
| 37 | return 129 | ||
| 38 | } | ||
| 39 | runID := strings.TrimSpace(argv[2]) | ||
| 40 | if !id.Valid(runID) { | ||
| 41 | fmt.Fprintf(ch, "vole: %q is not a run id; use the 26-character id from the run page\n", runID) | ||
| 42 | return 129 | ||
| 43 | } | ||
| 44 | if s.cfg.Runs == nil { | ||
| 45 | fmt.Fprint(ch, "vole: this instance has no CI queue\n") | ||
| 46 | return 1 | ||
| 47 | } | ||
| 48 | m, err := s.cfg.Repos.Open(name) | ||
| 49 | if err != nil { | ||
| 50 | fmt.Fprintf(ch, "vole: repository %q not found\n", name) | ||
| 51 | return 1 | ||
| 52 | } | ||
| 53 | a, err := m.ACL() | ||
| 54 | if err != nil { | ||
| 55 | fmt.Fprintf(ch, "vole: cannot read access rules for %s\n", name) | ||
| 56 | return 1 | ||
| 57 | } | ||
| 58 | // Managing a run is the maintainer capability, not triage of one item. This is | ||
| 59 | // the same check the browser's action makes, on the account this key resolved to. | ||
| 60 | if !a.CanTriage(user) { | ||
| 61 | fmt.Fprintf(ch, "vole: %s may not cancel runs in %s; only a maintainer or admin may manage CI\n", user, name) | ||
| 62 | return 1 | ||
| 63 | } | ||
| 64 | run, err := s.cfg.Runs.Get(runID) | ||
| 65 | if err != nil { | ||
| 66 | fmt.Fprintf(ch, "vole: cannot read run %s: %v\n", runID, err) | ||
| 67 | return 1 | ||
| 68 | } | ||
| 69 | if run == nil || run.Repo != name { | ||
| 70 | fmt.Fprintf(ch, "vole: no run %s in %s\n", runID, name) | ||
| 71 | return 1 | ||
| 72 | } | ||
| 73 | |||
| 74 | now := time.Now().UTC() | ||
| 75 | switch run.Status { | ||
| 76 | case runs.StatusQueued: | ||
| 77 | ok, err := s.cfg.Runs.CancelQueued(run.ID, name, user, now) | ||
| 78 | if err != nil { | ||
| 79 | fmt.Fprintf(ch, "vole: cannot cancel %s: %v\n", run.ID, err) | ||
| 80 | return 1 | ||
| 81 | } | ||
| 82 | if !ok { | ||
| 83 | fmt.Fprintf(ch, "vole: %s started before it could be canceled; try again\n", run.ID) | ||
| 84 | return 1 | ||
| 85 | } | ||
| 86 | s.auditRunCancel(user, name, run.ID) | ||
| 87 | fmt.Fprintf(ch, "canceled %s while it was waiting for a runner\n", run.ID) | ||
| 88 | return 0 | ||
| 89 | case runs.StatusRunning: | ||
| 90 | ok, err := s.cfg.Runs.CancelRunning(run.ID, name, user, now) | ||
| 91 | if err != nil { | ||
| 92 | fmt.Fprintf(ch, "vole: cannot stop %s: %v\n", run.ID, err) | ||
| 93 | return 1 | ||
| 94 | } | ||
| 95 | if !ok { | ||
| 96 | fmt.Fprintf(ch, "vole: %s finished before it could be stopped\n", run.ID) | ||
| 97 | return 1 | ||
| 98 | } | ||
| 99 | s.auditRunCancel(user, name, run.ID) | ||
| 100 | fmt.Fprintf(ch, "asked %s to stop; its runner stops at the next heartbeat\n", run.ID) | ||
| 101 | return 0 | ||
| 102 | default: | ||
| 103 | fmt.Fprintf(ch, "vole: %s is %s and cannot be canceled; only a queued or running run can be canceled\n", | ||
| 104 | run.ID, run.Status) | ||
| 105 | return 1 | ||
| 106 | } | ||
| 107 | } | ||
| 108 | |||
| 109 | // auditRunCancel records the decision. It is the same action name the browser | ||
| 110 | // writes, because it is the same action: the transport is not what an operator | ||
| 111 | // looking for a canceled run wants to filter on. A failure is reported in the | ||
| 112 | // server log rather than refusing the cancel, which has already happened in the | ||
| 113 | // queue — the rule the browser's audit follows for the same reason. | ||
| 114 | func (s *Server) auditRunCancel(user, repo, runID string) { | ||
| 115 | if err := audit.Append(s.cfg.AuditPath, audit.Record{ | ||
| 116 | Action: "run.cancel", | ||
| 117 | Actor: user, | ||
| 118 | Subject: repo, | ||
| 119 | Detail: runID, | ||
| 120 | }); err != nil { | ||
| 121 | s.cfg.Log.Error("audit log write failed", "account", user, "repo", repo, "run", runID, "error", err) | ||
| 122 | } | ||
| 123 | } | ||