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.

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/hook/records.go +201

@@ -0,0 +1,201 @@
1 package hook
2
3 // Who may report a run result.
4 //
5 // A finished attempt's record is written by the runner that ran it: the party that
6 // did the work states what came of it, signs the commit and pushes it with the
7 // artifacts the build published. The gate's question is not "is this record well
8 // formed" but "was this account allowed to be the one that reported this attempt",
9 // and the answer is the same allowlist that decides who may lease a job —
10 // `vole acl runners`. Admins are implicit there for the same reason as everywhere
11 // else: they can rewrite the list.
12 //
13 // Two further rules make the record describe something that actually happened. The
14 // commit that adds it must be signed by a key the pushing account holds — that
15 // signature is the whole attestation, so an unsigned record is a claim nobody can
16 // check — and the record must describe the job the queue handed to that account: the
17 // same run, the same attempt, the same commit, workflow and job. Without the second
18 // rule an allowed runner could report a green result for a commit nobody asked it to
19 // build, or invent attempt 99 for a finished run and have the next rebuild believe it.
20
21 import (
22 "fmt"
23 "os"
24 "strings"
25
26 "github.com/rodent-software/vole/internal/acl"
27 "github.com/rodent-software/vole/internal/gitx"
28 "github.com/rodent-software/vole/internal/identity"
29 "github.com/rodent-software/vole/internal/runs"
30 )
31
32 func recordFindings(g *gitx.Repo, c change, a *acl.ACL, actor, actorID, repoName, usersDB, runsDB string, who func(string) string) []string {
33 added, problems, err := runs.CheckPush(g, c.old, c.new)
34 if err != nil {
35 return []string{fmt.Sprintf("%s: cannot read the run records in this push: %v", c.ref, err)}
36 }
37 if len(added) == 0 {
38 return problems
39 }
40 if a == nil || !a.CanReport(actorID) {
41 people := "none named, so only admins"
42 if a != nil {
43 people = runnerSummary(a, who)
44 }
45 return append(problems, fmt.Sprintf(
46 "%s: %s reported a run result but is not a runner for %s (runners: %s). "+
47 "An admin can allow that account with `vole acl runners %s --add %s`",
48 c.ref, actor, repoName, people, repoName, actor))
49 }
50 keys, err := rosterKeys(usersDB, actorID)
51 if err != nil {
52 return append(problems, fmt.Sprintf("%s: cannot check who %s is, refusing the record: %v", c.ref, actor, err))
53 }
54 signers, err := allowedSigners(keys, actorID)
55 if err != nil {
56 return append(problems, fmt.Sprintf("%s: %v", c.ref, err))
57 }
58 defer os.Remove(signers)
59
60 if runsDB == "" {
61 return append(problems, fmt.Sprintf(
62 "%s: this instance has no run queue, so a run result has nothing to be a result of", c.ref))
63 }
64 store, err := runs.Open(runsDB)
65 if err != nil {
66 return append(problems, fmt.Sprintf("%s: cannot open the run queue: %v", c.ref, err))
67 }
68 defer store.Close()
69
70 for _, rec := range added {
71 problems = append(problems, recordProblems(g, store, signers, rec, actor, actorID, repoName)...)
72 }
73 return problems
74 }
75
76 // recordProblems checks one added record against the account that pushed it, the
77 // signature on the commit that carries it, and the job the queue handed out.
78 func recordProblems(g *gitx.Repo, store *runs.Store, signers string, rec runs.Record, actor, actorID, repoName string) []string {
79 where := runs.RecordPath(rec.RunID, rec.Attempt)
80 var out []string
81
82 // The signer is not the pusher's to choose: the file names an account, and the
83 // commit has to be signed by that account, or the name is decoration.
84 if rec.Signer != actorID {
85 out = append(out, fmt.Sprintf(
86 "%s: the record names %s as the account that reported it, but %s pushed it; "+
87 "a runner reports its own results", where, rec.Signer, actor))
88 }
89
90 // The signature is the attestation, so this is the check the record exists for.
91 // The allowed-signers file holds only this account's keys, so a good signature is
92 // one this account could have made.
93 if rec.Commit == "" {
94 out = append(out, fmt.Sprintf("%s: the commit that adds the record could not be found", where))
95 } else if principal, fp, err := g.VerifyCommitSignature(rec.Commit, signers); err != nil {
96 out = append(out, fmt.Sprintf(
97 "%s: the commit %s that carries this record is not signed by a key of %s (%s)",
98 where, short(rec.Commit), actor, err))
99 } else {
100 if principal != actorID {
101 out = append(out, fmt.Sprintf(
102 "%s: the signature names %s, not %s", where, principal, actor))
103 }
104 if rec.Key != fp {
105 out = append(out, fmt.Sprintf(
106 "%s: the record names key %s but the commit was signed with %s", where, rec.Key, fp))
107 }
108 }
109
110 if rec.Repo != repoName {
111 out = append(out, fmt.Sprintf("%s: the record says it ran in %q, not %s", where, rec.Repo, repoName))
112 }
113 run, err := store.Get(rec.RunID)
114 if err != nil {
115 return append(out, fmt.Sprintf("%s: cannot read the run: %v", where, err))
116 }
117 if run == nil {
118 return append(out, fmt.Sprintf("%s: %s is not a run this instance queued", where, rec.RunID))
119 }
120 switch {
121 case run.Repo != repoName:
122 out = append(out, fmt.Sprintf("%s: run %s belongs to %s", where, rec.RunID, run.Repo))
123 case run.Status != runs.StatusRunning || run.LeaseOwner != actorID:
124 out = append(out, fmt.Sprintf(
125 "%s: %s is not running under %s's lease right now (%s), so only the runner holding it can report it",
126 where, rec.RunID, actor, describe(run)))
127 case run.Attempts != rec.Attempt:
128 out = append(out, fmt.Sprintf("%s: the queue is on attempt %d for %s, not %d",
129 where, run.Attempts, rec.RunID, rec.Attempt))
130 case rec.SHA != run.SHA || rec.Ref != run.Ref || rec.Workflow != run.Workflow ||
131 rec.WorkflowSHA != run.WorkflowSHA || rec.Job != run.Job || rec.StepsSHA != run.StepsSHA:
132 out = append(out, fmt.Sprintf(
133 "%s: the record describes a different job than the one this instance queued for %s "+
134 "(it must name the commit, workflow and job it was handed)", where, rec.RunID))
135 }
136 return out
137 }
138
139 // allowedSigners writes an OpenSSH allowed-signers file holding one account's keys,
140 // with the account id as the principal. It is the whole identity rule for a signed
141 // commit: a signature verifies only if one of these keys made it, and the principal
142 // git reports is the account, not whatever name the commit claims.
143 //
144 // The file is temporary because it is per-push state, not configuration: the roster is
145 // the configuration.
146 func allowedSigners(keys []identity.Key, principal string) (string, error) {
147 var b strings.Builder
148 for _, k := range keys {
149 f := strings.Fields(k.Line)
150 if len(f) < 2 {
151 continue
152 }
153 fmt.Fprintf(&b, "%s %s %s\n", principal, f[0], f[1])
154 }
155 if b.Len() == 0 {
156 return "", fmt.Errorf("%s has no keys a commit could be signed with", principal)
157 }
158 f, err := os.CreateTemp("", "vole-allowed-signers-*")
159 if err != nil {
160 return "", err
161 }
162 if _, err := f.WriteString(b.String()); err != nil {
163 f.Close()
164 os.Remove(f.Name())
165 return "", err
166 }
167 if err := f.Close(); err != nil {
168 os.Remove(f.Name())
169 return "", err
170 }
171 return f.Name(), nil
172 }
173
174 // rosterKeys loads the keys the account holds now. A retired key cannot report, which
175 // is the point of retiring it.
176 func rosterKeys(usersDB, accountID string) ([]identity.Key, error) {
177 if usersDB == "" {
178 return nil, fmt.Errorf("this instance has no roster, so a record cannot be attributed to anybody")
179 }
180 store, err := identity.Open(usersDB)
181 if err != nil {
182 return nil, err
183 }
184 defer store.Close()
185 acct, err := store.Find(accountID)
186 if err != nil {
187 return nil, err
188 }
189 if acct == nil {
190 return nil, fmt.Errorf("no account %s", accountID)
191 }
192 return acct.Keys, nil
193 }
194
195 // describe renders a run's queue state for a refusal.
196 func describe(run *runs.Run) string {
197 if run.LeaseOwner == "" {
198 return run.Status
199 }
200 return run.Status + " on " + run.LeaseOwner
201 }