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.
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/ssh/attest_test.go +60−48
| @@ -1,11 +1,10 @@ | |||
| 1 | 1 | package ssh | |
| 2 | 2 | ||
| 3 | // A finished run is recorded because the runner published a signed commit, and the | ||
| 4 | // server creates nothing. These tests speak the runner protocol over a real SSH | ||
| 5 | // channel and put a record on the branch the way a runner does, because the property | ||
| 6 | // is about the boundary between two parties: the account comes from the key that | ||
| 7 | // opened the connection, the record comes from the push, and the finish only closes | ||
| 8 | // the lease and mirrors what the branch already says. | ||
| 3 | // A finished run is stored by the server when the runner sends the record with its | ||
| 4 | // finish over the authenticated channel. These tests speak the runner protocol over a | ||
| 5 | // real SSH channel and check the boundary: the account comes from the key, the record | ||
| 6 | // must describe the job the queue handed out, and a retry of the same record is | ||
| 7 | // idempotent because the attempt is append-only. | ||
| 9 | 8 | ||
| 10 | 9 | import ( | |
| 11 | 10 | "encoding/json" | |
| @@ -15,7 +14,6 @@ import ( | |||
| 15 | 14 | ||
| 16 | 15 | "github.com/rodent-software/vole/ci/runs" | |
| 17 | 16 | "github.com/rodent-software/vole/core/id" | |
| 18 | "github.com/rodent-software/vole/metadata" | ||
| 19 | 17 | xssh "golang.org/x/crypto/ssh" | |
| 20 | 18 | ) | |
| 21 | 19 | ||
| @@ -97,31 +95,24 @@ func (h *harness) leaseRun(t *testing.T, c *runnerConn, jobName string) (*runs.R | |||
| 97 | 95 | return got, lease.Run | |
| 98 | 96 | } | |
| 99 | 97 | ||
| 100 | // publishRecord puts a record on the branch the way a runner does: one signed commit, | ||
| 101 | // written straight into the server's repository so the test does not have to drive a | ||
| 102 | // second push protocol. | ||
| 103 | func (h *harness) publishRecord(t *testing.T, job *runnerJob, attempt int, signer, keyPath string) string { | ||
| 104 | t.Helper() | ||
| 105 | m, err := h.repos.Open(h.repoName) | ||
| 106 | if err != nil { | ||
| 107 | t.Fatal(err) | ||
| 108 | } | ||
| 98 | // recordFor builds the record a runner would send for a leased job. | ||
| 99 | func recordFor(job *runnerJob, signer string) runs.Record { | ||
| 100 | now := time.Now().UTC() | ||
| 109 | 101 | rec := runs.Record{ | |
| 110 | RunID: job.ID, Attempt: attempt, Repo: h.repoName, Ref: job.Ref, | ||
| 102 | RunID: job.ID, Attempt: job.Attempt, Repo: job.Repo, Ref: job.Ref, | ||
| 111 | 103 | SHA: job.SHA, Workflow: job.Workflow, WorkflowSHA: job.WorkflowSHA, | |
| 112 | Job: job.Job, StepsSHA: job.StepsSHA, Exit: 0, Finished: time.Now().UTC(), | ||
| 104 | Job: job.Job, StepsSHA: job.StepsSHA, Exit: 0, Finished: now, | ||
| 113 | 105 | Signer: signer, | |
| 114 | 106 | } | |
| 115 | commit, err := runs.WriteRecord(m.Git(), metadata.Ref, rec, "writer", nil) | ||
| 116 | if err != nil { | ||
| 117 | t.Fatalf("publishing the record: %v", err) | ||
| 107 | if len(job.Steps) > 0 { | ||
| 108 | rec.Steps = []runs.StepResult{{ | ||
| 109 | Name: job.Steps[0].Name, Started: now.Add(-time.Second), Finished: now, Exit: 0, | ||
| 110 | }} | ||
| 118 | 111 | } | |
| 119 | return commit | ||
| 112 | return rec | ||
| 120 | 113 | } | |
| 121 | 114 | ||
| 122 | // A finish cannot invent a result: without a record on the branch it is refused, and | ||
| 123 | // with one it mirrors what the runner already published. | ||
| 124 | func TestAFinishMirrorsTheRecordAndCreatesNothing(t *testing.T) { | ||
| 115 | func TestAFinishStoresTheRecord(t *testing.T) { | ||
| 125 | 116 | t.Parallel() | |
| 126 | 117 | h := newHarness(t) | |
| 127 | 118 | c := h.runner(t, h.keyPath) | |
| @@ -141,9 +132,9 @@ func TestAFinishMirrorsTheRecordAndCreatesNothing(t *testing.T) { | |||
| 141 | 132 | t.Fatalf("lease = %+v, run = %+v", job, run) | |
| 142 | 133 | } | |
| 143 | 134 | ||
| 144 | // 1. Nothing on the branch: refused, and nothing recorded. | ||
| 135 | // 1. Nothing sent: refused, and nothing recorded. | ||
| 145 | 136 | rep := c.call(runnerRequest{Op: "finish", Run: job.ID}) | |
| 146 | if rep.Error == "" || !strings.Contains(rep.Error, "no record") { | ||
| 137 | if rep.Error == "" || !strings.Contains(rep.Error, "did not send a record") { | ||
| 147 | 138 | t.Fatalf("a finish with no record was accepted: %+v", rep) | |
| 148 | 139 | } | |
| 149 | 140 | if got, _ := h.runs.Get(run.ID); got.Status != runs.StatusRunning { | |
| @@ -152,47 +143,68 @@ func TestAFinishMirrorsTheRecordAndCreatesNothing(t *testing.T) { | |||
| 152 | 143 | ||
| 153 | 144 | // 2. A record that names another account is refused: the channel decides who is | |
| 154 | 145 | // finishing, and the record has to be that account's. | |
| 155 | _, job2 := h.leaseRun(t, c, "check") | ||
| 156 | h.publishRecord(t, job2, 1, "01M5READER0000000000000000Z", h.keyPath) | ||
| 157 | rep = c.call(runnerRequest{Op: "finish", Run: job2.ID}) | ||
| 158 | if rep.Error == "" || !strings.Contains(rep.Error, "another account") { | ||
| 146 | rec := recordFor(job, "01M5READER0000000000000000Z") | ||
| 147 | rep = c.call(runnerRequest{Op: "finish", Run: job.ID, Record: &rec}) | ||
| 148 | if rep.Error == "" || !strings.Contains(rep.Error, "reporter") { | ||
| 159 | 149 | t.Fatalf("a record naming another account was accepted: %+v", rep) | |
| 160 | 150 | } | |
| 161 | 151 | ||
| 162 | 152 | // 3. A record for an attempt the queue is not on is refused. Replay is how a | |
| 163 | 153 | // runner would try to rewrite the history of an attempt it never ran. | |
| 164 | _, job3 := h.leaseRun(t, c, "audit") | ||
| 165 | h.publishRecord(t, job3, 7, h.writerID, h.keyPath) | ||
| 166 | rep = c.call(runnerRequest{Op: "finish", Run: job3.ID}) | ||
| 167 | if rep.Error == "" || !strings.Contains(rep.Error, "attempt 7") { | ||
| 154 | rec = recordFor(job, h.writerID) | ||
| 155 | rec.Attempt = job.Attempt + 6 | ||
| 156 | rep = c.call(runnerRequest{Op: "finish", Run: job.ID, Record: &rec}) | ||
| 157 | if rep.Error == "" || !strings.Contains(rep.Error, "attempt") { | ||
| 168 | 158 | t.Fatalf("a record for another attempt was accepted: %+v", rep) | |
| 169 | 159 | } | |
| 170 | 160 | ||
| 171 | // 4. The real thing: the runner published attempt 1, signed, and the finish mirrors | ||
| 172 | // it into the queue. | ||
| 173 | run4, job4 := h.leaseRun(t, c, "release") | ||
| 174 | commit := h.publishRecord(t, job4, run4.Attempts, h.writerID, h.keyPath) | ||
| 175 | rep = c.call(runnerRequest{Op: "finish", Run: job4.ID}) | ||
| 161 | // 4. A record that describes another commit is refused. | ||
| 162 | rec = recordFor(job, h.writerID) | ||
| 163 | rec.SHA = strings.Repeat("ef", 20) | ||
| 164 | rep = c.call(runnerRequest{Op: "finish", Run: job.ID, Record: &rec}) | ||
| 165 | if rep.Error == "" || !strings.Contains(rep.Error, "commit") { | ||
| 166 | t.Fatalf("a record for another commit was accepted: %+v", rep) | ||
| 167 | } | ||
| 168 | |||
| 169 | // 5. The real thing: the server stores the attempt and the run's result. | ||
| 170 | rec = recordFor(job, h.writerID) | ||
| 171 | rep = c.call(runnerRequest{Op: "finish", Run: job.ID, Record: &rec}) | ||
| 176 | 172 | if rep.Error != "" || !rep.OK { | |
| 177 | t.Fatalf("a finish with a signed record was refused: %+v", rep) | ||
| 173 | t.Fatalf("a finish with a record was refused: %+v", rep) | ||
| 178 | 174 | } | |
| 179 | got, err := h.runs.Get(run4.ID) | ||
| 175 | got, err := h.runs.Get(run.ID) | ||
| 180 | 176 | if err != nil || got == nil { | |
| 181 | 177 | t.Fatal(err) | |
| 182 | 178 | } | |
| 183 | 179 | if got.Status != runs.StatusSuccess || got.ExitCode != 0 { | |
| 184 | 180 | t.Fatalf("run = %+v", got) | |
| 185 | 181 | } | |
| 186 | att, err := h.runs.Attestation(run4.ID) | ||
| 182 | att, err := h.runs.Attestation(run.ID) | ||
| 187 | 183 | if err != nil || att == nil { | |
| 188 | 184 | t.Fatalf("no attestation: %v", err) | |
| 189 | 185 | } | |
| 190 | if att.Signer != h.writerID { | ||
| 186 | if att.Signer != h.writerID || att.CommitID != "" { | ||
| 191 | 187 | t.Fatalf("attestation = %+v", att) | |
| 192 | 188 | } | |
| 193 | // The mirrored row points back at the commit, which is what a reader follows to the | ||
| 194 | // bytes. | ||
| 195 | if att.CommitID != commit { | ||
| 196 | t.Fatalf("attestation names commit %q, want %q", att.CommitID, commit) | ||
| 189 | stored, err := h.runs.Attempt(run.ID, job.Attempt) | ||
| 190 | if err != nil || stored == nil { | ||
| 191 | t.Fatalf("the attempt was not stored: %v", err) | ||
| 192 | } | ||
| 193 | if stored.Signer != h.writerID || stored.SHA != job.SHA || len(stored.Steps) != 1 { | ||
| 194 | t.Fatalf("stored attempt = %+v", stored) | ||
| 195 | } | ||
| 196 | |||
| 197 | // 6. A retry after a lost reply is the same attempt and is accepted. | ||
| 198 | rep = c.call(runnerRequest{Op: "finish", Run: job.ID, Record: &rec}) | ||
| 199 | if rep.Error != "" || !rep.OK { | ||
| 200 | t.Fatalf("a retry of the same record was refused: %+v", rep) | ||
| 201 | } | ||
| 202 | |||
| 203 | // 7. Different bytes for an attempt that already has a record are refused. | ||
| 204 | changed := rec | ||
| 205 | changed.Exit = 1 | ||
| 206 | rep = c.call(runnerRequest{Op: "finish", Run: job.ID, Record: &changed}) | ||
| 207 | if rep.OK { | ||
| 208 | t.Fatalf("a rewritten record was accepted: %+v", rep) | ||
| 197 | 209 | } | |
| 198 | 210 | } | |