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.

Phase 4: the runner publishes its result as a signed commit

90fdaf397a1120677a1246201369e6dcc82b0aed · Keenan Nemetz · 17d ago · parent 96cbb6fa

Phase 4: the runner publishes its result as a signed commit

A finished run's result existed only in data/runs.db and the object store, so a
clone carried the commits and the workflow but not the fact that CI passed, who
said so, or the log it said it with. And the previous cut of this change had the
server commit the record — which is the box vouching for a party, the same
mistake as the instance signing key that was deleted.

The runner now publishes it. When an attempt ends the runner commits
runs/<run id>/<attempt>/ — record.md (repo, ref, sha, workflow blob, job, steps
digest, exit, finish time, signer, key) plus an LFS pointer for the log and one
per artifact — together with any release pointers the build published, and
pushes that once. One push per run: the result and the artifacts it produced are
one fact, so a release cannot carry bytes whose build has no record.

The commit is signed with the runner's SSH key, and that signature is the whole
attestation: it covers the tree, the parents and the message, so the separate
claim and signature file are gone, and there is no second statement to keep in
step with the files it describes. pre-receive checks it against an
allowed-signers file built from the pushing account's keys, so identity comes
from the key and not from a name typed into a commit.

Who may report is the repository's runner allowlist — the same list that decides
who may lease a job — and the record must describe the job the queue handed that
account: same run, same attempt, same commit, workflow, job and steps digest.
Without that an allowed runner could report a green result for a commit nobody
asked it to build, or invent attempt 99 and have the next rebuild believe it.
Records are append-only, with no admin override: an edit or a deletion is a
rewrite of evidence, and the alternative to refusing is running the job again.

runs.db keeps the queue (queued and leased jobs have no record because they have
not happened) and mirrors finished runs as derived rows, which vole index
rebuild reconstructs from the branch. The attestation row now names the commit
instead of storing a claim and a signature.

Measured: a real run finished, a bare clone carried runs/<id>/1/record.md, git
verify-commit accepted that commit under the roster and refused an unsigned one;
a writer who is not a runner was refused by pre-receive naming the allowlist;
one CI release commit carried both the record and releases/v1.0.0/tool-linux;
deleting runs.db and rebuilding restored the row, the attestation and the log.

internal/server/integration_test.go +167−7

