diff --git a/.github/workflows/dotnet.yml b/.github/workflows/develop.yml similarity index 92% rename from .github/workflows/dotnet.yml rename to .github/workflows/develop.yml index e38e13c0c..be96d0611 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/develop.yml @@ -1,14 +1,11 @@ # This workflow will build a .NET project # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net -name: .NET +name: develop -#on: -# push: -# branches: [ "develop" ] -# pull_request: -# branches: [ "develop" ] -on: [push, pull_request] +on: + push: + branches: [ "develop" ] jobs: build: diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 000000000..e0d1e89e4 --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,29 @@ +# This workflow will build a .NET project +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net + +name: PR + +on: pull_request + # branches-ignore: + # - main + # - develop + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup .NET 9.0 + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 9.0.x + - name: Restore + run: dotnet restore ./Ocelot.sln -p:TargetFramework=net9.0 + - name: Build + run: dotnet build --no-restore ./Ocelot.sln --framework net9.0 + - name: Unit Tests + run: dotnet test --no-restore --no-build --verbosity normal --framework net9.0 ./test/Ocelot.UnitTests/Ocelot.UnitTests.csproj + - name: Integration Tests + run: dotnet test --no-restore --no-build --verbosity normal --framework net9.0 ./test/Ocelot.IntegrationTests/Ocelot.IntegrationTests.csproj + - name: Acceptance Tests + run: dotnet test --no-restore --no-build --verbosity normal --framework net9.0 ./test/Ocelot.AcceptanceTests/Ocelot.AcceptanceTests.csproj diff --git a/docs/features/kubernetes.rst b/docs/features/kubernetes.rst index eeeccd592..d7d9b71a7 100644 --- a/docs/features/kubernetes.rst +++ b/docs/features/kubernetes.rst @@ -1,4 +1,4 @@ -.. |K8sLogo| image:: https://kubernetes.io/images/nav_logo2.svg +.. |K8sLogo| image:: https://raw.githubusercontent.com/kubernetes/kubernetes/master/logo/logo.png :alt: K8s Logo :height: 50 :target: https://kubernetes.io @@ -8,87 +8,188 @@ :height: 17 :target: https://kubernetes.io -|K8sLogo| K8s [#f1]_ -==================== +.. _KubeClient: https://www.nuget.org/packages/KubeClient +.. _Ocelot.Provider.Kubernetes: https://www.nuget.org/packages/Ocelot.Provider.Kubernetes +.. _package: https://www.nuget.org/packages/Ocelot.Provider.Kubernetes - A part of feature: :doc:`../features/servicediscovery` +|K8sLogo| Kubernetes (K8s) [#f1]_ +================================= + + Sub-feature of: :doc:`../features/servicediscovery` + Quick Links: `Wikipedia `_ | `K8s Website `_ | `K8s Documentation `_ | `K8s GitHub `_ Ocelot will call the `K8s `_ endpoints API in a given namespace to get all of the endpoints for a pod and then load balance across them. -Ocelot used to use the services API to send requests to the `K8s`_ service but this was changed in PR `1134 `_ because the service did not load balance as expected. +Ocelot used to use the services API to send requests to the `K8s`_ service but this was changed in pull request `1134`_ because the service did not load balance as expected. + +Our NuGet `Ocelot.Provider.Kubernetes`_ extension package is based on the `KubeClient`_ package. +For a comprehensive understanding, it is essential refer to the `KubeClient`_ documentation. + +.. _k8s-install: Install ------- -The first thing you need to do is install the `NuGet package `_ that provides |kubernetes| support in Ocelot [#f2]_: +The first thing you need to do is install the `package`_ that provides |kubernetes| support in Ocelot: .. code-block:: powershell Install-Package Ocelot.Provider.Kubernetes -Then add the following to your ``ConfigureServices`` method: +``AddKubernetes(bool)`` method +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: csharp + :emphasize-lines: 3 + + public static class OcelotBuilderExtensions + { + public static IOcelotBuilder AddKubernetes(this IOcelotBuilder builder, bool usePodServiceAccount = true); + } + +This extension-method adds `K8s`_ services **with** or **without** using a pod service account. +Then add the following to your `Program `_: .. code-block:: csharp + :emphasize-lines: 3 - services.AddOcelot().AddKubernetes(); + builder.Services + .AddOcelot(builder.Configuration) + .AddKubernetes(); // usePodServiceAccount is true If you have services deployed in Kubernetes, you will normally use the naming service to access them. -Default ``usePodServiceAccount = true``, which means that Service Account using Pod to access the service of the K8s cluster needs to be Service Account based on RBAC authorization: + +1. By default the ``useServiceAccount`` argument is true, which means that Service Account using Pod to access the service of the `K8s`_ cluster needs to be Service Account based on RBAC authorization: + + You can replicate a Permissive using RBAC role bindings (see `Permissive RBAC Permissions `_), + `K8s`_ API server and token will read from pod. + + .. code-block:: bash + + kubectl create clusterrolebinding permissive-binding --clusterrole=cluster-admin --user=admin --user=kubelet --group=system:serviceaccounts + + Finally, it creates the `KubeClient`_ from pod service account. + +2. When the ``useServiceAccount`` argument is false, you need to provide `KubeClientOptions `_ to create `KubeClient`_ using them. + You have to bind the options configuration section for the DI ``IOptions`` interface or register a custom action to initialize the options: + + .. code-block:: csharp + :emphasize-lines: 9, 10, 13 + + Action configureKubeClient = opts => + { + opts.ApiEndPoint = new UriBuilder("https", "my-host", 443).Uri; + opts.AccessToken = "my-token"; + opts.AuthStrategy = KubeAuthStrategy.BearerToken; + opts.AllowInsecure = true; + }; + builder.Services + .AddOptions() + .Configure(configureKubeClient); // mannual binding options via IOptions + builder.Services + .AddOcelot(builder.Configuration) + .AddKubernetes(false); // don't use pod service account, and IOptions is reused + + Finally, it creates the `KubeClient`_ from your options. + + **Note**: For understanding the ``IOptions`` interface, please refer to the Microsoft Learn documentation: `Options pattern in .NET `_. + +.. _k8s-addkubernetes-action-method: + +``AddKubernetes(Action)`` method [#f2]_ +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. code-block:: csharp + :emphasize-lines: 3 public static class OcelotBuilderExtensions { - public static IOcelotBuilder AddKubernetes(this IOcelotBuilder builder, bool usePodServiceAccount = true); + public static IOcelotBuilder AddKubernetes(this IOcelotBuilder builder, Action configureOptions, /*optional params*/); } -You can replicate a Permissive using RBAC role bindings (see `Permissive RBAC Permissions `_), -`K8s`_ API server and token will read from pod. +This extension method adds `K8s`_ services **without** using a pod service account, explicitly calling an action to initialize configuration options for `KubeClient`_. +It operates in two modes: + +1. If ``configureOptions`` is provided (action is not null), it calls the action, ignoring all optional arguments. + + .. code-block:: csharp + :emphasize-lines: 8 + + Action configureKubeClient = opts => + { + opts.ApiEndPoint = new UriBuilder("https", "my-host", 443).Uri; + // ... + }; + builder.Services + .AddOcelot(builder.Configuration) + .AddKubernetes(configureKubeClient); // without optional arguments + +.. _break: http://break.do + + **Note**: Optional arguments do not make sense; all settings are defined inside the ``configureKubeClient`` action. + +2. If ``configureOptions`` is not provided (action is null), it reads the global ``ServiceDiscoveryProvider`` :ref:`k8s-configuration` options and reuses them to initialize the following properties: + ``ApiEndPoint``, ``AccessToken``, and ``KubeNamespace``, finally initializing the rest of the properties with optional arguments. + + .. code-block:: csharp + :emphasize-lines: 3, 5 + + builder.Services + .AddOcelot(builder.Configuration) + .AddKubernetes(null, allowInsecure: true, /*optional args*/) // shortened version + // or + .AddKubernetes(configureOptions: null, allowInsecure: true, /*optional args*/); // long version + +.. _break2: http://break.do -.. code-block:: bash + **Note**: Optional arguments must be used here in addition to the options coming from the global ``ServiceDiscoveryProvider`` :ref:`k8s-configuration`. + Find the comprehensive documentation in the C# code of the `AddKubernetes `_ methods. - kubectl create clusterrolebinding permissive-binding --clusterrole=cluster-admin --user=admin --user=kubelet --group=system:serviceaccounts +.. _k8s-configuration: Configuration ------------- -The following examples show how to set up a Route that will work in Kubernetes. -The most important thing is the **ServiceName** which is made up of the Kubernetes service name. -We also need to set up the **ServiceDiscoveryProvider** in **GlobalConfiguration**. +The following examples show how to set up a route that will work in Kubernetes. +The most important thing is the ``ServiceName`` which is made up of the Kubernetes service name. +We also need to set up the ``ServiceDiscoveryProvider`` in ``GlobalConfiguration``. -Kube default provider -^^^^^^^^^^^^^^^^^^^^^ +``Kube`` provider +^^^^^^^^^^^^^^^^^ The example here shows a typical configuration: .. code-block:: json - "Routes": [ - { - "ServiceName": "downstreamservice", - // ... - } - ], - "GlobalConfiguration": { - "ServiceDiscoveryProvider": { - "Host": "192.168.0.13", - "Port": 443, - "Token": "txpc696iUhbVoudg164r93CxDTrKRVWG", - "Namespace": "Dev", - "Type": "Kube" - } + "Routes": [ + { + "ServiceName": "my-service", + // ... } + ], + "GlobalConfiguration": { + "ServiceDiscoveryProvider": { + "Scheme": "https", + "Host": "my-host", + "Port": 443, + "Token": "my-token", + "Namespace": "Dev", + "Type": "Kube" + } + } -Service deployment in **Namespace** ``Dev``, **ServiceDiscoveryProvider** type is ``Kube``, you also can set :ref:`k8s-pollkube-provider` type. +Service deployment in ``Dev`` namespace, and discovery provider type is ``Kube``, you also can set :ref:`k8s-pollkube-provider` type. - **Note 1**: ``Host``, ``Port`` and ``Token`` are no longer in use. + **Note 1**: ``Scheme``, ``Host``, ``Port``, and ``Token`` are not used if ``usePodServiceAccount`` is true when `KubeClient`_ is created from a pod service account. + Please refer to the :ref:`k8s-install` section for technical details. **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``; + Please refer to the :ref:`k8s-downstream-scheme-vs-port-names` section for technical details. .. _k8s-pollkube-provider: -PollKube provider -^^^^^^^^^^^^^^^^^ +``PollKube`` provider +^^^^^^^^^^^^^^^^^^^^^ You use Ocelot to poll Kubernetes for latest service information rather than per request. If you want to poll Kubernetes for the latest services rather than per request (default behaviour) then you need to set the following configuration: @@ -103,23 +204,23 @@ If you want to poll Kubernetes for the latest services rather than per request ( The polling interval is in milliseconds and tells Ocelot how often to call Kubernetes for changes in service configuration. -Please note, there are tradeoffs here. -If you poll Kubernetes, it is possible Ocelot will not know if a service is down depending on your polling interval and you might get more errors than if you get the latest services per request. -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. + **Note**, there are tradeoffs here. + If you poll Kubernetes, it is possible Ocelot will not know if a service is down depending on your polling interval and you might get more errors than if you get the latest services per request. + 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, except perhaps through a `discussion `_. -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 "Routes": [ { - "ServiceName": "downstreamservice", - "ServiceNamespace": "downstream-namespace" + "ServiceName": "my-service", + "ServiceNamespace": "my-namespace" } ] @@ -159,11 +260,22 @@ you must define ``DownstreamScheme`` to enable the provider to recognize the des } ] -**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``. +.. _break3: http://break.do + + **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] :doc:`../features/kubernetes` feature was requested as part of issue `345 `_ to add support for `Kubernetes `_ :doc:`../features/servicediscovery` provider, and released in version `13.4.1 `_ -.. [#f2] `Wikipedia `_ | `K8s Website `_ | `K8s Documentation `_ | `K8s GitHub `_ -.. [#f3] :ref:`k8s-downstream-scheme-vs-port-names` feature was requested as part of issue `1967 `_ and released in version `23.3 `_ +.. [#f1] :doc:`../features/kubernetes` feature was requested as part of issue `345`_ to add support for `Kubernetes `_ :doc:`../features/servicediscovery` provider, and released in version `13.4.1`_ +.. [#f2] :ref:`k8s-addkubernetes-action-method` was requested as part of issue `2255`_ (PR `2257`_), and released in version `24.0.0`_ +.. [#f3] :ref:`k8s-downstream-scheme-vs-port-names` feature was requested as part of issue `1967`_ and released in version `23.3.0`_ + +.. _345: https://github.com/ThreeMammals/Ocelot/issues/345 +.. _1134: https://github.com/ThreeMammals/Ocelot/pull/1134 +.. _1967: https://github.com/ThreeMammals/Ocelot/issues/1967 +.. _2255: https://github.com/ThreeMammals/Ocelot/issues/2255 +.. _2257: https://github.com/ThreeMammals/Ocelot/pull/2257 +.. _13.4.1: https://github.com/ThreeMammals/Ocelot/releases/tag/13.4.1 +.. _23.3.0: https://github.com/ThreeMammals/Ocelot/releases/tag/23.3.0 +.. _24.0.0: https://github.com/ThreeMammals/Ocelot/releases/tag/24.0.0 diff --git a/samples/Kubernetes/ApiGateway/Program.cs b/samples/Kubernetes/ApiGateway/Program.cs index c6eda7ab2..ba9c98f4b 100644 --- a/samples/Kubernetes/ApiGateway/Program.cs +++ b/samples/Kubernetes/ApiGateway/Program.cs @@ -1,17 +1,66 @@ -using Ocelot.DependencyInjection; +using KubeClient; +using Ocelot.DependencyInjection; using Ocelot.Middleware; using Ocelot.Provider.Kubernetes; using Ocelot.Samples.Web; //_ = OcelotHostBuilder.Create(args); var builder = WebApplication.CreateBuilder(args); - builder.Configuration .SetBasePath(builder.Environment.ContentRootPath) .AddOcelot(); + +goto Case4; // Your case should be selected here!!! + +// Link: https://github.com/ThreeMammals/Ocelot/blob/develop/docs/features/kubernetes.rst#addkubernetes-bool-method +Case1: // Use a pod service account builder.Services .AddOcelot(builder.Configuration) .AddKubernetes(); +goto Start; + +// Link: https://github.com/ThreeMammals/Ocelot/blob/develop/docs/features/kubernetes.rst#addkubernetes-bool-method +Case2: // Don't use a pod service account, manually bind options +Action configureOptions = opts => +{ + opts.ApiEndPoint = new UriBuilder(Uri.UriSchemeHttps, "my-host", 443).Uri; + opts.AccessToken = "my-token"; + opts.AuthStrategy = KubeAuthStrategy.BearerToken; + opts.AllowInsecure = true; +}; +builder.Services + .AddOptions() + .Configure(configureOptions); // mannual binding options via IOptions +builder.Services + .AddOcelot().AddKubernetes(false); // don't use pod service account +goto Start; + +// Link: https://github.com/ThreeMammals/Ocelot/blob/develop/docs/features/kubernetes.rst#addkubernetes-action-kubeclientoptions-method +Case3: // Use global ServiceDiscoveryProvider json-options +Action myOptions = opts => +{ + opts.ApiEndPoint = new UriBuilder(Uri.UriSchemeHttps, "my-host", 443).Uri; + opts.AccessToken = "my-token"; + opts.AuthStrategy = KubeAuthStrategy.BearerToken; + opts.AllowInsecure = true; // here is wrong value! +}; +builder.Services + .AddOcelot(builder.Configuration) + .AddKubernetes(myOptions); // configure options with action, without optional args +goto Start; + +// Link: https://github.com/ThreeMammals/Ocelot/blob/develop/docs/features/kubernetes.rst#addkubernetes-action-kubeclientoptions-method +Case4: // Use global ServiceDiscoveryProvider json-options +Action? none = null; +builder.Services + .AddOcelot(builder.Configuration) + .AddKubernetes(null, allowInsecure: true /*optional args*/) // shorten version + // or + .AddKubernetes(none, allowInsecure: true /*optional args*/) // shorten version 2 + // or + .AddKubernetes(configureOptions: null, allowInsecure: true /*optional args*/); // don't configure options with action, but do with optional args + +Start: if (builder.Environment.IsDevelopment()) { diff --git a/samples/Kubernetes/ApiGateway/ocelot.json b/samples/Kubernetes/ApiGateway/ocelot.json index b3cfcbae8..8211fcab2 100644 --- a/samples/Kubernetes/ApiGateway/ocelot.json +++ b/samples/Kubernetes/ApiGateway/ocelot.json @@ -1,7 +1,7 @@ { "Routes": [ { - "ServiceName": "downstreamservice", + "ServiceName": "my-service", "UpstreamHttpMethod": [ "Get" ], "UpstreamPathTemplate": "/values", "DownstreamPathTemplate": "/api/values", @@ -19,11 +19,12 @@ ], "GlobalConfiguration": { "ServiceDiscoveryProvider": { + "Scheme": "https", "Host": "192.168.0.13", "Port": 443, "Token": "txpc696iUhbVoudg164r93CxDTrKRVWG", "Namespace": "dev", - "Type": "kube" + "Type": "Kube" } } } diff --git a/samples/OpenTracing/Ocelot.Samples.OpenTracing.csproj b/samples/OpenTracing/Ocelot.Samples.OpenTracing.csproj index cf1cb1efe..d126f1718 100644 --- a/samples/OpenTracing/Ocelot.Samples.OpenTracing.csproj +++ b/samples/OpenTracing/Ocelot.Samples.OpenTracing.csproj @@ -16,6 +16,6 @@ - + diff --git a/src/Ocelot.Provider.Kubernetes/OcelotBuilderExtensions.cs b/src/Ocelot.Provider.Kubernetes/OcelotBuilderExtensions.cs index 97b764494..42038b124 100644 --- a/src/Ocelot.Provider.Kubernetes/OcelotBuilderExtensions.cs +++ b/src/Ocelot.Provider.Kubernetes/OcelotBuilderExtensions.cs @@ -1,13 +1,26 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Ocelot.Configuration.File; using Ocelot.DependencyInjection; using Ocelot.Provider.Kubernetes.Interfaces; +using System.Net; +using System.Reflection; +using System.Security.Cryptography.X509Certificates; namespace Ocelot.Provider.Kubernetes; public static class OcelotBuilderExtensions { + /// + /// Adds Kubernetes (K8s) services with or without using a pod service account. + /// By default, is set to , which means using a pod service account. + /// + /// If is , it internally injects an configuration section object (where TOptions is ) to configure . + /// The Ocelot Builder instance. + /// If true, it creates the client from pod service account. + /// The reference to the same extended object. public static IOcelotBuilder AddKubernetes(this IOcelotBuilder builder, bool usePodServiceAccount = true) { builder.Services @@ -18,20 +31,112 @@ public static IOcelotBuilder AddKubernetes(this IOcelotBuilder builder, bool use .AddSingleton() .AddSingleton(); - return builder; KubeApiClient KubeApiClientFactory(IServiceProvider sp) { + var logger = sp.GetService(); if (usePodServiceAccount) { - return KubeApiClient.CreateFromPodServiceAccount(sp.GetService()); + return KubeApiClient.CreateFromPodServiceAccount(logger); } KubeClientOptions options = sp.GetRequiredService>().Value; - options.LoggerFactory ??= sp.GetService(); - + options.LoggerFactory ??= logger; return KubeApiClient.Create(options); } } + + /// + /// Adds Kubernetes (K8s) services without using a pod service account, explicitly calling a factory-action to initialize configuration options for . + /// Before adding services, it internally configures options by registering the action in DI; thus an (where TOptions is ) object becomes available in the DI container. + /// + /// It operates in 2 modes: + /// + /// If is provided (action is not null), it calls the action ignoring all optional arguments. + /// If is not provided (action is null), it reads the global options and reuses them to initialize the following properties: , , and , finally initializing the rest of the properties with optional arguments. + /// + /// + /// The Ocelot Builder instance. + /// An action to initialize of the client. It can be null: read the remarks. + /// Optional scheme to build URI when the global is unknown, defaulting to 'https' aka . + /// Optional host to build URI when the global is unknown, defaulting to 'localhost' aka . + /// Optional port to build URI when the global is unknown, defaulting to 443. + /// Optional namespace to initialize option when the global is unknown, defaulting to 'default'. + /// Optional username to initialize the option. + /// Optional password to initialize the option. + /// Optional command to initialize the option. + /// Optional arguments to initialize the option. + /// Optional selector to initialize the option. + /// Optional selector to initialize the option. + /// Optional token to initialize the option. + /// Optional date-time to initialize the option. + /// Optional certificate to initialize the option. + /// Optional certificate to initialize the option. + /// Optional verification flag to initialize the option, defaulting to false. + /// Optional strategy to initialize the option, defaulting to . + /// Optional log flag to initialize the option, defaulting to false. + /// Optional log flag to initialize the option, defaulting to false. + /// Optional factory to initialize the option. + /// Optional list to add assemblies to the option, defaulting to empty list. + /// Optional dictionary to initialize the option, defaulting to empty list. + /// The reference to the same extended object. + public static IOcelotBuilder AddKubernetes(this IOcelotBuilder builder, Action configureOptions, // required params + string defaultScheme = null, string defaultHost = null, int? defaultPort = null, string defaultNamespace = null, // optional params + string username = null, string password = null, + string accessTokenCommand = null, string accessTokenCommandArguments = null, string accessTokenSelector = null, string accessTokenExpirySelector = null, + string initialAccessToken = null, DateTime? initialTokenExpiryUtc = null, + X509Certificate2 clientCertificate = null, X509Certificate2 certificationAuthorityCertificate = null, + bool? allowInsecure = null, KubeAuthStrategy? authStrategy = null, + bool? logHeaders = null, bool? logPayloads = null, ILoggerFactory loggerFactory = null, + List modelTypeAssemblies = null, Dictionary environmentVariables = null) + { + configureOptions ??= Configure; + builder.Services.AddOptions().Configure(configureOptions); + return builder.AddKubernetes(false); + + void Configure(KubeClientOptions options) + { + // Initialize properties with values coming from global ServiceDiscoveryProvider options + var key = $"{nameof(FileConfiguration.GlobalConfiguration)}:{nameof(FileGlobalConfiguration.ServiceDiscoveryProvider)}"; + var section = builder.Configuration.GetSection(key); + var scheme = section.Str(nameof(FileServiceDiscoveryProvider.Scheme), defaultScheme ?? Uri.UriSchemeHttps); + var host = section.Str(nameof(FileServiceDiscoveryProvider.Host), defaultHost ?? IPAddress.Loopback.ToString()); + var port = section.Int(nameof(FileServiceDiscoveryProvider.Port), defaultPort ?? 443); + options.ApiEndPoint = new UriBuilder(scheme, host, port).Uri; + options.KubeNamespace = section.Str(nameof(FileServiceDiscoveryProvider.Namespace), defaultNamespace ?? "default"); + options.AccessToken = section.GetValue(nameof(FileServiceDiscoveryProvider.Token)); + + // Initialize properties with values coming from optional arguments + options.AuthStrategy = authStrategy ?? KubeAuthStrategy.BearerToken; + options.AllowInsecure = allowInsecure ?? false; + options.AccessTokenCommand = accessTokenCommand; + options.AccessTokenCommandArguments = accessTokenCommandArguments; + options.AccessTokenExpirySelector = accessTokenExpirySelector; + options.AccessTokenSelector = accessTokenSelector; + options.CertificationAuthorityCertificate = certificationAuthorityCertificate; + options.ClientCertificate = clientCertificate; + options.EnvironmentVariables = environmentVariables ?? new(); + options.InitialAccessToken = initialAccessToken; + options.InitialTokenExpiryUtc = initialTokenExpiryUtc; + options.LoggerFactory = loggerFactory; + options.LogHeaders = logHeaders ?? false; + options.LogPayloads = logPayloads ?? false; + options.ModelTypeAssemblies.AddRange(modelTypeAssemblies ?? new()); + options.Password = password; + options.Username = username; + } + } + + private static string Str(this IConfigurationSection sec, string key, string defaultValue) + { + string val = sec.GetValue(key, defaultValue); + return string.IsNullOrEmpty(val) ? defaultValue : val; + } + + private static int Int(this IConfigurationSection sec, string key, int defaultValue) + { + int val = sec.GetValue(key, defaultValue); + return val <= 0 ? defaultValue : val; + } } diff --git a/test/Ocelot.AcceptanceTests/ServiceDiscovery/KubernetesServiceDiscoveryTests.cs b/test/Ocelot.AcceptanceTests/ServiceDiscovery/KubernetesServiceDiscoveryTests.cs index 7350d5ba7..9b6a5c6da 100644 --- a/test/Ocelot.AcceptanceTests/ServiceDiscovery/KubernetesServiceDiscoveryTests.cs +++ b/test/Ocelot.AcceptanceTests/ServiceDiscovery/KubernetesServiceDiscoveryTests.cs @@ -155,6 +155,35 @@ public void ShouldHighlyLoadOnUnstableKubeProvider_WithRoundRobinLoadBalancing(i ThenServiceCountersShouldMatchLeasingCounters(_roundRobinAnalyzer, servicePorts, totalRequests); } + [Fact] + [Trait("Feat", "2256")] + public void ShouldReturnServicesFromK8s_AddKubernetesWithNullConfigureOptions() + { + const string namespaces = nameof(KubernetesServiceDiscoveryTests); + const string serviceName = nameof(ShouldReturnServicesFromK8s_AddKubernetesWithNullConfigureOptions); + var servicePort = PortFinder.GetRandomPort(); + var downstreamUrl = LoopbackLocalhostUrl(servicePort); + var downstream = new Uri(downstreamUrl); + var subsetV1 = GivenSubsetAddress(downstream); + var endpoints = GivenEndpoints(subsetV1); + var route = GivenRouteWithServiceName(namespaces); + var configuration = GivenKubeConfiguration(namespaces, route, "txpc696iUhbVoudg164r93CxDTrKRVWG"); + var downstreamResponse = serviceName; + this.Given(x => GivenServiceInstanceIsRunning(downstreamUrl, downstreamResponse)) + .And(x => x.GivenThereIsAFakeKubernetesProvider(endpoints, serviceName, namespaces)) + .And(_ => GivenThereIsAConfiguration(configuration)) + .And(_ => GivenOcelotIsRunningWithServices(AddKubernetesWithNullConfigureOptions)) + .When(_ => WhenIGetUrlOnTheApiGateway("/")) + .Then(_ => ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(_ => ThenTheResponseBodyShouldBe($"1:{downstreamResponse}")) + .And(x => ThenAllServicesShouldHaveBeenCalledTimes(1)) + .And(x => x.ThenTheTokenIs("Bearer txpc696iUhbVoudg164r93CxDTrKRVWG")) + .BDDfy(); + } + + private void AddKubernetesWithNullConfigureOptions(IServiceCollection services) + => services.AddOcelot().AddKubernetes(configureOptions: null); + private (EndpointsV1 Endpoints, int[] ServicePorts) ArrangeHighLoadOnKubeProviderAndRoundRobinBalancer( int totalServices, [CallerMemberName] string serviceName = nameof(ArrangeHighLoadOnKubeProviderAndRoundRobinBalancer)) @@ -245,10 +274,10 @@ private FileRoute GivenRouteWithServiceName(string serviceNamespace, LoadBalancerOptions = new() { Type = loadBalancerType }, }; - private FileConfiguration GivenKubeConfiguration(string serviceNamespace, params FileRoute[] routes) + private FileConfiguration GivenKubeConfiguration(string serviceNamespace, FileRoute route, string token = null) { var u = new Uri(_kubernetesUrl); - var configuration = GivenConfiguration(routes); + var configuration = GivenConfiguration(route); configuration.GlobalConfiguration.ServiceDiscoveryProvider = new() { Scheme = u.Scheme, @@ -257,6 +286,7 @@ private FileConfiguration GivenKubeConfiguration(string serviceNamespace, params Type = nameof(Kube), PollingInterval = 0, Namespace = serviceNamespace, + Token = token ?? "Test", }; return configuration; } @@ -312,9 +342,8 @@ private static ServiceDescriptor GetValidateScopesDescriptor() => ServiceDescriptor.Singleton>( new DefaultServiceProviderFactory(new() { ValidateScopes = true })); private IOcelotBuilder AddKubernetes(IServiceCollection services) => services - .Configure(_kubeClientOptionsConfigure) .Replace(GetValidateScopesDescriptor()) - .AddOcelot().AddKubernetes(false); + .AddOcelot().AddKubernetes(_kubeClientOptionsConfigure); private void WithKubernetes(IServiceCollection services) => AddKubernetes(services); private void WithKubernetesAndRoundRobin(IServiceCollection services) => AddKubernetes(services) diff --git a/test/Ocelot.UnitTests/Kubernetes/KubeTests.cs b/test/Ocelot.UnitTests/Kubernetes/KubeTests.cs index d6c13f60d..e30c44bc8 100644 --- a/test/Ocelot.UnitTests/Kubernetes/KubeTests.cs +++ b/test/Ocelot.UnitTests/Kubernetes/KubeTests.cs @@ -17,7 +17,7 @@ namespace Ocelot.UnitTests.Kubernetes; /// Contains integration tests. /// Move to integration testing, and add at least one "happy path" unit test. /// -public class KubeTests +public class KubeTests : FileUnitTest { private readonly Mock _factory; private readonly Mock _logger; @@ -117,7 +117,7 @@ public async Task Should_return_single_service_from_k8s_during_concurrent_calls( return (client, options, provider, config); } - private EndpointsV1 GivenEndpoints( + protected EndpointsV1 GivenEndpoints( string namespaces = nameof(KubeTests), [CallerMemberName] string serviceName = "test") { @@ -145,7 +145,7 @@ private EndpointsV1 GivenEndpoints( return endpoints; } - private IWebHost GivenThereIsAFakeKubeServiceDiscoveryProvider(string url, string namespaces, string serviceName, + protected IWebHost GivenThereIsAFakeKubeServiceDiscoveryProvider(string url, string namespaces, string serviceName, EndpointsV1 endpointEntries, out Lazy receivedToken) { var token = string.Empty; diff --git a/test/Ocelot.UnitTests/Kubernetes/KubernetesProviderFactoryTests.cs b/test/Ocelot.UnitTests/Kubernetes/KubernetesProviderFactoryTests.cs index 949d87fe4..710560ea5 100644 --- a/test/Ocelot.UnitTests/Kubernetes/KubernetesProviderFactoryTests.cs +++ b/test/Ocelot.UnitTests/Kubernetes/KubernetesProviderFactoryTests.cs @@ -1,51 +1,63 @@ using KubeClient; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; using Ocelot.Configuration; using Ocelot.Configuration.Builder; +using Ocelot.Configuration.File; using Ocelot.DependencyInjection; using Ocelot.Provider.Kubernetes; using Ocelot.Provider.Kubernetes.Interfaces; using Ocelot.ServiceDiscovery; using Ocelot.ServiceDiscovery.Providers; using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; namespace Ocelot.UnitTests.Kubernetes; -public class KubernetesProviderFactoryTests : UnitTest +public sealed class KubernetesProviderFactoryTests : FileUnitTest { private readonly IOcelotBuilder _builder; public KubernetesProviderFactoryTests() { - _builder = new ServiceCollection() - .AddOcelot().AddKubernetes(); + var config = new FileConfiguration(); + config.GlobalConfiguration.ServiceDiscoveryProvider = new() + { + Scheme = Uri.UriSchemeHttp, + Host = "localhost", + Port = 888, + Namespace = nameof(KubernetesProviderFactoryTests), + Token = TestID, + }; + var configuration = new ConfigurationBuilder() + .SetBasePath(AppContext.BaseDirectory) + .AddOcelot(config, null, MergeOcelotJson.ToMemory) + .Build(); + _builder = new ServiceCollection().AddOcelot(configuration); } [Theory] [Trait("Bug", "977")] [InlineData(typeof(Kube))] [InlineData(typeof(PollKube))] - public void CreateProvider_IKubeApiClientHasOriginalLifetimeWithEnabledScopesValidation_ShouldResolveProvider(Type providerType) + public void CreateProvider_ClientHasOriginalLifetimeWithEnabledScopesValidation_ShouldResolveProvider(Type providerType) { // Arrange + _builder.AddKubernetes(); var kubeClient = new Mock(); kubeClient.Setup(x => x.ResourceClient(It.IsAny>())) .Returns(Mock.Of()); var descriptor = _builder.Services.First(x => x.ServiceType == typeof(IKubeApiClient)); _builder.Services.Replace(ServiceDescriptor.Describe(descriptor.ServiceType, _ => kubeClient.Object, descriptor.Lifetime)); - var serviceProvider = _builder.Services.BuildServiceProvider(true); - var config = GivenServiceProvider(providerType.Name); - var route = GivenRoute(); // Act - IServiceDiscoveryProvider actual = null; - var resolving = () => actual = serviceProvider - .GetRequiredService() // returns KubernetesProviderFactory.Get instance - .Invoke(serviceProvider, config, route); + var actual = CreateProvider(providerType.Name); // Assert - resolving.ShouldNotThrow(); actual.ShouldNotBeNull().ShouldBeOfType(providerType); } @@ -53,27 +65,150 @@ public void CreateProvider_IKubeApiClientHasOriginalLifetimeWithEnabledScopesVal [Trait("Bug", "977")] [InlineData(nameof(Kube))] [InlineData(nameof(PollKube))] - public void CreateProvider_IKubeApiClientHasScopedLifetimeWithEnabledScopesValidation_ShouldFailToResolve(string providerType) + public void CreateProvider_ClientHasScopedLifetimeWithEnabledScopesValidation_ShouldFailToResolve(string providerType) { // Arrange + _builder.AddKubernetes(); var descriptor = ServiceDescriptor.Describe(typeof(IKubeApiClient), _ => Mock.Of(), ServiceLifetime.Scoped); _builder.Services.Replace(descriptor); - var serviceProvider = _builder.Services.BuildServiceProvider(true); - var config = GivenServiceProvider(providerType); - var route = GivenRoute(); // Act IServiceDiscoveryProvider actual = null; - var resolving = () => actual = serviceProvider - .GetRequiredService() // returns KubernetesProviderFactory.Get instance - .Invoke(serviceProvider, config, route); + var func = () => actual = CreateProvider(providerType); // Assert - var ex = resolving.ShouldThrow(); + var ex = func.ShouldThrow(); ex.Message.ShouldContain("Cannot resolve scoped service 'KubeClient.IKubeApiClient' from root provider"); actual.ShouldBeNull(); } + [Fact] + [Trait("Feat", "2256")] + public async Task CreateProvider_KubeApiClientFactory_ShouldCreateFromPodServiceAccount() + { + // Arrange + _builder.AddKubernetes(true); // !!! + + // Impossible to mock the KubeClientOptions.FromPodServiceAccount, so let's write stub here + Environment.SetEnvironmentVariable("KUBERNETES_SERVICE_HOST", IPAddress.Loopback.ToString()); + Environment.SetEnvironmentVariable("KUBERNETES_SERVICE_PORT", PortFinder.GetRandomPort().ToString()); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + Console.WriteLine($"{nameof(CreateProvider_KubeApiClientFactory_ShouldCreateFromPodServiceAccount)}: Skipping the test because of Linux environment..."); + return; + } + + // /var/run/secrets/kubernetes.io/serviceaccount\namespace + var currentPath = AppContext.BaseDirectory; + var drive = Path.GetPathRoot(currentPath); + var serviceAccountPath = Path.Combine(drive, "var", "run", "secrets", "kubernetes.io", "serviceaccount"); + + _folders.Add(serviceAccountPath); + if (!Directory.Exists(serviceAccountPath)) + { + Directory.CreateDirectory(serviceAccountPath); + } + + var path = Path.Combine(serviceAccountPath, "namespace"); + await File.WriteAllTextAsync(path, nameof(CreateProvider_KubeApiClientFactory_ShouldCreateFromPodServiceAccount)); + _files.Add(path); + + path = Path.Combine(serviceAccountPath, "token"); + await File.WriteAllTextAsync(path, TestID); + _files.Add(path); + + path = Path.Combine(serviceAccountPath, "ca.crt"); + await CreateCertificate(path); + _files.Add(path); + + // Act + var actual = CreateProvider(nameof(Kube)); + + // Assert + actual.ShouldNotBeNull().ShouldBeOfType(); + } + + [Fact] + [Trait("Feat", "2256")] + public void CreateProvider_KubeApiClientFactory_ShouldCreateFromOptions() + { + // Arrange + _builder.AddKubernetes(false); // !!! + + // In app user must setup by the following: + //MyOptions options = new(); + //_builder.Configuration.GetSection(nameof(MyOptions)).Bind(options); + var options = new Mock>(); + options.SetupGet(x => x.Value).Returns(new KubeClientOptions + { + ApiEndPoint = new UriBuilder(Uri.UriSchemeHttps, IPAddress.Loopback.ToString(), PortFinder.GetRandomPort()).Uri, + ClientCertificate = CreateCertificate(), + KubeNamespace = nameof(CreateProvider_KubeApiClientFactory_ShouldCreateFromOptions), + }); + _builder.Services.AddSingleton>(options.Object); + + // Act + var actual = CreateProvider(nameof(Kube)); + + // Assert + actual.ShouldNotBeNull().ShouldBeOfType(); + } + + [Fact] + [Trait("Feat", "2256")] + public void CreateProvider_HasConfigureOptions_ShouldCallConfigure() + { + // Arrange + _builder.AddKubernetes(configureOptions: null, username: "myUser"); // !!! + + // Act, Assert + var actual = CreateProvider(nameof(Kube)); + actual.ShouldNotBeNull().ShouldBeOfType(); + + // Act, Assert + var provider = _builder.Services.BuildServiceProvider(true); + var o = provider.GetService>().ShouldNotBeNull(); + o.Value.ShouldNotBeNull().Username.ShouldBe("myUser"); + + // Act, Assert + var configureOptions = provider.GetService>().ShouldNotBeNull(); + var opts = new KubeClientOptions(); + configureOptions.Configure(opts); + opts.Username.ShouldBe("myUser"); + } + + private static X509Certificate2 CreateCertificate() + { + // Generate a self-signed certificate + using RSA rsa = RSA.Create(2048); + var request = new CertificateRequest("CN=MyCertificate", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + + // Add extensions to the certificate (optional) + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false)); + request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, false)); + request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false)); + + return request.CreateSelfSigned(DateTimeOffset.Now, DateTimeOffset.Now.AddYears(1)); + } + + private static async Task CreateCertificate(string crtFile) + { + var certificate = CreateCertificate(); + byte[] certBytes = certificate.Export(X509ContentType.Cert); + await File.WriteAllBytesAsync(crtFile, certBytes); + } + + private IServiceDiscoveryProvider CreateProvider(string providerType) + { + var serviceProvider = _builder.Services.BuildServiceProvider(true); + var config = GivenServiceProvider(providerType); + var route = GivenRoute(); + return serviceProvider + .GetRequiredService() // returns KubernetesProviderFactory.Get instance + .Invoke(serviceProvider, config, route); + } + private static ServiceProviderConfiguration GivenServiceProvider(string type) => new( type: type, scheme: string.Empty, @@ -81,8 +216,8 @@ public void CreateProvider_IKubeApiClientHasScopedLifetimeWithEnabledScopesValid port: 1, token: string.Empty, configurationKey: string.Empty, - pollingInterval: 1); + pollingInterval: 9_000); - private static DownstreamRoute GivenRoute([CallerMemberName] string serviceName = "test-service") + private static DownstreamRoute GivenRoute([CallerMemberName] string serviceName = nameof(KubernetesProviderFactoryTests)) => new DownstreamRouteBuilder().WithServiceName(serviceName).Build(); } diff --git a/test/Ocelot.UnitTests/Kubernetes/OcelotBuilderExtensionsTests.cs b/test/Ocelot.UnitTests/Kubernetes/OcelotBuilderExtensionsTests.cs index 43e90d7de..1c0ade07e 100644 --- a/test/Ocelot.UnitTests/Kubernetes/OcelotBuilderExtensionsTests.cs +++ b/test/Ocelot.UnitTests/Kubernetes/OcelotBuilderExtensionsTests.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using Ocelot.DependencyInjection; using Ocelot.Provider.Kubernetes; using Ocelot.Provider.Kubernetes.Interfaces; @@ -56,20 +57,41 @@ public void AddKubernetes_DefaultServices_HappyPath() _ocelotBuilder = _services.AddOcelot(_configRoot).AddKubernetes(); // Assert - var descriptor = _services.SingleOrDefault(Of).ShouldNotBeNull(); - descriptor.Lifetime.ShouldBe(ServiceLifetime.Singleton); // 2180 scenario + AssertServices(); + } + + [Fact] + [Trait("Feat", "2256")] + public void AddKubernetes_NoAction_HappyPath() + { + // Arrange + Action noAction = null; + _ocelotBuilder = _services.AddOcelot(_configRoot); + + // Act + _ocelotBuilder.AddKubernetes(noAction); - descriptor = _services.SingleOrDefault(Of).ShouldNotBeNull(); - descriptor.Lifetime.ShouldBe(ServiceLifetime.Singleton); + // Assert + AssertServices(); + Assert>(); // not IOptions + } - descriptor = _services.SingleOrDefault(Of).ShouldNotBeNull(); - descriptor.Lifetime.ShouldBe(ServiceLifetime.Singleton); + private void AssertServices() + { + Assert(); // 2180 scenario + Assert(); + Assert(); + Assert(); + } - descriptor = _services.SingleOrDefault(Of).ShouldNotBeNull(); - descriptor.Lifetime.ShouldBe(ServiceLifetime.Singleton); + private void Assert(ServiceLifetime lifetime = ServiceLifetime.Singleton) + where T : class + { + var descriptor = _services.SingleOrDefault(Of).ShouldNotBeNull(); + descriptor.Lifetime.ShouldBe(lifetime); } - private static bool Of(ServiceDescriptor descriptor) - where TType : class - => descriptor.ServiceType.Equals(typeof(TType)); + private static bool Of(ServiceDescriptor descriptor) + where T : class + => descriptor.ServiceType.Equals(typeof(T)); }