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.
Store CI run records in the database, not the repository
b312e1660ac3d570fc35350a22b3514c56ab466f · Keenan Nemetz · 11d ago · parent db5e4382
Store CI run records in the database, not the repository A finished attempt was a set of files on the vole branch: a record, step ranges, and artifact pointers, pushed as a commit. At 959 files, CI records were 849 of them, and every human metadata write had to carry them. The runner now sends the record with its finish message over the SSH channel that holds the lease. The server checks the account, the job, and the log and artifact pointers, then writes the attempt, steps, artifacts, attestation, and run result in one transaction in data/runs.db. A tagged build still pushes its release pointers to the branch after the record is stored. Pre-receive refuses a new runs/ file. vole admin index rebuild imports records still on an old branch; records written since are never deleted by a rebuild. The run page reads the stored attempt, and runs.db is now a source that must be backed up. This stops new CI history from entering Git. Existing commits keep the records they already have; removing those would require rewriting the protected vole branch, which this change does not do.
server/integration_test.go +94−110
| @@ -17,7 +17,6 @@ import ( | |||
| 17 | 17 | "os" | |
| 18 | 18 | "os/exec" | |
| 19 | 19 | "path/filepath" | |
| 20 | "runtime" | ||
| 21 | 20 | "strings" | |
| 22 | 21 | "sync" | |
| 23 | 22 | "syscall" | |
| @@ -28,7 +27,7 @@ import ( | |||
| 28 | 27 | ||
| 29 | 28 | "github.com/rodent-software/vole/ci" | |
| 30 | 29 | "github.com/rodent-software/vole/ci/runs" | |
| 31 | "github.com/rodent-software/vole/core/git" | ||
| 30 | "github.com/rodent-software/vole/core/id" | ||
| 32 | 31 | "github.com/rodent-software/vole/forge/access" | |
| 33 | 32 | "github.com/rodent-software/vole/forge/config" | |
| 34 | 33 | "github.com/rodent-software/vole/forge/identity" | |
| @@ -527,6 +526,68 @@ func TestRebuildIndexAfterDelete(t *testing.T) { | |||
| 527 | 526 | } | |
| 528 | 527 | } | |
| 529 | 528 | ||
| 529 | // TestIndexRebuildImportsLegacyRunRecords is the upgrade path: records written | ||
| 530 | // before CI history moved into the database are still on the branch, and a rebuild | ||
| 531 | // imports them into runs.db instead of rebuilding the queue from Git forever. | ||
| 532 | func TestIndexRebuildImportsLegacyRunRecords(t *testing.T) { | ||
| 533 | t.Parallel() | ||
| 534 | f := newFixture(t) | ||
| 535 | f.publish(f.repo) | ||
| 536 | |||
| 537 | now := time.Now().UTC() | ||
| 538 | runID, err := id.New(now) | ||
| 539 | if err != nil { | ||
| 540 | t.Fatal(err) | ||
| 541 | } | ||
| 542 | m, err := f.srv.Repos.Open(f.repo) | ||
| 543 | if err != nil { | ||
| 544 | t.Fatal(err) | ||
| 545 | } | ||
| 546 | rec := runs.Record{ | ||
| 547 | RunID: runID, Attempt: 1, Repo: f.repo, Ref: "refs/heads/main", | ||
| 548 | SHA: strings.Repeat("ab", 20), Workflow: ".vole/workflows/ci.yaml", | ||
| 549 | WorkflowSHA: strings.Repeat("cd", 20), Job: "build", StepsSHA: "steps-digest", | ||
| 550 | Exit: 0, Finished: now, Signer: "keenan", | ||
| 551 | Steps: []runs.StepResult{{ | ||
| 552 | Name: "build", Started: now.Add(-time.Second), Finished: now, | ||
| 553 | }}, | ||
| 554 | } | ||
| 555 | if _, err := runs.WriteRecord(m.Git(), metadata.Ref, rec, "keenan", nil); err != nil { | ||
| 556 | t.Fatalf("writing the legacy record: %v", err) | ||
| 557 | } | ||
| 558 | |||
| 559 | // A fresh database, as after restoring the repositories but not runs.db. | ||
| 560 | f.srv.SSH.Close() | ||
| 561 | f.http.Close() | ||
| 562 | f.srv.Close() | ||
| 563 | for _, p := range []string{f.cfg.RunsDB(), f.cfg.RunsDB() + "-wal", f.cfg.RunsDB() + "-shm"} { | ||
| 564 | if err := os.Remove(p); err != nil && !os.IsNotExist(err) { | ||
| 565 | t.Fatal(err) | ||
| 566 | } | ||
| 567 | } | ||
| 568 | cmd := exec.Command(binPath, "index", "rebuild", "--config", f.cfgFile(t)) | ||
| 569 | if out, err := cmd.CombinedOutput(); err != nil { | ||
| 570 | t.Fatalf("rebuild: %v\n%s", err, out) | ||
| 571 | } | ||
| 572 | |||
| 573 | store, err := runs.Open(f.cfg.RunsDB()) | ||
| 574 | if err != nil { | ||
| 575 | t.Fatal(err) | ||
| 576 | } | ||
| 577 | defer store.Close() | ||
| 578 | got, err := store.Get(runID) | ||
| 579 | if err != nil || got == nil { | ||
| 580 | t.Fatalf("the legacy run was not imported: %v", err) | ||
| 581 | } | ||
| 582 | if got.Status != runs.StatusSuccess || got.Attempts != 1 { | ||
| 583 | t.Fatalf("imported run = %+v", got) | ||
| 584 | } | ||
| 585 | stored, err := store.Attempt(runID, 1) | ||
| 586 | if err != nil || stored == nil || stored.Signer != "keenan" || len(stored.Steps) != 1 { | ||
| 587 | t.Fatalf("imported attempt = %+v, %v", stored, err) | ||
| 588 | } | ||
| 589 | } | ||
| 590 | |||
| 530 | 591 | // TestRosterIsOneDatabaseFile proves the backup story end to end: the roster is | |
| 531 | 592 | // one SQLite file, a consistent copy of it is complete, and no system repository | |
| 532 | 593 | // or key directory exists beside it. | |
| @@ -2846,24 +2907,19 @@ jobs: | |||
| 2846 | 2907 | t.Fatalf("the served log hashes to %s, but the run names %s: the object is not the runner's bytes", sum, done.LogOID) | |
| 2847 | 2908 | } | |
| 2848 | 2909 | ||
| 2849 | // The result is a commit the runner pushed: the record names this job, its log and | ||
| 2850 | // its outcome, and the queue mirrors who reported it and which commit carries it. | ||
| 2851 | // Attribution is the boundary's — the push had to come from that account — and it | ||
| 2852 | // is recorded once, in the push log, rather than carried as a signature. | ||
| 2910 | // The result is stored in the database, not on the branch: the record names this | ||
| 2911 | // job, its log and its outcome, and the queue records who reported it. Attribution | ||
| 2912 | // is the SSH channel's, and it is logged rather than carried as a signature. | ||
| 2853 | 2913 | att, err := f.srv.Runs.Attestation(done.ID) | |
| 2854 | 2914 | if err != nil || att == nil { | |
| 2855 | 2915 | t.Fatalf("no attestation on the run: %v", err) | |
| 2856 | 2916 | } | |
| 2857 | if att.Signer == "" || att.CommitID == "" { | ||
| 2917 | if att.Signer == "" { | ||
| 2858 | 2918 | t.Fatalf("attestation is not attributed: %+v", att) | |
| 2859 | 2919 | } | |
| 2860 | m, err := f.srv.Repos.Open(f.repo) | ||
| 2861 | if err != nil { | ||
| 2862 | t.Fatal(err) | ||
| 2863 | } | ||
| 2864 | rec, err := runs.ReadRecord(m.Git(), metadata.Ref, done.ID) | ||
| 2920 | rec, err := f.srv.Runs.Attempt(done.ID, done.Attempts) | ||
| 2865 | 2921 | if err != nil || rec == nil { | |
| 2866 | t.Fatalf("no record on the branch: %v", err) | ||
| 2922 | t.Fatalf("no stored attempt: %v", err) | ||
| 2867 | 2923 | } | |
| 2868 | 2924 | if rec.Log == nil || rec.Log.OID != done.LogOID || rec.Exit != 0 || rec.Job != done.Job { | |
| 2869 | 2925 | t.Fatalf("the record does not describe the run: %+v", rec) | |
| @@ -2952,11 +3008,7 @@ jobs: | |||
| 2952 | 3008 | done.ImageSHA != wantSHA { | |
| 2953 | 3009 | t.Fatalf("finished run = %+v", done) | |
| 2954 | 3010 | } | |
| 2955 | m, err := f.srv.Repos.Open(f.repo) | ||
| 2956 | if err != nil { | ||
| 2957 | t.Fatal(err) | ||
| 2958 | } | ||
| 2959 | rec, err := runs.ReadRecord(m.Git(), metadata.Ref, done.ID) | ||
| 3011 | rec, err := f.srv.Runs.Attempt(done.ID, done.Attempts) | ||
| 2960 | 3012 | if err != nil { | |
| 2961 | 3013 | t.Fatal(err) | |
| 2962 | 3014 | } | |
| @@ -2988,10 +3040,10 @@ func TestARepoFilterOutsideTheGrantIsRefusedAtStartup(t *testing.T) { | |||
| 2988 | 3040 | } | |
| 2989 | 3041 | } | |
| 2990 | 3042 | ||
| 2991 | // TestRunRecordIsPortable is the promise the record makes: the result of a run is | ||
| 2992 | // on the metadata branch, so a plain clone carries it, and the queue's finished | ||
| 2993 | // half can be rebuilt from git after the database is deleted. | ||
| 2994 | func TestRunRecordIsPortable(t *testing.T) { | ||
| 3043 | // TestRunRecordLivesInTheDatabase is the promise after the move: a finished attempt | ||
| 3044 | // is stored in runs.db, and the metadata branch carries no run record. CI history is | ||
| 3045 | // instance-local; a clone does not carry it. | ||
| 3046 | func TestRunRecordLivesInTheDatabase(t *testing.T) { | ||
| 2995 | 3047 | t.Parallel() | |
| 2996 | 3048 | f := newFixtureWith(t, func(c *config.Config) { c.LeaseSeconds = 2 }) | |
| 2997 | 3049 | f.publish(f.repo) | |
| @@ -3002,117 +3054,49 @@ jobs: | |||
| 3002 | 3054 | image: alpine:3.22 | |
| 3003 | 3055 | steps: | |
| 3004 | 3056 | - name: say hello | |
| 3005 | run: echo "recorded, not just queued" | ||
| 3057 | run: echo "recorded in the database" | ||
| 3006 | 3058 | `)) | |
| 3007 | 3059 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { | |
| 3008 | 3060 | t.Fatalf("push: %s", errs) | |
| 3009 | 3061 | } | |
| 3010 | 3062 | f.waitForRun(t, 5*time.Second, func(r *runs.Run) bool { return r.Status == runs.StatusQueued }) | |
| 3011 | |||
| 3012 | cmd := exec.Command(binPath, "runner", "--remote", f.sshBase(), "--once", "--dir", t.TempDir()) | ||
| 3013 | cmd.Env = f.ciEnv() | ||
| 3014 | if out, err := cmd.CombinedOutput(); err != nil { | ||
| 3015 | t.Fatalf("runner: %v\n%s", err, out) | ||
| 3016 | } | ||
| 3017 | done := f.waitForRun(t, 10*time.Second, func(r *runs.Run) bool { | ||
| 3018 | return r.Status != runs.StatusQueued && r.Status != runs.StatusRunning | ||
| 3019 | }) | ||
| 3063 | f.runOnce(t) | ||
| 3064 | done := f.terminalRun(t) | ||
| 3020 | 3065 | if done.Status != runs.StatusSuccess { | |
| 3021 | 3066 | t.Fatalf("run status = %s", done.Status) | |
| 3022 | 3067 | } | |
| 3023 | 3068 | ||
| 3024 | m, err := f.srv.Repos.Open(f.repo) | ||
| 3025 | if err != nil { | ||
| 3026 | t.Fatal(err) | ||
| 3027 | } | ||
| 3028 | rec, err := runs.ReadRecord(m.Git(), metadata.Ref, done.ID) | ||
| 3069 | rec, err := f.srv.Runs.Attempt(done.ID, done.Attempts) | ||
| 3029 | 3070 | if err != nil { | |
| 3030 | t.Fatalf("reading the record: %v", err) | ||
| 3071 | t.Fatalf("reading the attempt: %v", err) | ||
| 3031 | 3072 | } | |
| 3032 | 3073 | if rec == nil { | |
| 3033 | t.Fatal("a finished run left no record on " + metadata.Ref) | ||
| 3074 | t.Fatal("a finished run left no attempt in runs.db") | ||
| 3034 | 3075 | } | |
| 3035 | att, err := f.srv.Runs.Attestation(done.ID) | ||
| 3036 | if err != nil || att == nil { | ||
| 3037 | t.Fatalf("no attestation in the queue: %v", err) | ||
| 3038 | } | ||
| 3039 | // The queue mirrors the record, and the record is the one on the branch: same | ||
| 3040 | // attempt, same reporter, same commit. | ||
| 3041 | if rec.Signer != att.Signer || rec.Attempt != done.Attempts { | ||
| 3042 | t.Fatalf("record = %+v, attestation = %+v", rec, att) | ||
| 3043 | } | ||
| 3044 | if rec.Commit != att.CommitID { | ||
| 3045 | t.Fatalf("the record is in commit %q but the queue says %q", rec.Commit, att.CommitID) | ||
| 3046 | } | ||
| 3047 | |||
| 3048 | // Portability, measured: a clone carries the record with it. | ||
| 3049 | clone := filepath.Join(t.TempDir(), "carried.git") | ||
| 3050 | if _, errs, err := f.git(t.TempDir(), "clone", "--bare", "-q", f.sshURL(f.repo), clone); err != nil { | ||
| 3051 | t.Fatalf("clone: %s", errs) | ||
| 3076 | if rec.Signer == "" || rec.SHA != done.SHA || rec.Attempt != done.Attempts { | ||
| 3077 | t.Fatalf("attempt = %+v", rec) | ||
| 3052 | 3078 | } | |
| 3053 | carried, err := runs.ReadRecord(git.Open(clone), metadata.Ref, done.ID) | ||
| 3054 | if err != nil { | ||
| 3055 | t.Fatalf("reading the record from a clone: %v", err) | ||
| 3056 | } | ||
| 3057 | if carried == nil { | ||
| 3058 | t.Fatal("a clone does not carry the run record") | ||
| 3059 | } | ||
| 3060 | if !carried.Same(*rec) { | ||
| 3061 | t.Fatalf("the clone's record differs: %+v\nwant\n%+v", carried, rec) | ||
| 3079 | if len(rec.Steps) != 1 || rec.Steps[0].Name != "say hello" { | ||
| 3080 | t.Fatalf("the attempt does not describe the step: %+v", rec.Steps) | ||
| 3062 | 3081 | } | |
| 3063 | 3082 | ||
| 3064 | // The finished half of the queue is derived, so deleting it loses nothing: the | ||
| 3065 | // rebuild reconstructs the row, the attestation the gate reads and the log the | ||
| 3066 | // page serves from the record. | ||
| 3067 | for _, p := range []string{f.cfg.RunsDB(), f.cfg.RunsDB() + "-wal", f.cfg.RunsDB() + "-shm"} { | ||
| 3068 | if err := os.Remove(p); err != nil && !os.IsNotExist(err) { | ||
| 3069 | t.Fatal(err) | ||
| 3070 | } | ||
| 3071 | } | ||
| 3072 | rebuild := exec.Command(binPath, "index", "rebuild", "--config", f.cfgFile(t)) | ||
| 3073 | if out, err := rebuild.CombinedOutput(); err != nil { | ||
| 3074 | t.Fatalf("rebuild: %v\n%s", err, out) | ||
| 3075 | } | ||
| 3076 | store, err := runs.Open(f.cfg.RunsDB()) | ||
| 3083 | // The branch is not a copy. A clone gets the code and the issue metadata, not | ||
| 3084 | // the CI history. | ||
| 3085 | m, err := f.srv.Repos.Open(f.repo) | ||
| 3077 | 3086 | if err != nil { | |
| 3078 | 3087 | t.Fatal(err) | |
| 3079 | 3088 | } | |
| 3080 | defer store.Close() | ||
| 3081 | restored, err := store.Get(done.ID) | ||
| 3082 | if err != nil || restored == nil { | ||
| 3083 | t.Fatalf("the run was not restored from the record: %v", err) | ||
| 3084 | } | ||
| 3085 | if restored.Status != runs.StatusSuccess || restored.SHA != done.SHA || restored.Attempts != done.Attempts { | ||
| 3086 | t.Fatalf("restored run = %+v", restored) | ||
| 3087 | } | ||
| 3088 | if restored.LogOID != done.LogOID || restored.LogOID == "" { | ||
| 3089 | t.Fatalf("restored log = %q, want %q", restored.LogOID, done.LogOID) | ||
| 3090 | } | ||
| 3091 | // The gate's question, asked of the rebuilt database. | ||
| 3092 | results, err := store.ForSHA(f.repo, done.SHA) | ||
| 3089 | onBranch, err := runs.ReadRecord(m.Git(), metadata.Ref, done.ID) | ||
| 3093 | 3090 | if err != nil { | |
| 3094 | t.Fatal(err) | ||
| 3095 | } | ||
| 3096 | if len(results) != 1 { | ||
| 3097 | t.Fatalf("%d run(s) for the commit, want 1", len(results)) | ||
| 3091 | t.Fatalf("reading the branch: %v", err) | ||
| 3098 | 3092 | } | |
| 3099 | restoredAtt, err := store.Attestation(done.ID) | ||
| 3100 | if err != nil || restoredAtt == nil { | ||
| 3101 | t.Fatalf("the attestation was not restored: %v", err) | ||
| 3102 | } | ||
| 3103 | if restoredAtt.Signer != att.Signer || restoredAtt.CommitID != att.CommitID { | ||
| 3104 | t.Fatalf("restored attestation = %+v", restoredAtt) | ||
| 3093 | if onBranch != nil { | ||
| 3094 | t.Fatalf("the run record is still on %s: %+v", metadata.Ref, onBranch) | ||
| 3105 | 3095 | } | |
| 3106 | 3096 | ||
| 3107 | // The machine the attempt ran on travelled with the record: it is on the row after | ||
| 3108 | // the rebuild, and the page says so, which is the reason to store it at all. | ||
| 3109 | // The runner reported its own build target, so the fixture knows what to expect. | ||
| 3110 | wantOS, wantArch := runtime.GOOS, runtime.GOARCH | ||
| 3111 | if restored.OS != wantOS || restored.Arch != wantArch { | ||
| 3112 | t.Fatalf("restored machine = %q/%q, want %q/%q", restored.OS, restored.Arch, wantOS, wantArch) | ||
| 3113 | } | ||
| 3114 | if _, page := f.get("/r/" + f.repo + "/runs/" + done.ID); !strings.Contains(page, "ran on "+wantOS+"/"+wantArch) { | ||
| 3115 | t.Fatalf("the run page does not say which machine ran it:\n%s", page) | ||
| 3097 | // The page reads the attempt, not a tree walk. | ||
| 3098 | if _, page := f.get("/r/" + f.repo + "/runs/" + done.ID); !strings.Contains(page, "say hello") { | ||
| 3099 | t.Fatalf("the run page does not show the stored step:\n%s", page) | ||
| 3116 | 3100 | } | |
| 3117 | 3101 | } | |
| 3118 | 3102 | ||