Add Floating IP scanning and a durable address registry with configurable history depth

Adds POST /api/v1/admin/ips/scan (plus an optional periodic ticker) to
discover free Floating IPs in the OpenStack project and feed them straight
into the check queue. More importantly, decouples check/event history from
ip_queue's lifecycle: a new ip_registry table (migration 0007) gives every
address ever submitted a durable identity, so deleting it from the queue no
longer destroys its history — it's still reachable via the new
GET /api/v1/admin/registry[/{ip}] endpoints and the dashboard's /registry
pages, with retention depth configurable in check cycles per address
(history_retention_cycles, 0 = unlimited).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
ayurishchevandClaude Sonnet 5 committed 2026-09-23 09:52:01 +03:00
1 parent 78b20fa5be
commit 582b44f314
47 files changed
+1781 -107

No files matched your search

+45
View File
@@ -395,6 +395,43 @@ func (o *Orchestrator) ClearQueue(ctx context.Context) (db.DeleteIPsResult, erro
return result, nil
}
// ScanFloatingIPs lists every floating IP in the OpenStack project, filters
// to the ones not currently associated to any port (the free pool awaiting
// validation before reissue), and submits that address list to the check
// queue via db.SubmitIPs — the same entry point the admin API's "add
// addresses" call uses, so add/requeue/reorder semantics are identical
// whether the address list came from an operator or from this scan. Returns
// the SubmitIPs outcome plus how many free floating IPs were found in total
// (which can be larger than the sum of the SubmitIPsResult slices, since
// addresses already mid-check are silently skipped — see db.SubmitIPs).
func (o *Orchestrator) ScanFloatingIPs(ctx context.Context) (db.SubmitIPsResult, int, error) {
fips, err := o.OS.ListFloatingIPs(ctx)
if err != nil {
return db.SubmitIPsResult{}, 0, fmt.Errorf("list floating ips: %w", err)
}
var free []string
for _, f := range fips {
if f.PortID == "" {
free = append(free, f.Address)
}
}
if len(free) == 0 {
o.event(ctx, "control-api", "", nil, "fip_scan", `{"scanned_free":0}`)
return db.SubmitIPsResult{}, 0, nil
}
result, err := o.DB.SubmitIPs(ctx, free)
if err != nil {
return result, len(free), fmt.Errorf("submit scanned ips: %w", err)
}
o.event(ctx, "control-api", "", nil, "fip_scan", fmt.Sprintf(
`{"scanned_free":%d,"added":%d,"requeued":%d,"reordered":%d,"skipped_in_progress":%d}`,
len(free), len(result.Added), len(result.Requeued), len(result.Reordered), len(result.SkippedInProgress)))
return result, len(free), nil
}
// deletedAddressesPayload builds the event payload for the batch delete
// operations — a proper JSON array via encoding/json rather than fmt's %q
// slice formatting (which produces space-separated quoted strings, not
@@ -508,6 +545,14 @@ func (o *Orchestrator) aggregateAndRelease(ctx context.Context, item db.IPQueueI
o.event(ctx, "control-api", "", &item.ID, "aggregated",
fmt.Sprintf(`{"result":%q,"checks":%d,"passed":%d,"missing":%d}`, result, len(checks), passCount, missing))
if settings, err := o.DB.GetSettings(ctx); err != nil {
o.Log.Error("get settings for history retention", "ip_id", item.ID, "err", err)
} else if settings.HistoryRetentionCycles > 0 {
if err := o.DB.PruneRegistryHistory(ctx, item.RegistryID, settings.HistoryRetentionCycles); err != nil {
o.Log.Error("prune registry history", "ip_id", item.ID, "registry_id", item.RegistryID, "err", err)
}
}
if item.FIPID != "" {
if err := o.OS.DisassociateFloatingIP(ctx, item.FIPID); err != nil {
o.Log.Error("disassociate fip", "ip_id", item.ID, "fip_id", item.FIPID, "err", err)
@@ -882,3 +882,60 @@ func TestAggregationWaitsForTLSAndSSHResults(t *testing.T) {
t.Fatalf("expected partial (missing ssh/tls-443 counted against it), got %s", ip.OverallResult)
}
}
// TestScanFloatingIPsSubmitsOnlyFreeAddresses proves ScanFloatingIPs filters
// out floating IPs already associated to a port and only submits the free
// pool to the check queue.
func TestScanFloatingIPsSubmitsOnlyFreeAddresses(t *testing.T) {
ctx := context.Background()
o, d, mock := newTestOrchestrator(t, 180)
mock.Seed("fip-free-1", "1.1.1.1", "svc-project")
mock.Seed("fip-free-2", "2.2.2.2", "svc-project")
mock.SeedWithPort("fip-occupied", "3.3.3.3", "svc-project", "some-other-port")
result, scanned, err := o.ScanFloatingIPs(ctx)
if err != nil {
t.Fatalf("scan floating ips: %v", err)
}
if scanned != 2 {
t.Fatalf("expected 2 free fips scanned, got %d", scanned)
}
if len(result.Added) != 2 {
t.Fatalf("expected 2 addresses added, got %+v", result)
}
ips, err := d.ListIPs(ctx)
if err != nil {
t.Fatalf("list ips: %v", err)
}
seen := map[string]bool{}
for _, ip := range ips {
seen[ip.IPAddress] = true
}
if !seen["1.1.1.1"] || !seen["2.2.2.2"] {
t.Fatalf("expected free addresses queued, got %+v", ips)
}
if seen["3.3.3.3"] {
t.Fatalf("expected occupied address not queued, got %+v", ips)
}
}
// TestScanFloatingIPsNoFreeAddressesIsNotAnError proves scanning a project
// with no free floating IPs (or none at all) succeeds with an empty result
// rather than hitting SubmitIPs' "addresses must not be empty" validation.
func TestScanFloatingIPsNoFreeAddressesIsNotAnError(t *testing.T) {
ctx := context.Background()
o, _, mock := newTestOrchestrator(t, 180)
mock.SeedWithPort("fip-occupied", "3.3.3.3", "svc-project", "some-other-port")
result, scanned, err := o.ScanFloatingIPs(ctx)
if err != nil {
t.Fatalf("scan floating ips: %v", err)
}
if scanned != 0 {
t.Fatalf("expected 0 free fips scanned, got %d", scanned)
}
if len(result.Added) != 0 {
t.Fatalf("expected nothing added, got %+v", result)
}
}