admin runners forget drops a key's heartbeat row !366
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 | that has polled as a runner: the account, the key's fingerprint, when it | 432 | that has polled as a runner: the account, the key's fingerprint, when it |
| 433 | last polled, the repositories it may claim — its attachments for a runner | 433 | last polled, the repositories it may claim — its attachments for a runner |
| 434 | key, the =-repos= it asked for or =any= for an admin key — and the build | 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 | pending now, and over the last day how many were claimed, how long they | 438 | pending now, and over the last day how many were claimed, how long they |
| 437 | waited to be claimed (average and worst), and | 439 | waited to be claimed (average and worst), and |
| 438 | how many the reaper ended instead of a runner reporting them. A build a runner claimed and never | 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 | 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. | ||
| 285 | func withSub(cmd *cobra.Command, subs ...*cobra.Command) *cobra.Command { | ||
| 286 | cmd.AddCommand(subs...) | ||
| 287 | return cmd | ||
| 288 | } | ||
| 289 | |||
| 282 | func group(use, short string, subs ...*cobra.Command) *cobra.Command { | 290 | func 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 | ||
| 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 | func runAdminRunners(c *Ctx, args []string) int { | 467 | func 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. | ||
| 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 | return err | 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 | // RunnerDone records that the key reported and holds nothing now. | 161 | // RunnerDone records that the key reported and holds nothing now. |
| 149 | func (s *Store) RunnerDone(keyID int64) error { | 162 | func (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. | ||
| 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 | } | ||