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

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

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

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

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

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

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

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

228 lines
7.3 KiB
Go

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}
}