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>
217 lines
7.4 KiB
Go
217 lines
7.4 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// findOrCreateRegistryTx returns the ip_registry row id for addr, creating
|
|
// it (next_cycle starting at 1) the first time this address is ever seen.
|
|
// Safe to call on every (re)submission of an address — ip_address is
|
|
// UNIQUE, so a second call for the same address is a no-op lookup.
|
|
func findOrCreateRegistryTx(ctx context.Context, tx *sql.Tx, addr string, now string) (int64, error) {
|
|
if _, err := tx.ExecContext(ctx, `
|
|
INSERT INTO ip_registry (ip_address, first_seen_at, last_seen_at, next_cycle, created_at, updated_at)
|
|
VALUES (?, ?, ?, 1, ?, ?)
|
|
ON CONFLICT(ip_address) DO NOTHING
|
|
`, addr, now, now, now, now); err != nil {
|
|
return 0, fmt.Errorf("find or create registry for %s: %w", addr, err)
|
|
}
|
|
var id int64
|
|
if err := tx.QueryRowContext(ctx, `SELECT id FROM ip_registry WHERE ip_address=?`, addr).Scan(&id); err != nil {
|
|
return 0, fmt.Errorf("lookup registry id for %s: %w", addr, err)
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
// nextRegistryCycleTx allocates the next cycle_id for registryID and
|
|
// advances last_seen_at. Call once per fresh generation of check history for
|
|
// an address — a brand new ip_queue row, an admin-triggered requeue, or a
|
|
// system retry that bumps attempt_number — so every generation's checks get
|
|
// a cycle_id no other generation, past or future (even across ip_queue row
|
|
// deletion and recreation), will ever reuse.
|
|
func nextRegistryCycleTx(ctx context.Context, tx *sql.Tx, registryID int64, now string) (int, error) {
|
|
var cycle int
|
|
if err := tx.QueryRowContext(ctx, `SELECT next_cycle FROM ip_registry WHERE id=?`, registryID).Scan(&cycle); err != nil {
|
|
return 0, fmt.Errorf("read next_cycle for registry %d: %w", registryID, err)
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `
|
|
UPDATE ip_registry SET next_cycle=next_cycle+1, last_seen_at=?, updated_at=? WHERE id=?
|
|
`, now, now, registryID); err != nil {
|
|
return 0, fmt.Errorf("advance next_cycle for registry %d: %w", registryID, err)
|
|
}
|
|
return cycle, nil
|
|
}
|
|
|
|
// RegistrySummary is one row of the full IP registry, combining the durable
|
|
// ip_registry record with a rollup of its check history and, if the address
|
|
// currently has a live ip_queue row, that row's state.
|
|
type RegistrySummary struct {
|
|
RegistryItem
|
|
TotalCycles int
|
|
LastResult string
|
|
LastCheckedAt *time.Time
|
|
InQueue bool
|
|
CurrentState string
|
|
}
|
|
|
|
// ListRegistry returns every address ever submitted, newest first-seen
|
|
// last, each with a summary of its accumulated check history. Reads two
|
|
// simple queries plus one aggregate rather than a single large join, since
|
|
// the registry is expected to stay small enough (one row per distinct
|
|
// address ever seen) that this is simpler to reason about than a
|
|
// multi-way correlated subquery.
|
|
func (d *DB) ListRegistry(ctx context.Context) ([]RegistrySummary, error) {
|
|
rows, err := d.QueryContext(ctx, `
|
|
SELECT id, ip_address, first_seen_at, last_seen_at, next_cycle, created_at, updated_at
|
|
FROM ip_registry ORDER BY first_seen_at
|
|
`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
items, err := scanRegistryItems(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
out := make([]RegistrySummary, len(items))
|
|
for i, item := range items {
|
|
s := RegistrySummary{RegistryItem: item}
|
|
if err := d.fillRegistrySummary(ctx, &s); err != nil {
|
|
return nil, err
|
|
}
|
|
out[i] = s
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// GetRegistryByAddress returns the registry row (with summary) for a single
|
|
// address, or ErrNotFound if it has never been submitted.
|
|
func (d *DB) GetRegistryByAddress(ctx context.Context, address string) (*RegistrySummary, error) {
|
|
row := d.QueryRowContext(ctx, `
|
|
SELECT id, ip_address, first_seen_at, last_seen_at, next_cycle, created_at, updated_at
|
|
FROM ip_registry WHERE ip_address=?
|
|
`, address)
|
|
item, err := scanRegistryItem(row)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, fmt.Errorf("ip %q: %w", address, ErrNotFound)
|
|
}
|
|
return nil, err
|
|
}
|
|
s := &RegistrySummary{RegistryItem: *item}
|
|
if err := d.fillRegistrySummary(ctx, s); err != nil {
|
|
return nil, err
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func (d *DB) fillRegistrySummary(ctx context.Context, s *RegistrySummary) error {
|
|
if err := d.QueryRowContext(ctx, `
|
|
SELECT COUNT(DISTINCT cycle_id) FROM checks WHERE registry_id=?
|
|
`, s.ID).Scan(&s.TotalCycles); err != nil {
|
|
return err
|
|
}
|
|
|
|
var lastResult sql.NullBool
|
|
var lastCheckedAt sql.NullString
|
|
if err := d.QueryRowContext(ctx, `
|
|
SELECT success, checked_at FROM checks WHERE registry_id=? ORDER BY cycle_id DESC, checked_at DESC LIMIT 1
|
|
`, s.ID).Scan(&lastResult, &lastCheckedAt); err != nil && err != sql.ErrNoRows {
|
|
return err
|
|
}
|
|
if lastResult.Valid {
|
|
if lastResult.Bool {
|
|
s.LastResult = "pass"
|
|
} else {
|
|
s.LastResult = "fail"
|
|
}
|
|
}
|
|
t, err := nullStringToTimePtr(lastCheckedAt)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.LastCheckedAt = t
|
|
|
|
var state sql.NullString
|
|
if err := d.QueryRowContext(ctx, `SELECT state FROM ip_queue WHERE registry_id=?`, s.ID).Scan(&state); err != nil && err != sql.ErrNoRows {
|
|
return err
|
|
}
|
|
if state.Valid {
|
|
s.InQueue = true
|
|
s.CurrentState = state.String
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func scanRegistryItems(rows *sql.Rows) ([]RegistryItem, error) {
|
|
var out []RegistryItem
|
|
for rows.Next() {
|
|
item, err := scanRegistryItem(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, *item)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func scanRegistryItem(row rowScanner) (*RegistryItem, error) {
|
|
var item RegistryItem
|
|
var firstSeenAt, lastSeenAt, createdAt, updatedAt string
|
|
if err := row.Scan(&item.ID, &item.IPAddress, &firstSeenAt, &lastSeenAt, &item.NextCycle, &createdAt, &updatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
var err error
|
|
if item.FirstSeenAt, err = dbToTime(firstSeenAt); err != nil {
|
|
return nil, err
|
|
}
|
|
if item.LastSeenAt, err = dbToTime(lastSeenAt); err != nil {
|
|
return nil, err
|
|
}
|
|
if item.CreatedAt, err = dbToTime(createdAt); err != nil {
|
|
return nil, err
|
|
}
|
|
if item.UpdatedAt, err = dbToTime(updatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
return &item, nil
|
|
}
|
|
|
|
// PruneRegistryHistory deletes all but the newest keepCycles cycles' worth
|
|
// of check history for registryID — a no-op if keepCycles <= 0 (unlimited
|
|
// retention) or the address has that many cycles or fewer. events rows tied
|
|
// to this registry are pruned to the same cutoff; ip_registry itself and its
|
|
// next_cycle counter are never touched, so pruning never risks a future
|
|
// cycle_id collision.
|
|
func (d *DB) PruneRegistryHistory(ctx context.Context, registryID int64, keepCycles int) error {
|
|
if keepCycles <= 0 {
|
|
return nil
|
|
}
|
|
var cutoff sql.NullInt64
|
|
err := d.QueryRowContext(ctx, `
|
|
SELECT MIN(cycle_id) FROM (
|
|
SELECT DISTINCT cycle_id FROM checks WHERE registry_id=? ORDER BY cycle_id DESC LIMIT ?
|
|
)
|
|
`, registryID, keepCycles).Scan(&cutoff)
|
|
if err != nil {
|
|
return fmt.Errorf("find prune cutoff for registry %d: %w", registryID, err)
|
|
}
|
|
if !cutoff.Valid {
|
|
return nil // fewer than keepCycles cycles recorded — nothing to prune
|
|
}
|
|
if _, err := d.ExecContext(ctx, `DELETE FROM checks WHERE registry_id=? AND cycle_id<?`, registryID, cutoff.Int64); err != nil {
|
|
return fmt.Errorf("prune checks for registry %d: %w", registryID, err)
|
|
}
|
|
// Cut events at the same cycle boundary as checks, so an address's audit
|
|
// trail and its check history are retained (and pruned) in lockstep.
|
|
if _, err := d.ExecContext(ctx, `DELETE FROM events WHERE registry_id=? AND cycle_id<?`, registryID, cutoff.Int64); err != nil {
|
|
return fmt.Errorf("prune events for registry %d: %w", registryID, err)
|
|
}
|
|
return nil
|
|
}
|