diff --git a/tracer/src/Datadog.Trace/FeatureFlags/Agentless/AgentlessConfigurationSource.cs b/tracer/src/Datadog.Trace/FeatureFlags/Agentless/AgentlessConfigurationSource.cs new file mode 100644 index 000000000000..4f2658fda111 --- /dev/null +++ b/tracer/src/Datadog.Trace/FeatureFlags/Agentless/AgentlessConfigurationSource.cs @@ -0,0 +1,424 @@ +// +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache 2 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2017 Datadog, Inc. +// + +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Threading; +using System.Threading.Tasks; +using Datadog.Trace.Agent; +using Datadog.Trace.Agent.Transports; +using Datadog.Trace.FeatureFlags.Rcm.Model; +using Datadog.Trace.Headers; +using Datadog.Trace.Logging; +using Datadog.Trace.Telemetry; +using Datadog.Trace.Util; + +namespace Datadog.Trace.FeatureFlags.Agentless; + +/// +/// Polls the agentless endpoint for flag configuration. Polling is billable, so it is only +/// started once application code has activated the provider. +/// +internal sealed class AgentlessConfigurationSource : IDisposable +{ + private const int MaxAttempts = 3; + private const double RetryJitter = 0.2; + + private static readonly TimeSpan FirstRetryMin = TimeSpan.FromSeconds(2); + private static readonly TimeSpan FirstRetryMax = TimeSpan.FromSeconds(10); + private static readonly TimeSpan SecondRetryMin = TimeSpan.FromSeconds(5); + private static readonly TimeSpan SecondRetryMax = TimeSpan.FromSeconds(30); + + // A jittered retry delay never drops below this, so a short poll interval cannot turn + // retries into a burst against the endpoint. + private static readonly TimeSpan MinRetryDelay = TimeSpan.FromSeconds(1); + + private static readonly IDatadogLogger Log = DatadogLogging.GetLoggerFor(typeof(AgentlessConfigurationSource)); + + private readonly IApiRequestFactory _requestFactory; + private readonly Uri _endpoint; + private readonly TimeSpan _pollInterval; + private readonly TimeSpan _requestTimeout; + private readonly Func _applyConfiguration; + private readonly Func _waitAsync; + private readonly CancellationTokenSource _shutdown = new(); + private readonly Random _random = new(); + + // Only ever touched from the poll loop. + private readonly HashSet _loggedFailureCategories = new(); + private bool _malformedPayloadLogged; + private bool _applyFailureLogged; + private string? _etag; + + private int _started; + + internal AgentlessConfigurationSource( + Uri endpoint, + IApiRequestFactory requestFactory, + TimeSpan pollInterval, + TimeSpan requestTimeout, + Func applyConfiguration, + Func? waitAsync = null) + { + _endpoint = endpoint; + _requestFactory = requestFactory; + _pollInterval = pollInterval; + _requestTimeout = requestTimeout; + _applyConfiguration = applyConfiguration; + _waitAsync = waitAsync ?? Task.Delay; + } + + /// + /// Creates the source, or returns null when it cannot be operated: a base URL that is + /// not a URL, or the managed endpoint without an API key. Polling anyway would only produce + /// failures every interval. + /// + public static AgentlessConfigurationSource? Create(FeatureFlagsSettings settings, Func applyConfiguration) + { + if (!AgentlessEndpoint.TryCreate(settings.Site, settings.Env, settings.AgentlessBaseUrl, out var endpoint, out var error)) + { + Log.Error("Feature Flags agentless source is unavailable: {Error}", error); + return null; + } + + if (endpoint.IsManaged && StringUtil.IsNullOrEmpty(settings.ApiKey)) + { + Log.Error("Feature Flags agentless source requires an API key. Set DD_API_KEY, or point DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL at an endpoint of your own."); + return null; + } + + return new AgentlessConfigurationSource( + endpoint.Uri, + CreateRequestFactory(endpoint, settings), + settings.PollInterval, + settings.RequestTimeout, + applyConfiguration); + } + + /// + /// Starts polling. Idempotent. + /// + public void Start() + { + if (Interlocked.CompareExchange(ref _started, 1, 0) != 0) + { + return; + } + + // Deliberately not wrapped in Task.Run: this is called from provider initialization, which + // is waiting for the first configuration, so the first request should go out on the calling + // thread rather than queue behind whatever else is on the thread pool. + _ = RunAsync().ContinueWith(t => Log.Error(t.Exception, "Feature Flags agentless poll loop failed"), TaskContinuationOptions.OnlyOnFaulted); + } + + /// + /// Runs a single poll, including its in-tick retries. + /// + internal async Task PollAsync() + { + var result = default(PollResult); + + for (var attempt = 1; attempt <= MaxAttempts; attempt++) + { + result = await RequestAsync().ConfigureAwait(false); + + if (_shutdown.IsCancellationRequested) + { + // A shutdown mid-poll leaves the response unusable for state transitions: keep + // last-known-good and the current ETag. + return; + } + + if (!IsRetryable(result)) + { + break; + } + + if (attempt == MaxAttempts) + { + // Every attempt failed in a retryable way. Last-known-good stays in place. + WarnFailure(result, MaxAttempts); + return; + } + + await WaitAsync(RetryDelay(attempt)).ConfigureAwait(false); + + if (_shutdown.IsCancellationRequested) + { + return; + } + } + + if (_shutdown.IsCancellationRequested) + { + // A shutdown during the final attempt leaves the response unusable for state + // transitions: keep last-known-good and the current ETag. + return; + } + + await ApplyAsync(result).ConfigureAwait(false); + } + + public void Dispose() + { + // The request in flight is bounded by the request timeout, and the loop is never joined, + // so a shutdown does not wait for it. A poll that completes after disposal is prevented + // from applying its result by the shutdown check in PollAsync. + try + { + _shutdown.Cancel(); + } + catch (Exception ex) + { + Log.Debug(ex, "Error cancelling the Feature Flags agentless poll loop"); + } + } + + // The concrete type is returned rather than the interface because CA1859 asks for it on a + // private member, which is also why the signature varies by target framework. +#if NETCOREAPP + private static HttpClientRequestFactory CreateRequestFactory(AgentlessEndpoint endpoint, FeatureFlagsSettings settings) +#else + private static ApiWebRequestFactory CreateRequestFactory(AgentlessEndpoint endpoint, FeatureFlagsSettings settings) +#endif + { + var headers = new List> + { + // The endpoint serves gzip, and neither transport decompresses for us. + new("Accept-Encoding", "gzip"), + new(TelemetryConstants.ClientLibraryLanguageHeader, TracerConstants.Language), + new(TelemetryConstants.ClientLibraryVersionHeader, TracerConstants.ThreePartVersion), + + // Without this the poll is itself instrumented, producing a span per poll and letting + // auto-instrumentation recurse through the poller's own client. + new(HttpHeaderNames.TracingEnabled, "false"), + }; + + if (endpoint.IsManaged) + { + // A custom endpoint is left to report its own authentication failure rather than + // having the Datadog credential sent to it. + headers.Add(new(TelemetryConstants.ApiKeyHeader, settings.ApiKey!)); + } + +#if NETCOREAPP + return new HttpClientRequestFactory(endpoint.Uri, headers.ToArray(), timeout: settings.RequestTimeout); +#else + return new ApiWebRequestFactory(endpoint.Uri, headers.ToArray(), timeout: settings.RequestTimeout); +#endif + } + + private static bool IsRetryable(in PollResult result) + => result.StatusCode is not { } status || status is 408 or 429 or (>= 500 and <= 599); + + private async Task RunAsync() + { + Log.Debug("AgentlessConfigurationSource::RunAsync -> Enter"); + + while (!_shutdown.IsCancellationRequested) + { + try + { + await PollAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + Log.Error(ex, "Feature Flags agentless poll failed unexpectedly"); + } + + // Fixed delay after completion, so polls never overlap. + await WaitAsync(_pollInterval).ConfigureAwait(false); + } + + Log.Debug("AgentlessConfigurationSource::RunAsync -> Exit"); + } + + private async Task WaitAsync(TimeSpan delay) + { + try + { + await _waitAsync(delay, _shutdown.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Shutting down + } + } + + private TimeSpan RetryDelay(int attempt) + { + var seconds = attempt == 1 + ? Clamp(_pollInterval.TotalSeconds / 6, FirstRetryMin, FirstRetryMax) + : Clamp(_pollInterval.TotalSeconds / 3, SecondRetryMin, SecondRetryMax); + + double jitter; + lock (_random) + { + jitter = 1 - RetryJitter + (_random.NextDouble() * RetryJitter * 2); + } + + return TimeSpan.FromSeconds(Math.Max(MinRetryDelay.TotalSeconds, seconds * jitter)); + + static double Clamp(double value, TimeSpan minimum, TimeSpan maximum) + => Math.Max(minimum.TotalSeconds, Math.Min(maximum.TotalSeconds, value)); + } + + private async Task RequestAsync() + { + try + { + var request = _requestFactory.Create(_endpoint); + if (_etag is { } etag) + { + request.AddHeader("If-None-Match", etag); + } + +#if NETCOREAPP + // HttpClient.Timeout applies to async calls, so no explicit race is needed. + using var response = await request.GetAsync().ConfigureAwait(false); +#else + // HttpWebRequest.Timeout does not apply to async calls (GetResponseAsync), so we race + // the request against an explicit delay to bound the wait on net461/netstandard2.0. + var getTask = request.GetAsync(); + var timeoutTask = Task.Delay(_requestTimeout); + + if (await Task.WhenAny(getTask, timeoutTask).ConfigureAwait(false) == timeoutTask) + { + // The request is still in flight. Dispose the response when it eventually completes + // (success or fault) so the underlying connection is released. + _ = getTask.ContinueWith( + t => { try { using var r = t.Result; } catch { } }, + TaskContinuationOptions.None); + + return new PollResult(statusCode: null, etag: null, body: null, error: new TimeoutException($"Feature Flags agentless request timed out after {_requestTimeout.TotalSeconds}s")); + } + + using var response = await getTask.ConfigureAwait(false); +#endif + + // Only a 200 carries configuration; other bodies are never decoded as one. + var body = response.StatusCode == 200 ? await ReadBodyAsync(response).ConfigureAwait(false) : null; + return new PollResult(response.StatusCode, response.GetHeader("ETag"), body, error: null); + } + catch (Exception ex) + { + return new PollResult(statusCode: null, etag: null, body: null, error: ex); + } + } + + private async Task ReadBodyAsync(IApiResponse response) + { + var stream = await response.GetStreamAsync().ConfigureAwait(false); + GZipStream? decompressed = null; + + try + { + if (response.GetContentEncodingType() == ContentEncodingType.GZip) + { + decompressed = new GZipStream(stream, CompressionMode.Decompress); + } + + using var reader = new StreamReader(decompressed ?? stream, response.GetCharsetEncoding()); + return await reader.ReadToEndAsync().ConfigureAwait(false); + } + finally + { + decompressed?.Dispose(); + } + } + + private Task ApplyAsync(PollResult result) + { + switch (result.StatusCode) + { + case 304: + // Nothing changed, and the ETag stays as it is. + return Task.CompletedTask; + case 401 or 403: + WarnFailure(result, attempts: 1); + return Task.CompletedTask; + case not 200: + WarnFailure(result, attempts: 1); + return Task.CompletedTask; + } + + if (!UfcConfigurationParser.TryParse(result.Body, out var configuration, out var error)) + { + if (!_malformedPayloadLogged) + { + _malformedPayloadLogged = true; + Log.Error("Feature Flags agentless endpoint returned an unusable payload: {Error}", error); + } + + return Task.CompletedTask; + } + + if (!_applyConfiguration(configuration)) + { + if (!_applyFailureLogged) + { + _applyFailureLogged = true; + Log.Warning("Feature Flags agentless configuration could not be applied"); + } + + return Task.CompletedTask; + } + + // The ETag advances only once parsing and applying have both succeeded. Advancing on + // receipt would acknowledge a payload that was never applied, and every later poll would + // answer 304, pinning the process to stale configuration with no way back. + var newEtag = result.ETag?.Trim(); + _etag = StringUtil.IsNullOrEmpty(newEtag) ? null : newEtag; + + return Task.CompletedTask; + } + + /// + /// Warns once per failure category. A dead endpoint would otherwise produce a warning every + /// poll interval, indefinitely. + /// + private void WarnFailure(in PollResult result, int attempts) + { + var category = result.StatusCode switch + { + 401 or 403 => "authentication", + not null => "http", + _ => "request", + }; + + if (!_loggedFailureCategories.Add(category)) + { + return; + } + + switch (result.StatusCode) + { + case 401 or 403: + Log.Error("Feature Flags agentless endpoint returned HTTP {StatusCode}; verify endpoint authentication", result.StatusCode!.Value); + break; + case not null: + Log.Error("Feature Flags agentless endpoint returned HTTP {StatusCode} after {Attempts} attempts", result.StatusCode.Value, attempts); + break; + default: + Log.Error(result.Error, "Feature Flags agentless request failed after {Attempts} attempts", attempts); + break; + } + } + + internal readonly struct PollResult(int? statusCode, string? etag, string? body, Exception? error) + { + public int? StatusCode { get; } = statusCode; + + public string? ETag { get; } = etag; + + public string? Body { get; } = body; + + public Exception? Error { get; } = error; + } +} diff --git a/tracer/src/Datadog.Trace/FeatureFlags/Agentless/UfcConfigurationParser.cs b/tracer/src/Datadog.Trace/FeatureFlags/Agentless/UfcConfigurationParser.cs new file mode 100644 index 000000000000..a078f5f4374d --- /dev/null +++ b/tracer/src/Datadog.Trace/FeatureFlags/Agentless/UfcConfigurationParser.cs @@ -0,0 +1,91 @@ +// +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache 2 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2017 Datadog, Inc. +// + +#nullable enable + +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using Datadog.Trace.FeatureFlags.Rcm.Model; +using Datadog.Trace.Vendors.Newtonsoft.Json; +using Datadog.Trace.Vendors.Newtonsoft.Json.Linq; + +namespace Datadog.Trace.FeatureFlags.Agentless; + +/// +/// Reads the JSON:API envelope returned by the agentless endpoint. +/// +internal static class UfcConfigurationParser +{ + private const string ResourceType = "universal-flag-configuration"; + + /// + /// Validates a JSON:API Universal Flag Configuration response and returns data.attributes, + /// which is the document the evaluator consumes. A raw UFC document is rejected, including from + /// a custom endpoint, so that every source agrees on one wire format. + /// + /// The response body. + /// The parsed configuration. + /// Why the payload was rejected. + /// true when the payload matches the contract. + public static bool TryParse(string? body, [NotNullWhen(true)] out ServerConfiguration? configuration, out string? error) + { + configuration = null; + error = null; + + JToken payload; + try + { + using var stringReader = new StringReader(body ?? string.Empty); + + // Timestamps stay strings: the model carries createdAt verbatim, and letting Newtonsoft + // turn it into a date would also make the type check below fail. + using var jsonReader = new JsonTextReader(stringReader) { DateParseHandling = DateParseHandling.None }; + payload = JToken.ReadFrom(jsonReader); + } + catch (Exception) + { + error = "Malformed UFC payload"; + return false; + } + + if (payload is not JObject + || payload["data"] is not JObject data + || data["type"]?.Type != JTokenType.String + || data["type"]?.Value() != ResourceType) + { + error = "Expected a JSON:API Universal Flag Configuration resource"; + return false; + } + + if (data["attributes"] is not JObject attributes + || attributes["format"]?.Type != JTokenType.String + || attributes["createdAt"]?.Type != JTokenType.String + || attributes["environment"] is not JObject environment + || environment["name"]?.Type != JTokenType.String + || attributes["flags"] is not JObject) + { + error = "Expected a Universal Flag Configuration v1 object"; + return false; + } + + try + { + configuration = attributes.ToObject(); + } + catch (Exception) + { + configuration = null; + } + + if (configuration is null) + { + error = "Expected a Universal Flag Configuration v1 object"; + return false; + } + + return true; + } +} diff --git a/tracer/test/Datadog.Trace.Tests/FeatureFlags/AgentlessConfigurationSourceTests.cs b/tracer/test/Datadog.Trace.Tests/FeatureFlags/AgentlessConfigurationSourceTests.cs new file mode 100644 index 000000000000..e2e39a15acde --- /dev/null +++ b/tracer/test/Datadog.Trace.Tests/FeatureFlags/AgentlessConfigurationSourceTests.cs @@ -0,0 +1,285 @@ +// +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache 2 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2017 Datadog, Inc. +// + +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Datadog.Trace.Agent; +using Datadog.Trace.FeatureFlags.Agentless; +using Datadog.Trace.FeatureFlags.Rcm.Model; +using Datadog.Trace.TestHelpers.TransportHelpers; +using FluentAssertions; +using Xunit; + +namespace Datadog.Trace.Tests.FeatureFlags; + +public class AgentlessConfigurationSourceTests +{ + private const string Body = """ + { "data": { "type": "universal-flag-configuration", + "attributes": { "format": "SERVER", "createdAt": "2025-01-01T00:00:00Z", + "environment": { "name": "production" }, "flags": {} } } } + """; + + private static readonly Uri Endpoint = new("https://ufc-server.ff-cdn.datadoghq.com/api/v2/feature-flagging/config/rules-based/server"); + + [Fact] + public async Task AppliesConfigurationFromA200() + { + var applied = new List(); + var factory = new TestRequestFactory(uri => new TestApiRequest(uri, responseContent: Body)); + using var source = CreateSource(factory, applied); + + await source.PollAsync(); + + applied.Should().ContainSingle(); + applied[0].Environment!.Name.Should().Be("production"); + factory.RequestsSent.Should().ContainSingle(); + } + + [Fact] + public async Task SendsTheEtagOfTheLastAppliedConfiguration() + { + var applied = new List(); + var factory = new TestRequestFactory( + uri => new TestApiRequest(uri, responseContent: Body, responseHeaders: new() { { "ETag", "\"ufc-v1\"" } })); + using var source = CreateSource(factory, applied); + + await source.PollAsync(); + applied.Should().ContainSingle(); + + // Second poll should send If-None-Match + await source.PollAsync(); + factory.RequestsSent.Should().HaveCount(2); + factory.RequestsSent[1].ExtraHeaders.Should().ContainKey("If-None-Match"); + factory.RequestsSent[1].ExtraHeaders["If-None-Match"].Should().Be("\"ufc-v1\""); + } + + [Fact] + public async Task DoesNotApplyOn304() + { + var applied = new List(); + var factory = new TestRequestFactory( + uri => new TestApiRequest(uri, statusCode: 304, responseContent: "{}")); + using var source = CreateSource(factory, applied); + + await source.PollAsync(); + + applied.Should().BeEmpty(); + } + + [Fact] + public async Task DoesNotApplyOn401() + { + var applied = new List(); + var factory = new TestRequestFactory( + uri => new TestApiRequest(uri, statusCode: 401, responseContent: "Unauthorized")); + using var source = CreateSource(factory, applied); + + await source.PollAsync(); + + applied.Should().BeEmpty(); + } + + [Fact] + public async Task DoesNotApplyOnMalformedPayload() + { + var applied = new List(); + var factory = new TestRequestFactory( + uri => new TestApiRequest(uri, statusCode: 200, responseContent: "not json")); + using var source = CreateSource(factory, applied); + + await source.PollAsync(); + + applied.Should().BeEmpty(); + } + + [Fact] + public async Task DoesNotApplyAfterDisposal() + { + var applied = new List(); + var factory = new TestRequestFactory(uri => new TestApiRequest(uri, responseContent: Body)); + var source = CreateSource(factory, applied); + + // A shutdown mid-poll leaves the response unusable for a state transition. + source.Dispose(); + await source.PollAsync(); + + factory.RequestsSent.Should().ContainSingle(); + applied.Should().BeEmpty(); + } + + [Fact] + public async Task DoesNotApplyWhenDisposedAfterRequestSucceeds() + { + var applied = new List(); + AgentlessConfigurationSource? sourceRef = null; + var factory = new TestRequestFactory(uri => + { + var request = new DisposingApiRequest(uri, Body); + request.Source = sourceRef; + return request; + }); + using var source = CreateSource(factory, applied); + sourceRef = source; + + await source.PollAsync(); + + applied.Should().BeEmpty(); + } + + [Fact] + public async Task RetriesOn500ThenAppliesOnSuccess() + { + var applied = new List(); + var factory = new TestRequestFactory( + uri => new TestApiRequest(uri, statusCode: 500, responseContent: "error"), + uri => new TestApiRequest(uri, responseContent: Body)); + using var source = CreateSource(factory, applied); + + await source.PollAsync(); + + applied.Should().ContainSingle(); + factory.RequestsSent.Should().HaveCount(2); + } + + [Fact] + public async Task RetriesUpToMaxAttemptsOn500() + { + var applied = new List(); + var factory = new TestRequestFactory( + uri => new TestApiRequest(uri, statusCode: 500, responseContent: "error"), + uri => new TestApiRequest(uri, statusCode: 500, responseContent: "error"), + uri => new TestApiRequest(uri, statusCode: 500, responseContent: "error")); + using var source = CreateSource(factory, applied); + + await source.PollAsync(); + + applied.Should().BeEmpty(); + factory.RequestsSent.Should().HaveCount(3); + } + + [Fact] + public async Task DoesNotRetryOn400() + { + var applied = new List(); + var factory = new TestRequestFactory( + uri => new TestApiRequest(uri, statusCode: 400, responseContent: "bad request")); + using var source = CreateSource(factory, applied); + + await source.PollAsync(); + + applied.Should().BeEmpty(); + factory.RequestsSent.Should().ContainSingle(); + } + + [Fact] + public async Task HandlesGzipResponse() + { + var applied = new List(); + var factory = new TestRequestFactory(uri => new GzipApiRequest(uri, Body)); + using var source = CreateSource(factory, applied); + + await source.PollAsync(); + + applied.Should().ContainSingle(); + applied[0].Environment!.Name.Should().Be("production"); + } + + [Fact] + public async Task HandlesNetworkError() + { + var applied = new List(); + var factory = new TestRequestFactory( + uri => new ThrowingApiRequest(uri), + uri => new ThrowingApiRequest(uri), + uri => new ThrowingApiRequest(uri)); + using var source = CreateSource(factory, applied); + + await source.PollAsync(); + + applied.Should().BeEmpty(); + factory.RequestsSent.Should().HaveCount(3); + } + + private static AgentlessConfigurationSource CreateSource(TestRequestFactory factory, List applied) + => new( + Endpoint, + factory, + TimeSpan.FromSeconds(30), + TimeSpan.FromSeconds(5), + configuration => + { + applied.Add(configuration); + return true; + }, + NoWait); + + private static Task NoWait(TimeSpan delay, CancellationToken cancellationToken) => Task.CompletedTask; + + private class ThrowingApiRequest(Uri endpoint) : TestApiRequest(endpoint) + { + public override Task GetAsync() => throw new IOException("The connection was refused"); + } + + private class GzipApiRequest(Uri endpoint, string body) : TestApiRequest(endpoint) + { + public override Task GetAsync() => Task.FromResult(new GzipApiResponse(body)); + } + + private class GzipApiResponse(string body) : IApiResponse + { + public int StatusCode => 200; + + public long ContentLength => -1; + + public string? ContentTypeHeader => "application/json"; + + public string? ContentEncodingHeader => "gzip"; + + public void Dispose() + { + } + + public string? GetHeader(string headerName) => null; + + public Encoding GetCharsetEncoding() => Encoding.UTF8; + + public ContentEncodingType GetContentEncodingType() => ContentEncodingType.GZip; + + public Task GetStreamAsync() + { + var compressed = new MemoryStream(); + using (var gzip = new GZipStream(compressed, CompressionMode.Compress, leaveOpen: true)) + { + var bytes = Encoding.UTF8.GetBytes(body); + gzip.Write(bytes, 0, bytes.Length); + } + + compressed.Position = 0; + return Task.FromResult(compressed); + } + } + + private class DisposingApiRequest(Uri endpoint, string body) : TestApiRequest(endpoint, responseContent: body) + { + public AgentlessConfigurationSource? Source { get; set; } + + public override Task GetAsync() + { + var response = base.GetAsync(); + // Simulate a shutdown arriving after the request completes but before ApplyAsync. + Source?.Dispose(); + return response; + } + } +} diff --git a/tracer/test/Datadog.Trace.Tests/FeatureFlags/UfcConfigurationParserTests.cs b/tracer/test/Datadog.Trace.Tests/FeatureFlags/UfcConfigurationParserTests.cs new file mode 100644 index 000000000000..b57c9568ebcd --- /dev/null +++ b/tracer/test/Datadog.Trace.Tests/FeatureFlags/UfcConfigurationParserTests.cs @@ -0,0 +1,112 @@ +// +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache 2 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2017 Datadog, Inc. +// + +#nullable enable + +using System.Collections.Generic; +using Datadog.Trace.FeatureFlags.Agentless; +using Datadog.Trace.FeatureFlags.Rcm.Model; +using FluentAssertions; +using Xunit; + +namespace Datadog.Trace.Tests.FeatureFlags; + +public class UfcConfigurationParserTests +{ + private const string ValidEnvelope = """ + { "data": { "type": "universal-flag-configuration", + "attributes": { "format": "SERVER", "createdAt": "2025-01-01T00:00:00Z", + "environment": { "name": "production" }, "flags": {} } } } + """; + + private const string Attributes = """ + { "format": "SERVER", "createdAt": "2025-01-01T00:00:00Z", + "environment": { "name": "production" }, "flags": {} } + """; + + [Fact] + public void ParsesValidEnvelope() + { + UfcConfigurationParser.TryParse(ValidEnvelope, out var configuration, out var error) + .Should().BeTrue(); + + error.Should().BeNull(); + configuration.Should().NotBeNull(); + configuration!.Environment!.Name.Should().Be("production"); + configuration.Flags.Should().BeEmpty(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("not json")] + [InlineData("{ \"data\": ")] + public void RejectsMalformedJson(string? body) + { + UfcConfigurationParser.TryParse(body, out var configuration, out var error).Should().BeFalse(); + + configuration.Should().BeNull(); + error.Should().Be("Malformed UFC payload"); + } + + [Theory] + // A raw UFC document is rejected too, so every source agrees on one wire format. + [InlineData(Attributes)] + // Wrong resource type + [InlineData("""{ "data": { "type": "wrong-type", "attributes": { "format": "SERVER", "createdAt": "x", "environment": { "name": "prod" }, "flags": {} } } }""")] + // Missing data + [InlineData("""{ "meta": {} }""")] + // data is not an object + [InlineData("""{ "data": "string" }""")] + // data.type is not a string (object) + [InlineData("""{ "data": { "type": { "nested": true }, "attributes": { "format": "SERVER", "createdAt": "x", "environment": { "name": "prod" }, "flags": {} } } }""")] + // data.type is not a string (array) + [InlineData("""{ "data": { "type": [1, 2], "attributes": { "format": "SERVER", "createdAt": "x", "environment": { "name": "prod" }, "flags": {} } } }""")] + public void RejectsInvalidEnvelope(string body) + { + UfcConfigurationParser.TryParse(body, out var configuration, out var error).Should().BeFalse(); + + configuration.Should().BeNull(); + error.Should().Be("Expected a JSON:API Universal Flag Configuration resource"); + } + + [Theory] + // Missing format + [InlineData("""{ "data": { "type": "universal-flag-configuration", "attributes": { "createdAt": "x", "environment": { "name": "prod" }, "flags": {} } } }""")] + // Missing createdAt + [InlineData("""{ "data": { "type": "universal-flag-configuration", "attributes": { "format": "SERVER", "environment": { "name": "prod" }, "flags": {} } } }""")] + // Missing environment + [InlineData("""{ "data": { "type": "universal-flag-configuration", "attributes": { "format": "SERVER", "createdAt": "x", "flags": {} } } }""")] + // environment.name is not a string + [InlineData("""{ "data": { "type": "universal-flag-configuration", "attributes": { "format": "SERVER", "createdAt": "x", "environment": { "name": 123 }, "flags": {} } } }""")] + // Missing flags + [InlineData("""{ "data": { "type": "universal-flag-configuration", "attributes": { "format": "SERVER", "createdAt": "x", "environment": { "name": "prod" } } } }""")] + // flags is not an object + [InlineData("""{ "data": { "type": "universal-flag-configuration", "attributes": { "format": "SERVER", "createdAt": "x", "environment": { "name": "prod" }, "flags": [] } } }""")] + public void RejectsInvalidAttributes(string body) + { + UfcConfigurationParser.TryParse(body, out var configuration, out var error).Should().BeFalse(); + + configuration.Should().BeNull(); + error.Should().Be("Expected a Universal Flag Configuration v1 object"); + } + + [Fact] + public void ParsesFlagsFromEnvelope() + { + var body = """ + { "data": { "type": "universal-flag-configuration", + "attributes": { "format": "SERVER", "createdAt": "2025-01-01T00:00:00Z", + "environment": { "name": "production" }, + "flags": { "test-flag": { "key": "test-flag", "enabled": true, "variationType": "BOOLEAN" } } } } } + """; + + UfcConfigurationParser.TryParse(body, out var configuration, out _).Should().BeTrue(); + + configuration!.Flags.Should().NotBeNull(); + configuration!.Flags!.Should().ContainKey("test-flag"); + configuration!.Flags!["test-flag"].Enabled.Should().BeTrue(); + } +}