Scan floating IPs in the background, page by page, so thousands of addresses work
The "Scan Floating IP" button failed with a client timeout: the project now holds ~6.4k floating IPs and the scan listed them all in one unpaginated, timeout-less Neutron request on the HTTP request context. openstack: ListFreeFloatingIPs reads marker-based pages (fields= keeps them small) with per-page retry/backoff on transport errors, 5xx and 429, and every request now has a timeout (also ends hangs inside the orchestrator tick). orchestrator: the scan is a single-flight background job on the process context with progress (clearing/listing/enqueuing/done/error), dry_run, full discovery before anything is enqueued, then SubmitIPs in chunks of 500 in ascending IP order; a failed read leaves the queue untouched. The auto-cycle gets a "scanning" phase that polls the job, so the control loop and autoCycleMu are never held across OpenStack/DB work; it recovers after a restart and waits for (instead of adopting) a scan started by someone else. db: migration 0009 (indexes), paged ListIPsPage/ListRegistryPage, GROUP BY counters, EXISTS completion check, set-based ClearAllIPs. API: POST /admin/ips/scan -> 202 (dry_run, wait), GET /admin/ips/scan, paging and filters on /admin/ips and /admin/registry (bare arrays without limit), results_by_overall in /admin/status. dashboard: scan progress panel and dry-run button, paginated /ips and /registry with server-side filters, Overview on counters and capped lists with progress/ETA, "select all N by filter", hx-params fix for per-row buttons, real counts in confirmations. Also: docs (API, USAGE, DASHBOARD, README), plan and review under docs/changes/, bin/ rebuilt with new SHA256SUMS. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
debf2afed2
commit
aff8fe38b5
61 files changed
+5833
-536
No files matched your search
+130
-20
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user