diff --git a/README.md b/README.md index 23a6cae..6509777 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# K8sOperator.NET +# K8sOperator.NET ![Github Release](https://img.shields.io/github/v/release/pmdevers/K8sOperator.NET) ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/pmdevers/K8sOperator.NET/.github%2Fworkflows%2Fbuild-publish.yml) @@ -7,7 +7,6 @@ ![Github Pull Request Open](https://img.shields.io/github/issues-pr/pmdevers/K8sOperator.NET) [![Scheduled Code Security Testing](https://github.com/pmdevers/K8sOperator.NET/actions/workflows/security-analysis.yml/badge.svg?event=schedule)](https://github.com/pmdevers/K8sOperator.NET/actions/workflows/security-analysis.yml) - K8sOperator.NET is a powerful and intuitive library designed for creating Kubernetes Operators using C#. It simplifies the development of robust, cloud-native operators by leveraging the full capabilities of the .NET ecosystem, making it easier than ever to manage complex Kubernetes workloads with custom automation. ![Alt text](https://raw.githubusercontent.com/pmdevers/K8sOperator.NET/master/assets/logo.png "logo") @@ -16,117 +15,431 @@ K8sOperator.NET is a powerful and intuitive library designed for creating Kubern - [Features](#features) - [Installation](#installation) +- [Quick Start](#quick-start) - [Usage](#usage) + - [Creating a Custom Resource](#creating-a-custom-resource) + - [Implementing a Controller](#implementing-a-controller) + - [Setting Up the Operator](#setting-up-the-operator) +- [Commands](#commands) - [Configuration](#configuration) + - [MSBuild Properties](#msbuild-properties) + - [Auto-Generated Files](#auto-generated-files) +- [Docker Support](#docker-support) - [Contributing](#contributing) - [License](#license) ## Features -- Easy integration +- 🚀 **Easy Integration** - Simple, intuitive API for building Kubernetes operators +- 🎯 **Custom Resource Support** - Built-in support for Custom Resource Definitions (CRDs) +- 🔄 **Automatic Reconciliation** - Event-driven reconciliation with finalizer support +- 📦 **MSBuild Integration** - Automatic generation of manifests, Docker files, and launch settings +- 🐳 **Docker Ready** - Generate optimized Dockerfiles with best practices +- 🛠️ **Built-in Commands** - Help, version, install, and code generation commands +- 🔐 **Security First** - Non-root containers, RBAC support, and security best practices +- 📝 **Source Generators** - Compile-time generation of boilerplate code +- 🎨 **Flexible Configuration** - MSBuild properties for operator customization +- 🧪 **Testable** - Built with testing in mind ## Installation -To install the package, use the following command in your .NET Core project: +To install K8sOperator.NET, add the package to your .NET project: ```bash dotnet add package K8sOperator.NET -dotnet add package K8sOperator.NET.Generators ``` -Alternatively, you can add it manually to your `.csproj` file: +Or add it manually to your `.csproj` file: ```xml - - + +``` + +## Quick Start + +Create a new ASP.NET Core Web Application and add K8sOperator.NET: + +```bash +dotnet new web -n MyOperator +cd MyOperator +dotnet add package K8sOperator.NET +``` + +Update your `Program.cs`: + +```csharp +using K8sOperator.NET; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddOperator(); + +var app = builder.Build(); + +// Map your controllers here +// app.MapController(); + +await app.RunOperatorAsync(); ``` ## Usage -Here are some basic examples of how to use the library: +### Creating a Custom Resource + +Define your custom resource by inheriting from `CustomResource`: + +```csharp +using K8sOperator.NET; + +[KubernetesEntity( + Group = "example.com", + ApiVersion = "v1", + Kind = "MyResource", + PluralName = "myresources")] +public class MyResource : CustomResource +{ + public class MySpec + { + public string Name { get; set; } = string.Empty; + public int Replicas { get; set; } = 1; + } + + public class MyStatus + { + public string Phase { get; set; } = "Pending"; + public DateTime? LastUpdated { get; set; } + } +} +``` + +### Implementing a Controller + +Create a controller to handle your custom resource: -### Setup +```csharp +using K8sOperator.NET; +using Microsoft.Extensions.Logging; + +public class MyController : OperatorController +{ + private readonly ILogger _logger; + + public MyController(ILogger logger) + { + _logger = logger; + } + + public override async Task AddOrModifyAsync( + MyResource resource, + CancellationToken cancellationToken) + { + _logger.LogInformation( + "Reconciling {Name} with {Replicas} replicas", + resource.Spec.Name, + resource.Spec.Replicas); + + // Your reconciliation logic here + resource.Status = new MyResource.MyStatus + { + Phase = "Running", + LastUpdated = DateTime.UtcNow + }; + + await Task.CompletedTask; + } + + public override async Task DeleteAsync( + MyResource resource, + CancellationToken cancellationToken) + { + _logger.LogInformation("Deleting {Name}", resource.Metadata.Name); + + // Cleanup logic here + await Task.CompletedTask; + } + + public override async Task FinalizeAsync( + MyResource resource, + CancellationToken cancellationToken) + { + _logger.LogInformation("Finalizing {Name}", resource.Metadata.Name); + + // Finalization logic here + await Task.CompletedTask; + } +} +``` + +### Setting Up the Operator + +Wire everything together in `Program.cs`: ```csharp using K8sOperator.NET; +using MyOperator; -var builder = OperatorHost.CreateOperatorApplicationBuilder(args); +var builder = WebApplication.CreateBuilder(args); -builder.AddController() - .WithFinalizer("project.local.finalizer"); +// Add operator services +builder.Services.AddOperator(); var app = builder.Build(); -app.AddInstall(); +// Map the controller to watch MyResource +app.MapController(); + +// Run the operator +await app.RunOperatorAsync(); +``` + +## Commands + +K8sOperator.NET includes several built-in commands: + +| Command | Description | Availability | +|---------|-------------|--------------| +| `operator` | Run the operator (watches for resources) | All builds | +| `install` | Generate Kubernetes installation manifests | All builds | +| `version` | Display version information | All builds | +| `help` | Show available commands | All builds | +| `generate-launchsettings` | Generate Visual Studio launch profiles | Debug only | +| `generate-dockerfile` | Generate optimized Dockerfile | Debug only | + +**Note:** The `generate-*` commands are development tools and are only available in Debug builds. They are automatically excluded from Release builds to keep your production operator lean. -await app.RunAsync(); +### Running Commands +```bash +# Run the operator +dotnet run -- operator + +# Generate installation manifests +dotnet run -- install > install.yaml + +# Show version +dotnet run -- version + +# Generate launch settings (Debug only) +dotnet run -c Debug -- generate-launchsettings + +# Generate Dockerfile (Debug only) +dotnet run -c Debug -- generate-dockerfile ``` -### add custom launchSettings.json +## Configuration -```json +### MSBuild Properties + +K8sOperator.NET uses MSBuild properties to configure your operator. Add these to your `.csproj` file: + +```xml + + + my-operator + my-namespace + + + ghcr.io + myorg/my-operator + 1.0.0 + alpine + + + true + true + +``` + +#### Available Properties + +| Property | Default | Description | +|----------|---------|-------------| +| `OperatorName` | `{project-name}` | Name of the operator | +| `OperatorNamespace` | `{project-name}-system` | Kubernetes namespace | +| `ContainerRegistry` | `ghcr.io` | Container registry URL | +| `ContainerRepository` | `{Company}/{OperatorName}` | Repository path | +| `ContainerImageTag` | `{Version}` | Image tag | +| `ContainerFamily` | (empty) | Image variant (e.g., `alpine`, `distroless`) | +| `CreateOperatorLaunchSettings` | `false` | Auto-generate launch profiles | +| `GenerateOperatorDockerfile` | `false` | Auto-generate Dockerfile | +### Auto-Generated Files + +When enabled, K8sOperator.NET automatically generates: + +#### 1. Assembly Attributes + +Metadata is embedded in your assembly: + +```csharp +[assembly: OperatorNameAttribute("my-operator")] +[assembly: NamespaceAttribute("my-namespace")] +[assembly: DockerImageAttribute("ghcr.io", "myorg/my-operator", "1.0.0-alpine")] +``` + +#### 2. Launch Settings (`Properties/launchSettings.json`) + +Visual Studio launch profiles for all registered commands: + +```json { - "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 - }, - "Help": { - "commandName": "Project", - "commandLineArgs": "", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - }, - "dotnetRunMessages": true - }, - "Version": { - "commandName": "Project", - "commandLineArgs": "version", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - }, - "dotnetRunMessages": true - } + "profiles": { + "Operator": { + "commandName": "Project", + "commandLineArgs": "operator", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } }, - "$schema": "http://json.schemastore.org/launchsettings.json" + "Install": { + "commandName": "Project", + "commandLineArgs": "install > ./install.yaml" + } + } } +``` +#### 3. Dockerfile + +Optimized multi-stage Dockerfile with security best practices: + +```dockerfile +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY ["MyOperator.csproj", "./"] +RUN dotnet restore +COPY . . +RUN dotnet publish -c Release -o /app/publish + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final +WORKDIR /app +RUN groupadd -r operator && useradd -r -g operator operator +COPY --from=build /app/publish . +RUN chown -R operator:operator /app +USER operator +ENTRYPOINT ["dotnet", "MyOperator.dll"] +CMD ["operator"] ``` -## Configuration +#### 4. .dockerignore + +Optimized Docker ignore file to reduce image size. + +## Docker Support -By running the `Install` profile will create the install.yaml file in the root of the project. This file can be used to install the operator in a Kubernetes cluster. +### Building Docker Images + +K8sOperator.NET generates production-ready Dockerfiles with: + +- ✅ Multi-stage builds for smaller images +- ✅ Non-root user for security +- ✅ Health checks +- ✅ .NET 10 runtime +- ✅ Optimized layer caching + +Generate a Dockerfile: + +```bash +dotnet run -- generate-dockerfile +``` + +Build the image: + +```bash +docker build -t ghcr.io/myorg/my-operator:1.0.0 . +``` + +Push to registry: + +```bash +docker push ghcr.io/myorg/my-operator:1.0.0 +``` + +### Installing in Kubernetes + +Generate installation manifests: + +```bash +dotnet run -- install > install.yaml +``` + +The generated manifest includes: +- Custom Resource Definitions (CRDs) +- ServiceAccount +- ClusterRole and ClusterRoleBinding +- Deployment + +Apply to your cluster: -Run the following command to install the operator: ```bash kubectl apply -f install.yaml ``` +### Verify Installation + +```bash +# Check if operator is running +kubectl get pods -n my-namespace + +# View operator logs +kubectl logs -n my-namespace deployment/my-operator -f + +# Check CRDs +kubectl get crds + +# Create a custom resource +kubectl apply -f my-resource.yaml +``` + + ## Contributing Contributions are welcome! Please feel free to submit a pull request or open an issue if you encounter any bugs or have feature requests. +### Development Setup + +1. Clone the repository + ```bash + git clone https://github.com/pmdevers/K8sOperator.NET.git + cd K8sOperator.NET + ``` + +2. Build the solution + ```bash + dotnet build + ``` + +3. Run tests + ```bash + dotnet test + ``` + +4. Run the example operator + ```bash + cd examples/SimpleOperator + dotnet run -- operator + ``` + +### Contribution Guidelines + 1. Fork the repository -2. Create your feature branch (`git checkout -b feature/fooBar`) -3. Commit your changes (`git commit -am 'Add some fooBar'`) -4. Push to the branch (`git push origin feature/fooBar`) -5. Create a new Pull Request +2. Create your feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'Add some amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request + +Please ensure: +- ✅ All tests pass +- ✅ Code follows existing style conventions +- ✅ New features include tests +- ✅ Documentation is updated ## License This project is licensed under the MIT License - see the [LICENSE](LICENSE.md) file for details. + +--- + +**Built with ❤️ using .NET 10** + +For more examples and documentation, visit the [GitHub repository](https://github.com/pmdevers/K8sOperator.NET). diff --git a/examples/SimpleOperator/Dockerfile b/examples/SimpleOperator/Dockerfile index 22d9682..1d097e8 100644 --- a/examples/SimpleOperator/Dockerfile +++ b/examples/SimpleOperator/Dockerfile @@ -42,3 +42,4 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ # Entrypoint ENTRYPOINT ["dotnet", "SimpleOperator.dll"] CMD ["operator"] + diff --git a/examples/SimpleOperator/Program.cs b/examples/SimpleOperator/Program.cs index a44848d..6d80422 100644 --- a/examples/SimpleOperator/Program.cs +++ b/examples/SimpleOperator/Program.cs @@ -1,12 +1,19 @@ using K8sOperator.NET; +using K8sOperator.NET.Generation; using SimpleOperator.Controllers; var builder = WebApplication.CreateBuilder(args); -builder.Services.AddOperator(); +builder.Services.AddOperator(x => +{ + //x.WithLeaderElection(); + +}); var app = builder.Build(); -app.MapController(); +app.MapController() + .WithNamespaceScope(); +//.WithFinalizer("todo.example.com/finalizer"); await app.RunOperatorAsync(); diff --git a/examples/SimpleOperator/Properties/launchSettings.json b/examples/SimpleOperator/Properties/launchSettings.json index d9d9d4e..bda3e33 100644 --- a/examples/SimpleOperator/Properties/launchSettings.json +++ b/examples/SimpleOperator/Properties/launchSettings.json @@ -9,24 +9,32 @@ "dotnetRunMessages": true }, "Operator": { + "commandName": "Project", + "commandLineArgs": "operator", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true + }, + "Install": { "commandName": "Project", - "commandLineArgs": "operator", + "commandLineArgs": "install", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }, "dotnetRunMessages": true }, - "Install": { + "Version": { "commandName": "Project", - "commandLineArgs": "install", + "commandLineArgs": "version", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }, "dotnetRunMessages": true }, - "Version": { + "Create": { "commandName": "Project", - "commandLineArgs": "version", + "commandLineArgs": "create", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }, @@ -50,4 +58,4 @@ } }, "schema": "http://json.schemastore.org/launchsettings.json" -} \ No newline at end of file +} diff --git a/examples/SimpleOperator/Resources/TodoItem.cs b/examples/SimpleOperator/Resources/TodoItem.cs index 84b1fff..e6f0580 100644 --- a/examples/SimpleOperator/Resources/TodoItem.cs +++ b/examples/SimpleOperator/Resources/TodoItem.cs @@ -1,9 +1,13 @@ using k8s.Models; using K8sOperator.NET; +using K8sOperator.NET.Metadata; namespace SimpleOperator.Resources; [KubernetesEntity(Group = "app.example.com", ApiVersion = "v1", Kind = "TodoItem", PluralName = "todoitems")] +[AdditionalPrinterColumn(Name = "Title", Description = "Todo Title", Path = ".spec.title", Type = "string")] +[AdditionalPrinterColumn(Name = "Description", Description = "Todo Description", Path = ".spec.description", Type = "string")] +[AdditionalPrinterColumn(Name = "State", Description = "Todo State", Path = ".status.state", Type = "string")] public class TodoItem : CustomResource { public class TodoSpec diff --git a/examples/SimpleOperator/SimpleOperator.csproj b/examples/SimpleOperator/SimpleOperator.csproj index 878d5ca..712f7b1 100644 --- a/examples/SimpleOperator/SimpleOperator.csproj +++ b/examples/SimpleOperator/SimpleOperator.csproj @@ -16,8 +16,10 @@ simple-operator simple-system + alpha diff --git a/src/K8sOperator.NET/Builder/ControllerBuilder.cs b/src/K8sOperator.NET/Builder/ControllerBuilder.cs index c3d4acc..4c04173 100644 --- a/src/K8sOperator.NET/Builder/ControllerBuilder.cs +++ b/src/K8sOperator.NET/Builder/ControllerBuilder.cs @@ -28,3 +28,5 @@ public IOperatorController Build() } public List Metadata { get; } = []; } + + diff --git a/src/K8sOperator.NET/Commands/CreateCommand.cs b/src/K8sOperator.NET/Commands/CreateCommand.cs new file mode 100644 index 0000000..e555e1f --- /dev/null +++ b/src/K8sOperator.NET/Commands/CreateCommand.cs @@ -0,0 +1,34 @@ +using k8s; +using K8sOperator.NET.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace K8sOperator.NET.Commands; + +[OperatorArgument("create", Description = "Creates a resource definition.", Order = 4)] +public class CreateCommand(IHost app) : IOperatorCommand +{ + public Task RunAsync(string[] args) + { + if (args.Length < 2) + { + Console.WriteLine($"Please provide a resourcename."); + return Task.CompletedTask; + } + + var datasource = app.Services.GetRequiredService(); + var watchers = datasource.GetWatchers().ToList(); + var watcher = watchers.FirstOrDefault(x => x.Controller.ResourceType.Name.Equals(args[1], StringComparison.CurrentCultureIgnoreCase)); + + if (watcher == null) + { + Console.WriteLine($"Unknown resource: {args[1]}"); + return Task.CompletedTask; + } + + var activator = Activator.CreateInstance(watcher.Controller.ResourceType) as CustomResource; + activator.Initialize(); + Console.WriteLine(KubernetesYaml.Serialize(activator)); + return Task.CompletedTask; + } +} diff --git a/src/K8sOperator.NET/Commands/GenerateDockerfileCommand.cs b/src/K8sOperator.NET/Commands/GenerateDockerfileCommand.cs index db9a73b..a0c8675 100644 --- a/src/K8sOperator.NET/Commands/GenerateDockerfileCommand.cs +++ b/src/K8sOperator.NET/Commands/GenerateDockerfileCommand.cs @@ -1,14 +1,19 @@ -using K8sOperator.NET.Builder; +using K8sOperator.NET.Builder; using K8sOperator.NET.Metadata; using Microsoft.Extensions.Hosting; using System.Reflection; -using System.Text; +using System.Text.RegularExpressions; namespace K8sOperator.NET.Commands; -[OperatorArgument("generate-dockerfile", Description = "Generates a Dockerfile for the operator", Order = 101, ShowInHelp = false)] -public class GenerateDockerfileCommand(IHost host) : IOperatorCommand +[OperatorArgument("generate-dockerfile", Description = "Generates a Dockerfile for the operator", Order = 101)] +public partial class GenerateDockerfileCommand(IHost host) : IOperatorCommand { + private readonly IHost _host = host; + + [GeneratedRegex(@"Version=v?(\d+\.\d+)", RegexOptions.Compiled)] + private static partial Regex VersionRegex(); + public async Task RunAsync(string[] args) { var assembly = Assembly.GetEntryAssembly(); @@ -18,19 +23,29 @@ public async Task RunAsync(string[] args) ?? DockerImageAttribute.Default; var projectName = AppDomain.CurrentDomain.FriendlyName.Replace(".dll", ""); - var targetFramework = "net10.0"; // Can be made dynamic if needed - var dockerfile = GenerateDockerfileContent(projectName, targetFramework, operatorName); + // Get the .NET version from the assembly's target framework + var dotnetVersion = GetDotNetVersion(assembly); + + // Read templates from embedded resources + var dockerfileContent = await ReadEmbeddedResourceAsync("Dockerfile.template"); + var dockerignoreContent = await ReadEmbeddedResourceAsync(".dockerignore.template"); + + // Replace placeholders + dockerfileContent = dockerfileContent + .Replace("{PROJECT_NAME}", projectName) + .Replace("{DOTNET_VERSION}", dotnetVersion); var dockerfilePath = Path.Combine(Directory.GetCurrentDirectory(), "Dockerfile"); - await File.WriteAllTextAsync(dockerfilePath, dockerfile); + await File.WriteAllTextAsync(dockerfilePath, dockerfileContent); var dockerignorePath = Path.Combine(Directory.GetCurrentDirectory(), ".dockerignore"); - await File.WriteAllTextAsync(dockerignorePath, GenerateDockerignoreContent()); + await File.WriteAllTextAsync(dockerignorePath, dockerignoreContent); - Console.WriteLine($"✅ Generated Dockerfile at: {dockerfilePath}"); - Console.WriteLine($"✅ Generated .dockerignore at: {dockerignorePath}"); + Console.WriteLine($"? Generated Dockerfile at: {dockerfilePath}"); + Console.WriteLine($"? Generated .dockerignore at: {dockerignorePath}"); Console.WriteLine($" Operator: {operatorName}"); + Console.WriteLine($" .NET Version: {dotnetVersion}"); Console.WriteLine($" Image: {dockerImage.Registry}/{dockerImage.Repository}:{dockerImage.Tag}"); Console.WriteLine(); Console.WriteLine("To build the image:"); @@ -40,111 +55,39 @@ public async Task RunAsync(string[] args) Console.WriteLine($" docker push {dockerImage.Registry}/{dockerImage.Repository}:{dockerImage.Tag}"); } - private static string GenerateDockerfileContent(string projectName, string targetFramework, string operatorName) + private static string GetDotNetVersion(Assembly? assembly) { - var sb = new StringBuilder(); - - sb.AppendLine("# Build stage"); - sb.AppendLine("FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build"); - sb.AppendLine("WORKDIR /src"); - sb.AppendLine(); - sb.AppendLine("# Copy project file and restore dependencies"); - sb.AppendLine($"COPY [\"{projectName}.csproj\", \"./\"]"); - sb.AppendLine("RUN dotnet restore"); - sb.AppendLine(); - sb.AppendLine("# Copy source code and build"); - sb.AppendLine("COPY . ."); - sb.AppendLine("RUN dotnet build -c Release -o /app/build"); - sb.AppendLine(); - sb.AppendLine("# Publish stage"); - sb.AppendLine("FROM build AS publish"); - sb.AppendLine("RUN dotnet publish -c Release -o /app/publish /p:UseAppHost=false"); - sb.AppendLine(); - sb.AppendLine("# Runtime stage"); - sb.AppendLine("FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final"); - sb.AppendLine("WORKDIR /app"); - sb.AppendLine(); - sb.AppendLine("# Create non-root user"); - sb.AppendLine("RUN groupadd -r operator && useradd -r -g operator operator"); - sb.AppendLine(); - sb.AppendLine("# Copy published app"); - sb.AppendLine("COPY --from=publish /app/publish ."); - sb.AppendLine(); - sb.AppendLine("# Set ownership"); - sb.AppendLine("RUN chown -R operator:operator /app"); - sb.AppendLine(); - sb.AppendLine("# Switch to non-root user"); - sb.AppendLine("USER operator"); - sb.AppendLine(); - sb.AppendLine("# Set environment variables"); - sb.AppendLine("ENV ASPNETCORE_ENVIRONMENT=Production \\"); - sb.AppendLine(" DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false \\"); - sb.AppendLine(" DOTNET_EnableDiagnostics=0"); - sb.AppendLine(); - sb.AppendLine("# Health check"); - sb.AppendLine("HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \\"); - sb.AppendLine($" CMD dotnet {projectName}.dll version || exit 1"); - sb.AppendLine(); - sb.AppendLine("# Entrypoint"); - sb.AppendLine($"ENTRYPOINT [\"dotnet\", \"{projectName}.dll\"]"); - sb.AppendLine("CMD [\"operator\"]"); - - return sb.ToString(); + if (assembly == null) + return "10.0"; // Default fallback + + // Get the target framework attribute + var targetFrameworkAttr = assembly.GetCustomAttribute(); + + if (targetFrameworkAttr == null) + return "10.0"; // Default fallback + + // Extract version from framework name (e.g., ".NETCoreApp,Version=v10.0" -> "10.0") + var frameworkName = targetFrameworkAttr.FrameworkName; + + // Try to parse the version using the generated regex + var versionMatch = VersionRegex().Match(frameworkName); + + if (versionMatch.Success) + { + return versionMatch.Groups[1].Value; + } + + return "10.0"; // Default fallback } - private static string GenerateDockerignoreContent() + private static async Task ReadEmbeddedResourceAsync(string resourceName) { - var sb = new StringBuilder(); - - sb.AppendLine("# Git"); - sb.AppendLine(".git"); - sb.AppendLine(".gitignore"); - sb.AppendLine(".gitattributes"); - sb.AppendLine(); - sb.AppendLine("# Build results"); - sb.AppendLine("bin/"); - sb.AppendLine("obj/"); - sb.AppendLine("[Bb]uild/"); - sb.AppendLine("[Dd]ebug/"); - sb.AppendLine("[Rr]elease/"); - sb.AppendLine(); - sb.AppendLine("# Visual Studio"); - sb.AppendLine(".vs/"); - sb.AppendLine(".vscode/"); - sb.AppendLine("*.user"); - sb.AppendLine("*.suo"); - sb.AppendLine("*.userosscache"); - sb.AppendLine("*.sln.docstates"); - sb.AppendLine(); - sb.AppendLine("# Test results"); - sb.AppendLine("[Tt]est[Rr]esult*/"); - sb.AppendLine("[Bb]uild[Ll]og.*"); - sb.AppendLine("TestResults/"); - sb.AppendLine(); - sb.AppendLine("# NuGet"); - sb.AppendLine("*.nupkg"); - sb.AppendLine("*.snupkg"); - sb.AppendLine("packages/"); - sb.AppendLine(); - sb.AppendLine("# Docker"); - sb.AppendLine("Dockerfile"); - sb.AppendLine(".dockerignore"); - sb.AppendLine(); - sb.AppendLine("# Kubernetes"); - sb.AppendLine("*.yaml"); - sb.AppendLine("*.yml"); - sb.AppendLine(); - sb.AppendLine("# Documentation"); - sb.AppendLine("*.md"); - sb.AppendLine("README*"); - sb.AppendLine("LICENSE"); - sb.AppendLine(); - sb.AppendLine("# IDE"); - sb.AppendLine(".idea/"); - sb.AppendLine("*.swp"); - sb.AppendLine("*.swo"); - sb.AppendLine("*~"); - - return sb.ToString(); + var assembly = typeof(GenerateDockerfileCommand).Assembly; + var fullResourceName = $"K8sOperator.NET.Templates.{resourceName}"; + + await using var stream = assembly.GetManifestResourceStream(fullResourceName) + ?? throw new InvalidOperationException($"Could not find embedded resource: {fullResourceName}"); + using var reader = new StreamReader(stream); + return await reader.ReadToEndAsync(); } } diff --git a/src/K8sOperator.NET/Commands/GenerateLaunchSettingsCommand.cs b/src/K8sOperator.NET/Commands/GenerateLaunchSettingsCommand.cs index 83e7a7a..2ab43bc 100644 --- a/src/K8sOperator.NET/Commands/GenerateLaunchSettingsCommand.cs +++ b/src/K8sOperator.NET/Commands/GenerateLaunchSettingsCommand.cs @@ -34,7 +34,7 @@ public async Task RunAsync(string[] args) schema = "http://json.schemastore.org/launchsettings.json" }; - var json = JsonSerializer.Serialize(launchSettings, new JsonSerializerOptions + string json = JsonSerializer.Serialize(launchSettings, new JsonSerializerOptions() { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, @@ -74,7 +74,7 @@ private static string ToPascalCase(string input) { sb.Append(char.ToUpper(part[0])); if (part.Length > 1) - sb.Append(part.Substring(1).ToLower()); + sb.Append(part[1..].ToLower()); } } diff --git a/src/K8sOperator.NET/Commands/InstallCommand.cs b/src/K8sOperator.NET/Commands/InstallCommand.cs index 82d6ff9..8fd5d9e 100644 --- a/src/K8sOperator.NET/Commands/InstallCommand.cs +++ b/src/K8sOperator.NET/Commands/InstallCommand.cs @@ -1,4 +1,4 @@ -using k8s; +using k8s; using k8s.Models; using K8sOperator.NET; using K8sOperator.NET.Builder; @@ -14,14 +14,11 @@ public class InstallCommand(IHost app) : IOperatorCommand { private readonly StringWriter _output = new(); - /// - /// - /// - /// public async Task RunAsync(string[] args) { var dataSource = 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); @@ -35,6 +32,7 @@ public async Task RunAsync(string[] args) await Write(clusterrole); await Write(clusterrolebinding); + await Write(ns); await Write(deployment); Console.WriteLine(_output.ToString()); @@ -46,12 +44,26 @@ private async Task Write(IKubernetesObject obj) await _output.WriteLineAsync("---"); } + private static V1Namespace CreateNamespace(IReadOnlyList metadata) + { + var ns = metadata.OfType().FirstOrDefault() + ?? NamespaceAttribute.Default; + + var nsBuilder = KubernetesObjectBuilder.Create(); + nsBuilder.WithName(ns.Namespace); + + return nsBuilder.Build(); + } + private static V1CustomResourceDefinition CreateCustomResourceDefinition(IEventWatcher item) { var group = item.Metadata.OfType().First(); - var scope = item.Metadata.OfType().FirstOrDefault(); + var scope = item.Metadata.OfType().FirstOrDefault() + ?? ScopeAttribute.Default; + + var columns = item.Metadata.OfType(); - var crdBuilder = new CustomResourceDefinitionBuilder(); + var crdBuilder = KubernetesObjectBuilder.Create(); crdBuilder .WithName($"{group.PluralName}.{group.Group}".ToLower()) .WithSpec() @@ -62,7 +74,7 @@ private static V1CustomResourceDefinition CreateCustomResourceDefinition(IEventW plural: group.PluralName.ToLower(), singular: group.Kind.ToLower() ) - .WithScope(scope == null ? EntityScope.Cluster : EntityScope.Namespaced) + .WithScope(scope.Scope) .WithVersion( group.ApiVersion, schema => @@ -70,6 +82,11 @@ private static V1CustomResourceDefinition CreateCustomResourceDefinition(IEventW schema.WithSchemaForType(item.Controller.ResourceType); schema.WithServed(true); schema.WithStorage(true); + foreach (var column in columns) + { + schema.WithAdditionalPrinterColumn(column.Name, column.Type, column.Description, column.Path); + } + }); return crdBuilder.Build(); @@ -77,25 +94,28 @@ private static V1CustomResourceDefinition CreateCustomResourceDefinition(IEventW private static V1Deployment CreateDeployment(IReadOnlyList metadata) { - var name = metadata.TryGetValue(x => x.OperatorName)!; - var image = metadata.TryGetValue(x => x.GetImage())!; - var ns = metadata.TryGetValue(x => x.Namespace); + var name = metadata.OfType().FirstOrDefault() + ?? OperatorNameAttribute.Default; + var image = metadata.OfType().FirstOrDefault() + ?? DockerImageAttribute.Default; + var ns = metadata.OfType().FirstOrDefault() + ?? NamespaceAttribute.Default; - var deployment = DeploymentBuilder.Create(); + var deployment = KubernetesObjectBuilder.Create(); deployment - .WithName($"{name}-deployment") - .WithNamespace(ns) - .WithLabel("operator-deployment", name) + .WithName($"{name.OperatorName}") + .WithNamespace(ns.Namespace) + .WithLabel("operator", name.OperatorName) .WithSpec() .WithReplicas(1) .WithRevisionHistory(0) .WithSelector(matchLabels: x => { - x.Add("operator-deployment", name); + x.Add("operator", name.OperatorName); }) .WithTemplate() - .WithLabel("operator-deployment", name) + .WithLabel("operator", name.OperatorName) .WithPod() .WithSecurityContext(b => @@ -113,13 +133,13 @@ private static V1Deployment CreateDeployment(IReadOnlyList metadata) .WithSecurityContext(x => { x.AllowPrivilegeEscalation(false); - x.RunAsRoot(); + x.RunAsNonRoot(); x.RunAsUser(2024); x.RunAsGroup(2024); x.WithCapabilities(x => x.WithDrop("ALL")); }) - .WithName(name) - .WithImage(image) + .WithName(name.OperatorName) + .WithImage(image.GetImage()) .WithResources( limits: x => { @@ -138,22 +158,27 @@ private static V1Deployment CreateDeployment(IReadOnlyList metadata) private static V1ClusterRoleBinding CreateClusterRoleBinding(IReadOnlyList metadata) { - var name = metadata.TryGetValue(x => x.OperatorName); + var name = metadata.OfType().FirstOrDefault() + ?? OperatorNameAttribute.Default; + + var ns = metadata.OfType().FirstOrDefault() + ?? NamespaceAttribute.Default; - var clusterrolebinding = new ClusterRoleBindingBuilder() - .WithName($"{name}-role-binding") - .WithRoleRef("rbac.authorization.k8s.io", "ClusterRole", $"{name}-role") - .WithSubject(kind: "ServiceAccount", name: "default", ns: "system"); + 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); return clusterrolebinding.Build(); } private static V1ClusterRole CreateClusterRole(IReadOnlyList metadata, IEnumerable watchers) { - var name = metadata.TryGetValue(x => x.OperatorName); + var name = metadata.OfType().FirstOrDefault() + ?? OperatorNameAttribute.Default; - var clusterrole = new ClusterRoleBuilder() - .WithName($"{name}-role"); + var clusterrole = KubernetesObjectBuilder.Create() + .WithName($"{name.OperatorName}-role"); clusterrole.AddRule() .WithGroups("") diff --git a/src/K8sOperator.NET/Commands/VersionCommand.cs b/src/K8sOperator.NET/Commands/VersionCommand.cs index 17166ff..c1c4ea6 100644 --- a/src/K8sOperator.NET/Commands/VersionCommand.cs +++ b/src/K8sOperator.NET/Commands/VersionCommand.cs @@ -13,17 +13,19 @@ internal class VersionCommand(IHost app) : IOperatorCommand public Task RunAsync(string[] args) { var watcher = app.Services.GetRequiredService(); - var name = watcher.Metadata.TryGetValue(x => x.OperatorName); - var version = watcher.Metadata.TryGetValue(x => x.Tag); + var name = watcher.Metadata.OfType().FirstOrDefault() + ?? OperatorNameAttribute.Default; + var version = watcher.Metadata.OfType().FirstOrDefault() + ?? DockerImageAttribute.Default; - if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(version)) + if (string.IsNullOrWhiteSpace(name.OperatorName) || string.IsNullOrWhiteSpace(version.Tag)) { Console.WriteLine("Operator name or version metadata is missing."); return Task.CompletedTask; } Console.WriteLine($"{name} version {version}."); - Console.WriteLine($"Docker Info: {watcher.Metadata.TryGetValue(x => x.GetImage())}."); + Console.WriteLine($"Docker Info: {version.GetImage()}."); return Task.CompletedTask; } } diff --git a/src/K8sOperator.NET/EventWatcher.cs b/src/K8sOperator.NET/EventWatcher.cs index 1bf7d2d..0774ac4 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.Generation; using K8sOperator.NET.Metadata; using Microsoft.Extensions.Logging; using System.Text.Json; @@ -29,7 +30,7 @@ public async Task Start(CancellationToken cancellationToken) { try { - Logger.BeginWatch(_crd.PluralName, _labelSelector); + Logger.BeginWatch(Crd.PluralName, LabelSelector.LabelSelector); await foreach (var (type, item) in GetWatchStream()) { @@ -67,7 +68,7 @@ public async Task Start(CancellationToken cancellationToken) } finally { - Logger.EndWatch(_crd.PluralName, _labelSelector); + Logger.EndWatch(Crd.PluralName, LabelSelector.LabelSelector); if (!cancellationToken.IsCancellationRequested) { @@ -197,14 +198,14 @@ private async Task HandleDeletedEventAsync(T resource, CancellationToken cancell private Task RemoveFinalizerAsync(T resource, CancellationToken cancellationToken) { - resource.Metadata.Finalizers.Remove(Finalizer); + resource.Metadata.Finalizers.Remove(Finalizer.Finalizer); return ReplaceAsync(resource, cancellationToken); } private Task AddFinalizerAsync(T resource, CancellationToken cancellationToken) { // Add the finalizer - resource.Metadata.EnsureFinalizers().Add(Finalizer); + resource.Metadata.EnsureFinalizers().Add(Finalizer.Finalizer); return ReplaceAsync(resource, cancellationToken); } @@ -221,7 +222,7 @@ private async Task ReplaceAsync(T resource, CancellationToken cancellationTok } private bool HasFinalizers(T resource) - => resource.Metadata.Finalizers?.Contains(Finalizer) ?? false; + => resource.Metadata.Finalizers?.Contains(Finalizer.Finalizer) ?? false; private async Task HandleAddOrModifyAsync(T resource, CancellationToken cancellationToken) { @@ -251,65 +252,62 @@ private async Task HandleAddOrModifyAsync(T resource, CancellationToken cancella private Task ResourceReplaceAsync(T resource, CancellationToken cancellationToken) { - var ns = metadata.OfType().FirstOrDefault(); - if (ns == null) + return Scope.Scope switch { - return kubernetes.CustomObjects.ReplaceClusterCustomObjectAsync( + EntityScope.Cluster => kubernetes.CustomObjects.ReplaceClusterCustomObjectAsync( body: resource, - group: _crd.Group, - version: _crd.ApiVersion, - plural: _crd.PluralName, + group: Crd.Group, + version: Crd.ApiVersion, + plural: Crd.PluralName, name: resource.Metadata.Name, - cancellationToken: cancellationToken); - } - else - { - return kubernetes.CustomObjects.ReplaceNamespacedCustomObjectAsync( + cancellationToken: cancellationToken), + + EntityScope.Namespaced => kubernetes.CustomObjects.ReplaceNamespacedCustomObjectAsync( body: resource, - group: _crd.Group, - version: _crd.ApiVersion, - namespaceParameter: ns.Namespace, - plural: _crd.PluralName, + group: Crd.Group, + version: Crd.ApiVersion, + namespaceParameter: Namespace.Namespace, + plural: Crd.PluralName, name: resource.Metadata.Name, - cancellationToken: cancellationToken); - } + cancellationToken: cancellationToken), + + _ => throw new ArgumentException("Invalid scope"), + }; } private IAsyncEnumerable<(WatchEventType type, object item)> GetWatchStream() { - var ns = metadata.OfType().FirstOrDefault(); - - if (ns == null) + return Scope.Scope switch { - return kubernetes.CustomObjects.WatchListClusterCustomObjectAsync( - group: _crd.Group, - version: _crd.ApiVersion, - plural: _crd.PluralName, + EntityScope.Cluster => kubernetes.CustomObjects.WatchListClusterCustomObjectAsync( + group: Crd.Group, + version: Crd.ApiVersion, + plural: Crd.PluralName, allowWatchBookmarks: true, - labelSelector: _labelSelector, + labelSelector: LabelSelector.LabelSelector, timeoutSeconds: (int)TimeSpan.FromMinutes(60).TotalSeconds, onError: (ex) => { - Logger.LogWatchError(ex, "cluster-wide", _crd.PluralName, _labelSelector); + Logger.LogWatchError(ex, "cluster-wide", Crd.PluralName, LabelSelector.LabelSelector); }, - cancellationToken: _cancellationToken); - } - else - { - return kubernetes.CustomObjects.WatchListNamespacedCustomObjectAsync( - group: _crd.Group, - version: _crd.ApiVersion, - namespaceParameter: ns.Namespace, - plural: _crd.PluralName, + cancellationToken: _cancellationToken), + + EntityScope.Namespaced => kubernetes.CustomObjects.WatchListNamespacedCustomObjectAsync( + group: Crd.Group, + version: Crd.ApiVersion, + namespaceParameter: Namespace.Namespace, + plural: Crd.PluralName, allowWatchBookmarks: true, - labelSelector: _labelSelector, + labelSelector: LabelSelector.LabelSelector, timeoutSeconds: (int)TimeSpan.FromMinutes(60).TotalSeconds, onError: (ex) => { - Logger.LogWatchError(ex, ns.Namespace, _crd.PluralName, _labelSelector); + Logger.LogWatchError(ex, Namespace.Namespace, Crd.PluralName, LabelSelector.LabelSelector); }, - cancellationToken: _cancellationToken); - } + cancellationToken: _cancellationToken), + + _ => throw new ArgumentException("Invalid scope"), + }; } private CancellationToken _cancellationToken = CancellationToken.None; @@ -317,9 +315,17 @@ private Task ResourceReplaceAsync(T resource, CancellationToken cancellationT private readonly ChangeTracker _changeTracker = new(); - private readonly KubernetesEntityAttribute _crd = metadata.OfType().FirstOrDefault() + 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 string Finalizer => Metadata.OfType().FirstOrDefault()?.Finalizer ?? FinalizerAttribute.Default; - private readonly string _labelSelector = metadata.OfType().FirstOrDefault()?.LabelSelector ?? string.Empty; + private NamespaceAttribute Namespace => Metadata.OfType().FirstOrDefault() ?? + NamespaceAttribute.Default; + + private ScopeAttribute Scope => Metadata.OfType().FirstOrDefault() ?? + ScopeAttribute.Default; + private FinalizerAttribute Finalizer => Metadata.OfType().FirstOrDefault() + ?? FinalizerAttribute.Default; + + private LabelSelectorAttribute LabelSelector => Metadata.OfType().FirstOrDefault() + ?? LabelSelectorAttribute.Default; } diff --git a/src/K8sOperator.NET/Generation/ClusterRoleBindingBuilder.cs b/src/K8sOperator.NET/Generation/ClusterRoleBindingBuilder.cs deleted file mode 100644 index 22d8727..0000000 --- a/src/K8sOperator.NET/Generation/ClusterRoleBindingBuilder.cs +++ /dev/null @@ -1,14 +0,0 @@ -using k8s; -using k8s.Models; - -namespace K8sOperator.NET.Generation; - -internal class ClusterRoleBindingBuilder : KubernetesObjectBuilderWithMetadata -{ - public override V1ClusterRoleBinding Build() - { - var role = base.Build(); - role.Initialize(); - return role; - } -} diff --git a/src/K8sOperator.NET/Generation/ClusterRoleBindingBuilderExtensions.cs b/src/K8sOperator.NET/Generation/ClusterRoleBindingBuilderExtensions.cs index 35be0e5..1ba3848 100644 --- a/src/K8sOperator.NET/Generation/ClusterRoleBindingBuilderExtensions.cs +++ b/src/K8sOperator.NET/Generation/ClusterRoleBindingBuilderExtensions.cs @@ -18,7 +18,7 @@ public static class ClusterRoleBindingBuilderExtensions /// The name of the role. /// The configured builder. public static TBuilder WithRoleRef(this TBuilder builder, string apiGroup, string kind, string name) - where TBuilder : IKubernetesObjectBuilder + where TBuilder : IObjectBuilder { builder.Add(x => { @@ -39,7 +39,7 @@ public static TBuilder WithRoleRef(this TBuilder builder, string apiGr /// The namespace of the subject, if applicable. /// The configured builder. public static TBuilder WithSubject(this TBuilder builder, string kind, string name, string? apiGroup = null, string? ns = null) - where TBuilder : IKubernetesObjectBuilder + where TBuilder : IObjectBuilder { builder.Add(x => { diff --git a/src/K8sOperator.NET/Generation/ClusterRoleBuilder.cs b/src/K8sOperator.NET/Generation/ClusterRoleBuilder.cs deleted file mode 100644 index 70e145e..0000000 --- a/src/K8sOperator.NET/Generation/ClusterRoleBuilder.cs +++ /dev/null @@ -1,14 +0,0 @@ -using k8s; -using k8s.Models; - -namespace K8sOperator.NET.Generation; - -internal class ClusterRoleBuilder : KubernetesObjectBuilderWithMetadata -{ - public override V1ClusterRole Build() - { - var role = base.Build(); - role.Initialize(); - return role; - } -} diff --git a/src/K8sOperator.NET/Generation/ClusterRoleBuilderExtensions.cs b/src/K8sOperator.NET/Generation/ClusterRoleBuilderExtensions.cs index 958749c..192e68c 100644 --- a/src/K8sOperator.NET/Generation/ClusterRoleBuilderExtensions.cs +++ b/src/K8sOperator.NET/Generation/ClusterRoleBuilderExtensions.cs @@ -12,9 +12,9 @@ public static class ClusterRoleBuilderExtensions /// /// The builder instance for the ClusterRole. /// A builder for configuring the policy rule. - public static IKubernetesObjectBuilder AddRule(this IKubernetesObjectBuilder builder) + public static IObjectBuilder AddRule(this IObjectBuilder builder) { - var b = new PolicyRuleBuilder(); + var b = new ObjectBuilder(); builder.Add(x => { x.Rules ??= []; diff --git a/src/K8sOperator.NET/Generation/ContainerBuilder.cs b/src/K8sOperator.NET/Generation/ContainerBuilder.cs deleted file mode 100644 index 18d4bc8..0000000 --- a/src/K8sOperator.NET/Generation/ContainerBuilder.cs +++ /dev/null @@ -1,8 +0,0 @@ -using k8s.Models; - -namespace K8sOperator.NET.Generation; - -internal class ContainerBuilder : KubernetesObjectBuilder -{ - -} diff --git a/src/K8sOperator.NET/Generation/CustomResourceDefinitionBuilder.cs b/src/K8sOperator.NET/Generation/CustomResourceDefinitionBuilder.cs deleted file mode 100644 index a446353..0000000 --- a/src/K8sOperator.NET/Generation/CustomResourceDefinitionBuilder.cs +++ /dev/null @@ -1,15 +0,0 @@ -using k8s; -using k8s.Models; - -namespace K8sOperator.NET.Generation; - -internal class CustomResourceDefinitionBuilder : KubernetesObjectBuilderWithMetadata -{ - public override V1CustomResourceDefinition Build() - { - var crd = base.Build(); - crd.Initialize(); - return crd; - } -} - diff --git a/src/K8sOperator.NET/Generation/CustomResourceDefinitionBuilderExtensions.cs b/src/K8sOperator.NET/Generation/CustomResourceDefinitionBuilderExtensions.cs index 43c2478..557c9d1 100644 --- a/src/K8sOperator.NET/Generation/CustomResourceDefinitionBuilderExtensions.cs +++ b/src/K8sOperator.NET/Generation/CustomResourceDefinitionBuilderExtensions.cs @@ -1,6 +1,7 @@ using k8s; using k8s.Models; using System.Reflection; +using System.Reflection.Emit; namespace K8sOperator.NET.Generation; @@ -16,10 +17,10 @@ public static partial class CustomResourceDefinitionBuilderExtensions /// The type of the builder. /// The builder instance. /// A builder for configuring the CustomResourceDefinition spec. - public static IKubernetesObjectBuilder WithSpec(this TBuilder builder) - where TBuilder : IKubernetesObjectBuilder + public static IObjectBuilder WithSpec(this TBuilder builder) + where TBuilder : IObjectBuilder { - var specBuilder = new KubernetesObjectBuilder(); + var specBuilder = new ObjectBuilder(); builder.Add(x => x.Spec = specBuilder.Build()); return specBuilder; } @@ -32,7 +33,7 @@ public static IKubernetesObjectBuilder WithSpec< /// The API group of the CustomResourceDefinition. /// The configured builder. public static TBuilder WithGroup(this TBuilder builder, string group) - where TBuilder : IKubernetesObjectBuilder + where TBuilder : IObjectBuilder { builder.Add(x => x.Group = group); return builder; @@ -57,7 +58,7 @@ public static TBuilder WithNames(this TBuilder builder, string singular, string[]? shortnames = null, string[]? categories = null) - where TBuilder : IKubernetesObjectBuilder + where TBuilder : IObjectBuilder { builder.Add(x => x.Names = new() { @@ -79,7 +80,7 @@ public static TBuilder WithNames(this TBuilder builder, /// The scope of the CustomResourceDefinition. /// The configured builder. public static TBuilder WithScope(this TBuilder builder, EntityScope scope) - where TBuilder : IKubernetesObjectBuilder + where TBuilder : IObjectBuilder { builder.Add(x => x.Scope = scope.ToString()); return builder; @@ -93,10 +94,10 @@ public static TBuilder WithScope(this TBuilder builder, EntityScope sc /// The name of the version. /// An action to configure the schema for the version. /// The configured builder. - public static TBuilder WithVersion(this TBuilder builder, string name, Action> schema) - where TBuilder : IKubernetesObjectBuilder + public static TBuilder WithVersion(this TBuilder builder, string name, Action> schema) + where TBuilder : IObjectBuilder { - var b = new KubernetesObjectBuilder(); + var b = new ObjectBuilder(); b.Add(x => x.Name = name); schema(b); @@ -121,7 +122,7 @@ public static TBuilder WithVersion(this TBuilder builder, string name, /// A value indicating whether the version is served. /// The configured builder. public static TBuilder WithServed(this TBuilder builder, bool served) - where TBuilder : IKubernetesObjectBuilder + where TBuilder : IObjectBuilder { builder.Add(x => x.Served = served); return builder; @@ -135,12 +136,31 @@ public static TBuilder WithServed(this TBuilder builder, bool served) /// A value indicating whether the version is stored. /// The configured builder. public static TBuilder WithStorage(this TBuilder builder, bool storage) - where TBuilder : IKubernetesObjectBuilder + where TBuilder : IObjectBuilder { builder.Add(x => x.Storage = storage); return builder; } + public static TBuilder WithAdditionalPrinterColumn(this TBuilder builder, + string name, string type, string description, string jsonPath, int priority = 0) + where TBuilder : IObjectBuilder + { + builder.Add(x => { + x.AdditionalPrinterColumns ??= []; + x.AdditionalPrinterColumns.Add(new V1CustomResourceColumnDefinition() + { + Name = name, + Type = type, + Description = description, + JsonPath = jsonPath, + Priority = priority, + }); + }); + + return builder; + } + /// /// Configures the schema for the CustomResourceDefinition version based on the specified resource type. /// @@ -149,10 +169,10 @@ public static TBuilder WithStorage(this TBuilder builder, bool storage /// The type of the resource. /// The configured builder. public static TBuilder WithSchemaForType(this TBuilder builder, Type resourceType) - where TBuilder : IKubernetesObjectBuilder + where TBuilder : IObjectBuilder { - var b = new KubernetesObjectBuilder(); - var s = new KubernetesObjectBuilder(); + var b = new ObjectBuilder(); + var s = new ObjectBuilder(); s.OfType("object"); @@ -180,11 +200,11 @@ public static TBuilder WithSchemaForType(this TBuilder builder, Type r /// The builder instance. /// An action to configure the schema. /// The configured builder. - public static TBuilder WithSchema(this TBuilder builder, Action> schema) - where TBuilder : IKubernetesObjectBuilder + public static TBuilder WithSchema(this TBuilder builder, Action> schema) + where TBuilder : IObjectBuilder { - var b = new KubernetesObjectBuilder(); - var s = new KubernetesObjectBuilder(); + var b = new ObjectBuilder(); + var s = new ObjectBuilder(); schema(s); builder.Add(x => @@ -203,7 +223,7 @@ public static TBuilder WithSchema(this TBuilder builder, ActionThe type of the schema property. /// The configured builder. public static TBuilder OfType(this TBuilder builder, string type) - where TBuilder : IKubernetesObjectBuilder + where TBuilder : IObjectBuilder { builder.Add(x => x.Type = type); return builder; @@ -219,7 +239,7 @@ public static TBuilder OfType(this TBuilder builder, string type) /// The configured builder. /// Thrown when the provided type is not valid. public static TBuilder OfType(this TBuilder builder, Type type, bool? nullable = false) - where TBuilder : IKubernetesObjectBuilder + where TBuilder : IObjectBuilder { if (type.FullName == "System.String") { @@ -293,7 +313,7 @@ public static TBuilder OfType(this TBuilder builder, Type type, bool? /// A value indicating whether the property is nullable. /// The configured builder. public static TBuilder IsNullable(this TBuilder builder, bool nullable) - where TBuilder : IKubernetesObjectBuilder + where TBuilder : IObjectBuilder { builder.Add(x => x.Nullable = nullable); return builder; @@ -307,10 +327,10 @@ public static TBuilder IsNullable(this TBuilder builder, bool nullable /// The name of the property. /// An action to configure the schema for the property. /// The configured builder. - public static TBuilder WithProperty(this TBuilder builder, string name, Action> schema) - where TBuilder : IKubernetesObjectBuilder + public static TBuilder WithProperty(this TBuilder builder, string name, Action> schema) + where TBuilder : IObjectBuilder { - var p = new KubernetesObjectBuilder(); + var p = new ObjectBuilder(); schema(p); builder.Add(x => @@ -329,7 +349,7 @@ public static TBuilder WithProperty(this TBuilder builder, string name /// The names of the required properties. /// The configured builder. public static TBuilder WithRequired(this TBuilder builder, IEnumerable? names) - where TBuilder : IKubernetesObjectBuilder + where TBuilder : IObjectBuilder { builder.Add(x => x.Required = names?.Select(name => $"{name[..1].ToLowerInvariant()}{name[1..]}").ToList()); return builder; @@ -337,7 +357,7 @@ public static TBuilder WithRequired(this TBuilder builder, IEnumerable private static TBuilder ObjectType(this TBuilder builder, Type type) - where TBuilder : IKubernetesObjectBuilder + where TBuilder : IObjectBuilder { switch (type.FullName) { @@ -390,7 +410,7 @@ private static TBuilder ObjectType(this TBuilder builder, Type type) } } private static TBuilder EnumType(this TBuilder builder, Type type) - where TBuilder : IKubernetesObjectBuilder + where TBuilder : IObjectBuilder { builder.Add(x => { diff --git a/src/K8sOperator.NET/Generation/DeploymentBuilder.cs b/src/K8sOperator.NET/Generation/DeploymentBuilder.cs deleted file mode 100644 index 821ef42..0000000 --- a/src/K8sOperator.NET/Generation/DeploymentBuilder.cs +++ /dev/null @@ -1,27 +0,0 @@ -using k8s; -using k8s.Models; - -namespace K8sOperator.NET.Generation; - -/// -/// Provides functionality for creating Kubernetes Deployment objects. -/// -public static class DeploymentBuilder -{ - /// - /// Creates a new instance of a deployment builder that includes metadata configuration. - /// - /// An instance of for building a Kubernetes Deployment. - public static IKubernetesObjectBuilderWithMetadata Create() - => new DeploymentBuilderImp(); -} - -internal class DeploymentBuilderImp : KubernetesObjectBuilderWithMetadata -{ - public override V1Deployment Build() - { - var deployment = base.Build(); - deployment.Initialize(); - return deployment; - } -} diff --git a/src/K8sOperator.NET/Generation/DeploymentBuilderExtensions.cs b/src/K8sOperator.NET/Generation/DeploymentBuilderExtensions.cs index 97e3d1e..354bacc 100644 --- a/src/K8sOperator.NET/Generation/DeploymentBuilderExtensions.cs +++ b/src/K8sOperator.NET/Generation/DeploymentBuilderExtensions.cs @@ -14,10 +14,10 @@ public static class DeploymentBuilderExtensions /// The type of the builder. /// The builder instance. /// A builder for configuring the deployment spec. - public static IKubernetesObjectBuilder WithSpec(this TBuilder builder) - where TBuilder : IKubernetesObjectBuilder + public static IObjectBuilder WithSpec(this TBuilder builder) + where TBuilder : IObjectBuilder { - var specBuilder = new KubernetesObjectBuilder(); + var specBuilder = new ObjectBuilder(); builder.Add(x => x.Spec = specBuilder.Build()); return specBuilder; } @@ -30,7 +30,7 @@ public static IKubernetesObjectBuilder WithSpec(this /// The number of replicas. Defaults to 1. /// The configured builder. public static TBuilder WithReplicas(this TBuilder builder, int replicas = 1) - where TBuilder : IKubernetesObjectBuilder + where TBuilder : IObjectBuilder { builder.Add(x => x.Replicas = replicas); return builder; @@ -44,7 +44,7 @@ public static TBuilder WithReplicas(this TBuilder builder, int replica /// The revision history limit. Defaults to 0. /// The configured builder. public static TBuilder WithRevisionHistory(this TBuilder builder, int revisionHistoryLimit = 0) - where TBuilder : IKubernetesObjectBuilder + where TBuilder : IObjectBuilder { builder.Add(x => x.RevisionHistoryLimit = revisionHistoryLimit); return builder; @@ -61,7 +61,7 @@ public static TBuilder WithRevisionHistory(this TBuilder builder, int public static TBuilder WithSelector(this TBuilder builder, Action>? matchExpressions = null, Action>? matchLabels = null) - where TBuilder : IKubernetesObjectBuilder + where TBuilder : IObjectBuilder { var labels = new Dictionary(); matchLabels?.Invoke(labels); @@ -83,10 +83,11 @@ public static TBuilder WithSelector(this TBuilder builder, /// The type of the builder. /// The builder instance. /// A builder for configuring the pod template spec. - public static IKubernetesObjectBuilder WithTemplate(this TBuilder builder) - where TBuilder : IKubernetesObjectBuilder + public static IObjectBuilder WithTemplate(this TBuilder builder) + where TBuilder : IObjectBuilder { - var podBuilder = new KubernetesObjectBuilderWithMetadata(); + var podBuilder = KubernetesObjectBuilder.CreateMeta(); + builder.Add(x => x.Template = podBuilder.Build()); return podBuilder; } @@ -97,10 +98,10 @@ public static IKubernetesObjectBuilder WithTemplate /// The type of the builder. /// The builder instance. /// A builder for configuring the pod spec. - public static IKubernetesObjectBuilder WithPod(this TBuilder builder) - where TBuilder : IKubernetesObjectBuilder + public static IObjectBuilder WithPod(this TBuilder builder) + where TBuilder : IObjectBuilder { - var podBuilder = new KubernetesObjectBuilder(); + var podBuilder = new ObjectBuilder(); builder.Add(x => x.Spec = podBuilder.Build()); return podBuilder; } @@ -113,10 +114,10 @@ public static IKubernetesObjectBuilder WithPod(this TBuilde /// The type of the builder. /// The builder instance. /// A builder for configuring the container. - public static IKubernetesObjectBuilder AddContainer(this TBuilder builder) - where TBuilder : IKubernetesObjectBuilder + public static IObjectBuilder AddContainer(this TBuilder builder) + where TBuilder : IObjectBuilder { - var b = new ContainerBuilder(); + var b = new ObjectBuilder(); builder.Add(x => { x.Containers ??= []; @@ -133,7 +134,7 @@ public static IKubernetesObjectBuilder AddContainer(this /// The name of the container. /// The configured builder. public static TBuilder WithName(this TBuilder builder, string name) - where TBuilder : IKubernetesObjectBuilder + where TBuilder : IObjectBuilder { builder.Add(x => x.Name = name); return builder; @@ -147,7 +148,7 @@ public static TBuilder WithName(this TBuilder builder, string name) /// The image of the container. /// The configured builder. public static TBuilder WithImage(this TBuilder builder, string image) - where TBuilder : IKubernetesObjectBuilder + where TBuilder : IObjectBuilder { builder.Add(x => x.Image = image); return builder; @@ -167,7 +168,7 @@ public static TBuilder WithResources(this TBuilder builder, Action>? limits = null, Action>? requests = null ) - where TBuilder : IKubernetesObjectBuilder + where TBuilder : IObjectBuilder { var c = new List(); claims?.Invoke(c); @@ -195,7 +196,7 @@ public static TBuilder WithResources(this TBuilder builder, /// An action to configure the object field selector. /// The configured builder. public static TBuilder AddEnvFromObjectField(this TBuilder builder, string name, Action action) - where TBuilder : IKubernetesObjectBuilder + where TBuilder : IObjectBuilder { return builder.AddEnv(name, action); } @@ -209,7 +210,7 @@ public static TBuilder AddEnvFromObjectField(this TBuilder builder, st /// An action to configure the ConfigMap key selector. /// The configured builder. public static TBuilder AddEnvFromSecretKey(this TBuilder builder, string name, Action action) - where TBuilder : IKubernetesObjectBuilder + where TBuilder : IObjectBuilder { return builder.AddEnv(name, action); } @@ -223,7 +224,7 @@ public static TBuilder AddEnvFromSecretKey(this TBuilder builder, stri /// An action to configure the secret key selector. /// The configured builder. public static TBuilder AddEnvFromConfigMapKey(this TBuilder builder, string name, Action action) - where TBuilder : IKubernetesObjectBuilder + where TBuilder : IObjectBuilder { return builder.AddEnv(name, action); } @@ -237,7 +238,7 @@ public static TBuilder AddEnvFromConfigMapKey(this TBuilder builder, s /// An action to configure the resource field selector. /// The configured builder. public static TBuilder AddEnvFromResourceField(this TBuilder builder, string name, Action action) - where TBuilder : IKubernetesObjectBuilder + where TBuilder : IObjectBuilder { return builder.AddEnv(name, action); } @@ -249,10 +250,10 @@ public static TBuilder AddEnvFromResourceField(this TBuilder builder, /// The builder instance. /// An action to configure the security context. /// The configured builder. - public static TBuilder WithSecurityContext(this TBuilder builder, Action> securityContext) - where TBuilder : IKubernetesObjectBuilder + public static TBuilder WithSecurityContext(this TBuilder builder, Action> securityContext) + where TBuilder : IObjectBuilder { - var b = new KubernetesObjectBuilder(); + var b = new ObjectBuilder(); securityContext(b); builder.Add(x => x.SecurityContext = b.Build()); @@ -266,10 +267,10 @@ public static TBuilder WithSecurityContext(this TBuilder builder, Acti /// The builder instance. /// An action to configure the security context. /// The configured builder. - public static TBuilder WithSecurityContext(this TBuilder builder, Action> securityContext) - where TBuilder : IKubernetesObjectBuilder + public static TBuilder WithSecurityContext(this TBuilder builder, Action> securityContext) + where TBuilder : IObjectBuilder { - var b = new KubernetesObjectBuilder(); + var b = new ObjectBuilder(); securityContext(b); builder.Add(x => x.SecurityContext = b.Build()); @@ -284,7 +285,7 @@ public static TBuilder WithSecurityContext(this TBuilder builder, Acti /// A value indicating whether to allow privilege escalation. Defaults to true. /// The configured builder. public static TBuilder AllowPrivilegeEscalation(this TBuilder builder, bool allowPrivilegeEscalation = true) - where TBuilder : IKubernetesObjectBuilder + where TBuilder : IObjectBuilder { builder.Add(x => x.AllowPrivilegeEscalation = allowPrivilegeEscalation); return builder; @@ -297,10 +298,10 @@ public static TBuilder AllowPrivilegeEscalation(this TBuilder builder, /// The builder instance. /// A value indicating whether to run as a root user. Defaults to true. /// The configured builder. - public static TBuilder RunAsRoot(this TBuilder builder, bool runAsRoot = true) - where TBuilder : IKubernetesObjectBuilder + public static TBuilder RunAsNonRoot(this TBuilder builder, bool runAsNonRoot = true) + where TBuilder : IObjectBuilder { - builder.Add(x => x.RunAsNonRoot = runAsRoot); + builder.Add(x => x.RunAsNonRoot = runAsNonRoot); return builder; } @@ -312,7 +313,7 @@ public static TBuilder RunAsRoot(this TBuilder builder, bool runAsRoot /// The user ID to run the container as. /// The configured builder. public static TBuilder RunAsUser(this TBuilder builder, int userId) - where TBuilder : IKubernetesObjectBuilder + where TBuilder : IObjectBuilder { builder.Add(x => x.RunAsUser = userId); return builder; @@ -326,7 +327,7 @@ public static TBuilder RunAsUser(this TBuilder builder, int userId) /// The group ID to run the container as. /// The configured builder. public static TBuilder RunAsGroup(this TBuilder builder, int groupId) - where TBuilder : IKubernetesObjectBuilder + where TBuilder : IObjectBuilder { builder.Add(x => x.RunAsGroup = groupId); return builder; @@ -339,10 +340,10 @@ public static TBuilder RunAsGroup(this TBuilder builder, int groupId) /// The builder instance. /// An action to configure the security capabilities. /// The configured builder. - public static TBuilder WithCapabilities(this TBuilder builder, Action> capabilities) - where TBuilder : IKubernetesObjectBuilder + public static TBuilder WithCapabilities(this TBuilder builder, Action> capabilities) + where TBuilder : IObjectBuilder { - var b = new KubernetesObjectBuilder(); + var b = new ObjectBuilder(); capabilities(b); builder.Add(x => x.Capabilities = b.Build()); return builder; @@ -356,7 +357,7 @@ public static TBuilder WithCapabilities(this TBuilder builder, Action< /// An array of capabilities to drop. /// The configured builder. public static TBuilder WithDrop(this TBuilder builder, params string[] capability) - where TBuilder : IKubernetesObjectBuilder + where TBuilder : IObjectBuilder { builder.Add(x => x.Drop = capability); return builder; @@ -371,7 +372,7 @@ public static TBuilder WithDrop(this TBuilder builder, params string[] /// The value of the environment variable. /// The configured builder. public static TBuilder AddEnv(this TBuilder builder, string name, string value) - where TBuilder : IKubernetesObjectBuilder + where TBuilder : IObjectBuilder { builder.Add(x => { @@ -397,7 +398,7 @@ public static TBuilder AddEnv(this TBuilder builder, string name, stri /// The configured builder. /// Thrown when the resource type is not supported. public static TBuilder AddEnv(this TBuilder builder, string name, Action action) - where TBuilder : IKubernetesObjectBuilder + where TBuilder : IObjectBuilder where T : new() { var value = new T(); @@ -431,7 +432,7 @@ public static TBuilder AddEnv(this TBuilder builder, string name, A /// The duration in seconds that Kubernetes waits before forcefully terminating the pod. /// The configured builder. public static TBuilder WithTerminationGracePeriodSeconds(this TBuilder builder, int terminationGracePeriodSeconds) - where TBuilder : IKubernetesObjectBuilder + where TBuilder : IObjectBuilder { builder.Add(x => { diff --git a/src/K8sOperator.NET/Generation/KubernetesObjectBuilder.cs b/src/K8sOperator.NET/Generation/KubernetesObjectBuilder.cs index 1cb6dce..914f50f 100644 --- a/src/K8sOperator.NET/Generation/KubernetesObjectBuilder.cs +++ b/src/K8sOperator.NET/Generation/KubernetesObjectBuilder.cs @@ -1,16 +1,19 @@ -namespace K8sOperator.NET.Generation; +using k8s; +using k8s.Models; + +namespace K8sOperator.NET.Generation; /// /// Describes a Generic Kubernetes Resource Builder /// /// -public interface IKubernetesObjectBuilder +public interface IObjectBuilder { /// /// Adds an action to the builder. /// /// - void Add(Action action); + IObjectBuilder Add(Action action); /// /// Builds the resource with the added actions. @@ -19,14 +22,17 @@ public interface IKubernetesObjectBuilder T Build(); } -internal class KubernetesObjectBuilder : IKubernetesObjectBuilder - where T : class, new() +internal class ObjectBuilder : IObjectBuilder + where T : new() { private readonly List> _actions = []; - public void Add(Action action) + public static IObjectBuilder Create() => new ObjectBuilder(); + + public IObjectBuilder Add(Action action) { _actions.Add(action); + return this; } public virtual T Build() @@ -41,3 +47,23 @@ public virtual T Build() return o; } } + +public static class KubernetesObjectBuilder +{ + /// + /// Creates a new Kubernetes object builder for the specified type. + /// + /// The type of the Kubernetes object. + /// A new instance of . + public static IObjectBuilder Create() + where T : IKubernetesObject, new() + { + return new ObjectBuilder().Add(x => x.Initialize()); + } + + public static IObjectBuilder CreateMeta() + where T : IMetadata, new() + { + return new ObjectBuilder().Add(x => x.Metadata = new()); + } +} diff --git a/src/K8sOperator.NET/Generation/KubernetesObjectBuilderExtentions.cs b/src/K8sOperator.NET/Generation/KubernetesObjectBuilderExtentions.cs index ac6af6d..6384bf3 100644 --- a/src/K8sOperator.NET/Generation/KubernetesObjectBuilderExtentions.cs +++ b/src/K8sOperator.NET/Generation/KubernetesObjectBuilderExtentions.cs @@ -14,7 +14,7 @@ public static class KubernetesObjectBuilderExtentions /// The builder instance. /// The name to assign to the Kubernetes object. /// The configured builder. - public static IKubernetesObjectBuilder WithName(this IKubernetesObjectBuilder builder, string name) + public static IObjectBuilder WithName(this IObjectBuilder builder, string name) where T : IMetadata { builder.Add(x => @@ -31,7 +31,7 @@ public static IKubernetesObjectBuilder WithName(this IKubernetesObjectBuil /// The builder instance. /// The namespace to assign to the Kubernetes object. /// The configured builder. - public static IKubernetesObjectBuilder WithNamespace(this IKubernetesObjectBuilder builder, string? ns) + public static IObjectBuilder WithNamespace(this IObjectBuilder builder, string? ns) where T : IMetadata { builder.Add(x => @@ -49,7 +49,7 @@ public static IKubernetesObjectBuilder WithNamespace(this IKubernetesObjec /// The key of the label. /// The value of the label. /// The configured builder. - public static IKubernetesObjectBuilder WithLabel(this IKubernetesObjectBuilder builder, string key, string value) + public static IObjectBuilder WithLabel(this IObjectBuilder builder, string key, string value) where T : IMetadata { builder.Add(x => diff --git a/src/K8sOperator.NET/Generation/KubernetesObjectBuilderWithMetaData.cs b/src/K8sOperator.NET/Generation/KubernetesObjectBuilderWithMetaData.cs deleted file mode 100644 index 1ab1c2e..0000000 --- a/src/K8sOperator.NET/Generation/KubernetesObjectBuilderWithMetaData.cs +++ /dev/null @@ -1,24 +0,0 @@ -using k8s; -using k8s.Models; - -namespace K8sOperator.NET.Generation; - -/// -/// Represents a builder interface for Kubernetes objects that include metadata. -/// -/// The type of the Kubernetes object that includes metadata. -public interface IKubernetesObjectBuilderWithMetadata : IKubernetesObjectBuilder - where T : IMetadata -{ - -} - -internal class KubernetesObjectBuilderWithMetadata - : KubernetesObjectBuilder, IKubernetesObjectBuilderWithMetadata - where T : class, IMetadata, new() -{ - public KubernetesObjectBuilderWithMetadata() - { - Add(x => x.Metadata = new V1ObjectMeta()); - } -} diff --git a/src/K8sOperator.NET/Generation/KubernetesObjectBuilderWithMetadataExtentions.cs b/src/K8sOperator.NET/Generation/KubernetesObjectBuilderWithMetadataExtentions.cs index 0457981..66c517d 100644 --- a/src/K8sOperator.NET/Generation/KubernetesObjectBuilderWithMetadataExtentions.cs +++ b/src/K8sOperator.NET/Generation/KubernetesObjectBuilderWithMetadataExtentions.cs @@ -16,8 +16,7 @@ public static class KubernetesObjectBuilderWithMetadataExtentions /// The builder instance. /// The name to assign to the Kubernetes object. /// The configured builder. - public static TBuilder WithName(TBuilder builder, string name) - where TBuilder : IKubernetesObjectBuilderWithMetadata + public static IObjectBuilder WithName(IObjectBuilder builder, string name) where T : IKubernetesObject { builder.Add(x => x.Metadata.Name = name); diff --git a/src/K8sOperator.NET/Generation/LeaseBuilderExtensions.cs b/src/K8sOperator.NET/Generation/LeaseBuilderExtensions.cs new file mode 100644 index 0000000..7e23bf4 --- /dev/null +++ b/src/K8sOperator.NET/Generation/LeaseBuilderExtensions.cs @@ -0,0 +1,53 @@ +using k8s.Models; + +namespace K8sOperator.NET.Generation; + +public static class LeaseBuilderExtensions +{ + extension(IObjectBuilder builder) + { + /// + /// Sets the holder identity of the lease. + /// + /// The holder identity to assign to the lease. + /// The configured builder. + public IObjectBuilder WithSpecs() + { + var specBuilder = new ObjectBuilder(); + builder.Add(x => x.Spec = specBuilder.Build()); + return specBuilder; + } + } + + extension(IObjectBuilder builder) + { + /// + /// Sets the holder identity of the lease. + /// + /// The holder identity to assign to the lease. + /// The configured builder. + public IObjectBuilder WithHolderIdentity(string holderIdentity) + { + builder.Add(x => x.HolderIdentity = holderIdentity); + return builder; + } + + public IObjectBuilder WithLeaseDuration(int seconds) + { + builder.Add(x => x.LeaseDurationSeconds = seconds); + return builder; + } + + public IObjectBuilder WithAcquireTime(DateTime acquireTime) + { + builder.Add(x => x.AcquireTime = acquireTime); + return builder; + } + + public IObjectBuilder WithRenewTime(DateTime renewTime) + { + builder.Add(x => x.RenewTime = renewTime); + return builder; + } + } +} diff --git a/src/K8sOperator.NET/Generation/MetadataExtensions.cs b/src/K8sOperator.NET/Generation/MetadataExtensions.cs index e09f7fa..9c9c267 100644 --- a/src/K8sOperator.NET/Generation/MetadataExtensions.cs +++ b/src/K8sOperator.NET/Generation/MetadataExtensions.cs @@ -10,141 +10,102 @@ namespace K8sOperator.NET.Generation; /// public static class MetadataExtensions { - /// - /// Tries to get a value from the metadata collection based on the specified type and selector function. - /// - /// The type to search for in the metadata collection. - /// The type of the value to return. - /// The metadata collection. - /// The selector function to apply if the type is found. - /// The selected value if the type is found; otherwise, the default value of . - public static T2? TryGetValue(this IReadOnlyList metaData, Func selector) + extension(ConventionBuilder builder) { - var type = metaData.OfType().FirstOrDefault(); - return type is null ? default : selector(type); - } + public ConventionBuilder WithClusterScope() + => builder.WithSingle(new ScopeAttribute(EntityScope.Cluster)); - /// - /// Configures the builder with a Kubernetes entity group, version, kind, and plural name. - /// - /// The type of the builder. - /// The builder to configure. - /// The Kubernetes API group. - /// The API version. Defaults to "v1". - /// The kind of the Kubernetes entity. - /// The plural name of the Kubernetes entity. - /// The configured builder. - public static TBuilder WithGroup(this TBuilder builder, - string group = "", - string version = "v1", - string kind = "", - string pluralName = "" - ) - where TBuilder : ConventionBuilder - { - builder.WithSingle(new KubernetesEntityAttribute() - { - Group = group, - ApiVersion = version, - Kind = kind, - PluralName = pluralName - }); - return builder; - } + public ConventionBuilder WithNamespaceScope() + => builder.WithSingle(new ScopeAttribute(EntityScope.Namespaced)); - /// - /// Configures the builder to watch a specific namespace. - /// - /// The type of the builder. - /// The builder to configure. - /// The namespace to watch. - /// The configured builder. - public static TBuilder ForNamespace(this TBuilder builder, string watchNamespace) - where TBuilder : ConventionBuilder - { - builder.WithSingle(new NamespaceAttribute(watchNamespace)); - return builder; - } - - /// - /// Configures the builder with a label selector for filtering Kubernetes resources. - /// - /// The type of the builder. - /// The builder to configure. - /// The label selector string. - /// The configured builder. - public static TBuilder WithLabel(this TBuilder builder, string labelselector) - where TBuilder : ConventionBuilder - { - builder.WithSingle(new LabelSelectorAttribute(labelselector)); - return builder; - } + /// + /// Configures the builder with a Kubernetes entity group, version, kind, and plural name. + /// + /// The type of the builder. + /// The builder to configure. + /// The Kubernetes API group. + /// The API version. Defaults to "v1". + /// The kind of the Kubernetes entity. + /// The plural name of the Kubernetes entity. + /// The configured builder. + public ConventionBuilder WithGroup( + string group = "", + string version = "v1", + string kind = "", + string pluralName = "" + ) + { + return builder.WithSingle(new KubernetesEntityAttribute() + { + Group = group, + ApiVersion = version, + Kind = kind, + PluralName = pluralName + }); + } - /// - /// Configures the builder with a finalizer for the Kubernetes resource. - /// - /// The type of the builder. - /// The builder to configure. - /// The finalizer string. - /// The configured builder. - public static TBuilder WithFinalizer(this TBuilder builder, string finalizer) - where TBuilder : ConventionBuilder - { - builder.WithMetadata(new FinalizerAttribute(finalizer)); - return builder; - } + /// + /// Configures the builder with a label selector for filtering Kubernetes resources. + /// + /// The type of the builder. + /// The builder to configure. + /// The label selector string. + /// The configured builder. + public ConventionBuilder WithLabel(string labelselector) + => builder.WithSingle(new LabelSelectorAttribute(labelselector)); - /// - /// - /// - /// - /// - /// - /// - public static TBuilder RemoveMetadata(this TBuilder builder, object item) - where TBuilder : ConventionBuilder - { - builder.Add(b => - { - b.Metadata.RemoveAll(x => x.GetType() == item.GetType()); - }); + /// + /// Configures the builder with a finalizer for the Kubernetes resource. + /// + /// The type of the builder. + /// The builder to configure. + /// The finalizer string. + /// The configured builder. + public ConventionBuilder WithFinalizer(string finalizer) + => builder.WithSingle(new FinalizerAttribute(finalizer)); - return builder; - } - /// - /// - /// - /// - /// - /// - /// - public static TBuilder WithMetadata(this TBuilder builder, params object[] items) - where TBuilder : ConventionBuilder - { - builder.Add(b => - { - foreach (var item in items) + /// + /// + /// + /// + /// + /// + /// + public ConventionBuilder RemoveMetadata(object item) + => builder.Add(b => { - b.Metadata.Add(item); - } - }); + b.Metadata.RemoveAll(x => x.GetType() == item.GetType()); + }); - return builder; - } + /// + /// + /// + /// + /// + /// + /// + public ConventionBuilder WithMetadata(params object[] items) + => builder.Add(b => + { + foreach (var item in items) + { + b.Metadata.Add(item); + } + }); - /// - /// - /// - /// - /// - /// - /// - public static TBuilder WithSingle(this TBuilder builder, object metadata) - where TBuilder : ConventionBuilder - { - builder.RemoveMetadata(metadata); - builder.WithMetadata(metadata); - return builder; + /// + /// + /// + /// + /// + /// + /// + public ConventionBuilder WithSingle(object metadata) + { + builder.RemoveMetadata(metadata); + builder.WithMetadata([metadata]); + return builder; + } } } diff --git a/src/K8sOperator.NET/Generation/PolicyRuleBuilder.cs b/src/K8sOperator.NET/Generation/PolicyRuleBuilder.cs deleted file mode 100644 index 8683f28..0000000 --- a/src/K8sOperator.NET/Generation/PolicyRuleBuilder.cs +++ /dev/null @@ -1,8 +0,0 @@ -using k8s.Models; - -namespace K8sOperator.NET.Generation; - -internal class PolicyRuleBuilder : KubernetesObjectBuilder -{ - -} diff --git a/src/K8sOperator.NET/Generation/PolicyRuleBuilderExtensions.cs b/src/K8sOperator.NET/Generation/PolicyRuleBuilderExtensions.cs index 5514a9c..ed0f636 100644 --- a/src/K8sOperator.NET/Generation/PolicyRuleBuilderExtensions.cs +++ b/src/K8sOperator.NET/Generation/PolicyRuleBuilderExtensions.cs @@ -15,7 +15,7 @@ public static class PolicyRuleBuilderExtensions /// The API groups to assign to the policy rule. /// The configured builder. public static TBuilder WithGroups(this TBuilder builder, params string[] groups) - where TBuilder : IKubernetesObjectBuilder + where TBuilder : IObjectBuilder { builder.Add(x => x.ApiGroups = groups); return builder; @@ -29,7 +29,7 @@ public static TBuilder WithGroups(this TBuilder builder, params string /// The verbs to assign to the policy rule. /// The configured builder. public static TBuilder WithVerbs(this TBuilder builder, params string[] verbs) - where TBuilder : IKubernetesObjectBuilder + where TBuilder : IObjectBuilder { builder.Add(x => x.Verbs = verbs); return builder; @@ -43,7 +43,7 @@ public static TBuilder WithVerbs(this TBuilder builder, params string[ /// The resources to assign to the policy rule. /// The configured builder. public static TBuilder WithResources(this TBuilder builder, params string[] resources) - where TBuilder : IKubernetesObjectBuilder + where TBuilder : IObjectBuilder { builder.Add(x => x.Resources = resources); return builder; diff --git a/src/K8sOperator.NET/ILeaderElectionService.cs b/src/K8sOperator.NET/ILeaderElectionService.cs new file mode 100644 index 0000000..82f8d11 --- /dev/null +++ b/src/K8sOperator.NET/ILeaderElectionService.cs @@ -0,0 +1,25 @@ +namespace K8sOperator.NET; + +public interface ILeaderElectionService +{ + bool IsLeader { get; } + Task StartAsync(CancellationToken stoppingToken); + + /// + /// Waits until leadership is acquired. + /// + Task WaitForLeadershipAsync(CancellationToken cancellationToken); + + /// + /// Waits until leadership is lost. + /// + Task WaitForLeadershipLostAsync(CancellationToken cancellationToken); +} + +internal class NoopLeaderElectionService() : ILeaderElectionService +{ + public bool IsLeader => true; + public Task StartAsync(CancellationToken stoppingToken) => Task.CompletedTask; + public Task WaitForLeadershipAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task WaitForLeadershipLostAsync(CancellationToken cancellationToken) => Task.Delay(Timeout.Infinite, cancellationToken); +} diff --git a/src/K8sOperator.NET/K8sOperator.NET.csproj b/src/K8sOperator.NET/K8sOperator.NET.csproj index f89e89b..d37aac3 100644 --- a/src/K8sOperator.NET/K8sOperator.NET.csproj +++ b/src/K8sOperator.NET/K8sOperator.NET.csproj @@ -43,11 +43,16 @@ + + + + + + - diff --git a/src/K8sOperator.NET/LaunchSettings.json b/src/K8sOperator.NET/LaunchSettings.json deleted file mode 100644 index d8fcd68..0000000 --- a/src/K8sOperator.NET/LaunchSettings.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "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 - }, - "Help": { - "commandName": "Project", - "commandLineArgs": "", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - }, - "dotnetRunMessages": true - }, - "Version": { - "commandName": "Project", - "commandLineArgs": "version", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - }, - "dotnetRunMessages": true - } - }, - "$schema": "http://json.schemastore.org/launchsettings.json" -} diff --git a/src/K8sOperator.NET/LeaderLectionService.cs b/src/K8sOperator.NET/LeaderLectionService.cs new file mode 100644 index 0000000..23a3e0a --- /dev/null +++ b/src/K8sOperator.NET/LeaderLectionService.cs @@ -0,0 +1,201 @@ +using k8s; +using k8s.Autorest; +using k8s.Models; +using K8sOperator.NET.Generation; +using System.Net; + +namespace K8sOperator.NET; + +public class LeaderElectionOptions +{ + public string LeaseName { get; set; } = string.Empty; + public string LeaseNamespace { get; set; } = string.Empty; + public bool Enabled { get; set; } = false; + public TimeSpan LeaseDuration { get; set; } = TimeSpan.FromSeconds(15); + public TimeSpan RenewInterval { get; set; } = TimeSpan.FromSeconds(5); + public TimeSpan RetryPeriod { get; set; } = TimeSpan.FromSeconds(2); +} + +public class LeaderElectionService(IKubernetes kubernetes, LeaderElectionOptions options) : ILeaderElectionService +{ + private readonly LeaderElectionOptions _options = options; + private readonly string _identity = $"{Environment.MachineName}-{Guid.NewGuid()}"; + private readonly object _lock = new(); + private Task? _renewalTask; + private TaskCompletionSource _leadershipAcquired = new(TaskCreationOptions.RunContinuationsAsynchronously); + private TaskCompletionSource _leadershipLost = new(TaskCreationOptions.RunContinuationsAsynchronously); + private bool _isLeader; + + public bool IsLeader + { + get + { + lock (_lock) + { + return _isLeader; + } + } + internal set + { + lock (_lock) + { + if (_isLeader == value) + return; + + _isLeader = value; + + if (value) + { + _leadershipAcquired.TrySetResult(); + _leadershipLost = new(TaskCreationOptions.RunContinuationsAsynchronously); + } + else + { + _leadershipLost.TrySetResult(); + _leadershipAcquired = new(TaskCreationOptions.RunContinuationsAsynchronously); + } + } + } + } + + public Task WaitForLeadershipAsync(CancellationToken cancellationToken) + { + TaskCompletionSource tcs; + lock (_lock) + { + if (_isLeader) + return Task.CompletedTask; + + tcs = _leadershipAcquired; + } + + return tcs.Task.WaitAsync(cancellationToken); + } + + public Task WaitForLeadershipLostAsync(CancellationToken cancellationToken) + { + TaskCompletionSource tcs; + lock (_lock) + { + if (!_isLeader) + return Task.CompletedTask; + + tcs = _leadershipLost; + } + + return tcs.Task.WaitAsync(cancellationToken); + } + + public async Task StartAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + if (await TryAcquireLeaseAsync(stoppingToken)) + { + IsLeader = true; + _renewalTask = RenewLeaseLoopAsync(stoppingToken); + await _renewalTask; + } + else + { + IsLeader = false; + await Task.Delay(_options.RetryPeriod, stoppingToken); + } + } + } + + private async Task RenewLeaseLoopAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested && IsLeader) + { + await Task.Delay(_options.RenewInterval, cancellationToken); + + try + { + if (!await TryUpdateLeaseAsync(cancellationToken)) + { + IsLeader = false; + break; + } + } + catch (Exception) + { + IsLeader = false; + break; + } + } + } + + + private async Task TryAcquireLeaseAsync(CancellationToken cancellationToken) + { + var leaseBuilder = KubernetesObjectBuilder.Create(); + + leaseBuilder.WithName(_options.LeaseName) + .WithNamespace(_options.LeaseNamespace) + .WithSpecs() + .WithHolderIdentity(_identity) + .WithLeaseDuration((int)_options.LeaseDuration.TotalSeconds) + .WithAcquireTime(DateTime.UtcNow) + .WithRenewTime(DateTime.UtcNow); + + var lease = leaseBuilder.Build(); + + try + { + await kubernetes.CoordinationV1.CreateNamespacedLeaseAsync( + lease, + _options.LeaseNamespace, + cancellationToken: cancellationToken); + return true; + } + catch (HttpOperationException ex) when (ex.Response.StatusCode == HttpStatusCode.Conflict) + { + // Lease already exists, try to acquire + return await TryUpdateLeaseAsync(cancellationToken); + } + } + + private async Task TryUpdateLeaseAsync(CancellationToken cancellationToken) + { + try + { + var existingLease = await kubernetes.CoordinationV1.ReadNamespacedLeaseAsync( + _options.LeaseName, + _options.LeaseNamespace, + cancellationToken: cancellationToken); + + var now = DateTime.UtcNow; + var renewTime = existingLease.Spec.RenewTime?.ToUniversalTime() ?? DateTime.MinValue; + var leaseDuration = TimeSpan.FromSeconds(existingLease.Spec.LeaseDurationSeconds ?? 15); + var isExpired = now > renewTime.Add(leaseDuration); + var isCurrentHolder = existingLease.Spec.HolderIdentity == _identity; + + if (!isExpired && !isCurrentHolder) + { + return false; + } + + existingLease.Spec.HolderIdentity = _identity; + existingLease.Spec.RenewTime = now; + + if (!isCurrentHolder) + { + existingLease.Spec.AcquireTime = now; + existingLease.Spec.LeaseTransitions = (existingLease.Spec.LeaseTransitions ?? 0) + 1; + } + + await kubernetes.CoordinationV1.ReplaceNamespacedLeaseAsync( + existingLease, + _options.LeaseName, + _options.LeaseNamespace, + cancellationToken: cancellationToken); + + return true; + } + catch (HttpOperationException ex) when (ex.Response.StatusCode == HttpStatusCode.Conflict) + { + return false; + } + } +} diff --git a/src/K8sOperator.NET/Metadata/AdditionalPrinterColumn.cs b/src/K8sOperator.NET/Metadata/AdditionalPrinterColumn.cs new file mode 100644 index 0000000..7e4eea0 --- /dev/null +++ b/src/K8sOperator.NET/Metadata/AdditionalPrinterColumn.cs @@ -0,0 +1,13 @@ +namespace K8sOperator.NET.Metadata; + +[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; } +} + + diff --git a/src/K8sOperator.NET/Metadata/FinalizerAttribute.cs b/src/K8sOperator.NET/Metadata/FinalizerAttribute.cs index f1089d7..9f00669 100644 --- a/src/K8sOperator.NET/Metadata/FinalizerAttribute.cs +++ b/src/K8sOperator.NET/Metadata/FinalizerAttribute.cs @@ -5,7 +5,7 @@ namespace K8sOperator.NET.Metadata; [AttributeUsage(AttributeTargets.Class)] public class FinalizerAttribute(string finalizer) : Attribute { - public const string Default = "default"; + public static FinalizerAttribute Default => new("default"); public string Finalizer { get; } = finalizer; public override string ToString() => DebuggerHelpers.GetDebugText(nameof(Finalizer), Finalizer); diff --git a/src/K8sOperator.NET/Metadata/LabelSelectorAttribute.cs b/src/K8sOperator.NET/Metadata/LabelSelectorAttribute.cs index e9a8f0b..b7ce440 100644 --- a/src/K8sOperator.NET/Metadata/LabelSelectorAttribute.cs +++ b/src/K8sOperator.NET/Metadata/LabelSelectorAttribute.cs @@ -5,6 +5,8 @@ namespace K8sOperator.NET.Metadata; [AttributeUsage(AttributeTargets.Class)] public class LabelSelectorAttribute(string labelSelector) : Attribute { + public static LabelSelectorAttribute Default => new(string.Empty); + public string LabelSelector { get; } = labelSelector; public override string ToString() => DebuggerHelpers.GetDebugText(nameof(LabelSelector), LabelSelector); diff --git a/src/K8sOperator.NET/Metadata/ScopedAttribute.cs b/src/K8sOperator.NET/Metadata/ScopedAttribute.cs new file mode 100644 index 0000000..4cc548b --- /dev/null +++ b/src/K8sOperator.NET/Metadata/ScopedAttribute.cs @@ -0,0 +1,13 @@ +using K8sOperator.NET.Generation; +using K8sOperator.NET.Helpers; + +namespace K8sOperator.NET.Metadata; + +[AttributeUsage(AttributeTargets.Class)] +public class ScopeAttribute(EntityScope scope) : Attribute +{ + public static ScopeAttribute Default { get; } = new(EntityScope.Namespaced); + public EntityScope Scope { get; } = scope; + public override string ToString() + => DebuggerHelpers.GetDebugText(nameof(Scope), Scope); +} diff --git a/src/K8sOperator.NET/Operator.targets b/src/K8sOperator.NET/Operator.targets index e8a80d5..106bab1 100644 --- a/src/K8sOperator.NET/Operator.targets +++ b/src/K8sOperator.NET/Operator.targets @@ -44,15 +44,21 @@ <_ContainerImageTagValue Condition=" '$(ContainerImageTag)' == '' ">$(Version) <_ContainerImageTagValue Condition=" '$(ContainerImageTag)' != '' ">$(ContainerImageTag) + + <_ContainerRepositorySuffix Condition=" '$(ContainerRepository)' == '' ">$(Company)/ + <_ContainerRepositorySuffix Condition=" '$(ContainerRepository)' != '' ">$(ContainerRepository)/ + <_ContainerFamilySuffix Condition=" '$(ContainerFamily)' != '' ">-$(ContainerFamily) <_ContainerFamilySuffix Condition=" '$(ContainerFamily)' == '' "> + <_FullContainerTag>$(_ContainerImageTagValue)$(_ContainerFamilySuffix) + <_FullContainerRepository>$(_ContainerRepositorySuffix)$(OperatorName) <_Parameter1>$(ContainerRegistry) - <_Parameter2>$(ContainerRepository) + <_Parameter2>$(_FullContainerRepository) <_Parameter3>$(_FullContainerTag) diff --git a/src/K8sOperator.NET/OperatorExtensions.cs b/src/K8sOperator.NET/OperatorExtensions.cs index adf7468..bd9acc1 100644 --- a/src/K8sOperator.NET/OperatorExtensions.cs +++ b/src/K8sOperator.NET/OperatorExtensions.cs @@ -1,7 +1,8 @@ -using k8s; +using k8s; using K8sOperator.NET; using K8sOperator.NET.Builder; using K8sOperator.NET.Commands; +using K8sOperator.NET.Generation; using K8sOperator.NET.Metadata; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; @@ -28,6 +29,8 @@ public IServiceCollection AddOperator(Action? configure = null) ds.Add(); ds.Add(); ds.Add(); + ds.Add(); + ds.Add(); ds.Add(); @@ -48,13 +51,21 @@ public IServiceCollection AddOperator(Action? configure = null) return new EventWatcherDatasource(sp, [operatorName, dockerImage, ns]); }); - services.TryAddSingleton((sp) => + services.TryAddSingleton((sp) => { - var config = builder?.Configuration + var config = builder?.KubeConfig ?? KubernetesClientConfiguration.BuildDefaultConfig(); return new Kubernetes(config); }); - services.TryAddSingleton(); + + services.TryAddSingleton(sp => builder.LeaderElection); + services.TryAddSingleton(sp => + { + var o = sp.GetRequiredService(); + var type = o.Enabled ? typeof(LeaderElectionService) : typeof(NoopLeaderElectionService); + return (ILeaderElectionService)ActivatorUtilities.CreateInstance(sp, type); + }); + services.AddHostedService(); return services; } @@ -72,7 +83,6 @@ public ConventionBuilder MapController() extension(IHost app) { - public Task RunOperatorAsync() { var args = Environment.GetCommandLineArgs().Skip(1).ToArray(); @@ -102,5 +112,34 @@ bool Filter(CommandInfo command) public class OperatorBuilder { - public KubernetesClientConfiguration? Configuration { get; set; } + 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/src/K8sOperator.NET/OperatorService.cs b/src/K8sOperator.NET/OperatorService.cs index 815432f..4726c6a 100644 --- a/src/K8sOperator.NET/OperatorService.cs +++ b/src/K8sOperator.NET/OperatorService.cs @@ -9,10 +9,56 @@ public class OperatorService(IServiceProvider serviceProvider) : BackgroundServi { public IServiceProvider ServiceProvider { get; } = serviceProvider; public EventWatcherDatasource Datasource { get; } = serviceProvider.GetRequiredService(); + public ILeaderElectionService LeaderElectionService { get; } = serviceProvider.GetRequiredService(); private readonly List _runningTasks = []; protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var logger = ServiceProvider.GetRequiredService>(); + + // Start leader election in background + var leaderElectionTask = Task.Run(() => LeaderElectionService.StartAsync(stoppingToken), stoppingToken); + + while (!stoppingToken.IsCancellationRequested) + { + // Wait until we become leader (event-driven) + logger.LogInformation("Waiting to become leader..."); + await LeaderElectionService.WaitForLeadershipAsync(stoppingToken); + + if (stoppingToken.IsCancellationRequested) + break; + + logger.LogInformation("Became leader. Starting watchers..."); + + // We are now the leader, start watchers + using var watcherCts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); + var watcherTask = Task.Run(() => StartWatchers(watcherCts.Token), watcherCts.Token); + + // Wait until leadership is lost (event-driven) + await LeaderElectionService.WaitForLeadershipLostAsync(stoppingToken); + + logger.LogInformation("Lost leadership. Stopping watchers..."); + + // Leadership lost or stopping, cancel watchers + await watcherCts.CancelAsync(); + + try + { + await watcherTask; + } + catch (OperationCanceledException) + { + // Expected when leadership is lost + } + + _runningTasks.Clear(); + } + + await leaderElectionTask; + } + + public async Task StartWatchers(CancellationToken stoppingToken) { var watchers = Datasource.GetWatchers().ToList(); var logger = ServiceProvider.GetRequiredService>(); diff --git a/src/K8sOperator.NET/Templates/.dockerignore.template b/src/K8sOperator.NET/Templates/.dockerignore.template new file mode 100644 index 0000000..0ada9b3 --- /dev/null +++ b/src/K8sOperator.NET/Templates/.dockerignore.template @@ -0,0 +1,48 @@ +# Git +.git +.gitignore +.gitattributes + +# Build results +bin/ +obj/ +[Bb]uild/ +[Dd]ebug/ +[Rr]elease/ + +# Visual Studio +.vs/ +.vscode/ +*.user +*.suo +*.userosscache +*.sln.docstates + +# Test results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* +TestResults/ + +# NuGet +*.nupkg +*.snupkg +packages/ + +# Docker +Dockerfile +.dockerignore + +# Kubernetes +*.yaml +*.yml + +# Documentation +*.md +README* +LICENSE + +# IDE +.idea/ +*.swp +*.swo +*~ diff --git a/src/K8sOperator.NET/Templates/Dockerfile.template b/src/K8sOperator.NET/Templates/Dockerfile.template new file mode 100644 index 0000000..159086a --- /dev/null +++ b/src/K8sOperator.NET/Templates/Dockerfile.template @@ -0,0 +1,45 @@ +# Build stage +FROM mcr.microsoft.com/dotnet/sdk:{DOTNET_VERSION} AS build +WORKDIR /src + +# Copy project file and restore dependencies +COPY ["{PROJECT_NAME}.csproj", "./"] +RUN dotnet restore + +# Copy source code and build +COPY . . +RUN dotnet build -c Release -o /app/build + +# Publish stage +FROM build AS publish +RUN dotnet publish -c Release -o /app/publish /p:UseAppHost=false + +# Runtime stage +FROM mcr.microsoft.com/dotnet/aspnet:{DOTNET_VERSION} AS final +WORKDIR /app + +# Create non-root user +RUN groupadd -r operator && useradd -r -g operator operator + +# Copy published app +COPY --from=publish /app/publish . + +# Set ownership +RUN chown -R operator:operator /app + +# Switch to non-root user +USER operator + +# Set environment variables +ENV ASPNETCORE_ENVIRONMENT=Production \ + DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false \ + DOTNET_EnableDiagnostics=0 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD dotnet {PROJECT_NAME}.dll version || exit 1 + +# Entrypoint +ENTRYPOINT ["dotnet", "{PROJECT_NAME}.dll"] +CMD ["operator"] + diff --git a/test/K8sOperator.NET.Target.Tests/K8sOperator.NET.Target.Tests.csproj b/test/K8sOperator.NET.Target.Tests/K8sOperator.NET.Target.Tests.csproj index 8a03a64..3123a6f 100644 --- a/test/K8sOperator.NET.Target.Tests/K8sOperator.NET.Target.Tests.csproj +++ b/test/K8sOperator.NET.Target.Tests/K8sOperator.NET.Target.Tests.csproj @@ -4,7 +4,7 @@ net10.0 true - true + false false @@ -17,4 +17,8 @@ + + + + diff --git a/test/K8sOperator.NET.Target.Tests/Properties/launchSettings.json b/test/K8sOperator.NET.Target.Tests/Properties/launchSettings.json index d8fcd68..cda51cc 100644 --- a/test/K8sOperator.NET.Target.Tests/Properties/launchSettings.json +++ b/test/K8sOperator.NET.Target.Tests/Properties/launchSettings.json @@ -1,37 +1,12 @@ { - "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 - }, - "Help": { - "commandName": "Project", - "commandLineArgs": "", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - }, - "dotnetRunMessages": true - }, - "Version": { - "commandName": "Project", - "commandLineArgs": "version", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - }, - "dotnetRunMessages": true - } - }, - "$schema": "http://json.schemastore.org/launchsettings.json" -} + "profiles": { + "K8sOperator.NET.Target.Tests": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:60298;http://localhost:60299" + } + } +} \ No newline at end of file diff --git a/test/K8sOperator.NET.Tests/OperatorExtensions_Tests.cs b/test/K8sOperator.NET.Tests/OperatorExtensions_Tests.cs index 7676a46..16e976e 100644 --- a/test/K8sOperator.NET.Tests/OperatorExtensions_Tests.cs +++ b/test/K8sOperator.NET.Tests/OperatorExtensions_Tests.cs @@ -1,5 +1,6 @@ using k8s; using K8sOperator.NET; +using K8sOperator.NET.Builder; using K8sOperator.NET.Tests.Fixtures; using K8sOperator.NET.Tests.Mocks; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -28,7 +29,7 @@ public async Task AddOperator_ValidServices_AddsOperatorService() services.AddOperator(); // Assert var serviceProvider = services.BuildServiceProvider(); - OperatorService GetHostedServices() => serviceProvider.GetRequiredService(); + IHostedService GetHostedServices() => serviceProvider.GetRequiredService(); await Assert.That(GetHostedServices).ThrowsNothing(); } @@ -108,14 +109,18 @@ public async Task AddOperator_ValidServices_RegistersDefaultCommands() var host = new HostBuilder() .ConfigureServices(s => { - s.AddOperator(x => x.Configuration = server.GetKubernetesClientConfiguration()); + s.AddOperator(x => x.WithKubeConfig(server.GetKubernetesClientConfiguration())); }) .Build(); var commandDatasource = host.Services.GetRequiredService(); var commands = commandDatasource.GetCommands(host); - await Assert.That(commands).Count().IsEqualTo(6); + var totalCommands = typeof(IOperatorCommand).Assembly.GetTypes() + .Where(t => typeof(IOperatorCommand).IsAssignableFrom(t) && !t.IsInterface && !t.IsAbstract) + .Count(); + + await Assert.That(commands).Count().IsEqualTo(totalCommands); } [Test]