diff --git a/.github/workflows/build-publish.yml b/.github/workflows/build-publish.yml index 2b6a338..c725c85 100644 --- a/.github/workflows/build-publish.yml +++ b/.github/workflows/build-publish.yml @@ -35,7 +35,11 @@ jobs: run: dotnet build -c Release - name: Test - run: dotnet test -c Release + run: | + for project in test/*/*.csproj; do + echo "Running tests for $project..." + dotnet run --project "$project" -c Release + done - name: DotNet Pack if: startsWith(github.ref, 'refs/tags/') diff --git a/.github/workflows/security-analysis.yml b/.github/workflows/security-analysis.yml index da71f37..e10fe6c 100644 --- a/.github/workflows/security-analysis.yml +++ b/.github/workflows/security-analysis.yml @@ -28,7 +28,7 @@ jobs: fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@v4 with: languages: csharp @@ -41,4 +41,4 @@ jobs: run: dotnet build --configuration Release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@v4 diff --git a/Directory.Packages.props b/Directory.Packages.props deleted file mode 100644 index 3db64ff..0000000 --- a/Directory.Packages.props +++ /dev/null @@ -1,27 +0,0 @@ - - - - true - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/K8sOperator.NET.slnx b/K8sOperator.NET.slnx new file mode 100644 index 0000000..e172296 --- /dev/null +++ b/K8sOperator.NET.slnx @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/K8sOperator.sln b/K8sOperator.sln deleted file mode 100644 index 4b8b1fc..0000000 --- a/K8sOperator.sln +++ /dev/null @@ -1,79 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 18 -VisualStudioVersion = 18.1.11312.151 d18.0 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{898CC489-C84A-49BD-9D77-3CEA1F6A7180}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "props", "props", "{029923F0-FD53-4B75-BA07-F102BBE9C429}" - ProjectSection(SolutionItems) = preProject - src\Directory.Build.props = src\Directory.Build.props - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{9320CC2F-6BB6-4B29-B625-EB427EE87891}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "props", "props", "{0CCCC7F3-A522-4535-8D5A-1E53815936D3}" - ProjectSection(SolutionItems) = preProject - test\Directory.Build.props = test\Directory.Build.props - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "assets", "assets", "{4D1F501E-294C-4B22-9792-1BBB2B553C69}" - ProjectSection(SolutionItems) = preProject - assets\logo.png = assets\logo.png - EndProjectSection -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "K8sOperator.NET.Tests", "test\K8sOperator.NET.Tests\K8sOperator.NET.Tests.csproj", "{C0360068-BBDE-4ABF-B357-765C792CDCF5}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "K8sOperator.NET", "src\K8sOperator.NET\K8sOperator.NET.csproj", "{160DFED1-DD63-412C-9D60-84D965626DD9}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "examples", "examples", "{ED5FF81E-F3EA-4BEF-9B72-31A24F9386E3}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SimpleOperator", "examples\SimpleOperator\SimpleOperator.csproj", "{B7588B10-BDD2-4622-BB16-18EFFBE7BE25}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "K8sOperator.NET.Generators", "src\K8sOperator.NET.Generators\K8sOperator.NET.Generators.csproj", "{183BE367-0544-4AD1-B741-2BA2F186BC0E}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "K8sOperator.NET.Generators.Tests", "test\K8sOperator.NET.Generators.Tests\K8sOperator.NET.Generators.Tests.csproj", "{D07BFDEA-0E46-4822-9F38-9581425FD93F}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {C0360068-BBDE-4ABF-B357-765C792CDCF5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C0360068-BBDE-4ABF-B357-765C792CDCF5}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C0360068-BBDE-4ABF-B357-765C792CDCF5}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C0360068-BBDE-4ABF-B357-765C792CDCF5}.Release|Any CPU.Build.0 = Release|Any CPU - {160DFED1-DD63-412C-9D60-84D965626DD9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {160DFED1-DD63-412C-9D60-84D965626DD9}.Debug|Any CPU.Build.0 = Debug|Any CPU - {160DFED1-DD63-412C-9D60-84D965626DD9}.Release|Any CPU.ActiveCfg = Release|Any CPU - {160DFED1-DD63-412C-9D60-84D965626DD9}.Release|Any CPU.Build.0 = Release|Any CPU - {B7588B10-BDD2-4622-BB16-18EFFBE7BE25}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B7588B10-BDD2-4622-BB16-18EFFBE7BE25}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B7588B10-BDD2-4622-BB16-18EFFBE7BE25}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B7588B10-BDD2-4622-BB16-18EFFBE7BE25}.Release|Any CPU.Build.0 = Release|Any CPU - {183BE367-0544-4AD1-B741-2BA2F186BC0E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {183BE367-0544-4AD1-B741-2BA2F186BC0E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {183BE367-0544-4AD1-B741-2BA2F186BC0E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {183BE367-0544-4AD1-B741-2BA2F186BC0E}.Release|Any CPU.Build.0 = Release|Any CPU - {D07BFDEA-0E46-4822-9F38-9581425FD93F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {D07BFDEA-0E46-4822-9F38-9581425FD93F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {D07BFDEA-0E46-4822-9F38-9581425FD93F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {D07BFDEA-0E46-4822-9F38-9581425FD93F}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {029923F0-FD53-4B75-BA07-F102BBE9C429} = {898CC489-C84A-49BD-9D77-3CEA1F6A7180} - {0CCCC7F3-A522-4535-8D5A-1E53815936D3} = {9320CC2F-6BB6-4B29-B625-EB427EE87891} - {C0360068-BBDE-4ABF-B357-765C792CDCF5} = {9320CC2F-6BB6-4B29-B625-EB427EE87891} - {160DFED1-DD63-412C-9D60-84D965626DD9} = {898CC489-C84A-49BD-9D77-3CEA1F6A7180} - {B7588B10-BDD2-4622-BB16-18EFFBE7BE25} = {ED5FF81E-F3EA-4BEF-9B72-31A24F9386E3} - {183BE367-0544-4AD1-B741-2BA2F186BC0E} = {898CC489-C84A-49BD-9D77-3CEA1F6A7180} - {D07BFDEA-0E46-4822-9F38-9581425FD93F} = {9320CC2F-6BB6-4B29-B625-EB427EE87891} - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {49DE7562-1920-49AD-A220-E10302CEC632} - EndGlobalSection -EndGlobal diff --git a/examples/Directory.Build.props b/examples/Directory.Build.props new file mode 100644 index 0000000..72cf253 --- /dev/null +++ b/examples/Directory.Build.props @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/examples/SimpleOperator/.dockerignore b/examples/SimpleOperator/.dockerignore new file mode 100644 index 0000000..0ada9b3 --- /dev/null +++ b/examples/SimpleOperator/.dockerignore @@ -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/examples/SimpleOperator/Controllers/TodoController.cs b/examples/SimpleOperator/Controllers/TodoController.cs new file mode 100644 index 0000000..b63e3d1 --- /dev/null +++ b/examples/SimpleOperator/Controllers/TodoController.cs @@ -0,0 +1,89 @@ +using K8sOperator.NET; +using SimpleOperator.Resources; + +namespace SimpleOperator.Controllers; + +public class TodoController : OperatorController +{ + private readonly ILogger _logger; + + public TodoController(ILogger logger) + { + _logger = logger; + } + + public override async Task AddOrModifyAsync(TodoItem resource, CancellationToken cancellationToken) + { + _logger.LogInformation( + "Processing TodoItem: {Name} - Title: {Title}, Priority: {Priority}", + resource.Metadata.Name, + resource.Spec.Title, + resource.Spec.Priority); + + // Initialize status if needed + if (resource.Status == null) + { + resource.Status = new TodoItem.TodoStatus(); + } + + // Update reconciliation count + resource.Status.ReconciliationCount++; + + // Business logic: Auto-complete if due date is passed + if (resource.Spec.DueDate.HasValue && + resource.Spec.DueDate.Value < DateTime.UtcNow && + resource.Status.State != "completed") + { + resource.Status.State = "overdue"; + resource.Status.Message = $"Task is overdue by {(DateTime.UtcNow - resource.Spec.DueDate.Value).Days} days"; + _logger.LogWarning("TodoItem {Name} is overdue!", resource.Metadata.Name); + } + else if (resource.Status.State == "pending") + { + resource.Status.State = "in-progress"; + resource.Status.Message = "Task is being processed"; + _logger.LogInformation("TodoItem {Name} moved to in-progress", resource.Metadata.Name); + } + + // Simulate some async work + await Task.Delay(100, cancellationToken); + } + + public override async Task DeleteAsync(TodoItem resource, CancellationToken cancellationToken) + { + _logger.LogInformation( + "Deleting TodoItem: {Name}. Final state was: {State}", + resource.Metadata.Name, + resource.Status?.State ?? "unknown"); + + // Cleanup logic here (e.g., remove external resources) + await Task.CompletedTask; + } + + public override async Task FinalizeAsync(TodoItem resource, CancellationToken cancellationToken) + { + _logger.LogInformation( + "Finalizing TodoItem: {Name}", + resource.Metadata.Name); + + // Perform cleanup before resource is completely deleted + // For example: remove related resources, notify external systems, etc. + + await Task.CompletedTask; + } + + public override async Task ErrorAsync(TodoItem resource, CancellationToken cancellationToken) + { + _logger.LogError( + "Error occurred for TodoItem: {Name}", + resource.Metadata.Name); + + if (resource.Status != null) + { + resource.Status.State = "error"; + resource.Status.Message = "An error occurred during reconciliation"; + } + + await Task.CompletedTask; + } +} diff --git a/examples/SimpleOperator/Dockerfile b/examples/SimpleOperator/Dockerfile new file mode 100644 index 0000000..22d9682 --- /dev/null +++ b/examples/SimpleOperator/Dockerfile @@ -0,0 +1,44 @@ +# Build stage +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +# Copy project file and restore dependencies +COPY ["SimpleOperator.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:10.0 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 SimpleOperator.dll version || exit 1 + +# Entrypoint +ENTRYPOINT ["dotnet", "SimpleOperator.dll"] +CMD ["operator"] diff --git a/examples/SimpleOperator/Program.cs b/examples/SimpleOperator/Program.cs index a466e79..a44848d 100644 --- a/examples/SimpleOperator/Program.cs +++ b/examples/SimpleOperator/Program.cs @@ -1,21 +1,12 @@ using K8sOperator.NET; -using K8sOperator.NET.Extensions; -using K8sOperator.NET.Generators; -using SimpleOperator.Projects; +using SimpleOperator.Controllers; +var builder = WebApplication.CreateBuilder(args); -var builder = OperatorHost.CreateOperatorApplicationBuilder(args) - //.WithName("simple-operator") - .WithNamespace("simple-ops-system"); - -builder.AddController() - .WithFinalizer("testitem.local.finalizer"); - -builder.AddController() - .WithFinalizer("project.local.finalizer"); +builder.Services.AddOperator(); var app = builder.Build(); -app.AddInstall(); +app.MapController(); -await app.RunAsync(); +await app.RunOperatorAsync(); diff --git a/examples/SimpleOperator/Projects/Project.cs b/examples/SimpleOperator/Projects/Project.cs deleted file mode 100644 index 0f5fbec..0000000 --- a/examples/SimpleOperator/Projects/Project.cs +++ /dev/null @@ -1,20 +0,0 @@ -using k8s.Models; -using K8sOperator.NET.Models; - -namespace SimpleOperator.Projects; - -[KubernetesEntity(Group = "operator.io", ApiVersion = "v1alpha1", Kind = "Project", PluralName = "projects")] -public class Project : CustomResource -{ - public class Specs - { - public string Name { get; set; } = string.Empty; - public string Organization { get; set; } = string.Empty; - public string Project { get; set; } = string.Empty; - } - - public class ProjectStatus - { - public string Result { get; set; } = string.Empty; - } -} diff --git a/examples/SimpleOperator/Projects/ProjectController.cs b/examples/SimpleOperator/Projects/ProjectController.cs deleted file mode 100644 index 2065c4c..0000000 --- a/examples/SimpleOperator/Projects/ProjectController.cs +++ /dev/null @@ -1,36 +0,0 @@ -using K8sOperator.NET; -using K8sOperator.NET.Metadata; - -namespace SimpleOperator.Projects; - -[Namespace("default")] -public class ProjectController(ILoggerFactory logger) : Controller -{ - private readonly ILogger _logger = logger.CreateLogger(); - - public override Task AddOrModifyAsync(Project resource, CancellationToken cancellationToken) - { - _logger.LogDebug("Controller AddOrModify received."); - - resource.Metadata.Labels = new Dictionary - { - { "created", "created" } - }; - - resource.Status.Result = "HEHE"; - - return base.AddOrModifyAsync(resource, cancellationToken); - } - - public override Task DeleteAsync(Project resource, CancellationToken cancellationToken) - { - _logger.LogDebug("Controller Delete received."); - return base.DeleteAsync(resource, cancellationToken); - } - - public override Task FinalizeAsync(Project resource, CancellationToken cancellationToken) - { - _logger.LogDebug("Controller Finalize received."); - return base.FinalizeAsync(resource, cancellationToken); - } -} diff --git a/examples/SimpleOperator/Projects/TestItem.cs b/examples/SimpleOperator/Projects/TestItem.cs deleted file mode 100644 index 3858835..0000000 --- a/examples/SimpleOperator/Projects/TestItem.cs +++ /dev/null @@ -1,19 +0,0 @@ -using k8s.Models; -using K8sOperator.NET.Models; -using static SimpleOperator.Projects.TestItem; - -namespace SimpleOperator.Projects; - -[KubernetesEntity(Group = "operator.io", ApiVersion = "v1alpha1", Kind = "TestItem", PluralName = "testitems")] -public class TestItem : CustomResource -{ - public class TestItemSpec - { - public string? String { get; set; } - } - - public class TestItemStatus - { - - } -} diff --git a/examples/SimpleOperator/Projects/TestItemController.cs b/examples/SimpleOperator/Projects/TestItemController.cs deleted file mode 100644 index e58c71e..0000000 --- a/examples/SimpleOperator/Projects/TestItemController.cs +++ /dev/null @@ -1,31 +0,0 @@ -using K8sOperator.NET; - -namespace SimpleOperator.Projects; - -public class TestItemController : Controller -{ - public override Task AddOrModifyAsync(TestItem resource, CancellationToken cancellationToken) - { - return base.AddOrModifyAsync(resource, cancellationToken); - } - - public override Task BookmarkAsync(TestItem resource, CancellationToken cancellationToken) - { - return base.BookmarkAsync(resource, cancellationToken); - } - - public override Task DeleteAsync(TestItem resource, CancellationToken cancellationToken) - { - return base.DeleteAsync(resource, cancellationToken); - } - - public override Task ErrorAsync(TestItem resource, CancellationToken cancellationToken) - { - return base.ErrorAsync(resource, cancellationToken); - } - - public override Task FinalizeAsync(TestItem resource, CancellationToken cancellationToken) - { - return base.FinalizeAsync(resource, cancellationToken); - } -} diff --git a/examples/SimpleOperator/Properties/launchSettings.json b/examples/SimpleOperator/Properties/launchSettings.json index ef260a2..d9d9d4e 100644 --- a/examples/SimpleOperator/Properties/launchSettings.json +++ b/examples/SimpleOperator/Properties/launchSettings.json @@ -1,37 +1,53 @@ { - "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": { + "Help": { + "commandName": "Project", + "commandLineArgs": "help", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true }, - "$schema": "http://json.schemastore.org/launchsettings.json" -} + "Operator": { + "commandName": "Project", + "commandLineArgs": "operator", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true + }, + "Install": { + "commandName": "Project", + "commandLineArgs": "install", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true + }, + "Version": { + "commandName": "Project", + "commandLineArgs": "version", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true + }, + "GenerateLaunchsettings": { + "commandName": "Project", + "commandLineArgs": "generate-launchsettings", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true + }, + "GenerateDockerfile": { + "commandName": "Project", + "commandLineArgs": "generate-dockerfile", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true + } + }, + "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 new file mode 100644 index 0000000..84b1fff --- /dev/null +++ b/examples/SimpleOperator/Resources/TodoItem.cs @@ -0,0 +1,24 @@ +using k8s.Models; +using K8sOperator.NET; + +namespace SimpleOperator.Resources; + +[KubernetesEntity(Group = "app.example.com", ApiVersion = "v1", Kind = "TodoItem", PluralName = "todoitems")] +public class TodoItem : CustomResource +{ + public class TodoSpec + { + public string Title { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public string Priority { get; set; } = "medium"; // low, medium, high + public DateTime? DueDate { get; set; } + } + + public class TodoStatus + { + public string State { get; set; } = "pending"; // pending, in-progress, completed + public DateTime? CompletedAt { get; set; } + public string Message { get; set; } = string.Empty; + public int ReconciliationCount { get; set; } + } +} diff --git a/examples/SimpleOperator/SimpleOperator.csproj b/examples/SimpleOperator/SimpleOperator.csproj index df57e67..878d5ca 100644 --- a/examples/SimpleOperator/SimpleOperator.csproj +++ b/examples/SimpleOperator/SimpleOperator.csproj @@ -4,20 +4,29 @@ net10.0 enable enable - pmdevers + true + + true + true + pmdevers + 1.0.0 + + + simple-operator + simple-system ghcr.io - pmdevers/simple-operator - 1.0.0 - -alpha0011 - + simple-operator + alpha - - + + + + diff --git a/examples/SimpleOperator/appsettings.Development.json b/examples/SimpleOperator/appsettings.Development.json index a6e86ac..0c208ae 100644 --- a/examples/SimpleOperator/appsettings.Development.json +++ b/examples/SimpleOperator/appsettings.Development.json @@ -1,7 +1,7 @@ { "Logging": { "LogLevel": { - "Default": "Debug", + "Default": "Information", "Microsoft.AspNetCore": "Warning" } } diff --git a/examples/SimpleOperator/appsettings.json b/examples/SimpleOperator/appsettings.json index 23039ed..10f68b8 100644 --- a/examples/SimpleOperator/appsettings.json +++ b/examples/SimpleOperator/appsettings.json @@ -1,7 +1,7 @@ { "Logging": { "LogLevel": { - "Default": "Debug", + "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, diff --git a/examples/SimpleOperator/install.yaml b/examples/SimpleOperator/install.yaml deleted file mode 100644 index e69de29..0000000 diff --git a/global.json b/global.json index 588227b..441008e 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,9 @@ { "sdk": { - "version": "10.0.100", - "rollForward": "latestFeature" + "version": "10.0.100", + "rollForward": "latestFeature" + }, + "test": { + "runner": "Microsoft.Testing.Platform" } - } \ No newline at end of file +} diff --git a/scripts/hooks/README.md b/scripts/hooks/README.md new file mode 100644 index 0000000..304360c --- /dev/null +++ b/scripts/hooks/README.md @@ -0,0 +1,35 @@ +# Git Hooks + +This directory contains git hooks that can be installed to improve the development workflow. + +## Available Hooks + +### pre-commit +Runs all unit tests before allowing a commit. If any tests fail, the commit will be aborted. + +## Installation + +### Windows (PowerShell) +```powershell +.\scripts\hooks\install-hooks.ps1 +``` + +### Linux/Mac (Bash) +```bash +chmod +x scripts/hooks/install-hooks.sh +./scripts/hooks/install-hooks.sh +``` + +### Manual Installation +Copy the hooks to your `.git/hooks` directory: +```bash +cp scripts/hooks/pre-commit .git/hooks/pre-commit +chmod +x .git/hooks/pre-commit +``` + +## Bypassing Hooks + +If you need to bypass the hooks for a specific commit (not recommended), use: +```bash +git commit --no-verify +``` diff --git a/scripts/hooks/install-hooks.ps1 b/scripts/hooks/install-hooks.ps1 new file mode 100644 index 0000000..8d94017 --- /dev/null +++ b/scripts/hooks/install-hooks.ps1 @@ -0,0 +1,25 @@ +# PowerShell script to install git hooks +Write-Host "Installing git hooks..." -ForegroundColor Cyan + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$repoRoot = Split-Path -Parent (Split-Path -Parent $scriptDir) +$gitHooksDir = Join-Path $repoRoot ".git\hooks" + +# Check if .git directory exists +if (-not (Test-Path (Join-Path $repoRoot ".git"))) { + Write-Error "Not in a git repository. Please run this script from the repository root." + exit 1 +} + +# Copy pre-commit hook +$sourceHook = Join-Path $scriptDir "pre-commit" +$targetHook = Join-Path $gitHooksDir "pre-commit" + +Copy-Item -Path $sourceHook -Destination $targetHook -Force +Write-Host "✓ Installed pre-commit hook" -ForegroundColor Green + +# Make executable (for Git Bash/WSL) +git update-index --chmod=+x "$targetHook" + +Write-Host "`nGit hooks installed successfully!" -ForegroundColor Green +Write-Host "The pre-commit hook will now run tests before each commit." -ForegroundColor Gray diff --git a/scripts/hooks/install-hooks.sh b/scripts/hooks/install-hooks.sh new file mode 100644 index 0000000..b4add1d --- /dev/null +++ b/scripts/hooks/install-hooks.sh @@ -0,0 +1,23 @@ +#!/bin/sh +# Shell script to install git hooks + +echo "Installing git hooks..." + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +GIT_HOOKS_DIR="$REPO_ROOT/.git/hooks" + +# Check if .git directory exists +if [ ! -d "$REPO_ROOT/.git" ]; then + echo "Error: Not in a git repository." + exit 1 +fi + +# Copy pre-commit hook +cp "$SCRIPT_DIR/pre-commit" "$GIT_HOOKS_DIR/pre-commit" +chmod +x "$GIT_HOOKS_DIR/pre-commit" + +echo "✓ Installed pre-commit hook" +echo "" +echo "Git hooks installed successfully!" +echo "The pre-commit hook will now run tests before each commit." diff --git a/scripts/hooks/pre-commit b/scripts/hooks/pre-commit new file mode 100644 index 0000000..fc828aa --- /dev/null +++ b/scripts/hooks/pre-commit @@ -0,0 +1,22 @@ +#!/bin/sh +# Pre-commit hook to run unit tests before allowing a commit + +echo "Running tests before commit..." + +# Run tests for all projects in test directory +for project in test/*/*.csproj; do + echo "Running tests for $project..." + dotnet run --project "$project" + + # Capture the exit code + TEST_EXIT_CODE=$? + + # If tests failed, prevent the commit + if [ $TEST_EXIT_CODE -ne 0 ]; then + echo "Tests failed in $project. Commit aborted." + exit 1 + fi +done + +echo "All tests passed. Proceeding with commit." +exit 0 diff --git a/src/Directory.Build.props b/src/Directory.Build.props deleted file mode 100644 index fb860a7..0000000 --- a/src/Directory.Build.props +++ /dev/null @@ -1,48 +0,0 @@ - - - - net10.0 - true - enable - enable - true - PMDEvers - - $(ProjectName) - - 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. - - Patrick Evers - $(CompanyName) - - - - - - $(CompanyName) - $(MSBuildProjectName.Replace(" ", "_")) - README.md - logo.png - https://github.com/$(CompanyName)/$(ProjectName) - MIT - git - https://github.com/$(CompanyName)/$(ProjectName) - true - true - - - - - - True - \ - false - - - True - \ - - - - - diff --git a/src/K8sOperator.NET.Generators/K8sOperator.NET.Generators.csproj b/src/K8sOperator.NET.Generators/K8sOperator.NET.Generators.csproj deleted file mode 100644 index 9daa8f6..0000000 --- a/src/K8sOperator.NET.Generators/K8sOperator.NET.Generators.csproj +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/src/K8sOperator.NET.Generators/OperatorApplicationExtensions.cs b/src/K8sOperator.NET.Generators/OperatorApplicationExtensions.cs deleted file mode 100644 index f7e875b..0000000 --- a/src/K8sOperator.NET.Generators/OperatorApplicationExtensions.cs +++ /dev/null @@ -1,22 +0,0 @@ -using K8sOperator.NET.Builder; - -namespace K8sOperator.NET.Generators; - -/// -/// -/// -public static class OperatorApplicationExtensions -{ - /// - /// - /// - /// - /// - /// - /// - public static IOperatorCommandConventionBuilder AddInstall(this TBuilder builder, int? order = null) - where TBuilder : IOperatorApplication - { - return builder.AddCommand(order ?? -1); - } -} diff --git a/src/K8sOperator.NET.Generators/Utilities.cs b/src/K8sOperator.NET.Generators/Utilities.cs deleted file mode 100644 index 18bdbdb..0000000 --- a/src/K8sOperator.NET.Generators/Utilities.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Reflection; - -namespace K8sOperator.NET.Generators; -internal static class Utilities -{ - /// - /// Check if a type is nullable. - /// - /// The type. - /// True if the type is nullable (i.e. contains "nullable" in its name). - public static bool IsNullable(this Type type) - => type.FullName?.Contains("Nullable") == true; - /// - /// Check if a property is nullable. - /// - /// The property. - /// True if the type is nullable (i.e. contains "nullable" in its name). - public static bool IsNullable(this PropertyInfo prop) - => new NullabilityInfoContext().Create(prop).ReadState == NullabilityState.Nullable || - prop.PropertyType.FullName?.Contains("Nullable") == true; -} diff --git a/src/K8sOperator.NET/Builder/CommandBuilder.cs b/src/K8sOperator.NET/Builder/CommandBuilder.cs new file mode 100644 index 0000000..01b2309 --- /dev/null +++ b/src/K8sOperator.NET/Builder/CommandBuilder.cs @@ -0,0 +1,47 @@ +using K8sOperator.NET.Helpers; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using System.Reflection; + +namespace K8sOperator.NET.Builder; + +public class CommandBuilder(IServiceProvider serviceProvider, Type commandType) +{ + public IServiceProvider ServiceProvider { get; } = serviceProvider; + public Type CommandType { get; } = commandType; + public List Metadata { get; } = []; + + public IOperatorCommand Build(IHost app) + { + var meta = CommandType.GetCustomAttribute(false) ?? + new(CommandType.Name.Replace("Command", string.Empty).ToLower()); + + Metadata.Add(meta); + + return (IOperatorCommand)ActivatorUtilities.CreateInstance(ServiceProvider, CommandType, app); + } +} + +public interface IOperatorCommand +{ + Task RunAsync(string[] args); +} + +[AttributeUsage(AttributeTargets.Class)] +public class OperatorArgumentAttribute(string argument) : Attribute +{ + /// + public string Argument { get; set; } = argument; + + /// + public string Description { get; set; } = string.Empty; + /// + /// + /// + public int Order { get; set; } = 1; + + public bool ShowInHelp { get; set; } = true; + + public override string ToString() + => DebuggerHelpers.GetDebugText(nameof(Argument), Argument); +} diff --git a/src/K8sOperator.NET/Builder/ControllerBuilder.cs b/src/K8sOperator.NET/Builder/ControllerBuilder.cs index c1e6391..c3d4acc 100644 --- a/src/K8sOperator.NET/Builder/ControllerBuilder.cs +++ b/src/K8sOperator.NET/Builder/ControllerBuilder.cs @@ -1,34 +1,30 @@ -using k8s.Models; -using Microsoft.Extensions.DependencyInjection; -using System.Reflection; +using Microsoft.Extensions.DependencyInjection; namespace K8sOperator.NET.Builder; -/// -/// Interface for building an Operator Controller. -/// -public interface IControllerBuilder +public class ControllerBuilder { - /// - /// Gets the list of metadata associated with the controller. - /// - List Metadata { get; } -} - -internal class ControllerBuilder(IServiceProvider serviceProvider, Type controllerType) : IControllerBuilder -{ - private readonly IServiceProvider _serviceProvider = serviceProvider; - private readonly Type _controllerType = controllerType; + private ControllerBuilder(IServiceProvider serviceProvider, Type controllerType, List metadata) + { + ServiceProvider = serviceProvider; + ControllerType = controllerType; + Metadata = metadata; + } + public IServiceProvider ServiceProvider { get; } + public Type ControllerType { get; set; } - public List Metadata { get; } = []; + public static ControllerBuilder Create(IServiceProvider serviceProvider, Type controllerType, List metadata) + => new(serviceProvider, controllerType, metadata); - public IController Build() + public IOperatorController Build() { - var controller = (IController)ActivatorUtilities.CreateInstance(_serviceProvider, _controllerType); - Metadata.AddRange(_controllerType.GetCustomAttributes()); + var controller = (IOperatorController)ActivatorUtilities.CreateInstance(ServiceProvider, ControllerType); + Metadata.AddRange(ControllerType.GetCustomAttributes(true)); - var attributes = controller.ResourceType.GetCustomAttributes(); + var attributes = controller.ResourceType.GetCustomAttributes(true); Metadata.AddRange(attributes); + return controller; } + public List Metadata { get; } = []; } diff --git a/src/K8sOperator.NET/Builder/ControllerConventionBuilder.cs b/src/K8sOperator.NET/Builder/ControllerConventionBuilder.cs deleted file mode 100644 index f434a70..0000000 --- a/src/K8sOperator.NET/Builder/ControllerConventionBuilder.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace K8sOperator.NET.Builder; - -/// -/// -/// -public interface IControllerConventionBuilder : IConventionBuilder { } - -internal class ControllerConventionBuilder( - ICollection> conventions, - ICollection> finallyConventions) -: ConventionBuilder(conventions, finallyConventions), IControllerConventionBuilder -{ -} diff --git a/src/K8sOperator.NET/Builder/ConventionBuilder.cs b/src/K8sOperator.NET/Builder/ConventionBuilder.cs index f2e243b..8b46d35 100644 --- a/src/K8sOperator.NET/Builder/ConventionBuilder.cs +++ b/src/K8sOperator.NET/Builder/ConventionBuilder.cs @@ -1,34 +1,10 @@ namespace K8sOperator.NET.Builder; -/// -/// Desribes a Operator Convention Builder -/// -public interface IConventionBuilder +public class ConventionBuilder(ICollection> conventions) { - /// - /// Add Convention - /// - /// - void Add(Action convention); - - /// - /// Add Finally Convention - /// - /// - void Finally(Action convention); -} - -internal class ConventionBuilder( - ICollection> conventions, - ICollection> finallyConventions) : IConventionBuilder -{ - public void Add(Action convention) + public ConventionBuilder Add(Action convention) { conventions.Add(convention); - } - - public void Finally(Action convention) - { - finallyConventions.Add(convention); + return this; } } diff --git a/src/K8sOperator.NET/Builder/EventWatcherBuilder.cs b/src/K8sOperator.NET/Builder/EventWatcherBuilder.cs index 4729ca0..1180f77 100644 --- a/src/K8sOperator.NET/Builder/EventWatcherBuilder.cs +++ b/src/K8sOperator.NET/Builder/EventWatcherBuilder.cs @@ -1,22 +1,29 @@ -using K8sOperator.NET.Metadata; -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; namespace K8sOperator.NET.Builder; -internal class EventWatcherBuilder(IServiceProvider serviceProvider, IController controller, IReadOnlyList metadata) +public class EventWatcherBuilder { - public IEventWatcher Build() + public IServiceProvider ServiceProvider { get; } + public IOperatorController Controller { get; } + public List Metadata { get; } + + private EventWatcherBuilder(IServiceProvider serviceProvider, + IOperatorController controller, + List metadata) { - var watcherType = typeof(EventWatcher<>).MakeGenericType(controller.ResourceType); - IKubernetesClient client = ActivatorUtilities.CreateInstance(serviceProvider); + ServiceProvider = serviceProvider; + Controller = controller; + Metadata = metadata; + } - var ns = metadata.OfType().FirstOrDefault(); - if (ns is not null) - { - client = ActivatorUtilities.CreateInstance(serviceProvider); - } + public static EventWatcherBuilder Create(IServiceProvider serviceProvider, IOperatorController controller, List metadata) + => new(serviceProvider, controller, metadata); + + public IEventWatcher Build() + { + var watcherType = typeof(EventWatcher<>).MakeGenericType(Controller.ResourceType); - - return (IEventWatcher)ActivatorUtilities.CreateInstance(serviceProvider, watcherType, client, controller, metadata); + return (IEventWatcher)ActivatorUtilities.CreateInstance(ServiceProvider, watcherType, Controller, Metadata); } } diff --git a/src/K8sOperator.NET/Builder/KubernetesBuilder.cs b/src/K8sOperator.NET/Builder/KubernetesBuilder.cs deleted file mode 100644 index bae05bd..0000000 --- a/src/K8sOperator.NET/Builder/KubernetesBuilder.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; - -namespace K8sOperator.NET.Builder; - -/// -/// Describes a IKubernetesBuilder -/// -public interface IKubernetesBuilder -{ - /// - /// The service collection. - /// - public IServiceCollection Services { get; } -} - -internal sealed class KubernetesBuilder(IServiceCollection services) : IKubernetesBuilder -{ - public IServiceCollection Services { get; } = services; -} diff --git a/src/K8sOperator.NET/Builder/LoggingBuilder.cs b/src/K8sOperator.NET/Builder/LoggingBuilder.cs deleted file mode 100644 index 8bdb8ab..0000000 --- a/src/K8sOperator.NET/Builder/LoggingBuilder.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace K8sOperator.NET.Builder; - -internal sealed class LoggingBuilder(IServiceCollection services) : ILoggingBuilder -{ - public IServiceCollection Services { get; } = services; -} diff --git a/src/K8sOperator.NET/Builder/OperatorCommandBuilder.cs b/src/K8sOperator.NET/Builder/OperatorCommandBuilder.cs deleted file mode 100644 index 5da5b11..0000000 --- a/src/K8sOperator.NET/Builder/OperatorCommandBuilder.cs +++ /dev/null @@ -1,97 +0,0 @@ -using K8sOperator.NET.Metadata; -using Microsoft.Extensions.DependencyInjection; -using System.Reflection; - -namespace K8sOperator.NET.Builder; - -/// -/// -/// -public interface IOperatorCommandBuilder -{ - /// - /// - /// - IServiceProvider ServiceProvider { get; } - /// - /// - /// - List Metadata { get; } -} - -/// -/// -/// -public interface IOperatorCommand -{ - /// - /// - /// - /// - Task RunAsync(string[] args); -} - -internal class OperatorCommandBuilder(IServiceProvider serviceProvider, Type commandType) : IOperatorCommandBuilder -{ - public IServiceProvider ServiceProvider { get; } = serviceProvider; - public Type CommandType { get; } = commandType; - public List Metadata { get; } = []; - - public IOperatorCommand Build() - { - var meta = CommandType.GetCustomAttribute(false) ?? new(CommandType.Name.Replace("Command", string.Empty).ToLower()); - Metadata.Add(meta); - - return (IOperatorCommand)ActivatorUtilities.CreateInstance(ServiceProvider, CommandType); - } -} - -/// -/// -/// -public static class OperatorCommandsBuilderExtensions -{ - /// - /// - /// - /// - /// - public static IOperatorCommandConventionBuilder AddCommand(this IOperatorApplication app, int order = 1) - where T : class, IOperatorCommand - { - return app.Commands.AddCommand(typeof(T), order); - } - - /// - /// - /// - /// - /// - /// - /// - /// - public static TBuilder WithArgument(this TBuilder builder, string argument, string description) - where TBuilder : IOperatorCommandConventionBuilder - { - FinallyReplace(builder, new OperatorArgumentAttribute(argument) { Description = description}); - return builder; - } - - /// - /// - /// - /// - /// - /// - /// - public static TBuilder FinallyReplace(this TBuilder builder, object item) - where TBuilder : IOperatorCommandConventionBuilder - { - builder.Finally(b => { - b.Metadata.RemoveAll(x => x.GetType() == item.GetType()); - b.Metadata.Add(item); - }); - - return builder; - } -} diff --git a/src/K8sOperator.NET/Builder/OperatorCommandConventionBuilder.cs b/src/K8sOperator.NET/Builder/OperatorCommandConventionBuilder.cs deleted file mode 100644 index 3c1b06a..0000000 --- a/src/K8sOperator.NET/Builder/OperatorCommandConventionBuilder.cs +++ /dev/null @@ -1,14 +0,0 @@ - -namespace K8sOperator.NET.Builder; - -/// -/// -/// -public interface IOperatorCommandConventionBuilder : IConventionBuilder { } - -internal class OperatorCommandConventionBuilder( - ICollection> conventions, - ICollection> finallyConventions) -: ConventionBuilder(conventions, finallyConventions), IOperatorCommandConventionBuilder -{ -} diff --git a/src/K8sOperator.NET/Builder/OperatorHostBuilder.cs b/src/K8sOperator.NET/Builder/OperatorHostBuilder.cs deleted file mode 100644 index 6073656..0000000 --- a/src/K8sOperator.NET/Builder/OperatorHostBuilder.cs +++ /dev/null @@ -1,151 +0,0 @@ -using K8sOperator.NET.Extensions; -using K8sOperator.NET.Metadata; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using System.Collections.Generic; -using System.Data.Common; -using System.Reflection; - -namespace K8sOperator.NET.Builder; - -/// -/// Interface for building an Operator Application. -/// -public interface IOperatorApplicationBuilder -{ - /// - /// Gets the configuration settings for the application. - /// - IConfigurationManager Configuration { get; } - - /// - /// Gets the collection of services used by the application. - /// - IServiceCollection Services { get; } - - /// - /// Gets the logging builder for configuring logging services. - /// - ILoggingBuilder Logging { get; } - - /// - /// Gets the data source for the controller, providing access to the Kubernetes resources. - /// - IControllerDataSource DataSource { get; } - - /// - /// - /// - IKubernetesBuilder Kubernetes { get; } - - /// - /// Gets the list of metadata associated with the application. - /// - List Metadata { get; } - - /// - /// Adds a controller to the operator application using the specified type. - /// - /// The type of the controller to add. - /// The controller convention builder for further configuration. - IControllerConventionBuilder AddController(Type controllerType); - - /// - /// Builds the Operator Application based on the configured settings and services. - /// - /// An instance of representing the built application. - - IOperatorApplication Build(); -} - -internal class OperatorApplicationBuilder : IOperatorApplicationBuilder, IControllerBuilder -{ - private readonly List _metadata = []; - private readonly ServiceCollection _serviceCollection = new(); - private readonly ConfigurationManager _configurationManager = new(); - private readonly LoggingBuilder _logging; - private readonly KubernetesBuilder _kubernetes; - private readonly ControllerDatasource _datasource; - private readonly string[] _args; - - internal OperatorApplicationBuilder(string[] args) - { - _logging = new(_serviceCollection); - _kubernetes = new(_serviceCollection); - - _args = args; - - ConfigureConfiguration(); - ConfigureLogging(); - ConfigureKubernetes(); - ConfigureMetadata(); - - _datasource = new ControllerDatasource(_metadata); - } - - private void ConfigureMetadata() - { - var operatorName = Assembly.GetEntryAssembly()?.GetCustomAttribute() - ?? OperatorNameAttribute.Default; - - var dockerImage = Assembly.GetEntryAssembly()?.GetCustomAttribute() - ?? DockerImageAttribute.Default; - - var ns = Assembly.GetEntryAssembly()?.GetCustomAttribute() - ?? NamespaceAttribute.Default; - - _metadata.AddRange([operatorName, dockerImage, ns]); - } - public IConfigurationManager Configuration => _configurationManager; - public IServiceCollection Services => _serviceCollection; - public ILoggingBuilder Logging => _logging; - public IKubernetesBuilder Kubernetes => _kubernetes; - public IControllerDataSource DataSource => _datasource; - public List Metadata => _metadata; - - public IControllerConventionBuilder AddController(Type controllerType) - { - return _datasource.AddController(controllerType) - .WithMetadata([.. _metadata.Where(x => x is not NamespaceAttribute)]); - } - - public IOperatorApplication Build() - { - _serviceCollection.AddSingleton(_configurationManager); - _serviceCollection.AddSingleton(_datasource); - _serviceCollection.AddSingleton(x => new OperatorHostApplication(x, _args)); - - var serviceProvider = _serviceCollection.BuildServiceProvider(); - return serviceProvider.GetRequiredService(); - } - - private void ConfigureConfiguration() - { - _configurationManager - .SetBasePath(Directory.GetCurrentDirectory()) - .AddJsonFile("appsettings.json", true, true) - .AddJsonFile($"appsettings.{Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")}.json", true, true) - .AddEnvironmentVariables() - .AddCommandLine(_args); - } - - private void ConfigureLogging() - { - _serviceCollection.AddLogging(config => - { - config.AddConfiguration(Configuration.GetSection("Logging")); - config.AddSimpleConsole(options => - { - options.IncludeScopes = false; - options.SingleLine = true; - options.TimestampFormat = "HH:mm:ss "; - }); - }); - } - - private void ConfigureKubernetes() - { - _serviceCollection.AddKubernetes(); - } -} diff --git a/src/K8sOperator.NET/ChangeTracker.cs b/src/K8sOperator.NET/ChangeTracker.cs index 1f75bf5..75fe467 100644 --- a/src/K8sOperator.NET/ChangeTracker.cs +++ b/src/K8sOperator.NET/ChangeTracker.cs @@ -1,11 +1,9 @@ -using K8sOperator.NET.Models; +namespace K8sOperator.NET; -namespace K8sOperator.NET; - -internal class ChangeTracker +public class ChangeTracker { private readonly Dictionary _lastResourceGenerationProcessed = []; - + public bool IsResourceGenerationAlreadyHandled(CustomResource resource) { bool processedInPast = _lastResourceGenerationProcessed.TryGetValue(resource.Metadata.Uid, out long resourceGeneration); diff --git a/src/K8sOperator.NET/CommandDatasource.cs b/src/K8sOperator.NET/CommandDatasource.cs index e2233b7..9956261 100644 --- a/src/K8sOperator.NET/CommandDatasource.cs +++ b/src/K8sOperator.NET/CommandDatasource.cs @@ -1,33 +1,14 @@ using K8sOperator.NET.Builder; -using System.Runtime.InteropServices; +using Microsoft.Extensions.Hosting; namespace K8sOperator.NET; -/// -/// -/// -public interface ICommandDatasource -{ - /// - /// - /// - /// - /// - /// - public IOperatorCommandConventionBuilder AddCommand(Type operatorCommandType, int? order = null); - /// - /// - /// - /// - public IEnumerable GetCommands(); -} -internal class CommandDatasource(IServiceProvider serviceProvider) : ICommandDatasource +public class CommandDatasource(IServiceProvider serviceProvider) { - private sealed class CommandEntry + private sealed record CommandEntry { public required Type CommandType { get; init; } - public required List> Conventions { get; init; } - public required List> FinallyConventions { get; init; } + public required List> Conventions { get; init; } public required int Order { get; init; } } @@ -35,63 +16,38 @@ private sealed class CommandEntry public IServiceProvider ServiceProvider { get; } = serviceProvider; - public IOperatorCommandConventionBuilder AddCommand(Type operatorCommandType, int? order = null) + public ConventionBuilder Add() + where TCommand : IOperatorCommand { - var conventions = new List>(); - var finallyConventions = new List>(); - - _commands.Add(new() + var conventions = new List>(); + _commands.Add(new CommandEntry { - CommandType = operatorCommandType, + CommandType = typeof(TCommand), Conventions = conventions, - FinallyConventions = finallyConventions, - Order = order ?? (_commands.Count + 1) + Order = 1 }); - - return new OperatorCommandConventionBuilder(conventions, finallyConventions); + return new ConventionBuilder(conventions); } - public IEnumerable GetCommands() + public IEnumerable GetCommands(IHost app) { foreach (var command in _commands.OrderBy(x => x.Order)) { - var builder = new OperatorCommandBuilder(ServiceProvider, command.CommandType); + var builder = new CommandBuilder(ServiceProvider, command.CommandType); foreach (var convention in command.Conventions) { convention(builder); } - var result = builder.Build(); - - foreach (var convention in command.FinallyConventions) - { - convention(builder); - } + var result = builder.Build(app); - yield return new() - { - Command = result, - Metadata = builder.Metadata, - }; + yield return new(result, builder.Metadata); } } } - -/// -/// -/// -public class CommandInfo -{ - /// - /// - /// - public required IOperatorCommand Command { get; init; } - /// - /// - /// - public required IList Metadata { get; init; } -} - - +public record CommandInfo( + IOperatorCommand Command, + IEnumerable Metadata +); diff --git a/src/K8sOperator.NET/Commands/GenerateDockerfileCommand.cs b/src/K8sOperator.NET/Commands/GenerateDockerfileCommand.cs new file mode 100644 index 0000000..db9a73b --- /dev/null +++ b/src/K8sOperator.NET/Commands/GenerateDockerfileCommand.cs @@ -0,0 +1,150 @@ +using K8sOperator.NET.Builder; +using K8sOperator.NET.Metadata; +using Microsoft.Extensions.Hosting; +using System.Reflection; +using System.Text; + +namespace K8sOperator.NET.Commands; + +[OperatorArgument("generate-dockerfile", Description = "Generates a Dockerfile for the operator", Order = 101, ShowInHelp = false)] +public class GenerateDockerfileCommand(IHost host) : IOperatorCommand +{ + public async Task RunAsync(string[] args) + { + var assembly = Assembly.GetEntryAssembly(); + var operatorName = assembly?.GetCustomAttribute()?.OperatorName + ?? OperatorNameAttribute.Default.OperatorName; + var dockerImage = assembly?.GetCustomAttribute() + ?? DockerImageAttribute.Default; + + var projectName = AppDomain.CurrentDomain.FriendlyName.Replace(".dll", ""); + var targetFramework = "net10.0"; // Can be made dynamic if needed + + var dockerfile = GenerateDockerfileContent(projectName, targetFramework, operatorName); + + var dockerfilePath = Path.Combine(Directory.GetCurrentDirectory(), "Dockerfile"); + await File.WriteAllTextAsync(dockerfilePath, dockerfile); + + var dockerignorePath = Path.Combine(Directory.GetCurrentDirectory(), ".dockerignore"); + await File.WriteAllTextAsync(dockerignorePath, GenerateDockerignoreContent()); + + Console.WriteLine($"✅ Generated Dockerfile at: {dockerfilePath}"); + Console.WriteLine($"✅ Generated .dockerignore at: {dockerignorePath}"); + Console.WriteLine($" Operator: {operatorName}"); + Console.WriteLine($" Image: {dockerImage.Registry}/{dockerImage.Repository}:{dockerImage.Tag}"); + Console.WriteLine(); + Console.WriteLine("To build the image:"); + Console.WriteLine($" docker build -t {dockerImage.Registry}/{dockerImage.Repository}:{dockerImage.Tag} ."); + Console.WriteLine(); + Console.WriteLine("To push the image:"); + Console.WriteLine($" docker push {dockerImage.Registry}/{dockerImage.Repository}:{dockerImage.Tag}"); + } + + private static string GenerateDockerfileContent(string projectName, string targetFramework, string operatorName) + { + 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(); + } + + private static string GenerateDockerignoreContent() + { + 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(); + } +} diff --git a/src/K8sOperator.NET/Commands/GenerateLaunchSettingsCommand.cs b/src/K8sOperator.NET/Commands/GenerateLaunchSettingsCommand.cs new file mode 100644 index 0000000..83e7a7a --- /dev/null +++ b/src/K8sOperator.NET/Commands/GenerateLaunchSettingsCommand.cs @@ -0,0 +1,83 @@ +using K8sOperator.NET.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using System.Text; +using System.Text.Json; + +namespace K8sOperator.NET.Commands; + +[OperatorArgument("generate-launchsettings", Description = "Generates launchSettings.json based on registered commands", Order = 100, ShowInHelp = false)] +public class GenerateLaunchSettingsCommand(IHost host) : IOperatorCommand +{ + public async Task RunAsync(string[] args) + { + var commandDatasource = host.Services.GetRequiredService(); + var commands = commandDatasource.GetCommands(host); + + var launchSettings = new + { + profiles = commands + .Where(c => c.Metadata.OfType().Any()) + .ToDictionary( + c => ToPascalCase(c.Metadata.OfType().First().Argument), + c => new + { + commandName = "Project", + commandLineArgs = c.Metadata.OfType().First().Argument, + environmentVariables = new Dictionary + { + ["ASPNETCORE_ENVIRONMENT"] = "Development" + }, + dotnetRunMessages = true + } + ), + schema = "http://json.schemastore.org/launchsettings.json" + }; + + var json = JsonSerializer.Serialize(launchSettings, new JsonSerializerOptions + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DictionaryKeyPolicy = null // Don't change dictionary keys + }); + + var propertiesDir = Path.Combine(Directory.GetCurrentDirectory(), "Properties"); + Directory.CreateDirectory(propertiesDir); + + var launchSettingsPath = Path.Combine(propertiesDir, "launchSettings.json"); + await File.WriteAllTextAsync(launchSettingsPath, json); + + Console.WriteLine($"✅ Generated launchSettings.json at: {launchSettingsPath}"); + Console.WriteLine($" Found {commands.Count()} command(s):"); + + foreach (var cmd in commands) + { + var arg = cmd.Metadata.OfType().FirstOrDefault(); + if (arg != null) + { + Console.WriteLine($" - {arg.Argument}"); + } + } + } + + private static string ToPascalCase(string input) + { + if (string.IsNullOrEmpty(input)) + return input; + + var parts = input.Split('-', '_'); + var sb = new StringBuilder(); + + foreach (var part in parts) + { + if (!string.IsNullOrEmpty(part)) + { + sb.Append(char.ToUpper(part[0])); + if (part.Length > 1) + sb.Append(part.Substring(1).ToLower()); + } + } + + return sb.ToString(); + } +} diff --git a/src/K8sOperator.NET/Commands/HelpCommand.cs b/src/K8sOperator.NET/Commands/HelpCommand.cs index d0da8d9..7dd4eb6 100644 --- a/src/K8sOperator.NET/Commands/HelpCommand.cs +++ b/src/K8sOperator.NET/Commands/HelpCommand.cs @@ -1,25 +1,28 @@ using K8sOperator.NET.Builder; -using K8sOperator.NET.Metadata; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using static K8sOperator.NET.Helpers.ConsoleHelpers; namespace K8sOperator.NET.Commands; -[OperatorArgument("help", Description = "Displays all commands.", Order = 999)] -internal partial class HelpCommand(IOperatorApplication app) : IOperatorCommand +[OperatorArgument("help", Description = "Displays all commands", Order = 999)] +public class HelpCommand(IHost app) : IOperatorCommand { public Task RunAsync(string[] args) { - var commands = app.Commands.GetCommands(); - var list = commands.SelectMany(x => x.Metadata.OfType()).ToList(); - var maxSize = list.Max(x => x.Argument.Length); + var appname = AppDomain.CurrentDomain.FriendlyName; + var commandDatasource = app.Services.GetRequiredService(); + var commands = commandDatasource.GetCommands(app); + var info = commands.SelectMany(x => x.Metadata.OfType()).ToList(); + var maxSize = info.Count > 0 ? info.Max(x => x.Argument.Length) : 0; - Console.WriteLine($"Welcome to the help for {RED}{app.Name}{NORMAL}."); + Console.WriteLine($"Welcome to the help for {RED}{appname}{NORMAL}."); Console.WriteLine(); Console.WriteLine("Usage:"); - Console.WriteLine($"{BOLD}{app.Name}{NOBOLD} [command]"); + Console.WriteLine($"{BOLD}{appname}{NOBOLD} [command]"); Console.WriteLine(); Console.WriteLine($"{BOLD}Available Commands:{NOBOLD}"); - foreach (var argument in list) + foreach (var argument in info.Where(x => x.ShowInHelp)) { Console.WriteLine($"{SPACE}{GREEN}{argument.Argument.PadRight(maxSize)}{NORMAL}{SPACE}{YELLOW}{argument.Description}{NORMAL}"); } diff --git a/src/K8sOperator.NET.Generators/Install.cs b/src/K8sOperator.NET/Commands/InstallCommand.cs similarity index 81% rename from src/K8sOperator.NET.Generators/Install.cs rename to src/K8sOperator.NET/Commands/InstallCommand.cs index 9462da2..82d6ff9 100644 --- a/src/K8sOperator.NET.Generators/Install.cs +++ b/src/K8sOperator.NET/Commands/InstallCommand.cs @@ -1,17 +1,16 @@ using k8s; using k8s.Models; +using K8sOperator.NET; using K8sOperator.NET.Builder; -using K8sOperator.NET.Extensions; -using K8sOperator.NET.Generators.Builders; +using K8sOperator.NET.Generation; using K8sOperator.NET.Metadata; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; -namespace K8sOperator.NET.Generators; +namespace K8sOperator.NET.Commands; -/// -/// -/// [OperatorArgument("install", Description = "Generates the install manifests")] -public class InstallCommand(IOperatorApplication app) : IOperatorCommand +public class InstallCommand(IHost app) : IOperatorCommand { private readonly StringWriter _output = new(); @@ -21,10 +20,11 @@ public class InstallCommand(IOperatorApplication app) : IOperatorCommand /// public async Task RunAsync(string[] args) { - var watchers = app.DataSource.GetWatchers(app.ServiceProvider); - var clusterrole = CreateClusterRole(app.DataSource.Metadata, watchers); - var clusterrolebinding = CreateClusterRoleBinding(app.DataSource.Metadata); - var deployment = CreateDeployment(app.DataSource.Metadata); + var dataSource = app.Services.GetRequiredService(); + var watchers = dataSource.GetWatchers().ToList(); + var clusterrole = CreateClusterRole(dataSource.Metadata, watchers); + var clusterrolebinding = CreateClusterRoleBinding(dataSource.Metadata); + var deployment = CreateDeployment(dataSource.Metadata); foreach (var item in watchers) { @@ -49,17 +49,17 @@ private async Task Write(IKubernetesObject obj) private static V1CustomResourceDefinition CreateCustomResourceDefinition(IEventWatcher item) { var group = item.Metadata.OfType().First(); - var scope = item.Metadata.OfType().FirstOrDefault(); + var scope = item.Metadata.OfType().FirstOrDefault(); var crdBuilder = new CustomResourceDefinitionBuilder(); crdBuilder - .WithName($"{group.PluralName}.{group.Group}") + .WithName($"{group.PluralName}.{group.Group}".ToLower()) .WithSpec() .WithGroup(group.Group) .WithNames( kind: group.Kind, kindList: $"{group.Kind}List", - plural: group.PluralName, + plural: group.PluralName.ToLower(), singular: group.Kind.ToLower() ) .WithScope(scope == null ? EntityScope.Cluster : EntityScope.Namespaced) @@ -77,9 +77,9 @@ 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.TryGetValue(x => x.OperatorName)!; + var image = metadata.TryGetValue(x => x.GetImage())!; + var ns = metadata.TryGetValue(x => x.Namespace); var deployment = DeploymentBuilder.Create(); @@ -138,7 +138,7 @@ private static V1Deployment CreateDeployment(IReadOnlyList metadata) private static V1ClusterRoleBinding CreateClusterRoleBinding(IReadOnlyList metadata) { - var name = metadata.TryGetValue(x => x.OperatorName); + var name = metadata.TryGetValue(x => x.OperatorName); var clusterrolebinding = new ClusterRoleBindingBuilder() .WithName($"{name}-role-binding") @@ -150,7 +150,7 @@ private static V1ClusterRoleBinding CreateClusterRoleBinding(IReadOnlyList metadata, IEnumerable watchers) { - var name = metadata.TryGetValue(x => x.OperatorName); + var name = metadata.TryGetValue(x => x.OperatorName); var clusterrole = new ClusterRoleBuilder() .WithName($"{name}-role"); @@ -174,11 +174,11 @@ private static V1ClusterRole CreateClusterRole(IReadOnlyList metadata, I { clusterrole.AddRule() .WithGroups(item.Key) - .WithResources(item.Select(x => x.PluralName).ToArray()) + .WithResources([.. item.Select(x => x.PluralName)]) .WithVerbs("*"); clusterrole.AddRule() .WithGroups(item.Key) - .WithResources(item.Select(x => $"{x.PluralName}/status").ToArray()) + .WithResources([.. item.Select(x => $"{x.PluralName}/status")]) .WithVerbs("get", "update", "patch"); } @@ -186,4 +186,3 @@ private static V1ClusterRole CreateClusterRole(IReadOnlyList metadata, I } } - diff --git a/src/K8sOperator.NET/Commands/OperatorCommand.cs b/src/K8sOperator.NET/Commands/OperatorCommand.cs index 1647a92..9ed1588 100644 --- a/src/K8sOperator.NET/Commands/OperatorCommand.cs +++ b/src/K8sOperator.NET/Commands/OperatorCommand.cs @@ -1,13 +1,13 @@ using K8sOperator.NET.Builder; -using K8sOperator.NET.Metadata; +using Microsoft.Extensions.Hosting; namespace K8sOperator.NET.Commands; [OperatorArgument("operator", Description = "Starts the operator.", Order = -2)] -internal class OperatorCommand(IOperatorApplication app) : IOperatorCommand +public class OperatorCommand(IHost app) : IOperatorCommand { public Task RunAsync(string[] args) { - return new Operator(app).RunAsync(); + return app.RunAsync(); } } diff --git a/src/K8sOperator.NET/Commands/VersionCommand.cs b/src/K8sOperator.NET/Commands/VersionCommand.cs index 2d92cc6..17166ff 100644 --- a/src/K8sOperator.NET/Commands/VersionCommand.cs +++ b/src/K8sOperator.NET/Commands/VersionCommand.cs @@ -1,15 +1,29 @@ -using K8sOperator.NET.Builder; +using K8sOperator.NET; +using K8sOperator.NET.Builder; +using K8sOperator.NET.Generation; using K8sOperator.NET.Metadata; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; namespace K8sOperator.NET.Commands; [OperatorArgument("version", Description = "Shows the version", Order = 998)] -internal class VersionCommand(IOperatorApplication app) : IOperatorCommand +internal class VersionCommand(IHost app) : IOperatorCommand { public Task RunAsync(string[] args) { - Console.WriteLine($"{app.Name} version {app.Version}."); + var watcher = app.Services.GetRequiredService(); + var name = watcher.Metadata.TryGetValue(x => x.OperatorName); + var version = watcher.Metadata.TryGetValue(x => x.Tag); + if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(version)) + { + 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())}."); return Task.CompletedTask; } } diff --git a/src/K8sOperator.NET/ControllerDatasource.cs b/src/K8sOperator.NET/ControllerDatasource.cs deleted file mode 100644 index a774f1f..0000000 --- a/src/K8sOperator.NET/ControllerDatasource.cs +++ /dev/null @@ -1,123 +0,0 @@ -using K8sOperator.NET.Builder; -using System.Collections; - -namespace K8sOperator.NET; - -/// -/// Describes a Controller Datasource -/// -public interface IControllerDataSource -{ - /// - /// Gets a readonly list of metadata - /// - IReadOnlyList Metadata { get; } - - /// - /// - /// - /// - /// - IEnumerable GetWatchers(IServiceProvider serviceProvider); -} - -internal class ControllerDatasource(List metadata) : IControllerDataSource -{ - private readonly List _entries = []; - public IReadOnlyList Metadata => metadata; - - internal IControllerConventionBuilder AddController(Type controllerType) - { - var conventions = new AddAfterProcessBuildConventionCollection(); - var finallyConventions = new AddAfterProcessBuildConventionCollection(); - - _entries.Add(new() - { - ControllerType = controllerType, - Conventions = conventions, - FinallyConventions = finallyConventions - }); - - return new ControllerConventionBuilder(conventions, finallyConventions); - } - - public IEnumerable GetWatchers(IServiceProvider serviceProvider) - { - foreach (var entry in _entries) - { - var builder = new ControllerBuilder(serviceProvider, entry.ControllerType); - - foreach (var convention in entry.Conventions) - { - convention(builder); - } - - var controller = builder.Build(); - - foreach (var convention in entry.FinallyConventions) - { - convention(builder); - } - - var eventWatcher = new EventWatcherBuilder(serviceProvider, controller, builder.Metadata) - .Build(); - - yield return eventWatcher; - } - } - - private sealed class ControllerEntry - { - public required Type ControllerType { get; init; } - public required AddAfterProcessBuildConventionCollection Conventions { get; init; } - public required AddAfterProcessBuildConventionCollection FinallyConventions { get; init; } - - } - internal sealed class AddAfterProcessBuildConventionCollection : ICollection> - { - private readonly List> _actions = []; - public bool IsReadOnly { get; set; } - - public int Count => ((ICollection>)_actions).Count; - - public void Add(Action convention) - { - if (IsReadOnly) - { - throw new InvalidOperationException($"{nameof(ControllerDatasource)} can not be modified after build."); - } - - _actions.Add(convention); - } - - public void Clear() - { - ((ICollection>)_actions).Clear(); - } - - public bool Contains(Action item) - { - return ((ICollection>)_actions).Contains(item); - } - - public void CopyTo(Action[] array, int arrayIndex) - { - ((ICollection>)_actions).CopyTo(array, arrayIndex); - } - - public IEnumerator> GetEnumerator() - { - return ((IEnumerable>)_actions).GetEnumerator(); - } - - public bool Remove(Action item) - { - return ((ICollection>)_actions).Remove(item); - } - - IEnumerator IEnumerable.GetEnumerator() - { - return ((IEnumerable)_actions).GetEnumerator(); - } - } -} diff --git a/src/K8sOperator.NET/Models/CustomResource.cs b/src/K8sOperator.NET/CustomResource.cs similarity index 98% rename from src/K8sOperator.NET/Models/CustomResource.cs rename to src/K8sOperator.NET/CustomResource.cs index 3c79cdc..9d4dadd 100644 --- a/src/K8sOperator.NET/Models/CustomResource.cs +++ b/src/K8sOperator.NET/CustomResource.cs @@ -2,7 +2,7 @@ using k8s.Models; using System.Text.Json.Serialization; -namespace K8sOperator.NET.Models; +namespace K8sOperator.NET; /// /// Represents a custom resource in Kubernetes, inheriting from and implementing . @@ -45,3 +45,4 @@ public abstract class CustomResource : CustomResource, IS [JsonPropertyName("status")] public TStatus? Status { get; set; } } + diff --git a/src/K8sOperator.NET/EventWatcher.cs b/src/K8sOperator.NET/EventWatcher.cs index 9c73045..1bf7d2d 100644 --- a/src/K8sOperator.NET/EventWatcher.cs +++ b/src/K8sOperator.NET/EventWatcher.cs @@ -1,53 +1,24 @@ -using k8s; +using k8s; using k8s.Autorest; using k8s.Models; -using K8sOperator.NET.Extensions; +using K8sOperator.NET; using K8sOperator.NET.Metadata; -using K8sOperator.NET.Models; using Microsoft.Extensions.Logging; using System.Text.Json; namespace K8sOperator.NET; -/// -/// Interface representing an event watcher that monitors Kubernetes events and interacts with a controller. -/// -public interface IEventWatcher -{ - /// - /// Gets the metadata associated with the event watcher. - /// - public IReadOnlyList Metadata { get; } - - /// - /// Gets the controller that processes events captured by the event watcher. - /// - public IController Controller { get; } - - /// - /// Starts the event watcher, monitoring for events and processing them using the controller. - /// - /// A token to cancel the operation. - /// A task representing the asynchronous operation. - Task Start(CancellationToken cancellationToken); -} - -internal class EventWatcher(IKubernetesClient client, Controller controller, List metadata, ILoggerFactory loggerfactory) : IEventWatcher +public class EventWatcher( + IKubernetes kubernetes, + OperatorController controller, + List metadata, + ILoggerFactory loggerFactory) : IEventWatcher where T : CustomResource { - private KubernetesEntityAttribute Crd => Metadata.OfType().First(); - private string LabelSelector => Metadata.OfType().FirstOrDefault()?.LabelSelector ?? string.Empty; - private string Finalizer => Metadata.OfType().FirstOrDefault()?.Finalizer ?? FinalizerAttribute.Default; - - private readonly ChangeTracker _changeTracker = new(); - private bool _isRunning; - private CancellationToken _cancellationToken = CancellationToken.None; - private readonly Controller _controller = controller; - public IKubernetesClient Client { get; } = client; - public ILogger Logger { get; } = loggerfactory.CreateLogger("watcher"); public IReadOnlyList Metadata { get; } = metadata; - public IController Controller => _controller; + public ILogger Logger { get; } = loggerFactory.CreateLogger("Watcher"); + public IOperatorController Controller { get; } = controller; public async Task Start(CancellationToken cancellationToken) { @@ -58,13 +29,13 @@ public async Task Start(CancellationToken cancellationToken) { try { - Logger.BeginWatch(Crd.PluralName, LabelSelector); + Logger.BeginWatch(_crd.PluralName, _labelSelector); - await foreach (var (type, item) in Client.WatchAsync(LabelSelector, cancellationToken)) + await foreach (var (type, item) in GetWatchStream()) { if (item is JsonElement je) { - var i = je.Deserialize(); + var i = KubernetesJson.Deserialize(je); if (i is not null) { OnEvent(type, i); @@ -75,33 +46,34 @@ public async Task Start(CancellationToken cancellationToken) { OnEvent(type, resource); continue; - } + }// Handle each watch event } } catch (TaskCanceledException ex) { - Logger.WatcherError($"Task was canceled: {ex.Message}"); + Logger.WatcherError("Task was canceled: " + ex.Message); } catch (OperationCanceledException ex) { - Logger.WatcherError($"Operation was canceled: {ex.Message}"); + Logger.WatcherError("Operation was canceled: " + ex.Message); } catch (HttpOperationException ex) { - Logger.WatcherError($"Http Error: {ex.Response.Content}, restarting..."); + Logger.WatcherError("Http Error: " + ex.Response.Content); } catch (HttpRequestException ex) { - Logger.WatcherError($"Http Request Error: {ex.Message}, restarting..."); + Logger.WatcherError("Http Request Error: " + ex.Message); } finally { - Logger.EndWatch(Crd.PluralName, LabelSelector); + Logger.EndWatch(_crd.PluralName, _labelSelector); if (!cancellationToken.IsCancellationRequested) { try { + Logger.LogInformation("Watcher stopped, waiting to restart..."); await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); } catch (TaskCanceledException) @@ -113,7 +85,6 @@ public async Task Start(CancellationToken cancellationToken) } } - private void OnEvent(WatchEventType eventType, T customResource) { Logger.EventReceived(eventType, customResource); @@ -164,7 +135,7 @@ private async Task HandleErrorEventAsync(T resource, CancellationToken cancellat Logger.BeginError(resource); - await _controller.ErrorAsync(resource, cancellationToken); + await controller.ErrorAsync(resource, cancellationToken); Logger.EndError(resource); } @@ -175,7 +146,7 @@ private async Task HandleBookmarkEventAsync(T resource, CancellationToken cancel Logger.BeginBookmark(resource); - await _controller.BookmarkAsync(resource, cancellationToken); + await controller.BookmarkAsync(resource, cancellationToken); _changeTracker.TrackResourceGenerationAsHandled(resource); @@ -194,7 +165,7 @@ private async Task HandleFinalizeAsync(T resource, CancellationToken cancellatio Logger.BeginFinalize(resource); - await _controller.FinalizeAsync(resource, cancellationToken); + await controller.FinalizeAsync(resource, cancellationToken); if (HasFinalizers(resource)) { @@ -217,7 +188,7 @@ private async Task HandleDeletedEventAsync(T resource, CancellationToken cancell Logger.BeginDelete(resource); - await _controller.DeleteAsync(resource, cancellationToken); + await controller.DeleteAsync(resource, cancellationToken); _changeTracker.TrackResourceGenerationAsDeleted(resource); @@ -244,7 +215,7 @@ private async Task ReplaceAsync(T resource, CancellationToken cancellationTok Logger.ReplaceResource(resource); // Replace the resource - var result = await Client.ReplaceAsync(resource, cancellationToken); + var result = await ResourceReplaceAsync(resource, cancellationToken); return result; } @@ -269,7 +240,7 @@ private async Task HandleAddOrModifyAsync(T resource, CancellationToken cancella return; } - await _controller.AddOrModifyAsync(resource, cancellationToken); + await controller.AddOrModifyAsync(resource, cancellationToken); resource = await ReplaceAsync(resource, _cancellationToken); @@ -277,5 +248,78 @@ private async Task HandleAddOrModifyAsync(T resource, CancellationToken cancella Logger.EndAddOrModify(resource); } -} + private Task ResourceReplaceAsync(T resource, CancellationToken cancellationToken) + { + var ns = metadata.OfType().FirstOrDefault(); + if (ns == null) + { + return kubernetes.CustomObjects.ReplaceClusterCustomObjectAsync( + body: resource, + group: _crd.Group, + version: _crd.ApiVersion, + plural: _crd.PluralName, + name: resource.Metadata.Name, + cancellationToken: cancellationToken); + } + else + { + return kubernetes.CustomObjects.ReplaceNamespacedCustomObjectAsync( + body: resource, + group: _crd.Group, + version: _crd.ApiVersion, + namespaceParameter: ns.Namespace, + plural: _crd.PluralName, + name: resource.Metadata.Name, + cancellationToken: cancellationToken); + } + } + + private IAsyncEnumerable<(WatchEventType type, object item)> GetWatchStream() + { + var ns = metadata.OfType().FirstOrDefault(); + + if (ns == null) + { + return kubernetes.CustomObjects.WatchListClusterCustomObjectAsync( + group: _crd.Group, + version: _crd.ApiVersion, + plural: _crd.PluralName, + allowWatchBookmarks: true, + labelSelector: _labelSelector, + timeoutSeconds: (int)TimeSpan.FromMinutes(60).TotalSeconds, + onError: (ex) => + { + Logger.LogWatchError(ex, "cluster-wide", _crd.PluralName, _labelSelector); + }, + cancellationToken: _cancellationToken); + } + else + { + return kubernetes.CustomObjects.WatchListNamespacedCustomObjectAsync( + group: _crd.Group, + version: _crd.ApiVersion, + namespaceParameter: ns.Namespace, + plural: _crd.PluralName, + allowWatchBookmarks: true, + labelSelector: _labelSelector, + timeoutSeconds: (int)TimeSpan.FromMinutes(60).TotalSeconds, + onError: (ex) => + { + Logger.LogWatchError(ex, ns.Namespace, _crd.PluralName, _labelSelector); + }, + cancellationToken: _cancellationToken); + } + } + + private CancellationToken _cancellationToken = CancellationToken.None; + private bool _isRunning = false; + + private readonly ChangeTracker _changeTracker = new(); + + private readonly 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; +} diff --git a/src/K8sOperator.NET/EventWatcherDatasource.cs b/src/K8sOperator.NET/EventWatcherDatasource.cs new file mode 100644 index 0000000..a38c6d7 --- /dev/null +++ b/src/K8sOperator.NET/EventWatcherDatasource.cs @@ -0,0 +1,69 @@ +using K8sOperator.NET.Builder; + +namespace K8sOperator.NET; + +public interface IEventWatcher +{ + Task Start(CancellationToken cancellationToken); + IReadOnlyList Metadata { get; } + + IOperatorController Controller { get; } +} + + +public class EventWatcherDatasource(IServiceProvider serviceProvider, List metadata) +{ + private readonly List _controllers = []; + public IServiceProvider ServiceProvider { get; } = serviceProvider; + + public List Metadata { get; } = metadata; + + public ConventionBuilder Add() + where TController : IOperatorController + { + var conventions = new List>(); + _controllers.Add(new ControllerEntry + { + ControllerType = typeof(TController), + Conventions = conventions, + }); + + return new ConventionBuilder(conventions); + } + + public IEnumerable GetWatchers() + { + foreach (var controller in _controllers) + { + var builder = ControllerBuilder.Create(ServiceProvider, controller.ControllerType, Metadata); + + foreach (var convention in controller.Conventions) + { + convention(builder); + } + + var result = builder.Build(); + + var eventWatcher = EventWatcherBuilder.Create(ServiceProvider, result, builder.Metadata) + .Build(); + + yield return eventWatcher; + } + } + + private sealed record ControllerEntry + { + public required Type ControllerType { get; init; } + public required List> Conventions { get; init; } + } +} + +public record ControllerInfo( + IOperatorController Controller, + IEnumerable Metadata +); + +public interface IOperatorController +{ + Type ResourceType { get; } +} diff --git a/src/K8sOperator.NET/Extensions/ControllerConventionBuilderExtensions.cs b/src/K8sOperator.NET/Extensions/ControllerConventionBuilderExtensions.cs deleted file mode 100644 index 7928574..0000000 --- a/src/K8sOperator.NET/Extensions/ControllerConventionBuilderExtensions.cs +++ /dev/null @@ -1,125 +0,0 @@ -using K8sOperator.NET.Builder; - -namespace K8sOperator.NET.Extensions; - -/// -/// -/// -public static class ControllerConventionBuilderExtensions -{ - /// - /// - /// - /// - /// - /// - /// - public static TBuilder RemoveMetadata(this TBuilder builder, object item) - where TBuilder : IControllerConventionBuilder - { - builder.Add(b => - { - b.Metadata.RemoveAll(x => x.GetType() == item.GetType()); - }); - - return builder; - } - - /// - /// - /// - /// - /// - /// - /// - public static TBuilder WithMetadata(this TBuilder builder, params object[] items) - where TBuilder : IControllerConventionBuilder - { - builder.Add(b => - { - foreach (var item in items) - { - b.Metadata.Add(item); - } - }); - - return builder; - } - - /// - /// - /// - /// - /// - /// - /// - public static TBuilder WithSingle(this TBuilder builder, object metadata) - where TBuilder : IControllerConventionBuilder - { - builder.RemoveMetadata(metadata); - builder.WithMetadata(metadata); - return builder; - } -} - - - -/// -/// -/// -public static class OperatorCommandConventionBuilderExtensions -{ - /// - /// - /// - /// - /// - /// - /// - public static TBuilder RemoveMetadata(this TBuilder builder, object item) - where TBuilder : IOperatorCommandConventionBuilder - { - builder.Add(b => - { - b.Metadata.RemoveAll(x => x.GetType() == item.GetType()); - }); - - return builder; - } - - /// - /// - /// - /// - /// - /// - /// - public static TBuilder WithMetadata(this TBuilder builder, params object[] items) - where TBuilder : IOperatorCommandConventionBuilder - { - builder.Add(b => - { - foreach (var item in items) - { - b.Metadata.Add(item); - } - }); - - return builder; - } - - /// - /// - /// - /// - /// - /// - /// - public static TBuilder WithSingle(this TBuilder builder, object metadata) - where TBuilder : IOperatorCommandConventionBuilder - { - builder.RemoveMetadata(metadata); - builder.WithMetadata(metadata); - return builder; - } -} diff --git a/src/K8sOperator.NET/Extensions/KubernetesBuilderExtensions.cs b/src/K8sOperator.NET/Extensions/KubernetesBuilderExtensions.cs deleted file mode 100644 index 5292598..0000000 --- a/src/K8sOperator.NET/Extensions/KubernetesBuilderExtensions.cs +++ /dev/null @@ -1,34 +0,0 @@ -using k8s; -using Microsoft.Extensions.DependencyInjection; - -namespace K8sOperator.NET.Extensions; - -/// -/// Extension methods for Kubenetes -/// -public static class KubernetesBuilderExtensions -{ - /// - /// Adds Kubernetes client to the servicecollection - /// - /// - /// - public static IServiceCollection AddKubernetes(this IServiceCollection services) - { - services.AddSingleton(x => { - - KubernetesClientConfiguration config; - - if (KubernetesClientConfiguration.IsInCluster()) - { - config = KubernetesClientConfiguration.InClusterConfig(); - } - else - { - config = KubernetesClientConfiguration.BuildConfigFromConfigFile(); - } - return new Kubernetes(config); - }); - return services; - } -} diff --git a/src/K8sOperator.NET/Extensions/OperatorHostBuilderExtensions.cs b/src/K8sOperator.NET/Extensions/OperatorHostBuilderExtensions.cs deleted file mode 100644 index 0151951..0000000 --- a/src/K8sOperator.NET/Extensions/OperatorHostBuilderExtensions.cs +++ /dev/null @@ -1,70 +0,0 @@ -using K8sOperator.NET; -using K8sOperator.NET.Builder; -using K8sOperator.NET.Metadata; - -namespace K8sOperator.NET.Extensions; - -/// -/// Provides extension methods for configuring an operator host. -/// -public static class OperatorHostBuilderExtensions -{ - - /// - /// Sets the name of the operator application. - /// - /// The operator application builder. - /// The name of the operator. - /// The configured operator application builder. - public static IOperatorApplicationBuilder WithName(this IOperatorApplicationBuilder builder, - string name - ) - { - builder.Metadata.RemoveAll(x => x.GetType() == typeof(OperatorNameAttribute)); - builder.Metadata.Add(new OperatorNameAttribute(name.ToLowerInvariant())); - return builder; - } - - /// - /// - /// - /// - /// - /// - public static IOperatorApplicationBuilder WithNamespace(this IOperatorApplicationBuilder builder, string ns) - { - builder.Metadata.RemoveAll(x => x is NamespaceAttribute); - builder.Metadata.Add(new NamespaceAttribute(ns.ToLowerInvariant())); - return builder; - } - - /// - /// Sets the Docker image information for the operator application. - /// - /// The operator application builder. - /// The Docker registry. Defaults to "ghcr.io". - /// The Docker repository. - /// The tag of the Docker image. - /// The configured operator application builder. - public static IOperatorApplicationBuilder WithImage(this IOperatorApplicationBuilder builder, - string registery = "ghcr.io", - string repository = "", - string tag = "" - ) - { - builder.Metadata.RemoveAll(x => x.GetType() == typeof(DockerImageAttribute)); - builder.Metadata.Add(new DockerImageAttribute(registery, repository, tag)); - return builder; - } - - /// - /// Adds a controller to the operator application. - /// - /// The type of the controller to add. - /// The operator application builder. - /// The controller convention builder for further configuration. - public static IControllerConventionBuilder AddController(this IOperatorApplicationBuilder builder) - where T : IController - => builder.AddController(typeof(T)); - -} diff --git a/src/K8sOperator.NET.Generators/Builders/ClusterRoleBindingBuilder.cs b/src/K8sOperator.NET/Generation/ClusterRoleBindingBuilder.cs similarity index 74% rename from src/K8sOperator.NET.Generators/Builders/ClusterRoleBindingBuilder.cs rename to src/K8sOperator.NET/Generation/ClusterRoleBindingBuilder.cs index 7fc4e21..22d8727 100644 --- a/src/K8sOperator.NET.Generators/Builders/ClusterRoleBindingBuilder.cs +++ b/src/K8sOperator.NET/Generation/ClusterRoleBindingBuilder.cs @@ -1,9 +1,9 @@ using k8s; using k8s.Models; -namespace K8sOperator.NET.Generators.Builders; +namespace K8sOperator.NET.Generation; -internal class ClusterRoleBindingBuilder : KubernetesObjectBuilderWithMetaData +internal class ClusterRoleBindingBuilder : KubernetesObjectBuilderWithMetadata { public override V1ClusterRoleBinding Build() { diff --git a/src/K8sOperator.NET.Generators/Builders/ClusterRoleBindingBuilderExtensions.cs b/src/K8sOperator.NET/Generation/ClusterRoleBindingBuilderExtensions.cs similarity index 97% rename from src/K8sOperator.NET.Generators/Builders/ClusterRoleBindingBuilderExtensions.cs rename to src/K8sOperator.NET/Generation/ClusterRoleBindingBuilderExtensions.cs index aecd608..35be0e5 100644 --- a/src/K8sOperator.NET.Generators/Builders/ClusterRoleBindingBuilderExtensions.cs +++ b/src/K8sOperator.NET/Generation/ClusterRoleBindingBuilderExtensions.cs @@ -1,6 +1,6 @@ using k8s.Models; -namespace K8sOperator.NET.Generators.Builders; +namespace K8sOperator.NET.Generation; /// /// Provides extension methods for building Kubernetes ClusterRoleBindings. diff --git a/src/K8sOperator.NET.Generators/Builders/ClusterRoleBuilder.cs b/src/K8sOperator.NET/Generation/ClusterRoleBuilder.cs similarity index 77% rename from src/K8sOperator.NET.Generators/Builders/ClusterRoleBuilder.cs rename to src/K8sOperator.NET/Generation/ClusterRoleBuilder.cs index 5fafb2e..70e145e 100644 --- a/src/K8sOperator.NET.Generators/Builders/ClusterRoleBuilder.cs +++ b/src/K8sOperator.NET/Generation/ClusterRoleBuilder.cs @@ -1,9 +1,9 @@ using k8s; using k8s.Models; -namespace K8sOperator.NET.Generators.Builders; +namespace K8sOperator.NET.Generation; -internal class ClusterRoleBuilder : KubernetesObjectBuilderWithMetaData +internal class ClusterRoleBuilder : KubernetesObjectBuilderWithMetadata { public override V1ClusterRole Build() { diff --git a/src/K8sOperator.NET.Generators/Builders/ClusterRoleBuilderExtensions.cs b/src/K8sOperator.NET/Generation/ClusterRoleBuilderExtensions.cs similarity index 93% rename from src/K8sOperator.NET.Generators/Builders/ClusterRoleBuilderExtensions.cs rename to src/K8sOperator.NET/Generation/ClusterRoleBuilderExtensions.cs index 6a52e6d..958749c 100644 --- a/src/K8sOperator.NET.Generators/Builders/ClusterRoleBuilderExtensions.cs +++ b/src/K8sOperator.NET/Generation/ClusterRoleBuilderExtensions.cs @@ -1,6 +1,6 @@ using k8s.Models; -namespace K8sOperator.NET.Generators.Builders; +namespace K8sOperator.NET.Generation; /// /// Provides extension methods for building Kubernetes ClusterRoles. diff --git a/src/K8sOperator.NET.Generators/Builders/ContainerBuilder.cs b/src/K8sOperator.NET/Generation/ContainerBuilder.cs similarity index 67% rename from src/K8sOperator.NET.Generators/Builders/ContainerBuilder.cs rename to src/K8sOperator.NET/Generation/ContainerBuilder.cs index 353e914..18d4bc8 100644 --- a/src/K8sOperator.NET.Generators/Builders/ContainerBuilder.cs +++ b/src/K8sOperator.NET/Generation/ContainerBuilder.cs @@ -1,6 +1,6 @@ using k8s.Models; -namespace K8sOperator.NET.Generators.Builders; +namespace K8sOperator.NET.Generation; internal class ContainerBuilder : KubernetesObjectBuilder { diff --git a/src/K8sOperator.NET.Generators/Builders/CustomResourceDefinitionBuilder.cs b/src/K8sOperator.NET/Generation/CustomResourceDefinitionBuilder.cs similarity index 72% rename from src/K8sOperator.NET.Generators/Builders/CustomResourceDefinitionBuilder.cs rename to src/K8sOperator.NET/Generation/CustomResourceDefinitionBuilder.cs index 666bf1a..a446353 100644 --- a/src/K8sOperator.NET.Generators/Builders/CustomResourceDefinitionBuilder.cs +++ b/src/K8sOperator.NET/Generation/CustomResourceDefinitionBuilder.cs @@ -1,9 +1,9 @@ using k8s; using k8s.Models; -namespace K8sOperator.NET.Generators.Builders; +namespace K8sOperator.NET.Generation; -internal class CustomResourceDefinitionBuilder : KubernetesObjectBuilderWithMetaData +internal class CustomResourceDefinitionBuilder : KubernetesObjectBuilderWithMetadata { public override V1CustomResourceDefinition Build() { diff --git a/src/K8sOperator.NET.Generators/Builders/CustomResourceDefinitionBuilderExtensions.cs b/src/K8sOperator.NET/Generation/CustomResourceDefinitionBuilderExtensions.cs similarity index 93% rename from src/K8sOperator.NET.Generators/Builders/CustomResourceDefinitionBuilderExtensions.cs rename to src/K8sOperator.NET/Generation/CustomResourceDefinitionBuilderExtensions.cs index b1972c5..43c2478 100644 --- a/src/K8sOperator.NET.Generators/Builders/CustomResourceDefinitionBuilderExtensions.cs +++ b/src/K8sOperator.NET/Generation/CustomResourceDefinitionBuilderExtensions.cs @@ -1,12 +1,8 @@ using k8s; using k8s.Models; -using K8sOperator.NET.Generators; -using K8sOperator.NET.Helpers; -using K8sOperator.NET.Metadata; -using K8sOperator.NET.Models; using System.Reflection; -namespace K8sOperator.NET.Generators.Builders; +namespace K8sOperator.NET.Generation; /// /// Provides extension methods for building Kubernetes CustomResourceDefinitions. @@ -259,7 +255,17 @@ public static TBuilder OfType(this TBuilder builder, Type type, bool? { builder.Add(x => { - x.Type = "boolean"; + x.Type = "string"; + x.Nullable = nullable; + }); + return builder; + } + + if (type.FullName == "System.DateTime") + { + builder.Add(x => + { + x.Type = "string"; x.Nullable = nullable; }); return builder; @@ -389,13 +395,27 @@ private static TBuilder EnumType(this TBuilder builder, Type type) builder.Add(x => { x.Type = "string"; - x.EnumProperty = Enum.GetNames(type).Cast().ToList(); + x.EnumProperty = [.. Enum.GetNames(type).Cast()]; }); return builder; } - + /// + /// Check if a type is nullable. + /// + /// The type. + /// True if the type is nullable (i.e. contains "nullable" in its name). + public static bool IsNullable(this Type type) + => type.FullName?.Contains("Nullable") == true; + /// + /// Check if a property is nullable. + /// + /// The property. + /// True if the type is nullable (i.e. contains "nullable" in its name). + public static bool IsNullable(this PropertyInfo prop) + => new NullabilityInfoContext().Create(prop).ReadState == NullabilityState.Nullable || + prop.PropertyType.FullName?.Contains("Nullable") == true; } diff --git a/src/K8sOperator.NET.Generators/Builders/DeploymentBuilder.cs b/src/K8sOperator.NET/Generation/DeploymentBuilder.cs similarity index 91% rename from src/K8sOperator.NET.Generators/Builders/DeploymentBuilder.cs rename to src/K8sOperator.NET/Generation/DeploymentBuilder.cs index 0f018fc..821ef42 100644 --- a/src/K8sOperator.NET.Generators/Builders/DeploymentBuilder.cs +++ b/src/K8sOperator.NET/Generation/DeploymentBuilder.cs @@ -1,7 +1,7 @@ using k8s; using k8s.Models; -namespace K8sOperator.NET.Generators.Builders; +namespace K8sOperator.NET.Generation; /// /// Provides functionality for creating Kubernetes Deployment objects. @@ -16,7 +16,7 @@ public static IKubernetesObjectBuilderWithMetadata Create() => new DeploymentBuilderImp(); } -internal class DeploymentBuilderImp : KubernetesObjectBuilderWithMetaData +internal class DeploymentBuilderImp : KubernetesObjectBuilderWithMetadata { public override V1Deployment Build() { diff --git a/src/K8sOperator.NET.Generators/Builders/DeploymentBuilderExtensions.cs b/src/K8sOperator.NET/Generation/DeploymentBuilderExtensions.cs similarity index 99% rename from src/K8sOperator.NET.Generators/Builders/DeploymentBuilderExtensions.cs rename to src/K8sOperator.NET/Generation/DeploymentBuilderExtensions.cs index f29de55..97e3d1e 100644 --- a/src/K8sOperator.NET.Generators/Builders/DeploymentBuilderExtensions.cs +++ b/src/K8sOperator.NET/Generation/DeploymentBuilderExtensions.cs @@ -1,6 +1,6 @@ using k8s.Models; -namespace K8sOperator.NET.Generators.Builders; +namespace K8sOperator.NET.Generation; /// /// Provides extension methods for building Kubernetes Deployment objects. @@ -86,7 +86,7 @@ public static TBuilder WithSelector(this TBuilder builder, public static IKubernetesObjectBuilder WithTemplate(this TBuilder builder) where TBuilder : IKubernetesObjectBuilder { - var podBuilder = new KubernetesObjectBuilderWithMetaData(); + var podBuilder = new KubernetesObjectBuilderWithMetadata(); builder.Add(x => x.Template = podBuilder.Build()); return podBuilder; } diff --git a/src/K8sOperator.NET.Generators/Builders/EntityScope.cs b/src/K8sOperator.NET/Generation/EntityScope.cs similarity index 84% rename from src/K8sOperator.NET.Generators/Builders/EntityScope.cs rename to src/K8sOperator.NET/Generation/EntityScope.cs index f087a3e..408cec4 100644 --- a/src/K8sOperator.NET.Generators/Builders/EntityScope.cs +++ b/src/K8sOperator.NET/Generation/EntityScope.cs @@ -1,4 +1,4 @@ -namespace K8sOperator.NET.Generators.Builders; +namespace K8sOperator.NET.Generation; /// diff --git a/src/K8sOperator.NET.Generators/Builders/KubernetesObjectBuilder.cs b/src/K8sOperator.NET/Generation/KubernetesObjectBuilder.cs similarity index 94% rename from src/K8sOperator.NET.Generators/Builders/KubernetesObjectBuilder.cs rename to src/K8sOperator.NET/Generation/KubernetesObjectBuilder.cs index 1fd9768..1cb6dce 100644 --- a/src/K8sOperator.NET.Generators/Builders/KubernetesObjectBuilder.cs +++ b/src/K8sOperator.NET/Generation/KubernetesObjectBuilder.cs @@ -1,4 +1,4 @@ -namespace K8sOperator.NET.Generators.Builders; +namespace K8sOperator.NET.Generation; /// /// Describes a Generic Kubernetes Resource Builder diff --git a/src/K8sOperator.NET.Generators/Builders/KubernetesObjectBuilderExtentions.cs b/src/K8sOperator.NET/Generation/KubernetesObjectBuilderExtentions.cs similarity index 93% rename from src/K8sOperator.NET.Generators/Builders/KubernetesObjectBuilderExtentions.cs rename to src/K8sOperator.NET/Generation/KubernetesObjectBuilderExtentions.cs index 6dd6ecc..ac6af6d 100644 --- a/src/K8sOperator.NET.Generators/Builders/KubernetesObjectBuilderExtentions.cs +++ b/src/K8sOperator.NET/Generation/KubernetesObjectBuilderExtentions.cs @@ -1,7 +1,6 @@ -using k8s; -using k8s.Models; +using k8s.Models; -namespace K8sOperator.NET.Generators.Builders; +namespace K8sOperator.NET.Generation; /// /// Provides extension methods for configuring Kubernetes objects with metadata. @@ -33,9 +32,9 @@ public static IKubernetesObjectBuilder WithName(this IKubernetesObjectBuil /// The namespace to assign to the Kubernetes object. /// The configured builder. public static IKubernetesObjectBuilder WithNamespace(this IKubernetesObjectBuilder builder, string? ns) - where T : IMetadata + where T : IMetadata { - builder.Add(x => + builder.Add(x => { x.Metadata.SetNamespace(ns); }); diff --git a/src/K8sOperator.NET.Generators/Builders/KubernetesObjectBuilderWithMetaData.cs b/src/K8sOperator.NET/Generation/KubernetesObjectBuilderWithMetaData.cs similarity index 79% rename from src/K8sOperator.NET.Generators/Builders/KubernetesObjectBuilderWithMetaData.cs rename to src/K8sOperator.NET/Generation/KubernetesObjectBuilderWithMetaData.cs index d80962a..1ab1c2e 100644 --- a/src/K8sOperator.NET.Generators/Builders/KubernetesObjectBuilderWithMetaData.cs +++ b/src/K8sOperator.NET/Generation/KubernetesObjectBuilderWithMetaData.cs @@ -1,7 +1,7 @@ using k8s; using k8s.Models; -namespace K8sOperator.NET.Generators.Builders; +namespace K8sOperator.NET.Generation; /// /// Represents a builder interface for Kubernetes objects that include metadata. @@ -13,11 +13,11 @@ public interface IKubernetesObjectBuilderWithMetadata : IKubernetesObject } -internal class KubernetesObjectBuilderWithMetaData +internal class KubernetesObjectBuilderWithMetadata : KubernetesObjectBuilder, IKubernetesObjectBuilderWithMetadata where T : class, IMetadata, new() { - public KubernetesObjectBuilderWithMetaData() + public KubernetesObjectBuilderWithMetadata() { Add(x => x.Metadata = new V1ObjectMeta()); } diff --git a/src/K8sOperator.NET.Generators/Builders/KubernetesObjectBuilderWithMetadataExtentions.cs b/src/K8sOperator.NET/Generation/KubernetesObjectBuilderWithMetadataExtentions.cs similarity index 95% rename from src/K8sOperator.NET.Generators/Builders/KubernetesObjectBuilderWithMetadataExtentions.cs rename to src/K8sOperator.NET/Generation/KubernetesObjectBuilderWithMetadataExtentions.cs index 7684cf1..0457981 100644 --- a/src/K8sOperator.NET.Generators/Builders/KubernetesObjectBuilderWithMetadataExtentions.cs +++ b/src/K8sOperator.NET/Generation/KubernetesObjectBuilderWithMetadataExtentions.cs @@ -1,7 +1,7 @@ using k8s; using k8s.Models; -namespace K8sOperator.NET.Generators.Builders; +namespace K8sOperator.NET.Generation; /// /// Provides extension methods for configuring Kubernetes objects with metadata. diff --git a/src/K8sOperator.NET/Extensions/MetaDataExtensions.cs b/src/K8sOperator.NET/Generation/MetadataExtensions.cs similarity index 66% rename from src/K8sOperator.NET/Extensions/MetaDataExtensions.cs rename to src/K8sOperator.NET/Generation/MetadataExtensions.cs index ca92d3e..e09f7fa 100644 --- a/src/K8sOperator.NET/Extensions/MetaDataExtensions.cs +++ b/src/K8sOperator.NET/Generation/MetadataExtensions.cs @@ -1,8 +1,9 @@ using k8s.Models; using K8sOperator.NET.Builder; +using K8sOperator.NET.Generation; using K8sOperator.NET.Metadata; -namespace K8sOperator.NET.Extensions; +namespace K8sOperator.NET.Generation; /// /// Provides extension methods for metadata manipulation and configuration of Kubernetes controllers. @@ -33,13 +34,13 @@ public static class MetadataExtensions /// The kind of the Kubernetes entity. /// The plural name of the Kubernetes entity. /// The configured builder. - public static TBuilder WithGroup(this TBuilder builder, + public static TBuilder WithGroup(this TBuilder builder, string group = "", string version = "v1", string kind = "", string pluralName = "" ) - where TBuilder : IControllerConventionBuilder + where TBuilder : ConventionBuilder { builder.WithSingle(new KubernetesEntityAttribute() { @@ -59,12 +60,9 @@ public static TBuilder WithGroup(this TBuilder builder, /// The namespace to watch. /// The configured builder. public static TBuilder ForNamespace(this TBuilder builder, string watchNamespace) - where TBuilder : IControllerConventionBuilder + where TBuilder : ConventionBuilder { - builder.Finally(x => { - x.Metadata.RemoveAll(x => x is NamespaceAttribute); - x.Metadata.Add(new NamespaceAttribute(watchNamespace)); - }); + builder.WithSingle(new NamespaceAttribute(watchNamespace)); return builder; } @@ -76,7 +74,7 @@ public static TBuilder ForNamespace(this TBuilder builder, string watc /// The label selector string. /// The configured builder. public static TBuilder WithLabel(this TBuilder builder, string labelselector) - where TBuilder : IControllerConventionBuilder + where TBuilder : ConventionBuilder { builder.WithSingle(new LabelSelectorAttribute(labelselector)); return builder; @@ -90,9 +88,63 @@ public static TBuilder WithLabel(this TBuilder builder, string labelse /// The finalizer string. /// The configured builder. public static TBuilder WithFinalizer(this TBuilder builder, string finalizer) - where TBuilder : IControllerConventionBuilder + where TBuilder : ConventionBuilder { builder.WithMetadata(new FinalizerAttribute(finalizer)); return builder; } + + /// + /// + /// + /// + /// + /// + /// + public static TBuilder RemoveMetadata(this TBuilder builder, object item) + where TBuilder : ConventionBuilder + { + builder.Add(b => + { + b.Metadata.RemoveAll(x => x.GetType() == item.GetType()); + }); + + return builder; + } + + /// + /// + /// + /// + /// + /// + /// + public static TBuilder WithMetadata(this TBuilder builder, params object[] items) + where TBuilder : ConventionBuilder + { + builder.Add(b => + { + foreach (var item in items) + { + b.Metadata.Add(item); + } + }); + + return builder; + } + + /// + /// + /// + /// + /// + /// + /// + public static TBuilder WithSingle(this TBuilder builder, object metadata) + where TBuilder : ConventionBuilder + { + builder.RemoveMetadata(metadata); + builder.WithMetadata(metadata); + return builder; + } } diff --git a/src/K8sOperator.NET.Generators/Builders/PolicyRuleBuilder.cs b/src/K8sOperator.NET/Generation/PolicyRuleBuilder.cs similarity index 68% rename from src/K8sOperator.NET.Generators/Builders/PolicyRuleBuilder.cs rename to src/K8sOperator.NET/Generation/PolicyRuleBuilder.cs index 2749e05..8683f28 100644 --- a/src/K8sOperator.NET.Generators/Builders/PolicyRuleBuilder.cs +++ b/src/K8sOperator.NET/Generation/PolicyRuleBuilder.cs @@ -1,6 +1,6 @@ using k8s.Models; -namespace K8sOperator.NET.Generators.Builders; +namespace K8sOperator.NET.Generation; internal class PolicyRuleBuilder : KubernetesObjectBuilder { diff --git a/src/K8sOperator.NET.Generators/Builders/PolicyRuleBuilderExtensions.cs b/src/K8sOperator.NET/Generation/PolicyRuleBuilderExtensions.cs similarity index 97% rename from src/K8sOperator.NET.Generators/Builders/PolicyRuleBuilderExtensions.cs rename to src/K8sOperator.NET/Generation/PolicyRuleBuilderExtensions.cs index 77a6fba..5514a9c 100644 --- a/src/K8sOperator.NET.Generators/Builders/PolicyRuleBuilderExtensions.cs +++ b/src/K8sOperator.NET/Generation/PolicyRuleBuilderExtensions.cs @@ -1,6 +1,6 @@ using k8s.Models; -namespace K8sOperator.NET.Generators.Builders; +namespace K8sOperator.NET.Generation; /// /// Provides extension methods for building Kubernetes policy rules. diff --git a/src/K8sOperator.NET/Helpers/Console.cs b/src/K8sOperator.NET/Helpers/ConsoleHelpers.cs similarity index 97% rename from src/K8sOperator.NET/Helpers/Console.cs rename to src/K8sOperator.NET/Helpers/ConsoleHelpers.cs index 41e5d17..b261845 100644 --- a/src/K8sOperator.NET/Helpers/Console.cs +++ b/src/K8sOperator.NET/Helpers/ConsoleHelpers.cs @@ -1,5 +1,6 @@ namespace K8sOperator.NET.Helpers; -internal static class ConsoleHelpers + +public static class ConsoleHelpers { internal static readonly string SPACE = string.Empty.PadRight(3); internal static readonly string NL = Environment.NewLine; // shortcut diff --git a/src/K8sOperator.NET/Helpers/DebuggerHelpers.cs b/src/K8sOperator.NET/Helpers/DebuggerHelpers.cs index f5848f3..37c3947 100644 --- a/src/K8sOperator.NET/Helpers/DebuggerHelpers.cs +++ b/src/K8sOperator.NET/Helpers/DebuggerHelpers.cs @@ -1,4 +1,5 @@ namespace K8sOperator.NET.Helpers; + internal static class DebuggerHelpers { public static string GetDebugText(string key, object value) diff --git a/src/K8sOperator.NET/Helpers/Utilities.cs b/src/K8sOperator.NET/Helpers/Utilities.cs deleted file mode 100644 index dd2fb1c..0000000 --- a/src/K8sOperator.NET/Helpers/Utilities.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Reflection; - -namespace K8sOperator.NET.Helpers; -internal static class Utilities -{ - /// - /// Check if a type is nullable. - /// - /// The type. - /// True if the type is nullable (i.e. contains "nullable" in its name). - public static bool IsNullable(this Type type) - => type.FullName?.Contains("Nullable") == true; - /// - /// Check if a property is nullable. - /// - /// The property. - /// True if the type is nullable (i.e. contains "nullable" in its name). - public static bool IsNullable(this PropertyInfo prop) - => new NullabilityInfoContext().Create(prop).ReadState == NullabilityState.Nullable || - prop.PropertyType.FullName?.Contains("Nullable") == true; -} diff --git a/src/K8sOperator.NET/K8sOperator.NET.csproj b/src/K8sOperator.NET/K8sOperator.NET.csproj index 086500b..f89e89b 100644 --- a/src/K8sOperator.NET/K8sOperator.NET.csproj +++ b/src/K8sOperator.NET/K8sOperator.NET.csproj @@ -1,19 +1,68 @@  - 75af4ad7-a38b-4759-92a0-fa12fc4f56dd + net10.0 + enable + enable + true + + true + + true + false + + + + PMDEvers + $(ProjectName) + + 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. + + Patrick Evers + $(CompanyName) + + + + + + + $(CompanyName) + $(MSBuildProjectName.Replace(" ", "_")) + README.md + logo.png + https://github.com/$(CompanyName)/$(ProjectName) + MIT + git + https://github.com/$(CompanyName)/$(ProjectName) - - - + + + + + - + + + + + + + True - \build\$(ProjectName).targets - + \ + false + + + True + \ + + + + + diff --git a/src/K8sOperator.NET/KubernetesClient.cs b/src/K8sOperator.NET/KubernetesClient.cs deleted file mode 100644 index eeaf65c..0000000 --- a/src/K8sOperator.NET/KubernetesClient.cs +++ /dev/null @@ -1,113 +0,0 @@ -using k8s; -using k8s.Models; -using K8sOperator.NET.Extensions; -using K8sOperator.NET.Models; -using Microsoft.Extensions.Logging; -using System.Reflection; - -namespace K8sOperator.NET; - -internal interface IKubernetesClient -{ - IAsyncEnumerable<(WatchEventType, object)> WatchAsync(string labelSelector, CancellationToken cancellationToken) - where T : CustomResource; - Task ReplaceAsync(T resource, CancellationToken cancellationToken) - where T : CustomResource; - - -} - -internal class NamespacedKubernetesClient(IKubernetes client, ILogger logger, string ns = "default") : IKubernetesClient -{ - public IKubernetes Client { get; } = client; - public ILogger Logger { get; } = logger; - public string Namespace { get; } = ns; - - public IAsyncEnumerable<(WatchEventType, object)> WatchAsync(string labelSelector, CancellationToken cancellationToken) where T : CustomResource - { - var info = typeof(T).GetCustomAttribute()!; - - Logger.WatchAsync(Namespace, info.PluralName, labelSelector); - - var response = Client.CustomObjects.WatchListNamespacedCustomObjectAsync( - info.Group, - info.ApiVersion, - Namespace, - info.PluralName, - allowWatchBookmarks: true, - labelSelector: labelSelector, - timeoutSeconds: (int)TimeSpan.FromMinutes(60).TotalSeconds, - cancellationToken: cancellationToken - ); - - return response; - } - - public async Task ReplaceAsync(T resource, CancellationToken cancellationToken) - where T : CustomResource - { - Logger.ReplaceResource(resource); - - var info = resource.GetType().GetCustomAttribute()!; - - // Replace the resource - var result = await Client.CustomObjects.ReplaceNamespacedCustomObjectAsync( - resource, - info.Group, - info.ApiVersion, - resource.Metadata.NamespaceProperty, - info.PluralName, - resource.Metadata.Name, - cancellationToken: cancellationToken - ).ConfigureAwait(false); - - return result; - } -} - - -internal class ClusterKubernetesClient(IKubernetes client, ILogger logger) : IKubernetesClient -{ - public IKubernetes Client { get; } = client; - public ILogger Logger { get; } = logger; - - public async Task ReplaceAsync(T resource, CancellationToken cancellationToken) - where T : CustomResource - { - Logger.ReplaceResource(resource); - - var info = resource.GetType().GetCustomAttribute()!; - - // Replace the resource - var result = await Client.CustomObjects.ReplaceClusterCustomObjectAsync( - resource, - info.Group, - info.ApiVersion, - info.PluralName, - resource.Metadata.Name, - cancellationToken: cancellationToken - ).ConfigureAwait(false); - - return result; - } - - public IAsyncEnumerable<(WatchEventType, object)> WatchAsync(string labelSelector, CancellationToken cancellationToken) - where T : CustomResource - { - var info = typeof(T).GetCustomAttribute()!; - - Logger.WatchAsync("cluster-wide", info.PluralName, labelSelector); - - var response = Client.CustomObjects.WatchListClusterCustomObjectAsync( - info.Group, - info.ApiVersion, - info.PluralName, - allowWatchBookmarks: true, - labelSelector: labelSelector, - timeoutSeconds: (int)TimeSpan.FromMinutes(60).TotalSeconds, - cancellationToken: cancellationToken - ); - - return response; - } -} diff --git a/src/K8sOperator.NET/LaunchSettings.json b/src/K8sOperator.NET/LaunchSettings.json new file mode 100644 index 0000000..d8fcd68 --- /dev/null +++ b/src/K8sOperator.NET/LaunchSettings.json @@ -0,0 +1,37 @@ +{ + "profiles": { + "Operator": { + "commandName": "Project", + "commandLineArgs": "operator", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true + }, + "Install": { + "commandName": "Project", + "commandLineArgs": "install > ./install.yaml", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true + }, + "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/Extensions/LoggingExtensions.cs b/src/K8sOperator.NET/LoggerExtensions.cs similarity index 95% rename from src/K8sOperator.NET/Extensions/LoggingExtensions.cs rename to src/K8sOperator.NET/LoggerExtensions.cs index d501f81..fbba5b8 100644 --- a/src/K8sOperator.NET/Extensions/LoggingExtensions.cs +++ b/src/K8sOperator.NET/LoggerExtensions.cs @@ -1,17 +1,10 @@ using k8s; -using K8sOperator.NET.Models; using Microsoft.Extensions.Logging; -namespace K8sOperator.NET.Extensions; +namespace K8sOperator.NET; -internal static partial class LoggingExtensions +public static partial class LoggerExtensions { - [LoggerMessage( - EventId = 0, - Level = LogLevel.Information, - Message = "Start Operator." - )] - public static partial void StartOperator(this ILogger logger); [LoggerMessage( EventId = 1, @@ -193,5 +186,4 @@ internal static partial class LoggingExtensions Message = "WatchAsync {ns}/{plural} {labelselector}" )] public static partial void WatchAsync(this ILogger logger, string ns, string plural, string labelselector); - } diff --git a/src/K8sOperator.NET/Metadata/DescriptionMetadata.cs b/src/K8sOperator.NET/Metadata/DescriptionMetadata.cs deleted file mode 100644 index 4e86658..0000000 --- a/src/K8sOperator.NET/Metadata/DescriptionMetadata.cs +++ /dev/null @@ -1,32 +0,0 @@ -using K8sOperator.NET.Helpers; - -namespace K8sOperator.NET.Metadata; - -/// -/// Represents metadata that includes a description. -/// -public interface IDescriptionMetadata -{ - /// - /// Gets the description associated with the metadata. - /// - string Description { get; } -} - - -/// -/// -/// -/// -[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] -public class DescriptionAttribute(string description) : Attribute, IDescriptionMetadata -{ - /// - /// - /// - public string Description { get; set; } = description; - - /// - public override string ToString() - => DebuggerHelpers.GetDebugText(nameof(Description), Description); -} diff --git a/src/K8sOperator.NET/Metadata/DockerImageAttribute.cs b/src/K8sOperator.NET/Metadata/DockerImageAttribute.cs new file mode 100644 index 0000000..e935670 --- /dev/null +++ b/src/K8sOperator.NET/Metadata/DockerImageAttribute.cs @@ -0,0 +1,17 @@ +using K8sOperator.NET.Helpers; + +namespace K8sOperator.NET.Metadata; + +[AttributeUsage(AttributeTargets.Assembly)] +public class DockerImageAttribute(string registry, string repository, string tag) : Attribute +{ + public static DockerImageAttribute Default => new("ghcr.io", "operator/operator", "latest"); + + public string Registry { get; set; } = registry; + public string Repository { get; set; } = repository; + public string Tag { get; set; } = tag; + public string GetImage() => $"{Registry}/{Repository}:{Tag}"; + + public override string ToString() + => DebuggerHelpers.GetDebugText("DockerImage", GetImage()); +} diff --git a/src/K8sOperator.NET/Metadata/FinalizerAttribute.cs b/src/K8sOperator.NET/Metadata/FinalizerAttribute.cs new file mode 100644 index 0000000..f1089d7 --- /dev/null +++ b/src/K8sOperator.NET/Metadata/FinalizerAttribute.cs @@ -0,0 +1,12 @@ +using K8sOperator.NET.Helpers; + +namespace K8sOperator.NET.Metadata; + +[AttributeUsage(AttributeTargets.Class)] +public class FinalizerAttribute(string finalizer) : Attribute +{ + public const string Default = "default"; + public string Finalizer { get; } = finalizer; + public override string ToString() + => DebuggerHelpers.GetDebugText(nameof(Finalizer), Finalizer); +} diff --git a/src/K8sOperator.NET/Metadata/FinalizerMetadata.cs b/src/K8sOperator.NET/Metadata/FinalizerMetadata.cs deleted file mode 100644 index cb5d673..0000000 --- a/src/K8sOperator.NET/Metadata/FinalizerMetadata.cs +++ /dev/null @@ -1,43 +0,0 @@ -using K8sOperator.NET.Helpers; - -namespace K8sOperator.NET.Metadata; - -/// -/// Represents metadata for a Kubernetes finalizer. -/// -public interface IFinalizerMetadata -{ - /// - /// Gets the name of the finalizer. - /// - string Finalizer { get; } -} - - -/// -/// Mark a Controller that it has a finalizer -/// -/// -[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] -public class FinalizerAttribute(string finalizer) : Attribute, IFinalizerMetadata -{ - /// - /// Default value of the finalizer. - /// - public const string Default = "operator.default.finalizer"; - - /// - public string Finalizer { get; set; } = finalizer; - - /// - public override string ToString() - => DebuggerHelpers.GetDebugText(nameof(Finalizer), Finalizer); -} - - - - - - - - diff --git a/src/K8sOperator.NET/Metadata/ImageRepositoryMetadata.cs b/src/K8sOperator.NET/Metadata/ImageRepositoryMetadata.cs deleted file mode 100644 index 5eba681..0000000 --- a/src/K8sOperator.NET/Metadata/ImageRepositoryMetadata.cs +++ /dev/null @@ -1,59 +0,0 @@ -using K8sOperator.NET.Helpers; - -namespace K8sOperator.NET.Metadata; - -/// -/// Interface representing metadata for a Docker image. -/// -public interface IImageMetadata -{ - /// - /// Gets the Docker registry where the image is stored. - /// - string Registery { get; } - - /// - /// Gets the Docker repository containing the image. - /// - string Repository { get; } - - /// - /// Gets the tag of the Docker image. - /// - string Tag { get; } - - /// - /// Constructs and returns the full image name in the format "registry/repository/name:tag". - /// - /// The full Docker image name. - string GetImage(); -} - -/// -/// Annotates the assemnbly with the docker image information. -/// -/// The registry of the image. -/// The image repository name. -/// The tag of the image. -[AttributeUsage(AttributeTargets.Assembly)] -public class DockerImageAttribute(string registery, string repository, string tag) : Attribute, IImageMetadata -{ - /// - /// Default docker image - /// - public static DockerImageAttribute Default => new("ghcr.io", "operator/operator", "latest"); - - /// - public string Registery {get;set;} = registery; - /// - public string Repository { get; set; } = repository; - /// - public string Tag { get; set; } = tag; - /// - public string GetImage() => $"{Registery}/{Repository}:{Tag}"; - - - /// - public override string ToString() - => DebuggerHelpers.GetDebugText("DockerImage", GetImage()); -} diff --git a/src/K8sOperator.NET/Metadata/LabelSelectorAttribute.cs b/src/K8sOperator.NET/Metadata/LabelSelectorAttribute.cs new file mode 100644 index 0000000..e9a8f0b --- /dev/null +++ b/src/K8sOperator.NET/Metadata/LabelSelectorAttribute.cs @@ -0,0 +1,11 @@ +using K8sOperator.NET.Helpers; + +namespace K8sOperator.NET.Metadata; + +[AttributeUsage(AttributeTargets.Class)] +public class LabelSelectorAttribute(string labelSelector) : Attribute +{ + public string LabelSelector { get; } = labelSelector; + public override string ToString() + => DebuggerHelpers.GetDebugText(nameof(LabelSelector), LabelSelector); +} diff --git a/src/K8sOperator.NET/Metadata/LabelSelectorMetadata.cs b/src/K8sOperator.NET/Metadata/LabelSelectorMetadata.cs deleted file mode 100644 index dd1487c..0000000 --- a/src/K8sOperator.NET/Metadata/LabelSelectorMetadata.cs +++ /dev/null @@ -1,34 +0,0 @@ -using K8sOperator.NET.Helpers; -using System.Xml.Linq; - -namespace K8sOperator.NET.Metadata; - -/// -/// Interface representing metadata for a label selector used to filter Kubernetes resources. -/// -public interface ILabelSelectorMetadata -{ - /// - /// Gets the label selector string used to filter resources based on labels. - /// - public string LabelSelector { get; } -} - -/// -/// -/// -/// -[AttributeUsage(AttributeTargets.Class)] -public class LabelSelectorAttribute(string labelSelector) : Attribute, ILabelSelectorMetadata -{ - /// - /// - /// - public string LabelSelector { get; set; } = labelSelector; - /// - /// - /// - /// - public override string ToString() - => DebuggerHelpers.GetDebugText(nameof(LabelSelector), LabelSelector); -} diff --git a/src/K8sOperator.NET/Metadata/NamespaceAttribute.cs b/src/K8sOperator.NET/Metadata/NamespaceAttribute.cs new file mode 100644 index 0000000..dca1585 --- /dev/null +++ b/src/K8sOperator.NET/Metadata/NamespaceAttribute.cs @@ -0,0 +1,15 @@ +using K8sOperator.NET.Helpers; + +namespace K8sOperator.NET.Metadata; + +[AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class)] +public sealed class NamespaceAttribute(string ns) : Attribute +{ + public static NamespaceAttribute Default => new("default"); + + public string Namespace { get; set; } = ns; + + public override string ToString() + => DebuggerHelpers.GetDebugText(nameof(Namespace), Namespace); +} + diff --git a/src/K8sOperator.NET/Metadata/NamespaceMetadata.cs b/src/K8sOperator.NET/Metadata/NamespaceMetadata.cs deleted file mode 100644 index b2c0307..0000000 --- a/src/K8sOperator.NET/Metadata/NamespaceMetadata.cs +++ /dev/null @@ -1,37 +0,0 @@ -using K8sOperator.NET.Helpers; -using System.Xml.Linq; - -namespace K8sOperator.NET.Metadata; - -/// -/// Interface representing metadata for a specific Kubernetes namespace. -/// -public interface INamespaceMetadata -{ - /// - /// Gets the namespace to for Kubernetes resources. - /// - public string Namespace { get; } -} - -/// -/// Sets the Namespace -/// -/// -[AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class)] -public sealed class NamespaceAttribute(string ns) : Attribute, INamespaceMetadata -{ - /// - /// Default namespace - /// - public static NamespaceAttribute Default => new("default"); - - /// - /// The namespace - /// - public string Namespace { get; set;} = ns; - - /// - public override string ToString() - => DebuggerHelpers.GetDebugText(nameof(Namespace), Namespace); -} diff --git a/src/K8sOperator.NET/Metadata/OperatorArgumentMetadata.cs b/src/K8sOperator.NET/Metadata/OperatorArgumentMetadata.cs deleted file mode 100644 index 8360bf9..0000000 --- a/src/K8sOperator.NET/Metadata/OperatorArgumentMetadata.cs +++ /dev/null @@ -1,46 +0,0 @@ -using K8sOperator.NET.Helpers; - -namespace K8sOperator.NET.Metadata; - -/// -/// -/// -internal interface ICommandArgumentMetadata -{ - /// - /// - /// - public string Argument { get; } - - /// - /// - /// - public string Description { get; } - - /// - /// - /// - public int Order { get; } -} - -/// -/// -/// -/// -[AttributeUsage(AttributeTargets.Class)] -public class OperatorArgumentAttribute(string argument) : Attribute, ICommandArgumentMetadata -{ - /// - public string Argument {get; set; } = argument; - - /// - public string Description { get; set; } = string.Empty; - /// - /// - /// - public int Order { get; set; } = 1; - - /// - public override string ToString() - => DebuggerHelpers.GetDebugText(nameof(Argument), Argument); -} diff --git a/src/K8sOperator.NET/Metadata/OperatorNameAttribute.cs b/src/K8sOperator.NET/Metadata/OperatorNameAttribute.cs new file mode 100644 index 0000000..2d16cd9 --- /dev/null +++ b/src/K8sOperator.NET/Metadata/OperatorNameAttribute.cs @@ -0,0 +1,15 @@ +using K8sOperator.NET.Helpers; + +namespace K8sOperator.NET.Metadata; + +[AttributeUsage(AttributeTargets.Assembly)] +public class OperatorNameAttribute(string name) : Attribute +{ + public static OperatorNameAttribute Default => new("operator"); + + public string OperatorName => name; + + public override string ToString() + => DebuggerHelpers.GetDebugText(nameof(OperatorName), OperatorName); +} + diff --git a/src/K8sOperator.NET/Metadata/OperatorNameMetadata.cs b/src/K8sOperator.NET/Metadata/OperatorNameMetadata.cs deleted file mode 100644 index e2738c7..0000000 --- a/src/K8sOperator.NET/Metadata/OperatorNameMetadata.cs +++ /dev/null @@ -1,35 +0,0 @@ -using K8sOperator.NET.Helpers; - -namespace K8sOperator.NET.Metadata; - -/// -/// Interface representing metadata for the name of an operator. -/// -public interface IOperatorNameMetadata -{ - /// - /// Gets the name of the operator. - /// - string OperatorName { get; } -} - - -/// -/// Sets the name of the operator at assembly level. -/// -/// -[AttributeUsage(AttributeTargets.Assembly)] -public class OperatorNameAttribute(string name) : Attribute, IOperatorNameMetadata -{ - /// - /// Default value of the attribute - /// - public static OperatorNameAttribute Default => new("operator"); - - /// - public string OperatorName => name; - - /// - public override string ToString() - => DebuggerHelpers.GetDebugText(nameof(OperatorName), OperatorName); -} diff --git a/src/K8sOperator.NET/Operator.cs b/src/K8sOperator.NET/Operator.cs deleted file mode 100644 index 8cf8c05..0000000 --- a/src/K8sOperator.NET/Operator.cs +++ /dev/null @@ -1,35 +0,0 @@ -using K8sOperator.NET.Extensions; - -namespace K8sOperator.NET; - -internal class Operator(IOperatorApplication app) -{ - private readonly CancellationTokenSource _tokenSource = new(); - public string ArgumentName { get; } = "operator"; - - public async Task RunAsync() - { - var watchers = app.DataSource.GetWatchers(app.ServiceProvider) ?? []; - var logger = app.Logger.CreateLogger("operator"); - - logger.StartOperator(); - - if (!watchers.Any()) - { - logger.NoWatchers(); - return; - } - - var tasks = new List(); - - foreach (var watcher in watchers) - { - tasks.Add(watcher.Start(_tokenSource.Token)); - } - - await Task.WhenAll(tasks); - - await _tokenSource.CancelAsync(); - _tokenSource.Dispose(); - } -} diff --git a/src/K8sOperator.NET/Operator.targets b/src/K8sOperator.NET/Operator.targets index ed7329f..e8a80d5 100644 --- a/src/K8sOperator.NET/Operator.targets +++ b/src/K8sOperator.NET/Operator.targets @@ -1,26 +1,88 @@ + + + + + + + + + + + + + + - 1.0.0 $(MSBuildProjectName.Replace(" ", "_").ToLower()) + $(MSBuildProjectName.Replace(" ", "_").ToLower())-system ghcr.io $(Company)/$(OperatorName) - $(PackageVersion) + $(Version) + + + false + + + false + <_Parameter1>$(OperatorName) - <_Parameter1>$(OperatorName)-system - - - <_Parameter1>$(ContainerRegistry) - <_Parameter2>$(ContainerRepository) - <_Parameter3>$(ContainerImageTag)$(ContainerFamily) + <_Parameter1>$(OperatorNamespace) + + + + + <_ContainerImageTagValue Condition=" '$(ContainerImageTag)' == '' ">$(Version) + <_ContainerImageTagValue Condition=" '$(ContainerImageTag)' != '' ">$(ContainerImageTag) + + <_ContainerFamilySuffix Condition=" '$(ContainerFamily)' != '' ">-$(ContainerFamily) + <_ContainerFamilySuffix Condition=" '$(ContainerFamily)' == '' "> + <_FullContainerTag>$(_ContainerImageTagValue)$(_ContainerFamilySuffix) + + + + <_Parameter1>$(ContainerRegistry) + <_Parameter2>$(ContainerRepository) + <_Parameter3>$(_FullContainerTag) + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/K8sOperator.NET/Controller.cs b/src/K8sOperator.NET/OperatorController.cs similarity index 86% rename from src/K8sOperator.NET/Controller.cs rename to src/K8sOperator.NET/OperatorController.cs index e887303..14a1dfe 100644 --- a/src/K8sOperator.NET/Controller.cs +++ b/src/K8sOperator.NET/OperatorController.cs @@ -1,23 +1,10 @@ -using K8sOperator.NET.Models; - -namespace K8sOperator.NET; - -/// -/// Represents a controller interface for managing Kubernetes resources. -/// -public interface IController -{ - /// - /// Gets the type of the Kubernetes resource that the controller manages. - /// - Type ResourceType { get; } -} +namespace K8sOperator.NET; /// /// Represents a base controller for managing Kubernetes custom resources of type . /// /// The type of custom resource managed by the controller. -public abstract class Controller : IController +public abstract class OperatorController : IOperatorController where T : CustomResource { /// @@ -64,7 +51,7 @@ public virtual Task FinalizeAsync(T resource, CancellationToken cancellationToke /// The resource to bookmark. /// A token to cancel the operation. /// A task representing the asynchronous operation. - public virtual Task BookmarkAsync(T resource, CancellationToken cancellationToken) + public virtual Task BookmarkAsync(T resource, CancellationToken cancellationToken) { return Task.CompletedTask; } diff --git a/src/K8sOperator.NET/OperatorException.cs b/src/K8sOperator.NET/OperatorException.cs new file mode 100644 index 0000000..d1a7e98 --- /dev/null +++ b/src/K8sOperator.NET/OperatorException.cs @@ -0,0 +1,6 @@ +namespace K8sOperator.NET; + +public class OperatorException(string message) : Exception(message) +{ + +} diff --git a/src/K8sOperator.NET/OperatorExtensions.cs b/src/K8sOperator.NET/OperatorExtensions.cs new file mode 100644 index 0000000..adf7468 --- /dev/null +++ b/src/K8sOperator.NET/OperatorExtensions.cs @@ -0,0 +1,106 @@ +using k8s; +using K8sOperator.NET; +using K8sOperator.NET.Builder; +using K8sOperator.NET.Commands; +using K8sOperator.NET.Metadata; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using System.Reflection; + +namespace K8sOperator.NET; + +public static class OperatorExtensions +{ + extension(IServiceCollection services) + { + public IServiceCollection AddOperator(Action? configure = null) + { + var builder = new OperatorBuilder(); + configure?.Invoke(builder); + + services.TryAddSingleton(sp => + { + var ds = new CommandDatasource(sp); + + ds.Add(); + ds.Add(); + ds.Add(); + ds.Add(); + ds.Add(); + ds.Add(); + + return ds; + }); + + services.TryAddSingleton(sp => + { + var operatorName = Assembly.GetEntryAssembly()?.GetCustomAttribute() + ?? OperatorNameAttribute.Default; + + var dockerImage = Assembly.GetEntryAssembly()?.GetCustomAttribute() + ?? DockerImageAttribute.Default; + + var ns = Assembly.GetEntryAssembly()?.GetCustomAttribute() + ?? NamespaceAttribute.Default; + + return new EventWatcherDatasource(sp, [operatorName, dockerImage, ns]); + }); + + services.TryAddSingleton((sp) => + { + var config = builder?.Configuration + ?? KubernetesClientConfiguration.BuildDefaultConfig(); + return new Kubernetes(config); + }); + services.TryAddSingleton(); + + return services; + } + } + + extension(IApplicationBuilder app) + { + public ConventionBuilder MapController() + where T : IOperatorController + { + var datasource = app.ApplicationServices.GetRequiredService(); + return datasource.Add(); + } + } + + extension(IHost app) + { + + public Task RunOperatorAsync() + { + var args = Environment.GetCommandLineArgs().Skip(1).ToArray(); + var commandDatasource = app.Services.GetRequiredService(); + var command = commandDatasource.GetCommands(app) + .FirstOrDefault(Filter)?.Command; + + bool Filter(CommandInfo command) + { + var attr = command.Metadata.OfType().FirstOrDefault(); + if (attr is null) return false; + var arg = attr.Argument; + return args.FirstOrDefault() == arg || args.FirstOrDefault() == $"--{arg}"; + } + + if (command == null) + { + var helpCommand = commandDatasource.GetCommands(app) + .First(c => c.Command is HelpCommand).Command; + return helpCommand.RunAsync(args); + } + + return command.RunAsync(args); + } + } +} + +public class OperatorBuilder +{ + public KubernetesClientConfiguration? Configuration { get; set; } +} diff --git a/src/K8sOperator.NET/OperatorHost.cs b/src/K8sOperator.NET/OperatorHost.cs deleted file mode 100644 index 4e3e075..0000000 --- a/src/K8sOperator.NET/OperatorHost.cs +++ /dev/null @@ -1,127 +0,0 @@ -using K8sOperator.NET.Builder; -using K8sOperator.NET.Commands; -using K8sOperator.NET.Metadata; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace K8sOperator.NET; - -/// -/// Interface representing an operator application that manages Kubernetes resources. -/// -public interface IOperatorApplication -{ - /// - /// The name of the operator. - /// - string Name { get; } - - /// - /// The version number of the operator - /// - public string Version { get; } - - /// - /// Gets the service provider that is used to resolve dependencies within the application. - /// - IServiceProvider ServiceProvider { get; } - - /// - /// The application's configured Microsoft.Extensions.Configuration.IConfiguration - /// - IConfiguration Configuration { get; } - - /// - /// The application's configured Logger Factory - /// - ILoggerFactory Logger { get; } - - /// - /// Gets the data source that provides access to Kubernetes controllers. - /// - IControllerDataSource DataSource { get; } - - /// - /// - /// - ICommandDatasource Commands { get; } - - /// - /// Runs the operator application asynchronously, managing the lifecycle of Kubernetes resources. - /// - /// A task representing the asynchronous operation. - Task RunAsync(); -} - -/// -/// Provides functionality to create and configure an operator application. -/// -public static class OperatorHost -{ - /// - /// Creates an used to configure and build an operator application. - /// - /// The command-line arguments passed to the application. - /// An instance of for further configuration. - public static IOperatorApplicationBuilder CreateOperatorApplicationBuilder(params string[] args) - => new OperatorApplicationBuilder(args); -} - -internal class OperatorHostApplication : IOperatorApplication -{ - private readonly string[] _args; - - internal OperatorHostApplication( - IServiceProvider serviceProvider, - string[] args - ) - { - ServiceProvider = serviceProvider; - Configuration = ServiceProvider.GetRequiredService(); - DataSource = ServiceProvider.GetRequiredService(); - Logger = ServiceProvider.GetRequiredService(); - Commands = new CommandDatasource(serviceProvider); - - Commands.AddCommand(typeof(OperatorCommand)); - Commands.AddCommand(typeof(VersionCommand)); - Commands.AddCommand(typeof(HelpCommand)); - - _args = args; - } - - public IServiceProvider ServiceProvider { get; } - - public IConfiguration Configuration { get; } - - public IControllerDataSource DataSource { get; } - - public ICommandDatasource Commands { get; } - - public ILoggerFactory Logger { get; } - - public string Name => DataSource.Metadata.OfType().First().OperatorName; - public string Version => DataSource.Metadata.OfType().First().Tag; - - public async Task RunAsync() - { - var commands = Commands.GetCommands(); - var command = commands - .FirstOrDefault(Filter) - ?.Command; - - if(command == null) - { - await new HelpCommand(this).RunAsync([.._args]); - return; - } - - await command.RunAsync(_args); - } - - private bool Filter(CommandInfo command) - { - var arg = command.Metadata.OfType().First().Argument; - return _args.FirstOrDefault() == arg || _args.FirstOrDefault() == $"--{arg}"; - } -} diff --git a/src/K8sOperator.NET/OperatorService.cs b/src/K8sOperator.NET/OperatorService.cs new file mode 100644 index 0000000..815432f --- /dev/null +++ b/src/K8sOperator.NET/OperatorService.cs @@ -0,0 +1,48 @@ +using K8sOperator.NET; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace K8sOperator.NET; + +public class OperatorService(IServiceProvider serviceProvider) : BackgroundService +{ + public IServiceProvider ServiceProvider { get; } = serviceProvider; + public EventWatcherDatasource Datasource { get; } = serviceProvider.GetRequiredService(); + + private readonly List _runningTasks = []; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var watchers = Datasource.GetWatchers().ToList(); + var logger = ServiceProvider.GetRequiredService>(); + + if (!watchers.Any()) + { + logger.LogInformation("No event watchers registered."); + return; + } + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); + foreach (var watcher in watchers) + { + var task = Task.Run(async () => + { + try + { + await watcher.Start(cts.Token); + } + catch (OperatorException) + { + await cts.CancelAsync(); + throw; + } + + } + , cts.Token); + _runningTasks.Add(task); + } + + await Task.WhenAll(_runningTasks); + } +} diff --git a/test/Directory.Build.props b/test/Directory.Build.props index d80851a..ff3d160 100644 --- a/test/Directory.Build.props +++ b/test/Directory.Build.props @@ -1,31 +1,10 @@ - - - - net10.0 - true - enable - enable - - false - true - - - - - - - - - - - - - - - - - - - - - + + + + + + + + diff --git a/test/K8sOperator.NET.Generators.Tests/K8sOperator.NET.Generators.Tests.csproj b/test/K8sOperator.NET.Generators.Tests/K8sOperator.NET.Generators.Tests.csproj deleted file mode 100644 index 31f38bb..0000000 --- a/test/K8sOperator.NET.Generators.Tests/K8sOperator.NET.Generators.Tests.csproj +++ /dev/null @@ -1,12 +0,0 @@ - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - diff --git a/test/K8sOperator.NET.Generators.Tests/UnitTest1.cs b/test/K8sOperator.NET.Generators.Tests/UnitTest1.cs deleted file mode 100644 index e5e81e8..0000000 --- a/test/K8sOperator.NET.Generators.Tests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace K8sOperator.NET.Generators.Tests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -} \ No newline at end of file diff --git a/test/K8sOperator.NET.Target.Tests/K8sOperator.NET.Target.Tests.csproj b/test/K8sOperator.NET.Target.Tests/K8sOperator.NET.Target.Tests.csproj new file mode 100644 index 0000000..8a03a64 --- /dev/null +++ b/test/K8sOperator.NET.Target.Tests/K8sOperator.NET.Target.Tests.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + true + + true + + false + + + + + + + + + + + diff --git a/test/K8sOperator.NET.Target.Tests/OperatorTargets_Tests.cs b/test/K8sOperator.NET.Target.Tests/OperatorTargets_Tests.cs new file mode 100644 index 0000000..907bd5c --- /dev/null +++ b/test/K8sOperator.NET.Target.Tests/OperatorTargets_Tests.cs @@ -0,0 +1,48 @@ +using K8sOperator.NET; +using K8sOperator.NET.Metadata; +using System.Reflection; +using System.Threading.Tasks; + +namespace K8sOperator.NET.Target.Tests; + +public class OperatorTargets_Tests +{ + [Test] + public async Task Operator_Targets_Adds_OperatorName_Attribute() + { + // Arrange + var assembly = System.Reflection.Assembly.GetEntryAssembly() ?? System.Reflection.Assembly.GetExecutingAssembly(); + + // Act + var attribute = assembly.GetCustomAttribute(); + + // Assert + await Assert.That(attribute).IsNotNull(); + } + + [Test] + public async Task Operator_Targets_Adds_Namespace_Attribute() + { + // Arrange + var assembly = System.Reflection.Assembly.GetEntryAssembly() ?? System.Reflection.Assembly.GetExecutingAssembly(); + + // Act + var attribute = assembly.GetCustomAttribute(); + + // Assert + await Assert.That(attribute).IsNotNull(); + } + + [Test] + public async Task Operator_Targets_Adds_DockerImage_Attribute() + { + // Arrange + var assembly = System.Reflection.Assembly.GetEntryAssembly() ?? System.Reflection.Assembly.GetExecutingAssembly(); + + // Act + var attribute = assembly.GetCustomAttribute(); + + // Assert + await Assert.That(attribute).IsNotNull(); + } +} diff --git a/test/K8sOperator.NET.Target.Tests/Properties/launchSettings.json b/test/K8sOperator.NET.Target.Tests/Properties/launchSettings.json new file mode 100644 index 0000000..d8fcd68 --- /dev/null +++ b/test/K8sOperator.NET.Target.Tests/Properties/launchSettings.json @@ -0,0 +1,37 @@ +{ + "profiles": { + "Operator": { + "commandName": "Project", + "commandLineArgs": "operator", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true + }, + "Install": { + "commandName": "Project", + "commandLineArgs": "install > ./install.yaml", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true + }, + "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/test/K8sOperator.NET.Tests/ChangeTrackerTests.cs b/test/K8sOperator.NET.Tests/ChangeTracking_Tests.cs similarity index 71% rename from test/K8sOperator.NET.Tests/ChangeTrackerTests.cs rename to test/K8sOperator.NET.Tests/ChangeTracking_Tests.cs index f953940..16e3d67 100644 --- a/test/K8sOperator.NET.Tests/ChangeTrackerTests.cs +++ b/test/K8sOperator.NET.Tests/ChangeTracking_Tests.cs @@ -1,18 +1,14 @@ -using k8s; -using k8s.Models; +using k8s.Models; +using K8sOperator.NET.Tests.Fixtures; namespace K8sOperator.NET.Tests; -public class ChangeTrackerTests -{ - private readonly ChangeTracker _changeTracker; - public ChangeTrackerTests() - { - _changeTracker = new ChangeTracker(); - } +public class ChangeTracker_Tests +{ + private readonly ChangeTracker _changeTracker = new ChangeTracker(); - [Fact] - public void IsResourceGenerationAlreadyHandled_Should_ReturnFalse_IfNotTrackedBefore() + [Test] + public async Task IsResourceGenerationAlreadyHandled_Should_ReturnFalse_IfNotTrackedBefore() { // Arrange var resource = new TestResource @@ -28,11 +24,11 @@ public void IsResourceGenerationAlreadyHandled_Should_ReturnFalse_IfNotTrackedBe var result = _changeTracker.IsResourceGenerationAlreadyHandled(resource); // Assert - result.Should().BeFalse(); + await Assert.That(result).IsFalse(); } - [Fact] - public void IsResourceGenerationAlreadyHandled_Should_ReturnTrue_IfAlreadyTracked() + [Test] + public async Task IsResourceGenerationAlreadyHandled_Should_ReturnTrue_IfAlreadyTracked() { // Arrange var resource = new TestResource @@ -51,11 +47,11 @@ public void IsResourceGenerationAlreadyHandled_Should_ReturnTrue_IfAlreadyTracke var result = _changeTracker.IsResourceGenerationAlreadyHandled(resource); // Assert - result.Should().BeTrue(); + await Assert.That(result).IsTrue(); } - [Fact] - public void TrackResourceGenerationAsHandled_Should_UpdateTrackingCorrectly() + [Test] + public async Task TrackResourceGenerationAsHandled_Should_UpdateTrackingCorrectly() { // Arrange var resource = new TestResource @@ -74,11 +70,11 @@ public void TrackResourceGenerationAsHandled_Should_UpdateTrackingCorrectly() var result = _changeTracker.IsResourceGenerationAlreadyHandled(resource); // Assert - result.Should().BeTrue(); + await Assert.That(result).IsTrue(); } - [Fact] - public void TrackResourceGenerationAsDeleted_Should_RemoveTrackingForDeletedResource() + [Test] + public async Task TrackResourceGenerationAsDeleted_Should_RemoveTrackingForDeletedResource() { // Arrange var resource = new TestResource @@ -100,11 +96,11 @@ public void TrackResourceGenerationAsDeleted_Should_RemoveTrackingForDeletedReso var result = _changeTracker.IsResourceGenerationAlreadyHandled(resource); // Assert - result.Should().BeFalse(); + await Assert.That(result).IsFalse(); } - [Fact] - public void TrackResourceGenerationAsHandled_Should_NotThrow_WhenGenerationIsNull() + [Test] + public async Task TrackResourceGenerationAsHandled_Should_NotThrow_WhenGenerationIsNull() { // Arrange var resource = new TestResource @@ -121,6 +117,8 @@ public void TrackResourceGenerationAsHandled_Should_NotThrow_WhenGenerationIsNul // Assert var result = _changeTracker.IsResourceGenerationAlreadyHandled(resource); - result.Should().BeFalse(); + + await Assert.That(result).IsFalse(); + } } diff --git a/test/K8sOperator.NET.Tests/CommandDatasource_Tests.cs b/test/K8sOperator.NET.Tests/CommandDatasource_Tests.cs new file mode 100644 index 0000000..fed4ab4 --- /dev/null +++ b/test/K8sOperator.NET.Tests/CommandDatasource_Tests.cs @@ -0,0 +1,273 @@ +using K8sOperator.NET.Builder; +using K8sOperator.NET.Commands; + +namespace K8sOperator.NET.Tests; + +public class CommandDatasource_Tests +{ + private static ServiceProvider CreateServiceProvider() + { + var services = new ServiceCollection(); + services.AddSingleton(sp => Host.CreateDefaultBuilder().Build()); + return services.BuildServiceProvider(); + } + + [Test] + public async Task Constructor_Should_InitializeWithServiceProvider() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + + // Act + var datasource = new CommandDatasource(serviceProvider); + + // Assert + await Assert.That(datasource).IsNotNull(); + await Assert.That(datasource.ServiceProvider).IsEqualTo(serviceProvider); + } + + [Test] + public async Task Add_Should_AddCommandToDataSource() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var datasource = new CommandDatasource(serviceProvider); + + // Act + var builder = datasource.Add(); + + // Assert + await Assert.That(builder).IsNotNull(); + } + + [Test] + public async Task Add_Should_ReturnConventionBuilder() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var datasource = new CommandDatasource(serviceProvider); + + // Act + var result = datasource.Add(); + + // Assert + await Assert.That(result).IsTypeOf>(); + } + + [Test] + public async Task GetCommands_Should_ReturnEmptyWhenNoCommandsAdded() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var datasource = new CommandDatasource(serviceProvider); + var host = serviceProvider.GetRequiredService(); + + // Act + var commands = datasource.GetCommands(host).ToList(); + + // Assert + await Assert.That(commands).IsEmpty(); + } + + [Test] + public async Task GetCommands_Should_ReturnSingleCommandWhenOneAdded() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var datasource = new CommandDatasource(serviceProvider); + var host = serviceProvider.GetRequiredService(); + + datasource.Add(); + + // Act + var commands = datasource.GetCommands(host).ToList(); + + // Assert + await Assert.That(commands).Count().IsEqualTo(1); + await Assert.That(commands[0].Command).IsTypeOf(); + } + + [Test] + public async Task GetCommands_Should_ReturnMultipleCommandsInOrderAdded() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var datasource = new CommandDatasource(serviceProvider); + var host = serviceProvider.GetRequiredService(); + + datasource.Add(); + datasource.Add(); + + // Act + var commands = datasource.GetCommands(host).ToList(); + + // Assert + await Assert.That(commands).Count().IsEqualTo(2); + await Assert.That(commands[0].Command).IsTypeOf(); + await Assert.That(commands[1].Command).IsTypeOf(); + } + + [Test] + public async Task GetCommands_Should_IncludeMetadataFromCommandAttribute() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var datasource = new CommandDatasource(serviceProvider); + var host = serviceProvider.GetRequiredService(); + + datasource.Add(); + + // Act + var commands = datasource.GetCommands(host).ToList(); + + // Assert + await Assert.That(commands).Count().IsEqualTo(1); + var metadata = commands[0].Metadata.OfType().First(); + await Assert.That(metadata.Argument).IsEqualTo("operator"); + await Assert.That(metadata.Description).IsEqualTo("Starts the operator."); + } + + [Test] + public async Task GetCommands_Should_ApplyConventionsToCommandBuilder() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var datasource = new CommandDatasource(serviceProvider); + var host = serviceProvider.GetRequiredService(); + var conventionApplied = false; + + datasource.Add() + .Add(builder => + { + conventionApplied = true; + }); + + // Act + var commands = datasource.GetCommands(host).ToList(); + + // Assert + await Assert.That(conventionApplied).IsTrue(); + await Assert.That(commands).Count().IsEqualTo(1); + } + + [Test] + public async Task GetCommands_Should_ApplyMultipleConventionsInOrder() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var datasource = new CommandDatasource(serviceProvider); + var host = serviceProvider.GetRequiredService(); + var conventionOrder = new List(); + + datasource.Add() + .Add(builder => conventionOrder.Add(1)) + .Add(builder => conventionOrder.Add(2)) + .Add(builder => conventionOrder.Add(3)); + + // Act + var commands = datasource.GetCommands(host).ToList(); + + // Assert + await Assert.That(conventionOrder).IsEquivalentTo([1, 2, 3]); + } + + [Test] + public async Task GetCommands_Should_CreateNewCommandInstancesEachTime() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var datasource = new CommandDatasource(serviceProvider); + var host = serviceProvider.GetRequiredService(); + + datasource.Add(); + + // Act + var commands1 = datasource.GetCommands(host).ToList(); + var commands2 = datasource.GetCommands(host).ToList(); + + // Assert + await Assert.That(commands1[0].Command).IsNotEqualTo(commands2[0].Command); + } + + [Test] + public async Task GetCommands_Should_BuildCommandWithHost() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var datasource = new CommandDatasource(serviceProvider); + var host = serviceProvider.GetRequiredService(); + + datasource.Add(); + + // Act + var commands = datasource.GetCommands(host).ToList(); + + // Assert + var testCommand = (TestCommand)commands[0].Command; + await Assert.That(testCommand.Host).IsEqualTo(host); + } + + [Test] + public async Task GetCommands_Should_OrderCommandsByOrderProperty() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var datasource = new CommandDatasource(serviceProvider); + var host = serviceProvider.GetRequiredService(); + + // Add commands (they'll be ordered by Order property internally) + datasource.Add(); // Order = -2 + datasource.Add(); // Order = 1 (default) + datasource.Add(); // Order = 1 (default) + + // Act + var commands = datasource.GetCommands(host).ToList(); + + // Assert + await Assert.That(commands).Count().IsEqualTo(3); + // OperatorCommand should be first due to Order = -2 + await Assert.That(commands[0].Command).IsTypeOf(); + } + + [Test] + public async Task CommandInfo_Should_ContainCommandAndMetadata() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var datasource = new CommandDatasource(serviceProvider); + var host = serviceProvider.GetRequiredService(); + + datasource.Add(); + + // Act + var commandInfo = datasource.GetCommands(host).First(); + + // Assert + await Assert.That(commandInfo.Command).IsNotNull(); + await Assert.That(commandInfo.Metadata).IsNotNull(); + await Assert.That(commandInfo.Metadata).Count().IsEqualTo(1); + } + + // Test helper classes + [OperatorArgument("test", Description = "Test command")] + private class TestCommand(IHost host) : IOperatorCommand + { + public IHost Host { get; } = host; + + public Task RunAsync(string[] args) + { + return Task.CompletedTask; + } + } + + [OperatorArgument("another", Description = "Another test command")] + private class AnotherTestCommand(IHost host) : IOperatorCommand + { + private readonly IHost _host = host; + + public Task RunAsync(string[] args) + { + return Task.CompletedTask; + } + } +} diff --git a/test/K8sOperator.NET.Tests/ControllerDatasourceTests.cs b/test/K8sOperator.NET.Tests/ControllerDatasourceTests.cs deleted file mode 100644 index 02e222a..0000000 --- a/test/K8sOperator.NET.Tests/ControllerDatasourceTests.cs +++ /dev/null @@ -1,108 +0,0 @@ -namespace K8sOperator.NET.Tests; - -using AwesomeAssertions; -using k8s; -using K8sOperator.NET.Tests.Logging; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using NSubstitute; -using System; -using System.Collections.Generic; -using Xunit; -using Xunit.Abstractions; - -public class ControllerDatasourceTests -{ - private readonly List _metadata; - private readonly ControllerDatasource _controllerDatasource; - private readonly ITestOutputHelper _testOutput; - - public ControllerDatasourceTests(ITestOutputHelper testOutput) - { - _metadata = []; - _controllerDatasource = new ControllerDatasource(_metadata); - _testOutput = testOutput; - } - - [Fact] - public void AddController_Should_AddControllerEntryWithConventions() - { - // Arrange - var controllerType = typeof(MyController); - - // Act - var conventionBuilder = _controllerDatasource.AddController(controllerType); - - // Assert - conventionBuilder.Should().NotBeNull(); - _controllerDatasource.Metadata.Should().BeSameAs(_metadata); - } - - [Fact] - public void AddController_Should_AddConventionsToEntry() - { - // Arrange - var controllerType = typeof(MyController); - var kubernetes = Substitute.For(); - var provide = new ServiceCollection() - .AddLogging(x => - { - x.ClearProviders(); - x.AddTestOutput(_testOutput); - }) - .AddSingleton(kubernetes) - .BuildServiceProvider(); - - // Act - var conventionBuilder = _controllerDatasource.AddController(controllerType); - - // Adding a sample convention - conventionBuilder.Add(builder => { /* Convention logic */ }); - - var watcher = _controllerDatasource.GetWatchers(provide).FirstOrDefault(); - - watcher.Should().NotBeNull(); - watcher?.Controller.Should().BeOfType(); - } - - [Fact] - public void GetWatchers_Should_ReturnEventWatchers_WithAppliedConventions() - { - var services = new ServiceCollection(); - - services.AddSingleton(Substitute.For()); - services.AddLogging(x => - { - x.ClearProviders(); - x.AddTestOutput(_testOutput); - }); - // Arrange - var serviceProvider = services.BuildServiceProvider(); - var controllerType = typeof(MyController); - - _controllerDatasource.AddController(controllerType); - - // Act - var watchers = _controllerDatasource.GetWatchers(serviceProvider); - - watchers.Should().HaveCount(1); - } - - [Fact] - public void AddAfterProcessBuildConventionCollection_Should_ThrowIfModifiedAfterBuild() - { - // Arrange - var collection = new ControllerDatasource.AddAfterProcessBuildConventionCollection - { - IsReadOnly = true - }; - - // Act - Action act = () => collection.Add(builder => { }); - - // Assert - act.Should().Throw() - .WithMessage("*can not be modified after build*"); - } -} - diff --git a/test/K8sOperator.NET.Tests/ControllerTests.cs b/test/K8sOperator.NET.Tests/ControllerTests.cs deleted file mode 100644 index e37dc09..0000000 --- a/test/K8sOperator.NET.Tests/ControllerTests.cs +++ /dev/null @@ -1,100 +0,0 @@ -namespace K8sOperator.NET.Tests; - -public class ControllerTests -{ - // Base functionality tests - [Fact] - public async Task AddOrModifyAsync_Should_Return_CompletedTask() - { - // Arrange - var controller = new MyController(); - - // Act - await controller.AddOrModifyAsync(new TestResource(), CancellationToken.None); - - Assert.True(true); - } - - [Fact] - public async Task DeleteAsync_Should_Return_CompletedTask() - { - // Arrange - var controller = new MyController(); - - // Act - await controller.DeleteAsync(new TestResource(), CancellationToken.None); - - // Assert - Assert.True(true); - } - - [Fact] - public async Task FinalizeAsync_Should_Return_CompletedTask() - { - // Arrange - var controller = new MyController(); - - // Act - await controller.FinalizeAsync(new TestResource(), CancellationToken.None); - - Assert.True(true); - } - - [Fact] - public async Task BookmarkAsync_Should_Return_CompletedTask() - { - // Arrange - var controller = new MyController(); - - // Act - await controller.BookmarkAsync(new TestResource(), CancellationToken.None); - - // Assert - Assert.True(true); - } - - [Fact] - public async Task ErrorAsync_Should_Return_CompletedTask() - { - // Arrange - var controller = new MyController(); - - // Act - await controller.ErrorAsync(new TestResource(), CancellationToken.None); - - // Assert - Assert.True(true); - } - - // Test overriding in derived classes - [Fact] - public async Task Overridden_AddOrModifyAsync_Should_Call_CustomImplementation() - { - // Arrange - var derivedController = new DerivedTestController(); - var resource = new TestResource(); - - // Act - await derivedController.AddOrModifyAsync(resource, CancellationToken.None); - - - resource.Status.Should().NotBeNull(); - resource.Status?.Status.Should().Be("Changed"); - } - - // You can also extend these tests for DeleteAsync, FinalizeAsync, BookmarkAsync, and ErrorAsync -} - - -public class DerivedTestController : MyController -{ - public override Task AddOrModifyAsync(TestResource resource, CancellationToken cancellationToken) - { - resource.Status = new() - { - Status = "Changed" - }; - - return Task.CompletedTask; - } -} diff --git a/test/K8sOperator.NET.Tests/EntityScope.cs b/test/K8sOperator.NET.Tests/EntityScope.cs deleted file mode 100644 index d4863cc..0000000 --- a/test/K8sOperator.NET.Tests/EntityScope.cs +++ /dev/null @@ -1,53 +0,0 @@ -using k8s; -using K8sOperator.NET.Extensions; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; - -namespace K8sOperator.NET.Tests; -public class EntityScope_Tests -{ - [Fact] - public void NamespacedScope_should_use_NamespacedClient() - { - var builder = OperatorHost.CreateOperatorApplicationBuilder(); - - builder.AddController() - .ForNamespace("test"); - - builder.Services.RemoveAll(); - builder.Services.AddSingleton(x => Substitute.For()); - - var app = builder.Build(); - - var watcher = app.DataSource.GetWatchers(app.ServiceProvider).ToList(); - - watcher.Should().HaveCount(1); - } - - [Fact] - public void ClusterScope_should_use_NamespacedClient() - { - var builder = OperatorHost.CreateOperatorApplicationBuilder(); - - builder.Services.RemoveAll(); - builder.Services.AddSingleton(x => Substitute.For()); - - - builder.AddController(); - - var app = builder.Build(); - - var watcher = app.DataSource.GetWatchers(app.ServiceProvider).ToList(); - - watcher.Should().HaveCount(1); - } - - private class TestController : Controller { } - - private class Test2Controller : Controller { } - - public class ClusterResource : TestResource - { - - } -} diff --git a/test/K8sOperator.NET.Tests/EventWatcherDatasource_Tests.cs b/test/K8sOperator.NET.Tests/EventWatcherDatasource_Tests.cs new file mode 100644 index 0000000..0fbac84 --- /dev/null +++ b/test/K8sOperator.NET.Tests/EventWatcherDatasource_Tests.cs @@ -0,0 +1,353 @@ +using k8s.Models; +using K8sOperator.NET.Builder; +using K8sOperator.NET.Metadata; +using K8sOperator.NET.Tests.Fixtures; +using K8sOperator.NET.Tests.Mocks; + +namespace K8sOperator.NET.Tests; + +public class EventWatcherDatasource_Tests +{ + private static ServiceProvider CreateServiceProvider() + { + var server = new MockKubeApiServer(); + var services = new ServiceCollection(); + services.AddSingleton(sp => server.Client); + services.AddSingleton(sp => LoggerFactory.Create(builder => { })); + return services.BuildServiceProvider(); + } + + private static List CreateMetadata() + { + return + [ + OperatorNameAttribute.Default, + DockerImageAttribute.Default, + NamespaceAttribute.Default + ]; + } + + [Test] + public async Task Constructor_Should_InitializeWithServiceProviderAndMetadata() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var metadata = CreateMetadata(); + + // Act + var datasource = new EventWatcherDatasource(serviceProvider, metadata); + + // Assert + await Assert.That(datasource).IsNotNull(); + await Assert.That(datasource.ServiceProvider).IsEqualTo(serviceProvider); + await Assert.That(datasource.Metadata).IsEqualTo(metadata); + } + + [Test] + public async Task Add_Should_AddControllerToDataSource() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var metadata = CreateMetadata(); + var datasource = new EventWatcherDatasource(serviceProvider, metadata); + + // Act + var builder = datasource.Add(); + + // Assert + await Assert.That(builder).IsNotNull(); + } + + [Test] + public async Task Add_Should_ReturnConventionBuilder() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var metadata = CreateMetadata(); + var datasource = new EventWatcherDatasource(serviceProvider, metadata); + + // Act + var result = datasource.Add(); + + // Assert + await Assert.That(result).IsTypeOf>(); + } + + [Test] + public async Task GetWatchers_Should_ReturnEmptyWhenNoControllersAdded() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var metadata = CreateMetadata(); + var datasource = new EventWatcherDatasource(serviceProvider, metadata); + + // Act + var watchers = datasource.GetWatchers().ToList(); + + // Assert + await Assert.That(watchers).IsEmpty(); + } + + [Test] + public async Task GetWatchers_Should_ReturnSingleWatcherWhenOneControllerAdded() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var metadata = CreateMetadata(); + var datasource = new EventWatcherDatasource(serviceProvider, metadata); + + datasource.Add(); + + // Act + var watchers = datasource.GetWatchers().ToList(); + + // Assert + await Assert.That(watchers).Count().IsEqualTo(1); + await Assert.That(watchers[0]).IsNotNull(); + await Assert.That(watchers[0].Controller).IsTypeOf(); + } + + [Test] + public async Task GetWatchers_Should_ReturnMultipleWatchersWhenMultipleControllersAdded() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var metadata = CreateMetadata(); + var datasource = new EventWatcherDatasource(serviceProvider, metadata); + + datasource.Add(); + datasource.Add(); + + // Act + var watchers = datasource.GetWatchers().ToList(); + + // Assert + await Assert.That(watchers).Count().IsEqualTo(2); + await Assert.That(watchers[0].Controller).IsTypeOf(); + await Assert.That(watchers[1].Controller).IsTypeOf(); + } + + [Test] + public async Task GetWatchers_Should_ApplyConventionsToControllerBuilder() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var metadata = CreateMetadata(); + var datasource = new EventWatcherDatasource(serviceProvider, metadata); + var conventionApplied = false; + + datasource.Add() + .Add(builder => + { + conventionApplied = true; + }); + + // Act + var watchers = datasource.GetWatchers().ToList(); + + // Assert + await Assert.That(conventionApplied).IsTrue(); + await Assert.That(watchers).Count().IsEqualTo(1); + } + + [Test] + public async Task GetWatchers_Should_ApplyMultipleConventionsInOrder() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var metadata = CreateMetadata(); + var datasource = new EventWatcherDatasource(serviceProvider, metadata); + var conventionOrder = new List(); + + datasource.Add() + .Add(builder => conventionOrder.Add(1)) + .Add(builder => conventionOrder.Add(2)) + .Add(builder => conventionOrder.Add(3)); + + // Act + var watchers = datasource.GetWatchers().ToList(); + + // Assert + await Assert.That(conventionOrder).IsEquivalentTo([1, 2, 3]); + } + + [Test] + public async Task GetWatchers_Should_CreateEventWatcherWithMetadata() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var metadata = CreateMetadata(); + var datasource = new EventWatcherDatasource(serviceProvider, metadata); + + datasource.Add(); + + // Act + var watchers = datasource.GetWatchers().ToList(); + + // Assert + await Assert.That(watchers[0].Metadata).IsNotNull(); + await Assert.That(watchers[0].Metadata).Count().IsGreaterThan(0); + } + + [Test] + public async Task GetWatchers_Should_IncludeGlobalMetadataInWatcher() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var metadata = CreateMetadata(); + var datasource = new EventWatcherDatasource(serviceProvider, metadata); + + datasource.Add(); + + // Act + var watchers = datasource.GetWatchers().ToList(); + + // Assert + var watcherMetadata = watchers[0].Metadata; + await Assert.That(watcherMetadata.OfType()).HasSingleItem(); + await Assert.That(watcherMetadata.OfType()).HasSingleItem(); + await Assert.That(watcherMetadata.OfType()).HasSingleItem(); + } + + [Test] + public async Task GetWatchers_Should_IncludeResourceMetadataInWatcher() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var metadata = CreateMetadata(); + var datasource = new EventWatcherDatasource(serviceProvider, metadata); + + datasource.Add(); + + // Act + var watchers = datasource.GetWatchers().ToList(); + + // Assert + var watcherMetadata = watchers[0].Metadata; + var kubernetesEntity = watcherMetadata.OfType().FirstOrDefault(); + await Assert.That(kubernetesEntity).IsNotNull(); + await Assert.That(kubernetesEntity!.Group).IsEqualTo("unittest"); + await Assert.That(kubernetesEntity.ApiVersion).IsEqualTo("v1"); + await Assert.That(kubernetesEntity.Kind).IsEqualTo("TestResource"); + } + + [Test] + public async Task GetWatchers_Should_CreateNewWatcherInstancesEachTime() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var metadata = CreateMetadata(); + var datasource = new EventWatcherDatasource(serviceProvider, metadata); + + datasource.Add(); + + // Act + var watchers1 = datasource.GetWatchers().ToList(); + var watchers2 = datasource.GetWatchers().ToList(); + + // Assert + await Assert.That(watchers1[0]).IsNotEqualTo(watchers2[0]); + } + + [Test] + public async Task GetWatchers_Should_CreateWatcherForCorrectResourceType() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var metadata = CreateMetadata(); + var datasource = new EventWatcherDatasource(serviceProvider, metadata); + + datasource.Add(); + + // Act + var watchers = datasource.GetWatchers().ToList(); + + // Assert + await Assert.That(watchers[0].Controller.ResourceType).IsEqualTo(typeof(TestResource)); + } + + [Test] + public async Task GetWatchers_Should_HandleMultipleControllersWithDifferentResourceTypes() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var metadata = CreateMetadata(); + var datasource = new EventWatcherDatasource(serviceProvider, metadata); + + datasource.Add(); + datasource.Add(); + + // Act + var watchers = datasource.GetWatchers().ToList(); + + // Assert + await Assert.That(watchers).Count().IsEqualTo(2); + await Assert.That(watchers[0].Controller.ResourceType).IsEqualTo(typeof(TestResource)); + await Assert.That(watchers[1].Controller.ResourceType).IsEqualTo(typeof(AnotherTestResource)); + } + + [Test] + public async Task Metadata_Should_BeAccessibleProperty() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var metadata = CreateMetadata(); + var datasource = new EventWatcherDatasource(serviceProvider, metadata); + + // Act & Assert + await Assert.That(datasource.Metadata).IsEqualTo(metadata); + await Assert.That(datasource.Metadata).Count().IsEqualTo(3); + } + + [Test] + public async Task ServiceProvider_Should_BeAccessibleProperty() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var metadata = CreateMetadata(); + var datasource = new EventWatcherDatasource(serviceProvider, metadata); + + // Act & Assert + await Assert.That(datasource.ServiceProvider).IsEqualTo(serviceProvider); + } + + [Test] + public async Task GetWatchers_Should_YieldWatchersLazily() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var metadata = CreateMetadata(); + var datasource = new EventWatcherDatasource(serviceProvider, metadata); + var buildCount = 0; + + datasource.Add() + .Add(_ => buildCount++); + + // Act + var watcherEnumerable = datasource.GetWatchers(); + + // Assert - Not built yet + await Assert.That(buildCount).IsEqualTo(0); + + // Enumerate to trigger build + var watchers = watcherEnumerable.ToList(); + await Assert.That(buildCount).IsEqualTo(1); + } + + // Test helper classes + [LabelSelector("app=test")] + [Finalizer("test-finalizer")] + private class TestController : OperatorController + { + } + + [LabelSelector("app=another")] + private class AnotherTestController : OperatorController + { + } + + [KubernetesEntity(Group = "unittest", ApiVersion = "v1", Kind = "AnotherTestResource", PluralName = "anothertestresources")] + private class AnotherTestResource : CustomResource + { + } +} diff --git a/test/K8sOperator.NET.Tests/EventWatcherTests.cs b/test/K8sOperator.NET.Tests/EventWatcherTests.cs deleted file mode 100644 index 82ef4db..0000000 --- a/test/K8sOperator.NET.Tests/EventWatcherTests.cs +++ /dev/null @@ -1,235 +0,0 @@ -using k8s; -using k8s.Models; -using K8sOperator.NET.Metadata; -using K8sOperator.NET.Models; -using K8sOperator.NET.Tests.Mocks; -using K8sOperator.NET.Tests.Mocks.Endpoints; -using Microsoft.Extensions.Logging; -using Xunit.Abstractions; - -#pragma warning disable CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed - -namespace K8sOperator.NET.Tests; - -public class EventWatcherTests -{ - private static Watcher.WatchEvent Added => CreateEvent(WatchEventType.Modified, - new TestResource() - { - Metadata = new() - { - Name = "test", - NamespaceProperty = "default", - Finalizers = ["finalize"], - Uid = "1" - } - }); - - private static Watcher.WatchEvent Finalize => CreateEvent(WatchEventType.Added, - new TestResource() - { - Metadata = new() - { - Name = "test", - NamespaceProperty = "default", - DeletionTimestamp = TimeProvider.System.GetUtcNow().DateTime, - Finalizers = ["finalize"], - Uid = "1" - } - }); - - private static Watcher.WatchEvent Deleted => CreateEvent(WatchEventType.Deleted, - new TestResource() - { - Metadata = new() - { - Name = "test", - NamespaceProperty = "default", - Finalizers = ["finalize"], - Uid = "1" - } - }); - - private static Watcher.WatchEvent CreateEvent(WatchEventType type, T item) - where T : CustomResource - { - return new Watcher.WatchEvent { Type = type, Object = item }; - } - - private readonly ITestOutputHelper _testOutput; - private readonly Controller _controller = Substitute.For>(); - private readonly CancellationTokenSource _tokenSource; - private readonly ILoggerFactory _loggerFactory = Substitute.For(); - private readonly ILogger _logger = Substitute.For(); - private readonly List _metadata; - - public EventWatcherTests(ITestOutputHelper testOutput) - { - _tokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(2)); - _testOutput = testOutput; - _loggerFactory.CreateLogger(Arg.Any()).Returns(_logger); - _metadata = [ - new KubernetesEntityAttribute { Group = "group", ApiVersion = "v1", Kind = "Test", PluralName = "tests" }, - new NamespaceAttribute("default"), - Substitute.For(), - new FinalizerAttribute("finalize") - ]; - } - - [Fact] - public async Task Start_Should_StartWatchAndLogStart() - { - var cancellationToken = _tokenSource.Token; - - using (var server = new MockKubeApiServer(_testOutput, endpoints => - { - endpoints.MapWatchNamespacedCustomObjectAsync(Added); - })) - { - var client = new NamespacedKubernetesClient(server.GetMockedKubernetesClient(), _loggerFactory.CreateLogger()); - var watcher = new EventWatcher(client, _controller, _metadata, _loggerFactory); - - await watcher.Start(cancellationToken); - } - - _loggerFactory.Received(2).CreateLogger(Arg.Any()); - } - - [Fact] - public async Task OnEvent_Should_HandleAddedEventAndCallAddOrModifyAsync() - { - var cancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(2)); - var eventProcessed = new TaskCompletionSource(); - - // Setup the controller to signal when AddOrModifyAsync is called - _controller.AddOrModifyAsync(Arg.Any(), Arg.Any()) - .Returns(x => - { - eventProcessed.TrySetResult(true); - return Task.CompletedTask; - }); - - using (var server = new MockKubeApiServer(_testOutput, endpoints => - { - endpoints.MapWatchNamespacedCustomObjectAsync(Added); - endpoints.MapReplaceNamespacedCustomObjectAsync(); - })) - { - var client = new NamespacedKubernetesClient(server.GetMockedKubernetesClient(), _loggerFactory.CreateLogger()); - var watcher = new EventWatcher(client, _controller, _metadata, _loggerFactory); - - var watchTask = Task.Run(async () => await watcher.Start(cancellationToken.Token)); - - // Wait for either the event to be processed or timeout - var completedTask = await Task.WhenAny(eventProcessed.Task, Task.Delay(TimeSpan.FromSeconds(3))); - - if (completedTask != eventProcessed.Task) - { - throw new TimeoutException("AddOrModifyAsync was not called within the timeout period"); - } - } - - _loggerFactory.Received(2).CreateLogger(Arg.Any()); - - _controller.Received(1).AddOrModifyAsync(Arg.Any(), Arg.Any()); - } - - [Fact] - public async Task OnEvent_Should_HandleDeletedEventAndCallDeleteAsync() - { - var cancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(2)); - var eventProcessed = new TaskCompletionSource(); - - // Setup the controller to signal when DeleteAsync is called - _controller.DeleteAsync(Arg.Any(), Arg.Any()) - .Returns(x => - { - eventProcessed.TrySetResult(true); - return Task.CompletedTask; - }); - - using (var server = new MockKubeApiServer(_testOutput, endpoints => - { - endpoints.MapWatchNamespacedCustomObjectAsync(Deleted); - endpoints.MapReplaceNamespacedCustomObjectAsync(); - })) - { - var client = new NamespacedKubernetesClient(server.GetMockedKubernetesClient(), _loggerFactory.CreateLogger()); - var watcher = new EventWatcher(client, _controller, _metadata, _loggerFactory); - - var watchTask = Task.Run(async () => await watcher.Start(cancellationToken.Token)); - - // Wait for either the event to be processed or timeout - var completedTask = await Task.WhenAny(eventProcessed.Task, Task.Delay(TimeSpan.FromSeconds(3))); - - if (completedTask != eventProcessed.Task) - { - throw new TimeoutException("DeleteAsync was not called within the timeout period"); - } - } - - _loggerFactory.Received(2).CreateLogger(Arg.Any()); - - _controller.Received(1).DeleteAsync(Arg.Any(), Arg.Any()); - } - - [Fact] - public async Task HandleFinalizeAsync_Should_CallFinalizeAndRemoveFinalizer() - { - var cancellationToken = _tokenSource.Token; - var eventProcessed = new TaskCompletionSource(); - - // Setup the controller to signal when FinalizeAsync is called - _controller.FinalizeAsync(Arg.Any(), Arg.Any()) - .Returns(x => - { - eventProcessed.TrySetResult(true); - return Task.CompletedTask; - }); - - using (var server = new MockKubeApiServer(_testOutput, endpoints => - { - endpoints.MapWatchNamespacedCustomObjectAsync(Finalize); - endpoints.MapReplaceNamespacedCustomObjectAsync(); - })) - { - var client = new NamespacedKubernetesClient(server.GetMockedKubernetesClient(), _loggerFactory.CreateLogger()); - var watcher = new EventWatcher(client, _controller, _metadata, _loggerFactory); - - var watchTask = Task.Run(async () => await watcher.Start(cancellationToken)); - - // Wait for either the event to be processed or timeout - var completedTask = await Task.WhenAny(eventProcessed.Task, Task.Delay(TimeSpan.FromSeconds(3))); - - if (completedTask != eventProcessed.Task) - { - throw new TimeoutException("FinalizeAsync was not called within the timeout period"); - } - } - - _loggerFactory.Received(2).CreateLogger(Arg.Any()); - - _controller.Received(1).FinalizeAsync(Arg.Any(), Arg.Any()); - } - - [Fact] - public void HandleAddOrModifyAsync_Should_AddFinalizer_IfNotPresent() - { - Assert.True(true); - } -} - - -[KubernetesEntity(Group = "unittest", ApiVersion = "v1", Kind = "TestResource", PluralName = "testresources")] -public class TestResource : CustomResource -{ - public class TestStatus - { - public string Status { get; set; } = string.Empty; - } - - public class TestSpec - { - public string Property { get; set; } = string.Empty; - } -} diff --git a/test/K8sOperator.NET.Tests/EventWatcher_Tests.cs b/test/K8sOperator.NET.Tests/EventWatcher_Tests.cs new file mode 100644 index 0000000..50387d4 --- /dev/null +++ b/test/K8sOperator.NET.Tests/EventWatcher_Tests.cs @@ -0,0 +1,110 @@ +using k8s.Models; +using K8sOperator.NET.Metadata; +using K8sOperator.NET.Tests.Fixtures; +using K8sOperator.NET.Tests.Logging; +using K8sOperator.NET.Tests.Mocks; +using NSubstitute; +using System.Reflection; + +namespace K8sOperator.NET.Tests; + +public class EventWatcher_Tests() +{ + private readonly CancellationTokenSource _tokenSource = new(TimeSpan.FromSeconds(2)); + private readonly OperatorController _controller = Substitute.For>(); + private readonly ILoggerFactory _loggerFactory = Substitute.For(); + private readonly List _metadata = [ + typeof(TestResource).GetCustomAttribute()!, + new NamespaceAttribute("default"), + new LabelSelectorAttribute("label"), + new FinalizerAttribute("finalize") + ]; + + [Test] + public async Task Start_Should_StartWatchAndLogStart() + { + var cancellationToken = _tokenSource.Token; + + using var server = new MockKubeApiServer(endpoints => + { + endpoints.CustomObjects.WatchListClusterCustomObjectAsync(WatchEvents.Added); + }); + + var watcher = new EventWatcher(server.Client, _controller, _metadata, _loggerFactory); + + await watcher.Start(cancellationToken); + + _loggerFactory.Received(1).CreateLogger(Arg.Any()); + } + + [Test] + public async Task OnEvent_Should_HandleAddedEventAndCallAddOrModifyAsync() + { + var cancellationToken = _tokenSource.Token; + using var server = new MockKubeApiServer(endpoints => + { + endpoints.CustomObjects.WatchListClusterCustomObjectAsync(WatchEvents.Added); + endpoints.CustomObjects.ReplaceNamespacedCustomObjectAsync(); + }); + + var watcher = new EventWatcher(server.Client, _controller, _metadata, _loggerFactory); + + await watcher.Start(cancellationToken); + + await _controller.Received(1).AddOrModifyAsync(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task OnEvent_Should_HandleDeletedEventAndCallDeleteAsync() + { + var cancellationToken = _tokenSource.Token; + + using var server = new MockKubeApiServer(endpoints => + { + endpoints.CustomObjects.WatchListClusterCustomObjectAsync(WatchEvents.Deleted); + endpoints.CustomObjects.ReplaceNamespacedCustomObjectAsync(); + }); + + var watcher = new EventWatcher(server.Client, _controller, _metadata, _loggerFactory); + + await watcher.Start(cancellationToken); + + await _controller.Received(1).DeleteAsync(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task HandleFinalizeAsync_Should_CallFinalizeAndRemoveFinalizer() + { + var cancellationToken = _tokenSource.Token; + + using var server = new MockKubeApiServer(endpoints => + { + endpoints.CustomObjects.WatchListClusterCustomObjectAsync(WatchEvents.Finalize); + endpoints.CustomObjects.ReplaceNamespacedCustomObjectAsync(); + }); + + var watcher = new EventWatcher(server.Client, _controller, _metadata, _loggerFactory); + + await watcher.Start(cancellationToken); + + await _controller.Received(1).FinalizeAsync(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task HandleAddOrModifyAsync_Should_AddFinalizer_IfNotPresent() + { + var cancellationToken = _tokenSource.Token; + using var server = new MockKubeApiServer(endpoints => + { + endpoints.CustomObjects.WatchListClusterCustomObjectAsync(WatchEvents.Added); + endpoints.CustomObjects.ReplaceNamespacedCustomObjectAsync(resource: async x => + { + await Assert.That(x?.Metadata.Finalizers).Contains("finalize"); + }); + }); + + var watcher = new EventWatcher(server.Client, _controller, _metadata, _loggerFactory); + + await watcher.Start(cancellationToken); + } +} diff --git a/test/K8sOperator.NET.Tests/Fixtures/TestResource.cs b/test/K8sOperator.NET.Tests/Fixtures/TestResource.cs new file mode 100644 index 0000000..d52b27e --- /dev/null +++ b/test/K8sOperator.NET.Tests/Fixtures/TestResource.cs @@ -0,0 +1,17 @@ +using k8s.Models; + +namespace K8sOperator.NET.Tests.Fixtures; + +[KubernetesEntity(Group = "unittest", ApiVersion = "v1", Kind = "TestResource", PluralName = "testresources")] +public class TestResource : CustomResource +{ + public class TestStatus + { + public string Status { get; set; } = string.Empty; + } + + public class TestSpec + { + public string Property { get; set; } = string.Empty; + } +} diff --git a/test/K8sOperator.NET.Tests/GlobalSetup.cs b/test/K8sOperator.NET.Tests/GlobalSetup.cs new file mode 100644 index 0000000..18d2026 --- /dev/null +++ b/test/K8sOperator.NET.Tests/GlobalSetup.cs @@ -0,0 +1,4 @@ +// Here you could define global logic that would affect all tests + +// You can use attributes at the assembly level to apply to all tests in the assembly +[assembly: System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] diff --git a/test/K8sOperator.NET.Tests/K8sOperator.NET.Tests.csproj b/test/K8sOperator.NET.Tests/K8sOperator.NET.Tests.csproj index f7573eb..c8c8703 100644 --- a/test/K8sOperator.NET.Tests/K8sOperator.NET.Tests.csproj +++ b/test/K8sOperator.NET.Tests/K8sOperator.NET.Tests.csproj @@ -1,17 +1,20 @@ - + + + + net10.0 + enable + enable + true + + - - - + + + + - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - + - + + \ No newline at end of file diff --git a/test/K8sOperator.NET.Tests/Logging/TestOutputLogger.cs b/test/K8sOperator.NET.Tests/Logging/TestContextLogger.cs similarity index 63% rename from test/K8sOperator.NET.Tests/Logging/TestOutputLogger.cs rename to test/K8sOperator.NET.Tests/Logging/TestContextLogger.cs index e4fde7b..dc422cd 100644 --- a/test/K8sOperator.NET.Tests/Logging/TestOutputLogger.cs +++ b/test/K8sOperator.NET.Tests/Logging/TestContextLogger.cs @@ -1,19 +1,16 @@ -using Microsoft.Extensions.Logging; -using System.Reactive.Disposables; -using Xunit.Abstractions; +namespace K8sOperator.NET.Tests.Logging; -namespace K8sOperator.NET.Tests.Logging; /// -/// An implementation of that writes to the output of the current Xunit test. +/// An implementation of that writes to the output of the current TUnit Test Context. /// -internal sealed class TestOutputLogger +internal sealed class TestContextLogger : ILogger { /// - /// Initializes a new instance of the class. - /// Create a new . + /// Initializes a new instance of the class. + /// Create a new . /// - /// + /// /// The output for the current test. /// /// @@ -22,7 +19,7 @@ internal sealed class TestOutputLogger /// /// The logger's minimum log level. /// - public TestOutputLogger(ITestOutputHelper testOutput, string loggerCategory, LogLevel minLogLevel) + public TestContextLogger(TestContext context, string loggerCategory, LogLevel minLogLevel) { if (string.IsNullOrWhiteSpace(loggerCategory)) { @@ -31,7 +28,7 @@ public TestOutputLogger(ITestOutputHelper testOutput, string loggerCategory, Log nameof(loggerCategory)); } - TestOutput = testOutput ?? throw new ArgumentNullException(nameof(testOutput)); + Context = context ?? throw new ArgumentNullException(nameof(context)); LoggerCategory = loggerCategory; MinLogLevel = minLogLevel; } @@ -39,7 +36,7 @@ public TestOutputLogger(ITestOutputHelper testOutput, string loggerCategory, Log /// /// The output for the current test. /// - public ITestOutputHelper TestOutput { get; } + public TestContext Context { get; } /// /// The logger's category name. @@ -52,9 +49,13 @@ public TestOutputLogger(ITestOutputHelper testOutput, string loggerCategory, Log public LogLevel MinLogLevel { get; } public IDisposable? BeginScope(TState state) where TState : notnull - => Disposable.Empty; - + => NullScope.Instance; + private sealed class NullScope : IDisposable + { + public static NullScope Instance { get; } = new(); + public void Dispose() { } + } /// /// Check if the given is enabled. @@ -73,7 +74,7 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except try { - TestOutput.WriteLine(string.Format( + Context.Output.WriteLine(string.Format( "[{0}] {1}: {2}", logLevel, LoggerCategory, @@ -81,7 +82,7 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except if (exception != null) { - TestOutput.WriteLine( + Context.Output.WriteLine( exception.ToString()); } } @@ -98,3 +99,22 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except } } + +public sealed class TestContextLoggerFactory(TestContext testContext) + : ILoggerFactory +{ + private readonly TestContext _testContext = testContext ?? throw new ArgumentNullException(nameof(testContext)); + + public void Dispose() + { + } + public ILogger CreateLogger(string categoryName) + => new TestContextLogger(_testContext, categoryName, LogLevel.Debug); + + public void AddProvider(ILoggerProvider provider) + { + throw new NotImplementedException(); + } +} + + diff --git a/test/K8sOperator.NET.Tests/Logging/TestOutputLoggerProvider.cs b/test/K8sOperator.NET.Tests/Logging/TestContextLoggerProvider.cs similarity index 56% rename from test/K8sOperator.NET.Tests/Logging/TestOutputLoggerProvider.cs rename to test/K8sOperator.NET.Tests/Logging/TestContextLoggerProvider.cs index 3941b95..4635e79 100644 --- a/test/K8sOperator.NET.Tests/Logging/TestOutputLoggerProvider.cs +++ b/test/K8sOperator.NET.Tests/Logging/TestContextLoggerProvider.cs @@ -1,32 +1,26 @@ -using Microsoft.Extensions.Logging; -using Xunit.Abstractions; - -namespace K8sOperator.NET.Tests.Logging; +namespace K8sOperator.NET.Tests.Logging; /// -/// Logger provider for logging to Xunit test output. +/// Logger provider for logging to TUnit Test Context. /// -internal sealed class TestOutputLoggerProvider +internal sealed class TestContextLoggerProvider : ILoggerProvider { /// - /// Initializes a new instance of the class. - /// Create a new . + /// Initializes a new instance of the class. + /// Create a new . /// - /// + /// /// The output for the current test. /// /// /// The logger's minimum log level. /// - public TestOutputLoggerProvider(ITestOutputHelper testOutput, LogLevel minLogLevel) + public TestContextLoggerProvider(TestContext testContext, LogLevel minLogLevel) { - if (testOutput == null) - { - throw new ArgumentNullException(nameof(testOutput)); - } + ArgumentNullException.ThrowIfNull(testContext); - TestOutput = testOutput; + Context = testContext; MinLogLevel = minLogLevel; } @@ -40,7 +34,7 @@ public void Dispose() /// /// The output for the current test. /// - private ITestOutputHelper TestOutput { get; } + private TestContext Context { get; } /// /// The logger's minimum log level. @@ -56,5 +50,5 @@ public void Dispose() /// /// The logger, as an . /// - public ILogger CreateLogger(string categoryName) => new TestOutputLogger(TestOutput, categoryName, MinLogLevel); + public ILogger CreateLogger(string categoryName) => new TestContextLogger(Context, categoryName, MinLogLevel); } diff --git a/test/K8sOperator.NET.Tests/Logging/TestContextLoggingExtensions.cs b/test/K8sOperator.NET.Tests/Logging/TestContextLoggingExtensions.cs new file mode 100644 index 0000000..f86d4d1 --- /dev/null +++ b/test/K8sOperator.NET.Tests/Logging/TestContextLoggingExtensions.cs @@ -0,0 +1,32 @@ +namespace K8sOperator.NET.Tests.Logging; + +/// +/// Extension methods for logging to TUnit Test Context. +/// +public static class TestContextLoggingExtensions +{ + extension(ILoggingBuilder logging) + { + /// + /// Log to test output. + /// + /// + /// The global logging configuration. + /// + /// + /// Output for the current test. + /// + /// + /// The minimum level to log at. + /// + public void AddTestLogging(TestContext testContext, + LogLevel minLogLevel = LogLevel.Information) + { + ArgumentNullException.ThrowIfNull(logging); + ArgumentNullException.ThrowIfNull(testContext); + + logging.AddProvider( + new TestContextLoggerProvider(testContext, minLogLevel)); + } + } +} diff --git a/test/K8sOperator.NET.Tests/Logging/TestOutputLoggingExtensions.cs b/test/K8sOperator.NET.Tests/Logging/TestOutputLoggingExtensions.cs deleted file mode 100644 index f7956c4..0000000 --- a/test/K8sOperator.NET.Tests/Logging/TestOutputLoggingExtensions.cs +++ /dev/null @@ -1,73 +0,0 @@ -using Microsoft.Extensions.Logging; -using Xunit.Abstractions; - -namespace K8sOperator.NET.Tests.Logging; - -/// -/// Extension methods for logging to Xunit text output. -/// -public static class TestOutputLoggingExtensions -{ - /// - /// Log to test output. - /// - /// - /// The global logging configuration. - /// - /// - /// Output for the current test. - /// - /// - /// The minimum level to log at. - /// - public static void AddTestOutput(this ILoggingBuilder logging, ITestOutputHelper testOutput, - LogLevel minLogLevel = LogLevel.Information) - { - if (logging == null) - { - throw new ArgumentNullException(nameof(logging)); - } - - if (testOutput == null) - { - throw new ArgumentNullException(nameof(testOutput)); - } - - logging.AddProvider( - new TestOutputLoggerProvider(testOutput, minLogLevel)); - } - - /// - /// Log to test output. - /// - /// - /// The logger factory. - /// - /// - /// Output for the current test. - /// - /// - /// The minimum level to log at. - /// - /// - /// The logger factory (enables inline use / method-chaining). - /// - public static ILoggerFactory AddTestOutput(this ILoggerFactory loggers, ITestOutputHelper testOutput, - LogLevel minLogLevel = LogLevel.Information) - { - if (loggers == null) - { - throw new ArgumentNullException(nameof(loggers)); - } - - if (testOutput == null) - { - throw new ArgumentNullException(nameof(testOutput)); - } - - loggers.AddProvider( - new TestOutputLoggerProvider(testOutput, minLogLevel)); - - return loggers; - } -} diff --git a/test/K8sOperator.NET.Tests/Mocks/Endpoints/ListNamespacedCustomObjectWithHttpMessagesAsync.cs b/test/K8sOperator.NET.Tests/Mocks/Endpoints/ListNamespacedCustomObjectWithHttpMessagesAsync.cs deleted file mode 100644 index 2c2621e..0000000 --- a/test/K8sOperator.NET.Tests/Mocks/Endpoints/ListNamespacedCustomObjectWithHttpMessagesAsync.cs +++ /dev/null @@ -1,28 +0,0 @@ -using k8s; -using K8sOperator.NET.Models; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace K8sOperator.NET.Tests.Mocks.Endpoints; -internal static class ListNamespacedCustomObjectWithHttpMessagesAsync -{ - public static void MapListNamespacedCustomObjectWithHttpMessagesAsync(this IEndpointRouteBuilder builder, Watcher.WatchEvent? watchEvent = null) - where T : CustomResource, new() - { - builder.MapGet("/apis/{group}/{version}/namespaces/{namespace}/{plural}", async context => - { - if(watchEvent is null) - { - var j = KubernetesJson.Serialize(new T()); - await context.Response.WriteAsync(j); - return; - } - - var json = KubernetesJson.Serialize(watchEvent); - await context.Response.WriteAsync(json); - await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(true); - - }); - } -} diff --git a/test/K8sOperator.NET.Tests/Mocks/Endpoints/ReplaceNamespacedCustomObjectAsync.cs b/test/K8sOperator.NET.Tests/Mocks/Endpoints/ReplaceNamespacedCustomObjectAsync.cs deleted file mode 100644 index 4c75533..0000000 --- a/test/K8sOperator.NET.Tests/Mocks/Endpoints/ReplaceNamespacedCustomObjectAsync.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using System.Text.Json; - -namespace K8sOperator.NET.Tests.Mocks.Endpoints; -internal static class ReplaceNamespacedCustomObjectAsync -{ - public static void MapReplaceNamespacedCustomObjectAsync(this IEndpointRouteBuilder builder) - { - builder.MapPut("/apis/{group}/{version}/namespaces/{namespace}/{plural}/{name}", async context => - { - // Mock replacing a custom resource - var requestBody = await JsonSerializer.DeserializeAsync(context.Request.Body); - - var jsonResponse = JsonSerializer.Serialize(requestBody); - context.Response.ContentType = "application/json"; - await context.Response.WriteAsync(jsonResponse); - }); - } -} diff --git a/test/K8sOperator.NET.Tests/Mocks/Endpoints/WatchNamespaced.cs b/test/K8sOperator.NET.Tests/Mocks/Endpoints/WatchNamespaced.cs deleted file mode 100644 index 53fd009..0000000 --- a/test/K8sOperator.NET.Tests/Mocks/Endpoints/WatchNamespaced.cs +++ /dev/null @@ -1,35 +0,0 @@ -using k8s; -using K8sOperator.NET.Models; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - - -namespace K8sOperator.NET.Tests.Mocks.Endpoints; - -internal static class WatchNamespaced -{ - public static void MapWatchNamespacedCustomObjectAsync(this IEndpointRouteBuilder builder, Watcher.WatchEvent? watchEvent = null) - where T : CustomResource, new() - { - // The correct URL pattern for Kubernetes watch API is the same as list but with ?watch=true query parameter - builder.MapGet("/apis/{group}/{version}/namespaces/{namespace}/{plural}", async context => - { - var isWatch = context.Request.Query["watch"].ToString() == "true"; - - if (!isWatch || watchEvent is null) - { - var j = KubernetesJson.Serialize(new T()); - await context.Response.WriteAsync(j); - return; - } - - // For watch requests, send the event as newline-delimited JSON - var json = KubernetesJson.Serialize(watchEvent); - await context.Response.WriteAsync(json); - await context.Response.WriteAsync("\n"); - await context.Response.Body.FlushAsync(); - await Task.Delay(TimeSpan.FromMilliseconds(100)).ConfigureAwait(true); - }); - } -} diff --git a/test/K8sOperator.NET.Tests/Mocks/MockKubeApiServer.cs b/test/K8sOperator.NET.Tests/Mocks/MockKubeApiServer.cs index b00de8c..34387e7 100644 --- a/test/K8sOperator.NET.Tests/Mocks/MockKubeApiServer.cs +++ b/test/K8sOperator.NET.Tests/Mocks/MockKubeApiServer.cs @@ -1,23 +1,78 @@ -using k8s; +using k8s; +using k8s.Models; using K8sOperator.NET.Tests.Logging; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server.Features; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; using System.Net; -using Xunit.Abstractions; +using System.Reflection; +using System.Text.Json; namespace K8sOperator.NET.Tests.Mocks; +public class MockKubeApiBuilder(IEndpointRouteBuilder builder) +{ + private readonly IEndpointRouteBuilder _builder = builder; + + public CustomObjectsImpl CustomObjects => new(_builder); + + public class CustomObjectsImpl(IEndpointRouteBuilder builder) + { + public void WatchListClusterCustomObjectAsync(Watcher.WatchEvent? watchEvent = null, string ns = "default") + where T : CustomResource, new() + { + var attr = typeof(T).GetCustomAttribute(); + var group = attr?.Group ?? throw new InvalidOperationException($"KubernetesEntityAttribute.Group not defined on {typeof(T).FullName}"); + var version = attr?.ApiVersion ?? throw new InvalidOperationException($"KubernetesEntityAttribute.Version not defined on {typeof(T).FullName}"); + var plural = attr?.PluralName ?? throw new InvalidOperationException($"KubernetesEntityAttribute.Plural not defined on {typeof(T).FullName}"); + + builder.MapGet($"/apis/{group}/{version}/namespaces/{ns}/{plural}", async context => + { + var isWatch = context.Request.Query["watch"].ToString() == "true"; + + if (!isWatch || watchEvent is null) + { + var j = KubernetesJson.Serialize(new T()); + await context.Response.WriteAsync(j); + return; + } + + // For watch requests, send the event as newline-delimited JSON + var json = KubernetesJson.Serialize(watchEvent); + await context.Response.WriteAsync(json); + await context.Response.WriteAsync("\n"); + await context.Response.Body.FlushAsync(); + await Task.Delay(TimeSpan.FromMilliseconds(100)).ConfigureAwait(true); + }); + } + + public void ReplaceNamespacedCustomObjectAsync(string ns = "default", Action? resource = null) + where T : CustomResource, new() + { + var attr = typeof(T).GetCustomAttribute(); + var group = attr?.Group ?? throw new InvalidOperationException($"KubernetesEntityAttribute.Group not defined on {typeof(T).FullName}"); + var version = attr?.ApiVersion ?? throw new InvalidOperationException($"KubernetesEntityAttribute.Version not defined on {typeof(T).FullName}"); + var plural = attr?.PluralName ?? throw new InvalidOperationException($"KubernetesEntityAttribute.Plural not defined on {typeof(T).FullName}"); + + builder.MapPut($"/apis/{group}/{version}/namespaces/{ns}/{plural}/{{name}}", async context => + { + // Mock replacing a custom resource + var requestBody = await JsonSerializer.DeserializeAsync(context.Request.Body); + + resource?.Invoke(requestBody); + + var jsonResponse = JsonSerializer.Serialize(requestBody); + context.Response.ContentType = "application/json"; + await context.Response.WriteAsync(jsonResponse); + }); + } + } +} + public sealed class MockKubeApiServer : IDisposable { private readonly IHost _server; - public MockKubeApiServer(ITestOutputHelper testOutput, Action? builder = null) + public MockKubeApiServer(Action? configureApi = null) { _server = new HostBuilder() .ConfigureWebHost(config => @@ -26,7 +81,7 @@ public MockKubeApiServer(ITestOutputHelper testOutput, Action { options.Listen(IPAddress.Loopback, 8888); }); + config.UseKestrel(options => { options.Listen(IPAddress.Loopback, 0); }); config.Configure(app => { // Mock Kube API routes @@ -34,41 +89,50 @@ public MockKubeApiServer(ITestOutputHelper testOutput, Action { - builder?.Invoke(endpoints); + var builder = new MockKubeApiBuilder(endpoints); + configureApi?.Invoke(builder); endpoints.Map("{*url}", (ILogger logger, string url) => { var safeUrl = url.Replace("\r", string.Empty).Replace("\n", string.Empty); - logger.LogInformation("route not handled: '{url}'", safeUrl); + + if (logger.IsEnabled(LogLevel.Information)) + logger.LogInformation("route not handled: '{url}'", safeUrl); }); }); }); config.ConfigureLogging(logging => { logging.ClearProviders(); - if (testOutput != null) - { - logging.AddTestOutput(testOutput); - } + logging.AddTestLogging(TestContext.Current); }); }) .Build(); - _server.Start(); + + Client = new Kubernetes(GetKubernetesClientConfiguration()); } - public Uri Uri => _server.Services.GetRequiredService().Features.Get()!.Addresses + public Uri Uri => _server.Services.GetRequiredService() + .Features + .Get()!.Addresses .Select(a => new Uri(a)).First(); - // Method to get the mocked Kubernetes client - public IKubernetes GetMockedKubernetesClient() - { - var config = new KubernetesClientConfiguration { Host = Uri.ToString() }; - return new Kubernetes(config); - } + public KubernetesClientConfiguration GetKubernetesClientConfiguration() + => new() { Host = Uri.ToString() }; + + public IKubernetes Client { get; } public void Dispose() { - _server.Dispose(); + try + { + _server?.StopAsync(TimeSpan.FromSeconds(5)).GetAwaiter().GetResult(); + } + catch { /* Ignore disposal errors */ } + finally + { + _server?.Dispose(); + } } } diff --git a/test/K8sOperator.NET.Tests/MyController.cs b/test/K8sOperator.NET.Tests/MyController.cs deleted file mode 100644 index b778bcf..0000000 --- a/test/K8sOperator.NET.Tests/MyController.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace K8sOperator.NET.Tests; - -// Dummy controller used for testing -public class MyController : Controller -{ -} - diff --git a/test/K8sOperator.NET.Tests/OperatorExtensions_Tests.cs b/test/K8sOperator.NET.Tests/OperatorExtensions_Tests.cs new file mode 100644 index 0000000..7676a46 --- /dev/null +++ b/test/K8sOperator.NET.Tests/OperatorExtensions_Tests.cs @@ -0,0 +1,131 @@ +using k8s; +using K8sOperator.NET; +using K8sOperator.NET.Tests.Fixtures; +using K8sOperator.NET.Tests.Mocks; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace K8sOperator.NET.Tests; + +public class OperatorExtensions_Tests +{ + [Test] + public async Task AddOperator_NullServices_ThrowsArgumentNullException() + { + // Arrange + IServiceCollection services = null!; + // Act + var ex = Assert.Throws(() => services.AddOperator()); + // Assert + await Assert.That(ex.ParamName).IsEqualTo("collection"); + } + + [Test] + public async Task AddOperator_ValidServices_AddsOperatorService() + { + // Arrange + IServiceCollection services = new ServiceCollection(); + // Act + services.AddOperator(); + // Assert + var serviceProvider = services.BuildServiceProvider(); + OperatorService GetHostedServices() => serviceProvider.GetRequiredService(); + + await Assert.That(GetHostedServices).ThrowsNothing(); + } + + [Test] + public async Task AddOperator_ValidServices_RegistersCommandDatasource() + { + // Arrange + IServiceCollection services = new ServiceCollection(); + // Act + services.AddOperator(); + // Assert + var serviceProvider = services.BuildServiceProvider(); + var commandDatasource = serviceProvider.GetService(); + + await Assert.That(commandDatasource).IsNotNull(); + } + + [Test] + public async Task AddOperator_ValidServices_RegistersEventWatcherDatasource() + { + // Arrange + IServiceCollection services = new ServiceCollection(); + // Act + services.AddOperator(); + // Assert + var serviceProvider = services.BuildServiceProvider(); + var eventWatcherDatasource = serviceProvider.GetService(); + await Assert.That(eventWatcherDatasource).IsNotNull(); + } + + [Test] + public async Task AddOperator_ValidServices_RegistersKubernetesClient() + { + using var server = new MockKubeApiServer(); + + // Arrange + IServiceCollection services = new ServiceCollection(); + + services.TryAddSingleton(sp => + { + var config = new KubernetesClientConfiguration + { + Host = server.Uri.ToString() + }; + return new Kubernetes(config); + }); + // Act + services.AddOperator(); + // Assert + var serviceProvider = services.BuildServiceProvider(); + var kubernetesClient = serviceProvider.GetService(); + await Assert.That(kubernetesClient).IsNotNull(); + } + + [Test] + public async Task AddOperator_WithConfigure_CallsConfigureAction() + { + // Arrange + IServiceCollection services = new ServiceCollection(); + var configureCalled = false; + // Act + services.AddOperator(builder => configureCalled = true); + // Assert + await Assert.That(configureCalled).IsTrue(); + } + + [Test] + public async Task AddOperator_ValidServices_RegistersDefaultCommands() + { + using var server = new MockKubeApiServer(c => + { + c.CustomObjects.WatchListClusterCustomObjectAsync(WatchEvents.Added); + }); + + // Assert + var host = new HostBuilder() + .ConfigureServices(s => + { + s.AddOperator(x => x.Configuration = server.GetKubernetesClientConfiguration()); + }) + .Build(); + + var commandDatasource = host.Services.GetRequiredService(); + var commands = commandDatasource.GetCommands(host); + + await Assert.That(commands).Count().IsEqualTo(6); + } + + [Test] + public async Task AddOperator_ReturnsSameServiceCollection() + { + // Arrange + IServiceCollection services = new ServiceCollection(); + // Act + var result = services.AddOperator(); + // Assert + await Assert.That(result).IsEqualTo(services); + } +} diff --git a/test/K8sOperator.NET.Tests/OperatorService_Tests.cs b/test/K8sOperator.NET.Tests/OperatorService_Tests.cs new file mode 100644 index 0000000..9b77df9 --- /dev/null +++ b/test/K8sOperator.NET.Tests/OperatorService_Tests.cs @@ -0,0 +1,5 @@ +namespace K8sOperator.NET.Tests; + +public class OperatorService_Tests +{ +} diff --git a/test/K8sOperator.NET.Tests/OperatorTests.cs b/test/K8sOperator.NET.Tests/OperatorTests.cs deleted file mode 100644 index 8494244..0000000 --- a/test/K8sOperator.NET.Tests/OperatorTests.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Microsoft.Extensions.Logging; -using Xunit.Abstractions; - -namespace K8sOperator.NET.Tests; - -public class OperatorTests -{ - private readonly ILogger _logger; - public OperatorTests(ITestOutputHelper testOutputHelper) - { - var builder = OperatorHost.CreateOperatorApplicationBuilder(); - var logProvider = Substitute.For(); - _logger = Substitute.For(); - - logProvider.CreateLogger(Arg.Any()).Returns(_logger); - - builder.Logging.ClearProviders(); - builder.Logging.AddProvider(logProvider); - - App = builder.Build(); - } - - public IOperatorApplication App { get; } -} diff --git a/test/K8sOperator.NET.Tests/Properties/launchSettings.json b/test/K8sOperator.NET.Tests/Properties/launchSettings.json new file mode 100644 index 0000000..6cabb85 --- /dev/null +++ b/test/K8sOperator.NET.Tests/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "K8sOperator.NET.Tests": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:51141;http://localhost:51142" + } + } +} \ No newline at end of file diff --git a/test/K8sOperator.NET.Tests/WatchEvents.cs b/test/K8sOperator.NET.Tests/WatchEvents.cs new file mode 100644 index 0000000..fb59978 --- /dev/null +++ b/test/K8sOperator.NET.Tests/WatchEvents.cs @@ -0,0 +1,49 @@ +using k8s; + +namespace K8sOperator.NET.Tests; + +public static class WatchEvents + where T : CustomResource, new() +{ + public static Watcher.WatchEvent Added => CreateEvent(WatchEventType.Modified, + new T() + { + Metadata = new() + { + Name = "test", + NamespaceProperty = "default", + Finalizers = ["finalize"], + Uid = "1" + } + }); + + public static Watcher.WatchEvent Finalize => CreateEvent(WatchEventType.Added, + new T() + { + Metadata = new() + { + Name = "test", + NamespaceProperty = "default", + DeletionTimestamp = TimeProvider.System.GetUtcNow().DateTime, + Finalizers = ["finalize"], + Uid = "1" + } + }); + + public static Watcher.WatchEvent Deleted => CreateEvent(WatchEventType.Deleted, + new T() + { + Metadata = new() + { + Name = "test", + NamespaceProperty = "default", + Finalizers = ["finalize"], + Uid = "1" + } + }); + + public static Watcher.WatchEvent CreateEvent(WatchEventType type, T item) + { + return new Watcher.WatchEvent { Type = type, Object = item }; + } +}