[management] Surface the settings ETag through the REST client

The Terraform provider consumes this client, and it is the client that has the
read-modify-write problem conditional requests solve, so the server-side work
is inert until the validator reaches it.

The new methods are additive and the existing four delegate to them unchanged.
A breaking signature change would buy a tidier surface at the cost of every
current caller, with no deprecation window, and the plain methods remain the
right default for callers that do not care.

CreateSettingsWithETag exists because the bootstrap emits a validator too, and
discarding it would force a client to read again before its first conditional
write — the round trip the header is there to avoid.

IsPreconditionFailed joins IsNotFound because a refused precondition is
otherwise an opaque APIError. Telling "someone else changed this, read again
and retry" apart from a genuine failure is the decision a conditional client
has to make, and it should not have to compare status codes by hand.

Validators arrive unquoted and go back out quoted, so no caller handles the
wire syntax. The e2e coverage drives all of it through the typed client
against a real server, playing out the case that motivates the feature: a
client plans an update, an operator turns PII redaction on in between, and the
client's write is refused instead of silently turning it back off.
This commit is contained in:
Brad Ison
2026-08-11 11:44:15 +02:00
parent 30c2010c09
commit 5085a2f96b
5 changed files with 339 additions and 13 deletions

View File

@@ -11,6 +11,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/e2e/harness"
"github.com/netbirdio/netbird/shared/management/client/rest"
"github.com/netbirdio/netbird/shared/management/http/api"
)
@@ -177,3 +178,86 @@ func TestSettingsBootstrapSelfAddressed(t *testing.T) {
require.NoError(t, err, "bootstrap after delete must succeed")
assert.Equal(t, "gw2.e2e.netbird.selfhosted", recreated.Endpoint, "the fresh bootstrap claims the new hostname")
}
// TestSettingsConditionalWrites covers the lost-update guard end to end, over
// the same REST client the Terraform provider uses: read the settings, take
// the entity-tag, and have a write refused when the row moved underneath it.
//
// The scenario is the one that motivates the feature. A client reads the
// settings and computes an update. An operator turns PII redaction on in the
// dashboard in the meantime. Without a precondition the client's write puts
// redaction straight back off — no error, no drift warning, a
// compliance-relevant control silently disabled. With one, the write is
// refused and the client can read again.
func TestSettingsConditionalWrites(t *testing.T) {
ctx := context.Background()
fresh, err := harnessStartFresh(ctx, t)
require.NoError(t, err, "start dedicated combined server")
const cluster = "eu.e2e.netbird.selfhosted"
bootstrapped, err := fresh.CreateSettings(ctx, api.AgentNetworkSettingsCreateRequest{
ProxyAddress: ptr(cluster),
})
require.NoError(t, err, "bootstrap must succeed")
// What the client plans against.
planned, etag, err := fresh.GetSettingsWithETag(ctx)
require.NoError(t, err, "read must succeed")
require.NotEmpty(t, etag, "the read must carry a validator")
assert.Equal(t, bootstrapped.Endpoint, planned.Endpoint)
_, again, err := fresh.GetSettingsWithETag(ctx)
require.NoError(t, err, "second read must succeed")
assert.Equal(t, etag, again, "an unchanged row must read as the same validator")
update := func(redactPii bool, retention int) api.AgentNetworkSettingsRequest {
return api.AgentNetworkSettingsRequest{
Endpoint: planned.Endpoint,
ProxyAddress: planned.ProxyAddress,
EnableLogCollection: true,
EnablePromptCollection: true,
RedactPii: redactPii,
AccessLogRetentionDays: retention,
}
}
// The operator's change, which the planning client never saw.
_, err = fresh.UpdateSettings(ctx, update(true, 21))
require.NoError(t, err, "the intervening update must succeed")
// The client's write, planned against the earlier read, would have turned
// redaction back off. It is refused instead.
_, _, err = fresh.UpdateSettingsIfMatch(ctx, update(false, 7), etag)
require.Error(t, err, "a stale precondition must be refused")
require.True(t, rest.IsPreconditionFailed(err),
"the refusal must be a precondition failure, got: %v", err)
intact, current, err := fresh.GetSettingsWithETag(ctx)
require.NoError(t, err, "read after the refusal must succeed")
assert.True(t, intact.RedactPii, "the refused write must not have turned redaction off")
require.NotNil(t, intact.AccessLogRetentionDays)
assert.Equal(t, 21, *intact.AccessLogRetentionDays, "the refused write must not have changed retention")
assert.NotEqual(t, etag, current, "the validator must have moved with the intervening update")
// Retrying against the current validator goes through, and hands back the
// validator for the write after it.
updated, next, err := fresh.UpdateSettingsIfMatch(ctx, update(true, 7), current)
require.NoError(t, err, "a matching precondition must be honoured")
require.NotNil(t, updated.AccessLogRetentionDays)
assert.Equal(t, 7, *updated.AccessLogRetentionDays, "the conditional write must apply")
assert.NotEmpty(t, next, "the write must return a validator")
assert.NotEqual(t, current, next, "the write must move the validator")
// The delete is conditional too, and refusing a stale one leaves the
// endpoint claimed.
require.Error(t, fresh.DeleteSettingsIfMatch(ctx, etag), "a stale precondition must refuse the delete")
stillThere, err := fresh.GetSettings(ctx)
require.NoError(t, err, "read after the refused delete must succeed")
assert.Equal(t, planned.Endpoint, stillThere.Endpoint, "the refused delete must leave the endpoint claimed")
require.NoError(t, fresh.DeleteSettingsIfMatch(ctx, next), "a matching precondition must be honoured")
gone, err := fresh.GetSettings(ctx)
require.NoError(t, err, "read after the delete must succeed")
assert.Empty(t, gone.Endpoint, "the row must be gone")
}

