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.
Give the CLI rerun too
70291860685b74018f96a47686e5e6160e11f14f · Keenan Nemetz · 6d ago · parent deb024d4
Give the CLI rerun too The run page offered Rerun while the CLI did not. It is the same maintainer action against the same instance-side queue: `vole run rerun <id>` queues a finished run again with the job it already pinned, refuses one that has not finished, and writes the same `run.rerun` audit record the page writes. Both transports now offer both run actions, which is what #168 asked of issues and comments applied to CI.
server/ssh/run.go +72−35
| @@ -1,18 +1,19 @@ | |||
| 1 | 1 | package ssh | |
| 2 | 2 | ||
| 3 | // Canceling a run over the SSH channel. | ||
| 3 | // Managing a run over the SSH channel. | ||
| 4 | 4 | // | |
| 5 | 5 | // vole-run-cancel <repository> <run-id> | |
| 6 | // vole-run-rerun <repository> <run-id> | ||
| 6 | 7 | // | |
| 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. | ||
| 8 | // Canceling and rerunning are writes to the queue, and the queue is instance-side, | ||
| 9 | // so the CLI asks the server the same way `vole change merge` does. They are the | ||
| 10 | // counterparts of the browser's buttons on a run page: the same account that may act | ||
| 11 | // from the page may act here, the same status decides what the command means, and | ||
| 12 | // the audit record names the account, the repository, and the run. | ||
| 12 | 13 | // | |
| 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. | ||
| 14 | // The verbs are not a shell. Each takes one repository and one run id and answers one | ||
| 15 | // line: a queued run is retired, a running one is asked to stop at its runner's next | ||
| 16 | // heartbeat, and a finished one can be queued again with the same pinned job. | ||
| 16 | 17 | ||
| 17 | 18 | import ( | |
| 18 | 19 | "fmt" | |
| @@ -26,77 +27,89 @@ import ( | |||
| 26 | 27 | "github.com/rodent-software/vole/internal/audit" | |
| 27 | 28 | ) | |
| 28 | 29 | ||
| 29 | func (s *Server) serveRunCancel(ch xssh.Channel, argv []string, user string) int { | ||
| 30 | // openRunFor resolves the target of a run-management verb. It writes the refusal to | ||
| 31 | // ch and reports false when the caller should stop. Both verbs ask the same | ||
| 32 | // questions in the same order, so a name that is refused for one is refused for the | ||
| 33 | // other with the same words: the account, the repository, the maintainer capability, | ||
| 34 | // and then the run. | ||
| 35 | func (s *Server) openRunFor(ch xssh.Channel, argv []string, user, usage string) (*runs.Run, string, bool) { | ||
| 30 | 36 | if len(argv) != 3 { | |
| 31 | fmt.Fprint(ch, "vole: usage: vole-run-cancel <repository> <run-id>\n") | ||
| 32 | return 129 | ||
| 37 | fmt.Fprintf(ch, "vole: usage: %s\n", usage) | ||
| 38 | return nil, "", false | ||
| 33 | 39 | } | |
| 34 | 40 | name, ok := repoName(argv[1]) | |
| 35 | 41 | if !ok { | |
| 36 | 42 | fmt.Fprint(ch, "vole: invalid repository name\n") | |
| 37 | return 129 | ||
| 43 | return nil, "", false | ||
| 38 | 44 | } | |
| 39 | 45 | runID := strings.TrimSpace(argv[2]) | |
| 40 | 46 | if !id.Valid(runID) { | |
| 41 | 47 | fmt.Fprintf(ch, "vole: %q is not a run id; use the 26-character id from the run page\n", runID) | |
| 42 | return 129 | ||
| 48 | return nil, "", false | ||
| 43 | 49 | } | |
| 44 | 50 | if s.cfg.Runs == nil { | |
| 45 | 51 | fmt.Fprint(ch, "vole: this instance has no CI queue\n") | |
| 46 | return 1 | ||
| 52 | return nil, "", false | ||
| 47 | 53 | } | |
| 48 | 54 | m, err := s.cfg.Repos.Open(name) | |
| 49 | 55 | if err != nil { | |
| 50 | 56 | fmt.Fprintf(ch, "vole: repository %q not found\n", name) | |
| 51 | return 1 | ||
| 57 | return nil, "", false | ||
| 52 | 58 | } | |
| 53 | 59 | a, err := m.ACL() | |
| 54 | 60 | if err != nil { | |
| 55 | 61 | fmt.Fprintf(ch, "vole: cannot read access rules for %s\n", name) | |
| 56 | return 1 | ||
| 62 | return nil, "", false | ||
| 57 | 63 | } | |
| 58 | 64 | // 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. | ||
| 65 | // the same check the browser's actions make, on the account this key resolved to. | ||
| 60 | 66 | 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 | ||
| 67 | fmt.Fprintf(ch, "vole: %s may not manage runs in %s; only a maintainer or admin may manage CI\n", user, name) | ||
| 68 | return nil, "", false | ||
| 63 | 69 | } | |
| 64 | 70 | run, err := s.cfg.Runs.Get(runID) | |
| 65 | 71 | if err != nil { | |
| 66 | 72 | fmt.Fprintf(ch, "vole: cannot read run %s: %v\n", runID, err) | |
| 67 | return 1 | ||
| 73 | return nil, "", false | ||
| 68 | 74 | } | |
| 69 | 75 | if run == nil || run.Repo != name { | |
| 70 | 76 | fmt.Fprintf(ch, "vole: no run %s in %s\n", runID, name) | |
| 71 | return 1 | ||
| 77 | return nil, "", false | ||
| 72 | 78 | } | |
| 79 | return run, name, true | ||
| 80 | } | ||
| 73 | 81 | ||
| 82 | func (s *Server) serveRunCancel(ch xssh.Channel, argv []string, user string) int { | ||
| 83 | run, name, ok := s.openRunFor(ch, argv, user, "vole-run-cancel <repository> <run-id>") | ||
| 84 | if !ok { | ||
| 85 | return 129 | ||
| 86 | } | ||
| 74 | 87 | now := time.Now().UTC() | |
| 75 | 88 | switch run.Status { | |
| 76 | 89 | case runs.StatusQueued: | |
| 77 | ok, err := s.cfg.Runs.CancelQueued(run.ID, name, user, now) | ||
| 90 | canceled, err := s.cfg.Runs.CancelQueued(run.ID, name, user, now) | ||
| 78 | 91 | if err != nil { | |
| 79 | 92 | fmt.Fprintf(ch, "vole: cannot cancel %s: %v\n", run.ID, err) | |
| 80 | 93 | return 1 | |
| 81 | 94 | } | |
| 82 | if !ok { | ||
| 95 | if !canceled { | ||
| 83 | 96 | fmt.Fprintf(ch, "vole: %s started before it could be canceled; try again\n", run.ID) | |
| 84 | 97 | return 1 | |
| 85 | 98 | } | |
| 86 | s.auditRunCancel(user, name, run.ID) | ||
| 99 | s.auditRun("run.cancel", user, name, run.ID) | ||
| 87 | 100 | fmt.Fprintf(ch, "canceled %s while it was waiting for a runner\n", run.ID) | |
| 88 | 101 | return 0 | |
| 89 | 102 | case runs.StatusRunning: | |
| 90 | ok, err := s.cfg.Runs.CancelRunning(run.ID, name, user, now) | ||
| 103 | canceled, err := s.cfg.Runs.CancelRunning(run.ID, name, user, now) | ||
| 91 | 104 | if err != nil { | |
| 92 | 105 | fmt.Fprintf(ch, "vole: cannot stop %s: %v\n", run.ID, err) | |
| 93 | 106 | return 1 | |
| 94 | 107 | } | |
| 95 | if !ok { | ||
| 108 | if !canceled { | ||
| 96 | 109 | fmt.Fprintf(ch, "vole: %s finished before it could be stopped\n", run.ID) | |
| 97 | 110 | return 1 | |
| 98 | 111 | } | |
| 99 | s.auditRunCancel(user, name, run.ID) | ||
| 112 | s.auditRun("run.cancel", user, name, run.ID) | ||
| 100 | 113 | fmt.Fprintf(ch, "asked %s to stop; its runner stops at the next heartbeat\n", run.ID) | |
| 101 | 114 | return 0 | |
| 102 | 115 | default: | |
| @@ -106,14 +119,38 @@ func (s *Server) serveRunCancel(ch xssh.Channel, argv []string, user string) int | |||
| 106 | 119 | } | |
| 107 | 120 | } | |
| 108 | 121 | ||
| 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) { | ||
| 122 | func (s *Server) serveRunRerun(ch xssh.Channel, argv []string, user string) int { | ||
| 123 | run, name, ok := s.openRunFor(ch, argv, user, "vole-run-rerun <repository> <run-id>") | ||
| 124 | if !ok { | ||
| 125 | return 129 | ||
| 126 | } | ||
| 127 | if run.Status == runs.StatusQueued || run.Status == runs.StatusRunning { | ||
| 128 | fmt.Fprintf(ch, "vole: %s is %s and has not finished yet; only a finished run can be rerun\n", | ||
| 129 | run.ID, run.Status) | ||
| 130 | return 1 | ||
| 131 | } | ||
| 132 | queued, err := s.cfg.Runs.Requeue(run.ID, name, time.Now().UTC()) | ||
| 133 | if err != nil { | ||
| 134 | fmt.Fprintf(ch, "vole: cannot queue %s again: %v\n", run.ID, err) | ||
| 135 | return 1 | ||
| 136 | } | ||
| 137 | if !queued { | ||
| 138 | fmt.Fprintf(ch, "vole: %s changed before it could be rerun; try again\n", run.ID) | ||
| 139 | return 1 | ||
| 140 | } | ||
| 141 | s.auditRun("run.rerun", user, name, run.ID) | ||
| 142 | fmt.Fprintf(ch, "queued %s again with the same pinned job\n", run.ID) | ||
| 143 | return 0 | ||
| 144 | } | ||
| 145 | |||
| 146 | // auditRun records the decision. It is the same action name the browser writes, | ||
| 147 | // because it is the same action: the transport is not what an operator looking for | ||
| 148 | // canceled or rerun work wants to filter on. A failure is reported in the server log | ||
| 149 | // rather than refusing the action, which has already happened in the queue — the | ||
| 150 | // rule the browser's audit follows for the same reason. | ||
| 151 | func (s *Server) auditRun(action, user, repo, runID string) { | ||
| 115 | 152 | if err := audit.Append(s.cfg.AuditPath, audit.Record{ | |
| 116 | Action: "run.cancel", | ||
| 153 | Action: action, | ||
| 117 | 154 | Actor: user, | |
| 118 | 155 | Subject: repo, | |
| 119 | 156 | Detail: runID, | |