Commit c72da83f1a

c72da83f1a127ce5d5310bddea7240344b0dc11f

parent: 84c34a5d09

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

cmc <hello@cleberg.net> · 2026-09-10 03:41 UTC

control, cli, wiki: admin runners forget drops a key's heartbeat row

A key that polled once by mistake stayed listed in admin runners with no
way to remove it.

Ref #184
.gitbay/wiki/Admin.org +3 −1
@@ -432,7 +432,9 @@ podman. A runner without it builds trusted commits only.
432432that has polled as a runner: the account, the key's fingerprint, when it
433433last polled, the repositories it may claim — its attachments for a runner
434434key, the =-repos= it asked for or =any= for an admin key — and the build
435it holds. =admin runners= also heads the list with the queue: builds
435it holds; =admin runners forget <fingerprint>= drops the row for a key
436that polled by mistake, the key itself untouched. =admin runners= also
437heads the list with the queue: builds
436438pending now, and over the last day how many were claimed, how long they
437439waited to be claimed (average and worst), and
438440how many the reaper ended instead of a runner reporting them. A build a runner claimed and never
cmd/gitbay/main.go +9 −1
@@ -111,7 +111,8 @@ func newRoot() *cobra.Command {
111111 ),
112112 pass("invite", "issue a registration invite and mail its code: --email <address>", passOpts{server: []string{"admin", "invite"}}),
113113 pass("stats", "instance statistics: counts and per-repository disk usage", passOpts{server: []string{"admin", "stats"}}),
114 pass("runners", "the build queue and runner accounts: last poll, scope, the build each holds", passOpts{server: []string{"admin", "runners"}}),
114 withSub(pass("runners", "the build queue and runner keys: last poll, scope, the build each holds", passOpts{server: []string{"admin", "runners"}}),
115 pass("forget", "drop a key's heartbeat row: <fingerprint>", passOpts{server: []string{"admin", "runners", "forget"}})),
115116 group("repo", "any repository, for moderation (audited)",
116117 pass("list", "every repository with size and last push: [--owner o] [--visibility v] [--limit n] [--cursor c]", passOpts{server: []string{"admin", "repo", "list"}}),
117118 pass("archive", "archive a repository: <owner/name>", passOpts{server: []string{"admin", "repo", "archive"}}),
@@ -279,6 +280,13 @@ func usesStdin(args []string) bool {
279280 return false
280281}
281282
283// withSub hangs subcommands off a passthrough command, so `admin runners`
284// still runs while `admin runners forget` reaches its own command.
285func withSub(cmd *cobra.Command, subs ...*cobra.Command) *cobra.Command {
286 cmd.AddCommand(subs...)
287 return cmd
288}
289
282290func group(use, short string, subs ...*cobra.Command) *cobra.Command {
283291 c := &cobra.Command{Use: use, Short: short}
284292 c.AddCommand(subs...)
internal/control/admin.go +23
@@ -33,6 +33,10 @@ func init() {
3333 Summary: "the build queue and runner accounts: last poll, scope, the build each holds (instance admins)",
3434 Usage: "admin runners",
3535 ReadOnly: true, SSHOnly: true, Run: runAdminRunners})
36 register(Command{Path: []string{"admin", "runners", "forget"},
37 Summary: "drop a key's runner heartbeat row, e.g. one that polled once by mistake (instance admins)",
38 Usage: "admin runners forget <fingerprint>",
39 SSHOnly: true, Run: runAdminRunnersForget})
3640 register(Command{Path: []string{"admin", "repo", "list"},
3741 Summary: "list every repository with size and last push (instance admins)",
3842 Usage: "admin repo list [--owner <name>] [--visibility public|private] [--limit <n>] [--cursor <c>]",
@@ -441,6 +445,25 @@ func runAdminRepoDelete(c *Ctx, args []string) int {
441445 return protocol.ExitOK
442446}
443447
448func runAdminRunnersForget(c *Ctx, args []string) int {
449 if code := requireInstanceAdmin(c); code >= 0 {
450 return code
451 }
452 if len(args) != 1 {
453 return c.fail(protocol.ExitUsage, "usage: admin runners forget <fingerprint>")
454 }
455 if err := c.Store.ForgetRunner(args[0]); err != nil {
456 if errors.Is(err, store.ErrNotFound) {
457 return c.fail(protocol.ExitNotFound, "no runner has polled with %s", args[0])
458 }
459 return c.fail(protocol.ExitFailure, "%v", err)
460 }
461 c.Store.Audit(c.User.ID, "admin runners.forget", map[string]any{"fingerprint": args[0]})
462 return c.emit(map[string]string{"forgot": args[0]}, func(w io.Writer) {
463 fmt.Fprintf(w, "forgot runner %s\n", args[0])
464 })
465}
466
444467func runAdminRunners(c *Ctx, args []string) int {
445468 if code := requireInstanceAdmin(c); code >= 0 {
446469 return code
internal/control/runnerattach_test.go +28
@@ -223,3 +223,31 @@ func TestRunnerDoneRefusedForUnattachedBuild(t *testing.T) {
223223 t.Fatalf("build was finished by a foreign key: %s", b.Status)
224224 }
225225}
226
227// admin runners forget drops one heartbeat row by fingerprint.
228func TestAdminRunnersForget(t *testing.T) {
229 f := newAttachFixture(t)
230 c, _ := f.ctx(f.alice, f.aliceKey, false)
231 runRunnerNext(c, nil)
232 admin, out := f.ctx(f.alice, f.aliceKey, true)
233 admin.Scope = "full"
234 if code := runAdminRunnersForget(admin, []string{"SHA256:nobody"}); code != protocol.ExitNotFound {
235 t.Fatalf("unknown fingerprint: exit %d, want %d: %s", code, protocol.ExitNotFound, out.String())
236 }
237 admin, out = f.ctx(f.alice, f.aliceKey, true)
238 admin.Scope = "full"
239 if code := runAdminRunnersForget(admin, []string{f.aliceKey.Fingerprint}); code != protocol.ExitOK {
240 t.Fatalf("forget: exit %d: %s", code, out.String())
241 }
242 admin, out = f.ctx(f.alice, f.aliceKey, true)
243 admin.Scope = "full"
244 runAdminRunners(admin, nil)
245 if strings.Contains(out.String(), f.aliceKey.Fingerprint) {
246 t.Fatalf("row still listed after forget:\n%s", out.String())
247 }
248 user, out := f.ctx(f.alice, f.aliceKey, false)
249 user.Scope = "full"
250 if code := runAdminRunnersForget(user, []string{f.aliceKey.Fingerprint}); code != protocol.ExitDenied {
251 t.Fatalf("non-admin forgot a runner: exit %d: %s", code, out.String())
252 }
253}
internal/store/runners.go +13
@@ -145,6 +145,19 @@ func (s *Store) TouchRunner(keyID, userID int64, scope string, buildID int64) er
145145 return err
146146}
147147
148// ForgetRunner drops a key's heartbeat row. The key itself stays.
149func (s *Store) ForgetRunner(fingerprint string) error {
150 res, err := s.DB.Exec(`DELETE FROM runner_seen
151 WHERE key_id = (SELECT id FROM ssh_keys WHERE fingerprint = ?)`, fingerprint)
152 if err != nil {
153 return err
154 }
155 if n, _ := res.RowsAffected(); n == 0 {
156 return ErrNotFound
157 }
158 return nil
159}
160
148161// RunnerDone records that the key reported and holds nothing now.
149162func (s *Store) RunnerDone(keyID int64) error {
150163 _, err := s.DB.Exec(`UPDATE runner_seen SET last_seen = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
internal/store/runners_test.go +24
@@ -146,3 +146,27 @@ func TestRunnerSeenPerKeyAndRepoList(t *testing.T) {
146146 t.Fatalf("RunnerRepoPaths: %v err=%v", paths, err)
147147 }
148148}
149
150// A heartbeat row outlives its usefulness when a key polled once by
151// mistake; forgetting it by fingerprint removes the row and nothing else.
152func TestForgetRunner(t *testing.T) {
153 s, uid, keyID, _, _ := runnerFixture(t)
154 if err := s.TouchRunner(keyID, uid, "", 0); err != nil {
155 t.Fatal(err)
156 }
157 if err := s.ForgetRunner("SHA256:nobody"); !errors.Is(err, ErrNotFound) {
158 t.Fatalf("unknown fingerprint: %v, want ErrNotFound", err)
159 }
160 if err := s.ForgetRunner("SHA256:runnerkey"); err != nil {
161 t.Fatal(err)
162 }
163 if rows, _ := s.ListRunners(); len(rows) != 0 {
164 t.Fatalf("row survived forget: %+v", rows)
165 }
166 if _, err := s.SSHKeyByFingerprint("SHA256:runnerkey"); err != nil {
167 t.Fatalf("forget removed the key itself: %v", err)
168 }
169 if err := s.ForgetRunner("SHA256:runnerkey"); !errors.Is(err, ErrNotFound) {
170 t.Fatalf("second forget: %v, want ErrNotFound", err)
171 }
172}