feature: handle delete operation for IPs

This commit is contained in:
ayurishchev committed 2026-08-23 22:24:55 +03:00
1 parent 4f597505cc
commit f8336740ad
20 files changed
+1083 -5

No files matched your search

+100
View File
@@ -10,6 +10,7 @@ package orchestrator
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
@@ -238,6 +239,105 @@ func (o *Orchestrator) ForceCancel(ctx context.Context, ipAddress string) error
return nil
}
// DeleteIP disassociates the floating IP if attached, then permanently
// removes the address and its full history — differs from ForceCancel,
// which keeps a cancelled record instead of deleting it. Works from any
// state, including actively checking: it does the same resource-freeing
// (FIP disassociation, validator release) ForceCancel does, but goes
// straight to physical deletion rather than parking in `cancelled`.
// Returns db.ErrNotFound if the address is unknown.
func (o *Orchestrator) DeleteIP(ctx context.Context, ipAddress string) error {
item, err := o.DB.GetIPByAddress(ctx, ipAddress)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("ip %q: %w", ipAddress, db.ErrNotFound)
}
return err
}
if item.FIPID != "" {
if err := o.OS.DisassociateFloatingIP(ctx, item.FIPID); err != nil {
o.Log.Error("disassociate fip on delete", "ip_id", item.ID, "fip_id", item.FIPID, "err", err)
}
}
if err := o.DB.DeleteIP(ctx, item.ID); err != nil {
return err
}
o.event(ctx, "control-api", "", nil, "ip_deleted", fmt.Sprintf(`{"ip_address":%q}`, ipAddress))
return nil
}
// DeleteIPs disassociates the floating IP (best-effort) for every address
// in the list that has one attached, then deletes the whole list in one
// DB.DeleteIPs call. Addresses not currently in the queue are simply
// omitted from the disassociation pass and reported back in NotFound by
// DB.DeleteIPs — not an error.
func (o *Orchestrator) DeleteIPs(ctx context.Context, addresses []string) (db.DeleteIPsResult, error) {
for _, addr := range addresses {
item, err := o.DB.GetIPByAddress(ctx, addr)
if err != nil {
continue // unknown address — DB.DeleteIPs will report it in NotFound
}
if item.FIPID != "" {
if err := o.OS.DisassociateFloatingIP(ctx, item.FIPID); err != nil {
o.Log.Error("disassociate fip on delete", "ip_id", item.ID, "fip_id", item.FIPID, "err", err)
}
}
}
result, err := o.DB.DeleteIPs(ctx, addresses)
if err != nil {
return result, err
}
o.event(ctx, "control-api", "", nil, "ips_deleted", deletedAddressesPayload(result.Deleted))
return result, nil
}
// ClearQueue deletes every address currently in the queue, regardless of
// state — the "delete everything" operation, implemented as DeleteIPs over
// the full current address list rather than a separate DB code path.
func (o *Orchestrator) ClearQueue(ctx context.Context) (db.DeleteIPsResult, error) {
items, err := o.DB.ListIPs(ctx)
if err != nil {
return db.DeleteIPsResult{}, fmt.Errorf("list ips: %w", err)
}
addresses := make([]string, len(items))
for i, item := range items {
addresses[i] = item.IPAddress
}
for _, item := range items {
if item.FIPID != "" {
if err := o.OS.DisassociateFloatingIP(ctx, item.FIPID); err != nil {
o.Log.Error("disassociate fip on clear queue", "ip_id", item.ID, "fip_id", item.FIPID, "err", err)
}
}
}
result, err := o.DB.DeleteIPs(ctx, addresses)
if err != nil {
return result, err
}
o.event(ctx, "control-api", "", nil, "queue_cleared", deletedAddressesPayload(result.Deleted))
return result, nil
}
// deletedAddressesPayload builds the event payload for the batch delete
// operations — a proper JSON array via encoding/json rather than fmt's %q
// slice formatting (which produces space-separated quoted strings, not
// valid JSON).
func deletedAddressesPayload(addresses []string) string {
b, err := json.Marshal(struct {
Addresses []string `json:"addresses"`
}{addresses})
if err != nil {
return "{}"
}
return string(b)
}
// sweepCheckingWindow moves IPs that have either finished reporting from
// every source, or hit the checking-window deadline, into aggregation.
func (o *Orchestrator) sweepCheckingWindow(ctx context.Context) error {
@@ -2,6 +2,7 @@ package orchestrator
import (
"context"
"errors"
"log/slog"
"os"
"path/filepath"
@@ -333,6 +334,81 @@ func TestInboundChecksDisabled(t *testing.T) {
}
}
// TestDeleteIPDisassociatesFIP proves DeleteIP disassociates a currently
// attached floating IP (via the mock) before permanently removing the
// address — the same resource-freeing ForceCancel does, but going straight
// to physical deletion instead of a `cancelled` record.
func TestDeleteIPDisassociatesFIP(t *testing.T) {
ctx := context.Background()
o, d, mock := newTestOrchestrator(t, 180)
mock.Seed("fip-1", "1.2.3.4", "svc-project")
_ = d.RegisterValidator(ctx, "validator-1", "host-1", "port-1", "v0.1")
_ = d.SeedQueue(ctx, []string{"1.2.3.4"})
o.Tick(ctx) // claim + associate -> awaiting_self_check, fip attached
ip, err := d.GetIPByAddress(ctx, "1.2.3.4")
if err != nil {
t.Fatalf("get ip: %v", err)
}
if ip.FIPID == "" {
t.Fatalf("expected fip associated before delete")
}
if err := o.DeleteIP(ctx, "1.2.3.4"); err != nil {
t.Fatalf("delete ip: %v", err)
}
if fip, _ := mock.GetFloatingIPByAddress(ctx, "1.2.3.4"); fip.PortID != "" {
t.Fatalf("expected fip disassociated on delete, still on port %q", fip.PortID)
}
if _, err := d.GetIPByAddress(ctx, "1.2.3.4"); err == nil {
t.Fatalf("expected ip row gone after delete")
}
v, err := d.GetValidator(ctx, "validator-1")
if err != nil {
t.Fatalf("get validator: %v", err)
}
if v.State != db.ValidatorIdle || v.CurrentIPID != nil {
t.Fatalf("expected validator freed, got state=%s current_ip=%v", v.State, v.CurrentIPID)
}
if err := o.DeleteIP(ctx, "1.2.3.4"); !errors.Is(err, db.ErrNotFound) {
t.Fatalf("expected ErrNotFound deleting already-gone ip, got %v", err)
}
}
// TestClearQueueDisassociatesAllFIPs proves ClearQueue deletes every
// address regardless of state and disassociates any attached floating IPs
// along the way.
func TestClearQueueDisassociatesAllFIPs(t *testing.T) {
ctx := context.Background()
o, d, mock := newTestOrchestrator(t, 180)
mock.Seed("fip-1", "1.2.3.4", "svc-project")
_ = d.RegisterValidator(ctx, "validator-1", "host-1", "port-1", "v0.1")
_ = d.SeedQueue(ctx, []string{"1.2.3.4", "5.6.7.8"})
o.Tick(ctx) // claims + associates 1.2.3.4; 5.6.7.8 stays queued
result, err := o.ClearQueue(ctx)
if err != nil {
t.Fatalf("clear queue: %v", err)
}
if len(result.Deleted) != 2 {
t.Fatalf("expected both addresses deleted, got %+v", result)
}
if fip, _ := mock.GetFloatingIPByAddress(ctx, "1.2.3.4"); fip.PortID != "" {
t.Fatalf("expected fip disassociated on clear, still on port %q", fip.PortID)
}
ips, err := d.ListIPs(ctx)
if err != nil {
t.Fatalf("list ips: %v", err)
}
if len(ips) != 0 {
t.Fatalf("expected empty queue after clear, got %+v", ips)
}
}
// TestInboundChecksPartialSites confirms a partially-configured sites list
// (fewer than 3 slots assigned) only waits on the sites actually
// configured — the two unassigned slots are never expected.