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>
This commit is contained in:
1 parent
debf2afed2
commit
aff8fe38b5
61 files changed
+5833
-536
No files matched your search
@@ -40,7 +40,9 @@ func newAuthTestServer(t *testing.T, admin, agent string) (*Server, *httptest.Se
|
||||
t.Fatalf("bootstrap: %v", err)
|
||||
}
|
||||
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
srv := New(d, orchestrator.New(d, openstack.NewMockClient(), cfg, log), log).WithAuth(admin, agent)
|
||||
orch := orchestrator.New(d, openstack.NewMockClient(), cfg, log)
|
||||
t.Cleanup(func() { orch.CancelScan() })
|
||||
srv := New(d, orch, log).WithAuth(admin, agent)
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
t.Cleanup(ts.Close)
|
||||
return srv, ts
|
||||
@@ -95,8 +97,8 @@ func TestRouteTableIsClassified(t *testing.T) {
|
||||
t.Fatalf("admin route %q is %s, want admin", rt.Pattern, rt.Access)
|
||||
}
|
||||
}
|
||||
if counts["admin"] != 33 || counts["agent"] != 5 || counts["open"] != 7 {
|
||||
t.Fatalf("access counts = %v, want admin=33 agent=5 open=7", counts)
|
||||
if counts["admin"] != 34 || counts["agent"] != 5 || counts["open"] != 7 {
|
||||
t.Fatalf("access counts = %v, want admin=34 agent=5 open=7", counts)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package httpapi
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"time"
|
||||
|
||||
"cloudipvalidator/internal/db"
|
||||
)
|
||||
|
||||
// DTOs for the admin queue-management and dynamic-config endpoints
|
||||
// (/api/v1/admin/ips, /api/v1/admin/config/*). Unlike the older read-only
|
||||
@@ -30,6 +34,7 @@ type deleteIPsResponse struct {
|
||||
|
||||
type clearQueueResponse struct {
|
||||
Deleted []string `json:"deleted"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type scanIPsResponse struct {
|
||||
@@ -40,6 +45,43 @@ type scanIPsResponse struct {
|
||||
SkippedInProgress []string `json:"skipped_in_progress"`
|
||||
}
|
||||
|
||||
// scanStatusDTO is the progress/status object of the background floating-IP
|
||||
// scan job (POST/GET /api/v1/admin/ips/scan). state is one of
|
||||
// idle|clearing|listing|enqueuing|done|error|cancelled; "idle" means no scan
|
||||
// has run in this control-api process yet.
|
||||
type scanStatusDTO struct {
|
||||
State string `json:"state"`
|
||||
Running bool `json:"running"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
Pages int `json:"pages"`
|
||||
Discovered int `json:"discovered"`
|
||||
Free int `json:"free"`
|
||||
Added int `json:"added"`
|
||||
Requeued int `json:"requeued"`
|
||||
Reordered int `json:"reordered"`
|
||||
SkippedInProgress int `json:"skipped_in_progress"`
|
||||
StartedAt *time.Time `json:"started_at"`
|
||||
FinishedAt *time.Time `json:"finished_at"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// ipsPageResponse / registryPageResponse are the paginated envelopes returned
|
||||
// by GET /admin/ips and GET /admin/registry when `limit` is given; total is
|
||||
// the number of rows matching the filters (before limit/offset).
|
||||
type ipsPageResponse struct {
|
||||
Items []db.IPQueueItem `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
}
|
||||
|
||||
type registryPageResponse struct {
|
||||
Items []registryDTO `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
}
|
||||
|
||||
// registryDTO is one row of the durable per-address registry — see
|
||||
// db.RegistrySummary.
|
||||
type registryDTO struct {
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"cloudipvalidator/internal/db"
|
||||
"cloudipvalidator/internal/orchestrator"
|
||||
)
|
||||
|
||||
func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -11,7 +17,13 @@ func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
|
||||
ips, err := s.DB.ListIPs(r.Context())
|
||||
// GROUP BY counts instead of loading every row: the dashboard polls this.
|
||||
byState, total, err := s.DB.CountIPsByState(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
byResult, err := s.DB.CountIPsByResult(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -21,24 +33,117 @@ func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
byState := map[string]int{}
|
||||
for _, ip := range ips {
|
||||
byState[ip.State]++
|
||||
overall := map[string]int{
|
||||
db.ResultPass: 0, db.ResultPartial: 0, db.ResultFail: 0, db.ResultCancelled: 0,
|
||||
}
|
||||
for res, n := range byResult {
|
||||
overall[res] = n
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"total_ips": len(ips),
|
||||
"ips_by_state": byState,
|
||||
"total_validators": len(validators),
|
||||
"total_ips": total,
|
||||
"ips_by_state": byState,
|
||||
"total_validators": len(validators),
|
||||
"results_by_overall": overall,
|
||||
})
|
||||
}
|
||||
|
||||
// handleAdminIPs lists the queue. Without `limit` it returns the bare array of
|
||||
// every row (the original contract); with `limit` (1..1000) it returns the
|
||||
// envelope {items,total,limit,offset}. Filters: state (csv of valid ip
|
||||
// states), q (substring of the address), result (pass|partial|fail|
|
||||
// cancelled), order (sequence|aggregated_at_desc); offset >= 0 (needs limit).
|
||||
func (s *Server) handleAdminIPs(w http.ResponseWriter, r *http.Request) {
|
||||
ips, err := s.DB.ListIPs(r.Context())
|
||||
q := r.URL.Query()
|
||||
limit, offset, paged, err := parsePaging(q)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
filter := db.IPFilter{Query: strings.TrimSpace(q.Get("q"))}
|
||||
for _, st := range strings.Split(q.Get("state"), ",") {
|
||||
st = strings.TrimSpace(st)
|
||||
if st == "" {
|
||||
continue
|
||||
}
|
||||
if !db.IsValidIPState(st) {
|
||||
writeError(w, http.StatusBadRequest, "invalid state "+strconv.Quote(st)+" (valid: "+strings.Join(db.IPStates, ", ")+")")
|
||||
return
|
||||
}
|
||||
filter.States = append(filter.States, st)
|
||||
}
|
||||
if res := q.Get("result"); res != "" {
|
||||
if !db.IsValidResult(res) {
|
||||
writeError(w, http.StatusBadRequest, "invalid result "+strconv.Quote(res)+" (valid: pass, partial, fail, cancelled)")
|
||||
return
|
||||
}
|
||||
filter.Result = res
|
||||
}
|
||||
switch order := q.Get("order"); order {
|
||||
case "", db.IPOrderSequence:
|
||||
filter.Order = db.IPOrderSequence
|
||||
case db.IPOrderAggregatedAtDesc:
|
||||
filter.Order = order
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "invalid order "+strconv.Quote(order)+" (valid: sequence, aggregated_at_desc)")
|
||||
return
|
||||
}
|
||||
|
||||
if len(q) == 0 {
|
||||
ips, err := s.DB.ListIPs(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, ips)
|
||||
return
|
||||
}
|
||||
items, total, err := s.DB.ListIPsPage(r.Context(), filter, limit, offset)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, ips)
|
||||
if !paged {
|
||||
writeJSON(w, http.StatusOK, items)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, ipsPageResponse{Items: items, Total: total, Limit: limit, Offset: offset})
|
||||
}
|
||||
|
||||
// maxPageLimit is the largest allowed `limit` of the paginated list endpoints.
|
||||
const maxPageLimit = 1000
|
||||
|
||||
// parsePaging reads limit/offset. paged is true when `limit` was given (the
|
||||
// envelope form); `offset` without `limit` is rejected.
|
||||
func parsePaging(q url.Values) (limit, offset int, paged bool, err error) {
|
||||
if v := q.Get("limit"); v != "" {
|
||||
limit, err = strconv.Atoi(v)
|
||||
if err != nil || limit < 1 || limit > maxPageLimit {
|
||||
return 0, 0, false, fmt.Errorf("limit must be an integer in 1..%d", maxPageLimit)
|
||||
}
|
||||
paged = true
|
||||
}
|
||||
if v := q.Get("offset"); v != "" {
|
||||
if !paged {
|
||||
return 0, 0, false, errors.New("offset requires limit")
|
||||
}
|
||||
offset, err = strconv.Atoi(v)
|
||||
if err != nil || offset < 0 {
|
||||
return 0, 0, false, errors.New("offset must be an integer >= 0")
|
||||
}
|
||||
}
|
||||
return limit, offset, paged, nil
|
||||
}
|
||||
|
||||
// parseBoolParam reads an optional boolean query parameter.
|
||||
func parseBoolParam(q url.Values, name string) (bool, error) {
|
||||
v := strings.ToLower(q.Get(name))
|
||||
switch v {
|
||||
case "", "0", "false":
|
||||
return false, nil
|
||||
case "1", "true":
|
||||
return true, nil
|
||||
}
|
||||
return false, fmt.Errorf("%s must be true or false", name)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminIPDetail(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -99,13 +204,34 @@ func (s *Server) handleAdminSubmitIPs(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// handleAdminScanFloatingIPs lists every floating IP in the configured
|
||||
// OpenStack project, filters to the free (unassociated) pool, and submits
|
||||
// that address list to the check queue — see orchestrator.ScanFloatingIPs.
|
||||
// Takes no body; POST is used (rather than GET) because it mutates the
|
||||
// queue, matching handleAdminSubmitIPs.
|
||||
// handleAdminScanFloatingIPs starts the background floating-IP scan (see
|
||||
// orchestrator.StartScan) and answers 202 with its status at once; a scan that
|
||||
// is already running is joined (202 with the running job's status, no second
|
||||
// job). Query: dry_run=true only discovers and counts, leaving the queue
|
||||
// untouched; wait=true blocks until the job finishes and answers 200 with the
|
||||
// classic synchronous body {scanned_free, added, requeued, reordered,
|
||||
// skipped_in_progress} (502 if the scan failed). Takes no body; POST is used
|
||||
// (rather than GET) because it mutates the queue, matching handleAdminSubmitIPs.
|
||||
func (s *Server) handleAdminScanFloatingIPs(w http.ResponseWriter, r *http.Request) {
|
||||
result, scannedFree, err := s.Orch.ScanFloatingIPs(r.Context())
|
||||
q := r.URL.Query()
|
||||
dryRun, err := parseBoolParam(q, "dry_run")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
wait, err := parseBoolParam(q, "wait")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
opts := orchestrator.ScanOptions{DryRun: dryRun}
|
||||
|
||||
if !wait {
|
||||
st, _ := s.Orch.StartScan(opts)
|
||||
writeJSON(w, http.StatusAccepted, toScanStatusDTO(st))
|
||||
return
|
||||
}
|
||||
result, scannedFree, err := s.Orch.ScanAndWait(r.Context(), opts)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
@@ -119,6 +245,29 @@ func (s *Server) handleAdminScanFloatingIPs(w http.ResponseWriter, r *http.Reque
|
||||
})
|
||||
}
|
||||
|
||||
// handleAdminScanStatus returns the current status/progress of the scan job.
|
||||
func (s *Server) handleAdminScanStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, toScanStatusDTO(s.Orch.ScanStatus()))
|
||||
}
|
||||
|
||||
func toScanStatusDTO(st orchestrator.ScanStatus) scanStatusDTO {
|
||||
return scanStatusDTO{
|
||||
State: string(st.State),
|
||||
Running: st.Running,
|
||||
DryRun: st.DryRun,
|
||||
Pages: st.Pages,
|
||||
Discovered: st.Discovered,
|
||||
Free: st.Free,
|
||||
Added: st.Added,
|
||||
Requeued: st.Requeued,
|
||||
Reordered: st.Reordered,
|
||||
SkippedInProgress: st.SkippedInProgress,
|
||||
StartedAt: st.StartedAt,
|
||||
FinishedAt: st.FinishedAt,
|
||||
Error: st.Error,
|
||||
}
|
||||
}
|
||||
|
||||
// handleAdminCancelIP force-stops a check in progress (or still-queued) for
|
||||
// the given address. Requires the orchestrator, since a floating IP may
|
||||
// need to be disassociated in OpenStack.
|
||||
@@ -176,7 +325,7 @@ func (s *Server) handleAdminClearQueue(w http.ResponseWriter, r *http.Request) {
|
||||
writeDBError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, clearQueueResponse{Deleted: emptyIfNil(result.Deleted)})
|
||||
writeJSON(w, http.StatusOK, clearQueueResponse{Deleted: emptyIfNil(result.Deleted), Count: len(result.Deleted)})
|
||||
}
|
||||
|
||||
// emptyIfNil turns a nil slice into an empty one so these fields always
|
||||
|
||||
@@ -5,8 +5,35 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cloudipvalidator/internal/orchestrator"
|
||||
)
|
||||
|
||||
// stepAutoCycleThroughScan drives one cycle start: the first step launches the
|
||||
// background scan (phase scanning), then it waits for the job and runs the
|
||||
// step that consumes its result.
|
||||
func stepAutoCycleThroughScan(t *testing.T, orch *orchestrator.Orchestrator) {
|
||||
t.Helper()
|
||||
orch.AutoCycleStep(t.Context())
|
||||
waitForScan(t, orch)
|
||||
orch.AutoCycleStep(t.Context())
|
||||
}
|
||||
|
||||
func waitForScan(t *testing.T, orch *orchestrator.Orchestrator) orchestrator.ScanStatus {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
for {
|
||||
if st := orch.ScanStatus(); !st.Running {
|
||||
return st
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("scan did not finish: %+v", orch.ScanStatus())
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeAutoCycle(t *testing.T, body []byte) autoCycleDTO {
|
||||
t.Helper()
|
||||
var dto autoCycleDTO
|
||||
@@ -122,6 +149,12 @@ func TestAutoCycleStartStop(t *testing.T) {
|
||||
// The engine picks it up on the next step and the API reflects it.
|
||||
orch.AutoCycleStep(t.Context())
|
||||
resp, body = fc.do(http.MethodGet, "/api/v1/admin/auto-cycle", nil)
|
||||
if dto := decodeAutoCycle(t, body); dto.Phase != "scanning" && dto.Phase != "running" {
|
||||
t.Fatalf("expected scanning right after the first step, got %+v", dto)
|
||||
}
|
||||
waitForScan(t, orch)
|
||||
orch.AutoCycleStep(t.Context())
|
||||
resp, body = fc.do(http.MethodGet, "/api/v1/admin/auto-cycle", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("get: status=%d body=%s", resp.StatusCode, body)
|
||||
}
|
||||
@@ -147,7 +180,7 @@ func TestAutoCycleStartIsIdempotent(t *testing.T) {
|
||||
if resp, body := fc.do(http.MethodPost, "/api/v1/admin/auto-cycle/start", nil); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("start: status=%d body=%s", resp.StatusCode, body)
|
||||
}
|
||||
orch.AutoCycleStep(t.Context())
|
||||
stepAutoCycleThroughScan(t, orch)
|
||||
|
||||
resp, body := fc.do(http.MethodPost, "/api/v1/admin/auto-cycle/start", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
|
||||
@@ -46,6 +46,8 @@ func newConfigTestHarness(t *testing.T) (*fakeClient, *db.DB, *orchestrator.Orch
|
||||
|
||||
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
orch := orchestrator.New(d, mock, cfg, log)
|
||||
// A background scan must never outlive the database it writes to.
|
||||
t.Cleanup(func() { orch.CancelScan() })
|
||||
srv := New(d, orch, log)
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
@@ -2,6 +2,8 @@ package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"cloudipvalidator/internal/db"
|
||||
)
|
||||
@@ -9,9 +11,30 @@ import (
|
||||
// handleAdminRegistry lists every address ever submitted to the check
|
||||
// queue, each with a summary of its accumulated check history — the
|
||||
// durable record that survives an address being deleted from ip_queue and
|
||||
// later re-added. See migrations/0007_ip_registry.sql.
|
||||
// later re-added. See migrations/0007_ip_registry.sql. Without `limit` it is
|
||||
// the bare array of every row; with `limit` (1..1000) it returns the envelope
|
||||
// {items,total,limit,offset} (filters: offset, q = substring of the address,
|
||||
// last_result = pass|partial|fail|cancelled).
|
||||
func (s *Server) handleAdminRegistry(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := s.DB.ListRegistry(r.Context())
|
||||
q := r.URL.Query()
|
||||
limit, offset, paged, err := parsePaging(q)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
filter := db.RegistryFilter{Query: strings.TrimSpace(q.Get("q")), LastResult: q.Get("last_result")}
|
||||
if filter.LastResult != "" && !db.IsValidResult(filter.LastResult) {
|
||||
writeError(w, http.StatusBadRequest, "invalid last_result "+strconv.Quote(filter.LastResult)+" (valid: pass, partial, fail, cancelled)")
|
||||
return
|
||||
}
|
||||
|
||||
var items []db.RegistrySummary
|
||||
var total int
|
||||
if len(q) == 0 {
|
||||
items, err = s.DB.ListRegistry(r.Context())
|
||||
} else {
|
||||
items, total, err = s.DB.ListRegistryPage(r.Context(), filter, limit, offset)
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -20,7 +43,11 @@ func (s *Server) handleAdminRegistry(w http.ResponseWriter, r *http.Request) {
|
||||
for i, it := range items {
|
||||
out[i] = registrySummaryToDTO(it)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
if !paged {
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, registryPageResponse{Items: out, Total: total, Limit: limit, Offset: offset})
|
||||
}
|
||||
|
||||
// handleAdminRegistryHistory returns one address's registry record plus its
|
||||
|
||||
@@ -10,14 +10,15 @@ import (
|
||||
"cloudipvalidator/internal/db"
|
||||
)
|
||||
|
||||
// TestScanFloatingIPsEndpoint proves POST /api/v1/admin/ips/scan only
|
||||
// queues floating IPs that are currently unassociated in OpenStack.
|
||||
// TestScanFloatingIPsEndpoint proves POST /api/v1/admin/ips/scan?wait=true
|
||||
// (the synchronous form) only queues floating IPs that are currently
|
||||
// unassociated in OpenStack and answers with the classic counters body.
|
||||
func TestScanFloatingIPsEndpoint(t *testing.T) {
|
||||
fc, _, _, mock := newConfigTestHarness(t)
|
||||
mock.Seed("fip-free", "5.5.5.5", "svc-project")
|
||||
mock.SeedWithPort("fip-occupied", "6.6.6.6", "svc-project", "some-port")
|
||||
|
||||
resp, body := fc.do(http.MethodPost, "/api/v1/admin/ips/scan", nil)
|
||||
resp, body := fc.do(http.MethodPost, "/api/v1/admin/ips/scan?wait=true", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("scan: status=%d body=%s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,7 @@ func (s *Server) routeTable() []route {
|
||||
{"GET /api/v1/admin/ips", s.handleAdminIPs, accessAdmin},
|
||||
{"POST /api/v1/admin/ips", s.handleAdminSubmitIPs, accessAdmin},
|
||||
{"POST /api/v1/admin/ips/scan", s.handleAdminScanFloatingIPs, accessAdmin},
|
||||
{"GET /api/v1/admin/ips/scan", s.handleAdminScanStatus, accessAdmin},
|
||||
{"GET /api/v1/admin/ips/{ip}", s.handleAdminIPDetail, accessAdmin},
|
||||
{"POST /api/v1/admin/ips/{ip}/cancel", s.handleAdminCancelIP, accessAdmin},
|
||||
{"DELETE /api/v1/admin/ips/{ip}", s.handleAdminDeleteIP, accessAdmin},
|
||||
|
||||
Reference in new issue
Block a user