Skip to content
This repository was archived by the owner on Feb 6, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<!-- For more info on central package management go to https://devblogs.microsoft.com/nuget/introducing-central-package-management/ -->
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<!-- Core packages - Updated to latest secure versions -->
<PackageVersion Include="KubernetesClient" Version="18.0.13" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.1" />
<!-- Test packages -->
<PackageVersion Include="AwesomeAssertions" Version="9.3.0" />
<PackageVersion Include="NSubstitute" Version="5.3.0" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<!-- Test project specific - Update to .NET 10 compatible versions -->
<PackageVersion Include="Microsoft.AspNetCore" Version="2.2.0" />
<PackageVersion Include="Microsoft.AspNetCore.Hosting.Abstractions" Version="2.2.0" />
<PackageVersion Include="Microsoft.AspNetCore.TestHost" Version="10.0.1" />
<PackageVersion Include="System.Linq.Async" Version="7.0.0" />
<PackageVersion Include="System.Reactive" Version="6.1.0" />
<!-- Development tools -->
<PackageVersion Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.21.0" />
</ItemGroup>
</Project>
6 changes: 2 additions & 4 deletions K8sOperator.sln
Original file line number Diff line number Diff line change
@@ -1,22 +1,20 @@

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}"
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}"
Expand Down
2 changes: 1 addition & 1 deletion examples/SimpleOperator/SimpleOperator.csproj
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Company>pmdevers</Company>
Expand Down
2 changes: 1 addition & 1 deletion global.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"sdk": {
"version": "8.0.100",
"version": "10.0.100",
"rollForward": "latestFeature"
}
}
2 changes: 1 addition & 1 deletion src/Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<!-- See https://aka.ms/dotnet/msbuild/customize for more details on customizing your build -->
<Project>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
Expand Down
17 changes: 0 additions & 17 deletions src/Directory.Packages.props

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public static TBuilder WithRoleRef<TBuilder>(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;
Expand All @@ -44,7 +44,7 @@ public static TBuilder WithSubject<TBuilder>(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;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using k8s.Authentication;
using k8s.Models;
using k8s.Models;

namespace K8sOperator.NET.Generators.Builders;

Expand Down Expand Up @@ -164,13 +163,13 @@ public static TBuilder WithImage<TBuilder>(this TBuilder builder, string image)
/// <param name="requests">An action to configure resource requests.</param>
/// <returns>The configured builder.</returns>
public static TBuilder WithResources<TBuilder>(this TBuilder builder,
Action<IList<V1ResourceClaim>>? claims = null,
Action<IList<Corev1ResourceClaim>>? claims = null,
Action<IDictionary<string, ResourceQuantity>>? limits = null,
Action<IDictionary<string, ResourceQuantity>>? requests = null
)
where TBuilder : IKubernetesObjectBuilder<V1Container>
{
var c = new List<V1ResourceClaim>();
var c = new List<Corev1ResourceClaim>();
claims?.Invoke(c);
var l = new Dictionary<string, ResourceQuantity>();
limits?.Invoke(l);
Expand Down
82 changes: 51 additions & 31 deletions src/K8sOperator.NET/EventWatcher.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -32,12 +33,12 @@ public interface IEventWatcher
}

internal class EventWatcher<T>(IKubernetesClient client, Controller<T> controller, List<object> metadata, ILoggerFactory loggerfactory) : IEventWatcher
where T: CustomResource
where T : CustomResource
{
private KubernetesEntityAttribute Crd => Metadata.OfType<KubernetesEntityAttribute>().First();
private string LabelSelector => Metadata.OfType<ILabelSelectorMetadata>().FirstOrDefault()?.LabelSelector ?? string.Empty;
private string Finalizer => Metadata.OfType<IFinalizerMetadata>().FirstOrDefault()?.Finalizer ?? FinalizerAttribute.Default;

private readonly ChangeTracker _changeTracker = new();
private bool _isRunning;
private CancellationToken _cancellationToken = CancellationToken.None;
Expand All @@ -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<T>(LabelSelector, cancellationToken);
Logger.BeginWatch(Crd.PluralName, LabelSelector);

await foreach (var (type, item) in response.WatchAsync<T, object>(OnError, cancellationToken))
await foreach (var (type, item) in Client.WatchAsync<T>(LabelSelector, cancellationToken))
{
OnEvent(type, item);
if (item is JsonElement je)
{
var i = je.Deserialize<T>();
if (i is not null)
{
OnEvent(type, i);
continue;
}
}
else if (item is T resource)
{
OnEvent(type, resource);
continue;
}
}
Comment on lines +63 to 79
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Events silently dropped when item type is unexpected or deserialization fails.

If item is neither JsonElement nor T, or if JsonElement deserialization returns null, the event is silently dropped with no logging. This could mask data issues or unexpected API responses.

Consider adding a log statement or warning for unhandled cases.

Suggested improvement
                     if (item is JsonElement je)
                     {
                         var i = je.Deserialize<T>();
                         if (i is not null)
                         {
                             OnEvent(type, i);
                             continue;
                         }
+                        Logger.LogWarning("Failed to deserialize JsonElement to {Type}", typeof(T).Name);
                     }
                     else if (item is T resource)
                     {
                         OnEvent(type, resource);
                         continue;
                     }
+                    else
+                    {
+                        Logger.LogWarning("Unexpected item type received: {Type}", item?.GetType().Name ?? "null");
+                    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await foreach (var (type, item) in Client.WatchAsync<T>(LabelSelector, cancellationToken))
{
OnEvent(type, item);
if (item is JsonElement je)
{
var i = je.Deserialize<T>();
if (i is not null)
{
OnEvent(type, i);
continue;
}
}
else if (item is T resource)
{
OnEvent(type, resource);
continue;
}
}
await foreach (var (type, item) in Client.WatchAsync<T>(LabelSelector, cancellationToken))
{
if (item is JsonElement je)
{
var i = je.Deserialize<T>();
if (i is not null)
{
OnEvent(type, i);
}
else
{
Logger.LogWarning("Failed to deserialize JsonElement to {Type}", typeof(T).Name);
}
}
else if (item is T resource)
{
OnEvent(type, resource);
}
else
{
Logger.LogWarning("Unexpected item type received: {Type}", item?.GetType().Name ?? "null");
}
}
🤖 Prompt for AI Agents
In @src/K8sOperator.NET/EventWatcher.cs around lines 63 - 79, The watch loop
currently drops events when item is neither JsonElement nor T or when
JsonElement.Deserialize<T>() yields null; update the loop that processes
Client.WatchAsync<T>(LabelSelector, cancellationToken) to log a warning (using
the component's logger) whenever item is an unexpected type or when
deserialization of JsonElement returns null, include the event "type" and item
type/contents (or raw JsonElement) in the message so operators can diagnose bad
payloads, and only call OnEvent(type, ...) for the successful T cases.

}
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)
{
Expand All @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
}
}

4 changes: 2 additions & 2 deletions src/K8sOperator.NET/Extensions/LoggingExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

}
2 changes: 0 additions & 2 deletions src/K8sOperator.NET/K8sOperator.NET.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,8 @@

<ItemGroup>
<PackageReference Include="KubernetesClient" />
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="System.Text.Json" />
</ItemGroup>

<ItemGroup>
Expand Down
19 changes: 8 additions & 11 deletions src/K8sOperator.NET/KubernetesClient.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using k8s;
using k8s.Autorest;
using k8s.Models;
using K8sOperator.NET.Extensions;
using K8sOperator.NET.Models;
Expand All @@ -10,11 +9,11 @@ namespace K8sOperator.NET;

internal interface IKubernetesClient
{
Task<HttpOperationResponse<object>> ListAsync<T>(string labelSelector, CancellationToken cancellationToken)
IAsyncEnumerable<(WatchEventType, object)> WatchAsync<T>(string labelSelector, CancellationToken cancellationToken)
where T : CustomResource;
Task<T> ReplaceAsync<T>(T resource, CancellationToken cancellationToken)
where T : CustomResource;


}

Expand All @@ -24,18 +23,17 @@ internal class NamespacedKubernetesClient(IKubernetes client, ILogger<Namespaced
public ILogger Logger { get; } = logger;
public string Namespace { get; } = ns;

public Task<HttpOperationResponse<object>> ListAsync<T>(string labelSelector, CancellationToken cancellationToken) where T : CustomResource
public IAsyncEnumerable<(WatchEventType, object)> WatchAsync<T>(string labelSelector, CancellationToken cancellationToken) where T : CustomResource
{
var info = typeof(T).GetCustomAttribute<KubernetesEntityAttribute>()!;

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,
Expand Down Expand Up @@ -93,18 +91,17 @@ public async Task<T> ReplaceAsync<T>(T resource, CancellationToken cancellationT
return result;
}

public Task<HttpOperationResponse<object>> ListAsync<T>(string labelSelector, CancellationToken cancellationToken)
public IAsyncEnumerable<(WatchEventType, object)> WatchAsync<T>(string labelSelector, CancellationToken cancellationToken)
where T : CustomResource
{
var info = typeof(T).GetCustomAttribute<KubernetesEntityAttribute>()!;

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,
Expand Down
Loading
Loading