mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-21 04:49:10 +02:00
[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:
@@ -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)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user