Add unit tests for KubernetesCluster, Tenant, ServiceInstance, and RegisterClusterHandler
- 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:
15
src/EntKube.Clusters/Domain/IClusterRepository.cs
Normal file
15
src/EntKube.Clusters/Domain/IClusterRepository.cs
Normal 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);
|
||||
}
|
||||
76
src/EntKube.Clusters/Domain/KubernetesCluster.cs
Normal file
76
src/EntKube.Clusters/Domain/KubernetesCluster.cs
Normal 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
|
||||
}
|
||||
17
src/EntKube.Clusters/EntKube.Clusters.csproj
Normal file
17
src/EntKube.Clusters/EntKube.Clusters.csproj
Normal 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>
|
||||
6
src/EntKube.Clusters/EntKube.Clusters.http
Normal file
6
src/EntKube.Clusters/EntKube.Clusters.http
Normal file
@@ -0,0 +1,6 @@
|
||||
@EntKube.Clusters_HostAddress = http://localhost:5243
|
||||
|
||||
GET {{EntKube.Clusters_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
@@ -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);
|
||||
@@ -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!));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
39
src/EntKube.Clusters/Program.cs
Normal file
39
src/EntKube.Clusters/Program.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
23
src/EntKube.Clusters/Properties/launchSettings.json
Normal file
23
src/EntKube.Clusters/Properties/launchSettings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
8
src/EntKube.Clusters/appsettings.Development.json
Normal file
8
src/EntKube.Clusters/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
9
src/EntKube.Clusters/appsettings.json
Normal file
9
src/EntKube.Clusters/appsettings.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
Reference in New Issue
Block a user