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 · 29m ago 32290114
history blame raw 11.4 KiB
1package ssh
2
3// The run actions the browser offers are also CLI verbs, and both go through the
4// same rules. These tests speak the runner protocol and the run-management verbs
5// over real SSH channels: a stop request reaches a running runner on its next
6// heartbeat, the stopped attempt keeps the canceled status, and a finished run can
7// be queued again.
8
9import (
10 "io"
11 "os"
12 "path/filepath"
13 "strings"
14 "testing"
15 "time"
16
17 "rodent.software/vole/ci/runs"
18 "rodent.software/vole/core/id"
19)
20
21func TestAHeartbeatCarriesTheStopRequest(t *testing.T) {
22 t.Parallel()
23 h := newHarness(t)
24 c := h.runner(t, h.keyPath)
25 run, job := h.leaseRun(t, c, "build")
26
27 // Nobody has asked yet, so the heartbeat is only a lease renewal.
28 if rep := c.call(runnerRequest{Op: "heartbeat", Run: job.ID}); !rep.OK || rep.Cancel != "" {
29 t.Fatalf("heartbeat before the request = %+v", rep)
30 }
31
32 ok, err := h.runs.CancelRunning(run.ID, h.repoName, "maintainer", time.Now().UTC())
33 if err != nil || !ok {
34 t.Fatalf("cancel running = %t, %v", ok, err)
35 }
36 // The lease is untouched: the runner still holds the job, and that is what makes
37 // its partial attempt acceptable when it arrives.
38 rep := c.call(runnerRequest{Op: "heartbeat", Run: job.ID})
39 if !rep.OK || rep.Cancel != "maintainer" {
40 t.Fatalf("heartbeat after the request = %+v", rep)
41 }
42
43 rec := recordFor(job, h.writerID)
44 rec.Exit = 1
45 for i := range rec.Steps {
46 rec.Steps[i].Exit = 1
47 }
48 rep = c.call(runnerRequest{Op: "finish", Run: job.ID, Record: &rec})
49 if !rep.OK {
50 t.Fatalf("the stopped attempt was refused: %+v", rep)
51 }
52 got, err := h.runs.Get(run.ID)
53 if err != nil {
54 t.Fatal(err)
55 }
56 if got.Status != runs.StatusCanceled || !got.CancelRequested || got.CancelActor != "maintainer" {
57 t.Fatalf("run after the stopped attempt = %+v", got)
58 }
59}
60
61// The CLI cancel asks the instance, not a clone: the queue and the access rules are
62// both server-side. It is the same action the browser offers, over the key the
63// account pushes with, and it writes the same audit record.
64func TestTheCLICancelsARunningRun(t *testing.T) {
65 t.Parallel()
66 h := newHarness(t)
67 runner := h.runner(t, h.keyPath)
68 run, job := h.leaseRun(t, runner, "build")
69
70 client := h.dial(t, h.keyPath)
71 sess, err := client.NewSession()
72 if err != nil {
73 t.Fatalf("new session: %v", err)
74 }
75 defer sess.Close()
76 out, err := sess.CombinedOutput("vole-run-cancel /demo.git " + run.ID)
77 if err != nil {
78 t.Fatalf("cancel: %v: %s", err, out)
79 }
80 if !strings.Contains(string(out), "stop") {
81 t.Fatalf("cancel said %q, want the runner's next-heartbeat outcome", out)
82 }
83
84 got, err := h.runs.Get(run.ID)
85 if err != nil {
86 t.Fatal(err)
87 }
88 if got.Status != runs.StatusRunning || !got.CancelRequested || got.CancelActor != h.writerID {
89 t.Fatalf("run after the CLI cancel = %+v", got)
90 }
91
92 // The runner learns from the reply it already asks for.
93 if rep := runner.call(runnerRequest{Op: "heartbeat", Run: job.ID}); !rep.OK || rep.Cancel != h.writerID {
94 t.Fatalf("heartbeat after the CLI cancel = %+v", rep)
95 }
96
97 raw, err := os.ReadFile(filepath.Join(h.dir, "audit.log"))
98 if err != nil {
99 t.Fatalf("reading the audit log: %v", err)
100 }
101 audit := string(raw)
102 for _, want := range []string{"run.cancel", h.writerID, h.repoName, run.ID} {
103 if !strings.Contains(audit, want) {
104 t.Fatalf("audit log does not name %q:\n%s", want, audit)
105 }
106 }
107}
108
109// Canceling is a maintainer capability, and the CLI asks the same rule the page
110// does: a reader may look at a run and may not stop it.
111func TestTheCLIWillNotCancelAsAReader(t *testing.T) {
112 t.Parallel()
113 h := newHarness(t)
114 runner := h.runner(t, h.keyPath)
115 run, _ := h.leaseRun(t, runner, "build")
116
117 client := h.dial(t, h.roKey)
118 sess, err := client.NewSession()
119 if err != nil {
120 t.Fatalf("new session: %v", err)
121 }
122 defer sess.Close()
123 out, err := sess.CombinedOutput("vole-run-cancel /demo.git " + run.ID)
124 if err == nil {
125 t.Fatalf("a reader canceled a run: %s", out)
126 }
127 if !strings.Contains(string(out), "may not manage runs") {
128 t.Fatalf("refusal = %q, want the missing capability", out)
129 }
130 got, err := h.runs.Get(run.ID)
131 if err != nil {
132 t.Fatal(err)
133 }
134 if got.CancelRequested {
135 t.Fatalf("a reader's cancel was applied: %+v", got)
136 }
137}
138
139// Rerun is the other action on a run page, and the CLI offers it with the same
140// rule: a finished run is queued again with the job it already pinned, and the
141// history of the attempt is kept.
142func TestTheCLIRerunsAFinishedRun(t *testing.T) {
143 t.Parallel()
144 h := newHarness(t)
145 runner := h.runner(t, h.keyPath)
146 run, _ := h.leaseRun(t, runner, "build")
147 if ok, err := h.runs.Finish(run.ID, h.writerID, 1, time.Now().UTC()); err != nil || !ok {
148 t.Fatalf("finish = %t, %v", ok, err)
149 }
150
151 client := h.dial(t, h.keyPath)
152 sess, err := client.NewSession()
153 if err != nil {
154 t.Fatalf("new session: %v", err)
155 }
156 defer sess.Close()
157 out, err := sess.CombinedOutput("vole-run-rerun /demo.git " + run.ID)
158 if err != nil {
159 t.Fatalf("rerun: %v: %s", err, out)
160 }
161 if !strings.Contains(string(out), "queued") {
162 t.Fatalf("rerun said %q, want a queued outcome", out)
163 }
164
165 got, err := h.runs.Get(run.ID)
166 if err != nil {
167 t.Fatal(err)
168 }
169 if got.Status != runs.StatusQueued || got.Attempts != run.Attempts {
170 t.Fatalf("run after the CLI rerun = %+v, want queued at the same attempt", got)
171 }
172
173 raw, err := os.ReadFile(filepath.Join(h.dir, "audit.log"))
174 if err != nil {
175 t.Fatalf("reading the audit log: %v", err)
176 }
177 audit := string(raw)
178 for _, want := range []string{"run.rerun", h.writerID, h.repoName, run.ID} {
179 if !strings.Contains(audit, want) {
180 t.Fatalf("audit log does not name %q:\n%s", want, audit)
181 }
182 }
183}
184
185// A run that has not finished cannot be rerun: that would run the same job twice,
186// not queue it again.
187func TestTheCLIWillNotRerunARunningRun(t *testing.T) {
188 t.Parallel()
189 h := newHarness(t)
190 runner := h.runner(t, h.keyPath)
191 run, _ := h.leaseRun(t, runner, "build")
192
193 client := h.dial(t, h.keyPath)
194 sess, err := client.NewSession()
195 if err != nil {
196 t.Fatalf("new session: %v", err)
197 }
198 defer sess.Close()
199 out, err := sess.CombinedOutput("vole-run-rerun /demo.git " + run.ID)
200 if err == nil {
201 t.Fatalf("a running run was rerun: %s", out)
202 }
203 if !strings.Contains(string(out), "only a finished run can be rerun") {
204 t.Fatalf("refusal = %q, want the finished-run rule", out)
205 }
206 got, err := h.runs.Get(run.ID)
207 if err != nil {
208 t.Fatal(err)
209 }
210 if got.Status != runs.StatusRunning {
211 t.Fatalf("a refused rerun changed the run: %+v", got)
212 }
213}
214
215// A reader may diagnose a failure: list the repository's runs, open one and read the
216// bytes its runner wrote. The log verb is the one command whose stdout is data, so a
217// refusal must arrive on stderr; otherwise a redirect would put an error at the top
218// of a file somebody is about to read.
219func TestTheCLIReadsARunAndItsLog(t *testing.T) {
220 t.Parallel()
221 h := newHarness(t)
222 runner := h.runner(t, h.keyPath)
223 run, job := h.leaseRun(t, runner, "build")
224
225 logText := "building image\nstep failed: want 1, got 2\n"
226 if _, err := h.logs.Append(h.repoName, run.ID, 1, 0, []byte(logText)); err != nil {
227 t.Fatalf("append log: %v", err)
228 }
229 rec := recordFor(job, h.writerID)
230 rec.Exit = 1
231 rec.LogSize = int64(len(logText))
232 rec.Steps[0].Exit = 1
233 rec.Steps[0].LogOffset = 0
234 rec.Steps[0].LogSize = int64(len(logText))
235 if rep := runner.call(runnerRequest{Op: "finish", Run: job.ID, Record: &rec}); !rep.OK {
236 t.Fatalf("finish: %+v", rep)
237 }
238
239 // The reader's key, not the writer's: looking is the read capability.
240 out, err := runCLI(t, h, h.roKey, "vole-run-list /demo.git - - - 10")
241 if err != nil {
242 t.Fatalf("list: %v: %s", err, out)
243 }
244 for _, want := range []string{run.ID, "failed", ".vole/workflows/ci.yaml", "build", "yes"} {
245 if !strings.Contains(string(out), want) {
246 t.Fatalf("list does not name %q:\n%s", want, out)
247 }
248 }
249
250 out, err = runCLI(t, h, h.roKey, "vole-run-show /demo.git "+run.ID)
251 if err != nil {
252 t.Fatalf("show: %v: %s", err, out)
253 }
254 for _, want := range []string{
255 "id: " + run.ID,
256 "status: failed (exit 1)",
257 "steps:",
258 "1. test",
259 "failed",
260 "logs:",
261 "attempt 1",
262 "artifacts:",
263 } {
264 if !strings.Contains(string(out), want) {
265 t.Fatalf("show does not contain %q:\n%s", want, out)
266 }
267 }
268
269 out, err = runCLI(t, h, h.roKey, "vole-run-log /demo.git "+run.ID+" 0 0")
270 if err != nil {
271 t.Fatalf("log: %v: %s", err, out)
272 }
273 if string(out) != logText {
274 t.Fatalf("log = %q, want %q", out, logText)
275 }
276
277 out, err = runCLI(t, h, h.roKey, "vole-run-log /demo.git "+run.ID+" 1 1")
278 if err != nil {
279 t.Fatalf("step log: %v: %s", err, out)
280 }
281 if string(out) != logText {
282 t.Fatalf("step log = %q, want %q", out, logText)
283 }
284
285 // A run id from another repository is not a cross-repository read, even though
286 // the id is valid and the account can read this repository.
287 other, err := id.New(time.Now().UTC())
288 if err != nil {
289 t.Fatal(err)
290 }
291 otherRun := run
292 otherRun.ID = other
293 otherRun.Repo = "other"
294 if _, err := h.runs.Enqueue(*otherRun, nil); err != nil {
295 t.Fatal(err)
296 }
297 out, err = runCLI(t, h, h.roKey, "vole-run-show /demo.git "+other)
298 if err == nil {
299 t.Fatalf("a run from another repository was shown: %s", out)
300 }
301 if !strings.Contains(string(out), "no run") {
302 t.Fatalf("refusal = %q, want a miss in this repository", out)
303 }
304}
305
306// A log refusal belongs on stderr, so a redirect gets the log or nothing. The empty
307// stdout is the contract; the exit status is how a script learns to read stderr.
308func TestTheCLIRefusesAMissingLogWithoutWritingStdout(t *testing.T) {
309 t.Parallel()
310 h := newHarness(t)
311 runner := h.runner(t, h.keyPath)
312 run, job := h.leaseRun(t, runner, "build")
313 if ok, err := h.runs.Finish(run.ID, h.writerID, 0, time.Now().UTC()); err != nil || !ok {
314 t.Fatalf("finish = %t, %v", ok, err)
315 }
316 _ = job
317
318 client := h.dial(t, h.roKey)
319 sess, err := client.NewSession()
320 if err != nil {
321 t.Fatalf("new session: %v", err)
322 }
323 defer sess.Close()
324 stdout, err := sess.StdoutPipe()
325 if err != nil {
326 t.Fatalf("stdout: %v", err)
327 }
328 stderr, err := sess.StderrPipe()
329 if err != nil {
330 t.Fatalf("stderr: %v", err)
331 }
332 if err := sess.Start("vole-run-log /demo.git " + run.ID + " 0 0"); err != nil {
333 t.Fatalf("start: %v", err)
334 }
335 data, _ := io.ReadAll(stdout)
336 reason, _ := io.ReadAll(stderr)
337 if err := sess.Wait(); err == nil {
338 t.Fatalf("a missing log succeeded")
339 }
340 if len(data) != 0 {
341 t.Fatalf("stdout = %q, want no log bytes", data)
342 }
343 if !strings.Contains(string(reason), "no log") {
344 t.Fatalf("stderr = %q, want the missing-log reason", reason)
345 }
346}
347
348// runCLI runs one verb on a fresh session: an ssh session carries one command, and a
349// second CombinedOutput on the same session is a test bug, not a server refusal.
350func runCLI(t *testing.T, h *harness, keyPath, command string) (string, error) {
351 t.Helper()
352 client := h.dial(t, keyPath)
353 sess, err := client.NewSession()
354 if err != nil {
355 t.Fatalf("new session: %v", err)
356 }
357 defer sess.Close()
358 out, err := sess.CombinedOutput(command)
359 return string(out), err
360}
361
362// The change page's Runs tab names the head's runs. The CLI's `change show` uses the
363// same map so a reviewer can carry an id straight to `run show`; the id is the part
364// that was previously only in the browser.
365func TestChangeStatusNamesTheHeadRun(t *testing.T) {
366 t.Parallel()
367 h := newHarness(t)
368 runner := h.runner(t, h.keyPath)
369 run, _ := h.leaseRun(t, runner, "build")
370 var out strings.Builder
371 writeRunLines(&out, h.runs, h.repoName, run.SHA, "head")
372 for _, want := range []string{"run: " + run.ID, "running", ".vole/workflows/ci.yaml/build", "(head)"} {
373 if !strings.Contains(out.String(), want) {
374 t.Fatalf("status line does not contain %q:\n%s", want, out.String())
375 }
376 }
377}
378