Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 5 additions & 9 deletions src/Ocelot.Provider.Kubernetes/EndPointClientV1.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using HTTPlease;
using KubeClient.Http;
using KubeClient.Models;
using KubeClient.ResourceClients;
using Ocelot.Provider.Kubernetes.Interfaces;
Expand All @@ -7,11 +7,10 @@ namespace Ocelot.Provider.Kubernetes;

public class EndPointClientV1 : KubeResourceClient, IEndPointClient
{
private readonly HttpRequest _collection;
private static readonly HttpRequest Collection = KubeRequest.Create("api/v1/namespaces/{Namespace}/endpoints/{ServiceName}");

public EndPointClientV1(IKubeApiClient client) : base(client)
{
_collection = KubeRequest.Create("api/v1/namespaces/{Namespace}/endpoints/{ServiceName}");
}

public async Task<EndpointsV1> GetAsync(string serviceName, string kubeNamespace = null, CancellationToken cancellationToken = default)
Expand All @@ -21,17 +20,14 @@ public async Task<EndpointsV1> GetAsync(string serviceName, string kubeNamespace
throw new ArgumentNullException(nameof(serviceName));
}

var request = _collection
var request = Collection
.WithTemplateParameters(new
{
Namespace = kubeNamespace ?? KubeClient.DefaultNamespace,
ServiceName = serviceName,
});

var response = await Http.GetAsync(request, cancellationToken);

return response.IsSuccessStatusCode
? await response.ReadContentAsAsync<EndpointsV1>()
: null;
return await Http.GetAsync(request, cancellationToken)
.ReadContentAsObjectV1Async<EndpointsV1>(operationDescription: $"get {nameof(EndpointsV1)}");
}
}
43 changes: 40 additions & 3 deletions src/Ocelot.Provider.Kubernetes/Kube.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ namespace Ocelot.Provider.Kubernetes;
/// </remarks>
public class Kube : IServiceDiscoveryProvider
{
private static readonly (string ResourceKind, string ResourceApiVersion) EndPointsKubeKind = KubeObjectV1.GetKubeKind<EndpointsV1>();

private readonly KubeRegistryConfiguration _configuration;
private readonly IOcelotLogger _logger;
private readonly IKubeApiClient _kubeApi;
Expand Down Expand Up @@ -46,9 +48,44 @@ public virtual async Task<List<Service>> GetAsync()
.ToList();
}

private Task<EndpointsV1> GetEndpoint() => _kubeApi
.ResourceClient<IEndPointClient>(client => new EndPointClientV1(client))
.GetAsync(_configuration.KeyOfServiceInK8s, _configuration.KubeNamespace);
private string Message(string details)
=> $"Failed to retrieve {EndPointsKubeKind.ResourceApiVersion}/{EndPointsKubeKind.ResourceKind} '{_configuration.KeyOfServiceInK8s}' in namespace '{_configuration.KubeNamespace}': {details}";

private async Task<EndpointsV1> GetEndpoint()
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've moved this logic here because it keeps the resource-client implementation cleaner (since it wouldn't otherwise be clear to someone who's not seen this code before what the lifetime of the resource client is, vs the lifetime of the containing type or its logger).

{
try
{
return await _kubeApi
.ResourceClient<IEndPointClient>(client => new EndPointClientV1(client))
.GetAsync(_configuration.KeyOfServiceInK8s, _configuration.KubeNamespace);
}
catch (KubeApiException ex)
{
string Msg()
{
StatusV1 status = ex.Status;
string httpStatusCode = "-"; // Unknown
if (ex.InnerException is HttpRequestException e)
{
httpStatusCode = e.StatusCode.ToString();
}

return Message($"(HTTP.{httpStatusCode}/{status.Status}/{status.Reason}): {status.Message}");
}

_logger.LogError(Msg, ex);
}
catch (HttpRequestException ex)
{
_logger.LogError(() => Message($"({ex.HttpRequestError}/HTTP.{ex.StatusCode})."), ex);
}
catch (Exception unexpected)
{
_logger.LogError(() => Message($"(an unexpected ex occurred)."), unexpected);
}

return null;
}

