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"); + } +}