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 | } | ||