Files
cloud-ip-validator/internal/openstack/mock.go
T
ayurishchevandClaude Sonnet 5.5 1ee5757004 Free validator ports in the cloud on clear/cancel/delete
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>
2026-10-01 21:00:31 +03:00

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
}