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
429 changes: 371 additions & 58 deletions README.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions examples/SimpleOperator/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,4 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
# Entrypoint
ENTRYPOINT ["dotnet", "SimpleOperator.dll"]
CMD ["operator"]

11 changes: 9 additions & 2 deletions examples/SimpleOperator/Program.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
using K8sOperator.NET;
using K8sOperator.NET.Generation;
using SimpleOperator.Controllers;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOperator();
builder.Services.AddOperator(x =>
{
//x.WithLeaderElection();

});

var app = builder.Build();

app.MapController<TodoController>();
app.MapController<TodoController>()
.WithNamespaceScope();
//.WithFinalizer("todo.example.com/finalizer");

await app.RunOperatorAsync();
20 changes: 14 additions & 6 deletions examples/SimpleOperator/Properties/launchSettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,32 @@
"dotnetRunMessages": true
},
"Operator": {
"commandName": "Project",
"commandLineArgs": "operator",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"dotnetRunMessages": true
},
"Install": {
"commandName": "Project",
"commandLineArgs": "operator",
"commandLineArgs": "install",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"dotnetRunMessages": true
},
"Install": {
"Version": {
"commandName": "Project",
"commandLineArgs": "install",
"commandLineArgs": "version",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"dotnetRunMessages": true
},
"Version": {
"Create": {
"commandName": "Project",
"commandLineArgs": "version",
"commandLineArgs": "create",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
Expand All @@ -50,4 +58,4 @@
}
},
"schema": "http://json.schemastore.org/launchsettings.json"
}
}
4 changes: 4 additions & 0 deletions examples/SimpleOperator/Resources/TodoItem.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
using k8s.Models;
using K8sOperator.NET;
using K8sOperator.NET.Metadata;

namespace SimpleOperator.Resources;

[KubernetesEntity(Group = "app.example.com", ApiVersion = "v1", Kind = "TodoItem", PluralName = "todoitems")]
[AdditionalPrinterColumn(Name = "Title", Description = "Todo Title", Path = ".spec.title", Type = "string")]
[AdditionalPrinterColumn(Name = "Description", Description = "Todo Description", Path = ".spec.description", Type = "string")]
[AdditionalPrinterColumn(Name = "State", Description = "Todo State", Path = ".status.state", Type = "string")]
public class TodoItem : CustomResource<TodoItem.TodoSpec, TodoItem.TodoStatus>
{
public class TodoSpec
Expand Down
2 changes: 2 additions & 0 deletions examples/SimpleOperator/SimpleOperator.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@
<PropertyGroup>
<OperatorName>simple-operator</OperatorName>
<OperatorNamespace>simple-system</OperatorNamespace>
<!--
<ContainerRegistry>ghcr.io</ContainerRegistry>
<ContainerRepository>simple-operator</ContainerRepository>
-->
<ContainerFamily>alpha</ContainerFamily>
</PropertyGroup>

Expand Down
2 changes: 2 additions & 0 deletions src/K8sOperator.NET/Builder/ControllerBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,5 @@ public IOperatorController Build()
}
public List<object> Metadata { get; } = [];
}


34 changes: 34 additions & 0 deletions src/K8sOperator.NET/Commands/CreateCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using k8s;
using K8sOperator.NET.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

namespace K8sOperator.NET.Commands;