private bool CheckErroneousState(EndpointsV1 endpoint)
=> (endpoint?.Subsets?.Count ?? 0) == 0; // null or count is zero
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@
<NoWarn>1591</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="KubeClient" Version="2.5.12" />
<PackageReference Include="KubeClient.Extensions.DependencyInjection" Version="2.5.12" />
<PackageReference Include="KubeClient" Version="3.0.1" />
<PackageReference Include="KubeClient.Extensions.DependencyInjection" Version="3.0.1" />
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
Expand Down
18 changes: 9 additions & 9 deletions test/Ocelot.AcceptanceTests/Ocelot.AcceptanceTests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -73,21 +73,21 @@
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="9.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="9.0.1" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="9.0.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.3" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.3" />
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="9.0.3" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.3" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.3" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.3" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="9.0.3" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="9.0.3" />
</ItemGroup>
<!-- Conditionally obtain references for the net 8.0 target -->
<ItemGroup Condition=" '$(TargetFramework)' == 'net8.0' ">
<PackageReference Include="Microsoft.AspNetCore.TestHost" Version="8.0.12" />
</ItemGroup>
<!-- Conditionally obtain references for the net 9.0 target -->
<ItemGroup Condition=" '$(TargetFramework)' == 'net9.0' ">
<PackageReference Include="Microsoft.AspNetCore.TestHost" Version="9.0.1" />
<PackageReference Include="Microsoft.AspNetCore.TestHost" Version="9.0.3" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using KubeClient;
using KubeClient.Models;
using KubeClient.ResourceClients;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
Expand Down Expand Up @@ -324,7 +325,7 @@ private void GivenThereIsAFakeKubernetesProvider(EndpointsV1 endpoints, bool isS
}

endpoints.Metadata.Generation = _k8sServiceGeneration;
json = JsonConvert.SerializeObject(endpoints);
json = JsonConvert.SerializeObject(endpoints, KubeResourceClient.SerializerSettings);
}

if (context.Request.Headers.TryGetValue("Authorization", out var values))
Expand Down
18 changes: 9 additions & 9 deletions test/Ocelot.IntegrationTests/Ocelot.IntegrationTests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,14 @@
<PackageReference Include="Shouldly" Version="4.2.1" />
<PackageReference Include="IdentityServer4" Version="4.1.2" />
<PackageReference Include="IdentityServer4.AccessTokenValidation" Version="3.0.1" />
<PackageReference Include="Microsoft.Data.SQLite" Version="9.0.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="9.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="9.0.1" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="9.0.1" />
<PackageReference Include="Microsoft.Data.SQLite" Version="9.0.3" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.3" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.3" />
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="9.0.3" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.3" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.3" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.3" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="9.0.3" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="9.0.3" />
</ItemGroup>
</Project>
98 changes: 95 additions & 3 deletions test/Ocelot.UnitTests/Kubernetes/KubeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
using Ocelot.Logging;
using Ocelot.Provider.Kubernetes;
using Ocelot.Provider.Kubernetes.Interfaces;
using Ocelot.Testing;
using Ocelot.Values;
using System.Runtime.CompilerServices;

Expand All @@ -19,6 +18,8 @@ namespace Ocelot.UnitTests.Kubernetes;
/// </summary>
public class KubeTests : FileUnitTest
{
static JsonSerializerSettings JsonSerializerSettings => KubeClient.ResourceClients.KubeResourceClient.SerializerSettings;

private readonly Mock<IOcelotLoggerFactory> _factory;
private readonly Mock<IOcelotLogger> _logger;

Expand All @@ -43,6 +44,7 @@ public async Task Should_return_service_from_k8s()
given.ClientOptions.ApiEndPoint.ToString(),
given.ProviderOptions.KubeNamespace,
given.ProviderOptions.KeyOfServiceInK8s,
responseStatusCode: HttpStatusCode.OK,
endpoints,
out Lazy<string> receivedToken);

Expand All @@ -54,6 +56,66 @@ public async Task Should_return_service_from_k8s()
receivedToken.Value.ShouldBe($"Bearer {nameof(Should_return_service_from_k8s)}");
}

