diff --git a/.github/workflows/build-publish.yml b/.github/workflows/build-publish.yml index f1b209e..2b6a338 100644 --- a/.github/workflows/build-publish.yml +++ b/.github/workflows/build-publish.yml @@ -29,7 +29,7 @@ jobs: - name: Setup dotnet uses: actions/setup-dotnet@v4 with: - dotnet-version: '8.x.x' + dotnet-version: '10.x' - name: Build run: dotnet build -c Release diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..3db64ff --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,27 @@ + + + + true + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/K8sOperator.sln b/K8sOperator.sln index f8c1709..4b8b1fc 100644 --- a/K8sOperator.sln +++ b/K8sOperator.sln @@ -1,14 +1,13 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31903.59 +# Visual Studio Version 18 +VisualStudioVersion = 18.1.11312.151 d18.0 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{898CC489-C84A-49BD-9D77-3CEA1F6A7180}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "props", "props", "{029923F0-FD53-4B75-BA07-F102BBE9C429}" ProjectSection(SolutionItems) = preProject src\Directory.Build.props = src\Directory.Build.props - src\Directory.Packages.props = src\Directory.Packages.props EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{9320CC2F-6BB6-4B29-B625-EB427EE87891}" @@ -16,7 +15,6 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "props", "props", "{0CCCC7F3-A522-4535-8D5A-1E53815936D3}" ProjectSection(SolutionItems) = preProject test\Directory.Build.props = test\Directory.Build.props - test\Directory.Packages.props = test\Directory.Packages.props EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "assets", "assets", "{4D1F501E-294C-4B22-9792-1BBB2B553C69}" diff --git a/examples/SimpleOperator/SimpleOperator.csproj b/examples/SimpleOperator/SimpleOperator.csproj index 294659d..df57e67 100644 --- a/examples/SimpleOperator/SimpleOperator.csproj +++ b/examples/SimpleOperator/SimpleOperator.csproj @@ -1,7 +1,7 @@  - net8.0 + net10.0 enable enable pmdevers diff --git a/global.json b/global.json index 90996b8..588227b 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "8.0.100", + "version": "10.0.100", "rollForward": "latestFeature" } } \ No newline at end of file diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 9f9f584..fb860a7 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,7 +1,7 @@  - net8.0 + net10.0 true enable enable diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props deleted file mode 100644 index d3d0c2d..0000000 --- a/src/Directory.Packages.props +++ /dev/null @@ -1,17 +0,0 @@ - - - - true - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/K8sOperator.NET.Generators/Builders/ClusterRoleBindingBuilderExtensions.cs b/src/K8sOperator.NET.Generators/Builders/ClusterRoleBindingBuilderExtensions.cs index 3d34998..aecd608 100644 --- a/src/K8sOperator.NET.Generators/Builders/ClusterRoleBindingBuilderExtensions.cs +++ b/src/K8sOperator.NET.Generators/Builders/ClusterRoleBindingBuilderExtensions.cs @@ -22,7 +22,7 @@ public static TBuilder WithRoleRef(this TBuilder builder, string apiGr { builder.Add(x => { - x.RoleRef = new V1RoleRef(apiGroup, kind, name); + x.RoleRef = new V1RoleRef() { ApiGroup = apiGroup, Kind = kind, Name = name }; }); return builder; @@ -44,7 +44,7 @@ public static TBuilder WithSubject(this TBuilder builder, string kind, builder.Add(x => { x.Subjects ??= []; - x.Subjects.Add(new Rbacv1Subject(kind, name, apiGroup, ns)); + x.Subjects.Add(new Rbacv1Subject() { Kind = kind, Name = name, ApiGroup = apiGroup, NamespaceProperty = ns }); }); return builder; diff --git a/src/K8sOperator.NET.Generators/Builders/DeploymentBuilderExtensions.cs b/src/K8sOperator.NET.Generators/Builders/DeploymentBuilderExtensions.cs index b6ac9b6..f29de55 100644 --- a/src/K8sOperator.NET.Generators/Builders/DeploymentBuilderExtensions.cs +++ b/src/K8sOperator.NET.Generators/Builders/DeploymentBuilderExtensions.cs @@ -1,5 +1,4 @@ -using k8s.Authentication; -using k8s.Models; +using k8s.Models; namespace K8sOperator.NET.Generators.Builders; @@ -164,13 +163,13 @@ public static TBuilder WithImage(this TBuilder builder, string image) /// An action to configure resource requests. /// The configured builder. public static TBuilder WithResources(this TBuilder builder, - Action>? claims = null, + Action>? claims = null, Action>? limits = null, Action>? requests = null ) where TBuilder : IKubernetesObjectBuilder { - var c = new List(); + var c = new List(); claims?.Invoke(c); var l = new Dictionary(); limits?.Invoke(l); diff --git a/src/K8sOperator.NET/EventWatcher.cs b/src/K8sOperator.NET/EventWatcher.cs index 409f39b..9c73045 100644 --- a/src/K8sOperator.NET/EventWatcher.cs +++ b/src/K8sOperator.NET/EventWatcher.cs @@ -1,10 +1,11 @@ -using k8s; +using k8s; using k8s.Autorest; using k8s.Models; using K8sOperator.NET.Extensions; using K8sOperator.NET.Metadata; using K8sOperator.NET.Models; using Microsoft.Extensions.Logging; +using System.Text.Json; namespace K8sOperator.NET; @@ -32,12 +33,12 @@ public interface IEventWatcher } internal class EventWatcher(IKubernetesClient client, Controller controller, List metadata, ILoggerFactory loggerfactory) : IEventWatcher - where T: CustomResource + where T : CustomResource { private KubernetesEntityAttribute Crd => Metadata.OfType().First(); private string LabelSelector => Metadata.OfType().FirstOrDefault()?.LabelSelector ?? string.Empty; private string Finalizer => Metadata.OfType().FirstOrDefault()?.Finalizer ?? FinalizerAttribute.Default; - + private readonly ChangeTracker _changeTracker = new(); private bool _isRunning; private CancellationToken _cancellationToken = CancellationToken.None; @@ -53,38 +54,65 @@ public async Task Start(CancellationToken cancellationToken) _cancellationToken = cancellationToken; _isRunning = true; - Logger.BeginWatch(Crd.PluralName, LabelSelector); - while (_isRunning && !_cancellationToken.IsCancellationRequested) { try { - var response = Client.ListAsync(LabelSelector, cancellationToken); + Logger.BeginWatch(Crd.PluralName, LabelSelector); - await foreach (var (type, item) in response.WatchAsync(OnError, cancellationToken)) + await foreach (var (type, item) in Client.WatchAsync(LabelSelector, cancellationToken)) { - OnEvent(type, item); + if (item is JsonElement je) + { + var i = je.Deserialize(); + if (i is not null) + { + OnEvent(type, i); + continue; + } + } + else if (item is T resource) + { + OnEvent(type, resource); + continue; + } } } - catch (TaskCanceledException) + catch (TaskCanceledException ex) { - Logger.WatcherError("Task was canceled."); + Logger.WatcherError($"Task was canceled: {ex.Message}"); } - catch (OperationCanceledException) + catch (OperationCanceledException ex) { - Logger.WatcherError("Operation was canceled restarting..."); - await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); + Logger.WatcherError($"Operation was canceled: {ex.Message}"); } catch (HttpOperationException ex) { Logger.WatcherError($"Http Error: {ex.Response.Content}, restarting..."); - await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); } - } + catch (HttpRequestException ex) + { + Logger.WatcherError($"Http Request Error: {ex.Message}, restarting..."); + } + finally + { + Logger.EndWatch(Crd.PluralName, LabelSelector); - Logger.EndWatch(Crd.PluralName, LabelSelector); + if (!cancellationToken.IsCancellationRequested) + { + try + { + await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); + } + catch (TaskCanceledException) + { + // Ignore cancellation during delay + } + } + } + } } - + private void OnEvent(WatchEventType eventType, T customResource) { @@ -93,7 +121,7 @@ private void OnEvent(WatchEventType eventType, T customResource) ProccessEventAsync(eventType, customResource!) .ContinueWith(t => { - if(t.IsFaulted) + if (t.IsFaulted) { var exception = t.Exception.Flatten().InnerException; Logger.ProcessEventError(exception, eventType, customResource); @@ -107,10 +135,10 @@ private async Task ProccessEventAsync(WatchEventType eventType, T resource) { case WatchEventType.Added: case WatchEventType.Modified: - if(resource.Metadata.DeletionTimestamp is not null) + if (resource.Metadata.DeletionTimestamp is not null) { await HandleFinalizeAsync(resource, _cancellationToken); - } + } else { await HandleAddOrModifyAsync(resource, _cancellationToken); @@ -148,7 +176,7 @@ private async Task HandleBookmarkEventAsync(T resource, CancellationToken cancel Logger.BeginBookmark(resource); await _controller.BookmarkAsync(resource, cancellationToken); - + _changeTracker.TrackResourceGenerationAsHandled(resource); Logger.EndBookmark(resource); @@ -242,20 +270,12 @@ private async Task HandleAddOrModifyAsync(T resource, CancellationToken cancella } await _controller.AddOrModifyAsync(resource, cancellationToken); - + resource = await ReplaceAsync(resource, _cancellationToken); - + _changeTracker.TrackResourceGenerationAsHandled(resource); Logger.EndAddOrModify(resource); } - - private void OnError(Exception exception) - { - if (_isRunning) - { - Logger.WatcherError(exception.Message); - } - } } diff --git a/src/K8sOperator.NET/Extensions/LoggingExtensions.cs b/src/K8sOperator.NET/Extensions/LoggingExtensions.cs index dba0b27..d501f81 100644 --- a/src/K8sOperator.NET/Extensions/LoggingExtensions.cs +++ b/src/K8sOperator.NET/Extensions/LoggingExtensions.cs @@ -190,8 +190,8 @@ internal static partial class LoggingExtensions [LoggerMessage( EventId = 29, Level = LogLevel.Information, - Message = "ListAsync {ns}/{plural} {labelselector}" + Message = "WatchAsync {ns}/{plural} {labelselector}" )] - public static partial void ListAsync(this ILogger logger, string ns, string plural, string labelselector); + public static partial void WatchAsync(this ILogger logger, string ns, string plural, string labelselector); } diff --git a/src/K8sOperator.NET/K8sOperator.NET.csproj b/src/K8sOperator.NET/K8sOperator.NET.csproj index 44a5d1c..086500b 100644 --- a/src/K8sOperator.NET/K8sOperator.NET.csproj +++ b/src/K8sOperator.NET/K8sOperator.NET.csproj @@ -6,10 +6,8 @@ - - diff --git a/src/K8sOperator.NET/KubernetesClient.cs b/src/K8sOperator.NET/KubernetesClient.cs index 3db5848..eeaf65c 100644 --- a/src/K8sOperator.NET/KubernetesClient.cs +++ b/src/K8sOperator.NET/KubernetesClient.cs @@ -1,5 +1,4 @@ using k8s; -using k8s.Autorest; using k8s.Models; using K8sOperator.NET.Extensions; using K8sOperator.NET.Models; @@ -10,11 +9,11 @@ namespace K8sOperator.NET; internal interface IKubernetesClient { - Task> ListAsync(string labelSelector, CancellationToken cancellationToken) + IAsyncEnumerable<(WatchEventType, object)> WatchAsync(string labelSelector, CancellationToken cancellationToken) where T : CustomResource; Task ReplaceAsync(T resource, CancellationToken cancellationToken) where T : CustomResource; - + } @@ -24,18 +23,17 @@ internal class NamespacedKubernetesClient(IKubernetes client, ILogger> ListAsync(string labelSelector, CancellationToken cancellationToken) where T : CustomResource + public IAsyncEnumerable<(WatchEventType, object)> WatchAsync(string labelSelector, CancellationToken cancellationToken) where T : CustomResource { var info = typeof(T).GetCustomAttribute()!; - Logger.ListAsync(Namespace, info.PluralName, labelSelector); + Logger.WatchAsync(Namespace, info.PluralName, labelSelector); - var response = Client.CustomObjects.ListNamespacedCustomObjectWithHttpMessagesAsync( + var response = Client.CustomObjects.WatchListNamespacedCustomObjectAsync( info.Group, info.ApiVersion, Namespace, info.PluralName, - watch: true, allowWatchBookmarks: true, labelSelector: labelSelector, timeoutSeconds: (int)TimeSpan.FromMinutes(60).TotalSeconds, @@ -93,18 +91,17 @@ public async Task ReplaceAsync(T resource, CancellationToken cancellationT return result; } - public Task> ListAsync(string labelSelector, CancellationToken cancellationToken) + public IAsyncEnumerable<(WatchEventType, object)> WatchAsync(string labelSelector, CancellationToken cancellationToken) where T : CustomResource { var info = typeof(T).GetCustomAttribute()!; - Logger.ListAsync("cluster-wide", info.PluralName, labelSelector); + Logger.WatchAsync("cluster-wide", info.PluralName, labelSelector); - var response = Client.CustomObjects.ListClusterCustomObjectWithHttpMessagesAsync( + var response = Client.CustomObjects.WatchListClusterCustomObjectAsync( info.Group, info.ApiVersion, info.PluralName, - watch: true, allowWatchBookmarks: true, labelSelector: labelSelector, timeoutSeconds: (int)TimeSpan.FromMinutes(60).TotalSeconds, diff --git a/test/Directory.Build.props b/test/Directory.Build.props index 351ff22..d80851a 100644 --- a/test/Directory.Build.props +++ b/test/Directory.Build.props @@ -1,7 +1,7 @@ - net8.0 + net10.0 true enable enable @@ -25,7 +25,7 @@ - + diff --git a/test/Directory.Packages.props b/test/Directory.Packages.props deleted file mode 100644 index 04d45c1..0000000 --- a/test/Directory.Packages.props +++ /dev/null @@ -1,19 +0,0 @@ - - - - true - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/test/K8sOperator.NET.Tests/ControllerDatasourceTests.cs b/test/K8sOperator.NET.Tests/ControllerDatasourceTests.cs index a493dda..02e222a 100644 --- a/test/K8sOperator.NET.Tests/ControllerDatasourceTests.cs +++ b/test/K8sOperator.NET.Tests/ControllerDatasourceTests.cs @@ -1,20 +1,14 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +namespace K8sOperator.NET.Tests; -namespace K8sOperator.NET.Tests; -using Xunit; -using FluentAssertions; +using AwesomeAssertions; +using k8s; +using K8sOperator.NET.Tests.Logging; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using NSubstitute; using System; using System.Collections.Generic; -using K8sOperator.NET.Builder; -using Microsoft.Extensions.DependencyInjection; -using k8s; -using Microsoft.Extensions.Logging; -using K8sOperator.NET.Tests.Logging; +using Xunit; using Xunit.Abstractions; public class ControllerDatasourceTests @@ -56,7 +50,7 @@ public void AddController_Should_AddConventionsToEntry() x.ClearProviders(); x.AddTestOutput(_testOutput); }) - .AddSingleton(kubernetes) + .AddSingleton(kubernetes) .BuildServiceProvider(); // Act @@ -77,7 +71,8 @@ public void GetWatchers_Should_ReturnEventWatchers_WithAppliedConventions() var services = new ServiceCollection(); services.AddSingleton(Substitute.For()); - services.AddLogging(x => { + services.AddLogging(x => + { x.ClearProviders(); x.AddTestOutput(_testOutput); }); diff --git a/test/K8sOperator.NET.Tests/EventWatcherTests.cs b/test/K8sOperator.NET.Tests/EventWatcherTests.cs index 4739fe4..82ef4db 100644 --- a/test/K8sOperator.NET.Tests/EventWatcherTests.cs +++ b/test/K8sOperator.NET.Tests/EventWatcherTests.cs @@ -1,11 +1,11 @@ -using Microsoft.Extensions.Logging; +using k8s; using k8s.Models; -using k8s; using K8sOperator.NET.Metadata; using K8sOperator.NET.Models; -using Xunit.Abstractions; using K8sOperator.NET.Tests.Mocks; using K8sOperator.NET.Tests.Mocks.Endpoints; +using Microsoft.Extensions.Logging; +using Xunit.Abstractions; #pragma warning disable CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed @@ -13,36 +13,42 @@ namespace K8sOperator.NET.Tests; public class EventWatcherTests { - private static Watcher.WatchEvent Added => CreateEvent(WatchEventType.Modified, - new TestResource() { Metadata = new() - { - Name = "test", - NamespaceProperty = "default", - Finalizers = ["finalize"], - Uid = "1" - } - }); + private static Watcher.WatchEvent Added => CreateEvent(WatchEventType.Modified, + new TestResource() + { + Metadata = new() + { + Name = "test", + NamespaceProperty = "default", + Finalizers = ["finalize"], + Uid = "1" + } + }); private static Watcher.WatchEvent Finalize => CreateEvent(WatchEventType.Added, - new TestResource() { Metadata = new() - { - Name = "test", - NamespaceProperty = "default", - DeletionTimestamp = TimeProvider.System.GetUtcNow().DateTime, - Finalizers = ["finalize"], - Uid = "1" - } - }); + new TestResource() + { + Metadata = new() + { + Name = "test", + NamespaceProperty = "default", + DeletionTimestamp = TimeProvider.System.GetUtcNow().DateTime, + Finalizers = ["finalize"], + Uid = "1" + } + }); private static Watcher.WatchEvent Deleted => CreateEvent(WatchEventType.Deleted, - new TestResource() { Metadata = new() - { - Name = "test", - NamespaceProperty = "default", - Finalizers = ["finalize"], - Uid = "1" - } - }); + new TestResource() + { + Metadata = new() + { + Name = "test", + NamespaceProperty = "default", + Finalizers = ["finalize"], + Uid = "1" + } + }); private static Watcher.WatchEvent CreateEvent(WatchEventType type, T item) where T : CustomResource @@ -75,9 +81,9 @@ public async Task Start_Should_StartWatchAndLogStart() { var cancellationToken = _tokenSource.Token; - using ( var server = new MockKubeApiServer(_testOutput, endpoints => + using (var server = new MockKubeApiServer(_testOutput, endpoints => { - endpoints.MapListNamespacedCustomObjectWithHttpMessagesAsync(); + endpoints.MapWatchNamespacedCustomObjectAsync(Added); })) { var client = new NamespacedKubernetesClient(server.GetMockedKubernetesClient(), _loggerFactory.CreateLogger()); @@ -85,25 +91,42 @@ public async Task Start_Should_StartWatchAndLogStart() await watcher.Start(cancellationToken); } - + _loggerFactory.Received(2).CreateLogger(Arg.Any()); } [Fact] public async Task OnEvent_Should_HandleAddedEventAndCallAddOrModifyAsync() { - var cancellationToken = _tokenSource.Token; - + var cancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + var eventProcessed = new TaskCompletionSource(); + + // Setup the controller to signal when AddOrModifyAsync is called + _controller.AddOrModifyAsync(Arg.Any(), Arg.Any()) + .Returns(x => + { + eventProcessed.TrySetResult(true); + return Task.CompletedTask; + }); + using (var server = new MockKubeApiServer(_testOutput, endpoints => { - endpoints.MapListNamespacedCustomObjectWithHttpMessagesAsync(Added); + endpoints.MapWatchNamespacedCustomObjectAsync(Added); endpoints.MapReplaceNamespacedCustomObjectAsync(); })) { var client = new NamespacedKubernetesClient(server.GetMockedKubernetesClient(), _loggerFactory.CreateLogger()); var watcher = new EventWatcher(client, _controller, _metadata, _loggerFactory); - await watcher.Start(cancellationToken); + var watchTask = Task.Run(async () => await watcher.Start(cancellationToken.Token)); + + // Wait for either the event to be processed or timeout + var completedTask = await Task.WhenAny(eventProcessed.Task, Task.Delay(TimeSpan.FromSeconds(3))); + + if (completedTask != eventProcessed.Task) + { + throw new TimeoutException("AddOrModifyAsync was not called within the timeout period"); + } } _loggerFactory.Received(2).CreateLogger(Arg.Any()); @@ -114,18 +137,35 @@ public async Task OnEvent_Should_HandleAddedEventAndCallAddOrModifyAsync() [Fact] public async Task OnEvent_Should_HandleDeletedEventAndCallDeleteAsync() { - var cancellationToken = _tokenSource.Token; + var cancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + var eventProcessed = new TaskCompletionSource(); + + // Setup the controller to signal when DeleteAsync is called + _controller.DeleteAsync(Arg.Any(), Arg.Any()) + .Returns(x => + { + eventProcessed.TrySetResult(true); + return Task.CompletedTask; + }); using (var server = new MockKubeApiServer(_testOutput, endpoints => { - endpoints.MapListNamespacedCustomObjectWithHttpMessagesAsync(Deleted); + endpoints.MapWatchNamespacedCustomObjectAsync(Deleted); endpoints.MapReplaceNamespacedCustomObjectAsync(); })) { var client = new NamespacedKubernetesClient(server.GetMockedKubernetesClient(), _loggerFactory.CreateLogger()); var watcher = new EventWatcher(client, _controller, _metadata, _loggerFactory); - await watcher.Start(cancellationToken); + var watchTask = Task.Run(async () => await watcher.Start(cancellationToken.Token)); + + // Wait for either the event to be processed or timeout + var completedTask = await Task.WhenAny(eventProcessed.Task, Task.Delay(TimeSpan.FromSeconds(3))); + + if (completedTask != eventProcessed.Task) + { + throw new TimeoutException("DeleteAsync was not called within the timeout period"); + } } _loggerFactory.Received(2).CreateLogger(Arg.Any()); @@ -137,17 +177,34 @@ public async Task OnEvent_Should_HandleDeletedEventAndCallDeleteAsync() public async Task HandleFinalizeAsync_Should_CallFinalizeAndRemoveFinalizer() { var cancellationToken = _tokenSource.Token; + var eventProcessed = new TaskCompletionSource(); + + // Setup the controller to signal when FinalizeAsync is called + _controller.FinalizeAsync(Arg.Any(), Arg.Any()) + .Returns(x => + { + eventProcessed.TrySetResult(true); + return Task.CompletedTask; + }); using (var server = new MockKubeApiServer(_testOutput, endpoints => { - endpoints.MapListNamespacedCustomObjectWithHttpMessagesAsync(Finalize); + endpoints.MapWatchNamespacedCustomObjectAsync(Finalize); endpoints.MapReplaceNamespacedCustomObjectAsync(); })) { var client = new NamespacedKubernetesClient(server.GetMockedKubernetesClient(), _loggerFactory.CreateLogger()); var watcher = new EventWatcher(client, _controller, _metadata, _loggerFactory); - await watcher.Start(cancellationToken); + var watchTask = Task.Run(async () => await watcher.Start(cancellationToken)); + + // Wait for either the event to be processed or timeout + var completedTask = await Task.WhenAny(eventProcessed.Task, Task.Delay(TimeSpan.FromSeconds(3))); + + if (completedTask != eventProcessed.Task) + { + throw new TimeoutException("FinalizeAsync was not called within the timeout period"); + } } _loggerFactory.Received(2).CreateLogger(Arg.Any()); diff --git a/test/K8sOperator.NET.Tests/K8sOperator.NET.Tests.csproj b/test/K8sOperator.NET.Tests/K8sOperator.NET.Tests.csproj index 986073d..f7573eb 100644 --- a/test/K8sOperator.NET.Tests/K8sOperator.NET.Tests.csproj +++ b/test/K8sOperator.NET.Tests/K8sOperator.NET.Tests.csproj @@ -1,7 +1,5 @@ - + - - diff --git a/test/K8sOperator.NET.Tests/Mocks/Endpoints/WatchNamespaced.cs b/test/K8sOperator.NET.Tests/Mocks/Endpoints/WatchNamespaced.cs new file mode 100644 index 0000000..53fd009 --- /dev/null +++ b/test/K8sOperator.NET.Tests/Mocks/Endpoints/WatchNamespaced.cs @@ -0,0 +1,35 @@ +using k8s; +using K8sOperator.NET.Models; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + + +namespace K8sOperator.NET.Tests.Mocks.Endpoints; + +internal static class WatchNamespaced +{ + public static void MapWatchNamespacedCustomObjectAsync(this IEndpointRouteBuilder builder, Watcher.WatchEvent? watchEvent = null) + where T : CustomResource, new() + { + // The correct URL pattern for Kubernetes watch API is the same as list but with ?watch=true query parameter + builder.MapGet("/apis/{group}/{version}/namespaces/{namespace}/{plural}", async context => + { + var isWatch = context.Request.Query["watch"].ToString() == "true"; + + if (!isWatch || watchEvent is null) + { + var j = KubernetesJson.Serialize(new T()); + await context.Response.WriteAsync(j); + return; + } + + // For watch requests, send the event as newline-delimited JSON + var json = KubernetesJson.Serialize(watchEvent); + await context.Response.WriteAsync(json); + await context.Response.WriteAsync("\n"); + await context.Response.Body.FlushAsync(); + await Task.Delay(TimeSpan.FromMilliseconds(100)).ConfigureAwait(true); + }); + } +} diff --git a/test/K8sOperator.NET.Tests/Mocks/MockKubeApiServer.cs b/test/K8sOperator.NET.Tests/Mocks/MockKubeApiServer.cs index 65c4c76..b00de8c 100644 --- a/test/K8sOperator.NET.Tests/Mocks/MockKubeApiServer.cs +++ b/test/K8sOperator.NET.Tests/Mocks/MockKubeApiServer.cs @@ -1,59 +1,63 @@ -using K8sOperator.NET.Tests.Logging; +using k8s; +using K8sOperator.NET.Tests.Logging; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using Xunit.Abstractions; -using Microsoft.AspNetCore.Builder; -using k8s; -using Microsoft.AspNetCore.Routing; using System.Net; -using Microsoft.AspNetCore; -using Microsoft.AspNetCore.Hosting.Server.Features; +using Xunit.Abstractions; namespace K8sOperator.NET.Tests.Mocks; + public sealed class MockKubeApiServer : IDisposable { - private readonly IWebHost _server; + private readonly IHost _server; public MockKubeApiServer(ITestOutputHelper testOutput, Action? builder = null) { - _server = WebHost.CreateDefaultBuilder() - .ConfigureServices(services => + _server = new HostBuilder() + .ConfigureWebHost(config => { - services.AddRouting(); - }) - .ConfigureLogging(logging => - { - logging.ClearProviders(); - - if (testOutput != null) + config.ConfigureServices(services => { - logging.AddTestOutput(testOutput); - } - }) - .UseKestrel(options => - { - options.Listen(IPAddress.Loopback, 0); - }) - .Configure(app => - { - // Mock Kube API routes - app.UseRouting(); - app.UseEndpoints(endpoints => + services.AddRouting(); + }); + config.UseKestrel(options => { options.Listen(IPAddress.Loopback, 8888); }); + config.Configure(app => { - builder?.Invoke(endpoints); + // Mock Kube API routes + app.UseRouting(); - endpoints.Map("{*url}", (ILogger logger, string url) => + app.UseEndpoints(endpoints => { - logger.LogInformation("route not handled: '{url}'", url); + builder?.Invoke(endpoints); + endpoints.Map("{*url}", (ILogger logger, string url) => + { + var safeUrl = url.Replace("\r", string.Empty).Replace("\n", string.Empty); + logger.LogInformation("route not handled: '{url}'", safeUrl); + }); }); }); - }).Build(); + config.ConfigureLogging(logging => + { + logging.ClearProviders(); + if (testOutput != null) + { + logging.AddTestOutput(testOutput); + } + }); + }) + .Build(); + - _server.Start(); + _server.Start(); } - public Uri Uri => _server.ServerFeatures.Get()!.Addresses + public Uri Uri => _server.Services.GetRequiredService().Features.Get()!.Addresses .Select(a => new Uri(a)).First(); // Method to get the mocked Kubernetes client @@ -65,8 +69,6 @@ public IKubernetes GetMockedKubernetesClient() public void Dispose() { - _server.StopAsync(); - _server.WaitForShutdown(); _server.Dispose(); } }