Commit 7b6823e3e9

7b6823e3e91a3969bf166e73dd598c294c8e2d20

parent: 81bb0d1408

Verified · cmc ci/build: success ci/test: success ci/vuln: success

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

ci: record runner polls, and admin runners to read them

A runner was an admin account with no record of when it last polled or
which repositories it had scoped itself to; the only sign of one was a
claim.

Migration 0030 adds runner_seen: one row per runner account with the
time of its last poll, the scope it asked for, and the build it holds.
runner next writes it on every poll, claim or not; runner done clears
the build. admin runners lists the rows, most recent first, over SSH
and on the host.

Closes #76

Layout: unified · split

cmd/gitbay/main.go +1
@@ -91,6 +91,7 @@ func newRoot() *cobra.Command {
9191 ),
9292 pass("invite", "issue a registration invite and mail its code: --email <address>", passOpts{server: []string{"admin", "invite"}}),
9393 pass("stats", "instance statistics: counts and per-repository disk usage", passOpts{server: []string{"admin", "stats"}}),
94 pass("runners", "runner accounts: last poll, scope, the build each holds", passOpts{server: []string{"admin", "runners"}}),
9495 group("repo", "any repository, for moderation (audited)",
9596 pass("list", "every repository with size and last push: [--owner o] [--visibility v] [--limit n] [--cursor c]", passOpts{server: []string{"admin", "repo", "list"}}),
9697 pass("archive", "archive a repository: <owner/name>", passOpts{server: []string{"admin", "repo", "archive"}}),
cmd/gitbayd/main.go +1
@@ -336,6 +336,7 @@ func adminCmd() *cobra.Command {
336336 configCmd,
337337 hostCmd("invite --email <address>", "issue a registration invite and email its code", "admin", "invite"),
338338 hostCmd("stats [--json]", "instance statistics: counts and per-repository disk usage", "admin", "stats"),
339 hostCmd("runners [--json]", "runner accounts: last poll, scope, the build each holds", "admin", "runners"),
339340 hostCmd("audit [--limit n] [--json]", "print the security audit log, newest first", "audit"),
340341 backupCmd(),
341342 gcCmd(),
e2e/reap_test.go +61
@@ -2,6 +2,7 @@ package e2e
22
33import (
44 "encoding/json"
5 "fmt"
56 "os"
67 "path/filepath"
78 "strings"
@@ -78,3 +79,63 @@ func TestStaleBuildReapedWithoutRunner(t *testing.T) {
7879 t.Fatalf("log lacks the abandonment note:\n%s", out)
7980 }
8081}
82
83func TestAdminRunners(t *testing.T) {
84 inst := startInstance(t)
85 rootKey := inst.newKey(t, "root")
86 aliceKey := inst.newKey(t, "alice")
87 runnerKey := inst.newKey(t, "ci")
88 inst.admin(t, "admin", "user", "create", "root", "--key", rootKey+".pub", "--admin")
89 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
90 inst.admin(t, "admin", "user", "create", "ci", "--key", runnerKey+".pub", "--admin")
91 if _, _, code := inst.ssh(t, aliceKey, "", "admin", "runners"); code != 4 {
92 t.Fatal("non-admin listed runners")
93 }
94 if out, _, code := inst.ssh(t, rootKey, "", "admin", "runners", "--json"); code != 0 || strings.TrimSpace(out) != `{"protocol_version":1,"data":[]}` {
95 t.Fatalf("no runners yet: exit %d %s", code, out)
96 }
97 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
98 t.Fatal("repo create failed")
99 }
100 // An idle poll registers the runner with its scope.
101 if _, _, code := inst.ssh(t, runnerKey, "", "runner", "next", "alice/app"); code != 0 {
102 t.Fatal("runner next failed")
103 }
104 out, _, _ := inst.ssh(t, rootKey, "", "admin", "runners")
105 if !strings.HasPrefix(out, "ci\t") || !strings.Contains(out, "\talice/app\tidle") {
106 t.Fatalf("idle runner row:\n%s", out)
107 }
108 work := t.TempDir()
109 env := inst.gitEnv(aliceKey)
110 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
111 dir := filepath.Join(work, "w")
112 os.MkdirAll(filepath.Join(dir, ".gitbay"), 0o755)
113 os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte("jobs:\n ok:\n steps:\n - echo fine\n"), 0o644)
114 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
115 mustGit(t, dir, env, "add", ".")
116 mustGit(t, dir, env, "commit", "-q", "-m", "ci")
117 mustGit(t, dir, env, "push", "-q", "origin", "main")
118 out, _, code := inst.ssh(t, runnerKey, "", "runner", "next", "--json")
119 if code != 0 || !strings.Contains(out, `"job":"ok"`) {
120 t.Fatalf("claim: %s", out)
121 }
122 var claim struct {
123 Data struct {
124 ID int64 `json:"id"`
125 } `json:"data"`
126 }
127 json.Unmarshal([]byte(out), &claim)
128 if out, _, _ := inst.ssh(t, rootKey, "", "admin", "runners"); !strings.Contains(out, "\tany\talice/app #1 ok since ") {
129 t.Fatalf("holding runner row:\n%s", out)
130 }
131 if _, _, code := inst.ssh(t, runnerKey, "", "runner", "done", fmt.Sprint(claim.Data.ID), "success"); code != 0 {
132 t.Fatal("runner done failed")
133 }
134 if out, _, _ := inst.ssh(t, rootKey, "", "admin", "runners"); !strings.Contains(out, "\tany\tidle") {
135 t.Fatalf("runner still holds a build after done:\n%s", out)
136 }
137 // Host-local, the same read.
138 if out := inst.admin(t, "admin", "runners", "--json"); !strings.Contains(out, `"username":"ci"`) {
139 t.Fatalf("host runners:\n%s", out)
140 }
141}
internal/control/admin.go +30
@@ -29,6 +29,10 @@ func init() {
2929 Summary: "remove instance admin from an account (never the last one)",
3030 Usage: "admin user demote <username>",
3131 SSHOnly: true, Run: runAdminUserDemote})
32 register(Command{Path: []string{"admin", "runners"},
33 Summary: "runner accounts: last poll, scope, the build each holds (instance admins)",
34 Usage: "admin runners",
35 ReadOnly: true, SSHOnly: true, Run: runAdminRunners})
3236 register(Command{Path: []string{"admin", "repo", "list"},
3337 Summary: "list every repository with size and last push (instance admins)",
3438 Usage: "admin repo list [--owner <name>] [--visibility public|private] [--limit <n>] [--cursor <c>]",
@@ -449,3 +453,29 @@ func runAdminRepoDelete(c *Ctx, args []string) int {
449453 c.Store.Audit(c.User.ID, "admin repo.delete", map[string]any{"repo": repo.Path()})
450454 return protocol.ExitOK
451455}
456
457func runAdminRunners(c *Ctx, args []string) int {
458 if code := requireInstanceAdmin(c); code >= 0 {
459 return code
460 }
461 if len(args) != 0 {
462 return c.fail(protocol.ExitUsage, "usage: admin runners")
463 }
464 runners, err := c.Store.ListRunners()
465 if err != nil {
466 return c.fail(protocol.ExitFailure, "%v", err)
467 }
468 return c.emit(runners, func(w io.Writer) {
469 for _, r := range runners {
470 scope := r.Scope
471 if scope == "" {
472 scope = "any"
473 }
474 held := "idle"
475 if r.BuildNumber != 0 {
476 held = fmt.Sprintf("%s #%d %s since %s", r.BuildRepo, r.BuildNumber, r.BuildJob, r.StartedAt)
477 }
478 fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", r.Username, r.LastSeen, scope, held)
479 }
480 })
481}
internal/control/build.go +3
@@ -329,6 +329,8 @@ func runRunnerNext(c *Ctx, args []string) int {
329329 if err != nil {
330330 return c.fail(protocol.ExitFailure, "%v", err)
331331 }
332 // The poll itself is the runner's heartbeat: admin runners reads it.
333 c.Store.TouchRunner(c.User.ID, strings.Join(args, ","), b.ID)
332334 if !ok {
333335 return c.emit(map[string]any{}, func(w io.Writer) { fmt.Fprintln(w, "no pending builds") })
334336 }
@@ -412,6 +414,7 @@ func runRunnerDone(c *Ctx, args []string) int {
412414 if err := c.Store.FinishBuild(id, args[1]); err != nil {
413415 return c.fail(protocol.ExitFailure, "finishing build %d: %v", id, err)
414416 }
417 c.Store.RunnerDone(c.User.ID)
415418 repo, err := c.Store.RepoByID(b.RepoID)
416419 if err != nil {
417420 return c.fail(protocol.ExitFailure, "%v", err)
internal/store/migrations/0030_runner_seen.down.sql added +1
@@ -0,0 +1 @@
1DROP TABLE runner_seen;
internal/store/migrations/0030_runner_seen.up.sql added +8
@@ -0,0 +1,8 @@
1-- One row per runner account: when it last polled, the repositories it
2-- asked to be limited to, and the build it currently holds.
3CREATE TABLE runner_seen (
4 user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
5 last_seen TEXT NOT NULL,
6 scope TEXT NOT NULL DEFAULT '',
7 build_id INTEGER REFERENCES builds(id) ON DELETE SET NULL
8);
internal/store/runners.go added +59
@@ -0,0 +1,59 @@
1package store
2
3// Runner is one runner account as the instance admin sees it.
4type Runner struct {
5 Username string `json:"username"`
6 LastSeen string `json:"last_seen"`
7 Scope string `json:"scope,omitempty"` // comma-joined owner/name, "" for any
8 // The build it holds, if any.
9 BuildRepo string `json:"build_repo,omitempty"`
10 BuildNumber int64 `json:"build_number,omitempty"`
11 BuildJob string `json:"build_job,omitempty"`
12 StartedAt string `json:"started_at,omitempty"`
13}
14
15// TouchRunner records a poll: the time, the scope the runner asked for,
16// and the build it just claimed (0 for none).
17func (s *Store) TouchRunner(userID int64, scope string, buildID int64) error {
18 _, err := s.DB.Exec(`INSERT INTO runner_seen (user_id, last_seen, scope, build_id)
19 VALUES (?1, strftime('%Y-%m-%dT%H:%M:%fZ','now'), ?2, NULLIF(?3, 0))
20 ON CONFLICT (user_id) DO UPDATE SET
21 last_seen = excluded.last_seen, scope = excluded.scope,
22 build_id = COALESCE(excluded.build_id, runner_seen.build_id)`,
23 userID, scope, buildID)
24 return err
25}
26
27// RunnerDone records that the runner reported and holds nothing now.
28func (s *Store) RunnerDone(userID int64) error {
29 _, err := s.DB.Exec(`UPDATE runner_seen SET last_seen = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
30 build_id = NULL WHERE user_id = ?`, userID)
31 return err
32}
33
34// ListRunners lists every account that has ever polled as a runner,
35// most recently seen first.
36func (s *Store) ListRunners() ([]Runner, error) {
37 rows, err := s.DB.Query(`SELECT u.username, r.last_seen, r.scope,
38 COALESCE(COALESCE(bu.username, bo.name) || '/' || br.name, ''),
39 COALESCE(b.number, 0), COALESCE(b.job, ''), COALESCE(b.started_at, '')
40 FROM runner_seen r JOIN users u ON u.id = r.user_id
41 LEFT JOIN builds b ON b.id = r.build_id AND b.status = 'running'
42 LEFT JOIN repos br ON br.id = b.repo_id
43 LEFT JOIN users bu ON br.owner_kind = 'user' AND bu.id = br.owner_id
44 LEFT JOIN orgs bo ON br.owner_kind = 'org' AND bo.id = br.owner_id
45 ORDER BY r.last_seen DESC`)
46 if err != nil {
47 return nil, err
48 }
49 defer rows.Close()
50 var out []Runner
51 for rows.Next() {
52 var r Runner
53 if err := rows.Scan(&r.Username, &r.LastSeen, &r.Scope, &r.BuildRepo, &r.BuildNumber, &r.BuildJob, &r.StartedAt); err != nil {
54 return nil, err
55 }
56 out = append(out, r)
57 }
58 return out, rows.Err()
59}