Runs (migration 0011): a run groups the cycles of one launch. It opens when an address enters an idle queue, takes everything submitted or re-checked while it is open and is finalized when all its addresses are done; a re-check after that opens a new run, so results of different runs never mix. check_runs, run_results (one result per address and run, with the verdict and the expected and stored check counts), subnets, run_id on ip_queue and checks. Existing data is split into runs at pauses of more than an hour; ingress checks get the validator that held the address (also at write time from now on). Analytics (internal/analytics): figures computed from the stored checks of the latest cycle of each address in the run, as facts next to the verdict: summary, reasons of partial, data quality, subnets, targets and the subnet x target matrix by check type, ingress by site, error classes, validators, and the address lists behind the indicators and error classes. API: analytics runs, report, lists (JSON or CSV), subnet list; run and subnet filters for the registry. Dashboard: /analytics matching the approved mockup (run selector, indicators with address lists and CSV, error-class dialogs, drill-down to the registry), subnet list on /settings. Sidebar: the control-api link state, theme toggle and logout moved to the top, the three dots next to the logo removed, sections grouped. Rebuilt bin/control-api and bin/admin-dashboard to match. Plan, summary and the updated README, API, USAGE, DASHBOARD and ADMIN_CLEANUP docs are in docs/. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
390 lines
14 KiB
Go
390 lines
14 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func submit(t *testing.T, d *DB, kind string, addrs ...string) {
|
|
t.Helper()
|
|
if _, err := d.SubmitIPsAs(context.Background(), addrs, kind); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func finish(t *testing.T, d *DB, addr, verdict string, expected int) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
ip, err := d.GetIPByAddress(ctx, addr)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := d.FinishIPExpected(ctx, ip.ID, verdict, expected); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func runs(t *testing.T, d *DB) []RunSummary {
|
|
t.Helper()
|
|
r, err := d.ListRuns(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return r
|
|
}
|
|
|
|
// An address entering an idle queue opens a run; everything submitted while it
|
|
// is open joins it; it is finalized when the last address is done.
|
|
func TestRunOpensJoinsAndFinalizes(t *testing.T) {
|
|
d, ctx := newTestDB(t)
|
|
submit(t, d, RunAuto, "1.1.1.1", "2.2.2.2")
|
|
submit(t, d, RunManual, "3.3.3.3") // joins the open run, kind stays auto
|
|
|
|
rs := runs(t, d)
|
|
if len(rs) != 1 || rs[0].State != RunOpen || rs[0].Kind != RunAuto || rs[0].Total != 3 || rs[0].Pending != 3 {
|
|
t.Fatalf("expected one open auto run with 3 pending rows: %+v", rs)
|
|
}
|
|
a, _ := d.GetIPByAddress(ctx, "1.1.1.1")
|
|
c, _ := d.GetIPByAddress(ctx, "3.3.3.3")
|
|
if a.ID == 0 || c.ID == 0 {
|
|
t.Fatal("rows missing")
|
|
}
|
|
|
|
finish(t, d, "1.1.1.1", ResultPass, 22)
|
|
finish(t, d, "2.2.2.2", ResultPartial, 22)
|
|
if rs = runs(t, d); rs[0].State != RunOpen || rs[0].Addresses != 2 || rs[0].Pending != 1 {
|
|
t.Fatalf("one address still pending, the run stays open: %+v", rs[0])
|
|
}
|
|
finish(t, d, "3.3.3.3", ResultFail, -1)
|
|
|
|
rs = runs(t, d)
|
|
if rs[0].State != RunFinalized || rs[0].FinalizedAt == nil || rs[0].Pass != 1 || rs[0].Partial != 1 || rs[0].Fail != 1 || rs[0].Addresses != 3 {
|
|
t.Fatalf("expected a finalized run with the three verdicts: %+v", rs[0])
|
|
}
|
|
res, err := d.ListRunResults(ctx, rs[0].ID)
|
|
if err != nil || len(res) != 3 {
|
|
t.Fatalf("results: %v %v", res, err)
|
|
}
|
|
byIP := map[string]RunResult{}
|
|
for _, r := range res {
|
|
byIP[r.IPAddress] = r
|
|
}
|
|
if byIP["1.1.1.1"].ExpectedChecks != 22 || byIP["3.3.3.3"].ExpectedChecks != -1 || byIP["2.2.2.2"].Verdict != ResultPartial {
|
|
t.Fatalf("results: %+v", byIP)
|
|
}
|
|
}
|
|
|
|
// A re-check after the run is finalized opens a new run and leaves the old one
|
|
// as it was; the old cycle's checks keep their run.
|
|
func TestRecheckAfterFinalizeOpensNewRun(t *testing.T) {
|
|
d, ctx := newTestDB(t)
|
|
ip := checkingIP(t, d, "1.1.1.1")
|
|
if _, err := d.UpsertCheckIfOpen(ctx, checkOf(ip, "icmp", true)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
finish(t, d, "1.1.1.1", ResultPass, 1)
|
|
first := runs(t, d)[0]
|
|
if first.State != RunFinalized {
|
|
t.Fatalf("expected finalized: %+v", first)
|
|
}
|
|
|
|
submit(t, d, RunManual, "1.1.1.1") // re-check of a finished address
|
|
rs := runs(t, d)
|
|
if len(rs) != 2 || rs[0].State != RunOpen || rs[0].ID == first.ID {
|
|
t.Fatalf("a re-check after the run ended must open a new run: %+v", rs)
|
|
}
|
|
ip, _ = d.GetIPByAddress(ctx, "1.1.1.1")
|
|
if err := d.SetChecking(ctx, ip.ID, time.Minute); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ip, _ = d.GetIP(ctx, ip.ID)
|
|
if _, err := d.UpsertCheckIfOpen(ctx, checkOf(ip, "icmp", false)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
finish(t, d, "1.1.1.1", ResultFail, 1)
|
|
|
|
var n1, n2 int
|
|
d.QueryRowContext(ctx, `SELECT COUNT(*) FROM checks WHERE run_id=?`, first.ID).Scan(&n1)
|
|
d.QueryRowContext(ctx, `SELECT COUNT(*) FROM checks WHERE run_id=?`, rs[0].ID).Scan(&n2)
|
|
if n1 != 1 || n2 != 1 {
|
|
t.Fatalf("each run keeps its own cycle's check: %d %d", n1, n2)
|
|
}
|
|
old, _ := d.ListRunResults(ctx, first.ID)
|
|
cur, _ := d.ListRunResults(ctx, rs[0].ID)
|
|
if len(old) != 1 || old[0].Verdict != ResultPass || old[0].CycleID != 1 || len(cur) != 1 || cur[0].Verdict != ResultFail || cur[0].CycleID != 2 {
|
|
t.Fatalf("results must not cross: old=%+v cur=%+v", old, cur)
|
|
}
|
|
// The checks of a run are the ones of its result cycle, nothing else.
|
|
var got []RunCheck
|
|
if err := d.EachRunCheck(ctx, first.ID, func(c RunCheck) { got = append(got, c) }); err != nil || len(got) != 1 || !got[0].Success {
|
|
t.Fatalf("run 1 checks: %+v %v", got, err)
|
|
}
|
|
}
|
|
|
|
// A re-check while the run is still open joins it and replaces the address's
|
|
// result, so a run holds one result per address.
|
|
func TestRecheckInsideOpenRunReplacesResult(t *testing.T) {
|
|
d, ctx := newTestDB(t)
|
|
submit(t, d, RunManual, "1.1.1.1", "2.2.2.2")
|
|
finish(t, d, "1.1.1.1", ResultFail, 1)
|
|
submit(t, d, RunManual, "1.1.1.1") // while 2.2.2.2 is still pending
|
|
finish(t, d, "1.1.1.1", ResultPass, 1)
|
|
finish(t, d, "2.2.2.2", ResultPass, 1)
|
|
|
|
rs := runs(t, d)
|
|
if len(rs) != 1 || rs[0].Addresses != 2 || rs[0].Pass != 2 || rs[0].Fail != 0 {
|
|
t.Fatalf("expected one run with the latest verdicts: %+v", rs)
|
|
}
|
|
res, _ := d.ListRunResults(ctx, rs[0].ID)
|
|
for _, r := range res {
|
|
if r.IPAddress == "1.1.1.1" && r.CycleID != 2 {
|
|
t.Fatalf("the re-checked address must show its latest cycle: %+v", r)
|
|
}
|
|
}
|
|
if n, err := d.CountRecheckedInRun(ctx, rs[0].ID); err != nil || n != 0 {
|
|
t.Fatalf("no checks stored, so no re-check counted: %d %v", n, err)
|
|
}
|
|
}
|
|
|
|
func TestRunEndsWhenQueueIsClearedOrDeleted(t *testing.T) {
|
|
d, ctx := newTestDB(t)
|
|
submit(t, d, RunManual, "1.1.1.1", "2.2.2.2")
|
|
finish(t, d, "1.1.1.1", ResultPass, 1)
|
|
if _, err := d.ClearAllIPs(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rs := runs(t, d)
|
|
if len(rs) != 1 || rs[0].State != RunFinalized || rs[0].Addresses != 1 {
|
|
t.Fatalf("clearing ends the run with what it has: %+v", rs)
|
|
}
|
|
|
|
// The next submission is a new run, not a join of the ended one.
|
|
submit(t, d, RunManual, "3.3.3.3")
|
|
if rs = runs(t, d); len(rs) != 2 || rs[0].State != RunOpen {
|
|
t.Fatalf("expected a new open run: %+v", rs)
|
|
}
|
|
// A run with no result at all disappears when its rows are deleted.
|
|
ip, _ := d.GetIPByAddress(ctx, "3.3.3.3")
|
|
if err := d.DeleteIP(ctx, ip.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rs = runs(t, d); len(rs) != 1 {
|
|
t.Fatalf("an empty run must be dropped: %+v", rs)
|
|
}
|
|
}
|
|
|
|
func TestCancelAndRetryFailureRecordResults(t *testing.T) {
|
|
d, ctx := newTestDB(t)
|
|
submit(t, d, RunManual, "1.1.1.1", "2.2.2.2")
|
|
a, _ := d.GetIPByAddress(ctx, "1.1.1.1")
|
|
if err := d.CancelIP(ctx, a.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b, _ := d.GetIPByAddress(ctx, "2.2.2.2")
|
|
if err := d.RequeueOrFail(ctx, b.ID, "", 0); err != nil { // retries exhausted at once
|
|
t.Fatal(err)
|
|
}
|
|
rs := runs(t, d)
|
|
if rs[0].State != RunFinalized || rs[0].Cancelled != 1 || rs[0].Fail != 1 {
|
|
t.Fatalf("cancelled and failed addresses are results of the run: %+v", rs[0])
|
|
}
|
|
}
|
|
|
|
// Ingress checks name the validator that held the address.
|
|
func TestIngressCheckTakesValidatorOfTheAddress(t *testing.T) {
|
|
d, ctx := newTestDB(t)
|
|
if err := d.RegisterValidator(ctx, "validator-7", "host", "port", "v"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ip := checkingIP(t, d, "1.1.1.1")
|
|
if _, err := d.ExecContext(ctx, `UPDATE ip_queue SET owner_validator_id='validator-7' WHERE id=?`, ip.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := d.UpsertCheckIfOpen(ctx, checkOf(ip, "icmp", true)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var v string
|
|
if err := d.QueryRowContext(ctx, `SELECT validator_id FROM checks WHERE ip_id=?`, ip.ID).Scan(&v); err != nil || v != "validator-7" {
|
|
t.Fatalf("validator of an ingress check = %q err=%v", v, err)
|
|
}
|
|
// An explicit validator (egress checks) is kept.
|
|
c := checkOf(ip, "https", true)
|
|
c.Source, c.ValidatorID, c.Target = SourceEgress, "validator-9", "https://x"
|
|
if _, err := d.UpsertCheckIfOpen(ctx, c); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := d.QueryRowContext(ctx, `SELECT validator_id FROM checks WHERE ip_id=? AND source='egress'`, ip.ID).Scan(&v); err != nil || v != "validator-9" {
|
|
t.Fatalf("egress validator = %q err=%v", v, err)
|
|
}
|
|
}
|
|
|
|
func TestSubnetsReplaceAndValidate(t *testing.T) {
|
|
d, ctx := newTestDB(t)
|
|
if err := d.ReplaceSubnets(ctx, []Subnet{{CIDR: "10.1.2.3/24", Label: "a"}, {CIDR: "10.0.0.0/8"}, {CIDR: "10.1.2.0/24", Label: "dup"}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := d.ListSubnets(ctx)
|
|
if err != nil || len(got) != 2 || got[0].CIDR != "10.0.0.0/8" || got[1].CIDR != "10.1.2.0/24" {
|
|
t.Fatalf("subnets must be canonical, de-duplicated and sorted by prefix: %+v %v", got, err)
|
|
}
|
|
if err := d.ReplaceSubnets(ctx, []Subnet{{CIDR: "nonsense"}}); !errors.Is(err, ErrValidation) {
|
|
t.Fatalf("expected ErrValidation, got %v", err)
|
|
}
|
|
if got, _ := d.ListSubnets(ctx); len(got) != 2 {
|
|
t.Fatalf("a rejected list must leave the old one: %+v", got)
|
|
}
|
|
if err := d.ReplaceSubnets(ctx, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, _ := d.ListSubnets(ctx); len(got) != 0 {
|
|
t.Fatalf("an empty list clears: %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestRegistryFilterByRunAndSubnet(t *testing.T) {
|
|
d, ctx := newTestDB(t)
|
|
submit(t, d, RunManual, "10.0.0.1", "10.0.0.2", "10.0.1.1")
|
|
for _, a := range []string{"10.0.0.1", "10.0.0.2", "10.0.1.1"} {
|
|
finish(t, d, a, ResultPass, -1)
|
|
}
|
|
runID := runs(t, d)[0].ID
|
|
submit(t, d, RunManual, "10.0.0.2") // second run holds only this address
|
|
finish(t, d, "10.0.0.2", ResultFail, -1)
|
|
secondID := runs(t, d)[0].ID
|
|
|
|
count := func(f RegistryFilter) int {
|
|
t.Helper()
|
|
_, total, err := d.ListRegistryPage(ctx, f, 50, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return total
|
|
}
|
|
if n := count(RegistryFilter{RunID: runID}); n != 3 {
|
|
t.Errorf("run 1: %d", n)
|
|
}
|
|
if n := count(RegistryFilter{RunID: secondID}); n != 1 {
|
|
t.Errorf("run 2: %d", n)
|
|
}
|
|
if n := count(RegistryFilter{Subnet: "10.0.0.0/24"}); n != 2 {
|
|
t.Errorf("subnet /24: %d", n)
|
|
}
|
|
if n := count(RegistryFilter{RunID: secondID, Subnet: "10.0.1.0/24"}); n != 0 {
|
|
t.Errorf("run 2 and the other subnet: %d", n)
|
|
}
|
|
if n := count(RegistryFilter{Subnet: "192.168.0.0/16"}); n != 0 {
|
|
t.Errorf("subnet with no address: %d", n)
|
|
}
|
|
if _, _, err := d.ListRegistryPage(ctx, RegistryFilter{Subnet: "x"}, 10, 0); !errors.Is(err, ErrValidation) {
|
|
t.Errorf("bad subnet: %v", err)
|
|
}
|
|
}
|
|
|
|
// Migration 0011 on a database of version 10: runs are cut at pauses of more
|
|
// than an hour, results come from the queue row or the checks, ingress checks
|
|
// get their validator from the fip_associated event, and live queue rows
|
|
// without a run are adopted into an open run when the database is opened.
|
|
func TestMigration0011BuildsRunsFromExistingData(t *testing.T) {
|
|
ctx := context.Background()
|
|
path := filepath.Join(t.TempDir(), "old.db")
|
|
raw, err := sql.Open("sqlite", path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw.SetMaxOpenConns(1)
|
|
for _, m := range migrations {
|
|
if m.version > 10 {
|
|
break
|
|
}
|
|
if _, err := raw.ExecContext(ctx, m.sql); err != nil {
|
|
t.Fatalf("migration %d: %v", m.version, err)
|
|
}
|
|
}
|
|
raw.ExecContext(ctx, `PRAGMA user_version=10`)
|
|
exec := func(q string, args ...any) {
|
|
t.Helper()
|
|
if _, err := raw.ExecContext(ctx, q, args...); err != nil {
|
|
t.Fatalf("seed: %v\n%s", err, q)
|
|
}
|
|
}
|
|
const ts = "2026-10-02T13:00:00Z"
|
|
for i, ip := range []string{"1.1.1.1", "2.2.2.2", "3.3.3.3"} {
|
|
exec(`INSERT INTO ip_registry (id, ip_address, first_seen_at, last_seen_at, next_cycle, created_at, updated_at) VALUES (?, ?, ?, ?, 3, ?, ?)`, i+1, ip, ts, ts, ts, ts)
|
|
}
|
|
// 1.1.1.1 and 2.2.2.2 finish minutes apart (run 1); 1.1.1.1 is checked
|
|
// again three hours later (run 2). 3.3.3.3 is still in the queue.
|
|
exec(`INSERT INTO ip_queue (id, ip_address, sequence, state, overall_result, aggregated_at, registry_id, cycle_id, created_at, updated_at)
|
|
VALUES (1, '1.1.1.1', 1, 'done', 'fail', '2026-10-02T16:00:30Z', 1, 2, ?, ?), (2, '2.2.2.2', 2, 'done', 'pass', '2026-10-02T13:05:30Z', 2, 1, ?, ?),
|
|
(3, '3.3.3.3', 3, 'checking', '', NULL, 3, 1, ?, ?)`, ts, ts, ts, ts, ts, ts)
|
|
chk := func(reg, cyc int, src, typ string, ok int, at string) {
|
|
exec(`INSERT INTO checks (registry_id, cycle_id, ip_id, ip_address, attempt_number, validator_id, source, check_type, target, success, checked_at, created_at)
|
|
VALUES (?, ?, ?, 'x', 1, '', ?, ?, 't', ?, ?, ?)`, reg, cyc, reg, src, typ, ok, at, at)
|
|
}
|
|
chk(1, 1, "egress", "https", 1, "2026-10-02T13:00:10Z")
|
|
chk(1, 1, "inbound-site-1", "icmp", 1, "2026-10-02T13:00:20Z")
|
|
chk(2, 1, "egress", "https", 1, "2026-10-02T13:05:00Z")
|
|
chk(1, 2, "egress", "https", 0, "2026-10-02T16:00:10Z")
|
|
exec(`INSERT INTO events (source_type, source_id, ip_id, event_type, payload, occurred_at, registry_id, cycle_id) VALUES
|
|
('control-api', '', 1, 'fip_associated', '{"fip_id":"f","validator_id":"vkiplab-v5"}', ?, 1, 1),
|
|
('control-api', '', 2, 'aggregated', '{"result":"pass","checks":1,"passed":1,"missing":1}', ?, 2, 1)`, ts, ts)
|
|
raw.Close()
|
|
|
|
d, err := Open(ctx, path)
|
|
if err != nil {
|
|
t.Fatalf("open (runs migration 11): %v", err)
|
|
}
|
|
defer d.Close()
|
|
|
|
rs := runs(t, d)
|
|
// run 1 and run 2 from the checks, plus the open run that adopted 3.3.3.3
|
|
if len(rs) != 3 {
|
|
t.Fatalf("expected 3 runs, got %+v", rs)
|
|
}
|
|
var first, second, open RunSummary
|
|
for _, r := range rs {
|
|
switch {
|
|
case r.State == RunOpen:
|
|
open = r
|
|
case r.Addresses == 2:
|
|
first = r
|
|
default:
|
|
second = r
|
|
}
|
|
}
|
|
if first.Pass != 2 || first.Fail != 0 || second.Fail != 1 || second.Addresses != 1 || open.Total != 1 {
|
|
t.Fatalf("runs: first=%+v second=%+v open=%+v", first, second, open)
|
|
}
|
|
res, _ := d.ListRunResults(ctx, first.ID)
|
|
for _, r := range res {
|
|
if r.IPAddress == "1.1.1.1" && (r.CycleID != 1 || r.Verdict != ResultPass || !r.Derived) {
|
|
// the queue row holds the later cycle, so this one is derived from its checks
|
|
t.Errorf("1.1.1.1 in the first run: %+v", r)
|
|
}
|
|
if r.IPAddress == "2.2.2.2" && (r.ExpectedChecks != 2 || r.RecordedChecks != 1 || r.Verdict != ResultPass || r.Derived) {
|
|
t.Errorf("result from the aggregated event: %+v", r)
|
|
}
|
|
}
|
|
res2, _ := d.ListRunResults(ctx, second.ID)
|
|
if len(res2) != 1 || res2[0].CycleID != 2 || res2[0].Verdict != ResultFail || res2[0].Derived {
|
|
t.Errorf("second run result: %+v", res2)
|
|
}
|
|
var v string
|
|
if err := d.QueryRowContext(ctx, `SELECT validator_id FROM checks WHERE source='inbound-site-1'`).Scan(&v); err != nil || v != "vkiplab-v5" {
|
|
t.Errorf("ingress validator = %q err=%v", v, err)
|
|
}
|
|
var unset int
|
|
d.QueryRowContext(ctx, `SELECT COUNT(*) FROM checks WHERE run_id IS NULL`).Scan(&unset)
|
|
if unset != 0 {
|
|
t.Errorf("%d checks without a run", unset)
|
|
}
|
|
var ver int
|
|
d.QueryRowContext(ctx, `PRAGMA user_version`).Scan(&ver)
|
|
if ver != 11 {
|
|
t.Errorf("user_version = %d", ver)
|
|
}
|
|
}
|