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>
228 lines
7.3 KiB
Go
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}
|
|
}
|