From 818f6bcfd81c8c2b81eb352ab6b01e9b09ee84f1 Mon Sep 17 00:00:00 2001 From: Patrick Evers Date: Mon, 12 Jan 2026 10:38:23 +0100 Subject: [PATCH 1/8] Updates target framework to .NET 10 Upgrades the project to target .NET 10. This change includes updating the target framework in the project files, global.json, Dockerfile, and project templates. It also updates package versions to be compatible with .NET 10 and uses central package management. The update ensures the project uses the latest .NET runtime and features. --- Directory.Packages.props | 29 +++++++ K8sOperator.sln | 6 +- examples/SimpleOperator/SimpleOperator.csproj | 2 +- global.json | 2 +- src/Directory.Build.props | 2 +- src/Directory.Packages.props | 17 ----- .../ClusterRoleBindingBuilderExtensions.cs | 4 +- .../Builders/DeploymentBuilderExtensions.cs | 7 +- src/K8sOperator.NET/EventWatcher.cs | 44 +++++------ src/K8sOperator.NET/K8sOperator.NET.csproj | 1 - src/K8sOperator.NET/KubernetesClient.cs | 15 ++-- test/Directory.Build.props | 4 +- test/Directory.Packages.props | 19 ----- .../ControllerDatasourceTests.cs | 25 +++--- .../K8sOperator.NET.Tests.csproj | 8 +- .../Mocks/MockKubeApiServer.cs | 76 ++++++++----------- 16 files changed, 112 insertions(+), 149 deletions(-) create mode 100644 Directory.Packages.props delete mode 100644 src/Directory.Packages.props delete mode 100644 test/Directory.Packages.props diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..ba05eef --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,29 @@ + + + + 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..d8a8fc0 100644 --- a/src/K8sOperator.NET/EventWatcher.cs +++ b/src/K8sOperator.NET/EventWatcher.cs @@ -1,4 +1,4 @@ -using k8s; +using k8s; using k8s.Autorest; using k8s.Models; using K8sOperator.NET.Extensions; @@ -32,12 +32,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,17 +53,17 @@ public async Task Start(CancellationToken cancellationToken) _cancellationToken = cancellationToken; _isRunning = true; - Logger.BeginWatch(Crd.PluralName, LabelSelector); - while (_isRunning && !_cancellationToken.IsCancellationRequested) { + Logger.BeginWatch(Crd.PluralName, LabelSelector); + try { - var response = Client.ListAsync(LabelSelector, cancellationToken); + var response = Client.WatchAsync(LabelSelector, cancellationToken); - await foreach (var (type, item) in response.WatchAsync(OnError, cancellationToken)) + await foreach (var (type, item) in response.ConfigureAwait(false)) { - OnEvent(type, item); + OnEvent(type, (T)item); } } catch (TaskCanceledException) @@ -80,11 +80,13 @@ public async Task Start(CancellationToken cancellationToken) Logger.WatcherError($"Http Error: {ex.Response.Content}, restarting..."); await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); } + finally + { + Logger.EndWatch(Crd.PluralName, LabelSelector); + } } - - Logger.EndWatch(Crd.PluralName, LabelSelector); } - + private void OnEvent(WatchEventType eventType, T customResource) { @@ -93,7 +95,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 +109,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 +150,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 +244,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/K8sOperator.NET.csproj b/src/K8sOperator.NET/K8sOperator.NET.csproj index 44a5d1c..a838a0e 100644 --- a/src/K8sOperator.NET/K8sOperator.NET.csproj +++ b/src/K8sOperator.NET/K8sOperator.NET.csproj @@ -9,7 +9,6 @@ - diff --git a/src/K8sOperator.NET/KubernetesClient.cs b/src/K8sOperator.NET/KubernetesClient.cs index 3db5848..3e76c9a 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); - 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); - 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/K8sOperator.NET.Tests.csproj b/test/K8sOperator.NET.Tests/K8sOperator.NET.Tests.csproj index 986073d..7e986ac 100644 --- a/test/K8sOperator.NET.Tests/K8sOperator.NET.Tests.csproj +++ b/test/K8sOperator.NET.Tests/K8sOperator.NET.Tests.csproj @@ -1,7 +1,9 @@ - + - - + + + + diff --git a/test/K8sOperator.NET.Tests/Mocks/MockKubeApiServer.cs b/test/K8sOperator.NET.Tests/Mocks/MockKubeApiServer.cs index 65c4c76..58faeee 100644 --- a/test/K8sOperator.NET.Tests/Mocks/MockKubeApiServer.cs +++ b/test/K8sOperator.NET.Tests/Mocks/MockKubeApiServer.cs @@ -1,60 +1,46 @@ -using K8sOperator.NET.Tests.Logging; +using k8s; +using K8sOperator.NET.Tests.Logging; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.TestHost; 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; 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) + public MockKubeApiServer(ITestOutputHelper testOutput, Action? endpoints = null) { - _server = WebHost.CreateDefaultBuilder() - .ConfigureServices(services => - { - services.AddRouting(); - }) - .ConfigureLogging(logging => - { - logging.ClearProviders(); - - if (testOutput != null) - { - logging.AddTestOutput(testOutput); - } - }) - .UseKestrel(options => - { - options.Listen(IPAddress.Loopback, 0); - }) - .Configure(app => - { - // Mock Kube API routes - app.UseRouting(); - app.UseEndpoints(endpoints => - { - builder?.Invoke(endpoints); - - endpoints.Map("{*url}", (ILogger logger, string url) => - { - logger.LogInformation("route not handled: '{url}'", url); - }); - }); - }).Build(); - - _server.Start(); + var builder = WebApplication.CreateBuilder(); + + builder.Services.AddRouting(); + builder.Logging.ClearProviders(); + if (testOutput != null) + { + builder.Logging.AddTestOutput(testOutput); + } + + var app = builder.Build(); + // Mock Kube API routes + app.UseRouting(); + + endpoints?.Invoke(app); + app.Map("{*url}", (ILogger logger, string url) => + { + logger.LogInformation("route not handled: '{url}'", url); + }); + + _server = builder.Build(); + _server.Start(); } - public Uri Uri => _server.ServerFeatures.Get()!.Addresses - .Select(a => new Uri(a)).First(); + public Uri Uri => _server.GetTestServer().BaseAddress; // Method to get the mocked Kubernetes client public IKubernetes GetMockedKubernetesClient() From d4e5ee4f8ca1659feb99f095db43623013a80fe8 Mon Sep 17 00:00:00 2001 From: Patrick Evers Date: Mon, 12 Jan 2026 11:12:25 +0100 Subject: [PATCH 2/8] Update Comments --- Directory.Packages.props | 2 -- .../Extensions/LoggingExtensions.cs | 2 +- src/K8sOperator.NET/KubernetesClient.cs | 4 ++-- .../K8sOperator.NET.Tests.csproj | 4 ---- .../Mocks/MockKubeApiServer.cs | 14 ++++++++------ 5 files changed, 11 insertions(+), 15 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index ba05eef..3db64ff 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,8 +6,6 @@ - - diff --git a/src/K8sOperator.NET/Extensions/LoggingExtensions.cs b/src/K8sOperator.NET/Extensions/LoggingExtensions.cs index dba0b27..b4a7010 100644 --- a/src/K8sOperator.NET/Extensions/LoggingExtensions.cs +++ b/src/K8sOperator.NET/Extensions/LoggingExtensions.cs @@ -192,6 +192,6 @@ internal static partial class LoggingExtensions Level = LogLevel.Information, Message = "ListAsync {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/KubernetesClient.cs b/src/K8sOperator.NET/KubernetesClient.cs index 3e76c9a..eeaf65c 100644 --- a/src/K8sOperator.NET/KubernetesClient.cs +++ b/src/K8sOperator.NET/KubernetesClient.cs @@ -27,7 +27,7 @@ internal class NamespacedKubernetesClient(IKubernetes client, ILogger()!; - Logger.ListAsync(Namespace, info.PluralName, labelSelector); + Logger.WatchAsync(Namespace, info.PluralName, labelSelector); var response = Client.CustomObjects.WatchListNamespacedCustomObjectAsync( info.Group, @@ -96,7 +96,7 @@ public async Task ReplaceAsync(T resource, CancellationToken cancellationT { var info = typeof(T).GetCustomAttribute()!; - Logger.ListAsync("cluster-wide", info.PluralName, labelSelector); + Logger.WatchAsync("cluster-wide", info.PluralName, labelSelector); var response = Client.CustomObjects.WatchListClusterCustomObjectAsync( info.Group, diff --git a/test/K8sOperator.NET.Tests/K8sOperator.NET.Tests.csproj b/test/K8sOperator.NET.Tests/K8sOperator.NET.Tests.csproj index 7e986ac..f7573eb 100644 --- a/test/K8sOperator.NET.Tests/K8sOperator.NET.Tests.csproj +++ b/test/K8sOperator.NET.Tests/K8sOperator.NET.Tests.csproj @@ -1,9 +1,5 @@  - - - - diff --git a/test/K8sOperator.NET.Tests/Mocks/MockKubeApiServer.cs b/test/K8sOperator.NET.Tests/Mocks/MockKubeApiServer.cs index 58faeee..bc3862b 100644 --- a/test/K8sOperator.NET.Tests/Mocks/MockKubeApiServer.cs +++ b/test/K8sOperator.NET.Tests/Mocks/MockKubeApiServer.cs @@ -20,23 +20,25 @@ public MockKubeApiServer(ITestOutputHelper testOutput, Action logger, string url) => + endpoints?.Invoke(_server); + _server.Map("{*url}", (ILogger logger, string url) => { - logger.LogInformation("route not handled: '{url}'", url); + var safeUrl = url.Replace("\r", string.Empty).Replace("\n", string.Empty); + logger.LogInformation("route not handled: '{url}'", safeUrl); }); - _server = builder.Build(); _server.Start(); } From 31873c6de7786670d131a676eb6671d034918fe6 Mon Sep 17 00:00:00 2001 From: Patrick Evers Date: Mon, 12 Jan 2026 11:17:09 +0100 Subject: [PATCH 3/8] Fix Nitpick --- src/K8sOperator.NET/EventWatcher.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/K8sOperator.NET/EventWatcher.cs b/src/K8sOperator.NET/EventWatcher.cs index d8a8fc0..03ce30b 100644 --- a/src/K8sOperator.NET/EventWatcher.cs +++ b/src/K8sOperator.NET/EventWatcher.cs @@ -55,10 +55,10 @@ public async Task Start(CancellationToken cancellationToken) while (_isRunning && !_cancellationToken.IsCancellationRequested) { - Logger.BeginWatch(Crd.PluralName, LabelSelector); - try { + Logger.BeginWatch(Crd.PluralName, LabelSelector); + var response = Client.WatchAsync(LabelSelector, cancellationToken); await foreach (var (type, item) in response.ConfigureAwait(false)) @@ -73,16 +73,17 @@ public async Task Start(CancellationToken cancellationToken) catch (OperationCanceledException) { Logger.WatcherError("Operation was canceled restarting..."); - await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); } catch (HttpOperationException ex) { Logger.WatcherError($"Http Error: {ex.Response.Content}, restarting..."); - await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); } finally { Logger.EndWatch(Crd.PluralName, LabelSelector); + + if (!cancellationToken.IsCancellationRequested) + await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); } } } From d4581f089c8abdb27591543326c8ff27515c9199 Mon Sep 17 00:00:00 2001 From: Patrick Evers Date: Mon, 12 Jan 2026 11:28:56 +0100 Subject: [PATCH 4/8] Dotnet 10 pipeline --- .github/workflows/build-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 316157af9c5b3a3ab20ada3f4945d1fc9376d665 Mon Sep 17 00:00:00 2001 From: Patrick Evers Date: Mon, 12 Jan 2026 12:21:55 +0100 Subject: [PATCH 5/8] Test Server Fix --- src/K8sOperator.NET/K8sOperator.NET.csproj | 1 - .../Mocks/MockKubeApiServer.cs | 15 ++++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/K8sOperator.NET/K8sOperator.NET.csproj b/src/K8sOperator.NET/K8sOperator.NET.csproj index a838a0e..086500b 100644 --- a/src/K8sOperator.NET/K8sOperator.NET.csproj +++ b/src/K8sOperator.NET/K8sOperator.NET.csproj @@ -6,7 +6,6 @@ - diff --git a/test/K8sOperator.NET.Tests/Mocks/MockKubeApiServer.cs b/test/K8sOperator.NET.Tests/Mocks/MockKubeApiServer.cs index bc3862b..ebfd76e 100644 --- a/test/K8sOperator.NET.Tests/Mocks/MockKubeApiServer.cs +++ b/test/K8sOperator.NET.Tests/Mocks/MockKubeApiServer.cs @@ -13,24 +13,29 @@ namespace K8sOperator.NET.Tests.Mocks; public sealed class MockKubeApiServer : IDisposable { - private readonly IHost _server; + private readonly WebApplication _server; + private readonly TestServer _testServer; public MockKubeApiServer(ITestOutputHelper testOutput, Action? endpoints = null) { var builder = WebApplication.CreateBuilder(); builder.Services.AddRouting(); - + builder.Logging.ClearProviders(); if (testOutput != null) { builder.Logging.AddTestOutput(testOutput); } + builder.WebHost.UseTestServer(); + _server = builder.Build(); + + _testServer = _server.GetTestServer(); + // Mock Kube API routes _server.UseRouting(); - _server.UseTestServer(); endpoints?.Invoke(_server); _server.Map("{*url}", (ILogger logger, string url) => @@ -42,7 +47,7 @@ public MockKubeApiServer(ITestOutputHelper testOutput, Action _server.GetTestServer().BaseAddress; + public Uri Uri => _testServer.BaseAddress; // Method to get the mocked Kubernetes client public IKubernetes GetMockedKubernetesClient() @@ -55,6 +60,6 @@ public void Dispose() { _server.StopAsync(); _server.WaitForShutdown(); - _server.Dispose(); + _testServer.Dispose(); } } From ee56dcd6612affc91ba6a7723af2416385199550 Mon Sep 17 00:00:00 2001 From: Patrick Evers Date: Mon, 12 Jan 2026 15:11:58 +0100 Subject: [PATCH 6/8] Serialization Issue --- src/K8sOperator.NET/EventWatcher.cs | 34 ++++++--- .../Extensions/LoggingExtensions.cs | 2 +- .../EventWatcherTests.cs | 71 ++++++++++--------- .../Mocks/Endpoints/WatchNamespaced.cs | 35 +++++++++ .../Mocks/MockKubeApiServer.cs | 71 +++++++++++-------- 5 files changed, 139 insertions(+), 74 deletions(-) create mode 100644 test/K8sOperator.NET.Tests/Mocks/Endpoints/WatchNamespaced.cs diff --git a/src/K8sOperator.NET/EventWatcher.cs b/src/K8sOperator.NET/EventWatcher.cs index 03ce30b..e4966cc 100644 --- a/src/K8sOperator.NET/EventWatcher.cs +++ b/src/K8sOperator.NET/EventWatcher.cs @@ -59,31 +59,47 @@ public async Task Start(CancellationToken cancellationToken) { Logger.BeginWatch(Crd.PluralName, LabelSelector); - var response = Client.WatchAsync(LabelSelector, cancellationToken); - - await foreach (var (type, item) in response.ConfigureAwait(false)) + await foreach (var (type, item) in Client.WatchAsync(LabelSelector, cancellationToken)) { - OnEvent(type, (T)item); + // Handle case where item might be JsonElement and needs conversion + T? resource = item is T typed ? typed : KubernetesJson.Deserialize(KubernetesJson.Serialize(item)); + if (resource is not null) + { + OnEvent(type, resource); + } } } - 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..."); + Logger.WatcherError($"Operation was canceled: {ex.Message}"); } catch (HttpOperationException ex) { Logger.WatcherError($"Http Error: {ex.Response.Content}, restarting..."); } + catch (HttpRequestException ex) + { + Logger.WatcherError($"Http Request Error: {ex.Message}, restarting..."); + } finally { Logger.EndWatch(Crd.PluralName, LabelSelector); if (!cancellationToken.IsCancellationRequested) - await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); + { + try + { + await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); + } + catch (TaskCanceledException) + { + // Ignore cancellation during delay + } + } } } } diff --git a/src/K8sOperator.NET/Extensions/LoggingExtensions.cs b/src/K8sOperator.NET/Extensions/LoggingExtensions.cs index b4a7010..d501f81 100644 --- a/src/K8sOperator.NET/Extensions/LoggingExtensions.cs +++ b/src/K8sOperator.NET/Extensions/LoggingExtensions.cs @@ -190,7 +190,7 @@ 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 WatchAsync(this ILogger logger, string ns, string plural, string labelselector); diff --git a/test/K8sOperator.NET.Tests/EventWatcherTests.cs b/test/K8sOperator.NET.Tests/EventWatcherTests.cs index 4739fe4..a70284b 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.MapListNamespacedCustomObjectWithHttpMessagesAsync(Added); })) { var client = new NamespacedKubernetesClient(server.GetMockedKubernetesClient(), _loggerFactory.CreateLogger()); @@ -85,7 +91,7 @@ public async Task Start_Should_StartWatchAndLogStart() await watcher.Start(cancellationToken); } - + _loggerFactory.Received(2).CreateLogger(Arg.Any()); } @@ -93,7 +99,6 @@ public async Task Start_Should_StartWatchAndLogStart() public async Task OnEvent_Should_HandleAddedEventAndCallAddOrModifyAsync() { var cancellationToken = _tokenSource.Token; - using (var server = new MockKubeApiServer(_testOutput, endpoints => { endpoints.MapListNamespacedCustomObjectWithHttpMessagesAsync(Added); 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 ebfd76e..b00de8c 100644 --- a/test/K8sOperator.NET.Tests/Mocks/MockKubeApiServer.cs +++ b/test/K8sOperator.NET.Tests/Mocks/MockKubeApiServer.cs @@ -2,52 +2,63 @@ 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.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using System.Net; using Xunit.Abstractions; namespace K8sOperator.NET.Tests.Mocks; public sealed class MockKubeApiServer : IDisposable { - private readonly WebApplication _server; - private readonly TestServer _testServer; + private readonly IHost _server; - public MockKubeApiServer(ITestOutputHelper testOutput, Action? endpoints = null) + public MockKubeApiServer(ITestOutputHelper testOutput, Action? builder = null) { - var builder = WebApplication.CreateBuilder(); + _server = new HostBuilder() + .ConfigureWebHost(config => + { + config.ConfigureServices(services => + { + services.AddRouting(); + }); + config.UseKestrel(options => { options.Listen(IPAddress.Loopback, 8888); }); + config.Configure(app => + { + // Mock Kube API routes + app.UseRouting(); - builder.Services.AddRouting(); + app.UseEndpoints(endpoints => + { + 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); + }); + }); + }); + config.ConfigureLogging(logging => + { + logging.ClearProviders(); + if (testOutput != null) + { + logging.AddTestOutput(testOutput); + } + }); + }) + .Build(); - builder.Logging.ClearProviders(); - if (testOutput != null) - { - builder.Logging.AddTestOutput(testOutput); - } - - builder.WebHost.UseTestServer(); - - _server = builder.Build(); - - _testServer = _server.GetTestServer(); - - // Mock Kube API routes - _server.UseRouting(); - - endpoints?.Invoke(_server); - _server.Map("{*url}", (ILogger logger, string url) => - { - var safeUrl = url.Replace("\r", string.Empty).Replace("\n", string.Empty); - logger.LogInformation("route not handled: '{url}'", safeUrl); - }); _server.Start(); } - public Uri Uri => _testServer.BaseAddress; + public Uri Uri => _server.Services.GetRequiredService().Features.Get()!.Addresses + .Select(a => new Uri(a)).First(); // Method to get the mocked Kubernetes client public IKubernetes GetMockedKubernetesClient() @@ -58,8 +69,6 @@ public IKubernetes GetMockedKubernetesClient() public void Dispose() { - _server.StopAsync(); - _server.WaitForShutdown(); - _testServer.Dispose(); + _server.Dispose(); } } From 27a63c782df3431d076417158a0379300de499f0 Mon Sep 17 00:00:00 2001 From: Patrick Evers Date: Mon, 12 Jan 2026 15:16:07 +0100 Subject: [PATCH 7/8] Test --- src/K8sOperator.NET/EventWatcher.cs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/K8sOperator.NET/EventWatcher.cs b/src/K8sOperator.NET/EventWatcher.cs index e4966cc..9c73045 100644 --- a/src/K8sOperator.NET/EventWatcher.cs +++ b/src/K8sOperator.NET/EventWatcher.cs @@ -5,6 +5,7 @@ using K8sOperator.NET.Metadata; using K8sOperator.NET.Models; using Microsoft.Extensions.Logging; +using System.Text.Json; namespace K8sOperator.NET; @@ -61,11 +62,19 @@ public async Task Start(CancellationToken cancellationToken) await foreach (var (type, item) in Client.WatchAsync(LabelSelector, cancellationToken)) { - // Handle case where item might be JsonElement and needs conversion - T? resource = item is T typed ? typed : KubernetesJson.Deserialize(KubernetesJson.Serialize(item)); - if (resource is not null) + 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; } } } From 77f044ac9d0fa44624774be4517208c66aa6e7a9 Mon Sep 17 00:00:00 2001 From: Patrick Evers Date: Mon, 12 Jan 2026 15:29:56 +0100 Subject: [PATCH 8/8] Fix Test --- .../EventWatcherTests.cs | 70 ++++++++++++++++--- 1 file changed, 61 insertions(+), 9 deletions(-) diff --git a/test/K8sOperator.NET.Tests/EventWatcherTests.cs b/test/K8sOperator.NET.Tests/EventWatcherTests.cs index a70284b..82ef4db 100644 --- a/test/K8sOperator.NET.Tests/EventWatcherTests.cs +++ b/test/K8sOperator.NET.Tests/EventWatcherTests.cs @@ -83,7 +83,7 @@ public async Task Start_Should_StartWatchAndLogStart() using (var server = new MockKubeApiServer(_testOutput, endpoints => { - endpoints.MapListNamespacedCustomObjectWithHttpMessagesAsync(Added); + endpoints.MapWatchNamespacedCustomObjectAsync(Added); })) { var client = new NamespacedKubernetesClient(server.GetMockedKubernetesClient(), _loggerFactory.CreateLogger()); @@ -98,17 +98,35 @@ public async Task Start_Should_StartWatchAndLogStart() [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()); @@ -119,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()); @@ -142,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());