From 3f9e4779d1a8bc95e814adef3ff338948fc5ab80 Mon Sep 17 00:00:00 2001 From: Patrick Evers Date: Fri, 23 Jan 2026 15:57:41 +0100 Subject: [PATCH] Introduces build tasks for code generation Moves code generation logic to MSBuild tasks, which improves the build process and allows for more flexibility in customizing the generated output. Removes the commands for generating launch settings and Dockerfile, replacing them with MSBuild tasks that automatically generate these files during the build process. Adds a configuration provider to centralize and prioritize operator configuration from assembly attributes, appsettings.json, and the OperatorBuilder, enabling easier customization. Updates EventWatcher and commands to use the new OperatorConfiguration for settings like operator name, image, and namespace. Adds new build tasks project and templates. Fixes #2 --- K8sOperator.NET.slnx | 1 + examples/SimpleOperator/Program.cs | 3 +- .../Properties/launchSettings.json | 36 +- examples/SimpleOperator/SimpleOperator.csproj | 6 +- .../GenerateDockerfileTask.cs | 110 ++++++ .../GenerateLaunchSettingsTask.cs | 62 ++++ .../K8sOperator.NET.BuildTasks.csproj | 24 ++ .../Templates/.dockerignore.template | 0 .../Templates/Dockerfile.template | 0 .../Templates/launchSettings.json.template | 37 ++ .../Builder/ControllerBuilder.cs | 15 +- .../Builder/EventWatcherBuilder.cs | 12 +- .../Commands/GenerateDockerfileCommand.cs | 93 ----- .../Commands/GenerateLaunchSettingsCommand.cs | 83 ----- .../Commands/InstallCommand.cs | 66 ++-- .../Commands/VersionCommand.cs | 14 +- .../Configuration/OperatorConfiguration.cs | 99 ++++++ .../OperatorConfigurationProvider.cs | 83 +++++ src/K8sOperator.NET/EventWatcher.cs | 12 +- src/K8sOperator.NET/EventWatcherDatasource.cs | 11 +- src/K8sOperator.NET/K8sOperator.NET.csproj | 37 +- .../Metadata/AdditionalPrinterColumn.cs | 10 +- src/K8sOperator.NET/Operator.targets | 44 ++- src/K8sOperator.NET/OperatorBuilder.cs | 35 ++ src/K8sOperator.NET/OperatorExtensions.cs | 99 +++--- .../EventWatcherDatasource_Tests.cs | 106 +++--- .../EventWatcher_Tests.cs | 11 +- .../OperatorConfiguration_Tests.cs | 318 ++++++++++++++++++ 28 files changed, 1005 insertions(+), 422 deletions(-) create mode 100644 src/K8sOperator.NET.BuildTasks/GenerateDockerfileTask.cs create mode 100644 src/K8sOperator.NET.BuildTasks/GenerateLaunchSettingsTask.cs create mode 100644 src/K8sOperator.NET.BuildTasks/K8sOperator.NET.BuildTasks.csproj rename src/{K8sOperator.NET => K8sOperator.NET.BuildTasks}/Templates/.dockerignore.template (100%) rename src/{K8sOperator.NET => K8sOperator.NET.BuildTasks}/Templates/Dockerfile.template (100%) create mode 100644 src/K8sOperator.NET.BuildTasks/Templates/launchSettings.json.template delete mode 100644 src/K8sOperator.NET/Commands/GenerateDockerfileCommand.cs delete mode 100644 src/K8sOperator.NET/Commands/GenerateLaunchSettingsCommand.cs create mode 100644 src/K8sOperator.NET/Configuration/OperatorConfiguration.cs create mode 100644 src/K8sOperator.NET/Configuration/OperatorConfigurationProvider.cs create mode 100644 src/K8sOperator.NET/OperatorBuilder.cs create mode 100644 test/K8sOperator.NET.Tests/OperatorConfiguration_Tests.cs diff --git a/K8sOperator.NET.slnx b/K8sOperator.NET.slnx index e172296..1ceea1e 100644 --- a/K8sOperator.NET.slnx +++ b/K8sOperator.NET.slnx @@ -3,6 +3,7 @@ + diff --git a/examples/SimpleOperator/Program.cs b/examples/SimpleOperator/Program.cs index 6d80422..c57ba4e 100644 --- a/examples/SimpleOperator/Program.cs +++ b/examples/SimpleOperator/Program.cs @@ -6,8 +6,7 @@ builder.Services.AddOperator(x => { - //x.WithLeaderElection(); - + x.WithLeaderElection(); }); var app = builder.Build(); diff --git a/examples/SimpleOperator/Properties/launchSettings.json b/examples/SimpleOperator/Properties/launchSettings.json index bda3e33..cdcab8f 100644 --- a/examples/SimpleOperator/Properties/launchSettings.json +++ b/examples/SimpleOperator/Properties/launchSettings.json @@ -1,24 +1,16 @@ { "profiles": { - "Help": { + "Operator": { "commandName": "Project", - "commandLineArgs": "help", + "commandLineArgs": "operator", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }, "dotnetRunMessages": true }, - "Operator": { - "commandName": "Project", - "commandLineArgs": "operator", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - }, - "dotnetRunMessages": true - }, "Install": { "commandName": "Project", - "commandLineArgs": "install", + "commandLineArgs": "install > install.yaml", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }, @@ -32,30 +24,14 @@ }, "dotnetRunMessages": true }, - "Create": { - "commandName": "Project", - "commandLineArgs": "create", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - }, - "dotnetRunMessages": true - }, - "GenerateLaunchsettings": { - "commandName": "Project", - "commandLineArgs": "generate-launchsettings", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - }, - "dotnetRunMessages": true - }, - "GenerateDockerfile": { + "Help": { "commandName": "Project", - "commandLineArgs": "generate-dockerfile", + "commandLineArgs": "help", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }, "dotnetRunMessages": true } }, - "schema": "http://json.schemastore.org/launchsettings.json" + "$schema": "http://json.schemastore.org/launchsettings.json" } diff --git a/examples/SimpleOperator/SimpleOperator.csproj b/examples/SimpleOperator/SimpleOperator.csproj index 712f7b1..2bea637 100644 --- a/examples/SimpleOperator/SimpleOperator.csproj +++ b/examples/SimpleOperator/SimpleOperator.csproj @@ -16,10 +16,10 @@ simple-operator simple-system - + alpha diff --git a/src/K8sOperator.NET.BuildTasks/GenerateDockerfileTask.cs b/src/K8sOperator.NET.BuildTasks/GenerateDockerfileTask.cs new file mode 100644 index 0000000..0afc061 --- /dev/null +++ b/src/K8sOperator.NET.BuildTasks/GenerateDockerfileTask.cs @@ -0,0 +1,110 @@ +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; +using System; +using System.IO; +using System.Text.RegularExpressions; + +namespace K8sOperator.NET.BuildTasks; + +public class GenerateDockerfileTask : Task +{ + [Required] + public string ProjectDirectory { get; set; } = string.Empty; + + [Required] + public string ProjectName { get; set; } = string.Empty; + + [Required] + public string TargetFramework { get; set; } = string.Empty; + + [Required] + public string OperatorName { get; set; } = string.Empty; + + [Required] + public string ContainerRegistry { get; set; } = string.Empty; + + [Required] + public string ContainerRepository { get; set; } = string.Empty; + + [Required] + public string ContainerTag { get; set; } = string.Empty; + + public override bool Execute() + { + try + { + var dockerfilePath = Path.Combine(ProjectDirectory, "Dockerfile"); + var dockerignorePath = Path.Combine(ProjectDirectory, ".dockerignore"); + + // Extract .NET version from TargetFramework + var dotnetVersion = ExtractDotNetVersion(TargetFramework); + + // Read templates from embedded resources + var dockerfileContent = ReadEmbeddedResource("Dockerfile.template"); + var dockerignoreContent = ReadEmbeddedResource(".dockerignore.template"); + + // Replace placeholders + dockerfileContent = dockerfileContent + .Replace("{PROJECT_NAME}", ProjectName) + .Replace("{DOTNET_VERSION}", dotnetVersion); + + if (!File.Exists(dockerfilePath)) + { + File.WriteAllText(dockerfilePath, dockerfileContent); + + Log.LogMessage(MessageImportance.High, $"Generated Dockerfile at: {dockerfilePath}"); + + } + + if (!File.Exists(dockerignorePath)) + { + File.WriteAllText(dockerignorePath, dockerignoreContent); + + Log.LogMessage(MessageImportance.High, $"Generated .dockerignore at: {dockerignorePath}"); + } + + // Log success + Log.LogMessage(MessageImportance.High, $"Operator: {OperatorName}"); + Log.LogMessage(MessageImportance.High, $" .NET Version: {dotnetVersion}"); + Log.LogMessage(MessageImportance.High, $" Image: {ContainerRegistry}/{ContainerRepository}:{ContainerTag}"); + Log.LogMessage(MessageImportance.High, ""); + Log.LogMessage(MessageImportance.High, "To build the image:"); + Log.LogMessage(MessageImportance.High, $" docker build -t {ContainerRegistry}/{ContainerRepository}:{ContainerTag} ."); + Log.LogMessage(MessageImportance.High, ""); + Log.LogMessage(MessageImportance.High, "To push the image:"); + Log.LogMessage(MessageImportance.High, $" docker push {ContainerRegistry}/{ContainerRepository}:{ContainerTag}"); + Log.LogMessage(MessageImportance.High, ""); + + return true; + } + catch (Exception ex) + { + Log.LogError($"Failed to generate Dockerfile: {ex.Message}"); + return false; + } + } + + private static string ExtractDotNetVersion(string targetFramework) + { + // Handle formats like "net10.0", "net8.0", "netcoreapp3.1" + var match = Regex.Match(targetFramework, @"net(?:coreapp)?(\d+\.\d+)"); + if (match.Success) + { + return match.Groups[1].Value; + } + + // Fallback + return "10.0"; + } + + private static string ReadEmbeddedResource(string resourceName) + { + var assembly = typeof(GenerateDockerfileTask).Assembly; + var fullResourceName = $"K8sOperator.NET.BuildTasks.Templates.{resourceName}"; + + using var stream = assembly.GetManifestResourceStream(fullResourceName) + ?? throw new InvalidOperationException($"Could not find embedded resource: {fullResourceName}"); + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } +} diff --git a/src/K8sOperator.NET.BuildTasks/GenerateLaunchSettingsTask.cs b/src/K8sOperator.NET.BuildTasks/GenerateLaunchSettingsTask.cs new file mode 100644 index 0000000..536310f --- /dev/null +++ b/src/K8sOperator.NET.BuildTasks/GenerateLaunchSettingsTask.cs @@ -0,0 +1,62 @@ +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; +using System; +using System.IO; + +namespace K8sOperator.NET.BuildTasks; + +public class GenerateLaunchSettingsTask : Task +{ + [Required] + public string ProjectDirectory { get; set; } = string.Empty; + + public override bool Execute() + { + try + { + var propertiesDir = Path.Combine(ProjectDirectory, "Properties"); + var launchSettingsPath = Path.Combine(propertiesDir, "launchSettings.json"); + + if (File.Exists(launchSettingsPath)) + { + Log.LogMessage(MessageImportance.Normal, $"launchSettings.json already exists at {launchSettingsPath}, skipping generation"); + return true; + } + + // Read template from embedded resources + var launchSettingsContent = ReadEmbeddedResource("launchSettings.json.template"); + + // Create Properties directory if needed + Directory.CreateDirectory(propertiesDir); + + // Write launchSettings.json + File.WriteAllText(launchSettingsPath, launchSettingsContent); + + // Log success + Log.LogMessage(MessageImportance.High, ""); + Log.LogMessage(MessageImportance.High, $"Generated launchSettings.json at: {launchSettingsPath}"); + Log.LogMessage(MessageImportance.High, $" Profiles: Operator, Install, Version, Help"); + Log.LogMessage(MessageImportance.High, ""); + + return true; + } + catch (Exception ex) + { + Log.LogError($"Failed to generate launchSettings.json: {ex.Message}"); + return false; + } + } + + private static string ReadEmbeddedResource(string resourceName) + { + var assembly = typeof(GenerateLaunchSettingsTask).Assembly; + var fullResourceName = $"K8sOperator.NET.BuildTasks.Templates.{resourceName}"; + + using var stream = assembly.GetManifestResourceStream(fullResourceName) + ?? throw new InvalidOperationException($"Could not find embedded resource: {fullResourceName}"); + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } +} + + diff --git a/src/K8sOperator.NET.BuildTasks/K8sOperator.NET.BuildTasks.csproj b/src/K8sOperator.NET.BuildTasks/K8sOperator.NET.BuildTasks.csproj new file mode 100644 index 0000000..8f501c7 --- /dev/null +++ b/src/K8sOperator.NET.BuildTasks/K8sOperator.NET.BuildTasks.csproj @@ -0,0 +1,24 @@ + + + + netstandard2.0 + latest + enable + false + true + + + + + + + + + + + + + + + + diff --git a/src/K8sOperator.NET/Templates/.dockerignore.template b/src/K8sOperator.NET.BuildTasks/Templates/.dockerignore.template similarity index 100% rename from src/K8sOperator.NET/Templates/.dockerignore.template rename to src/K8sOperator.NET.BuildTasks/Templates/.dockerignore.template diff --git a/src/K8sOperator.NET/Templates/Dockerfile.template b/src/K8sOperator.NET.BuildTasks/Templates/Dockerfile.template similarity index 100% rename from src/K8sOperator.NET/Templates/Dockerfile.template rename to src/K8sOperator.NET.BuildTasks/Templates/Dockerfile.template diff --git a/src/K8sOperator.NET.BuildTasks/Templates/launchSettings.json.template b/src/K8sOperator.NET.BuildTasks/Templates/launchSettings.json.template new file mode 100644 index 0000000..cdcab8f --- /dev/null +++ b/src/K8sOperator.NET.BuildTasks/Templates/launchSettings.json.template @@ -0,0 +1,37 @@ +{ + "profiles": { + "Operator": { + "commandName": "Project", + "commandLineArgs": "operator", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true + }, + "Install": { + "commandName": "Project", + "commandLineArgs": "install > install.yaml", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true + }, + "Version": { + "commandName": "Project", + "commandLineArgs": "version", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true + }, + "Help": { + "commandName": "Project", + "commandLineArgs": "help", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true + } + }, + "$schema": "http://json.schemastore.org/launchsettings.json" +} diff --git a/src/K8sOperator.NET/Builder/ControllerBuilder.cs b/src/K8sOperator.NET/Builder/ControllerBuilder.cs index 4c04173..e5a6b3d 100644 --- a/src/K8sOperator.NET/Builder/ControllerBuilder.cs +++ b/src/K8sOperator.NET/Builder/ControllerBuilder.cs @@ -1,20 +1,22 @@ -using Microsoft.Extensions.DependencyInjection; +using K8sOperator.NET.Configuration; +using Microsoft.Extensions.DependencyInjection; namespace K8sOperator.NET.Builder; public class ControllerBuilder { - private ControllerBuilder(IServiceProvider serviceProvider, Type controllerType, List metadata) + private ControllerBuilder(IServiceProvider serviceProvider, Type controllerType, OperatorConfiguration configuration) { ServiceProvider = serviceProvider; ControllerType = controllerType; - Metadata = metadata; + Configuration = configuration; } public IServiceProvider ServiceProvider { get; } public Type ControllerType { get; set; } + public OperatorConfiguration Configuration { get; } - public static ControllerBuilder Create(IServiceProvider serviceProvider, Type controllerType, List metadata) - => new(serviceProvider, controllerType, metadata); + public static ControllerBuilder Create(IServiceProvider serviceProvider, Type controllerType, OperatorConfiguration configuration) + => new(serviceProvider, controllerType, configuration); public IOperatorController Build() { @@ -30,3 +32,6 @@ public IOperatorController Build() } + + + diff --git a/src/K8sOperator.NET/Builder/EventWatcherBuilder.cs b/src/K8sOperator.NET/Builder/EventWatcherBuilder.cs index 1180f77..f044ea4 100644 --- a/src/K8sOperator.NET/Builder/EventWatcherBuilder.cs +++ b/src/K8sOperator.NET/Builder/EventWatcherBuilder.cs @@ -1,4 +1,5 @@ -using Microsoft.Extensions.DependencyInjection; +using K8sOperator.NET.Configuration; +using Microsoft.Extensions.DependencyInjection; namespace K8sOperator.NET.Builder; @@ -6,24 +7,27 @@ public class EventWatcherBuilder { public IServiceProvider ServiceProvider { get; } public IOperatorController Controller { get; } + public OperatorConfiguration Configuration { get; } public List Metadata { get; } private EventWatcherBuilder(IServiceProvider serviceProvider, + OperatorConfiguration configuration, IOperatorController controller, List metadata) { ServiceProvider = serviceProvider; + Configuration = configuration; Controller = controller; Metadata = metadata; } - public static EventWatcherBuilder Create(IServiceProvider serviceProvider, IOperatorController controller, List metadata) - => new(serviceProvider, controller, metadata); + public static EventWatcherBuilder Create(IServiceProvider serviceProvider, OperatorConfiguration configuration, IOperatorController controller, List metadata) + => new(serviceProvider, configuration, controller, metadata); public IEventWatcher Build() { var watcherType = typeof(EventWatcher<>).MakeGenericType(Controller.ResourceType); - return (IEventWatcher)ActivatorUtilities.CreateInstance(ServiceProvider, watcherType, Controller, Metadata); + return (IEventWatcher)ActivatorUtilities.CreateInstance(ServiceProvider, watcherType, Configuration, Controller, Metadata); } } diff --git a/src/K8sOperator.NET/Commands/GenerateDockerfileCommand.cs b/src/K8sOperator.NET/Commands/GenerateDockerfileCommand.cs deleted file mode 100644 index a0c8675..0000000 --- a/src/K8sOperator.NET/Commands/GenerateDockerfileCommand.cs +++ /dev/null @@ -1,93 +0,0 @@ -using K8sOperator.NET.Builder; -using K8sOperator.NET.Metadata; -using Microsoft.Extensions.Hosting; -using System.Reflection; -using System.Text.RegularExpressions; - -namespace K8sOperator.NET.Commands; - -[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(); - var operatorName = assembly?.GetCustomAttribute()?.OperatorName - ?? OperatorNameAttribute.Default.OperatorName; - var dockerImage = assembly?.GetCustomAttribute() - ?? DockerImageAttribute.Default; - - var projectName = AppDomain.CurrentDomain.FriendlyName.Replace(".dll", ""); - - // 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, dockerfileContent); - - var dockerignorePath = Path.Combine(Directory.GetCurrentDirectory(), ".dockerignore"); - await File.WriteAllTextAsync(dockerignorePath, dockerignoreContent); - - 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:"); - Console.WriteLine($" docker build -t {dockerImage.Registry}/{dockerImage.Repository}:{dockerImage.Tag} ."); - Console.WriteLine(); - Console.WriteLine("To push the image:"); - Console.WriteLine($" docker push {dockerImage.Registry}/{dockerImage.Repository}:{dockerImage.Tag}"); - } - - private static string GetDotNetVersion(Assembly? assembly) - { - if (assembly == null) - return "10.0"; // Default fallback - - // Get the target framework attribute - var targetFrameworkAttr = assembly.GetCustomAttribute(); - - 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 async Task ReadEmbeddedResourceAsync(string resourceName) - { - 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(); - } -} diff --git a/src/K8sOperator.NET/Commands/GenerateLaunchSettingsCommand.cs b/src/K8sOperator.NET/Commands/GenerateLaunchSettingsCommand.cs deleted file mode 100644 index 2ab43bc..0000000 --- a/src/K8sOperator.NET/Commands/GenerateLaunchSettingsCommand.cs +++ /dev/null @@ -1,83 +0,0 @@ -using K8sOperator.NET.Builder; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using System.Text; -using System.Text.Json; - -namespace K8sOperator.NET.Commands; - -[OperatorArgument("generate-launchsettings", Description = "Generates launchSettings.json based on registered commands", Order = 100, ShowInHelp = false)] -public class GenerateLaunchSettingsCommand(IHost host) : IOperatorCommand -{ - public async Task RunAsync(string[] args) - { - var commandDatasource = host.Services.GetRequiredService(); - var commands = commandDatasource.GetCommands(host); - - var launchSettings = new - { - profiles = commands - .Where(c => c.Metadata.OfType().Any()) - .ToDictionary( - c => ToPascalCase(c.Metadata.OfType().First().Argument), - c => new - { - commandName = "Project", - commandLineArgs = c.Metadata.OfType().First().Argument, - environmentVariables = new Dictionary - { - ["ASPNETCORE_ENVIRONMENT"] = "Development" - }, - dotnetRunMessages = true - } - ), - schema = "http://json.schemastore.org/launchsettings.json" - }; - - string json = JsonSerializer.Serialize(launchSettings, new JsonSerializerOptions() - { - WriteIndented = true, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DictionaryKeyPolicy = null // Don't change dictionary keys - }); - - var propertiesDir = Path.Combine(Directory.GetCurrentDirectory(), "Properties"); - Directory.CreateDirectory(propertiesDir); - - var launchSettingsPath = Path.Combine(propertiesDir, "launchSettings.json"); - await File.WriteAllTextAsync(launchSettingsPath, json); - - Console.WriteLine($"✅ Generated launchSettings.json at: {launchSettingsPath}"); - Console.WriteLine($" Found {commands.Count()} command(s):"); - - foreach (var cmd in commands) - { - var arg = cmd.Metadata.OfType().FirstOrDefault(); - if (arg != null) - { - Console.WriteLine($" - {arg.Argument}"); - } - } - } - - private static string ToPascalCase(string input) - { - if (string.IsNullOrEmpty(input)) - return input; - - var parts = input.Split('-', '_'); - var sb = new StringBuilder(); - - foreach (var part in parts) - { - if (!string.IsNullOrEmpty(part)) - { - sb.Append(char.ToUpper(part[0])); - if (part.Length > 1) - sb.Append(part[1..].ToLower()); - } - } - - return sb.ToString(); - } -} diff --git a/src/K8sOperator.NET/Commands/InstallCommand.cs b/src/K8sOperator.NET/Commands/InstallCommand.cs index 8fd5d9e..ee693f8 100644 --- a/src/K8sOperator.NET/Commands/InstallCommand.cs +++ b/src/K8sOperator.NET/Commands/InstallCommand.cs @@ -2,6 +2,7 @@ using k8s.Models; using K8sOperator.NET; using K8sOperator.NET.Builder; +using K8sOperator.NET.Configuration; using K8sOperator.NET.Generation; using K8sOperator.NET.Metadata; using Microsoft.Extensions.DependencyInjection; @@ -17,11 +18,12 @@ public class InstallCommand(IHost app) : IOperatorCommand public async Task RunAsync(string[] args) { var dataSource = app.Services.GetRequiredService(); + var config = app.Services.GetRequiredService(); var watchers = dataSource.GetWatchers().ToList(); - var ns = CreateNamespace(dataSource.Metadata); - var clusterrole = CreateClusterRole(dataSource.Metadata, watchers); - var clusterrolebinding = CreateClusterRoleBinding(dataSource.Metadata); - var deployment = CreateDeployment(dataSource.Metadata); + var ns = CreateNamespace(config); + var clusterrole = CreateClusterRole(config, watchers); + var clusterrolebinding = CreateClusterRoleBinding(config); + var deployment = CreateDeployment(config); foreach (var item in watchers) { @@ -44,13 +46,10 @@ private async Task Write(IKubernetesObject obj) await _output.WriteLineAsync("---"); } - private static V1Namespace CreateNamespace(IReadOnlyList metadata) + private static V1Namespace CreateNamespace(OperatorConfiguration config) { - var ns = metadata.OfType().FirstOrDefault() - ?? NamespaceAttribute.Default; - var nsBuilder = KubernetesObjectBuilder.Create(); - nsBuilder.WithName(ns.Namespace); + nsBuilder.WithName(config.Namespace); return nsBuilder.Build(); } @@ -89,33 +88,27 @@ private static V1CustomResourceDefinition CreateCustomResourceDefinition(IEventW }); + return crdBuilder.Build(); } - private static V1Deployment CreateDeployment(IReadOnlyList metadata) + private static V1Deployment CreateDeployment(OperatorConfiguration config) { - var name = metadata.OfType().FirstOrDefault() - ?? OperatorNameAttribute.Default; - var image = metadata.OfType().FirstOrDefault() - ?? DockerImageAttribute.Default; - var ns = metadata.OfType().FirstOrDefault() - ?? NamespaceAttribute.Default; - var deployment = KubernetesObjectBuilder.Create(); deployment - .WithName($"{name.OperatorName}") - .WithNamespace(ns.Namespace) - .WithLabel("operator", name.OperatorName) + .WithName($"{config.OperatorName}") + .WithNamespace(config.Namespace) + .WithLabel("operator", config.OperatorName) .WithSpec() .WithReplicas(1) .WithRevisionHistory(0) .WithSelector(matchLabels: x => { - x.Add("operator", name.OperatorName); + x.Add("operator", config.OperatorName); }) .WithTemplate() - .WithLabel("operator", name.OperatorName) + .WithLabel("operator", config.OperatorName) .WithPod() .WithSecurityContext(b => @@ -138,8 +131,8 @@ private static V1Deployment CreateDeployment(IReadOnlyList metadata) x.RunAsGroup(2024); x.WithCapabilities(x => x.WithDrop("ALL")); }) - .WithName(name.OperatorName) - .WithImage(image.GetImage()) + .WithName(config.OperatorName) + .WithImage(config.ContainerImage) .WithResources( limits: x => { @@ -149,6 +142,8 @@ private static V1Deployment CreateDeployment(IReadOnlyList metadata) requests: x => { x.Add("cpu", new ResourceQuantity("100m")); + + x.Add("memory", new ResourceQuantity("64Mi")); } ); @@ -156,29 +151,20 @@ private static V1Deployment CreateDeployment(IReadOnlyList metadata) return deployment.Build(); } - private static V1ClusterRoleBinding CreateClusterRoleBinding(IReadOnlyList metadata) + private static V1ClusterRoleBinding CreateClusterRoleBinding(OperatorConfiguration config) { - var name = metadata.OfType().FirstOrDefault() - ?? OperatorNameAttribute.Default; - - var ns = metadata.OfType().FirstOrDefault() - ?? NamespaceAttribute.Default; - var clusterrolebinding = KubernetesObjectBuilder.Create() - .WithName($"{name.OperatorName}-role-binding") - .WithRoleRef("rbac.authorization.k8s.io", "ClusterRole", $"{name.OperatorName}-role") - .WithSubject(kind: "ServiceAccount", name: "default", ns: ns.Namespace); + .WithName($"{config.OperatorName}-role-binding") + .WithRoleRef("rbac.authorization.k8s.io", "ClusterRole", $"{config.OperatorName}-role") + .WithSubject(kind: "ServiceAccount", name: "default", ns: config.Namespace); return clusterrolebinding.Build(); } - private static V1ClusterRole CreateClusterRole(IReadOnlyList metadata, IEnumerable watchers) + private static V1ClusterRole CreateClusterRole(OperatorConfiguration config, IEnumerable watchers) { - var name = metadata.OfType().FirstOrDefault() - ?? OperatorNameAttribute.Default; - var clusterrole = KubernetesObjectBuilder.Create() - .WithName($"{name.OperatorName}-role"); + .WithName($"{config.OperatorName}-role"); clusterrole.AddRule() .WithGroups("") @@ -188,7 +174,7 @@ private static V1ClusterRole CreateClusterRole(IReadOnlyList metadata, I clusterrole.AddRule() .WithGroups("coordination.k8s.io") .WithResources("leases") - .WithVerbs("*"); + .WithVerbs("create", "update", "get"); var rules = watchers .Select(x => x.Metadata.OfType().First()) diff --git a/src/K8sOperator.NET/Commands/VersionCommand.cs b/src/K8sOperator.NET/Commands/VersionCommand.cs index c1c4ea6..8a793db 100644 --- a/src/K8sOperator.NET/Commands/VersionCommand.cs +++ b/src/K8sOperator.NET/Commands/VersionCommand.cs @@ -1,5 +1,6 @@ using K8sOperator.NET; using K8sOperator.NET.Builder; +using K8sOperator.NET.Configuration; using K8sOperator.NET.Generation; using K8sOperator.NET.Metadata; using Microsoft.Extensions.DependencyInjection; @@ -12,20 +13,17 @@ internal class VersionCommand(IHost app) : IOperatorCommand { public Task RunAsync(string[] args) { - var watcher = app.Services.GetRequiredService(); - var name = watcher.Metadata.OfType().FirstOrDefault() - ?? OperatorNameAttribute.Default; - var version = watcher.Metadata.OfType().FirstOrDefault() - ?? DockerImageAttribute.Default; + var config = app.Services.GetRequiredService(); - if (string.IsNullOrWhiteSpace(name.OperatorName) || string.IsNullOrWhiteSpace(version.Tag)) + if (string.IsNullOrWhiteSpace(config.OperatorName) || string.IsNullOrWhiteSpace(config.ContainerTag)) { Console.WriteLine("Operator name or version metadata is missing."); return Task.CompletedTask; } - Console.WriteLine($"{name} version {version}."); - Console.WriteLine($"Docker Info: {version.GetImage()}."); + Console.WriteLine($"{config.OperatorName} version {config.ContainerTag}."); + Console.WriteLine($"Docker Info: {config.ContainerImage}."); return Task.CompletedTask; } } + diff --git a/src/K8sOperator.NET/Configuration/OperatorConfiguration.cs b/src/K8sOperator.NET/Configuration/OperatorConfiguration.cs new file mode 100644 index 0000000..e7b4dfe --- /dev/null +++ b/src/K8sOperator.NET/Configuration/OperatorConfiguration.cs @@ -0,0 +1,99 @@ +namespace K8sOperator.NET.Configuration; + +/// +/// Configuration for the Kubernetes Operator. +/// Can be populated from assembly attributes, appsettings.json, or OperatorBuilder. +/// +public class OperatorConfiguration +{ + private string _containerTag = "latest"; + + /// + /// Name of the operator (e.g., "my-operator") + /// + public string OperatorName { get; set; } = string.Empty; + + /// + /// Kubernetes namespace where the operator runs (e.g., "my-operator-system") + /// + public string Namespace { get; set; } = "default"; + + /// + /// Docker image registry (e.g., "ghcr.io") + /// + public string ContainerRegistry { get; set; } = "ghcr.io"; + + /// + /// Docker image repository (e.g., "company/my-operator") + /// + public string ContainerRepository { get; set; } = string.Empty; + + /// + /// Docker image tag (e.g., "1.0.0" or "1.0.0-alpha"). Defaults to "latest" if not set. + /// + public string ContainerTag + { + get => string.IsNullOrWhiteSpace(_containerTag) ? "latest" : _containerTag; + set => _containerTag = value; + } + + /// + /// Full container image (registry/repository:tag). + /// Validates that ContainerRegistry and ContainerRepository are not empty. + /// + /// + /// Thrown when ContainerRegistry or ContainerRepository are null, empty, or whitespace. + /// + public string ContainerImage + { + get + { + var registry = ContainerRegistry?.Trim(); + var repository = ContainerRepository?.Trim(); + var tag = ContainerTag; // Already handles default "latest" + + if (string.IsNullOrWhiteSpace(registry)) + { + throw new InvalidOperationException( + "ContainerRegistry must be configured before accessing ContainerImage. " + + "Set it via assembly attributes, appsettings.json, or OperatorBuilder."); + } + + if (string.IsNullOrWhiteSpace(repository)) + { + throw new InvalidOperationException( + "ContainerRepository must be configured before accessing ContainerImage. " + + "Set it via assembly attributes, appsettings.json, or OperatorBuilder."); + } + + return $"{registry}/{repository}:{tag}"; + } + } + + /// + /// Validates that all required configuration properties are set. + /// + /// + /// Thrown when required configuration is missing. + /// + public void Validate() + { + if (string.IsNullOrWhiteSpace(OperatorName)) + { + throw new InvalidOperationException( + "OperatorName must be configured. " + + "Set it via assembly attributes, appsettings.json, or OperatorBuilder."); + } + + if (string.IsNullOrWhiteSpace(Namespace)) + { + throw new InvalidOperationException( + "Namespace must be configured. " + + "Set it via assembly attributes, appsettings.json, or OperatorBuilder."); + } + + // Trigger ContainerImage validation + _ = ContainerImage; + } +} + diff --git a/src/K8sOperator.NET/Configuration/OperatorConfigurationProvider.cs b/src/K8sOperator.NET/Configuration/OperatorConfigurationProvider.cs new file mode 100644 index 0000000..2c99329 --- /dev/null +++ b/src/K8sOperator.NET/Configuration/OperatorConfigurationProvider.cs @@ -0,0 +1,83 @@ +using K8sOperator.NET.Metadata; +using Microsoft.Extensions.Configuration; +using System.Reflection; + +namespace K8sOperator.NET.Configuration; + +/// +/// Provides operator configuration from multiple sources with priority: +/// 1. OperatorBuilder (explicit configuration) +/// 2. appsettings.json / IConfiguration +/// 3. Assembly attributes (build-time metadata) +/// 4. Defaults +/// +public class OperatorConfigurationProvider( + IConfiguration? configuration = null, + Assembly? assembly = null) +{ + private readonly Assembly _assembly = assembly + ?? Assembly.GetEntryAssembly() + ?? Assembly.GetExecutingAssembly(); + + /// + /// Build operator configuration from all available sources + /// + public OperatorConfiguration Build() + { + var config = new OperatorConfiguration(); + + // 1. Start with assembly attributes (lowest priority) + ApplyAssemblyAttributes(config); + + // 2. Apply configuration (e.g., appsettings.json) + ApplyConfiguration(config); + + // 3. OperatorBuilder can override in AddOperator() (highest priority, done by caller) + + return config; + } + + private void ApplyAssemblyAttributes(OperatorConfiguration config) + { + // Read OperatorName from assembly attribute + var operatorNameAttr = _assembly.GetCustomAttribute(); + if (operatorNameAttr != null && !string.IsNullOrEmpty(operatorNameAttr.OperatorName)) + { + config.OperatorName = operatorNameAttr.OperatorName; + } + + // Read Namespace from assembly attribute + var namespaceAttr = _assembly.GetCustomAttribute(); + if (namespaceAttr != null && !string.IsNullOrEmpty(namespaceAttr.Namespace)) + { + config.Namespace = namespaceAttr.Namespace; + } + + // Read Docker image from assembly attribute + var dockerAttr = _assembly.GetCustomAttribute(); + if (dockerAttr != null) + { + if (!string.IsNullOrEmpty(dockerAttr.Registry)) + config.ContainerRegistry = dockerAttr.Registry; + + if (!string.IsNullOrEmpty(dockerAttr.Repository)) + config.ContainerRepository = dockerAttr.Repository; + + if (!string.IsNullOrEmpty(dockerAttr.Tag)) + config.ContainerTag = dockerAttr.Tag; + } + } + + private void ApplyConfiguration(OperatorConfiguration config) + { + if (configuration == null) + return; + + // Bind from appsettings.json section: "Operator" + var section = configuration.GetSection("Operator"); + if (section.Exists()) + { + section.Bind(config); + } + } +} diff --git a/src/K8sOperator.NET/EventWatcher.cs b/src/K8sOperator.NET/EventWatcher.cs index 0774ac4..88da054 100644 --- a/src/K8sOperator.NET/EventWatcher.cs +++ b/src/K8sOperator.NET/EventWatcher.cs @@ -2,6 +2,7 @@ using k8s.Autorest; using k8s.Models; using K8sOperator.NET; +using K8sOperator.NET.Configuration; using K8sOperator.NET.Generation; using K8sOperator.NET.Metadata; using Microsoft.Extensions.Logging; @@ -10,6 +11,7 @@ namespace K8sOperator.NET; public class EventWatcher( + OperatorConfiguration configuration, IKubernetes kubernetes, OperatorController controller, List metadata, @@ -17,6 +19,7 @@ public class EventWatcher( where T : CustomResource { + public OperatorConfiguration Configuration { get; } = configuration; public IReadOnlyList Metadata { get; } = metadata; public ILogger Logger { get; } = loggerFactory.CreateLogger("Watcher"); public IOperatorController Controller { get; } = controller; @@ -266,7 +269,7 @@ private Task ResourceReplaceAsync(T resource, CancellationToken cancellationT body: resource, group: Crd.Group, version: Crd.ApiVersion, - namespaceParameter: Namespace.Namespace, + namespaceParameter: Configuration.Namespace, plural: Crd.PluralName, name: resource.Metadata.Name, cancellationToken: cancellationToken), @@ -295,14 +298,14 @@ private Task ResourceReplaceAsync(T resource, CancellationToken cancellationT EntityScope.Namespaced => kubernetes.CustomObjects.WatchListNamespacedCustomObjectAsync( group: Crd.Group, version: Crd.ApiVersion, - namespaceParameter: Namespace.Namespace, + namespaceParameter: Configuration.Namespace, plural: Crd.PluralName, allowWatchBookmarks: true, labelSelector: LabelSelector.LabelSelector, timeoutSeconds: (int)TimeSpan.FromMinutes(60).TotalSeconds, onError: (ex) => { - Logger.LogWatchError(ex, Namespace.Namespace, Crd.PluralName, LabelSelector.LabelSelector); + Logger.LogWatchError(ex, Configuration.Namespace, Crd.PluralName, LabelSelector.LabelSelector); }, cancellationToken: _cancellationToken), @@ -318,9 +321,6 @@ private Task ResourceReplaceAsync(T resource, CancellationToken cancellationT private KubernetesEntityAttribute Crd => Metadata.OfType().FirstOrDefault() ?? throw new InvalidOperationException($"Controller metadata must include a {nameof(KubernetesEntityAttribute)}. Ensure the controller's resource type is properly decorated."); - private NamespaceAttribute Namespace => Metadata.OfType().FirstOrDefault() ?? - NamespaceAttribute.Default; - private ScopeAttribute Scope => Metadata.OfType().FirstOrDefault() ?? ScopeAttribute.Default; private FinalizerAttribute Finalizer => Metadata.OfType().FirstOrDefault() diff --git a/src/K8sOperator.NET/EventWatcherDatasource.cs b/src/K8sOperator.NET/EventWatcherDatasource.cs index a38c6d7..396c100 100644 --- a/src/K8sOperator.NET/EventWatcherDatasource.cs +++ b/src/K8sOperator.NET/EventWatcherDatasource.cs @@ -1,4 +1,5 @@ using K8sOperator.NET.Builder; +using K8sOperator.NET.Configuration; namespace K8sOperator.NET; @@ -11,12 +12,12 @@ public interface IEventWatcher } -public class EventWatcherDatasource(IServiceProvider serviceProvider, List metadata) +public class EventWatcherDatasource(IServiceProvider serviceProvider, OperatorConfiguration configuration) { private readonly List _controllers = []; public IServiceProvider ServiceProvider { get; } = serviceProvider; - public List Metadata { get; } = metadata; + public OperatorConfiguration Configuration { get; } = configuration; public ConventionBuilder Add() where TController : IOperatorController @@ -35,7 +36,7 @@ public IEnumerable GetWatchers() { foreach (var controller in _controllers) { - var builder = ControllerBuilder.Create(ServiceProvider, controller.ControllerType, Metadata); + var builder = ControllerBuilder.Create(ServiceProvider, controller.ControllerType, Configuration); foreach (var convention in controller.Conventions) { @@ -44,7 +45,7 @@ public IEnumerable GetWatchers() var result = builder.Build(); - var eventWatcher = EventWatcherBuilder.Create(ServiceProvider, result, builder.Metadata) + var eventWatcher = EventWatcherBuilder.Create(ServiceProvider, Configuration, result, builder.Metadata) .Build(); yield return eventWatcher; @@ -58,6 +59,8 @@ private sealed record ControllerEntry } } + + public record ControllerInfo( IOperatorController Controller, IEnumerable Metadata diff --git a/src/K8sOperator.NET/K8sOperator.NET.csproj b/src/K8sOperator.NET/K8sOperator.NET.csproj index d37aac3..027fb95 100644 --- a/src/K8sOperator.NET/K8sOperator.NET.csproj +++ b/src/K8sOperator.NET/K8sOperator.NET.csproj @@ -39,22 +39,47 @@ + - - - - - - + + + + + all + none + + + + + + + + + <_BuildTasksDlls Include="..\K8sOperator.NET.BuildTasks\bin\$(Configuration)\netstandard2.0\*.dll" /> + + + + + + true + build\netstandard2.0\%(Filename)%(Extension) + + + true + buildTransitive\netstandard2.0\%(Filename)%(Extension) + + + + True diff --git a/src/K8sOperator.NET/Metadata/AdditionalPrinterColumn.cs b/src/K8sOperator.NET/Metadata/AdditionalPrinterColumn.cs index 7e4eea0..0873831 100644 --- a/src/K8sOperator.NET/Metadata/AdditionalPrinterColumn.cs +++ b/src/K8sOperator.NET/Metadata/AdditionalPrinterColumn.cs @@ -3,11 +3,11 @@ [AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] public class AdditionalPrinterColumnAttribute : Attribute { - public string Name { get; set; } - public string Type { get; set; } - public string Description { get; set; } - public string Path { get; set; } - public int Priority { get; set; } + public required string Name { get; set; } + public required string Type { get; set; } + public string Description { get; set; } = string.Empty; + public required string Path { get; set; } + public int Priority { get; set; } = 0; } diff --git a/src/K8sOperator.NET/Operator.targets b/src/K8sOperator.NET/Operator.targets index 106bab1..0daf699 100644 --- a/src/K8sOperator.NET/Operator.targets +++ b/src/K8sOperator.NET/Operator.targets @@ -1,5 +1,14 @@ + + + + <_BuildTasksPath Condition="'$(_BuildTasksPath)' == '' AND Exists('$(MSBuildThisFileDirectory)..\K8sOperator.NET.BuildTasks\bin\$(Configuration)\netstandard2.0\K8sOperator.NET.BuildTasks.dll')">$(MSBuildThisFileDirectory)..\K8sOperator.NET.BuildTasks\bin\$(Configuration)\netstandard2.0\K8sOperator.NET.BuildTasks.dll + <_BuildTasksPath Condition="'$(_BuildTasksPath)' == ''">$(MSBuildThisFileDirectory)..\build\netstandard2.0\K8sOperator.NET.BuildTasks.dll + + + + @@ -69,26 +78,29 @@ - + - - - - - + - - - - - - + + + + + + diff --git a/src/K8sOperator.NET/OperatorBuilder.cs b/src/K8sOperator.NET/OperatorBuilder.cs new file mode 100644 index 0000000..8130c41 --- /dev/null +++ b/src/K8sOperator.NET/OperatorBuilder.cs @@ -0,0 +1,35 @@ +using k8s; +using K8sOperator.NET.Configuration; + +namespace K8sOperator.NET; + +public class OperatorBuilder +{ + public OperatorBuilder() + { + LeaderElection = new LeaderElectionOptions + { + Enabled = false + }; + } + + public KubernetesClientConfiguration? KubeConfig { get; set; } + public LeaderElectionOptions LeaderElection { get; set; } + + /// + /// Operator configuration that can be set programmatically. + /// This has the highest priority and will override assembly attributes and appsettings.json. + /// + public OperatorConfiguration? OperatorConfiguration { get; set; } + + public void WithKubeConfig(KubernetesClientConfiguration config) + { + KubeConfig = config; + } + + public void WithLeaderElection(Action? actions = null) + { + LeaderElection.Enabled = true; + actions?.Invoke(LeaderElection); + } +} diff --git a/src/K8sOperator.NET/OperatorExtensions.cs b/src/K8sOperator.NET/OperatorExtensions.cs index bd9acc1..1a682a3 100644 --- a/src/K8sOperator.NET/OperatorExtensions.cs +++ b/src/K8sOperator.NET/OperatorExtensions.cs @@ -2,13 +2,13 @@ using K8sOperator.NET; using K8sOperator.NET.Builder; using K8sOperator.NET.Commands; +using K8sOperator.NET.Configuration; using K8sOperator.NET.Generation; -using K8sOperator.NET.Metadata; using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; -using System.Reflection; namespace K8sOperator.NET; @@ -21,6 +21,33 @@ public IServiceCollection AddOperator(Action? configure = null) var builder = new OperatorBuilder(); configure?.Invoke(builder); + // Register operator configuration + services.TryAddSingleton(sp => + { + var configuration = sp.GetService(); + var provider = new OperatorConfigurationProvider(configuration); + var config = provider.Build(); + + // Apply OperatorBuilder overrides if provided + if (builder.OperatorConfiguration != null) + { + if (!string.IsNullOrEmpty(builder.OperatorConfiguration.OperatorName)) + config.OperatorName = builder.OperatorConfiguration.OperatorName; + if (!string.IsNullOrEmpty(builder.OperatorConfiguration.Namespace)) + config.Namespace = builder.OperatorConfiguration.Namespace; + if (!string.IsNullOrEmpty(builder.OperatorConfiguration.ContainerRegistry)) + config.ContainerRegistry = builder.OperatorConfiguration.ContainerRegistry; + if (!string.IsNullOrEmpty(builder.OperatorConfiguration.ContainerRepository)) + config.ContainerRepository = builder.OperatorConfiguration.ContainerRepository; + if (!string.IsNullOrEmpty(builder.OperatorConfiguration.ContainerTag)) + config.ContainerTag = builder.OperatorConfiguration.ContainerTag; + } + + config.Validate(); + + return config; + }); + services.TryAddSingleton(sp => { var ds = new CommandDatasource(sp); @@ -31,24 +58,14 @@ public IServiceCollection AddOperator(Action? configure = null) ds.Add(); ds.Add(); - ds.Add(); - ds.Add(); - return ds; }); services.TryAddSingleton(sp => { - var operatorName = Assembly.GetEntryAssembly()?.GetCustomAttribute() - ?? OperatorNameAttribute.Default; - - var dockerImage = Assembly.GetEntryAssembly()?.GetCustomAttribute() - ?? DockerImageAttribute.Default; - - var ns = Assembly.GetEntryAssembly()?.GetCustomAttribute() - ?? NamespaceAttribute.Default; - - return new EventWatcherDatasource(sp, [operatorName, dockerImage, ns]); + // Use OperatorConfiguration directly + var config = sp.GetRequiredService(); + return new EventWatcherDatasource(sp, config); }); services.TryAddSingleton((sp) => @@ -58,7 +75,23 @@ public IServiceCollection AddOperator(Action? configure = null) return new Kubernetes(config); }); - services.TryAddSingleton(sp => builder.LeaderElection); + services.TryAddSingleton(sp => + { + var config = sp.GetRequiredService(); + var leaderElection = builder.LeaderElection; + + // Set default lease name and namespace if not already set + if (string.IsNullOrEmpty(leaderElection.LeaseName)) + { + leaderElection.LeaseName = $"{config.OperatorName}-leader-election"; + } + if (string.IsNullOrEmpty(leaderElection.LeaseNamespace)) + { + leaderElection.LeaseNamespace = config.Namespace; + } + + return leaderElection; + }); services.TryAddSingleton(sp => { var o = sp.GetRequiredService(); @@ -109,37 +142,3 @@ bool Filter(CommandInfo command) } } } - -public class OperatorBuilder -{ - public static NamespaceAttribute Namespace = Assembly.GetExecutingAssembly().GetCustomAttribute() ?? - NamespaceAttribute.Default; - public static DockerImageAttribute Docker = Assembly.GetExecutingAssembly().GetCustomAttribute() ?? - DockerImageAttribute.Default; - public static OperatorNameAttribute Operator = Assembly.GetExecutingAssembly().GetCustomAttribute() ?? - OperatorNameAttribute.Default; - - public OperatorBuilder() - { - LeaderElection = new ObjectBuilder().Add(x => - { - x.LeaseName = $"{Operator.OperatorName}-leader-election"; - x.LeaseNamespace = Namespace.Namespace; - }).Build(); - } - - - public KubernetesClientConfiguration? KubeConfig { get; set; } - public LeaderElectionOptions LeaderElection { get; set; } - - public void WithKubeConfig(KubernetesClientConfiguration config) - { - KubeConfig = config; - } - - public void WithLeaderElection(Action? actions = null) - { - LeaderElection.Enabled = true; - actions?.Invoke(LeaderElection); - } -} diff --git a/test/K8sOperator.NET.Tests/EventWatcherDatasource_Tests.cs b/test/K8sOperator.NET.Tests/EventWatcherDatasource_Tests.cs index 0fbac84..57d700f 100644 --- a/test/K8sOperator.NET.Tests/EventWatcherDatasource_Tests.cs +++ b/test/K8sOperator.NET.Tests/EventWatcherDatasource_Tests.cs @@ -1,5 +1,6 @@ using k8s.Models; using K8sOperator.NET.Builder; +using K8sOperator.NET.Configuration; using K8sOperator.NET.Metadata; using K8sOperator.NET.Tests.Fixtures; using K8sOperator.NET.Tests.Mocks; @@ -17,14 +18,16 @@ private static ServiceProvider CreateServiceProvider() return services.BuildServiceProvider(); } - private static List CreateMetadata() + private static OperatorConfiguration CreateConfiguration() { - return - [ - OperatorNameAttribute.Default, - DockerImageAttribute.Default, - NamespaceAttribute.Default - ]; + return new OperatorConfiguration + { + OperatorName = OperatorNameAttribute.Default.OperatorName, + ContainerRegistry = DockerImageAttribute.Default.Registry, + ContainerRepository = DockerImageAttribute.Default.Repository, + ContainerTag = DockerImageAttribute.Default.Tag, + Namespace = NamespaceAttribute.Default.Namespace + }; } [Test] @@ -32,15 +35,15 @@ public async Task Constructor_Should_InitializeWithServiceProviderAndMetadata() { // Arrange var serviceProvider = CreateServiceProvider(); - var metadata = CreateMetadata(); + var configuration = CreateConfiguration(); // Act - var datasource = new EventWatcherDatasource(serviceProvider, metadata); + var datasource = new EventWatcherDatasource(serviceProvider, configuration); // Assert await Assert.That(datasource).IsNotNull(); await Assert.That(datasource.ServiceProvider).IsEqualTo(serviceProvider); - await Assert.That(datasource.Metadata).IsEqualTo(metadata); + await Assert.That(datasource.Configuration).IsEqualTo(configuration); } [Test] @@ -48,8 +51,8 @@ public async Task Add_Should_AddControllerToDataSource() { // Arrange var serviceProvider = CreateServiceProvider(); - var metadata = CreateMetadata(); - var datasource = new EventWatcherDatasource(serviceProvider, metadata); + var configuration = CreateConfiguration(); + var datasource = new EventWatcherDatasource(serviceProvider, configuration); // Act var builder = datasource.Add(); @@ -63,8 +66,8 @@ public async Task Add_Should_ReturnConventionBuilder() { // Arrange var serviceProvider = CreateServiceProvider(); - var metadata = CreateMetadata(); - var datasource = new EventWatcherDatasource(serviceProvider, metadata); + var configuration = CreateConfiguration(); + var datasource = new EventWatcherDatasource(serviceProvider, configuration); // Act var result = datasource.Add(); @@ -78,8 +81,8 @@ public async Task GetWatchers_Should_ReturnEmptyWhenNoControllersAdded() { // Arrange var serviceProvider = CreateServiceProvider(); - var metadata = CreateMetadata(); - var datasource = new EventWatcherDatasource(serviceProvider, metadata); + var configuration = CreateConfiguration(); + var datasource = new EventWatcherDatasource(serviceProvider, configuration); // Act var watchers = datasource.GetWatchers().ToList(); @@ -93,8 +96,8 @@ public async Task GetWatchers_Should_ReturnSingleWatcherWhenOneControllerAdded() { // Arrange var serviceProvider = CreateServiceProvider(); - var metadata = CreateMetadata(); - var datasource = new EventWatcherDatasource(serviceProvider, metadata); + var configuration = CreateConfiguration(); + var datasource = new EventWatcherDatasource(serviceProvider, configuration); datasource.Add(); @@ -112,8 +115,8 @@ public async Task GetWatchers_Should_ReturnMultipleWatchersWhenMultipleControlle { // Arrange var serviceProvider = CreateServiceProvider(); - var metadata = CreateMetadata(); - var datasource = new EventWatcherDatasource(serviceProvider, metadata); + var configuration = CreateConfiguration(); + var datasource = new EventWatcherDatasource(serviceProvider, configuration); datasource.Add(); datasource.Add(); @@ -132,8 +135,8 @@ public async Task GetWatchers_Should_ApplyConventionsToControllerBuilder() { // Arrange var serviceProvider = CreateServiceProvider(); - var metadata = CreateMetadata(); - var datasource = new EventWatcherDatasource(serviceProvider, metadata); + var configuration = CreateConfiguration(); + var datasource = new EventWatcherDatasource(serviceProvider, configuration); var conventionApplied = false; datasource.Add() @@ -155,8 +158,8 @@ public async Task GetWatchers_Should_ApplyMultipleConventionsInOrder() { // Arrange var serviceProvider = CreateServiceProvider(); - var metadata = CreateMetadata(); - var datasource = new EventWatcherDatasource(serviceProvider, metadata); + var configuration = CreateConfiguration(); + var datasource = new EventWatcherDatasource(serviceProvider, configuration); var conventionOrder = new List(); datasource.Add() @@ -176,8 +179,8 @@ public async Task GetWatchers_Should_CreateEventWatcherWithMetadata() { // Arrange var serviceProvider = CreateServiceProvider(); - var metadata = CreateMetadata(); - var datasource = new EventWatcherDatasource(serviceProvider, metadata); + var configuration = CreateConfiguration(); + var datasource = new EventWatcherDatasource(serviceProvider, configuration); datasource.Add(); @@ -189,33 +192,13 @@ public async Task GetWatchers_Should_CreateEventWatcherWithMetadata() await Assert.That(watchers[0].Metadata).Count().IsGreaterThan(0); } - [Test] - public async Task GetWatchers_Should_IncludeGlobalMetadataInWatcher() - { - // Arrange - var serviceProvider = CreateServiceProvider(); - var metadata = CreateMetadata(); - var datasource = new EventWatcherDatasource(serviceProvider, metadata); - - datasource.Add(); - - // Act - var watchers = datasource.GetWatchers().ToList(); - - // Assert - var watcherMetadata = watchers[0].Metadata; - await Assert.That(watcherMetadata.OfType()).HasSingleItem(); - await Assert.That(watcherMetadata.OfType()).HasSingleItem(); - await Assert.That(watcherMetadata.OfType()).HasSingleItem(); - } - [Test] public async Task GetWatchers_Should_IncludeResourceMetadataInWatcher() { // Arrange var serviceProvider = CreateServiceProvider(); - var metadata = CreateMetadata(); - var datasource = new EventWatcherDatasource(serviceProvider, metadata); + var configuration = CreateConfiguration(); + var datasource = new EventWatcherDatasource(serviceProvider, configuration); datasource.Add(); @@ -236,8 +219,8 @@ public async Task GetWatchers_Should_CreateNewWatcherInstancesEachTime() { // Arrange var serviceProvider = CreateServiceProvider(); - var metadata = CreateMetadata(); - var datasource = new EventWatcherDatasource(serviceProvider, metadata); + var configuration = CreateConfiguration(); + var datasource = new EventWatcherDatasource(serviceProvider, configuration); datasource.Add(); @@ -254,8 +237,8 @@ public async Task GetWatchers_Should_CreateWatcherForCorrectResourceType() { // Arrange var serviceProvider = CreateServiceProvider(); - var metadata = CreateMetadata(); - var datasource = new EventWatcherDatasource(serviceProvider, metadata); + var configuration = CreateConfiguration(); + var datasource = new EventWatcherDatasource(serviceProvider, configuration); datasource.Add(); @@ -271,8 +254,8 @@ public async Task GetWatchers_Should_HandleMultipleControllersWithDifferentResou { // Arrange var serviceProvider = CreateServiceProvider(); - var metadata = CreateMetadata(); - var datasource = new EventWatcherDatasource(serviceProvider, metadata); + var configuration = CreateConfiguration(); + var datasource = new EventWatcherDatasource(serviceProvider, configuration); datasource.Add(); datasource.Add(); @@ -291,12 +274,11 @@ public async Task Metadata_Should_BeAccessibleProperty() { // Arrange var serviceProvider = CreateServiceProvider(); - var metadata = CreateMetadata(); - var datasource = new EventWatcherDatasource(serviceProvider, metadata); + var configuration = CreateConfiguration(); + var datasource = new EventWatcherDatasource(serviceProvider, configuration); // Act & Assert - await Assert.That(datasource.Metadata).IsEqualTo(metadata); - await Assert.That(datasource.Metadata).Count().IsEqualTo(3); + await Assert.That(datasource.Configuration).IsEqualTo(configuration); } [Test] @@ -304,8 +286,8 @@ public async Task ServiceProvider_Should_BeAccessibleProperty() { // Arrange var serviceProvider = CreateServiceProvider(); - var metadata = CreateMetadata(); - var datasource = new EventWatcherDatasource(serviceProvider, metadata); + var configuration = CreateConfiguration(); + var datasource = new EventWatcherDatasource(serviceProvider, configuration); // Act & Assert await Assert.That(datasource.ServiceProvider).IsEqualTo(serviceProvider); @@ -316,8 +298,8 @@ public async Task GetWatchers_Should_YieldWatchersLazily() { // Arrange var serviceProvider = CreateServiceProvider(); - var metadata = CreateMetadata(); - var datasource = new EventWatcherDatasource(serviceProvider, metadata); + var configuration = CreateConfiguration(); + var datasource = new EventWatcherDatasource(serviceProvider, configuration); var buildCount = 0; datasource.Add() diff --git a/test/K8sOperator.NET.Tests/EventWatcher_Tests.cs b/test/K8sOperator.NET.Tests/EventWatcher_Tests.cs index 50387d4..fd47087 100644 --- a/test/K8sOperator.NET.Tests/EventWatcher_Tests.cs +++ b/test/K8sOperator.NET.Tests/EventWatcher_Tests.cs @@ -1,4 +1,5 @@ using k8s.Models; +using K8sOperator.NET.Configuration; using K8sOperator.NET.Metadata; using K8sOperator.NET.Tests.Fixtures; using K8sOperator.NET.Tests.Logging; @@ -30,7 +31,7 @@ public async Task Start_Should_StartWatchAndLogStart() endpoints.CustomObjects.WatchListClusterCustomObjectAsync(WatchEvents.Added); }); - var watcher = new EventWatcher(server.Client, _controller, _metadata, _loggerFactory); + var watcher = new EventWatcher(new OperatorConfiguration(), server.Client, _controller, _metadata, _loggerFactory); await watcher.Start(cancellationToken); @@ -47,7 +48,7 @@ public async Task OnEvent_Should_HandleAddedEventAndCallAddOrModifyAsync() endpoints.CustomObjects.ReplaceNamespacedCustomObjectAsync(); }); - var watcher = new EventWatcher(server.Client, _controller, _metadata, _loggerFactory); + var watcher = new EventWatcher(new OperatorConfiguration(), server.Client, _controller, _metadata, _loggerFactory); await watcher.Start(cancellationToken); @@ -65,7 +66,7 @@ public async Task OnEvent_Should_HandleDeletedEventAndCallDeleteAsync() endpoints.CustomObjects.ReplaceNamespacedCustomObjectAsync(); }); - var watcher = new EventWatcher(server.Client, _controller, _metadata, _loggerFactory); + var watcher = new EventWatcher(new OperatorConfiguration(), server.Client, _controller, _metadata, _loggerFactory); await watcher.Start(cancellationToken); @@ -83,7 +84,7 @@ public async Task HandleFinalizeAsync_Should_CallFinalizeAndRemoveFinalizer() endpoints.CustomObjects.ReplaceNamespacedCustomObjectAsync(); }); - var watcher = new EventWatcher(server.Client, _controller, _metadata, _loggerFactory); + var watcher = new EventWatcher(new OperatorConfiguration(), server.Client, _controller, _metadata, _loggerFactory); await watcher.Start(cancellationToken); @@ -103,7 +104,7 @@ public async Task HandleAddOrModifyAsync_Should_AddFinalizer_IfNotPresent() }); }); - var watcher = new EventWatcher(server.Client, _controller, _metadata, _loggerFactory); + var watcher = new EventWatcher(new OperatorConfiguration(), server.Client, _controller, _metadata, _loggerFactory); await watcher.Start(cancellationToken); } diff --git a/test/K8sOperator.NET.Tests/OperatorConfiguration_Tests.cs b/test/K8sOperator.NET.Tests/OperatorConfiguration_Tests.cs new file mode 100644 index 0000000..a27c2c0 --- /dev/null +++ b/test/K8sOperator.NET.Tests/OperatorConfiguration_Tests.cs @@ -0,0 +1,318 @@ +using K8sOperator.NET.Configuration; + +namespace K8sOperator.NET.Tests; + +public class OperatorConfiguration_Tests +{ + [Test] + public async Task ContainerTag_Should_DefaultToLatest_WhenNotSet() + { + // Arrange + var config = new OperatorConfiguration + { + ContainerRegistry = "ghcr.io", + ContainerRepository = "myorg/myapp" + }; + + // Act + var tag = config.ContainerTag; + + // Assert + await Assert.That(tag).IsEqualTo("latest"); + } + + [Test] + public async Task ContainerTag_Should_ReturnLatest_WhenSetToEmptyString() + { + // Arrange + var config = new OperatorConfiguration + { + ContainerRegistry = "ghcr.io", + ContainerRepository = "myorg/myapp", + ContainerTag = "" + }; + + // Act + var tag = config.ContainerTag; + + // Assert + await Assert.That(tag).IsEqualTo("latest"); + } + + [Test] + public async Task ContainerTag_Should_ReturnLatest_WhenSetToWhitespace() + { + // Arrange + var config = new OperatorConfiguration + { + ContainerRegistry = "ghcr.io", + ContainerRepository = "myorg/myapp", + ContainerTag = " " + }; + + // Act + var tag = config.ContainerTag; + + // Assert + await Assert.That(tag).IsEqualTo("latest"); + } + + [Test] + public async Task ContainerTag_Should_ReturnSetValue_WhenValid() + { + // Arrange + var config = new OperatorConfiguration + { + ContainerRegistry = "ghcr.io", + ContainerRepository = "myorg/myapp", + ContainerTag = "1.0.0" + }; + + // Act + var tag = config.ContainerTag; + + // Assert + await Assert.That(tag).IsEqualTo("1.0.0"); + } + + [Test] + public async Task ContainerImage_Should_BuildCorrectImage_WithAllProperties() + { + // Arrange + var config = new OperatorConfiguration + { + ContainerRegistry = "ghcr.io", + ContainerRepository = "myorg/myapp", + ContainerTag = "1.0.0" + }; + + // Act + var image = config.ContainerImage; + + // Assert + await Assert.That(image).IsEqualTo("ghcr.io/myorg/myapp:1.0.0"); + } + + [Test] + public async Task ContainerImage_Should_UseLatestTag_WhenTagNotSet() + { + // Arrange + var config = new OperatorConfiguration + { + ContainerRegistry = "ghcr.io", + ContainerRepository = "myorg/myapp" + }; + + // Act + var image = config.ContainerImage; + + // Assert + await Assert.That(image).IsEqualTo("ghcr.io/myorg/myapp:latest"); + } + + [Test] + public async Task ContainerImage_Should_ThrowException_WhenRegistryIsEmpty() + { + // Arrange + var config = new OperatorConfiguration + { + ContainerRegistry = "", + ContainerRepository = "myorg/myapp" + }; + + // Act & Assert + var exception = await Assert.That(() => config.ContainerImage) + .Throws(); + + await Assert.That(exception).IsNotNull(); + await Assert.That(exception!.Message).Contains("ContainerRegistry must be configured"); + } + + [Test] + public async Task ContainerImage_Should_ThrowException_WhenRegistryIsWhitespace() + { + // Arrange + var config = new OperatorConfiguration + { + ContainerRegistry = " ", + ContainerRepository = "myorg/myapp" + }; + + // Act & Assert + var exception = await Assert.That(() => config.ContainerImage) + .Throws(); + + await Assert.That(exception).IsNotNull(); + await Assert.That(exception!.Message).Contains("ContainerRegistry must be configured"); + } + + [Test] + public async Task ContainerImage_Should_ThrowException_WhenRepositoryIsEmpty() + { + // Arrange + var config = new OperatorConfiguration + { + ContainerRegistry = "ghcr.io", + ContainerRepository = "" + }; + + // Act & Assert + var exception = await Assert.That(() => config.ContainerImage) + .Throws(); + + await Assert.That(exception).IsNotNull(); + await Assert.That(exception!.Message).Contains("ContainerRepository must be configured"); + } + + [Test] + public async Task ContainerImage_Should_ThrowException_WhenRepositoryIsWhitespace() + { + // Arrange + var config = new OperatorConfiguration + { + ContainerRegistry = "ghcr.io", + ContainerRepository = " " + }; + + // Act & Assert + var exception = await Assert.That(() => config.ContainerImage) + .Throws(); + + await Assert.That(exception).IsNotNull(); + await Assert.That(exception!.Message).Contains("ContainerRepository must be configured"); + } + + [Test] + public async Task ContainerImage_Should_TrimRegistryAndRepository() + { + // Arrange + var config = new OperatorConfiguration + { + ContainerRegistry = " ghcr.io ", + ContainerRepository = " myorg/myapp ", + ContainerTag = "1.0.0" + }; + + // Act + var image = config.ContainerImage; + + // Assert + await Assert.That(image).IsEqualTo("ghcr.io/myorg/myapp:1.0.0"); + } + + [Test] + public async Task Validate_Should_ThrowException_WhenOperatorNameIsEmpty() + { + // Arrange + var config = new OperatorConfiguration + { + OperatorName = "", + ContainerRegistry = "ghcr.io", + ContainerRepository = "myorg/myapp" + }; + + // Act & Assert + var exception = await Assert.That(() => config.Validate()) + .Throws(); + + await Assert.That(exception).IsNotNull(); + await Assert.That(exception!.Message).Contains("OperatorName must be configured"); + } + + [Test] + public async Task Validate_Should_ThrowException_WhenNamespaceIsEmpty() + { + // Arrange + var config = new OperatorConfiguration + { + OperatorName = "my-operator", + Namespace = "", + ContainerRegistry = "ghcr.io", + ContainerRepository = "myorg/myapp" + }; + + // Act & Assert + var exception = await Assert.That(() => config.Validate()) + .Throws(); + + await Assert.That(exception).IsNotNull(); + await Assert.That(exception!.Message).Contains("Namespace must be configured"); + } + + [Test] + public async Task Validate_Should_ThrowException_WhenContainerRegistryIsEmpty() + { + // Arrange + var config = new OperatorConfiguration + { + OperatorName = "my-operator", + Namespace = "default", + ContainerRegistry = "", + ContainerRepository = "myorg/myapp" + }; + + // Act & Assert + var exception = await Assert.That(() => config.Validate()) + .Throws(); + + await Assert.That(exception).IsNotNull(); + await Assert.That(exception!.Message).Contains("ContainerRegistry must be configured"); + } + + [Test] + public async Task Validate_Should_ThrowException_WhenContainerRepositoryIsEmpty() + { + // Arrange + var config = new OperatorConfiguration + { + OperatorName = "my-operator", + Namespace = "default", + ContainerRegistry = "ghcr.io", + ContainerRepository = "" + }; + + // Act & Assert + var exception = await Assert.That(() => config.Validate()) + .Throws(); + + await Assert.That(exception).IsNotNull(); + await Assert.That(exception!.Message).Contains("ContainerRepository must be configured"); + } + + [Test] + public void Validate_Should_NotThrow_WhenAllPropertiesAreValid() + { + // Arrange + var config = new OperatorConfiguration + { + OperatorName = "my-operator", + Namespace = "default", + ContainerRegistry = "ghcr.io", + ContainerRepository = "myorg/myapp", + ContainerTag = "1.0.0" + }; + + // Act & Assert - Should not throw + config.Validate(); + } + + [Test] + public async Task Validate_Should_NotThrow_WhenTagIsEmpty_UsesLatestDefault() + { + // Arrange + var config = new OperatorConfiguration + { + OperatorName = "my-operator", + Namespace = "default", + ContainerRegistry = "ghcr.io", + ContainerRepository = "myorg/myapp" + // ContainerTag not set - should default to "latest" + }; + + // Act & Assert - Should not throw + config.Validate(); + + // Verify the image is valid + await Assert.That(config.ContainerImage).IsEqualTo("ghcr.io/myorg/myapp:latest"); + } +}