admin control features and admin dashboard

This commit is contained in:
ayurishchev committed 2026-08-23 20:39:22 +03:00
1 parent c630f13c57
commit 37910e410b
69 files changed
+4959 -400

No files matched your search

+104
View File
@@ -0,0 +1,104 @@
package db
import (
"context"
"fmt"
"cloudipvalidator/internal/config"
)
// BootstrapFromConfig ensures the database's baseline state matches the
// YAML config on a fresh install, then gets out of the way.
//
// - ip_addresses -> SeedQueue: unchanged, always additive. This is a
// separate concept from the runtime admin queue API (SubmitIPs) — every
// control-api startup re-adds any address from cfg.IPAddresses that
// isn't already in the queue.
// - validators / sites / target_groups / check_types: applied from YAML
// only if the corresponding table is currently empty. Once any row
// exists (via this bootstrap or the admin API), YAML for that section
// is ignored on every subsequent startup — the database is the source
// of truth from that point on. This is what makes admin API changes to
// these four entities survive a restart.
func (d *DB) BootstrapFromConfig(ctx context.Context, cfg *config.ControlAPI) error {
if err := d.bootstrapValidators(ctx, cfg.Validators); err != nil {
return fmt.Errorf("bootstrap validators: %w", err)
}
if err := d.bootstrapSites(ctx, cfg.Sites); err != nil {
return fmt.Errorf("bootstrap sites: %w", err)
}
if err := d.bootstrapTargetGroups(ctx, cfg.Targets); err != nil {
return fmt.Errorf("bootstrap target groups: %w", err)
}
if err := d.bootstrapCheckTypes(ctx, cfg.CheckTypes); err != nil {
return fmt.Errorf("bootstrap check types: %w", err)
}
if err := d.SeedQueue(ctx, cfg.IPAddresses); err != nil {
return fmt.Errorf("seed ip queue: %w", err)
}
return nil
}
func (d *DB) bootstrapValidators(ctx context.Context, validators []config.ValidatorConfig) error {
var count int
if err := d.QueryRowContext(ctx, `SELECT COUNT(*) FROM validators`).Scan(&count); err != nil {
return err
}
if count > 0 {
return nil
}
for _, v := range validators {
if err := d.RegisterValidator(ctx, v.ValidatorID, "", v.OSPortID, ""); err != nil {
return fmt.Errorf("seed validator %s: %w", v.ValidatorID, err)
}
}
return nil
}
func (d *DB) bootstrapSites(ctx context.Context, sites []config.SiteConfig) error {
var count int
if err := d.QueryRowContext(ctx, `SELECT COUNT(*) FROM sites`).Scan(&count); err != nil {
return err
}
if count > 0 {
return nil
}
for _, s := range sites {
if err := d.UpsertSite(ctx, s.Index, s.SiteID); err != nil {
return fmt.Errorf("seed site %s: %w", s.SiteID, err)
}
}
return nil
}
func (d *DB) bootstrapTargetGroups(ctx context.Context, targets map[string][]string) error {
var count int
if err := d.QueryRowContext(ctx, `SELECT COUNT(*) FROM target_groups`).Scan(&count); err != nil {
return err
}
if count > 0 {
return nil
}
for name, addrs := range targets {
if err := d.UpsertTargetGroup(ctx, name, addrs); err != nil {
return fmt.Errorf("seed target group %s: %w", name, err)
}
}
return nil
}
func (d *DB) bootstrapCheckTypes(ctx context.Context, checkTypes []config.CheckTypeConfig) error {
var count int
if err := d.QueryRowContext(ctx, `SELECT COUNT(*) FROM check_types`).Scan(&count); err != nil {
return err
}
if count > 0 {
return nil
}
for _, ct := range checkTypes {
if err := d.UpsertCheckType(ctx, ct.Name, ct.Enabled, ct.Targets); err != nil {
return fmt.Errorf("seed check type %s: %w", ct.Name, err)
}
}
return nil
}
+38 -17
View File
@@ -16,6 +16,21 @@ import (
//go:embed migrations/0001_init.sql
var initSchema string
//go:embed migrations/0002_dynamic_config.sql
var dynamicConfigSchema string
// migrations is the ordered list of schema versions. Each entry's SQL is
// applied, in order, for any version greater than the database's current
// PRAGMA user_version — so a fresh database walks the whole list and an
// existing one only picks up what's new.
var migrations = []struct {
version int
sql string
}{
{1, initSchema},
{2, dynamicConfigSchema},
}
type DB struct {
*sql.DB
}
@@ -52,30 +67,36 @@ func Open(ctx context.Context, path string) (*DB, error) {
return d, nil
}
// migrate applies the embedded schema exactly once, tracked via
// PRAGMA user_version so repeated startups are no-ops.
// migrate applies every pending migration in order, tracked via
// PRAGMA user_version so repeated startups only apply what's new (and a
// fresh database walks the whole list once).
func (d *DB) migrate(ctx context.Context) error {
var version int
if err := d.QueryRowContext(ctx, "PRAGMA user_version").Scan(&version); err != nil {
return fmt.Errorf("read user_version: %w", err)
}
if version >= 1 {
return nil
}
tx, err := d.BeginTx(ctx, nil)
if err != nil {
return err
for _, m := range migrations {
if m.version <= version {
continue
}
tx, err := d.BeginTx(ctx, nil)
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, m.sql); err != nil {
tx.Rollback()
return fmt.Errorf("apply migration %d: %w", m.version, err)
}
if _, err := tx.ExecContext(ctx, fmt.Sprintf("PRAGMA user_version=%d", m.version)); err != nil {
tx.Rollback()
return fmt.Errorf("set user_version=%d: %w", m.version, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration %d: %w", m.version, err)
}
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, initSchema); err != nil {
return fmt.Errorf("apply 0001_init.sql: %w", err)
}
if _, err := tx.ExecContext(ctx, "PRAGMA user_version=1"); err != nil {
return fmt.Errorf("set user_version: %w", err)
}
return tx.Commit()
return nil
}
// Now returns the current time truncated to millisecond precision, the
+26
View File
@@ -0,0 +1,26 @@
package db
import "errors"
// Sentinel errors returned by the dynamic-config and IP-queue admin
// mutations. Callers (internal/httpapi) map these to HTTP status codes via
// errors.Is rather than defaulting everything to 500.
var (
// ErrNotFound: the referenced entity does not exist.
ErrNotFound = errors.New("not found")
// ErrConflict: the entity already exists, or a unique slot/key is
// already taken by something else.
ErrConflict = errors.New("conflict")
// ErrBusy: the entity can't be removed because it's currently in use
// (e.g. a validator that owns an in-progress IP).
ErrBusy = errors.New("busy")
// ErrInUse: the entity can't be removed because something else
// references it (e.g. a target group referenced by a check type).
ErrInUse = errors.New("in use")
// ErrValidation: the request is well-formed but refers to something
// invalid (e.g. a check type naming a target group that doesn't exist).
ErrValidation = errors.New("validation failed")
// ErrInvalidState: the requested state transition isn't valid from the
// entity's current state (e.g. cancelling an already-finished IP).
ErrInvalidState = errors.New("invalid state")
)
@@ -0,0 +1,26 @@
-- Dynamic configuration tables: sites, target_groups, check_types.
-- validators already exists (0001_init.sql) and needs no new columns.
-- Bootstrap semantics (apply YAML only when the table is empty) live in
-- internal/db/bootstrap.go, not in this migration.
CREATE TABLE sites (
idx INTEGER PRIMARY KEY, -- 1, 2 or 3 -- fixed slot
site_id TEXT NOT NULL UNIQUE,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE TABLE target_groups (
group_name TEXT PRIMARY KEY,
targets TEXT NOT NULL, -- JSON array of strings
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE TABLE check_types (
name TEXT PRIMARY KEY,
enabled BOOLEAN NOT NULL DEFAULT 1,
target_groups TEXT NOT NULL, -- JSON array of group names
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
+42 -3
View File
@@ -20,9 +20,10 @@ const (
IPDone = "done"
IPFailed = "failed"
ResultPass = "pass"
ResultPartial = "partial"
ResultFail = "fail"
ResultPass = "pass"
ResultPartial = "partial"
ResultFail = "fail"
ResultCancelled = "cancelled"
SourceEgress = "egress"
)
@@ -115,3 +116,41 @@ type Event struct {
OccurredAt time.Time
CreatedAt time.Time
}
type Site struct {
Index int
SiteID string
CreatedAt time.Time
UpdatedAt time.Time
}
type TargetGroup struct {
Name string
Targets []string
CreatedAt time.Time
UpdatedAt time.Time
}
type CheckType struct {
Name string
Enabled bool
TargetGroups []string
CreatedAt time.Time
UpdatedAt time.Time
}
// ResolvedCheckType is a check type with its target groups already expanded
// into a flat target list — what AssignmentForValidator hands to an agent.
type ResolvedCheckType struct {
Type string
Targets []string
}
// SubmitIPsResult categorizes how each address in a SubmitIPs call was
// handled.
type SubmitIPsResult struct {
Added []string
Requeued []string
Reordered []string
SkippedInProgress []string
}
+141
View File
@@ -0,0 +1,141 @@
package db
import (
"context"
"encoding/json"
"fmt"
)
// ListCheckTypes returns all configured check types, ordered by name.
func (d *DB) ListCheckTypes(ctx context.Context) ([]CheckType, error) {
rows, err := d.QueryContext(ctx, `
SELECT name, enabled, target_groups, created_at, updated_at FROM check_types ORDER BY name
`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []CheckType
for rows.Next() {
ct, err := scanCheckType(rows)
if err != nil {
return nil, err
}
out = append(out, *ct)
}
return out, rows.Err()
}
// ListResolvedCheckTypes returns every enabled check type with its target
// groups already expanded into a flat target list — the egress
// check-type/target configuration handed to a validator-agent once its IP
// is ready to be worked on. This is the dynamic equivalent of what
// orchestrator.New used to compute once from static YAML config.
func (d *DB) ListResolvedCheckTypes(ctx context.Context) ([]ResolvedCheckType, error) {
groups, err := d.loadTargetGroupsMap(ctx)
if err != nil {
return nil, err
}
rows, err := d.QueryContext(ctx, `
SELECT name, target_groups FROM check_types WHERE enabled=1 ORDER BY name
`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ResolvedCheckType
for rows.Next() {
var name, groupsJSON string
if err := rows.Scan(&name, &groupsJSON); err != nil {
return nil, err
}
var groupNames []string
if err := json.Unmarshal([]byte(groupsJSON), &groupNames); err != nil {
return nil, fmt.Errorf("decode target_groups for check type %q: %w", name, err)
}
var targets []string
for _, g := range groupNames {
targets = append(targets, groups[g]...)
}
out = append(out, ResolvedCheckType{Type: name, Targets: targets})
}
return out, rows.Err()
}
func (d *DB) loadTargetGroupsMap(ctx context.Context) (map[string][]string, error) {
groups, err := d.ListTargetGroups(ctx)
if err != nil {
return nil, err
}
out := make(map[string][]string, len(groups))
for _, g := range groups {
out[g.Name] = g.Targets
}
return out, nil
}
// UpsertCheckType creates or replaces a check type. All named target groups
// must already exist.
func (d *DB) UpsertCheckType(ctx context.Context, name string, enabled bool, targetGroups []string) error {
if name == "" {
return fmt.Errorf("check type name must not be empty: %w", ErrValidation)
}
existing, err := d.loadTargetGroupsMap(ctx)
if err != nil {
return err
}
for _, g := range targetGroups {
if _, ok := existing[g]; !ok {
return fmt.Errorf("check type %q references unknown target group %q: %w", name, g, ErrValidation)
}
}
payload, err := json.Marshal(targetGroups)
if err != nil {
return err
}
now := timeToDB(Now())
_, err = d.ExecContext(ctx, `
INSERT INTO check_types (name, enabled, target_groups, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(name) DO UPDATE SET
enabled=excluded.enabled, target_groups=excluded.target_groups, updated_at=excluded.updated_at
`, name, enabled, string(payload), now, now)
if err != nil {
return fmt.Errorf("upsert check type: %w", err)
}
return nil
}
// DeleteCheckType removes a check type.
func (d *DB) DeleteCheckType(ctx context.Context, name string) error {
res, err := d.ExecContext(ctx, `DELETE FROM check_types WHERE name=?`, name)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return fmt.Errorf("check type %q: %w", name, ErrNotFound)
}
return nil
}
func scanCheckType(row rowScanner) (*CheckType, error) {
var ct CheckType
var groupsJSON, createdAt, updatedAt string
if err := row.Scan(&ct.Name, &ct.Enabled, &groupsJSON, &createdAt, &updatedAt); err != nil {
return nil, err
}
if err := json.Unmarshal([]byte(groupsJSON), &ct.TargetGroups); err != nil {
return nil, fmt.Errorf("decode target_groups for check type %q: %w", ct.Name, err)
}
var err error
if ct.CreatedAt, err = dbToTime(createdAt); err != nil {
return nil, err
}
if ct.UpdatedAt, err = dbToTime(updatedAt); err != nil {
return nil, err
}
return &ct, nil
}
+332
View File
@@ -0,0 +1,332 @@
package db
import (
"context"
"errors"
"path/filepath"
"testing"
"time"
"cloudipvalidator/internal/config"
)
func newTestDB(t *testing.T) (*DB, context.Context) {
t.Helper()
ctx := context.Background()
d, err := Open(ctx, filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { d.Close() })
return d, ctx
}
func TestUpsertSiteValidation(t *testing.T) {
d, ctx := newTestDB(t)
if err := d.UpsertSite(ctx, 0, "site-1"); !errors.Is(err, ErrValidation) {
t.Fatalf("expected ErrValidation for idx=0, got %v", err)
}
if err := d.UpsertSite(ctx, 4, "site-1"); !errors.Is(err, ErrValidation) {
t.Fatalf("expected ErrValidation for idx=4, got %v", err)
}
if err := d.UpsertSite(ctx, 1, ""); !errors.Is(err, ErrValidation) {
t.Fatalf("expected ErrValidation for empty site_id, got %v", err)
}
if err := d.UpsertSite(ctx, 1, "site-1"); err != nil {
t.Fatalf("upsert site 1: %v", err)
}
if err := d.UpsertSite(ctx, 2, "site-1"); !errors.Is(err, ErrConflict) {
t.Fatalf("expected ErrConflict assigning site-1 to slot 2, got %v", err)
}
// Renaming the same slot is fine (not a conflict with itself).
if err := d.UpsertSite(ctx, 1, "site-1-renamed"); err != nil {
t.Fatalf("rename slot 1: %v", err)
}
if err := d.DeleteSite(ctx, 1); err != nil {
t.Fatalf("delete site 1: %v", err)
}
if err := d.DeleteSite(ctx, 1); !errors.Is(err, ErrNotFound) {
t.Fatalf("expected ErrNotFound deleting already-gone slot, got %v", err)
}
}
func TestTargetGroupInUseCannotBeDeleted(t *testing.T) {
d, ctx := newTestDB(t)
if err := d.UpsertTargetGroup(ctx, "web", []string{"https://example.test"}); err != nil {
t.Fatalf("upsert target group: %v", err)
}
if err := d.UpsertTargetGroup(ctx, "empty", nil); !errors.Is(err, ErrValidation) {
t.Fatalf("expected ErrValidation for empty targets, got %v", err)
}
if err := d.UpsertCheckType(ctx, "https", true, []string{"web"}); err != nil {
t.Fatalf("upsert check type: %v", err)
}
if err := d.DeleteTargetGroup(ctx, "web"); !errors.Is(err, ErrInUse) {
t.Fatalf("expected ErrInUse deleting group referenced by check type, got %v", err)
}
if err := d.DeleteCheckType(ctx, "https"); err != nil {
t.Fatalf("delete check type: %v", err)
}
if err := d.DeleteTargetGroup(ctx, "web"); err != nil {
t.Fatalf("delete target group after check type removed: %v", err)
}
}
func TestUpsertCheckTypeUnknownGroup(t *testing.T) {
d, ctx := newTestDB(t)
err := d.UpsertCheckType(ctx, "https", true, []string{"does-not-exist"})
if !errors.Is(err, ErrValidation) {
t.Fatalf("expected ErrValidation for unknown target group, got %v", err)
}
}
func TestListResolvedCheckTypesExpandsGroups(t *testing.T) {
d, ctx := newTestDB(t)
if err := d.UpsertTargetGroup(ctx, "web", []string{"https://a.test", "https://b.test"}); err != nil {
t.Fatalf("upsert web group: %v", err)
}
if err := d.UpsertTargetGroup(ctx, "extra", []string{"https://c.test"}); err != nil {
t.Fatalf("upsert extra group: %v", err)
}
if err := d.UpsertCheckType(ctx, "https", true, []string{"web", "extra"}); err != nil {
t.Fatalf("upsert https check type: %v", err)
}
if err := d.UpsertCheckType(ctx, "ssh", false, []string{"web"}); err != nil {
t.Fatalf("upsert disabled ssh check type: %v", err)
}
resolved, err := d.ListResolvedCheckTypes(ctx)
if err != nil {
t.Fatalf("list resolved check types: %v", err)
}
if len(resolved) != 1 {
t.Fatalf("expected only the enabled check type, got %d entries: %+v", len(resolved), resolved)
}
if resolved[0].Type != "https" || len(resolved[0].Targets) != 3 {
t.Fatalf("expected https with 3 expanded targets, got %+v", resolved[0])
}
}
func TestAdminValidatorCRUD(t *testing.T) {
d, ctx := newTestDB(t)
if err := d.AdminCreateValidator(ctx, "validator-1", "port-1"); err != nil {
t.Fatalf("create validator: %v", err)
}
if err := d.AdminCreateValidator(ctx, "validator-1", "port-2"); !errors.Is(err, ErrConflict) {
t.Fatalf("expected ErrConflict creating duplicate validator, got %v", err)
}
if err := d.AdminUpdateValidatorPort(ctx, "validator-1", "port-3"); err != nil {
t.Fatalf("update validator port: %v", err)
}
v, err := d.GetValidator(ctx, "validator-1")
if err != nil || v.OSPortID != "port-3" {
t.Fatalf("expected port-3 after update, got %+v (err=%v)", v, err)
}
if err := d.AdminUpdateValidatorPort(ctx, "no-such-validator", "port-9"); !errors.Is(err, ErrNotFound) {
t.Fatalf("expected ErrNotFound updating unknown validator, got %v", err)
}
// Claim it onto an IP, then deletion should be refused as busy.
if err := d.SeedQueue(ctx, []string{"1.2.3.4"}); err != nil {
t.Fatalf("seed queue: %v", err)
}
if _, err := d.ClaimNextQueued(ctx, "validator-1", time.Minute); err != nil {
t.Fatalf("claim next queued: %v", err)
}
if err := d.DeleteValidator(ctx, "validator-1"); !errors.Is(err, ErrBusy) {
t.Fatalf("expected ErrBusy deleting validator that owns an ip, got %v", err)
}
if err := d.ReleaseFIP(ctx, 1, "validator-1"); err != nil {
t.Fatalf("release fip: %v", err)
}
if err := d.DeleteValidator(ctx, "validator-1"); err != nil {
t.Fatalf("delete validator after release: %v", err)
}
if err := d.DeleteValidator(ctx, "validator-1"); !errors.Is(err, ErrNotFound) {
t.Fatalf("expected ErrNotFound deleting already-gone validator, got %v", err)
}
}
func TestBootstrapFromConfigIgnoresYAMLOnNonEmptyTables(t *testing.T) {
d, ctx := newTestDB(t)
cfg := &config.ControlAPI{
Validators: []config.ValidatorConfig{{ValidatorID: "validator-1", OSPortID: "port-1"}},
Sites: []config.SiteConfig{{SiteID: "site-1", Index: 1}},
Targets: map[string][]string{"web": {"https://example.test"}},
CheckTypes: []config.CheckTypeConfig{{Name: "https", Enabled: true, Targets: []string{"web"}}},
}
if err := d.BootstrapFromConfig(ctx, cfg); err != nil {
t.Fatalf("first bootstrap: %v", err)
}
// Simulate an admin API change that should survive a second bootstrap
// pass (e.g. a restart) even though the YAML still names the old port.
if err := d.AdminUpdateValidatorPort(ctx, "validator-1", "port-changed-via-api"); err != nil {
t.Fatalf("update validator port via api: %v", err)
}
if err := d.UpsertSite(ctx, 1, "site-changed-via-api"); err != nil {
t.Fatalf("update site via api: %v", err)
}
if err := d.BootstrapFromConfig(ctx, cfg); err != nil {
t.Fatalf("second bootstrap: %v", err)
}
v, err := d.GetValidator(ctx, "validator-1")
if err != nil {
t.Fatalf("get validator: %v", err)
}
if v.OSPortID != "port-changed-via-api" {
t.Fatalf("expected YAML to be ignored on non-empty table, got os_port_id=%q", v.OSPortID)
}
sites, err := d.ListSites(ctx)
if err != nil {
t.Fatalf("list sites: %v", err)
}
if len(sites) != 1 || sites[0].SiteID != "site-changed-via-api" {
t.Fatalf("expected YAML to be ignored on non-empty sites table, got %+v", sites)
}
}
func TestSubmitIPsMixedBatch(t *testing.T) {
d, ctx := newTestDB(t)
// Seed: 1.1.1.1 done, 2.2.2.2 already queued, 3.3.3.3 mid-check.
if err := d.SeedQueue(ctx, []string{"1.1.1.1", "2.2.2.2", "3.3.3.3"}); err != nil {
t.Fatalf("seed queue: %v", err)
}
ip1, err := d.GetIPByAddress(ctx, "1.1.1.1")
if err != nil {
t.Fatalf("get ip1: %v", err)
}
if err := d.FinishIP(ctx, ip1.ID, ResultPass); err != nil {
t.Fatalf("finish ip1: %v", err)
}
if err := d.AdminCreateValidator(ctx, "validator-1", "port-1"); err != nil {
t.Fatalf("create validator: %v", err)
}
claimed, err := d.ClaimNextQueued(ctx, "validator-1", time.Minute)
if err != nil || claimed == nil {
t.Fatalf("claim next queued: item=%+v err=%v", claimed, err)
}
if claimed.IPAddress != "2.2.2.2" {
t.Fatalf("expected to claim 2.2.2.2 (lowest sequence still queued), got %s", claimed.IPAddress)
}
// 2.2.2.2 is now assigning_fip (mid-check); 3.3.3.3 is still queued.
result, err := d.SubmitIPs(ctx, []string{"4.4.4.4", "1.1.1.1", "2.2.2.2", "3.3.3.3"})
if err != nil {
t.Fatalf("submit ips: %v", err)
}
if len(result.Added) != 1 || result.Added[0] != "4.4.4.4" {
t.Fatalf("expected 4.4.4.4 added, got %+v", result.Added)
}
if len(result.Requeued) != 1 || result.Requeued[0] != "1.1.1.1" {
t.Fatalf("expected 1.1.1.1 requeued (was done), got %+v", result.Requeued)
}
if len(result.Reordered) != 1 || result.Reordered[0] != "3.3.3.3" {
t.Fatalf("expected 3.3.3.3 reordered (was queued), got %+v", result.Reordered)
}
if len(result.SkippedInProgress) != 1 || result.SkippedInProgress[0] != "2.2.2.2" {
t.Fatalf("expected 2.2.2.2 skipped (mid-check), got %+v", result.SkippedInProgress)
}
// 2.2.2.2 must be untouched: still assigning_fip, same owner.
ip2, err := d.GetIPByAddress(ctx, "2.2.2.2")
if err != nil {
t.Fatalf("get ip2: %v", err)
}
if ip2.State != IPAssigningFIP || ip2.OwnerValidatorID == nil || *ip2.OwnerValidatorID != "validator-1" {
t.Fatalf("expected 2.2.2.2 untouched mid-check, got %+v", ip2)
}
// 1.1.1.1 must be freshly queued again with a bumped attempt number and
// cleared result.
ip1, err = d.GetIPByAddress(ctx, "1.1.1.1")
if err != nil {
t.Fatalf("get ip1 after requeue: %v", err)
}
if ip1.State != IPQueued || ip1.AttemptNumber != 2 || ip1.OverallResult != "" {
t.Fatalf("expected 1.1.1.1 requeued fresh, got %+v", ip1)
}
// Queue processing order should follow the submitted list order for the
// touched/new addresses: 4.4.4.4, 1.1.1.1, 3.3.3.3 (2.2.2.2 excluded,
// already claimed by validator-1, which is now busy — use a second
// idle validator to observe what's claimed next).
if err := d.AdminCreateValidator(ctx, "validator-2", "port-2"); err != nil {
t.Fatalf("create validator-2: %v", err)
}
claimed, err = d.ClaimNextQueued(ctx, "validator-2", time.Minute)
if err != nil {
t.Fatalf("claim after submit: %v", err)
}
if claimed == nil || claimed.IPAddress != "4.4.4.4" {
t.Fatalf("expected 4.4.4.4 to be claimed first per batch order, got %+v", claimed)
}
}
func TestSubmitIPsEmptyListRejected(t *testing.T) {
d, ctx := newTestDB(t)
if _, err := d.SubmitIPs(ctx, nil); !errors.Is(err, ErrValidation) {
t.Fatalf("expected ErrValidation for empty address list, got %v", err)
}
}
func TestCancelIP(t *testing.T) {
d, ctx := newTestDB(t)
if err := d.SeedQueue(ctx, []string{"1.2.3.4"}); err != nil {
t.Fatalf("seed queue: %v", err)
}
ip, err := d.GetIPByAddress(ctx, "1.2.3.4")
if err != nil {
t.Fatalf("get ip: %v", err)
}
// Cancel straight from queued.
if err := d.CancelIP(ctx, ip.ID); err != nil {
t.Fatalf("cancel queued ip: %v", err)
}
ip, _ = d.GetIP(ctx, ip.ID)
if ip.State != IPFailed || ip.OverallResult != ResultCancelled {
t.Fatalf("expected failed/cancelled, got state=%s result=%s", ip.State, ip.OverallResult)
}
// Cancelling an already-terminal IP is rejected.
if err := d.CancelIP(ctx, ip.ID); !errors.Is(err, ErrInvalidState) {
t.Fatalf("expected ErrInvalidState cancelling an already-finished ip, got %v", err)
}
// Cancel mid-check: claim, associate, then cancel — owner/lease/fip
// should be cleared by CancelIP itself (disassociation is the
// orchestrator's job, not this method's).
if err := d.SeedQueue(ctx, []string{"5.6.7.8"}); err != nil {
t.Fatalf("seed queue 2: %v", err)
}
if err := d.AdminCreateValidator(ctx, "validator-1", "port-1"); err != nil {
t.Fatalf("create validator: %v", err)
}
claimed, err := d.ClaimNextQueued(ctx, "validator-1", time.Minute)
if err != nil || claimed == nil {
t.Fatalf("claim: item=%+v err=%v", claimed, err)
}
if err := d.CancelIP(ctx, claimed.ID); err != nil {
t.Fatalf("cancel mid-check ip: %v", err)
}
got, _ := d.GetIP(ctx, claimed.ID)
if got.State != IPFailed || got.OverallResult != ResultCancelled || got.OwnerValidatorID != nil {
t.Fatalf("expected cancelled + owner cleared, got %+v", got)
}
}
+113
View File
@@ -222,6 +222,119 @@ func (d *DB) RequeueOrFail(ctx context.Context, ipID int64, validatorID string,
return tx.Commit()
}
// SubmitIPs is the single admin entry point for both "add new addresses to
// the queue" and "force a re-check of an already-finished address" — the
// same list can freely mix both. Addresses are processed in one
// transaction, in the order given:
//
// - unknown address: inserted as a new queued row.
// - address currently done/failed: reset to queued (new attempt,
// retry_count cleared — this is a deliberate admin-triggered restart,
// not a system retry).
// - address currently queued (not yet claimed): left in state=queued,
// only its sequence is updated.
// - address currently mid-check (assigning_fip / awaiting_self_check /
// checking / aggregating): left untouched entirely — never start a
// second concurrent check for the same address.
//
// Every touched/inserted address (new, requeued, or merely reordered) gets
// a sequence assigned in list order, continuing after the current max
// sequence, so a batch's relative order is preserved and, critically,
// resubmitting the same list later reproduces the same relative order.
func (d *DB) SubmitIPs(ctx context.Context, addresses []string) (SubmitIPsResult, error) {
var result SubmitIPsResult
if len(addresses) == 0 {
return result, fmt.Errorf("addresses must not be empty: %w", ErrValidation)
}
tx, err := d.BeginTx(ctx, nil)
if err != nil {
return result, err
}
defer tx.Rollback()
var base int
if err := tx.QueryRowContext(ctx, `SELECT COALESCE(MAX(sequence), -1) + 1 FROM ip_queue`).Scan(&base); err != nil {
return result, err
}
now := timeToDB(Now())
for i, addr := range addresses {
seq := base + i
var state string
err := tx.QueryRowContext(ctx, `SELECT state FROM ip_queue WHERE ip_address=?`, addr).Scan(&state)
switch {
case err == sql.ErrNoRows:
if _, err := tx.ExecContext(ctx, `
INSERT INTO ip_queue (ip_address, sequence, state, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
`, addr, seq, IPQueued, now, now); err != nil {
return result, fmt.Errorf("insert %s: %w", addr, err)
}
result.Added = append(result.Added, addr)
case err != nil:
return result, err
case state == IPDone || state == IPFailed:
if _, err := tx.ExecContext(ctx, `
UPDATE ip_queue SET
state=?, sequence=?, owner_validator_id=NULL, fip_id='', retry_count=0,
attempt_number=attempt_number+1, lease_expires_at=NULL, egress_complete=0,
site1_complete=0, site2_complete=0, site3_complete=0, overall_result='',
assigned_at=NULL, aggregated_at=NULL, fip_released_at=NULL, updated_at=?
WHERE ip_address=?
`, IPQueued, seq, now, addr); err != nil {
return result, fmt.Errorf("requeue %s: %w", addr, err)
}
result.Requeued = append(result.Requeued, addr)
case state == IPQueued:
if _, err := tx.ExecContext(ctx, `
UPDATE ip_queue SET sequence=?, updated_at=? WHERE ip_address=?
`, seq, now, addr); err != nil {
return result, fmt.Errorf("reorder %s: %w", addr, err)
}
result.Reordered = append(result.Reordered, addr)
default:
// Actively being processed (assigning_fip / awaiting_self_check
// / checking / aggregating) — leave it alone, don't duplicate.
result.SkippedInProgress = append(result.SkippedInProgress, addr)
}
}
if err := tx.Commit(); err != nil {
return result, err
}
return result, nil
}
// CancelIP force-stops a non-terminal IP: marks it failed with
// overall_result=cancelled. It does not disassociate the floating IP or
// free the owning validator — that requires the OpenStack client, so it's
// the caller's (orchestrator's) job to do that before/after calling this.
// Returns ErrInvalidState if the IP is already done/failed (including the
// race where aggregation finishes between the caller's read and this call —
// closed by the single-connection transactional UPDATE below).
func (d *DB) CancelIP(ctx context.Context, ipID int64) error {
now := timeToDB(Now())
res, err := d.ExecContext(ctx, `
UPDATE ip_queue SET
state=?, overall_result=?, aggregated_at=?, owner_validator_id=NULL, fip_id='',
lease_expires_at=NULL, updated_at=?
WHERE id=? AND state NOT IN (?, ?)
`, IPFailed, ResultCancelled, now, now, ipID, IPDone, IPFailed)
if err != nil {
return fmt.Errorf("cancel ip: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return fmt.Errorf("ip_id %d already finished: %w", ipID, ErrInvalidState)
}
return nil
}
func (d *DB) SetEgressComplete(ctx context.Context, ipID int64) error {
_, err := d.ExecContext(ctx, `UPDATE ip_queue SET egress_complete=1, updated_at=? WHERE id=?`, timeToDB(Now()), ipID)
return err
+97
View File
@@ -0,0 +1,97 @@
package db
import (
"context"
"database/sql"
"fmt"
)
// ListSites returns all configured prober sites, ordered by slot index.
func (d *DB) ListSites(ctx context.Context) ([]Site, error) {
rows, err := d.QueryContext(ctx, `
SELECT idx, site_id, created_at, updated_at FROM sites ORDER BY idx
`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Site
for rows.Next() {
var s Site
var createdAt, updatedAt string
if err := rows.Scan(&s.Index, &s.SiteID, &createdAt, &updatedAt); err != nil {
return nil, err
}
var err error
if s.CreatedAt, err = dbToTime(createdAt); err != nil {
return nil, err
}
if s.UpdatedAt, err = dbToTime(updatedAt); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
// GetSiteIndex resolves a configured site_id to its 1/2/3 slot index. It
// returns (0, nil) — not an error — if no site with that ID is configured,
// matching the "0 means unconfigured" convention used throughout the
// prober-facing handlers.
func (d *DB) GetSiteIndex(ctx context.Context, siteID string) (int, error) {
var idx int
err := d.QueryRowContext(ctx, `SELECT idx FROM sites WHERE site_id=?`, siteID).Scan(&idx)
if err == sql.ErrNoRows {
return 0, nil
}
if err != nil {
return 0, err
}
return idx, nil
}
// UpsertSite assigns (or renames) the site occupying the given slot. idx
// must be 1, 2, or 3 — the schema hard-caps the number of prober slots at
// three (see ip_queue.site{1,2,3}_complete). site_id must be unique across
// slots.
func (d *DB) UpsertSite(ctx context.Context, idx int, siteID string) error {
if idx < 1 || idx > 3 {
return fmt.Errorf("site index must be 1, 2, or 3, got %d: %w", idx, ErrValidation)
}
if siteID == "" {
return fmt.Errorf("site_id must not be empty: %w", ErrValidation)
}
var existingIdx int
err := d.QueryRowContext(ctx, `SELECT idx FROM sites WHERE site_id=? AND idx!=?`, siteID, idx).Scan(&existingIdx)
if err != nil && err != sql.ErrNoRows {
return err
}
if err == nil {
return fmt.Errorf("site_id %q already assigned to slot %d: %w", siteID, existingIdx, ErrConflict)
}
now := timeToDB(Now())
_, err = d.ExecContext(ctx, `
INSERT INTO sites (idx, site_id, created_at, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(idx) DO UPDATE SET site_id=excluded.site_id, updated_at=excluded.updated_at
`, idx, siteID, now, now)
if err != nil {
return fmt.Errorf("upsert site: %w", err)
}
return nil
}
// DeleteSite frees the given slot.
func (d *DB) DeleteSite(ctx context.Context, idx int) error {
res, err := d.ExecContext(ctx, `DELETE FROM sites WHERE idx=?`, idx)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return fmt.Errorf("site slot %d: %w", idx, ErrNotFound)
}
return nil
}
+109
View File
@@ -0,0 +1,109 @@
package db
import (
"context"
"database/sql"
"encoding/json"
"fmt"
)
// ListTargetGroups returns all configured target groups, ordered by name.
func (d *DB) ListTargetGroups(ctx context.Context) ([]TargetGroup, error) {
rows, err := d.QueryContext(ctx, `
SELECT group_name, targets, created_at, updated_at FROM target_groups ORDER BY group_name
`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []TargetGroup
for rows.Next() {
g, err := scanTargetGroup(rows)
if err != nil {
return nil, err
}
out = append(out, *g)
}
return out, rows.Err()
}
// GetTargetGroup returns a single target group by name.
func (d *DB) GetTargetGroup(ctx context.Context, name string) (*TargetGroup, error) {
row := d.QueryRowContext(ctx, `
SELECT group_name, targets, created_at, updated_at FROM target_groups WHERE group_name=?
`, name)
g, err := scanTargetGroup(row)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("target group %q: %w", name, ErrNotFound)
}
return g, err
}
// UpsertTargetGroup creates or replaces a target group's target list.
func (d *DB) UpsertTargetGroup(ctx context.Context, name string, targets []string) error {
if name == "" {
return fmt.Errorf("group name must not be empty: %w", ErrValidation)
}
if len(targets) == 0 {
return fmt.Errorf("target group %q must have at least one target: %w", name, ErrValidation)
}
payload, err := json.Marshal(targets)
if err != nil {
return err
}
now := timeToDB(Now())
_, err = d.ExecContext(ctx, `
INSERT INTO target_groups (group_name, targets, created_at, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(group_name) DO UPDATE SET targets=excluded.targets, updated_at=excluded.updated_at
`, name, string(payload), now, now)
if err != nil {
return fmt.Errorf("upsert target group: %w", err)
}
return nil
}
// DeleteTargetGroup removes a target group, refusing if any check type
// still references it.
func (d *DB) DeleteTargetGroup(ctx context.Context, name string) error {
checkTypes, err := d.ListCheckTypes(ctx)
if err != nil {
return err
}
for _, ct := range checkTypes {
for _, g := range ct.TargetGroups {
if g == name {
return fmt.Errorf("target group %q is used by check type %q: %w", name, ct.Name, ErrInUse)
}
}
}
res, err := d.ExecContext(ctx, `DELETE FROM target_groups WHERE group_name=?`, name)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return fmt.Errorf("target group %q: %w", name, ErrNotFound)
}
return nil
}
func scanTargetGroup(row rowScanner) (*TargetGroup, error) {
var g TargetGroup
var targetsJSON, createdAt, updatedAt string
if err := row.Scan(&g.Name, &targetsJSON, &createdAt, &updatedAt); err != nil {
return nil, err
}
if err := json.Unmarshal([]byte(targetsJSON), &g.Targets); err != nil {
return nil, fmt.Errorf("decode targets for group %q: %w", g.Name, err)
}
var err error
if g.CreatedAt, err = dbToTime(createdAt); err != nil {
return nil, err
}
if g.UpdatedAt, err = dbToTime(updatedAt); err != nil {
return nil, err
}
return &g, nil
}
+73
View File
@@ -148,6 +148,79 @@ func (d *DB) FreeValidator(ctx context.Context, validatorID string) error {
return err
}
// AdminCreateValidator registers a brand-new validator via the admin API.
// Unlike RegisterValidator (used by the agent's self-registration call),
// this refuses to upsert over an existing row.
func (d *DB) AdminCreateValidator(ctx context.Context, validatorID, osPortID string) error {
var exists int
err := d.QueryRowContext(ctx, `SELECT 1 FROM validators WHERE validator_id=?`, validatorID).Scan(&exists)
if err != nil && err != sql.ErrNoRows {
return err
}
if err == nil {
return fmt.Errorf("validator %q: %w", validatorID, ErrConflict)
}
now := timeToDB(Now())
_, err = d.ExecContext(ctx, `
INSERT INTO validators (validator_id, hostname, os_port_id, agent_version, state, created_at, updated_at)
VALUES (?, '', ?, '', ?, ?, ?)
`, validatorID, osPortID, ValidatorIdle, now, now)
if err != nil {
return fmt.Errorf("create validator: %w", err)
}
return nil
}
// AdminUpdateValidatorPort updates an existing validator's Neutron port ID.
func (d *DB) AdminUpdateValidatorPort(ctx context.Context, validatorID, osPortID string) error {
res, err := d.ExecContext(ctx, `
UPDATE validators SET os_port_id=?, updated_at=? WHERE validator_id=?
`, osPortID, timeToDB(Now()), validatorID)
if err != nil {
return fmt.Errorf("update validator port: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return fmt.Errorf("validator %q: %w", validatorID, ErrNotFound)
}
return nil
}
// DeleteValidator removes a validator, refusing if it currently owns an IP.
// ip_queue.owner_validator_id is a permanent historical record (set once an
// IP is claimed, never cleared on completion — see RequeueOrFail/
// ReleaseFIP), so any validator that has ever processed an IP would
// otherwise violate the FK constraint on delete; those historical
// references are cleared in the same transaction once we've confirmed the
// validator isn't currently busy.
func (d *DB) DeleteValidator(ctx context.Context, validatorID string) error {
tx, err := d.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var currentIPID sql.NullInt64
err = tx.QueryRowContext(ctx, `SELECT current_ip_id FROM validators WHERE validator_id=?`, validatorID).Scan(&currentIPID)
if err == sql.ErrNoRows {
return fmt.Errorf("validator %q: %w", validatorID, ErrNotFound)
}
if err != nil {
return err
}
if currentIPID.Valid {
return fmt.Errorf("validator %q owns ip_id %d: %w", validatorID, currentIPID.Int64, ErrBusy)
}
if _, err := tx.ExecContext(ctx, `UPDATE ip_queue SET owner_validator_id=NULL WHERE owner_validator_id=?`, validatorID); err != nil {
return fmt.Errorf("clear historical ip_queue references: %w", err)
}
if _, err := tx.ExecContext(ctx, `DELETE FROM validators WHERE validator_id=?`, validatorID); err != nil {
return fmt.Errorf("delete validator: %w", err)
}
return tx.Commit()
}
type rowScanner interface {
Scan(dest ...interface{}) error
}