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