From bc7a15ab71d9e9ed944fa3ec17952e77a696595d Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Tue, 4 Aug 2026 08:59:09 +0900 Subject: [PATCH] [management] Align agent-network API contracts for API clients (#7026) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes Work on the Terraform provider (terraform-provider-netbird #177–#183) surfaced places where the agent-network API broke its own contracts or deviated from the conventions the rest of the management API follows, forcing client-side workarounds. Settings reads now follow the settings-endpoint convention: GET always answers with a JSON object. Before bootstrap it returns the defaults with an empty cluster/subdomain/endpoint (previously 200 with a JSON `null` body, while the spec said 404). The settings PUT can bootstrap the account by carrying a `cluster` — previously the row could only come into existence through the first provider create, and a settings-first setup was impossible; a differing cluster on a bootstrapped account is rejected instead of silently ignored. PUT remains full-state. The provider PUT schema promised omit-preserves semantics for several operator-editable fields that the handler never delivered (it builds the row from the request, like every other update handler). The schema wording now matches the shipped full-state behavior; only the api_key (secret) and session keys stay preserved by the manager. Identity headers are always present in provider responses so an explicitly cleared value round-trips as an empty string. The Go REST client gains the full agent-network surface (catalog, providers, policies, guardrails, budget rules, settings), including a shim translating the legacy 200+`null` settings body from older servers into an `IsNotFound` error. Note for reviewers: the dashboard special-cased the `null` settings body; it needs a small follow-up for the new defaults response (in progress). --- e2e/agentnetwork/management_test.go | 11 + e2e/agentnetwork/settings_bootstrap_test.go | 114 ++++ .../agentnetwork/handlers/handlers_test.go | 16 +- .../handlers/providers_handler_test.go | 49 ++ .../agentnetwork/handlers/settings_handler.go | 25 +- .../handlers/settings_handler_test.go | 137 +++++ .../internals/modules/agentnetwork/manager.go | 153 ++++-- .../modules/agentnetwork/types/provider.go | 36 +- .../agentnetwork/types/provider_test.go | 38 ++ .../modules/agentnetwork/types/settings.go | 48 +- .../agentnetwork_budgetrule_realstack_test.go | 15 +- shared/management/client/rest/agentnetwork.go | 381 ++++++++++++++ .../client/rest/agentnetwork_test.go | 497 ++++++++++++++++++ shared/management/client/rest/client.go | 5 + shared/management/http/api/openapi.yml | 44 +- shared/management/http/api/types.gen.go | 35 +- 16 files changed, 1464 insertions(+), 140 deletions(-) create mode 100644 e2e/agentnetwork/settings_bootstrap_test.go create mode 100644 management/internals/modules/agentnetwork/handlers/settings_handler_test.go create mode 100644 shared/management/client/rest/agentnetwork.go create mode 100644 shared/management/client/rest/agentnetwork_test.go diff --git a/e2e/agentnetwork/management_test.go b/e2e/agentnetwork/management_test.go index cfd03f63c..6962f9796 100644 --- a/e2e/agentnetwork/management_test.go +++ b/e2e/agentnetwork/management_test.go @@ -160,8 +160,19 @@ func TestSettingsRoundTrip(t *testing.T) { assert.Equal(t, before.Cluster, flipped.Cluster, "cluster must be immutable across updates") assert.Equal(t, before.Subdomain, flipped.Subdomain, "subdomain must be immutable across updates") + // A cluster different from the pinned one must be rejected; echoing the + // pinned one back is valid. + _, err = srv.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{ + Cluster: ptr("attacker.cluster.invalid"), + EnableLogCollection: before.EnableLogCollection, + EnablePromptCollection: before.EnablePromptCollection, + RedactPii: before.RedactPii, + }) + requireClientError(t, err) + // Restore the original toggles. _, err = srv.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{ + Cluster: ptr(before.Cluster), EnableLogCollection: before.EnableLogCollection, EnablePromptCollection: before.EnablePromptCollection, RedactPii: before.RedactPii, diff --git a/e2e/agentnetwork/settings_bootstrap_test.go b/e2e/agentnetwork/settings_bootstrap_test.go new file mode 100644 index 000000000..ea56f7064 --- /dev/null +++ b/e2e/agentnetwork/settings_bootstrap_test.go @@ -0,0 +1,114 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// harnessStartFresh boots a dedicated combined server with its own fresh +// account and registers its teardown on t. +func harnessStartFresh(ctx context.Context, t *testing.T) (*harness.Combined, error) { + t.Helper() + fresh, err := harness.StartCombined(ctx) + if err != nil { + return nil, err + } + t.Cleanup(func() { _ = fresh.Terminate(context.Background()) }) + if _, err := fresh.Bootstrap(ctx); err != nil { + return nil, err + } + return fresh, nil +} + +// TestSettingsBootstrapViaPut covers the settings-first bootstrap path on an +// account that has never been bootstrapped: the GET reads as the defaults +// with an empty cluster/subdomain/endpoint, a PUT without a cluster has +// nothing to pin and fails, and a PUT carrying a cluster creates the row and +// pins it immutably. The shared srv cannot provide that starting state (any +// provider-creating test bootstraps it, and test order is deliberately not +// relied on), so this boots a dedicated combined server — the image is +// already built and cached by TestMain's StartCombined, so the extra cost is +// one container start. +func TestSettingsBootstrapViaPut(t *testing.T) { + ctx := context.Background() + + fresh, err := harnessStartFresh(ctx, t) + require.NoError(t, err, "start dedicated combined server") + + // Before agent-network bootstrap the settings read as the defaults, not + // as an error and not as a null body. + before, err := fresh.GetSettings(ctx) + require.NoError(t, err, "get settings on a fresh account must succeed") + assert.Empty(t, before.Cluster, "cluster must be empty before bootstrap") + assert.Empty(t, before.Subdomain, "subdomain must be empty before bootstrap") + assert.Empty(t, before.Endpoint, "endpoint must be empty before bootstrap, not a bare dot") + assert.True(t, before.EnableLogCollection, "defaults must show log collection on, matching bootstrap") + assert.False(t, before.EnablePromptCollection, "defaults must show prompt collection off") + + // A PUT without a cluster has nothing to pin the account to. + _, err = fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{ + EnableLogCollection: true, + }) + requireClientError(t, err) + + // A PUT carrying a cluster bootstraps the account and applies the + // mutable fields from the same request. Every toggle is set away from + // its bootstrap default so each assertion can actually fail. + const cluster = "e2e.bootstrap.netbird.selfhosted" + bootstrapped, err := fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{ + Cluster: ptr(cluster), + EnableLogCollection: false, + EnablePromptCollection: true, + RedactPii: true, + }) + require.NoError(t, err, "bootstrap settings via PUT must succeed") + assert.Equal(t, cluster, bootstrapped.Cluster, "cluster must be pinned from the request") + require.NotEmpty(t, bootstrapped.Subdomain, "subdomain must be assigned at bootstrap") + assert.Equal(t, bootstrapped.Subdomain+"."+cluster, bootstrapped.Endpoint, "endpoint must combine subdomain and cluster") + assert.False(t, bootstrapped.EnableLogCollection, "log collection from the bootstrap request must override the default") + assert.True(t, bootstrapped.EnablePromptCollection, "prompt collection from the bootstrap request must apply") + assert.True(t, bootstrapped.RedactPii, "redact toggle from the bootstrap request must apply") + + // The row is persisted: an independent read agrees on every field. + after, err := fresh.GetSettings(ctx) + require.NoError(t, err, "get settings after bootstrap must succeed") + assert.Equal(t, bootstrapped.Endpoint, after.Endpoint, "bootstrap must persist across reads") + assert.Equal(t, bootstrapped.EnableLogCollection, after.EnableLogCollection, "log collection must persist") + assert.Equal(t, bootstrapped.EnablePromptCollection, after.EnablePromptCollection, "prompt collection must persist") + assert.Equal(t, bootstrapped.RedactPii, after.RedactPii, "redact toggle must persist") + + // Once bootstrapped, later updates may omit the cluster entirely. + persisted, err := fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{ + EnableLogCollection: true, + EnablePromptCollection: false, + RedactPii: true, + }) + require.NoError(t, err, "post-bootstrap update without cluster must succeed") + assert.Equal(t, cluster, persisted.Cluster, "omitted cluster must keep the pinned value") + assert.True(t, persisted.EnableLogCollection, "post-bootstrap toggle must apply") + assert.False(t, persisted.EnablePromptCollection, "post-bootstrap toggle must apply") + + // The cluster is immutable: a different value is rejected rather than + // silently ignored, and the rejected update must not disturb anything. + _, err = fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{ + Cluster: ptr("other.cluster.invalid"), + EnableLogCollection: false, + }) + requireClientError(t, err) + + final, err := fresh.GetSettings(ctx) + require.NoError(t, err, "get settings after the rejected cluster change must succeed") + assert.Equal(t, persisted.Cluster, final.Cluster, "rejected update must not change the cluster") + assert.Equal(t, persisted.Endpoint, final.Endpoint, "rejected update must not change the endpoint") + assert.Equal(t, persisted.EnableLogCollection, final.EnableLogCollection, "rejected update must not apply its toggles") + assert.Equal(t, persisted.EnablePromptCollection, final.EnablePromptCollection, "rejected update must not apply its toggles") + assert.Equal(t, persisted.RedactPii, final.RedactPii, "rejected update must not apply its toggles") +} diff --git a/management/internals/modules/agentnetwork/handlers/handlers_test.go b/management/internals/modules/agentnetwork/handlers/handlers_test.go index 27ebea5dd..9d855c05d 100644 --- a/management/internals/modules/agentnetwork/handlers/handlers_test.go +++ b/management/internals/modules/agentnetwork/handlers/handlers_test.go @@ -17,6 +17,7 @@ import ( "github.com/netbirdio/netbird/management/internals/modules/agentnetwork" agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + "github.com/netbirdio/netbird/management/server/account" nbcontext "github.com/netbirdio/netbird/management/server/context" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/store" @@ -61,10 +62,23 @@ func newAgentNetworkHandlerFixture(t *testing.T) *agentNetworkHandlerFixture { Return(true, context.Background(), nil). AnyTimes() - manager := agentnetwork.NewManager(st, perms, nil, nil) + // Swallow activity events so the mutation paths (create/update/delete) + // are exercisable through the HTTP layer. + accounts := account.NewMockManager(ctrl) + accounts.EXPECT(). + StoreEvent(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + AnyTimes() + accounts.EXPECT(). + UpdateAccountPeers(gomock.Any(), gomock.Any(), gomock.Any()). + AnyTimes() + + manager := agentnetwork.NewManager(st, perms, accounts, nil) h := &handler{manager: manager} router := mux.NewRouter() + router.HandleFunc("/agent-network/providers", h.createProvider).Methods("POST") + router.HandleFunc("/agent-network/providers/{providerId}", h.getProvider).Methods("GET") + router.HandleFunc("/agent-network/providers/{providerId}", h.updateProvider).Methods("PUT") h.addPolicyEndpoints(router) h.addConsumptionEndpoints(router) h.addBudgetRuleEndpoints(router) diff --git a/management/internals/modules/agentnetwork/handlers/providers_handler_test.go b/management/internals/modules/agentnetwork/handlers/providers_handler_test.go index 649224c02..05024cde9 100644 --- a/management/internals/modules/agentnetwork/handlers/providers_handler_test.go +++ b/management/internals/modules/agentnetwork/handlers/providers_handler_test.go @@ -1,7 +1,9 @@ package handlers import ( + "encoding/json" "math" + nethttp "net/http" "testing" "github.com/stretchr/testify/assert" @@ -51,3 +53,50 @@ func TestValidate_ModelRates(t *testing.T) { assert.Error(t, validate(base(m), true), "case %q must be rejected", name) } } + +// TestProviderHandler_UpdateReplacesFullState pins the update contract shared +// with the other PUT endpoints: the request replaces the provider's mutable +// state, so optional fields absent from the JSON land as their zero values. +// The two exceptions are server-side: the api_key (a secret — omitted means +// "not rotated") and the session keypair, both preserved by the manager. The +// identity headers stay on the wire as explicit empty strings so a cleared +// value round-trips. +func TestProviderHandler_UpdateReplacesFullState(t *testing.T) { + f := newAgentNetworkHandlerFixture(t) + + create := `{ + "provider_id": "openai_api", + "name": "openai", + "upstream_url": "https://api.openai.com", + "api_key": "sk-test", + "enabled": true, + "metadata_disabled": true, + "skip_tls_verification": true, + "extra_values": {"x-portkey-config": "pc-prod-3f2a"}, + "identity_header_user_id": "x-bf-dim-netbird_user_id", + "models": [{"id": "gpt-4o", "input_per_1k": 0.0025, "output_per_1k": 0.01}] + }` + rec := f.do(t, nethttp.MethodPost, "/agent-network/providers", create) + require.Equal(t, nethttp.StatusOK, rec.Code, "create must succeed: %s", rec.Body.String()) + + var created api.AgentNetworkProvider + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &created)) + + // Minimal update: only the required fields, no api_key. Everything + // optional must land as its zero value. + update := `{"provider_id": "openai_api", "name": "openai-renamed", "upstream_url": "https://api.openai.com", "enabled": true}` + rec = f.do(t, nethttp.MethodPut, "/agent-network/providers/"+created.Id, update) + require.Equal(t, nethttp.StatusOK, rec.Code, "update without api_key must succeed (key is preserved): %s", rec.Body.String()) + + var updated api.AgentNetworkProvider + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &updated)) + assert.Equal(t, "openai-renamed", updated.Name, "sent field must apply") + assert.True(t, updated.Enabled, "sent field must apply") + assert.False(t, updated.MetadataDisabled, "omitted metadata_disabled must land as false — PUT replaces the full state") + assert.False(t, updated.SkipTlsVerification, "omitted skip_tls_verification must land as false") + assert.Nil(t, updated.ExtraValues, "omitted extra_values must be cleared") + assert.Equal(t, "", updated.IdentityHeaderUserId, "omitted identity header must be cleared yet stay on the wire") + assert.Empty(t, updated.Models, "omitted models must be cleared") + assert.Contains(t, rec.Body.String(), `"identity_header_user_id":""`, + "cleared identity header must round-trip as an explicit empty string") +} diff --git a/management/internals/modules/agentnetwork/handlers/settings_handler.go b/management/internals/modules/agentnetwork/handlers/settings_handler.go index c65efad0f..171750838 100644 --- a/management/internals/modules/agentnetwork/handlers/settings_handler.go +++ b/management/internals/modules/agentnetwork/handlers/settings_handler.go @@ -2,7 +2,6 @@ package handlers import ( "encoding/json" - "errors" "net/http" "github.com/gorilla/mux" @@ -11,19 +10,20 @@ import ( nbcontext "github.com/netbirdio/netbird/management/server/context" "github.com/netbirdio/netbird/shared/management/http/api" "github.com/netbirdio/netbird/shared/management/http/util" - "github.com/netbirdio/netbird/shared/management/status" ) // addSettingsEndpoints registers the Agent Network settings routes. The -// settings row is bootstrapped server-side on first provider create; GET reads -// it and PUT updates the mutable collection toggles (cluster/subdomain stay -// immutable). +// settings row is bootstrapped server-side on first provider create or on the +// first PUT carrying a cluster; GET reads it and PUT applies a partial update +// of the mutable collection toggles (cluster/subdomain stay immutable). func (h *handler) addSettingsEndpoints(router *mux.Router) { router.HandleFunc("/agent-network/settings", h.getSettings).Methods("GET", "OPTIONS") router.HandleFunc("/agent-network/settings", h.updateSettings).Methods("PUT", "OPTIONS") } -// updateSettings applies the collection toggles to the account's settings row. +// updateSettings replaces the mutable settings fields on the account's row. +// A request carrying a cluster bootstraps the row when the account doesn't +// have one yet. func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) { userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) if err != nil { @@ -48,11 +48,9 @@ func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) { util.WriteJSONObject(r.Context(), w, updated.ToAPIResponse()) } -// getSettings returns the account's agent-network settings. The settings -// row is bootstrapped on first provider create, so freshly-onboarded -// accounts have nothing to read. Rather than 404-ing in that case (which -// the dashboard would have to special-case), return a JSON null with 200 -// so consumers can branch on the body alone. +// getSettings returns the account's agent-network settings. Accounts that +// haven't been bootstrapped yet read as the defaults with an empty cluster, +// subdomain and endpoint; the manager synthesises that view. func (h *handler) getSettings(w http.ResponseWriter, r *http.Request) { userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) if err != nil { @@ -62,11 +60,6 @@ func (h *handler) getSettings(w http.ResponseWriter, r *http.Request) { settings, err := h.manager.GetSettings(r.Context(), userAuth.AccountId, userAuth.UserId) if err != nil { - var sErr *status.Error - if errors.As(err, &sErr) && sErr.Type() == status.NotFound { - util.WriteJSONObject(r.Context(), w, nil) - return - } util.WriteError(r.Context(), err, w) return } diff --git a/management/internals/modules/agentnetwork/handlers/settings_handler_test.go b/management/internals/modules/agentnetwork/handlers/settings_handler_test.go new file mode 100644 index 000000000..636ec5b26 --- /dev/null +++ b/management/internals/modules/agentnetwork/handlers/settings_handler_test.go @@ -0,0 +1,137 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// TestSettingsHandler_GetUnbootstrappedReturnsDefaults pins the settings-read +// convention shared with the account and DNS settings endpoints: settings +// always read as a JSON object. Before bootstrap that object carries the +// defaults with an empty cluster/subdomain/endpoint (the "not bootstrapped" +// signal) and no timestamps — never a 404 and never the legacy null body. +func TestSettingsHandler_GetUnbootstrappedReturnsDefaults(t *testing.T) { + f := newAgentNetworkHandlerFixture(t) + + rec := f.do(t, http.MethodGet, "/agent-network/settings", "") + require.Equal(t, http.StatusOK, rec.Code, + "unbootstrapped account must read as 200 with defaults: got %d body=%s", rec.Code, rec.Body.String()) + require.NotEqual(t, "null", trimSpace(rec.Body.String()), + "the legacy 200+null shape must not come back") + + var got api.AgentNetworkSettings + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + assert.Empty(t, got.Cluster, "cluster must be empty until bootstrapped") + assert.Empty(t, got.Subdomain, "subdomain must be empty until bootstrapped") + assert.Empty(t, got.Endpoint, "endpoint must be empty until bootstrapped, not a bare dot") + assert.True(t, got.EnableLogCollection, "defaults must show log collection on, matching bootstrap") + assert.False(t, got.EnablePromptCollection, "defaults must show prompt collection off") + assert.False(t, got.RedactPii, "defaults must show redaction off") + require.NotNil(t, got.AccessLogRetentionDays) + assert.Equal(t, 30, *got.AccessLogRetentionDays, "defaults must show the bootstrap retention") + assert.Nil(t, got.CreatedAt, "no timestamps before a row exists") + assert.Nil(t, got.UpdatedAt, "no timestamps before a row exists") +} + +// TestSettingsHandler_PutBootstrapsWithCluster covers the settings-first +// bootstrap path: a PUT carrying a cluster on an unbootstrapped account +// creates the row (cluster pinned, subdomain assigned) and applies the +// mutable fields from the same request. +func TestSettingsHandler_PutBootstrapsWithCluster(t *testing.T) { + f := newAgentNetworkHandlerFixture(t) + + rec := f.do(t, http.MethodPut, "/agent-network/settings", + `{"cluster": "eu.proxy.netbird.io", "enable_log_collection": true, "enable_prompt_collection": true, "redact_pii": false, "access_log_retention_days": 30}`) + require.Equal(t, http.StatusOK, rec.Code, "bootstrap PUT must succeed: %s", rec.Body.String()) + + var got api.AgentNetworkSettings + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + assert.Equal(t, "eu.proxy.netbird.io", got.Cluster, "cluster must be pinned from the request") + assert.NotEmpty(t, got.Subdomain, "subdomain must be assigned at bootstrap") + assert.Equal(t, got.Subdomain+".eu.proxy.netbird.io", got.Endpoint, "endpoint must combine subdomain and cluster") + assert.True(t, got.EnableLogCollection, "toggle from the bootstrap request must apply") + assert.True(t, got.EnablePromptCollection, "toggle from the bootstrap request must apply") + require.NotNil(t, got.AccessLogRetentionDays) + assert.Equal(t, 30, *got.AccessLogRetentionDays, "retention from the bootstrap request must apply") + + // The row is now readable via GET. + rec = f.do(t, http.MethodGet, "/agent-network/settings", "") + require.Equal(t, http.StatusOK, rec.Code, "GET after bootstrap must succeed") +} + +// TestSettingsHandler_PutWithoutClusterOnUnbootstrapped pins that a PUT +// without a cluster cannot conjure a settings row out of nothing — there is +// no cluster to pin — and surfaces as 404 like the GET. +func TestSettingsHandler_PutWithoutClusterOnUnbootstrapped(t *testing.T) { + f := newAgentNetworkHandlerFixture(t) + + rec := f.do(t, http.MethodPut, "/agent-network/settings", + `{"enable_log_collection": false, "enable_prompt_collection": false, "redact_pii": false}`) + assert.Equal(t, http.StatusNotFound, rec.Code, + "cluster-less PUT on an unbootstrapped account must 404: got %d body=%s", rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), "cluster", + "the error must point the caller at the bootstrap paths: %s", rec.Body.String()) +} + +// TestSettingsHandler_PutReplacesMutableFields pins the update contract shared +// with the other PUT endpoints: the request replaces every mutable field, so a +// toggle absent from the JSON lands as its zero value rather than being +// preserved. Cluster and subdomain survive untouched. +func TestSettingsHandler_PutReplacesMutableFields(t *testing.T) { + f := newAgentNetworkHandlerFixture(t) + + rec := f.do(t, http.MethodPut, "/agent-network/settings", + `{"cluster": "eu.proxy.netbird.io", "enable_log_collection": true, "enable_prompt_collection": true, "redact_pii": true, "access_log_retention_days": 14}`) + require.Equal(t, http.StatusOK, rec.Code, "bootstrap PUT must succeed: %s", rec.Body.String()) + + var before api.AgentNetworkSettings + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &before)) + + rec = f.do(t, http.MethodPut, "/agent-network/settings", + `{"enable_log_collection": true, "enable_prompt_collection": false, "redact_pii": false}`) + require.Equal(t, http.StatusOK, rec.Code, "update PUT must succeed: %s", rec.Body.String()) + + var got api.AgentNetworkSettings + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + assert.True(t, got.EnableLogCollection, "sent toggle must apply") + assert.False(t, got.EnablePromptCollection, "sent toggle must apply") + assert.False(t, got.RedactPii, "sent toggle must apply") + require.NotNil(t, got.AccessLogRetentionDays) + assert.Equal(t, 0, *got.AccessLogRetentionDays, + "retention absent from the request must land as the zero value — PUT replaces all mutable fields") + assert.Equal(t, before.Cluster, got.Cluster, "cluster must survive updates untouched") + assert.Equal(t, before.Subdomain, got.Subdomain, "subdomain must survive updates untouched") +} + +// TestSettingsHandler_PutRejectsClusterChange pins cluster immutability: once +// assigned, a differing cluster is rejected as a validation error instead of +// being silently ignored, so callers never observe a value other than the one +// they sent. Echoing the assigned cluster back stays valid, which lets +// declarative clients send their full desired state idempotently. +func TestSettingsHandler_PutRejectsClusterChange(t *testing.T) { + f := newAgentNetworkHandlerFixture(t) + + rec := f.do(t, http.MethodPut, "/agent-network/settings", + `{"cluster": "eu.proxy.netbird.io", "enable_log_collection": true, "enable_prompt_collection": false, "redact_pii": false}`) + require.Equal(t, http.StatusOK, rec.Code, "bootstrap PUT must succeed: %s", rec.Body.String()) + + rec = f.do(t, http.MethodPut, "/agent-network/settings", + `{"cluster": "us.proxy.netbird.io", "enable_log_collection": true, "enable_prompt_collection": false, "redact_pii": false}`) + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, + "cluster change must be rejected as a validation error: got %d body=%s", rec.Code, rec.Body.String()) + + rec = f.do(t, http.MethodPut, "/agent-network/settings", + `{"cluster": "eu.proxy.netbird.io", "enable_log_collection": true, "enable_prompt_collection": false, "redact_pii": true}`) + require.Equal(t, http.StatusOK, rec.Code, "echoing the assigned cluster must stay valid: %s", rec.Body.String()) + + var got api.AgentNetworkSettings + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + assert.Equal(t, "eu.proxy.netbird.io", got.Cluster, "cluster must be unchanged") + assert.True(t, got.RedactPii, "toggle sent alongside the echoed cluster must apply") +} diff --git a/management/internals/modules/agentnetwork/manager.go b/management/internals/modules/agentnetwork/manager.go index 2687e4534..ba2c06826 100644 --- a/management/internals/modules/agentnetwork/manager.go +++ b/management/internals/modules/agentnetwork/manager.go @@ -207,7 +207,7 @@ func (m *managerImpl) CreateProvider(ctx context.Context, userID string, provide } if strings.TrimSpace(bootstrapCluster) != "" { - if _, err := m.bootstrapSettingsIfNeeded(ctx, provider.AccountID, bootstrapCluster); err != nil { + if _, err := m.bootstrapSettingsIfNeeded(ctx, m.store, provider.AccountID, bootstrapCluster); err != nil { // The provider create has already succeeded; logging the // bootstrap miss matches the plan's PoC behaviour. The synth // path treats a missing settings row as a no-op, and the next @@ -559,40 +559,83 @@ func (m *managerImpl) DeleteBudgetRule(ctx context.Context, accountID, userID, r return nil } -// UpdateSettings applies the mutable account-level settings — the collection -// toggles — onto the existing row. Cluster and Subdomain are immutable and are -// preserved from the persisted row regardless of the input. Because the -// collection toggles change the synthesised service config (prompt-capture -// gating, access-log emission), a reconcile is triggered so the proxy and peer -// network maps converge on the new state. +// UpdateSettings replaces the mutable account-level settings — the collection +// toggles and retention — on the account's row. When the account has no +// settings row yet, a non-empty settings.Cluster bootstraps one (same path as +// first provider create); without it the update fails with NotFound. On an +// existing row the cluster and subdomain are immutable: a differing +// settings.Cluster is rejected rather than silently ignored so callers never +// observe a value other than what they sent. Because the collection toggles +// change the synthesised service config (prompt-capture gating, access-log +// emission), a reconcile is triggered so the proxy and peer network maps +// converge on the new state. func (m *managerImpl) UpdateSettings(ctx context.Context, userID string, settings *types.Settings) (*types.Settings, error) { if err := m.requirePermission(ctx, settings.AccountID, userID, modules.AgentNetworkSettings, operations.Update); err != nil { return nil, err } - existing, err := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthUpdate, settings.AccountID) + requestedCluster := strings.TrimSpace(settings.Cluster) + + // The row lock from LockingStrengthUpdate only holds for the duration of + // the surrounding transaction, so the read, the cluster-immutability + // check, and the save must share one — otherwise concurrent PUTs could + // interleave between them. + var updated *types.Settings + err := m.store.ExecuteInTransaction(ctx, func(tx store.Store) error { + existing, err := tx.GetAgentNetworkSettings(ctx, store.LockingStrengthUpdate, settings.AccountID) + switch { + case err == nil: + if requestedCluster != "" && requestedCluster != existing.Cluster { + return status.Errorf(status.InvalidArgument, "cluster is immutable once assigned (current: %s)", existing.Cluster) + } + case isNotFound(err): + if requestedCluster == "" { + return status.Errorf(status.NotFound, "agent network settings have not been bootstrapped yet; pass cluster to bootstrap them, or create a provider with bootstrap_cluster set") + } + // Bootstrapping pins the cluster and subdomain — a settings + // create on top of the update the caller already passed, matching + // the gate on the provider-create bootstrap path. + if err := m.requirePermission(ctx, settings.AccountID, userID, modules.AgentNetworkSettings, operations.Create); err != nil { + return err + } + existing, err = m.bootstrapSettingsIfNeeded(ctx, tx, settings.AccountID, requestedCluster) + if err != nil { + return err + } + default: + return fmt.Errorf("get agent network settings: %w", err) + } + + existing.EnableLogCollection = settings.EnableLogCollection + existing.EnablePromptCollection = settings.EnablePromptCollection + existing.RedactPii = settings.RedactPii + existing.AccessLogRetentionDays = settings.AccessLogRetentionDays + existing.UpdatedAt = time.Now().UTC() + + if err := tx.SaveAgentNetworkSettings(ctx, existing); err != nil { + return fmt.Errorf("save agent network settings: %w", err) + } + updated = existing + return nil + }) if err != nil { - return nil, fmt.Errorf("get agent network settings: %w", err) - } - - existing.EnableLogCollection = settings.EnableLogCollection - existing.EnablePromptCollection = settings.EnablePromptCollection - existing.RedactPii = settings.RedactPii - existing.AccessLogRetentionDays = settings.AccessLogRetentionDays - existing.UpdatedAt = time.Now().UTC() - - if err := m.store.SaveAgentNetworkSettings(ctx, existing); err != nil { - return nil, fmt.Errorf("save agent network settings: %w", err) + return nil, err } m.accountManager.StoreEvent(ctx, userID, settings.AccountID, settings.AccountID, activity.AgentNetworkSettingsUpdated, map[string]any{ - "log_collection": existing.EnableLogCollection, - "prompt_collection": existing.EnablePromptCollection, - "redact_pii": existing.RedactPii, + "log_collection": updated.EnableLogCollection, + "prompt_collection": updated.EnablePromptCollection, + "redact_pii": updated.RedactPii, }) m.reconcile(ctx, settings.AccountID) - return existing, nil + return updated, nil +} + +// isNotFound reports whether err is a status.NotFound error. +func isNotFound(err error) bool { + var sErr *status.Error + return errors.As(err, &sErr) && sErr.Type() == status.NotFound } // validateProviderRefs ensures every destination provider id refers to a @@ -616,22 +659,25 @@ func (m *managerImpl) validateProviderRefs(ctx context.Context, accountID string return nil } -// GetSettings returns the agent-network settings row for the account. -// Returns the underlying status.NotFound when no row has been -// bootstrapped yet (i.e. the account has no providers). +// GetSettings returns the agent-network settings row for the account. When no +// row has been bootstrapped yet, the defaults are returned (without +// persisting) with cluster and subdomain empty — settings always read as an +// object, like the account and DNS settings endpoints. func (m *managerImpl) GetSettings(ctx context.Context, accountID, userID string) (*types.Settings, error) { if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkSettings, operations.Read); err != nil { return nil, err } - return m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID) + settings, err := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID) + switch { + case err == nil: + return settings, nil + case isNotFound(err): + return types.DefaultSettings(accountID), nil + default: + return nil, err + } } -// 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 @@ -641,14 +687,20 @@ func (m *managerImpl) requireSettingsBootstrapPermission(ctx context.Context, ac if err == nil { return nil } - var sErr *status.Error - if !errors.As(err, &sErr) || sErr.Type() != status.NotFound { + if !isNotFound(err) { return fmt.Errorf("get agent network settings: %w", err) } return m.requirePermission(ctx, accountID, userID, modules.AgentNetworkSettings, operations.Create) } -func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, accountID, providerCluster string) (*types.Settings, error) { +// 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. st is the store to +// operate on — pass the transaction store when calling from within one. +func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, st store.Store, accountID, providerCluster string) (*types.Settings, error) { if accountID == "" { return nil, fmt.Errorf("bootstrap settings: account id is required") } @@ -656,16 +708,15 @@ func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, accountID, return nil, fmt.Errorf("bootstrap settings: provider cluster is required") } - existing, err := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID) + existing, err := st.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID) if err == nil { return existing, nil } - var sErr *status.Error - if !errors.As(err, &sErr) || sErr.Type() != status.NotFound { + if !isNotFound(err) { return nil, fmt.Errorf("get agent network settings: %w", err) } - siblings, err := m.store.GetAgentNetworkSettingsByCluster(ctx, store.LockingStrengthNone, providerCluster) + siblings, err := st.GetAgentNetworkSettingsByCluster(ctx, store.LockingStrengthNone, providerCluster) if err != nil { return nil, fmt.Errorf("list agent network settings on cluster: %w", err) } @@ -684,18 +735,12 @@ func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, accountID, m.labelRngMu.Unlock() 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. - EnableLogCollection: true, - AccessLogRetentionDays: types.DefaultAccessLogRetentionDays, - CreatedAt: now, - UpdatedAt: now, - } - if err := m.store.SaveAgentNetworkSettings(ctx, settings); err != nil { + settings := types.DefaultSettings(accountID) + settings.Cluster = providerCluster + settings.Subdomain = subdomain + settings.CreatedAt = now + settings.UpdatedAt = now + if err := st.SaveAgentNetworkSettings(ctx, settings); err != nil { return nil, fmt.Errorf("save agent network settings: %w", err) } return settings, nil @@ -898,8 +943,8 @@ func (*mockManager) UpdateBudgetRule(_ context.Context, _ string, r *types.Accou func (*mockManager) DeleteBudgetRule(_ context.Context, _, _, _ string) error { return nil } -func (*mockManager) GetSettings(_ context.Context, _, _ string) (*types.Settings, error) { - return nil, status.Errorf(status.NotFound, "agent network settings not found") +func (*mockManager) GetSettings(_ context.Context, accountID, _ string) (*types.Settings, error) { + return types.DefaultSettings(accountID), nil } func (*mockManager) UpdateSettings(_ context.Context, _ string, s *types.Settings) (*types.Settings, error) { diff --git a/management/internals/modules/agentnetwork/types/provider.go b/management/internals/modules/agentnetwork/types/provider.go index 96242f45f..b9a194bf6 100644 --- a/management/internals/modules/agentnetwork/types/provider.go +++ b/management/internals/modules/agentnetwork/types/provider.go @@ -164,9 +164,7 @@ func (p *Provider) FromAPIRequest(req *api.AgentNetworkProviderRequest) { p.MetadataDisabled = *req.MetadataDisabled } // Identity-header overrides for catalogs flagged Customizable. - // nil pointer = "field omitted on the wire" → leave the stored - // value untouched (per the openapi description). Empty string is - // an explicit clear that disables stamping for this dimension. + // Empty or omitted disables stamping for this dimension. if req.IdentityHeaderUserId != nil { p.IdentityHeaderUserID = strings.TrimSpace(*req.IdentityHeaderUserId) } @@ -192,16 +190,20 @@ func (p *Provider) ToAPIResponse() *api.AgentNetworkProvider { created := p.CreatedAt updated := p.UpdatedAt resp := &api.AgentNetworkProvider{ - Id: p.ID, - ProviderId: p.ProviderID, - Name: p.Name, - UpstreamUrl: p.UpstreamURL, - Models: models, - Enabled: p.Enabled, - SkipTlsVerification: p.SkipTLSVerification, - MetadataDisabled: p.MetadataDisabled, - CreatedAt: &created, - UpdatedAt: &updated, + Id: p.ID, + ProviderId: p.ProviderID, + Name: p.Name, + UpstreamUrl: p.UpstreamURL, + Models: models, + // Always present on the wire so an explicitly cleared header + // round-trips as "" instead of vanishing from the response. + IdentityHeaderUserId: p.IdentityHeaderUserID, + IdentityHeaderGroups: p.IdentityHeaderGroups, + Enabled: p.Enabled, + SkipTlsVerification: p.SkipTLSVerification, + MetadataDisabled: p.MetadataDisabled, + CreatedAt: &created, + UpdatedAt: &updated, } if len(p.ExtraValues) > 0 { out := make(map[string]string, len(p.ExtraValues)) @@ -210,14 +212,6 @@ func (p *Provider) ToAPIResponse() *api.AgentNetworkProvider { } resp.ExtraValues = &out } - if p.IdentityHeaderUserID != "" { - v := p.IdentityHeaderUserID - resp.IdentityHeaderUserId = &v - } - if p.IdentityHeaderGroups != "" { - v := p.IdentityHeaderGroups - resp.IdentityHeaderGroups = &v - } return resp } diff --git a/management/internals/modules/agentnetwork/types/provider_test.go b/management/internals/modules/agentnetwork/types/provider_test.go index f9756bb8b..fd553ece2 100644 --- a/management/internals/modules/agentnetwork/types/provider_test.go +++ b/management/internals/modules/agentnetwork/types/provider_test.go @@ -77,3 +77,41 @@ func TestProvider_MetadataDisabled_RoundTrip(t *testing.T) { assert.False(t, p.MetadataDisabled, "explicit false must clear metadata_disabled") assert.False(t, p.ToAPIResponse().MetadataDisabled, "response must reflect the cleared value") } + +// TestProvider_IdentityHeaders_AlwaysOnWire pins that the identity header +// fields are always present in the API response — an explicitly cleared +// ("") header must round-trip as "" rather than vanish, so API consumers +// (e.g. the Terraform provider) never observe a value other than the one +// they wrote. +func TestProvider_IdentityHeaders_AlwaysOnWire(t *testing.T) { + set := "x-bf-dim-netbird_user_id" + empty := "" + + base := func() *api.AgentNetworkProviderRequest { + return &api.AgentNetworkProviderRequest{ + ProviderId: "custom", + Name: "bifrost", + UpstreamUrl: "https://bifrost.internal", + } + } + + p := NewProvider("acc-1") + resp := p.ToAPIResponse() + assert.Equal(t, "", resp.IdentityHeaderUserId, "unset header must surface as empty string, not be omitted") + assert.Equal(t, "", resp.IdentityHeaderGroups, "unset header must surface as empty string, not be omitted") + + req := base() + req.IdentityHeaderUserId = &set + p.FromAPIRequest(req) + assert.Equal(t, set, p.ToAPIResponse().IdentityHeaderUserId, "configured header must round-trip") + + // Omitting the field preserves it. + p.FromAPIRequest(base()) + assert.Equal(t, set, p.ToAPIResponse().IdentityHeaderUserId, "omitted header must preserve the stored value") + + // An explicit "" clears it AND stays visible on the wire. + req = base() + req.IdentityHeaderUserId = &empty + p.FromAPIRequest(req) + assert.Equal(t, "", p.ToAPIResponse().IdentityHeaderUserId, "cleared header must round-trip as empty string") +} diff --git a/management/internals/modules/agentnetwork/types/settings.go b/management/internals/modules/agentnetwork/types/settings.go index d61d9deff..2c53877b5 100644 --- a/management/internals/modules/agentnetwork/types/settings.go +++ b/management/internals/modules/agentnetwork/types/settings.go @@ -1,6 +1,7 @@ package types import ( + "strings" "time" "github.com/netbirdio/netbird/shared/management/http/api" @@ -42,18 +43,34 @@ type Settings struct { // schema cohesive. func (Settings) TableName() string { return "agent_network_settings" } +// DefaultSettings returns the settings an account observes before its row is +// bootstrapped: log collection on with the default retention, everything else +// off, and no cluster/subdomain assigned yet. Bootstrap persists exactly these +// values plus the assigned cluster and subdomain, so the pre-bootstrap read +// and the freshly bootstrapped row agree. +func DefaultSettings(accountID string) *Settings { + return &Settings{ + AccountID: accountID, + EnableLogCollection: true, + AccessLogRetentionDays: DefaultAccessLogRetentionDays, + } +} + // Endpoint returns the bare hostname agents reach this account at: -// `.`. +// `.`. Empty until both halves are assigned at bootstrap. func (s *Settings) Endpoint() string { + if s.Cluster == "" || s.Subdomain == "" { + return "" + } return s.Subdomain + "." + s.Cluster } -// ToAPIResponse renders the settings as the API representation. +// ToAPIResponse renders the settings as the API representation. The +// timestamps are omitted while zero — a default (not yet bootstrapped) view +// has no persisted row to date. func (s *Settings) ToAPIResponse() *api.AgentNetworkSettings { - created := s.CreatedAt - updated := s.UpdatedAt retention := s.AccessLogRetentionDays - return &api.AgentNetworkSettings{ + resp := &api.AgentNetworkSettings{ Cluster: s.Cluster, Subdomain: s.Subdomain, Endpoint: s.Endpoint(), @@ -61,14 +78,27 @@ func (s *Settings) ToAPIResponse() *api.AgentNetworkSettings { EnablePromptCollection: s.EnablePromptCollection, RedactPii: s.RedactPii, AccessLogRetentionDays: &retention, - CreatedAt: &created, - UpdatedAt: &updated, } + if !s.CreatedAt.IsZero() { + created := s.CreatedAt + resp.CreatedAt = &created + } + if !s.UpdatedAt.IsZero() { + updated := s.UpdatedAt + resp.UpdatedAt = &updated + } + return resp } -// FromAPIRequest applies the mutable settings fields from the request. Cluster -// and Subdomain are immutable and intentionally not touched here. +// FromAPIRequest applies the request onto the receiver. The mutable +// collection fields are always replaced with the request values. Cluster +// participates only in bootstrap and the immutability check (see +// Manager.UpdateSettings); Subdomain is server-assigned and never taken +// from a request. func (s *Settings) FromAPIRequest(req *api.AgentNetworkSettingsRequest) { + if req.Cluster != nil { + s.Cluster = strings.TrimSpace(*req.Cluster) + } s.EnableLogCollection = req.EnableLogCollection s.EnablePromptCollection = req.EnablePromptCollection s.RedactPii = req.RedactPii diff --git a/management/server/agentnetwork_budgetrule_realstack_test.go b/management/server/agentnetwork_budgetrule_realstack_test.go index d17f2e26a..790285b4e 100644 --- a/management/server/agentnetwork_budgetrule_realstack_test.go +++ b/management/server/agentnetwork_budgetrule_realstack_test.go @@ -102,11 +102,20 @@ func TestAgentNetwork_UpdateSettings_PreservesImmutableAndTogglesCollection(t *t require.NotEmpty(t, before.Subdomain, "subdomain pinned at bootstrap") assert.False(t, before.EnablePromptCollection, "prompt collection defaults off") - // Attempt to flip toggles AND smuggle a different cluster/subdomain — the - // immutable fields must be ignored. + // A cluster different from the one pinned at bootstrap must be rejected + // outright — never silently swapped or ignored. + _, err = mgr.UpdateSettings(ctx, adminUserID, &agenttypes.Settings{ + AccountID: accountID, + Cluster: "attacker.cluster", + EnableLogCollection: true, + }) + require.Error(t, err, "UpdateSettings with a mismatched cluster must fail") + + // Flipping the toggles works with the pinned cluster echoed back (and + // with it omitted); the subdomain is never taken from the request. updated, err := mgr.UpdateSettings(ctx, adminUserID, &agenttypes.Settings{ AccountID: accountID, - Cluster: "attacker.cluster", + Cluster: clusterAddr, Subdomain: "evil", EnableLogCollection: true, EnablePromptCollection: true, diff --git a/shared/management/client/rest/agentnetwork.go b/shared/management/client/rest/agentnetwork.go new file mode 100644 index 000000000..cee053d17 --- /dev/null +++ b/shared/management/client/rest/agentnetwork.go @@ -0,0 +1,381 @@ +package rest + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// AgentNetworkAPI APIs for the Agent Network (AI/LLM gateway), do not use directly +// see more: https://docs.netbird.io/api/resources/agent-network +type AgentNetworkAPI struct { + c *Client +} + +// ListCatalogProviders lists the catalog of supported upstream AI providers +// (openai_api, anthropic_api, bedrock_api, ...) with their default models and +// pricing, used to prefill provider create forms. +func (a *AgentNetworkAPI) ListCatalogProviders(ctx context.Context) ([]api.AgentNetworkCatalogProvider, error) { + resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/catalog/providers", nil, nil) + if err != nil { + return nil, err + } + if resp.Body != nil { + defer resp.Body.Close() + } + ret, err := parseResponse[[]api.AgentNetworkCatalogProvider](resp) + return ret, err +} + +// ListProviders lists all Agent Network providers +func (a *AgentNetworkAPI) ListProviders(ctx context.Context) ([]api.AgentNetworkProvider, error) { + resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/providers", nil, nil) + if err != nil { + return nil, err + } + if resp.Body != nil { + defer resp.Body.Close() + } + ret, err := parseResponse[[]api.AgentNetworkProvider](resp) + return ret, err +} + +// GetProvider gets Agent Network provider info +func (a *AgentNetworkAPI) GetProvider(ctx context.Context, providerID string) (*api.AgentNetworkProvider, error) { + resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/providers/"+providerID, nil, nil) + if err != nil { + return nil, err + } + if resp.Body != nil { + defer resp.Body.Close() + } + ret, err := parseResponse[api.AgentNetworkProvider](resp) + return &ret, err +} + +// CreateProvider creates a new Agent Network provider. Set +// request.BootstrapCluster on the account's first provider to bootstrap the +// per-account gateway endpoint (alternatively bootstrap via UpdateSettings +// with a cluster). +func (a *AgentNetworkAPI) CreateProvider(ctx context.Context, request api.PostApiAgentNetworkProvidersJSONRequestBody) (*api.AgentNetworkProvider, error) { + requestBytes, err := json.Marshal(request) + if err != nil { + return nil, err + } + resp, err := a.c.NewRequest(ctx, "POST", "/api/agent-network/providers", bytes.NewReader(requestBytes), nil) + if err != nil { + return nil, err + } + if resp.Body != nil { + defer resp.Body.Close() + } + ret, err := parseResponse[api.AgentNetworkProvider](resp) + return &ret, err +} + +// UpdateProvider updates an Agent Network provider. The request replaces the +// provider's mutable state; only an omitted api_key keeps the stored key +// (secrets are never required to round-trip). +func (a *AgentNetworkAPI) UpdateProvider(ctx context.Context, providerID string, request api.PutApiAgentNetworkProvidersProviderIdJSONRequestBody) (*api.AgentNetworkProvider, error) { + requestBytes, err := json.Marshal(request) + if err != nil { + return nil, err + } + resp, err := a.c.NewRequest(ctx, "PUT", "/api/agent-network/providers/"+providerID, bytes.NewReader(requestBytes), nil) + if err != nil { + return nil, err + } + if resp.Body != nil { + defer resp.Body.Close() + } + ret, err := parseResponse[api.AgentNetworkProvider](resp) + return &ret, err +} + +// DeleteProvider deletes an Agent Network provider. Fails while any policy +// still references the provider — detach it first. +func (a *AgentNetworkAPI) DeleteProvider(ctx context.Context, providerID string) error { + resp, err := a.c.NewRequest(ctx, "DELETE", "/api/agent-network/providers/"+providerID, nil, nil) + if err != nil { + return err + } + if resp.Body != nil { + defer resp.Body.Close() + } + + return nil +} + +// ListPolicies lists all Agent Network policies +func (a *AgentNetworkAPI) ListPolicies(ctx context.Context) ([]api.AgentNetworkPolicy, error) { + resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/policies", nil, nil) + if err != nil { + return nil, err + } + if resp.Body != nil { + defer resp.Body.Close() + } + ret, err := parseResponse[[]api.AgentNetworkPolicy](resp) + return ret, err +} + +// GetPolicy gets Agent Network policy info +func (a *AgentNetworkAPI) GetPolicy(ctx context.Context, policyID string) (*api.AgentNetworkPolicy, error) { + resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/policies/"+policyID, nil, nil) + if err != nil { + return nil, err + } + if resp.Body != nil { + defer resp.Body.Close() + } + ret, err := parseResponse[api.AgentNetworkPolicy](resp) + return &ret, err +} + +// CreatePolicy creates a new Agent Network policy +func (a *AgentNetworkAPI) CreatePolicy(ctx context.Context, request api.PostApiAgentNetworkPoliciesJSONRequestBody) (*api.AgentNetworkPolicy, error) { + requestBytes, err := json.Marshal(request) + if err != nil { + return nil, err + } + resp, err := a.c.NewRequest(ctx, "POST", "/api/agent-network/policies", bytes.NewReader(requestBytes), nil) + if err != nil { + return nil, err + } + if resp.Body != nil { + defer resp.Body.Close() + } + ret, err := parseResponse[api.AgentNetworkPolicy](resp) + return &ret, err +} + +// UpdatePolicy updates an Agent Network policy +func (a *AgentNetworkAPI) UpdatePolicy(ctx context.Context, policyID string, request api.PutApiAgentNetworkPoliciesPolicyIdJSONRequestBody) (*api.AgentNetworkPolicy, error) { + requestBytes, err := json.Marshal(request) + if err != nil { + return nil, err + } + resp, err := a.c.NewRequest(ctx, "PUT", "/api/agent-network/policies/"+policyID, bytes.NewReader(requestBytes), nil) + if err != nil { + return nil, err + } + if resp.Body != nil { + defer resp.Body.Close() + } + ret, err := parseResponse[api.AgentNetworkPolicy](resp) + return &ret, err +} + +// DeletePolicy deletes an Agent Network policy +func (a *AgentNetworkAPI) DeletePolicy(ctx context.Context, policyID string) error { + resp, err := a.c.NewRequest(ctx, "DELETE", "/api/agent-network/policies/"+policyID, nil, nil) + if err != nil { + return err + } + if resp.Body != nil { + defer resp.Body.Close() + } + + return nil +} + +// ListGuardrails lists all Agent Network guardrails +func (a *AgentNetworkAPI) ListGuardrails(ctx context.Context) ([]api.AgentNetworkGuardrail, error) { + resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/guardrails", nil, nil) + if err != nil { + return nil, err + } + if resp.Body != nil { + defer resp.Body.Close() + } + ret, err := parseResponse[[]api.AgentNetworkGuardrail](resp) + return ret, err +} + +// GetGuardrail gets Agent Network guardrail info +func (a *AgentNetworkAPI) GetGuardrail(ctx context.Context, guardrailID string) (*api.AgentNetworkGuardrail, error) { + resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/guardrails/"+guardrailID, nil, nil) + if err != nil { + return nil, err + } + if resp.Body != nil { + defer resp.Body.Close() + } + ret, err := parseResponse[api.AgentNetworkGuardrail](resp) + return &ret, err +} + +// CreateGuardrail creates a new Agent Network guardrail +func (a *AgentNetworkAPI) CreateGuardrail(ctx context.Context, request api.PostApiAgentNetworkGuardrailsJSONRequestBody) (*api.AgentNetworkGuardrail, error) { + requestBytes, err := json.Marshal(request) + if err != nil { + return nil, err + } + resp, err := a.c.NewRequest(ctx, "POST", "/api/agent-network/guardrails", bytes.NewReader(requestBytes), nil) + if err != nil { + return nil, err + } + if resp.Body != nil { + defer resp.Body.Close() + } + ret, err := parseResponse[api.AgentNetworkGuardrail](resp) + return &ret, err +} + +// UpdateGuardrail updates an Agent Network guardrail +func (a *AgentNetworkAPI) UpdateGuardrail(ctx context.Context, guardrailID string, request api.PutApiAgentNetworkGuardrailsGuardrailIdJSONRequestBody) (*api.AgentNetworkGuardrail, error) { + requestBytes, err := json.Marshal(request) + if err != nil { + return nil, err + } + resp, err := a.c.NewRequest(ctx, "PUT", "/api/agent-network/guardrails/"+guardrailID, bytes.NewReader(requestBytes), nil) + if err != nil { + return nil, err + } + if resp.Body != nil { + defer resp.Body.Close() + } + ret, err := parseResponse[api.AgentNetworkGuardrail](resp) + return &ret, err +} + +// DeleteGuardrail deletes an Agent Network guardrail +func (a *AgentNetworkAPI) DeleteGuardrail(ctx context.Context, guardrailID string) error { + resp, err := a.c.NewRequest(ctx, "DELETE", "/api/agent-network/guardrails/"+guardrailID, nil, nil) + if err != nil { + return err + } + if resp.Body != nil { + defer resp.Body.Close() + } + + return nil +} + +// ListBudgetRules lists all account-level Agent Network budget rules +func (a *AgentNetworkAPI) ListBudgetRules(ctx context.Context) ([]api.AgentNetworkBudgetRule, error) { + resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/budget-rules", nil, nil) + if err != nil { + return nil, err + } + if resp.Body != nil { + defer resp.Body.Close() + } + ret, err := parseResponse[[]api.AgentNetworkBudgetRule](resp) + return ret, err +} + +// GetBudgetRule gets Agent Network budget rule info +func (a *AgentNetworkAPI) GetBudgetRule(ctx context.Context, ruleID string) (*api.AgentNetworkBudgetRule, error) { + resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/budget-rules/"+ruleID, nil, nil) + if err != nil { + return nil, err + } + if resp.Body != nil { + defer resp.Body.Close() + } + ret, err := parseResponse[api.AgentNetworkBudgetRule](resp) + return &ret, err +} + +// CreateBudgetRule creates a new Agent Network budget rule +func (a *AgentNetworkAPI) CreateBudgetRule(ctx context.Context, request api.PostApiAgentNetworkBudgetRulesJSONRequestBody) (*api.AgentNetworkBudgetRule, error) { + requestBytes, err := json.Marshal(request) + if err != nil { + return nil, err + } + resp, err := a.c.NewRequest(ctx, "POST", "/api/agent-network/budget-rules", bytes.NewReader(requestBytes), nil) + if err != nil { + return nil, err + } + if resp.Body != nil { + defer resp.Body.Close() + } + ret, err := parseResponse[api.AgentNetworkBudgetRule](resp) + return &ret, err +} + +// UpdateBudgetRule updates an Agent Network budget rule +func (a *AgentNetworkAPI) UpdateBudgetRule(ctx context.Context, ruleID string, request api.PutApiAgentNetworkBudgetRulesRuleIdJSONRequestBody) (*api.AgentNetworkBudgetRule, error) { + requestBytes, err := json.Marshal(request) + if err != nil { + return nil, err + } + resp, err := a.c.NewRequest(ctx, "PUT", "/api/agent-network/budget-rules/"+ruleID, bytes.NewReader(requestBytes), nil) + if err != nil { + return nil, err + } + if resp.Body != nil { + defer resp.Body.Close() + } + ret, err := parseResponse[api.AgentNetworkBudgetRule](resp) + return &ret, err +} + +// DeleteBudgetRule deletes an Agent Network budget rule +func (a *AgentNetworkAPI) DeleteBudgetRule(ctx context.Context, ruleID string) error { + resp, err := a.c.NewRequest(ctx, "DELETE", "/api/agent-network/budget-rules/"+ruleID, nil, nil) + if err != nil { + return err + } + if resp.Body != nil { + defer resp.Body.Close() + } + + return nil +} + +// GetSettings gets the account's Agent Network gateway settings (cluster, +// subdomain, endpoint, collection toggles). An account that has not been +// bootstrapped yet — via UpdateSettings with a cluster, or by creating the +// first provider with bootstrap_cluster set — reads as the defaults with an +// empty Cluster, Subdomain and Endpoint. Management servers prior to that +// contract answered 200 with a JSON null body instead; that legacy shape is +// translated to an APIError matchable via IsNotFound rather than fabricating +// defaults the server never stated. +func (a *AgentNetworkAPI) GetSettings(ctx context.Context) (*api.AgentNetworkSettings, error) { + resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/settings", nil, nil) + if err != nil { + return nil, err + } + if resp.Body != nil { + defer resp.Body.Close() + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if trimmed := bytes.TrimSpace(body); len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { + return nil, &APIError{StatusCode: http.StatusNotFound, Message: "agent network settings not found"} + } + var ret api.AgentNetworkSettings + if err := json.Unmarshal(body, &ret); err != nil { + return nil, err + } + return &ret, nil +} + +// UpdateSettings updates the account's Agent Network settings; the request +// replaces every mutable field (collection toggles and retention). Setting +// request.Cluster bootstraps the settings row when the account does not have +// one yet; on a bootstrapped account it must match the assigned cluster (or +// be nil) and any other value is rejected — the cluster is immutable. +func (a *AgentNetworkAPI) UpdateSettings(ctx context.Context, request api.PutApiAgentNetworkSettingsJSONRequestBody) (*api.AgentNetworkSettings, error) { + requestBytes, err := json.Marshal(request) + if err != nil { + return nil, err + } + resp, err := a.c.NewRequest(ctx, "PUT", "/api/agent-network/settings", bytes.NewReader(requestBytes), nil) + if err != nil { + return nil, err + } + if resp.Body != nil { + defer resp.Body.Close() + } + ret, err := parseResponse[api.AgentNetworkSettings](resp) + return &ret, err +} diff --git a/shared/management/client/rest/agentnetwork_test.go b/shared/management/client/rest/agentnetwork_test.go new file mode 100644 index 000000000..053859125 --- /dev/null +++ b/shared/management/client/rest/agentnetwork_test.go @@ -0,0 +1,497 @@ +//go:build integration + +package rest_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/shared/management/client/rest" + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/http/util" +) + +var ( + testAgentNetworkProvider = api.AgentNetworkProvider{ + Id: "ainp_test", + ProviderId: "openai_api", + Name: "OpenAI", + UpstreamUrl: "https://api.openai.com", + Models: []api.AgentNetworkProviderModel{}, + Enabled: true, + } + + testAgentNetworkPolicy = api.AgentNetworkPolicy{ + Id: "ainpol_test", + Name: "Engineering → OpenAI", + Enabled: true, + SourceGroups: []string{"grp-eng"}, + DestinationProviderIds: []string{"ainp_test"}, + } + + testAgentNetworkGuardrail = api.AgentNetworkGuardrail{ + Id: "aingr_test", + Name: "No secrets", + } + + testAgentNetworkBudgetRule = api.AgentNetworkBudgetRule{ + Id: "ainbud_test", + Name: "Org monthly ceiling", + Enabled: true, + } + + testAgentNetworkSettings = api.AgentNetworkSettings{ + Cluster: "eu.proxy.netbird.io", + Subdomain: "violet", + Endpoint: "violet.eu.proxy.netbird.io", + EnableLogCollection: true, + AccessLogRetentionDays: ptr(30), + } +) + +func TestAgentNetwork_ListCatalogProviders_200(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/agent-network/catalog/providers", func(w http.ResponseWriter, r *http.Request) { + retBytes, _ := json.Marshal([]api.AgentNetworkCatalogProvider{{Id: "openai_api", Name: "OpenAI"}}) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.AgentNetwork.ListCatalogProviders(context.Background()) + require.NoError(t, err) + assert.Len(t, ret, 1) + assert.Equal(t, "openai_api", ret[0].Id) + }) +} + +func TestAgentNetwork_ListProviders_200(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/agent-network/providers", func(w http.ResponseWriter, r *http.Request) { + retBytes, _ := json.Marshal([]api.AgentNetworkProvider{testAgentNetworkProvider}) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.AgentNetwork.ListProviders(context.Background()) + require.NoError(t, err) + assert.Len(t, ret, 1) + assert.Equal(t, testAgentNetworkProvider, ret[0]) + }) +} + +func TestAgentNetwork_GetProvider_200(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/agent-network/providers/ainp_test", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + retBytes, _ := json.Marshal(testAgentNetworkProvider) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.AgentNetwork.GetProvider(context.Background(), "ainp_test") + require.NoError(t, err) + assert.Equal(t, testAgentNetworkProvider, *ret) + }) +} + +func TestAgentNetwork_GetProvider_Err(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/agent-network/providers/ainp_test", func(w http.ResponseWriter, r *http.Request) { + retBytes, _ := json.Marshal(util.ErrorResponse{Message: "not found", Code: 404}) + w.WriteHeader(404) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + _, err := c.AgentNetwork.GetProvider(context.Background(), "ainp_test") + require.Error(t, err) + assert.True(t, rest.IsNotFound(err), "a 404 must be matchable via IsNotFound") + }) +} + +func TestAgentNetwork_CreateProvider_200(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/agent-network/providers", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + reqBytes, err := io.ReadAll(r.Body) + require.NoError(t, err) + var req api.PostApiAgentNetworkProvidersJSONRequestBody + require.NoError(t, json.Unmarshal(reqBytes, &req)) + assert.Equal(t, "OpenAI", req.Name) + require.NotNil(t, req.BootstrapCluster) + assert.Equal(t, "eu.proxy.netbird.io", *req.BootstrapCluster) + retBytes, _ := json.Marshal(testAgentNetworkProvider) + _, err = w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.AgentNetwork.CreateProvider(context.Background(), api.PostApiAgentNetworkProvidersJSONRequestBody{ + ProviderId: "openai_api", + Name: "OpenAI", + UpstreamUrl: "https://api.openai.com", + ApiKey: ptr("sk-test"), + BootstrapCluster: ptr("eu.proxy.netbird.io"), + }) + require.NoError(t, err) + assert.Equal(t, testAgentNetworkProvider, *ret) + }) +} + +func TestAgentNetwork_UpdateProvider_200(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/agent-network/providers/ainp_test", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "PUT", r.Method) + reqBytes, err := io.ReadAll(r.Body) + require.NoError(t, err) + // Omitted optional fields must be absent from the wire (not + // zero-valued) so the server-side merge preserves them. + assert.NotContains(t, string(reqBytes), "api_key") + assert.NotContains(t, string(reqBytes), "models") + retBytes, _ := json.Marshal(testAgentNetworkProvider) + _, err = w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.AgentNetwork.UpdateProvider(context.Background(), "ainp_test", api.PutApiAgentNetworkProvidersProviderIdJSONRequestBody{ + ProviderId: "openai_api", + Name: "OpenAI", + UpstreamUrl: "https://api.openai.com", + }) + require.NoError(t, err) + assert.Equal(t, testAgentNetworkProvider, *ret) + }) +} + +func TestAgentNetwork_DeleteProvider_200(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/agent-network/providers/ainp_test", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "DELETE", r.Method) + _, err := w.Write([]byte("{}")) + require.NoError(t, err) + }) + err := c.AgentNetwork.DeleteProvider(context.Background(), "ainp_test") + require.NoError(t, err) + }) +} + +func TestAgentNetwork_ListPolicies_200(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/agent-network/policies", func(w http.ResponseWriter, r *http.Request) { + retBytes, _ := json.Marshal([]api.AgentNetworkPolicy{testAgentNetworkPolicy}) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.AgentNetwork.ListPolicies(context.Background()) + require.NoError(t, err) + assert.Len(t, ret, 1) + assert.Equal(t, testAgentNetworkPolicy, ret[0]) + }) +} + +func TestAgentNetwork_GetPolicy_200(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/agent-network/policies/ainpol_test", func(w http.ResponseWriter, r *http.Request) { + retBytes, _ := json.Marshal(testAgentNetworkPolicy) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.AgentNetwork.GetPolicy(context.Background(), "ainpol_test") + require.NoError(t, err) + assert.Equal(t, testAgentNetworkPolicy, *ret) + }) +} + +func TestAgentNetwork_CreatePolicy_200(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/agent-network/policies", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + retBytes, _ := json.Marshal(testAgentNetworkPolicy) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.AgentNetwork.CreatePolicy(context.Background(), api.PostApiAgentNetworkPoliciesJSONRequestBody{ + Name: "Engineering → OpenAI", + SourceGroups: []string{"grp-eng"}, + DestinationProviderIds: []string{"ainp_test"}, + }) + require.NoError(t, err) + assert.Equal(t, testAgentNetworkPolicy, *ret) + }) +} + +func TestAgentNetwork_UpdatePolicy_200(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/agent-network/policies/ainpol_test", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "PUT", r.Method) + retBytes, _ := json.Marshal(testAgentNetworkPolicy) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.AgentNetwork.UpdatePolicy(context.Background(), "ainpol_test", api.PutApiAgentNetworkPoliciesPolicyIdJSONRequestBody{ + Name: "Engineering → OpenAI", + SourceGroups: []string{"grp-eng"}, + DestinationProviderIds: []string{"ainp_test"}, + }) + require.NoError(t, err) + assert.Equal(t, testAgentNetworkPolicy, *ret) + }) +} + +func TestAgentNetwork_DeletePolicy_200(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/agent-network/policies/ainpol_test", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "DELETE", r.Method) + _, err := w.Write([]byte("{}")) + require.NoError(t, err) + }) + err := c.AgentNetwork.DeletePolicy(context.Background(), "ainpol_test") + require.NoError(t, err) + }) +} + +func TestAgentNetwork_ListGuardrails_200(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/agent-network/guardrails", func(w http.ResponseWriter, r *http.Request) { + retBytes, _ := json.Marshal([]api.AgentNetworkGuardrail{testAgentNetworkGuardrail}) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.AgentNetwork.ListGuardrails(context.Background()) + require.NoError(t, err) + assert.Len(t, ret, 1) + assert.Equal(t, testAgentNetworkGuardrail, ret[0]) + }) +} + +func TestAgentNetwork_GetGuardrail_200(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/agent-network/guardrails/aingr_test", func(w http.ResponseWriter, r *http.Request) { + retBytes, _ := json.Marshal(testAgentNetworkGuardrail) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.AgentNetwork.GetGuardrail(context.Background(), "aingr_test") + require.NoError(t, err) + assert.Equal(t, testAgentNetworkGuardrail, *ret) + }) +} + +func TestAgentNetwork_CreateGuardrail_200(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/agent-network/guardrails", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + retBytes, _ := json.Marshal(testAgentNetworkGuardrail) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.AgentNetwork.CreateGuardrail(context.Background(), api.PostApiAgentNetworkGuardrailsJSONRequestBody{ + Name: "No secrets", + }) + require.NoError(t, err) + assert.Equal(t, testAgentNetworkGuardrail, *ret) + }) +} + +func TestAgentNetwork_UpdateGuardrail_200(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/agent-network/guardrails/aingr_test", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "PUT", r.Method) + retBytes, _ := json.Marshal(testAgentNetworkGuardrail) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.AgentNetwork.UpdateGuardrail(context.Background(), "aingr_test", api.PutApiAgentNetworkGuardrailsGuardrailIdJSONRequestBody{ + Name: "No secrets", + }) + require.NoError(t, err) + assert.Equal(t, testAgentNetworkGuardrail, *ret) + }) +} + +func TestAgentNetwork_DeleteGuardrail_200(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/agent-network/guardrails/aingr_test", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "DELETE", r.Method) + _, err := w.Write([]byte("{}")) + require.NoError(t, err) + }) + err := c.AgentNetwork.DeleteGuardrail(context.Background(), "aingr_test") + require.NoError(t, err) + }) +} + +func TestAgentNetwork_ListBudgetRules_200(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/agent-network/budget-rules", func(w http.ResponseWriter, r *http.Request) { + retBytes, _ := json.Marshal([]api.AgentNetworkBudgetRule{testAgentNetworkBudgetRule}) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.AgentNetwork.ListBudgetRules(context.Background()) + require.NoError(t, err) + assert.Len(t, ret, 1) + assert.Equal(t, testAgentNetworkBudgetRule, ret[0]) + }) +} + +func TestAgentNetwork_GetBudgetRule_200(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/agent-network/budget-rules/ainbud_test", func(w http.ResponseWriter, r *http.Request) { + retBytes, _ := json.Marshal(testAgentNetworkBudgetRule) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.AgentNetwork.GetBudgetRule(context.Background(), "ainbud_test") + require.NoError(t, err) + assert.Equal(t, testAgentNetworkBudgetRule, *ret) + }) +} + +func TestAgentNetwork_CreateBudgetRule_200(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/agent-network/budget-rules", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + retBytes, _ := json.Marshal(testAgentNetworkBudgetRule) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.AgentNetwork.CreateBudgetRule(context.Background(), api.PostApiAgentNetworkBudgetRulesJSONRequestBody{ + Name: "Org monthly ceiling", + }) + require.NoError(t, err) + assert.Equal(t, testAgentNetworkBudgetRule, *ret) + }) +} + +func TestAgentNetwork_UpdateBudgetRule_200(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/agent-network/budget-rules/ainbud_test", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "PUT", r.Method) + retBytes, _ := json.Marshal(testAgentNetworkBudgetRule) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.AgentNetwork.UpdateBudgetRule(context.Background(), "ainbud_test", api.PutApiAgentNetworkBudgetRulesRuleIdJSONRequestBody{ + Name: "Org monthly ceiling", + }) + require.NoError(t, err) + assert.Equal(t, testAgentNetworkBudgetRule, *ret) + }) +} + +func TestAgentNetwork_DeleteBudgetRule_200(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/agent-network/budget-rules/ainbud_test", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "DELETE", r.Method) + _, err := w.Write([]byte("{}")) + require.NoError(t, err) + }) + err := c.AgentNetwork.DeleteBudgetRule(context.Background(), "ainbud_test") + require.NoError(t, err) + }) +} + +func TestAgentNetwork_GetSettings_200(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) { + retBytes, _ := json.Marshal(testAgentNetworkSettings) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.AgentNetwork.GetSettings(context.Background()) + require.NoError(t, err) + assert.Equal(t, testAgentNetworkSettings, *ret) + }) +} + +// TestAgentNetwork_GetSettings_UnbootstrappedDefaults pins the settings-read +// contract: an unbootstrapped account answers 200 with the defaults and empty +// cluster/subdomain/endpoint, which the client passes through untouched. +func TestAgentNetwork_GetSettings_UnbootstrappedDefaults(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) { + retBytes, _ := json.Marshal(api.AgentNetworkSettings{ + EnableLogCollection: true, + AccessLogRetentionDays: ptr(30), + }) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.AgentNetwork.GetSettings(context.Background()) + require.NoError(t, err) + assert.Empty(t, ret.Endpoint, "empty endpoint is the not-bootstrapped signal") + assert.True(t, ret.EnableLogCollection, "defaults must pass through") + }) +} + +func TestAgentNetwork_GetSettings_Err(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) { + retBytes, _ := json.Marshal(util.ErrorResponse{Message: "no", Code: 403}) + w.WriteHeader(403) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + _, err := c.AgentNetwork.GetSettings(context.Background()) + require.Error(t, err) + assert.Equal(t, "no", err.Error()) + }) +} + +// TestAgentNetwork_GetSettings_LegacyNullBody pins the compatibility shim for +// management servers that answered 200 with a JSON null body before the +// defaults contract: the client translates that shape into an IsNotFound +// error instead of returning a bogus zero-valued settings object or +// fabricating defaults the server never stated. +func TestAgentNetwork_GetSettings_LegacyNullBody(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) { + _, err := w.Write([]byte("null")) + require.NoError(t, err) + }) + ret, err := c.AgentNetwork.GetSettings(context.Background()) + require.Error(t, err) + assert.Nil(t, ret) + assert.True(t, rest.IsNotFound(err), "the legacy 200+null shape must surface as IsNotFound") + }) +} + +func TestAgentNetwork_UpdateSettings_200(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "PUT", r.Method) + reqBytes, err := io.ReadAll(r.Body) + require.NoError(t, err) + var req api.PutApiAgentNetworkSettingsJSONRequestBody + require.NoError(t, json.Unmarshal(reqBytes, &req)) + require.NotNil(t, req.Cluster, "bootstrap cluster must be on the wire") + assert.Equal(t, "eu.proxy.netbird.io", *req.Cluster) + assert.True(t, req.EnableLogCollection) + retBytes, _ := json.Marshal(testAgentNetworkSettings) + _, err = w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.AgentNetwork.UpdateSettings(context.Background(), api.PutApiAgentNetworkSettingsJSONRequestBody{ + Cluster: ptr("eu.proxy.netbird.io"), + EnableLogCollection: true, + }) + require.NoError(t, err) + assert.Equal(t, testAgentNetworkSettings, *ret) + }) +} + +func TestAgentNetwork_UpdateSettings_Err(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) { + retBytes, _ := json.Marshal(util.ErrorResponse{Message: "cluster is immutable once assigned (current: eu.proxy.netbird.io)", Code: 422}) + w.WriteHeader(422) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + _, err := c.AgentNetwork.UpdateSettings(context.Background(), api.PutApiAgentNetworkSettingsJSONRequestBody{ + Cluster: ptr("us.proxy.netbird.io"), + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "immutable") + }) +} diff --git a/shared/management/client/rest/client.go b/shared/management/client/rest/client.go index 43312b9e6..6154a6637 100644 --- a/shared/management/client/rest/client.go +++ b/shared/management/client/rest/client.go @@ -147,6 +147,10 @@ type Client struct { // ReverseProxyTokens account-scoped proxy access tokens used to register // self-hosted (bring-your-own-proxy) `netbird proxy` instances. ReverseProxyTokens *ReverseProxyTokensAPI + + // AgentNetwork NetBird Agent Network (AI/LLM gateway) APIs: catalog, + // providers, policies, guardrails, budget rules and account settings. + AgentNetwork *AgentNetworkAPI } // New initialize new Client instance using PAT token @@ -209,6 +213,7 @@ func (c *Client) initialize() { c.ReverseProxyClusters = &ReverseProxyClustersAPI{c} c.ReverseProxyDomains = &ReverseProxyDomainsAPI{c} c.ReverseProxyTokens = &ReverseProxyTokensAPI{c} + c.AgentNetwork = &AgentNetworkAPI{c} } // NewRequest creates and executes new management API request diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index 8ad3d932c..551a60e2a 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -5149,12 +5149,12 @@ components: identity_header_user_id: type: string description: | - Wire header name the proxy stamps with the caller's display identity (user email or peer name) when the catalog entry's HeaderPair is `customizable`. Empty disables stamping for this dimension. Ignored when the catalog entry has a fixed HeaderPair (e.g. LiteLLM, Portkey). Used today by Bifrost: typical values are `x-bf-lh-netbird_user_id` (always-on log metadata) or `x-bf-dim-netbird_user_id` (Prometheus / OTEL — requires the label to be pre-declared in the gateway's `client.prometheus_labels` config). + Wire header name the proxy stamps with the caller's display identity (user email or peer name) when the catalog entry's HeaderPair is `customizable`. Always present in responses; empty disables stamping for this dimension. Ignored when the catalog entry has a fixed HeaderPair (e.g. LiteLLM, Portkey). Used today by Bifrost: typical values are `x-bf-lh-netbird_user_id` (always-on log metadata) or `x-bf-dim-netbird_user_id` (Prometheus / OTEL — requires the label to be pre-declared in the gateway's `client.prometheus_labels` config). example: "x-bf-dim-netbird_user_id" identity_header_groups: type: string description: | - Wire header name the proxy stamps with the caller's NetBird groups as a comma-separated list (sorted) when the catalog entry's HeaderPair is `customizable`. Empty disables stamping for this dimension. Same per-catalog semantics as `identity_header_user_id`. + Wire header name the proxy stamps with the caller's NetBird groups as a comma-separated list (sorted) when the catalog entry's HeaderPair is `customizable`. Always present in responses; empty disables stamping for this dimension. Same per-catalog semantics as `identity_header_user_id`. example: "x-bf-dim-netbird_groups" enabled: type: boolean @@ -5186,6 +5186,8 @@ components: - name - upstream_url - models + - identity_header_user_id + - identity_header_groups - enabled - skip_tls_verification - metadata_disabled @@ -5222,7 +5224,7 @@ components: extra_values: type: object description: | - Operator-typed values for catalog-declared extra headers (see AgentNetworkProvider.extra_values). When present on a request, the whole map replaces the stored values. Empty strings drop the corresponding key. + Operator-typed values for catalog-declared extra headers (see AgentNetworkProvider.extra_values). The request's map replaces the stored values; empty strings drop the corresponding key. additionalProperties: type: string example: @@ -5230,12 +5232,12 @@ components: identity_header_user_id: type: string description: | - Wire header name for the caller's display identity. See AgentNetworkProvider.identity_header_user_id. When omitted on a request, the stored value is left unchanged; pass an empty string explicitly to clear it (which disables stamping for this dimension). + Wire header name for the caller's display identity. See AgentNetworkProvider.identity_header_user_id. Empty or omitted disables stamping for this dimension. example: "x-bf-dim-netbird_user_id" identity_header_groups: type: string description: | - Wire header name for the caller's groups CSV. See AgentNetworkProvider.identity_header_groups. Same omit / empty semantics as `identity_header_user_id`. + Wire header name for the caller's groups CSV. See AgentNetworkProvider.identity_header_groups. Same semantics as `identity_header_user_id`. example: "x-bf-dim-netbird_groups" enabled: type: boolean @@ -5243,11 +5245,11 @@ components: example: true skip_tls_verification: type: boolean - description: Skip upstream TLS certificate verification when the proxy dials this provider's URL. For self-hosted / internal gateways behind a private or self-signed certificate. Defaults to false. When omitted on update, the stored value is left unchanged. + description: Skip upstream TLS certificate verification when the proxy dials this provider's URL. For self-hosted / internal gateways behind a private or self-signed certificate. Defaults to false. example: false metadata_disabled: type: boolean - description: Disable identity metadata injection (the caller's user + authorizing group) for this provider. Defaults to false (metadata is injected). When omitted on update, the stored value is left unchanged. + description: Disable identity metadata injection (the caller's user + authorizing group) for this provider. Defaults to false (metadata is injected). example: false required: - provider_id @@ -6191,19 +6193,19 @@ components: - cache_cost_usd AgentNetworkSettings: type: object - description: Per-account Agent Network gateway settings. One row per account; cluster and subdomain are auto-assigned on first provider create and immutable thereafter. + description: Per-account Agent Network gateway settings. One row per account; cluster and subdomain are assigned at bootstrap and immutable thereafter. Before bootstrap the account reads as the default values with empty cluster, subdomain and endpoint. properties: cluster: type: string - description: Address of the NetBird proxy cluster fronting this account's agent-network endpoint. + description: Address of the NetBird proxy cluster fronting this account's agent-network endpoint. Empty until the account is bootstrapped. example: "eu.proxy.netbird.io" subdomain: type: string - description: Auto-generated DNS-safe label that prefixes the cluster to form the agent-network endpoint. + description: Auto-generated DNS-safe label that prefixes the cluster to form the agent-network endpoint. Empty until the account is bootstrapped. example: "violet" endpoint: type: string - description: Bare hostname agents call for this account, computed as `.`. + description: Bare hostname agents call for this account, computed as `.`. Empty until the account is bootstrapped. example: "violet.eu.proxy.netbird.io" enable_log_collection: type: boolean @@ -6224,13 +6226,13 @@ components: created_at: type: string format: date-time - description: Timestamp when the settings row was created. + description: Timestamp when the settings row was created. Absent until the account is bootstrapped. readOnly: true example: "2026-04-26T10:30:00Z" updated_at: type: string format: date-time - description: Timestamp when the settings row was last updated. + description: Timestamp when the settings row was last updated. Absent until the account is bootstrapped. readOnly: true example: "2026-04-26T10:30:00Z" required: @@ -6240,12 +6242,14 @@ components: - enable_log_collection - enable_prompt_collection - redact_pii - - created_at - - updated_at AgentNetworkSettingsRequest: type: object - description: Mutable account-level Agent Network settings. Cluster and subdomain are immutable and not accepted here. + description: Account-level Agent Network settings update. The request replaces every mutable field. `cluster` additionally bootstraps the per-account settings row when the account does not have one yet; the subdomain is always server-assigned. properties: + cluster: + type: string + description: Address of the NetBird proxy cluster fronting this account's agent-network endpoint. When the account has no settings row yet, providing it bootstraps the row (assigning the subdomain that forms the agent endpoint). The cluster is immutable once assigned — later updates must omit it or send the assigned value; any other value is rejected. + example: "eu.proxy.netbird.io" enable_log_collection: type: boolean description: Whether per-request access-log entries are collected for this account's agent-network traffic. @@ -13690,7 +13694,7 @@ paths: /api/agent-network/settings: get: summary: Retrieve Agent Network settings - description: Returns the per-account Agent Network gateway settings (cluster, subdomain, endpoint). Returns 404 when no provider has been created yet — settings are lazily bootstrapped on first provider create. + description: Returns the per-account Agent Network gateway settings (cluster, subdomain, endpoint). Before the account is bootstrapped — on first provider create (`bootstrap_cluster`) or via PUT with `cluster` — the response carries the default values with empty cluster, subdomain and endpoint. tags: [ Agent Network ] security: - BearerAuth: [ ] @@ -13706,13 +13710,11 @@ paths: "$ref": "#/components/responses/requires_authentication" '403': "$ref": "#/components/responses/forbidden" - '404': - "$ref": "#/components/responses/not_found" '500': "$ref": "#/components/responses/internal_error" put: summary: Update Agent Network settings - description: Updates the mutable account-level Agent Network settings (collection toggles). Cluster and subdomain are immutable and ignored if sent. Returns 404 when settings have not been bootstrapped (no provider created yet). + description: Updates the account-level Agent Network settings; the request replaces every mutable field (collection toggles and retention). When the account has no settings row yet, providing `cluster` bootstraps it (assigning the subdomain that forms the agent endpoint); without `cluster` the request returns 404. Sending a `cluster` different from the assigned one is rejected (the cluster is immutable once assigned). The subdomain is always server-assigned and immutable. tags: [ Agent Network ] security: - BearerAuth: [ ] @@ -13738,6 +13740,8 @@ paths: "$ref": "#/components/responses/forbidden" '404': "$ref": "#/components/responses/not_found" + '422': + "$ref": "#/components/responses/validation_failed" '500': "$ref": "#/components/responses/internal_error" /api/agent-network/budget-rules: diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index 87dd9ccfc..dec644114 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -2275,11 +2275,11 @@ type AgentNetworkProvider struct { // Id Provider ID Id string `json:"id"` - // IdentityHeaderGroups Wire header name the proxy stamps with the caller's NetBird groups as a comma-separated list (sorted) when the catalog entry's HeaderPair is `customizable`. Empty disables stamping for this dimension. Same per-catalog semantics as `identity_header_user_id`. - IdentityHeaderGroups *string `json:"identity_header_groups,omitempty"` + // IdentityHeaderGroups Wire header name the proxy stamps with the caller's NetBird groups as a comma-separated list (sorted) when the catalog entry's HeaderPair is `customizable`. Always present in responses; empty disables stamping for this dimension. Same per-catalog semantics as `identity_header_user_id`. + IdentityHeaderGroups string `json:"identity_header_groups"` - // IdentityHeaderUserId Wire header name the proxy stamps with the caller's display identity (user email or peer name) when the catalog entry's HeaderPair is `customizable`. Empty disables stamping for this dimension. Ignored when the catalog entry has a fixed HeaderPair (e.g. LiteLLM, Portkey). Used today by Bifrost: typical values are `x-bf-lh-netbird_user_id` (always-on log metadata) or `x-bf-dim-netbird_user_id` (Prometheus / OTEL — requires the label to be pre-declared in the gateway's `client.prometheus_labels` config). - IdentityHeaderUserId *string `json:"identity_header_user_id,omitempty"` + // IdentityHeaderUserId Wire header name the proxy stamps with the caller's display identity (user email or peer name) when the catalog entry's HeaderPair is `customizable`. Always present in responses; empty disables stamping for this dimension. Ignored when the catalog entry has a fixed HeaderPair (e.g. LiteLLM, Portkey). Used today by Bifrost: typical values are `x-bf-lh-netbird_user_id` (always-on log metadata) or `x-bf-dim-netbird_user_id` (Prometheus / OTEL — requires the label to be pre-declared in the gateway's `client.prometheus_labels` config). + IdentityHeaderUserId string `json:"identity_header_user_id"` // MetadataDisabled Whether identity metadata injection is disabled for this provider. When enabled (the default), the proxy stamps the caller's user and authorizing group onto upstream requests as provider-specific metadata (e.g. AWS Bedrock's X-Amzn-Bedrock-Request-Metadata header). Set true to suppress it. MetadataDisabled bool `json:"metadata_disabled"` @@ -2335,16 +2335,16 @@ type AgentNetworkProviderRequest struct { // Enabled Whether the provider is enabled. Defaults to true on create. Enabled *bool `json:"enabled,omitempty"` - // ExtraValues Operator-typed values for catalog-declared extra headers (see AgentNetworkProvider.extra_values). When present on a request, the whole map replaces the stored values. Empty strings drop the corresponding key. + // ExtraValues Operator-typed values for catalog-declared extra headers (see AgentNetworkProvider.extra_values). The request's map replaces the stored values; empty strings drop the corresponding key. ExtraValues *map[string]string `json:"extra_values,omitempty"` - // IdentityHeaderGroups Wire header name for the caller's groups CSV. See AgentNetworkProvider.identity_header_groups. Same omit / empty semantics as `identity_header_user_id`. + // IdentityHeaderGroups Wire header name for the caller's groups CSV. See AgentNetworkProvider.identity_header_groups. Same semantics as `identity_header_user_id`. IdentityHeaderGroups *string `json:"identity_header_groups,omitempty"` - // IdentityHeaderUserId Wire header name for the caller's display identity. See AgentNetworkProvider.identity_header_user_id. When omitted on a request, the stored value is left unchanged; pass an empty string explicitly to clear it (which disables stamping for this dimension). + // IdentityHeaderUserId Wire header name for the caller's display identity. See AgentNetworkProvider.identity_header_user_id. Empty or omitted disables stamping for this dimension. IdentityHeaderUserId *string `json:"identity_header_user_id,omitempty"` - // MetadataDisabled Disable identity metadata injection (the caller's user + authorizing group) for this provider. Defaults to false (metadata is injected). When omitted on update, the stored value is left unchanged. + // MetadataDisabled Disable identity metadata injection (the caller's user + authorizing group) for this provider. Defaults to false (metadata is injected). MetadataDisabled *bool `json:"metadata_disabled,omitempty"` // Models Models exposed through this endpoint, with the operator's per-1k input/output prices. Empty means all catalog models are allowed at catalog prices. @@ -2356,22 +2356,22 @@ type AgentNetworkProviderRequest struct { // ProviderId Catalog identifier for the upstream AI provider (e.g. openai_api, anthropic_api, azure_openai_api, bedrock_api, vertex_ai_api, mistral_api, custom). ProviderId string `json:"provider_id"` - // SkipTlsVerification Skip upstream TLS certificate verification when the proxy dials this provider's URL. For self-hosted / internal gateways behind a private or self-signed certificate. Defaults to false. When omitted on update, the stored value is left unchanged. + // SkipTlsVerification Skip upstream TLS certificate verification when the proxy dials this provider's URL. For self-hosted / internal gateways behind a private or self-signed certificate. Defaults to false. SkipTlsVerification *bool `json:"skip_tls_verification,omitempty"` // UpstreamUrl Full upstream URL (with scheme) that NetBird forwards traffic to. UpstreamUrl string `json:"upstream_url"` } -// AgentNetworkSettings Per-account Agent Network gateway settings. One row per account; cluster and subdomain are auto-assigned on first provider create and immutable thereafter. +// AgentNetworkSettings Per-account Agent Network gateway settings. One row per account; cluster and subdomain are assigned at bootstrap and immutable thereafter. Before bootstrap the account reads as the default values with empty cluster, subdomain and endpoint. type AgentNetworkSettings struct { // AccessLogRetentionDays Days to retain full access-log rows; older rows are swept. 0 or less means keep indefinitely. Usage records are retained independently. AccessLogRetentionDays *int `json:"access_log_retention_days,omitempty"` - // Cluster Address of the NetBird proxy cluster fronting this account's agent-network endpoint. + // Cluster Address of the NetBird proxy cluster fronting this account's agent-network endpoint. Empty until the account is bootstrapped. Cluster string `json:"cluster"` - // CreatedAt Timestamp when the settings row was created. + // CreatedAt Timestamp when the settings row was created. Absent until the account is bootstrapped. CreatedAt *time.Time `json:"created_at,omitempty"` // EnableLogCollection Whether per-request access-log entries are collected for this account's agent-network traffic. @@ -2380,24 +2380,27 @@ type AgentNetworkSettings struct { // EnablePromptCollection Master switch for request/response prompt capture. Capture runs only when this is on AND a policy guardrail also enables it. EnablePromptCollection bool `json:"enable_prompt_collection"` - // Endpoint Bare hostname agents call for this account, computed as `.`. + // Endpoint Bare hostname agents call for this account, computed as `.`. Empty until the account is bootstrapped. Endpoint string `json:"endpoint"` // RedactPii Whether captured prompts have PII redacted. Effective redaction is the OR of this and any policy guardrail's redact setting. RedactPii bool `json:"redact_pii"` - // Subdomain Auto-generated DNS-safe label that prefixes the cluster to form the agent-network endpoint. + // Subdomain Auto-generated DNS-safe label that prefixes the cluster to form the agent-network endpoint. Empty until the account is bootstrapped. Subdomain string `json:"subdomain"` - // UpdatedAt Timestamp when the settings row was last updated. + // UpdatedAt Timestamp when the settings row was last updated. Absent until the account is bootstrapped. UpdatedAt *time.Time `json:"updated_at,omitempty"` } -// AgentNetworkSettingsRequest Mutable account-level Agent Network settings. Cluster and subdomain are immutable and not accepted here. +// AgentNetworkSettingsRequest Account-level Agent Network settings update. The request replaces every mutable field. `cluster` additionally bootstraps the per-account settings row when the account does not have one yet; the subdomain is always server-assigned. type AgentNetworkSettingsRequest struct { // AccessLogRetentionDays Days to retain full access-log rows; older rows are swept. 0 or less means keep indefinitely. AccessLogRetentionDays *int `json:"access_log_retention_days,omitempty"` + // Cluster Address of the NetBird proxy cluster fronting this account's agent-network endpoint. When the account has no settings row yet, providing it bootstraps the row (assigning the subdomain that forms the agent endpoint). The cluster is immutable once assigned — later updates must omit it or send the assigned value; any other value is rejected. + Cluster *string `json:"cluster,omitempty"` + // EnableLogCollection Whether per-request access-log entries are collected for this account's agent-network traffic. EnableLogCollection bool `json:"enable_log_collection"`