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 · 4h 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

client/remote.go +158−7

@@ -8,7 +8,9 @@ import (
8 8 "os"
9 9 "os/exec"
10 10 "sort"
11 "strconv"
11 12 "strings"
13 "time"
12 14
13 15 voleid "rodent.software/vole/core/id"
14 16 "rodent.software/vole/core/role"
@@ -362,21 +364,170 @@ func RerunRun(gitDir, remote, runID string) (string, error) {
362 364 return runVerb(gitDir, remote, runID, "vole-run-rerun")
363 365 }
364 366
365 // runVerb resolves the instance and repository for one run-management verb and runs
366 // it. The caller has already decided which verb; this is only the wire.
367 func runVerb(gitDir, remote, runID, verb string) (string, error) {
367 // RunSummary is one row of `vole run list`. The server answers with the fields in
368 // this order, tab-separated; the CLI formats them. Keeping the wire this small means
369 // the listing does not grow a second serialization of the queue's row.
370 type RunSummary struct {
371 ID string
372 Status string
373 // Workflow and Job are the stable keys. The titles are pinned with the run, but a
374 // line is a place for the thing a command takes, not a paragraph.
375 Workflow string
376 Job string
377 Branch string
378 SHA string
379 Trigger string
380 // TriggerTitle is Trigger in the words the web uses ("pushed to main").
381 TriggerTitle string
382 ExitCode int
383 Attempts int
384 HasLog bool
385 Created time.Time
386 }
387
388 // listRunFields is the number of tab-separated fields serveRunList writes. A change
389 // to one side without the other is a parse error rather than a silently wrong column.
390 const listRunFields = 12
391
392 // ListRuns asks the instance for the newest runs in this repository. status, branch
393 // and sha are filters; an empty string means no filter. The server bounds limit.
394 func ListRuns(gitDir, remote, status, branch, sha string, limit int) ([]RunSummary, error) {
395 host, port, path, err := SSHTarget(gitDir, remote)
396 if err != nil {
397 return nil, err
398 }
399 if RepoNameFromPath(path) == "" {
400 return nil, fmt.Errorf("%s does not name a repository", remote)
401 }
402 args := []string{"vole-run-list", path,
403 valueOrDash(status), valueOrDash(branch), valueOrDash(sha), strconv.Itoa(limit)}
404 quoted := make([]string, len(args))
405 for i, arg := range args {
406 quoted[i] = quoteSSHArg(arg)
407 }
408 out, err := RunSSH(host, port, strings.Join(quoted, " "))
409 if err != nil {
410 return nil, err
411 }
412 return parseRunSummaries(out)
413 }
414
415 // ShowRun asks the instance for one run the way its page would answer. The result is
416 // display text, like `vole change show`'s status block: the server owns the words so
417 // the CLI and the browser cannot disagree about what a run did.
418 func ShowRun(gitDir, remote, runID string) (string, error) {
419 host, port, command, err := runCommand(gitDir, remote, runID, "vole-run-show")
420 if err != nil {
421 return "", err
422 }
423 out, err := RunSSH(host, port, command)
424 if err != nil {
425 return "", err
426 }
427 return strings.TrimRight(out, "\n"), nil
428 }
429
430 // StreamRunLog copies one attempt's log to w. attempt 0 means the attempt the run is
431 // on (or its newest logged one), and step 0 means the whole attempt. The bytes are
432 // written as they arrive, so a large log is not held in memory just to be printed;
433 // a refusal arrives on stderr and never at the top of the log.
434 func StreamRunLog(gitDir, remote, runID string, attempt, step int, w io.Writer) error {
435 host, port, command, err := runCommand(gitDir, remote, runID, "vole-run-log",
436 strconv.Itoa(attempt), strconv.Itoa(step))
437 if err != nil {
438 return err
439 }
440 cmd := SSHCommand(host, port, command)
441 cmd.Stdout = w
442 var stderr strings.Builder
443 cmd.Stderr = &stderr
444 if err := cmd.Run(); err != nil {
445 reason := strings.TrimSpace(stderr.String())
446 if msg, ok := strings.CutPrefix(reason, "vole:"); ok {
447 return errors.New(strings.TrimSpace(msg))
448 }
449 if reason != "" {
450 return fmt.Errorf("%s: %w: %s", host, err, reason)
451 }
452 return fmt.Errorf("%s: %w", host, err)
453 }
454 return nil
455 }
456
457 // parseRunSummaries reads the tab-separated rows serveRunList writes. An answer with
458 // the wrong width is a protocol mismatch, not an empty listing, because dropping it
459 // would make an upgraded server look like a repository with no runs.
460 func parseRunSummaries(out string) ([]RunSummary, error) {
461 var list []RunSummary
462 for _, line := range strings.Split(out, "\n") {
463 if strings.TrimSpace(line) == "" {
464 continue
465 }
466 f := strings.Split(line, "\t")
467 if len(f) != listRunFields {
468 return nil, fmt.Errorf("the instance answered a run list with %d fields, want %d; upgrade the client and server together", len(f), listRunFields)
469 }
470 exitCode, err := strconv.Atoi(f[8])
471 if err != nil {
472 return nil, fmt.Errorf("run %s: exit %q is not a number", f[0], f[8])
473 }
474 attempts, err := strconv.Atoi(f[9])
475 if err != nil {
476 return nil, fmt.Errorf("run %s: attempts %q is not a number", f[0], f[9])
477 }
478 created, err := time.Parse(time.RFC3339, f[11])
479 if err != nil {
480 return nil, fmt.Errorf("run %s: created %q is not a time", f[0], f[11])
481 }
482 list = append(list, RunSummary{
483 ID: f[0], Status: f[1], Workflow: f[2], Job: f[3], Branch: f[4],
484 SHA: f[5], Trigger: f[6], TriggerTitle: f[7], ExitCode: exitCode,
485 Attempts: attempts, HasLog: f[10] == "yes", Created: created,
486 })
487 }
488 return list, nil
489 }
490
491 // valueOrDash keeps an empty filter in its argument position, so the server can tell
492 // "unset" from a value that starts with a dash.
493 func valueOrDash(v string) string {
494 if strings.TrimSpace(v) == "" {
495 return "-"
496 }
497 return v
498 }
499
500 // runCommand resolves the instance and repository for a run verb and builds the one
501 // command line the server parses. The caller has already decided which verb; this is
502 // only the wire.
503 func runCommand(gitDir, remote, runID, verb string, extra ...string) (host, port, command string, err error) {
368 504 runID = strings.ToUpper(strings.TrimSpace(runID))
369 505 if !voleid.Valid(runID) {
370 return "", fmt.Errorf("%q is not a run id; use the 26-character id from the run page", runID)
506 return "", "", "", fmt.Errorf("%q is not a run id; use the 26-character id from the run page", runID)
371 507 }
372 508 host, port, path, err := SSHTarget(gitDir, remote)
373 509 if err != nil {
374 return "", err
510 return "", "", "", err
375 511 }
376 512 if RepoNameFromPath(path) == "" {
377 return "", fmt.Errorf("%s does not name a repository", remote)
513 return "", "", "", fmt.Errorf("%s does not name a repository", remote)
514 }
515 args := append([]string{verb, path, runID}, extra...)
516 quoted := make([]string, len(args))
517 for i, arg := range args {
518 quoted[i] = quoteSSHArg(arg)
519 }
520 return host, port, strings.Join(quoted, " "), nil
521 }
522
523 // runVerb resolves the instance and repository for one run-management verb and runs
524 // it. The caller has already decided which verb; this is only the wire.
525 func runVerb(gitDir, remote, runID, verb string) (string, error) {
526 host, port, command, err := runCommand(gitDir, remote, runID, verb)
527 if err != nil {
528 return "", err
378 529 }
379 out, err := RunSSH(host, port, verb+" "+quoteSSHArg(path)+" "+quoteSSHArg(runID))
530 out, err := RunSSH(host, port, command)
380 531 if err != nil {
381 532 return "", err
382 533 }