Files
cloud-ip-validator/internal/httpapi/handlers_scale_test.go
T
ayurishchevandClaude Sonnet 5.5 aff8fe38b5 Scan floating IPs in the background, page by page, so thousands of addresses work
The "Scan Floating IP" button failed with a client timeout: the project now
holds ~6.4k floating IPs and the scan listed them all in one unpaginated,
timeout-less Neutron request on the HTTP request context.

openstack: ListFreeFloatingIPs reads marker-based pages (fields= keeps them
small) with per-page retry/backoff on transport errors, 5xx and 429, and every
request now has a timeout (also ends hangs inside the orchestrator tick).

orchestrator: the scan is a single-flight background job on the process
context with progress (clearing/listing/enqueuing/done/error), dry_run, full
discovery before anything is enqueued, then SubmitIPs in chunks of 500 in
ascending IP order; a failed read leaves the queue untouched. The auto-cycle
gets a "scanning" phase that polls the job, so the control loop and
autoCycleMu are never held across OpenStack/DB work; it recovers after a
restart and waits for (instead of adopting) a scan started by someone else.

db: migration 0009 (indexes), paged ListIPsPage/ListRegistryPage, GROUP BY
counters, EXISTS completion check, set-based ClearAllIPs.

API: POST /admin/ips/scan -> 202 (dry_run, wait), GET /admin/ips/scan, paging
and filters on /admin/ips and /admin/registry (bare arrays without limit),
results_by_overall in /admin/status.

dashboard: scan progress panel and dry-run button, paginated /ips and
/registry with server-side filters, Overview on counters and capped lists
with progress/ETA, "select all N by filter", hx-params fix for per-row
buttons, real counts in confirmations.

Also: docs (API, USAGE, DASHBOARD, README), plan and review under
docs/changes/, bin/ rebuilt with new SHA256SUMS.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 19:31:11 +03:00

365 lines
13 KiB
Go

