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.

Read a run and its log from the CLI

92d8afc86ab7a5e8f2e56a8981e0614aa55980f1 · Keenan Nemetz · 3h ago · parent 4b8c9e1d

Read a run and its log from the CLI

The queue and its logs live on the instance, so the CLI now has read verbs
beside the cancel and rerun writes: run list, run show and run logs. They
check the repository's read capability, scope an id to its repository, and
stream a log to stdout with refusals on stderr.

change show lists the runs for the head and, when the landing needs one, the
combined result, so a red check carries the id needed to open it. The queue
search narrows by status, branch and commit prefix without a pager.

closes: 01M6YZAQZVJWTA9BA5RV2H6PQ8

server/ssh/run_test.go +166

@@ -7,6 +7,7 @@ package ssh
7 7 // be queued again.
8 8
9 9 import (
10 "io"
10 11 "os"
11 12 "path/filepath"
12 13 "strings"
@@ -14,6 +15,7 @@ import (
14 15 "time"
15 16
16 17 "rodent.software/vole/ci/runs"
18 "rodent.software/vole/core/id"
17 19 )
18 20
19 21 func TestAHeartbeatCarriesTheStopRequest(t *testing.T) {
@@ -209,3 +211,167 @@ func TestTheCLIWillNotRerunARunningRun(t *testing.T) {
209 211 t.Fatalf("a refused rerun changed the run: %+v", got)
210 212 }
211 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.
219 func 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.
308 func 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.
350 func 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.
365 func 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 }