admin control features and admin dashboard
This commit is contained in:
1 parent
c630f13c57
commit
37910e410b
69 files changed
+4959
-400
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(¤tIPID)
|
||||
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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user