Add unit tests for KubernetesCluster, Tenant, ServiceInstance, and RegisterClusterHandler
Some checks failed
Build EntKube / build (push) Failing after 27s
Package Helm Chart / lint (push) Failing after 36s
Package Helm Chart / package (push) Has been skipped

- Implement tests for KubernetesCluster including registration, connectivity status, and error handling.
- Create tests for Tenant creation, member management, and status changes.
- Add tests for ServiceInstance provisioning and state management.
- Introduce RegisterClusterHandler tests to validate registration requests and error scenarios.
- Set up project files for new test projects with necessary dependencies.
This commit is contained in:
Nils Blomgren
2026-05-05 11:44:36 +02:00
parent 461fa36a46
commit a96dd33039
203 changed files with 2876 additions and 553 deletions

View File

@@ -0,0 +1,15 @@
namespace EntKube.Clusters.Domain;
/// <summary>
/// Defines how the cluster service persists and retrieves cluster aggregates.
/// The implementation lives in the infrastructure layer — the domain only
/// knows about this contract, keeping it persistence-agnostic.
/// </summary>
public interface IClusterRepository
{
Task<KubernetesCluster?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<IReadOnlyList<KubernetesCluster>> GetAllAsync(CancellationToken ct = default);
Task AddAsync(KubernetesCluster cluster, CancellationToken ct = default);
Task UpdateAsync(KubernetesCluster cluster, CancellationToken ct = default);
Task DeleteAsync(Guid id, CancellationToken ct = default);
}

View File

@@ -0,0 +1,76 @@
namespace EntKube.Clusters.Domain;
/// <summary>
/// A KubernetesCluster represents a registered cluster that EntKube manages.
/// It holds the connection details, health state, and metadata needed to
/// interact with the cluster's API server. This is the aggregate root for
/// all cluster-related operations.
/// </summary>
public class KubernetesCluster
{
public Guid Id { get; private set; }
public string Name { get; private set; } = string.Empty;
public string ApiServerUrl { get; private set; } = string.Empty;
public ClusterStatus Status { get; private set; }
public string? KubeConfigSecret { get; private set; }
public DateTimeOffset RegisteredAt { get; private set; }
public DateTimeOffset? LastHealthCheckAt { get; private set; }
private KubernetesCluster() { }
/// <summary>
/// Registers a new cluster in the platform. At this point, we know the cluster
/// exists and we have connection details — but we haven't verified connectivity yet.
/// The cluster starts in a Pending state until a health check confirms it's reachable.
/// </summary>
public static KubernetesCluster Register(string name, string apiServerUrl, string? kubeConfigSecret)
{
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException("Cluster name is required.", nameof(name));
}
if (string.IsNullOrWhiteSpace(apiServerUrl))
{
throw new ArgumentException("API server URL is required.", nameof(apiServerUrl));
}
return new KubernetesCluster
{
Id = Guid.NewGuid(),
Name = name,
ApiServerUrl = apiServerUrl,
KubeConfigSecret = kubeConfigSecret,
Status = ClusterStatus.Pending,
RegisteredAt = DateTimeOffset.UtcNow
};
}
/// <summary>
/// After a successful health check, we mark the cluster as connected.
/// This means the platform can now schedule work against this cluster.
/// </summary>
public void MarkConnected()
{
Status = ClusterStatus.Connected;
LastHealthCheckAt = DateTimeOffset.UtcNow;
}
/// <summary>
/// When a health check fails, we mark the cluster as unreachable.
/// Existing workloads keep running, but no new provisioning can happen
/// until connectivity is restored.
/// </summary>
public void MarkUnreachable()
{
Status = ClusterStatus.Unreachable;
LastHealthCheckAt = DateTimeOffset.UtcNow;
}
}
public enum ClusterStatus
{
Pending,
Connected,
Unreachable
}

View File

