admin runners forget drops a key's heartbeat row !366

merged merged by cmc on 2026-09-10 04:18 UTC · krz/gitbay:runners-forget into main

6 files changed, +100 −2

Layout: unified · split

.gitbay/wiki/Admin.org +3 −1
@@ -432,7 +432,9 @@ podman. A runner without it builds trusted commits only.
432that has polled as a runner: the account, the key's fingerprint, when it 432that has polled as a runner: the account, the key's fingerprint, when it
433last polled, the repositories it may claim — its attachments for a runner 433last polled, the repositories it may claim — its attachments for a runner
434key, the =-repos= it asked for or =any= for an admin key — and the build 434key, 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
436pending now, and over the last day how many were claimed, how long they 438pending now, and over the last day how many were claimed, how long they
437waited to be claimed (average and worst), and 439waited to be claimed (average and worst), and
438how many the reaper ended instead of a runner reporting them. A build a runner claimed and never 440how 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 {
111 ), 111 ),
112 pass("invite", "issue a registration invite and mail its code: --email <address>", passOpts{server: []string{"admin", "invite"}}), 112 pass("invite", "issue a registration invite and mail its code: --email <address>", passOpts{server: []string{"admin", "invite"}}),
113 pass("stats", "instance statistics: counts and per-repository disk usage", passOpts{server: []string{"admin", "stats"}}), 113 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"}})),
115 group("repo", "any repository, for moderation (audited)", 116 group("repo", "any repository, for moderation (audited)",
116 pass("list", "every repository with size and last push: [--owner o] [--visibility v] [--limit n] [--cursor c]", passOpts{server: []string{"admin", "repo", "list"}}), 117 pass("list", "every repository with size and last push: [--owner o] [--visibility v] [--limit n] [--cursor c]", passOpts{server: []string{"admin", "repo", "list"}}),
117 pass("archive", "archive a repository: <owner/name>", passOpts{server: []string{"admin", "repo", "archive"}}), 118 pass("archive", "archive a repository: <owner/name>", passOpts{server: []string{"admin", "repo", "archive"}}),
@@ -279,6 +280,13 @@ func usesStdin(args []string) bool {
279 return false 280 return false
280} 281}
281 282
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
282func group(use, short string, subs ...*cobra.Command) *cobra.Command { 290func group(use, short string, subs ...*cobra.Command) *cobra.Command {
283 c := &cobra.Command{Use: use, Short: short} 291 c := &cobra.Command{Use: use, Short: short}
284 c.AddCommand(subs...) 292 c.AddCommand(subs...)
internal/control/admin.go +23
@@ -33,6 +33,10 @@ func init() {
33 Summary: "the build queue and runner accounts: last poll, scope, the build each holds (instance admins)", 33 Summary: "the build queue and runner accounts: last poll, scope, the build each holds (instance admins)",
34 Usage: "admin runners", 34 Usage: "admin runners",
35 ReadOnly: true, SSHOnly: true, Run: runAdminRunners}) 35 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})
36 register(Command{Path: []string{"admin", "repo", "list"}, 40 register(Command{Path: []string{"admin", "repo", "list"},
37 Summary: "list every repository with size and last push (instance admins)", 41 Summary: "list every repository with size and last push (instance admins)",
38 Usage: "admin repo list [--owner <name>] [--visibility public|private] [--limit <n>] [--cursor <c>]", 42 Usage: "admin repo list [--owner <name>] [--visibility public|private] [--limit <n>] [--cursor <c>]",
@@ -441,6 +445,25 @@ func runAdminRepoDelete(c *Ctx, args []string) int {
441 return protocol.ExitOK 445 return protocol.ExitOK
442} 446}
443 447
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
444func runAdminRunners(c *Ctx, args []string) int { 467func runAdminRunners(c *Ctx, args []string) int {
445 if code := requireInstanceAdmin(c); code >= 0 { 468 if code := requireInstanceAdmin(c); code >= 0 {
446 return code 469 return code
internal/control/runnerattach_test.go +28
@@ -223,3 +223,31 @@ func TestRunnerDoneRefusedForUnattachedBuild(t *testing.T) {
223 t.Fatalf("build was finished by a foreign key: %s", b.Status) 223 t.Fatalf("build was finished by a foreign key: %s", b.Status)
224 } 224 }
225} 225}
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
145 return err 145 return err
146} 146}
147 147
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
148// RunnerDone records that the key reported and holds nothing now. 161// RunnerDone records that the key reported and holds nothing now.
149func (s *Store) RunnerDone(keyID int64) error { 162func (s *Store) RunnerDone(keyID int64) error {
150 _, err := s.DB.Exec(`UPDATE runner_seen SET last_seen = strftime('%Y-%m-%dT%H:%M:%fZ','now'), 163 _, 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) {
146 t.Fatalf("RunnerRepoPaths: %v err=%v", paths, err) 146 t.Fatalf("RunnerRepoPaths: %v err=%v", paths, err)
147 } 147 }
148} 148}
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}