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