vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Stream run logs over the runner's SSH channel
5ece70a50ebda392626b92bdf29d28bc9b444954 · Keenan Nemetz · 10d ago · parent fdf2340f
Stream run logs over the runner's SSH channel The runner wrote the log to a temp file and uploaded it as one LFS object when the job ended, so a running job had no log to read and a log was stored like a shared artifact even though it belongs to one attempt. The runner sends 128 KiB chunks over the same runner channel that holds the lease. The server appends them to data/logs/<repo>/<run>/<attempt>.log and records the size while the run is still running. The finish record carries the final size; the server checks the file length, syncs it, and stores the result. A partial or failed stream leaves no log on the record. Logs are no longer LFS objects: they are not shared, not content-addressed, and the web serves the file directly, with range and step slices. GC no longer treats them as LFS roots.
server/integration_test.go +11−16
| @@ -5,7 +5,6 @@ import ( | |||
| 5 | 5 | "bytes" | |
| 6 | 6 | "crypto/ed25519" | |
| 7 | 7 | "crypto/rand" | |
| 8 | "crypto/sha256" | ||
| 9 | 8 | "encoding/pem" | |
| 10 | 9 | "errors" | |
| 11 | 10 | "fmt" | |
| @@ -2827,21 +2826,19 @@ jobs: | |||
| 2827 | 2826 | if done.Status != runs.StatusSuccess { | |
| 2828 | 2827 | t.Fatalf("run status = %s (exit %d)", done.Status, done.ExitCode) | |
| 2829 | 2828 | } | |
| 2830 | // The log is the runner's own object, not something the queue collected: the id | ||
| 2831 | // in the run is the hash of the bytes the runner uploaded, the queue holds no | ||
| 2832 | // text at all, and the page serves those bytes verbatim. | ||
| 2833 | if done.LogOID == "" { | ||
| 2834 | t.Fatalf("finished run has no archived log: %+v", done) | ||
| 2835 | } | ||
| 2836 | if done.Log != "" { | ||
| 2837 | t.Fatalf("the queue kept a copy of the log: %q", done.Log) | ||
| 2829 | // The log is a file under the instance's log directory, streamed while the job | ||
| 2830 | // ran rather than uploaded as one object at the end. The page serves those bytes. | ||
| 2831 | logPath := filepath.Join(f.cfg.LogsDir(), f.repo, done.ID, fmt.Sprintf("%d.log", done.Attempts)) | ||
| 2832 | stored, err := os.ReadFile(logPath) | ||
| 2833 | if err != nil { | ||
| 2834 | t.Fatalf("finished run has no log file: %v", err) | ||
| 2838 | 2835 | } | |
| 2839 | 2836 | code, body := f.get("/r/" + f.repo + "/runs/" + done.ID + "/log") | |
| 2840 | 2837 | if code != 200 || !strings.Contains(body, "hello from the runner") { | |
| 2841 | 2838 | t.Fatalf("run log page = %d, body:\n%s", code, body) | |
| 2842 | 2839 | } | |
| 2843 | if sum := fmt.Sprintf("%x", sha256.Sum256([]byte(body))); sum != done.LogOID { | ||
| 2844 | t.Fatalf("the served log hashes to %s, but the run names %s: the object is not the runner's bytes", sum, done.LogOID) | ||
| 2840 | if body != string(stored) { | ||
| 2841 | t.Fatalf("the served log differs from %s", logPath) | ||
| 2845 | 2842 | } | |
| 2846 | 2843 | ||
| 2847 | 2844 | // The result is stored in the database, not on the branch: the record names this | |
| @@ -2858,7 +2855,7 @@ jobs: | |||
| 2858 | 2855 | if err != nil || rec == nil { | |
| 2859 | 2856 | t.Fatalf("no stored attempt: %v", err) | |
| 2860 | 2857 | } | |
| 2861 | if rec.Log == nil || rec.Log.OID != done.LogOID || rec.Exit != 0 || rec.Job != done.Job { | ||
| 2858 | if rec.LogSize != int64(len(stored)) || rec.Exit != 0 || rec.Job != done.Job { | ||
| 2862 | 2859 | t.Fatalf("the record does not describe the run: %+v", rec) | |
| 2863 | 2860 | } | |
| 2864 | 2861 | // The runner also records what each step did and where its output lives in the | |
| @@ -3096,11 +3093,9 @@ jobs: | |||
| 3096 | 3093 | return r.Status == runs.StatusSuccess || r.Status == runs.StatusFailed | |
| 3097 | 3094 | }) | |
| 3098 | 3095 | if done.Status != runs.StatusSuccess { | |
| 3099 | // The log is an object the finished attempt uploaded; the attempt that was | ||
| 3100 | // killed never uploaded one, which is the cost of not streaming. | ||
| 3101 | 3096 | _, body := f.get("/r/" + f.repo + "/runs/" + done.ID + "/log") | |
| 3102 | t.Fatalf("the retried run did not succeed: %s (exit %d, log object %q)\n%s", | ||
| 3103 | done.Status, done.ExitCode, done.LogOID, body) | ||
| 3097 | t.Fatalf("the retried run did not succeed: %s (exit %d)\n%s", | ||
| 3098 | done.Status, done.ExitCode, body) | ||
| 3104 | 3099 | } | |
| 3105 | 3100 | if done.Attempts < 2 { | |
| 3106 | 3101 | t.Fatalf("attempts = %d; the job was not actually handed to a second runner", done.Attempts) | |