Skip to content

Commit 71f92b0

Browse files
sarg3ntclaude
andcommitted
feat(rotation): Phase 4 retired-key cleaner (#72)
Adds a background sweeper that walks every enabled box on a tick and removes any keys whose retired_at + overlap window has passed — completing the install→use→remove three-phase rotation cycle without operator intervention. Originally Phase 4 in the issue plan also covered auto-rotation on a schedule (off by default, configurable cadence). That needs a new global-settings surface in the dashboard (no app-level config table exists today; only user_preferences), which is its own design pass. Deferring the auto-rotate scheduler to a follow-up so this PR stays focused on the piece that's actually needed regardless of whether auto-rotation is enabled. What's here ----------- `services/agent_keyring/cleaner.go` - `RetiredKeyCleaner` runs as a goroutine off the dashboard's process-lifetime context. - Hourly tick (`CleanerInterval`) — short enough that a 24h rotation cleans up within a few hours of its target, long enough that the sweep is cheap. - Immediate sweep on start so a freshly-deployed dashboard catches up on any retired keys left from manual rotations done while the prior instance was down. - Per-box failures are logged but don't halt the sweep; one unreachable agent shouldn't block cleanup on the others. `cmd/server/main.go` - Wires the cleaner into startup alongside the existing alert evaluator. Cancelled when main returns. Tests ----- - `TestCleaner_RemovesRetiredKeyOnTick` — rotates a box, then runs the cleaner with a 1ms overlap and 20ms interval; verifies the retired key is removed from both the mock agent and the DB. - `TestCleaner_NoopWhenNothingRetired` — no rotation happens, so the cleaner finds nothing to do; verifies the seeded entry survives a sweep. Refs: Phase 4 (lite) of the implementation plan posted to #72. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c66f37a commit 71f92b0

3 files changed

Lines changed: 197 additions & 0 deletions

File tree

gearbox/cmd/server/main.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
gbmiddleware "github.com/sarg3nt/gearbox/internal/framework/middleware"
2626
"github.com/sarg3nt/gearbox/internal/framework/models"
2727
"github.com/sarg3nt/gearbox/internal/framework/services"
28+
"github.com/sarg3nt/gearbox/internal/framework/services/agent_keyring"
2829
"github.com/sarg3nt/gearbox/internal/framework/services/alerts"
2930
"github.com/sarg3nt/gearbox/internal/framework/services/crypto"
3031
"github.com/sarg3nt/gearbox/internal/framework/services/email"
@@ -344,6 +345,24 @@ func main() {
344345
alertEvaluator.Start(30 * time.Second) // Evaluate alerts every 30 seconds
345346
logger.Info("alert evaluator initialized")
346347

348+
// Initialize retired-key cleaner. Each manual or scheduled rotation
349+
// leaves the demoted key in box_agent_keys with retired_at stamped
350+
// but role=secondary so the overlap window can preserve recovery.
351+
// The cleaner walks every box every CleanerInterval and removes
352+
// keys whose retired_at + overlap window has passed — both on the
353+
// agent and in the DB. See issue #72 Phase 4.
354+
keyringCleaner := agent_keyring.NewCleaner(
355+
agent_keyring.New(db, encryptor, logger),
356+
db,
357+
agent_keyring.DefaultOverlapWindow,
358+
agent_keyring.CleanerInterval,
359+
logger,
360+
)
361+
keyringCleanerCtx, cancelKeyringCleaner := context.WithCancel(context.Background())
362+
defer cancelKeyringCleaner()
363+
go keyringCleaner.Run(keyringCleanerCtx)
364+
logger.Info("retired-key cleaner initialized")
365+
347366
// Initialize WebSocket manager for Agent connections
348367
wsManager := collector.NewWebSocketManager(eventHub, registry, logger)
349368
logger.Info("WebSocket manager initialized")
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package agent_keyring
2+
3+
import (
4+
"context"
5+
"log/slog"
6+
"time"
7+
8+
"github.com/sarg3nt/gearbox/internal/framework/database"
9+
)
10+
11+
// CleanerInterval is the cadence at which RetiredKeyCleaner walks the
12+
// fleet looking for keys whose retired_at + overlap window has passed.
13+
// One hour is a sensible default: short enough that a 24-hour overlap
14+
// rotation cleans up within a few hours of its target, long enough that
15+
// the sweep is cheap (one DB query per box, one DELETE per agent).
16+
const CleanerInterval = 1 * time.Hour
17+
18+
// RetiredKeyCleaner is a background service that periodically walks
19+
// every box and asks the Rotator to remove any keys whose retired_at
20+
// is older than the overlap window. The Phase 3 manual-rotate buttons
21+
// stamp retired_at but don't remove the old key — the cleaner does, so
22+
// retired keys don't linger forever after a rotation.
23+
//
24+
// Phase 4 in the original plan also covered auto-rotation on a
25+
// schedule; that needs a new global-settings surface in the dashboard
26+
// (the dashboard doesn't have one yet for app-level config) and is
27+
// intentionally deferred to a follow-up. The cleaner is the smaller
28+
// piece that's genuinely needed regardless of whether auto-rotation
29+
// is enabled.
30+
type RetiredKeyCleaner struct {
31+
rotator *Rotator
32+
db *database.DB
33+
overlapWindow time.Duration
34+
interval time.Duration
35+
logger *slog.Logger
36+
}
37+
38+
// NewCleaner builds a cleaner around an existing rotator. Pass
39+
// overlapWindow=0 to use DefaultOverlapWindow; interval=0 → CleanerInterval.
40+
func NewCleaner(rotator *Rotator, db *database.DB, overlapWindow, interval time.Duration, logger *slog.Logger) *RetiredKeyCleaner {
41+
if overlapWindow <= 0 {
42+
overlapWindow = DefaultOverlapWindow
43+
}
44+
if interval <= 0 {
45+
interval = CleanerInterval
46+
}
47+
return &RetiredKeyCleaner{
48+
rotator: rotator,
49+
db: db,
50+
overlapWindow: overlapWindow,
51+
interval: interval,
52+
logger: logger,
53+
}
54+
}
55+
56+
// Run blocks until ctx is cancelled. Sweeps once immediately on start
57+
// so a freshly-deployed dashboard catches up on any retired keys left
58+
// over from manual rotations done while the previous instance was
59+
// down, then ticks every interval.
60+
//
61+
// Errors are logged but not propagated — one bad box shouldn't break
62+
// the fleet sweep, and we don't want to spam fatal errors at startup
63+
// for a transient agent outage.
64+
func (c *RetiredKeyCleaner) Run(ctx context.Context) {
65+
c.logger.Info("retired-key cleaner started",
66+
"interval", c.interval,
67+
"overlap_window", c.overlapWindow)
68+
69+
c.sweepOnce(ctx)
70+
71+
ticker := time.NewTicker(c.interval)
72+
defer ticker.Stop()
73+
for {
74+
select {
75+
case <-ctx.Done():
76+
c.logger.Info("retired-key cleaner stopped")
77+
return
78+
case <-ticker.C:
79+
c.sweepOnce(ctx)
80+
}
81+
}
82+
}
83+
84+
func (c *RetiredKeyCleaner) sweepOnce(ctx context.Context) {
85+
boxes, err := c.db.GetEnabledBoxes()
86+
if err != nil {
87+
c.logger.Warn("cleaner: failed to list boxes", "error", err)
88+
return
89+
}
90+
91+
total := 0
92+
for _, box := range boxes {
93+
if ctx.Err() != nil {
94+
return
95+
}
96+
removed, err := c.rotator.CleanupRetiredKeys(box.ID, c.overlapWindow)
97+
if err != nil {
98+
c.logger.Warn("cleaner: box sweep failed",
99+
"box_id", box.ID, "name", box.Name, "error", err)
100+
continue
101+
}
102+
if removed > 0 {
103+
c.logger.Info("cleaner: swept retired keys",
104+
"box_id", box.ID, "name", box.Name, "removed", removed)
105+
total += removed
106+
}
107+
}
108+
if total > 0 {
109+
c.logger.Info("cleaner: sweep complete", "total_removed", total)
110+
}
111+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package agent_keyring
2+
3+
import (
4+
"context"
5+
"testing"
6+
"time"
7+
)
8+
9+
func TestCleaner_RemovesRetiredKeyOnTick(t *testing.T) {
10+
rotator, mock, db, box := setupRotator(t)
11+
if _, err := rotator.RotateBox(box.ID, 24*time.Hour); err != nil {
12+
t.Fatalf("RotateBox: %v", err)
13+
}
14+
if mock.entryCount() != 2 {
15+
t.Fatalf("post-rotation agent entries = %d, want 2", mock.entryCount())
16+
}
17+
18+
// Tiny overlap + tiny interval so the cleaner sweeps quickly and
19+
// considers the just-retired legacy entry eligible.
20+
cleaner := NewCleaner(rotator, db, 1*time.Millisecond, 20*time.Millisecond, rotator.logger)
21+
22+
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
23+
defer cancel()
24+
done := make(chan struct{})
25+
go func() {
26+
cleaner.Run(ctx)
27+
close(done)
28+
}()
29+
30+
// Give it time for the immediate-on-start sweep to fire.
31+
time.Sleep(50 * time.Millisecond)
32+
cancel()
33+
<-done
34+
35+
if got := mock.entryCount(); got != 1 {
36+
t.Errorf("agent entries after cleaner sweep = %d, want 1", got)
37+
}
38+
keys, _ := db.GetBoxAgentKeys(box.ID)
39+
if len(keys) != 1 {
40+
t.Errorf("db keys after cleaner sweep = %d, want 1", len(keys))
41+
}
42+
}
43+
44+
func TestCleaner_NoopWhenNothingRetired(t *testing.T) {
45+
rotator, mock, db, box := setupRotator(t)
46+
// No rotation = no retired keys. Cleaner sweep should leave the
47+
// single legacy primary alone.
48+
49+
cleaner := NewCleaner(rotator, db, 1*time.Millisecond, 1*time.Hour, rotator.logger)
50+
ctx, cancel := context.WithCancel(context.Background())
51+
done := make(chan struct{})
52+
go func() {
53+
cleaner.Run(ctx)
54+
close(done)
55+
}()
56+
time.Sleep(20 * time.Millisecond)
57+
cancel()
58+
<-done
59+
60+
if got := mock.entryCount(); got != 1 {
61+
t.Errorf("mock entries = %d, want 1", got)
62+
}
63+
keys, _ := db.GetBoxAgentKeys(box.ID)
64+
if len(keys) != 1 {
65+
t.Errorf("db keys = %d, want 1", len(keys))
66+
}
67+
}

0 commit comments

Comments
 (0)