package httpapi
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"testing"
"time"
"cloudipvalidator/internal/db"
)
func getScanStatus(t *testing.T, fc *fakeClient) scanStatusDTO {
t.Helper()
resp, body := fc.do(http.MethodGet, "/api/v1/admin/ips/scan", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("scan status: %d %s", resp.StatusCode, body)
}
var st scanStatusDTO
if err := json.Unmarshal(body, &st); err != nil {
t.Fatalf("unmarshal scan status %q: %v", body, err)
}
return st
}
func waitScanDone(t *testing.T, fc *fakeClient) scanStatusDTO {
t.Helper()
deadline := time.Now().Add(30 * time.Second)
for {
st := getScanStatus(t, fc)
if !st.Running {
return st
}
if time.Now().After(deadline) {
t.Fatalf("scan still running: %+v", st)
}
time.Sleep(5 * time.Millisecond)
}
}
func TestScanStartReturns202AndStatusIsPollable(t *testing.T) {
fc, _, _, mock := newConfigTestHarness(t)
// Before any scan the status is idle, with explicit null times.
resp, body := fc.do(http.MethodGet, "/api/v1/admin/ips/scan", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status: %d %s", resp.StatusCode, body)
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(body, &raw); err != nil {
t.Fatal(err)
}
for _, k := range []string{"state", "running", "dry_run", "pages", "discovered", "free", "added", "requeued",
"reordered", "skipped_in_progress", "started_at", "finished_at", "error"} {
if _, ok := raw[k]; !ok {
t.Fatalf("missing key %q in %s", k, body)
}
}
if string(raw["state"]) != `"idle"` || string(raw["started_at"]) != "null" || string(raw["finished_at"]) != "null" {
t.Fatalf("expected idle with null times, got %s", body)
}
mock.SeedMany("fip", 250)
mock.SeedWithPort("busy", "203.0.113.5", "svc", "port-x")
mock.PageSize = 100
mock.PageDelay = 60 * time.Millisecond
resp, body = fc.do(http.MethodPost, "/api/v1/admin/ips/scan", nil)
if resp.StatusCode != http.StatusAccepted {
t.Fatalf("start: expected 202, got %d %s", resp.StatusCode, body)
}
var st scanStatusDTO
if err := json.Unmarshal(body, &st); err != nil {
t.Fatal(err)
}
if !st.Running || st.State != "listing" || st.StartedAt == nil {
t.Fatalf("expected a running listing job, got %+v", st)
}
// Starting again while it runs joins the same job: still 202, running.
resp, body = fc.do(http.MethodPost, "/api/v1/admin/ips/scan?dry_run=true", nil)
if resp.StatusCode != http.StatusAccepted {
t.Fatalf("join: expected 202, got %d %s", resp.StatusCode, body)
}
var joined scanStatusDTO
if err := json.Unmarshal(body, &joined); err != nil {
t.Fatal(err)
}
if !joined.Running || joined.DryRun || joined.StartedAt == nil || !joined.StartedAt.Equal(*st.StartedAt) {
t.Fatalf("expected the running (non-dry) job's status, got %+v", joined)
}
fin := waitScanDone(t, fc)
if fin.State != "done" || fin.Pages != 3 || fin.Discovered != 251 || fin.Free != 250 || fin.Added != 250 ||
fin.FinishedAt == nil || fin.Error != "" {
t.Fatalf("final status: %+v", fin)
}
if _, body := fc.do(http.MethodGet, "/api/v1/admin/status", nil); !strings.Contains(string(body), `"total_ips":250`) {
t.Fatalf("expected 250 queued, got %s", body)
}
}
func TestScanDryRunDoesNotTouchQueue(t *testing.T) {
fc, d, _, mock := newConfigTestHarness(t)
mock.SeedMany("fip", 30)
if err := d.SeedQueue(context.Background(), []string{"9.9.9.9"}); err != nil {
t.Fatal(err)
}
resp, body := fc.do(http.MethodPost, "/api/v1/admin/ips/scan?dry_run=true", nil)
if resp.StatusCode != http.StatusAccepted {
t.Fatalf("dry run start: %d %s", resp.StatusCode, body)
}
fin := waitScanDone(t, fc)
if fin.State != "done" || !fin.DryRun || fin.Free != 30 || fin.Added != 0 {
t.Fatalf("dry run status: %+v", fin)
}
_, body = fc.do(http.MethodGet, "/api/v1/admin/ips", nil)
var ips []db.IPQueueItem
if err := json.Unmarshal(body, &ips); err != nil || len(ips) != 1 || ips[0].IPAddress != "9.9.9.9" {
t.Fatalf("dry run must not touch the queue: %s err=%v", body, err)
}
// dry_run + wait: counters in the classic body, still no queue change.
resp, body = fc.do(http.MethodPost, "/api/v1/admin/ips/scan?dry_run=true&wait=true", nil)
var sr scanIPsResponse
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &sr) != nil || sr.ScannedFree != 30 || len(sr.Added) != 0 {
t.Fatalf("dry run wait: %d %s", resp.StatusCode, body)
}
}
func TestScanWaitErrorIs502AndStatusShowsError(t *testing.T) {
fc, d, _, mock := newConfigTestHarness(t)
mock.SeedMany("fip", 5)
mock.ListFailure = errors.New("neutron is down")
if err := d.SeedQueue(context.Background(), []string{"9.9.9.9"}); err != nil {
t.Fatal(err)
}
resp, body := fc.do(http.MethodPost, "/api/v1/admin/ips/scan?wait=true", nil)
if resp.StatusCode != http.StatusBadGateway || !strings.Contains(string(body), "neutron is down") {
t.Fatalf("expected 502 with the cause, got %d %s", resp.StatusCode, body)
}
st := getScanStatus(t, fc)
if st.State != "error" || st.Running || !strings.Contains(st.Error, "neutron is down") {
t.Fatalf("status after failure: %+v", st)
}
_, body = fc.do(http.MethodGet, "/api/v1/admin/ips", nil)
var ips []db.IPQueueItem
if err := json.Unmarshal(body, &ips); err != nil || len(ips) != 1 {
t.Fatalf("queue must be untouched after a failed scan: %s", body)
}
for _, q := range []string{"wait=maybe", "dry_run=2"} {
if resp, body := fc.do(http.MethodPost, "/api/v1/admin/ips/scan?"+q, nil); resp.StatusCode != http.StatusBadRequest {
t.Fatalf("%s: expected 400, got %d %s", q, resp.StatusCode, body)
}
}
}
// seedMixedQueue creates addresses 10.0.0.1..10.0.0.n (all queued), then moves
// some to done/failed with results.
func seedMixedQueue(t *testing.T, d *db.DB, n int) []string {
t.Helper()
ctx := context.Background()
var addrs []string
for i := 1; i <= n; i++ {
addrs = append(addrs, fmt.Sprintf("10.0.0.%d", i))
}
if _, err := d.SubmitIPs(ctx, addrs); err != nil {
t.Fatal(err)
}
finish := func(addr, result string, ok, bad int) {
ip, err := d.GetIPByAddress(ctx, addr)
if err != nil {
t.Fatal(err)
}
for i := 0; i < ok+bad; i++ {
if err := d.UpsertCheck(ctx, db.Check{
IPID: ip.ID, IPAddress: addr, AttemptNumber: ip.AttemptNumber, Source: db.SourceEgress,
CheckType: "https", Target: fmt.Sprintf("t%d", i), Success: i < ok, CheckedAt: db.Now(),
}); err != nil {
t.Fatal(err)
}
}
if err := d.FinishIP(ctx, ip.ID, result); err != nil {
t.Fatal(err)
}
}
finish("10.0.0.1", db.ResultPass, 1, 0)
finish("10.0.0.2", db.ResultPass, 1, 0)
finish("10.0.0.3", db.ResultFail, 0, 1)
finish("10.0.0.4", db.ResultPartial, 1, 1)
return addrs
}
func TestAdminIPsPaginationFiltersAndCompat(t *testing.T) {
fc, d, _, _ := newConfigTestHarness(t)
seedMixedQueue(t, d, 12)
// No params: exactly the old bare array.
resp, body := fc.do(http.MethodGet, "/api/v1/admin/ips", nil)
if resp.StatusCode != http.StatusOK || !strings.HasPrefix(strings.TrimSpace(string(body)), "[") {
t.Fatalf("expected a bare array, got %d %.60s", resp.StatusCode, body)
}
var all []db.IPQueueItem
if err := json.Unmarshal(body, &all); err != nil || len(all) != 12 {
t.Fatalf("bare array: n=%d err=%v", len(all), err)
}
page := func(query string) ipsPageResponse {
t.Helper()
resp, body := fc.do(http.MethodGet, "/api/v1/admin/ips?"+query, nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("%s: %d %s", query, resp.StatusCode, body)
}
var p ipsPageResponse
if err := json.Unmarshal(body, &p); err != nil {
t.Fatalf("%s: unmarshal: %v (%s)", query, err, body)
}
return p
}
p := page("limit=5")
if len(p.Items) != 5 || p.Total != 12 || p.Limit != 5 || p.Offset != 0 || p.Items[0].IPAddress != "10.0.0.1" {
t.Fatalf("limit=5: %+v", p)
}
p = page("limit=5&offset=10")
if len(p.Items) != 2 || p.Total != 12 || p.Offset != 10 {
t.Fatalf("offset=10: len=%d total=%d", len(p.Items), p.Total)
}
p = page("limit=50&state=done,failed")
if p.Total != 4 || len(p.Items) != 4 {
t.Fatalf("state csv: %+v", p)
}
p = page("limit=50&state=queued&q=0.0.1")
if p.Total != 3 { // 10.0.0.10, .11, .12
t.Fatalf("state+q: total=%d", p.Total)
}
p = page("limit=50&result=fail")
if p.Total != 1 || p.Items[0].IPAddress != "10.0.0.3" {
t.Fatalf("result: %+v", p)
}
p = page("limit=2&state=done,failed&order=aggregated_at_desc")
if p.Total != 4 || len(p.Items) != 2 || p.Items[0].AggregatedAt == nil {
t.Fatalf("order: %+v", p)
}
if p = page("limit=5&state=checking"); p.Total != 0 || p.Items == nil || len(p.Items) != 0 {
t.Fatalf("empty page must have items: [] , got %+v", p)
}
for _, bad := range []string{
"limit=0", "limit=1001", "limit=abc", "limit=5&offset=-1", "limit=5&offset=x", "offset=5",
"limit=5&state=bogus", "limit=5&state=done,nope", "limit=5&result=weird", "limit=5&order=random",
} {
if resp, body := fc.do(http.MethodGet, "/api/v1/admin/ips?"+bad, nil); resp.StatusCode != http.StatusBadRequest {
t.Fatalf("%s: expected 400, got %d %s", bad, resp.StatusCode, body)
}
}
if resp, _ := fc.do(http.MethodGet, "/api/v1/admin/ips?limit=1000", nil); resp.StatusCode != http.StatusOK {
t.Fatalf("limit=1000 must be allowed")
}
}
func TestAdminRegistryPaginationFiltersAndCompat(t *testing.T) {
fc, d, _, _ := newConfigTestHarness(t)
seedMixedQueue(t, d, 12)
resp, body := fc.do(http.MethodGet, "/api/v1/admin/registry", nil)
var bare []registryDTO
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &bare) != nil || len(bare) != 12 {
t.Fatalf("bare array: %d %.80s", resp.StatusCode, body)
}
page := func(query string) registryPageResponse {
t.Helper()
resp, body := fc.do(http.MethodGet, "/api/v1/admin/registry?"+query, nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("%s: %d %s", query, resp.StatusCode, body)
}
var p registryPageResponse
if err := json.Unmarshal(body, &p); err != nil {
t.Fatal(err)
}
return p
}
p := page("limit=5&offset=5")
if len(p.Items) != 5 || p.Total != 12 || p.Limit != 5 || p.Offset != 5 || p.Items[0].IPAddress != bare[5].IPAddress {
t.Fatalf("paging: %+v", p)
}
if p = page("limit=50&last_result=pass"); p.Total != 2 || len(p.Items) != 2 || p.Items[0].LastResult != "pass" {
t.Fatalf("last_result=pass: %+v", p)
}
if p = page("limit=50&last_result=partial"); p.Total != 1 || p.Items[0].IPAddress != "10.0.0.4" {
t.Fatalf("last_result=partial: %+v", p)
}
if p = page("limit=50&last_result=cancelled"); p.Total != 0 || p.Items == nil {
t.Fatalf("last_result=cancelled: %+v", p)
}
if p = page("limit=50&q=0.0.1"); p.Total != 4 { // 10.0.0.1, .10, .11, .12
t.Fatalf("q: %+v", p)
}
for _, bad := range []string{"limit=0", "limit=1001", "offset=1", "limit=5&last_result=bogus", "limit=5&offset=-3"} {
if resp, body := fc.do(http.MethodGet, "/api/v1/admin/registry?"+bad, nil); resp.StatusCode != http.StatusBadRequest {
t.Fatalf("%s: expected 400, got %d %s", bad, resp.StatusCode, body)
}
}
}
func TestAdminStatusResultsByOverall(t *testing.T) {
fc, d, _, _ := newConfigTestHarness(t)
// Empty: all four keys present with zeros.
_, body := fc.do(http.MethodGet, "/api/v1/admin/status", nil)
var st struct {
TotalIPs int `json:"total_ips"`
IPsByState map[string]int `json:"ips_by_state"`
TotalValidators int `json:"total_validators"`
ResultsByOverall map[string]int `json:"results_by_overall"`
}
if err := json.Unmarshal(body, &st); err != nil {
t.Fatalf("%s: %v", body, err)
}
if st.TotalIPs != 0 || len(st.ResultsByOverall) != 4 || st.ResultsByOverall["pass"] != 0 {
t.Fatalf("empty status: %s", body)
}
seedMixedQueue(t, d, 6)
_, body = fc.do(http.MethodGet, "/api/v1/admin/status", nil)
if err := json.Unmarshal(body, &st); err != nil {
t.Fatal(err)
}
r := st.ResultsByOverall
if st.TotalIPs != 6 || st.IPsByState["queued"] != 2 || st.IPsByState["done"] != 3 || st.IPsByState["failed"] != 1 {
t.Fatalf("by state: %s", body)
}
if r["pass"] != 2 || r["partial"] != 1 || r["fail"] != 1 || r["cancelled"] != 0 {
t.Fatalf("results_by_overall: %v", r)
}
}
func TestAdminClearReportsCount(t *testing.T) {
fc, d, _, _ := newConfigTestHarness(t)
seedMixedQueue(t, d, 7)
resp, body := fc.do(http.MethodPost, "/api/v1/admin/ips/clear", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("clear: %d %s", resp.StatusCode, body)
}
var cr struct {
Deleted []string `json:"deleted"`
Count int `json:"count"`
}
if err := json.Unmarshal(body, &cr); err != nil || cr.Count != 7 || len(cr.Deleted) != 7 {
t.Fatalf("clear body: %s err=%v", body, err)
}
resp, body = fc.do(http.MethodPost, "/api/v1/admin/ips/clear", nil)
if err := json.Unmarshal(body, &cr); err != nil || cr.Count != 0 || cr.Deleted == nil || len(cr.Deleted) != 0 {
t.Fatalf("empty clear: %s", body)
}
}