-
Notifications
You must be signed in to change notification settings - Fork 1.7k
Upgrade to KubeClient v3 and log failed Kubernetes API requests #2266
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 9 commits
ad2f5ec
fd98410
4b5a6ef
9a8bf9c
8472f4a
8a4faa3
6c1ac8a
6a6c374
c853a6f
9b4e5cd
f083fda
71c36e4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
|
@@ -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() | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
|
||
|
|
@@ -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; | ||
|
|
||
|
|
@@ -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); | ||
|
|
||
|
|
@@ -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. | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @raman-m small issue, as noted in these comments.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wow!... 😮
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 🙂
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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() | ||
|
|
@@ -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); | ||
|
|
@@ -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; | ||
|
|
@@ -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."; | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.