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:
ayurishchevandClaude Sonnet 5.5 committed 2026-10-01 19:31:11 +03:00
1 parent debf2afed2
commit aff8fe38b5
61 files changed
+5833 -536

No files matched your search

+26
View File
@@ -69,6 +69,17 @@ type OpenStackConfig struct {
UsernameEnv string `yaml:"username_env"` // default OS_USERNAME — used when auth_method: password
UserDomainNameEnv string `yaml:"user_domain_name_env"` // default OS_USER_DOMAIN_NAME
PasswordEnv string `yaml:"password_env"` // default OS_PASSWORD
// ListPageSize is how many floating IPs one Neutron list request asks
// for (the scan reads the project page by page). Default 200.
ListPageSize int `yaml:"list_page_size"`
// RequestTimeoutSeconds bounds every single HTTP request to Keystone and
// Neutron. Default 60.
RequestTimeoutSeconds int `yaml:"request_timeout_seconds"`
// ListPageRetries is how many times one failed page of the listing is
// retried (backoff 1s,2s,4s,...) on network errors, 5xx and 429. Default
// 5; a negative value disables retries.
ListPageRetries int `yaml:"list_page_retries"`
}
type OrchestratorConfig struct {
@@ -93,6 +104,9 @@ type OrchestratorConfig struct {
// POST /api/v1/admin/ips/scan or the dashboard's "Scan Floating IPs"
// button either way.
FIPScanIntervalSeconds int `yaml:"fip_scan_interval_seconds"`
// FIPScanTimeoutSeconds is the overall deadline of one background
// floating-IP scan (clear + paged read + enqueue). Default 1800.
FIPScanTimeoutSeconds int `yaml:"fip_scan_timeout_seconds"`
}
type AggregationConfig struct {
@@ -164,6 +178,15 @@ func LoadControlAPI(path string) (*ControlAPI, error) {
if c.OpenStack.PasswordEnv == "" {
c.OpenStack.PasswordEnv = "OS_PASSWORD"
}
if c.OpenStack.ListPageSize == 0 {
c.OpenStack.ListPageSize = 200
}
if c.OpenStack.RequestTimeoutSeconds == 0 {
c.OpenStack.RequestTimeoutSeconds = 60
}
if c.OpenStack.ListPageRetries == 0 {
c.OpenStack.ListPageRetries = 5
}
if c.Auth.AdminTokenEnv == "" {
c.Auth.AdminTokenEnv = "CONTROL_API_ADMIN_TOKEN"
}
@@ -191,6 +214,9 @@ func LoadControlAPI(path string) (*ControlAPI, error) {
if c.Orchestrator.HeartbeatTimeoutSeconds == 0 {
c.Orchestrator.HeartbeatTimeoutSeconds = 30
}
if c.Orchestrator.FIPScanTimeoutSeconds == 0 {
c.Orchestrator.FIPScanTimeoutSeconds = 1800
}
return &c, nil
}
+66
View File
@@ -0,0 +1,66 @@
package config
import (
"bytes"
"os"
"path/filepath"
"testing"
)
func TestLoadControlAPIScanDefaults(t *testing.T) {
path := filepath.Join(t.TempDir(), "c.yaml")
if err := os.WriteFile(path, []byte("server:\n listen_addr: \":8080\"\n"), 0o600); err != nil {
t.Fatal(err)
}
c, err := LoadControlAPI(path)
if err != nil {
t.Fatalf("load: %v", err)
}
if c.OpenStack.ListPageSize != 200 || c.OpenStack.RequestTimeoutSeconds != 60 ||
c.OpenStack.ListPageRetries != 5 || c.Orchestrator.FIPScanTimeoutSeconds != 1800 {
t.Fatalf("unexpected defaults: openstack=%+v orchestrator=%+v", c.OpenStack, c.Orchestrator)
}
}
func TestLoadControlAPIScanOverrides(t *testing.T) {
path := filepath.Join(t.TempDir(), "c.yaml")
yaml := "openstack:\n list_page_size: 50\n request_timeout_seconds: 10\n list_page_retries: -1\n" +
"orchestrator:\n fip_scan_timeout_seconds: 99\n"
if err := os.WriteFile(path, []byte(yaml), 0o600); err != nil {
t.Fatal(err)
}
c, err := LoadControlAPI(path)
if err != nil {
t.Fatalf("load: %v", err)
}
if c.OpenStack.ListPageSize != 50 || c.OpenStack.RequestTimeoutSeconds != 10 ||
c.OpenStack.ListPageRetries != -1 || c.Orchestrator.FIPScanTimeoutSeconds != 99 {
t.Fatalf("overrides lost: openstack=%+v orchestrator=%+v", c.OpenStack, c.Orchestrator)
}
}
// The shipped example must load and carry the scan settings, and the rxprod
// copy must stay byte-identical to it.
func TestControlAPIExampleConfigs(t *testing.T) {
c, err := LoadControlAPI("../../configs/control-api.example.yaml")
if err != nil {
t.Fatalf("load example: %v", err)
}
if c.OpenStack.ListPageSize != 200 || c.Orchestrator.FIPScanTimeoutSeconds != 1800 {
t.Fatalf("example scan settings: %+v %+v", c.OpenStack, c.Orchestrator)
}
a, err := os.ReadFile("../../configs/control-api.example.yaml")
if err != nil {
t.Fatal(err)
}
b, err := os.ReadFile("../../rxprod-compose/sources/control-api.example.yaml")
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(a, b) {
t.Fatalf("rxprod-compose/sources/control-api.example.yaml differs from configs/control-api.example.yaml")
}
if _, err := LoadControlAPI("../../deploy/docker/control-api/control-api.docker.example.yaml"); err != nil {
t.Fatalf("load docker example: %v", err)
}
}
+130 -20
View File
@@ -8,6 +8,8 @@ import (
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
@@ -39,15 +41,44 @@ func (e *apiErr) Error() string {
type client struct {
baseURL string
http *http.Client
// long is used for clear / bulk operations, which legitimately take far
// longer than a plain read (thousands of rows in one transaction): same
// transport, but a longer whole-call timeout.
long *http.Client
// token, when non-empty, is sent to control-api as a Bearer credential.
token string
}
// longCallTimeout is the minimum whole-call timeout for clear and bulk
// operations (ClearQueue, DeleteIPs, SubmitIPs).
const longCallTimeout = 120 * time.Second
func newClient(baseURL string, timeout time.Duration) *client {
return &client{baseURL: baseURL, http: &http.Client{Timeout: timeout}}
longT := longCallTimeout
if timeout > longT {
longT = timeout
}
return &client{
baseURL: baseURL,
http: &http.Client{Timeout: timeout},
long: &http.Client{Timeout: longT},
}
}
func (c *client) do(ctx context.Context, method, path string, body, out interface{}) error {
return c.doWith(ctx, c.http, method, path, body, out)
}
// doLong is do with the long (clear/bulk) timeout.
func (c *client) doLong(ctx context.Context, method, path string, body, out interface{}) error {
hc := c.long
if hc == nil {
hc = c.http
}
return c.doWith(ctx, hc, method, path, body, out)
}
func (c *client) doWith(ctx context.Context, hc *http.Client, method, path string, body, out interface{}) error {
var reader io.Reader
if body != nil {
b, err := json.Marshal(body)
@@ -67,7 +98,7 @@ func (c *client) do(ctx context.Context, method, path string, body, out interfac
req.Header.Set("Authorization", "Bearer "+c.token)
}
resp, err := c.http.Do(req)
resp, err := hc.Do(req)
if err != nil {
return &apiErr{Status: 0, Message: err.Error()}
}
@@ -96,9 +127,58 @@ func (c *client) Status(ctx context.Context) (statusResponse, error) {
return out, err
}
func (c *client) ListIPs(ctx context.Context) ([]ipQueueItem, error) {
var out []ipQueueItem
err := c.do(ctx, http.MethodGet, "/api/v1/admin/ips", nil, &out)
// maxPageLimit is control-api's cap on `limit`.
const maxPageLimit = 1000
// clampLimit keeps limit within 1..maxPageLimit: a request without `limit`
// would make control-api answer with the legacy unbounded bare array.
func clampLimit(limit int) int {
if limit < 1 {
return 1
}
if limit > maxPageLimit {
return maxPageLimit
}
return limit
}
// ipsQuery selects one page of GET /admin/ips: server-side filters plus
// limit/offset. Order is "sequence" (default) or "aggregated_at_desc".
type ipsQuery struct {
States []string
Q string
Result string
Order string
Limit int
Offset int
}
func (q ipsQuery) values() url.Values {
v := url.Values{}
v.Set("limit", strconv.Itoa(clampLimit(q.Limit)))
if q.Offset > 0 {
v.Set("offset", strconv.Itoa(q.Offset))
}
if len(q.States) > 0 {
v.Set("state", strings.Join(q.States, ","))
}
if q.Q != "" {
v.Set("q", q.Q)
}
if q.Result != "" {
v.Set("result", q.Result)
}
if q.Order != "" {
v.Set("order", q.Order)
}
return v
}
// ListIPsPage returns one page of the check queue plus the total number of
// rows matching the filter. Never loads the whole queue.
func (c *client) ListIPsPage(ctx context.Context, q ipsQuery) (ipsPage, error) {
var out ipsPage
err := c.do(ctx, http.MethodGet, "/api/v1/admin/ips?"+q.values().Encode(), nil, &out)
return out, err
}
@@ -112,7 +192,7 @@ func (c *client) GetIP(ctx context.Context, ip string) (ipDetailResponse, error)
// forcing a recheck of already-finished ones — see docs/API.md.
func (c *client) SubmitIPs(ctx context.Context, addresses []string) (submitIPsResponse, error) {
var out submitIPsResponse
err := c.do(ctx, http.MethodPost, "/api/v1/admin/ips", map[string][]string{"addresses": addresses}, &out)
err := c.doLong(ctx, http.MethodPost, "/api/v1/admin/ips", map[string][]string{"addresses": addresses}, &out)
return out, err
}
@@ -129,7 +209,7 @@ func (c *client) DeleteIP(ctx context.Context, ip string) error {
// DeleteIPs permanently removes a specific list of addresses in one call.
func (c *client) DeleteIPs(ctx context.Context, addresses []string) (deleteIPsResponse, error) {
var out deleteIPsResponse
err := c.do(ctx, http.MethodPost, "/api/v1/admin/ips/delete", map[string][]string{"addresses": addresses}, &out)
err := c.doLong(ctx, http.MethodPost, "/api/v1/admin/ips/delete", map[string][]string{"addresses": addresses}, &out)
return out, err
}
@@ -137,25 +217,55 @@ func (c *client) DeleteIPs(ctx context.Context, addresses []string) (deleteIPsRe
// including those actively being checked.
func (c *client) ClearQueue(ctx context.Context) (clearQueueResponse, error) {
var out clearQueueResponse
err := c.do(ctx, http.MethodPost, "/api/v1/admin/ips/clear", nil, &out)
err := c.doLong(ctx, http.MethodPost, "/api/v1/admin/ips/clear", nil, &out)
return out, err
}
// ScanFloatingIPs lists the OpenStack project's free (unassociated)
// floating IPs and submits them to the check queue — see
// orchestrator.ScanFloatingIPs.
func (c *client) ScanFloatingIPs(ctx context.Context) (scanIPsResponse, error) {
var out scanIPsResponse
err := c.do(ctx, http.MethodPost, "/api/v1/admin/ips/scan", nil, &out)
// StartScan starts control-api's background floating-IP scan (or joins the
// one already running) and returns immediately with its current status.
func (c *client) StartScan(ctx context.Context, dryRun bool) (scanStatusDTO, error) {
var out scanStatusDTO
path := "/api/v1/admin/ips/scan"
if dryRun {
path += "?dry_run=true"
}
err := c.do(ctx, http.MethodPost, path, nil, &out)
return out, err
}
// ListRegistry returns every address ever submitted to the check queue,
// each with a summary of its accumulated check history — survives an
// address being deleted from the queue and later re-added.
func (c *client) ListRegistry(ctx context.Context) ([]registryItem, error) {
var out []registryItem
err := c.do(ctx, http.MethodGet, "/api/v1/admin/registry", nil, &out)
// ScanStatus returns the progress of the background scan job.
func (c *client) ScanStatus(ctx context.Context) (scanStatusDTO, error) {
var out scanStatusDTO
err := c.do(ctx, http.MethodGet, "/api/v1/admin/ips/scan", nil, &out)
return out, err
}
// registryQuery selects one page of GET /admin/registry.
type registryQuery struct {
Q string
LastResult string
Limit int
Offset int
}
// ListRegistryPage returns one page of the registry (every address ever
// submitted to the check queue, each with a summary of its accumulated check
// history — survives an address being deleted from the queue and later
// re-added) plus the total number of rows matching the filter.
func (c *client) ListRegistryPage(ctx context.Context, q registryQuery) (registryPage, error) {
v := url.Values{}
v.Set("limit", strconv.Itoa(clampLimit(q.Limit)))
if q.Offset > 0 {
v.Set("offset", strconv.Itoa(q.Offset))
}
if q.Q != "" {
v.Set("q", q.Q)
}
if q.LastResult != "" {
v.Set("last_result", q.LastResult)
}
var out registryPage
err := c.do(ctx, http.MethodGet, "/api/v1/admin/registry?"+v.Encode(), nil, &out)
return out, err
}
+194 -16
View File
@@ -9,6 +9,8 @@ import (
"net/http/httptest"
"net/url"
"os"
"sort"
"strconv"
"strings"
"sync"
"testing"
@@ -42,6 +44,27 @@ type fakeControlAPI struct {
registry map[string]registryItem
registryChecks map[string][]check
// Scan job state machine (see the scan handlers): POST starts a job that
// stays "running" for scanRunPolls GET polls (0 = finishes at once), then
// ends as done — or as error when scanFinalError is set. scan is the status
// served by GET; tests may also set it directly (with scanPollsLeft == 0 it
// stays as is). scanStartStatus != 0 makes POST fail with that HTTP status.
scan scanStatusDTO
scanRunPolls int
scanPollsLeft int
scanFinalError string
scanStartStatus int
// Requests seen on the list endpoints, for "never loads everything" checks:
// the raw query of every GET /ips (ipsQueries) and the number of GET
// /registry calls without `limit` (bare), plus the sizes of the DeleteIPs /
// SubmitIPs bulk calls.
ipsQueries []string
registryQueries []string
bareIPsCalls int
deleteChunks []int
submitChunks []int
// autoCycle is the state served by /api/v1/admin/auto-cycle*;
// autoCycleDown makes all four endpoints answer 500 (unavailable API).
autoCycle autoCycleDTO
@@ -86,16 +109,72 @@ func (f *fakeControlAPI) handler() http.Handler {
f.mu.Lock()
defer f.mu.Unlock()
byState := map[string]int{}
results := map[string]int{"pass": 0, "partial": 0, "fail": 0, "cancelled": 0}
for _, ip := range f.ips {
byState[ip.State]++
if ip.OverallResult != "" {
results[ip.OverallResult]++
}
}
writeJSON(w, http.StatusOK, statusResponse{TotalIPs: len(f.ips), IPsByState: byState, TotalValidators: len(f.validators)})
writeJSON(w, http.StatusOK, statusResponse{TotalIPs: len(f.ips), IPsByState: byState, TotalValidators: len(f.validators), ResultsByOverall: results})
})
mux.HandleFunc("GET /api/v1/admin/ips", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
writeJSON(w, http.StatusOK, f.ips)
f.ipsQueries = append(f.ipsQueries, r.URL.RawQuery)
qv := r.URL.Query()
if qv.Get("limit") == "" {
// Legacy shape: the whole queue as a bare array.
f.bareIPsCalls++
writeJSON(w, http.StatusOK, f.ips)
return
}
limit, err := strconv.Atoi(qv.Get("limit"))
if err != nil || limit < 1 || limit > 1000 {
writeAPIErr(w, http.StatusBadRequest, "limit must be 1..1000")
return
}
offset, _ := strconv.Atoi(qv.Get("offset"))
var states map[string]bool
if st := qv.Get("state"); st != "" {
states = map[string]bool{}
for _, x := range strings.Split(st, ",") {
states[x] = true
}
}
var matched []ipQueueItem
for _, ip := range f.ips {
if states != nil && !states[ip.State] {
continue
}
if q := qv.Get("q"); q != "" && !strings.Contains(strings.ToLower(ip.IPAddress), strings.ToLower(q)) {
continue
}
if res := qv.Get("result"); res != "" && ip.OverallResult != res {
continue
}
matched = append(matched, ip)
}
if qv.Get("order") == "aggregated_at_desc" {
sort.SliceStable(matched, func(i, j int) bool {
a, b := matched[i].AggregatedAt, matched[j].AggregatedAt
if a == nil || b == nil {
return a != nil && b == nil
}
return a.After(*b)
})
} else {
sort.SliceStable(matched, func(i, j int) bool { return matched[i].Sequence < matched[j].Sequence })
}
page := []ipQueueItem{}
if offset < len(matched) {
page = matched[offset:]
if len(page) > limit {
page = page[:limit]
}
}
writeJSON(w, http.StatusOK, ipsPage{Items: page, Total: len(matched), Limit: limit, Offset: offset})
})
mux.HandleFunc("GET /api/v1/admin/ips/{ip}", func(w http.ResponseWriter, r *http.Request) {
@@ -122,6 +201,7 @@ func (f *fakeControlAPI) handler() http.Handler {
writeAPIErr(w, http.StatusBadRequest, "addresses must not be empty")
return
}
f.submitChunks = append(f.submitChunks, len(req.Addresses))
resp := submitIPsResponse{}
for _, addr := range req.Addresses {
idx := f.findIP(addr)
@@ -190,6 +270,7 @@ func (f *fakeControlAPI) handler() http.Handler {
writeAPIErr(w, http.StatusBadRequest, "addresses must not be empty")
return
}
f.deleteChunks = append(f.deleteChunks, len(req.Addresses))
resp := deleteIPsResponse{}
for _, addr := range req.Addresses {
idx := f.findIP(addr)
@@ -210,6 +291,7 @@ func (f *fakeControlAPI) handler() http.Handler {
for _, ip := range f.ips {
resp.Deleted = append(resp.Deleted, ip.IPAddress)
}
resp.Count = len(resp.Deleted)
f.ips = nil
writeJSON(w, http.StatusOK, resp)
})
@@ -246,18 +328,32 @@ func (f *fakeControlAPI) handler() http.Handler {
mux.HandleFunc("POST /api/v1/admin/ips/scan", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
resp := scanIPsResponse{ScannedFree: len(f.scanFreeAddresses)}
for _, addr := range f.scanFreeAddresses {
idx := f.findIP(addr)
if idx < 0 {
now := time.Now()
f.ips = append(f.ips, ipQueueItem{IPAddress: addr, State: "queued", CreatedAt: now, UpdatedAt: now})
resp.Added = append(resp.Added, addr)
continue
}
resp.Reordered = append(resp.Reordered, addr)
if f.scanStartStatus != 0 {
writeAPIErr(w, f.scanStartStatus, "scan refused")
return
}
writeJSON(w, http.StatusOK, resp)
if !f.scan.Running {
now := time.Now()
f.scan = scanStatusDTO{State: "listing", Running: true, DryRun: r.URL.Query().Get("dry_run") == "true", StartedAt: &now}
f.scanPollsLeft = f.scanRunPolls
if f.scanPollsLeft == 0 {
f.finishScan()
}
}
writeJSON(w, http.StatusAccepted, f.scan)
})
mux.HandleFunc("GET /api/v1/admin/ips/scan", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
if f.scan.Running && f.scanPollsLeft > 0 {
f.scanPollsLeft--
if f.scanPollsLeft == 0 {
f.finishScan()
} else {
f.scan.Pages++
}
}
writeJSON(w, http.StatusOK, f.scan)
})
mux.HandleFunc("GET /api/v1/admin/auto-cycle", func(w http.ResponseWriter, r *http.Request) {
@@ -329,11 +425,37 @@ func (f *fakeControlAPI) handler() http.Handler {
mux.HandleFunc("GET /api/v1/admin/registry", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
out := make([]registryItem, 0, len(f.registry))
qv := r.URL.Query()
f.registryQueries = append(f.registryQueries, r.URL.RawQuery)
matched := make([]registryItem, 0, len(f.registry))
for _, item := range f.registry {
out = append(out, item)
if q := qv.Get("q"); q != "" && !strings.Contains(strings.ToLower(item.IPAddress), strings.ToLower(q)) {
continue
}
if lr := qv.Get("last_result"); lr != "" && item.LastResult != lr {
continue
}
matched = append(matched, item)
}
writeJSON(w, http.StatusOK, out)
sort.Slice(matched, func(i, j int) bool { return matched[i].IPAddress < matched[j].IPAddress })
if qv.Get("limit") == "" {
writeJSON(w, http.StatusOK, matched)
return
}
limit, err := strconv.Atoi(qv.Get("limit"))
if err != nil || limit < 1 || limit > 1000 {
writeAPIErr(w, http.StatusBadRequest, "limit must be 1..1000")
return
}
offset, _ := strconv.Atoi(qv.Get("offset"))
page := []registryItem{}
if offset < len(matched) {
page = matched[offset:]
if len(page) > limit {
page = page[:limit]
}
}
writeJSON(w, http.StatusOK, registryPage{Items: page, Total: len(matched), Limit: limit, Offset: offset})
})
mux.HandleFunc("GET /api/v1/admin/registry/{ip}", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
@@ -544,6 +666,51 @@ func (f *fakeControlAPI) handler() http.Handler {
})
}
// finishScan ends the running scan job (caller holds f.mu): the discovered
// free addresses are queued unless it was a dry run, or the job fails with
// scanFinalError.
func (f *fakeControlAPI) finishScan() {
now := time.Now()
f.scan.Running = false
f.scan.FinishedAt = &now
f.scan.Discovered = len(f.scanFreeAddresses)
f.scan.Free = len(f.scanFreeAddresses)
if f.scanFinalError != "" {
f.scan.State = "error"
f.scan.Error = f.scanFinalError
return
}
f.scan.State = "done"
if f.scan.DryRun {
return
}
for _, addr := range f.scanFreeAddresses {
if f.findIP(addr) < 0 {
f.ips = append(f.ips, ipQueueItem{IPAddress: addr, State: "queued", Sequence: len(f.ips) + 1, CreatedAt: now, UpdatedAt: now})
f.scan.Added++
} else {
f.scan.Reordered++
}
}
}
// seedIPs appends n queued addresses 10.x.y.z (distinct, in sequence order)
// and returns them. Use it for tests that need pages' worth of rows.
func (f *fakeControlAPI) seedIPs(n int) []string {
f.mu.Lock()
defer f.mu.Unlock()
now := time.Now()
out := make([]string, 0, n)
base := len(f.ips)
for i := 0; i < n; i++ {
k := base + i + 1
addr := fmt.Sprintf("10.%d.%d.%d", k/65536, (k/256)%256, k%256)
f.ips = append(f.ips, ipQueueItem{IPAddress: addr, State: "queued", Sequence: k, CreatedAt: now, UpdatedAt: now})
out = append(out, addr)
}
return out
}
func (f *fakeControlAPI) findIP(addr string) int {
for i, ip := range f.ips {
if ip.IPAddress == addr {
@@ -596,3 +763,14 @@ func postForm(t *testing.T, ts *httptest.Server, method, path string, form url.V
body, _ := io.ReadAll(resp.Body)
return string(body)
}
// newSlowAPI serves an empty JSON object for every request after delay.
func newSlowAPI(t *testing.T, delay time.Duration) string {
t.Helper()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(delay)
writeJSON(w, http.StatusOK, map[string]interface{}{})
}))
t.Cleanup(ts.Close)
return ts.URL
}
+151 -6
View File
@@ -1,6 +1,7 @@
package dashboard
import (
"fmt"
"strconv"
"time"
)
@@ -20,6 +21,27 @@ type statusResponse struct {
TotalIPs int `json:"total_ips"`
IPsByState map[string]int `json:"ips_by_state"`
TotalValidators int `json:"total_validators"`
// ResultsByOverall counts finished addresses by overall result
// (pass/partial/fail/cancelled).
ResultsByOverall map[string]int `json:"results_by_overall"`
}
// Terminal queue states: the address needs no further processing. Shared by
// the overview progress indicator; "occupied" counts as terminal too (the
// check cycle never ran because the floating IP was already bound).
var terminalStates = []string{"done", "failed", "occupied"}
// activeStates are the states of an address that is being worked on right
// now (everything between "queued" and a terminal state).
var activeStates = []string{"assigning_fip", "awaiting_self_check", "checking", "aggregating"}
// sumStates adds up the counts of the given states in a status breakdown.
func sumStates(byState map[string]int, states []string) int {
n := 0
for _, s := range states {
n += byState[s]
}
return n
}
type ipQueueItem struct {
@@ -99,14 +121,135 @@ type deleteIPsResponse struct {
type clearQueueResponse struct {
Deleted []string `json:"deleted"`
Count int `json:"count"`
}
type scanIPsResponse struct {
ScannedFree int `json:"scanned_free"`
Added []string `json:"added"`
Requeued []string `json:"requeued"`
Reordered []string `json:"reordered"`
SkippedInProgress []string `json:"skipped_in_progress"`
// ipsPage is the paginated envelope of GET /admin/ips (sent when the request
// carries `limit`).
type ipsPage struct {
Items []ipQueueItem `json:"items"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
// registryPage is the paginated envelope of GET /admin/registry.
type registryPage struct {
Items []registryItem `json:"items"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
// scanStatusDTO is the state of control-api's background floating-IP scan job
// (POST/GET /api/v1/admin/ips/scan).
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"`
}
// Finished reports a job that has run to a terminal state (as opposed to
// "idle" = never started, or still running).
func (s scanStatusDTO) Finished() bool {
if s.Running {
return false
}
switch s.State {
case "done", "error", "cancelled":
return true
}
return false
}
// StateLabel is the Russian description of the job's state.
func (s scanStatusDTO) StateLabel() string {
switch s.State {
case "clearing":
return "очистка"
case "listing":
return "читаются страницы"
case "enqueuing":
return "ставятся в очередь"
case "done":
return "готово"
case "error":
return "ошибка"
case "cancelled":
return "отменено"
case "idle", "":
return "нет активного сканирования"
default:
return s.State
}
}
// PillClass picks the pill style for the state.
func (s scanStatusDTO) PillClass() string {
switch s.State {
case "done":
return "pill-success"
case "error":
return "pill-danger"
case "cancelled":
return "pill-cancel"
case "clearing", "listing", "enqueuing":
return "pill-info"
default:
return "pill-neutral"
}
}
// Handled is how many of the free addresses the enqueuing phase has already
// processed.
func (s scanStatusDTO) Handled() int {
return s.Added + s.Requeued + s.Reordered + s.SkippedInProgress
}
// Indeterminate is true while the amount of work is not known yet.
func (s scanStatusDTO) Indeterminate() bool {
return s.State == "clearing" || s.State == "listing" || (s.State == "enqueuing" && s.Free <= 0)
}
// Elapsed is the human-readable run time: until now while running, until
// finished_at afterwards; empty when the job never started.
func (s scanStatusDTO) Elapsed() string {
if s.StartedAt == nil {
return ""
}
end := time.Now()
if !s.Running && s.FinishedAt != nil {
end = *s.FinishedAt
}
return fmtDuration(end.Sub(*s.StartedAt))
}
// fmtDuration renders a duration in Russian as "2 ч 05 мин", "3 мин 07 с" or
// "42 с" — coarse on purpose (progress/ETA display).
func fmtDuration(d time.Duration) string {
if d < 0 {
d = 0
}
sec := int(d.Round(time.Second) / time.Second)
h, m, sc := sec/3600, (sec%3600)/60, sec%60
switch {
case h > 0:
return fmt.Sprintf("%d ч %02d мин", h, m)
case m > 0:
return fmt.Sprintf("%d мин %02d с", m, sc)
default:
return fmt.Sprintf("%d с", sc)
}
}
// registryItem is one row of the durable per-address registry — see
@@ -200,6 +343,8 @@ func (a autoCycleDTO) MaxRunMinutes() string { return secondsToMinutes(a.MaxRu
// PhaseLabel is the Russian description of the current phase.
func (a autoCycleDTO) PhaseLabel() string {
switch a.Phase {
case "scanning":
return "сканирование Floating IP"
case "running":
return "идёт проверка"
case "waiting":
+279 -38
View File
@@ -1,14 +1,122 @@
package dashboard
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
)
// bulkChunk is how many addresses go into one DeleteIPs/SubmitIPs call when
// an operation spans a whole filter ("scope=all").
const bulkChunk = 500
// ipStates are the queue states control-api accepts in the `state` filter.
var ipStates = []string{"queued", "assigning_fip", "awaiting_self_check", "checking", "aggregating", "done", "failed", "occupied"}
var ipResults = []string{"pass", "partial", "fail", "cancelled"}
func containsStr(list []string, s string) bool {
for _, v := range list {
if v == s {
return true
}
}
return false
}
// ipsFilter is the server-side filter of the /ips list. State is a filter
// token: one of "", "queued", "active" (every in-progress state), "done",
// "failed", "occupied", or a comma-separated list of raw queue states; the
// result filter is separate (Result) and may be combined with it.
type ipsFilter struct {
Q string
State string
Result string
}
// parseIPsFilter reads q/state/result from the request (query or form body).
// The state <select> posts a single `state` value, so "result:pass" selects a
// result filter; anything unrecognised is dropped rather than forwarded.
func parseIPsFilter(r *http.Request) ipsFilter {
f := ipsFilter{
Q: strings.TrimSpace(r.FormValue("q")),
State: strings.TrimSpace(r.FormValue("state")),
Result: strings.TrimSpace(r.FormValue("result")),
}
if res, ok := strings.CutPrefix(f.State, "result:"); ok {
f.State, f.Result = "", res
}
if !containsStr(ipResults, f.Result) {
f.Result = ""
}
switch f.State {
case "", "active":
default:
for _, st := range strings.Split(f.State, ",") {
if !containsStr(ipStates, st) {
f.State = ""
break
}
}
}
return f
}
// States expands the state token into the list sent to control-api.
func (f ipsFilter) States() []string {
switch f.State {
case "":
return nil
case "active":
return activeStates
default:
return strings.Split(f.State, ",")
}
}
// Active reports whether any filter is applied.
func (f ipsFilter) Active() bool { return f.Q != "" || f.State != "" || f.Result != "" }
// Token is the value of the state <select> option matching the filter.
func (f ipsFilter) Token() string {
if f.State == "" && f.Result != "" {
return "result:" + f.Result
}
return f.State
}
func (f ipsFilter) values() url.Values {
v := url.Values{}
if f.Q != "" {
v.Set("q", f.Q)
}
if f.State != "" {
v.Set("state", f.State)
}
if f.Result != "" {
v.Set("result", f.Result)
}
return v
}
type ipsPageData struct {
PageData
Items []ipQueueItem
FIPSettleSeconds int
Filter ipsFilter
Page, PerPage int
// Total is the number of rows matching the filter; QueueTotal is the whole
// queue (what "Очистить всё" would remove).
Total, QueueTotal int
Pager pagerData
// SelfURL is this page's own URL (filter + page), re-requested to reload
// the table when a scan finishes.
SelfURL string
PerPageOptions []int
Scan scanProgressData
}
type ipDetailData struct {
@@ -16,13 +124,67 @@ type ipDetailData struct {
Detail ipDetailResponse
}
func (s *Server) handleIPsPage(w http.ResponseWriter, r *http.Request) {
items, err := s.CA.ListIPs(r.Context())
settings, settingsErr := s.CA.GetOrchestratorSettings(r.Context())
// loadIPsData fetches the page of the queue selected by the request's
// page/per_page/q/state/result params. A page that became empty (e.g. after
// deleting its last rows) is clamped to the last non-empty page.
func (s *Server) loadIPsData(r *http.Request) (ipsPageData, error) {
ctx := r.Context()
f := parseIPsFilter(r)
perPage := parsePerPage(r.FormValue("per_page"))
page := parsePage(r.FormValue("page"))
q := ipsQuery{States: f.States(), Q: f.Q, Result: f.Result, Order: "sequence", Limit: perPage, Offset: (page - 1) * perPage}
res, err := s.CA.ListIPsPage(ctx, q)
if err == nil {
err = settingsErr
if clamped := clampPage(page, res.Total, perPage); clamped != page {
page = clamped
q.Offset = (page - 1) * perPage
res, err = s.CA.ListIPsPage(ctx, q)
}
}
data := ipsPageData{
Items: res.Items,
Filter: f,
Page: page,
PerPage: perPage,
Total: res.Total,
QueueTotal: res.Total,
PerPageOptions: perPageOptions,
}
data.Pager = newPager("/ips", "ips-table-wrap", f.values(), page, perPage, res.Total)
data.SelfURL = pageURL("/ips", f.values(), page, perPage)
if settings, settingsErr := s.CA.GetOrchestratorSettings(ctx); settingsErr != nil {
if err == nil {
err = settingsErr
}
} else {
data.FIPSettleSeconds = settings.FIPSettleSeconds
}
if err == nil && f.Active() {
// "Очистить всё" ignores the filter: show the real queue size in its
// confirmation. Non-fatal — the label just falls back to the filtered total.
if st, stErr := s.CA.Status(ctx); stErr == nil {
data.QueueTotal = st.TotalIPs
}
}
return data, err
}
func (s *Server) handleIPsPage(w http.ResponseWriter, r *http.Request) {
data, err := s.loadIPsData(r)
// A filter/pager request from htmx swaps only #ips-table-wrap (hx-select),
// so there is no need to re-render the whole page (and re-query the scan
// status). A history-restore fetch needs the full page.
if r.Header.Get("HX-Request") == "true" && r.Header.Get("HX-History-Restore-Request") != "true" {
s.renderFragment(w, "ips_table_wrap", data, err)
return
}
if st, scanErr := s.CA.ScanStatus(r.Context()); scanErr != nil {
s.Log.Warn("ips: scan status unavailable", "err", scanErr)
} else {
data.Scan = newScanProgress(st)
}
data := ipsPageData{Items: items, FIPSettleSeconds: settings.FIPSettleSeconds}
data.ActiveNav = "ips"
data.Banner = bannerFor(err)
s.renderPage(w, r, "ips_page", data)
@@ -37,20 +199,18 @@ func (s *Server) handleIPDetail(w http.ResponseWriter, r *http.Request) {
s.renderPage(w, r, "ip_detail_page", data)
}
// renderIPsTable re-fetches the current queue and renders the ips_table
// fragment, tagging actionErr (if any) on the shared error banner. Called
// after every mutating /ips/* request so the table always reflects true
// current state regardless of whether the mutation itself succeeded.
// renderIPsTable re-fetches the current page of the queue (same page/filter as
// the request, carried in hidden #ips-form inputs) and renders the
// ips_table_wrap fragment, tagging actionErr (if any) on the shared error
// banner. Called after every mutating /ips/* request so the table always
// reflects true current state regardless of whether the mutation itself
// succeeded.
func (s *Server) renderIPsTable(w http.ResponseWriter, r *http.Request, actionErr error) {
items, listErr := s.CA.ListIPs(r.Context())
data, err := s.loadIPsData(r)
if actionErr == nil {
actionErr = listErr
actionErr = err
}
settings, settingsErr := s.CA.GetOrchestratorSettings(r.Context())
if actionErr == nil {
actionErr = settingsErr
}
s.renderFragment(w, "ips_table", ipsPageData{Items: items, FIPSettleSeconds: settings.FIPSettleSeconds}, actionErr)
s.renderFragment(w, "ips_table_wrap", data, actionErr)
}
func (s *Server) handleIPsSubmit(w http.ResponseWriter, r *http.Request) {
@@ -63,7 +223,7 @@ func (s *Server) handleIPsSubmit(w http.ResponseWriter, r *http.Request) {
s.renderIPsTable(w, r, &apiErr{Status: http.StatusBadRequest, Message: "укажите хотя бы один адрес"})
return
}
_, err := s.CA.SubmitIPs(r.Context(), addresses)
err := s.submitChunked(r.Context(), addresses)
s.renderIPsTable(w, r, err)
}
@@ -85,31 +245,76 @@ func (s *Server) handleIPDelete(w http.ResponseWriter, r *http.Request) {
s.renderIPsTable(w, r, err)
}
func (s *Server) handleIPsDeleteSelected(w http.ResponseWriter, r *http.Request) {
// submitChunked feeds addresses to SubmitIPs in chunks of bulkChunk so one
// huge list never becomes one huge request/transaction.
func (s *Server) submitChunked(ctx context.Context, addresses []string) error {
for _, part := range chunk(addresses, bulkChunk) {
if _, err := s.CA.SubmitIPs(ctx, part); err != nil {
return err
}
}
return nil
}
// resolveFilterAddresses lists every address matching filter by paging
// ListIPsPage (≤ maxPageLimit rows per call), for "select all N by filter".
func (s *Server) resolveFilterAddresses(ctx context.Context, f ipsFilter) ([]string, error) {
var out []string
for offset := 0; ; {
page, err := s.CA.ListIPsPage(ctx, ipsQuery{States: f.States(), Q: f.Q, Result: f.Result, Order: "sequence", Limit: maxPageLimit, Offset: offset})
if err != nil {
return nil, err
}
for _, it := range page.Items {
out = append(out, it.IPAddress)
}
offset += len(page.Items)
if len(page.Items) == 0 || offset >= page.Total {
return out, nil
}
}
}
// bulkAddresses returns the addresses a bulk delete/recheck acts on: the
// checked rows of the current page, or — with scope=all — everything that
// matches the current filter, resolved server-side.
func (s *Server) bulkAddresses(r *http.Request) ([]string, error) {
if err := r.ParseForm(); err != nil {
s.renderIPsTable(w, r, fmt.Errorf("invalid form: %w", err))
return
return nil, fmt.Errorf("invalid form: %w", err)
}
var addresses []string
if r.FormValue("scope") == "all" {
var err error
addresses, err = s.resolveFilterAddresses(r.Context(), parseIPsFilter(r))
if err != nil {
return nil, err
}
} else {
addresses = r.Form["addresses"]
}
addresses := r.Form["addresses"]
if len(addresses) == 0 {
s.renderIPsTable(w, r, &apiErr{Status: http.StatusBadRequest, Message: "ничего не выбрано"})
return
return nil, &apiErr{Status: http.StatusBadRequest, Message: "ничего не выбрано"}
}
return addresses, nil
}
func (s *Server) handleIPsDeleteSelected(w http.ResponseWriter, r *http.Request) {
addresses, err := s.bulkAddresses(r)
if err == nil {
for _, part := range chunk(addresses, bulkChunk) {
if _, err = s.CA.DeleteIPs(r.Context(), part); err != nil {
break
}
}
}
_, err := s.CA.DeleteIPs(r.Context(), addresses)
s.renderIPsTable(w, r, err)
}
func (s *Server) handleIPsRecheckSelected(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.renderIPsTable(w, r, fmt.Errorf("invalid form: %w", err))
return
addresses, err := s.bulkAddresses(r)
if err == nil {
err = s.submitChunked(r.Context(), addresses)
}
addresses := r.Form["addresses"]
if len(addresses) == 0 {
s.renderIPsTable(w, r, &apiErr{Status: http.StatusBadRequest, Message: "ничего не выбрано"})
return
}
_, err := s.CA.SubmitIPs(r.Context(), addresses)
s.renderIPsTable(w, r, err)
}
@@ -118,10 +323,46 @@ func (s *Server) handleIPsClear(w http.ResponseWriter, r *http.Request) {
s.renderIPsTable(w, r, err)
}
// handleIPsScan lists the OpenStack project's free (unassociated) floating
// IPs and submits them to the check queue in one step — see
// client.ScanFloatingIPs.
// scanProgressData drives the scan_progress partial. Poll keeps the partial's
// own hx-trigger="every 2s" alive: while the job runs, or while control-api is
// transiently unreachable (so one failed poll doesn't freeze the panel).
type scanProgressData struct {
Status scanStatusDTO
Poll bool
}
func newScanProgress(st scanStatusDTO) scanProgressData {
return scanProgressData{Status: st, Poll: st.Running}
}
// renderScanProgress renders the scan panel fragment. A finished job also
// sends `HX-Trigger: scan-finished`, which makes #ips-table-wrap reload.
// Errors go to the shared banner; only transient ones (transport/5xx) keep
// polling when keepPolling is set.
func (s *Server) renderScanProgress(w http.ResponseWriter, st scanStatusDTO, err error, keepPolling bool) {
data := newScanProgress(st)
if err != nil {
data = scanProgressData{}
var ae *apiErr
if keepPolling && errors.As(err, &ae) && (ae.Status == 0 || ae.Status >= 500) {
data.Poll = true
}
} else if st.Finished() {
w.Header().Set("HX-Trigger", "scan-finished")
}
s.renderFragment(w, "scan_progress", data, err)
}
// handleIPsScan starts the background floating-IP scan (or joins the running
// one) and returns the progress panel at once — the job itself runs in
// control-api, so this never waits for OpenStack. ?dry_run=true only counts.
func (s *Server) handleIPsScan(w http.ResponseWriter, r *http.Request) {
_, err := s.CA.ScanFloatingIPs(r.Context())
s.renderIPsTable(w, r, err)
st, err := s.CA.StartScan(r.Context(), r.URL.Query().Get("dry_run") == "true")
s.renderScanProgress(w, st, err, false)
}
// handleIPsScanStatus is the progress panel's poll target.
func (s *Server) handleIPsScanStatus(w http.ResponseWriter, r *http.Request) {
st, err := s.CA.ScanStatus(r.Context())
s.renderScanProgress(w, st, err, true)
}
+109 -71
View File
@@ -2,23 +2,51 @@ package dashboard
import (
"net/http"
"sort"
"strings"
"time"
)
const (
// overviewActiveLimit caps the "В работе" table; overviewQueueLimit caps
// the compact list of the next queued addresses. The queue itself can hold
// thousands of rows, so the overview never lists more than these.
overviewActiveLimit = 100
overviewQueueLimit = 10
// etaMinSamples is how many completed rows (with AggregatedAt) the ETA
// needs to derive a rate from.
etaMinSamples = 5
)
// overviewProgress is the "Готово D из T" indicator of the stats block.
type overviewProgress struct {
Total, Done, Active, Queued, Percent int
// ETA is a rough time-to-finish estimate ("" when not enough data).
ETA string
}
type overviewData struct {
PageData
Status statusResponse
CurrentItems []ipQueueItem
Status statusResponse
// ActiveItems are the addresses being checked right now (≤ overviewActiveLimit
// of ActiveTotal); QueuedItems are the next few waiting ones (QueuedTotal
// in total). Both stay empty under a result filter: an address without a
// verdict can't match one.
ActiveItems []ipQueueItem
ActiveTotal int
QueuedItems []ipQueueItem
QueuedTotal int
LastCompleted []ipQueueItem
Breakdown map[string]int
LastN int
PollSeconds int
Query string
StatusFilter string
Progress overviewProgress
// AutoCycle is nil when the auto-cycle status could not be fetched; the
// indicator is then simply hidden (a non-fatal failure).
// indicator is then simply hidden (a non-fatal failure). Scan is likewise
// nil when the scan status is unavailable.
AutoCycle *autoCycleDTO
Scan *scanStatusDTO
}
func (s *Server) loadOverview(r *http.Request) (overviewData, error) {
@@ -27,30 +55,58 @@ func (s *Server) loadOverview(r *http.Request) (overviewData, error) {
if err != nil {
return overviewData{}, err
}
ips, err := s.CA.ListIPs(ctx)
q := strings.TrimSpace(r.URL.Query().Get("q"))
resultFilter := r.URL.Query().Get("status")
if !containsStr(ipResults, resultFilter) {
resultFilter = ""
}
lastN := s.Cfg.LastCompletedCount
if lastN < 1 {
lastN = 20
}
data := overviewData{
Status: status,
LastN: lastN,
PollSeconds: s.Cfg.OverviewPollIntervalS,
Query: q,
StatusFilter: resultFilter,
}
if resultFilter == "" {
active, err := s.CA.ListIPsPage(ctx, ipsQuery{States: activeStates, Q: q, Order: "sequence", Limit: overviewActiveLimit})
if err != nil {
return overviewData{}, err
}
queued, err := s.CA.ListIPsPage(ctx, ipsQuery{States: []string{"queued"}, Q: q, Order: "sequence", Limit: overviewQueueLimit})
if err != nil {
return overviewData{}, err
}
data.ActiveItems, data.ActiveTotal = active.Items, active.Total
data.QueuedItems, data.QueuedTotal = queued.Items, queued.Total
}
completed, err := s.CA.ListIPsPage(ctx, ipsQuery{States: []string{"done", "failed"}, Q: q, Result: resultFilter, Order: "aggregated_at_desc", Limit: lastN})
if err != nil {
return overviewData{}, err
}
var autoCycle *autoCycleDTO
data.LastCompleted = completed.Items
data.Breakdown = resultBreakdown(completed.Items)
// ETA from the unfiltered window only: a filtered list is not a sample of
// the checks' real throughput.
data.Progress = computeProgress(status, completed.Items, q == "" && resultFilter == "")
if ac, acErr := s.CA.GetAutoCycle(ctx); acErr != nil {
s.Log.Warn("overview: auto-cycle status unavailable", "err", acErr)
} else {
autoCycle = &ac
data.AutoCycle = &ac
}
q := strings.TrimSpace(r.URL.Query().Get("q"))
resultFilter := r.URL.Query().Get("status")
last := lastCompleted(ips, s.Cfg.LastCompletedCount)
return overviewData{
Status: status,
CurrentItems: filterQueueItems(currentlyChecking(ips), q, resultFilter),
LastCompleted: filterQueueItems(last, q, resultFilter),
Breakdown: resultBreakdown(last),
LastN: s.Cfg.LastCompletedCount,
PollSeconds: s.Cfg.OverviewPollIntervalS,
Query: q,
StatusFilter: resultFilter,
AutoCycle: autoCycle,
}, nil
if sc, scErr := s.CA.ScanStatus(ctx); scErr != nil {
s.Log.Warn("overview: scan status unavailable", "err", scErr)
} else if sc.Running {
data.Scan = &sc
}
return data, nil
}
func (s *Server) handleOverview(w http.ResponseWriter, r *http.Request) {
@@ -75,40 +131,45 @@ func (s *Server) handleOverviewFragment(w http.ResponseWriter, r *http.Request)
}
}
// currentlyChecking is every IP not yet in a terminal state, ordered by
// queue position — the "текущая проверка" live snapshot. No backend
// concept of a "run" exists; this is computed fresh on every request.
func currentlyChecking(ips []ipQueueItem) []ipQueueItem {
var out []ipQueueItem
for _, ip := range ips {
if ip.State != "done" && ip.State != "failed" {
out = append(out, ip)
// computeProgress derives the done/active/queued counters from the status
// breakdown (done = every terminal state, "occupied" included) and, when
// useETA is set and the newest-first list of completed rows has at least
// etaMinSamples with AggregatedAt, a rough ETA from their completion rate.
func computeProgress(st statusResponse, completed []ipQueueItem, useETA bool) overviewProgress {
p := overviewProgress{
Total: st.TotalIPs,
Done: sumStates(st.IPsByState, terminalStates),
Active: sumStates(st.IPsByState, activeStates),
Queued: st.IPsByState["queued"],
}
if p.Total > 0 {
p.Percent = p.Done * 100 / p.Total
}
remaining := p.Active + p.Queued
if !useETA || remaining == 0 {
return p
}
var stamps []time.Time
for _, ip := range completed {
if ip.AggregatedAt != nil {
stamps = append(stamps, *ip.AggregatedAt)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Sequence < out[j].Sequence })
return out
}
// lastCompleted returns the n most recently completed (done/failed) IPs by
// AggregatedAt descending — the "последняя завершённая проверка" summary
// window. This is an operational definition, not a real "batch": resubmit
// n if the window size needs tuning (overview.last_completed_count).
func lastCompleted(ips []ipQueueItem, n int) []ipQueueItem {
var done []ipQueueItem
for _, ip := range ips {
if (ip.State == "done" || ip.State == "failed") && ip.AggregatedAt != nil {
done = append(done, ip)
}
if len(stamps) < etaMinSamples {
return p
}
sort.Slice(done, func(i, j int) bool { return done[i].AggregatedAt.After(*done[j].AggregatedAt) })
if len(done) > n {
done = done[:n]
// completed is newest-first: stamps[0] is the newest, the last the oldest.
span := stamps[0].Sub(stamps[len(stamps)-1])
if span <= 0 {
return p
}
return done
perItem := span / time.Duration(len(stamps)-1)
p.ETA = fmtDuration(perItem * time.Duration(remaining))
return p
}
// resultBreakdown counts OverallResult values across exactly the given
// items (normally the output of lastCompleted) — pass/partial/fail/cancelled.
// items (the "последние N завершённых" window) — pass/partial/fail/cancelled.
func resultBreakdown(items []ipQueueItem) map[string]int {
out := map[string]int{"pass": 0, "partial": 0, "fail": 0, "cancelled": 0}
for _, ip := range items {
@@ -116,26 +177,3 @@ func resultBreakdown(items []ipQueueItem) map[string]int {
}
return out
}
// filterQueueItems narrows items to those whose address contains q
// (case-insensitive substring) and, if status is set, whose OverallResult
// matches it exactly. A still-in-progress item always has an empty
// OverallResult, so picking any specific status hides it — the intended
// behavior for "Текущая проверка", which has no verdict yet.
func filterQueueItems(items []ipQueueItem, q, status string) []ipQueueItem {
if q == "" && status == "" {
return items
}
q = strings.ToLower(q)
out := make([]ipQueueItem, 0, len(items))
for _, ip := range items {
if q != "" && !strings.Contains(strings.ToLower(ip.IPAddress), q) {
continue
}
if status != "" && ip.OverallResult != status {
continue
}
out = append(out, ip)
}
return out
}
+40 -30
View File
@@ -2,14 +2,19 @@ package dashboard
import (
"net/http"
"net/url"
"strings"
)
type registryPageData struct {
PageData
Items []registryItem
Query string
StatusFilter string
Items []registryItem
Query string
StatusFilter string
Page, PerPage int
Total int
Pager pagerData
PerPageOptions []int
}
type registryDetailData struct {
@@ -22,43 +27,48 @@ type registryDetailData struct {
// record that survives an address being deleted from /ips and later
// re-added. See internal/db/migrations/0007_ip_registry.sql. Optional
// ?q=&status= query params narrow the list by address substring and by
// LastResult — see filterRegistryItems.
// LastResult, and ?page=&per_page= select a page — all applied server-side
// (control-api's ListRegistryPage), so only the visible rows are transferred.
func (s *Server) handleRegistryPage(w http.ResponseWriter, r *http.Request) {
items, err := s.CA.ListRegistry(r.Context())
q := strings.TrimSpace(r.URL.Query().Get("q"))
status := r.URL.Query().Get("status")
if !containsStr(ipResults, status) {
status = ""
}
perPage := parsePerPage(r.URL.Query().Get("per_page"))
page := parsePage(r.URL.Query().Get("page"))
query := registryQuery{Q: q, LastResult: status, Limit: perPage, Offset: (page - 1) * perPage}
res, err := s.CA.ListRegistryPage(r.Context(), query)
if err == nil {
if clamped := clampPage(page, res.Total, perPage); clamped != page {
page = clamped
query.Offset = (page - 1) * perPage
res, err = s.CA.ListRegistryPage(r.Context(), query)
}
}
params := url.Values{}
if q != "" {
params.Set("q", q)
}
if status != "" {
params.Set("status", status)
}
data := registryPageData{
Items: filterRegistryItems(items, q, status),
Query: q,
StatusFilter: status,
Items: res.Items,
Query: q,
StatusFilter: status,
Page: page,
PerPage: perPage,
Total: res.Total,
Pager: newPager("/registry", "registry-table-wrap", params, page, perPage, res.Total),
PerPageOptions: perPageOptions,
}
data.ActiveNav = "registry"
data.Banner = bannerFor(err)
s.renderPage(w, r, "registry_page", data)
}
// filterRegistryItems narrows items to those whose address contains q
// (case-insensitive substring) and, if status is set, whose LastResult
// matches it exactly — the registry list's search-by-IP and
// filter-by-status, mirroring filterQueueItems in handlers_overview.go.
func filterRegistryItems(items []registryItem, q, status string) []registryItem {
if q == "" && status == "" {
return items
}
q = strings.ToLower(q)
out := make([]registryItem, 0, len(items))
for _, it := range items {
if q != "" && !strings.Contains(strings.ToLower(it.IPAddress), q) {
continue
}
if status != "" && it.LastResult != status {
continue
}
out = append(out, it)
}
return out
}
// handleRegistryDetail shows one address's full retained check history
// across every cycle it has ever run, not just the current attempt — see
// ip_detail_content in ip_detail.html for the attempt-scoped equivalent.
-72
View File
@@ -558,20 +558,6 @@ func TestTargetsAndCheckTypesRoundTrip(t *testing.T) {
}
}
func TestIPsScan(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
fake.scanFreeAddresses = []string{"5.5.5.5"}
ts := newTestServer(t, caURL)
body := postForm(t, ts, "POST", "/ips/scan", nil)
if !strings.Contains(body, "5.5.5.5") {
t.Fatalf("expected scanned address in re-rendered table, got:\n%s", body)
}
if len(fake.ips) != 1 || fake.ips[0].IPAddress != "5.5.5.5" {
t.Fatalf("expected fake control-api queue to contain the scanned address, got %+v", fake.ips)
}
}
func TestRegistryPageAndDetail(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
now := time.Now()
@@ -667,64 +653,6 @@ func TestControlAPIUnreachable(t *testing.T) {
}
}
func TestFilterQueueItems(t *testing.T) {
items := []ipQueueItem{
{IPAddress: "1.1.1.1", OverallResult: "pass"},
{IPAddress: "1.1.1.2", OverallResult: "fail"},
{IPAddress: "2.2.2.2", OverallResult: "pass"},
}
if got := filterQueueItems(items, "", ""); len(got) != 3 {
t.Fatalf("expected no-op with empty q/status, got %+v", got)
}
if got := filterQueueItems(items, "1.1.1", ""); len(got) != 2 {
t.Fatalf("expected 2 matches for q=1.1.1, got %+v", got)
}
if got := filterQueueItems(items, "1.1.1.1", ""); len(got) != 1 || got[0].IPAddress != "1.1.1.1" {
t.Fatalf("expected exact-substring match, got %+v", got)
}
if got := filterQueueItems(items, "1.1.1.1", ""); len(got) != 1 {
t.Fatalf("expected search to be case/substring based, got %+v", got)
}
if got := filterQueueItems(items, "", "pass"); len(got) != 2 {
t.Fatalf("expected 2 matches for status=pass, got %+v", got)
}
if got := filterQueueItems(items, "1.1.1", "pass"); len(got) != 1 || got[0].IPAddress != "1.1.1.1" {
t.Fatalf("expected q+status combined with AND, got %+v", got)
}
if got := filterQueueItems(items, "9.9.9.9", ""); len(got) != 0 {
t.Fatalf("expected no matches, got %+v", got)
}
// Case-insensitivity, via a query with mixed-case letters (IP octets
// are numeric, so exercise it through IPv6-shaped input instead).
mixed := []ipQueueItem{{IPAddress: "fe80::AbCd"}}
if got := filterQueueItems(mixed, "abcd", ""); len(got) != 1 {
t.Fatalf("expected case-insensitive search to match, got %+v", got)
}
}
func TestFilterRegistryItems(t *testing.T) {
items := []registryItem{
{IPAddress: "1.1.1.1", LastResult: "pass"},
{IPAddress: "1.1.1.2", LastResult: "partial"},
{IPAddress: "2.2.2.2", LastResult: ""},
}
if got := filterRegistryItems(items, "", ""); len(got) != 3 {
t.Fatalf("expected no-op with empty q/status, got %+v", got)
}
if got := filterRegistryItems(items, "1.1.1", ""); len(got) != 2 {
t.Fatalf("expected 2 matches for q=1.1.1, got %+v", got)
}
if got := filterRegistryItems(items, "", "partial"); len(got) != 1 || got[0].IPAddress != "1.1.1.2" {
t.Fatalf("expected exactly the partial-result address, got %+v", got)
}
if got := filterRegistryItems(items, "2.2.2", "partial"); len(got) != 0 {
t.Fatalf("expected q+status combined with AND to exclude non-matching, got %+v", got)
}
}
func TestAutoCyclePanelRenders(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
finished := time.Now().Add(-time.Hour)
+118
View File
@@ -0,0 +1,118 @@
package dashboard
import (
"net/url"
"strconv"
"strings"
)
// Server-side pagination shared by /ips and /registry: `page` (1-based,
// clamped to the last page) and `per_page` (one of perPageOptions, else
// defaultPerPage).
const defaultPerPage = 50
var perPageOptions = []int{25, 50, 100, 200}
// parsePerPage accepts only the whitelisted page sizes.
func parsePerPage(raw string) int {
n, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil {
return defaultPerPage
}
for _, o := range perPageOptions {
if n == o {
return n
}
}
return defaultPerPage
}
// parsePage returns the 1-based page number; anything invalid is page 1.
func parsePage(raw string) int {
n, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil || n < 1 {
return 1
}
return n
}
func pageCount(total, perPage int) int {
if total <= 0 || perPage <= 0 {
return 1
}
return (total + perPage - 1) / perPage
}
// clampPage limits page to the last non-empty page for the given total.
func clampPage(page, total, perPage int) int {
if last := pageCount(total, perPage); page > last {
page = last
}
if page < 1 {
page = 1
}
return page
}
// pagerData drives the shared "pager" template partial.
type pagerData struct {
// Wrap is the id of the swap-target element holding the table (the
// ‹ › links replace it via hx-select + hx-target).
Wrap string
Page, PerPage, Total, Pages, From, To int
PrevURL, NextURL string
}
// newPager builds the pager for page (already clamped) of total rows. base is
// the page path; params are the filters to preserve in the links (without
// page/per_page, which the pager sets itself).
func newPager(base, wrap string, params url.Values, page, perPage, total int) pagerData {
p := pagerData{Wrap: wrap, Page: page, PerPage: perPage, Total: total, Pages: pageCount(total, perPage)}
if total > 0 {
p.From = (page-1)*perPage + 1
p.To = page * perPage
if p.To > total {
p.To = total
}
}
if page > 1 {
p.PrevURL = pageURL(base, params, page-1, perPage)
}
if page < p.Pages {
p.NextURL = pageURL(base, params, page+1, perPage)
}
return p
}
// pageURL builds base?...&page=N&per_page=M with every value escaped by
// url.Values (so a search string like "a&b" can't smuggle in a parameter).
// Page 1 omits `page`.
func pageURL(base string, params url.Values, page, perPage int) string {
v := url.Values{}
for k, vals := range params {
for _, val := range vals {
if val != "" {
v.Add(k, val)
}
}
}
v.Set("per_page", strconv.Itoa(perPage))
if page > 1 {
v.Set("page", strconv.Itoa(page))
}
return base + "?" + v.Encode()
}
// chunk splits list into slices of at most size elements.
func chunk(list []string, size int) [][]string {
var out [][]string
for len(list) > size {
out = append(out, list[:size])
list = list[size:]
}
if len(list) > 0 {
out = append(out, list)
}
return out
}
+1
View File
@@ -18,6 +18,7 @@ func (s *Server) routes(mux *http.ServeMux) {
mux.HandleFunc("GET /ips/{ip}", s.handleIPDetail)
mux.HandleFunc("POST /ips", s.handleIPsSubmit)
mux.HandleFunc("POST /ips/scan", s.handleIPsScan)
mux.HandleFunc("GET /ips/scan/status", s.handleIPsScanStatus)
mux.HandleFunc("POST /ips/{ip}/recheck", s.handleIPRecheck)
mux.HandleFunc("POST /ips/{ip}/cancel", s.handleIPCancel)
mux.HandleFunc("DELETE /ips/{ip}", s.handleIPDelete)
+685
View File
@@ -0,0 +1,685 @@
package dashboard
import (
"fmt"
"html"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"testing"
"time"
)
// Tests for the scale features: background scan panel, server-side
// pagination/filters on /ips and /registry, bulk selection by filter, and the
// bounded overview.
func ipRowLink(ip string) string { return `href="/ips/` + ip + `"` }
func countRows(body string) int { return strings.Count(body, `name="addresses" value="`) }
func hasHXTriggerEvery(body string) bool { return strings.Contains(body, `hx-trigger="every 2s"`) }
var hrefRe = regexp.MustCompile(`href="([^"]*)"[^>]*rel="(prev|next)"`)
// pagerLink extracts the (unescaped, parsed) prev/next link of the pager.
func pagerLink(t *testing.T, body, rel string) *url.URL {
t.Helper()
for _, m := range hrefRe.FindAllStringSubmatch(body, -1) {
if m[2] == rel {
u, err := url.Parse(html.UnescapeString(m[1]))
if err != nil {
t.Fatalf("parse pager link %q: %v", m[1], err)
}
return u
}
}
return nil
}
func TestIPsScan(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
fake.scanFreeAddresses = []string{"5.5.5.5", "5.5.5.6"}
fake.scanRunPolls = 2
ts := newTestServer(t, caURL)
// Start: answers at once with the panel; it polls itself, the scan
// buttons are disabled and nothing is queued yet.
resp, body := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/ips/scan"})
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
for _, want := range []string{
`id="scan-progress"`, `hx-get="/ips/scan/status"`, `hx-trigger="every 2s"`, "читаются страницы",
`<progress aria-label="Сканирование"></progress>`, ` disabled`,
} {
if !strings.Contains(body, want) {
t.Fatalf("expected %q in the running panel, got:\n%s", want, body)
}
}
if resp.Header.Get("HX-Trigger") != "" {
t.Fatalf("a running scan must not fire scan-finished, got %q", resp.Header.Get("HX-Trigger"))
}
if len(fake.ips) != 0 {
t.Fatalf("nothing should be queued before the job finishes, got %+v", fake.ips)
}
// First poll: still running, keeps polling.
resp, body = doReq(t, ts, reqOpts{path: "/ips/scan/status"})
if !hasHXTriggerEvery(body) || resp.Header.Get("HX-Trigger") != "" {
t.Fatalf("expected a still-polling panel without HX-Trigger, got %q:\n%s", resp.Header.Get("HX-Trigger"), body)
}
// Second poll: finished — no polling trigger, HX-Trigger tells the table to reload.
resp, body = doReq(t, ts, reqOpts{path: "/ips/scan/status"})
if hasHXTriggerEvery(body) || strings.Contains(body, `hx-get="/ips/scan/status"`) {
t.Fatalf("a finished panel must stop polling, got:\n%s", body)
}
if got := resp.Header.Get("HX-Trigger"); got != "scan-finished" {
t.Fatalf("HX-Trigger = %q, want scan-finished", got)
}
for _, want := range []string{"готово", "<dt>добавлено</dt><dd>2</dd>", `<progress max="1" value="1"`} {
if !strings.Contains(body, want) {
t.Fatalf("expected %q in the finished panel, got:\n%s", want, body)
}
}
if strings.Contains(body, " disabled") {
t.Fatalf("buttons must be enabled again once finished, got:\n%s", body)
}
if len(fake.ips) != 2 {
t.Fatalf("expected both scanned addresses queued, got %+v", fake.ips)
}
// The table reload target: #ips-table-wrap listens for scan-finished and
// re-fetches the current /ips URL.
page := get(t, ts, "/ips")
if !strings.Contains(page, `hx-trigger="scan-finished from:body"`) || !strings.Contains(page, `hx-select="#ips-table-wrap"`) {
t.Fatalf("expected #ips-table-wrap to reload on scan-finished, got:\n%s", page)
}
if !strings.Contains(page, ipRowLink("5.5.5.5")) {
t.Fatalf("expected the scanned address in the table, got:\n%s", page)
}
}
func TestIPsScanErrorAndDryRunAndRefusal(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
fake.scanFreeAddresses = []string{"5.5.5.5"}
fake.scanFinalError = "openstack: list floating ips: boom"
ts := newTestServer(t, caURL)
// A job that fails: the panel shows the error and stops polling.
resp, body := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/ips/scan"})
for _, want := range []string{"ошибка", "openstack: list floating ips: boom", `class="scan-error"`} {
if !strings.Contains(body, want) {
t.Fatalf("expected %q in the error panel, got:\n%s", want, body)
}
}
if hasHXTriggerEvery(body) || resp.Header.Get("HX-Trigger") != "scan-finished" {
t.Fatalf("an errored job must stop polling and signal scan-finished, got %q:\n%s", resp.Header.Get("HX-Trigger"), body)
}
if len(fake.ips) != 0 {
t.Fatalf("a failed scan must not queue anything, got %+v", fake.ips)
}
// Dry run: the flag reaches control-api, the queue stays untouched.
fake.mu.Lock()
fake.scanFinalError = ""
fake.scan = scanStatusDTO{}
fake.scanRunPolls = 1
fake.mu.Unlock()
_, body = doReq(t, ts, reqOpts{method: http.MethodPost, path: "/ips/scan?dry_run=true"})
if !strings.Contains(body, "пробный запуск") {
t.Fatalf("expected the dry-run marker, got:\n%s", body)
}
_, body = doReq(t, ts, reqOpts{path: "/ips/scan/status"})
if !strings.Contains(body, "готово") || len(fake.ips) != 0 {
t.Fatalf("dry run must finish without queueing, queue=%+v body:\n%s", fake.ips, body)
}
// control-api refuses to start: banner, no polling.
fake.mu.Lock()
fake.scanStartStatus = http.StatusConflict
fake.mu.Unlock()
_, body = doReq(t, ts, reqOpts{method: http.MethodPost, path: "/ips/scan"})
if !strings.Contains(body, "alert-warning") || hasHXTriggerEvery(body) {
t.Fatalf("expected a client-error banner and no polling, got:\n%s", body)
}
}
func TestIPsPageRendersRunningScanPanelOutsideForm(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
now := time.Now()
fake.scan = scanStatusDTO{State: "enqueuing", Running: true, Pages: 33, Discovered: 6440, Free: 6440, Added: 1200, StartedAt: &now}
ts := newTestServer(t, caURL)
page := get(t, ts, "/ips")
for _, want := range []string{`hx-get="/ips/scan/status"`, "ставятся в очередь", `<progress max="6440" value="1200"`, "<dt>прочитано страниц</dt><dd>33</dd>"} {
if !strings.Contains(page, want) {
t.Fatalf("expected %q in the page, got:\n%s", want, page)
}
}
if strings.Index(page, `id="scan-progress"`) > strings.Index(page, `<form id="ips-form"`) {
t.Fatalf("the scan panel must sit above (outside) #ips-form")
}
}
func TestPageURLEscapesQuery(t *testing.T) {
got := pageURL("/ips", url.Values{"q": {"a b&c=d"}, "state": {"queued"}}, 3, 100)
u, err := url.Parse(got)
if err != nil {
t.Fatal(err)
}
q := u.Query()
if u.Path != "/ips" || q.Get("q") != "a b&c=d" || q.Get("state") != "queued" || q.Get("page") != "3" || q.Get("per_page") != "100" || len(q) != 4 {
t.Fatalf("unexpected URL %q (query %v)", got, q)
}
if first := pageURL("/ips", nil, 1, 50); strings.Contains(first, "page=") && !strings.Contains(first, "per_page=") {
t.Fatalf("page 1 must not carry page=, got %q", first)
}
}
func TestIPsPagination(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
fake.seedIPs(120)
ts := newTestServer(t, caURL)
body := get(t, ts, "/ips")
if !strings.Contains(body, "Показано 1–50 из 120") || countRows(body) != 50 {
t.Fatalf("page 1: want «Показано 1–50 из 120» and 50 rows, got %d rows:\n%s", countRows(body), body)
}
if next := pagerLink(t, body, "next"); next == nil || next.Query().Get("page") != "2" {
t.Fatalf("expected a next link to page 2, got %v", next)
}
if pagerLink(t, body, "prev") != nil {
t.Fatalf("page 1 must have no prev link")
}
body = get(t, ts, "/ips?page=2")
if !strings.Contains(body, "Показано 51–100 из 120") || countRows(body) != 50 {
t.Fatalf("page 2: got:\n%s", body)
}
if prev := pagerLink(t, body, "prev"); prev == nil || prev.Query().Get("page") != "" {
t.Fatalf("page 2 must link back to page 1 (no page param), got %v", prev)
}
// Last page, and a page past the end clamps to it; junk is page 1.
for _, p := range []string{"3", "99"} {
body = get(t, ts, "/ips?page="+p)
if !strings.Contains(body, "Показано 101–120 из 120") || countRows(body) != 20 || pagerLink(t, body, "next") != nil {
t.Fatalf("page=%s: want the last page, got:\n%s", p, body)
}
}
for _, p := range []string{"0", "-4", "abc"} {
if body = get(t, ts, "/ips?page="+p); !strings.Contains(body, "Показано 1–50 из 120") {
t.Fatalf("page=%s: want page 1, got:\n%s", p, body)
}
}
// per_page: only 25/50/100/200 are accepted, anything else is the default.
for per, want := range map[string]int{"25": 25, "100": 100, "200": 120, "77": 50, "": 50, "x": 50} {
if body = get(t, ts, "/ips?per_page="+per); countRows(body) != want {
t.Fatalf("per_page=%q: want %d rows, got %d", per, want, countRows(body))
}
}
}
func TestIPsPaginationKeepsFilterInLinks(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
fake.seedIPs(120)
ts := newTestServer(t, caURL)
// 10.0.0.1 matches .1, .10-.19, .100-.120: 32 rows.
body := get(t, ts, "/ips?q=10.0.0.1&state=queued&per_page=25")
if !strings.Contains(body, "Показано 1–25 из 32") {
t.Fatalf("expected the filtered total, got:\n%s", body)
}
next := pagerLink(t, body, "next")
if next == nil {
t.Fatalf("expected a next link")
}
q := next.Query()
if q.Get("q") != "10.0.0.1" || q.Get("state") != "queued" || q.Get("per_page") != "25" || q.Get("page") != "2" {
t.Fatalf("next link lost the filter: %v", next)
}
// The same filter is carried by the hidden inputs of #ips-form and by the
// wrapper's own reload URL.
for _, want := range []string{
`name="q" value="10.0.0.1"`, `name="state" value="queued"`, `name="per_page" value="25"`, `name="page" value="1"`,
`hx-get="/ips?per_page=25&amp;q=10.0.0.1&amp;state=queued"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("expected %q in the page, got:\n%s", want, body)
}
}
}
func TestIPsFilters(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
now := time.Now()
mk := func(ip, state, result string, seq int) ipQueueItem {
return ipQueueItem{IPAddress: ip, State: state, OverallResult: result, Sequence: seq, AggregatedAt: &now, CreatedAt: now, UpdatedAt: now}
}
fake.ips = []ipQueueItem{
mk("1.1.1.1", "queued", "", 1), mk("1.1.1.2", "checking", "", 2), mk("1.1.1.3", "awaiting_self_check", "", 3),
mk("2.2.2.1", "done", "pass", 4), mk("2.2.2.2", "failed", "fail", 5), mk("2.2.2.3", "failed", "cancelled", 6),
mk("3.3.3.3", "occupied", "", 7),
}
ts := newTestServer(t, caURL)
cases := []struct {
query string
want []string
}{
{"", []string{"1.1.1.1", "1.1.1.2", "1.1.1.3", "2.2.2.1", "2.2.2.2", "2.2.2.3", "3.3.3.3"}},
{"state=queued", []string{"1.1.1.1"}},
{"state=active", []string{"1.1.1.2", "1.1.1.3"}},
{"state=done", []string{"2.2.2.1"}},
{"state=failed", []string{"2.2.2.2", "2.2.2.3"}},
{"state=occupied", []string{"3.3.3.3"}},
{"state=result:fail", []string{"2.2.2.2"}},
{"result=cancelled", []string{"2.2.2.3"}},
{"state=failed&result=cancelled", []string{"2.2.2.3"}},
{"q=1.1.1&state=active", []string{"1.1.1.2", "1.1.1.3"}},
{"state=bogus", []string{"1.1.1.1", "1.1.1.2", "1.1.1.3", "2.2.2.1", "2.2.2.2", "2.2.2.3", "3.3.3.3"}},
}
for _, c := range cases {
body := get(t, ts, "/ips?"+c.query)
got := map[string]bool{}
for _, ip := range []string{"1.1.1.1", "1.1.1.2", "1.1.1.3", "2.2.2.1", "2.2.2.2", "2.2.2.3", "3.3.3.3"} {
got[ip] = strings.Contains(body, ipRowLink(ip))
}
for ip, in := range got {
if want := containsStr(c.want, ip); in != want {
t.Fatalf("?%s: row %s present=%v, want %v", c.query, ip, in, want)
}
}
}
// The select echoes the active filter; an empty filtered result says so.
if body := get(t, ts, "/ips?state=result:fail"); !strings.Contains(body, `value="result:fail" selected`) {
t.Fatalf("expected the result option to be selected, got:\n%s", body)
}
if body := get(t, ts, "/ips?q=zzz"); !strings.Contains(body, "Ничего не найдено по текущему фильтру") {
t.Fatalf("expected the filtered-empty message, got:\n%s", body)
}
// An htmx filter request gets just the swappable wrapper, not the page.
_, frag := doReq(t, ts, reqOpts{path: "/ips?state=queued", headers: map[string]string{"HX-Request": "true"}})
if strings.Contains(frag, "<html") || !strings.Contains(frag, `id="ips-table-wrap"`) || !strings.Contains(frag, ipRowLink("1.1.1.1")) {
t.Fatalf("expected the table wrapper fragment, got:\n%s", frag)
}
// Filters are applied server-side: control-api got them as parameters.
fake.mu.Lock()
defer fake.mu.Unlock()
var sawState bool
for _, q := range fake.ipsQueries {
if strings.Contains(q, "state=queued") && strings.Contains(q, "limit=50") {
sawState = true
}
}
if !sawState || fake.bareIPsCalls != 0 {
t.Fatalf("expected paginated filtered requests only (bare=%d), got %v", fake.bareIPsCalls, fake.ipsQueries)
}
}
func TestIPsMutationsKeepPageAndFilter(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
addrs := fake.seedIPs(120)
ts := newTestServer(t, caURL)
// Per-row delete on page 2 (the URL carries only the context params).
_, body := doReq(t, ts, reqOpts{method: http.MethodDelete, path: "/ips/" + addrs[60] + "?page=2&per_page=50&state=queued"})
if !strings.Contains(body, "Показано 51–100 из 119") || strings.Contains(body, ipRowLink(addrs[60])) {
t.Fatalf("delete must re-render page 2 of the same filter, got:\n%s", body)
}
for _, want := range []string{`name="page" value="2"`, `name="state" value="queued"`} {
if !strings.Contains(body, want) {
t.Fatalf("expected %q in the re-rendered form, got:\n%s", want, body)
}
}
// Per-row recheck keeps the page too.
body = postForm(t, ts, "POST", "/ips/"+addrs[70]+"/recheck", url.Values{"page": {"2"}, "per_page": {"50"}})
if !strings.Contains(body, "Показано 51–100 из 119") {
t.Fatalf("recheck must stay on page 2, got:\n%s", body)
}
// Deleting every row of the last page clamps to the last non-empty page.
fake.mu.Lock()
var last []string
for _, ip := range fake.ips[100:] {
last = append(last, ip.IPAddress)
}
fake.mu.Unlock()
body = postForm(t, ts, "POST", "/ips/delete", url.Values{"page": {"3"}, "per_page": {"50"}, "addresses": last})
if !strings.Contains(body, "Показано 51–100 из 100") || !strings.Contains(body, `name="page" value="2"`) {
t.Fatalf("expected a clamp to page 2 after the last page emptied, got:\n%s", body)
}
// Adding addresses through the top form keeps the context hidden inputs' page too.
body = postForm(t, ts, "POST", "/ips", url.Values{"addresses": {"9.9.9.9"}, "page": {"2"}, "per_page": {"50"}})
if !strings.Contains(body, "Показано 51–100 из 101") {
t.Fatalf("expected page 2 of 101 after adding, got:\n%s", body)
}
}
func TestIPsBulkScopeAllResolvesByFilterInChunks(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
fake.seedIPs(1300)
now := time.Now()
fake.mu.Lock()
for _, a := range []string{"2.2.2.1", "2.2.2.2", "2.2.2.3"} {
fake.ips = append(fake.ips, ipQueueItem{IPAddress: a, State: "done", OverallResult: "pass", AggregatedAt: &now, CreatedAt: now, UpdatedAt: now})
}
fake.mu.Unlock()
ts := newTestServer(t, caURL)
// Recheck everything in state=done: three addresses, one chunk.
postForm(t, ts, "POST", "/ips/recheck", url.Values{"scope": {"all"}, "state": {"done"}})
if len(fake.submitChunks) != 1 || fake.submitChunks[0] != 3 {
t.Fatalf("submit chunks = %v, want [3]", fake.submitChunks)
}
// Delete all 1300 queued+rechecked rows matching q=10. (the checked
// boxes of the page are ignored under scope=all): chunks of ≤500.
body := postForm(t, ts, "POST", "/ips/delete", url.Values{
"scope": {"all"}, "q": {"10."}, "state": {"queued"}, "per_page": {"50"}, "addresses": {"2.2.2.1"},
})
if got := fmt.Sprint(fake.deleteChunks); got != "[500 500 300]" {
t.Fatalf("delete chunks = %s, want [500 500 300]", got)
}
fake.mu.Lock()
left := len(fake.ips)
var big bool
for _, q := range fake.ipsQueries {
v, _ := url.ParseQuery(q)
if n, _ := strconv.Atoi(v.Get("limit")); n > 1000 || n == 0 {
big = true
}
}
bare := fake.bareIPsCalls
fake.mu.Unlock()
if left != 3 {
t.Fatalf("expected only the 3 non-matching rows left, got %d", left)
}
if big || bare != 0 {
t.Fatalf("every list call must be paginated with limit ≤ 1000, got %v (bare=%d)", fake.ipsQueries, bare)
}
if !strings.Contains(body, "Ничего не найдено по текущему фильтру") {
t.Fatalf("expected the emptied filter view, got:\n%s", body)
}
// scope=all with nothing matching is a client error, not a silent no-op.
body = postForm(t, ts, "POST", "/ips/delete", url.Values{"scope": {"all"}, "q": {"nothing"}})
if !strings.Contains(body, "alert-warning") {
t.Fatalf("expected a banner for an empty scope=all, got:\n%s", body)
}
}
func TestIPsTableControls(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
fake.seedIPs(120)
now := time.Now()
fake.mu.Lock()
fake.ips[0].State = "checking"
fake.ips[1].State = "done"
fake.ips[1].OverallResult = "pass"
fake.ips[1].AggregatedAt = &now
fake.mu.Unlock()
ts := newTestServer(t, caURL)
body := get(t, ts, "/ips")
const params = `hx-params="page,per_page,q,state,result"`
// Every per-row button and the clear/scan buttons restrict the request
// params (htmx would otherwise put every checked checkbox into the URL).
var rowButtons int
for _, b := range strings.Split(body, "<button")[1:] {
tag := b[:strings.Index(b, ">")]
perRow := strings.Contains(tag, `hx-delete="/ips/`) ||
(strings.Contains(tag, `hx-post="/ips/1`) && (strings.Contains(tag, "/recheck") || strings.Contains(tag, "/cancel")))
if perRow {
rowButtons++
}
if perRow || strings.Contains(tag, `hx-post="/ips/clear"`) || strings.Contains(tag, `hx-post="/ips/scan`) {
if !strings.Contains(tag, params) {
t.Fatalf("button without hx-params: <button%s>", tag)
}
}
}
if rowButtons != 100 { // delete + recheck/cancel on each of the 50 rows
t.Fatalf("expected 100 per-row buttons, found %d", rowButtons)
}
// Bulk delete/recheck submit the whole form (checked boxes + scope), so no whitelist there.
for _, b := range strings.Split(body, "<button")[1:] {
tag := b[:strings.Index(b, ">")]
if (strings.Contains(tag, `hx-post="/ips/delete"`) || strings.Contains(tag, `hx-post="/ips/recheck"`)) && strings.Contains(tag, "hx-params") {
t.Fatalf("bulk buttons must submit the whole form: <button%s>", tag)
}
}
// Bulk selection UI with real counts.
for _, want := range []string{
"Выбрано на странице: <b data-sel-count>0</b> из 50",
"Выбрать все 120 по фильтру",
"Удалить ВСЕ 120 адресов, включая идущие проверки? Действие необратимо.",
"Удалить ВСЕ 120 адресов из очереди, включая идущие проверки? Действие необратимо.",
`<input type="hidden" name="scope" value="">`,
} {
if !strings.Contains(body, want) {
t.Fatalf("expected %q in the page, got:\n%s", want, body)
}
}
// Under a filter the clear confirmation shows the whole queue, the
// delete-all one the filtered count.
body = get(t, ts, "/ips?q=10.0.0.1")
for _, want := range []string{"Удалить ВСЕ 32 адреса по текущему фильтру, включая", "Удалить ВСЕ 120 адресов из очереди"} {
if !strings.Contains(body, want) {
t.Fatalf("expected %q under a filter, got:\n%s", want, body)
}
}
// Everything on one page: no "select all by filter" offer.
body = get(t, ts, "/ips?q=10.0.0.119")
if strings.Contains(body, `class="select-all-link"`) {
t.Fatalf("no select-all link when the filter fits on the page, got:\n%s", body)
}
}
func TestOverviewBoundedWithThousandsQueued(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
fake.seedIPs(5000)
now := time.Now()
fake.mu.Lock()
for i := 0; i < 3; i++ {
fake.ips[i].State = "checking"
}
for i := 0; i < 30; i++ {
at := now.Add(-time.Duration(i) * time.Minute)
fake.ips = append(fake.ips, ipQueueItem{IPAddress: fmt.Sprintf("2.2.2.%d", i), State: "done", OverallResult: "pass", AggregatedAt: &at, CreatedAt: now, UpdatedAt: now})
}
fake.mu.Unlock()
ts := newTestServer(t, caURL)
for _, path := range []string{"/overview", "/overview/fragment"} {
body := get(t, ts, path)
// 3 active + the next 10 queued + the last 20 completed.
if n := strings.Count(body, `href="/ips/`); n != 33 {
t.Fatalf("%s: expected 33 address rows (3+10+20), got %d", path, n)
}
for _, want := range []string{"В очереди: <b>4997</b>", `href="/ips?state=queued"`, "Последние 20 завершённых", "Ближайшие в очереди"} {
if !strings.Contains(body, want) {
t.Fatalf("%s: expected %q, got:\n%s", path, want, body)
}
}
if !strings.Contains(body, ipRowLink("2.2.2.0")) || strings.Contains(body, ipRowLink("2.2.2.25")) {
t.Fatalf("%s: expected only the 20 newest completed rows", path)
}
}
fake.mu.Lock()
defer fake.mu.Unlock()
if fake.bareIPsCalls != 0 {
t.Fatalf("the overview must never load the whole queue, bare calls = %d", fake.bareIPsCalls)
}
if len(fake.ipsQueries) == 0 {
t.Fatalf("expected paginated list requests")
}
for _, q := range fake.ipsQueries {
v, _ := url.ParseQuery(q)
if n, _ := strconv.Atoi(v.Get("limit")); n < 1 || n > 100 {
t.Fatalf("overview list request without a small limit: %q", q)
}
}
}
func TestOverviewProgressIndicatorAndETA(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
fake.seedIPs(100)
now := time.Now()
fake.mu.Lock()
// 6 completed rows, one every 10 s (span 50 s): 10 s per address.
for i := 0; i < 6; i++ {
at := now.Add(-time.Duration(i) * 10 * time.Second)
fake.ips = append(fake.ips, ipQueueItem{IPAddress: fmt.Sprintf("2.2.2.%d", i), State: "done", OverallResult: "pass", AggregatedAt: &at, CreatedAt: now, UpdatedAt: now})
}
// "occupied" counts as finished work too.
for i := 0; i < 2; i++ {
fake.ips = append(fake.ips, ipQueueItem{IPAddress: fmt.Sprintf("3.3.3.%d", i), State: "occupied", CreatedAt: now, UpdatedAt: now})
}
fake.mu.Unlock()
ts := newTestServer(t, caURL)
body := get(t, ts, "/overview")
for _, want := range []string{
"Готово 8 из 108 (7%) · в работе 0 · в очереди 100",
`<progress max="108" value="8"`,
"осталось ≈ 16 мин 40 с", // 100 queued × 10 s
"Итоги проверок: pass 6",
} {
if !strings.Contains(body, want) {
t.Fatalf("expected %q in the stats block, got:\n%s", want, body)
}
}
// The ETA is not derived from a filtered window.
if body = get(t, ts, "/overview?q=2.2.2"); strings.Contains(body, "осталось") {
t.Fatalf("no ETA under a filter, got:\n%s", body)
}
// Too few samples: no ETA.
fake.mu.Lock()
fake.ips = append(fake.ips[:100], fake.ips[100:103]...)
fake.mu.Unlock()
if body = get(t, ts, "/overview"); strings.Contains(body, "осталось") || !strings.Contains(body, "Готово 3 из 103") {
t.Fatalf("expected progress without ETA for <5 samples, got:\n%s", body)
}
}
func TestOverviewScanLineAndScanningPhase(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
fake.seedIPs(2)
now := time.Now()
fake.autoCycle = autoCycleDTO{Enabled: true, IntervalSeconds: 3600, Phase: "scanning", NextRunAt: &now}
ts := newTestServer(t, caURL)
// No running scan: no scan line.
if body := get(t, ts, "/overview"); strings.Contains(body, "Сканирование:") {
t.Fatalf("no scan line while idle, got:\n%s", body)
}
fake.mu.Lock()
fake.scan = scanStatusDTO{State: "enqueuing", Running: true, Pages: 33, Discovered: 6440, Free: 6439, Added: 500, StartedAt: &now}
fake.mu.Unlock()
body := get(t, ts, "/overview")
for _, want := range []string{"Сканирование: ставятся в очередь", "прочитано страниц 33", "найдено адресов 6440", "свободных 6439", "сканирование Floating IP", "Автоцикл активен"} {
if !strings.Contains(body, want) {
t.Fatalf("expected %q, got:\n%s", want, body)
}
}
// The polled fragment refreshes the line via the OOB stats block.
frag := get(t, ts, "/overview/fragment")
oob := strings.Index(frag, `id="overview-stats" hx-swap-oob="true"`)
if oob < 0 || !strings.Contains(frag[oob:], "Сканирование: ставятся в очередь") {
t.Fatalf("expected the scan line inside the OOB stats block, got:\n%s", frag)
}
}
func TestRegistryPaginationAndServerSideFilter(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
now := time.Now()
results := []string{"pass", "fail", "partial"}
for i := 0; i < 120; i++ {
ip := fmt.Sprintf("10.0.0.%d", i+1)
fake.registry[ip] = registryItem{IPAddress: ip, FirstSeenAt: now, LastSeenAt: now, TotalCycles: 1, LastResult: results[i%3]}
}
ts := newTestServer(t, caURL)
body := get(t, ts, "/registry?page=2")
if !strings.Contains(body, "Показано 51–100 из 120") || strings.Count(body, `href="/registry/10.`) != 50 {
t.Fatalf("expected page 2 with 50 rows, got:\n%s", body)
}
if body = get(t, ts, "/registry?page=99"); !strings.Contains(body, "Показано 101–120 из 120") {
t.Fatalf("expected a clamp to the last page, got:\n%s", body)
}
// 40 of the 120 are "fail"; q and status survive in the pager links and
// reach control-api as parameters (no client-side filtering).
body = get(t, ts, "/registry?status=fail&q=10.0.0&per_page=25")
if !strings.Contains(body, "Показано 1–25 из 40") {
t.Fatalf("expected the server-side filtered total, got:\n%s", body)
}
next := pagerLink(t, body, "next")
if next == nil || next.Path != "/registry" || next.Query().Get("status") != "fail" || next.Query().Get("q") != "10.0.0" ||
next.Query().Get("per_page") != "25" || next.Query().Get("page") != "2" {
t.Fatalf("pager link lost the filter: %v", next)
}
fake.mu.Lock()
defer fake.mu.Unlock()
last := fake.registryQueries[len(fake.registryQueries)-1]
for _, want := range []string{"last_result=fail", "q=10.0.0", "limit=25"} {
if !strings.Contains(last, want) {
t.Fatalf("control-api request %q lacks %s", last, want)
}
}
}
func TestLongTimeoutForClearAndBulkCalls(t *testing.T) {
// A short per-call timeout (50 ms) breaks plain reads of a slow control-api
// but not clear/bulk operations, which use the long client.
slow := newSlowAPI(t, 200*time.Millisecond)
c := newClient(slow, 50*time.Millisecond)
if _, err := c.Status(t.Context()); err == nil {
t.Fatalf("expected the short timeout to fail a plain read")
}
if _, err := c.ClearQueue(t.Context()); err != nil {
t.Fatalf("ClearQueue must use the long timeout: %v", err)
}
if _, err := c.DeleteIPs(t.Context(), []string{"1.1.1.1"}); err != nil {
t.Fatalf("DeleteIPs must use the long timeout: %v", err)
}
if _, err := c.SubmitIPs(t.Context(), []string{"1.1.1.1"}); err != nil {
t.Fatalf("SubmitIPs must use the long timeout: %v", err)
}
}
func TestScanRoutesPassOriginCheckWhenAuthEnabled(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
fake.scanRunPolls = 1
_, ts := newAuthTestServer(t, caURL, nil)
cookie := login(t, ts)
resp, body := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/ips/scan", cookie: cookie})
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("scan without Origin: status=%d, want 403", resp.StatusCode)
}
resp, body = doReq(t, ts, reqOpts{method: http.MethodPost, path: "/ips/scan", cookie: cookie, headers: map[string]string{"Origin": ts.URL}})
if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="scan-progress"`) {
t.Fatalf("scan with Origin: status=%d body:\n%s", resp.StatusCode, body)
}
resp, body = doReq(t, ts, reqOpts{path: "/ips/scan/status", cookie: cookie})
if resp.StatusCode != http.StatusOK || resp.Header.Get("HX-Trigger") != "scan-finished" {
t.Fatalf("status poll: status=%d trigger=%q body:\n%s", resp.StatusCode, resp.Header.Get("HX-Trigger"), body)
}
}
+50
View File
@@ -559,3 +559,53 @@ textarea.autosize { overflow-y: hidden; resize: none; }
}
.sidebar-user-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sidebar-user + .sidebar-foot { margin-top: 0; }
/* ---------- scan progress, progress bars, pager, bulk selection ---------- */
progress {
appearance: none; -webkit-appearance: none;
display: block; width: 100%; height: 8px;
border: 0; border-radius: var(--radius-sm);
background: var(--surface-alt); color: var(--accent);
overflow: hidden;
}
progress::-webkit-progress-bar { background: var(--surface-alt); border-radius: var(--radius-sm); }
progress::-webkit-progress-value { background: var(--accent); border-radius: var(--radius-sm); }
progress::-moz-progress-bar { background: var(--accent); border-radius: var(--radius-sm); }
/* Indeterminate (no value attribute): a sliding stripe, Firefox/Chromium. */
progress:indeterminate { background: linear-gradient(90deg, var(--surface-alt) 0, var(--accent-soft) 40%, var(--surface-alt) 80%); background-size: 200% 100%; animation: progress-slide 1.4s linear infinite; }
progress:indeterminate::-webkit-progress-value { background: transparent; }
progress:indeterminate::-moz-progress-bar { background: transparent; }
@keyframes progress-slide { from { background-position: 200% 0; } to { background-position: -200% 0; } }
@media (prefers-reduced-motion: reduce) { progress:indeterminate { animation: none; } }
.overall-progress { margin-top: 12px; font-size: 12.5px; color: var(--text-muted); }
.overall-progress p { margin-bottom: 6px; }
.scan-progress { margin-top: 16px; }
.scan-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; }
.scan-progress progress { margin-bottom: 10px; }
.scan-counters { display: flex; flex-wrap: wrap; gap: 6px 22px; font-size: 12.5px; color: var(--text-muted); }
.scan-counters dt { display: inline; }
.scan-counters dd { display: inline; margin: 0 0 0 4px; color: var(--text); font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.scan-error { margin-top: 8px; padding: 8px 10px; border: 1px solid var(--danger-border); border-radius: var(--radius-xs); background: var(--danger-soft); color: var(--danger); font-size: 12.5px; }
.btn[disabled] { opacity: .5; cursor: not-allowed; }
.pager { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-top: 12px; font-size: 12.5px; color: var(--text-muted); }
.pager-nav { display: inline-flex; align-items: center; gap: 8px; }
.pager-page { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.btn.is-disabled { opacity: .4; pointer-events: none; }
.panel > .pager { margin: 0; padding: 10px 16px; border-top: 1px solid var(--border-soft); }
.bulk-bar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.bulk-hint { padding: 8px 16px; font-size: 12.5px; color: var(--text-muted); border-top: 1px solid var(--border-soft); border-bottom: 1px solid var(--border-soft); background: var(--surface-alt); }
.bulk-hint b { color: var(--text); font-family: var(--font-mono); font-weight: 500; }
.select-all-link, .scope-all-msg, .btn-del-all { display: none; }
#ips-form.sel-all .select-all-link { display: inline; margin-left: 10px; }
#ips-form.scope-all .select-all-link { display: none; }
#ips-form.scope-all .scope-all-msg { display: inline; margin-left: 10px; color: var(--warning); }
#ips-form.scope-all .btn-del-all { display: inline-flex; }
#ips-form.scope-all .btn-del-sel { display: none; }
.queue-line { margin: 14px 0 8px; font-size: 13px; color: var(--text-muted); }
.queue-line b { color: var(--text); font-family: var(--font-mono); font-weight: 500; }
table.compact td, table.compact th { padding-top: 6px; padding-bottom: 6px; }
+126 -15
View File
@@ -24,7 +24,7 @@
<div class="panel">
<div class="panel-body">
<form hx-post="/ips" hx-target="#ips-table-wrap" hx-swap="innerHTML" hx-sync="#ips-table-wrap:queue last" hx-on::after-request="this.reset()">
<form hx-post="/ips" hx-target="#ips-table-wrap" hx-swap="outerHTML" hx-include=".ips-ctx" hx-sync="#ips-table-wrap:queue last" hx-on::after-request="this.reset()">
<div class="field-row">
<div class="field" style="flex: 1 1 420px;">
<label for="addresses">Адреса (по одному на строку или через запятую)</label>
@@ -36,28 +36,127 @@
<p class="muted" style="margin-top:8px">Новый адрес встаёт в очередь; уже завершённый (done/failed) запускается заново
(тот же вызов); тот, что сейчас проверяется, не трогается. Кнопка «Сканировать Floating IP» ниже делает то же самое
автоматически: находит в проекте OpenStack все свободные (не привязанные к порту) Floating IP и сразу передаёт их
в очередь на проверку. Полная история проверок по каждому адресу — включая уже удалённые из очереди — доступна в
<a href="/registry">реестре</a>.</p>
в очередь на проверку — сканирование идёт в фоне, прогресс виден в панели под кнопкой. Полная история проверок по
каждому адресу — включая уже удалённые из очереди — доступна в <a href="/registry">реестре</a>.</p>
</div>
</div>
<div id="ips-table-wrap">
{{template "scan_progress" .Scan}}
<form id="ips-filter" class="panel" onsubmit="return false" style="margin-top:16px">
<div class="panel-body field-row">
<div class="field" style="flex:1 1 260px">
<label for="ips-q">Поиск по IP</label>
<input type="search" id="ips-q" name="q" value="{{.Filter.Q}}" placeholder="203.0.113.10"
hx-get="/ips" hx-select="#ips-table-wrap" hx-target="#ips-table-wrap" hx-swap="outerHTML"
hx-include="#ips-filter" hx-trigger="input changed delay:300ms"
hx-replace-url="true" hx-sync="#ips-table-wrap:queue last">
</div>
<div class="field">
<label for="ips-state">Состояние</label>
<select id="ips-state" name="state"
hx-get="/ips" hx-select="#ips-table-wrap" hx-target="#ips-table-wrap" hx-swap="outerHTML"
hx-include="#ips-filter" hx-trigger="change"
hx-replace-url="true" hx-sync="#ips-table-wrap:queue last">
{{$tok := .Filter.Token}}
<option value="">Все</option>
<option value="queued" {{if eq $tok "queued"}}selected{{end}}>в очереди (queued)</option>
<option value="active" {{if eq $tok "active"}}selected{{end}}>в работе (assigning_fip, awaiting_self_check, checking, aggregating)</option>
<option value="done" {{if eq $tok "done"}}selected{{end}}>done</option>
<option value="failed" {{if eq $tok "failed"}}selected{{end}}>failed</option>
<option value="occupied" {{if eq $tok "occupied"}}selected{{end}}>occupied</option>
<option value="result:pass" {{if eq $tok "result:pass"}}selected{{end}}>результат: pass</option>
<option value="result:partial" {{if eq $tok "result:partial"}}selected{{end}}>результат: partial</option>
<option value="result:fail" {{if eq $tok "result:fail"}}selected{{end}}>результат: fail</option>
<option value="result:cancelled" {{if eq $tok "result:cancelled"}}selected{{end}}>результат: cancelled</option>
</select>
</div>
<div class="field" style="flex:0 0 120px">
<label for="ips-per-page">На странице</label>
<select id="ips-per-page" name="per_page"
hx-get="/ips" hx-select="#ips-table-wrap" hx-target="#ips-table-wrap" hx-swap="outerHTML"
hx-include="#ips-filter" hx-trigger="change"
hx-replace-url="true" hx-sync="#ips-table-wrap:queue last">
{{$pp := .PerPage}}
{{range .PerPageOptions}}<option value="{{.}}" {{if eq . $pp}}selected{{end}}>{{.}}</option>
{{end}}
</select>
</div>
</div>
</form>
{{template "ips_table_wrap" .}}
{{end}}
{{/* #ips-table-wrap is the swap target of every table mutation, the filter form
and the pager (all hx-swap="outerHTML", so the wrapper's own hx-get always
points at the page/filter it currently shows). It listens for the
scan-finished event (HX-Trigger from the scan progress panel) and reloads
itself. hx-disinherit="*": its attributes must not leak into the buttons
inside. */}}
{{define "ips_table_wrap"}}
<div id="ips-table-wrap" hx-get="{{.SelfURL}}" hx-trigger="scan-finished from:body" hx-select="#ips-table-wrap" hx-target="this" hx-swap="outerHTML" hx-disinherit="*">
{{template "ips_table" .}}
</div>
{{end}}
{{/* Scan progress panel. Lives outside #ips-form (no checkbox payload, survives
table swaps). While the job runs it polls itself; the response that finds
it finished has no hx-trigger and carries HX-Trigger: scan-finished. */}}
{{define "scan_progress"}}
{{$s := .Status}}
<div id="scan-progress" class="panel scan-progress"{{if .Poll}} hx-get="/ips/scan/status" hx-trigger="every 2s" hx-swap="outerHTML"{{end}}>
<div class="panel-body">
<div class="scan-actions">
<button type="button" class="btn btn-ghost btn-sm" hx-post="/ips/scan" hx-target="#scan-progress" hx-swap="outerHTML" hx-params="page,per_page,q,state,result"{{if $s.Running}} disabled{{end}}>Сканировать Floating IP</button>
<button type="button" class="btn btn-ghost btn-sm" hx-post="/ips/scan?dry_run=true" hx-target="#scan-progress" hx-swap="outerHTML" hx-params="page,per_page,q,state,result"{{if $s.Running}} disabled{{end}} title="Только найти и посчитать свободные адреса, очередь не меняется">Пробное сканирование</button>
{{if and $s.State (ne $s.State "idle")}}<span class="pill {{$s.PillClass}}">{{$s.StateLabel}}</span>{{if $s.DryRun}} <span class="muted">пробный запуск: очередь не меняется</span>{{end}}{{else}}<span class="muted">Найти все свободные Floating IP проекта и поставить их в очередь.</span>{{end}}
</div>
{{if and $s.State (ne $s.State "idle")}}
{{if or $s.Running (eq $s.State "done")}}
{{if $s.Indeterminate}}<progress aria-label="Сканирование"></progress>
{{else if eq $s.State "done"}}<progress max="1" value="1" aria-label="Сканирование"></progress>
{{else}}<progress max="{{$s.Free}}" value="{{$s.Handled}}" aria-label="Сканирование"></progress>{{end}}
{{end}}
<dl class="scan-counters">
<div><dt>прочитано страниц</dt><dd>{{$s.Pages}}</dd></div>
<div><dt>найдено адресов</dt><dd>{{$s.Discovered}}</dd></div>
<div><dt>свободных</dt><dd>{{$s.Free}}</dd></div>
<div><dt>добавлено</dt><dd>{{$s.Added}}</dd></div>
<div><dt>повторно</dt><dd>{{$s.Requeued}}</dd></div>
<div><dt>переупорядочено</dt><dd>{{$s.Reordered}}</dd></div>
{{if $s.SkippedInProgress}}<div><dt>уже в работе</dt><dd>{{$s.SkippedInProgress}}</dd></div>{{end}}
{{with $s.Elapsed}}<div><dt>время</dt><dd>{{.}}</dd></div>{{end}}
</dl>
{{if $s.Error}}<p class="scan-error">{{$s.Error}}</p>{{end}}
{{end}}
</div>
</div>
{{end}}
{{define "ips_table"}}
<form id="ips-form">
<form id="ips-form" onchange="var f=this,k=f.querySelectorAll('input[name=addresses]:checked').length,n=f.querySelectorAll('input[name=addresses]').length;f.querySelector('[data-sel-count]').textContent=k;f.classList.toggle('sel-all',n>0&&k===n);if(k!==n){f.elements.scope.value='';f.classList.remove('scope-all')}">
<input type="hidden" class="ips-ctx" name="page" value="{{.Page}}">
<input type="hidden" class="ips-ctx" name="per_page" value="{{.PerPage}}">
<input type="hidden" class="ips-ctx" name="q" value="{{.Filter.Q}}">
<input type="hidden" class="ips-ctx" name="state" value="{{.Filter.State}}">
<input type="hidden" class="ips-ctx" name="result" value="{{.Filter.Result}}">
<input type="hidden" name="scope" value="">
<div class="panel">
<div class="panel-body" style="display:flex; gap:8px; align-items:center;">
<button type="button" class="btn btn-ghost btn-sm" hx-post="/ips/scan" hx-target="#ips-table-wrap" hx-swap="innerHTML" hx-sync="#ips-table-wrap:queue last">Сканировать Floating IP</button>
<button type="button" class="btn btn-ghost btn-sm" hx-post="/ips/recheck" hx-target="#ips-table-wrap" hx-swap="innerHTML" hx-sync="#ips-table-wrap:queue last">Перепроверить выбранные</button>
<button type="button" class="btn btn-danger-ghost btn-sm" hx-post="/ips/delete" hx-target="#ips-table-wrap" hx-swap="innerHTML" hx-sync="#ips-table-wrap:queue last" hx-confirm="Удалить выбранные адреса без возможности восстановления?">Удалить выбранные</button>
<button type="button" class="btn btn-danger-ghost btn-sm" hx-post="/ips/clear" hx-target="#ips-table-wrap" hx-swap="innerHTML" hx-sync="#ips-table-wrap:queue last" hx-confirm="Удалить ВСЕ адреса из очереди, включая те, что сейчас проверяются? Действие необратимо.">Очистить всё</button>
<div class="panel-body bulk-bar">
<button type="button" class="btn btn-ghost btn-sm" hx-post="/ips/recheck" hx-target="#ips-table-wrap" hx-swap="outerHTML" hx-sync="#ips-table-wrap:queue last">Перепроверить выбранные</button>
<button type="button" class="btn btn-danger-ghost btn-sm btn-del-sel" hx-post="/ips/delete" hx-target="#ips-table-wrap" hx-swap="outerHTML" hx-sync="#ips-table-wrap:queue last" hx-confirm="Удалить выбранные адреса без возможности восстановления?">Удалить выбранные</button>
<button type="button" class="btn btn-danger-ghost btn-sm btn-del-all" hx-post="/ips/delete" hx-target="#ips-table-wrap" hx-swap="outerHTML" hx-sync="#ips-table-wrap:queue last" hx-confirm="Удалить ВСЕ {{.Total}} {{pluralAddr .Total}}{{if .Filter.Active}} по текущему фильтру{{end}}, включая идущие проверки? Действие необратимо.">Удалить все {{.Total}} по фильтру</button>
<button type="button" class="btn btn-danger-ghost btn-sm" hx-post="/ips/clear" hx-target="#ips-table-wrap" hx-swap="outerHTML" hx-params="page,per_page,q,state,result" hx-sync="#ips-table-wrap:queue last" hx-confirm="Удалить ВСЕ {{.QueueTotal}} {{pluralAddr .QueueTotal}} из очереди, включая идущие проверки? Действие необратимо.">Очистить всё</button>
</div>
<div class="bulk-hint">
Выбрано на странице: <b data-sel-count>0</b> из {{len .Items}}
{{if gt .Total (len .Items)}}<a href="#" class="select-all-link" onclick="var f=this.closest('form');f.elements.scope.value='all';f.classList.add('scope-all');return false">Выбрать все {{.Total}} по фильтру</a>
<span class="scope-all-msg">Выбраны все {{.Total}} {{pluralAddr .Total}} по фильтру (не только на этой странице).</span>{{end}}
</div>
<div class="table-scroll">
<table>
<thead><tr><th><input type="checkbox" onclick="this.closest('table').querySelectorAll('input[name=addresses]').forEach(cb => cb.checked = this.checked)"></th><th>Адрес</th><th>Состояние</th><th>Валидатор</th><th>Попытка</th><th>Обновлено</th><th></th></tr></thead>
<thead><tr><th><input type="checkbox" aria-label="Выбрать все на странице" onclick="this.closest('table').querySelectorAll('input[name=addresses]').forEach(cb => cb.checked = this.checked)"></th><th>Адрес</th><th>Состояние</th><th>Валидатор</th><th>Попытка</th><th>Обновлено</th><th></th></tr></thead>
<tbody>
{{range .Items}}
{{$b := ipBadge .State .OverallResult .FIPAssociatedAt $.FIPSettleSeconds}}
@@ -72,11 +171,11 @@
<td data-label="">
<div class="actions">
{{if $terminal}}
<button class="btn btn-ghost btn-sm" hx-post="/ips/{{.IPAddress}}/recheck" hx-target="#ips-table-wrap" hx-swap="innerHTML" hx-sync="#ips-table-wrap:queue last">Перепроверить</button>
<button class="btn btn-ghost btn-sm" hx-post="/ips/{{.IPAddress}}/recheck" hx-target="#ips-table-wrap" hx-swap="outerHTML" hx-params="page,per_page,q,state,result" hx-sync="#ips-table-wrap:queue last">Перепроверить</button>
{{else}}
<button class="btn btn-danger-ghost btn-sm" hx-post="/ips/{{.IPAddress}}/cancel" hx-target="#ips-table-wrap" hx-swap="innerHTML" hx-sync="#ips-table-wrap:queue last" hx-confirm="Остановить проверку {{.IPAddress}}?">Отменить</button>
<button class="btn btn-danger-ghost btn-sm" hx-post="/ips/{{.IPAddress}}/cancel" hx-target="#ips-table-wrap" hx-swap="outerHTML" hx-params="page,per_page,q,state,result" hx-sync="#ips-table-wrap:queue last" hx-confirm="Остановить проверку {{.IPAddress}}?">Отменить</button>
{{end}}
<button class="btn btn-danger-ghost btn-sm" hx-delete="/ips/{{.IPAddress}}" hx-target="#ips-table-wrap" hx-swap="innerHTML" hx-sync="#ips-table-wrap:queue last" hx-confirm="Удалить {{.IPAddress}} без возможности восстановления?">Удалить</button>
<button class="btn btn-danger-ghost btn-sm" hx-delete="/ips/{{.IPAddress}}" hx-target="#ips-table-wrap" hx-swap="outerHTML" hx-params="page,per_page,q,state,result" hx-sync="#ips-table-wrap:queue last" hx-confirm="Удалить {{.IPAddress}} без возможности восстановления?">Удалить</button>
</div>
</td>
</tr>
@@ -85,6 +184,18 @@
</table>
</div>
</div>
{{template "pager" .Pager}}
</form>
{{if not .Items}}<p class="muted">Очередь пуста.</p>{{end}}
{{if not .Items}}<p class="muted">{{if .Filter.Active}}Ничего не найдено по текущему фильтру.{{else}}Очередь пуста.{{end}}</p>{{end}}
{{end}}
{{define "pager"}}{{if .Total}}
<div class="pager">
<span class="pager-info">Показано {{.From}}–{{.To}} из {{.Total}}</span>
<span class="pager-nav">
{{if .PrevURL}}<a class="btn btn-ghost btn-sm" href="{{.PrevURL}}" hx-get="{{.PrevURL}}" hx-select="#{{.Wrap}}" hx-target="#{{.Wrap}}" hx-swap="outerHTML" hx-push-url="true" rel="prev" aria-label="Предыдущая страница">‹</a>{{else}}<span class="btn btn-ghost btn-sm is-disabled" aria-disabled="true">‹</span>{{end}}
<span class="pager-page">{{.Page}} / {{.Pages}}</span>
{{if .NextURL}}<a class="btn btn-ghost btn-sm" href="{{.NextURL}}" hx-get="{{.NextURL}}" hx-select="#{{.Wrap}}" hx-target="#{{.Wrap}}" hx-swap="outerHTML" hx-push-url="true" rel="next" aria-label="Следующая страница">›</a>{{else}}<span class="btn btn-ghost btn-sm is-disabled" aria-disabled="true">›</span>{{end}}
</span>
</div>
{{end}}{{end}}
+2 -2
View File
@@ -50,8 +50,8 @@
</select>
</div>
</div>
<p class="muted" style="padding:0 16px 12px">Действует на обе таблицы ниже. Фильтр по статусу — это фильтр по
итоговому результату, поэтому при выборе конкретного статуса строки «Текущей проверки» (у неё ещё нет результата)
<p class="muted" style="padding:0 16px 12px">Действует на все списки ниже. Фильтр по статусу — это фильтр по
итоговому результату, поэтому при выборе конкретного статуса строки «В работе» и «В очереди» (у них ещё нет результата)
не показываются.</p>
</form>
@@ -6,6 +6,18 @@
<div class="stat-card{{if eq $state "failed"}} bad{{else if eq $state "checking"}} accented{{end}}"><span class="value">{{$count}}</span><span class="label">{{$state}}</span></div>
{{end}}
</div>
{{if .Progress.Total}}
<div class="overall-progress" id="overview-progress">
<p>Готово {{.Progress.Done}} из {{.Progress.Total}} ({{.Progress.Percent}}%) · в работе {{.Progress.Active}} · в очереди {{.Progress.Queued}}{{if .Progress.ETA}} · осталось ≈ {{.Progress.ETA}}{{end}}</p>
<progress max="{{.Progress.Total}}" value="{{.Progress.Done}}" aria-label="Готово"></progress>
</div>
{{end}}
{{with .Status.ResultsByOverall}}{{if or (index . "pass") (index . "partial") (index . "fail") (index . "cancelled")}}
<p class="muted" id="overview-results" style="margin-top:8px">Итоги проверок: pass {{index . "pass"}} · partial {{index . "partial"}} · fail {{index . "fail"}} · cancelled {{index . "cancelled"}}</p>
{{end}}{{end}}
{{with .Scan}}
<p class="muted" id="overview-scan" style="margin-top:8px">Сканирование: {{.StateLabel}}{{if .DryRun}} (пробное){{end}} · прочитано страниц {{.Pages}}, найдено адресов {{.Discovered}}, свободных {{.Free}}{{if .Handled}}, обработано {{.Handled}}{{end}} · <a href="/ips">подробнее</a></p>
{{end}}
{{with .AutoCycle}}{{if .Enabled}}
<p class="muted" id="overview-auto-cycle" style="margin-top:12px"><span class="pill pill-info">Автоцикл активен</span> · {{.PhaseLabel}} · следующий запуск: {{fmtTime .NextRunAt}}</p>
{{end}}{{end}}
@@ -20,14 +32,14 @@
{{define "overview_stats_oob"}}<div id="overview-stats" hx-swap-oob="true">{{template "overview_stats" .}}</div>{{end}}
{{define "overview_tables"}}
<h2 class="section-title">Текущая проверка</h2>
{{if .CurrentItems}}
<h2 class="section-title">В работе</h2>
{{if .ActiveItems}}
<div class="panel">
<div class="table-scroll">
<table>
<thead><tr><th>Адрес</th><th>Состояние</th><th>Валидатор</th><th>Попытка</th><th>Назначено</th></tr></thead>
<tbody>
{{range .CurrentItems}}
{{range .ActiveItems}}
{{$b := ipBadge .State .OverallResult .FIPAssociatedAt 0}}
<tr>
<td class="addr" data-label="Адрес"><a href="/ips/{{.IPAddress}}">{{.IPAddress}}</a></td>
@@ -41,11 +53,35 @@
</table>
</div>
</div>
{{if gt .ActiveTotal (len .ActiveItems)}}<p class="muted">Показано {{len .ActiveItems}} из {{.ActiveTotal}} · <a href="/ips?state=active{{if .Query}}&amp;q={{.Query}}{{end}}">все в работе</a></p>{{end}}
{{else}}
{{if or .Query .StatusFilter}}<p class="muted">Ничего не найдено по текущему фильтру.</p>
{{else}}<p class="muted">Сейчас нет адресов в обработке.</p>{{end}}
{{end}}
{{if not .StatusFilter}}
<p class="queue-line">В очереди: <b>{{.QueuedTotal}}</b> · <a href="/ips?state=queued{{if .Query}}&amp;q={{.Query}}{{end}}">открыть весь список</a></p>
{{if .QueuedItems}}
<div class="panel">
<div class="table-scroll">
<table class="compact">
<thead><tr><th>Ближайшие в очереди</th><th>Состояние</th></tr></thead>
<tbody>
{{range .QueuedItems}}
{{$b := ipBadge .State .OverallResult .FIPAssociatedAt 0}}
<tr>
<td class="addr" data-label="Адрес"><a href="/ips/{{.IPAddress}}">{{.IPAddress}}</a></td>
<td data-label="Состояние"><span class="pill {{$b.Class}}">{{$b.Label}}</span></td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{if gt .QueuedTotal (len .QueuedItems)}}<p class="muted">Показаны ближайшие {{len .QueuedItems}} из {{.QueuedTotal}}.</p>{{end}}
{{end}}
{{end}}
<h2 class="section-title">Последние {{.LastN}} завершённых</h2>
<div class="breakdown">
<span>pass <b>{{index .Breakdown "pass"}}</b></span>
@@ -47,6 +47,17 @@
<option value="cancelled" {{if eq .StatusFilter "cancelled"}}selected{{end}}>cancelled</option>
</select>
</div>
<div class="field" style="flex:0 0 120px">
<label for="registry-per-page">На странице</label>
<select id="registry-per-page" name="per_page"
hx-get="/registry" hx-select="#registry-table-wrap" hx-target="#registry-table-wrap" hx-swap="outerHTML"
hx-include="#registry-filter" hx-trigger="change"
hx-replace-url="true" hx-sync="#registry-table-wrap:queue last">
{{$pp := .PerPage}}
{{range .PerPageOptions}}<option value="{{.}}" {{if eq . $pp}}selected{{end}}>{{.}}</option>
{{end}}
</select>
</div>
</div>
</form>
@@ -83,6 +94,7 @@
</tbody>
</table>
</div>
{{template "pager" .Pager}}
</div>
{{else if or .Query .StatusFilter}}<p class="muted">Ничего не найдено по текущему фильтру.</p>
{{else}}<p class="muted">Реестр пуст — ни один адрес ещё не ставился на проверку.</p>{{end}}
+4
View File
@@ -37,6 +37,9 @@ var ipRegistrySchema string
//go:embed migrations/0008_auto_cycle.sql
var autoCycleSchema string
//go:embed migrations/0009_scale_indexes.sql
var scaleIndexesSchema 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
@@ -53,6 +56,7 @@ var migrations = []struct {
{6, proberHeartbeatSchema},
{7, ipRegistrySchema},
{8, autoCycleSchema},
{9, scaleIndexesSchema},
}
type DB struct {
@@ -0,0 +1,11 @@
-- Scale indexes (see docs/changes: FIP scan at scale).
--
-- idx_ip_queue_registry: ListRegistry/ListRegistryPage look up the live
-- ip_queue row of every registry address by registry_id; without an index
-- that was a full scan per address, i.e. O(n^2) with thousands of addresses.
--
-- idx_ip_queue_state_aggregated: paged "recently finished" listings
-- (state filter, newest aggregated_at first).
CREATE INDEX idx_ip_queue_registry ON ip_queue(registry_id);
CREATE INDEX idx_ip_queue_state_aggregated ON ip_queue(state, aggregated_at);
+29
View File
@@ -39,6 +39,32 @@ const (
SourceEgress = "egress"
)
// IPStates lists every valid ip_queue state, in lifecycle order.
var IPStates = []string{
IPQueued, IPAssigningFIP, IPAwaitingSelfCheck, IPChecking, IPAggregating,
IPDone, IPFailed, IPOccupied,
}
// IsValidIPState reports whether s is one of IPStates.
func IsValidIPState(s string) bool {
for _, v := range IPStates {
if v == s {
return true
}
}
return false
}
// IsValidResult reports whether s is a valid overall result value
// (pass | partial | fail | cancelled).
func IsValidResult(s string) bool {
switch s {
case ResultPass, ResultPartial, ResultFail, ResultCancelled:
return true
}
return false
}
// InboundSource returns the checks.source value for the given prober site
// index (1-based), e.g. InboundSource(1) == "inbound-site-1".
func InboundSource(siteIndex int) string {
@@ -232,6 +258,9 @@ const (
AutoCyclePhaseIdle = "idle"
AutoCyclePhaseRunning = "running"
AutoCyclePhaseWaiting = "waiting"
// AutoCyclePhaseScanning: the queue is being cleared and the floating IPs
// are being (re)discovered and enqueued by the background scan job.
AutoCyclePhaseScanning = "scanning"
AutoCycleOutcomeCompleted = "completed"
AutoCycleOutcomeNoFreeIPs = "no_free_ips"
+1 -1
View File
@@ -102,7 +102,7 @@ func (d *DB) SetAutoCycleEnabled(ctx context.Context, enabled bool, nextRunAt *t
// touch the configuration or the enabled flag.
func (d *DB) UpdateAutoCycleState(ctx context.Context, s AutoCycleState) error {
switch s.Phase {
case AutoCyclePhaseIdle, AutoCyclePhaseRunning, AutoCyclePhaseWaiting:
case AutoCyclePhaseIdle, AutoCyclePhaseScanning, AutoCyclePhaseRunning, AutoCyclePhaseWaiting:
default:
return fmt.Errorf("invalid auto-cycle phase %q: %w", s.Phase, ErrValidation)
}
+258
View File
@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"fmt"
"strings"
"time"
)
@@ -497,6 +498,263 @@ func deleteIPTx(ctx context.Context, tx *sql.Tx, ipID int64) error {
return nil
}
// ClearAllIPs deletes every ip_queue row in one short, set-based
// transaction (five statements regardless of the queue size) and returns the
// deleted addresses in queue order. It does exactly what deleteIPTx does per
// row: frees every validator that still points at a doomed row, detaches
// checks/events (their registry_id keeps the history), drops the ephemeral
// ip_site_checks progress flags and finally the rows themselves.
// Disassociating attached floating IPs is the caller's (orchestrator's) job.
func (d *DB) ClearAllIPs(ctx context.Context) ([]string, error) {
tx, err := d.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
rows, err := tx.QueryContext(ctx, `SELECT ip_address FROM ip_queue ORDER BY sequence`)
if err != nil {
return nil, err
}
deleted := []string{}
for rows.Next() {
var addr string
if err := rows.Scan(&addr); err != nil {
rows.Close()
return nil, err
}
deleted = append(deleted, addr)
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, err
}
rows.Close()
now := timeToDB(Now())
if _, err := tx.ExecContext(ctx, `
UPDATE validators SET state=?, current_ip_id=NULL, updated_at=?
WHERE current_ip_id IS NOT NULL
`, ValidatorIdle, now); err != nil {
return nil, fmt.Errorf("free owning validators: %w", err)
}
if _, err := tx.ExecContext(ctx, `UPDATE checks SET ip_id=NULL WHERE ip_id IS NOT NULL`); err != nil {
return nil, fmt.Errorf("detach checks: %w", err)
}
if _, err := tx.ExecContext(ctx, `UPDATE events SET ip_id=NULL WHERE ip_id IS NOT NULL`); err != nil {
return nil, fmt.Errorf("detach events: %w", err)
}
if _, err := tx.ExecContext(ctx, `DELETE FROM ip_site_checks`); err != nil {
return nil, fmt.Errorf("delete ip_site_checks: %w", err)
}
if _, err := tx.ExecContext(ctx, `DELETE FROM ip_queue`); err != nil {
return nil, fmt.Errorf("delete ip_queue rows: %w", err)
}
if err := tx.Commit(); err != nil {
return nil, err
}
return deleted, nil
}
// FIPRef identifies a queue row that currently holds a Neutron floating-IP
// association.
type FIPRef struct {
IPID int64
IPAddress string
FIPID string
}
// ListFIPRefs returns every queue row with an attached floating IP (fip_id
// set) — typically at most one per validator — so a bulk clear can
// disassociate them without loading the whole queue.
func (d *DB) ListFIPRefs(ctx context.Context) ([]FIPRef, error) {
rows, err := d.QueryContext(ctx, `SELECT id, ip_address, fip_id FROM ip_queue WHERE fip_id<>'' ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []FIPRef
for rows.Next() {
var r FIPRef
if err := rows.Scan(&r.IPID, &r.IPAddress, &r.FIPID); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
// ListFIPRefsByAddresses is ListFIPRefs restricted to the given addresses
// (unknown addresses and rows without an attached floating IP are simply
// absent), using a handful of IN (...) queries instead of one lookup per
// address.
func (d *DB) ListFIPRefsByAddresses(ctx context.Context, addresses []string) ([]FIPRef, error) {
const chunk = 500
var out []FIPRef
for start := 0; start < len(addresses); start += chunk {
end := start + chunk
if end > len(addresses) {
end = len(addresses)
}
part := addresses[start:end]
args := make([]any, len(part))
for i, a := range part {
args[i] = a
}
rows, err := d.QueryContext(ctx,
`SELECT id, ip_address, fip_id FROM ip_queue WHERE fip_id<>'' AND ip_address IN (`+placeholders(len(part))+`)`, args...)
if err != nil {
return nil, err
}
for rows.Next() {
var r FIPRef
if err := rows.Scan(&r.IPID, &r.IPAddress, &r.FIPID); err != nil {
rows.Close()
return nil, err
}
out = append(out, r)
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, err
}
rows.Close()
}
return out, nil
}
func placeholders(n int) string {
if n <= 0 {
return ""
}
return strings.TrimSuffix(strings.Repeat("?,", n), ",")
}
// CountIPsByState returns how many ip_queue rows are in each state, plus the
// grand total, via a single GROUP BY (no row loading).
func (d *DB) CountIPsByState(ctx context.Context) (map[string]int, int, error) {
rows, err := d.QueryContext(ctx, `SELECT state, COUNT(*) FROM ip_queue GROUP BY state`)
if err != nil {
return nil, 0, err
}
defer rows.Close()
counts := map[string]int{}
total := 0
for rows.Next() {
var state string
var n int
if err := rows.Scan(&state, &n); err != nil {
return nil, 0, err
}
counts[state] = n
total += n
}
return counts, total, rows.Err()
}
// CountIPsByResult returns how many ip_queue rows carry each non-empty
// overall_result (pass/partial/fail/cancelled), via a single GROUP BY.
func (d *DB) CountIPsByResult(ctx context.Context) (map[string]int, error) {
rows, err := d.QueryContext(ctx, `
SELECT overall_result, COUNT(*) FROM ip_queue WHERE overall_result<>'' GROUP BY overall_result
`)
if err != nil {
return nil, err
}
defer rows.Close()
counts := map[string]int{}
for rows.Next() {
var res string
var n int
if err := rows.Scan(&res, &n); err != nil {
return nil, err
}
counts[res] = n
}
return counts, rows.Err()
}
// AnyNonTerminalIP reports whether any ip_queue row is still unfinished (its
// state is none of done/failed/occupied).
func (d *DB) AnyNonTerminalIP(ctx context.Context) (bool, error) {
var any bool
err := d.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM ip_queue WHERE state NOT IN (?, ?, ?))`,
IPDone, IPFailed, IPOccupied).Scan(&any)
return any, err
}
// Order values for IPFilter.Order.
const (
IPOrderSequence = "sequence"
IPOrderAggregatedAtDesc = "aggregated_at_desc"
)
// IPFilter narrows ListIPsPage. The zero value matches everything, ordered by
// queue sequence.
type IPFilter struct {
States []string // any of these states (empty = all)
Query string // substring of ip_address
Result string // overall_result equals this (pass|partial|fail|cancelled)
Order string // IPOrderSequence (default) | IPOrderAggregatedAtDesc
}
// ListIPsPage returns one page (limit/offset) of ip_queue rows matching f,
// plus the total number of matching rows. limit <= 0 means no limit.
func (d *DB) ListIPsPage(ctx context.Context, f IPFilter, limit, offset int) ([]IPQueueItem, int, error) {
var conds []string
var args []any
if len(f.States) > 0 {
conds = append(conds, "state IN ("+placeholders(len(f.States))+")")
for _, s := range f.States {
args = append(args, s)
}
}
if f.Query != "" {
conds = append(conds, "instr(ip_address, ?) > 0")
args = append(args, f.Query)
}
if f.Result != "" {
conds = append(conds, "overall_result = ?")
args = append(args, f.Result)
}
where := ""
if len(conds) > 0 {
where = "WHERE " + strings.Join(conds, " AND ") + " "
}
var total int
if err := d.QueryRowContext(ctx, `SELECT COUNT(*) FROM ip_queue `+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
order := "ORDER BY sequence"
if f.Order == IPOrderAggregatedAtDesc {
// Timestamps are stored as RFC3339Nano, which trims trailing zeros
// and so does not sort correctly as text; strftime normalizes them to
// a fixed millisecond layout. NULL aggregated_at sorts last in DESC.
order = "ORDER BY strftime('%Y-%m-%d %H:%M:%f', aggregated_at) DESC, sequence"
}
q := ipQueueSelect + where + order
qargs := append([]any(nil), args...)
if limit > 0 {
q += " LIMIT ? OFFSET ?"
qargs = append(qargs, limit, offset)
}
rows, err := d.QueryContext(ctx, q, qargs...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
items, err := scanIPQueueItems(rows)
if err != nil {
return nil, 0, err
}
if items == nil {
items = []IPQueueItem{}
}
return items, total, 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
+80 -1
View File
@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"fmt"
"strings"
"time"
)
@@ -66,7 +67,7 @@ type RegistrySummary struct {
func (d *DB) ListRegistry(ctx context.Context) ([]RegistrySummary, error) {
rows, err := d.QueryContext(ctx, `
SELECT id, ip_address, first_seen_at, last_seen_at, next_cycle, created_at, updated_at
FROM ip_registry ORDER BY first_seen_at
FROM ip_registry ORDER BY first_seen_at, id
`)
if err != nil {
return nil, err
@@ -89,6 +90,84 @@ func (d *DB) ListRegistry(ctx context.Context) ([]RegistrySummary, error) {
return out, nil
}
// RegistryFilter narrows ListRegistryPage. The zero value matches everything.
type RegistryFilter struct {
Query string // substring of ip_address
LastResult string // pass|partial|fail|cancelled — same meaning as RegistrySummary.LastResult
}
// lastResultCond is the SQL form of fillRegistrySummary's LastResult rule,
// over `ip_registry r LEFT JOIN ip_queue q ON q.registry_id=r.id`: a live
// queue row's overall_result is authoritative (and a live row without one has
// no verdict); with no live row the latest recorded cycle is classified from
// its checks (pass if all succeeded, fail if none, partial otherwise).
const lastResultCond = `(
(q.id IS NOT NULL AND q.overall_result = ?)
OR (q.id IS NULL AND (
SELECT CASE WHEN SUM(c.success) = 0 THEN 'fail'
WHEN SUM(c.success) = COUNT(*) THEN 'pass'
ELSE 'partial' END
FROM checks c
WHERE c.registry_id = r.id
AND c.cycle_id = (SELECT MAX(c2.cycle_id) FROM checks c2 WHERE c2.registry_id = r.id)
HAVING COUNT(*) > 0
) = ?)
)`
// ListRegistryPage returns one page (limit/offset) of the registry in the same
// order as ListRegistry, filtered by f, plus the total number of matching
// rows. LIMIT/OFFSET are applied in SQL before the per-row summary queries,
// so only the rows of the page pay for them. limit <= 0 means no limit.
func (d *DB) ListRegistryPage(ctx context.Context, f RegistryFilter, limit, offset int) ([]RegistrySummary, int, error) {
var conds []string
var args []any
if f.Query != "" {
conds = append(conds, "instr(r.ip_address, ?) > 0")
args = append(args, f.Query)
}
if f.LastResult != "" {
conds = append(conds, lastResultCond)
args = append(args, f.LastResult, f.LastResult)
}
from := ` FROM ip_registry r LEFT JOIN ip_queue q ON q.registry_id = r.id `
where := ""
if len(conds) > 0 {
where = "WHERE " + strings.Join(conds, " AND ") + " "
}
var total int
if err := d.QueryRowContext(ctx, `SELECT COUNT(*)`+from+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
q := `SELECT r.id, r.ip_address, r.first_seen_at, r.last_seen_at, r.next_cycle, r.created_at, r.updated_at` +
from + where + `ORDER BY r.first_seen_at, r.id`
qargs := append([]any(nil), args...)
if limit > 0 {
q += " LIMIT ? OFFSET ?"
qargs = append(qargs, limit, offset)
}
rows, err := d.QueryContext(ctx, q, qargs...)
if err != nil {
return nil, 0, err
}
items, err := scanRegistryItems(rows)
rows.Close()
if err != nil {
return nil, 0, err
}
out := make([]RegistrySummary, len(items))
for i, item := range items {
s := RegistrySummary{RegistryItem: item}
if err := d.fillRegistrySummary(ctx, &s); err != nil {
return nil, 0, err
}
out[i] = s
}
return out, total, nil
}
// GetRegistryByAddress returns the registry row (with summary) for a single
// address, or ErrNotFound if it has never been submitted.
func (d *DB) GetRegistryByAddress(ctx context.Context, address string) (*RegistrySummary, error) {
+405
View File
@@ -0,0 +1,405 @@
package db
import (
"context"
"fmt"
"reflect"
"sort"
"testing"
"time"
)
// scaleAddrs returns n distinct addresses 10.<a>.<b>.<c> in ascending order.
func scaleAddrs(n int) []string {
out := make([]string, n)
for i := 0; i < n; i++ {
out[i] = fmt.Sprintf("10.%d.%d.%d", (i/65536)%256, (i/256)%256, i%256)
}
return out
}
// finishWithChecks submits the address, records ok successes and bad
// failures as the current cycle's checks and, when finish != "", finishes the
// row with that overall result.
func finishWithChecks(t *testing.T, d *DB, addr string, ok, bad int, finish string) {
t.Helper()
ctx := context.Background()
ip, err := d.GetIPByAddress(ctx, addr)
if err != nil {
t.Fatalf("get %s: %v", addr, err)
}
for i := 0; i < ok+bad; i++ {
if err := d.UpsertCheck(ctx, Check{
IPID: ip.ID, IPAddress: addr, AttemptNumber: ip.AttemptNumber,
Source: SourceEgress, CheckType: "https", Target: fmt.Sprintf("https://t%d.test", i),
Success: i < ok, CheckedAt: Now(),
}); err != nil {
t.Fatalf("upsert check: %v", err)
}
}
if finish != "" {
if err := d.FinishIP(ctx, ip.ID, finish); err != nil {
t.Fatalf("finish: %v", err)
}
}
}
func TestListIPsPageFiltersTotalOrder(t *testing.T) {
d, ctx := newTestDB(t)
addrs := []string{"10.0.0.1", "10.0.0.2", "10.0.0.3", "10.0.1.1", "10.0.1.2", "192.168.0.10"}
if _, err := d.SubmitIPs(ctx, addrs); err != nil {
t.Fatalf("submit: %v", err)
}
// 10.0.0.1 pass, 10.0.0.2 fail, 10.0.0.3 checking, 10.0.1.1 occupied.
finishWithChecks(t, d, "10.0.0.1", 1, 0, ResultPass)
time.Sleep(3 * time.Millisecond)
finishWithChecks(t, d, "10.0.0.2", 0, 1, ResultFail)
ip3, _ := d.GetIPByAddress(ctx, "10.0.0.3")
if err := d.SetChecking(ctx, ip3.ID, time.Minute); err != nil {
t.Fatal(err)
}
ip4, _ := d.GetIPByAddress(ctx, "10.0.1.1")
if err := d.MarkFIPOccupied(ctx, ip4.ID, ""); err != nil {
t.Fatal(err)
}
addrsOf := func(items []IPQueueItem) []string {
out := []string{}
for _, it := range items {
out = append(out, it.IPAddress)
}
return out
}
items, total, err := d.ListIPsPage(ctx, IPFilter{}, 4, 0)
if err != nil || total != 6 || !reflect.DeepEqual(addrsOf(items), addrs[:4]) {
t.Fatalf("page 1: total=%d items=%v err=%v", total, addrsOf(items), err)
}
items, total, _ = d.ListIPsPage(ctx, IPFilter{}, 4, 4)
if total != 6 || !reflect.DeepEqual(addrsOf(items), addrs[4:]) {
t.Fatalf("page 2: total=%d items=%v", total, addrsOf(items))
}
items, total, _ = d.ListIPsPage(ctx, IPFilter{}, 4, 100)
if total != 6 || len(items) != 0 || items == nil {
t.Fatalf("offset past end: total=%d items=%v", total, items)
}
items, total, _ = d.ListIPsPage(ctx, IPFilter{States: []string{IPDone, IPFailed}}, 50, 0)
if total != 2 || !reflect.DeepEqual(addrsOf(items), []string{"10.0.0.1", "10.0.0.2"}) {
t.Fatalf("states filter: total=%d items=%v", total, addrsOf(items))
}
items, total, _ = d.ListIPsPage(ctx, IPFilter{States: []string{IPQueued}, Query: "10.0.1."}, 50, 0)
if total != 1 || addrsOf(items)[0] != "10.0.1.2" {
t.Fatalf("state+q filter: total=%d items=%v", total, addrsOf(items))
}
items, total, _ = d.ListIPsPage(ctx, IPFilter{Result: ResultFail}, 50, 0)
if total != 1 || addrsOf(items)[0] != "10.0.0.2" {
t.Fatalf("result filter: total=%d items=%v", total, addrsOf(items))
}
// q is a plain substring, not a LIKE pattern: % and _ match literally.
if _, total, _ = d.ListIPsPage(ctx, IPFilter{Query: "%"}, 50, 0); total != 0 {
t.Fatalf("expected literal substring match, total=%d", total)
}
// Newest aggregated first; never-aggregated rows last.
items, _, _ = d.ListIPsPage(ctx, IPFilter{Order: IPOrderAggregatedAtDesc}, 50, 0)
got := addrsOf(items)
if got[0] != "10.0.1.1" && got[0] != "10.0.0.2" {
t.Fatalf("expected a finished row first, got %v", got)
}
if items[0].AggregatedAt == nil || items[len(items)-1].AggregatedAt != nil {
t.Fatalf("expected aggregated rows first and unaggregated last: %v", got)
}
for i := 1; i < len(items); i++ {
a, b := items[i-1].AggregatedAt, items[i].AggregatedAt
if a != nil && b != nil && a.Before(*b) {
t.Fatalf("not sorted by aggregated_at desc at %d: %v", i, got)
}
}
}
func TestCountAndNonTerminal(t *testing.T) {
d, ctx := newTestDB(t)
byState, total, err := d.CountIPsByState(ctx)
if err != nil || total != 0 || len(byState) != 0 {
t.Fatalf("empty: %v %d %v", byState, total, err)
}
if any, err := d.AnyNonTerminalIP(ctx); err != nil || any {
t.Fatalf("empty queue must not report non-terminal: %v %v", any, err)
}
if _, err := d.SubmitIPs(ctx, []string{"1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4"}); err != nil {
t.Fatal(err)
}
finishWithChecks(t, d, "1.1.1.1", 1, 0, ResultPass)
finishWithChecks(t, d, "1.1.1.2", 1, 1, ResultPartial)
ip3, _ := d.GetIPByAddress(ctx, "1.1.1.3")
if err := d.CancelIP(ctx, ip3.ID); err != nil {
t.Fatal(err)
}
byState, total, err = d.CountIPsByState(ctx)
if err != nil || total != 4 || byState[IPDone] != 2 || byState[IPFailed] != 1 || byState[IPQueued] != 1 {
t.Fatalf("by state: %v total=%d err=%v", byState, total, err)
}
byResult, err := d.CountIPsByResult(ctx)
if err != nil || len(byResult) != 3 || byResult[ResultPass] != 1 || byResult[ResultPartial] != 1 || byResult[ResultCancelled] != 1 {
t.Fatalf("by result: %v err=%v", byResult, err)
}
if any, _ := d.AnyNonTerminalIP(ctx); !any {
t.Fatalf("a queued row is non-terminal")
}
ip4, _ := d.GetIPByAddress(ctx, "1.1.1.4")
if err := d.MarkFIPOccupied(ctx, ip4.ID, ""); err != nil {
t.Fatal(err)
}
if any, _ := d.AnyNonTerminalIP(ctx); any {
t.Fatalf("done/failed/occupied only: expected terminal")
}
}
// TestListRegistryPageMatchesListRegistry builds a mixed dataset (live rows
// with every overall result, an in-progress live row, deleted rows whose
// last cycle classifies as pass/partial/fail, and an address without checks)
// and verifies that ListRegistryPage's SQL filter selects exactly the rows
// ListRegistry+fillRegistrySummary labels with the same LastResult.
func TestListRegistryPageMatchesListRegistry(t *testing.T) {
d, ctx := newTestDB(t)
addrs := scaleAddrs(14)
if _, err := d.SubmitIPs(ctx, addrs); err != nil {
t.Fatal(err)
}
// live rows with an aggregated result
finishWithChecks(t, d, addrs[0], 2, 0, ResultPass)
finishWithChecks(t, d, addrs[1], 1, 1, ResultPartial)
finishWithChecks(t, d, addrs[2], 0, 2, ResultFail)
ip3, _ := d.GetIPByAddress(ctx, addrs[3])
if err := d.CancelIP(ctx, ip3.ID); err != nil {
t.Fatal(err)
}
// live row, passing checks recorded, but cycle unfinished -> no verdict
finishWithChecks(t, d, addrs[4], 2, 0, "")
// rows to be deleted: result derived from the checks of the last cycle
finishWithChecks(t, d, addrs[5], 2, 0, ResultPass) // -> pass
finishWithChecks(t, d, addrs[6], 1, 2, ResultPartial) // -> partial
finishWithChecks(t, d, addrs[7], 0, 2, ResultFail) // -> fail
finishWithChecks(t, d, addrs[8], 0, 0, "") // deleted, no checks -> ""
// a deleted row whose first cycle failed but whose last passed
finishWithChecks(t, d, addrs[9], 0, 1, ResultFail)
if _, err := d.DeleteIPs(ctx, []string{addrs[5], addrs[6], addrs[7], addrs[8], addrs[9]}); err != nil {
t.Fatal(err)
}
if _, err := d.SubmitIPs(ctx, []string{addrs[9]}); err != nil {
t.Fatal(err)
}
finishWithChecks(t, d, addrs[9], 1, 0, ResultPass)
if _, err := d.DeleteIPs(ctx, []string{addrs[9]}); err != nil {
t.Fatal(err)
}
all, err := d.ListRegistry(ctx)
if err != nil || len(all) != 14 {
t.Fatalf("list registry: n=%d err=%v", len(all), err)
}
// No filter: same rows in the same order as ListRegistry, paged.
var paged []RegistrySummary
for off := 0; ; off += 5 {
page, total, err := d.ListRegistryPage(ctx, RegistryFilter{}, 5, off)
if err != nil || total != 14 {
t.Fatalf("page off=%d total=%d err=%v", off, total, err)
}
if len(page) == 0 {
break
}
paged = append(paged, page...)
}
if len(paged) != len(all) {
t.Fatalf("paged %d rows, want %d", len(paged), len(all))
}
for i := range all {
if all[i].IPAddress != paged[i].IPAddress || all[i].LastResult != paged[i].LastResult {
t.Fatalf("row %d differs: %+v vs %+v", i, all[i], paged[i])
}
}
for _, res := range []string{ResultPass, ResultPartial, ResultFail, ResultCancelled} {
var want []string
for _, s := range all {
if s.LastResult == res {
want = append(want, s.IPAddress)
}
}
page, total, err := d.ListRegistryPage(ctx, RegistryFilter{LastResult: res}, 100, 0)
if err != nil {
t.Fatal(err)
}
var got []string
for _, s := range page {
got = append(got, s.IPAddress)
if s.LastResult != res {
t.Fatalf("%s: row %s has LastResult %q", res, s.IPAddress, s.LastResult)
}
}
sort.Strings(want)
sort.Strings(got)
if total != len(want) || !reflect.DeepEqual(got, want) || len(want) == 0 {
t.Fatalf("last_result=%s: total=%d got=%v want=%v", res, total, got, want)
}
}
// pass: addrs[0] (live) + addrs[5] + addrs[9] (deleted, latest cycle).
if _, total, _ := d.ListRegistryPage(ctx, RegistryFilter{LastResult: ResultPass}, 100, 0); total != 3 {
t.Fatalf("expected 3 pass rows, got %d", total)
}
// q filter + LIMIT applies after the filter, total is the filtered count.
page, total, err := d.ListRegistryPage(ctx, RegistryFilter{Query: "10.0.0.1"}, 2, 0)
if err != nil || total != 5 || len(page) != 2 { // 10.0.0.1, .10-.13
t.Fatalf("q filter: total=%d len=%d err=%v", total, len(page), err)
}
page, total, _ = d.ListRegistryPage(ctx, RegistryFilter{Query: "10.0.0.1", LastResult: ResultPass}, 10, 0)
if total != 0 || len(page) != 0 {
// 10.0.0.1 is partial; 10.0.0.10-13 have no verdict or fail.
t.Fatalf("q+last_result: total=%d page=%+v", total, page)
}
}
func TestClearAllIPsKeepsHistoryAndFreesValidators(t *testing.T) {
d, ctx := newTestDB(t)
if err := d.AdminCreateValidator(ctx, "validator-1", "port-1"); err != nil {
t.Fatal(err)
}
addrs := []string{"5.5.5.1", "5.5.5.2", "5.5.5.3"}
if _, err := d.SubmitIPs(ctx, addrs); err != nil {
t.Fatal(err)
}
finishWithChecks(t, d, "5.5.5.2", 1, 1, ResultPartial)
claimed, err := d.ClaimNextQueued(ctx, "validator-1", time.Minute)
if err != nil || claimed == nil {
t.Fatalf("claim: %v %v", claimed, err)
}
if err := d.SetFIPAssociated(ctx, claimed.ID, "fip-9", time.Minute); err != nil {
t.Fatal(err)
}
if err := d.SetEgressComplete(ctx, claimed.ID); err != nil {
t.Fatal(err)
}
if err := d.SetSiteComplete(ctx, claimed.ID, 1); err != nil {
t.Fatal(err)
}
refs, err := d.ListFIPRefs(ctx)
if err != nil || len(refs) != 1 || refs[0].FIPID != "fip-9" || refs[0].IPAddress != claimed.IPAddress {
t.Fatalf("fip refs: %+v err=%v", refs, err)
}
refs, err = d.ListFIPRefsByAddresses(ctx, []string{claimed.IPAddress, "nope"})
if err != nil || len(refs) != 1 {
t.Fatalf("fip refs by address: %+v err=%v", refs, err)
}
if refs, _ := d.ListFIPRefsByAddresses(ctx, []string{"5.5.5.3"}); len(refs) != 0 {
t.Fatalf("row without fip must not be listed: %+v", refs)
}
deleted, err := d.ClearAllIPs(ctx)
if err != nil {
t.Fatalf("clear: %v", err)
}
sort.Strings(deleted)
if !reflect.DeepEqual(deleted, addrs) {
t.Fatalf("deleted %v, want %v", deleted, addrs)
}
if items, _ := d.ListIPs(ctx); len(items) != 0 {
t.Fatalf("queue not empty: %+v", items)
}
v, err := d.GetValidator(ctx, "validator-1")
if err != nil || v.State != ValidatorIdle || v.CurrentIPID != nil {
t.Fatalf("validator not freed: %+v err=%v", v, err)
}
// History and registry rows survive, detached from the queue.
reg, err := d.GetRegistryByAddress(ctx, "5.5.5.2")
if err != nil || reg.TotalCycles != 1 || reg.LastResult != ResultPartial || reg.InQueue {
t.Fatalf("registry after clear: %+v err=%v", reg, err)
}
checks, err := d.ListChecksForRegistry(ctx, reg.ID, nil)
if err != nil || len(checks) != 2 || checks[0].IPID != 0 {
t.Fatalf("checks after clear: %+v err=%v", checks, err)
}
// Clearing an empty queue is fine and returns an empty (non-nil) list.
if deleted, err := d.ClearAllIPs(ctx); err != nil || deleted == nil || len(deleted) != 0 {
t.Fatalf("second clear: %v %v", deleted, err)
}
// The same addresses can be re-submitted afterwards.
if res, err := d.SubmitIPs(ctx, addrs); err != nil || len(res.Added) != 3 {
t.Fatalf("resubmit: %+v err=%v", res, err)
}
}
func TestMigration0009Indexes(t *testing.T) {
d, ctx := newTestDB(t)
for _, name := range []string{"idx_ip_queue_registry", "idx_ip_queue_state_aggregated"} {
var n int
if err := d.QueryRowContext(ctx, `SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name=?`, name).Scan(&n); err != nil || n != 1 {
t.Fatalf("index %s missing (n=%d err=%v)", name, n, err)
}
}
var ver int
if err := d.QueryRowContext(ctx, `PRAGMA user_version`).Scan(&ver); err != nil || ver < 9 {
t.Fatalf("user_version=%d err=%v", ver, err)
}
}
// TestScaleSmoke6440 pushes a realistic project size through the hot paths
// with a loose time bound: the point is the absence of O(n^2) / N+1 work, not
// a benchmark.
func TestScaleSmoke6440(t *testing.T) {
if testing.Short() {
t.Skip("scale smoke test skipped in -short mode")
}
d, ctx := newTestDB(t)
addrs := scaleAddrs(6440)
start := time.Now()
for off := 0; off < len(addrs); off += 500 {
end := min(off+500, len(addrs))
if _, err := d.SubmitIPs(ctx, addrs[off:end]); err != nil {
t.Fatalf("submit chunk: %v", err)
}
}
submitDur := time.Since(start)
start = time.Now()
page, total, err := d.ListRegistryPage(ctx, RegistryFilter{}, 100, 3000)
if err != nil || total != 6440 || len(page) != 100 {
t.Fatalf("registry page: total=%d len=%d err=%v", total, len(page), err)
}
if _, total, err = d.ListRegistryPage(ctx, RegistryFilter{LastResult: ResultPass}, 100, 0); err != nil || total != 0 {
t.Fatalf("registry last_result filter: total=%d err=%v", total, err)
}
registryDur := time.Since(start)
start = time.Now()
if items, total, err := d.ListIPsPage(ctx, IPFilter{States: []string{IPQueued}, Query: "10.0.1."}, 50, 0); err != nil || total != 256 || len(items) != 50 {
t.Fatalf("ips page: total=%d len=%d err=%v", total, len(items), err)
}
if by, total, err := d.CountIPsByState(ctx); err != nil || total != 6440 || by[IPQueued] != 6440 {
t.Fatalf("count: %v %d %v", by, total, err)
}
if any, err := d.AnyNonTerminalIP(ctx); err != nil || !any {
t.Fatalf("any non terminal: %v %v", any, err)
}
queryDur := time.Since(start)
start = time.Now()
deleted, err := d.ClearAllIPs(ctx)
if err != nil || len(deleted) != 6440 {
t.Fatalf("clear: n=%d err=%v", len(deleted), err)
}
clearDur := time.Since(start)
t.Logf("submit=%v registry=%v queries=%v clear=%v", submitDur, registryDur, queryDur, clearDur)
if registryDur > 10*time.Second || queryDur > 5*time.Second || clearDur > 10*time.Second {
t.Fatalf("too slow: registry=%v queries=%v clear=%v", registryDur, queryDur, clearDur)
}
}
+5 -3
View File
@@ -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)
}
}
+43 -1
View File
@@ -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 {
+165 -16
View File
@@ -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
+34 -1
View File
@@ -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 {
+2
View File
@@ -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)
+30 -3
View File
@@ -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
+4 -3
View File
@@ -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)
}
+364
View File
@@ -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)
}
}
+1
View File
@@ -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},
+88 -11
View File
@@ -3,10 +3,13 @@ package openstack
import (
"context"
"fmt"
"net/url"
"time"
"github.com/gophercloud/gophercloud/v2"
osauth "github.com/gophercloud/gophercloud/v2/openstack"
"github.com/gophercloud/gophercloud/v2/openstack/networking/v2/extensions/layer3/floatingips"
"github.com/gophercloud/gophercloud/v2/pagination"
)
// AuthMethod selects how the client obtains the token it uses for Neutron
@@ -51,10 +54,20 @@ type ClientConfig struct {
ProjectID string
Region string
Interface string // "public" | "internal" | "admin"; "" defaults to "public"
// RequestTimeout bounds every single HTTP request to Keystone/Neutron
// (provider.HTTPClient.Timeout). Zero means no timeout (not recommended:
// a hung Neutron call would otherwise block the caller forever).
RequestTimeout time.Duration
// ListPageRetries is how many times one failed page of the floating-IP
// listing is retried (exponential backoff 1s,2s,4s,...). Zero disables
// retries; negative values mean "use DefaultListPageRetries".
ListPageRetries int
}
type Client struct {
networking *gophercloud.ServiceClient
retry pageRetry
}
// buildAuthOptions translates ClientConfig into gophercloud.AuthOptions. It
@@ -119,6 +132,10 @@ func NewClient(ctx context.Context, cfg ClientConfig) (*Client, error) {
return nil, fmt.Errorf("openstack: authenticate: %w", err)
}
if cfg.RequestTimeout > 0 {
provider.HTTPClient.Timeout = cfg.RequestTimeout
}
iface := cfg.Interface
if iface == "" {
iface = string(gophercloud.AvailabilityPublic)
@@ -131,7 +148,11 @@ func NewClient(ctx context.Context, cfg ClientConfig) (*Client, error) {
return nil, fmt.Errorf("openstack: networking client: %w", err)
}
return &Client{networking: networking}, nil
retries := cfg.ListPageRetries
if retries < 0 {
retries = DefaultListPageRetries
}
return &Client{networking: networking, retry: pageRetry{Retries: retries}}, nil
}
func (c *Client) GetFloatingIPByAddress(ctx context.Context, address string) (*FloatingIP, error) {
@@ -150,22 +171,78 @@ func (c *Client) GetFloatingIPByAddress(ctx context.Context, address string) (*F
return &FloatingIP{ID: f.ID, Address: f.FloatingIP, PortID: f.PortID, ProjectID: f.TenantID}, nil
}
func (c *Client) ListFloatingIPs(ctx context.Context) ([]FloatingIP, error) {
pages, err := floatingips.List(c.networking, floatingips.ListOpts{}).AllPages(ctx)
// fipListFields is the set of attributes requested from Neutron when listing:
// everything FloatingIP needs and nothing more (the full resource is several
// times larger, which matters with thousands of floating IPs).
var fipListFields = []string{"id", "floating_ip_address", "port_id", "project_id"}
// pagedListOpts wraps floatingips.ListOpts to add the `fields` query
// parameter, which gophercloud's ListOpts does not expose.
type pagedListOpts struct {
floatingips.ListOpts
fields []string
}
func (o pagedListOpts) ToFloatingIPListQuery() (string, error) {
q, err := o.ListOpts.ToFloatingIPListQuery()
if err != nil {
return "", err
}
if len(o.fields) == 0 {
return q, nil
}
v := url.Values{}
for _, f := range o.fields {
v.Add("fields", f)
}
if q == "" {
return "?" + v.Encode(), nil
}
return q + "&" + v.Encode(), nil
}
// fetchPage requests exactly one page (limit entries after marker). It uses
// marker pagination driven by us rather than gophercloud's `next` link: behind
// a proxy that link may point at an internal host.
func (c *Client) fetchPage(ctx context.Context, marker string, limit int) ([]FloatingIP, error) {
opts := pagedListOpts{
ListOpts: floatingips.ListOpts{Limit: limit, Marker: marker},
fields: fipListFields,
}
var out []FloatingIP
err := floatingips.List(c.networking, opts).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) {
list, err := floatingips.ExtractFloatingIPs(page)
if err != nil {
return false, fmt.Errorf("openstack: extract floating ips: %w", err)
}
out = make([]FloatingIP, 0, len(list))
for _, f := range list {
proj := f.TenantID
if proj == "" {
proj = f.ProjectID // Neutron may return only project_id when `fields` is used
}
out = append(out, FloatingIP{ID: f.ID, Address: f.FloatingIP, PortID: f.PortID, ProjectID: proj})
}
return false, nil // one page per request
})
if err != nil {
return nil, fmt.Errorf("openstack: list floating ips: %w", err)
}
list, err := floatingips.ExtractFloatingIPs(pages)
if err != nil {
return nil, fmt.Errorf("openstack: extract floating ips: %w", err)
}
out := make([]FloatingIP, 0, len(list))
for _, f := range list {
out = append(out, FloatingIP{ID: f.ID, Address: f.FloatingIP, PortID: f.PortID, ProjectID: f.TenantID})
}
return out, nil
}
// ListFreeFloatingIPs pages through every floating IP of the project (page by
// page, retrying transient failures per page) and hands each page to onPage in
// server order. All entries of a page are passed — free and associated; the
// caller filters. It returns the number of non-empty pages read.
func (c *Client) ListFreeFloatingIPs(ctx context.Context, pageSize int, onPage func([]FloatingIP) error) (int, error) {
return paginate(ctx, pageSize, c.retry, c.fetchPage, onPage)
}
func (c *Client) ListFloatingIPs(ctx context.Context) ([]FloatingIP, error) {
return listAll(ctx, c)
}
func (c *Client) AssociateFloatingIP(ctx context.Context, fipID, portID string) error {
_, err := floatingips.Update(ctx, c.networking, fipID, floatingips.UpdateOpts{
PortID: &portID,
+86
View File
@@ -0,0 +1,86 @@
package openstack
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gophercloud/gophercloud/v2"
)
// TestClientListFreeFloatingIPsAgainstFakeNeutron drives the real Client
// against an httptest server: marker pagination, the `fields` projection, a
// transient 503 that is retried and an empty port_id meaning "free".
func TestClientListFreeFloatingIPsAgainstFakeNeutron(t *testing.T) {
const total = 5
var requests int
failedOnce := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
q := r.URL.Query()
if got := q["fields"]; len(got) != 4 {
t.Errorf("expected 4 fields params, got %v", got)
}
if q.Get("limit") != "2" {
t.Errorf("expected limit=2, got %q", q.Get("limit"))
}
if q.Get("marker") == "id-1" && !failedOnce {
failedOnce = true
http.Error(w, "busy", http.StatusServiceUnavailable)
return
}
start := 0
if m := q.Get("marker"); m != "" {
fmt.Sscanf(m, "id-%d", &start)
start++
}
type fip struct {
ID string `json:"id"`
Addr string `json:"floating_ip_address"`
PortID *string `json:"port_id"`
TenantID string `json:"tenant_id"`
}
var items []fip
for i := start; i < total && len(items) < 2; i++ {
f := fip{ID: fmt.Sprintf("id-%d", i), Addr: fmt.Sprintf("203.0.113.%d", i+1), TenantID: "p"}
if i == 1 {
p := "port-1"
f.PortID = &p
}
items = append(items, f)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"floatingips": items})
}))
defer srv.Close()
c := &Client{
networking: &gophercloud.ServiceClient{
ProviderClient: &gophercloud.ProviderClient{HTTPClient: *srv.Client()},
Endpoint: srv.URL + "/",
ResourceBase: srv.URL + "/v2.0/",
},
retry: pageRetry{Retries: 3, Sleep: func(context.Context, time.Duration) error { return nil }},
}
var all []FloatingIP
pages, err := c.ListFreeFloatingIPs(context.Background(), 2, func(p []FloatingIP) error {
all = append(all, p...)
return nil
})
if err != nil {
t.Fatalf("list: %v", err)
}
if pages != 3 || len(all) != total {
t.Fatalf("expected 3 pages / 5 fips, got %d / %d", pages, len(all))
}
if all[1].PortID != "port-1" || all[0].PortID != "" || all[4].Address != "203.0.113.5" {
t.Fatalf("unexpected mapping: %+v", all)
}
if requests != 4 { // 3 pages + 1 retried 503
t.Fatalf("expected 4 requests, got %d", requests)
}
}
+22
View File
@@ -30,6 +30,15 @@ type FloatingIPClient interface {
// the check queue without an admin having to enumerate them by hand.
ListFloatingIPs(ctx context.Context) ([]FloatingIP, error)
// ListFreeFloatingIPs reads the project's floating IPs page by page
// (pageSize entries per request; <=0 means DefaultListPageSize), calling
// onPage with every page in server order — transient per-page failures
// are retried inside, so a long listing survives a flaky Neutron. Every
// entry of the page is passed, associated or free: the caller filters on
// PortID. It returns the number of non-empty pages read. An error from
// onPage stops the walk and is returned as-is.
ListFreeFloatingIPs(ctx context.Context, pageSize int, onPage func(page []FloatingIP) error) (pages int, err error)
// AssociateFloatingIP attaches the floating IP to the given Neutron
// port (the validator's primary NIC port).
AssociateFloatingIP(ctx context.Context, fipID, portID string) error
@@ -53,3 +62,16 @@ func IsNotFound(err error) bool {
_, ok := err.(*notFoundError)
return ok
}
// listAll collects every page of c.ListFreeFloatingIPs into one slice; it is
// the shared implementation of ListFloatingIPs on top of the paged method.
func listAll(ctx context.Context, c FloatingIPClient) ([]FloatingIP, error) {
var all []FloatingIP
if _, err := c.ListFreeFloatingIPs(ctx, DefaultListPageSize, func(page []FloatingIP) error {
all = append(all, page...)
return nil
}); err != nil {
return nil, err
}
return all, nil
}
+90 -4
View File
@@ -3,7 +3,10 @@ package openstack
import (
"context"
"fmt"
"net/netip"
"sort"
"sync"
"time"
)
// MockClient is an in-memory FloatingIPClient used by unit tests and the
@@ -22,6 +25,24 @@ type MockClient struct {
// ListFailure, when non-nil, is returned by every ListFloatingIPs call
// until the test resets it to nil — to exercise the scan-error path.
ListFailure error
// ListFailures is a queue of errors consumed one per page request (each
// request, including retries, pops the head; an empty queue means no
// failure), on top of the sticky ListFailure. Exercises per-page retry.
ListFailures []error
// PageSize, when > 0, overrides the page size requested by the caller.
PageSize int
// PageDelay is an artificial latency added to every page request
// (honours ctx cancellation) — to simulate a slow Neutron.
PageDelay time.Duration
// PageRetries / Sleep configure per-page retry like the real client;
// zero retries by default so a failure surfaces immediately.
PageRetries int
Sleep func(ctx context.Context, d time.Duration) error
// PageCalls counts page requests served (successful or failed).
PageCalls int
}
func NewMockClient() *MockClient {
@@ -60,19 +81,84 @@ func (m *MockClient) GetFloatingIPByAddress(ctx context.Context, address string)
return &f, nil
}
func (m *MockClient) ListFloatingIPs(ctx context.Context) ([]FloatingIP, error) {
// SeedMany registers n free floating IPs with IDs "<prefix>-00000"… and
// ascending addresses starting at 198.18.0.1 (TEST-NET benchmarking range),
// returning the addresses in seed order.
func (m *MockClient) SeedMany(prefix string, n int) []string {
m.mu.Lock()
defer m.mu.Unlock()
base := netip.MustParseAddr("198.18.0.1")
addrs := make([]string, 0, n)
a := base
for i := 0; i < n; i++ {
id := fmt.Sprintf("%s-%05d", prefix, i)
addr := a.String()
m.fips[id] = &FloatingIP{ID: id, Address: addr, ProjectID: "mock-project"}
m.byIP[addr] = id
addrs = append(addrs, addr)
a = a.Next()
}
return addrs
}
func (m *MockClient) fetchPage(ctx context.Context, marker string, limit int) ([]FloatingIP, error) {
m.mu.Lock()
delay := m.PageDelay
m.mu.Unlock()
if delay > 0 {
if err := sleepCtx(ctx, delay); err != nil {
return nil, err
}
}
m.mu.Lock()
defer m.mu.Unlock()
m.PageCalls++
if m.ListFailure != nil {
return nil, m.ListFailure
}
out := make([]FloatingIP, 0, len(m.fips))
for _, f := range m.fips {
out = append(out, *f)
if len(m.ListFailures) > 0 {
err := m.ListFailures[0]
m.ListFailures = m.ListFailures[1:]
if err != nil {
return nil, err
}
}
if m.PageSize > 0 {
limit = m.PageSize
}
ids := make([]string, 0, len(m.fips))
for id := range m.fips {
if id > marker {
ids = append(ids, id)
}
}
sort.Strings(ids)
if len(ids) > limit {
ids = ids[:limit]
}
out := make([]FloatingIP, 0, len(ids))
for _, id := range ids {
out = append(out, *m.fips[id])
}
return out, nil
}
// ListFreeFloatingIPs pages through the seeded floating IPs sorted by ID, like
// the real client does against Neutron (see FloatingIPClient).
func (m *MockClient) ListFreeFloatingIPs(ctx context.Context, pageSize int, onPage func([]FloatingIP) error) (int, error) {
m.mu.Lock()
rp := pageRetry{Retries: m.PageRetries, Sleep: m.Sleep}
if m.PageSize > 0 {
pageSize = m.PageSize
}
m.mu.Unlock()
return paginate(ctx, pageSize, rp, m.fetchPage, onPage)
}
func (m *MockClient) ListFloatingIPs(ctx context.Context) ([]FloatingIP, error) {
return listAll(ctx, m)
}
func (m *MockClient) AssociateFloatingIP(ctx context.Context, fipID, portID string) error {
m.mu.Lock()
defer m.mu.Unlock()
+148
View File
@@ -0,0 +1,148 @@
package openstack
import (
"context"
"errors"
"io"
"net"
"strings"
"time"
)
// DefaultListPageSize is the page size used when the caller passes a
// non-positive one.
const DefaultListPageSize = 200
// DefaultListPageRetries is how many times a single failed page request is
// retried (so up to DefaultListPageRetries+1 attempts in total).
const DefaultListPageRetries = 5
// pageFetcher fetches one page of floating IPs: at most limit entries that
// follow the entry with ID marker ("" = from the start), in a stable server
// order. A page shorter than limit is the last one.
type pageFetcher func(ctx context.Context, marker string, limit int) ([]FloatingIP, error)
// pageRetry configures per-page retries. The zero value retries nothing.
type pageRetry struct {
// Retries is the number of retries after the first failed attempt.
Retries int
// Sleep waits for d or until ctx is done; nil uses a real timer. Tests
// inject a fake to avoid real backoff delays.
Sleep func(ctx context.Context, d time.Duration) error
}
// retryBackoff is the delay before retry number attempt (1-based):
// 1s, 2s, 4s, 8s, 16s, then capped at 30s.
func retryBackoff(attempt int) time.Duration {
if attempt < 1 {
attempt = 1
}
d := time.Second << uint(attempt-1)
if d > 30*time.Second || d <= 0 {
d = 30 * time.Second
}
return d
}
func sleepCtx(ctx context.Context, d time.Duration) error {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
return nil
}
}
// statusCoder is implemented by gophercloud.ErrUnexpectedResponseCode (and
// anything wrapping it): it carries the HTTP status of a failed request.
type statusCoder interface{ GetStatusCode() int }
// IsRetryableListError reports whether a failed page request is worth
// repeating: transport-level failures (timeouts, resets, EOF /
// RemoteDisconnected), HTTP 429 and HTTP 5xx. Any other 4xx, context
// cancellation and unknown errors are not retried.
func IsRetryableListError(err error) bool {
if err == nil {
return false
}
if errors.Is(err, context.Canceled) {
return false
}
var sc statusCoder
if errors.As(err, &sc) {
code := sc.GetStatusCode()
return code == 429 || code >= 500
}
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
return true
}
// A deadline is retryable only when it is a per-request network timeout
// (net.Error), which is checked below; the caller's own ctx deadline is
// filtered out by the paginator via ctx.Err().
var ne net.Error
if errors.As(err, &ne) {
return true
}
msg := strings.ToLower(err.Error())
for _, s := range []string{
"remotedisconnected", "remote end closed connection",
"connection reset", "connection refused", "broken pipe",
"unexpected eof", "eof", "timeout", "tls handshake",
} {
if strings.Contains(msg, s) {
return true
}
}
return false
}
// paginate walks all pages with marker-based pagination, retrying each failed
// page per rp, and hands every non-empty page to onPage in server order. It
// returns the number of non-empty pages delivered. An error from onPage, a
// non-retryable error, exhausted retries or ctx cancellation stop the walk.
func paginate(ctx context.Context, pageSize int, rp pageRetry, fetch pageFetcher, onPage func([]FloatingIP) error) (int, error) {
if pageSize <= 0 {
pageSize = DefaultListPageSize
}
sleep := rp.Sleep
if sleep == nil {
sleep = sleepCtx
}
marker := ""
pages := 0
for {
if err := ctx.Err(); err != nil {
return pages, err
}
var page []FloatingIP
var err error
for attempt := 0; ; attempt++ {
page, err = fetch(ctx, marker, pageSize)
if err == nil {
break
}
if ctx.Err() != nil {
return pages, ctx.Err()
}
if attempt >= rp.Retries || !IsRetryableListError(err) {
return pages, err
}
if serr := sleep(ctx, retryBackoff(attempt+1)); serr != nil {
return pages, serr
}
}
if len(page) == 0 {
return pages, nil
}
pages++
if err := onPage(page); err != nil {
return pages, err
}
if len(page) < pageSize {
return pages, nil
}
marker = page[len(page)-1].ID
}
}
+227
View File
@@ -0,0 +1,227 @@
package openstack
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"testing"
"time"
"github.com/gophercloud/gophercloud/v2"
"github.com/gophercloud/gophercloud/v2/openstack/networking/v2/extensions/layer3/floatingips"
)
func TestMockPaginationReturnsAllPages(t *testing.T) {
m := NewMockClient()
m.PageSize = 200
m.SeedMany("fip", 2500)
seen := map[string]bool{}
var sizes []int
pages, err := m.ListFreeFloatingIPs(context.Background(), 200, func(page []FloatingIP) error {
sizes = append(sizes, len(page))
for _, f := range page {
if seen[f.ID] {
t.Fatalf("duplicate id %s", f.ID)
}
seen[f.ID] = true
}
return nil
})
if err != nil {
t.Fatalf("list: %v", err)
}
if len(seen) != 2500 {
t.Fatalf("expected 2500 unique fips, got %d", len(seen))
}
if pages != 13 || len(sizes) != 13 || sizes[12] != 100 {
t.Fatalf("expected 13 pages with a short last one, got pages=%d sizes=%v", pages, sizes)
}
if m.PageCalls != 13 {
t.Fatalf("expected 13 page requests, got %d", m.PageCalls)
}
// ListFloatingIPs stays available on top of the paged method.
all, err := m.ListFloatingIPs(context.Background())
if err != nil || len(all) != 2500 {
t.Fatalf("ListFloatingIPs: n=%d err=%v", len(all), err)
}
}
func TestMockPaginationExactMultipleOfPageSize(t *testing.T) {
m := NewMockClient()
m.SeedMany("fip", 400)
pages, err := m.ListFreeFloatingIPs(context.Background(), 200, func([]FloatingIP) error { return nil })
if err != nil || pages != 2 {
t.Fatalf("expected 2 non-empty pages, got pages=%d err=%v", pages, err)
}
}
func TestPageRetryOnTransientErrors(t *testing.T) {
m := NewMockClient()
m.PageSize = 100
m.SeedMany("fip", 250)
m.PageRetries = 5
var slept []time.Duration
m.Sleep = func(ctx context.Context, d time.Duration) error {
slept = append(slept, d)
return nil
}
// Fail the first request twice, then the 2nd page once.
m.ListFailures = []error{io.ErrUnexpectedEOF, errors.New("RemoteDisconnected('Remote end closed connection')"), nil, io.EOF}
n := 0
pages, err := m.ListFreeFloatingIPs(context.Background(), 100, func(p []FloatingIP) error {
n += len(p)
return nil
})
if err != nil {
t.Fatalf("expected retries to succeed, got %v", err)
}
if n != 250 || pages != 3 {
t.Fatalf("expected 250 fips in 3 pages, got %d in %d", n, pages)
}
want := []time.Duration{time.Second, 2 * time.Second, time.Second}
if len(slept) != len(want) {
t.Fatalf("expected %d backoffs, got %v", len(want), slept)
}
for i := range want {
if slept[i] != want[i] {
t.Fatalf("backoff[%d]=%v want %v (all %v)", i, slept[i], want[i], slept)
}
}
}
func TestPageRetryGivesUpAfterRetries(t *testing.T) {
m := NewMockClient()
m.SeedMany("fip", 10)
m.PageRetries = 2
m.Sleep = func(context.Context, time.Duration) error { return nil }
m.ListFailure = io.ErrUnexpectedEOF // sticky
_, err := m.ListFreeFloatingIPs(context.Background(), 5, func([]FloatingIP) error { return nil })
if !errors.Is(err, io.ErrUnexpectedEOF) {
t.Fatalf("expected the page error after retries, got %v", err)
}
if m.PageCalls != 3 {
t.Fatalf("expected 1+2 attempts, got %d", m.PageCalls)
}
}
func TestPageNonRetryableAborts(t *testing.T) {
m := NewMockClient()
m.SeedMany("fip", 10)
m.PageRetries = 5
m.Sleep = func(context.Context, time.Duration) error {
t.Fatal("must not back off for a non-retryable error")
return nil
}
boom := errors.New("403 forbidden")
m.ListFailures = []error{boom}
called := false
_, err := m.ListFreeFloatingIPs(context.Background(), 5, func([]FloatingIP) error { called = true; return nil })
if !errors.Is(err, boom) || called || m.PageCalls != 1 {
t.Fatalf("expected immediate abort: err=%v called=%v calls=%d", err, called, m.PageCalls)
}
}
func TestPageOnPageErrorStops(t *testing.T) {
m := NewMockClient()
m.SeedMany("fip", 50)
stop := errors.New("stop")
pages, err := m.ListFreeFloatingIPs(context.Background(), 10, func([]FloatingIP) error { return stop })
if !errors.Is(err, stop) || pages != 1 || m.PageCalls != 1 {
t.Fatalf("expected stop after first page: pages=%d err=%v calls=%d", pages, err, m.PageCalls)
}
}
func TestPageContextCancel(t *testing.T) {
m := NewMockClient()
m.SeedMany("fip", 100)
m.PageDelay = 5 * time.Second
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
start := time.Now()
_, err := m.ListFreeFloatingIPs(ctx, 10, func([]FloatingIP) error { return nil })
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("expected deadline error, got %v", err)
}
if time.Since(start) > 2*time.Second {
t.Fatalf("cancel was not honoured promptly")
}
// Cancellation during the backoff sleep is honoured too.
m2 := NewMockClient()
m2.SeedMany("fip", 10)
m2.PageRetries = 3
m2.ListFailure = io.EOF
ctx2, cancel2 := context.WithCancel(context.Background())
m2.Sleep = func(ctx context.Context, d time.Duration) error { cancel2(); return ctx.Err() }
_, err = m2.ListFreeFloatingIPs(ctx2, 5, func([]FloatingIP) error { return nil })
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected canceled, got %v", err)
}
}
func TestIsRetryableListError(t *testing.T) {
status := func(code int) error {
return gophercloud.ErrUnexpectedResponseCode{Actual: code, Expected: []int{200}}
}
cases := []struct {
name string
err error
want bool
}{
{"nil", nil, false},
{"eof", io.EOF, true},
{"unexpected eof", io.ErrUnexpectedEOF, true},
{"wrapped eof", fmt.Errorf("list: %w", io.EOF), true},
{"remote disconnected text", errors.New("Get x: RemoteDisconnected('Remote end closed connection without response')"), true},
{"connection reset", errors.New("read tcp: connection reset by peer"), true},
{"connection refused", errors.New("dial tcp: connection refused"), true},
{"net timeout", &url.Error{Op: "Get", URL: "http://x", Err: &net.DNSError{IsTimeout: true}}, true},
{"500", status(http.StatusInternalServerError), true},
{"503 wrapped", fmt.Errorf("openstack: list floating ips: %w", status(503)), true},
{"429", status(http.StatusTooManyRequests), true},
{"400", status(http.StatusBadRequest), false},
{"401", status(http.StatusUnauthorized), false},
{"404", status(http.StatusNotFound), false},
{"canceled", context.Canceled, false},
{"plain", errors.New("boom"), false},
}
for _, c := range cases {
if got := IsRetryableListError(c.err); got != c.want {
t.Errorf("%s: IsRetryableListError=%v want %v", c.name, got, c.want)
}
}
}
func TestPagedListOptsQuery(t *testing.T) {
o := pagedListOpts{ListOpts: floatingipsListOpts("m1", 200), fields: fipListFields}
q, err := o.ToFloatingIPListQuery()
if err != nil {
t.Fatal(err)
}
v, err := url.ParseQuery(q[1:])
if err != nil {
t.Fatalf("parse %q: %v", q, err)
}
if v.Get("limit") != "200" || v.Get("marker") != "m1" {
t.Fatalf("limit/marker missing in %q", q)
}
if got := v["fields"]; len(got) != 4 || got[0] != "id" || got[1] != "floating_ip_address" || got[2] != "port_id" || got[3] != "project_id" {
t.Fatalf("unexpected fields %v in %q", got, q)
}
// Without any other option the query must still start with '?'.
q, _ = pagedListOpts{fields: []string{"id"}}.ToFloatingIPListQuery()
if q != "?fields=id" {
t.Fatalf("got %q", q)
}
}
func floatingipsListOpts(marker string, limit int) floatingips.ListOpts {
return floatingips.ListOpts{Marker: marker, Limit: limit}
}
+131 -63
View File
@@ -11,11 +11,14 @@ import (
// This file implements the automatic check cycle: an optional, repeating
// "clear queue -> scan floating IPs -> wait until every queued address has
// reached a terminal state -> wait interval" scenario. Steps 1-2 reuse
// ClearQueue/ScanFloatingIPs verbatim; step 3 needs no code at all because
// Tick already picks up `queued` addresses. All state lives in the database
// (db.AutoCycle), so the cycle survives a control-api restart and the
// interval/limits can be changed at runtime.
// reached a terminal state -> wait interval" scenario. Steps 1-2 run as ONE
// background scan job (StartScan{ClearFirst:true}) so the control loop and
// autoCycleMu are never held across OpenStack/DB-heavy work: the cycle sits in
// phase `scanning` while the job runs and each step merely polls it. Step 3
// needs no code at all because Tick already picks up `queued` addresses. All
// state lives in the database (db.AutoCycle), so the cycle survives a
// control-api restart (a restart in phase `scanning` simply starts the scan
// again) and the interval/limits can be changed at runtime.
// GetAutoCycle returns the current auto-cycle configuration and state.
func (o *Orchestrator) GetAutoCycle(ctx context.Context) (db.AutoCycle, error) {
@@ -65,12 +68,18 @@ func (o *Orchestrator) StopAutoCycle(ctx context.Context) error {
// "stopped" describes an interrupted run. Stopping during the pause
// between cycles must not overwrite the result of the last finished one.
outcome := ""
if ac.Phase == db.AutoCyclePhaseRunning {
if ac.Phase == db.AutoCyclePhaseRunning || ac.Phase == db.AutoCyclePhaseScanning {
outcome = db.AutoCycleOutcomeStopped
}
if err := o.DB.SetAutoCycleEnabled(ctx, false, nil, outcome); err != nil {
return fmt.Errorf("disable auto cycle: %w", err)
}
if ac.Phase == db.AutoCyclePhaseScanning {
// Persisted first, so a step racing in right after cannot restart the
// scan; then abort the background job (queue chunks already enqueued
// stay, like in-flight checks).
o.CancelScan()
}
o.event(ctx, "control-api", "", nil, "auto_cycle_stopped", autoCyclePayload(map[string]any{
"phase": ac.Phase,
}))
@@ -106,7 +115,11 @@ func (o *Orchestrator) autoCycleStep(ctx context.Context, now time.Time) {
return
}
if ac.Phase == db.AutoCyclePhaseRunning {
switch ac.Phase {
case db.AutoCyclePhaseScanning:
o.autoCycleCheckScan(ctx, ac, now)
return
case db.AutoCyclePhaseRunning:
o.autoCycleCheckRun(ctx, ac, now)
return
}
@@ -118,95 +131,145 @@ func (o *Orchestrator) autoCycleStep(ctx context.Context, now time.Time) {
o.autoCycleStartRun(ctx, ac, now)
}
// autoCycleStartRun performs steps 1-2 of the scenario (clear the queue,
// scan floating IPs) and moves the state to running, or straight to waiting
// if there is nothing to wait for.
// autoCycleStartRun begins a cycle: it starts the background scan job (clear
// the queue, then discover and enqueue the free floating IPs) and moves to
// phase `scanning` right away. Nothing slow happens here, so autoCycleMu is
// released immediately and the control loop keeps ticking.
func (o *Orchestrator) autoCycleStartRun(ctx context.Context, ac db.AutoCycle, now time.Time) {
interval := time.Duration(ac.IntervalSeconds) * time.Second
next := now.Add(interval)
if _, started := o.StartScan(ScanOptions{ClearFirst: true}); !started {
// A scan started by someone else (an operator's manual or dry-run scan,
// the periodic scan) is in flight. It is not this cycle's scan: it may
// not clear the queue, or may not enqueue anything at all (dry run), so
// adopting it would end in a "completed" cycle over an untouched or
// empty queue. Change nothing and try again on the next step, once it
// has finished (it is bounded by fip_scan_timeout_seconds).
o.Log.Info("auto cycle: another floating ip scan is running, waiting for it to finish")
return
}
st := autoCycleStateOf(ac)
st.LastRunStartedAt = &now
st.RunStartedAt = nil
st.NextRunAt = nil
st.Phase = db.AutoCyclePhaseScanning
st.LastError = ""
if err := o.DB.UpdateAutoCycleState(ctx, st); err != nil {
o.Log.Error("auto cycle: save state", "err", err)
}
}
fail := func(step string, err error) {
o.Log.Error("auto cycle: step failed", "step", step, "err", err)
// autoCycleCheckScan handles the scanning phase by polling the scan job.
func (o *Orchestrator) autoCycleCheckScan(ctx context.Context, ac db.AutoCycle, now time.Time) {
scan := o.ScanStatus()
switch {
case scan.Running:
return
case scan.State == ScanIdle:
// Phase `scanning` but no job in this process: control-api restarted
// mid-scan. Starting again is idempotent (clear + scan).
o.Log.Info("auto cycle: restarting floating ip scan after restart")
o.StartScan(ScanOptions{ClearFirst: true})
return
}
interval := time.Duration(ac.IntervalSeconds) * time.Second
next := now.Add(interval)
st := autoCycleStateOf(ac)
switch scan.State {
case ScanError:
o.Log.Error("auto cycle: step failed", "step", "scan floating ips", "err", scan.Error)
st.Phase = db.AutoCyclePhaseWaiting
st.RunStartedAt = nil
st.NextRunAt = &next
st.LastRunFinishedAt = &now
st.LastOutcome = db.AutoCycleOutcomeError
st.LastError = fmt.Sprintf("%s: %v", step, err)
if uerr := o.DB.UpdateAutoCycleState(ctx, st); uerr != nil {
o.Log.Error("auto cycle: save state", "err", uerr)
st.LastError = "scan floating ips: " + scan.Error
if err := o.DB.UpdateAutoCycleState(ctx, st); err != nil {
o.Log.Error("auto cycle: save state", "err", err)
return
}
o.event(ctx, "control-api", "", nil, "auto_cycle_error", autoCyclePayload(map[string]any{
"step": step,
"error": err.Error(),
"step": "scan floating ips",
"error": scan.Error,
}))
}
if _, err := o.ClearQueue(ctx); err != nil {
fail("clear queue", err)
return
}
_, scanned, err := o.ScanFloatingIPs(ctx)
if err != nil {
fail("scan floating ips", err)
return
}
st.LastScannedFree = scanned
st.LastError = ""
if scanned == 0 {
// Nothing was queued; waiting for completion would never end.
o.Log.Info("auto cycle: no free floating ips, waiting for next interval")
case ScanCancelled:
if life := o.lifetimeErr(); life != nil {
// The process is shutting down: leave the phase as is so the next
// start resumes the cycle (scanning without a job restarts it).
return
}
// Cancelled by an operator (CancelScan): treat as an interrupted run.
st.Phase = db.AutoCyclePhaseWaiting
st.RunStartedAt = nil
st.NextRunAt = &next
st.LastRunFinishedAt = &now
st.LastOutcome = db.AutoCycleOutcomeNoFreeIPs
st.LastOutcome = db.AutoCycleOutcomeStopped
if err := o.DB.UpdateAutoCycleState(ctx, st); err != nil {
o.Log.Error("auto cycle: save state", "err", err)
}
return
}
st.Phase = db.AutoCyclePhaseRunning
st.RunStartedAt = &now
st.NextRunAt = nil
if err := o.DB.UpdateAutoCycleState(ctx, st); err != nil {
o.Log.Error("auto cycle: save state", "err", err)
return
default: // ScanDone
st.LastScannedFree = scan.Free
st.LastError = ""
if scan.Free == 0 {
// Nothing was queued; waiting for completion would never end.
o.Log.Info("auto cycle: no free floating ips, waiting for next interval")
st.Phase = db.AutoCyclePhaseWaiting
st.RunStartedAt = nil
st.NextRunAt = &next
st.LastRunFinishedAt = &now
st.LastOutcome = db.AutoCycleOutcomeNoFreeIPs
if err := o.DB.UpdateAutoCycleState(ctx, st); err != nil {
o.Log.Error("auto cycle: save state", "err", err)
}
return
}
// max_run_seconds counts from the end of the scan.
st.Phase = db.AutoCyclePhaseRunning
st.RunStartedAt = &now
st.NextRunAt = nil
if err := o.DB.UpdateAutoCycleState(ctx, st); err != nil {
o.Log.Error("auto cycle: save state", "err", err)
return
}
o.event(ctx, "control-api", "", nil, "auto_cycle_started", autoCyclePayload(map[string]any{
"reason": "cycle",
"scanned_free": scan.Free,
}))
}
o.event(ctx, "control-api", "", nil, "auto_cycle_started", autoCyclePayload(map[string]any{
"reason": "cycle",
"scanned_free": scanned,
}))
}
func (o *Orchestrator) lifetimeErr() error {
o.scan.mu.Lock()
defer o.scan.mu.Unlock()
return o.scan.lifetime().Err()
}
// autoCycleCheckRun handles the running phase: finish the cycle once every
// queued address is terminal, or give up after max_run_seconds.
func (o *Orchestrator) autoCycleCheckRun(ctx context.Context, ac db.AutoCycle, now time.Time) {
items, err := o.DB.ListIPs(ctx)
// An empty queue counts as finished: right after the scan it cannot be
// empty (scanned > 0), so it only happens when an operator cleared or
// deleted every address mid-cycle — and then there is nothing to wait for
// (with max_run_seconds=0 the cycle would otherwise hang forever).
// EXISTS is cheap enough to run on every tick, however long the queue.
pending, err := o.DB.AnyNonTerminalIP(ctx)
if err != nil {
o.Log.Error("auto cycle: list ips", "err", err)
o.Log.Error("auto cycle: check pending ips", "err", err)
return
}
interval := time.Duration(ac.IntervalSeconds) * time.Second
next := now.Add(interval)
// An empty queue counts as finished: right after the scan it cannot be
// empty (scanned > 0), so it only happens when an operator cleared or
// deleted every address mid-cycle — and then there is nothing to wait for
// (with max_run_seconds=0 the cycle would otherwise hang forever).
allTerminal := true
for _, it := range items {
if !isTerminalIPState(it.State) {
allTerminal = false
break
if !pending {
_, addresses, err := o.DB.CountIPsByState(ctx)
if err != nil {
o.Log.Error("auto cycle: count ips", "err", err)
return
}
}
if allTerminal {
st := autoCycleStateOf(ac)
st.Phase = db.AutoCyclePhaseWaiting
st.RunStartedAt = nil
@@ -220,7 +283,7 @@ func (o *Orchestrator) autoCycleCheckRun(ctx context.Context, ac db.AutoCycle, n
return
}
o.event(ctx, "control-api", "", nil, "auto_cycle_completed", autoCyclePayload(map[string]any{
"addresses": len(items),
"addresses": addresses,
"runs": st.RunsTotal,
}))
return
@@ -228,6 +291,11 @@ func (o *Orchestrator) autoCycleCheckRun(ctx context.Context, ac db.AutoCycle, n
if ac.MaxRunSeconds > 0 && ac.RunStartedAt != nil &&
now.Sub(*ac.RunStartedAt) > time.Duration(ac.MaxRunSeconds)*time.Second {
_, addresses, err := o.DB.CountIPsByState(ctx)
if err != nil {
o.Log.Error("auto cycle: count ips", "err", err)
return
}
// The queue is left untouched: the next cycle clears it anyway, and
// an operator can still inspect what got stuck.
st := autoCycleStateOf(ac)
@@ -243,7 +311,7 @@ func (o *Orchestrator) autoCycleCheckRun(ctx context.Context, ac db.AutoCycle, n
}
o.event(ctx, "control-api", "", nil, "auto_cycle_timeout", autoCyclePayload(map[string]any{
"max_run_seconds": ac.MaxRunSeconds,
"addresses": len(items),
"addresses": addresses,
}))
}
}
+294 -15
View File
@@ -58,6 +58,37 @@ func countEvents(t *testing.T, d *db.DB, eventType string) int {
return n
}
// waitScan blocks until the background scan job is no longer running.
func waitScan(t *testing.T, o *Orchestrator) ScanStatus {
t.Helper()
return waitScanFor(t, o, 30*time.Second)
}
func waitScanFor(t *testing.T, o *Orchestrator, limit time.Duration) ScanStatus {
t.Helper()
deadline := time.Now().Add(limit)
for {
if st := o.ScanStatus(); !st.Running {
return st
}
if time.Now().After(deadline) {
t.Fatalf("scan job did not finish in time: %+v", o.ScanStatus())
}
time.Sleep(2 * time.Millisecond)
}
}
// stepStartCycle drives one cycle start: the first step launches the
// background scan (phase scanning), then we wait for the job and the second
// step, at the same virtual time, consumes its result.
func stepStartCycle(t *testing.T, o *Orchestrator, now time.Time) {
t.Helper()
ctx := context.Background()
o.autoCycleStep(ctx, now)
waitScan(t, o)
o.autoCycleStep(ctx, now)
}
func TestAutoCycleDisabledIsNoOp(t *testing.T) {
ctx := context.Background()
o, d, mock := newTestOrchestrator(t, 180)
@@ -111,7 +142,7 @@ func TestAutoCycleStartClearsQueueAndScans(t *testing.T) {
}
now := db.Now()
o.autoCycleStep(ctx, now)
stepStartCycle(t, o, now)
got := queuedAddresses(t, d)
if _, stale := got["9.9.9.9"]; stale {
@@ -154,7 +185,7 @@ func TestAutoCycleWaitsWhileChecksInProgressAndTickPicksUpQueue(t *testing.T) {
}
t0 := db.Now()
o.autoCycleStep(ctx, t0)
stepStartCycle(t, o, t0)
// Existing Tick logic starts the checks on its own.
o.Tick(ctx)
@@ -198,7 +229,7 @@ func TestAutoCycleCompletesAndRepeatsAfterInterval(t *testing.T) {
}
t0 := db.Now()
o.autoCycleStep(ctx, t0)
stepStartCycle(t, o, t0)
// done, failed and occupied are all terminal.
if _, err := d.ExecContext(ctx, `UPDATE ip_queue SET state=? WHERE ip_address=?`, db.IPDone, "1.1.1.1"); err != nil {
@@ -241,7 +272,7 @@ func TestAutoCycleCompletesAndRepeatsAfterInterval(t *testing.T) {
}
// At next_run_at: new cycle starts, queue is rebuilt from scratch.
o.autoCycleStep(ctx, wantNext)
stepStartCycle(t, o, wantNext)
ac = getAutoCycle(t, d)
if ac.Phase != db.AutoCyclePhaseRunning {
t.Fatalf("expected running again, got %+v", ac)
@@ -267,7 +298,7 @@ func TestAutoCycleTimeout(t *testing.T) {
}
t0 := db.Now()
o.autoCycleStep(ctx, t0)
stepStartCycle(t, o, t0)
o.autoCycleStep(ctx, t0.Add(300*time.Second))
if ac := getAutoCycle(t, d); ac.Phase != db.AutoCyclePhaseRunning {
@@ -302,7 +333,7 @@ func TestAutoCycleNoLimitNeverTimesOut(t *testing.T) {
t.Fatalf("start: %v", err)
}
t0 := db.Now()
o.autoCycleStep(ctx, t0)
stepStartCycle(t, o, t0)
o.autoCycleStep(ctx, t0.Add(1000*time.Hour))
if ac := getAutoCycle(t, d); ac.Phase != db.AutoCyclePhaseRunning {
t.Fatalf("max_run_seconds=0 means no limit, got %+v", ac)
@@ -320,7 +351,7 @@ func TestAutoCycleManualClearMidCycleCompletes(t *testing.T) {
t.Fatalf("start: %v", err)
}
t0 := db.Now()
o.autoCycleStep(ctx, t0)
stepStartCycle(t, o, t0)
if ac := getAutoCycle(t, d); ac.Phase != db.AutoCyclePhaseRunning {
t.Fatalf("expected running after start, got %+v", ac)
}
@@ -350,7 +381,7 @@ func TestAutoCycleNoFreeIPs(t *testing.T) {
}
now := db.Now()
o.autoCycleStep(ctx, now)
stepStartCycle(t, o, now)
ac := getAutoCycle(t, d)
if ac.Phase != db.AutoCyclePhaseWaiting || ac.LastOutcome != db.AutoCycleOutcomeNoFreeIPs {
@@ -378,7 +409,7 @@ func TestAutoCycleOpenStackErrorRetriesNextInterval(t *testing.T) {
}
t0 := db.Now()
o.autoCycleStep(ctx, t0)
stepStartCycle(t, o, t0)
ac := getAutoCycle(t, d)
if ac.Phase != db.AutoCyclePhaseWaiting || ac.LastOutcome != db.AutoCycleOutcomeError {
@@ -400,7 +431,7 @@ func TestAutoCycleOpenStackErrorRetriesNextInterval(t *testing.T) {
// OpenStack recovers: the next interval starts a normal cycle and the
// stale error is cleared.
mock.ListFailure = nil
o.autoCycleStep(ctx, t0.Add(60*time.Second))
stepStartCycle(t, o, t0.Add(60*time.Second))
ac = getAutoCycle(t, d)
if ac.Phase != db.AutoCyclePhaseRunning || ac.LastError != "" {
t.Fatalf("expected running with cleared error, got %+v", ac)
@@ -415,7 +446,7 @@ func TestAutoCycleStartIsIdempotent(t *testing.T) {
t.Fatalf("start: %v", err)
}
t0 := db.Now()
o.autoCycleStep(ctx, t0)
stepStartCycle(t, o, t0)
if err := o.StartAutoCycle(ctx); err != nil {
t.Fatalf("second start: %v", err)
@@ -440,7 +471,7 @@ func TestAutoCycleStopMidCycle(t *testing.T) {
t.Fatalf("start: %v", err)
}
t0 := db.Now()
o.autoCycleStep(ctx, t0)
stepStartCycle(t, o, t0)
o.Tick(ctx) // check in flight
if err := o.StopAutoCycle(ctx); err != nil {
@@ -484,7 +515,7 @@ func TestAutoCycleStopWhileWaitingKeepsLastOutcome(t *testing.T) {
t.Fatalf("start: %v", err)
}
t0 := db.Now()
o.autoCycleStep(ctx, t0)
stepStartCycle(t, o, t0)
finishAllIPs(t, d, db.IPDone)
o.autoCycleStep(ctx, t0.Add(5*time.Second))
if ac := getAutoCycle(t, d); ac.Phase != db.AutoCyclePhaseWaiting || ac.LastOutcome != db.AutoCycleOutcomeCompleted {
@@ -512,7 +543,7 @@ func TestAutoCycleSurvivesRestart(t *testing.T) {
t.Fatalf("start: %v", err)
}
t0 := db.Now()
o.autoCycleStep(ctx, t0)
stepStartCycle(t, o, t0)
// A fresh Orchestrator on the same database (a control-api restart)
// continues the running phase instead of starting over.
@@ -537,8 +568,256 @@ func TestAutoCycleSurvivesRestart(t *testing.T) {
if ac := getAutoCycle(t, d); ac.Phase != db.AutoCyclePhaseWaiting {
t.Fatalf("expected waiting until next_run_at, got %+v", ac)
}
o3.autoCycleStep(ctx, t1.Add(300*time.Second))
stepStartCycle(t, o3, t1.Add(300*time.Second))
if ac := getAutoCycle(t, d); ac.Phase != db.AutoCyclePhaseRunning {
t.Fatalf("expected a new cycle at next_run_at, got %+v", ac)
}
}
func TestAutoCycleScanningPhaseThenRunning(t *testing.T) {
ctx := context.Background()
o, d, mock := newTestOrchestrator(t, 180)
mock.SeedMany("fip", 30)
mock.PageSize = 10
mock.PageDelay = 100 * time.Millisecond
setAutoCycleParams(t, d, 60, 300)
if err := d.SeedQueue(ctx, []string{"9.9.9.9"}); err != nil {
t.Fatal(err)
}
if err := o.StartAutoCycle(ctx); err != nil {
t.Fatal(err)
}
t0 := db.Now()
o.autoCycleStep(ctx, t0)
ac := getAutoCycle(t, d)
if ac.Phase != db.AutoCyclePhaseScanning || ac.RunStartedAt != nil || ac.NextRunAt != nil {
t.Fatalf("expected scanning without run_started_at, got %+v", ac)
}
if ac.LastRunStartedAt == nil || !ac.LastRunStartedAt.Equal(t0) {
t.Fatalf("expected last_run_started_at=%v, got %v", t0, ac.LastRunStartedAt)
}
if !o.ScanStatus().Running {
t.Fatalf("expected the scan job to be running")
}
// While the job runs, further steps leave the phase alone, even far past
// max_run_seconds (it only counts from the end of the scan).
o.autoCycleStep(ctx, t0.Add(time.Hour))
if ac := getAutoCycle(t, d); ac.Phase != db.AutoCyclePhaseScanning || ac.LastOutcome != "" {
t.Fatalf("expected still scanning, got %+v", ac)
}
waitScan(t, o)
t1 := t0.Add(2 * time.Hour)
o.autoCycleStep(ctx, t1)
ac = getAutoCycle(t, d)
if ac.Phase != db.AutoCyclePhaseRunning || ac.LastScannedFree != 30 {
t.Fatalf("expected running with last_scanned_free=30, got %+v", ac)
}
if ac.RunStartedAt == nil || !ac.RunStartedAt.Equal(t1) {
t.Fatalf("run_started_at must be the scan end %v, got %v", t1, ac.RunStartedAt)
}
if got := queuedAddresses(t, d); len(got) != 30 {
t.Fatalf("expected the 30 scanned addresses only, got %d", len(got))
}
// max_run_seconds is measured from t1.
o.autoCycleStep(ctx, t1.Add(300*time.Second))
if ac := getAutoCycle(t, d); ac.Phase != db.AutoCyclePhaseRunning {
t.Fatalf("expected still running at the limit, got %+v", ac)
}
o.autoCycleStep(ctx, t1.Add(301*time.Second))
if ac := getAutoCycle(t, d); ac.LastOutcome != db.AutoCycleOutcomeTimeout {
t.Fatalf("expected timeout, got %+v", ac)
}
}
func TestAutoCycleScanErrorSurfacesAndRetries(t *testing.T) {
ctx := context.Background()
o, d, mock := newTestOrchestrator(t, 180)
mock.SeedMany("fip", 5)
mock.ListFailure = errors.New("neutron is down")
setAutoCycleParams(t, d, 60, 0)
if err := o.StartAutoCycle(ctx); err != nil {
t.Fatal(err)
}
t0 := db.Now()
o.autoCycleStep(ctx, t0)
if ac := getAutoCycle(t, d); ac.Phase != db.AutoCyclePhaseScanning {
t.Fatalf("expected scanning first, got %+v", ac)
}
waitScan(t, o)
t1 := t0.Add(time.Second)
o.autoCycleStep(ctx, t1)
ac := getAutoCycle(t, d)
if ac.Phase != db.AutoCyclePhaseWaiting || ac.LastOutcome != db.AutoCycleOutcomeError ||
!strings.Contains(ac.LastError, "neutron is down") || ac.NextRunAt == nil || !ac.NextRunAt.Equal(t1.Add(time.Minute)) {
t.Fatalf("expected waiting/error with next_run_at=t1+interval, got %+v", ac)
}
if countEvents(t, d, "auto_cycle_error") != 1 {
t.Fatalf("expected one auto_cycle_error event")
}
}
func TestAutoCycleRestartInScanningPhaseStartsScanAgain(t *testing.T) {
ctx := context.Background()
o, d, mock := newTestOrchestrator(t, 180)
mock.SeedMany("fip", 12)
if err := d.SeedQueue(ctx, []string{"9.9.9.9"}); err != nil {
t.Fatal(err)
}
setAutoCycleParams(t, d, 60, 0)
if err := o.StartAutoCycle(ctx); err != nil {
t.Fatal(err)
}
t0 := db.Now()
// Persisted state of a process that died mid-scan.
if err := d.UpdateAutoCycleState(ctx, db.AutoCycleState{Phase: db.AutoCyclePhaseScanning, LastRunStartedAt: &t0}); err != nil {
t.Fatal(err)
}
o2 := &Orchestrator{DB: d, OS: mock, Cfg: o.Cfg, Agg: o.Agg, Log: o.Log}
t.Cleanup(func() { o2.CancelScan() })
if st := o2.ScanStatus(); st.State != ScanIdle {
t.Fatalf("fresh process must have no scan job, got %+v", st)
}
o2.autoCycleStep(ctx, t0.Add(time.Second))
if ac := getAutoCycle(t, d); ac.Phase != db.AutoCyclePhaseScanning {
t.Fatalf("expected to stay in scanning while the new job runs, got %+v", ac)
}
if st := o2.ScanStatus(); st.State == ScanIdle {
t.Fatalf("expected a new scan job to be started")
}
waitScan(t, o2)
t1 := t0.Add(2 * time.Second)
o2.autoCycleStep(ctx, t1)
ac := getAutoCycle(t, d)
if ac.Phase != db.AutoCyclePhaseRunning || ac.LastScannedFree != 12 || ac.RunStartedAt == nil || !ac.RunStartedAt.Equal(t1) {
t.Fatalf("expected running after the restarted scan, got %+v", ac)
}
got := queuedAddresses(t, d)
if _, stale := got["9.9.9.9"]; stale || len(got) != 12 {
t.Fatalf("restarted scan must clear and re-scan, got %d rows (stale=%v)", len(got), stale)
}
}
func TestAutoCycleStopCancelsScan(t *testing.T) {
ctx := context.Background()
o, d, mock := newTestOrchestrator(t, 180)
mock.SeedMany("fip", 30)
mock.PageSize = 10
mock.PageDelay = 5 * time.Second
if err := o.StartAutoCycle(ctx); err != nil {
t.Fatal(err)
}
t0 := db.Now()
o.autoCycleStep(ctx, t0)
if ac := getAutoCycle(t, d); ac.Phase != db.AutoCyclePhaseScanning || !o.ScanStatus().Running {
t.Fatalf("precondition: scanning with a live job, got %+v", ac)
}
if err := o.StopAutoCycle(ctx); err != nil {
t.Fatalf("stop: %v", err)
}
ac := getAutoCycle(t, d)
if ac.Enabled || ac.Phase != db.AutoCyclePhaseIdle || ac.LastOutcome != db.AutoCycleOutcomeStopped {
t.Fatalf("expected disabled/idle/stopped, got %+v", ac)
}
if st := o.ScanStatus(); st.State != ScanCancelled || st.Running {
t.Fatalf("expected the scan to be cancelled, got %+v", st)
}
// Later steps do nothing.
o.autoCycleStep(ctx, t0.Add(time.Hour))
if ac := getAutoCycle(t, d); ac.Enabled || ac.Phase != db.AutoCyclePhaseIdle {
t.Fatalf("expected no activity after stop, got %+v", ac)
}
}
// A slow scan must neither block the control loop (Tick, auto-cycle steps)
// nor Start/Stop/Get on the auto-cycle: autoCycleMu is never held across it.
func TestSlowScanDoesNotBlockTickOrAutoCycleCalls(t *testing.T) {
ctx := context.Background()
o, d, mock := newTestOrchestrator(t, 180)
mock.SeedMany("fip", 20)
mock.PageSize = 10
mock.PageDelay = 1500 * time.Millisecond
if err := d.RegisterValidator(ctx, "validator-1", "host-1", "port-1", "v0.1"); err != nil {
t.Fatal(err)
}
within := func(what string, limit time.Duration, f func()) {
t.Helper()
start := time.Now()
f()
if el := time.Since(start); el > limit {
t.Fatalf("%s took %v while a scan was running (limit %v)", what, el, limit)
}
}
within("StartAutoCycle", 500*time.Millisecond, func() {
if err := o.StartAutoCycle(ctx); err != nil {
t.Fatal(err)
}
})
t0 := db.Now()
within("first auto-cycle step", 500*time.Millisecond, func() { o.autoCycleStep(ctx, t0) })
if !o.ScanStatus().Running {
t.Fatalf("scan should be in flight")
}
within("Tick", 500*time.Millisecond, func() { o.Tick(ctx) })
within("polling auto-cycle step", 500*time.Millisecond, func() { o.autoCycleStep(ctx, t0.Add(time.Second)) })
within("GetAutoCycle", 500*time.Millisecond, func() {
if _, err := o.GetAutoCycle(ctx); err != nil {
t.Fatal(err)
}
})
within("StopAutoCycle", 3*time.Second, func() {
if err := o.StopAutoCycle(ctx); err != nil {
t.Fatal(err)
}
})
}
// A scan that is already running (an operator's dry run, a manual or periodic
// scan) is NOT a cycle's scan: it may not clear the queue or enqueue anything.
// The cycle must wait for it and then run its own clear+scan; following the
// foreign job would end in "completed" over an untouched/empty queue.
func TestAutoCycleWaitsForForeignScanInsteadOfFollowingIt(t *testing.T) {
ctx := context.Background()
o, d, mock := newTestOrchestrator(t, 180)
mock.Seed("fip-1", "1.1.1.1", "svc")
mock.PageSize = 1
mock.PageDelay = 80 * time.Millisecond // keeps the dry run in flight for a while
if err := d.SeedQueue(ctx, []string{"9.9.9.9"}); err != nil {
t.Fatalf("seed queue: %v", err)
}
setAutoCycleParams(t, d, 60, 0)
if err := o.StartAutoCycle(ctx); err != nil {
t.Fatalf("start: %v", err)
}
if _, started := o.StartScan(ScanOptions{DryRun: true}); !started {
t.Fatalf("precondition: the dry run must start")
}
t0 := db.Now()
o.autoCycleStep(ctx, t0)
ac := getAutoCycle(t, d)
if ac.Phase == db.AutoCyclePhaseScanning || ac.Phase == db.AutoCyclePhaseRunning {
t.Fatalf("cycle must not adopt the foreign scan, got phase %q", ac.Phase)
}
if got := queuedAddresses(t, d); got["9.9.9.9"] != db.IPQueued {
t.Fatalf("queue must be untouched while the foreign scan runs, got %v", got)
}
waitScan(t, o) // the dry run ends
mock.PageDelay = 0
stepStartCycle(t, o, t0.Add(time.Second))
ac = getAutoCycle(t, d)
if ac.Phase != db.AutoCyclePhaseRunning || ac.LastScannedFree != 1 {
t.Fatalf("expected the cycle's own scan to complete (running, 1 free), got %+v", ac)
}
got := queuedAddresses(t, d)
if _, stale := got["9.9.9.9"]; stale || got["1.1.1.1"] != db.IPQueued {
t.Fatalf("the cycle's own scan must clear the old queue and enqueue 1.1.1.1, got %v", got)
}
}
+50 -69
View File
@@ -41,6 +41,14 @@ type Orchestrator struct {
// autoCycleMu serializes AutoCycleStep with StartAutoCycle/StopAutoCycle
// so an API call can never interleave with a half-finished step.
autoCycleMu sync.Mutex
// ScanPageSize is the Neutron page size used by the floating-IP scan
// (config openstack.list_page_size); 0 means openstack.DefaultListPageSize.
ScanPageSize int
// scan is the background floating-IP scan job (see scanjob.go); its zero
// value is ready to use.
scan scanJob
}
// New constructs an Orchestrator. Egress check types/targets, prober sites,
@@ -58,6 +66,8 @@ func New(d *db.DB, osClient openstack.FloatingIPClient, cfg *config.ControlAPI,
Cfg: cfg.Orchestrator,
Agg: cfg.Aggregation,
Log: log,
ScanPageSize: cfg.OpenStack.ListPageSize,
}
}
@@ -349,17 +359,16 @@ func (o *Orchestrator) DeleteIP(ctx context.Context, ipAddress string) error {
// in the list that has one attached, then deletes the whole list in one
// DB.DeleteIPs call. Addresses not currently in the queue are simply
// omitted from the disassociation pass and reported back in NotFound by
// DB.DeleteIPs — not an error.
// DB.DeleteIPs — not an error. The rows holding a floating IP are found with
// a few IN (...) queries rather than one lookup per address.
func (o *Orchestrator) DeleteIPs(ctx context.Context, addresses []string) (db.DeleteIPsResult, error) {
for _, addr := range addresses {
item, err := o.DB.GetIPByAddress(ctx, addr)
if err != nil {
continue // unknown address — DB.DeleteIPs will report it in NotFound
}
if item.FIPID != "" {
if err := o.OS.DisassociateFloatingIP(ctx, item.FIPID); err != nil {
o.Log.Error("disassociate fip on delete", "ip_id", item.ID, "fip_id", item.FIPID, "err", err)
}
refs, err := o.DB.ListFIPRefsByAddresses(ctx, addresses)
if err != nil {
o.Log.Error("list attached fips before delete", "err", err)
}
for _, ref := range refs {
if err := o.OS.DisassociateFloatingIP(ctx, ref.FIPID); err != nil {
o.Log.Error("disassociate fip on delete", "ip_id", ref.IPID, "fip_id", ref.FIPID, "err", err)
}
}
@@ -372,79 +381,51 @@ func (o *Orchestrator) DeleteIPs(ctx context.Context, addresses []string) (db.De
}
// ClearQueue deletes every address currently in the queue, regardless of
// state — the "delete everything" operation, implemented as DeleteIPs over
// the full current address list rather than a separate DB code path.
// state — the "delete everything" operation. It is set-based (see
// db.ClearAllIPs): O(1) statements however many rows there are. Floating IPs
// attached to rows are disassociated first (best-effort), and only for rows
// that actually hold one.
func (o *Orchestrator) ClearQueue(ctx context.Context) (db.DeleteIPsResult, error) {
items, err := o.DB.ListIPs(ctx)
refs, err := o.DB.ListFIPRefs(ctx)
if err != nil {
return db.DeleteIPsResult{}, fmt.Errorf("list ips: %w", err)
return db.DeleteIPsResult{}, fmt.Errorf("list attached fips: %w", err)
}
addresses := make([]string, len(items))
for i, item := range items {
addresses[i] = item.IPAddress
}
for _, item := range items {
if item.FIPID != "" {
if err := o.OS.DisassociateFloatingIP(ctx, item.FIPID); err != nil {
o.Log.Error("disassociate fip on clear queue", "ip_id", item.ID, "fip_id", item.FIPID, "err", err)
}
for _, ref := range refs {
if err := o.OS.DisassociateFloatingIP(ctx, ref.FIPID); err != nil {
o.Log.Error("disassociate fip on clear queue", "ip_id", ref.IPID, "fip_id", ref.FIPID, "err", err)
}
}
result, err := o.DB.DeleteIPs(ctx, addresses)
deleted, err := o.DB.ClearAllIPs(ctx)
if err != nil {
return result, err
return db.DeleteIPsResult{}, err
}
o.event(ctx, "control-api", "", nil, "queue_cleared", deletedAddressesPayload(result.Deleted))
return result, nil
o.event(ctx, "control-api", "", nil, "queue_cleared", deletedAddressesPayload(deleted))
return db.DeleteIPsResult{Deleted: deleted}, nil
}
// ScanFloatingIPs lists every floating IP in the OpenStack project, filters
// to the ones not currently associated to any port (the free pool awaiting
// validation before reissue), and submits that address list to the check
// queue via db.SubmitIPs — the same entry point the admin API's "add
// addresses" call uses, so add/requeue/reorder semantics are identical
// whether the address list came from an operator or from this scan. Returns
// the SubmitIPs outcome plus how many free floating IPs were found in total
// (which can be larger than the sum of the SubmitIPsResult slices, since
// addresses already mid-check are silently skipped — see db.SubmitIPs).
func (o *Orchestrator) ScanFloatingIPs(ctx context.Context) (db.SubmitIPsResult, int, error) {
fips, err := o.OS.ListFloatingIPs(ctx)
if err != nil {
return db.SubmitIPsResult{}, 0, fmt.Errorf("list floating ips: %w", err)
}
var free []string
for _, f := range fips {
if f.PortID == "" {
free = append(free, f.Address)
}
}
if len(free) == 0 {
o.event(ctx, "control-api", "", nil, "fip_scan", `{"scanned_free":0}`)
return db.SubmitIPsResult{}, 0, nil
}
result, err := o.DB.SubmitIPs(ctx, free)
if err != nil {
return result, len(free), fmt.Errorf("submit scanned ips: %w", err)
}
o.event(ctx, "control-api", "", nil, "fip_scan", fmt.Sprintf(
`{"scanned_free":%d,"added":%d,"requeued":%d,"reordered":%d,"skipped_in_progress":%d}`,
len(free), len(result.Added), len(result.Requeued), len(result.Reordered), len(result.SkippedInProgress)))
return result, len(free), nil
}
// maxEventAddresses caps how many addresses a batch-delete event payload
// lists: clearing 6440 addresses must not write a 100 KB event row.
const maxEventAddresses = 50
// deletedAddressesPayload builds the event payload for the batch delete
// operations — a proper JSON array via encoding/json rather than fmt's %q
// slice formatting (which produces space-separated quoted strings, not
// valid JSON).
// operations — a proper JSON object via encoding/json: the total count plus
// at most the first maxEventAddresses addresses (and "truncated":true when
// the list was cut).
func deletedAddressesPayload(addresses []string) string {
b, err := json.Marshal(struct {
p := struct {
Count int `json:"count"`
Addresses []string `json:"addresses"`
}{addresses})
Truncated bool `json:"truncated,omitempty"`
}{Count: len(addresses), Addresses: addresses}
if p.Addresses == nil {
p.Addresses = []string{}
}
if len(p.Addresses) > maxEventAddresses {
p.Addresses = p.Addresses[:maxEventAddresses]
p.Truncated = true
}
b, err := json.Marshal(p)
if err != nil {
return "{}"
}
+4 -1
View File
@@ -64,7 +64,10 @@ func newTestOrchestratorWithSites(t *testing.T, leaseTTLSeconds int, sites []con
}
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
return New(d, mock, cfg, log), d, mock
o := New(d, mock, cfg, log)
// A background scan must never outlive the database it writes to.
t.Cleanup(func() { o.CancelScan() })
return o, d, mock
}
func TestHappyPath(t *testing.T) {
+370
View File
@@ -0,0 +1,370 @@
package orchestrator
import (
"context"
"errors"
"fmt"
"net/netip"
"sort"
"sync"
"time"
"cloudipvalidator/internal/db"
"cloudipvalidator/internal/openstack"
)
// This file implements the background floating-IP scan job. With thousands of
// floating IPs a scan takes tens of seconds (Neutron is read page by page)
// and then enqueues thousands of rows, so it can neither run inside an HTTP
// request nor under the auto-cycle mutex. StartScan launches it on the
// process-lifetime context and returns immediately; the job publishes its
// progress as a ScanStatus that anyone can poll.
// ScanState is the phase of the scan job.
type ScanState string
const (
ScanIdle ScanState = "idle" // no scan has run in this process yet
ScanClearing ScanState = "clearing"
ScanListing ScanState = "listing"
ScanEnqueuing ScanState = "enqueuing"
ScanDone ScanState = "done"
ScanError ScanState = "error"
ScanCancelled ScanState = "cancelled"
)
const (
// scanChunkSize is how many addresses go into one db.SubmitIPs call, i.e.
// one short transaction; the single DB connection is released in between
// so the orchestrator tick and the API stay responsive.
scanChunkSize = 500
// defaultScanTimeout is used when Cfg.FIPScanTimeoutSeconds is zero.
defaultScanTimeout = 1800 * time.Second
// cancelWait bounds how long CancelScan waits for the job to wind down.
cancelWait = 5 * time.Second
)
// ScanOptions selects the variant of a scan.
type ScanOptions struct {
// ClearFirst clears the whole queue before scanning (auto-cycle step 1).
// Ignored together with DryRun: a dry run never touches the queue.
ClearFirst bool
// DryRun only discovers and counts the free floating IPs; the queue is
// left untouched.
DryRun bool
}
// ScanStatus is a snapshot of the scan job's progress.
type ScanStatus struct {
State ScanState
Running bool
DryRun bool
Pages int // Neutron pages read so far
Discovered int // floating IPs seen (free and associated)
Free int // of those, free (no port) — the addresses to enqueue
Added int
Requeued int
Reordered int
SkippedInProgress int
StartedAt *time.Time
FinishedAt *time.Time
Error string
}
// scanResult is what a finished job leaves for the synchronous wrapper.
type scanResult struct {
submit db.SubmitIPsResult
free int
err error
}
// scanRun is one execution of the job.
type scanRun struct {
done chan struct{} // closed when the job has finished (result is set)
cancel context.CancelFunc
result scanResult
}
// scanJob is the zero-value-usable scan state embedded in Orchestrator
// (tests build Orchestrator as a literal, so there is no constructor-only
// initialization).
type scanJob struct {
mu sync.Mutex
lifeCtx context.Context // process lifetime; nil = context.Background()
status ScanStatus // zero value reads as idle (see snapshot)
run *scanRun // current/last run
cancelRequested bool
}
// SetContext sets the lifetime context background jobs (the scan) run on.
// Call once at startup; until then context.Background() is used. Cancelling
// it cancels a running scan.
func (o *Orchestrator) SetContext(ctx context.Context) {
o.scan.mu.Lock()
defer o.scan.mu.Unlock()
o.scan.lifeCtx = ctx
}
func (j *scanJob) lifetime() context.Context {
if j.lifeCtx != nil {
return j.lifeCtx
}
return context.Background()
}
// snapshot returns a copy of the status; caller holds j.mu.
func (j *scanJob) snapshot() ScanStatus {
st := j.status
if st.State == "" {
st.State = ScanIdle
}
return st
}
// ScanStatus returns the current scan status (state "idle" if no scan has run
// in this process yet).
func (o *Orchestrator) ScanStatus() ScanStatus {
o.scan.mu.Lock()
defer o.scan.mu.Unlock()
return o.scan.snapshot()
}
// StartScan starts the background scan job, or — single-flight — joins the one
// already running: then started is false and the returned status is the
// running job's. It never blocks on OpenStack or the database.
func (o *Orchestrator) StartScan(opts ScanOptions) (ScanStatus, bool) {
st, _, started := o.startScan(opts)
return st, started
}
func (o *Orchestrator) startScan(opts ScanOptions) (ScanStatus, *scanRun, bool) {
j := &o.scan
j.mu.Lock()
defer j.mu.Unlock()
if j.status.Running && j.run != nil {
return j.snapshot(), j.run, false
}
timeout := time.Duration(o.Cfg.FIPScanTimeoutSeconds) * time.Second
if timeout <= 0 {
timeout = defaultScanTimeout
}
ctx, cancel := context.WithTimeout(j.lifetime(), timeout)
run := &scanRun{done: make(chan struct{}), cancel: cancel}
if opts.DryRun {
opts.ClearFirst = false
}
now := db.Now()
state := ScanListing
if opts.ClearFirst {
state = ScanClearing
}
j.run = run
j.cancelRequested = false
j.status = ScanStatus{State: state, Running: true, DryRun: opts.DryRun, StartedAt: &now}
go o.runScan(ctx, run, opts)
return j.snapshot(), run, true
}
// CancelScan cancels a running scan and waits (briefly) for it to wind down.
// It reports whether a running scan was cancelled. Already-enqueued chunks
// stay in the queue.
func (o *Orchestrator) CancelScan() bool {
j := &o.scan
j.mu.Lock()
run := j.run
if !j.status.Running || run == nil {
j.mu.Unlock()
return false
}
j.cancelRequested = true
j.mu.Unlock()
run.cancel()
select {
case <-run.done:
case <-time.After(cancelWait):
}
return true
}
// update mutates the status under the lock.
func (j *scanJob) update(f func(*ScanStatus)) {
j.mu.Lock()
defer j.mu.Unlock()
f(&j.status)
}
func (o *Orchestrator) runScan(ctx context.Context, run *scanRun, opts ScanOptions) {
j := &o.scan
defer run.cancel()
res, err := o.doScan(ctx, opts)
run.result = res
j.mu.Lock()
finished := db.Now()
switch {
case err == nil:
j.status.State = ScanDone
case j.cancelRequested || errors.Is(j.lifetime().Err(), context.Canceled):
j.status.State = ScanCancelled
err = fmt.Errorf("scan cancelled: %w", context.Canceled)
j.status.Error = "cancelled"
case errors.Is(err, context.DeadlineExceeded):
j.status.State = ScanError
err = fmt.Errorf("scan timed out: %w", err)
j.status.Error = err.Error()
default:
j.status.State = ScanError
j.status.Error = err.Error()
}
j.status.Running = false
j.status.FinishedAt = &finished
state := j.status.State
j.mu.Unlock()
run.result.err = err
if state == ScanError || state == ScanCancelled {
o.Log.Warn("floating ip scan did not complete", "state", state, "err", err)
}
close(run.done)
}
// doScan is the scan algorithm: optional clear -> read every page into memory
// -> sort -> (unless dry run) enqueue in chunks -> one fip_scan event.
func (o *Orchestrator) doScan(ctx context.Context, opts ScanOptions) (scanResult, error) {
j := &o.scan
var res scanResult
if opts.ClearFirst {
if _, err := o.ClearQueue(ctx); err != nil {
return res, fmt.Errorf("clear queue: %w", err)
}
j.update(func(s *ScanStatus) { s.State = ScanListing })
}
// Read everything first: a read error after retries must leave the queue
// untouched, so nothing is enqueued until discovery is complete.
var free []string
seen := map[string]struct{}{}
pages, err := o.OS.ListFreeFloatingIPs(ctx, o.ScanPageSize, func(page []openstack.FloatingIP) error {
nFree := 0
for _, f := range page {
if f.PortID != "" || f.Address == "" {
continue
}
if _, dup := seen[f.Address]; dup {
continue
}
seen[f.Address] = struct{}{}
free = append(free, f.Address)
nFree++
}
j.update(func(s *ScanStatus) {
s.Pages++
s.Discovered += len(page)
s.Free += nFree
})
return ctx.Err()
})
if err != nil {
return res, fmt.Errorf("list floating ips: %w", err)
}
j.update(func(s *ScanStatus) { s.Pages = pages })
res.free = len(free)
sortAddressesAscending(free)
if !opts.DryRun && len(free) > 0 {
j.update(func(s *ScanStatus) { s.State = ScanEnqueuing })
for off := 0; off < len(free); off += scanChunkSize {
if err := ctx.Err(); err != nil {
return res, err
}
chunk := free[off:min(off+scanChunkSize, len(free))]
r, err := o.DB.SubmitIPs(ctx, chunk)
res.submit.Added = append(res.submit.Added, r.Added...)
res.submit.Requeued = append(res.submit.Requeued, r.Requeued...)
res.submit.Reordered = append(res.submit.Reordered, r.Reordered...)
res.submit.SkippedInProgress = append(res.submit.SkippedInProgress, r.SkippedInProgress...)
j.update(func(s *ScanStatus) {
s.Added += len(r.Added)
s.Requeued += len(r.Requeued)
s.Reordered += len(r.Reordered)
s.SkippedInProgress += len(r.SkippedInProgress)
})
if err != nil {
return res, fmt.Errorf("submit scanned ips: %w", err)
}
}
}
if !opts.DryRun {
o.event(ctx, "control-api", "", nil, "fip_scan", fmt.Sprintf(
`{"scanned_free":%d,"pages":%d,"added":%d,"requeued":%d,"reordered":%d,"skipped_in_progress":%d}`,
len(free), pages, len(res.submit.Added), len(res.submit.Requeued),
len(res.submit.Reordered), len(res.submit.SkippedInProgress)))
}
return res, nil
}
// sortAddressesAscending orders addresses numerically (10.0.0.2 before
// 10.0.0.10) so the queue order is deterministic; anything that does not parse
// as an IP goes last, in string order.
func sortAddressesAscending(addrs []string) {
type keyed struct {
s string
ip netip.Addr
ok bool
}
ks := make([]keyed, len(addrs))
for i, a := range addrs {
ip, err := netip.ParseAddr(a)
ks[i] = keyed{s: a, ip: ip, ok: err == nil}
}
sort.SliceStable(ks, func(i, j int) bool {
a, b := ks[i], ks[j]
switch {
case a.ok && b.ok:
return a.ip.Compare(b.ip) < 0
case a.ok != b.ok:
return a.ok
default:
return a.s < b.s
}
})
for i := range ks {
addrs[i] = ks[i].s
}
}
// ScanFloatingIPs lists every floating IP in the OpenStack project, filters
// to the ones not currently associated to any port (the free pool awaiting
// validation before reissue), and submits that address list to the check
// queue via db.SubmitIPs — the same entry point the admin API's "add
// addresses" call uses, so add/requeue/reorder semantics are identical
// whether the address list came from an operator or from this scan. It is the
// synchronous "start (or join) the background scan and wait for it" wrapper:
// returns the aggregated SubmitIPs outcome plus how many free floating IPs
// were found in total (which can be larger than the sum of the
// SubmitIPsResult slices, since addresses already mid-check are silently
// skipped — see db.SubmitIPs). If ctx is cancelled first it returns
// ctx.Err(); the background job keeps running.
func (o *Orchestrator) ScanFloatingIPs(ctx context.Context) (db.SubmitIPsResult, int, error) {
return o.ScanAndWait(ctx, ScanOptions{})
}
// ScanAndWait starts (or joins) the background scan with opts and waits for it
// to finish, returning the aggregated SubmitIPs outcome and the number of free
// floating IPs found. Joining a scan that is already running returns that
// scan's result regardless of opts. If ctx ends first it returns ctx.Err()
// and the job keeps running.
func (o *Orchestrator) ScanAndWait(ctx context.Context, opts ScanOptions) (db.SubmitIPsResult, int, error) {
_, run, _ := o.startScan(opts)
select {
case <-run.done:
case <-ctx.Done():
return db.SubmitIPsResult{}, 0, ctx.Err()
}
return run.result.submit, run.result.free, run.result.err
}
+398
View File
@@ -0,0 +1,398 @@
package orchestrator
import (
"context"
"encoding/json"
"errors"
"net/netip"
"strconv"
"strings"
"testing"
"time"
"cloudipvalidator/internal/db"
)
func eventPayload(t *testing.T, d *db.DB, eventType string) string {
t.Helper()
var p string
if err := d.QueryRowContext(context.Background(),
`SELECT payload FROM events WHERE event_type=? ORDER BY id DESC LIMIT 1`, eventType).Scan(&p); err != nil {
t.Fatalf("read %s event: %v", eventType, err)
}
return p
}
func TestScanStatusIdleBeforeAnyScan(t *testing.T) {
// Zero-value Orchestrator literal (as several tests build it) must work.
o := &Orchestrator{}
st := o.ScanStatus()
if st.State != ScanIdle || st.Running || st.StartedAt != nil {
t.Fatalf("expected idle, got %+v", st)
}
if o.CancelScan() {
t.Fatalf("nothing to cancel")
}
}
func TestScanJobSingleFlightAndProgress(t *testing.T) {
o, d, mock := newTestOrchestrator(t, 180)
mock.SeedMany("fip", 600)
mock.SeedWithPort("busy", "203.0.113.9", "svc", "port-x")
mock.PageSize = 100
mock.PageDelay = 40 * time.Millisecond
st, started := o.StartScan(ScanOptions{})
if !started || !st.Running || st.State != ScanListing || st.StartedAt == nil {
t.Fatalf("expected a started listing job, got started=%v %+v", started, st)
}
st2, started2 := o.StartScan(ScanOptions{DryRun: true})
if started2 || !st2.Running || st2.DryRun {
t.Fatalf("second start must join the running job (not dry-run), got started=%v %+v", started2, st2)
}
// The synchronous wrapper joins the same job instead of scanning twice.
type out struct {
res db.SubmitIPsResult
scanned int
err error
}
ch := make(chan out, 1)
go func() {
r, n, err := o.ScanFloatingIPs(context.Background())
ch <- out{r, n, err}
}()
// Progress is visible while the job runs.
sawProgress := false
for i := 0; i < 200 && o.ScanStatus().Running; i++ {
if s := o.ScanStatus(); s.Pages > 0 && s.Pages < 7 {
sawProgress = true
}
time.Sleep(5 * time.Millisecond)
}
got := <-ch
if got.err != nil || got.scanned != 600 || len(got.res.Added) != 600 {
t.Fatalf("wrapper result: %+v", got)
}
if !sawProgress {
t.Fatalf("expected to observe intermediate progress")
}
fin := waitScan(t, o)
if fin.State != ScanDone || fin.Running || fin.FinishedAt == nil || fin.Error != "" {
t.Fatalf("expected done, got %+v", fin)
}
if fin.Pages != 7 || fin.Discovered != 601 || fin.Free != 600 || fin.Added != 600 {
t.Fatalf("unexpected counters: %+v", fin)
}
if countEvents(t, d, "fip_scan") != 1 {
t.Fatalf("expected exactly one fip_scan event, got %d", countEvents(t, d, "fip_scan"))
}
var p struct {
ScannedFree int `json:"scanned_free"`
Pages int `json:"pages"`
Added int `json:"added"`
}
if err := json.Unmarshal([]byte(eventPayload(t, d, "fip_scan")), &p); err != nil || p.ScannedFree != 600 || p.Added != 600 || p.Pages != 7 {
t.Fatalf("fip_scan payload: %+v err=%v", p, err)
}
// A new scan after completion starts a fresh job; everything is requeued
// or reordered (idempotent), nothing added.
mock.PageDelay = 0
st3, started3 := o.StartScan(ScanOptions{})
if !started3 {
t.Fatalf("a finished job must not block a new one: %+v", st3)
}
fin = waitScan(t, o)
if fin.State != ScanDone || fin.Added != 0 || fin.Reordered != 600 {
t.Fatalf("rescan: %+v", fin)
}
}
func TestScanJobDryRunLeavesQueueUntouched(t *testing.T) {
ctx := context.Background()
o, d, mock := newTestOrchestrator(t, 180)
mock.SeedMany("fip", 450)
mock.PageSize = 200
if err := d.SeedQueue(ctx, []string{"9.9.9.9"}); err != nil {
t.Fatal(err)
}
o.StartScan(ScanOptions{DryRun: true, ClearFirst: true}) // ClearFirst is ignored for dry runs
st := waitScan(t, o)
if st.State != ScanDone || !st.DryRun || st.Free != 450 || st.Pages != 3 || st.Added != 0 {
t.Fatalf("dry run status: %+v", st)
}
if got := queuedAddresses(t, d); len(got) != 1 || got["9.9.9.9"] != db.IPQueued {
t.Fatalf("dry run must not touch the queue, got %d rows", len(got))
}
if countEvents(t, d, "fip_scan") != 0 || countEvents(t, d, "queue_cleared") != 0 {
t.Fatalf("dry run must not emit scan/clear events")
}
}
func TestScanJobReadErrorLeavesQueueUntouched(t *testing.T) {
ctx := context.Background()
o, d, mock := newTestOrchestrator(t, 180)
mock.SeedMany("fip", 450)
mock.PageSize = 200
if err := d.SeedQueue(ctx, []string{"9.9.9.9"}); err != nil {
t.Fatal(err)
}
// Page 1 succeeds, page 2 fails: the 200 already read must NOT be queued.
mock.ListFailures = []error{nil, errors.New("neutron exploded")}
o.StartScan(ScanOptions{})
st := waitScan(t, o)
if st.State != ScanError || !strings.Contains(st.Error, "neutron exploded") || st.FinishedAt == nil {
t.Fatalf("expected error state, got %+v", st)
}
if st.Pages != 1 || st.Added != 0 {
t.Fatalf("expected 1 page read and nothing added, got %+v", st)
}
if got := queuedAddresses(t, d); len(got) != 1 || got["9.9.9.9"] != db.IPQueued {
t.Fatalf("queue must be untouched after a read error, got %d rows", len(got))
}
if countEvents(t, d, "fip_scan") != 0 {
t.Fatalf("no fip_scan event on failure")
}
// The wrapper reports the same error.
mock.ListFailure = errors.New("still down")
if _, _, err := o.ScanFloatingIPs(ctx); err == nil || !strings.Contains(err.Error(), "still down") {
t.Fatalf("expected wrapped list error, got %v", err)
}
}
func TestScanJobClearFirst(t *testing.T) {
ctx := context.Background()
o, d, mock := newTestOrchestrator(t, 180)
mock.Seed("fip-1", "1.1.1.1", "svc")
if err := d.SeedQueue(ctx, []string{"9.9.9.9", "8.8.8.8"}); err != nil {
t.Fatal(err)
}
st, _ := o.StartScan(ScanOptions{ClearFirst: true})
if st.State != ScanClearing {
t.Fatalf("expected clearing as first state, got %s", st.State)
}
fin := waitScan(t, o)
if fin.State != ScanDone || fin.Added != 1 {
t.Fatalf("clear-first scan: %+v", fin)
}
if got := queuedAddresses(t, d); len(got) != 1 || got["1.1.1.1"] != db.IPQueued {
t.Fatalf("expected only the scanned address, got %v", got)
}
var payload struct {
Count int `json:"count"`
Addresses []string `json:"addresses"`
}
if err := json.Unmarshal([]byte(eventPayload(t, d, "queue_cleared")), &payload); err != nil || payload.Count != 2 || len(payload.Addresses) != 2 {
t.Fatalf("queue_cleared payload: %+v err=%v", payload, err)
}
}
// 6440 free addresses (the real stand's size) plus a few out-of-order ones:
// everything is queued in ascending numeric IP order within a sane time.
func TestScanJobEnqueuesAllInAscendingOrderAtScale(t *testing.T) {
if testing.Short() {
t.Skip("scale test skipped in -short mode")
}
ctx := context.Background()
o, d, mock := newTestOrchestrator(t, 180)
o.ScanPageSize = 200
mock.SeedMany("fip", 6440)
// IDs sort before "fip-*", addresses sort numerically before 198.18.*;
// 10.0.0.9 < 10.0.0.200 numerically but not as strings.
mock.Seed("aaa-2", "10.0.0.200", "svc")
mock.Seed("aaa-1", "10.0.0.9", "svc")
mock.SeedWithPort("occupied", "203.0.113.1", "svc", "port-x")
start := time.Now()
o.StartScan(ScanOptions{})
st := waitScanFor(t, o, 5*time.Minute) // -race is an order of magnitude slower
elapsed := time.Since(start)
if st.State != ScanDone || st.Free != 6442 || st.Added != 6442 || st.Discovered != 6443 || st.Pages != 33 {
t.Fatalf("scan status: %+v", st)
}
if elapsed > 3*time.Minute {
t.Fatalf("scanning 6442 addresses took %v", elapsed)
}
t.Logf("scan+enqueue of %d addresses took %v", st.Added, elapsed)
items, err := d.ListIPs(ctx)
if err != nil || len(items) != 6442 {
t.Fatalf("queue: n=%d err=%v", len(items), err)
}
var prev netip.Addr
for i, it := range items {
ip := netip.MustParseAddr(it.IPAddress)
if i > 0 && ip.Compare(prev) <= 0 {
t.Fatalf("queue not ascending at %d: %s after %s", i, it.IPAddress, prev)
}
if it.Sequence != i {
t.Fatalf("expected contiguous sequences, row %d has %d", i, it.Sequence)
}
prev = ip
}
if items[0].IPAddress != "10.0.0.9" || items[1].IPAddress != "10.0.0.200" {
t.Fatalf("numeric ordering broken: %s, %s", items[0].IPAddress, items[1].IPAddress)
}
if _, stale := queuedAddresses(t, d)["203.0.113.1"]; stale {
t.Fatalf("occupied address must not be queued")
}
}
func TestScanJobCancelAndLifetimeContext(t *testing.T) {
ctx := context.Background()
o, d, mock := newTestOrchestrator(t, 180)
mock.SeedMany("fip", 100)
mock.PageSize = 10
mock.PageDelay = 5 * time.Second
if err := d.SeedQueue(ctx, []string{"9.9.9.9"}); err != nil {
t.Fatal(err)
}
o.StartScan(ScanOptions{})
time.Sleep(20 * time.Millisecond)
start := time.Now()
if !o.CancelScan() {
t.Fatalf("expected a running scan to be cancelled")
}
if time.Since(start) > 3*time.Second {
t.Fatalf("cancel took too long")
}
st := o.ScanStatus()
if st.State != ScanCancelled || st.Running || st.FinishedAt == nil {
t.Fatalf("expected cancelled, got %+v", st)
}
if got := queuedAddresses(t, d); len(got) != 1 {
t.Fatalf("cancelled scan must not enqueue, got %d rows", len(got))
}
// Cancelling the process-lifetime context also stops a running scan.
life, cancelLife := context.WithCancel(context.Background())
o.SetContext(life)
o.StartScan(ScanOptions{})
time.Sleep(20 * time.Millisecond)
cancelLife()
if st := waitScan(t, o); st.State != ScanCancelled {
t.Fatalf("expected cancelled by lifetime ctx, got %+v", st)
}
}
func TestScanJobDeadline(t *testing.T) {
o, _, mock := newTestOrchestrator(t, 180)
o.Cfg.FIPScanTimeoutSeconds = 1
mock.SeedMany("fip", 20)
mock.PageSize = 10
mock.PageDelay = 10 * time.Second
o.StartScan(ScanOptions{})
st := waitScan(t, o)
if st.State != ScanError || !strings.Contains(st.Error, "timed out") {
t.Fatalf("expected timeout error, got %+v", st)
}
}
func TestScanFloatingIPsWrapperHonoursCallerContext(t *testing.T) {
o, _, mock := newTestOrchestrator(t, 180)
mock.SeedMany("fip", 20)
mock.PageSize = 10
mock.PageDelay = 5 * time.Second
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
if _, _, err := o.ScanFloatingIPs(ctx); !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("expected the caller's ctx error, got %v", err)
}
// The job itself keeps going until cancelled (cleanup cancels it).
if !o.ScanStatus().Running {
t.Fatalf("background job should still be running")
}
}
func TestSortAddressesAscending(t *testing.T) {
in := []string{"10.0.0.10", "zzz", "10.0.0.9", "2001:db8::1", "9.255.255.255", "aaa", "10.0.0.2"}
sortAddressesAscending(in)
want := []string{"9.255.255.255", "10.0.0.2", "10.0.0.9", "10.0.0.10", "2001:db8::1", "aaa", "zzz"}
for i := range want {
if in[i] != want[i] {
t.Fatalf("got %v want %v", in, want)
}
}
}
func TestClearQueueEventPayloadIsTruncated(t *testing.T) {
ctx := context.Background()
o, d, _ := newTestOrchestrator(t, 180)
var addrs []string
for i := 0; i < 120; i++ {
addrs = append(addrs, "10.1.0."+strconv.Itoa(i))
}
if err := d.SeedQueue(ctx, addrs); err != nil {
t.Fatal(err)
}
res, err := o.ClearQueue(ctx)
if err != nil || len(res.Deleted) != 120 {
t.Fatalf("clear: %+v err=%v", res, err)
}
var p struct {
Count int `json:"count"`
Addresses []string `json:"addresses"`
Truncated bool `json:"truncated"`
}
if err := json.Unmarshal([]byte(eventPayload(t, d, "queue_cleared")), &p); err != nil {
t.Fatal(err)
}
if p.Count != 120 || len(p.Addresses) != 50 || !p.Truncated || p.Addresses[0] != "10.1.0.0" {
t.Fatalf("payload: count=%d addrs=%d truncated=%v", p.Count, len(p.Addresses), p.Truncated)
}
}
func TestDeleteIPsDisassociatesOnlyAttachedFIPs(t *testing.T) {
ctx := context.Background()
o, d, mock := newTestOrchestrator(t, 180)
mock.Seed("fip-a", "1.1.1.1", "svc")
mock.Seed("fip-b", "2.2.2.2", "svc")
if _, err := d.SubmitIPs(ctx, []string{"1.1.1.1", "2.2.2.2", "3.3.3.3"}); err != nil {
t.Fatal(err)
}
if err := d.RegisterValidator(ctx, "validator-1", "host-1", "port-1", "v0.1"); err != nil {
t.Fatal(err)
}
claimed, err := d.ClaimNextQueued(ctx, "validator-1", time.Minute)
if err != nil || claimed == nil || claimed.IPAddress != "1.1.1.1" {
t.Fatalf("claim: %+v err=%v", claimed, err)
}
if err := mock.AssociateFloatingIP(ctx, "fip-a", "port-1"); err != nil {
t.Fatal(err)
}
if err := d.SetFIPAssociated(ctx, claimed.ID, "fip-a", time.Minute); err != nil {
t.Fatal(err)
}
// An unrelated, externally associated FIP must stay as it is.
if err := mock.AssociateFloatingIP(ctx, "fip-b", "other-port"); err != nil {
t.Fatal(err)
}
res, err := o.DeleteIPs(ctx, []string{"1.1.1.1", "2.2.2.2", "nope"})
if err != nil || len(res.Deleted) != 2 || len(res.NotFound) != 1 {
t.Fatalf("delete: %+v err=%v", res, err)
}
a, _ := mock.GetFloatingIPByAddress(ctx, "1.1.1.1")
b, _ := mock.GetFloatingIPByAddress(ctx, "2.2.2.2")
if a.PortID != "" {
t.Fatalf("attached fip must be disassociated, still on %q", a.PortID)
}
if b.PortID != "other-port" {
t.Fatalf("row without recorded fip_id must not trigger a disassociate, got %q", b.PortID)
}
v, _ := d.GetValidator(ctx, "validator-1")
if v.State != db.ValidatorIdle {
t.Fatalf("validator must be freed, got %s", v.State)
}
}