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/hook/records_test.go +8−148

@@ -21,7 +21,7 @@ import (
21 21 "github.com/rodent-software/vole/metadata"
22 22 )
23 23
24 // recordFixture is everything a record push needs: a repository, a roster, an ACL naming
24 // recordFixture is everything a legacy record push needs: a repository, a roster, an ACL naming
25 25 // one allowed runner, and a queue with a leased job to report on.
26 26 type recordFixture struct {
27 27 t *testing.T
@@ -178,156 +178,16 @@ func (f *recordFixture) preReceive(actor, old, new string) (int, string) {
178 178 return code, out.String()
179 179 }
180 180
181 func TestOnlyAnAllowedRunnerMayReportAResult(t *testing.T) {
182 t.Parallel()
181 func TestRunRecordPushIsRefused(t *testing.T) {
183 182 f := newRecordFixture(t)
183 rec := f.record(f.run.Attempts, f.runnerID)
184 f.push(rec)
184 185
185 // The allowed runner reports its attempt: accepted, because it names the account
186 // that pushed it and describes the job it was leased.
187 old := f.push(f.record(1, f.runnerID))
188 if code, out := f.preReceive(f.runnerID, old, f.current); code != 0 {
189 t.Fatalf("an allowed runner's record was refused:\n%s", out)
190 }
191
192 // A writer who is not on the runners list cannot report at all, even with a record
193 // of its own making: whose build the host believes is the allowlist's question.
194 other := f.record(2, f.otherID)
195 old = f.push(other)
196 code, out := f.preReceive(f.otherID, old, f.current)
197 if code == 0 {
198 t.Fatalf("a non-runner's record was accepted:\n%s", out)
199 }
200 if !strings.Contains(out, "not a runner") || !strings.Contains(out, "acl runners") {
201 t.Fatalf("the refusal should say who may report and how to allow them:\n%s", out)
202 }
203 }
204
205 // The record has to name the account that pushed it. That is the whole attribution rule
206 // now — the same one an issue's author and an approval's owner follow — so a record
207 // re-attributing itself to somebody else is refused at the boundary, and there is no
208 // second mechanism to disagree with it later.
209 func TestARecordCannotBeAttributedToAnotherAccount(t *testing.T) {
210 t.Parallel()
211 f := newRecordFixture(t)
212
213 rec := f.record(1, f.otherID)
214 old := f.push(rec)
215 code, out := f.preReceive(f.runnerID, old, f.current)
216 if code == 0 {
217 t.Fatalf("a record naming another account was accepted:\n%s", out)
218 }
219 if !strings.Contains(out, "pushed it") {
220 t.Fatalf("the refusal should say who pushed it:\n%s", out)
221 }
222 }
223
224 // An unsigned record is now simply a record: attribution is the boundary's, and there is
225 // no signature anywhere else in the system for it to be inconsistent with.
226 func TestARecordNeedsNoSignature(t *testing.T) {
227 t.Parallel()
228 f := newRecordFixture(t)
229 g := f.repo.Git()
230 files, err := f.record(1, f.runnerID).Files()
231 if err != nil {
232 t.Fatal(err)
233 }
234 tree, err := git.WriteFlatTree(g, files)
235 if err != nil {
236 t.Fatal(err)
237 }
238 commit, err := g.CommitTreeAs(tree, "", "runs: run "+f.run.ID+" attempt 1: success", "runner")
239 if err != nil {
240 t.Fatal(err)
241 }
242 if err := g.UpdateRef(metadata.Ref, commit); err != nil {
243 t.Fatal(err)
244 }
245 if code, out := f.preReceive(f.runnerID, "", commit); code != 0 {
246 t.Fatalf("an unsigned record was refused:\n%s", out)
247 }
248 }
249
250 func TestARecordIsAppendOnlyAtTheGate(t *testing.T) {
251 t.Parallel()
252 f := newRecordFixture(t)
253 g := f.repo.Git()
254 f.push(f.record(1, f.runnerID))
255 oldTip := f.current
256
257 // Editing the record of an attempt is a rewrite of evidence.
258 files, err := g.FlatTree(f.current)
259 if err != nil {
260 t.Fatal(err)
261 }
262 path := runs.RecordPath(f.run.ID, 1) + "/record.md"
263 // The edit has to change the blob: a record that says the same thing in the same
264 // bytes would be an empty commit, which git would refuse to make anyway.
265 files[path] = []byte(strings.Replace(string(files[path]), "exit: 0", "exit: 0\nnote: edited", 1))
266 tree, err := git.WriteFlatTree(g, files)
267 if err != nil {
268 t.Fatal(err)
269 }
270 edited, err := g.CommitTreeAs(tree, f.current, "runs: an edit", "runner")
271 if err != nil {
272 t.Fatal(err)
273 }
274 code, out := f.preReceive(f.runnerID, oldTip, edited)
275 if code == 0 {
276 t.Fatalf("an edited record was accepted:\n%s", out)
277 }
278 if !strings.Contains(out, "never rewritten or deleted") {
279 t.Fatalf("the refusal should say records are append-only:\n%s", out)
280 }
281 }
282
283 // The record has to describe the job the queue handed *that* account. Without this an
284 // allowed runner could invent attempt 99 for a run that already finished and have the
285 // next rebuild believe it.
286 func TestARecordMustDescribeTheLeasedJob(t *testing.T) {
287 t.Parallel()
288 f := newRecordFixture(t)
289 g := f.repo.Git()
290 rec := f.record(99, f.runnerID)
291 commit, err := runs.WriteRecord(g, metadata.Ref, rec, "runner", nil)
292 if err != nil {
293 t.Fatal(err)
294 }
295 if err := g.UpdateRef(metadata.Ref, commit); err != nil {
296 t.Fatal(err)
297 }
298 code, out := f.preReceive(f.runnerID, "", commit)
299 if code == 0 {
300 t.Fatalf("a record for another attempt was accepted:\n%s", out)
301 }
302 if !strings.Contains(out, "attempt") {
303 t.Fatalf("the refusal should name the attempt:\n%s", out)
304 }
305 }
306
307 // Retirement has to reach the runners list. The list is edited by hand and the
308 // name stays in the ACL row after the account is retired, so membership alone
309 // would let a revoked account keep reporting results — and keep leasing jobs,
310 // which means executing repository code.
311 func TestARetiredRunnerMayNotReportAResult(t *testing.T) {
312 t.Parallel()
313 f := newRecordFixture(t)
314
315 old := f.push(f.record(1, f.runnerID))
316 ident, err := identity.Open(f.usersDB)
317 if err != nil {
318 t.Fatal(err)
319 }
320 defer ident.Close()
321 ident.AuditPath = filepath.Join(f.dir, "audit.log")
322 if _, err := ident.Retire(f.runnerID, "test"); err != nil {
323 t.Fatalf("retire: %v", err)
324 }
325
326 code, out := f.preReceive(f.runnerID, old, f.current)
186 code, out := f.preReceive(f.runnerID, "", f.current)
327 187 if code == 0 {
328 t.Fatalf("a retired runner's record was accepted:\n%s", out)
188 t.Fatal("a run-record push was accepted; records belong in the database now")
329 189 }
330 if !strings.Contains(out, "not a runner") {
331 t.Fatalf("the refusal does not name the rule:\n%s", out)
190 if !strings.Contains(out, "stored in the instance database") {
191 t.Fatalf("the refusal does not name the new home:\n%s", out)
332 192 }
333 193 }