An address still being associated (assigning_fip) has no fip_id in the database, so "clear queue" did not detach it, and the association then finished after the row was gone, leaving the floating IP on the validator port for good. - After clear/cancel/delete, ask the cloud which floating IPs sit on the affected validator ports (new ListFloatingIPsByPort) and detach those that this system queued (known in ip_registry); foreign ones are left. - SetFIPAssociated applies only to a row still in assigning_fip; if the address was removed meanwhile, associateFIP detaches the floating IP. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
203 lines
5.5 KiB
Go
203 lines
5.5 KiB
Go
package openstack
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/netip"
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// MockClient is an in-memory FloatingIPClient used by unit tests and the
|
|
// offline end-to-end harness. Seed it with the pool of floating IPs the
|
|
// scenario expects to exist before use.
|
|
type MockClient struct {
|
|
mu sync.Mutex
|
|
fips map[string]*FloatingIP // keyed by ID
|
|
byIP map[string]string // address -> ID
|
|
|
|
// AssociateFailures/DisassociateFailures let tests force a failure for
|
|
// a specific floating-IP ID on its next call, to exercise retry paths.
|
|
AssociateFailures map[string]error
|
|
DisassociateFailures map[string]error
|
|
|
|
// ListFailure, when non-nil, is returned by every ListFloatingIPs call
|
|
// until the test resets it to nil — to exercise the scan-error path.
|
|
ListFailure error
|
|
|
|
// ListFailures is a queue of errors consumed one per page request (each
|
|
// request, including retries, pops the head; an empty queue means no
|
|
// failure), on top of the sticky ListFailure. Exercises per-page retry.
|
|
ListFailures []error
|
|
|
|
// PageSize, when > 0, overrides the page size requested by the caller.
|
|
PageSize int
|
|
// PageDelay is an artificial latency added to every page request
|
|
// (honours ctx cancellation) — to simulate a slow Neutron.
|
|
PageDelay time.Duration
|
|
// PageRetries / Sleep configure per-page retry like the real client;
|
|
// zero retries by default so a failure surfaces immediately.
|
|
PageRetries int
|
|
Sleep func(ctx context.Context, d time.Duration) error
|
|
|
|
// PageCalls counts page requests served (successful or failed).
|
|
PageCalls int
|
|
}
|
|
|
|
func NewMockClient() *MockClient {
|
|
return &MockClient{
|
|
fips: make(map[string]*FloatingIP),
|
|
byIP: make(map[string]string),
|
|
}
|
|
}
|
|
|
|
// Seed registers a floating IP as if pre-allocated in the service project.
|
|
func (m *MockClient) Seed(id, address, projectID string) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.fips[id] = &FloatingIP{ID: id, Address: address, ProjectID: projectID}
|
|
m.byIP[address] = id
|
|
}
|
|
|
|
// SeedWithPort registers a floating IP as already associated to portID —
|
|
// for simulating a FIP that's occupied (e.g. by another VM's port) before a
|
|
// test's Tick runs.
|
|
func (m *MockClient) SeedWithPort(id, address, projectID, portID string) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.fips[id] = &FloatingIP{ID: id, Address: address, ProjectID: projectID, PortID: portID}
|
|
m.byIP[address] = id
|
|
}
|
|
|
|
func (m *MockClient) GetFloatingIPByAddress(ctx context.Context, address string) (*FloatingIP, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
id, ok := m.byIP[address]
|
|
if !ok {
|
|
return nil, ErrNotFound(address)
|
|
}
|
|
f := *m.fips[id]
|
|
return &f, nil
|
|
}
|
|
|
|
// SeedMany registers n free floating IPs with IDs "<prefix>-00000"… and
|
|
// ascending addresses starting at 198.18.0.1 (TEST-NET benchmarking range),
|
|
// returning the addresses in seed order.
|
|
func (m *MockClient) SeedMany(prefix string, n int) []string {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
base := netip.MustParseAddr("198.18.0.1")
|
|
addrs := make([]string, 0, n)
|
|
a := base
|
|
for i := 0; i < n; i++ {
|
|
id := fmt.Sprintf("%s-%05d", prefix, i)
|
|
addr := a.String()
|
|
m.fips[id] = &FloatingIP{ID: id, Address: addr, ProjectID: "mock-project"}
|
|
m.byIP[addr] = id
|
|
addrs = append(addrs, addr)
|
|
a = a.Next()
|
|
}
|
|
return addrs
|
|
}
|
|
|
|
func (m *MockClient) fetchPage(ctx context.Context, marker string, limit int) ([]FloatingIP, error) {
|
|
m.mu.Lock()
|
|
delay := m.PageDelay
|
|
m.mu.Unlock()
|
|
if delay > 0 {
|
|
if err := sleepCtx(ctx, delay); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.PageCalls++
|
|
if m.ListFailure != nil {
|
|
return nil, m.ListFailure
|
|
}
|
|
if len(m.ListFailures) > 0 {
|
|
err := m.ListFailures[0]
|
|
m.ListFailures = m.ListFailures[1:]
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if m.PageSize > 0 {
|
|
limit = m.PageSize
|
|
}
|
|
ids := make([]string, 0, len(m.fips))
|
|
for id := range m.fips {
|
|
if id > marker {
|
|
ids = append(ids, id)
|
|
}
|
|
}
|
|
sort.Strings(ids)
|
|
if len(ids) > limit {
|
|
ids = ids[:limit]
|
|
}
|
|
out := make([]FloatingIP, 0, len(ids))
|
|
for _, id := range ids {
|
|
out = append(out, *m.fips[id])
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ListFreeFloatingIPs pages through the seeded floating IPs sorted by ID, like
|
|
// the real client does against Neutron (see FloatingIPClient).
|
|
func (m *MockClient) ListFreeFloatingIPs(ctx context.Context, pageSize int, onPage func([]FloatingIP) error) (int, error) {
|
|
m.mu.Lock()
|
|
rp := pageRetry{Retries: m.PageRetries, Sleep: m.Sleep}
|
|
if m.PageSize > 0 {
|
|
pageSize = m.PageSize
|
|
}
|
|
m.mu.Unlock()
|
|
return paginate(ctx, pageSize, rp, m.fetchPage, onPage)
|
|
}
|
|
|
|
func (m *MockClient) ListFloatingIPs(ctx context.Context) ([]FloatingIP, error) {
|
|
return listAll(ctx, m)
|
|
}
|
|
|
|
func (m *MockClient) ListFloatingIPsByPort(ctx context.Context, portID string) ([]FloatingIP, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
var out []FloatingIP
|
|
for _, f := range m.fips {
|
|
if f.PortID == portID {
|
|
out = append(out, *f)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (m *MockClient) AssociateFloatingIP(ctx context.Context, fipID, portID string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if err := m.AssociateFailures[fipID]; err != nil {
|
|
delete(m.AssociateFailures, fipID)
|
|
return err
|
|
}
|
|
f, ok := m.fips[fipID]
|
|
if !ok {
|
|
return fmt.Errorf("mock openstack: unknown floating ip %q", fipID)
|
|
}
|
|
f.PortID = portID
|
|
return nil
|
|
}
|
|
|
|
func (m *MockClient) DisassociateFloatingIP(ctx context.Context, fipID string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if err := m.DisassociateFailures[fipID]; err != nil {
|
|
delete(m.DisassociateFailures, fipID)
|
|
return err
|
|
}
|
|
f, ok := m.fips[fipID]
|
|
if !ok {
|
|
return fmt.Errorf("mock openstack: unknown floating ip %q", fipID)
|
|
}
|
|
f.PortID = ""
|
|
return nil
|
|
}
|