@@ -28,8 +28,10 @@ import (
28 28 "github.com/rodent-software/vole/internal/acl"
29 29 "github.com/rodent-software/vole/internal/config"
30 30 "github.com/rodent-software/vole/internal/frontmatter"
31 "github.com/rodent-software/vole/internal/gitx"
31 32 "github.com/rodent-software/vole/internal/identity"
32 33 "github.com/rodent-software/vole/internal/index"
34 "github.com/rodent-software/vole/internal/issues"
33 35 "github.com/rodent-software/vole/internal/repo"
34 36 "github.com/rodent-software/vole/internal/runs"
35 37 "github.com/rodent-software/vole/internal/server"
@@ -2642,22 +2644,30 @@ jobs:
2642 2644 t.Fatalf("the served log hashes to %s, but the run names %s: the object is not the runner's bytes", sum, done.LogOID)
2643 2645 }
2644 2646
2645 // The result is signed by the runner, and the record is checkable afterwards: the
2646 // stored claim names this job, its log and its outcome, and the signature verifies
2647 // The result is a signed commit the runner pushed, and it is checkable afterwards:
2648 // the record names this job, its log and its outcome, and the commit verifies
2647 2649 // under the account that ran it. The page says who, and it is the runner rather
2648 2650 // than the instance.
2649 2651 att, err := f.srv.Runs.Attestation(done.ID)
2650 2652 if err != nil || att == nil {
2651 2653 t.Fatalf("no attestation on the run: %v", err)
2652 2654 }
2653 if att.Signer == "" || att.Fingerprint == "" {
2655 if att.Signer == "" || att.Fingerprint == "" || att.CommitID == "" {
2654 2656 t.Fatalf("attestation is not attributed: %+v", att)
2655 2657 }
2656 if !strings.Contains(string(att.Payload), "run "+done.ID) ||
2657 !strings.Contains(string(att.Payload), "log "+done.LogOID) ||
2658 !strings.Contains(string(att.Payload), "exit 0") {
2659 t.Fatalf("the claim does not describe the run:\n%s", att.Payload)
2658 m, err := f.srv.Repos.Open(f.repo)
2659 if err != nil {
2660 t.Fatal(err)
2661 }
2662 rec, err := runs.ReadRecord(m.Git(), issues.Ref, done.ID)
2663 if err != nil || rec == nil {
2664 t.Fatalf("no record on the branch: %v", err)
2665 }
2666 if rec.Log == nil || rec.Log.OID != done.LogOID || rec.Exit != 0 || rec.Job != done.Job {
2667 t.Fatalf("the record does not describe the run: %+v", rec)
2660 2668 }
2669 // The signature is the attestation: verify the commit the way a reader would.
2670 verifyCommit(t, f, m, att.CommitID, att.Signer)
2661 2671 if _, page := f.get("/r/" + f.repo + "/runs/" + done.ID); !strings.Contains(page, "attested by") {
2662 2672 t.Fatalf("the run page does not say who attested it:\n%s", page)
2663 2673 }
@@ -2666,6 +2676,123 @@ jobs:
2666 2676 }
2667 2677 }
2668 2678
2679 // TestRunRecordIsPortable is the promise the record makes: the result of a run is
2680 // on the metadata branch, so a plain clone carries it, and the queue's finished
2681 // half can be rebuilt from git after the database is deleted.
2682 func TestRunRecordIsPortable(t *testing.T) {
2683 f := newFixtureWith(t, func(c *config.Config) { c.LeaseSeconds = 2 })
2684 f.publish(f.repo)
2685
2686 work := workRepo(t, workflowFile(`version: 1
2687 jobs:
2688 test:
2689 steps:
2690 - name: say hello
2691 run: echo "recorded, not just queued"
2692 `))
2693 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
2694 t.Fatalf("push: %s", errs)
2695 }
2696 f.waitForRun(t, 5*time.Second, func(r *runs.Run) bool { return r.Status == runs.StatusQueued })
2697
2698 cmd := exec.Command(binPath, "runner", "--remote", f.sshBase(), "--once", "--dir", t.TempDir())
2699 cmd.Env = f.ciEnv()
2700 if out, err := cmd.CombinedOutput(); err != nil {
2701 t.Fatalf("runner: %v\n%s", err, out)
2702 }
2703 done := f.waitForRun(t, 10*time.Second, func(r *runs.Run) bool {
2704 return r.Status != runs.StatusQueued && r.Status != runs.StatusRunning
2705 })
2706 if done.Status != runs.StatusSuccess {
2707 t.Fatalf("run status = %s", done.Status)
2708 }
2709
2710 m, err := f.srv.Repos.Open(f.repo)
2711 if err != nil {
2712 t.Fatal(err)
2713 }
2714 rec, err := runs.ReadRecord(m.Git(), issues.Ref, done.ID)
2715 if err != nil {
2716 t.Fatalf("reading the record: %v", err)
2717 }
2718 if rec == nil {
2719 t.Fatal("a finished run left no record on " + issues.Ref)
2720 }
2721 att, err := f.srv.Runs.Attestation(done.ID)
2722 if err != nil || att == nil {
2723 t.Fatalf("no attestation in the queue: %v", err)
2724 }
2725 // The queue mirrors the record, and the record is the one on the branch: same
2726 // attempt, same signer, same key, same commit.
2727 if rec.Signer != att.Signer || rec.Key != att.Fingerprint || rec.Attempt != done.Attempts {
2728 t.Fatalf("record = %+v, attestation = %+v", rec, att)
2729 }
2730 if rec.Commit != att.CommitID {
2731 t.Fatalf("the record is in commit %q but the queue says %q", rec.Commit, att.CommitID)
2732 }
2733 verifyCommit(t, f, m, rec.Commit, rec.Signer)
2734
2735 // Portability, measured: a clone carries the record with it.
2736 clone := filepath.Join(t.TempDir(), "carried.git")
2737 if _, errs, err := f.git(t.TempDir(), "clone", "--bare", "-q", f.sshURL(f.repo), clone); err != nil {
2738 t.Fatalf("clone: %s", errs)
2739 }
2740 carried, err := runs.ReadRecord(gitx.Open(clone), issues.Ref, done.ID)
2741 if err != nil {
2742 t.Fatalf("reading the record from a clone: %v", err)
2743 }
2744 if carried == nil {
2745 t.Fatal("a clone does not carry the run record")
2746 }
2747 if !carried.Same(*rec) {
2748 t.Fatalf("the clone's record differs: %+v\nwant\n%+v", carried, rec)
2749 }
2750
2751 // The finished half of the queue is derived, so deleting it loses nothing: the
2752 // rebuild reconstructs the row, the attestation the gate reads and the log the
2753 // page serves from the record.
2754 for _, p := range []string{f.cfg.RunsDB(), f.cfg.RunsDB() + "-wal", f.cfg.RunsDB() + "-shm"} {
2755 if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
2756 t.Fatal(err)
2757 }
2758 }
2759 rebuild := exec.Command(binPath, "index", "rebuild", "--config", f.cfgFile(t))
2760 if out, err := rebuild.CombinedOutput(); err != nil {
2761 t.Fatalf("rebuild: %v\n%s", err, out)
2762 }
2763 store, err := runs.Open(f.cfg.RunsDB())
2764 if err != nil {
2765 t.Fatal(err)
2766 }
2767 defer store.Close()
2768 restored, err := store.Get(done.ID)
2769 if err != nil || restored == nil {
2770 t.Fatalf("the run was not restored from the record: %v", err)
2771 }
2772 if restored.Status != runs.StatusSuccess || restored.SHA != done.SHA || restored.Attempts != done.Attempts {
2773 t.Fatalf("restored run = %+v", restored)
2774 }
2775 if restored.LogOID != done.LogOID || restored.LogOID == "" {
2776 t.Fatalf("restored log = %q, want %q", restored.LogOID, done.LogOID)
2777 }
2778 // The gate's question, asked of the rebuilt database.
2779 results, err := store.ForSHA(f.repo, done.SHA)
2780 if err != nil {
2781 t.Fatal(err)
2782 }
2783 if len(results) != 1 {
2784 t.Fatalf("%d run(s) for the commit, want 1", len(results))
2785 }
2786 restoredAtt, err := store.Attestation(done.ID)
2787 if err != nil || restoredAtt == nil {
2788 t.Fatalf("the attestation was not restored: %v", err)
2789 }
2790 if restoredAtt.Signer != att.Signer || restoredAtt.CommitID != att.CommitID ||
2791 restoredAtt.Fingerprint != att.Fingerprint {
2792 t.Fatalf("restored attestation = %+v", restoredAtt)
2793 }
2794 }
2795
2669 2796 // TestKilledRunnerIsRequeued is the other half of the contract: killing a runner
2670 2797 // mid-job returns the job to the queue and another runner finishes it.
2671 2798 func TestKilledRunnerIsRequeued(t *testing.T) {
@@ -2810,6 +2937,39 @@ func (f *fixture) runOnceAs(t *testing.T, keyPath string) {
2810 2937 }
2811 2938 }
2812 2939
2940 // verifyCommit checks a record's commit the way a reader holding the roster would: the
2941 // signature has to verify under a key of the account that reported the result, and the
2942 // principal git names has to be that account. It is the same check `pre-receive` makes,
2943 // run from the reading side.
2944 func verifyCommit(t *testing.T, f *fixture, m *repo.Meta, commit, accountID string) {
2945 t.Helper()
2946 acct, err := f.srv.Identity.Find(accountID)
2947 if err != nil || acct == nil {
2948 t.Fatalf("no account %s: %v", accountID, err)
2949 }
2950 var b strings.Builder
2951 for _, k := range acct.Keys {
2952 fields := strings.Fields(k.Line)
2953 if len(fields) >= 2 {
2954 fmt.Fprintf(&b, "%s %s %s\n", accountID, fields[0], fields[1])
2955 }
2956 }
2957 path := filepath.Join(t.TempDir(), "allowed")
2958 if err := os.WriteFile(path, []byte(b.String()), 0o600); err != nil {
2959 t.Fatal(err)
2960 }
2961 principal, fingerprint, err := m.Git().VerifyCommitSignature(commit, path)
2962 if err != nil {
2963 t.Fatalf("the record's commit is not signed by %s: %v", accountID, err)
2964 }
2965 if principal != accountID {
2966 t.Fatalf("signed by principal %q, want %q", principal, accountID)
2967 }
2968 if !strings.HasPrefix(fingerprint, "SHA256:") {
2969 t.Fatalf("fingerprint = %q", fingerprint)
2970 }
2971 }
2972
2813 2973 // cfgFile writes a config that points at this fixture's data directory, so a test can
2814 2974 // run the CLI the way an operator would next to the running server.
2815 2975 func (f *fixture) cfgFile(t *testing.T) string {