Files
cloud-ip-validator/internal/dashboard/client.go
T
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

395 lines
13 KiB
Go

package dashboard
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// apiErr is returned by every client method for a non-2xx response from
// control-api, or for a transport-level failure (control-api unreachable/
// timeout, Status==0). Handlers use it (via errors.As) to render an error
// banner with the right status/severity instead of a raw 500 — see
// writeErrorBanner in render.go.
type apiErr struct {
Status int
Message string
}
func (e *apiErr) Error() string {
if e.Status == 0 {
return fmt.Sprintf("control-api недоступен: %s", e.Message)
}
return fmt.Sprintf("control-api: %d %s", e.Status, e.Message)
}
// client is a thin, dashboard-local JSON client for control-api's
// /api/v1/admin/* surface. It intentionally doesn't reuse
// internal/apiclient.Client (shared by validator-agent/prober): that
// client's Do only returns an opaque error, with no way to recover the
// HTTP status code — which the dashboard needs to render 400 vs 404 vs 409
// vs 5xx differently. Duplicating ~30 lines here avoids widening
// apiclient's contract (and its blast radius on the other two binaries)
// for a need only this package has.
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 {
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)
if err != nil {
return fmt.Errorf("marshal request: %w", err)
}
reader = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader)
if err != nil {
return fmt.Errorf("build request: %w", err)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
resp, err := hc.Do(req)
if err != nil {
return &apiErr{Status: 0, Message: err.Error()}
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 300 {
msg := string(respBody)
var er errorResponse
if json.Unmarshal(respBody, &er) == nil && er.Error != "" {
msg = er.Error
}
return &apiErr{Status: resp.StatusCode, Message: msg}
}
if out != nil && len(respBody) > 0 {
if err := json.Unmarshal(respBody, out); err != nil {
return fmt.Errorf("decode response from %s %s: %w", method, path, err)
}
}
return nil
}
func (c *client) Status(ctx context.Context) (statusResponse, error) {
var out statusResponse
err := c.do(ctx, http.MethodGet, "/api/v1/admin/status", nil, &out)
return out, err
}
// 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
}
func (c *client) GetIP(ctx context.Context, ip string) (ipDetailResponse, error) {
var out ipDetailResponse
err := c.do(ctx, http.MethodGet, "/api/v1/admin/ips/"+url.PathEscape(ip), nil, &out)
return out, err
}
// SubmitIPs is the single entry point for both adding new addresses and
// 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.doLong(ctx, http.MethodPost, "/api/v1/admin/ips", map[string][]string{"addresses": addresses}, &out)
return out, err
}
func (c *client) CancelIP(ctx context.Context, ip string) error {
return c.do(ctx, http.MethodPost, "/api/v1/admin/ips/"+url.PathEscape(ip)+"/cancel", nil, nil)
}
// DeleteIP permanently removes one address and its full history — unlike
// CancelIP, there's nothing left to look up afterward.
func (c *client) DeleteIP(ctx context.Context, ip string) error {
return c.do(ctx, http.MethodDelete, "/api/v1/admin/ips/"+url.PathEscape(ip), nil, nil)
}
// 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.doLong(ctx, http.MethodPost, "/api/v1/admin/ips/delete", map[string][]string{"addresses": addresses}, &out)
return out, err
}
// ClearQueue permanently removes every address currently in the queue,
// including those actively being checked.
func (c *client) ClearQueue(ctx context.Context) (clearQueueResponse, error) {
var out clearQueueResponse
err := c.doLong(ctx, http.MethodPost, "/api/v1/admin/ips/clear", nil, &out)
return out, err
}
// 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
}
// 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
}
// GetRegistryHistory returns one address's registry record plus its full
// retained check history across every cycle still kept.
func (c *client) GetRegistryHistory(ctx context.Context, ip string) (registryHistoryResponse, error) {
var out registryHistoryResponse
err := c.do(ctx, http.MethodGet, "/api/v1/admin/registry/"+url.PathEscape(ip), nil, &out)
return out, err
}
func (c *client) ListValidators(ctx context.Context) ([]validatorDTO, error) {
var out []validatorDTO
err := c.do(ctx, http.MethodGet, "/api/v1/admin/config/validators", nil, &out)
return out, err
}
func (c *client) CreateValidator(ctx context.Context, id, osPortID string) error {
body := map[string]string{"validator_id": id, "os_port_id": osPortID}
return c.do(ctx, http.MethodPost, "/api/v1/admin/config/validators", body, nil)
}
func (c *client) UpdateValidator(ctx context.Context, id, osPortID string) error {
body := map[string]string{"os_port_id": osPortID}
return c.do(ctx, http.MethodPut, "/api/v1/admin/config/validators/"+url.PathEscape(id), body, nil)
}
func (c *client) DeleteValidator(ctx context.Context, id string) error {
return c.do(ctx, http.MethodDelete, "/api/v1/admin/config/validators/"+url.PathEscape(id), nil, nil)
}
func (c *client) ListSites(ctx context.Context) ([]siteDTO, error) {
var out []siteDTO
err := c.do(ctx, http.MethodGet, "/api/v1/admin/config/sites", nil, &out)
return out, err
}
func (c *client) PutSite(ctx context.Context, index int, siteID string) error {
body := map[string]string{"site_id": siteID}
return c.do(ctx, http.MethodPut, fmt.Sprintf("/api/v1/admin/config/sites/%d", index), body, nil)
}
func (c *client) DeleteSite(ctx context.Context, index int) error {
return c.do(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/admin/config/sites/%d", index), nil, nil)
}
func (c *client) ListTargetGroups(ctx context.Context) ([]targetGroupDTO, error) {
var out []targetGroupDTO
err := c.do(ctx, http.MethodGet, "/api/v1/admin/config/targets", nil, &out)
return out, err
}
func (c *client) PutTargetGroup(ctx context.Context, name string, targets []string) error {
body := map[string][]string{"targets": targets}
return c.do(ctx, http.MethodPut, "/api/v1/admin/config/targets/"+url.PathEscape(name), body, nil)
}
func (c *client) DeleteTargetGroup(ctx context.Context, name string) error {
return c.do(ctx, http.MethodDelete, "/api/v1/admin/config/targets/"+url.PathEscape(name), nil, nil)
}
func (c *client) ListCheckTypes(ctx context.Context) ([]checkTypeDTO, error) {
var out []checkTypeDTO
err := c.do(ctx, http.MethodGet, "/api/v1/admin/config/check-types", nil, &out)
return out, err
}
func (c *client) PutCheckType(ctx context.Context, name string, enabled bool, targets []string) error {
body := map[string]interface{}{"enabled": enabled, "targets": targets}
return c.do(ctx, http.MethodPut, "/api/v1/admin/config/check-types/"+url.PathEscape(name), body, nil)
}
func (c *client) DeleteCheckType(ctx context.Context, name string) error {
return c.do(ctx, http.MethodDelete, "/api/v1/admin/config/check-types/"+url.PathEscape(name), nil, nil)
}
func (c *client) GetOrchestratorSettings(ctx context.Context) (orchestratorSettingsDTO, error) {
var out orchestratorSettingsDTO
err := c.do(ctx, http.MethodGet, "/api/v1/admin/config/orchestrator", nil, &out)
return out, err
}
func (c *client) PutOrchestratorSettings(ctx context.Context, fipSettleSeconds, historyRetentionCycles int) (orchestratorSettingsDTO, error) {
var out orchestratorSettingsDTO
err := c.do(ctx, http.MethodPut, "/api/v1/admin/config/orchestrator",
orchestratorSettingsDTO{FIPSettleSeconds: fipSettleSeconds, HistoryRetentionCycles: historyRetentionCycles}, &out)
return out, err
}
func (c *client) GetInboundChecks(ctx context.Context) (inboundChecksDTO, error) {
var out inboundChecksDTO
err := c.do(ctx, http.MethodGet, "/api/v1/admin/config/inbound-checks", nil, &out)
return out, err
}
func (c *client) PutInboundChecks(ctx context.Context, ports []int, icmp bool) (inboundChecksDTO, error) {
var out inboundChecksDTO
err := c.do(ctx, http.MethodPut, "/api/v1/admin/config/inbound-checks",
inboundChecksDTO{Ports: ports, ICMP: icmp}, &out)
return out, err
}
func (c *client) GetAutoCycle(ctx context.Context) (autoCycleDTO, error) {
var out autoCycleDTO
err := c.do(ctx, http.MethodGet, "/api/v1/admin/auto-cycle", nil, &out)
return out, err
}
func (c *client) PutAutoCycle(ctx context.Context, intervalSeconds, maxRunSeconds int) (autoCycleDTO, error) {
var out autoCycleDTO
err := c.do(ctx, http.MethodPut, "/api/v1/admin/auto-cycle",
map[string]int{"interval_seconds": intervalSeconds, "max_run_seconds": maxRunSeconds}, &out)
return out, err
}
func (c *client) StartAutoCycle(ctx context.Context) (autoCycleDTO, error) {
var out autoCycleDTO
err := c.do(ctx, http.MethodPost, "/api/v1/admin/auto-cycle/start", nil, &out)
return out, err
}
func (c *client) StopAutoCycle(ctx context.Context) (autoCycleDTO, error) {
var out autoCycleDTO
err := c.do(ctx, http.MethodPost, "/api/v1/admin/auto-cycle/stop", nil, &out)
return out, err
}