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.

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 }