[OperatorArgument("create", Description = "Creates a resource definition.", Order = 4)]
public class CreateCommand(IHost app) : IOperatorCommand
{
public Task RunAsync(string[] args)
{
if (args.Length < 2)
{
Console.WriteLine($"Please provide a resourcename.");
return Task.CompletedTask;
}

var datasource = app.Services.GetRequiredService<EventWatcherDatasource>();
var watchers = datasource.GetWatchers().ToList();
var watcher = watchers.FirstOrDefault(x => x.Controller.ResourceType.Name.Equals(args[1], StringComparison.CurrentCultureIgnoreCase));

if (watcher == null)
{
Console.WriteLine($"Unknown resource: {args[1]}");
return Task.CompletedTask;
}

var activator = Activator.CreateInstance(watcher.Controller.ResourceType) as CustomResource;
activator.Initialize();
Console.WriteLine(KubernetesYaml.Serialize(activator));
Comment on lines +29 to +31
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Null dereference risk when Activator.CreateInstance fails.

If the resource type lacks a parameterless constructor or doesn't derive from CustomResource, activator will be null, causing a NullReferenceException on line 33.

🐛 Proposed fix
-        var activator = Activator.CreateInstance(watcher.Controller.ResourceType) as CustomResource;
-        activator.Initialize();
-        Console.WriteLine(KubernetesYaml.Serialize(activator));
+        var resource = Activator.CreateInstance(watcher.Controller.ResourceType) as CustomResource;
+        if (resource == null)
+        {
+            Console.WriteLine($"Failed to create instance of {watcher.Controller.ResourceType.Name}. Ensure it has a parameterless constructor and derives from CustomResource.");
+            return Task.CompletedTask;
+        }
+        resource.Initialize();
+        Console.WriteLine(KubernetesYaml.Serialize(resource));
📝 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
var activator = Activator.CreateInstance(watcher.Controller.ResourceType) as CustomResource;
activator.Initialize();
Console.WriteLine(KubernetesYaml.Serialize(activator));
var resource = Activator.CreateInstance(watcher.Controller.ResourceType) as CustomResource;
if (resource == null)
{
Console.WriteLine($"Failed to create instance of {watcher.Controller.ResourceType.Name}. Ensure it has a parameterless constructor and derives from CustomResource.");
return Task.CompletedTask;
}
resource.Initialize();
Console.WriteLine(KubernetesYaml.Serialize(resource));
🤖 Prompt for AI Agents
In `@src/K8sOperator.NET/Commands/CreateCommand.cs` around lines 32 - 34, The code
calls Activator.CreateInstance(watcher.Controller.ResourceType) and immediately
uses activator (CustomResource) without null-check, which can throw a
NullReferenceException if creation fails or the type isn't a CustomResource;
update the CreateCommand logic to validate the result: check that activator is
not null and is a CustomResource before calling activator.Initialize() and
KubernetesYaml.Serialize, and if the check fails either throw a descriptive
exception or log an error indicating watcher.Controller.ResourceType could not
be instantiated or does not derive from CustomResource so callers can diagnose
the missing parameterless constructor or wrong type.

return Task.CompletedTask;
}
}
167 changes: 55 additions & 112 deletions src/K8sOperator.NET/Commands/GenerateDockerfileCommand.cs
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
using K8sOperator.NET.Builder;
using K8sOperator.NET.Builder;
using K8sOperator.NET.Metadata;
using Microsoft.Extensions.Hosting;
using System.Reflection;
using System.Text;
using System.Text.RegularExpressions;

namespace K8sOperator.NET.Commands;

