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