Manage external site-prober actions va API and Dashboard

This commit is contained in:
ayurishchev committed 2026-08-26 19:45:48 +03:00
1 parent f22ad569b6
commit 42f584dd3c
28 files changed
+717 -32

No files matched your search

+26
View File
@@ -2,6 +2,7 @@ package db
import (
"context"
"encoding/json"
"fmt"
"cloudipvalidator/internal/config"
@@ -36,6 +37,9 @@ func (d *DB) BootstrapFromConfig(ctx context.Context, cfg *config.ControlAPI) er
if err := d.bootstrapSettings(ctx, cfg.Orchestrator.FIPSettleSeconds); err != nil {
return fmt.Errorf("bootstrap settings: %w", err)
}
if err := d.bootstrapInboundChecks(ctx, cfg.Inbound.Ports, cfg.Inbound.ICMP); err != nil {
return fmt.Errorf("bootstrap inbound checks: %w", err)
}
if err := d.SeedQueue(ctx, cfg.IPAddresses); err != nil {
return fmt.Errorf("seed ip queue: %w", err)
}
@@ -120,3 +124,25 @@ func (d *DB) bootstrapSettings(ctx context.Context, fipSettleSeconds int) error
`, fipSettleSeconds, now, now)
return err
}
func (d *DB) bootstrapInboundChecks(ctx context.Context, ports []int, icmp bool) error {
var count int
if err := d.QueryRowContext(ctx, `SELECT COUNT(*) FROM inbound_checks_settings`).Scan(&count); err != nil {
return err
}
if count > 0 {
return nil
}
if ports == nil {
ports = []int{}
}
payload, err := json.Marshal(ports)
if err != nil {
return err
}
now := timeToDB(Now())
_, err = d.ExecContext(ctx, `
INSERT INTO inbound_checks_settings (id, ports, icmp, created_at, updated_at) VALUES (1, ?, ?, ?, ?)
`, string(payload), icmp, now, now)
return err
}
+4
View File
@@ -22,6 +22,9 @@ var dynamicConfigSchema string
//go:embed migrations/0003_fip_settle_delay.sql
var fipSettleDelaySchema string
//go:embed migrations/0004_inbound_checks_admin.sql
var inboundChecksAdminSchema 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
@@ -33,6 +36,7 @@ var migrations = []struct {
{1, initSchema},
{2, dynamicConfigSchema},
{3, fipSettleDelaySchema},
{4, inboundChecksAdminSchema},
}
type DB struct {
@@ -0,0 +1,16 @@
-- Support for admin-API-configurable prober check types (see docs/USAGE.md,
-- "Управление типами проверок пробера"). Previously orchestrator.inbound_checks
-- was a YAML-only value baked into the process at startup.
-- Singleton row, deliberately separate from `settings`: unlike
-- fip_settle_seconds, inbound checks need no cross-field validation against
-- other orchestrator config, and ports is a list rather than a scalar, so it
-- doesn't fit the "add a column to settings" guidance left in
-- 0003_fip_settle_delay.sql.
CREATE TABLE inbound_checks_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
ports TEXT NOT NULL, -- JSON array of int, e.g. "[22,80,443,8080]"
icmp BOOLEAN NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
+10
View File
@@ -171,3 +171,13 @@ type Settings struct {
CreatedAt time.Time
UpdatedAt time.Time
}
// InboundChecksSettings is the singleton row describing what the prober
// checks on every site for every in-flight IP (TCP ports + optional ICMP).
// Admin-configurable at runtime (see queries_inbound.go).
type InboundChecksSettings struct {
Ports []int
ICMP bool
CreatedAt time.Time
UpdatedAt time.Time
}
+65
View File
@@ -0,0 +1,65 @@
package db
import (
"context"
"encoding/json"
"fmt"
)
// GetInboundChecks returns the singleton inbound-checks settings row.
// BootstrapFromConfig guarantees it exists before any other code path can
// observe it, so sql.ErrNoRows here would indicate a bootstrap bug, not a
// normal condition.
func (d *DB) GetInboundChecks(ctx context.Context) (InboundChecksSettings, error) {
var s InboundChecksSettings
var portsJSON, createdAt, updatedAt string
err := d.QueryRowContext(ctx, `
SELECT ports, icmp, created_at, updated_at FROM inbound_checks_settings WHERE id=1
`).Scan(&portsJSON, &s.ICMP, &createdAt, &updatedAt)
if err != nil {
return InboundChecksSettings{}, err
}
if err := json.Unmarshal([]byte(portsJSON), &s.Ports); err != nil {
return InboundChecksSettings{}, fmt.Errorf("decode inbound check ports: %w", err)
}
if s.Ports == nil {
s.Ports = []int{}
}
if s.CreatedAt, err = dbToTime(createdAt); err != nil {
return InboundChecksSettings{}, err
}
if s.UpdatedAt, err = dbToTime(updatedAt); err != nil {
return InboundChecksSettings{}, err
}
return s, nil
}
// SetInboundChecks persists a new prober check configuration. Validation is
// purely local (port range, no duplicates) — unlike fip_settle_seconds,
// there's no cross-field dependency on other orchestrator config, so this
// is called directly by the HTTP handler without going through
// orchestrator.Orchestrator.
func (d *DB) SetInboundChecks(ctx context.Context, ports []int, icmp bool) error {
seen := make(map[int]bool, len(ports))
for _, p := range ports {
if p < 1 || p > 65535 {
return fmt.Errorf("port %d out of range 1..65535: %w", p, ErrValidation)
}
if seen[p] {
return fmt.Errorf("duplicate port %d: %w", p, ErrValidation)
}
seen[p] = true
}
if ports == nil {
ports = []int{}
}
payload, err := json.Marshal(ports)
if err != nil {
return err
}
now := timeToDB(Now())
_, err = d.ExecContext(ctx, `
UPDATE inbound_checks_settings SET ports=?, icmp=?, updated_at=? WHERE id=1
`, string(payload), icmp, now)
return err
}
+103
View File
@@ -0,0 +1,103 @@
package db
import (
"errors"
"reflect"
"testing"
"cloudipvalidator/internal/config"
)
func TestBootstrapInboundChecksSeedsOnceFromConfig(t *testing.T) {
d, ctx := newTestDB(t)
cfg := &config.ControlAPI{Inbound: config.InboundConfig{Ports: []int{22, 80}, ICMP: true}}
if err := d.BootstrapFromConfig(ctx, cfg); err != nil {
t.Fatalf("first bootstrap: %v", err)
}
settings, err := d.GetInboundChecks(ctx)
if err != nil {
t.Fatalf("get inbound checks: %v", err)
}
if !reflect.DeepEqual(settings.Ports, []int{22, 80}) || !settings.ICMP {
t.Fatalf("expected seeded {[22 80] true}, got %+v", settings)
}
// A second bootstrap with a different YAML value must not overwrite the
// now-non-empty table — same "DB is source of truth once seeded"
// semantics as validators/sites/targets/check_types/settings.
cfg2 := &config.ControlAPI{Inbound: config.InboundConfig{Ports: []int{443}, ICMP: false}}
if err := d.BootstrapFromConfig(ctx, cfg2); err != nil {
t.Fatalf("second bootstrap: %v", err)
}
settings, err = d.GetInboundChecks(ctx)
if err != nil {
t.Fatalf("get inbound checks after second bootstrap: %v", err)
}
if !reflect.DeepEqual(settings.Ports, []int{22, 80}) || !settings.ICMP {
t.Fatalf("expected YAML to be ignored on non-empty table, got %+v", settings)
}
}
func TestSetInboundChecksRejectsPortOutOfRange(t *testing.T) {
d, ctx := newTestDB(t)
if err := d.BootstrapFromConfig(ctx, &config.ControlAPI{}); err != nil {
t.Fatalf("bootstrap: %v", err)
}
for _, p := range []int{0, -1, 65536, 70000} {
if err := d.SetInboundChecks(ctx, []int{p}, false); !errors.Is(err, ErrValidation) {
t.Fatalf("port %d: expected ErrValidation, got %v", p, err)
}
}
}
func TestSetInboundChecksRejectsDuplicatePorts(t *testing.T) {
d, ctx := newTestDB(t)
if err := d.BootstrapFromConfig(ctx, &config.ControlAPI{}); err != nil {
t.Fatalf("bootstrap: %v", err)
}
if err := d.SetInboundChecks(ctx, []int{22, 80, 22}, false); !errors.Is(err, ErrValidation) {
t.Fatalf("expected ErrValidation for duplicate port, got %v", err)
}
}
func TestGetSetInboundChecksRoundTrip(t *testing.T) {
d, ctx := newTestDB(t)
if err := d.BootstrapFromConfig(ctx, &config.ControlAPI{}); err != nil {
t.Fatalf("bootstrap: %v", err)
}
before, err := d.GetInboundChecks(ctx)
if err != nil {
t.Fatalf("get inbound checks: %v", err)
}
if len(before.Ports) != 0 || before.ICMP {
t.Fatalf("expected default {[] false}, got %+v", before)
}
if err := d.SetInboundChecks(ctx, []int{22, 443, 8080}, true); err != nil {
t.Fatalf("set inbound checks: %v", err)
}
after, err := d.GetInboundChecks(ctx)
if err != nil {
t.Fatalf("get inbound checks after set: %v", err)
}
if !reflect.DeepEqual(after.Ports, []int{22, 443, 8080}) || !after.ICMP {
t.Fatalf("expected {[22 443 8080] true}, got %+v", after)
}
if after.UpdatedAt.Before(before.UpdatedAt) {
t.Fatalf("expected updated_at not to go backwards, before=%v after=%v", before.UpdatedAt, after.UpdatedAt)
}
// Setting an empty port list + icmp:false is legal — it's the "disable
// all inbound checks without touching sites" configuration.
if err := d.SetInboundChecks(ctx, nil, false); err != nil {
t.Fatalf("set empty inbound checks: %v", err)
}
cleared, err := d.GetInboundChecks(ctx)
if err != nil {
t.Fatalf("get inbound checks after clear: %v", err)
}
if len(cleared.Ports) != 0 || cleared.ICMP {
t.Fatalf("expected {[] false} after clearing, got %+v", cleared)
}
}