View File

@@ -153,6 +153,37 @@ func (c *Combined) DeleteSettings(ctx context.Context) error {
return anDelete(ctx, c, "/api/agent-network/settings")
}
// The conditional-request wrappers go through the typed REST client rather
// than anRequest, so the e2e run exercises the client's own header handling —
// the quoting on the way out and the unquoting on the way back — against a
// real server, which is the path the Terraform provider takes.
// GetSettingsWithETag reads the settings along with the entity-tag that makes
// a following write conditional.
func (c *Combined) GetSettingsWithETag(ctx context.Context) (api.AgentNetworkSettings, string, error) {
settings, etag, err := c.api.AgentNetwork.GetSettingsWithETag(ctx)
if err != nil {
return api.AgentNetworkSettings{}, "", err
}
return *settings, etag, nil
}
// UpdateSettingsIfMatch applies the update only if etag is still current,
// returning the entity-tag of the row it wrote.
func (c *Combined) UpdateSettingsIfMatch(ctx context.Context, req api.AgentNetworkSettingsRequest, etag string) (api.AgentNetworkSettings, string, error) {
settings, newETag, err := c.api.AgentNetwork.UpdateSettingsIfMatch(ctx, req, etag)
if err != nil {
return api.AgentNetworkSettings{}, "", err
}
return *settings, newETag, nil
}
// DeleteSettingsIfMatch deletes the settings row only if etag is still
// current.
func (c *Combined) DeleteSettingsIfMatch(ctx context.Context, etag string) error {
return c.api.AgentNetwork.DeleteSettingsIfMatch(ctx, etag)
}
// ListConsumption returns the account's consumption rows (possibly empty).
func (c *Combined) ListConsumption(ctx context.Context) ([]api.AgentNetworkConsumption, error) {
return anRequest[[]api.AgentNetworkConsumption](ctx, c, http.MethodGet, "/api/agent-network/consumption", nil)

View File

@@ -6,6 +6,8 @@ import (
"encoding/json"
"io"
"net/http"
"strconv"
"strings"
"github.com/netbirdio/netbird/shared/management/http/api"
)
@@ -336,25 +338,35 @@ func (a *AgentNetworkAPI) DeleteBudgetRule(ctx context.Context, ruleID string) e
// to an APIError matchable via IsNotFound rather than fabricating defaults
// the server never stated.
func (a *AgentNetworkAPI) GetSettings(ctx context.Context) (*api.AgentNetworkSettings, error) {
settings, _, err := a.GetSettingsWithETag(ctx)
return settings, err
}
// GetSettingsWithETag is GetSettings, additionally returning the entity-tag
// the server derived for the settings it returned. Hand that validator to
// UpdateSettingsIfMatch or DeleteSettingsIfMatch to make the write conditional
// on nothing having changed in between — the read-modify-write cycle that
// otherwise silently reverts a concurrent change.
func (a *AgentNetworkAPI) GetSettingsWithETag(ctx context.Context) (*api.AgentNetworkSettings, string, error) {
resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/settings", nil, nil)
if err != nil {
return nil, err
return nil, "", err
}
if resp.Body != nil {
defer resp.Body.Close()
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
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"}
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 nil, "", err
}
return &ret, nil
return &ret, etagFrom(resp), nil
}
// CreateSettings bootstraps the account's Agent Network settings row,
@@ -363,19 +375,30 @@ func (a *AgentNetworkAPI) GetSettings(ctx context.Context) (*api.AgentNetworkSet
// request.Endpoint (self-addressed dedicated endpoint, claimed verbatim) must
// be set. Returns a conflict when the account already has a settings row.
func (a *AgentNetworkAPI) CreateSettings(ctx context.Context, request api.PostApiAgentNetworkSettingsJSONRequestBody) (*api.AgentNetworkSettings, error) {
settings, _, err := a.CreateSettingsWithETag(ctx, request)
return settings, err
}
// CreateSettingsWithETag is CreateSettings, additionally returning the
// entity-tag of the row it bootstrapped, so a client can follow the bootstrap
// with a conditional write without an intervening read.
func (a *AgentNetworkAPI) CreateSettingsWithETag(ctx context.Context, request api.PostApiAgentNetworkSettingsJSONRequestBody) (*api.AgentNetworkSettings, string, error) {
requestBytes, err := json.Marshal(request)
if err != nil {
return nil, err
return nil, "", err
}
resp, err := a.c.NewRequest(ctx, "POST", "/api/agent-network/settings", bytes.NewReader(requestBytes), nil)
if err != nil {
return nil, err
return nil, "", err
}
if resp.Body != nil {
defer resp.Body.Close()
}
ret, err := parseResponse[api.AgentNetworkSettings](resp)
return &ret, err
if err != nil {
return nil, "", err
}
return &ret, etagFrom(resp), nil
}
// UpdateSettings updates the account's Agent Network settings; the request
@@ -385,19 +408,35 @@ func (a *AgentNetworkAPI) CreateSettings(ctx context.Context, request api.PostAp
// a request carrying different values is rejected. Returns not-found until
// the account is bootstrapped.
func (a *AgentNetworkAPI) UpdateSettings(ctx context.Context, request api.PutApiAgentNetworkSettingsJSONRequestBody) (*api.AgentNetworkSettings, error) {
settings, _, err := a.UpdateSettingsIfMatch(ctx, request, "")
return settings, err
}
// UpdateSettingsIfMatch is UpdateSettings made conditional on etag — the
// validator from an earlier read — still being current, and returns the
// validator of the row it wrote. This is what closes the read-modify-write
// window: a settings change made between the read and this write makes the
// request fail with a precondition-failed APIError instead of reverting it.
//
// An empty etag sends no precondition and updates unconditionally, which is
// what UpdateSettings does.
func (a *AgentNetworkAPI) UpdateSettingsIfMatch(ctx context.Context, request api.PutApiAgentNetworkSettingsJSONRequestBody, etag string) (*api.AgentNetworkSettings, string, error) {
requestBytes, err := json.Marshal(request)
if err != nil {
return nil, err
return nil, "", err
}
resp, err := a.c.NewRequest(ctx, "PUT", "/api/agent-network/settings", bytes.NewReader(requestBytes), nil)
resp, err := a.c.newRequest(ctx, "PUT", "/api/agent-network/settings", bytes.NewReader(requestBytes), nil, ifMatchHeader(etag))
if err != nil {
return nil, err
return nil, "", err
}
if resp.Body != nil {
defer resp.Body.Close()
}
ret, err := parseResponse[api.AgentNetworkSettings](resp)
return &ret, err
if err != nil {
return nil, "", err
}
return &ret, etagFrom(resp), nil
}
// DeleteSettings deletes the account's Agent Network settings row, releasing
@@ -405,7 +444,20 @@ func (a *AgentNetworkAPI) UpdateSettings(ctx context.Context, request api.PutApi
// exists for the account or while a proxy is actively serving the endpoint.
// Bootstrapping again afterwards allocates a new endpoint.
func (a *AgentNetworkAPI) DeleteSettings(ctx context.Context) error {
resp, err := a.c.NewRequest(ctx, "DELETE", "/api/agent-network/settings", nil, nil)
return a.DeleteSettingsIfMatch(ctx, "")
}
// DeleteSettingsIfMatch is DeleteSettings made conditional on etag — the
// validator from an earlier read — still being current. Sending it matters
// more here than on update: the server's other two refusals are about state
// (no providers, no serving proxy), so this is the only thing that stops a
// client working from an old read of one row from releasing the endpoint of
// the row that replaced it.
//
// An empty etag sends no precondition and deletes unconditionally, which is
// what DeleteSettings does.
func (a *AgentNetworkAPI) DeleteSettingsIfMatch(ctx context.Context, etag string) error {
resp, err := a.c.newRequest(ctx, "DELETE", "/api/agent-network/settings", nil, nil, ifMatchHeader(etag))
if err != nil {
return err
}
@@ -415,3 +467,20 @@ func (a *AgentNetworkAPI) DeleteSettings(ctx context.Context) error {
return nil
}
// etagFrom returns the bare validator from a response, with the transport's
// quoting stripped so a caller can hand it straight back to an If-Match
// parameter without knowing the wire syntax.
func etagFrom(resp *http.Response) string {
return strings.Trim(resp.Header.Get("ETag"), `"`)
}
// ifMatchHeader renders the precondition headers for a bare validator,
// re-applying the quoting etagFrom stripped. An empty validator yields no
// headers at all — an unconditional request.
func ifMatchHeader(etag string) map[string]string {
if etag == "" {
return nil
}
return map[string]string{"If-Match": strconv.Quote(etag)}
}

View File

@@ -559,3 +559,123 @@ func TestAgentNetwork_DeleteSettings_Guarded(t *testing.T) {
assert.Contains(t, err.Error(), "cannot be deleted")
})
}
// TestAgentNetwork_GetSettings_ETag pins that the validator surfaces to the
// caller with the transport's quoting stripped, so it can be handed straight
// back to a conditional write without the caller knowing the wire syntax.
func TestAgentNetwork_GetSettings_ETag(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("ETag", `"9f86d081884c7d65"`)
retBytes, _ := json.Marshal(testAgentNetworkSettings)
_, err := w.Write(retBytes)
require.NoError(t, err)
})
ret, etag, err := c.AgentNetwork.GetSettingsWithETag(context.Background())
require.NoError(t, err)
assert.Equal(t, testAgentNetworkSettings, *ret)
assert.Equal(t, "9f86d081884c7d65", etag, "the validator must arrive unquoted")
})
}
// TestAgentNetwork_UpdateSettings_IfMatch covers the round trip that makes the
// whole feature usable: a validator taken from a read goes back out quoted on
// the write, and the write's own validator comes back for the next one.
func TestAgentNetwork_UpdateSettings_IfMatch(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, `"9f86d081884c7d65"`, r.Header.Get("If-Match"),
"the precondition must go out quoted as a strong entity-tag")
w.Header().Set("ETag", `"0011223344556677"`)
retBytes, _ := json.Marshal(testAgentNetworkSettings)
_, err := w.Write(retBytes)
require.NoError(t, err)
})
_, etag, err := c.AgentNetwork.UpdateSettingsIfMatch(context.Background(), api.PutApiAgentNetworkSettingsJSONRequestBody{
Endpoint: "brave-otter.eu.proxy.netbird.io",
ProxyAddress: "eu.proxy.netbird.io",
EnableLogCollection: true,
}, "9f86d081884c7d65")
require.NoError(t, err)
assert.Equal(t, "0011223344556677", etag, "the write must return the new validator")
})
}
// TestAgentNetwork_UpdateSettings_NoPrecondition pins the back-compatible
// path: the plain method sends no If-Match at all, rather than an empty or
// wildcard one, so it stays the unconditional update it has always been.
func TestAgentNetwork_UpdateSettings_NoPrecondition(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.Empty(t, r.Header.Values("If-Match"), "an unconditional update must send no precondition")
retBytes, _ := json.Marshal(testAgentNetworkSettings)
_, err := w.Write(retBytes)
require.NoError(t, err)
})
_, err := c.AgentNetwork.UpdateSettings(context.Background(), api.PutApiAgentNetworkSettingsJSONRequestBody{
Endpoint: "brave-otter.eu.proxy.netbird.io",
ProxyAddress: "eu.proxy.netbird.io",
EnableLogCollection: true,
})
require.NoError(t, err)
})
}
// TestAgentNetwork_UpdateSettings_StalePrecondition pins how a refused write
// reaches the caller: as an APIError a client can recognise as staleness and
// answer by reading again, rather than as an opaque failure.
func TestAgentNetwork_UpdateSettings_StalePrecondition(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: "if-match precondition failed: the settings have changed since they were read; get them again and retry", Code: 412})
w.WriteHeader(412)
_, err := w.Write(retBytes)
require.NoError(t, err)
})
_, _, err := c.AgentNetwork.UpdateSettingsIfMatch(context.Background(), api.PutApiAgentNetworkSettingsJSONRequestBody{
Endpoint: "brave-otter.eu.proxy.netbird.io",
ProxyAddress: "eu.proxy.netbird.io",
EnableLogCollection: true,
}, "9f86d081884c7d65")
require.Error(t, err)
assert.True(t, rest.IsPreconditionFailed(err), "a refused precondition must be recognisable as one")
assert.False(t, rest.IsNotFound(err), "it must not be confused with an unbootstrapped account")
})
}
// TestAgentNetwork_CreateSettings_ETag pins that the bootstrap hands back a
// validator, which is what lets a client follow it with a conditional write
// without an intervening read.
func TestAgentNetwork_CreateSettings_ETag(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("ETag", `"9f86d081884c7d65"`)
retBytes, _ := json.Marshal(testAgentNetworkSettings)
_, err := w.Write(retBytes)
require.NoError(t, err)
})
_, etag, err := c.AgentNetwork.CreateSettingsWithETag(context.Background(), api.PostApiAgentNetworkSettingsJSONRequestBody{
ProxyAddress: ptr("eu.proxy.netbird.io"),
})
require.NoError(t, err)
assert.Equal(t, "9f86d081884c7d65", etag, "the bootstrap must return a validator")
})
}
// TestAgentNetwork_DeleteSettings_IfMatch covers the conditional delete on the
// wire, and that the plain method still sends nothing.
func TestAgentNetwork_DeleteSettings_IfMatch(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
var seen []string
mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "DELETE", r.Method)
seen = append(seen, r.Header.Get("If-Match"))
_, err := w.Write([]byte("{}"))
require.NoError(t, err)
})
require.NoError(t, c.AgentNetwork.DeleteSettingsIfMatch(context.Background(), "9f86d081884c7d65"))
require.NoError(t, c.AgentNetwork.DeleteSettings(context.Background()))
assert.Equal(t, []string{`"9f86d081884c7d65"`, ""}, seen,
"the conditional delete must carry the quoted validator and the plain one must carry nothing")
})
}