[OperatorArgument("generate-dockerfile", Description = "Generates a Dockerfile for the operator", Order = 101, ShowInHelp = false)]
public class GenerateDockerfileCommand(IHost host) : IOperatorCommand
[OperatorArgument("generate-dockerfile", Description = "Generates a Dockerfile for the operator", Order = 101)]
public partial class GenerateDockerfileCommand(IHost host) : IOperatorCommand
{
private readonly IHost _host = host;

[GeneratedRegex(@"Version=v?(\d+\.\d+)", RegexOptions.Compiled)]
private static partial Regex VersionRegex();

public async Task RunAsync(string[] args)
{
var assembly = Assembly.GetEntryAssembly();
Expand All @@ -18,19 +23,29 @@ public async Task RunAsync(string[] args)
?? DockerImageAttribute.Default;

var projectName = AppDomain.CurrentDomain.FriendlyName.Replace(".dll", "");
var targetFramework = "net10.0"; // Can be made dynamic if needed

var dockerfile = GenerateDockerfileContent(projectName, targetFramework, operatorName);
// Get the .NET version from the assembly's target framework
var dotnetVersion = GetDotNetVersion(assembly);

// Read templates from embedded resources
var dockerfileContent = await ReadEmbeddedResourceAsync("Dockerfile.template");
var dockerignoreContent = await ReadEmbeddedResourceAsync(".dockerignore.template");

// Replace placeholders
dockerfileContent = dockerfileContent
.Replace("{PROJECT_NAME}", projectName)
.Replace("{DOTNET_VERSION}", dotnetVersion);

var dockerfilePath = Path.Combine(Directory.GetCurrentDirectory(), "Dockerfile");
await File.WriteAllTextAsync(dockerfilePath, dockerfile);
await File.WriteAllTextAsync(dockerfilePath, dockerfileContent);

var dockerignorePath = Path.Combine(Directory.GetCurrentDirectory(), ".dockerignore");
await File.WriteAllTextAsync(dockerignorePath, GenerateDockerignoreContent());
await File.WriteAllTextAsync(dockerignorePath, dockerignoreContent);

Console.WriteLine($" Generated Dockerfile at: {dockerfilePath}");
Console.WriteLine($" Generated .dockerignore at: {dockerignorePath}");
Console.WriteLine($"? Generated Dockerfile at: {dockerfilePath}");
Console.WriteLine($"? Generated .dockerignore at: {dockerignorePath}");
Console.WriteLine($" Operator: {operatorName}");
Console.WriteLine($" .NET Version: {dotnetVersion}");
Console.WriteLine($" Image: {dockerImage.Registry}/{dockerImage.Repository}:{dockerImage.Tag}");
Console.WriteLine();
Console.WriteLine("To build the image:");
Expand All @@ -40,111 +55,39 @@ public async Task RunAsync(string[] args)
Console.WriteLine($" docker push {dockerImage.Registry}/{dockerImage.Repository}:{dockerImage.Tag}");
}

private static string GenerateDockerfileContent(string projectName, string targetFramework, string operatorName)
private static string GetDotNetVersion(Assembly? assembly)
{
var sb = new StringBuilder();

sb.AppendLine("# Build stage");
sb.AppendLine("FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build");
sb.AppendLine("WORKDIR /src");
sb.AppendLine();
sb.AppendLine("# Copy project file and restore dependencies");
sb.AppendLine($"COPY [\"{projectName}.csproj\", \"./\"]");
sb.AppendLine("RUN dotnet restore");
sb.AppendLine();
sb.AppendLine("# Copy source code and build");
sb.AppendLine("COPY . .");
sb.AppendLine("RUN dotnet build -c Release -o /app/build");
sb.AppendLine();
sb.AppendLine("# Publish stage");
sb.AppendLine("FROM build AS publish");
sb.AppendLine("RUN dotnet publish -c Release -o /app/publish /p:UseAppHost=false");
sb.AppendLine();
sb.AppendLine("# Runtime stage");
sb.AppendLine("FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final");
sb.AppendLine("WORKDIR /app");
sb.AppendLine();
sb.AppendLine("# Create non-root user");
sb.AppendLine("RUN groupadd -r operator && useradd -r -g operator operator");
sb.AppendLine();
sb.AppendLine("# Copy published app");
sb.AppendLine("COPY --from=publish /app/publish .");
sb.AppendLine();
sb.AppendLine("# Set ownership");
sb.AppendLine("RUN chown -R operator:operator /app");
sb.AppendLine();
sb.AppendLine("# Switch to non-root user");
sb.AppendLine("USER operator");
sb.AppendLine();
sb.AppendLine("# Set environment variables");
sb.AppendLine("ENV ASPNETCORE_ENVIRONMENT=Production \\");
sb.AppendLine(" DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false \\");
sb.AppendLine(" DOTNET_EnableDiagnostics=0");
sb.AppendLine();
sb.AppendLine("# Health check");
sb.AppendLine("HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \\");
sb.AppendLine($" CMD dotnet {projectName}.dll version || exit 1");
sb.AppendLine();
sb.AppendLine("# Entrypoint");
sb.AppendLine($"ENTRYPOINT [\"dotnet\", \"{projectName}.dll\"]");
sb.AppendLine("CMD [\"operator\"]");

return sb.ToString();
if (assembly == null)
return "10.0"; // Default fallback

// Get the target framework attribute
var targetFrameworkAttr = assembly.GetCustomAttribute<System.Runtime.Versioning.TargetFrameworkAttribute>();

if (targetFrameworkAttr == null)
return "10.0"; // Default fallback

// Extract version from framework name (e.g., ".NETCoreApp,Version=v10.0" -> "10.0")
var frameworkName = targetFrameworkAttr.FrameworkName;

// Try to parse the version using the generated regex
var versionMatch = VersionRegex().Match(frameworkName);

if (versionMatch.Success)
{
return versionMatch.Groups[1].Value;
}

return "10.0"; // Default fallback
}

private static string GenerateDockerignoreContent()
private static async Task<string> ReadEmbeddedResourceAsync(string resourceName)
{
var sb = new StringBuilder();

sb.AppendLine("# Git");
sb.AppendLine(".git");
sb.AppendLine(".gitignore");
sb.AppendLine(".gitattributes");
sb.AppendLine();
sb.AppendLine("# Build results");
sb.AppendLine("bin/");
sb.AppendLine("obj/");
sb.AppendLine("[Bb]uild/");
sb.AppendLine("[Dd]ebug/");
sb.AppendLine("[Rr]elease/");
sb.AppendLine();
sb.AppendLine("# Visual Studio");
sb.AppendLine(".vs/");
sb.AppendLine(".vscode/");
sb.AppendLine("*.user");
sb.AppendLine("*.suo");
sb.AppendLine("*.userosscache");
sb.AppendLine("*.sln.docstates");
sb.AppendLine();
sb.AppendLine("# Test results");
sb.AppendLine("[Tt]est[Rr]esult*/");
sb.AppendLine("[Bb]uild[Ll]og.*");
sb.AppendLine("TestResults/");
sb.AppendLine();
sb.AppendLine("# NuGet");
sb.AppendLine("*.nupkg");
sb.AppendLine("*.snupkg");
sb.AppendLine("packages/");
sb.AppendLine();
sb.AppendLine("# Docker");
sb.AppendLine("Dockerfile");
sb.AppendLine(".dockerignore");
sb.AppendLine();
sb.AppendLine("# Kubernetes");
sb.AppendLine("*.yaml");
sb.AppendLine("*.yml");
sb.AppendLine();
sb.AppendLine("# Documentation");
sb.AppendLine("*.md");
sb.AppendLine("README*");
sb.AppendLine("LICENSE");
sb.AppendLine();
sb.AppendLine("# IDE");
sb.AppendLine(".idea/");
sb.AppendLine("*.swp");
sb.AppendLine("*.swo");
sb.AppendLine("*~");

return sb.ToString();
var assembly = typeof(GenerateDockerfileCommand).Assembly;
var fullResourceName = $"K8sOperator.NET.Templates.{resourceName}";

await using var stream = assembly.GetManifestResourceStream(fullResourceName)
?? throw new InvalidOperationException($"Could not find embedded resource: {fullResourceName}");
using var reader = new StreamReader(stream);
return await reader.ReadToEndAsync();
}
}
4 changes: 2 additions & 2 deletions src/K8sOperator.NET/Commands/GenerateLaunchSettingsCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public async Task RunAsync(string[] args)
schema = "http://json.schemastore.org/launchsettings.json"
};

var json = JsonSerializer.Serialize(launchSettings, new JsonSerializerOptions
string json = JsonSerializer.Serialize(launchSettings, new JsonSerializerOptions()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Expand Down Expand Up @@ -74,7 +74,7 @@ private static string ToPascalCase(string input)
{
sb.Append(char.ToUpper(part[0]));
if (part.Length > 1)
sb.Append(part.Substring(1).ToLower());
sb.Append(part[1..].ToLower());
}
}

Expand Down
Loading
Loading