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. |
| 432 | 432 | that has polled as a runner: the account, the key's fingerprint, when it |
| 433 | 433 | last polled, the repositories it may claim — its attachments for a runner |
| 434 | 434 | key, the =-repos= it asked for or =any= for an admin key — and the build |
| 435 | | it holds. =admin runners= also heads the list with the queue: builds |
| 435 | it holds; =admin runners forget <fingerprint>= drops the row for a key |
| 436 | that polled by mistake, the key itself untouched. =admin runners= also |
| 437 | heads the list with the queue: builds |
| 436 | 438 | pending now, and over the last day how many were claimed, how long they |
| 437 | 439 | waited to be claimed (average and worst), and |
| 438 | 440 | how 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 | 112 | pass("invite", "issue a registration invite and mail its code: --email <address>", passOpts{server: []string{"admin", "invite"}}), |
| 113 | 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 | 116 | group("repo", "any repository, for moderation (audited)", |
| 116 | 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 | 118 | pass("archive", "archive a repository: <owner/name>", passOpts{server: []string{"admin", "repo", "archive"}}), |
| @@ -279,6 +280,13 @@ func usesStdin(args []string) bool { |
| 279 | 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. |
| 285 | func withSub(cmd *cobra.Command, subs ...*cobra.Command) *cobra.Command { |
| 286 | cmd.AddCommand(subs...) |
| 287 | return cmd |
| 288 | } |
| 289 | |
| 282 | 290 | func group(use, short string, subs ...*cobra.Command) *cobra.Command { |
| 283 | 291 | c := &cobra.Command{Use: use, Short: short} |
| 284 | 292 | c.AddCommand(subs...) |
internal/control/admin.go
+23
| @@ -33,6 +33,10 @@ func init() { |
| 33 | 33 | Summary: "the build queue and runner accounts: last poll, scope, the build each holds (instance admins)", |
| 34 | 34 | Usage: "admin runners", |
| 35 | 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 | 40 | register(Command{Path: []string{"admin", "repo", "list"}, |
| 37 | 41 | Summary: "list every repository with size and last push (instance admins)", |
| 38 | 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 | 445 | return protocol.ExitOK |
| 442 | 446 | } |
| 443 | 447 | |
| 448 | func 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 | |
| 444 | 467 | func runAdminRunners(c *Ctx, args []string) int { |
| 445 | 468 | if code := requireInstanceAdmin(c); code >= 0 { |
| 446 | 469 | return code |
internal/control/runnerattach_test.go
+28
| @@ -223,3 +223,31 @@ func TestRunnerDoneRefusedForUnattachedBuild(t *testing.T) { |
| 223 | 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. |
| 228 | func 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 | 145 | return err |
| 146 | 146 | } |
| 147 | 147 | |
| 148 | // ForgetRunner drops a key's heartbeat row. The key itself stays. |
| 149 | func (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 | 161 | // RunnerDone records that the key reported and holds nothing now. |
| 149 | 162 | func (s *Store) RunnerDone(keyID int64) error { |
| 150 | 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 | 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. |
| 152 | func 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 | } |