[Theory]
[InlineData(HttpStatusCode.BadRequest)]
[InlineData(HttpStatusCode.Forbidden)]
[InlineData(HttpStatusCode.InternalServerError)]
[InlineData(HttpStatusCode.NotFound)]
[Trait("PR", "2266")]
public async Task Should_not_return_service_from_k8s_when_k8s_api_returns_error_response(HttpStatusCode expectedStatusCode)
{
// Arrange
var given = GivenClientAndProvider(out var serviceBuilder);
serviceBuilder.Setup(x => x.BuildServices(It.IsAny<KubeRegistryConfiguration>(), It.IsAny<EndpointsV1>()))
.Returns(new Service[] { new(nameof(Should_not_return_service_from_k8s_when_k8s_api_returns_error_response), new("localhost", 80), string.Empty, string.Empty, Array.Empty<string>()) });

var endpoints = GivenEndpoints();
using var kubernetes = GivenThereIsAFakeKubeServiceDiscoveryProvider(
given.ClientOptions.ApiEndPoint.ToString(),
given.ProviderOptions.KubeNamespace,
given.ProviderOptions.KeyOfServiceInK8s,
expectedStatusCode,
endpoints,
out Lazy<string> receivedToken);

string expectedKubeApiErrorMessage = GetKubeApiErrorMessage(serviceName: given.ProviderOptions.KeyOfServiceInK8s, given.ProviderOptions.KubeNamespace, expectedStatusCode);
string expectedLogMessage = $"Failed to retrieve v1/Endpoints '{given.ProviderOptions.KeyOfServiceInK8s}' in namespace '{given.ProviderOptions.KubeNamespace}': (HTTP.{expectedStatusCode}/Failure/{expectedStatusCode}): {expectedKubeApiErrorMessage}";
_logger.Setup(logger => logger.LogError(It.IsAny<Func<string>>(), It.IsAny<Exception>()))
.Callback((Func<string> messageFactory, Exception exception) =>
{
messageFactory.ShouldNotBeNull();

string logMessage = messageFactory();
logMessage.ShouldNotBeNullOrWhiteSpace();

// This is a little fragile, as it may change if other entries are logged due to implementation changes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@raman-m small issue, as noted in these comments.

Copy link
Member

@raman-m raman-m Mar 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wow!... 😮
I don't understand why it is difficult to test. Can't we set up the mocker accordingly?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem (as it looks to me) is that the test can't know for sure what other components may log messages as well (in this case, the retry facility uses the same logger and trying to verify those logged messages would make the whole thing too fragile by making this unit test coupled to the implementation of other components such as the retry).

I've always aimed to make unit tests, in particular, to test behaviour without being coupled to the implementation; otherwise the tests may have to be changed each time the implementation changes which removes one of the (in my opinion) best benefits of unit testing.

But I'm not that familiar with the code-base and so I'm open to changing it if you believe that would be better 🙂

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, the issue lies in the retry logic of the upper context, correct? It seems we have been debating this extensively. If the problem truly exists, I will identify it in the next round of refactoring the logic. For now, we can omit this.

// Unfortunately, the use of a factory delegate for the log message, combined with reuse of Kube's logger for Retry.OperationAsync makes this tricky to test any other way so this is probably the best we can do for now.
if (logMessage.StartsWith("Ocelot Retry strategy"))
{
return;
}

logMessage.ShouldBe(expectedLogMessage);

exception.ShouldNotBeNull();
KubeApiException kubeApiException = exception.ShouldBeOfType<KubeApiException>();
StatusV1 errorResponse = kubeApiException.Status;
errorResponse.Status.ShouldBe(StatusV1.FailureStatus);
errorResponse.Code.ShouldBe((int)expectedStatusCode);
errorResponse.Reason.ShouldBe(expectedStatusCode.ToString());
errorResponse.Message.ShouldNotBeNullOrWhiteSpace();
})
.Verifiable($"IOcelotLogger.LogError() was not called.");

// Act
var services = await given.Provider.GetAsync();

// Assert
services.ShouldNotBeNull().Count.ShouldBe(0);
receivedToken.Value.ShouldBe($"Bearer {nameof(Should_not_return_service_from_k8s_when_k8s_api_returns_error_response)}");
_logger.Verify();
}

