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 "-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 }