Skip the check cycle for a Floating IP already occupied by another port

The cloud is live: the address list submitted as "free" (bootstrap config
or POST /api/v1/admin/ips) can drift by the time the orchestrator claims
it, or an operator can queue an already-occupied address by mistake.
Neutron's floating-IP association is a blind "last write wins" PUT with
no conflict error to catch, so associateFIP now checks the FIP's PortID
(already fetched via GetFloatingIPByAddress) before associating, guarded
against the false-positive of the FIP already belonging to this same
validator's own port.

A match routes the address straight to a new terminal ip_queue.state
("occupied", distinct from failed/fail) via db.MarkFIPOccupied — no
retries, since Neutron won't free it on its own and requeuing would let
it be reclaimed again next tick, starving the rest of the queue — plus a
dedicated fip_occupied audit event. Resubmitting the address later (once
the conflict is resolved) resets it to queued via the existing
POST /api/v1/admin/ips resubmit path (CancelIP/ListExpiredLeases updated
to treat occupied as terminal too). admin-dashboard gets its own "занят"
badge, distinct from fail/partial/cancelled.

Rebuilt bin/{control-api,admin-dashboard,prober,validator-agent} and
bin/SHA256SUMS per docs/SETUP.md's documented build recipe, since
control-api and admin-dashboard source changed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NeVbMVEiE7XQAkBd7HQgj6
This commit is contained in:
ayurishchevandClaude Sonnet 5 committed 2026-09-13 23:54:35 +03:00
1 parent 2246369b64
commit 93c79b63ea
17 files changed
+326 -17

No files matched your search

+6 -1
View File
@@ -20,7 +20,10 @@ type Badge struct{ Class, Label string }
// elapsed since the floating IP was associated, the row gets a distinct
// "прогрев FIP" badge instead of looking identical to a row just waiting
// on the agent's next poll. The time math happens here, not in the
// template, same as fmtTime's existing precedent.
// template, same as fmtTime's existing precedent. state=="occupied" gets
// its own pill, deliberately separate from the done/failed result switch
// below — it means the check cycle never ran (the floating IP was already
// bound to another port at claim time), not that a check failed.
func ipBadge(state, result string, fipAssociatedAt *time.Time, settleSeconds int) Badge {
switch state {
case "done", "failed":
@@ -36,6 +39,8 @@ func ipBadge(state, result string, fipAssociatedAt *time.Time, settleSeconds int
}
case "queued":
return Badge{"pill-neutral", "queued"}
case "occupied":
return Badge{"pill-occupied", "занят"}
case "awaiting_self_check":
if settleSeconds > 0 && fipAssociatedAt != nil && time.Now().Before(fipAssociatedAt.Add(time.Duration(settleSeconds)*time.Second)) {
return Badge{"pill-warning", "прогрев FIP"}
+4
View File
@@ -38,6 +38,7 @@
--info: #0E6FA8; --info-soft: #DDEEF8;
--neutral: #5B6576; --neutral-soft: #E4E8EE;
--cancel: #6D4FC4; --cancel-soft: #ECE7FA;
--occupied: #0F7B72; --occupied-soft: #DCF3F0;
--overlay: rgba(10, 14, 20, .32);
@@ -82,6 +83,7 @@
--info: #5AC0FF; --info-soft: #10222E;
--neutral: #A3ACBE; --neutral-soft: #1B212B;
--cancel: #B79CFF; --cancel-soft: #221B3A;
--occupied: #4FD8C9; --occupied-soft: #102B28;
--overlay: rgba(0, 0, 0, .55);
@@ -114,6 +116,7 @@
--info: #5AC0FF; --info-soft: #10222E;
--neutral: #A3ACBE; --neutral-soft: #1B212B;
--cancel: #B79CFF; --cancel-soft: #221B3A;
--occupied: #4FD8C9; --occupied-soft: #102B28;
--overlay: rgba(0, 0, 0, .55);
@@ -411,6 +414,7 @@ td.num { font-family: var(--font-mono); font-variant-numeric: tabular-nums; colo
.pill-warning { background: var(--warning-soft); color: var(--warning); }
.pill-danger { background: var(--danger-soft); color: var(--danger); }
.pill-cancel { background: var(--cancel-soft); color: var(--cancel); }
.pill-occupied { background: var(--occupied-soft); color: var(--occupied); }
/* ---------- alerts ---------- */
.alert {
+1 -1
View File
@@ -56,7 +56,7 @@
<tbody>
{{range .Items}}
{{$b := ipBadge .State .OverallResult .FIPAssociatedAt $.FIPSettleSeconds}}
{{$terminal := or (eq .State "done") (eq .State "failed")}}
{{$terminal := or (eq .State "done") (eq .State "failed") (eq .State "occupied")}}
<tr>
<td data-label=""><input type="checkbox" name="addresses" value="{{.IPAddress}}"></td>
<td class="addr" data-label="Адрес"><a href="/ips/{{.IPAddress}}">{{.IPAddress}}</a></td>