[Fact] // This is not unit test! LoL :) This should be an integration test or even an acceptance test...
[Trait("Bug", "2110")]
public async Task Should_return_single_service_from_k8s_during_concurrent_calls()
Expand Down Expand Up @@ -147,6 +209,12 @@ protected EndpointsV1 GivenEndpoints(

protected IWebHost GivenThereIsAFakeKubeServiceDiscoveryProvider(string url, string namespaces, string serviceName,
EndpointsV1 endpointEntries, out Lazy<string> receivedToken)
{
return GivenThereIsAFakeKubeServiceDiscoveryProvider(url, namespaces, serviceName, HttpStatusCode.OK, endpointEntries, out receivedToken);
}

protected IWebHost GivenThereIsAFakeKubeServiceDiscoveryProvider(string url, string namespaces, string serviceName,
HttpStatusCode responseStatusCode, EndpointsV1 endpointEntries, out Lazy<string> receivedToken)
{
var token = string.Empty;
receivedToken = new(() => token);
Expand All @@ -155,14 +223,33 @@ Task ProcessKubernetesRequest(HttpContext context)
{
if (context.Request.Path.Value == $"/api/v1/namespaces/{namespaces}/endpoints/{serviceName}")
{
string responseBody;

if (context.Request.Headers.TryGetValue("Authorization", out var values))
{
token = values.First();
}

var json = JsonConvert.SerializeObject(endpointEntries);
if (responseStatusCode == HttpStatusCode.OK)
{
responseBody = JsonConvert.SerializeObject(endpointEntries, JsonSerializerSettings);
}
else
{
responseBody = JsonConvert.SerializeObject(new StatusV1
{
Message = GetKubeApiErrorMessage(serviceName, namespaces, responseStatusCode),
Reason = responseStatusCode.ToString(),
Code = (int)responseStatusCode,
Status = StatusV1.FailureStatus,

}, JsonSerializerSettings);
}

context.Response.StatusCode = (int)responseStatusCode;
context.Response.Headers.Append("Content-Type", "application/json");
return context.Response.WriteAsync(json);

return context.Response.WriteAsync(responseBody);
}

return Task.CompletedTask;
Expand All @@ -179,4 +266,9 @@ Task ProcessKubernetesRequest(HttpContext context)
host.Start();
return host;
}

private static string GetKubeApiErrorMessage(string serviceName, string kubeNamespace, HttpStatusCode responseStatusCode)
{
return $"Failed to retrieve v1/Endpoints '{serviceName}' in namespace '{kubeNamespace}' (HTTP.{responseStatusCode}/Failure/{responseStatusCode}): This is an error response for HTTP status code {(int)responseStatusCode} ('{responseStatusCode}') from the fake Kubernetes API.";
}
}
18 changes: 9 additions & 9 deletions test/Ocelot.UnitTests/Ocelot.UnitTests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -77,21 +77,21 @@
<PackageReference Include="CacheManager.Microsoft.Extensions.Logging" Version="2.0.0-beta-1629" />
<PackageReference Include="Polly" Version="8.5.1" />
<PackageReference Include="Polly.Testing" Version="8.5.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="9.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="9.0.1" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="9.0.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.3" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.3" />
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="9.0.3" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.3" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.3" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.3" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="9.0.3" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="9.0.3" />
</ItemGroup>
<!-- Conditionally obtain references for the net 8.0 target -->
<ItemGroup Condition=" '$(TargetFramework)' == 'net8.0' ">
<PackageReference Include="Microsoft.AspNetCore.TestHost" Version="8.0.12" />
</ItemGroup>
<!-- Conditionally obtain references for the net 9.0 target -->
<ItemGroup Condition=" '$(TargetFramework)' == 'net9.0' ">
<PackageReference Include="Microsoft.AspNetCore.TestHost" Version="9.0.1" />
<PackageReference Include="Microsoft.AspNetCore.TestHost" Version="9.0.3" />
</ItemGroup>
</Project>
Loading