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