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
Conversation
Commits 1
Files changed 6
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.
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 }
Checks
success
ci/build success 2026-09-10 03:58 UTC · 23s
ci/test success 2026-09-10 04:07 UTC · 9m33s
Revisions
1. 7bf5fb5f85 2026-09-10 03:41 UTC
2. c72da83f1a 2026-09-10 03:57 UTC · compare to previous
Source and target
krz/gitbay:runners-forget into main
merged at c72da83f1a · base 84c34a5d09 · branch deleted