Merge branch 'main' into agent-network-validate-proxy-cluster

This commit is contained in:
Maycon Santos
2026-09-12 10:45:11 +02:00
committed by GitHub
506 changed files with 31909 additions and 11042 deletions
+143
View File
@@ -0,0 +1,143 @@
//go:build e2e
package agentnetwork
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/shared/management/http/api"
)
// joinGroup places the PAT's own user into the group so caller-scoped answers
// (GET /api/agent-network/agent-config) see the policies sourced from it, and
// restores the previous auto-groups on cleanup. Self-service updates of one's
// own auto_groups are permitted for every role, so this needs no second user.
func joinGroup(t *testing.T, ctx context.Context, groupID string) {
t.Helper()
me, err := srv.API().Users.Current(ctx)
require.NoError(t, err, "read current user")
before := append([]string(nil), me.AutoGroups...)
_, err = srv.API().Users.Update(ctx, me.Id, api.PutApiUsersUserIdJSONRequestBody{
Role: me.Role,
IsBlocked: me.IsBlocked,
AutoGroups: append(append([]string(nil), before...), groupID),
})
require.NoError(t, err, "add the caller to the policy source group")
t.Cleanup(func() {
_, _ = srv.API().Users.Update(context.Background(), me.Id, api.PutApiUsersUserIdJSONRequestBody{
Role: me.Role,
IsBlocked: me.IsBlocked,
AutoGroups: before,
})
})
}
// configProvider returns the agent-config entry for the named provider, nil
// when the answer does not offer it. The suite shares one account, so other
// tests' fixtures may add unrelated providers to the caller's answer.
func configProvider(cfg api.AgentNetworkAgentConfig, name string) *api.AgentNetworkAgentConfigProvider {
for i := range cfg.Providers {
if cfg.Providers[i].Name == name {
return &cfg.Providers[i]
}
}
return nil
}
// TestAgentConfigAllowlistOfDeclaredModels reproduces the post-#7221 field
// report: a provider carrying a declared model set plus a policy guardrail
// whose allowlist holds those same declared ids must advertise the models on
// GET /api/agent-network/agent-config — the guardrail was built FROM the
// provider's model list (the dashboard's allowlist picker persists the
// declared ids verbatim), so nothing about the setup excludes them.
//
// The plain case passes today. The path-style case (Bedrock; Vertex has the
// same shape) fails: the declared id is compared through the proxy parser's
// canonical form (region prefix and version suffix stripped) while the
// allowlist entry is not, so the raw-vs-raw pair never intersects and the
// caller sees an empty model list. The same one-sided normalization sits in
// policyPermitsModel, so the proxy also denies the model at request time —
// the guardrail meant to allow exactly this model turns it off end to end.
func TestAgentConfigAllowlistOfDeclaredModels(t *testing.T) {
ctx := context.Background()
cases := []struct {
name string
catalogID string
upstream string
declared string
}{
{
name: "plain-declared-id",
catalogID: "openai_api",
upstream: "https://api.openai.com",
declared: "gpt-4o-mini",
},
{
// The operator declares the id AWS issues — region-prefixed
// inference profile with a version suffix — and the allowlist
// picker copies it as-is.
name: "bedrock-declared-id",
catalogID: "bedrock_api",
upstream: "https://bedrock-runtime.eu-central-1.amazonaws.com",
declared: "eu.anthropic.claude-sonnet-4-5-20250929-v1:0",
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-agentcfg-" + tc.name})
require.NoError(t, err, "create source group")
t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) })
joinGroup(t, ctx, grp.Id)
providerName := "e2e-agentcfg-" + tc.name
prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{
Name: providerName,
ProviderId: tc.catalogID,
UpstreamUrl: tc.upstream,
ApiKey: ptr("sk-dummy-e2e-key"),
Enabled: ptr(true),
Models: &[]api.AgentNetworkProviderModel{{Id: tc.declared, InputPer1k: 0.001, OutputPer1k: 0.002}},
})
require.NoError(t, err, "create provider")
t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) })
// Allowlist exactly the declared model, the way the dashboard
// builds a guardrail from the provider's model list.
var gr api.AgentNetworkGuardrailRequest
gr.Name = "e2e-agentcfg-" + tc.name
gr.Checks.ModelAllowlist.Enabled = true
gr.Checks.ModelAllowlist.Models = []string{tc.declared}
guard, err := srv.CreateGuardrail(ctx, gr)
require.NoError(t, err, "create guardrail")
t.Cleanup(func() { _ = srv.DeleteGuardrail(context.Background(), guard.Id) })
pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{
Name: "e2e-agentcfg-" + tc.name,
Enabled: ptr(true),
SourceGroups: []string{grp.Id},
DestinationProviderIds: []string{prov.Id},
GuardrailIds: &[]string{guard.Id},
})
require.NoError(t, err, "create policy")
t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) })
cfg, err := srv.GetAgentConfig(ctx)
require.NoError(t, err, "read the caller-scoped agent config")
require.True(t, cfg.Configured, "the account endpoint is bootstrapped by TestMain")
entry := configProvider(cfg, providerName)
require.NotNil(t, entry, "the policy authorizes the caller for the provider, so it must be offered")
assert.False(t, entry.AllModelsAllowed, "an allowlist guardrail restricts the provider")
assert.Equal(t, []string{tc.declared}, entry.Models,
"the allowlist holds the provider's own declared id, so that model must be advertised")
})
}
}
@@ -0,0 +1,235 @@
//go:build e2e
package agentnetwork
import (
"context"
"net/http"
"os"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/shared/management/client/rest"
"github.com/netbirdio/netbird/shared/management/http/api"
)
// credentialCase is one vendor to try the save-time check against. The key is
// the real one the suite already sources; corrupting it is what produces the
// refusal, so the pair of cases differ only in the credential.
type credentialCase struct {
name string
catalogID string
upstream string
apiKey string
}
// liveCredentialCases mirrors the discovery matrix's env gating so a partial
// key set still yields partial coverage. Vertex is left out: its credential is
// a service-account keyfile, and mangling one produces a client-side parse
// failure rather than the vendor refusal this is about.
func liveCredentialCases() []credentialCase {
var cases []credentialCase
if k := os.Getenv("OPENAI_TOKEN"); k != "" {
cases = append(cases, credentialCase{
name: "openai", catalogID: "openai_api",
upstream: "https://api.openai.com", apiKey: k,
})
}
if k := os.Getenv("ANTHROPIC_TOKEN"); k != "" {
cases = append(cases, credentialCase{
name: "anthropic", catalogID: "anthropic_api",
upstream: "https://api.anthropic.com", apiKey: k,
})
}
if k := os.Getenv("AWS_BEARER_TOKEN_BEDROCK"); k != "" {
region := os.Getenv("AWS_REGION")
if region == "" {
region = "eu-central-1"
}
cases = append(cases, credentialCase{
name: "bedrock", catalogID: "bedrock_api",
upstream: "https://bedrock-runtime." + region + ".amazonaws.com", apiKey: k,
})
}
return cases
}
// TestLiveProviderCredentialCheck drives the save-time check against the real
// vendors. A unit test can only assert that a mocked refusal is classified;
// what it cannot show is that these vendors refuse a bad key on their listing
// endpoint at all, which is the assumption the whole feature rests on.
//
// The good-key case matters just as much as the bad one: a check that refused
// everything would pass a test asserting only the refusal, and would make the
// product unusable.
//
// The suite asserts on the vendors themselves, so it inherits their
// availability: the check blocks on 5xx and 429 by design, and a vendor outage
// or a rate limit during a run fails "a good credential saves" with a
// perfectly valid key. There is no retry here on purpose — a retry loop would
// also mask the outage classification these tests exist to prove. Re-run the
// job.
func TestLiveProviderCredentialCheck(t *testing.T) {
cases := liveCredentialCases()
if len(cases) == 0 {
t.Skip("no live provider credentials in the environment; source ~/.llm-keys to run")
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Run("a good credential saves", func(t *testing.T) {
prov, err := srv.CreateProvider(ctx, credentialProviderRequest(tc, "e2e-cred-ok-"+tc.name, tc.apiKey))
require.NoError(t, err, "the suite's own credential must pass its check")
t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) })
require.NotEmpty(t, prov.Id)
})
t.Run("a rejected credential is refused", func(t *testing.T) {
_, err := srv.CreateProvider(ctx, credentialProviderRequest(tc, "e2e-cred-bad-"+tc.name, corrupt(tc.apiKey)))
require.Error(t, err, "a key the vendor rejects must not save")
var apiErr *rest.APIError
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusUnprocessableEntity, apiErr.StatusCode,
"a refused credential is the caller's problem to fix, not a server fault")
require.Contains(t, strings.ToLower(apiErr.Message), "rejected the credential",
"the message must name the credential rather than the url")
// The record must be absent, not merely unusable: a provider
// saved despite its check is the state this prevents.
all, listErr := srv.ListProviders(ctx)
require.NoError(t, listErr)
for _, p := range all {
require.NotEqual(t, "e2e-cred-bad-"+tc.name, p.Name, "a refused provider must not be stored")
}
})
})
}
}
// TestLiveProviderUrlCheck points a real credential at a host that is not the
// vendor's API. It is the half of the split a wrong key cannot exercise: the
// operator has to be told the URL is at fault while their key is fine.
//
// One vendor, deliberately. The transport classification under test happens
// before any vendor is reached, so running it per configured vendor would
// repeat the same code path and multiply the wall-clock of a suite that
// already creates real records. cases[0] is whichever vendor the environment
// supplies first.
func TestLiveProviderUrlCheck(t *testing.T) {
cases := liveCredentialCases()
if len(cases) == 0 {
t.Skip("no live provider credentials in the environment; source ~/.llm-keys to run")
}
tc := cases[0]
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
// A name that resolves nowhere. The check has to reach a verdict without
// the vendor's help, which is the transport half of the classification.
req := credentialProviderRequest(tc, "e2e-cred-badurl", tc.apiKey)
req.UpstreamUrl = "https://not-a-real-vendor-host.netbird-e2e.invalid"
_, err := srv.CreateProvider(ctx, req)
require.Error(t, err, "an upstream that does not resolve must not save")
var apiErr *rest.APIError
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusUnprocessableEntity, apiErr.StatusCode)
require.Contains(t, strings.ToLower(apiErr.Message), "could not be reached",
"the message must name the url rather than the credential")
// An error is not the same fact as an absent record: a handler that saved
// first and reported afterwards would satisfy everything above.
all, listErr := srv.ListProviders(ctx)
require.NoError(t, listErr)
for _, p := range all {
require.NotEqual(t, "e2e-cred-badurl", p.Name, "a refused provider must not be stored")
}
}
// TestLiveProviderUpdateKeepsTheWorkingKey is the state the check exists to
// prevent on the update path: a rejected rotation that has already replaced
// the credential would take a working provider down.
//
// Also one vendor: the behaviour is in the manager's merge, not in any
// vendor's response, and each run creates and mutates a real provider record.
func TestLiveProviderUpdateKeepsTheWorkingKey(t *testing.T) {
cases := liveCredentialCases()
if len(cases) == 0 {
t.Skip("no live provider credentials in the environment; source ~/.llm-keys to run")
}
tc := cases[0]
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
prov, err := srv.CreateProvider(ctx, credentialProviderRequest(tc, "e2e-cred-rotate", tc.apiKey))
require.NoError(t, err)
t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) })
rotation := credentialProviderRequest(tc, "e2e-cred-rotate", corrupt(tc.apiKey))
_, err = srv.UpdateProvider(ctx, prov.Id, rotation)
require.Error(t, err, "a rotation the vendor rejects must not be stored")
var apiErr *rest.APIError
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusUnprocessableEntity, apiErr.StatusCode)
// The stored key is never returned by the API, so the proof that it
// survived is that an edit which reuses it still passes its check. A
// replaced key would fail here exactly as the rotation just did.
//
// The trailing slash is what makes that an actual check: an edit touching
// neither the url, the key nor the catalog entry is stored without asking
// the vendor anything, so a rename alone would pass whatever is on the
// record. Only the host is read out of the upstream, so the same vendor is
// reached — but the string differs, and the check runs.
recheck := credentialProviderRequest(tc, "e2e-cred-rotate-renamed", "")
recheck.UpstreamUrl = tc.upstream + "/"
updated, err := srv.UpdateProvider(ctx, prov.Id, recheck)
require.NoError(t, err, "the working key must still be the stored one")
require.Equal(t, "e2e-cred-rotate-renamed", updated.Name)
}
// credentialProviderRequest builds a create/update body for a case. An empty apiKey is
// omitted rather than sent blank, which is how the form asks to keep whatever
// is already stored.
func credentialProviderRequest(tc credentialCase, name, apiKey string) api.AgentNetworkProviderRequest {
req := api.AgentNetworkProviderRequest{
Name: name,
ProviderId: tc.catalogID,
UpstreamUrl: tc.upstream,
Enabled: ptr(true),
}
if apiKey != "" {
req.ApiKey = &apiKey
}
return req
}
// corrupt returns a key the vendor will reject while keeping the shape of the
// original. Replacing the last character rather than appending keeps any
// length or prefix validation satisfied, so the refusal comes from the vendor
// checking the secret rather than from it rejecting an obviously malformed
// one.
func corrupt(key string) string {
if key == "" {
return key
}
last := key[len(key)-1]
replacement := byte('A')
if last == 'A' {
replacement = 'B'
}
return key[:len(key)-1] + string(replacement)
}
@@ -0,0 +1,132 @@
//go:build e2e
package agentnetwork
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/e2e/harness"
"github.com/netbirdio/netbird/shared/management/http/api"
)
// TestModelAllowlistOfDeclaredIDsServed drives the setup an operator actually
// builds for a path-routed provider: the models are declared in the form the
// vendor issues (Bedrock's region-prefixed, versioned inference-profile id;
// Vertex's model@version), and the guardrail allowlist is built from that
// declared list — the dashboard's allowlist picker persists the declared ids
// verbatim. A request for the declared model must be served end to end, and a
// model outside the allowlist must still be denied.
//
// TestModelAllowlistEnforced never caught this because it registers and
// allowlists the pre-normalized catalog form (see the catalogModel comment
// there and the one in providerRequest: "register the normalized form here or
// routing fails as model_not_routable") — the harness encoded the
// canonicalization workaround instead of the shape operators configure.
func TestModelAllowlistOfDeclaredIDsServed(t *testing.T) {
var providers []providerCase
for _, pc := range availableProviders() {
if pc.kind == harness.WireBedrock || pc.kind == harness.WireVertex {
providers = append(providers, pc)
}
}
if len(providers) == 0 {
t.Skip("no path-routed provider keys set (AWS_BEARER_TOKEN_BEDROCK / GOOGLE_VERTEX_*); source ~/.llm-keys")
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
defer cancel()
grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-declared-allowlist"})
require.NoError(t, err, "create group")
t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) })
ephemeral := false
sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{
Name: "e2e-declared-allowlist-client",
Type: "reusable",
ExpiresIn: 86400,
UsageLimit: 0,
AutoGroups: []string{grp.Id},
Ephemeral: &ephemeral,
})
require.NoError(t, err, "mint setup key")
t.Cleanup(func() { _ = srv.API().SetupKeys.Delete(context.Background(), sk.Id) })
// Providers declaring the raw vendor-issued model id — NOT the normalized
// catalog form providerRequest would register.
ids := make([]string, 0, len(providers))
declared := make([]string, 0, len(providers))
for _, pc := range providers {
req := providerRequest(pc)
req.Models = &[]api.AgentNetworkProviderModel{{Id: pc.model, InputPer1k: 0.001, OutputPer1k: 0.002}}
prov, perr := srv.CreateProvider(ctx, req)
require.NoError(t, perr, "create provider %s", pc.name)
id := prov.Id
ids = append(ids, id)
declared = append(declared, pc.model)
t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), id) })
}
// Guardrail allowlisting the declared ids verbatim, the way the dashboard
// builds an allowlist from the providers' model lists.
var gr api.AgentNetworkGuardrailRequest
gr.Name = "e2e-declared-allowlist"
gr.Checks.ModelAllowlist.Enabled = true
gr.Checks.ModelAllowlist.Models = declared
guard, err := srv.CreateGuardrail(ctx, gr)
require.NoError(t, err, "create guardrail")
t.Cleanup(func() { _ = srv.DeleteGuardrail(context.Background(), guard.Id) })
enabled := true
pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{
Name: "e2e-declared-allowlist",
Enabled: &enabled,
SourceGroups: []string{grp.Id},
DestinationProviderIds: ids,
GuardrailIds: &[]string{guard.Id},
})
require.NoError(t, err, "create policy")
t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) })
settings, err := srv.GetSettings(ctx)
require.NoError(t, err, "read settings for endpoint")
require.NotEmpty(t, settings.Endpoint, "agent-network endpoint must be assigned")
proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-proxy-declared-allowlist")
require.NoError(t, err, "mint proxy token via CLI")
px, err := harness.StartProxy(ctx, srv, proxyToken)
require.NoError(t, err, "start proxy")
t.Cleanup(func() { _ = px.Terminate(context.Background()) })
cl, err := harness.StartClient(ctx, srv, sk.Key)
require.NoError(t, err, "start client")
t.Cleanup(func() { _ = cl.Terminate(context.Background()) })
require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management")
// Probe first: the GET resolves the endpoint (DNS error fails) and its first packet wakes the lazy proxy peer, so WaitProxyPeer sees it connected; any HTTP status counts.
proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint)
require.NoError(t, err, "resolve agent-network endpoint to proxy IP")
if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil {
t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background()))
}
for _, pc := range providers {
pc := pc
t.Run(pc.name, func(t *testing.T) {
// The model the operator declared and allowlisted is served end to
// end: the route must claim it and the guardrail must permit it,
// both through the canonicalization the parser applies at request
// time — whatever id form the operator configured.
assert.Equal(t, 200, sendModel(ctx, t, cl, settings.Endpoint, proxyIP, pc, pc.model),
"the declared and allowlisted model must be served for %s", pc.name)
// A model outside the allowlist stays denied.
assert.Equal(t, 403, sendModel(ctx, t, cl, settings.Endpoint, proxyIP, pc, disallowedModel(pc)),
"model outside the allowlist must be denied for %s", pc.name)
})
}
}
+9 -3
View File
@@ -16,14 +16,20 @@ import (
func ptr[T any](v T) *T { return &v }
// newProvider creates an OpenAI-catalog provider with a dummy key (these tests
// never call the upstream) and registers cleanup.
// newProvider creates an OpenAI-catalog provider these tests can hang a policy
// off, and registers cleanup. Nothing here calls the upstream.
func newProvider(t *testing.T, ctx context.Context, name string) api.AgentNetworkProvider {
t.Helper()
// A provider save is credential-checked against the vendor, and every
// caller here wants a provider row to hang a policy off rather than a
// working upstream. A private address is left unchecked — the proxy would
// reach it through the tunnel, management cannot reach it at all — which
// keeps this fixture independent of whether the run has vendor keys, and
// covers the unchecked-provider-still-saves path while it is at it.
prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{
Name: name,
ProviderId: "openai_api",
UpstreamUrl: "https://api.openai.com",
UpstreamUrl: "https://10.255.255.1",
ApiKey: ptr("sk-dummy-e2e-key"),
})
require.NoError(t, err, "create provider %q", name)