[management] Add support for pagination in GetAllPeers call (#8190)

* use libopenapi OpenAPI generator

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* added a handler for v1alpha1/peers

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* wire up request validator

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* wired up spec-based validation

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* use sync validation; set base url to v1alpha1

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* moved stuff around, extracted runtime libopenapu deps into runtime_tooling

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* testing /peers path params

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* fixed tests

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* added user schemas and paths

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* use api validation in tests

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* post-merge fixes

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* deleted tmp command used to test validator integration

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* require request body in post/put requests in order to force validation

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* making linter happy

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* switch to api/v1alpha1

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* go mod tidy

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* keep the original order of middleware: metrics, cors, then auth

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* return 422 on validation errors

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* cleanups

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* more cleanups

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* v1alpha1 spec cleanups

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* no need for a double-pointer

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* more linter fixes

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* removed more double pointers

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* use di to inject api_v0 and api_v1 http routers

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* do not re-add middleware on repeated call to ApiHandler

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* support for pagination and filtering in /peers call

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* calling ApiRouter() now also calls ApiV1Router()

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* adding peer filters

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* introduced paginated GetAllPeers call

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* wire up pagination

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* convert pagination state to query scope

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* peer filter wip

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* implemented peer filter

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* test for filtering by os

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* support for sorting of peers

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* support for ordering + tests

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* more tests

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* wire up total number of records in paginated GetAllPeers call

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* cleaned up GetAllPeers response

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* pacify linter

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* cleanups and fixes

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* moar cleanups

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* pacify linter

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* remove duplicated test

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* make linter happy

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* moving GetAllPeersPaginated to peer manager, p. 1

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* moving GetAllPeersPaginated to peer manager, p. 2

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* added peer manager tests for GetPeersPaginated

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* order peers in GetAccountPeersPaginated by their ids by default

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* always order by peer_id, filter out proxies

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* fixed broken tests

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* support for filtering of peers by peer_id in GetAllPeers()

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* filtering by peer_ids

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* use lowercase in 'like' queries, drop ordering by ip

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* drop sort_by ip key

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* drop forced_approve filter

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* replace mac with darwin in peer os filter

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* fix peers query parameter: use peer_ids

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* fixed broken peer_test

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* responded to feedback

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

---------

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
This commit is contained in:
dmitri-netbird
2026-10-19 22:19:39 +02:00
committed by GitHub
parent 36fe467aba
commit 8fef487e42
24 changed files with 1571 additions and 230 deletions
+157 -2
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"net"
"net/netip"
"strings"
"time"
"github.com/jackc/pgx/v5"
@@ -494,7 +495,6 @@ func (s *SqlStore) GetPeerByPeerPubKey(ctx context.Context, lockStrength Locking
return &peer, nil
}
// GetAccountPeers retrieves peers for an account.
func (s *SqlStore) GetAccountPeers(ctx context.Context, lockStrength LockingStrength, accountID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) {
var peers []*nbpeer.Peer
tx := s.db
@@ -514,7 +514,6 @@ func (s *SqlStore) GetAccountPeers(ctx context.Context, lockStrength LockingStre
if macFilter != "" {
query = query.Where("meta_network_addresses LIKE ?", "%"+macFilter+"%")
}
if err := query.Find(&peers).Error; err != nil {
log.WithContext(ctx).Errorf("failed to get peers from the store: %s", err)
return nil, status.Errorf(status.Internal, "failed to get peers from store")
@@ -523,6 +522,162 @@ func (s *SqlStore) GetAccountPeers(ctx context.Context, lockStrength LockingStre
return peers, nil
}
// GetAccountPeers retrieves peers for an account.
func (s *SqlStore) GetAccountPeersPaginated(ctx context.Context, lockStrength LockingStrength, accountID string, pagination PaginationState, filters PeerFilters, sorting PeerSorting) ([]*nbpeer.Peer, int, error) {
// main query
var peers []*nbpeer.Peer
{
tx := s.db
if lockStrength != LockingStrengthNone {
tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)})
}
query := tx.Where("peers.account_id=?", accountID)
query = query.Where("(peers.proxy_meta_embedded=false or peers.proxy_meta_embedded is null)")
query = filters.AsConditions(query)
query = pagination.AsScope(query)
query = sorting.AsOrderBy(ctx, query)
if err := query.Distinct().Find(&peers).Error; err != nil {
log.WithContext(ctx).Errorf("failed to get peers from the store: %s", err)
return nil, 0, status.Errorf(status.Internal, "failed to get peers from store")
}
}
// total number of records
var peerCount int64
{
tx := s.db
if lockStrength != LockingStrengthNone {
tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)})
}
query := tx.Where("peers.account_id=?", accountID)
query = query.Where("(peers.proxy_meta_embedded=false or peers.proxy_meta_embedded is null)")
query = filters.AsConditions(query)
if err := query.Table("peers").Select("count(distinct(peers.id))").Count(&peerCount).Error; err != nil {
log.WithContext(ctx).Errorf("failed to get total number of peers from the store: %s", err)
return nil, 0, status.Errorf(status.Internal, "failed to get total number of peers from store")
}
}
return peers, int(peerCount), nil
}
type PaginationState struct {
Page int
PageSize int
}
func (p *PaginationState) AsScope(db *gorm.DB) *gorm.DB {
// empty pagination state
if p.PageSize == 0 {
return db
}
offset := max(p.Page-1, 0) * p.PageSize
return db.Offset(offset).Limit(p.PageSize)
}
type PeerFilters struct {
UserId string
GroupIds []string
Connected *bool
ApprovalRequired *bool
Os []string
IP string
MAC string
Hostname string
Name string
IsServer *bool
PeersIds []string
}
func (f *PeerFilters) AsConditions(db *gorm.DB) *gorm.DB {
if len(f.GroupIds) > 0 {
db = db.Joins("join group_peers on peers.id=group_peers.peer_id and peers.account_id=group_peers.account_id").Where("group_peers.group_id in (?)", f.GroupIds)
}
if f.UserId != "" {
db = db.Where("user_id=?", f.UserId)
}
if f.Connected != nil {
db = db.Where("peer_status_connected=?", *f.Connected)
}
if f.ApprovalRequired != nil {
db = db.Where("peer_status_requires_approval=?", *f.ApprovalRequired)
}
if len(f.Os) > 0 {
db = db.Where("meta_go_os in (?)", f.Os)
}
if f.IP != "" {
db = db.Where("ip like ? or lower(ipv6) like ?", fmt.Sprintf("%%%s%%", f.IP), fmt.Sprintf("%%%s%%", strings.ToLower(f.IP)))
}
if f.MAC != "" {
db = db.Where("lower(meta_network_addresses) like ?", fmt.Sprintf("%%%s%%", strings.ToLower(f.MAC)))
}
if f.Hostname != "" {
db = db.Where("lower(meta_hostname) like ?", fmt.Sprintf("%%%s%%", strings.ToLower(f.Hostname)))
}
if f.Name != "" {
db = db.Where("lower(name) like ?", fmt.Sprintf("%%%s%%", strings.ToLower(f.Name)))
}
if f.IsServer != nil {
if *f.IsServer {
db = db.Where("(user_id='' or user_id is null)")
} else {
db = db.Where("user_id<>'' and user_id is not null")
}
}
if len(f.PeersIds) > 0 {
db = db.Where("id in (?)", f.PeersIds)
}
return db
}
type PeerSortByField int
const (
PeerSortByName PeerSortByField = iota
PeerSortByLastSeen
PeerSortByVersion
)
type OrderBy int
const (
AscOrderBy OrderBy = iota
DescOrderBy
)
type PeerSortingKey struct {
Field PeerSortByField
Order OrderBy
}
type PeerSorting struct {
Fields []PeerSortingKey
}
func (s *PeerSorting) AsOrderBy(ctx context.Context, db *gorm.DB) *gorm.DB {
order := make([]clause.OrderByColumn, 0)
for _, key := range s.Fields {
switch key.Field {
case PeerSortByName:
order = append(order, clause.OrderByColumn{Column: clause.Column{Name: "peers.name"}, Desc: key.Order == DescOrderBy})
case PeerSortByLastSeen:
order = append(order, clause.OrderByColumn{Column: clause.Column{Name: "peers.peer_status_last_seen"}, Desc: key.Order == DescOrderBy})
case PeerSortByVersion:
order = append(order, clause.OrderByColumn{Column: clause.Column{Name: "meta_wt_version"}, Desc: key.Order == DescOrderBy})
default:
log.WithContext(ctx).Warnf("invalid order by column '%v', skipping", key.Field)
}
}
// always order/break ties by id asc order
order = append(order, clause.OrderByColumn{Column: clause.Column{Name: "peers.id"}, Desc: false})
return db.Order(clause.OrderBy{Columns: order})
}
// GetUserPeers retrieves peers for a user.
func (s *SqlStore) GetUserPeers(ctx context.Context, lockStrength LockingStrength, accountID, userID string) ([]*nbpeer.Peer, error) {
tx := s.db
@@ -512,7 +512,7 @@ func TestSqlStore_GetAccountPeers(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
peers, err := store.GetAccountPeers(context.Background(), LockingStrengthNone, tt.accountID, tt.nameFilter, tt.ipFilter, "")
peers, _, err := store.GetAccountPeersPaginated(context.Background(), LockingStrengthNone, tt.accountID, PaginationState{}, PeerFilters{Hostname: tt.nameFilter, IP: tt.ipFilter}, PeerSorting{})
require.NoError(t, err)
require.Len(t, peers, tt.expectedCount)
})
@@ -555,7 +555,7 @@ func TestSqlStore_GetAccountPeers_FilterByMac(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
peers, err := store.GetAccountPeers(ctx, LockingStrengthNone, accountID, "", "", tt.macFilter)
peers, _, err := store.GetAccountPeersPaginated(ctx, LockingStrengthNone, accountID, PaginationState{}, PeerFilters{MAC: tt.macFilter}, PeerSorting{})
require.NoError(t, err)
require.Len(t, peers, tt.expectedCount)
})
@@ -0,0 +1,250 @@
package store
import (
"context"
"testing"
"github.com/stretchr/testify/require"
)
func TestSqlStore_FilterPeers(t *testing.T) {
runTestForAllEnginesNoSqliteSeed(t, "../testdata/peers_pagination.sql", func(t *testing.T, engine string, store Store) {
var tests = []struct {
description string
filters PeerFilters
expectedLength int
expectedPeers []string
}{
{
description: "filter by user_id",
filters: PeerFilters{UserId: "user-id-1"},
expectedLength: 1,
expectedPeers: []string{"Peer-1-name"},
},
{
description: "filter by connected status",
filters: PeerFilters{Connected: new(true)},
expectedLength: 2,
expectedPeers: []string{"Peer-1-name", "peer-2-name"},
},
{
description: "filter by approval required status",
filters: PeerFilters{ApprovalRequired: new(true)},
expectedLength: 2,
expectedPeers: []string{"peer-2-name", "peer-3-name"},
},
{
description: "filter by ipv4",
filters: PeerFilters{IP: "10.10.10"},
expectedLength: 2,
expectedPeers: []string{"Peer-1-name", "peer-2-name"},
},
{
description: "filter by ipv6",
filters: PeerFilters{IP: "ba80:6aa5:89f1:44d7:8701:869"},
expectedLength: 2,
expectedPeers: []string{"peer-2-name", "peer-3-name"},
},
{
description: "filter by ipv6 (uppercase)",
filters: PeerFilters{IP: "BA80:6AA5:89f1:44d7:8701:869"},
expectedLength: 2,
expectedPeers: []string{"peer-2-name", "peer-3-name"},
},
{
description: "filter by mac",
filters: PeerFilters{MAC: ":15:5d:24:0c"},
expectedLength: 3,
expectedPeers: []string{"Peer-1-name", "peer-2-name", "peer-3-name"},
},
{
description: "filter by mac (uppercase)",
filters: PeerFilters{MAC: ":15:5D:24:0C"},
expectedLength: 3,
expectedPeers: []string{"Peer-1-name", "peer-2-name", "peer-3-name"},
},
{
description: "filter by kind (server)",
filters: PeerFilters{IsServer: new(true), Os: []string{"linux"}},
expectedLength: 3,
expectedPeers: []string{"peer-4-name", "peer-5-name", "peer-6-name"},
},
{
description: "filter by kind (user_device)",
filters: PeerFilters{IsServer: new(false)},
expectedLength: 3,
expectedPeers: []string{"Peer-1-name", "peer-2-name", "peer-3-name"},
},
{
description: "filter by group_ids",
filters: PeerFilters{GroupIds: []string{"group-one-resource-id", "group-two-resources-id"}},
expectedLength: 2,
expectedPeers: []string{"Peer-1-name", "peer-2-name"},
},
{
description: "filter by os",
filters: PeerFilters{Os: []string{"linux", "ios"}},
expectedLength: 5,
expectedPeers: []string{"Peer-1-name", "peer-2-name", "peer-4-name", "peer-5-name", "peer-6-name"},
},
{
description: "filter by peer id",
filters: PeerFilters{PeersIds: []string{"peer-id-one", "peer-id-five", "peer-id-three"}},
expectedLength: 3,
expectedPeers: []string{"Peer-1-name", "peer-3-name", "peer-5-name"},
},
{
description: "filter by name",
filters: PeerFilters{Name: "peer-1"},
expectedLength: 1,
expectedPeers: []string{"Peer-1-name"},
},
{
description: "filter by name (upper-case)",
filters: PeerFilters{Name: "PEER-1"},
expectedLength: 1,
expectedPeers: []string{"Peer-1-name"},
},
{
description: "filter by hostname",
filters: PeerFilters{Hostname: "one-name.local"},
expectedLength: 1,
expectedPeers: []string{"Peer-1-name"},
},
{
description: "filter by hostname (upper-case)",
filters: PeerFilters{Hostname: "ONE-NAME.LOCAL"},
expectedLength: 1,
expectedPeers: []string{"Peer-1-name"},
},
}
for _, tt := range tests {
t.Run(tt.description+" "+engine, func(t *testing.T) {
t.Helper()
peers, count, err := store.GetAccountPeersPaginated(context.Background(), LockingStrengthNone, "account-1",
PaginationState{}, tt.filters, PeerSorting{})
require.NoError(t, err)
require.Len(t, peers, tt.expectedLength)
require.Equal(t, tt.expectedLength, count)
actualPeerNames := make([]string, 0, len(peers))
for _, p := range peers {
actualPeerNames = append(actualPeerNames, p.Name)
}
require.ElementsMatch(t, actualPeerNames, tt.expectedPeers)
})
}
})
}
func TestSqlStore_PeersPaging(t *testing.T) {
runTestForAllEnginesNoSqliteSeed(t, "../testdata/peers_pagination.sql", func(t *testing.T, engine string, store Store) {
var tests = []struct {
description string
page int
pageSize int
expectedLength int
expectedCount int
expectedPeers []string
}{
{
description: "page 1, size 4",
page: 1,
pageSize: 4,
expectedLength: 4,
expectedCount: 6,
expectedPeers: []string{"Peer-1-name", "peer-2-name", "peer-3-name", "peer-4-name"},
},
{
description: "page 2, size 4",
page: 2,
pageSize: 4,
expectedLength: 2,
expectedCount: 6,
expectedPeers: []string{"peer-5-name", "peer-6-name"},
},
}
for _, tt := range tests {
t.Run(tt.description+" "+engine, func(t *testing.T) {
t.Helper()
peers, count, err := store.GetAccountPeersPaginated(context.Background(), LockingStrengthNone, "account-1",
PaginationState{Page: tt.page, PageSize: tt.pageSize}, PeerFilters{}, PeerSorting{Fields: []PeerSortingKey{{Field: PeerSortByName}}})
require.NoError(t, err)
require.Len(t, peers, tt.expectedLength)
require.Equal(t, tt.expectedCount, count)
actualPeerNames := make([]string, 0, len(peers))
for _, p := range peers {
actualPeerNames = append(actualPeerNames, p.Name)
}
require.ElementsMatch(t, actualPeerNames, tt.expectedPeers)
})
}
})
}
func TestSqlStore_PeersSorting(t *testing.T) {
runTestForAllEnginesNoSqliteSeed(t, "../testdata/peers_pagination.sql", func(t *testing.T, engine string, store Store) {
var tests = []struct {
description string
sorting PeerSorting
expectedLength int
expectedPeers []string
}{
{
description: "default order (by id ascending)",
sorting: PeerSorting{Fields: []PeerSortingKey{}},
expectedLength: 6,
expectedPeers: []string{"peer-5-name", "peer-4-name", "Peer-1-name", "peer-6-name", "peer-3-name", "peer-2-name"},
},
{
description: "by name ascending",
sorting: PeerSorting{Fields: []PeerSortingKey{{Field: PeerSortByName}}},
expectedLength: 6,
expectedPeers: []string{"Peer-1-name", "peer-2-name", "peer-3-name", "peer-4-name", "peer-5-name", "peer-6-name"},
},
{
description: "by name descending",
sorting: PeerSorting{Fields: []PeerSortingKey{{Field: PeerSortByName, Order: DescOrderBy}}},
expectedLength: 6,
expectedPeers: []string{"peer-6-name", "peer-5-name", "peer-4-name", "peer-3-name", "peer-2-name", "Peer-1-name"},
},
{
description: "by last_seen ascending",
sorting: PeerSorting{Fields: []PeerSortingKey{{Field: PeerSortByLastSeen}}},
expectedLength: 6,
expectedPeers: []string{"peer-6-name", "peer-4-name", "peer-5-name", "peer-3-name", "peer-2-name", "Peer-1-name"},
},
{
description: "by last_seen descending",
sorting: PeerSorting{Fields: []PeerSortingKey{{Field: PeerSortByLastSeen, Order: DescOrderBy}}},
expectedLength: 6,
expectedPeers: []string{"Peer-1-name", "peer-2-name", "peer-3-name", "peer-5-name", "peer-4-name", "peer-6-name"},
},
{
description: "by version ascending",
sorting: PeerSorting{Fields: []PeerSortingKey{{Field: PeerSortByVersion}}},
expectedLength: 6,
expectedPeers: []string{"Peer-1-name", "peer-2-name", "peer-4-name", "peer-3-name", "peer-6-name", "peer-5-name"},
},
{
description: "by version descending",
sorting: PeerSorting{Fields: []PeerSortingKey{{Field: PeerSortByVersion, Order: DescOrderBy}}},
expectedLength: 6,
expectedPeers: []string{"peer-5-name", "peer-6-name", "peer-3-name", "peer-4-name", "peer-2-name", "Peer-1-name"},
},
}
for _, tt := range tests {
t.Run(tt.description+" "+engine, func(t *testing.T) {
t.Helper()
peers, _, err := store.GetAccountPeersPaginated(context.Background(), LockingStrengthNone, "account-1",
PaginationState{}, PeerFilters{}, tt.sorting)
require.NoError(t, err)
require.Len(t, peers, tt.expectedLength)
actualPeerNames := make([]string, 0, len(peers))
for _, p := range peers {
actualPeerNames = append(actualPeerNames, p.Name)
}
require.Equal(t, tt.expectedPeers, actualPeerNames)
})
}
})
}
+16
View File
@@ -42,6 +42,22 @@ func runTestForAllEngines(t *testing.T, testDataFile string, f func(t *testing.T
}
}
func runTestForAllEnginesNoSqliteSeed(t *testing.T, testDataFile string, f func(t *testing.T, engine string, store Store)) {
t.Helper()
for _, engine := range supportedEngines {
if os.Getenv("NETBIRD_STORE_ENGINE") != "" && os.Getenv("NETBIRD_STORE_ENGINE") != string(engine) {
continue
}
t.Setenv("NETBIRD_STORE_ENGINE", string(engine))
store, cleanUp, err := NewTestStore(context.Background(), testDataFile, t.TempDir())
assert.NoError(t, err, "engine: ", string(engine))
t.Cleanup(cleanUp)
assert.NoError(t, err)
f(t, string(engine), store)
os.Unsetenv("NETBIRD_STORE_ENGINE")
}
}
func Test_NewStore(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("The SQLite store is not properly supported by Windows yet")
+100 -17
View File
@@ -23,6 +23,7 @@ import (
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/netbirdio/netbird/dns"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
@@ -161,6 +162,7 @@ type Store interface {
AddPeerToAccount(ctx context.Context, peer *nbpeer.Peer) error
GetPeerByPeerPubKey(ctx context.Context, lockStrength LockingStrength, peerKey string) (*nbpeer.Peer, error)
GetAccountPeers(ctx context.Context, lockStrength LockingStrength, accountID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error)
GetAccountPeersPaginated(ctx context.Context, lockStrength LockingStrength, accountID string, pagination PaginationState, filters PeerFilters, sorting PeerSorting) ([]*nbpeer.Peer, int, error)
GetUserPeers(ctx context.Context, lockStrength LockingStrength, accountID, userID string) ([]*nbpeer.Peer, error)
GetPeerByID(ctx context.Context, lockStrength LockingStrength, accountID string, peerID string) (*nbpeer.Peer, error)
GetPeersByIDs(ctx context.Context, lockStrength LockingStrength, accountID string, peerIDs []string) (map[string]*nbpeer.Peer, error)
@@ -723,7 +725,7 @@ func NewTestStoreFromSQL(ctx context.Context, filename string, dataDir string) (
}
if filename != "" {
err = LoadSQL(conn.DB(nil), filename)
err = LoadSQL(conn.DB(nil), filename, translaterForSql(kind))
if err != nil {
_ = conn.Close()
return nil, nil, fmt.Errorf("failed to load SQL file: %v", err)
@@ -747,7 +749,7 @@ func NewTestStoreFromSQL(ctx context.Context, filename string, dataDir string) (
maxRetries := 2
for i := 0; i < maxRetries; i++ {
sqlStore, cleanup, err = getSqlStoreEngine(ctx, store, kind)
sqlStore, cleanup, err = getSqlStoreEngine(ctx, store, kind, dataDir)
if err == nil {
return sqlStore, cleanup, nil
}
@@ -759,6 +761,59 @@ func NewTestStoreFromSQL(ctx context.Context, filename string, dataDir string) (
return nil, nil, fmt.Errorf("failed to create test store after %d attempts: %v", maxRetries, err)
}
func NewTestStore(ctx context.Context, filename string, dataDir string) (Store, func(), error) {
kind := getStoreEngineFromEnv()
if kind == "" {
kind = types.SqliteStoreEngine
}
var sqlStore *SqlStore
var cleanup func()
var err error
maxRetries := 2
for i := 0; i < maxRetries; i++ {
sqlStore, cleanup, err = getSqlStoreEngine(ctx, nil, kind, dataDir)
if err == nil {
break
}
if i < maxRetries-1 {
time.Sleep(100 * time.Millisecond)
}
}
if err != nil {
return nil, nil, fmt.Errorf("failed to create test store after %d attempts: %v", maxRetries, err)
}
if filename != "" {
err = LoadSQL(sqlStore.db, filename, translaterForSql(kind))
if err != nil {
_ = sqlStore.Close(ctx)
return nil, nil, fmt.Errorf("failed to load SQL file: %v", err)
}
}
err = addAllGroupToAccount(ctx, sqlStore)
if err != nil {
_ = sqlStore.Close(ctx)
return nil, nil, fmt.Errorf("failed to add all group to account: %v", err)
}
return sqlStore, cleanup, nil
}
// use to translate double-quote escaped reserved names (ansi)
// to mysql tick-escaped ones
func translaterForSql(kind types.Engine) *strings.Replacer {
if kind != types.MysqlStoreEngine {
return strings.NewReplacer()
}
return strings.NewReplacer(
"\"key\"", "`key`",
"\"groups\"", "`groups`",
)
}
func addAllGroupToAccount(ctx context.Context, store Store) error {
allAccounts := store.GetAllAccounts(ctx)
for _, account := range allAccounts {
@@ -782,8 +837,8 @@ func addAllGroupToAccount(ctx context.Context, store Store) error {
return nil
}
func getSqlStoreEngine(ctx context.Context, sqliteStore *SqlStore, kind types.Engine) (Store, func(), error) {
store := sqliteStore
func getSqlStoreEngine(ctx context.Context, sqliteStore *SqlStore, kind types.Engine, dataDir string) (*SqlStore, func(), error) {
var store *SqlStore
var cleanup func()
var err error
switch kind {
@@ -795,6 +850,15 @@ func getSqlStoreEngine(ctx context.Context, sqliteStore *SqlStore, kind types.En
cleanup = func() {
// sqlite doesn't need to be cleaned up
}
store = sqliteStore
var conn *db.Conn
if store == nil {
conn, err = db.OpenSqliteFile(ctx, dataDir, db.SqliteFileName)
if err != nil {
return nil, nil, fmt.Errorf("failed to create test store: %v", err)
}
store, err = NewSqlStore(ctx, conn, nil, false)
}
}
if err != nil {
return nil, cleanup, fmt.Errorf("failed to create test store: %v", err)
@@ -803,7 +867,7 @@ func getSqlStoreEngine(ctx context.Context, sqliteStore *SqlStore, kind types.En
closeConnection := func() {
cleanup()
store.Close(ctx)
if store != sqliteStore {
if sqliteStore != nil && store != sqliteStore {
// The sqlite store only seeded the engine under test; without this
// every test leaks its connection and the opener goroutines.
sqliteStore.Close(ctx)
@@ -813,7 +877,7 @@ func getSqlStoreEngine(ctx context.Context, sqliteStore *SqlStore, kind types.En
return store, closeConnection, nil
}
func newReusedPostgresStore(ctx context.Context, store *SqlStore, kind types.Engine) (*SqlStore, func(), error) {
func newReusedPostgresStore(ctx context.Context, sqliteStore *SqlStore, kind types.Engine) (*SqlStore, func(), error) {
dsn, ok := lookupDSNEnv(PostgresDsnEnv, PostgresDsnEnvLegacy)
if !ok || dsn == "" {
var err error
@@ -846,16 +910,25 @@ func newReusedPostgresStore(ctx context.Context, store *SqlStore, kind types.Eng
return nil, nil, err
}
store, err = newPostgresqlStoreFromSqlStore(ctx, store, dsn, nil, true)
if err != nil {
cleanup()
return nil, nil, err
var store *SqlStore
if sqliteStore != nil {
store, err = newPostgresqlStoreFromSqlStore(ctx, sqliteStore, dsn, nil, true)
if err != nil {
cleanup()
return nil, nil, err
}
} else {
store, err = NewPostgresqlStoreForTests(ctx, dsn, nil, false)
if err != nil {
cleanup()
return nil, nil, err
}
}
return store, cleanup, nil
}
func newReusedMysqlStore(ctx context.Context, store *SqlStore, kind types.Engine) (*SqlStore, func(), error) {
func newReusedMysqlStore(ctx context.Context, sqliteStore *SqlStore, kind types.Engine) (*SqlStore, func(), error) {
dsn, ok := lookupDSNEnv(mysqlDsnEnv, mysqlDsnEnvLegacy)
if !ok || dsn == "" {
var err error
@@ -900,10 +973,19 @@ func newReusedMysqlStore(ctx context.Context, store *SqlStore, kind types.Engine
return nil, nil, err
}
store, err = newMysqlStoreFromSqlStore(ctx, store, dsn, nil, true)
if err != nil {
cleanup()
return nil, nil, err
var store *SqlStore
if sqliteStore != nil {
store, err = newMysqlStoreFromSqlStore(ctx, sqliteStore, dsn, nil, true)
if err != nil {
cleanup()
return nil, nil, err
}
} else {
store, err = NewMysqlStore(ctx, dsn, nil, false)
if err != nil {
cleanup()
return nil, nil, err
}
}
return store, cleanup, nil
@@ -1135,6 +1217,7 @@ func createRandomDB(dsn string, admin *gorm.DB, engine types.Engine, template st
dropDB, err = gorm.Open(postgres.Open(originalDSN), &gorm.Config{
SkipDefaultTransaction: true,
PrepareStmt: false,
Logger: logger.Default.LogMode(logger.Info),
})
if err != nil {
log.Errorf("failed to connect for dropping database %s: %v", dbName, err)
@@ -1205,7 +1288,7 @@ func replaceDBName(dsn, newDBName string) string {
return re.ReplaceAllString(dsn, `${pre}`+newDBName+`${post}`)
}
func LoadSQL(db *gorm.DB, filepath string) error {
func LoadSQL(db *gorm.DB, filepath string, translator *strings.Replacer) error {
sqlContent, err := os.ReadFile(filepath)
if err != nil {
return err
@@ -1214,7 +1297,7 @@ func LoadSQL(db *gorm.DB, filepath string) error {
queries := strings.Split(string(sqlContent), ";")
for _, query := range queries {
query = strings.TrimSpace(query)
query = translator.Replace(strings.TrimSpace(query))
if query != "" {
err := db.Exec(query).Error
if err != nil {
+16
View File
@@ -1344,6 +1344,22 @@ func (mr *MockStoreMockRecorder) GetAccountPeers(ctx, lockStrength, accountID, n
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountPeers", reflect.TypeOf((*MockStore)(nil).GetAccountPeers), ctx, lockStrength, accountID, nameFilter, ipFilter, macFilter)
}
// GetAccountPeersPaginated mocks base method.
func (m *MockStore) GetAccountPeersPaginated(ctx context.Context, lockStrength LockingStrength, accountID string, pagination PaginationState, filters PeerFilters, sorting PeerSorting) ([]*peer.Peer, int, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetAccountPeersPaginated", ctx, lockStrength, accountID, pagination, filters, sorting)
ret0, _ := ret[0].([]*peer.Peer)
ret1, _ := ret[1].(int)
ret2, _ := ret[2].(error)
return ret0, ret1, ret2
}
// GetAccountPeersPaginated indicates an expected call of GetAccountPeersPaginated.
func (mr *MockStoreMockRecorder) GetAccountPeersPaginated(ctx, lockStrength, accountID, pagination, filters, sorting any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountPeersPaginated", reflect.TypeOf((*MockStore)(nil).GetAccountPeersPaginated), ctx, lockStrength, accountID, pagination, filters, sorting)
}
// GetAccountPeersWithExpiration mocks base method.
func (m *MockStore) GetAccountPeersWithExpiration(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*peer.Peer, error) {
m.ctrl.T.Helper()