From 0b247afdf0c9cba7fd37f603102d151dc7e74487 Mon Sep 17 00:00:00 2001 From: Raman Maksimchuk Date: Fri, 26 Apr 2024 18:38:54 +0300 Subject: [PATCH] #1967 Customize K8s services creation in `Kube` service discovery provider (#2052) * Initial refactoring * Interfaces namespace * `IKubeServiceBuilder` interface vs `KubeServiceBuilder` class * `IKubeServiceCreator` interface vs `KubeServiceCreator` class * Customize K8s services creation * Add logger * namespace Ocelot.AcceptanceTests.ServiceDiscovery * Add `KubernetesServiceDiscoveryTests` * Unit tests * AAA pattern * Acceptance tests * Update kubernetes.rst * Check docs --- docs/features/kubernetes.rst | 50 +- .../EndPointClientV1.cs | 1 + .../{ => Interfaces}/IEndPointClient.cs | 2 +- .../Interfaces/IKubeServiceBuilder.cs | 9 + .../Interfaces/IKubeServiceCreator.cs | 10 + src/Ocelot.Provider.Kubernetes/Kube.cs | 46 +- .../KubeRegistryConfiguration.cs | 12 +- .../KubeServiceBuilder.cs | 36 + .../KubeServiceCreator.cs | 59 + .../KubernetesProviderFactory.cs | 7 +- .../OcelotBuilderExtensions.cs | 20 +- .../ServiceDiscoveryFinderDelegate.cs | 7 +- .../Ocelot.AcceptanceTests.csproj | 5 +- .../ConsulServiceDiscoveryTests.cs} | 1488 ++++++++--------- .../EurekaServiceDiscoveryTests.cs | 547 +++--- .../KubernetesServiceDiscoveryTests.cs | 212 +++ .../ServiceFabricTests.cs | 410 ++--- test/Ocelot.AcceptanceTests/Steps.cs | 25 +- .../Kubernetes/KubeServiceBuilderTests.cs | 162 ++ .../Kubernetes/KubeServiceCreatorTests.cs | 150 ++ test/Ocelot.UnitTests/Kubernetes/KubeTests.cs | 33 +- test/Ocelot.UnitTests/Ocelot.UnitTests.csproj | 2 +- 22 files changed, 1982 insertions(+), 1311 deletions(-) rename src/Ocelot.Provider.Kubernetes/{ => Interfaces}/IEndPointClient.cs (83%) create mode 100644 src/Ocelot.Provider.Kubernetes/Interfaces/IKubeServiceBuilder.cs create mode 100644 src/Ocelot.Provider.Kubernetes/Interfaces/IKubeServiceCreator.cs create mode 100644 src/Ocelot.Provider.Kubernetes/KubeServiceBuilder.cs create mode 100644 src/Ocelot.Provider.Kubernetes/KubeServiceCreator.cs rename test/Ocelot.AcceptanceTests/{ServiceDiscoveryTests.cs => ServiceDiscovery/ConsulServiceDiscoveryTests.cs} (97%) rename test/Ocelot.AcceptanceTests/{ => ServiceDiscovery}/EurekaServiceDiscoveryTests.cs (96%) create mode 100644 test/Ocelot.AcceptanceTests/ServiceDiscovery/KubernetesServiceDiscoveryTests.cs rename test/Ocelot.AcceptanceTests/{ => ServiceDiscovery}/ServiceFabricTests.cs (94%) create mode 100644 test/Ocelot.UnitTests/Kubernetes/KubeServiceBuilderTests.cs create mode 100644 test/Ocelot.UnitTests/Kubernetes/KubeServiceCreatorTests.cs diff --git a/docs/features/kubernetes.rst b/docs/features/kubernetes.rst index 44411e589..8c6967c87 100644 --- a/docs/features/kubernetes.rst +++ b/docs/features/kubernetes.rst @@ -73,7 +73,11 @@ The example here shows a typical configuration: } Service deployment in **Namespace** ``Dev``, **ServiceDiscoveryProvider** type is ``Kube``, you also can set :ref:`k8s-pollkube-provider` type. -Note: **Host**, **Port** and **Token** are no longer in use. + + **Note 1**: ``Host``, ``Port`` and ``Token`` are no longer in use. + + **Note 2**: The ``Kube`` provider searches for the service entry using ``ServiceName`` and then retrieves the first available port from the ``EndpointSubsetV1.Ports`` collection. + Therefore, if the port name is not specified, the default downstream scheme will be ``http``; .. _k8s-pollkube-provider: @@ -99,10 +103,10 @@ This really depends on how volatile your services are. We doubt it will matter for most people and polling may give a tiny performance improvement over calling Kubernetes per request. There is no way for Ocelot to work these out for you. -Global vs Route levels -^^^^^^^^^^^^^^^^^^^^^^ +Global vs Route Levels +---------------------- -If your downstream service resides in a different namespace, you can override the global setting at the Route-level by specifying a **ServiceNamespace**: +If your downstream service resides in a different namespace, you can override the global setting at the Route-level by specifying a ``ServiceNamespace``: .. code-block:: json @@ -113,7 +117,45 @@ If your downstream service resides in a different namespace, you can override th } ] +Downstream Scheme vs Port Names [#f3]_ +-------------------------------------- + +Kubernetes configuration permits the definition of multiple ports with names for each address of an endpoint subset. +When binding multiple ports, you assign a name to each subset port. +To allow the ``Kube`` provider to recognize the desired port by its name, you need to specify the ``DownstreamScheme`` with the port's name; +if not, the collection's first port entry will be chosen by default. + +For instance, consider a service on Kubernetes that exposes two ports: ``https`` for **443** and ``http`` for **80**, as follows: + +.. code-block:: text + + Name: my-service + Namespace: default + Subsets: + Addresses: 10.1.161.59 + Ports: + Name Port Protocol + ---- ---- -------- + https 443 TCP + http 80 TCP + +**When** you need to use the ``http`` port while intentionally bypassing the default ``https`` port (first one), +you must define ``DownstreamScheme`` to enable the provider to recognize the desired ``http`` port by comparing ``DownstreamScheme`` with the port name as follows: + +.. code-block:: json + + "Routes": [ + { + "ServiceName": "my-service", + "DownstreamScheme": "http", // port name -> http -> port is 80 + } + ] + +**Note**: In the absence of a specified ``DownstreamScheme`` (which is the default behavior), the ``Kube`` provider will select **the first available port** from the ``EndpointSubsetV1.Ports`` collection. +Consequently, if the port name is not designated, the default downstream scheme utilized will be ``http``. + """" .. [#f1] `Wikipedia `_ | `K8s Website `_ | `K8s Documentation `_ | `K8s GitHub `_ .. [#f2] This feature was requested as part of `issue 345 `_ to add support for `Kubernetes `_ :doc:`../features/servicediscovery` provider. +.. [#f3] *"Downstream Scheme vs Port Names"* feature was requested as part of `issue 1967 `_ and released in version `23.3 `_ diff --git a/src/Ocelot.Provider.Kubernetes/EndPointClientV1.cs b/src/Ocelot.Provider.Kubernetes/EndPointClientV1.cs index 22f58e538..83418957b 100644 --- a/src/Ocelot.Provider.Kubernetes/EndPointClientV1.cs +++ b/src/Ocelot.Provider.Kubernetes/EndPointClientV1.cs @@ -1,6 +1,7 @@ using HTTPlease; using KubeClient.Models; using KubeClient.ResourceClients; +using Ocelot.Provider.Kubernetes.Interfaces; namespace Ocelot.Provider.Kubernetes { diff --git a/src/Ocelot.Provider.Kubernetes/IEndPointClient.cs b/src/Ocelot.Provider.Kubernetes/Interfaces/IEndPointClient.cs similarity index 83% rename from src/Ocelot.Provider.Kubernetes/IEndPointClient.cs rename to src/Ocelot.Provider.Kubernetes/Interfaces/IEndPointClient.cs index 6dfca972d..10f79f8af 100644 --- a/src/Ocelot.Provider.Kubernetes/IEndPointClient.cs +++ b/src/Ocelot.Provider.Kubernetes/Interfaces/IEndPointClient.cs @@ -1,7 +1,7 @@ using KubeClient.Models; using KubeClient.ResourceClients; -namespace Ocelot.Provider.Kubernetes; +namespace Ocelot.Provider.Kubernetes.Interfaces; public interface IEndPointClient : IKubeResourceClient { diff --git a/src/Ocelot.Provider.Kubernetes/Interfaces/IKubeServiceBuilder.cs b/src/Ocelot.Provider.Kubernetes/Interfaces/IKubeServiceBuilder.cs new file mode 100644 index 000000000..d5c6bcc30 --- /dev/null +++ b/src/Ocelot.Provider.Kubernetes/Interfaces/IKubeServiceBuilder.cs @@ -0,0 +1,9 @@ +using KubeClient.Models; +using Ocelot.Values; + +namespace Ocelot.Provider.Kubernetes.Interfaces; + +public interface IKubeServiceBuilder +{ + IEnumerable BuildServices(KubeRegistryConfiguration configuration, EndpointsV1 endpoint); +} diff --git a/src/Ocelot.Provider.Kubernetes/Interfaces/IKubeServiceCreator.cs b/src/Ocelot.Provider.Kubernetes/Interfaces/IKubeServiceCreator.cs new file mode 100644 index 000000000..a6ace7b2d --- /dev/null +++ b/src/Ocelot.Provider.Kubernetes/Interfaces/IKubeServiceCreator.cs @@ -0,0 +1,10 @@ +using KubeClient.Models; +using Ocelot.Values; + +namespace Ocelot.Provider.Kubernetes.Interfaces; + +public interface IKubeServiceCreator +{ + IEnumerable Create(KubeRegistryConfiguration configuration, EndpointsV1 endpoint, EndpointSubsetV1 subset); + IEnumerable CreateInstance(KubeRegistryConfiguration configuration, EndpointsV1 endpoint, EndpointSubsetV1 subset, EndpointAddressV1 address); +} diff --git a/src/Ocelot.Provider.Kubernetes/Kube.cs b/src/Ocelot.Provider.Kubernetes/Kube.cs index 15b5cf6cc..5350f43b9 100644 --- a/src/Ocelot.Provider.Kubernetes/Kube.cs +++ b/src/Ocelot.Provider.Kubernetes/Kube.cs @@ -1,5 +1,6 @@ using KubeClient.Models; using Ocelot.Logging; +using Ocelot.Provider.Kubernetes.Interfaces; using Ocelot.Values; namespace Ocelot.Provider.Kubernetes; @@ -9,47 +10,44 @@ namespace Ocelot.Provider.Kubernetes; /// public class Kube : IServiceDiscoveryProvider { - private readonly KubeRegistryConfiguration _kubeRegistryConfiguration; + private readonly KubeRegistryConfiguration _configuration; private readonly IOcelotLogger _logger; private readonly IKubeApiClient _kubeApi; - - public Kube(KubeRegistryConfiguration kubeRegistryConfiguration, IOcelotLoggerFactory factory, IKubeApiClient kubeApi) + private readonly IKubeServiceBuilder _serviceBuilder; + private readonly List _services; + + public Kube( + KubeRegistryConfiguration configuration, + IOcelotLoggerFactory factory, + IKubeApiClient kubeApi, + IKubeServiceBuilder serviceBuilder) { - _kubeRegistryConfiguration = kubeRegistryConfiguration; + _configuration = configuration; _logger = factory.CreateLogger(); _kubeApi = kubeApi; + _serviceBuilder = serviceBuilder; + _services = new(); } - public async Task> GetAsync() + public virtual async Task> GetAsync() { var endpoint = await _kubeApi .ResourceClient(client => new EndPointClientV1(client)) - .GetAsync(_kubeRegistryConfiguration.KeyOfServiceInK8s, _kubeRegistryConfiguration.KubeNamespace); + .GetAsync(_configuration.KeyOfServiceInK8s, _configuration.KubeNamespace); - var services = new List(); - if (endpoint != null && endpoint.Subsets.Any()) + _services.Clear(); + if (endpoint?.Subsets.Count != 0) { - services.AddRange(BuildServices(endpoint)); + _services.AddRange(BuildServices(_configuration, endpoint)); } else { - _logger.LogWarning(() => $"namespace:{_kubeRegistryConfiguration.KubeNamespace}service:{_kubeRegistryConfiguration.KeyOfServiceInK8s} Unable to use ,it is invalid. Address must contain host only e.g. localhost and port must be greater than 0"); + _logger.LogWarning(() => $"K8s Namespace:{_configuration.KubeNamespace}, Service:{_configuration.KeyOfServiceInK8s}; Unable to use: it is invalid. Address must contain host only e.g. localhost and port must be greater than 0!"); } - return services; + return _services; } - private static List BuildServices(EndpointsV1 endpoint) - { - var services = new List(); - - foreach (var subset in endpoint.Subsets) - { - services.AddRange(subset.Addresses.Select(address => new Service(endpoint.Metadata.Name, - new ServiceHostAndPort(address.Ip, subset.Ports.First().Port), - endpoint.Metadata.Uid, string.Empty, Enumerable.Empty()))); - } - - return services; - } + protected virtual IEnumerable BuildServices(KubeRegistryConfiguration configuration, EndpointsV1 endpoint) + => _serviceBuilder.BuildServices(configuration, endpoint); } diff --git a/src/Ocelot.Provider.Kubernetes/KubeRegistryConfiguration.cs b/src/Ocelot.Provider.Kubernetes/KubeRegistryConfiguration.cs index 2a3d7e815..b264e1b67 100644 --- a/src/Ocelot.Provider.Kubernetes/KubeRegistryConfiguration.cs +++ b/src/Ocelot.Provider.Kubernetes/KubeRegistryConfiguration.cs @@ -1,8 +1,8 @@ -namespace Ocelot.Provider.Kubernetes +namespace Ocelot.Provider.Kubernetes; + +public class KubeRegistryConfiguration { - public class KubeRegistryConfiguration - { - public string KubeNamespace { get; set; } - public string KeyOfServiceInK8s { get; set; } - } + public string KubeNamespace { get; set; } + public string KeyOfServiceInK8s { get; set; } + public string Scheme { get; set; } } diff --git a/src/Ocelot.Provider.Kubernetes/KubeServiceBuilder.cs b/src/Ocelot.Provider.Kubernetes/KubeServiceBuilder.cs new file mode 100644 index 000000000..589cfe5ba --- /dev/null +++ b/src/Ocelot.Provider.Kubernetes/KubeServiceBuilder.cs @@ -0,0 +1,36 @@ +using KubeClient.Models; +using Ocelot.Logging; +using Ocelot.Provider.Kubernetes.Interfaces; +using Ocelot.Values; + +namespace Ocelot.Provider.Kubernetes; + +public class KubeServiceBuilder : IKubeServiceBuilder +{ + private readonly IOcelotLogger _logger; + private readonly IKubeServiceCreator _serviceCreator; + + public KubeServiceBuilder(IOcelotLoggerFactory factory, IKubeServiceCreator serviceCreator) + { + ArgumentNullException.ThrowIfNull(factory); + _logger = factory.CreateLogger(); + + ArgumentNullException.ThrowIfNull(serviceCreator); + _serviceCreator = serviceCreator; + } + + public virtual IEnumerable BuildServices(KubeRegistryConfiguration configuration, EndpointsV1 endpoint) + { + ArgumentNullException.ThrowIfNull(configuration); + ArgumentNullException.ThrowIfNull(endpoint); + + var services = endpoint.Subsets + .SelectMany(subset => _serviceCreator.Create(configuration, endpoint, subset)) + .ToArray(); + + _logger.LogDebug(() => $"K8s '{Check(endpoint.Kind)}:{Check(endpoint.ApiVersion)}:{Check(endpoint.Metadata?.Name)}' endpoint: Total built {services.Length} services."); + return services; + } + + private static string Check(string str) => string.IsNullOrEmpty(str) ? "?" : str; +} diff --git a/src/Ocelot.Provider.Kubernetes/KubeServiceCreator.cs b/src/Ocelot.Provider.Kubernetes/KubeServiceCreator.cs new file mode 100644 index 000000000..3d51159c3 --- /dev/null +++ b/src/Ocelot.Provider.Kubernetes/KubeServiceCreator.cs @@ -0,0 +1,59 @@ +using KubeClient.Models; +using Ocelot.Logging; +using Ocelot.Provider.Kubernetes.Interfaces; +using Ocelot.Values; + +namespace Ocelot.Provider.Kubernetes; + +public class KubeServiceCreator : IKubeServiceCreator +{ + private readonly IOcelotLogger _logger; + + public KubeServiceCreator(IOcelotLoggerFactory factory) + { + ArgumentNullException.ThrowIfNull(factory); + _logger = factory.CreateLogger(); + } + + public virtual IEnumerable Create(KubeRegistryConfiguration configuration, EndpointsV1 endpoint, EndpointSubsetV1 subset) + => (configuration == null || endpoint == null || subset == null) + ? Array.Empty() + : subset.Addresses + .SelectMany(address => CreateInstance(configuration, endpoint, subset, address)) + .ToArray(); + + public virtual IEnumerable CreateInstance(KubeRegistryConfiguration configuration, EndpointsV1 endpoint, EndpointSubsetV1 subset, EndpointAddressV1 address) + { + var instance = new Service( + GetServiceName(configuration, endpoint, subset, address), + GetServiceHostAndPort(configuration, endpoint, subset, address), + GetServiceId(configuration, endpoint, subset, address), + GetServiceVersion(configuration, endpoint, subset, address), + GetServiceTags(configuration, endpoint, subset, address) + ); + return new Service[] { instance }; + } + + protected virtual string GetServiceName(KubeRegistryConfiguration configuration, EndpointsV1 endpoint, EndpointSubsetV1 subset, EndpointAddressV1 address) + => endpoint.Metadata?.Name; + + protected virtual ServiceHostAndPort GetServiceHostAndPort(KubeRegistryConfiguration configuration, EndpointsV1 endpoint, EndpointSubsetV1 subset, EndpointAddressV1 address) + { + var ports = subset.Ports; + bool portNameToScheme(EndpointPortV1 p) => string.Equals(p.Name, configuration.Scheme, StringComparison.InvariantCultureIgnoreCase); + var portV1 = string.IsNullOrEmpty(configuration.Scheme) || !ports.Any(portNameToScheme) + ? ports.FirstOrDefault() + : ports.FirstOrDefault(portNameToScheme); + portV1 ??= new(); + portV1.Name ??= configuration.Scheme ?? string.Empty; + _logger.LogDebug(() => $"K8s service with key '{configuration.KeyOfServiceInK8s}' and address {address.Ip}; Detected port is {portV1.Name}:{portV1.Port}. Total {ports.Count} ports of [{string.Join(',', ports.Select(p => p.Name))}]."); + return new ServiceHostAndPort(address.Ip, portV1.Port, portV1.Name); + } + + protected virtual string GetServiceId(KubeRegistryConfiguration configuration, EndpointsV1 endpoint, EndpointSubsetV1 subset, EndpointAddressV1 address) + => endpoint.Metadata?.Uid; + protected virtual string GetServiceVersion(KubeRegistryConfiguration configuration, EndpointsV1 endpoint, EndpointSubsetV1 subset, EndpointAddressV1 address) + => endpoint.ApiVersion; + protected virtual IEnumerable GetServiceTags(KubeRegistryConfiguration configuration, EndpointsV1 endpoint, EndpointSubsetV1 subset, EndpointAddressV1 address) + => Enumerable.Empty(); +} diff --git a/src/Ocelot.Provider.Kubernetes/KubernetesProviderFactory.cs b/src/Ocelot.Provider.Kubernetes/KubernetesProviderFactory.cs index 4507c03e6..a3a1d48c0 100644 --- a/src/Ocelot.Provider.Kubernetes/KubernetesProviderFactory.cs +++ b/src/Ocelot.Provider.Kubernetes/KubernetesProviderFactory.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using Ocelot.Configuration; -using Ocelot.Logging; +using Ocelot.Logging; +using Ocelot.Provider.Kubernetes.Interfaces; namespace Ocelot.Provider.Kubernetes { @@ -17,14 +18,16 @@ private static IServiceDiscoveryProvider CreateProvider(IServiceProvider provide { var factory = provider.GetService(); var kubeClient = provider.GetService(); + var serviceBuilder = provider.GetService(); var configuration = new KubeRegistryConfiguration { KeyOfServiceInK8s = route.ServiceName, KubeNamespace = string.IsNullOrEmpty(route.ServiceNamespace) ? config.Namespace : route.ServiceNamespace, + Scheme = route.DownstreamScheme, }; - var defaultK8sProvider = new Kube(configuration, factory, kubeClient); + var defaultK8sProvider = new Kube(configuration, factory, kubeClient, serviceBuilder); return PollKube.Equals(config.Type, StringComparison.OrdinalIgnoreCase) ? new PollKube(config.PollingInterval, factory, defaultK8sProvider) diff --git a/src/Ocelot.Provider.Kubernetes/OcelotBuilderExtensions.cs b/src/Ocelot.Provider.Kubernetes/OcelotBuilderExtensions.cs index 0110bddbe..fadedc356 100644 --- a/src/Ocelot.Provider.Kubernetes/OcelotBuilderExtensions.cs +++ b/src/Ocelot.Provider.Kubernetes/OcelotBuilderExtensions.cs @@ -1,16 +1,18 @@ using Microsoft.Extensions.DependencyInjection; using Ocelot.DependencyInjection; +using Ocelot.Provider.Kubernetes.Interfaces; -namespace Ocelot.Provider.Kubernetes +namespace Ocelot.Provider.Kubernetes; + +public static class OcelotBuilderExtensions { - public static class OcelotBuilderExtensions + public static IOcelotBuilder AddKubernetes(this IOcelotBuilder builder, bool usePodServiceAccount = true) { - public static IOcelotBuilder AddKubernetes(this IOcelotBuilder builder, bool usePodServiceAccount = true) - { - builder.Services - .AddSingleton(KubernetesProviderFactory.Get) - .AddKubeClient(usePodServiceAccount); - return builder; - } + builder.Services + .AddKubeClient(usePodServiceAccount) + .AddSingleton(KubernetesProviderFactory.Get) + .AddSingleton() + .AddSingleton(); + return builder; } } diff --git a/src/Ocelot/ServiceDiscovery/ServiceDiscoveryFinderDelegate.cs b/src/Ocelot/ServiceDiscovery/ServiceDiscoveryFinderDelegate.cs index c6e500925..e6c49a7d6 100644 --- a/src/Ocelot/ServiceDiscovery/ServiceDiscoveryFinderDelegate.cs +++ b/src/Ocelot/ServiceDiscovery/ServiceDiscoveryFinderDelegate.cs @@ -1,7 +1,6 @@ using Ocelot.Configuration; using Ocelot.ServiceDiscovery.Providers; -namespace Ocelot.ServiceDiscovery -{ - public delegate IServiceDiscoveryProvider ServiceDiscoveryFinderDelegate(IServiceProvider provider, ServiceProviderConfiguration config, DownstreamRoute route); -} +namespace Ocelot.ServiceDiscovery; + +public delegate IServiceDiscoveryProvider ServiceDiscoveryFinderDelegate(IServiceProvider provider, ServiceProviderConfiguration config, DownstreamRoute route); diff --git a/test/Ocelot.AcceptanceTests/Ocelot.AcceptanceTests.csproj b/test/Ocelot.AcceptanceTests/Ocelot.AcceptanceTests.csproj index 701a23d67..c997d07c5 100644 --- a/test/Ocelot.AcceptanceTests/Ocelot.AcceptanceTests.csproj +++ b/test/Ocelot.AcceptanceTests/Ocelot.AcceptanceTests.csproj @@ -30,13 +30,14 @@ - - + + + diff --git a/test/Ocelot.AcceptanceTests/ServiceDiscoveryTests.cs b/test/Ocelot.AcceptanceTests/ServiceDiscovery/ConsulServiceDiscoveryTests.cs similarity index 97% rename from test/Ocelot.AcceptanceTests/ServiceDiscoveryTests.cs rename to test/Ocelot.AcceptanceTests/ServiceDiscovery/ConsulServiceDiscoveryTests.cs index 7ba087e55..d25c2075b 100644 --- a/test/Ocelot.AcceptanceTests/ServiceDiscoveryTests.cs +++ b/test/Ocelot.AcceptanceTests/ServiceDiscovery/ConsulServiceDiscoveryTests.cs @@ -1,744 +1,744 @@ -using Consul; -using Microsoft.AspNetCore.Http; -using Newtonsoft.Json; -using Ocelot.Configuration.File; -using System.Text.RegularExpressions; - -namespace Ocelot.AcceptanceTests -{ - public class ServiceDiscoveryTests : IDisposable - { - private readonly Steps _steps; - private readonly List _consulServices; - private int _counterOne; - private int _counterTwo; - private int _counterConsul; - private static readonly object SyncLock = new(); - private string _downstreamPath; - private string _receivedToken; - private readonly ServiceHandler _serviceHandler; - private readonly ServiceHandler _serviceHandler2; - private readonly ServiceHandler _consulHandler; - - public ServiceDiscoveryTests() - { - _serviceHandler = new ServiceHandler(); - _serviceHandler2 = new ServiceHandler(); - _consulHandler = new ServiceHandler(); - _steps = new Steps(); - _consulServices = new List(); - } - - [Fact] - public void should_use_consul_service_discovery_and_load_balance_request() - { - var consulPort = PortFinder.GetRandomPort(); - var servicePort1 = PortFinder.GetRandomPort(); - var servicePort2 = PortFinder.GetRandomPort(); - var serviceName = "product"; - var downstreamServiceOneUrl = $"http://localhost:{servicePort1}"; - var downstreamServiceTwoUrl = $"http://localhost:{servicePort2}"; - var fakeConsulServiceDiscoveryUrl = $"http://localhost:{consulPort}"; - var serviceEntryOne = new ServiceEntry - { - Service = new AgentService - { - Service = serviceName, - Address = "localhost", - Port = servicePort1, - ID = Guid.NewGuid().ToString(), - Tags = Array.Empty(), - }, - }; - var serviceEntryTwo = new ServiceEntry - { - Service = new AgentService - { - Service = serviceName, - Address = "localhost", - Port = servicePort2, - ID = Guid.NewGuid().ToString(), - Tags = Array.Empty(), - }, - }; - - var configuration = new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/", - DownstreamScheme = "http", - UpstreamPathTemplate = "/", - UpstreamHttpMethod = new List { "Get" }, - ServiceName = serviceName, - LoadBalancerOptions = new FileLoadBalancerOptions { Type = "LeastConnection" }, - }, - }, - GlobalConfiguration = new FileGlobalConfiguration - { - ServiceDiscoveryProvider = new FileServiceDiscoveryProvider - { - Scheme = "http", - Host = "localhost", - Port = consulPort, - }, - }, - }; - - this.Given(x => x.GivenProductServiceOneIsRunning(downstreamServiceOneUrl, 200)) - .And(x => x.GivenProductServiceTwoIsRunning(downstreamServiceTwoUrl, 200)) - .And(x => x.GivenThereIsAFakeConsulServiceDiscoveryProvider(fakeConsulServiceDiscoveryUrl)) - .And(x => x.GivenTheServicesAreRegisteredWithConsul(serviceEntryOne, serviceEntryTwo)) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunningWithConsul()) - .When(x => _steps.WhenIGetUrlOnTheApiGatewayMultipleTimes("/", 50)) - .Then(x => x.ThenTheTwoServicesShouldHaveBeenCalledTimes(50)) - .And(x => x.ThenBothServicesCalledRealisticAmountOfTimes(24, 26)) - .BDDfy(); - } - - [Fact] - public void should_handle_request_to_consul_for_downstream_service_and_make_request() - { - var consulPort = PortFinder.GetRandomPort(); - var servicePort = PortFinder.GetRandomPort(); - const string serviceName = "web"; - var downstreamServiceOneUrl = $"http://localhost:{servicePort}"; - var fakeConsulServiceDiscoveryUrl = $"http://localhost:{consulPort}"; - var serviceEntryOne = new ServiceEntry - { - Service = new AgentService - { - Service = serviceName, - Address = "localhost", - Port = servicePort, - ID = "web_90_0_2_224_8080", - Tags = new[] { "version-v1" }, - }, - }; - - var configuration = new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/api/home", - DownstreamScheme = "http", - UpstreamPathTemplate = "/home", - UpstreamHttpMethod = new List { "Get", "Options" }, - ServiceName = serviceName, - LoadBalancerOptions = new FileLoadBalancerOptions { Type = "LeastConnection" }, - }, - }, - GlobalConfiguration = new FileGlobalConfiguration - { - ServiceDiscoveryProvider = new FileServiceDiscoveryProvider - { - Scheme = "http", - Host = "localhost", - Port = consulPort, - }, - }, - }; - - this.Given(x => x.GivenThereIsAServiceRunningOn(downstreamServiceOneUrl, "/api/home", 200, "Hello from Laura")) - .And(x => x.GivenThereIsAFakeConsulServiceDiscoveryProvider(fakeConsulServiceDiscoveryUrl)) - .And(x => x.GivenTheServicesAreRegisteredWithConsul(serviceEntryOne)) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunningWithConsul()) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/home")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) - .BDDfy(); - } - - [Fact] - public void should_handle_request_to_consul_for_downstream_service_and_make_request_no_re_routes() - { - var consulPort = PortFinder.GetRandomPort(); - const string serviceName = "web"; - var downstreamServicePort = PortFinder.GetRandomPort(); - var downstreamServiceOneUrl = $"http://localhost:{downstreamServicePort}"; - var fakeConsulServiceDiscoveryUrl = $"http://localhost:{consulPort}"; - var serviceEntryOne = new ServiceEntry - { - Service = new AgentService - { - Service = serviceName, - Address = "localhost", - Port = downstreamServicePort, - ID = "web_90_0_2_224_8080", - Tags = new[] { "version-v1" }, - }, - }; - - var configuration = new FileConfiguration - { - GlobalConfiguration = new FileGlobalConfiguration - { - ServiceDiscoveryProvider = new FileServiceDiscoveryProvider - { - Scheme = "http", - Host = "localhost", - Port = consulPort, - }, - DownstreamScheme = "http", - HttpHandlerOptions = new FileHttpHandlerOptions - { - AllowAutoRedirect = true, - UseCookieContainer = true, - UseTracing = false, - }, - }, - }; - - this.Given(x => x.GivenThereIsAServiceRunningOn(downstreamServiceOneUrl, "/something", 200, "Hello from Laura")) - .And(x => x.GivenThereIsAFakeConsulServiceDiscoveryProvider(fakeConsulServiceDiscoveryUrl)) - .And(x => x.GivenTheServicesAreRegisteredWithConsul(serviceEntryOne)) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunningWithConsul()) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/web/something")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) - .BDDfy(); - } - - [Fact] - public void should_use_consul_service_discovery_and_load_balance_request_no_re_routes() - { - var consulPort = PortFinder.GetRandomPort(); - var serviceName = "product"; - var serviceOnePort = PortFinder.GetRandomPort(); - var serviceTwoPort = PortFinder.GetRandomPort(); - var downstreamServiceOneUrl = $"http://localhost:{serviceOnePort}"; - var downstreamServiceTwoUrl = $"http://localhost:{serviceTwoPort}"; - var fakeConsulServiceDiscoveryUrl = $"http://localhost:{consulPort}"; - var serviceEntryOne = new ServiceEntry - { - Service = new AgentService - { - Service = serviceName, - Address = "localhost", - Port = serviceOnePort, - ID = Guid.NewGuid().ToString(), - Tags = Array.Empty(), - }, - }; - var serviceEntryTwo = new ServiceEntry - { - Service = new AgentService - { - Service = serviceName, - Address = "localhost", - Port = serviceTwoPort, - ID = Guid.NewGuid().ToString(), - Tags = Array.Empty(), - }, - }; - - var configuration = new FileConfiguration - { - GlobalConfiguration = new FileGlobalConfiguration - { - ServiceDiscoveryProvider = new FileServiceDiscoveryProvider - { - Scheme = "http", - Host = "localhost", - Port = consulPort, - }, - LoadBalancerOptions = new FileLoadBalancerOptions { Type = "LeastConnection" }, - DownstreamScheme = "http", - }, - }; - - this.Given(x => x.GivenProductServiceOneIsRunning(downstreamServiceOneUrl, 200)) - .And(x => x.GivenProductServiceTwoIsRunning(downstreamServiceTwoUrl, 200)) - .And(x => x.GivenThereIsAFakeConsulServiceDiscoveryProvider(fakeConsulServiceDiscoveryUrl)) - .And(x => x.GivenTheServicesAreRegisteredWithConsul(serviceEntryOne, serviceEntryTwo)) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunningWithConsul()) - .When(x => _steps.WhenIGetUrlOnTheApiGatewayMultipleTimes($"/{serviceName}/", 50)) - .Then(x => x.ThenTheTwoServicesShouldHaveBeenCalledTimes(50)) - .And(x => x.ThenBothServicesCalledRealisticAmountOfTimes(24, 26)) - .BDDfy(); - } - - [Fact] - public void should_use_token_to_make_request_to_consul() - { - var token = "abctoken"; - var consulPort = PortFinder.GetRandomPort(); - var serviceName = "web"; - var servicePort = PortFinder.GetRandomPort(); - var downstreamServiceOneUrl = $"http://localhost:{servicePort}"; - var fakeConsulServiceDiscoveryUrl = $"http://localhost:{consulPort}"; - var serviceEntryOne = new ServiceEntry - { - Service = new AgentService - { - Service = serviceName, - Address = "localhost", - Port = servicePort, - ID = "web_90_0_2_224_8080", - Tags = new[] { "version-v1" }, - }, - }; - - var configuration = new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/api/home", - DownstreamScheme = "http", - UpstreamPathTemplate = "/home", - UpstreamHttpMethod = new List { "Get", "Options" }, - ServiceName = serviceName, - LoadBalancerOptions = new FileLoadBalancerOptions { Type = "LeastConnection" }, - }, - }, - GlobalConfiguration = new FileGlobalConfiguration - { - ServiceDiscoveryProvider = new FileServiceDiscoveryProvider - { - Scheme = "http", - Host = "localhost", - Port = consulPort, - Token = token, - }, - }, - }; - - this.Given(_ => GivenThereIsAServiceRunningOn(downstreamServiceOneUrl, "/api/home", 200, "Hello from Laura")) - .And(_ => GivenThereIsAFakeConsulServiceDiscoveryProvider(fakeConsulServiceDiscoveryUrl)) - .And(_ => GivenTheServicesAreRegisteredWithConsul(serviceEntryOne)) - .And(_ => _steps.GivenThereIsAConfiguration(configuration)) - .And(_ => _steps.GivenOcelotIsRunningWithConsul()) - .When(_ => _steps.WhenIGetUrlOnTheApiGateway("/home")) - .Then(_ => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(_ => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) - .And(_ => ThenTheTokenIs(token)) - .BDDfy(); - } - - [Fact] - public void should_send_request_to_service_after_it_becomes_available_in_consul() - { - var consulPort = PortFinder.GetRandomPort(); - var serviceName = "product"; - var servicePort1 = PortFinder.GetRandomPort(); - var servicePort2 = PortFinder.GetRandomPort(); - var downstreamServiceOneUrl = $"http://localhost:{servicePort1}"; - var downstreamServiceTwoUrl = $"http://localhost:{servicePort2}"; - var fakeConsulServiceDiscoveryUrl = $"http://localhost:{consulPort}"; - var serviceEntryOne = new ServiceEntry - { - Service = new AgentService - { - Service = serviceName, - Address = "localhost", - Port = servicePort1, - ID = Guid.NewGuid().ToString(), - Tags = Array.Empty(), - }, - }; - var serviceEntryTwo = new ServiceEntry - { - Service = new AgentService - { - Service = serviceName, - Address = "localhost", - Port = servicePort2, - ID = Guid.NewGuid().ToString(), - Tags = Array.Empty(), - }, - }; - - var configuration = new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/", - DownstreamScheme = "http", - UpstreamPathTemplate = "/", - UpstreamHttpMethod = new List { "Get" }, - ServiceName = serviceName, - LoadBalancerOptions = new FileLoadBalancerOptions { Type = "LeastConnection" }, - }, - }, - GlobalConfiguration = new FileGlobalConfiguration - { - ServiceDiscoveryProvider = new FileServiceDiscoveryProvider - { - Scheme = "http", - Host = "localhost", - Port = consulPort, - }, - }, - }; - - this.Given(x => x.GivenProductServiceOneIsRunning(downstreamServiceOneUrl, 200)) - .And(x => x.GivenProductServiceTwoIsRunning(downstreamServiceTwoUrl, 200)) - .And(x => x.GivenThereIsAFakeConsulServiceDiscoveryProvider(fakeConsulServiceDiscoveryUrl)) - .And(x => x.GivenTheServicesAreRegisteredWithConsul(serviceEntryOne, serviceEntryTwo)) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunningWithConsul()) - .And(x => _steps.WhenIGetUrlOnTheApiGatewayMultipleTimes("/", 10)) - .And(x => x.ThenTheTwoServicesShouldHaveBeenCalledTimes(10)) - .And(x => x.ThenBothServicesCalledRealisticAmountOfTimes(4, 6)) - .And(x => WhenIRemoveAService(serviceEntryTwo)) - .And(x => GivenIResetCounters()) - .And(x => _steps.WhenIGetUrlOnTheApiGatewayMultipleTimes("/", 10)) - .And(x => ThenOnlyOneServiceHasBeenCalled()) - .And(x => WhenIAddAServiceBackIn(serviceEntryTwo)) - .And(x => GivenIResetCounters()) - .When(x => _steps.WhenIGetUrlOnTheApiGatewayMultipleTimes("/", 10)) - .Then(x => x.ThenTheTwoServicesShouldHaveBeenCalledTimes(10)) - .And(x => x.ThenBothServicesCalledRealisticAmountOfTimes(4, 6)) - .BDDfy(); - } - - [Fact] - public void should_handle_request_to_poll_consul_for_downstream_service_and_make_request() - { - var consulPort = PortFinder.GetRandomPort(); - const string serviceName = "web"; - var downstreamServicePort = PortFinder.GetRandomPort(); - var downstreamServiceOneUrl = $"http://localhost:{downstreamServicePort}"; - var fakeConsulServiceDiscoveryUrl = $"http://localhost:{consulPort}"; - var serviceEntryOne = new ServiceEntry - { - Service = new AgentService - { - Service = serviceName, - Address = "localhost", - Port = downstreamServicePort, - ID = $"web_90_0_2_224_{downstreamServicePort}", - Tags = new[] { "version-v1" }, - }, - }; - - var configuration = new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/api/home", - DownstreamScheme = "http", - UpstreamPathTemplate = "/home", - UpstreamHttpMethod = new List { "Get", "Options" }, - ServiceName = serviceName, - LoadBalancerOptions = new FileLoadBalancerOptions { Type = "LeastConnection" }, - }, - }, - GlobalConfiguration = new FileGlobalConfiguration - { - ServiceDiscoveryProvider = new FileServiceDiscoveryProvider - { - Scheme = "http", - Host = "localhost", - Port = consulPort, - Type = "PollConsul", - PollingInterval = 0, - Namespace = string.Empty, - }, - }, - }; - - this.Given(x => x.GivenThereIsAServiceRunningOn(downstreamServiceOneUrl, "/api/home", 200, "Hello from Laura")) - .And(x => x.GivenThereIsAFakeConsulServiceDiscoveryProvider(fakeConsulServiceDiscoveryUrl)) - .And(x => x.GivenTheServicesAreRegisteredWithConsul(serviceEntryOne)) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunningWithConsul()) - .When(x => _steps.WhenIGetUrlOnTheApiGatewayWaitingForTheResponseToBeOk("/home")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) - .BDDfy(); - } - - [Theory] - [Trait("PR", "1944")] - [Trait("Issues", "849 1496")] - [InlineData("LeastConnection")] - [InlineData("RoundRobin")] - [InlineData("NoLoadBalancer")] - [InlineData("CookieStickySessions")] - public void Should_use_consul_service_discovery_based_on_upstream_host(string loadBalancerType) - { - // Simulate two DIFFERENT downstream services (e.g. product services for US and EU markets) - // with different ServiceNames (e.g. product-us and product-eu), - // UpstreamHost is used to determine which ServiceName to use when making a request to Consul (e.g. Host: us-shop goes to product-us) - var consulPort = PortFinder.GetRandomPort(); - var servicePortUS = PortFinder.GetRandomPort(); - var servicePortEU = PortFinder.GetRandomPort(); - var serviceNameUS = "product-us"; - var serviceNameEU = "product-eu"; - var downstreamServiceUrlUS = $"http://localhost:{servicePortUS}"; - var downstreamServiceUrlEU = $"http://localhost:{servicePortEU}"; - var upstreamHostUS = "us-shop"; - var upstreamHostEU = "eu-shop"; - var publicUrlUS = $"http://{upstreamHostUS}"; - var publicUrlEU = $"http://{upstreamHostEU}"; - var responseBodyUS = "Phone chargers with US plug"; - var responseBodyEU = "Phone chargers with EU plug"; - var fakeConsulServiceDiscoveryUrl = $"http://localhost:{consulPort}"; - var serviceEntryUS = new ServiceEntry - { - Service = new AgentService - { - Service = serviceNameUS, - Address = "localhost", - Port = servicePortUS, - ID = Guid.NewGuid().ToString(), - Tags = new string[] { "US" }, - }, - }; - var serviceEntryEU = new ServiceEntry - { - Service = new AgentService - { - Service = serviceNameEU, - Address = "localhost", - Port = servicePortEU, - ID = Guid.NewGuid().ToString(), - Tags = new string[] { "EU" }, - }, - }; - - var configuration = new FileConfiguration - { - Routes = new() - { - new() - { - DownstreamPathTemplate = "/products", - DownstreamScheme = "http", - UpstreamPathTemplate = "/", - UpstreamHttpMethod = new() { "Get" }, - UpstreamHost = upstreamHostUS, - ServiceName = serviceNameUS, - LoadBalancerOptions = new() { Type = loadBalancerType }, - }, - new() - { - DownstreamPathTemplate = "/products", - DownstreamScheme = "http", - UpstreamPathTemplate = "/", - UpstreamHttpMethod = new() {"Get" }, - UpstreamHost = upstreamHostEU, - ServiceName = serviceNameEU, - LoadBalancerOptions = new() { Type = loadBalancerType }, - }, - }, - GlobalConfiguration = new() - { - ServiceDiscoveryProvider = new() - { - Scheme = "http", - Host = "localhost", - Port = consulPort, - }, - }, - }; - - // Ocelot request for http://us-shop/ should find 'product-us' in Consul, call /products and return "Phone chargers with US plug" - // Ocelot request for http://eu-shop/ should find 'product-eu' in Consul, call /products and return "Phone chargers with EU plug" - this.Given(x => x._serviceHandler.GivenThereIsAServiceRunningOn(downstreamServiceUrlUS, "/products", MapGet("/products", responseBodyUS))) - .And(x => x._serviceHandler2.GivenThereIsAServiceRunningOn(downstreamServiceUrlEU, "/products", MapGet("/products", responseBodyEU))) - .And(x => x.GivenThereIsAFakeConsulServiceDiscoveryProvider(fakeConsulServiceDiscoveryUrl)) - .And(x => x.GivenTheServicesAreRegisteredWithConsul(serviceEntryUS, serviceEntryEU)) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunningWithConsul(publicUrlUS, publicUrlEU)) - .When(x => _steps.WhenIGetUrlOnTheApiGateway(publicUrlUS), "When I get US shop for the first time") - .Then(x => x.ThenConsulShouldHaveBeenCalledTimes(1)) - .And(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe(responseBodyUS)) - .When(x => _steps.WhenIGetUrlOnTheApiGateway(publicUrlEU), "When I get EU shop for the first time") - .Then(x => x.ThenConsulShouldHaveBeenCalledTimes(2)) - .And(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe(responseBodyEU)) - .When(x => _steps.WhenIGetUrlOnTheApiGateway(publicUrlUS), "When I get US shop again") - .Then(x => x.ThenConsulShouldHaveBeenCalledTimes(3)) - .And(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe(responseBodyUS)) - .When(x => _steps.WhenIGetUrlOnTheApiGateway(publicUrlEU), "When I get EU shop again") - .Then(x => x.ThenConsulShouldHaveBeenCalledTimes(4)) - .And(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe(responseBodyEU)) - .BDDfy(); - } - - private void ThenTheTokenIs(string token) - { - _receivedToken.ShouldBe(token); - } - - private void WhenIAddAServiceBackIn(ServiceEntry serviceEntryTwo) - { - _consulServices.Add(serviceEntryTwo); - } - - private void ThenOnlyOneServiceHasBeenCalled() - { - _counterOne.ShouldBe(10); - _counterTwo.ShouldBe(0); - } - - private void WhenIRemoveAService(ServiceEntry serviceEntryTwo) - { - _consulServices.Remove(serviceEntryTwo); - } - - private void GivenIResetCounters() - { - _counterOne = 0; - _counterTwo = 0; - _counterConsul = 0; - } - - private void ThenBothServicesCalledRealisticAmountOfTimes(int bottom, int top) - { - _counterOne.ShouldBeInRange(bottom, top); - _counterOne.ShouldBeInRange(bottom, top); - } - - private void ThenTheTwoServicesShouldHaveBeenCalledTimes(int expected) - { - var total = _counterOne + _counterTwo; - total.ShouldBe(expected); - } - - private void GivenTheServicesAreRegisteredWithConsul(params ServiceEntry[] serviceEntries) - { - foreach (var serviceEntry in serviceEntries) - { - _consulServices.Add(serviceEntry); - } - } - - private void GivenThereIsAFakeConsulServiceDiscoveryProvider(string url) - { - _consulHandler.GivenThereIsAServiceRunningOn(url, async context => - { - if (context.Request.Headers.TryGetValue("X-Consul-Token", out var values)) - { - _receivedToken = values.First(); - } - - // Parse the request path to get the service name - var pathMatch = Regex.Match(context.Request.Path.Value, "/v1/health/service/(?[^/]+)"); - if (pathMatch.Success) - { - _counterConsul++; - - // Use the parsed service name to filter the registered Consul services - var serviceName = pathMatch.Groups["serviceName"].Value; - var services = _consulServices.Where(x => x.Service.Service == serviceName).ToList(); - var json = JsonConvert.SerializeObject(services); - context.Response.Headers.Append("Content-Type", "application/json"); - await context.Response.WriteAsync(json); - } - }); - } - - private void ThenConsulShouldHaveBeenCalledTimes(int expected) - { - _counterConsul.ShouldBe(expected); - } - - private void GivenProductServiceOneIsRunning(string url, int statusCode) - { - _serviceHandler.GivenThereIsAServiceRunningOn(url, async context => - { - try - { - string response; - lock (SyncLock) - { - _counterOne++; - response = _counterOne.ToString(); - } - - context.Response.StatusCode = statusCode; - await context.Response.WriteAsync(response); - } - catch (Exception exception) - { - await context.Response.WriteAsync(exception.StackTrace); - } - }); - } - - private void GivenProductServiceTwoIsRunning(string url, int statusCode) - { - _serviceHandler2.GivenThereIsAServiceRunningOn(url, async context => - { - try - { - string response; - lock (SyncLock) - { - _counterTwo++; - response = _counterTwo.ToString(); - } - - context.Response.StatusCode = statusCode; - await context.Response.WriteAsync(response); - } - catch (Exception exception) - { - await context.Response.WriteAsync(exception.StackTrace); - } - }); - } - - private void GivenThereIsAServiceRunningOn(string baseUrl, string basePath, int statusCode, string responseBody) - { - _serviceHandler.GivenThereIsAServiceRunningOn(baseUrl, basePath, async context => - { - _downstreamPath = !string.IsNullOrEmpty(context.Request.PathBase.Value) ? context.Request.PathBase.Value : context.Request.Path.Value; - - if (_downstreamPath != basePath) - { - context.Response.StatusCode = statusCode; - await context.Response.WriteAsync("downstream path didnt match base path"); - } - else - { - context.Response.StatusCode = statusCode; - await context.Response.WriteAsync(responseBody); - } - }); - } - - private RequestDelegate MapGet(string path, string responseBody) => async context => - { - var downstreamPath = !string.IsNullOrEmpty(context.Request.PathBase.Value) ? context.Request.PathBase.Value : context.Request.Path.Value; - if (downstreamPath == path) - { - context.Response.StatusCode = 200; - await context.Response.WriteAsync(responseBody); - } - else - { - context.Response.StatusCode = 404; - await context.Response.WriteAsync("Not Found"); - } - }; - - public void Dispose() - { - _serviceHandler?.Dispose(); - _serviceHandler2?.Dispose(); - _consulHandler?.Dispose(); - _steps.Dispose(); - } - } -} +using Consul; +using Microsoft.AspNetCore.Http; +using Newtonsoft.Json; +using Ocelot.Configuration.File; +using System.Text.RegularExpressions; + +namespace Ocelot.AcceptanceTests.ServiceDiscovery +{ + public class ConsulServiceDiscoveryTests : IDisposable + { + private readonly Steps _steps; + private readonly List _consulServices; + private int _counterOne; + private int _counterTwo; + private int _counterConsul; + private static readonly object SyncLock = new(); + private string _downstreamPath; + private string _receivedToken; + private readonly ServiceHandler _serviceHandler; + private readonly ServiceHandler _serviceHandler2; + private readonly ServiceHandler _consulHandler; + + public ConsulServiceDiscoveryTests() + { + _serviceHandler = new ServiceHandler(); + _serviceHandler2 = new ServiceHandler(); + _consulHandler = new ServiceHandler(); + _steps = new Steps(); + _consulServices = new List(); + } + + [Fact] + public void should_use_consul_service_discovery_and_load_balance_request() + { + var consulPort = PortFinder.GetRandomPort(); + var servicePort1 = PortFinder.GetRandomPort(); + var servicePort2 = PortFinder.GetRandomPort(); + var serviceName = "product"; + var downstreamServiceOneUrl = $"http://localhost:{servicePort1}"; + var downstreamServiceTwoUrl = $"http://localhost:{servicePort2}"; + var fakeConsulServiceDiscoveryUrl = $"http://localhost:{consulPort}"; + var serviceEntryOne = new ServiceEntry + { + Service = new AgentService + { + Service = serviceName, + Address = "localhost", + Port = servicePort1, + ID = Guid.NewGuid().ToString(), + Tags = Array.Empty(), + }, + }; + var serviceEntryTwo = new ServiceEntry + { + Service = new AgentService + { + Service = serviceName, + Address = "localhost", + Port = servicePort2, + ID = Guid.NewGuid().ToString(), + Tags = Array.Empty(), + }, + }; + + var configuration = new FileConfiguration + { + Routes = new List + { + new() + { + DownstreamPathTemplate = "/", + DownstreamScheme = "http", + UpstreamPathTemplate = "/", + UpstreamHttpMethod = new List { "Get" }, + ServiceName = serviceName, + LoadBalancerOptions = new FileLoadBalancerOptions { Type = "LeastConnection" }, + }, + }, + GlobalConfiguration = new FileGlobalConfiguration + { + ServiceDiscoveryProvider = new FileServiceDiscoveryProvider + { + Scheme = "http", + Host = "localhost", + Port = consulPort, + }, + }, + }; + + this.Given(x => x.GivenProductServiceOneIsRunning(downstreamServiceOneUrl, 200)) + .And(x => x.GivenProductServiceTwoIsRunning(downstreamServiceTwoUrl, 200)) + .And(x => x.GivenThereIsAFakeConsulServiceDiscoveryProvider(fakeConsulServiceDiscoveryUrl)) + .And(x => x.GivenTheServicesAreRegisteredWithConsul(serviceEntryOne, serviceEntryTwo)) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunningWithConsul()) + .When(x => _steps.WhenIGetUrlOnTheApiGatewayMultipleTimes("/", 50)) + .Then(x => x.ThenTheTwoServicesShouldHaveBeenCalledTimes(50)) + .And(x => x.ThenBothServicesCalledRealisticAmountOfTimes(24, 26)) + .BDDfy(); + } + + [Fact] + public void should_handle_request_to_consul_for_downstream_service_and_make_request() + { + var consulPort = PortFinder.GetRandomPort(); + var servicePort = PortFinder.GetRandomPort(); + const string serviceName = "web"; + var downstreamServiceOneUrl = $"http://localhost:{servicePort}"; + var fakeConsulServiceDiscoveryUrl = $"http://localhost:{consulPort}"; + var serviceEntryOne = new ServiceEntry + { + Service = new AgentService + { + Service = serviceName, + Address = "localhost", + Port = servicePort, + ID = "web_90_0_2_224_8080", + Tags = new[] { "version-v1" }, + }, + }; + + var configuration = new FileConfiguration + { + Routes = new List + { + new() + { + DownstreamPathTemplate = "/api/home", + DownstreamScheme = "http", + UpstreamPathTemplate = "/home", + UpstreamHttpMethod = new List { "Get", "Options" }, + ServiceName = serviceName, + LoadBalancerOptions = new FileLoadBalancerOptions { Type = "LeastConnection" }, + }, + }, + GlobalConfiguration = new FileGlobalConfiguration + { + ServiceDiscoveryProvider = new FileServiceDiscoveryProvider + { + Scheme = "http", + Host = "localhost", + Port = consulPort, + }, + }, + }; + + this.Given(x => x.GivenThereIsAServiceRunningOn(downstreamServiceOneUrl, "/api/home", 200, "Hello from Laura")) + .And(x => x.GivenThereIsAFakeConsulServiceDiscoveryProvider(fakeConsulServiceDiscoveryUrl)) + .And(x => x.GivenTheServicesAreRegisteredWithConsul(serviceEntryOne)) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunningWithConsul()) + .When(x => _steps.WhenIGetUrlOnTheApiGateway("/home")) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) + .BDDfy(); + } + + [Fact] + public void should_handle_request_to_consul_for_downstream_service_and_make_request_no_re_routes() + { + var consulPort = PortFinder.GetRandomPort(); + const string serviceName = "web"; + var downstreamServicePort = PortFinder.GetRandomPort(); + var downstreamServiceOneUrl = $"http://localhost:{downstreamServicePort}"; + var fakeConsulServiceDiscoveryUrl = $"http://localhost:{consulPort}"; + var serviceEntryOne = new ServiceEntry + { + Service = new AgentService + { + Service = serviceName, + Address = "localhost", + Port = downstreamServicePort, + ID = "web_90_0_2_224_8080", + Tags = new[] { "version-v1" }, + }, + }; + + var configuration = new FileConfiguration + { + GlobalConfiguration = new FileGlobalConfiguration + { + ServiceDiscoveryProvider = new FileServiceDiscoveryProvider + { + Scheme = "http", + Host = "localhost", + Port = consulPort, + }, + DownstreamScheme = "http", + HttpHandlerOptions = new FileHttpHandlerOptions + { + AllowAutoRedirect = true, + UseCookieContainer = true, + UseTracing = false, + }, + }, + }; + + this.Given(x => x.GivenThereIsAServiceRunningOn(downstreamServiceOneUrl, "/something", 200, "Hello from Laura")) + .And(x => x.GivenThereIsAFakeConsulServiceDiscoveryProvider(fakeConsulServiceDiscoveryUrl)) + .And(x => x.GivenTheServicesAreRegisteredWithConsul(serviceEntryOne)) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunningWithConsul()) + .When(x => _steps.WhenIGetUrlOnTheApiGateway("/web/something")) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) + .BDDfy(); + } + + [Fact] + public void should_use_consul_service_discovery_and_load_balance_request_no_re_routes() + { + var consulPort = PortFinder.GetRandomPort(); + var serviceName = "product"; + var serviceOnePort = PortFinder.GetRandomPort(); + var serviceTwoPort = PortFinder.GetRandomPort(); + var downstreamServiceOneUrl = $"http://localhost:{serviceOnePort}"; + var downstreamServiceTwoUrl = $"http://localhost:{serviceTwoPort}"; + var fakeConsulServiceDiscoveryUrl = $"http://localhost:{consulPort}"; + var serviceEntryOne = new ServiceEntry + { + Service = new AgentService + { + Service = serviceName, + Address = "localhost", + Port = serviceOnePort, + ID = Guid.NewGuid().ToString(), + Tags = Array.Empty(), + }, + }; + var serviceEntryTwo = new ServiceEntry + { + Service = new AgentService + { + Service = serviceName, + Address = "localhost", + Port = serviceTwoPort, + ID = Guid.NewGuid().ToString(), + Tags = Array.Empty(), + }, + }; + + var configuration = new FileConfiguration + { + GlobalConfiguration = new FileGlobalConfiguration + { + ServiceDiscoveryProvider = new FileServiceDiscoveryProvider + { + Scheme = "http", + Host = "localhost", + Port = consulPort, + }, + LoadBalancerOptions = new FileLoadBalancerOptions { Type = "LeastConnection" }, + DownstreamScheme = "http", + }, + }; + + this.Given(x => x.GivenProductServiceOneIsRunning(downstreamServiceOneUrl, 200)) + .And(x => x.GivenProductServiceTwoIsRunning(downstreamServiceTwoUrl, 200)) + .And(x => x.GivenThereIsAFakeConsulServiceDiscoveryProvider(fakeConsulServiceDiscoveryUrl)) + .And(x => x.GivenTheServicesAreRegisteredWithConsul(serviceEntryOne, serviceEntryTwo)) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunningWithConsul()) + .When(x => _steps.WhenIGetUrlOnTheApiGatewayMultipleTimes($"/{serviceName}/", 50)) + .Then(x => x.ThenTheTwoServicesShouldHaveBeenCalledTimes(50)) + .And(x => x.ThenBothServicesCalledRealisticAmountOfTimes(24, 26)) + .BDDfy(); + } + + [Fact] + public void should_use_token_to_make_request_to_consul() + { + var token = "abctoken"; + var consulPort = PortFinder.GetRandomPort(); + var serviceName = "web"; + var servicePort = PortFinder.GetRandomPort(); + var downstreamServiceOneUrl = $"http://localhost:{servicePort}"; + var fakeConsulServiceDiscoveryUrl = $"http://localhost:{consulPort}"; + var serviceEntryOne = new ServiceEntry + { + Service = new AgentService + { + Service = serviceName, + Address = "localhost", + Port = servicePort, + ID = "web_90_0_2_224_8080", + Tags = new[] { "version-v1" }, + }, + }; + + var configuration = new FileConfiguration + { + Routes = new List + { + new() + { + DownstreamPathTemplate = "/api/home", + DownstreamScheme = "http", + UpstreamPathTemplate = "/home", + UpstreamHttpMethod = new List { "Get", "Options" }, + ServiceName = serviceName, + LoadBalancerOptions = new FileLoadBalancerOptions { Type = "LeastConnection" }, + }, + }, + GlobalConfiguration = new FileGlobalConfiguration + { + ServiceDiscoveryProvider = new FileServiceDiscoveryProvider + { + Scheme = "http", + Host = "localhost", + Port = consulPort, + Token = token, + }, + }, + }; + + this.Given(_ => GivenThereIsAServiceRunningOn(downstreamServiceOneUrl, "/api/home", 200, "Hello from Laura")) + .And(_ => GivenThereIsAFakeConsulServiceDiscoveryProvider(fakeConsulServiceDiscoveryUrl)) + .And(_ => GivenTheServicesAreRegisteredWithConsul(serviceEntryOne)) + .And(_ => _steps.GivenThereIsAConfiguration(configuration)) + .And(_ => _steps.GivenOcelotIsRunningWithConsul()) + .When(_ => _steps.WhenIGetUrlOnTheApiGateway("/home")) + .Then(_ => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(_ => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) + .And(_ => ThenTheTokenIs(token)) + .BDDfy(); + } + + [Fact] + public void should_send_request_to_service_after_it_becomes_available_in_consul() + { + var consulPort = PortFinder.GetRandomPort(); + var serviceName = "product"; + var servicePort1 = PortFinder.GetRandomPort(); + var servicePort2 = PortFinder.GetRandomPort(); + var downstreamServiceOneUrl = $"http://localhost:{servicePort1}"; + var downstreamServiceTwoUrl = $"http://localhost:{servicePort2}"; + var fakeConsulServiceDiscoveryUrl = $"http://localhost:{consulPort}"; + var serviceEntryOne = new ServiceEntry + { + Service = new AgentService + { + Service = serviceName, + Address = "localhost", + Port = servicePort1, + ID = Guid.NewGuid().ToString(), + Tags = Array.Empty(), + }, + }; + var serviceEntryTwo = new ServiceEntry + { + Service = new AgentService + { + Service = serviceName, + Address = "localhost", + Port = servicePort2, + ID = Guid.NewGuid().ToString(), + Tags = Array.Empty(), + }, + }; + + var configuration = new FileConfiguration + { + Routes = new List + { + new() + { + DownstreamPathTemplate = "/", + DownstreamScheme = "http", + UpstreamPathTemplate = "/", + UpstreamHttpMethod = new List { "Get" }, + ServiceName = serviceName, + LoadBalancerOptions = new FileLoadBalancerOptions { Type = "LeastConnection" }, + }, + }, + GlobalConfiguration = new FileGlobalConfiguration + { + ServiceDiscoveryProvider = new FileServiceDiscoveryProvider + { + Scheme = "http", + Host = "localhost", + Port = consulPort, + }, + }, + }; + + this.Given(x => x.GivenProductServiceOneIsRunning(downstreamServiceOneUrl, 200)) + .And(x => x.GivenProductServiceTwoIsRunning(downstreamServiceTwoUrl, 200)) + .And(x => x.GivenThereIsAFakeConsulServiceDiscoveryProvider(fakeConsulServiceDiscoveryUrl)) + .And(x => x.GivenTheServicesAreRegisteredWithConsul(serviceEntryOne, serviceEntryTwo)) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunningWithConsul()) + .And(x => _steps.WhenIGetUrlOnTheApiGatewayMultipleTimes("/", 10)) + .And(x => x.ThenTheTwoServicesShouldHaveBeenCalledTimes(10)) + .And(x => x.ThenBothServicesCalledRealisticAmountOfTimes(4, 6)) + .And(x => WhenIRemoveAService(serviceEntryTwo)) + .And(x => GivenIResetCounters()) + .And(x => _steps.WhenIGetUrlOnTheApiGatewayMultipleTimes("/", 10)) + .And(x => ThenOnlyOneServiceHasBeenCalled()) + .And(x => WhenIAddAServiceBackIn(serviceEntryTwo)) + .And(x => GivenIResetCounters()) + .When(x => _steps.WhenIGetUrlOnTheApiGatewayMultipleTimes("/", 10)) + .Then(x => x.ThenTheTwoServicesShouldHaveBeenCalledTimes(10)) + .And(x => x.ThenBothServicesCalledRealisticAmountOfTimes(4, 6)) + .BDDfy(); + } + + [Fact] + public void should_handle_request_to_poll_consul_for_downstream_service_and_make_request() + { + var consulPort = PortFinder.GetRandomPort(); + const string serviceName = "web"; + var downstreamServicePort = PortFinder.GetRandomPort(); + var downstreamServiceOneUrl = $"http://localhost:{downstreamServicePort}"; + var fakeConsulServiceDiscoveryUrl = $"http://localhost:{consulPort}"; + var serviceEntryOne = new ServiceEntry + { + Service = new AgentService + { + Service = serviceName, + Address = "localhost", + Port = downstreamServicePort, + ID = $"web_90_0_2_224_{downstreamServicePort}", + Tags = new[] { "version-v1" }, + }, + }; + + var configuration = new FileConfiguration + { + Routes = new List + { + new() + { + DownstreamPathTemplate = "/api/home", + DownstreamScheme = "http", + UpstreamPathTemplate = "/home", + UpstreamHttpMethod = new List { "Get", "Options" }, + ServiceName = serviceName, + LoadBalancerOptions = new FileLoadBalancerOptions { Type = "LeastConnection" }, + }, + }, + GlobalConfiguration = new FileGlobalConfiguration + { + ServiceDiscoveryProvider = new FileServiceDiscoveryProvider + { + Scheme = "http", + Host = "localhost", + Port = consulPort, + Type = "PollConsul", + PollingInterval = 0, + Namespace = string.Empty, + }, + }, + }; + + this.Given(x => x.GivenThereIsAServiceRunningOn(downstreamServiceOneUrl, "/api/home", 200, "Hello from Laura")) + .And(x => x.GivenThereIsAFakeConsulServiceDiscoveryProvider(fakeConsulServiceDiscoveryUrl)) + .And(x => x.GivenTheServicesAreRegisteredWithConsul(serviceEntryOne)) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunningWithConsul()) + .When(x => _steps.WhenIGetUrlOnTheApiGatewayWaitingForTheResponseToBeOk("/home")) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) + .BDDfy(); + } + + [Theory] + [Trait("PR", "1944")] + [Trait("Issues", "849 1496")] + [InlineData("LeastConnection")] + [InlineData("RoundRobin")] + [InlineData("NoLoadBalancer")] + [InlineData("CookieStickySessions")] + public void Should_use_consul_service_discovery_based_on_upstream_host(string loadBalancerType) + { + // Simulate two DIFFERENT downstream services (e.g. product services for US and EU markets) + // with different ServiceNames (e.g. product-us and product-eu), + // UpstreamHost is used to determine which ServiceName to use when making a request to Consul (e.g. Host: us-shop goes to product-us) + var consulPort = PortFinder.GetRandomPort(); + var servicePortUS = PortFinder.GetRandomPort(); + var servicePortEU = PortFinder.GetRandomPort(); + var serviceNameUS = "product-us"; + var serviceNameEU = "product-eu"; + var downstreamServiceUrlUS = $"http://localhost:{servicePortUS}"; + var downstreamServiceUrlEU = $"http://localhost:{servicePortEU}"; + var upstreamHostUS = "us-shop"; + var upstreamHostEU = "eu-shop"; + var publicUrlUS = $"http://{upstreamHostUS}"; + var publicUrlEU = $"http://{upstreamHostEU}"; + var responseBodyUS = "Phone chargers with US plug"; + var responseBodyEU = "Phone chargers with EU plug"; + var fakeConsulServiceDiscoveryUrl = $"http://localhost:{consulPort}"; + var serviceEntryUS = new ServiceEntry + { + Service = new AgentService + { + Service = serviceNameUS, + Address = "localhost", + Port = servicePortUS, + ID = Guid.NewGuid().ToString(), + Tags = new string[] { "US" }, + }, + }; + var serviceEntryEU = new ServiceEntry + { + Service = new AgentService + { + Service = serviceNameEU, + Address = "localhost", + Port = servicePortEU, + ID = Guid.NewGuid().ToString(), + Tags = new string[] { "EU" }, + }, + }; + + var configuration = new FileConfiguration + { + Routes = new() + { + new() + { + DownstreamPathTemplate = "/products", + DownstreamScheme = "http", + UpstreamPathTemplate = "/", + UpstreamHttpMethod = new() { "Get" }, + UpstreamHost = upstreamHostUS, + ServiceName = serviceNameUS, + LoadBalancerOptions = new() { Type = loadBalancerType }, + }, + new() + { + DownstreamPathTemplate = "/products", + DownstreamScheme = "http", + UpstreamPathTemplate = "/", + UpstreamHttpMethod = new() {"Get" }, + UpstreamHost = upstreamHostEU, + ServiceName = serviceNameEU, + LoadBalancerOptions = new() { Type = loadBalancerType }, + }, + }, + GlobalConfiguration = new() + { + ServiceDiscoveryProvider = new() + { + Scheme = "http", + Host = "localhost", + Port = consulPort, + }, + }, + }; + + // Ocelot request for http://us-shop/ should find 'product-us' in Consul, call /products and return "Phone chargers with US plug" + // Ocelot request for http://eu-shop/ should find 'product-eu' in Consul, call /products and return "Phone chargers with EU plug" + this.Given(x => x._serviceHandler.GivenThereIsAServiceRunningOn(downstreamServiceUrlUS, "/products", MapGet("/products", responseBodyUS))) + .And(x => x._serviceHandler2.GivenThereIsAServiceRunningOn(downstreamServiceUrlEU, "/products", MapGet("/products", responseBodyEU))) + .And(x => x.GivenThereIsAFakeConsulServiceDiscoveryProvider(fakeConsulServiceDiscoveryUrl)) + .And(x => x.GivenTheServicesAreRegisteredWithConsul(serviceEntryUS, serviceEntryEU)) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunningWithConsul(publicUrlUS, publicUrlEU)) + .When(x => _steps.WhenIGetUrlOnTheApiGateway(publicUrlUS), "When I get US shop for the first time") + .Then(x => x.ThenConsulShouldHaveBeenCalledTimes(1)) + .And(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe(responseBodyUS)) + .When(x => _steps.WhenIGetUrlOnTheApiGateway(publicUrlEU), "When I get EU shop for the first time") + .Then(x => x.ThenConsulShouldHaveBeenCalledTimes(2)) + .And(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe(responseBodyEU)) + .When(x => _steps.WhenIGetUrlOnTheApiGateway(publicUrlUS), "When I get US shop again") + .Then(x => x.ThenConsulShouldHaveBeenCalledTimes(3)) + .And(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe(responseBodyUS)) + .When(x => _steps.WhenIGetUrlOnTheApiGateway(publicUrlEU), "When I get EU shop again") + .Then(x => x.ThenConsulShouldHaveBeenCalledTimes(4)) + .And(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe(responseBodyEU)) + .BDDfy(); + } + + private void ThenTheTokenIs(string token) + { + _receivedToken.ShouldBe(token); + } + + private void WhenIAddAServiceBackIn(ServiceEntry serviceEntryTwo) + { + _consulServices.Add(serviceEntryTwo); + } + + private void ThenOnlyOneServiceHasBeenCalled() + { + _counterOne.ShouldBe(10); + _counterTwo.ShouldBe(0); + } + + private void WhenIRemoveAService(ServiceEntry serviceEntryTwo) + { + _consulServices.Remove(serviceEntryTwo); + } + + private void GivenIResetCounters() + { + _counterOne = 0; + _counterTwo = 0; + _counterConsul = 0; + } + + private void ThenBothServicesCalledRealisticAmountOfTimes(int bottom, int top) + { + _counterOne.ShouldBeInRange(bottom, top); + _counterOne.ShouldBeInRange(bottom, top); + } + + private void ThenTheTwoServicesShouldHaveBeenCalledTimes(int expected) + { + var total = _counterOne + _counterTwo; + total.ShouldBe(expected); + } + + private void GivenTheServicesAreRegisteredWithConsul(params ServiceEntry[] serviceEntries) + { + foreach (var serviceEntry in serviceEntries) + { + _consulServices.Add(serviceEntry); + } + } + + private void GivenThereIsAFakeConsulServiceDiscoveryProvider(string url) + { + _consulHandler.GivenThereIsAServiceRunningOn(url, async context => + { + if (context.Request.Headers.TryGetValue("X-Consul-Token", out var values)) + { + _receivedToken = values.First(); + } + + // Parse the request path to get the service name + var pathMatch = Regex.Match(context.Request.Path.Value, "/v1/health/service/(?[^/]+)"); + if (pathMatch.Success) + { + _counterConsul++; + + // Use the parsed service name to filter the registered Consul services + var serviceName = pathMatch.Groups["serviceName"].Value; + var services = _consulServices.Where(x => x.Service.Service == serviceName).ToList(); + var json = JsonConvert.SerializeObject(services); + context.Response.Headers.Append("Content-Type", "application/json"); + await context.Response.WriteAsync(json); + } + }); + } + + private void ThenConsulShouldHaveBeenCalledTimes(int expected) + { + _counterConsul.ShouldBe(expected); + } + + private void GivenProductServiceOneIsRunning(string url, int statusCode) + { + _serviceHandler.GivenThereIsAServiceRunningOn(url, async context => + { + try + { + string response; + lock (SyncLock) + { + _counterOne++; + response = _counterOne.ToString(); + } + + context.Response.StatusCode = statusCode; + await context.Response.WriteAsync(response); + } + catch (Exception exception) + { + await context.Response.WriteAsync(exception.StackTrace); + } + }); + } + + private void GivenProductServiceTwoIsRunning(string url, int statusCode) + { + _serviceHandler2.GivenThereIsAServiceRunningOn(url, async context => + { + try + { + string response; + lock (SyncLock) + { + _counterTwo++; + response = _counterTwo.ToString(); + } + + context.Response.StatusCode = statusCode; + await context.Response.WriteAsync(response); + } + catch (Exception exception) + { + await context.Response.WriteAsync(exception.StackTrace); + } + }); + } + + private void GivenThereIsAServiceRunningOn(string baseUrl, string basePath, int statusCode, string responseBody) + { + _serviceHandler.GivenThereIsAServiceRunningOn(baseUrl, basePath, async context => + { + _downstreamPath = !string.IsNullOrEmpty(context.Request.PathBase.Value) ? context.Request.PathBase.Value : context.Request.Path.Value; + + if (_downstreamPath != basePath) + { + context.Response.StatusCode = statusCode; + await context.Response.WriteAsync("downstream path didnt match base path"); + } + else + { + context.Response.StatusCode = statusCode; + await context.Response.WriteAsync(responseBody); + } + }); + } + + private RequestDelegate MapGet(string path, string responseBody) => async context => + { + var downstreamPath = !string.IsNullOrEmpty(context.Request.PathBase.Value) ? context.Request.PathBase.Value : context.Request.Path.Value; + if (downstreamPath == path) + { + context.Response.StatusCode = 200; + await context.Response.WriteAsync(responseBody); + } + else + { + context.Response.StatusCode = 404; + await context.Response.WriteAsync("Not Found"); + } + }; + + public void Dispose() + { + _serviceHandler?.Dispose(); + _serviceHandler2?.Dispose(); + _consulHandler?.Dispose(); + _steps.Dispose(); + } + } +} diff --git a/test/Ocelot.AcceptanceTests/EurekaServiceDiscoveryTests.cs b/test/Ocelot.AcceptanceTests/ServiceDiscovery/EurekaServiceDiscoveryTests.cs similarity index 96% rename from test/Ocelot.AcceptanceTests/EurekaServiceDiscoveryTests.cs rename to test/Ocelot.AcceptanceTests/ServiceDiscovery/EurekaServiceDiscoveryTests.cs index 1b7c1e99f..a12c20641 100644 --- a/test/Ocelot.AcceptanceTests/EurekaServiceDiscoveryTests.cs +++ b/test/Ocelot.AcceptanceTests/ServiceDiscovery/EurekaServiceDiscoveryTests.cs @@ -1,281 +1,282 @@ using Microsoft.AspNetCore.Http; using Newtonsoft.Json; using Ocelot.Configuration.File; +using Ocelot.LoadBalancer.LoadBalancers; using Steeltoe.Common.Discovery; - -namespace Ocelot.AcceptanceTests -{ - public class EurekaServiceDiscoveryTests : IDisposable - { - private readonly Steps _steps; - private readonly List _eurekaInstances; - private readonly ServiceHandler _serviceHandler; - - public EurekaServiceDiscoveryTests() - { - _serviceHandler = new ServiceHandler(); - _steps = new Steps(); - _eurekaInstances = new List(); - } - + +namespace Ocelot.AcceptanceTests.ServiceDiscovery +{ + public class EurekaServiceDiscoveryTests : IDisposable + { + private readonly Steps _steps; + private readonly List _eurekaInstances; + private readonly ServiceHandler _serviceHandler; + + public EurekaServiceDiscoveryTests() + { + _serviceHandler = new ServiceHandler(); + _steps = new Steps(); + _eurekaInstances = new List(); + } + [Theory] [InlineData(true)] - [InlineData(false)] - public void should_use_eureka_service_discovery_and_make_request(bool dotnetRunningInContainer) - { - Environment.SetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER", dotnetRunningInContainer.ToString()); - var eurekaPort = 8761; - var serviceName = "product"; + [InlineData(false)] + public void should_use_eureka_service_discovery_and_make_request(bool dotnetRunningInContainer) + { + Environment.SetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER", dotnetRunningInContainer.ToString()); + var eurekaPort = 8761; + var serviceName = "product"; var downstreamServicePort = PortFinder.GetRandomPort(); - var downstreamServiceOneUrl = $"http://localhost:{downstreamServicePort}"; - var fakeEurekaServiceDiscoveryUrl = $"http://localhost:{eurekaPort}"; - - var instanceOne = new FakeEurekaService(serviceName, "localhost", downstreamServicePort, false, - new Uri($"http://localhost:{downstreamServicePort}"), new Dictionary()); - - var configuration = new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/", - DownstreamScheme = "http", - UpstreamPathTemplate = "/", - UpstreamHttpMethod = new List { "Get" }, - ServiceName = serviceName, - LoadBalancerOptions = new FileLoadBalancerOptions { Type = "LeastConnection" }, - }, - }, - GlobalConfiguration = new FileGlobalConfiguration - { - ServiceDiscoveryProvider = new FileServiceDiscoveryProvider - { - Type = "Eureka", - }, - }, - }; - - this.Given(x => x.GivenEurekaProductServiceOneIsRunning(downstreamServiceOneUrl)) - .And(x => x.GivenThereIsAFakeEurekaServiceDiscoveryProvider(fakeEurekaServiceDiscoveryUrl, serviceName)) - .And(x => x.GivenTheServicesAreRegisteredWithEureka(instanceOne)) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunningWithEureka()) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(_ => _steps.ThenTheResponseBodyShouldBe(nameof(EurekaServiceDiscoveryTests))) - .BDDfy(); - } - - private void GivenTheServicesAreRegisteredWithEureka(params IServiceInstance[] serviceInstances) - { - foreach (var instance in serviceInstances) - { - _eurekaInstances.Add(instance); - } - } - - private void GivenThereIsAFakeEurekaServiceDiscoveryProvider(string url, string serviceName) - { - _serviceHandler.GivenThereIsAServiceRunningOn(url, async context => - { - if (context.Request.Path.Value == "/eureka/apps/") - { - var apps = new List(); - - foreach (var serviceInstance in _eurekaInstances) - { - var a = new Application - { - name = serviceName, - instance = new List - { - new() - { - instanceId = $"{serviceInstance.Host}:{serviceInstance}", - hostName = serviceInstance.Host, - app = serviceName, - ipAddr = "127.0.0.1", - status = "UP", - overriddenstatus = "UNKNOWN", - port = new Port {value = serviceInstance.Port, enabled = "true"}, - securePort = new SecurePort {value = serviceInstance.Port, enabled = "true"}, - countryId = 1, - dataCenterInfo = new DataCenterInfo {value = "com.netflix.appinfo.InstanceInfo$DefaultDataCenterInfo", name = "MyOwn"}, - leaseInfo = new LeaseInfo - { - renewalIntervalInSecs = 30, - durationInSecs = 90, - registrationTimestamp = 1457714988223, - lastRenewalTimestamp= 1457716158319, - evictionTimestamp = 0, - serviceUpTimestamp = 1457714988223, - }, - metadata = new Metadata - { - value = "java.util.Collections$EmptyMap", - }, - homePageUrl = $"{serviceInstance.Host}:{serviceInstance.Port}", - statusPageUrl = $"{serviceInstance.Host}:{serviceInstance.Port}", - healthCheckUrl = $"{serviceInstance.Host}:{serviceInstance.Port}", - vipAddress = serviceName, - isCoordinatingDiscoveryServer = "false", - lastUpdatedTimestamp = "1457714988223", - lastDirtyTimestamp = "1457714988172", - actionType = "ADDED", - }, - }, - }; - - apps.Add(a); - } - - var applications = new EurekaApplications - { - applications = new Applications - { - application = apps, - apps__hashcode = "UP_1_", - versions__delta = "1", - }, - }; - - var json = JsonConvert.SerializeObject(applications); - context.Response.Headers.Append("Content-Type", "application/json"); - await context.Response.WriteAsync(json); - } - }); - } - - private void GivenEurekaProductServiceOneIsRunning(string url) - { - _serviceHandler.GivenThereIsAServiceRunningOn(url, async context => - { - try - { - context.Response.StatusCode = 200; - await context.Response.WriteAsync(nameof(EurekaServiceDiscoveryTests)); - } - catch (Exception exception) - { - await context.Response.WriteAsync(exception.StackTrace); - } - }); - } - - public void Dispose() + var downstreamServiceOneUrl = $"http://localhost:{downstreamServicePort}"; + var fakeEurekaServiceDiscoveryUrl = $"http://localhost:{eurekaPort}"; + + var instanceOne = new FakeEurekaService(serviceName, "localhost", downstreamServicePort, false, + new Uri($"http://localhost:{downstreamServicePort}"), new Dictionary()); + + var configuration = new FileConfiguration + { + Routes = new List + { + new() + { + DownstreamPathTemplate = "/", + DownstreamScheme = "http", + UpstreamPathTemplate = "/", + UpstreamHttpMethod = new List { "Get" }, + ServiceName = serviceName, + LoadBalancerOptions = new FileLoadBalancerOptions { Type = nameof(LeastConnection) }, + }, + }, + GlobalConfiguration = new FileGlobalConfiguration + { + ServiceDiscoveryProvider = new FileServiceDiscoveryProvider + { + Type = "Eureka", + }, + }, + }; + + this.Given(x => x.GivenEurekaProductServiceOneIsRunning(downstreamServiceOneUrl)) + .And(x => x.GivenThereIsAFakeEurekaServiceDiscoveryProvider(fakeEurekaServiceDiscoveryUrl, serviceName)) + .And(x => x.GivenTheServicesAreRegisteredWithEureka(instanceOne)) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunningWithEureka()) + .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(_ => _steps.ThenTheResponseBodyShouldBe(nameof(EurekaServiceDiscoveryTests))) + .BDDfy(); + } + + private void GivenTheServicesAreRegisteredWithEureka(params IServiceInstance[] serviceInstances) + { + foreach (var instance in serviceInstances) + { + _eurekaInstances.Add(instance); + } + } + + private void GivenThereIsAFakeEurekaServiceDiscoveryProvider(string url, string serviceName) + { + _serviceHandler.GivenThereIsAServiceRunningOn(url, async context => + { + if (context.Request.Path.Value == "/eureka/apps/") + { + var apps = new List(); + + foreach (var serviceInstance in _eurekaInstances) + { + var a = new Application + { + name = serviceName, + instance = new List + { + new() + { + instanceId = $"{serviceInstance.Host}:{serviceInstance}", + hostName = serviceInstance.Host, + app = serviceName, + ipAddr = "127.0.0.1", + status = "UP", + overriddenstatus = "UNKNOWN", + port = new Port {value = serviceInstance.Port, enabled = "true"}, + securePort = new SecurePort {value = serviceInstance.Port, enabled = "true"}, + countryId = 1, + dataCenterInfo = new DataCenterInfo {value = "com.netflix.appinfo.InstanceInfo$DefaultDataCenterInfo", name = "MyOwn"}, + leaseInfo = new LeaseInfo + { + renewalIntervalInSecs = 30, + durationInSecs = 90, + registrationTimestamp = 1457714988223, + lastRenewalTimestamp= 1457716158319, + evictionTimestamp = 0, + serviceUpTimestamp = 1457714988223, + }, + metadata = new Metadata + { + value = "java.util.Collections$EmptyMap", + }, + homePageUrl = $"{serviceInstance.Host}:{serviceInstance.Port}", + statusPageUrl = $"{serviceInstance.Host}:{serviceInstance.Port}", + healthCheckUrl = $"{serviceInstance.Host}:{serviceInstance.Port}", + vipAddress = serviceName, + isCoordinatingDiscoveryServer = "false", + lastUpdatedTimestamp = "1457714988223", + lastDirtyTimestamp = "1457714988172", + actionType = "ADDED", + }, + }, + }; + + apps.Add(a); + } + + var applications = new EurekaApplications + { + applications = new Applications + { + application = apps, + apps__hashcode = "UP_1_", + versions__delta = "1", + }, + }; + + var json = JsonConvert.SerializeObject(applications); + context.Response.Headers.Append("Content-Type", "application/json"); + await context.Response.WriteAsync(json); + } + }); + } + + private void GivenEurekaProductServiceOneIsRunning(string url) + { + _serviceHandler.GivenThereIsAServiceRunningOn(url, async context => + { + try + { + context.Response.StatusCode = 200; + await context.Response.WriteAsync(nameof(EurekaServiceDiscoveryTests)); + } + catch (Exception exception) + { + await context.Response.WriteAsync(exception.StackTrace); + } + }); + } + + public void Dispose() + { + _serviceHandler?.Dispose(); + _steps.Dispose(); + } + } + + public class FakeEurekaService : IServiceInstance + { + public FakeEurekaService(string serviceId, string host, int port, bool isSecure, Uri uri, IDictionary metadata) { - _serviceHandler?.Dispose(); - _steps.Dispose(); - } - } - - public class FakeEurekaService : IServiceInstance - { - public FakeEurekaService(string serviceId, string host, int port, bool isSecure, Uri uri, IDictionary metadata) - { - ServiceId = serviceId; - Host = host; - Port = port; - IsSecure = isSecure; - Uri = uri; - Metadata = metadata; - } - - public string ServiceId { get; } - public string Host { get; } - public int Port { get; } - public bool IsSecure { get; } - public Uri Uri { get; } - public IDictionary Metadata { get; } - } - - public class Port - { - [JsonProperty("$")] - public int value { get; set; } - - [JsonProperty("@enabled")] - public string enabled { get; set; } - } - - public class SecurePort - { - [JsonProperty("$")] - public int value { get; set; } - - [JsonProperty("@enabled")] - public string enabled { get; set; } - } - - public class DataCenterInfo - { - [JsonProperty("@class")] - public string value { get; set; } - - public string name { get; set; } - } - - public class LeaseInfo - { - public int renewalIntervalInSecs { get; set; } - - public int durationInSecs { get; set; } - - public long registrationTimestamp { get; set; } - - public long lastRenewalTimestamp { get; set; } - - public int evictionTimestamp { get; set; } - - public long serviceUpTimestamp { get; set; } - } - - public class Metadata - { - [JsonProperty("@class")] - public string value { get; set; } - } - - public class Instance - { - public string instanceId { get; set; } - public string hostName { get; set; } - public string app { get; set; } - public string ipAddr { get; set; } - public string status { get; set; } - public string overriddenstatus { get; set; } - public Port port { get; set; } - public SecurePort securePort { get; set; } - public int countryId { get; set; } - public DataCenterInfo dataCenterInfo { get; set; } - public LeaseInfo leaseInfo { get; set; } - public Metadata metadata { get; set; } - public string homePageUrl { get; set; } - public string statusPageUrl { get; set; } - public string healthCheckUrl { get; set; } - public string vipAddress { get; set; } - public string isCoordinatingDiscoveryServer { get; set; } - public string lastUpdatedTimestamp { get; set; } - public string lastDirtyTimestamp { get; set; } - public string actionType { get; set; } - } - - public class Application - { - public string name { get; set; } - public List instance { get; set; } - } - - public class Applications - { - public string versions__delta { get; set; } - public string apps__hashcode { get; set; } - public List application { get; set; } - } - - public class EurekaApplications - { - public Applications applications { get; set; } - } -} + ServiceId = serviceId; + Host = host; + Port = port; + IsSecure = isSecure; + Uri = uri; + Metadata = metadata; + } + + public string ServiceId { get; } + public string Host { get; } + public int Port { get; } + public bool IsSecure { get; } + public Uri Uri { get; } + public IDictionary Metadata { get; } + } + + public class Port + { + [JsonProperty("$")] + public int value { get; set; } + + [JsonProperty("@enabled")] + public string enabled { get; set; } + } + + public class SecurePort + { + [JsonProperty("$")] + public int value { get; set; } + + [JsonProperty("@enabled")] + public string enabled { get; set; } + } + + public class DataCenterInfo + { + [JsonProperty("@class")] + public string value { get; set; } + + public string name { get; set; } + } + + public class LeaseInfo + { + public int renewalIntervalInSecs { get; set; } + + public int durationInSecs { get; set; } + + public long registrationTimestamp { get; set; } + + public long lastRenewalTimestamp { get; set; } + + public int evictionTimestamp { get; set; } + + public long serviceUpTimestamp { get; set; } + } + + public class Metadata + { + [JsonProperty("@class")] + public string value { get; set; } + } + + public class Instance + { + public string instanceId { get; set; } + public string hostName { get; set; } + public string app { get; set; } + public string ipAddr { get; set; } + public string status { get; set; } + public string overriddenstatus { get; set; } + public Port port { get; set; } + public SecurePort securePort { get; set; } + public int countryId { get; set; } + public DataCenterInfo dataCenterInfo { get; set; } + public LeaseInfo leaseInfo { get; set; } + public Metadata metadata { get; set; } + public string homePageUrl { get; set; } + public string statusPageUrl { get; set; } + public string healthCheckUrl { get; set; } + public string vipAddress { get; set; } + public string isCoordinatingDiscoveryServer { get; set; } + public string lastUpdatedTimestamp { get; set; } + public string lastDirtyTimestamp { get; set; } + public string actionType { get; set; } + } + + public class Application + { + public string name { get; set; } + public List instance { get; set; } + } + + public class Applications + { + public string versions__delta { get; set; } + public string apps__hashcode { get; set; } + public List application { get; set; } + } + + public class EurekaApplications + { + public Applications applications { get; set; } + } +} diff --git a/test/Ocelot.AcceptanceTests/ServiceDiscovery/KubernetesServiceDiscoveryTests.cs b/test/Ocelot.AcceptanceTests/ServiceDiscovery/KubernetesServiceDiscoveryTests.cs new file mode 100644 index 000000000..5ca22da6e --- /dev/null +++ b/test/Ocelot.AcceptanceTests/ServiceDiscovery/KubernetesServiceDiscoveryTests.cs @@ -0,0 +1,212 @@ +using KubeClient; +using KubeClient.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Newtonsoft.Json; +using Ocelot.Configuration.File; +using Ocelot.DependencyInjection; +using Ocelot.LoadBalancer.LoadBalancers; +using Ocelot.Provider.Kubernetes; +using System.Runtime.CompilerServices; + +namespace Ocelot.AcceptanceTests.ServiceDiscovery; + +public sealed class KubernetesServiceDiscoveryTests : Steps, IDisposable +{ + private readonly string _kubernetesUrl; + private readonly IKubeApiClient _clientFactory; + private readonly ServiceHandler _serviceHandler; + private readonly ServiceHandler _kubernetesHandler; + private string _receivedToken; + + public KubernetesServiceDiscoveryTests() + { + _kubernetesUrl = DownstreamUrl(PortFinder.GetRandomPort()); //5567 + var option = new KubeClientOptions + { + ApiEndPoint = new Uri(_kubernetesUrl), + AccessToken = "txpc696iUhbVoudg164r93CxDTrKRVWG", + AuthStrategy = KubeAuthStrategy.BearerToken, + AllowInsecure = true, + }; + _clientFactory = KubeApiClient.Create(option); + _serviceHandler = new ServiceHandler(); + _kubernetesHandler = new ServiceHandler(); + } + + public override void Dispose() + { + _serviceHandler.Dispose(); + _kubernetesHandler.Dispose(); + base.Dispose(); + } + + [Fact] + public void ShouldReturnServicesFromK8s() + { + const string namespaces = nameof(KubernetesServiceDiscoveryTests); + const string serviceName = nameof(ShouldReturnServicesFromK8s); + var servicePort = PortFinder.GetRandomPort(); + var downstreamUrl = DownstreamUrl(servicePort); + var downstream = new Uri(downstreamUrl); + var subsetV1 = new EndpointSubsetV1(); + subsetV1.Addresses.Add(new() + { + Ip = Dns.GetHostAddresses(downstream.Host).Select(x => x.ToString()).First(a => a.Contains('.')), + Hostname = downstream.Host, + }); + subsetV1.Ports.Add(new() + { + Name = downstream.Scheme, + Port = servicePort, + }); + var endpoints = GivenEndpoints(subsetV1); + var route = GivenRouteWithServiceName(namespaces); + var configuration = GivenKubeConfiguration(namespaces, route); + var downstreamResponse = serviceName; + this.Given(x => GivenK8sProductServiceOneIsRunning(downstreamUrl, downstreamResponse)) + .And(x => GivenThereIsAFakeKubernetesProvider(serviceName, namespaces, endpoints)) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => x.GivenOcelotIsRunningWithKubernetes()) + .When(x => WhenIGetUrlOnTheApiGateway("/")) + .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(_ => ThenTheResponseBodyShouldBe(downstreamResponse)) + .And(_ => ThenTheTokenIs("Bearer txpc696iUhbVoudg164r93CxDTrKRVWG")) + .BDDfy(); + } + + [Theory] + [Trait("Feat", "1967")] + [InlineData("", HttpStatusCode.BadGateway)] + [InlineData("http", HttpStatusCode.OK)] + public void ShouldReturnServicesByPortNameAsDownstreamScheme(string downstreamScheme, HttpStatusCode statusCode) + { + const string serviceName = "example-web"; + const string namespaces = "default"; + var servicePort = PortFinder.GetRandomPort(); + var downstreamUrl = DownstreamUrl(servicePort); + var downstream = new Uri(downstreamUrl); + + var subsetV1 = new EndpointSubsetV1(); + subsetV1.Addresses.Add(new() + { + Ip = Dns.GetHostAddresses(downstream.Host).Select(x => x.ToString()).First(a => a.Contains('.')), + Hostname = downstream.Host, + }); + subsetV1.Ports.Add(new() + { + Name = "https", // This service instance is offline -> BadGateway + Port = 443, + }); + subsetV1.Ports.Add(new() + { + Name = downstream.Scheme, // http, should be real scheme + Port = downstream.Port, // not 80, should be real port + }); + var endpoints = GivenEndpoints(subsetV1); + + var route = GivenRouteWithServiceName(namespaces); + route.DownstreamPathTemplate = "/{url}"; + route.DownstreamScheme = downstreamScheme; // !!! Warning !!! Select port by name as scheme + route.UpstreamPathTemplate = "/api/example/{url}"; + route.ServiceName = serviceName; // "example-web" + var configuration = GivenKubeConfiguration(namespaces, route); + + this.Given(x => GivenK8sProductServiceOneIsRunning(downstreamUrl, nameof(ShouldReturnServicesByPortNameAsDownstreamScheme))) + .And(x => GivenThereIsAFakeKubernetesProvider(serviceName, namespaces, endpoints)) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => x.GivenOcelotIsRunningWithKubernetes()) + .When(x => WhenIGetUrlOnTheApiGateway("/api/example/1")) + .Then(x => ThenTheStatusCodeShouldBe(statusCode)) + .And(_ => ThenTheResponseBodyShouldBe(downstreamScheme == "http" + ? nameof(ShouldReturnServicesByPortNameAsDownstreamScheme) : string.Empty)) + .And(_ => ThenTheTokenIs("Bearer txpc696iUhbVoudg164r93CxDTrKRVWG")) + .BDDfy(); + } + + private void ThenTheTokenIs(string token) + { + _receivedToken.ShouldBe(token); + } + + private EndpointsV1 GivenEndpoints(EndpointSubsetV1 subset, [CallerMemberName] string serviceName = "") + { + var e = new EndpointsV1() + { + Kind = "endpoint", + ApiVersion = "1.0", + Metadata = new() + { + Name = serviceName, + Namespace = nameof(KubernetesServiceDiscoveryTests), + }, + }; + e.Subsets.Add(subset); + return e; + } + + private FileRoute GivenRouteWithServiceName(string serviceNamespace, [CallerMemberName] string serviceName = null) => new() + { + DownstreamPathTemplate = "/", + DownstreamScheme = Uri.UriSchemeHttp, + UpstreamPathTemplate = "/", + UpstreamHttpMethod = new() { HttpMethods.Get }, + ServiceName = serviceName, + ServiceNamespace = serviceNamespace, + LoadBalancerOptions = new() { Type = nameof(LeastConnection) }, + }; + + private FileConfiguration GivenKubeConfiguration(string serviceNamespace, params FileRoute[] routes) + { + var u = new Uri(_kubernetesUrl); + var configuration = GivenConfiguration(routes); + configuration.GlobalConfiguration.ServiceDiscoveryProvider = new() + { + Scheme = u.Scheme, + Host = u.Host, + Port = u.Port, + Type = nameof(Kube), + PollingInterval = 0, + Namespace = serviceNamespace, + }; + return configuration; + } + + private void GivenThereIsAFakeKubernetesProvider(string serviceName, string namespaces, EndpointsV1 endpoints) + => _kubernetesHandler.GivenThereIsAServiceRunningOn(_kubernetesUrl, async context => + { + if (context.Request.Path.Value == $"/api/v1/namespaces/{namespaces}/endpoints/{serviceName}") + { + if (context.Request.Headers.TryGetValue("Authorization", out var values)) + { + _receivedToken = values.First(); + } + + var json = JsonConvert.SerializeObject(endpoints); + context.Response.Headers.Append("Content-Type", "application/json"); + await context.Response.WriteAsync(json); + } + }); + + private void GivenOcelotIsRunningWithKubernetes() + => GivenOcelotIsRunningWithServices(s => + { + s.AddOcelot().AddKubernetes(); + s.RemoveAll().AddSingleton(_clientFactory); + }); + + private void GivenK8sProductServiceOneIsRunning(string url, string response) + => _serviceHandler.GivenThereIsAServiceRunningOn(url, async context => + { + try + { + context.Response.StatusCode = (int)HttpStatusCode.OK; + await context.Response.WriteAsync(response ?? nameof(HttpStatusCode.OK)); + } + catch (Exception exception) + { + await context.Response.WriteAsync(exception.StackTrace); + } + }); +} diff --git a/test/Ocelot.AcceptanceTests/ServiceFabricTests.cs b/test/Ocelot.AcceptanceTests/ServiceDiscovery/ServiceFabricTests.cs similarity index 94% rename from test/Ocelot.AcceptanceTests/ServiceFabricTests.cs rename to test/Ocelot.AcceptanceTests/ServiceDiscovery/ServiceFabricTests.cs index 20db1ed4a..739b19a7d 100644 --- a/test/Ocelot.AcceptanceTests/ServiceFabricTests.cs +++ b/test/Ocelot.AcceptanceTests/ServiceDiscovery/ServiceFabricTests.cs @@ -1,209 +1,209 @@ using Microsoft.AspNetCore.Http; using Ocelot.Configuration.File; - -namespace Ocelot.AcceptanceTests -{ - public class ServiceFabricTests : IDisposable - { - private readonly Steps _steps; - private string _downstreamPath; - private readonly ServiceHandler _serviceHandler; - - public ServiceFabricTests() - { - _serviceHandler = new ServiceHandler(); - _steps = new Steps(); - } - + +namespace Ocelot.AcceptanceTests.ServiceDiscovery +{ + public class ServiceFabricTests : IDisposable + { + private readonly Steps _steps; + private string _downstreamPath; + private readonly ServiceHandler _serviceHandler; + + public ServiceFabricTests() + { + _serviceHandler = new ServiceHandler(); + _steps = new Steps(); + } + [Fact] - [Trait("PR", "570")] - [Trait("Bug", "555")] - public void should_fix_issue_555() - { - var port = PortFinder.GetRandomPort(); - - var configuration = new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/{everything}", - DownstreamScheme = "http", - UpstreamPathTemplate = "/{everything}", - UpstreamHttpMethod = new List { "Get" }, - ServiceName = "OcelotServiceApplication/OcelotApplicationService", - }, - }, - GlobalConfiguration = new FileGlobalConfiguration - { - ServiceDiscoveryProvider = new FileServiceDiscoveryProvider - { - Host = "localhost", - Port = port, - Type = "ServiceFabric", - }, - }, - }; - - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/OcelotServiceApplication/OcelotApplicationService/a", 200, "Hello from Laura", "b=c")) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunning()) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/a?b=c")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) - .BDDfy(); - } - - [Fact] - public void should_support_service_fabric_naming_and_dns_service_stateless_and_guest() - { - var port = PortFinder.GetRandomPort(); - - var configuration = new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/api/values", - DownstreamScheme = "http", - UpstreamPathTemplate = "/EquipmentInterfaces", - UpstreamHttpMethod = new List { "Get" }, - ServiceName = "OcelotServiceApplication/OcelotApplicationService", - }, - }, - GlobalConfiguration = new FileGlobalConfiguration - { - ServiceDiscoveryProvider = new FileServiceDiscoveryProvider - { - Host = "localhost", - Port = port, - Type = "ServiceFabric", - }, - }, - }; - - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/OcelotServiceApplication/OcelotApplicationService/api/values", 200, "Hello from Laura", "test=best")) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunning()) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/EquipmentInterfaces?test=best")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) - .BDDfy(); - } - - [Fact] - public void should_support_service_fabric_naming_and_dns_service_statefull_and_actors() - { - var port = PortFinder.GetRandomPort(); - - var configuration = new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/api/values", - DownstreamScheme = "http", - UpstreamPathTemplate = "/EquipmentInterfaces", - UpstreamHttpMethod = new List { "Get" }, - ServiceName = "OcelotServiceApplication/OcelotApplicationService", - }, - }, - GlobalConfiguration = new FileGlobalConfiguration - { - ServiceDiscoveryProvider = new FileServiceDiscoveryProvider - { - Host = "localhost", - Port = port, - Type = "ServiceFabric", - }, - }, - }; - - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/OcelotServiceApplication/OcelotApplicationService/api/values", 200, "Hello from Laura", "PartitionKind=test&PartitionKey=1")) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunning()) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/EquipmentInterfaces?PartitionKind=test&PartitionKey=1")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) - .BDDfy(); - } - - [Theory] - [Trait("PR", "722")] + [Trait("PR", "570")] + [Trait("Bug", "555")] + public void should_fix_issue_555() + { + var port = PortFinder.GetRandomPort(); + + var configuration = new FileConfiguration + { + Routes = new List + { + new() + { + DownstreamPathTemplate = "/{everything}", + DownstreamScheme = "http", + UpstreamPathTemplate = "/{everything}", + UpstreamHttpMethod = new List { "Get" }, + ServiceName = "OcelotServiceApplication/OcelotApplicationService", + }, + }, + GlobalConfiguration = new FileGlobalConfiguration + { + ServiceDiscoveryProvider = new FileServiceDiscoveryProvider + { + Host = "localhost", + Port = port, + Type = "ServiceFabric", + }, + }, + }; + + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/OcelotServiceApplication/OcelotApplicationService/a", 200, "Hello from Laura", "b=c")) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunning()) + .When(x => _steps.WhenIGetUrlOnTheApiGateway("/a?b=c")) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) + .BDDfy(); + } + + [Fact] + public void should_support_service_fabric_naming_and_dns_service_stateless_and_guest() + { + var port = PortFinder.GetRandomPort(); + + var configuration = new FileConfiguration + { + Routes = new List + { + new() + { + DownstreamPathTemplate = "/api/values", + DownstreamScheme = "http", + UpstreamPathTemplate = "/EquipmentInterfaces", + UpstreamHttpMethod = new List { "Get" }, + ServiceName = "OcelotServiceApplication/OcelotApplicationService", + }, + }, + GlobalConfiguration = new FileGlobalConfiguration + { + ServiceDiscoveryProvider = new FileServiceDiscoveryProvider + { + Host = "localhost", + Port = port, + Type = "ServiceFabric", + }, + }, + }; + + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/OcelotServiceApplication/OcelotApplicationService/api/values", 200, "Hello from Laura", "test=best")) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunning()) + .When(x => _steps.WhenIGetUrlOnTheApiGateway("/EquipmentInterfaces?test=best")) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) + .BDDfy(); + } + + [Fact] + public void should_support_service_fabric_naming_and_dns_service_statefull_and_actors() + { + var port = PortFinder.GetRandomPort(); + + var configuration = new FileConfiguration + { + Routes = new List + { + new() + { + DownstreamPathTemplate = "/api/values", + DownstreamScheme = "http", + UpstreamPathTemplate = "/EquipmentInterfaces", + UpstreamHttpMethod = new List { "Get" }, + ServiceName = "OcelotServiceApplication/OcelotApplicationService", + }, + }, + GlobalConfiguration = new FileGlobalConfiguration + { + ServiceDiscoveryProvider = new FileServiceDiscoveryProvider + { + Host = "localhost", + Port = port, + Type = "ServiceFabric", + }, + }, + }; + + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/OcelotServiceApplication/OcelotApplicationService/api/values", 200, "Hello from Laura", "PartitionKind=test&PartitionKey=1")) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunning()) + .When(x => _steps.WhenIGetUrlOnTheApiGateway("/EquipmentInterfaces?PartitionKind=test&PartitionKey=1")) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) + .BDDfy(); + } + + [Theory] + [Trait("PR", "722")] [Trait("Feat", "721")] - [InlineData("/api/{version}/values", "/values", "Service_{version}/Api", "/Service_1.0/Api/values", "/api/1.0/values?test=best", "test=best")] - [InlineData("/api/{version}/{all}", "/{all}", "Service_{version}/Api", "/Service_1.0/Api/products", "/api/1.0/products?test=the-best-from-Aly", "test=the-best-from-Aly")] - public void should_support_placeholder_in_service_fabric_service_name(string upstream, string downstream, string serviceName, string downstreamUrl, string url, string query) - { - var port = PortFinder.GetRandomPort(); - - var configuration = new FileConfiguration - { - Routes = new() - { - new() - { - DownstreamPathTemplate = downstream, - DownstreamScheme = Uri.UriSchemeHttp, - UpstreamPathTemplate = upstream, - UpstreamHttpMethod = new() { HttpMethods.Get }, - ServiceName = serviceName, - }, - }, - GlobalConfiguration = new FileGlobalConfiguration - { - ServiceDiscoveryProvider = new FileServiceDiscoveryProvider - { - Host = "localhost", - Port = port, - Type = "ServiceFabric", - }, - }, - }; - - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", downstreamUrl, 200, "Hello from Felix Boers", query)) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunning()) - .When(x => _steps.WhenIGetUrlOnTheApiGateway(url)) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Felix Boers")) - .BDDfy(); - } - - private void GivenThereIsAServiceRunningOn(string baseUrl, string basePath, int statusCode, string responseBody, string expectedQueryString) - { - _serviceHandler.GivenThereIsAServiceRunningOn(baseUrl, basePath, async context => - { - _downstreamPath = !string.IsNullOrEmpty(context.Request.PathBase.Value) ? context.Request.PathBase.Value : context.Request.Path.Value; - - if (_downstreamPath != basePath) - { - context.Response.StatusCode = (int)HttpStatusCode.NotFound; - await context.Response.WriteAsync("downstream path didnt match base path"); - } - else - { - if (context.Request.QueryString.Value.Contains(expectedQueryString)) - { - context.Response.StatusCode = statusCode; - await context.Response.WriteAsync(responseBody); - } - else - { - context.Response.StatusCode = (int)HttpStatusCode.NotFound; - await context.Response.WriteAsync("downstream path didnt match base path"); - } - } - }); - } - - public void Dispose() - { - _serviceHandler?.Dispose(); - _steps.Dispose(); - } - } -} + [InlineData("/api/{version}/values", "/values", "Service_{version}/Api", "/Service_1.0/Api/values", "/api/1.0/values?test=best", "test=best")] + [InlineData("/api/{version}/{all}", "/{all}", "Service_{version}/Api", "/Service_1.0/Api/products", "/api/1.0/products?test=the-best-from-Aly", "test=the-best-from-Aly")] + public void should_support_placeholder_in_service_fabric_service_name(string upstream, string downstream, string serviceName, string downstreamUrl, string url, string query) + { + var port = PortFinder.GetRandomPort(); + + var configuration = new FileConfiguration + { + Routes = new() + { + new() + { + DownstreamPathTemplate = downstream, + DownstreamScheme = Uri.UriSchemeHttp, + UpstreamPathTemplate = upstream, + UpstreamHttpMethod = new() { HttpMethods.Get }, + ServiceName = serviceName, + }, + }, + GlobalConfiguration = new FileGlobalConfiguration + { + ServiceDiscoveryProvider = new FileServiceDiscoveryProvider + { + Host = "localhost", + Port = port, + Type = "ServiceFabric", + }, + }, + }; + + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", downstreamUrl, 200, "Hello from Felix Boers", query)) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunning()) + .When(x => _steps.WhenIGetUrlOnTheApiGateway(url)) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Felix Boers")) + .BDDfy(); + } + + private void GivenThereIsAServiceRunningOn(string baseUrl, string basePath, int statusCode, string responseBody, string expectedQueryString) + { + _serviceHandler.GivenThereIsAServiceRunningOn(baseUrl, basePath, async context => + { + _downstreamPath = !string.IsNullOrEmpty(context.Request.PathBase.Value) ? context.Request.PathBase.Value : context.Request.Path.Value; + + if (_downstreamPath != basePath) + { + context.Response.StatusCode = (int)HttpStatusCode.NotFound; + await context.Response.WriteAsync("downstream path didnt match base path"); + } + else + { + if (context.Request.QueryString.Value.Contains(expectedQueryString)) + { + context.Response.StatusCode = statusCode; + await context.Response.WriteAsync(responseBody); + } + else + { + context.Response.StatusCode = (int)HttpStatusCode.NotFound; + await context.Response.WriteAsync("downstream path didnt match base path"); + } + } + }); + } + + public void Dispose() + { + _serviceHandler?.Dispose(); + _steps.Dispose(); + } + } +} diff --git a/test/Ocelot.AcceptanceTests/Steps.cs b/test/Ocelot.AcceptanceTests/Steps.cs index 5acb57cbc..0d5505fcd 100644 --- a/test/Ocelot.AcceptanceTests/Steps.cs +++ b/test/Ocelot.AcceptanceTests/Steps.cs @@ -779,30 +779,7 @@ public void GivenOcelotIsRunningWithMinimumLogLevel(Logger logger, string appset } public void GivenOcelotIsRunningWithEureka() - { - _webHostBuilder = new WebHostBuilder(); - - _webHostBuilder - .ConfigureAppConfiguration((hostingContext, config) => - { - config.SetBasePath(hostingContext.HostingEnvironment.ContentRootPath); - var env = hostingContext.HostingEnvironment; - config.AddJsonFile("appsettings.json", true, false) - .AddJsonFile($"appsettings.{env.EnvironmentName}.json", true, false); - config.AddJsonFile(_ocelotConfigFileName, false, false); - config.AddEnvironmentVariables(); - }) - .ConfigureServices(s => - { - s.AddOcelot() - .AddEureka(); - }) - .Configure(app => { app.UseOcelot().Wait(); }); - - _ocelotServer = new TestServer(_webHostBuilder); - - _ocelotClient = _ocelotServer.CreateClient(); - } + => GivenOcelotIsRunningWithServices(s => s.AddOcelot().AddEureka()); public void GivenOcelotIsRunningWithPolly() => GivenOcelotIsRunningWithServices(WithPolly); public static void WithPolly(IServiceCollection services) => services.AddOcelot().AddPolly(); diff --git a/test/Ocelot.UnitTests/Kubernetes/KubeServiceBuilderTests.cs b/test/Ocelot.UnitTests/Kubernetes/KubeServiceBuilderTests.cs new file mode 100644 index 000000000..31f76c4e3 --- /dev/null +++ b/test/Ocelot.UnitTests/Kubernetes/KubeServiceBuilderTests.cs @@ -0,0 +1,162 @@ +using KubeClient.Models; +using Ocelot.Logging; +using Ocelot.Provider.Kubernetes; +using Ocelot.Provider.Kubernetes.Interfaces; +using Ocelot.Values; + +namespace Ocelot.UnitTests.Kubernetes; + +[Trait("Feat", "1967")] +public sealed class KubeServiceBuilderTests +{ + private readonly Mock factory; + private readonly Mock serviceCreator; + private readonly Mock logger; + private KubeServiceBuilder sut; + + public KubeServiceBuilderTests() + { + factory = new(); + serviceCreator = new(); + logger = new(); + } + + private void Arrange() + { + factory.Setup(x => x.CreateLogger()) + .Returns(logger.Object) + .Verifiable(); + logger.Setup(x => x.LogDebug(It.IsAny>())) + .Verifiable(); + sut = new KubeServiceBuilder(factory.Object, serviceCreator.Object); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + public void Cstor_NullArgs_ThrownException(bool isFactory, bool isServiceCreator) + { + // Arrange + var arg1 = isFactory ? factory.Object : null; + var arg2 = isServiceCreator ? serviceCreator.Object : null; + + // Act, Assert + Assert.Throws( + arg1 is null ? "factory" : arg2 is null ? "serviceCreator" : string.Empty, + () => sut = new KubeServiceBuilder(arg1, arg2)); + } + + [Fact] + public void Cstor_NotNullArgs_ObjCreated() + { + // Arrange + factory.Setup(x => x.CreateLogger()).Verifiable(); + + // Act + sut = new KubeServiceBuilder(factory.Object, serviceCreator.Object); + + // Assert + Assert.NotNull(sut); + factory.Verify(x => x.CreateLogger(), Times.Once()); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + public void BuildServices_NullArgs_ThrownException(bool isConfiguration, bool isEndpoint) + { + // Arrange + var arg1 = isConfiguration ? new KubeRegistryConfiguration() : null; + var arg2 = isEndpoint ? new EndpointsV1() : null; + Arrange(); + + // Act, Assert + Assert.Throws( + arg1 is null ? "configuration" : arg2 is null ? "endpoint" : string.Empty, + () => _ = sut.BuildServices(arg1, arg2)); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public void BuildServices_WithSubsets_SelectedManyServicesPerSubset(int subsetCount) + { + // Arrange + var configuration = new KubeRegistryConfiguration(); + var endpoint = new EndpointsV1(); + for (int i = 1; i <= subsetCount; i++) + { + var subset = new EndpointSubsetV1(); + subset.Addresses.Add(new() { NodeName = "subset" + i, Hostname = i.ToString() }); + endpoint.Subsets.Add(subset); + } + + serviceCreator.Setup(x => x.Create(configuration, endpoint, It.IsAny())) + .Returns((c, e, s) => + { + var item = s.Addresses[0]; + int count = int.Parse(item.Hostname); + var list = new List(count); + while (count > 0) + { + var id = count--.ToString(); + list.Add(new Service($"{item.NodeName}-service{id}", null, id, id, null)); + } + + return list; + }); + var many = endpoint.Subsets.Sum(s => int.Parse(s.Addresses[0].Hostname)); + Arrange(); + + // Act + var actual = sut.BuildServices(configuration, endpoint); + + // Assert + Assert.NotNull(actual); + var l = actual.ToList(); + Assert.Equal(many, l.Count); + serviceCreator.Verify(x => x.Create(configuration, endpoint, It.IsAny()), + Times.Exactly(endpoint.Subsets.Count)); + logger.Verify(x => x.LogDebug(It.IsAny>()), + Times.Once()); + } + + [Theory] + [InlineData(false, false, false, false, "K8s '?:?:?' endpoint: Total built 0 services.")] + [InlineData(false, false, false, true, "K8s '?:?:?' endpoint: Total built 0 services.")] + [InlineData(false, false, true, false, "K8s '?:?:?' endpoint: Total built 0 services.")] + [InlineData(false, false, true, true, "K8s '?:?:Name' endpoint: Total built 0 services.")] + [InlineData(false, true, true, true, "K8s '?:ApiVersion:Name' endpoint: Total built 0 services.")] + [InlineData(true, true, true, true, "K8s 'Kind:ApiVersion:Name' endpoint: Total built 0 services.")] + public void BuildServices_WithEndpoint_LogDebug(bool hasKind, bool hasApiVersion, bool hasMetadata, bool hasMetadataName, string message) + { + // Arrange + var configuration = new KubeRegistryConfiguration(); + var endpoint = new EndpointsV1() + { + Kind = hasKind ? nameof(EndpointsV1.Kind) : null, + ApiVersion = hasApiVersion ? nameof(EndpointsV1.ApiVersion) : null, + Metadata = hasMetadata ? new() + { + Name = hasMetadataName ? nameof(ObjectMetaV1.Name) : null, + } : null, + }; + Arrange(); + string actualMesssage = null; + logger.Setup(x => x.LogDebug(It.IsAny>())) + .Callback>(f => actualMesssage = f.Invoke()); + + // Act + var actual = sut.BuildServices(configuration, endpoint); + + // Assert + Assert.NotNull(actual); + logger.Verify(x => x.LogDebug(It.IsAny>()), + Times.Once()); + Assert.NotNull(actualMesssage); + Assert.Equal(message, actualMesssage); + } +} diff --git a/test/Ocelot.UnitTests/Kubernetes/KubeServiceCreatorTests.cs b/test/Ocelot.UnitTests/Kubernetes/KubeServiceCreatorTests.cs new file mode 100644 index 000000000..a8794ff87 --- /dev/null +++ b/test/Ocelot.UnitTests/Kubernetes/KubeServiceCreatorTests.cs @@ -0,0 +1,150 @@ +using KubeClient.Models; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http; +using Ocelot.Logging; +using Ocelot.Provider.Kubernetes; + +namespace Ocelot.UnitTests.Kubernetes; + +[Trait("Feat", "1967")] +public sealed class KubeServiceCreatorTests +{ + private readonly Mock factory; + private readonly Mock logger; + private KubeServiceCreator sut; + + public KubeServiceCreatorTests() + { + factory = new(); + logger = new(); + } + + private void Arrange() + { + factory.Setup(x => x.CreateLogger()) + .Returns(logger.Object) + .Verifiable(); + logger.Setup(x => x.LogDebug(It.IsAny>())) + .Verifiable(); + sut = new KubeServiceCreator(factory.Object); + } + + [Fact] + public void Cstor_NullArg_ThrownException() + { + // Arrange, Act, Assert + Assert.Throws("factory", + () => sut = new KubeServiceCreator(null)); + } + + [Fact] + public void Cstor_NotNullArg_ObjCreated() + { + // Arrange + factory.Setup(x => x.CreateLogger()).Verifiable(); + + // Act + sut = new KubeServiceCreator(factory.Object); + + // Assert + Assert.NotNull(sut); + factory.Verify(x => x.CreateLogger(), Times.Once()); + } + + [Theory] + [InlineData(false, true, true)] + [InlineData(true, false, true)] + [InlineData(true, true, false)] + public void Create_NullArgs_ReturnedEmpty(bool isConfiguration, bool isEndpoint, bool isSubset) + { + // Arrange + var arg1 = isConfiguration ? new KubeRegistryConfiguration() : null; + var arg2 = isEndpoint ? new EndpointsV1() : null; + var arg3 = isSubset ? new EndpointSubsetV1() : null; + Arrange(); + + // Act + var actual = sut.Create(arg1, arg2, arg3); + + // Assert + Assert.NotNull(actual); + Assert.Empty(actual); + } + + [Fact(DisplayName = "Create: With empty args -> No exceptions during creation")] + public void Create_NotNullButEmptyArgs_CreatedEmptyService() + { + // Arrange + var arg1 = new KubeRegistryConfiguration() + { + KubeNamespace = nameof(KubeServiceCreatorTests), + KeyOfServiceInK8s = nameof(Create_NotNullButEmptyArgs_CreatedEmptyService), + }; + var arg2 = new EndpointsV1(); + var arg3 = new EndpointSubsetV1(); + arg3.Addresses.Add(new()); + arg2.Subsets.Add(arg3); + Arrange(); + + // Act + var actual = sut.Create(arg1, arg2, arg3); + + // Assert + Assert.NotNull(actual); + Assert.NotEmpty(actual); + var actualService = actual.SingleOrDefault(); + Assert.NotNull(actualService); + Assert.Null(actualService.Name); + } + + [Fact] + public void Create_ValidArgs_HappyPath() + { + // Arrange + var arg1 = new KubeRegistryConfiguration() + { + KubeNamespace = nameof(KubeServiceCreatorTests), + KeyOfServiceInK8s = nameof(Create_ValidArgs_HappyPath), + Scheme = "happy", //nameof(HttpScheme.Http), + }; + var arg2 = new EndpointsV1() + { + ApiVersion = "v1", + Metadata = new() + { + Namespace = nameof(KubeServiceCreatorTests), + Name = nameof(Create_ValidArgs_HappyPath), + Uid = Guid.NewGuid().ToString(), + }, + }; + var arg3 = new EndpointSubsetV1(); + arg3.Addresses.Add(new() + { + Ip = "8.8.8.8", + NodeName = "google", + Hostname = "dns.google", + }); + var ports = new List + { + new() { Name = nameof(HttpScheme.Http), Port = 80 }, + new() { Name = "happy", Port = 888 }, + }; + arg3.Ports.AddRange(ports); + arg2.Subsets.Add(arg3); + Arrange(); + + // Act + var actual = sut.Create(arg1, arg2, arg3); + + // Assert + Assert.NotNull(actual); + Assert.NotEmpty(actual); + var service = actual.SingleOrDefault(); + Assert.NotNull(service); + Assert.Equal(nameof(Create_ValidArgs_HappyPath), service.Name); + Assert.Equal("happy", service.HostAndPort.Scheme); + Assert.Equal(888, service.HostAndPort.DownstreamPort); + Assert.Equal("8.8.8.8", service.HostAndPort.DownstreamHost); + logger.Verify(x => x.LogDebug(It.IsAny>()), + Times.Once()); + } +} diff --git a/test/Ocelot.UnitTests/Kubernetes/KubeTests.cs b/test/Ocelot.UnitTests/Kubernetes/KubeTests.cs index 10207d6f4..213a25f65 100644 --- a/test/Ocelot.UnitTests/Kubernetes/KubeTests.cs +++ b/test/Ocelot.UnitTests/Kubernetes/KubeTests.cs @@ -6,11 +6,12 @@ using Newtonsoft.Json; using Ocelot.Logging; using Ocelot.Provider.Kubernetes; +using Ocelot.Provider.Kubernetes.Interfaces; using Ocelot.Values; namespace Ocelot.UnitTests.Kubernetes { - public class KubeTests : UnitTest, IDisposable + public class KubeTests : IDisposable { private IWebHost _fakeKubeBuilder; private readonly Kube _provider; @@ -21,10 +22,11 @@ public class KubeTests : UnitTest, IDisposable private readonly string _kubeHost; private readonly string _fakekubeServiceDiscoveryUrl; private List _services; + private string _receivedToken; private readonly Mock _factory; private readonly Mock _logger; - private string _receivedToken; private readonly IKubeApiClient _clientFactory; + private readonly Mock _serviceBuilder; public KubeTests() { @@ -33,8 +35,8 @@ public KubeTests() _port = 5567; _kubeHost = "localhost"; _fakekubeServiceDiscoveryUrl = $"{Uri.UriSchemeHttp}://{_kubeHost}:{_port}"; - _endpointEntries = new EndpointsV1(); - _factory = new Mock(); + _endpointEntries = new(); + _factory = new(); var option = new KubeClientOptions { @@ -45,19 +47,21 @@ public KubeTests() }; _clientFactory = KubeApiClient.Create(option); - _logger = new Mock(); + _logger = new(); _factory.Setup(x => x.CreateLogger()).Returns(_logger.Object); var config = new KubeRegistryConfiguration { KeyOfServiceInK8s = _serviceName, KubeNamespace = _namespaces, }; - _provider = new Kube(config, _factory.Object, _clientFactory); + _serviceBuilder = new(); + _provider = new Kube(config, _factory.Object, _clientFactory, _serviceBuilder.Object); } [Fact] public void Should_return_service_from_k8s() { + // Arrange var token = "Bearer txpc696iUhbVoudg164r93CxDTrKRVWG"; var endPointEntryOne = new EndpointsV1 { @@ -65,6 +69,7 @@ public void Should_return_service_from_k8s() ApiVersion = "1.0", Metadata = new ObjectMetaV1 { + Name = nameof(Should_return_service_from_k8s), Namespace = "dev", }, }; @@ -79,13 +84,17 @@ public void Should_return_service_from_k8s() Port = 80, }); endPointEntryOne.Subsets.Add(endpointSubsetV1); + _serviceBuilder.Setup(x => x.BuildServices(It.IsAny(), It.IsAny())) + .Returns(new Service[] { new(nameof(Should_return_service_from_k8s), new("localhost", 80), string.Empty, string.Empty, new string[0]) }); + GivenThereIsAFakeKubeServiceDiscoveryProvider(_fakekubeServiceDiscoveryUrl, _serviceName, _namespaces); + GivenTheServicesAreRegisteredWithKube(endPointEntryOne); + + // Act + WhenIGetTheServices(); - this.Given(x => GivenThereIsAFakeKubeServiceDiscoveryProvider(_fakekubeServiceDiscoveryUrl, _serviceName, _namespaces)) - .And(x => GivenTheServicesAreRegisteredWithKube(endPointEntryOne)) - .When(x => WhenIGetTheServices()) - .Then(x => ThenTheCountIs(1)) - .And(_ => ThenTheTokenIs(token)) - .BDDfy(); + // Assert + ThenTheCountIs(1); + ThenTheTokenIs(token); } private void ThenTheTokenIs(string token) diff --git a/test/Ocelot.UnitTests/Ocelot.UnitTests.csproj b/test/Ocelot.UnitTests/Ocelot.UnitTests.csproj index 30c2ca794..b9f107cfc 100644 --- a/test/Ocelot.UnitTests/Ocelot.UnitTests.csproj +++ b/test/Ocelot.UnitTests/Ocelot.UnitTests.csproj @@ -27,12 +27,12 @@ - +