Commit 9da9327c7a

9da9327c7a7b3decee4296dbd7b65e1822d9ac73

parent: a7aac9e9e6

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-09 02:32 UTC

store: ClaimBuild skips untrusted builds unless asked

Ref #184
internal/store/builds.go +12 −9
@@ -77,27 +77,30 @@ func scanBuild(row interface{ Scan(...any) error }) (Build, error) {
7777 return b, err
7878}
7979
80// ClaimBuild atomically hands the oldest pending build to a runner.
81// ClaimBuild takes the oldest pending build and marks it running. A
82// non-empty repoIDs restricts the claim to those repositories, which is how
83// a runner on a machine that should not execute every repository's steps
84// limits what it picks up.
85func (s *Store) ClaimBuild(repoIDs []int64) (Build, bool, error) {
80// ClaimBuild atomically hands the oldest pending build to a runner and
81// marks it running. A non-empty repoIDs restricts the claim to those
82// repositories. Untrusted builds — merge request heads from another
83// repository — are skipped unless untrusted is set: they run a stranger's
84// code, which only a runner that isolates should take.
85func (s *Store) ClaimBuild(repoIDs []int64, untrusted bool) (Build, bool, error) {
8686 tx, err := s.DB.Begin()
8787 if err != nil {
8888 return Build{}, false, err
8989 }
9090 defer tx.Rollback()
91 query := "SELECT id FROM builds WHERE status = 'pending' ORDER BY id LIMIT 1"
91 query := "SELECT id FROM builds WHERE status = 'pending'"
9292 args := []any{}
93 if !untrusted {
94 query += " AND trusted = 1"
95 }
9396 if len(repoIDs) > 0 {
9497 marks := strings.TrimSuffix(strings.Repeat("?,", len(repoIDs)), ",")
95 query = "SELECT id FROM builds WHERE status = 'pending' AND repo_id IN (" +
96 marks + ") ORDER BY id LIMIT 1"
98 query += " AND repo_id IN (" + marks + ")"
9799 for _, id := range repoIDs {
98100 args = append(args, id)
99101 }
100102 }
103 query += " ORDER BY id LIMIT 1"
101104 var id int64
102105 err = tx.QueryRow(query, args...).Scan(&id)
103106 if errors.Is(err, sql.ErrNoRows) {
internal/store/builds_test.go +45 −7
@@ -32,7 +32,7 @@ func TestReapStaleBuilds(t *testing.T) {
3232
3333 // Claim both, then age only the first past the deadline.
3434 for range 2 {
35 if _, ok, err := s.ClaimBuild(nil); err != nil || !ok {
35 if _, ok, err := s.ClaimBuild(nil, false); err != nil || !ok {
3636 t.Fatalf("claim: %v ok=%v", err, ok)
3737 }
3838 }
@@ -148,7 +148,7 @@ func TestClaimBuildScopedToRepos(t *testing.T) {
148148 t.Fatal(err)
149149 }
150150
151 b, ok, err := s.ClaimBuild([]int64{mine})
151 b, ok, err := s.ClaimBuild([]int64{mine}, false)
152152 if err != nil || !ok {
153153 t.Fatalf("claim: %v ok=%v", err, ok)
154154 }
@@ -158,11 +158,11 @@ func TestClaimBuildScopedToRepos(t *testing.T) {
158158 }
159159
160160 // Nothing left for that scope, even though another repo's build is pending.
161 if _, ok, err := s.ClaimBuild([]int64{mine}); err != nil || ok {
161 if _, ok, err := s.ClaimBuild([]int64{mine}, false); err != nil || ok {
162162 t.Fatalf("second scoped claim: err=%v ok=%v, want no build", err, ok)
163163 }
164164 // An unscoped runner still takes it.
165 if b, ok, err := s.ClaimBuild(nil); err != nil || !ok || b.RepoID != theirs {
165 if b, ok, err := s.ClaimBuild(nil, false); err != nil || !ok || b.RepoID != theirs {
166166 t.Fatalf("unscoped claim: err=%v ok=%v repo=%d", err, ok, b.RepoID)
167167 }
168168}
@@ -232,7 +232,7 @@ func TestSuccessBuildForTree(t *testing.T) {
232232 t.Fatal(err)
233233 }
234234 b, _ := s.BuildsForCommit(repoID, "aaa")
235 if _, ok, err := s.ClaimBuild([]int64{repoID}); err != nil || !ok {
235 if _, ok, err := s.ClaimBuild([]int64{repoID}, false); err != nil || !ok {
236236 t.Fatalf("claim: ok=%v err=%v", ok, err)
237237 }
238238 if err := s.FinishBuild(b["unit"].ID, "success"); err != nil {
@@ -262,7 +262,7 @@ func TestReapStaleBuildsAfterLogClosed(t *testing.T) {
262262 if _, err := s.CreateBuild(repoID, job, "abc", "main", `["true"]`, "", "", true); err != nil {
263263 t.Fatal(err)
264264 }
265 if _, ok, err := s.ClaimBuild([]int64{repoID}); err != nil || !ok {
265 if _, ok, err := s.ClaimBuild([]int64{repoID}, false); err != nil || !ok {
266266 t.Fatalf("claim %s: %v", job, err)
267267 }
268268 }
@@ -315,7 +315,7 @@ func TestQueueStatsFractionalAverage(t *testing.T) {
315315 if _, err := s.CreateBuild(1, "test", "abc123", "main", `["true"]`, "", "", true); err != nil {
316316 t.Fatal(err)
317317 }
318 if _, ok, err := s.ClaimBuild(nil); err != nil || !ok {
318 if _, ok, err := s.ClaimBuild(nil, false); err != nil || !ok {
319319 t.Fatalf("claim: %v ok=%v", err, ok)
320320 }
321321 }
@@ -331,3 +331,41 @@ func TestQueueStatsFractionalAverage(t *testing.T) {
331331 t.Fatalf("stats: %+v", q)
332332 }
333333}
334
335// A merge request head from a fork is untrusted. A claim skips it unless
336// the runner asked for untrusted builds, so a runner on someone's laptop
337// never executes a stranger's branch by default.
338func TestClaimBuildSkipsUntrustedUnlessAsked(t *testing.T) {
339 s := open(t)
340 if err := s.MigrateUp(); err != nil {
341 t.Fatal(err)
342 }
343 uid, err := s.CreateUser("cmc", true)
344 if err != nil {
345 t.Fatal(err)
346 }
347 repo, err := s.CreateRepo("user", uid, "app", "public")
348 if err != nil {
349 t.Fatal(err)
350 }
351 // Queued first, so an unfiltered claim would take it.
352 forkBuild, err := s.CreateBuild(repo, "unit", "abc123", "refs/merge-requests/1/head", `["true"]`, "", "", false)
353 if err != nil {
354 t.Fatal(err)
355 }
356 own, err := s.CreateBuild(repo, "unit", "def456", "main", `["true"]`, "", "", true)
357 if err != nil {
358 t.Fatal(err)
359 }
360 b, ok, err := s.ClaimBuild(nil, false)
361 if err != nil || !ok || b.Number != own {
362 t.Fatalf("trusted-only claim: err=%v ok=%v number=%d, want %d", err, ok, b.Number, own)
363 }
364 if _, ok, _ := s.ClaimBuild(nil, false); ok {
365 t.Fatal("trusted-only claim took the fork build")
366 }
367 b, ok, err = s.ClaimBuild(nil, true)
368 if err != nil || !ok || b.Number != forkBuild {
369 t.Fatalf("untrusted claim: err=%v ok=%v number=%d, want %d", err, ok, b.Number, forkBuild)
370 }
371}
internal/store/queues_test.go +1 −1
@@ -24,7 +24,7 @@ func TestQueuesListsPendingBuilds(t *testing.T) {
2424 if err != nil {
2525 t.Fatal(err)
2626 }
27 if _, ok, err := s.ClaimBuild(nil); err != nil || !ok {
27 if _, ok, err := s.ClaimBuild(nil, false); err != nil || !ok {
2828 t.Fatalf("claim: %v ok=%v", err, ok)
2929 }
3030