mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-04 14:51:27 +02:00
Compare commits
10 Commits
agent-netw
...
feat/agent
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7020917025 | ||
|
|
7f3a29d688 | ||
|
|
e493efd532 | ||
|
|
91dd9fa239 | ||
|
|
58d0793870 | ||
|
|
29ad3fad43 | ||
|
|
6203528f3a | ||
|
|
fabfacee55 | ||
|
|
df3619e1e5 | ||
|
|
b2d72534c5 |
@@ -83,6 +83,7 @@ type ServerConfig struct {
|
||||
// AgentNetworkConfig contains agent-network (LLM gateway) configuration.
|
||||
type AgentNetworkConfig struct {
|
||||
PricingDefaultsFile string `yaml:"pricingDefaultsFile"`
|
||||
Zone string `yaml:"zone"`
|
||||
}
|
||||
|
||||
// TLSConfig contains TLS/HTTPS settings
|
||||
@@ -732,6 +733,7 @@ func (c *CombinedConfig) ToManagementConfig() (*nbconfig.Config, error) {
|
||||
PerAccountHighestSupportedSyncMessageVersion: c.Server.PerAccountSupportedSyncMessageVersions,
|
||||
AgentNetwork: nbconfig.AgentNetwork{
|
||||
PricingDefaultsFile: c.Server.AgentNetwork.PricingDefaultsFile,
|
||||
Zone: c.Server.AgentNetwork.Zone,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -147,3 +147,11 @@ server:
|
||||
# # is re-read periodically (mtime poll). An explicitly configured path that
|
||||
# # fails to load fails startup; runtime reload errors keep the previous table.
|
||||
# pricingDefaultsFile: "pricing.yaml"
|
||||
#
|
||||
# # Parent DNS zone that Agent Network gateway endpoints are allocated
|
||||
# # under, producing <subdomain>.<zone>. Empty (the default) preserves the
|
||||
# # legacy behaviour of deriving the endpoint from the serving cluster, so
|
||||
# # self-hosted deployments are unaffected. Captured onto each settings row
|
||||
# # when that row is created; changing it later does not move existing
|
||||
# # tenants.
|
||||
# zone: "gateway.example.com"
|
||||
|
||||
@@ -39,6 +39,9 @@
|
||||
]
|
||||
},
|
||||
"DisableDefaultPolicy": $NETBIRD_MGMT_DISABLE_DEFAULT_POLICY,
|
||||
"AgentNetwork": {
|
||||
"Zone": "$NETBIRD_AGENT_NETWORK_ZONE"
|
||||
},
|
||||
"Datadir": "",
|
||||
"DataStoreEncryptionKey": "$NETBIRD_DATASTORE_ENC_KEY",
|
||||
"StoreConfig": {
|
||||
|
||||
296
management/internals/modules/agentnetwork/allocate_test.go
Normal file
296
management/internals/modules/agentnetwork/allocate_test.go
Normal file
@@ -0,0 +1,296 @@
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/labelgen"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
nbtypes "github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
// TestIsUniqueConstraintError_RecognisesAllThreeDialects — the allocator's
|
||||
// retry loop hinges on this. A missed dialect turns a retryable collision into
|
||||
// a hard provider-create failure.
|
||||
func TestIsUniqueConstraintError_RecognisesAllThreeDialects(t *testing.T) {
|
||||
for name, err := range map[string]error{
|
||||
"postgres": errors.New(`ERROR: duplicate key value violates unique constraint (SQLSTATE 23505)`),
|
||||
"mysql": errors.New(`Error 1062 (23000): Duplicate entry 'brave-otter'`),
|
||||
"sqlite": errors.New(`UNIQUE constraint failed: agent_network_settings.subdomain`),
|
||||
} {
|
||||
assert.True(t, isUniqueConstraintError(err), "%s violation must be recognised", name)
|
||||
}
|
||||
|
||||
assert.False(t, isUniqueConstraintError(errors.New("connection refused")),
|
||||
"unrelated errors must not be treated as retryable collisions")
|
||||
}
|
||||
|
||||
// newAllocatorTestStore wires a real sqlite store, mirroring the pattern in
|
||||
// provider_bootstrap_test.go's bootstrapFixture. The allocator tests exercise
|
||||
// bootstrapSettingsIfNeeded directly against a managerImpl built in-package,
|
||||
// so no permissions manager or account manager is needed.
|
||||
func newAllocatorTestStore(t *testing.T) store.Store {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("sqlite store not properly supported on Windows yet")
|
||||
}
|
||||
t.Setenv("NETBIRD_STORE_ENGINE", string(nbtypes.SqliteStoreEngine))
|
||||
|
||||
st, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir())
|
||||
require.NoError(t, err, "test store setup must succeed")
|
||||
t.Cleanup(cleanUp)
|
||||
return st
|
||||
}
|
||||
|
||||
// TestBootstrapSettings_StampsZoneAndTupleLabel — new rows must carry the
|
||||
// configured zone and a tuple label, which together give the tenant a
|
||||
// placement-independent address.
|
||||
func TestBootstrapSettings_StampsZoneAndTupleLabel(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newAllocatorTestStore(t)
|
||||
|
||||
m := &managerImpl{
|
||||
store: st,
|
||||
zone: "gateway.example",
|
||||
labelRng: rand.New(rand.NewSource(1)),
|
||||
}
|
||||
|
||||
settings, err := m.bootstrapSettingsIfNeeded(ctx, "account1", "cluster1.example.com")
|
||||
require.NoError(t, err, "bootstrap must succeed")
|
||||
require.NotNil(t, settings)
|
||||
|
||||
assert.Equal(t, "gateway.example", settings.Zone, "new row must carry the configured zone")
|
||||
assert.Equal(t, "cluster1.example.com", settings.Cluster)
|
||||
assert.Contains(t, settings.Subdomain, "-", "subdomain must be an adjective-noun tuple label")
|
||||
assert.Equal(t, "account1", settings.AccountID)
|
||||
assert.Equal(t, settings.Subdomain+".gateway.example", settings.Endpoint(),
|
||||
"endpoint must be placement-independent, hanging off the zone rather than the cluster")
|
||||
|
||||
persisted, err := st.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, settings.Subdomain, persisted.Subdomain, "returned settings must match the persisted row")
|
||||
assert.Equal(t, "gateway.example", persisted.Zone)
|
||||
}
|
||||
|
||||
// TestBootstrapSettings_RetriesOnCollision forces a duplicate by pre-inserting
|
||||
// a row whose subdomain matches the next label the seeded rng will draw, then
|
||||
// asserts allocation still succeeds with a different label and that no error
|
||||
// escapes.
|
||||
func TestBootstrapSettings_RetriesOnCollision(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newAllocatorTestStore(t)
|
||||
|
||||
const seed = 7
|
||||
|
||||
// Precompute the label a freshly seeded rng will draw first, without
|
||||
// disturbing the rng the manager will actually use.
|
||||
predictor := rand.New(rand.NewSource(seed))
|
||||
firstDraw := labelgen.PickTuple(predictor)
|
||||
require.NotEmpty(t, firstDraw, "test precondition: label pools must be non-empty")
|
||||
|
||||
// Pre-insert a colliding row on a different account so the allocator's
|
||||
// first attempt hits the unique index and must retry.
|
||||
require.NoError(t, st.CreateAgentNetworkSettings(ctx, &types.Settings{
|
||||
AccountID: "other-account",
|
||||
Cluster: "cluster1.example.com",
|
||||
Subdomain: firstDraw,
|
||||
}), "seeding the colliding row must succeed")
|
||||
|
||||
m := &managerImpl{
|
||||
store: st,
|
||||
labelRng: rand.New(rand.NewSource(seed)),
|
||||
}
|
||||
|
||||
settings, err := m.bootstrapSettingsIfNeeded(ctx, "account1", "cluster1.example.com")
|
||||
require.NoError(t, err, "allocation must succeed after retrying past the collision")
|
||||
require.NotNil(t, settings)
|
||||
assert.NotEqual(t, firstDraw, settings.Subdomain,
|
||||
"the retried allocation must not reuse the already-taken label")
|
||||
}
|
||||
|
||||
// TestBootstrapSettings_IsIdempotent — calling twice for one account returns
|
||||
// the existing row unchanged (the early-return path), and does NOT
|
||||
// re-allocate.
|
||||
func TestBootstrapSettings_IsIdempotent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newAllocatorTestStore(t)
|
||||
|
||||
m := &managerImpl{
|
||||
store: st,
|
||||
labelRng: rand.New(rand.NewSource(3)),
|
||||
}
|
||||
|
||||
first, err := m.bootstrapSettingsIfNeeded(ctx, "account1", "cluster1.example.com")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, first)
|
||||
|
||||
second, err := m.bootstrapSettingsIfNeeded(ctx, "account1", "cluster2.example.com")
|
||||
require.NoError(t, err, "second call must not error")
|
||||
require.NotNil(t, second)
|
||||
|
||||
assert.Equal(t, first.Subdomain, second.Subdomain, "second call must return the existing subdomain unchanged")
|
||||
assert.Equal(t, first.Cluster, second.Cluster, "second call must not repin the cluster to the new hint")
|
||||
|
||||
all, err := st.GetAllAgentNetworkSettings(ctx, store.LockingStrengthNone)
|
||||
require.NoError(t, err)
|
||||
var forAccount int
|
||||
for _, s := range all {
|
||||
if s.AccountID == "account1" {
|
||||
forAccount++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, forAccount, "exactly one row must exist for the account; no re-allocation")
|
||||
}
|
||||
|
||||
// TestBootstrapSettings_FailsAfterExhaustingAttempts — the retry loop's
|
||||
// failure mode. maxSubdomainAllocationAttempts consecutive collisions must
|
||||
// surface an error rather than inserting a duplicate, silently succeeding, or
|
||||
// looping forever.
|
||||
//
|
||||
// Seed 11 was checked to produce maxSubdomainAllocationAttempts distinct
|
||||
// labels from labelgen.PickTuple; a seed that repeated a label would leave
|
||||
// fewer than maxAttempts rows pre-inserted and the allocator would succeed on
|
||||
// the repeat instead of exhausting.
|
||||
func TestBootstrapSettings_FailsAfterExhaustingAttempts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newAllocatorTestStore(t)
|
||||
|
||||
const seed = 11
|
||||
predictor := rand.New(rand.NewSource(seed))
|
||||
seen := make(map[string]struct{}, maxSubdomainAllocationAttempts)
|
||||
for i := 0; i < maxSubdomainAllocationAttempts; i++ {
|
||||
label := labelgen.PickTuple(predictor)
|
||||
_, dup := seen[label]
|
||||
require.False(t, dup, "test precondition: seed %d must draw %d distinct labels, got a repeat %q at draw %d", seed, maxSubdomainAllocationAttempts, label, i)
|
||||
seen[label] = struct{}{}
|
||||
|
||||
require.NoError(t, st.CreateAgentNetworkSettings(ctx, &types.Settings{
|
||||
AccountID: fmt.Sprintf("squatter-%d", i),
|
||||
Cluster: "cluster1.example.com",
|
||||
Subdomain: label,
|
||||
}), "seeding colliding row %d must succeed", i)
|
||||
}
|
||||
|
||||
m := &managerImpl{
|
||||
store: st,
|
||||
labelRng: rand.New(rand.NewSource(seed)),
|
||||
}
|
||||
|
||||
settings, err := m.bootstrapSettingsIfNeeded(ctx, "account1", "cluster1.example.com")
|
||||
require.Error(t, err, "exhausting every attempt to a collision must not silently succeed")
|
||||
assert.Nil(t, settings, "no settings row may be returned on failure")
|
||||
assert.Contains(t, err.Error(), "attempts exhausted")
|
||||
|
||||
_, err = st.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
|
||||
assert.Error(t, err, "no settings row must be persisted for the account when allocation fails")
|
||||
}
|
||||
|
||||
// TestBootstrapSettings_ConcurrentBootstrapReturnsWinnersRow covers the
|
||||
// same-account race: Settings' primary key is AccountID, and the
|
||||
// existence pre-check in bootstrapSettingsIfNeeded runs outside the
|
||||
// transaction, so two concurrent first-provider creates for the same
|
||||
// account can both observe NotFound and both proceed to allocate. The
|
||||
// loser's INSERT then fails on the primary key rather than the subdomain
|
||||
// unique index — a string isUniqueConstraintError still recognises — and
|
||||
// must not be treated as a label collision to retry past; it must
|
||||
// re-read and return the winner's row.
|
||||
//
|
||||
// This is scripted against a gomock store rather than driven by real
|
||||
// goroutines against the sqlite test store: NewTestStoreFromSQL caps the
|
||||
// pool at a single open connection (see its startup log,
|
||||
// "max open db connections to 1"), which serialises statement execution
|
||||
// enough that reliably forcing the exact interleaving this test needs —
|
||||
// both pre-checks observing NotFound before either INSERT lands — would
|
||||
// depend on goroutine scheduling rather than the store, making a
|
||||
// real-goroutine version flaky rather than deterministic. Scripting the
|
||||
// exact sequence (pre-check miss, PK-shaped insert failure, re-read hit)
|
||||
// through a MockStore exercises the same re-read branch precisely and
|
||||
// deterministically.
|
||||
func TestBootstrapSettings_ConcurrentBootstrapReturnsWinnersRow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockStore := store.NewMockStore(ctrl)
|
||||
|
||||
winner := &types.Settings{
|
||||
AccountID: "account1",
|
||||
Cluster: "cluster1.example.com",
|
||||
Subdomain: "brave-otter",
|
||||
}
|
||||
|
||||
gomock.InOrder(
|
||||
// The pre-check: no row yet, so this bootstrap proceeds to allocate.
|
||||
mockStore.EXPECT().
|
||||
GetAgentNetworkSettings(gomock.Any(), store.LockingStrengthNone, "account1").
|
||||
Return(nil, status.Errorf(status.NotFound, "agent network settings not found")),
|
||||
// The insert loses the race. The message shape is the sqlite wording
|
||||
// for a primary-key violation on account_id (not the subdomain
|
||||
// index); this test locks down that the retry path recognizes that
|
||||
// shape as a race loss and re-reads the winner's row, rather than
|
||||
// misclassifying it as a subdomain conflict.
|
||||
mockStore.EXPECT().
|
||||
ExecuteInTransaction(gomock.Any(), gomock.Any()).
|
||||
DoAndReturn(func(_ context.Context, f func(store.Store) error) error {
|
||||
return f(mockStore)
|
||||
}),
|
||||
// The re-read after the PK conflict finds the concurrent winner's row.
|
||||
mockStore.EXPECT().
|
||||
GetAgentNetworkSettings(gomock.Any(), store.LockingStrengthNone, "account1").
|
||||
Return(winner, nil),
|
||||
)
|
||||
mockStore.EXPECT().
|
||||
CreateAgentNetworkSettings(gomock.Any(), gomock.Any()).
|
||||
Return(errors.New("UNIQUE constraint failed: agent_network_settings.account_id"))
|
||||
|
||||
m := &managerImpl{
|
||||
store: mockStore,
|
||||
labelRng: rand.New(rand.NewSource(9)),
|
||||
}
|
||||
|
||||
settings, err := m.bootstrapSettingsIfNeeded(ctx, "account1", "cluster1.example.com")
|
||||
require.NoError(t, err, "losing the same-account race must not surface as an error")
|
||||
require.NotNil(t, settings)
|
||||
assert.Same(t, winner, settings, "the loser must return the concurrent winner's row, not retry past it")
|
||||
}
|
||||
|
||||
// TestBootstrapSettings_NonRetryableErrorFailsImmediately guards the
|
||||
// isUniqueConstraintError branch itself: a regression that dropped that check
|
||||
// and retried on every ExecuteInTransaction error would leave every other test
|
||||
// in this file green, because none of them feed the loop a non-collision
|
||||
// failure. A generic store error must surface immediately, wrapped, and must
|
||||
// not be retried — asserting ExecuteInTransaction was called exactly once is
|
||||
// what proves the loop didn't retry.
|
||||
func TestBootstrapSettings_NonRetryableErrorFailsImmediately(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockStore := store.NewMockStore(ctrl)
|
||||
|
||||
mockStore.EXPECT().
|
||||
GetAgentNetworkSettings(gomock.Any(), store.LockingStrengthNone, "account1").
|
||||
Return(nil, status.Errorf(status.NotFound, "agent network settings not found"))
|
||||
|
||||
mockStore.EXPECT().
|
||||
ExecuteInTransaction(gomock.Any(), gomock.Any()).
|
||||
Return(errors.New("connection refused")).
|
||||
Times(1)
|
||||
|
||||
m := &managerImpl{
|
||||
store: mockStore,
|
||||
labelRng: rand.New(rand.NewSource(5)),
|
||||
}
|
||||
|
||||
settings, err := m.bootstrapSettingsIfNeeded(ctx, "account1", "cluster1.example.com")
|
||||
require.Error(t, err, "a non-collision store error must surface, not be swallowed")
|
||||
assert.Nil(t, settings)
|
||||
assert.Contains(t, err.Error(), "create agent network settings",
|
||||
"the non-retryable error must be wrapped and returned, not retried past")
|
||||
}
|
||||
120
management/internals/modules/agentnetwork/domainlookup_test.go
Normal file
120
management/internals/modules/agentnetwork/domainlookup_test.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
)
|
||||
|
||||
// TestSynthesizeServiceForDomain_ResolvesZoneBasedEndpoint — with a Zone the
|
||||
// hostname's parent is the zone, not the cluster, so the old "strip the first
|
||||
// label and match a cluster" prefilter found nothing and every zone-based
|
||||
// tenant failed to resolve on the auth path.
|
||||
func TestSynthesizeServiceForDomain_ResolvesZoneBasedEndpoint(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
|
||||
require.NoError(t, err, "real sqlite test store must come up")
|
||||
defer cleanup()
|
||||
|
||||
settings := newSynthTestSettings()
|
||||
settings.Cluster = "eu.proxy.netbird.io"
|
||||
settings.Zone = "gateway.netbird.ai"
|
||||
settings.Subdomain = "brave-otter"
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, settings))
|
||||
provider := newSynthTestProvider()
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "")))
|
||||
|
||||
domain := "brave-otter.gateway.netbird.ai"
|
||||
svc, err := SynthesizeServiceForDomain(ctx, s, domain)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, svc, "zone-based endpoint must resolve to the owning account's service")
|
||||
assert.Equal(t, domain, svc.Domain)
|
||||
}
|
||||
|
||||
// TestSynthesizeServiceForDomain_ResolvesLegacyClusterEndpoint — the
|
||||
// non-breaking guarantee. A row with no Zone still resolves at
|
||||
// <subdomain>.<cluster>, because the subdomain is the first label either way.
|
||||
func TestSynthesizeServiceForDomain_ResolvesLegacyClusterEndpoint(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
|
||||
require.NoError(t, err, "real sqlite test store must come up")
|
||||
defer cleanup()
|
||||
|
||||
settings := newSynthTestSettings()
|
||||
settings.Cluster = "eu.proxy.netbird.io"
|
||||
settings.Zone = ""
|
||||
settings.Subdomain = "swift-heron"
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, settings))
|
||||
provider := newSynthTestProvider()
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "")))
|
||||
|
||||
domain := "swift-heron.eu.proxy.netbird.io"
|
||||
svc, err := SynthesizeServiceForDomain(ctx, s, domain)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, svc, "legacy cluster-based endpoint must still resolve")
|
||||
assert.Equal(t, domain, svc.Domain)
|
||||
}
|
||||
|
||||
// TestSynthesizeServiceForDomain_LabelMatchesButParentDoesNot — the label is
|
||||
// globally unique, so a lookup by first label can hit a row that does NOT own
|
||||
// the queried hostname. That must resolve to nothing rather than to the wrong
|
||||
// account's service.
|
||||
func TestSynthesizeServiceForDomain_LabelMatchesButParentDoesNot(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
|
||||
require.NoError(t, err, "real sqlite test store must come up")
|
||||
defer cleanup()
|
||||
|
||||
settings := newSynthTestSettings()
|
||||
settings.Cluster = "eu.proxy.netbird.io"
|
||||
settings.Zone = "gateway.netbird.ai"
|
||||
settings.Subdomain = "brave-otter"
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, settings))
|
||||
provider := newSynthTestProvider()
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "")))
|
||||
|
||||
svc, err := SynthesizeServiceForDomain(ctx, s, "brave-otter.someone-elses.zone")
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, svc, "label matched a different endpoint's parent; must not resolve to the wrong account")
|
||||
}
|
||||
|
||||
// TestSynthesizeServiceForDomain_UnknownLabel — a hostname whose first label
|
||||
// belongs to no account is a miss, not an error: the caller falls back to the
|
||||
// persisted-service lookup and a returned error would mask that.
|
||||
func TestSynthesizeServiceForDomain_UnknownLabel(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
|
||||
require.NoError(t, err, "real sqlite test store must come up")
|
||||
defer cleanup()
|
||||
|
||||
svc, err := SynthesizeServiceForDomain(ctx, s, "nobody-home.gateway.netbird.ai")
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, svc, "unknown label must be a miss, not an error")
|
||||
}
|
||||
|
||||
// TestSynthesizeServiceForDomain_DegenerateInput — empty and single-label
|
||||
// hostnames have no dot to cut a subdomain label from, so they resolve to no
|
||||
// service, same as any other unowned hostname. The early-return guard that
|
||||
// catches them is an optimisation (it skips a store round trip that would
|
||||
// only miss anyway), not what makes this case correct — "" and "localhost"
|
||||
// would still come back nil, nil even without it, via the same not-found
|
||||
// fallthrough TestSynthesizeServiceForDomain_UnknownLabel exercises.
|
||||
func TestSynthesizeServiceForDomain_DegenerateInput(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
|
||||
require.NoError(t, err, "real sqlite test store must come up")
|
||||
defer cleanup()
|
||||
|
||||
for _, domain := range []string{"", "localhost"} {
|
||||
svc, err := SynthesizeServiceForDomain(ctx, s, domain)
|
||||
require.NoError(t, err, "domain %q", domain)
|
||||
assert.Nil(t, svc, "domain %q has no subdomain label to look up", domain)
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,7 @@ func newAgentNetworkHandlerFixture(t *testing.T) *agentNetworkHandlerFixture {
|
||||
Return(true, context.Background(), nil).
|
||||
AnyTimes()
|
||||
|
||||
manager := agentnetwork.NewManager(st, perms, nil, nil)
|
||||
manager := agentnetwork.NewManager(st, perms, nil, nil, "")
|
||||
h := &handler{manager: manager}
|
||||
|
||||
router := mux.NewRouter()
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// Package labelgen produces DNS-safe Agent Network subdomain labels.
|
||||
//
|
||||
// The adjective pool below pairs with the noun pool in words.go to form
|
||||
// `<adjective>-<noun>` labels. It is kept separate because words.go is almost
|
||||
// entirely nouns — drawing both halves from it produced unreadable pairs like
|
||||
// "millet-hammock". Entries are lowercase ASCII, 4-12 chars, free of hyphens
|
||||
// and digits, screened for offensive/brand/region-specific terms, and disjoint
|
||||
// from the noun pool (enforced by TestAdjectives_AreDisjointFromNouns).
|
||||
package labelgen
|
||||
|
||||
// adjectives is the descriptor half of a generated label.
|
||||
var adjectives = []string{
|
||||
"able", "active", "adept", "agile", "airy", "alert", "amiable", "ample",
|
||||
"ancient", "ardent", "artful", "astute", "balmy", "blithe", "bold", "bonny",
|
||||
"brave", "breezy", "brisk", "bubbly", "buoyant", "bushy", "candid", "canny",
|
||||
"cheery", "chilly", "chipper", "chunky", "civil", "classic", "clever", "comely",
|
||||
"compact", "cordial", "cosmic", "courtly", "crafty", "creamy", "crisp", "cuddly",
|
||||
"curious", "dainty", "dapper", "daring", "dashing", "deft", "dewy", "diligent",
|
||||
"downy", "dreamy", "dulcet", "durable", "dusky", "eager", "earnest", "earthy",
|
||||
"easy", "elated", "elegant", "epic", "fabled", "faithful", "fancy", "fearless",
|
||||
"feisty", "fervent", "fleet", "fluffy", "fond", "frisky", "frosty", "gallant",
|
||||
"genial", "genteel", "gentle", "giddy", "gilded", "glad", "glassy", "gleaming",
|
||||
"glossy", "graceful", "grand", "grainy", "hale", "hardy", "hearty", "hefty",
|
||||
"honest", "hopeful", "humble", "hushed", "immense", "jaunty", "jolly", "jovial",
|
||||
"joyful", "jubilant", "keen", "kindly", "kindred", "lanky", "leafy", "limber",
|
||||
"lively", "lofty", "loyal", "lucent", "lucid", "luminous", "lush", "maroon",
|
||||
"mellow", "merry", "mighty", "mindful", "mirthful", "misty", "modest", "muted",
|
||||
"nifty", "nimble", "noble", "patient", "peaceful", "pearly", "peppy", "perky",
|
||||
"petite", "placid", "playful", "pleasant", "plucky", "plush", "polite", "posh",
|
||||
"prancing", "pristine", "prompt", "proud", "prudent", "quaint", "quick", "quirky",
|
||||
"radiant", "ready", "regal", "restful", "robust", "rosy", "ruddy", "rugged",
|
||||
"sandy", "satin", "saucy", "savvy", "sedate", "serene", "shady", "shiny",
|
||||
"silken", "silky", "sincere", "sleek", "slender", "smart", "smooth", "snappy",
|
||||
"snug", "soaring", "sparkly", "spiffy", "spirited", "sprightly", "spry", "stalwart",
|
||||
"stately", "steady", "sterling", "stoic", "stormy", "stout", "sturdy", "sunlit",
|
||||
"supple", "svelte", "tawny", "tender", "tidy", "timeless", "trusty", "upbeat",
|
||||
"urbane", "valiant", "vast", "vernal", "vibrant", "vintage", "whimsy", "willing",
|
||||
"windy", "winsome", "wintry", "witty", "worthy", "zesty", "zippy",
|
||||
}
|
||||
@@ -2,18 +2,11 @@
|
||||
package labelgen
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// pickAttempts caps the random retries before falling back to the
|
||||
// suffixed form. Eight is a soft compromise: with a near-empty taken
|
||||
// set the very first pick almost always succeeds; when the wordlist is
|
||||
// densely populated the fallback eventually fires anyway.
|
||||
const pickAttempts = 8
|
||||
|
||||
var (
|
||||
dedupOnce sync.Once
|
||||
uniqWords []string
|
||||
@@ -37,30 +30,19 @@ func uniqueWords() []string {
|
||||
return uniqWords
|
||||
}
|
||||
|
||||
// PickUnique selects a label not already in `taken`. It tries up to
|
||||
// pickAttempts random picks; on exhaustion it scans the deduplicated
|
||||
// wordlist for any remaining free entry, and if none is left appends
|
||||
// `-<fallbackSuffix>` to a deterministic word and returns. The caller
|
||||
// is responsible for seeding rng (math/rand).
|
||||
func PickUnique(rng *rand.Rand, taken map[string]struct{}, fallbackSuffix string) string {
|
||||
pool := uniqueWords()
|
||||
if len(pool) == 0 {
|
||||
return fallbackSuffix
|
||||
// PickTuple returns an adjective-noun label such as "brave-otter". It is still
|
||||
// a single DNS label.
|
||||
//
|
||||
// It takes no `taken` set and has no fallback suffix. The noun pool holds 857
|
||||
// entries, which is ample per cluster but a hard ceiling once labels must be
|
||||
// unique across one shared zone; pairing an adjective with a noun spans
|
||||
// len(adjectives) * 857 instead. Uniqueness is enforced by a database
|
||||
// constraint and retried by the caller, rather than guessed from a pre-read
|
||||
// set that a concurrent allocation can invalidate.
|
||||
func PickTuple(rng *rand.Rand) string {
|
||||
nouns := uniqueWords()
|
||||
if len(nouns) == 0 || len(adjectives) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
for i := 0; i < pickAttempts; i++ {
|
||||
w := pool[rng.Intn(len(pool))]
|
||||
if _, ok := taken[w]; !ok {
|
||||
return w
|
||||
}
|
||||
}
|
||||
|
||||
for _, w := range pool {
|
||||
if _, ok := taken[w]; !ok {
|
||||
return w
|
||||
}
|
||||
}
|
||||
|
||||
w := pool[rng.Intn(len(pool))]
|
||||
return fmt.Sprintf("%s-%s", w, fallbackSuffix)
|
||||
return adjectives[rng.Intn(len(adjectives))] + "-" + nouns[rng.Intn(len(nouns))]
|
||||
}
|
||||
|
||||
@@ -9,78 +9,6 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestPickUnique_DeterministicWithSeededRng locks the property the
|
||||
// caller relies on: same seed + same taken set → same pick. Without
|
||||
// that, the bootstrap flow can't reproduce a label across retries.
|
||||
func TestPickUnique_DeterministicWithSeededRng(t *testing.T) {
|
||||
taken := map[string]struct{}{}
|
||||
|
||||
rngA := rand.New(rand.NewSource(42))
|
||||
rngB := rand.New(rand.NewSource(42))
|
||||
|
||||
a := PickUnique(rngA, taken, "abcd")
|
||||
b := PickUnique(rngB, taken, "abcd")
|
||||
|
||||
assert.Equal(t, a, b, "Same seed and taken set must produce identical pick")
|
||||
}
|
||||
|
||||
// TestPickUnique_AvoidsTakenWordsWhenMostAreReserved seeds taken with
|
||||
// every word in the pool except a handful and confirms PickUnique
|
||||
// finds one of the remaining free entries instead of returning the
|
||||
// fallback form.
|
||||
func TestPickUnique_AvoidsTakenWordsWhenMostAreReserved(t *testing.T) {
|
||||
pool := uniqueWords()
|
||||
require.NotEmpty(t, pool, "wordlist must be populated for the test to mean anything")
|
||||
|
||||
free := map[string]struct{}{
|
||||
pool[0]: {},
|
||||
pool[len(pool)/2]: {},
|
||||
pool[len(pool)-1]: {},
|
||||
}
|
||||
|
||||
taken := make(map[string]struct{}, len(pool))
|
||||
for _, w := range pool {
|
||||
if _, ok := free[w]; ok {
|
||||
continue
|
||||
}
|
||||
taken[w] = struct{}{}
|
||||
}
|
||||
|
||||
rng := rand.New(rand.NewSource(7))
|
||||
got := PickUnique(rng, taken, "abcd")
|
||||
|
||||
_, isFree := free[got]
|
||||
assert.True(t, isFree, "PickUnique must return one of the free words; got %q", got)
|
||||
assert.NotContains(t, got, "-", "Free pick must not be the suffix fallback form")
|
||||
}
|
||||
|
||||
// TestPickUnique_FallsBackWhenAllReserved exhausts the pool and
|
||||
// confirms PickUnique appends the supplied suffix instead of
|
||||
// returning a duplicate.
|
||||
func TestPickUnique_FallsBackWhenAllReserved(t *testing.T) {
|
||||
pool := uniqueWords()
|
||||
|
||||
taken := make(map[string]struct{}, len(pool))
|
||||
for _, w := range pool {
|
||||
taken[w] = struct{}{}
|
||||
}
|
||||
|
||||
rng := rand.New(rand.NewSource(99))
|
||||
got := PickUnique(rng, taken, "abcd")
|
||||
|
||||
assert.True(t, strings.HasSuffix(got, "-abcd"), "Exhausted pool must produce <word>-<suffix>; got %q", got)
|
||||
|
||||
prefix := strings.TrimSuffix(got, "-abcd")
|
||||
found := false
|
||||
for _, w := range pool {
|
||||
if w == prefix {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "Fallback prefix must be drawn from the wordlist; got %q", prefix)
|
||||
}
|
||||
|
||||
// TestUniqueWords_DropsDuplicates guards against authoring slips in
|
||||
// words.go: every entry must be unique and DNS-safe.
|
||||
func TestUniqueWords_DropsDuplicates(t *testing.T) {
|
||||
@@ -99,3 +27,82 @@ func TestUniqueWords_DropsDuplicates(t *testing.T) {
|
||||
}
|
||||
assert.GreaterOrEqual(t, len(pool), 500, "Pool must contain at least 500 unique words")
|
||||
}
|
||||
|
||||
// TestPickTuple_ShapeAndPoolMembership locks the wire-visible shape: an
|
||||
// adjective and a noun, each from its own pool, joined by a single hyphen so
|
||||
// the result stays one DNS label.
|
||||
func TestPickTuple_ShapeAndPoolMembership(t *testing.T) {
|
||||
nouns := uniqueWords()
|
||||
inNouns := make(map[string]struct{}, len(nouns))
|
||||
for _, w := range nouns {
|
||||
inNouns[w] = struct{}{}
|
||||
}
|
||||
inAdjectives := make(map[string]struct{}, len(adjectives))
|
||||
for _, a := range adjectives {
|
||||
inAdjectives[a] = struct{}{}
|
||||
}
|
||||
|
||||
rng := rand.New(rand.NewSource(7))
|
||||
for i := 0; i < 200; i++ {
|
||||
got := PickTuple(rng)
|
||||
|
||||
parts := strings.Split(got, "-")
|
||||
require.Len(t, parts, 2, "PickTuple must produce exactly two hyphen-joined words; got %q", got)
|
||||
|
||||
_, adjOK := inAdjectives[parts[0]]
|
||||
assert.True(t, adjOK, "First half must be an adjective; %q not in adjectives (from %q)", parts[0], got)
|
||||
_, nounOK := inNouns[parts[1]]
|
||||
assert.True(t, nounOK, "Second half must be a noun; %q not in words (from %q)", parts[1], got)
|
||||
|
||||
assert.LessOrEqual(t, len(got), 63, "Label must fit a DNS label; got %q (%d chars)", got, len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdjectives_AreDisjointFromNouns keeps the namespace a clean product and
|
||||
// prevents nonsense like "azure-azure": a handful of the noun pool's entries
|
||||
// are adjectival, and any overlap would let the same word land on both sides.
|
||||
func TestAdjectives_AreDisjointFromNouns(t *testing.T) {
|
||||
nouns := make(map[string]struct{}, len(uniqueWords()))
|
||||
for _, w := range uniqueWords() {
|
||||
nouns[w] = struct{}{}
|
||||
}
|
||||
for _, a := range adjectives {
|
||||
_, clash := nouns[a]
|
||||
assert.False(t, clash, "Adjective %q also appears in the noun pool; remove it from one list", a)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdjectives_AreDNSSafeAndDeduplicated mirrors the curation contract stated
|
||||
// in words.go: lowercase ASCII, 4-12 chars, no digits or hyphens, no repeats.
|
||||
func TestAdjectives_AreDNSSafeAndDeduplicated(t *testing.T) {
|
||||
seen := make(map[string]struct{}, len(adjectives))
|
||||
for _, a := range adjectives {
|
||||
_, dup := seen[a]
|
||||
assert.False(t, dup, "Duplicate adjective %q", a)
|
||||
seen[a] = struct{}{}
|
||||
|
||||
assert.Regexp(t, `^[a-z]{4,12}$`, a, "Adjective %q must be 4-12 lowercase ASCII letters", a)
|
||||
}
|
||||
assert.Greater(t, len(adjectives), 150, "Adjective pool too small to give a useful namespace")
|
||||
}
|
||||
|
||||
// TestPickTuple_DeterministicWithSeededRng documents that generation is a pure
|
||||
// function of the rng, which is what makes allocation retries reproducible in tests.
|
||||
func TestPickTuple_DeterministicWithSeededRng(t *testing.T) {
|
||||
a := PickTuple(rand.New(rand.NewSource(42)))
|
||||
b := PickTuple(rand.New(rand.NewSource(42)))
|
||||
assert.Equal(t, a, b, "Same seed must yield the same tuple")
|
||||
}
|
||||
|
||||
// TestPickTuple_SpansALargeNamespace guards the reason we moved to tuples: a
|
||||
// single-word pool caps the GLOBAL namespace at 857. Drawing many tuples must
|
||||
// yield overwhelmingly distinct values.
|
||||
func TestPickTuple_SpansALargeNamespace(t *testing.T) {
|
||||
rng := rand.New(rand.NewSource(11))
|
||||
seen := make(map[string]struct{}, 2000)
|
||||
for i := 0; i < 2000; i++ {
|
||||
seen[PickTuple(rng)] = struct{}{}
|
||||
}
|
||||
assert.Greater(t, len(seen), 1900,
|
||||
"2000 draws should be nearly all distinct across a ~200k namespace; got %d unique", len(seen))
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// hand-checked to avoid offensive, brand, or region-specific terms.
|
||||
package labelgen
|
||||
|
||||
// words is the pool PickUnique selects from. The slice is intentionally
|
||||
// words is the pool PickTuple draws its noun from. The slice is intentionally
|
||||
// not sorted — random picks distribute across the list naturally.
|
||||
var words = []string{
|
||||
"acorn", "adobe", "agate", "alder", "almond", "alpine", "amber", "amethyst",
|
||||
|
||||
@@ -122,6 +122,10 @@ type managerImpl struct {
|
||||
permissionsManager permissions.Manager
|
||||
proxyController proxy.Controller
|
||||
|
||||
// zone is the parent DNS zone stamped onto newly allocated settings rows.
|
||||
// Empty keeps the legacy <subdomain>.<cluster> endpoint form.
|
||||
zone string
|
||||
|
||||
// reconcileCache holds the last set of synthesised proxy mappings
|
||||
// per account so reconcile can emit precise Create/Update/Delete
|
||||
// updates instead of a full re-push on every mutation. Keyed by
|
||||
@@ -129,7 +133,7 @@ type managerImpl struct {
|
||||
reconcileMu sync.Mutex
|
||||
reconcileCache map[string]map[string]*proto.ProxyMapping
|
||||
|
||||
// labelRngMu guards labelRng. PickUnique consumes math/rand.Source
|
||||
// labelRngMu guards labelRng. PickTuple consumes math/rand.Source
|
||||
// state; concurrent provider creates would otherwise race.
|
||||
labelRngMu sync.Mutex
|
||||
labelRng *rand.Rand
|
||||
@@ -145,12 +149,14 @@ func NewManager(
|
||||
permissionsManager permissions.Manager,
|
||||
accountManager account.Manager,
|
||||
proxyController proxy.Controller,
|
||||
zone string,
|
||||
) Manager {
|
||||
return &managerImpl{
|
||||
store: store,
|
||||
accountManager: accountManager,
|
||||
permissionsManager: permissionsManager,
|
||||
proxyController: proxyController,
|
||||
zone: zone,
|
||||
reconcileCache: make(map[string]map[string]*proto.ProxyMapping),
|
||||
labelRng: rand.New(rand.NewSource(time.Now().UnixNano())),
|
||||
}
|
||||
@@ -303,6 +309,22 @@ func (m *managerImpl) DeleteProvider(ctx context.Context, accountID, userID, pro
|
||||
return nil
|
||||
}
|
||||
|
||||
// isUniqueConstraintError reports whether err is a duplicate-key rejection.
|
||||
//
|
||||
// The equivalent helper in management/server is unexported, so it cannot be
|
||||
// reused from here; this is a deliberate duplicate rather than a new dependency
|
||||
// on that package for a single three-line matcher. Keep the two in sync if a
|
||||
// dialect is added.
|
||||
func isUniqueConstraintError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "(SQLSTATE 23505)") || // postgres
|
||||
strings.Contains(msg, "Error 1062 (23000)") || // mysql
|
||||
strings.Contains(msg, "UNIQUE constraint failed") // sqlite
|
||||
}
|
||||
|
||||
func pluralize(n int, singular, plural string) string {
|
||||
if n == 1 {
|
||||
return singular
|
||||
@@ -626,12 +648,6 @@ func (m *managerImpl) GetSettings(ctx context.Context, accountID, userID string)
|
||||
return m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
|
||||
}
|
||||
|
||||
// bootstrapSettingsIfNeeded creates the per-account agent-network
|
||||
// settings row when missing. The cluster comes from the create-time
|
||||
// hint the dashboard sends (auto-picked from the active cluster list);
|
||||
// the subdomain is picked from the curated wordlist avoiding
|
||||
// collisions on the same cluster. Idempotent: if a row already exists
|
||||
// it is returned untouched and the hint is ignored.
|
||||
// requireSettingsBootstrapPermission gates the one-time settings bootstrap a
|
||||
// first provider create performs. Pinning the account's cluster and subdomain
|
||||
// is a settings write, so it needs the settings permission on top of the
|
||||
@@ -648,6 +664,15 @@ func (m *managerImpl) requireSettingsBootstrapPermission(ctx context.Context, ac
|
||||
return m.requirePermission(ctx, accountID, userID, modules.AgentNetworkSettings, operations.Create)
|
||||
}
|
||||
|
||||
// maxSubdomainAllocationAttempts bounds the allocate-and-insert retry loop in
|
||||
// bootstrapSettingsIfNeeded. Package-level (rather than function-local) so
|
||||
// tests can assert on the exhaustion path without duplicating the literal.
|
||||
const maxSubdomainAllocationAttempts = 10
|
||||
|
||||
// bootstrapSettingsIfNeeded creates the per-account agent-network settings
|
||||
// row when missing, allocating a subdomain unique across the whole zone.
|
||||
// Idempotent: if a row already exists it is returned untouched and the
|
||||
// cluster hint is ignored.
|
||||
func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, accountID, providerCluster string) (*types.Settings, error) {
|
||||
if accountID == "" {
|
||||
return nil, fmt.Errorf("bootstrap settings: account id is required")
|
||||
@@ -665,40 +690,66 @@ func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, accountID,
|
||||
return nil, fmt.Errorf("get agent network settings: %w", err)
|
||||
}
|
||||
|
||||
siblings, err := m.store.GetAgentNetworkSettingsByCluster(ctx, store.LockingStrengthNone, providerCluster)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list agent network settings on cluster: %w", err)
|
||||
}
|
||||
taken := make(map[string]struct{}, len(siblings))
|
||||
for _, s := range siblings {
|
||||
taken[s.Subdomain] = struct{}{}
|
||||
}
|
||||
|
||||
suffix := accountID
|
||||
if len(suffix) > 4 {
|
||||
suffix = suffix[:4]
|
||||
}
|
||||
|
||||
m.labelRngMu.Lock()
|
||||
subdomain := labelgen.PickUnique(m.labelRng, taken, suffix)
|
||||
m.labelRngMu.Unlock()
|
||||
|
||||
// Labels must be unique across the whole zone; the database's unique index
|
||||
// enforces that, and the loop below retries with a fresh label whenever an
|
||||
// attempt is rejected.
|
||||
now := time.Now().UTC()
|
||||
settings := &types.Settings{
|
||||
AccountID: accountID,
|
||||
Cluster: providerCluster,
|
||||
Subdomain: subdomain,
|
||||
// Logs on by default; usage is collected regardless. Retention bounds
|
||||
// how long full log rows are kept.
|
||||
AccountID: accountID,
|
||||
Cluster: providerCluster,
|
||||
Zone: m.zone,
|
||||
EnableLogCollection: true,
|
||||
AccessLogRetentionDays: types.DefaultAccessLogRetentionDays,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := m.store.SaveAgentNetworkSettings(ctx, settings); err != nil {
|
||||
return nil, fmt.Errorf("save agent network settings: %w", err)
|
||||
|
||||
for attempt := 1; attempt <= maxSubdomainAllocationAttempts; attempt++ {
|
||||
m.labelRngMu.Lock()
|
||||
settings.Subdomain = labelgen.PickTuple(m.labelRng)
|
||||
m.labelRngMu.Unlock()
|
||||
|
||||
if settings.Subdomain == "" {
|
||||
// Only reachable if either word pool were emptied; a database
|
||||
// insert of an empty subdomain would collide with the unique
|
||||
// index in a confusing way and produce a broken endpoint like
|
||||
// ".gateway.example". Fail loudly instead of looping or inserting.
|
||||
return nil, fmt.Errorf(
|
||||
"allocate agent network subdomain for account %s: label generator returned an empty label",
|
||||
accountID)
|
||||
}
|
||||
|
||||
// Each attempt gets its own transaction wrapping a single INSERT: on
|
||||
// postgres a failed statement poisons the enclosing transaction, so a
|
||||
// fresh transaction per attempt is what makes the retry loop work on
|
||||
// that dialect at all.
|
||||
err := m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
|
||||
return transaction.CreateAgentNetworkSettings(ctx, settings)
|
||||
})
|
||||
if err == nil {
|
||||
return settings, nil
|
||||
}
|
||||
if isUniqueConstraintError(err) {
|
||||
// A concurrent bootstrap for this account may have won the race: the
|
||||
// pre-check above is outside the transaction, and the settings PK is
|
||||
// account_id, so the loser's insert fails on the primary key rather
|
||||
// than the subdomain index. Re-read before assuming the label was
|
||||
// taken, so a same-account race resolves immediately instead of
|
||||
// burning every remaining attempt on the same primary-key conflict.
|
||||
if existing, getErr := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID); getErr == nil {
|
||||
return existing, nil
|
||||
}
|
||||
log.WithContext(ctx).Tracef(
|
||||
"agent-network subdomain %q taken, retrying (attempt %d/%d)",
|
||||
settings.Subdomain, attempt, maxSubdomainAllocationAttempts)
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("create agent network settings: %w", err)
|
||||
}
|
||||
return settings, nil
|
||||
|
||||
return nil, fmt.Errorf(
|
||||
"allocate agent network subdomain for account %s: %d attempts exhausted",
|
||||
accountID, maxSubdomainAllocationAttempts)
|
||||
}
|
||||
|
||||
// ListConsumption returns every consumption row recorded for the
|
||||
|
||||
@@ -48,7 +48,7 @@ func newBootstrapFixture(t *testing.T) *bootstrapFixture {
|
||||
accounts.EXPECT().BufferUpdateAccountPeers(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
|
||||
|
||||
return &bootstrapFixture{
|
||||
manager: NewManager(st, perms, accounts, nil),
|
||||
manager: NewManager(st, perms, accounts, nil, ""),
|
||||
store: st,
|
||||
perms: perms,
|
||||
}
|
||||
|
||||
@@ -116,45 +116,46 @@ func SynthesizeServicesForCluster(ctx context.Context, s store.Store, clusterAdd
|
||||
}
|
||||
|
||||
// SynthesizeServiceForDomain resolves a single agent-network service by its
|
||||
// public endpoint domain. It lists the (few) settings rows on the domain's
|
||||
// cluster, matches the one whose endpoint equals the domain, and synthesises
|
||||
// only that account — avoiding full per-account synthesis for every tenant on
|
||||
// the cluster, which is what auth/session paths previously paid. Returns nil
|
||||
// (no error) when no account owns the domain.
|
||||
// endpoint hostname. Both endpoint shapes put the account's label in the first
|
||||
// DNS label — <subdomain>.<cluster> and <subdomain>.<zone> — and the label is
|
||||
// globally unique, so this is a single indexed lookup for either shape. It
|
||||
// synthesises only the owning account rather than every tenant on a cluster,
|
||||
// which is what auth/session paths previously paid. Returns nil (no error) when
|
||||
// no account owns the hostname.
|
||||
func SynthesizeServiceForDomain(ctx context.Context, s store.Store, domain string) (*rpservice.Service, error) {
|
||||
domain = strings.TrimSpace(domain)
|
||||
cluster := clusterFromDomain(domain)
|
||||
if domain != "" && cluster != "" {
|
||||
settingsRows, err := s.GetAgentNetworkSettingsByCluster(ctx, store.LockingStrengthNone, cluster)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list agent network settings on cluster: %w", err)
|
||||
}
|
||||
for _, settings := range settingsRows {
|
||||
if settings == nil || settings.Endpoint() != domain {
|
||||
continue
|
||||
}
|
||||
services, serr := SynthesizeServices(ctx, s, settings.AccountID)
|
||||
if serr != nil {
|
||||
return nil, serr
|
||||
}
|
||||
for _, svc := range services {
|
||||
if svc != nil && svc.Domain == domain {
|
||||
return svc, nil
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
subdomain, _, found := strings.Cut(domain, ".")
|
||||
if !found || subdomain == "" {
|
||||
return nil, nil //nolint:nilnil // no label to resolve: not an owned endpoint
|
||||
}
|
||||
return nil, nil //nolint:nilnil // optional lookup: no account owns the domain
|
||||
}
|
||||
|
||||
// clusterFromDomain returns the cluster portion of an endpoint domain (every
|
||||
// label after the first).
|
||||
func clusterFromDomain(domain string) string {
|
||||
if i := strings.IndexByte(domain, '.'); i >= 0 {
|
||||
return domain[i+1:]
|
||||
settings, err := s.GetAgentNetworkSettingsBySubdomain(ctx, store.LockingStrengthNone, subdomain)
|
||||
if err != nil {
|
||||
var sErr *status.Error
|
||||
if errors.As(err, &sErr) && sErr.Type() == status.NotFound {
|
||||
return nil, nil //nolint:nilnil // no account owns the label
|
||||
}
|
||||
// A real store failure must surface: the caller treats nil as "not an
|
||||
// agent-network endpoint" and would silently mask a database error.
|
||||
return nil, fmt.Errorf("get agent network settings by subdomain: %w", err)
|
||||
}
|
||||
return ""
|
||||
|
||||
// The label is unique but the parent is not implied by it: a row owning
|
||||
// "brave-otter" does not own "brave-otter.some-other.zone".
|
||||
if settings.Endpoint() != domain {
|
||||
return nil, nil //nolint:nilnil // label matched a different endpoint
|
||||
}
|
||||
|
||||
services, err := SynthesizeServices(ctx, s, settings.AccountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, svc := range services {
|
||||
if svc != nil && svc.Domain == domain {
|
||||
return svc, nil
|
||||
}
|
||||
}
|
||||
return nil, nil //nolint:nilnil // owner found but it emits no service
|
||||
}
|
||||
|
||||
// SynthesizeServices builds the in-memory reverse-proxy service that
|
||||
@@ -934,6 +935,12 @@ func buildAccountService(
|
||||
middlewares []rpservice.MiddlewareConfig,
|
||||
sessionPriv, sessionPub string,
|
||||
) *rpservice.Service {
|
||||
// The proxy that serves this tenant — a dedicated proxy when one has been
|
||||
// assigned, else the shared cluster. This is the value mesh-DNS peer
|
||||
// selection and the connect-snapshot filter both join on.
|
||||
servingProxy := settings.ServingProxy()
|
||||
// The shared cluster address remains the placeholder target's ID; only the
|
||||
// advertised proxy address follows ServingProxy().
|
||||
cluster := settings.Cluster
|
||||
domain := settings.Endpoint()
|
||||
serviceID := SynthesizedServiceIDPrefix + accountID
|
||||
@@ -943,7 +950,8 @@ func buildAccountService(
|
||||
AccountID: accountID,
|
||||
Name: "agent-network-" + accountID,
|
||||
Domain: domain,
|
||||
ProxyCluster: cluster,
|
||||
ProxyCluster: servingProxy,
|
||||
DNSZone: settings.Zone, // empty for legacy rows → unchanged behavior
|
||||
Mode: rpservice.ModeHTTP,
|
||||
Enabled: true,
|
||||
Private: true,
|
||||
|
||||
@@ -1246,3 +1246,100 @@ func TestSynthesizeServices_EmptyAPIKey_FailsClosed(t *testing.T) {
|
||||
require.Error(t, err, "synthesis must refuse a provider with no api key")
|
||||
assert.Contains(t, err.Error(), "no api key", "error must surface the missing credential")
|
||||
}
|
||||
|
||||
// TestBuildAccountService_ProxyClusterFollowsServingProxyAddress — the whole
|
||||
// point of the column: the synthesized service must advertise the private
|
||||
// proxy's address, because that value is what mesh-DNS peer selection and the
|
||||
// connect-snapshot filter both join on. TargetId must NOT move with it — it
|
||||
// identifies the placeholder target the router rewrites per request, and only
|
||||
// the advertised proxy address follows ServingProxy().
|
||||
func TestBuildAccountService_ProxyClusterFollowsServingProxyAddress(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
mockStore := store.NewMockStore(ctrl)
|
||||
|
||||
settings := &types.Settings{
|
||||
AccountID: testAccountID,
|
||||
Cluster: testCluster,
|
||||
Zone: "gateway.netbird.ai",
|
||||
Subdomain: "brave-otter",
|
||||
ServingProxyAddress: "brave-otter.gateway.netbird.ai",
|
||||
}
|
||||
provider := newSynthTestProvider()
|
||||
policy := newSynthTestPolicy(provider.ID, "grp-eng", "")
|
||||
|
||||
expectSynthBaseInputs(mockStore, ctx, settings,
|
||||
[]*types.Provider{provider},
|
||||
[]*types.Policy{policy},
|
||||
[]*types.Guardrail{})
|
||||
|
||||
services, err := SynthesizeServices(ctx, mockStore, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, services, 1)
|
||||
|
||||
svc := services[0]
|
||||
assert.Equal(t, "brave-otter.gateway.netbird.ai", svc.ProxyCluster,
|
||||
"ProxyCluster must advertise the private proxy's address once ServingProxyAddress is set")
|
||||
require.Len(t, svc.Targets, 1)
|
||||
assert.Equal(t, testCluster, svc.Targets[0].TargetId,
|
||||
"TargetId is the noop placeholder target and must stay pinned to the shared cluster, not the serving proxy")
|
||||
}
|
||||
|
||||
// TestSynthesizeServicesForCluster_ExcludesPrivatelyServedTenant — a tenant
|
||||
// moved to a private proxy must drop out of the SHARED proxy's connect
|
||||
// snapshot, or both proxies would serve it. The existing
|
||||
// `svc.ProxyCluster == clusterAddr` filter does this for free once ProxyCluster
|
||||
// is the tenant hostname; this test proves the handoff rather than assuming it.
|
||||
func TestSynthesizeServicesForCluster_ExcludesPrivatelyServedTenant(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
provider := newSynthTestProvider()
|
||||
policy := newSynthTestPolicy(provider.ID, "grp-eng", "")
|
||||
|
||||
privatelyServed := &types.Settings{
|
||||
AccountID: testAccountID,
|
||||
Cluster: testCluster,
|
||||
Subdomain: testSubdomain,
|
||||
ServingProxyAddress: "brave-otter.gateway.netbird.ai",
|
||||
}
|
||||
|
||||
t.Run("privately served tenant is excluded from the shared cluster snapshot", func(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
mockStore := store.NewMockStore(ctrl)
|
||||
|
||||
mockStore.EXPECT().
|
||||
GetAgentNetworkSettingsByCluster(ctx, store.LockingStrengthNone, testCluster).
|
||||
Return([]*types.Settings{privatelyServed}, nil)
|
||||
expectSynthBaseInputs(mockStore, ctx, privatelyServed,
|
||||
[]*types.Provider{provider}, []*types.Policy{policy}, []*types.Guardrail{})
|
||||
|
||||
services, err := SynthesizeServicesForCluster(ctx, mockStore, testCluster)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, services, "a tenant served by a private proxy must not appear in the shared cluster's snapshot")
|
||||
})
|
||||
|
||||
t.Run("clearing ServingProxyAddress makes the tenant reappear", func(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
mockStore := store.NewMockStore(ctrl)
|
||||
|
||||
sharedAgain := &types.Settings{
|
||||
AccountID: testAccountID,
|
||||
Cluster: testCluster,
|
||||
Subdomain: testSubdomain,
|
||||
}
|
||||
|
||||
mockStore.EXPECT().
|
||||
GetAgentNetworkSettingsByCluster(ctx, store.LockingStrengthNone, testCluster).
|
||||
Return([]*types.Settings{sharedAgain}, nil)
|
||||
expectSynthBaseInputs(mockStore, ctx, sharedAgain,
|
||||
[]*types.Provider{provider}, []*types.Policy{policy}, []*types.Guardrail{})
|
||||
|
||||
services, err := SynthesizeServicesForCluster(ctx, mockStore, testCluster)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, services, 1, "clearing ServingProxyAddress must return the tenant to the shared cluster's snapshot")
|
||||
assert.Equal(t, testCluster, services[0].ProxyCluster)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -11,13 +11,37 @@ import (
|
||||
// the long-term aggregate and are retained independently.
|
||||
const DefaultAccessLogRetentionDays = 30
|
||||
|
||||
// Settings is the per-account agent-network configuration row. One
|
||||
// row per account. Cluster + Subdomain are immutable once written and
|
||||
// produce the public endpoint agents call (`<subdomain>.<cluster>`).
|
||||
// Settings is the per-account agent-network configuration row. One row per
|
||||
// account. The public endpoint agents call is `<subdomain>.<zone>` when a
|
||||
// zone is set, else `<subdomain>.<cluster>`. Cluster, Subdomain and Zone are
|
||||
// immutable once written; ServingProxyAddress is the one mutable column,
|
||||
// naming which proxy currently serves the account.
|
||||
type Settings struct {
|
||||
AccountID string `gorm:"primaryKey"`
|
||||
Cluster string
|
||||
Subdomain string `gorm:"index:idx_agent_network_settings_cluster_subdomain"`
|
||||
// Zone is the placement-independent parent zone the endpoint lives under,
|
||||
// captured from server config when the row is allocated. Immutable, like
|
||||
// Cluster and Subdomain.
|
||||
//
|
||||
// Empty means "legacy": the endpoint falls back to <subdomain>.<cluster>,
|
||||
// which embeds the serving proxy. Existing rows and any deployment that
|
||||
// configures no zone keep that behaviour unchanged.
|
||||
Zone string
|
||||
|
||||
// ServingProxyAddress is the address of the proxy currently serving this
|
||||
// account's gateway. Empty means the account is served by the shared proxy
|
||||
// at Cluster; set means a dedicated proxy serves it, and the value is that
|
||||
// proxy's address — for a per-account proxy, the account's own gateway
|
||||
// hostname.
|
||||
//
|
||||
// This is the only mutable column on this row. Cluster, Subdomain and Zone
|
||||
// are fixed once written, but moving an account onto a dedicated proxy — and
|
||||
// moving it back — is exactly one write here. Nothing in this repository
|
||||
// writes it: it is set by whatever external process assigns dedicated
|
||||
// proxies, and its zero value preserves existing behaviour for every current
|
||||
// row and every deployment that assigns none.
|
||||
ServingProxyAddress string
|
||||
|
||||
// Account-level collection controls sourced by the synthesizer.
|
||||
// EnableLogCollection gates the per-request access-log trail and defaults
|
||||
@@ -42,12 +66,31 @@ type Settings struct {
|
||||
// schema cohesive.
|
||||
func (Settings) TableName() string { return "agent_network_settings" }
|
||||
|
||||
// Endpoint returns the bare hostname agents reach this account at:
|
||||
// `<subdomain>.<cluster>`.
|
||||
// Endpoint returns the bare hostname agents reach this account at.
|
||||
//
|
||||
// With a Zone set this is `<subdomain>.<zone>` — deliberately independent of
|
||||
// which proxy serves the account, so moving between a shared and a private
|
||||
// proxy (or between clusters) is a DNS change only and never alters the
|
||||
// tenant's address. With no Zone it falls back to the legacy
|
||||
// `<subdomain>.<cluster>` form.
|
||||
func (s *Settings) Endpoint() string {
|
||||
if s.Zone != "" {
|
||||
return s.Subdomain + "." + s.Zone
|
||||
}
|
||||
return s.Subdomain + "." + s.Cluster
|
||||
}
|
||||
|
||||
// ServingProxy returns the address of the proxy that serves this account's
|
||||
// gateway: the dedicated proxy when one has been assigned, otherwise the shared
|
||||
// cluster. This is the value the synthesized service advertises as
|
||||
// ProxyCluster, which is what mesh-DNS peer selection joins on.
|
||||
func (s *Settings) ServingProxy() string {
|
||||
if s.ServingProxyAddress != "" {
|
||||
return s.ServingProxyAddress
|
||||
}
|
||||
return s.Cluster
|
||||
}
|
||||
|
||||
// ToAPIResponse renders the settings as the API representation.
|
||||
func (s *Settings) ToAPIResponse() *api.AgentNetworkSettings {
|
||||
created := s.CreatedAt
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestEndpoint_PrefersZoneOverCluster locks the decoupling: when a Zone is set
|
||||
// the hostname must NOT embed the serving cluster, so moving a tenant between
|
||||
// proxies never changes their address.
|
||||
func TestEndpoint_PrefersZoneOverCluster(t *testing.T) {
|
||||
s := &Settings{Subdomain: "brave-otter", Cluster: "eu.proxy.netbird.io", Zone: "gateway.netbird.ai"}
|
||||
assert.Equal(t, "brave-otter.gateway.netbird.ai", s.Endpoint())
|
||||
}
|
||||
|
||||
// TestEndpoint_FallsBackToClusterWhenZoneEmpty is the compatibility guarantee:
|
||||
// existing rows (and every self-hosted deployment, which sets no zone) keep
|
||||
// exactly the address they have today.
|
||||
func TestEndpoint_FallsBackToClusterWhenZoneEmpty(t *testing.T) {
|
||||
s := &Settings{Subdomain: "otter", Cluster: "eu.proxy.netbird.io"}
|
||||
assert.Equal(t, "otter.eu.proxy.netbird.io", s.Endpoint())
|
||||
}
|
||||
|
||||
// TestToAPIResponse_ExposesZoneAndDerivedEndpoint — the dashboard renders
|
||||
// Endpoint verbatim, so it must reflect the zone.
|
||||
func TestToAPIResponse_ExposesZoneAndDerivedEndpoint(t *testing.T) {
|
||||
s := &Settings{Subdomain: "brave-otter", Cluster: "eu.proxy.netbird.io", Zone: "gateway.netbird.ai"}
|
||||
resp := s.ToAPIResponse()
|
||||
assert.Equal(t, "brave-otter.gateway.netbird.ai", resp.Endpoint)
|
||||
}
|
||||
|
||||
// TestServingProxy_PrefersColumnOverCluster — a provisioned tenant is served by
|
||||
// its own proxy, whose address is its hostname, not the shared cluster.
|
||||
func TestServingProxy_PrefersColumnOverCluster(t *testing.T) {
|
||||
s := &Settings{Cluster: "eu.proxy.netbird.io", ServingProxyAddress: "brave-otter.gateway.netbird.ai"}
|
||||
assert.Equal(t, "brave-otter.gateway.netbird.ai", s.ServingProxy())
|
||||
}
|
||||
|
||||
// TestServingProxy_FallsBackToCluster is the compatibility guarantee: every
|
||||
// existing row, and every self-hosted deployment, is served by the shared proxy.
|
||||
func TestServingProxy_FallsBackToCluster(t *testing.T) {
|
||||
s := &Settings{Cluster: "eu.proxy.netbird.io"}
|
||||
assert.Equal(t, "eu.proxy.netbird.io", s.ServingProxy())
|
||||
}
|
||||
@@ -255,6 +255,13 @@ type Service struct {
|
||||
Private bool
|
||||
// AccessGroups is the group ID allowlist for inbound peers on private services. Mutually exclusive with bearer SSO.
|
||||
AccessGroups []string `json:"access_groups,omitempty" gorm:"serializer:json"`
|
||||
// DNSZone is the parent zone a private service's synthesized mesh A record
|
||||
// hangs under, for the case where that zone cannot be derived from
|
||||
// ProxyCluster or a validated custom domain — i.e. placement-free
|
||||
// agent-network endpoints, which are <subdomain>.<zone>. In-memory only:
|
||||
// set by the agent-network synthesizer on services it builds per read,
|
||||
// never stored and never exposed on the API or the proxy wire.
|
||||
DNSZone string `gorm:"-" json:"-"`
|
||||
}
|
||||
|
||||
// InitNewRecord generates a new unique ID and resets metadata for a newly created
|
||||
@@ -1412,6 +1419,7 @@ func (s *Service) Copy() *Service {
|
||||
PortAutoAssigned: s.PortAutoAssigned,
|
||||
Private: s.Private,
|
||||
AccessGroups: accessGroups,
|
||||
DNSZone: s.DNSZone,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1215,6 +1215,17 @@ func TestService_Copy_RoundtripsPrivate(t *testing.T) {
|
||||
assert.Equal(t, []string{"grp-admins", "grp-ops"}, svc.AccessGroups)
|
||||
}
|
||||
|
||||
// TestServiceCopy_PreservesDNSZone — DNSZone is in-memory only, so it is easy
|
||||
// to omit from Copy()'s explicit field list; if it is dropped, a copied
|
||||
// account silently loses its zone apex and the tenant's endpoint resolves to
|
||||
// nothing.
|
||||
func TestServiceCopy_PreservesDNSZone(t *testing.T) {
|
||||
svc := &Service{Domain: "brave-otter.gateway.netbird.ai", DNSZone: "gateway.netbird.ai"}
|
||||
cp := svc.Copy()
|
||||
require.NotNil(t, cp)
|
||||
assert.Equal(t, "gateway.netbird.ai", cp.DNSZone)
|
||||
}
|
||||
|
||||
func TestService_APIRoundtrip_Private(t *testing.T) {
|
||||
enabled := true
|
||||
private := true
|
||||
|
||||
@@ -204,6 +204,15 @@ type AgentNetwork struct {
|
||||
// prefill with). An explicitly configured path that fails to load
|
||||
// fails startup; runtime reload errors keep the previous table.
|
||||
PricingDefaultsFile string
|
||||
|
||||
// Zone is the parent DNS zone that Agent Network gateway endpoints are
|
||||
// allocated under, producing <subdomain>.<zone>.
|
||||
//
|
||||
// Empty (the default) preserves the legacy behaviour of deriving the
|
||||
// endpoint from the serving cluster, so self-hosted deployments are
|
||||
// unaffected. It is captured onto each settings row when that row is
|
||||
// created; changing it later does not move existing tenants.
|
||||
Zone string
|
||||
}
|
||||
|
||||
// ReverseProxy contains reverse proxy configuration in front of management.
|
||||
|
||||
@@ -202,6 +202,7 @@ func (s *BaseServer) AgentNetworkManager() agentnetwork.Manager {
|
||||
s.PermissionsManager(),
|
||||
s.AccountManager(),
|
||||
s.ServiceProxyController(),
|
||||
s.Config.AgentNetwork.Zone,
|
||||
)
|
||||
// Sweep expired agent-network access logs per account retention,
|
||||
// reusing the reverse-proxy cleanup interval config.
|
||||
|
||||
@@ -30,7 +30,7 @@ func TestAgentNetwork_BudgetRuleCRUD_RealManager(t *testing.T) {
|
||||
account := newAccountWithId(ctx, accountID, adminUserID, "agent-net.test", "", "", false)
|
||||
require.NoError(t, am.Store.SaveAccount(ctx, account), "SaveAccount must succeed")
|
||||
|
||||
mgr := agentnetwork.NewManager(am.Store, permissions.NewManager(am.Store), am, nil)
|
||||
mgr := agentnetwork.NewManager(am.Store, permissions.NewManager(am.Store), am, nil, "")
|
||||
|
||||
created, err := mgr.CreateBudgetRule(ctx, adminUserID, &agenttypes.AccountBudgetRule{
|
||||
AccountID: accountID,
|
||||
@@ -82,7 +82,7 @@ func TestAgentNetwork_UpdateSettings_PreservesImmutableAndTogglesCollection(t *t
|
||||
account := newAccountWithId(ctx, accountID, adminUserID, "agent-net.test", "", "", false)
|
||||
require.NoError(t, am.Store.SaveAccount(ctx, account), "SaveAccount must succeed")
|
||||
|
||||
mgr := agentnetwork.NewManager(am.Store, permissions.NewManager(am.Store), am, nil)
|
||||
mgr := agentnetwork.NewManager(am.Store, permissions.NewManager(am.Store), am, nil, "")
|
||||
|
||||
// Creating a provider bootstraps the settings row (cluster + subdomain).
|
||||
_, err = mgr.CreateProvider(ctx, adminUserID, &agenttypes.Provider{
|
||||
|
||||
@@ -90,7 +90,7 @@ func TestAgentNetwork_ProviderCRUD_FansOutToProxyAndClientPeers(t *testing.T) {
|
||||
// Real agentnetwork manager wired to the real account manager. proxyController
|
||||
// is nil (no gRPC cluster fan-out here) — the reconcile still fires
|
||||
// UpdateAccountPeers, which is the path under test.
|
||||
agentMgr := agentnetwork.NewManager(am.Store, permissions.NewManager(am.Store), am, nil)
|
||||
agentMgr := agentnetwork.NewManager(am.Store, permissions.NewManager(am.Store), am, nil, "")
|
||||
|
||||
provider, err := agentMgr.CreateProvider(ctx, adminUserID, &agenttypes.Provider{
|
||||
AccountID: accountID,
|
||||
|
||||
@@ -334,6 +334,30 @@ func (s *SqlStore) GetAgentNetworkSettingsByCluster(ctx context.Context, lockStr
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// GetAgentNetworkSettingsBySubdomain returns the settings row that owns the
|
||||
// given subdomain label. The label is globally unique (enforced by
|
||||
// idx_agent_network_settings_subdomain_unique), so at most one row can match,
|
||||
// which makes this an indexed point lookup rather than a scan.
|
||||
func (s *SqlStore) GetAgentNetworkSettingsBySubdomain(ctx context.Context, lockStrength LockingStrength, subdomain string) (*agentNetworkTypes.Settings, error) {
|
||||
tx := s.db
|
||||
if lockStrength != LockingStrengthNone {
|
||||
tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)})
|
||||
}
|
||||
|
||||
var settings agentNetworkTypes.Settings
|
||||
result := tx.Take(&settings, "subdomain = ?", subdomain)
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, status.Errorf(status.NotFound, "agent network settings for subdomain %s not found", subdomain)
|
||||
}
|
||||
|
||||
log.WithContext(ctx).Errorf("failed to get agent network settings by subdomain from store: %v", result.Error)
|
||||
return nil, status.Errorf(status.Internal, "failed to get agent network settings by subdomain from store")
|
||||
}
|
||||
|
||||
return &settings, nil
|
||||
}
|
||||
|
||||
// SaveAgentNetworkSettings upserts the per-account Agent Network
|
||||
// settings row.
|
||||
func (s *SqlStore) SaveAgentNetworkSettings(ctx context.Context, settings *agentNetworkTypes.Settings) error {
|
||||
@@ -346,6 +370,39 @@ func (s *SqlStore) SaveAgentNetworkSettings(ctx context.Context, settings *agent
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateAgentNetworkSettings inserts a new settings row.
|
||||
//
|
||||
// Unlike SaveAgentNetworkSettings (an upsert) this is a plain INSERT, and it
|
||||
// returns the driver error unwrapped. Both properties are required by the
|
||||
// subdomain allocator: it relies on the unique index rejecting a duplicate
|
||||
// label, and on being able to recognise that rejection so it can retry with a
|
||||
// fresh label instead of surfacing an error.
|
||||
func (s *SqlStore) CreateAgentNetworkSettings(ctx context.Context, settings *agentNetworkTypes.Settings) error {
|
||||
if err := s.db.Create(settings).Error; err != nil {
|
||||
log.WithContext(ctx).Debugf("failed to create agent network settings: %v", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetAgentNetworkServingProxyAddress points the account's gateway at a specific
|
||||
// serving proxy, or clears it (address == "") to return the account to the
|
||||
// shared proxy. Scoped to the one column on purpose: this runs concurrently
|
||||
// with unrelated settings updates, and a full-row upsert would clobber them.
|
||||
func (s *SqlStore) SetAgentNetworkServingProxyAddress(ctx context.Context, accountID, address string) error {
|
||||
result := s.db.Model(&agentNetworkTypes.Settings{}).
|
||||
Where("account_id = ?", accountID).
|
||||
Update("serving_proxy_address", address)
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to set agent network serving proxy address: %v", result.Error)
|
||||
return status.Errorf(status.Internal, "failed to set agent network serving proxy address")
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return status.Errorf(status.NotFound, "agent network settings for account %s not found", accountID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IncrementAgentNetworkConsumption atomically upserts the consumption
|
||||
// row keyed on (account, dim_kind, dim_id, window_seconds, window_start)
|
||||
// and adds the supplied deltas. Concurrent calls from multiple proxy
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
)
|
||||
|
||||
// TestAgentNetworkSettings_SubdomainIsGloballyUnique is the guard for the whole
|
||||
// allocation scheme: the label is now globally unique rather than per-cluster,
|
||||
// and the allocator depends on the DATABASE saying no. Two different accounts on
|
||||
// two different clusters must not be able to hold the same subdomain.
|
||||
func TestAgentNetworkSettings_SubdomainIsGloballyUnique(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, cleanup, err := NewTestStoreFromSQL(ctx, "", t.TempDir())
|
||||
require.NoError(t, err, "real sqlite test store must come up")
|
||||
defer cleanup()
|
||||
|
||||
first := &agentNetworkTypes.Settings{
|
||||
AccountID: "acc-unique-1",
|
||||
Cluster: "eu.proxy.example",
|
||||
Subdomain: "brave-otter",
|
||||
Zone: "gateway.example",
|
||||
}
|
||||
require.NoError(t, s.CreateAgentNetworkSettings(ctx, first), "first insert must succeed")
|
||||
|
||||
// Deliberately a different account AND a different cluster: under the old
|
||||
// per-cluster scheme this was legal, and it is exactly what must now fail.
|
||||
second := &agentNetworkTypes.Settings{
|
||||
AccountID: "acc-unique-2",
|
||||
Cluster: "us.proxy.example",
|
||||
Subdomain: "brave-otter",
|
||||
Zone: "gateway.example",
|
||||
}
|
||||
err = s.CreateAgentNetworkSettings(ctx, second)
|
||||
require.Error(t, err, "duplicate subdomain must be rejected by the unique index")
|
||||
|
||||
// The allocator recognises conflicts by matching the driver's message, so an
|
||||
// error that does not carry a unique-violation signature is useless to it
|
||||
// even though it is non-nil. These are the three signatures management's
|
||||
// isUniqueConstraintError matches (postgres / mysql / sqlite).
|
||||
msg := err.Error()
|
||||
assert.True(t,
|
||||
strings.Contains(msg, "(SQLSTATE 23505)") ||
|
||||
strings.Contains(msg, "Error 1062 (23000)") ||
|
||||
strings.Contains(msg, "UNIQUE constraint failed"),
|
||||
"error must be the raw driver error, recognisable as a unique violation; got %q", msg)
|
||||
}
|
||||
|
||||
// TestAgentNetworkSettings_CreateThenReadBack keeps CreateAgentNetworkSettings
|
||||
// honest as an insert path: the row it writes must be fully readable, including
|
||||
// the new Zone column.
|
||||
func TestAgentNetworkSettings_CreateThenReadBack(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, cleanup, err := NewTestStoreFromSQL(ctx, "", t.TempDir())
|
||||
require.NoError(t, err, "real sqlite test store must come up")
|
||||
defer cleanup()
|
||||
|
||||
want := &agentNetworkTypes.Settings{
|
||||
AccountID: "acc-readback-1",
|
||||
Cluster: "eu.proxy.example",
|
||||
Subdomain: "swift-heron",
|
||||
Zone: "gateway.example",
|
||||
}
|
||||
require.NoError(t, s.CreateAgentNetworkSettings(ctx, want))
|
||||
|
||||
got, err := s.GetAgentNetworkSettings(ctx, LockingStrengthNone, "acc-readback-1")
|
||||
require.NoError(t, err, "the inserted row must be readable")
|
||||
assert.Equal(t, "swift-heron", got.Subdomain)
|
||||
assert.Equal(t, "gateway.example", got.Zone, "the Zone column must round-trip")
|
||||
assert.Equal(t, "swift-heron.gateway.example", got.Endpoint(), "endpoint derives from zone")
|
||||
}
|
||||
@@ -361,7 +361,10 @@ type Store interface {
|
||||
GetAgentNetworkSettings(ctx context.Context, lockStrength LockingStrength, accountID string) (*agentNetworkTypes.Settings, error)
|
||||
GetAllAgentNetworkSettings(ctx context.Context, lockStrength LockingStrength) ([]*agentNetworkTypes.Settings, error)
|
||||
GetAgentNetworkSettingsByCluster(ctx context.Context, lockStrength LockingStrength, cluster string) ([]*agentNetworkTypes.Settings, error)
|
||||
GetAgentNetworkSettingsBySubdomain(ctx context.Context, lockStrength LockingStrength, subdomain string) (*agentNetworkTypes.Settings, error)
|
||||
SaveAgentNetworkSettings(ctx context.Context, settings *agentNetworkTypes.Settings) error
|
||||
CreateAgentNetworkSettings(ctx context.Context, settings *agentNetworkTypes.Settings) error
|
||||
SetAgentNetworkServingProxyAddress(ctx context.Context, accountID, address string) error
|
||||
IncrementAgentNetworkConsumption(ctx context.Context, accountID string, kind agentNetworkTypes.ConsumptionDimension, dimID string, windowSeconds int64, windowStart time.Time, tokensIn, tokensOut int64, costUSD float64) error
|
||||
IncrementAgentNetworkConsumptionBatch(ctx context.Context, accountID string, keys []agentNetworkTypes.ConsumptionKey, tokensIn, tokensOut int64, costUSD float64) error
|
||||
GetAgentNetworkConsumption(ctx context.Context, lockStrength LockingStrength, accountID string, kind agentNetworkTypes.ConsumptionDimension, dimID string, windowSeconds int64, windowStart time.Time) (*agentNetworkTypes.Consumption, error)
|
||||
@@ -658,6 +661,28 @@ func getMigrationsPostAuto(ctx context.Context) []migrationFunc {
|
||||
func(db *gorm.DB) error {
|
||||
return migration.FoldCostAggregatesIntoBuckets[agentNetworkTypes.AgentNetworkUsage](ctx, db)
|
||||
},
|
||||
func(db *gorm.DB) error {
|
||||
// Enforce globally-unique agent-network subdomains.
|
||||
//
|
||||
// Uniqueness used to be per-cluster and advisory (a pre-read
|
||||
// "taken" set with no DB constraint). Once the endpoint hangs off a
|
||||
// shared zone the label must be unique across that whole zone, and
|
||||
// the allocator depends on the database rejecting duplicates so it
|
||||
// can retry with a fresh label.
|
||||
//
|
||||
// The pre-existing idx_agent_network_settings_cluster_subdomain is
|
||||
// left in place: it is non-unique and indexes subdomain alone
|
||||
// (Cluster carries no tag), so it neither conflicts nor suffices.
|
||||
// It must also stay for a second, load-bearing reason on mysql:
|
||||
// its gorm:"index:" tag on the Subdomain field is what makes gorm
|
||||
// size that column as varchar(191) instead of longtext. mysql
|
||||
// cannot put a longtext column in a unique index at all, so
|
||||
// dropping this "redundant" index as unneeded would silently
|
||||
// break the migration above on that dialect.
|
||||
return migration.CreateIndexIfNotExists[agentNetworkTypes.Settings](
|
||||
ctx, db, "idx_agent_network_settings_subdomain_unique", "subdomain",
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -268,6 +268,20 @@ func (mr *MockStoreMockRecorder) CreateAgentNetworkAccessLog(ctx, entry, groups
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAgentNetworkAccessLog", reflect.TypeOf((*MockStore)(nil).CreateAgentNetworkAccessLog), ctx, entry, groups)
|
||||
}
|
||||
|
||||
// CreateAgentNetworkSettings mocks base method.
|
||||
func (m *MockStore) CreateAgentNetworkSettings(ctx context.Context, settings *types.Settings) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "CreateAgentNetworkSettings", ctx, settings)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// CreateAgentNetworkSettings indicates an expected call of CreateAgentNetworkSettings.
|
||||
func (mr *MockStoreMockRecorder) CreateAgentNetworkSettings(ctx, settings interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAgentNetworkSettings", reflect.TypeOf((*MockStore)(nil).CreateAgentNetworkSettings), ctx, settings)
|
||||
}
|
||||
|
||||
// CreateAgentNetworkUsage mocks base method.
|
||||
func (m *MockStore) CreateAgentNetworkUsage(ctx context.Context, usage *types.AgentNetworkUsage, groups []types.AgentNetworkUsageGroup) error {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -1702,6 +1716,21 @@ func (mr *MockStoreMockRecorder) GetAgentNetworkSettingsByCluster(ctx, lockStren
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkSettingsByCluster", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkSettingsByCluster), ctx, lockStrength, cluster)
|
||||
}
|
||||
|
||||
// GetAgentNetworkSettingsBySubdomain mocks base method.
|
||||
func (m *MockStore) GetAgentNetworkSettingsBySubdomain(ctx context.Context, lockStrength LockingStrength, subdomain string) (*types.Settings, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetAgentNetworkSettingsBySubdomain", ctx, lockStrength, subdomain)
|
||||
ret0, _ := ret[0].(*types.Settings)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetAgentNetworkSettingsBySubdomain indicates an expected call of GetAgentNetworkSettingsBySubdomain.
|
||||
func (mr *MockStoreMockRecorder) GetAgentNetworkSettingsBySubdomain(ctx, lockStrength, subdomain interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkSettingsBySubdomain", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkSettingsBySubdomain), ctx, lockStrength, subdomain)
|
||||
}
|
||||
|
||||
// GetAgentNetworkUsageRows mocks base method.
|
||||
func (m *MockStore) GetAgentNetworkUsageRows(ctx context.Context, lockStrength LockingStrength, accountID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkUsage, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -3637,6 +3666,20 @@ func (mr *MockStoreMockRecorder) SaveUsers(ctx, users interface{}) *gomock.Call
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveUsers", reflect.TypeOf((*MockStore)(nil).SaveUsers), ctx, users)
|
||||
}
|
||||
|
||||
// SetAgentNetworkServingProxyAddress mocks base method.
|
||||
func (m *MockStore) SetAgentNetworkServingProxyAddress(ctx context.Context, accountID, address string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "SetAgentNetworkServingProxyAddress", ctx, accountID, address)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// SetAgentNetworkServingProxyAddress indicates an expected call of SetAgentNetworkServingProxyAddress.
|
||||
func (mr *MockStoreMockRecorder) SetAgentNetworkServingProxyAddress(ctx, accountID, address interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetAgentNetworkServingProxyAddress", reflect.TypeOf((*MockStore)(nil).SetAgentNetworkServingProxyAddress), ctx, accountID, address)
|
||||
}
|
||||
|
||||
// SetFieldEncrypt mocks base method.
|
||||
func (m *MockStore) SetFieldEncrypt(enc *crypt.FieldEncrypt) {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
@@ -254,6 +254,7 @@ func (a *Account) SynthesizePrivateServiceZones(peerID string) []nbdns.CustomZon
|
||||
|
||||
peerGroups := a.GetPeerGroups(peerID)
|
||||
zonesByApex := map[string]*nbdns.CustomZone{}
|
||||
var skippedNoZoneApex []string
|
||||
|
||||
for _, svc := range a.Services {
|
||||
if svc == nil || !svc.Enabled || !svc.Private {
|
||||
@@ -272,6 +273,15 @@ func (a *Account) SynthesizePrivateServiceZones(peerID string) []nbdns.CustomZon
|
||||
|
||||
serviceDomainZone := a.privateServiceDomainZone(svc)
|
||||
if serviceDomainZone == "" {
|
||||
// This service passed every gate above (enabled, private,
|
||||
// AccessGroups, connected proxy peers) and would otherwise have
|
||||
// emitted a record, but its domain matches neither its DNSZone,
|
||||
// its ProxyCluster, nor any validated custom-domain row. Collected
|
||||
// rather than logged here — this runs per peer x per service, and
|
||||
// logging inline here would reintroduce the per-peer noise the
|
||||
// "0 zones" diagnostic below deliberately avoids.
|
||||
skippedNoZoneApex = append(skippedNoZoneApex,
|
||||
fmt.Sprintf("%s(domain=%s cluster=%s dns_zone=%q)", svc.ID, svc.Domain, svc.ProxyCluster, svc.DNSZone))
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -325,6 +335,10 @@ func (a *Account) SynthesizePrivateServiceZones(peerID string) []nbdns.CustomZon
|
||||
svc.ID, svc.Domain, svc.ProxyCluster, len(proxyPeers), skippedDisconnected)
|
||||
}
|
||||
}
|
||||
if len(skippedNoZoneApex) > 0 {
|
||||
log.Debugf("private-zone synth: peer %s account %s skipped %d service(s) with no matching zone apex: %s",
|
||||
peerID, a.Id, len(skippedNoZoneApex), strings.Join(skippedNoZoneApex, ", "))
|
||||
}
|
||||
|
||||
out := make([]nbdns.CustomZone, 0, len(zonesByApex))
|
||||
for _, zone := range zonesByApex {
|
||||
@@ -344,8 +358,19 @@ func (a *Account) SynthesizePrivateServiceZones(peerID string) []nbdns.CustomZon
|
||||
}
|
||||
|
||||
// privateServiceDomainZone returns the DNS zone name for the given private service domain by
|
||||
// looking at the proxy cluster domain then the custom domains.
|
||||
// checking its DNSZone, then the proxy cluster domain, then the custom domains.
|
||||
func (a *Account) privateServiceDomainZone(svc *service.Service) string {
|
||||
// Placement-free endpoints (<subdomain>.<zone>) carry their zone
|
||||
// explicitly: it is server config, so it matches neither the serving
|
||||
// proxy's address nor any per-account custom-domain row. Checked first so
|
||||
// the apex stays the zone even once ProxyCluster becomes the tenant
|
||||
// hostname itself (which happens when a dedicated per-account proxy serves
|
||||
// it), which would otherwise make the apex the full hostname and churn the
|
||||
// client's zone set when a tenant moves between proxies.
|
||||
if svc.DNSZone != "" && domainFromSuffix(svc.Domain, svc.DNSZone) {
|
||||
return svc.DNSZone
|
||||
}
|
||||
|
||||
if domainFromSuffix(svc.Domain, svc.ProxyCluster) {
|
||||
return svc.ProxyCluster
|
||||
}
|
||||
|
||||
@@ -423,6 +423,39 @@ func TestSynthesizePrivateServiceZones_MixedClusterCustomAndPublic(t *testing.T)
|
||||
"only the 4 private custom services surface in the custom zone (public one excluded)")
|
||||
}
|
||||
|
||||
// TestSynthesizePrivateServiceZones_ZoneBasedEndpoint_UsesZoneApex — a
|
||||
// zone-based tenant still served by the SHARED proxy has a hostname whose
|
||||
// parent is the zone, matching neither ProxyCluster nor any validated
|
||||
// custom-domain row. Without DNSZone the apex resolves to "" and the service is
|
||||
// skipped entirely, so the tenant's endpoint resolves to nothing.
|
||||
func TestSynthesizePrivateServiceZones_ZoneBasedEndpoint_UsesZoneApex(t *testing.T) {
|
||||
account := privateZoneTestAccount(t)
|
||||
svc := account.Services[0]
|
||||
svc.Domain = "brave-otter.gateway.netbird.ai"
|
||||
svc.DNSZone = "gateway.netbird.ai"
|
||||
// ProxyCluster stays the shared cluster address — the pre-private cohort.
|
||||
|
||||
zones := account.SynthesizePrivateServiceZones("user-peer")
|
||||
require.Len(t, zones, 1, "a zone-based endpoint must still produce one zone")
|
||||
assert.Equal(t, "gateway.netbird.ai.", zones[0].Domain, "apex must be the placement-free zone, not the cluster")
|
||||
require.Len(t, zones[0].Records, 1)
|
||||
assert.Equal(t, "brave-otter.gateway.netbird.ai.", zones[0].Records[0].Name)
|
||||
assert.Equal(t, "100.64.0.99", zones[0].Records[0].RData, "still points at the serving proxy peer")
|
||||
}
|
||||
|
||||
// TestSynthesizePrivateServiceZones_UnvalidatedDomain_StillSkipped locks the
|
||||
// scope of the fix: a service matching no cluster suffix, no validated custom
|
||||
// domain, AND carrying no DNSZone must keep resolving to nothing. A blanket
|
||||
// "use the parent domain" fallback would hand it mesh DNS and bypass domain
|
||||
// validation.
|
||||
func TestSynthesizePrivateServiceZones_UnvalidatedDomain_StillSkipped(t *testing.T) {
|
||||
account := privateZoneTestAccount(t)
|
||||
account.Services[0].Domain = "api.unvalidated.example.com"
|
||||
|
||||
zones := account.SynthesizePrivateServiceZones("user-peer")
|
||||
assert.Empty(t, zones, "no cluster suffix, no validated Domains row, no DNSZone → no records")
|
||||
}
|
||||
|
||||
// recordNames returns the record names of a zone for order-independent assertions.
|
||||
func recordNames(zone nbdns.CustomZone) []string {
|
||||
names := make([]string, 0, len(zone.Records))
|
||||
|
||||
@@ -53,7 +53,7 @@ func newChainIntegration(t *testing.T) *chainIntegrationFixture {
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(cleanUp)
|
||||
|
||||
manager := agentnetwork.NewManager(st, nil, nil, nil)
|
||||
manager := agentnetwork.NewManager(st, nil, nil, nil, "")
|
||||
|
||||
server := &mgmtgrpc.ProxyServiceServer{}
|
||||
server.SetAgentNetworkLimitsService(manager)
|
||||
|
||||
@@ -102,7 +102,7 @@ func TestReverseProxy_AgentNetworkRequest_FullChain(t *testing.T) {
|
||||
require.NoError(t, err, "real sqlite test store must come up")
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
anMgr := agentnetwork.NewManager(st, nil, nil, nil)
|
||||
anMgr := agentnetwork.NewManager(st, nil, nil, nil, "")
|
||||
server := &mgmtgrpc.ProxyServiceServer{}
|
||||
server.SetAgentNetworkLimitsService(anMgr)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user