View File

@@ -31,6 +31,19 @@ func IsNotFound(err error) bool {
return false
}
// IsPreconditionFailed returns true if the error represents a 412 Precondition
// Failed response — an If-Match the server refused, or an endpoint's own
// precondition. A caller that sent a conditional request can use this to tell
// "someone else changed it, read again and retry" apart from a real failure;
// the message distinguishes it from an endpoint's other 412s.
func IsPreconditionFailed(err error) bool {
var apiErr *APIError
if ok := errors.As(err, &apiErr); ok {
return apiErr.StatusCode == http.StatusPreconditionFailed
}
return false
}
// Client Management service HTTP REST API Client
type Client struct {
managementURL string
@@ -218,6 +231,12 @@ func (c *Client) initialize() {
// NewRequest creates and executes new management API request
func (c *Client) NewRequest(ctx context.Context, method, path string, body io.Reader, query map[string]string) (*http.Response, error) {
return c.newRequest(ctx, method, path, body, query, nil)
}
// newRequest is NewRequest with request headers, for the endpoints whose
// contract includes one — conditional requests carrying If-Match.
func (c *Client) newRequest(ctx context.Context, method, path string, body io.Reader, query, headers map[string]string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, method, c.managementURL+path, body)
if err != nil {
return nil, err
@@ -231,6 +250,9 @@ func (c *Client) NewRequest(ctx context.Context, method, path string, body io.Re
if c.userAgent != "" {
req.Header.Set("User-Agent", c.userAgent)
}
for name, value := range headers {
req.Header.Set(name, value)
}
if len(query) != 0 {
q := req.URL.Query()