@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\EntKube.SharedKernel\EntKube.SharedKernel.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,6 @@
@EntKube.Clusters_HostAddress = http://localhost:5243
GET {{EntKube.Clusters_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@@ -0,0 +1,39 @@
using EntKube.Clusters.Domain;
using EntKube.SharedKernel.Contracts;
using Microsoft.AspNetCore.Mvc;
namespace EntKube.Clusters.Features.GetClusters;
/// <summary>
/// Lists all registered clusters. The BFF (Web service) calls this endpoint
/// to populate the clusters dashboard. Returns a lightweight summary of each
/// cluster's name, status, and last health check time.
/// </summary>
public static class GetClustersEndpoint
{
public static void Map(IEndpointRouteBuilder app)
{
app.MapGet("/api/clusters", async (
[FromServices] IClusterRepository repository,
CancellationToken ct) =>
{
IReadOnlyList<KubernetesCluster> clusters = await repository.GetAllAsync(ct);
List<ClusterSummary> summaries = clusters.Select(c => new ClusterSummary(
c.Id,
c.Name,
c.ApiServerUrl,
c.Status.ToString(),
c.LastHealthCheckAt)).ToList();
return Results.Ok(ApiResponse<List<ClusterSummary>>.Ok(summaries));
});
}
}
public record ClusterSummary(
Guid Id,
string Name,
string ApiServerUrl,
string Status,
DateTimeOffset? LastHealthCheckAt);

View File

@@ -0,0 +1,30 @@
using EntKube.Clusters.Domain;
using EntKube.SharedKernel.Contracts;
using Microsoft.AspNetCore.Mvc;
namespace EntKube.Clusters.Features.RegisterCluster;
/// <summary>
/// Maps the HTTP POST /api/clusters endpoint. Receives a registration request,
/// delegates to the handler, and returns the new cluster's ID on success.
/// </summary>
public static class RegisterClusterEndpoint
{
public static void Map(IEndpointRouteBuilder app)
{
app.MapPost("/api/clusters", async (
[FromBody] RegisterClusterRequest request,
[FromServices] RegisterClusterHandler handler,
CancellationToken ct) =>
{
EntKube.SharedKernel.Domain.Result<Guid> result = await handler.HandleAsync(request, ct);
if (result.IsFailure)
{
return Results.BadRequest(ApiResponse<Guid>.Fail(result.Error!));
}
return Results.Created($"/api/clusters/{result.Value}", ApiResponse<Guid>.Ok(result.Value!));
});
}
}

View File

@@ -0,0 +1,53 @@
using EntKube.Clusters.Domain;
using EntKube.SharedKernel.Domain;
namespace EntKube.Clusters.Features.RegisterCluster;
/// <summary>
/// Handles the registration of a new Kubernetes cluster into the platform.
/// A tenant admin provides the cluster name, API server URL, and optionally
/// a kubeconfig secret reference. We validate the input, create the cluster
/// aggregate, and persist it. The cluster starts in Pending state until the
/// background health-check service confirms connectivity.
/// </summary>
public class RegisterClusterHandler
{
private readonly IClusterRepository repository;
public RegisterClusterHandler(IClusterRepository repository)
{
this.repository = repository;
}
public async Task<Result<Guid>> HandleAsync(RegisterClusterRequest request, CancellationToken ct = default)
{
// Validate that the caller provided the minimum required information.
// Without a name and API URL, we cannot register a cluster.
if (string.IsNullOrWhiteSpace(request.Name))
{
return Result.Failure<Guid>("Cluster name is required.");
}
if (string.IsNullOrWhiteSpace(request.ApiServerUrl))
{
return Result.Failure<Guid>("API server URL is required.");
}
// Create the cluster aggregate using the domain factory method.
// This encapsulates all the business rules for what a valid new cluster looks like.
KubernetesCluster cluster = KubernetesCluster.Register(
request.Name,
request.ApiServerUrl,
request.KubeConfigSecret);
// Persist the new cluster so it can be picked up by the health-check background service.
await repository.AddAsync(cluster, ct);
return Result.Success(cluster.Id);
}
}
public record RegisterClusterRequest(string Name, string ApiServerUrl, string? KubeConfigSecret);

View File

@@ -0,0 +1,43 @@
using EntKube.Clusters.Domain;
namespace EntKube.Clusters.Infrastructure;
/// <summary>
/// In-memory implementation of the cluster repository for local development
/// and testing. Production will replace this with an EF Core or Dapper
/// implementation backed by PostgreSQL.
/// </summary>
public class InMemoryClusterRepository : IClusterRepository
{
private readonly List<KubernetesCluster> clusters = new();
public Task<KubernetesCluster?> GetByIdAsync(Guid id, CancellationToken ct = default)
{
KubernetesCluster? cluster = clusters.FirstOrDefault(c => c.Id == id);
return Task.FromResult(cluster);
}
public Task<IReadOnlyList<KubernetesCluster>> GetAllAsync(CancellationToken ct = default)
{
IReadOnlyList<KubernetesCluster> result = clusters.AsReadOnly();
return Task.FromResult(result);
}
public Task AddAsync(KubernetesCluster cluster, CancellationToken ct = default)
{
clusters.Add(cluster);
return Task.CompletedTask;
}
public Task UpdateAsync(KubernetesCluster cluster, CancellationToken ct = default)
{
// In-memory: the reference is already updated since we store the object directly.
return Task.CompletedTask;
}
public Task DeleteAsync(Guid id, CancellationToken ct = default)
{
clusters.RemoveAll(c => c.Id == id);
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,39 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.GetClusters;
using EntKube.Clusters.Features.RegisterCluster;
using EntKube.Clusters.Infrastructure;
namespace EntKube.Clusters;
public class Program
{
public static void Main(string[] args)
{
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
// Register application services. The cluster repository is a singleton
// for the in-memory implementation — production will use a scoped EF context.
builder.Services.AddSingleton<IClusterRepository, InMemoryClusterRepository>();
builder.Services.AddScoped<RegisterClusterHandler>();
builder.Services.AddOpenApi();
WebApplication app = builder.Build();
// Configure the HTTP pipeline with OpenAPI for development tooling.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
// Map feature endpoints — each feature registers its own routes.
RegisterClusterEndpoint.Map(app);
GetClustersEndpoint.Map(app);
app.Run();
}
}

View File

@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5243",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7267;http://localhost:5243",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}