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